From 4104020dd6b82d47bc34843ff517a331b0637655 Mon Sep 17 00:00:00 2001 From: Keaton Svoma Date: Tue, 12 May 2026 20:43:38 -0500 Subject: [PATCH 01/23] feat(smartgroup): introduce Template + ParamSpec foundation types --- internal/smartgroup/types.go | 105 ++++++++++++++++++++++++++++++ internal/smartgroup/types_test.go | 57 ++++++++++++++++ 2 files changed, 162 insertions(+) create mode 100644 internal/smartgroup/types.go create mode 100644 internal/smartgroup/types_test.go diff --git a/internal/smartgroup/types.go b/internal/smartgroup/types.go new file mode 100644 index 0000000..e9f8ab7 --- /dev/null +++ b/internal/smartgroup/types.go @@ -0,0 +1,105 @@ +// Copyright 2026, Jamf Software LLC + +// Package smartgroup curates a library of Jamf Pro smart-group templates +// admins can instantiate via the CLI. Criterion-name strings are sourced from +// the JSS server (see criteria.go for citations); the library is exercised +// against a live tenant via pro smart-group verify-templates. +package smartgroup + +import "fmt" + +// ParamSpec describes a single named parameter on a parameterized template. +// Templates have at most one ParamSpec; multi-param templates should be split +// into discrete variants. +type ParamSpec struct { + Name string // CLI flag name, e.g. "stalled-after" + Type string // "int" | "string" | "version" + Default any // applied when caller omits the param; nil iff Required + Description string // for --help + Required bool +} + +// Template is one curated smart-group recipe in the library. +type Template struct { + Slug string // e.g. "encryption/not-encrypted" + Category string // e.g. "encryption" + Description string // one-line for table listings + Params []ParamSpec // zero or one entry + Build func(opts map[string]any) (SmartGroupRequest, error) +} + +// SmartGroupRequest is the JSON body posted to /v2/computer-groups/smart-groups. +// We define our own type rather than importing the generated SmartComputerGroupV2 +// so the smartgroup package can be tested in isolation. +type SmartGroupRequest struct { + Name string `json:"name"` + Description string `json:"description,omitempty"` + Criteria []Criterion `json:"criteria,omitempty"` + SiteID string `json:"siteId,omitempty"` +} + +// Criterion is one row in SmartGroupRequest.Criteria; matches the +// SmartSearchCriterion schema from specs/_MonolithLibrary.yaml. +type Criterion struct { + AndOr string `json:"andOr"` + Name string `json:"name"` + SearchType string `json:"searchType"` + Value string `json:"value"` + Priority int `json:"priority"` + OpeningParen bool `json:"openingParen"` + ClosingParen bool `json:"closingParen"` +} + +// ResolveOpts validates and normalises a caller-supplied opts map against the +// template's ParamSpec list. Required params must be present. Type mismatches +// return an error. Defaults are filled in when omitted. +func (t Template) ResolveOpts(in map[string]any) (map[string]any, error) { + out := make(map[string]any, len(t.Params)) + for _, p := range t.Params { + raw, present := in[p.Name] + if !present { + if p.Required { + return nil, fmt.Errorf("template %s requires param --%s", t.Slug, p.Name) + } + if p.Default != nil { + out[p.Name] = p.Default + } + continue + } + val, err := coerceTo(p.Type, raw) + if err != nil { + return nil, fmt.Errorf("template %s: param --%s: %w", t.Slug, p.Name, err) + } + out[p.Name] = val + } + return out, nil +} + +func coerceTo(typ string, raw any) (any, error) { + switch typ { + case "int": + switch v := raw.(type) { + case int: + return v, nil + case int64: + return int(v), nil + case float64: + return int(v), nil + case string: + var n int + if _, err := fmt.Sscanf(v, "%d", &n); err != nil { + return nil, fmt.Errorf("expected int, got %q", v) + } + return n, nil + default: + return nil, fmt.Errorf("expected int, got %T", raw) + } + case "string", "version": + if s, ok := raw.(string); ok { + return s, nil + } + return nil, fmt.Errorf("expected string, got %T", raw) + default: + return nil, fmt.Errorf("unknown param type %q", typ) + } +} diff --git a/internal/smartgroup/types_test.go b/internal/smartgroup/types_test.go new file mode 100644 index 0000000..7f648c6 --- /dev/null +++ b/internal/smartgroup/types_test.go @@ -0,0 +1,57 @@ +// Copyright 2026, Jamf Software LLC + +package smartgroup + +import ( + "testing" +) + +func TestValidateOpts_RequiredMissing(t *testing.T) { + spec := ParamSpec{Name: "below-version", Type: "string", Required: true} + tmpl := Template{ + Slug: "test/required", + Params: []ParamSpec{spec}, + } + _, err := tmpl.ResolveOpts(map[string]any{}) + if err == nil { + t.Fatal("expected error for missing required param, got nil") + } +} + +func TestValidateOpts_TypeMismatch(t *testing.T) { + spec := ParamSpec{Name: "days", Type: "int"} + tmpl := Template{ + Slug: "test/typed", + Params: []ParamSpec{spec}, + } + _, err := tmpl.ResolveOpts(map[string]any{"days": "not-an-int"}) + if err == nil { + t.Fatal("expected error for type mismatch, got nil") + } +} + +func TestValidateOpts_DefaultApplied(t *testing.T) { + spec := ParamSpec{Name: "days", Type: "int", Default: 7} + tmpl := Template{ + Slug: "test/default", + Params: []ParamSpec{spec}, + } + opts, err := tmpl.ResolveOpts(map[string]any{}) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if opts["days"] != 7 { + t.Fatalf("expected default 7, got %v", opts["days"]) + } +} + +func TestValidateOpts_NoParamsAccepted(t *testing.T) { + tmpl := Template{Slug: "test/noparam", Params: nil} + opts, err := tmpl.ResolveOpts(map[string]any{}) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if len(opts) != 0 { + t.Fatalf("expected empty opts, got %v", opts) + } +} From eca67d9074b20426d4511e01a38f31da1f013008 Mon Sep 17 00:00:00 2001 From: Keaton Svoma Date: Tue, 12 May 2026 20:51:11 -0500 Subject: [PATCH 02/23] fix(smartgroup): reject partial int parses, tighten error-content tests --- internal/smartgroup/types.go | 14 ++++++++++---- internal/smartgroup/types_test.go | 22 ++++++++++++++++++++++ 2 files changed, 32 insertions(+), 4 deletions(-) diff --git a/internal/smartgroup/types.go b/internal/smartgroup/types.go index e9f8ab7..6d9ea61 100644 --- a/internal/smartgroup/types.go +++ b/internal/smartgroup/types.go @@ -6,7 +6,10 @@ // against a live tenant via pro smart-group verify-templates. package smartgroup -import "fmt" +import ( + "fmt" + "strconv" +) // ParamSpec describes a single named parameter on a parameterized template. // Templates have at most one ParamSpec; multi-param templates should be split @@ -25,7 +28,10 @@ type Template struct { Category string // e.g. "encryption" Description string // one-line for table listings Params []ParamSpec // zero or one entry - Build func(opts map[string]any) (SmartGroupRequest, error) + // Build returns the SmartGroupRequest for this template. Nil only on + // templates registered without a Build func (a programming error); + // callers must guard before invoking. + Build func(opts map[string]any) (SmartGroupRequest, error) } // SmartGroupRequest is the JSON body posted to /v2/computer-groups/smart-groups. @@ -86,8 +92,8 @@ func coerceTo(typ string, raw any) (any, error) { case float64: return int(v), nil case string: - var n int - if _, err := fmt.Sscanf(v, "%d", &n); err != nil { + n, err := strconv.Atoi(v) + if err != nil { return nil, fmt.Errorf("expected int, got %q", v) } return n, nil diff --git a/internal/smartgroup/types_test.go b/internal/smartgroup/types_test.go index 7f648c6..7fa7739 100644 --- a/internal/smartgroup/types_test.go +++ b/internal/smartgroup/types_test.go @@ -3,6 +3,7 @@ package smartgroup import ( + "strings" "testing" ) @@ -16,6 +17,9 @@ func TestValidateOpts_RequiredMissing(t *testing.T) { if err == nil { t.Fatal("expected error for missing required param, got nil") } + if !strings.Contains(err.Error(), "--below-version") { + t.Errorf("expected error to mention --below-version, got: %v", err) + } } func TestValidateOpts_TypeMismatch(t *testing.T) { @@ -28,6 +32,9 @@ func TestValidateOpts_TypeMismatch(t *testing.T) { if err == nil { t.Fatal("expected error for type mismatch, got nil") } + if !strings.Contains(err.Error(), "expected int") { + t.Errorf("expected 'expected int' in error, got: %v", err) + } } func TestValidateOpts_DefaultApplied(t *testing.T) { @@ -55,3 +62,18 @@ func TestValidateOpts_NoParamsAccepted(t *testing.T) { t.Fatalf("expected empty opts, got %v", opts) } } + +func TestValidateOpts_StringIntPartialParseRejected(t *testing.T) { + spec := ParamSpec{Name: "days", Type: "int"} + tmpl := Template{ + Slug: "test/partial", + Params: []ParamSpec{spec}, + } + _, err := tmpl.ResolveOpts(map[string]any{"days": "30d"}) + if err == nil { + t.Fatal("expected error for partial-parse '30d', got nil") + } + if !strings.Contains(err.Error(), "expected int") { + t.Errorf("expected 'expected int' in error, got: %v", err) + } +} From ab5d6815aeaaa330f88ea6b10eb09cdbde651d84 Mon Sep 17 00:00:00 2001 From: Keaton Svoma Date: Tue, 12 May 2026 20:53:25 -0500 Subject: [PATCH 03/23] feat(smartgroup): add JSS-verified criterion-name constants --- internal/smartgroup/criteria.go | 98 ++++++++++++++++++++++++++++ internal/smartgroup/criteria_test.go | 28 ++++++++ 2 files changed, 126 insertions(+) create mode 100644 internal/smartgroup/criteria.go create mode 100644 internal/smartgroup/criteria_test.go diff --git a/internal/smartgroup/criteria.go b/internal/smartgroup/criteria.go new file mode 100644 index 0000000..d6e8a00 --- /dev/null +++ b/internal/smartgroup/criteria.go @@ -0,0 +1,98 @@ +// Copyright 2026, Jamf Software LLC + +package smartgroup + +// Smart-group criterion names. These strings must match Jamf Pro's smart-group +// criterion UI exactly. The canonical source is the JSS server repo +// (jamf/jss). Each const cites the file:line it was sourced from. Re-verify +// after any sync-specs pass that includes JSS source updates. + +const ( + // FileVault2StatusMatcher.java:@Component("FileVault 2 Status") + CriterionFV2Status = "FileVault 2 Status" + + // MatcherNameConstants.java:CD.FILE_VAULT_2_ENABLED + CriterionFV2Enabled = "FileVault 2 Enabled" + + // ComputerInventoryValues.java:103 + CriterionFV2RecoveryKeyType = "FileVault 2 Recovery Key Type" + + // ComputerInventoryValues.java:104 + CriterionFV2IndividualKeyValidation = "FileVault 2 Individual Key Validation" + + // ComputerInventoryValues.java:106 + CriterionFV2PersonalRecoveryKey = "FileVault 2 Personal Recovery Key" + + // MatcherNameConstants.java:CD.OPERATING_SYSTEM_VERSION + CriterionOSVersion = "Operating System Version" + + // MatcherNameConstants.java:CD.OPERATING_SYSTEM_BUILD + CriterionOSBuild = "Operating System Build" + + // MatcherNameConstants.java:CD.OPERATING_SYSTEM_SUPPLEMENTAL_VERSION_EXTRA + CriterionOSRapidSecurityResponse = "Operating System Rapid Security Response" + + // MatcherNameConstants.java:MDD.LAST_INVENTORY_UPDATE + CriterionLastInventoryUpdate = "Last Inventory Update" + + // MatcherNameConstants.java:MDD.BOOTSTRAP_TOKEN_ESCROWED + CriterionBootstrapTokenEscrowed = "Bootstrap Token Escrowed" + + // UserApprovedMdmMatcher.java:@Component("User Approved MDM") + CriterionUserApprovedMDM = "User Approved MDM" + + // MatcherNameConstants.java:MDD.MDM_PROFILE_EXPIRATION_DATE + CriterionMDMProfileExpirationDate = "MDM Profile Expiration Date" + + // MatcherNameConstants.java:CD.DECLARATIVE_DEVICE_MANAGEMENT_ENABLED + CriterionDDMEnabled = "Declarative Device Management Enabled" + + // ComputerInventoryValues.java:118 + CriterionGatekeeper = "Gatekeeper" + + // ComputerInventoryValues.java:119 + CriterionSIP = "System Integrity Protection" + + // MatcherNameConstants.java:CD.FIREWALL_ENABLED + CriterionFirewallEnabled = "Firewall Enabled" + + // MatcherNameConstants.java:MDD.SUPERVISED + CriterionSupervised = "Supervised" + + // MatcherNameConstants.java:E.PRESTAGE + CriterionEnrollmentMethodPrestage = "Enrollment Method: PreStage enrollment" + + // MatcherNameConstants.java:CD.APPLE_SILICON + CriterionAppleSilicon = "Apple Silicon" + + // Parallel inventory criterion; pro smart-group verify-templates is the + // empirical check against a live tenant. + CriterionJamfBinaryVersion = "Jamf Binary Version" +) + +// allCriterionConsts returns the full registry as a map for testing. +// Keep in sync with the const block above. +func allCriterionConsts() map[string]string { + return map[string]string{ + "CriterionFV2Status": CriterionFV2Status, + "CriterionFV2Enabled": CriterionFV2Enabled, + "CriterionFV2RecoveryKeyType": CriterionFV2RecoveryKeyType, + "CriterionFV2IndividualKeyValidation": CriterionFV2IndividualKeyValidation, + "CriterionFV2PersonalRecoveryKey": CriterionFV2PersonalRecoveryKey, + "CriterionOSVersion": CriterionOSVersion, + "CriterionOSBuild": CriterionOSBuild, + "CriterionOSRapidSecurityResponse": CriterionOSRapidSecurityResponse, + "CriterionLastInventoryUpdate": CriterionLastInventoryUpdate, + "CriterionBootstrapTokenEscrowed": CriterionBootstrapTokenEscrowed, + "CriterionUserApprovedMDM": CriterionUserApprovedMDM, + "CriterionMDMProfileExpirationDate": CriterionMDMProfileExpirationDate, + "CriterionDDMEnabled": CriterionDDMEnabled, + "CriterionGatekeeper": CriterionGatekeeper, + "CriterionSIP": CriterionSIP, + "CriterionFirewallEnabled": CriterionFirewallEnabled, + "CriterionSupervised": CriterionSupervised, + "CriterionEnrollmentMethodPrestage": CriterionEnrollmentMethodPrestage, + "CriterionAppleSilicon": CriterionAppleSilicon, + "CriterionJamfBinaryVersion": CriterionJamfBinaryVersion, + } +} diff --git a/internal/smartgroup/criteria_test.go b/internal/smartgroup/criteria_test.go new file mode 100644 index 0000000..22e554f --- /dev/null +++ b/internal/smartgroup/criteria_test.go @@ -0,0 +1,28 @@ +// Copyright 2026, Jamf Software LLC + +package smartgroup + +import ( + "strings" + "testing" +) + +func TestCriterionConstsNotEmpty(t *testing.T) { + consts := allCriterionConsts() + for name, value := range consts { + if strings.TrimSpace(value) == "" { + t.Errorf("criterion const %s is empty", name) + } + } +} + +func TestCriterionConstsUnique(t *testing.T) { + consts := allCriterionConsts() + seen := make(map[string]string) + for name, value := range consts { + if other, ok := seen[value]; ok { + t.Errorf("criterion value %q used by both %s and %s", value, name, other) + } + seen[value] = name + } +} From 28be92fc8ca02500826746ebe5d3eea195e22c84 Mon Sep 17 00:00:00 2001 From: Keaton Svoma Date: Tue, 12 May 2026 20:56:13 -0500 Subject: [PATCH 04/23] feat(smartgroup): add template Library registry with Register/Lookup/All/ByCategory/Categories/FuzzyMatch --- internal/smartgroup/library.go | 127 ++++++++++++++++++++++++++++ internal/smartgroup/library_test.go | 57 +++++++++++++ 2 files changed, 184 insertions(+) create mode 100644 internal/smartgroup/library.go create mode 100644 internal/smartgroup/library_test.go diff --git a/internal/smartgroup/library.go b/internal/smartgroup/library.go new file mode 100644 index 0000000..fe2f460 --- /dev/null +++ b/internal/smartgroup/library.go @@ -0,0 +1,127 @@ +// Copyright 2026, Jamf Software LLC + +package smartgroup + +import ( + "fmt" + "sort" + "strings" + "sync" +) + +// library is the in-memory registry of all curated templates. +// Concrete templates are registered via init() in their category files +// (encryption.go, updates.go, etc.). +var ( + libraryMu sync.RWMutex + library = make(map[string]Template) +) + +// Register adds a template to the library. Panics on duplicate slug — +// duplicate slugs are a programming error, not a runtime condition. +func Register(t Template) { + libraryMu.Lock() + defer libraryMu.Unlock() + if _, exists := library[t.Slug]; exists { + panic(fmt.Sprintf("smartgroup: duplicate slug %q", t.Slug)) + } + library[t.Slug] = t +} + +// Unregister removes a template; used only in tests. +func Unregister(slug string) { + libraryMu.Lock() + defer libraryMu.Unlock() + delete(library, slug) +} + +// Lookup returns the template by slug. The second return value reports +// whether the slug exists in the library. +func Lookup(slug string) (Template, bool) { + libraryMu.RLock() + defer libraryMu.RUnlock() + t, ok := library[slug] + return t, ok +} + +// All returns all templates ordered first by category, then by slug. +func All() []Template { + libraryMu.RLock() + defer libraryMu.RUnlock() + out := make([]Template, 0, len(library)) + for _, t := range library { + out = append(out, t) + } + sort.Slice(out, func(i, j int) bool { + if out[i].Category != out[j].Category { + return out[i].Category < out[j].Category + } + return out[i].Slug < out[j].Slug + }) + return out +} + +// ByCategory returns templates in one category, sorted by slug. +func ByCategory(category string) []Template { + cat := strings.ToLower(category) + out := make([]Template, 0) + for _, t := range All() { + if t.Category == cat { + out = append(out, t) + } + } + return out +} + +// Categories returns the sorted, unique list of categories present in the library. +func Categories() []string { + libraryMu.RLock() + defer libraryMu.RUnlock() + seen := make(map[string]struct{}, len(library)) + for _, t := range library { + seen[t.Category] = struct{}{} + } + out := make([]string, 0, len(seen)) + for c := range seen { + out = append(out, c) + } + sort.Strings(out) + return out +} + +// FuzzyMatch returns slugs that are similar to the input — used by the CLI +// to suggest corrections on unknown-slug errors. Returns at most 3 matches. +func FuzzyMatch(input string) []string { + input = strings.ToLower(input) + all := All() + type scored struct { + slug string + score int + } + cands := make([]scored, 0, len(all)) + for _, t := range all { + score := simpleScore(strings.ToLower(t.Slug), input) + if score > 0 { + cands = append(cands, scored{t.Slug, score}) + } + } + sort.Slice(cands, func(i, j int) bool { return cands[i].score > cands[j].score }) + out := make([]string, 0, 3) + for i := 0; i < len(cands) && i < 3; i++ { + out = append(out, cands[i].slug) + } + return out +} + +func simpleScore(a, b string) int { + if strings.Contains(a, b) { + return 100 - len(a) + } + common := 0 + for i := 0; i < len(a) && i < len(b); i++ { + if a[i] == b[i] { + common++ + } + } + return common +} diff --git a/internal/smartgroup/library_test.go b/internal/smartgroup/library_test.go new file mode 100644 index 0000000..55b63a2 --- /dev/null +++ b/internal/smartgroup/library_test.go @@ -0,0 +1,57 @@ +// Copyright 2026, Jamf Software LLC + +package smartgroup + +import ( + "sort" + "testing" +) + +func TestLibraryEmptyByDefault(t *testing.T) { + _, ok := Lookup("nonexistent/slug") + if ok { + t.Fatal("expected Lookup of missing slug to return false") + } +} + +func TestRegisterDuplicateSlugPanics(t *testing.T) { + defer func() { + if r := recover(); r == nil { + t.Fatal("expected panic on duplicate slug, got none") + } + }() + tmpl := Template{Slug: "test/dup", Category: "test", Build: trivialBuild} + Register(tmpl) + defer Unregister("test/dup") + Register(tmpl) +} + +func TestCategoriesReturnsSortedUnique(t *testing.T) { + Register(Template{Slug: "alpha/one", Category: "alpha", Build: trivialBuild}) + defer Unregister("alpha/one") + Register(Template{Slug: "beta/one", Category: "beta", Build: trivialBuild}) + defer Unregister("beta/one") + Register(Template{Slug: "alpha/two", Category: "alpha", Build: trivialBuild}) + defer Unregister("alpha/two") + + got := Categories() + if !sort.StringsAreSorted(got) { + t.Fatalf("categories not sorted: %v", got) + } + foundAlpha, foundBeta := false, false + for _, c := range got { + if c == "alpha" { + foundAlpha = true + } + if c == "beta" { + foundBeta = true + } + } + if !foundAlpha || !foundBeta { + t.Fatalf("expected alpha and beta in %v", got) + } +} + +func trivialBuild(_ map[string]any) (SmartGroupRequest, error) { + return SmartGroupRequest{}, nil +} From 654edb9868cd5b1551b2d9bc2fa5ef496d618103 Mon Sep 17 00:00:00 2001 From: Keaton Svoma Date: Tue, 12 May 2026 20:59:37 -0500 Subject: [PATCH 05/23] feat(smartgroup): add 6 encryption templates --- internal/smartgroup/encryption.go | 118 ++++++++++++++++++++++++++++ internal/smartgroup/library_test.go | 101 ++++++++++++++++++++++++ 2 files changed, 219 insertions(+) create mode 100644 internal/smartgroup/encryption.go diff --git a/internal/smartgroup/encryption.go b/internal/smartgroup/encryption.go new file mode 100644 index 0000000..3221b4a --- /dev/null +++ b/internal/smartgroup/encryption.go @@ -0,0 +1,118 @@ +// Copyright 2026, Jamf Software LLC + +package smartgroup + +import "fmt" + +func init() { + Register(encryptionNotEncrypted()) + Register(encryptionInvalidRecoveryKey()) + Register(encryptionEscrowMissing()) + Register(encryptionIRKOnlyDeprecated()) + Register(encryptionEncryptionStalled()) + Register(encryptionFVIneligible()) +} + +func encryptionNotEncrypted() Template { + return Template{ + Slug: "encryption/not-encrypted", + Category: "encryption", + Description: "Macs where FileVault 2 is not enabled", + Build: func(_ map[string]any) (SmartGroupRequest, error) { + return SmartGroupRequest{ + Description: "Auto-generated by jamf-cli (template: encryption/not-encrypted)", + Criteria: []Criterion{ + {AndOr: "and", Priority: 0, Name: CriterionFV2Status, SearchType: "is", Value: "Not Encrypted"}, + }, + }, nil + }, + } +} + +func encryptionInvalidRecoveryKey() Template { + return Template{ + Slug: "encryption/invalid-recovery-key", + Category: "encryption", + Description: "Macs with an INVALID escrowed recovery key (cannot unlock)", + Build: func(_ map[string]any) (SmartGroupRequest, error) { + return SmartGroupRequest{ + Description: "Auto-generated by jamf-cli (template: encryption/invalid-recovery-key)", + Criteria: []Criterion{ + {AndOr: "and", Priority: 0, Name: CriterionFV2IndividualKeyValidation, SearchType: "is", Value: "Not Valid"}, + }, + }, nil + }, + } +} + +func encryptionEscrowMissing() Template { + return Template{ + Slug: "encryption/escrow-missing", + Category: "encryption", + Description: "Macs without any escrowed recovery key", + Build: func(_ map[string]any) (SmartGroupRequest, error) { + return SmartGroupRequest{ + Description: "Auto-generated by jamf-cli (template: encryption/escrow-missing)", + Criteria: []Criterion{ + {AndOr: "and", Priority: 0, Name: CriterionFV2RecoveryKeyType, SearchType: "is", Value: ""}, + }, + }, nil + }, + } +} + +func encryptionIRKOnlyDeprecated() Template { + return Template{ + Slug: "encryption/irk-only-deprecated", + Category: "encryption", + Description: "Macs on the deprecated Institutional Recovery Key", + Build: func(_ map[string]any) (SmartGroupRequest, error) { + return SmartGroupRequest{ + Description: "Auto-generated by jamf-cli (template: encryption/irk-only-deprecated). IRK is deprecated for managed Macs; migrate to Personal Recovery Key escrow.", + Criteria: []Criterion{ + {AndOr: "and", Priority: 0, Name: CriterionFV2RecoveryKeyType, SearchType: "is", Value: "Institutional"}, + }, + }, nil + }, + } +} + +func encryptionEncryptionStalled() Template { + return Template{ + Slug: "encryption/encryption-stalled", + Category: "encryption", + Description: "Macs stuck mid-encryption (no inventory update in N days)", + Params: []ParamSpec{ + {Name: "stalled-after", Type: "int", Default: 7, Description: "Days since last inventory update"}, + }, + Build: func(opts map[string]any) (SmartGroupRequest, error) { + days, ok := opts["stalled-after"].(int) + if !ok { + return SmartGroupRequest{}, fmt.Errorf("expected int stalled-after, got %T", opts["stalled-after"]) + } + return SmartGroupRequest{ + Description: fmt.Sprintf("Auto-generated by jamf-cli (template: encryption/encryption-stalled, stalled-after=%d)", days), + Criteria: []Criterion{ + {AndOr: "and", Priority: 0, Name: CriterionFV2Status, SearchType: "is not", Value: "All Partitions Encrypted"}, + {AndOr: "and", Priority: 1, Name: CriterionLastInventoryUpdate, SearchType: "more than x days ago", Value: fmt.Sprintf("%d", days)}, + }, + }, nil + }, + } +} + +func encryptionFVIneligible() Template { + return Template{ + Slug: "encryption/fv-ineligible", + Category: "encryption", + Description: `Macs reporting FileVault 2 Status of "N/A" (ineligible hardware or never collected)`, + Build: func(_ map[string]any) (SmartGroupRequest, error) { + return SmartGroupRequest{ + Description: "Auto-generated by jamf-cli (template: encryption/fv-ineligible)", + Criteria: []Criterion{ + {AndOr: "and", Priority: 0, Name: CriterionFV2Status, SearchType: "is", Value: "N/A"}, + }, + }, nil + }, + } +} diff --git a/internal/smartgroup/library_test.go b/internal/smartgroup/library_test.go index 55b63a2..89cea35 100644 --- a/internal/smartgroup/library_test.go +++ b/internal/smartgroup/library_test.go @@ -55,3 +55,104 @@ func TestCategoriesReturnsSortedUnique(t *testing.T) { func trivialBuild(_ map[string]any) (SmartGroupRequest, error) { return SmartGroupRequest{}, nil } + +// ─── Encryption category golden tests ────────────────────────────────────── + +func TestEncryption_NotEncrypted_Golden(t *testing.T) { + tmpl, ok := Lookup("encryption/not-encrypted") + if !ok { + t.Fatal("template encryption/not-encrypted not registered") + } + req, err := tmpl.Build(map[string]any{}) + if err != nil { + t.Fatalf("Build failed: %v", err) + } + if len(req.Criteria) != 1 { + t.Fatalf("expected 1 criterion, got %d", len(req.Criteria)) + } + c := req.Criteria[0] + if c.Name != "FileVault 2 Status" || c.SearchType != "is" || c.Value != "Not Encrypted" { + t.Fatalf("unexpected criterion: %+v", c) + } +} + +func TestEncryption_InvalidRecoveryKey_Golden(t *testing.T) { + tmpl, _ := Lookup("encryption/invalid-recovery-key") + req, err := tmpl.Build(map[string]any{}) + if err != nil { + t.Fatalf("Build failed: %v", err) + } + c := req.Criteria[0] + if c.Name != "FileVault 2 Individual Key Validation" || c.SearchType != "is" || c.Value != "Not Valid" { + t.Fatalf("unexpected criterion: %+v", c) + } +} + +func TestEncryption_EscrowMissing_Golden(t *testing.T) { + tmpl, _ := Lookup("encryption/escrow-missing") + req, err := tmpl.Build(map[string]any{}) + if err != nil { + t.Fatalf("Build failed: %v", err) + } + c := req.Criteria[0] + if c.Name != "FileVault 2 Recovery Key Type" || c.SearchType != "is" || c.Value != "" { + t.Fatalf("unexpected criterion: %+v", c) + } +} + +func TestEncryption_IRKOnlyDeprecated_Golden(t *testing.T) { + tmpl, _ := Lookup("encryption/irk-only-deprecated") + req, err := tmpl.Build(map[string]any{}) + if err != nil { + t.Fatalf("Build failed: %v", err) + } + c := req.Criteria[0] + if c.Name != "FileVault 2 Recovery Key Type" || c.SearchType != "is" || c.Value != "Institutional" { + t.Fatalf("unexpected criterion: %+v", c) + } +} + +func TestEncryption_EncryptionStalled_GoldenDefault(t *testing.T) { + tmpl, _ := Lookup("encryption/encryption-stalled") + opts, err := tmpl.ResolveOpts(map[string]any{}) + if err != nil { + t.Fatalf("ResolveOpts: %v", err) + } + req, err := tmpl.Build(opts) + if err != nil { + t.Fatalf("Build: %v", err) + } + if len(req.Criteria) != 2 { + t.Fatalf("expected 2 criteria, got %d", len(req.Criteria)) + } + if req.Criteria[1].Value != "7" { + t.Fatalf("expected default 7 days, got %q", req.Criteria[1].Value) + } +} + +func TestEncryption_EncryptionStalled_GoldenCustom(t *testing.T) { + tmpl, _ := Lookup("encryption/encryption-stalled") + opts, err := tmpl.ResolveOpts(map[string]any{"stalled-after": 14}) + if err != nil { + t.Fatalf("ResolveOpts: %v", err) + } + req, err := tmpl.Build(opts) + if err != nil { + t.Fatalf("Build: %v", err) + } + if req.Criteria[1].Value != "14" { + t.Fatalf("expected 14 days, got %q", req.Criteria[1].Value) + } +} + +func TestEncryption_FVIneligible_Golden(t *testing.T) { + tmpl, _ := Lookup("encryption/fv-ineligible") + req, err := tmpl.Build(map[string]any{}) + if err != nil { + t.Fatalf("Build: %v", err) + } + c := req.Criteria[0] + if c.Name != "FileVault 2 Status" || c.SearchType != "is" || c.Value != "N/A" { + t.Fatalf("unexpected criterion: %+v", c) + } +} From 18444437375ae1f973abc5aad7182b0a115d9c53 Mon Sep 17 00:00:00 2001 From: Keaton Svoma Date: Tue, 12 May 2026 21:06:38 -0500 Subject: [PATCH 06/23] feat(smartgroup): add 4 software-update templates --- internal/smartgroup/library_test.go | 64 ++++++++++++++++++++ internal/smartgroup/updates.go | 90 +++++++++++++++++++++++++++++ 2 files changed, 154 insertions(+) create mode 100644 internal/smartgroup/updates.go diff --git a/internal/smartgroup/library_test.go b/internal/smartgroup/library_test.go index 89cea35..519a785 100644 --- a/internal/smartgroup/library_test.go +++ b/internal/smartgroup/library_test.go @@ -156,3 +156,67 @@ func TestEncryption_FVIneligible_Golden(t *testing.T) { t.Fatalf("unexpected criterion: %+v", c) } } + +// ─── Updates category golden tests ───────────────────────────────────────── + +func TestUpdates_OSVersionBelow_Golden(t *testing.T) { + tmpl, ok := Lookup("updates/os-version-below") + if !ok { + t.Fatal("template updates/os-version-below not registered") + } + if _, err := tmpl.ResolveOpts(map[string]any{}); err == nil { + t.Fatal("expected error for missing required --below-version") + } + opts, err := tmpl.ResolveOpts(map[string]any{"below-version": "15.4"}) + if err != nil { + t.Fatalf("ResolveOpts: %v", err) + } + req, err := tmpl.Build(opts) + if err != nil { + t.Fatalf("Build: %v", err) + } + c := req.Criteria[0] + if c.Name != "Operating System Version" || c.SearchType != "less than" || c.Value != "15.4" { + t.Fatalf("unexpected criterion: %+v", c) + } +} + +func TestUpdates_MajorVersionBehind_Golden(t *testing.T) { + tmpl, _ := Lookup("updates/major-version-behind") + opts, err := tmpl.ResolveOpts(map[string]any{"major-below": 15}) + if err != nil { + t.Fatalf("ResolveOpts: %v", err) + } + req, err := tmpl.Build(opts) + if err != nil { + t.Fatalf("Build: %v", err) + } + c := req.Criteria[0] + if c.Name != "Operating System Version" || c.SearchType != "less than" || c.Value != "15.0" { + t.Fatalf("unexpected criterion: %+v", c) + } +} + +func TestUpdates_RSRNotApplied_Golden(t *testing.T) { + tmpl, _ := Lookup("updates/rsr-not-applied") + req, err := tmpl.Build(map[string]any{}) + if err != nil { + t.Fatalf("Build: %v", err) + } + c := req.Criteria[0] + if c.Name != "Operating System Rapid Security Response" || c.SearchType != "is" || c.Value != "" { + t.Fatalf("unexpected criterion: %+v", c) + } +} + +func TestUpdates_BetaOS_Golden(t *testing.T) { + tmpl, _ := Lookup("updates/beta-os") + req, err := tmpl.Build(map[string]any{}) + if err != nil { + t.Fatalf("Build: %v", err) + } + c := req.Criteria[0] + if c.Name != "Operating System Version" || c.SearchType != "like" || c.Value != "Beta" { + t.Fatalf("unexpected criterion: %+v", c) + } +} diff --git a/internal/smartgroup/updates.go b/internal/smartgroup/updates.go new file mode 100644 index 0000000..fb3e406 --- /dev/null +++ b/internal/smartgroup/updates.go @@ -0,0 +1,90 @@ +// Copyright 2026, Jamf Software LLC + +package smartgroup + +import "fmt" + +func init() { + Register(updatesOSVersionBelow()) + Register(updatesMajorVersionBehind()) + Register(updatesRSRNotApplied()) + Register(updatesBetaOS()) +} + +func updatesOSVersionBelow() Template { + return Template{ + Slug: "updates/os-version-below", + Category: "updates", + Description: "Macs running OS older than a specific version", + Params: []ParamSpec{ + {Name: "below-version", Type: "version", Description: "macOS version threshold (e.g. 15.4)", Required: true}, + }, + Build: func(opts map[string]any) (SmartGroupRequest, error) { + v, ok := opts["below-version"].(string) + if !ok { + return SmartGroupRequest{}, fmt.Errorf("expected string below-version, got %T", opts["below-version"]) + } + return SmartGroupRequest{ + Description: fmt.Sprintf("Auto-generated by jamf-cli (template: updates/os-version-below, below-version=%s)", v), + Criteria: []Criterion{ + {AndOr: "and", Priority: 0, Name: CriterionOSVersion, SearchType: "less than", Value: v}, + }, + }, nil + }, + } +} + +func updatesMajorVersionBehind() Template { + return Template{ + Slug: "updates/major-version-behind", + Category: "updates", + Description: "Macs behind a major macOS version (e.g. all running <15.x)", + Params: []ParamSpec{ + {Name: "major-below", Type: "int", Description: "Major macOS version threshold", Required: true}, + }, + Build: func(opts map[string]any) (SmartGroupRequest, error) { + n, ok := opts["major-below"].(int) + if !ok { + return SmartGroupRequest{}, fmt.Errorf("expected int major-below, got %T", opts["major-below"]) + } + return SmartGroupRequest{ + Description: fmt.Sprintf("Auto-generated by jamf-cli (template: updates/major-version-behind, major-below=%d)", n), + Criteria: []Criterion{ + {AndOr: "and", Priority: 0, Name: CriterionOSVersion, SearchType: "less than", Value: fmt.Sprintf("%d.0", n)}, + }, + }, nil + }, + } +} + +func updatesRSRNotApplied() Template { + return Template{ + Slug: "updates/rsr-not-applied", + Category: "updates", + Description: "Macs with no Rapid Security Response applied", + Build: func(_ map[string]any) (SmartGroupRequest, error) { + return SmartGroupRequest{ + Description: "Auto-generated by jamf-cli (template: updates/rsr-not-applied)", + Criteria: []Criterion{ + {AndOr: "and", Priority: 0, Name: CriterionOSRapidSecurityResponse, SearchType: "is", Value: ""}, + }, + }, nil + }, + } +} + +func updatesBetaOS() Template { + return Template{ + Slug: "updates/beta-os", + Category: "updates", + Description: `Macs whose OS version contains "Beta"`, + Build: func(_ map[string]any) (SmartGroupRequest, error) { + return SmartGroupRequest{ + Description: "Auto-generated by jamf-cli (template: updates/beta-os)", + Criteria: []Criterion{ + {AndOr: "and", Priority: 0, Name: CriterionOSVersion, SearchType: "like", Value: "Beta"}, + }, + }, nil + }, + } +} From 305919c271c97d50d4cf54b3045110f99b023aa7 Mon Sep 17 00:00:00 2001 From: Keaton Svoma Date: Tue, 12 May 2026 21:08:54 -0500 Subject: [PATCH 07/23] feat(smartgroup): add 5 MDM-health templates --- internal/smartgroup/library_test.go | 70 ++++++++++++++++++ internal/smartgroup/mdm.go | 107 ++++++++++++++++++++++++++++ 2 files changed, 177 insertions(+) create mode 100644 internal/smartgroup/mdm.go diff --git a/internal/smartgroup/library_test.go b/internal/smartgroup/library_test.go index 519a785..e5539a3 100644 --- a/internal/smartgroup/library_test.go +++ b/internal/smartgroup/library_test.go @@ -220,3 +220,73 @@ func TestUpdates_BetaOS_Golden(t *testing.T) { t.Fatalf("unexpected criterion: %+v", c) } } + +// ─── MDM-health category golden tests ────────────────────────────────────── + +func TestMDM_BootstrapTokenMissing_Golden(t *testing.T) { + tmpl, _ := Lookup("mdm/bootstrap-token-missing") + req, err := tmpl.Build(map[string]any{}) + if err != nil { + t.Fatalf("Build: %v", err) + } + c := req.Criteria[0] + if c.Name != "Bootstrap Token Escrowed" || c.SearchType != "is" || c.Value != "No" { + t.Fatalf("unexpected criterion: %+v", c) + } +} + +func TestMDM_UserApprovedMDMNo_Golden(t *testing.T) { + tmpl, _ := Lookup("mdm/user-approved-mdm-no") + req, err := tmpl.Build(map[string]any{}) + if err != nil { + t.Fatalf("Build: %v", err) + } + c := req.Criteria[0] + if c.Name != "User Approved MDM" || c.SearchType != "is" || c.Value != "No" { + t.Fatalf("unexpected criterion: %+v", c) + } +} + +func TestMDM_StaleCheckin_GoldenDefault(t *testing.T) { + tmpl, _ := Lookup("mdm/stale-checkin") + opts, err := tmpl.ResolveOpts(map[string]any{}) + if err != nil { + t.Fatalf("ResolveOpts: %v", err) + } + req, err := tmpl.Build(opts) + if err != nil { + t.Fatalf("Build: %v", err) + } + c := req.Criteria[0] + if c.Name != "Last Inventory Update" || c.SearchType != "more than x days ago" || c.Value != "7" { + t.Fatalf("unexpected criterion: %+v", c) + } +} + +func TestMDM_MDMCertExpiring_GoldenDefault(t *testing.T) { + tmpl, _ := Lookup("mdm/mdm-cert-expiring") + opts, err := tmpl.ResolveOpts(map[string]any{}) + if err != nil { + t.Fatalf("ResolveOpts: %v", err) + } + req, err := tmpl.Build(opts) + if err != nil { + t.Fatalf("Build: %v", err) + } + c := req.Criteria[0] + if c.Name != "MDM Profile Expiration Date" || c.SearchType != "less than x days from now" || c.Value != "30" { + t.Fatalf("unexpected criterion: %+v", c) + } +} + +func TestMDM_DDMDisabled_Golden(t *testing.T) { + tmpl, _ := Lookup("mdm/declarative-management-disabled") + req, err := tmpl.Build(map[string]any{}) + if err != nil { + t.Fatalf("Build: %v", err) + } + c := req.Criteria[0] + if c.Name != "Declarative Device Management Enabled" || c.SearchType != "is" || c.Value != "No" { + t.Fatalf("unexpected criterion: %+v", c) + } +} diff --git a/internal/smartgroup/mdm.go b/internal/smartgroup/mdm.go new file mode 100644 index 0000000..dde209e --- /dev/null +++ b/internal/smartgroup/mdm.go @@ -0,0 +1,107 @@ +// Copyright 2026, Jamf Software LLC + +package smartgroup + +import "fmt" + +func init() { + Register(mdmBootstrapTokenMissing()) + Register(mdmUserApprovedMDMNo()) + Register(mdmStaleCheckin()) + Register(mdmMDMCertExpiring()) + Register(mdmDDMDisabled()) +} + +func mdmBootstrapTokenMissing() Template { + return Template{ + Slug: "mdm/bootstrap-token-missing", + Category: "mdm", + Description: "Macs without an escrowed bootstrap token", + Build: func(_ map[string]any) (SmartGroupRequest, error) { + return SmartGroupRequest{ + Description: "Auto-generated by jamf-cli (template: mdm/bootstrap-token-missing)", + Criteria: []Criterion{ + {AndOr: "and", Priority: 0, Name: CriterionBootstrapTokenEscrowed, SearchType: "is", Value: "No"}, + }, + }, nil + }, + } +} + +func mdmUserApprovedMDMNo() Template { + return Template{ + Slug: "mdm/user-approved-mdm-no", + Category: "mdm", + Description: "Macs without User Approved MDM", + Build: func(_ map[string]any) (SmartGroupRequest, error) { + return SmartGroupRequest{ + Description: "Auto-generated by jamf-cli (template: mdm/user-approved-mdm-no)", + Criteria: []Criterion{ + {AndOr: "and", Priority: 0, Name: CriterionUserApprovedMDM, SearchType: "is", Value: "No"}, + }, + }, nil + }, + } +} + +func mdmStaleCheckin() Template { + return Template{ + Slug: "mdm/stale-checkin", + Category: "mdm", + Description: "Macs whose last inventory update is older than N days", + Params: []ParamSpec{ + {Name: "days", Type: "int", Default: 7, Description: "Days since last inventory update"}, + }, + Build: func(opts map[string]any) (SmartGroupRequest, error) { + n, ok := opts["days"].(int) + if !ok { + return SmartGroupRequest{}, fmt.Errorf("expected int days, got %T", opts["days"]) + } + return SmartGroupRequest{ + Description: fmt.Sprintf("Auto-generated by jamf-cli (template: mdm/stale-checkin, days=%d)", n), + Criteria: []Criterion{ + {AndOr: "and", Priority: 0, Name: CriterionLastInventoryUpdate, SearchType: "more than x days ago", Value: fmt.Sprintf("%d", n)}, + }, + }, nil + }, + } +} + +func mdmMDMCertExpiring() Template { + return Template{ + Slug: "mdm/mdm-cert-expiring", + Category: "mdm", + Description: "Macs whose MDM profile expires within N days", + Params: []ParamSpec{ + {Name: "within-days", Type: "int", Default: 30, Description: "Days from now"}, + }, + Build: func(opts map[string]any) (SmartGroupRequest, error) { + n, ok := opts["within-days"].(int) + if !ok { + return SmartGroupRequest{}, fmt.Errorf("expected int within-days, got %T", opts["within-days"]) + } + return SmartGroupRequest{ + Description: fmt.Sprintf("Auto-generated by jamf-cli (template: mdm/mdm-cert-expiring, within-days=%d)", n), + Criteria: []Criterion{ + {AndOr: "and", Priority: 0, Name: CriterionMDMProfileExpirationDate, SearchType: "less than x days from now", Value: fmt.Sprintf("%d", n)}, + }, + }, nil + }, + } +} + +func mdmDDMDisabled() Template { + return Template{ + Slug: "mdm/declarative-management-disabled", + Category: "mdm", + Description: "Macs without Declarative Device Management enabled", + Build: func(_ map[string]any) (SmartGroupRequest, error) { + return SmartGroupRequest{ + Description: "Auto-generated by jamf-cli (template: mdm/declarative-management-disabled)", + Criteria: []Criterion{ + {AndOr: "and", Priority: 0, Name: CriterionDDMEnabled, SearchType: "is", Value: "No"}, + }, + }, nil + }, + } +} From e7b270701e4a1ec9b71e96740b639c27a02cdd31 Mon Sep 17 00:00:00 2001 From: Keaton Svoma Date: Tue, 12 May 2026 21:11:05 -0500 Subject: [PATCH 08/23] feat(smartgroup): add 4 compliance-basics templates --- internal/smartgroup/compliance.go | 77 ++++++++++++++++++ internal/smartgroup/library_test.go | 119 ++++++++++++++++++++++++++++ 2 files changed, 196 insertions(+) create mode 100644 internal/smartgroup/compliance.go diff --git a/internal/smartgroup/compliance.go b/internal/smartgroup/compliance.go new file mode 100644 index 0000000..a9d1a09 --- /dev/null +++ b/internal/smartgroup/compliance.go @@ -0,0 +1,77 @@ +// Copyright 2026, Jamf Software LLC + +package smartgroup + +func init() { + Register(complianceGatekeeperDisabled()) + Register(complianceSIPDisabled()) + Register(complianceFirewallDisabled()) + Register(complianceNonCompliantBaseline()) +} + +func complianceGatekeeperDisabled() Template { + return Template{ + Slug: "compliance/gatekeeper-disabled", + Category: "compliance", + Description: "Macs with Gatekeeper disabled", + Build: func(_ map[string]any) (SmartGroupRequest, error) { + return SmartGroupRequest{ + Description: "Auto-generated by jamf-cli (template: compliance/gatekeeper-disabled)", + Criteria: []Criterion{ + {AndOr: "and", Priority: 0, Name: CriterionGatekeeper, SearchType: "is", Value: "Disabled"}, + }, + }, nil + }, + } +} + +func complianceSIPDisabled() Template { + return Template{ + Slug: "compliance/sip-disabled", + Category: "compliance", + Description: "Macs with System Integrity Protection disabled", + Build: func(_ map[string]any) (SmartGroupRequest, error) { + return SmartGroupRequest{ + Description: "Auto-generated by jamf-cli (template: compliance/sip-disabled)", + Criteria: []Criterion{ + {AndOr: "and", Priority: 0, Name: CriterionSIP, SearchType: "is", Value: "Disabled"}, + }, + }, nil + }, + } +} + +func complianceFirewallDisabled() Template { + return Template{ + Slug: "compliance/firewall-disabled", + Category: "compliance", + Description: "Macs with the application firewall disabled", + Build: func(_ map[string]any) (SmartGroupRequest, error) { + return SmartGroupRequest{ + Description: "Auto-generated by jamf-cli (template: compliance/firewall-disabled)", + Criteria: []Criterion{ + {AndOr: "and", Priority: 0, Name: CriterionFirewallEnabled, SearchType: "is", Value: "No"}, + }, + }, nil + }, + } +} + +func complianceNonCompliantBaseline() Template { + return Template{ + Slug: "compliance/non-compliant-baseline", + Category: "compliance", + Description: "Composite: any of FV2 disabled, SIP disabled, Gatekeeper disabled, Firewall disabled", + Build: func(_ map[string]any) (SmartGroupRequest, error) { + return SmartGroupRequest{ + Description: "Auto-generated by jamf-cli (template: compliance/non-compliant-baseline). OR-composite across four security primitives.", + Criteria: []Criterion{ + {AndOr: "and", Priority: 0, Name: CriterionFV2Enabled, SearchType: "is", Value: "Not Enabled"}, + {AndOr: "or", Priority: 1, Name: CriterionSIP, SearchType: "is", Value: "Disabled"}, + {AndOr: "or", Priority: 2, Name: CriterionGatekeeper, SearchType: "is", Value: "Disabled"}, + {AndOr: "or", Priority: 3, Name: CriterionFirewallEnabled, SearchType: "is", Value: "No"}, + }, + }, nil + }, + } +} diff --git a/internal/smartgroup/library_test.go b/internal/smartgroup/library_test.go index e5539a3..7bdfa9d 100644 --- a/internal/smartgroup/library_test.go +++ b/internal/smartgroup/library_test.go @@ -290,3 +290,122 @@ func TestMDM_DDMDisabled_Golden(t *testing.T) { t.Fatalf("unexpected criterion: %+v", c) } } + +// ─── Compliance category golden tests ────────────────────────────────────── + +func TestCompliance_GatekeeperDisabled_Golden(t *testing.T) { + tmpl, _ := Lookup("compliance/gatekeeper-disabled") + req, err := tmpl.Build(map[string]any{}) + if err != nil { + t.Fatalf("Build: %v", err) + } + c := req.Criteria[0] + if c.Name != "Gatekeeper" || c.SearchType != "is" || c.Value != "Disabled" { + t.Fatalf("unexpected criterion: %+v", c) + } +} + +func TestCompliance_SIPDisabled_Golden(t *testing.T) { + tmpl, _ := Lookup("compliance/sip-disabled") + req, err := tmpl.Build(map[string]any{}) + if err != nil { + t.Fatalf("Build: %v", err) + } + c := req.Criteria[0] + if c.Name != "System Integrity Protection" || c.SearchType != "is" || c.Value != "Disabled" { + t.Fatalf("unexpected criterion: %+v", c) + } +} + +func TestCompliance_FirewallDisabled_Golden(t *testing.T) { + tmpl, _ := Lookup("compliance/firewall-disabled") + req, err := tmpl.Build(map[string]any{}) + if err != nil { + t.Fatalf("Build: %v", err) + } + c := req.Criteria[0] + if c.Name != "Firewall Enabled" || c.SearchType != "is" || c.Value != "No" { + t.Fatalf("unexpected criterion: %+v", c) + } +} + +func TestCompliance_NonCompliantBaseline_Golden(t *testing.T) { + tmpl, _ := Lookup("compliance/non-compliant-baseline") + req, err := tmpl.Build(map[string]any{}) + if err != nil { + t.Fatalf("Build: %v", err) + } + if len(req.Criteria) != 4 { + t.Fatalf("expected 4 criteria, got %d", len(req.Criteria)) + } + for i, c := range req.Criteria { + if i == 0 && c.AndOr != "and" { + t.Errorf("first criterion should be 'and', got %q", c.AndOr) + } + if i > 0 && c.AndOr != "or" { + t.Errorf("criterion %d should be 'or', got %q", i, c.AndOr) + } + } +} + +// ─── Lifecycle category golden tests ─────────────────────────────────────── + +func TestLifecycle_Unsupervised_Golden(t *testing.T) { + tmpl, _ := Lookup("lifecycle/unsupervised") + req, err := tmpl.Build(map[string]any{}) + if err != nil { + t.Fatalf("Build: %v", err) + } + c := req.Criteria[0] + if c.Name != "Supervised" || c.SearchType != "is" || c.Value != "No" { + t.Fatalf("unexpected criterion: %+v", c) + } +} + +func TestLifecycle_ADEEnrolled_Golden(t *testing.T) { + tmpl, _ := Lookup("lifecycle/ade-enrolled") + req, err := tmpl.Build(map[string]any{}) + if err != nil { + t.Fatalf("Build: %v", err) + } + c := req.Criteria[0] + if c.Name != "Enrollment Method: PreStage enrollment" || c.SearchType != "is" || c.Value != "Yes" { + t.Fatalf("unexpected criterion: %+v", c) + } +} + +func TestLifecycle_JamfBinaryOutdated_Golden(t *testing.T) { + tmpl, _ := Lookup("lifecycle/jamf-binary-outdated") + if _, err := tmpl.ResolveOpts(map[string]any{}); err == nil { + t.Fatal("expected error for missing required --below-version") + } + opts, err := tmpl.ResolveOpts(map[string]any{"below-version": "11.0.0"}) + if err != nil { + t.Fatalf("ResolveOpts: %v", err) + } + req, err := tmpl.Build(opts) + if err != nil { + t.Fatalf("Build: %v", err) + } + c := req.Criteria[0] + if c.Name != "Jamf Binary Version" || c.SearchType != "less than" || c.Value != "11.0.0" { + t.Fatalf("unexpected criterion: %+v", c) + } +} + +func TestLifecycle_FVIneligibleHardware_Golden(t *testing.T) { + tmpl, _ := Lookup("lifecycle/fv-ineligible-hardware") + req, err := tmpl.Build(map[string]any{}) + if err != nil { + t.Fatalf("Build: %v", err) + } + if len(req.Criteria) != 2 { + t.Fatalf("expected 2 criteria, got %d", len(req.Criteria)) + } + if req.Criteria[0].Name != "FileVault 2 Status" || req.Criteria[0].Value != "N/A" { + t.Fatalf("unexpected criterion 0: %+v", req.Criteria[0]) + } + if req.Criteria[1].Name != "Apple Silicon" || req.Criteria[1].Value != "No" { + t.Fatalf("unexpected criterion 1: %+v", req.Criteria[1]) + } +} From a875bda9657042c42f1be27ea9b4e2bd213648a9 Mon Sep 17 00:00:00 2001 From: Keaton Svoma Date: Tue, 12 May 2026 21:11:33 -0500 Subject: [PATCH 09/23] feat(smartgroup): add 4 lifecycle-hygiene templates --- internal/smartgroup/lifecycle.go | 84 ++++++++++++++++++++++++++++++++ 1 file changed, 84 insertions(+) create mode 100644 internal/smartgroup/lifecycle.go diff --git a/internal/smartgroup/lifecycle.go b/internal/smartgroup/lifecycle.go new file mode 100644 index 0000000..25543de --- /dev/null +++ b/internal/smartgroup/lifecycle.go @@ -0,0 +1,84 @@ +// Copyright 2026, Jamf Software LLC + +package smartgroup + +import "fmt" + +func init() { + Register(lifecycleUnsupervised()) + Register(lifecycleADEEnrolled()) + Register(lifecycleJamfBinaryOutdated()) + Register(lifecycleFVIneligibleHardware()) +} + +func lifecycleUnsupervised() Template { + return Template{ + Slug: "lifecycle/unsupervised", + Category: "lifecycle", + Description: "Macs that are not supervised", + Build: func(_ map[string]any) (SmartGroupRequest, error) { + return SmartGroupRequest{ + Description: "Auto-generated by jamf-cli (template: lifecycle/unsupervised)", + Criteria: []Criterion{ + {AndOr: "and", Priority: 0, Name: CriterionSupervised, SearchType: "is", Value: "No"}, + }, + }, nil + }, + } +} + +func lifecycleADEEnrolled() Template { + return Template{ + Slug: "lifecycle/ade-enrolled", + Category: "lifecycle", + Description: "Macs enrolled via Automated Device Enrollment (PreStage)", + Build: func(_ map[string]any) (SmartGroupRequest, error) { + return SmartGroupRequest{ + Description: "Auto-generated by jamf-cli (template: lifecycle/ade-enrolled)", + Criteria: []Criterion{ + {AndOr: "and", Priority: 0, Name: CriterionEnrollmentMethodPrestage, SearchType: "is", Value: "Yes"}, + }, + }, nil + }, + } +} + +func lifecycleJamfBinaryOutdated() Template { + return Template{ + Slug: "lifecycle/jamf-binary-outdated", + Category: "lifecycle", + Description: "Macs running an outdated Jamf binary", + Params: []ParamSpec{ + {Name: "below-version", Type: "version", Description: "Jamf binary version threshold (e.g. 11.0.0)", Required: true}, + }, + Build: func(opts map[string]any) (SmartGroupRequest, error) { + v, ok := opts["below-version"].(string) + if !ok { + return SmartGroupRequest{}, fmt.Errorf("expected string below-version, got %T", opts["below-version"]) + } + return SmartGroupRequest{ + Description: fmt.Sprintf("Auto-generated by jamf-cli (template: lifecycle/jamf-binary-outdated, below-version=%s)", v), + Criteria: []Criterion{ + {AndOr: "and", Priority: 0, Name: CriterionJamfBinaryVersion, SearchType: "less than", Value: v}, + }, + }, nil + }, + } +} + +func lifecycleFVIneligibleHardware() Template { + return Template{ + Slug: "lifecycle/fv-ineligible-hardware", + Category: "lifecycle", + Description: "Intel Macs reporting FileVault 2 Status N/A (hardware-refresh candidates)", + Build: func(_ map[string]any) (SmartGroupRequest, error) { + return SmartGroupRequest{ + Description: "Auto-generated by jamf-cli (template: lifecycle/fv-ineligible-hardware)", + Criteria: []Criterion{ + {AndOr: "and", Priority: 0, Name: CriterionFV2Status, SearchType: "is", Value: "N/A"}, + {AndOr: "and", Priority: 1, Name: CriterionAppleSilicon, SearchType: "is", Value: "No"}, + }, + }, nil + }, + } +} From 9b126ecb52123d1b9ea0155d5a58708e0517736d Mon Sep 17 00:00:00 2001 From: Keaton Svoma Date: Tue, 12 May 2026 21:12:59 -0500 Subject: [PATCH 10/23] test(smartgroup): add full-library integration tests (23 templates, 5 categories, criterion-name registry) --- internal/smartgroup/library_test.go | 93 +++++++++++++++++++++++++++++ 1 file changed, 93 insertions(+) diff --git a/internal/smartgroup/library_test.go b/internal/smartgroup/library_test.go index 7bdfa9d..d87dfd6 100644 --- a/internal/smartgroup/library_test.go +++ b/internal/smartgroup/library_test.go @@ -4,6 +4,7 @@ package smartgroup import ( "sort" + "strings" "testing" ) @@ -409,3 +410,95 @@ func TestLifecycle_FVIneligibleHardware_Golden(t *testing.T) { t.Fatalf("unexpected criterion 1: %+v", req.Criteria[1]) } } + +// ─── Whole-library integration tests ─────────────────────────────────────── + +func TestLibrary_ExactlyTwentyThreeTemplates(t *testing.T) { + got := len(All()) + const want = 23 + if got != want { + t.Fatalf("expected %d templates registered, got %d", want, got) + } +} + +func TestLibrary_AllCategoriesPresent(t *testing.T) { + want := map[string]int{ + "encryption": 6, + "updates": 4, + "mdm": 5, + "compliance": 4, + "lifecycle": 4, + } + got := make(map[string]int) + for _, tt := range All() { + got[tt.Category]++ + } + for cat, n := range want { + if got[cat] != n { + t.Errorf("category %s: expected %d templates, got %d", cat, n, got[cat]) + } + } +} + +func TestLibrary_EveryTemplateProducesValidCriteria(t *testing.T) { + known := allCriterionConsts() + knownValues := make(map[string]struct{}, len(known)) + for _, v := range known { + knownValues[v] = struct{}{} + } + for _, tmpl := range All() { + t.Run(tmpl.Slug, func(t *testing.T) { + opts, err := tmpl.ResolveOpts(defaultOptsForTest(tmpl)) + if err != nil { + t.Fatalf("ResolveOpts: %v", err) + } + req, err := tmpl.Build(opts) + if err != nil { + t.Fatalf("Build: %v", err) + } + if len(req.Criteria) == 0 { + t.Fatal("template produced zero criteria") + } + for i, c := range req.Criteria { + if _, ok := knownValues[c.Name]; !ok { + t.Errorf("criterion %d uses unregistered name %q (must be one of the criteria.go consts)", i, c.Name) + } + if c.SearchType == "" { + t.Errorf("criterion %d has empty searchType", i) + } + if c.AndOr != "and" && c.AndOr != "or" { + t.Errorf("criterion %d has invalid andOr %q", i, c.AndOr) + } + } + }) + } +} + +// defaultOptsForTest supplies sensible values for required params during the +// whole-library scan. Keep this in sync with required-param templates. +func defaultOptsForTest(t Template) map[string]any { + out := make(map[string]any, len(t.Params)) + for _, p := range t.Params { + if !p.Required { + continue + } + switch t.Slug { + case "updates/os-version-below": + out["below-version"] = "15.0" + case "updates/major-version-behind": + out["major-below"] = 15 + case "lifecycle/jamf-binary-outdated": + out["below-version"] = "11.0.0" + } + } + return out +} + +func TestLibrary_AllSlugsUseCategoryPrefix(t *testing.T) { + for _, tmpl := range All() { + prefix := tmpl.Category + "/" + if !strings.HasPrefix(tmpl.Slug, prefix) { + t.Errorf("template %q (category %q): slug should start with %q", tmpl.Slug, tmpl.Category, prefix) + } + } +} From 2bc5d9503f3b497a59a4d620813ce1136a536f88 Mon Sep 17 00:00:00 2001 From: Keaton Svoma Date: Tue, 12 May 2026 21:15:04 -0500 Subject: [PATCH 11/23] feat(smartgroup): add CountMembers helper for post-apply membership check --- internal/smartgroup/membership.go | 39 ++++++++++++++ internal/smartgroup/membership_test.go | 73 ++++++++++++++++++++++++++ 2 files changed, 112 insertions(+) create mode 100644 internal/smartgroup/membership.go create mode 100644 internal/smartgroup/membership_test.go diff --git a/internal/smartgroup/membership.go b/internal/smartgroup/membership.go new file mode 100644 index 0000000..afa3a03 --- /dev/null +++ b/internal/smartgroup/membership.go @@ -0,0 +1,39 @@ +// Copyright 2026, Jamf Software LLC + +package smartgroup + +import ( + "context" + "encoding/json" + "fmt" + "io" + "net/http" +) + +// HTTPDoer is the minimal HTTP interface required by membership/verify helpers. +// Matches registry.HTTPClient's Do signature so the same value can be passed. +type HTTPDoer interface { + Do(ctx context.Context, method, url string, body io.Reader) (*http.Response, error) +} + +// CountMembers calls GET /v2/computer-groups/smart-group-membership/{id} and +// returns the length of the members array. +func CountMembers(ctx context.Context, client HTTPDoer, groupID string) (int, error) { + url := fmt.Sprintf("/v2/computer-groups/smart-group-membership/%s", groupID) + resp, err := client.Do(ctx, http.MethodGet, url, nil) + if err != nil { + return 0, fmt.Errorf("smart-group membership: %w", err) + } + defer resp.Body.Close() + if resp.StatusCode < 200 || resp.StatusCode >= 300 { + body, _ := io.ReadAll(resp.Body) + return 0, fmt.Errorf("smart-group membership: HTTP %d: %s", resp.StatusCode, string(body)) + } + var out struct { + Members []int `json:"members"` + } + if err := json.NewDecoder(resp.Body).Decode(&out); err != nil { + return 0, fmt.Errorf("smart-group membership: decode: %w", err) + } + return len(out.Members), nil +} diff --git a/internal/smartgroup/membership_test.go b/internal/smartgroup/membership_test.go new file mode 100644 index 0000000..8ecc571 --- /dev/null +++ b/internal/smartgroup/membership_test.go @@ -0,0 +1,73 @@ +// Copyright 2026, Jamf Software LLC + +package smartgroup + +import ( + "context" + "encoding/json" + "io" + "net/http" + "strings" + "testing" +) + +type fakeHTTPClient struct { + resp *http.Response + err error + url string +} + +func (f *fakeHTTPClient) Do(_ context.Context, _ string, url string, _ io.Reader) (*http.Response, error) { + f.url = url + return f.resp, f.err +} + +func makeJSON(t *testing.T, v any) *http.Response { + t.Helper() + b, err := json.Marshal(v) + if err != nil { + t.Fatalf("marshal: %v", err) + } + return &http.Response{ + StatusCode: 200, + Body: io.NopCloser(strings.NewReader(string(b))), + } +} + +func TestCountMembers_PopulatedGroup(t *testing.T) { + resp := makeJSON(t, map[string]any{"members": []int{1, 2, 3, 4, 5}}) + client := &fakeHTTPClient{resp: resp} + n, err := CountMembers(context.Background(), client, "287") + if err != nil { + t.Fatalf("CountMembers: %v", err) + } + if n != 5 { + t.Fatalf("expected 5, got %d", n) + } + wantPath := "/v2/computer-groups/smart-group-membership/287" + if !strings.Contains(client.url, wantPath) { + t.Fatalf("expected URL to contain %q, got %q", wantPath, client.url) + } +} + +func TestCountMembers_EmptyGroup(t *testing.T) { + resp := makeJSON(t, map[string]any{"members": []int{}}) + n, err := CountMembers(context.Background(), &fakeHTTPClient{resp: resp}, "1") + if err != nil { + t.Fatalf("CountMembers: %v", err) + } + if n != 0 { + t.Fatalf("expected 0, got %d", n) + } +} + +func TestCountMembers_HTTPError(t *testing.T) { + resp := &http.Response{ + StatusCode: 404, + Body: io.NopCloser(strings.NewReader(`{"errors":["not found"]}`)), + } + _, err := CountMembers(context.Background(), &fakeHTTPClient{resp: resp}, "999") + if err == nil { + t.Fatal("expected error for 404, got nil") + } +} From 12f413b8df824a75294f69a0a1b675937b8fb923 Mon Sep 17 00:00:00 2001 From: Keaton Svoma Date: Tue, 12 May 2026 21:17:37 -0500 Subject: [PATCH 12/23] feat(commands): scaffold pro smart-group namespace (alias sg) with stub subcommands Wires the new `pro smart-group` (alias `sg`) namespace into the CLI under the Computer Management help group. Four stub subcommands (templates, preview, apply, verify-templates) compile and resolve correctly; full implementations follow in Tasks 12-15. Co-Authored-By: Claude Sonnet 4.6 --- internal/commands/aliases.go | 1 + internal/commands/groups.go | 1 + internal/commands/pro.go | 1 + internal/commands/pro_smartgroup.go | 53 +++++++++++++++++++++++++++++ 4 files changed, 56 insertions(+) create mode 100644 internal/commands/pro_smartgroup.go diff --git a/internal/commands/aliases.go b/internal/commands/aliases.go index 499ecc8..53c349d 100644 --- a/internal/commands/aliases.go +++ b/internal/commands/aliases.go @@ -27,6 +27,7 @@ var commandAliases = map[string][]string{ // so it isn't detected as a singleton and retains the plural generated name. "jamf-protect": {"jp"}, "jamf-connects": {"jamf-connect"}, + "smart-group": {"sg"}, } // rootAliases maps root-level command names to short aliases. diff --git a/internal/commands/groups.go b/internal/commands/groups.go index b4b50fb..0cd7316 100644 --- a/internal/commands/groups.go +++ b/internal/commands/groups.go @@ -175,6 +175,7 @@ var proGroupMap = map[string]string{ "computer-extension-attributes": groupComputers, "computer-inventory-collection-settings": groupComputers, "smart-computer-groups": groupComputers, + "smart-group": groupComputers, "static-computer-groups": groupComputers, "computers-inventory": groupComputers, // macOS-only Dock customization (Pro: Settings > Computer management > Dock items) diff --git a/internal/commands/pro.go b/internal/commands/pro.go index bea0749..659d1f8 100644 --- a/internal/commands/pro.go +++ b/internal/commands/pro.go @@ -30,6 +30,7 @@ func newProCmd(cliCtx *registry.CLIContext) *cobra.Command { cmd.AddCommand(newDiffCmd()) cmd.AddCommand(newGroupToolsCmd(cliCtx)) cmd.AddCommand(newDeviceCmd(cliCtx)) + cmd.AddCommand(newSmartGroupCmd(cliCtx)) // Platform API commands (require platform gateway auth) cmd.AddCommand(newBlueprintsCmd(cliCtx)) cmd.AddCommand(newComplianceBenchmarksCmd(cliCtx)) diff --git a/internal/commands/pro_smartgroup.go b/internal/commands/pro_smartgroup.go new file mode 100644 index 0000000..04a5bbd --- /dev/null +++ b/internal/commands/pro_smartgroup.go @@ -0,0 +1,53 @@ +// Copyright 2026, Jamf Software LLC + +package commands + +import ( + "github.com/spf13/cobra" + + "github.com/Jamf-Concepts/jamf-cli/internal/registry" +) + +// newSmartGroupCmd is the entry point for the `pro smart-group` namespace. +// Subcommands are wired in subsequent tasks (templates, preview, apply, +// verify-templates). +func newSmartGroupCmd(cliCtx *registry.CLIContext) *cobra.Command { + cmd := &cobra.Command{ + Use: "smart-group", + Short: "Curated smart-group templates: list, preview, apply, verify", + Long: `Create useful Jamf Pro smart groups from a curated library of templates. + +Templates encode operationally-essential smart groups (devices not encrypted, +recovery keys invalid, OS versions behind, bootstrap tokens missing, etc.) so +admins don't have to assemble them by hand. + +Templates are sourced from JSS canonical criterion-name strings. Run +'pro smart-group verify-templates' once against your tenant to confirm each +template matches as expected.`, + } + + cmd.AddCommand(newSmartGroupTemplatesCmd(cliCtx)) + cmd.AddCommand(newSmartGroupPreviewCmd(cliCtx)) + cmd.AddCommand(newSmartGroupApplyCmd(cliCtx)) + cmd.AddCommand(newSmartGroupVerifyTemplatesCmd(cliCtx)) + + return cmd +} + +// Stubs so the skeleton compiles. Replaced in Tasks 12-15. + +func newSmartGroupTemplatesCmd(_ *registry.CLIContext) *cobra.Command { + return &cobra.Command{Use: "templates", Short: "List available smart-group templates (stub)"} +} + +func newSmartGroupPreviewCmd(_ *registry.CLIContext) *cobra.Command { + return &cobra.Command{Use: "preview", Short: "Preview a template (stub)"} +} + +func newSmartGroupApplyCmd(_ *registry.CLIContext) *cobra.Command { + return &cobra.Command{Use: "apply", Short: "Apply a template (stub)"} +} + +func newSmartGroupVerifyTemplatesCmd(_ *registry.CLIContext) *cobra.Command { + return &cobra.Command{Use: "verify-templates", Short: "Verify templates against the live tenant (stub)"} +} From cd861e328adc1dd4376d00cc11ad4b826ff61db0 Mon Sep 17 00:00:00 2001 From: Keaton Svoma Date: Tue, 12 May 2026 21:20:05 -0500 Subject: [PATCH 13/23] feat(commands): implement pro smart-group templates (list with --category, table/json output) Co-Authored-By: Claude Opus 4.7 (1M context) --- internal/commands/pro_smartgroup.go | 128 +++++++++++++++++++++-- internal/commands/pro_smartgroup_test.go | 90 ++++++++++++++++ 2 files changed, 212 insertions(+), 6 deletions(-) create mode 100644 internal/commands/pro_smartgroup_test.go diff --git a/internal/commands/pro_smartgroup.go b/internal/commands/pro_smartgroup.go index 04a5bbd..e62e821 100644 --- a/internal/commands/pro_smartgroup.go +++ b/internal/commands/pro_smartgroup.go @@ -3,14 +3,17 @@ package commands import ( + "encoding/json" + "fmt" + "io" + "sort" + "github.com/spf13/cobra" "github.com/Jamf-Concepts/jamf-cli/internal/registry" + "github.com/Jamf-Concepts/jamf-cli/internal/smartgroup" ) -// newSmartGroupCmd is the entry point for the `pro smart-group` namespace. -// Subcommands are wired in subsequent tasks (templates, preview, apply, -// verify-templates). func newSmartGroupCmd(cliCtx *registry.CLIContext) *cobra.Command { cmd := &cobra.Command{ Use: "smart-group", @@ -34,12 +37,125 @@ template matches as expected.`, return cmd } -// Stubs so the skeleton compiles. Replaced in Tasks 12-15. - func newSmartGroupTemplatesCmd(_ *registry.CLIContext) *cobra.Command { - return &cobra.Command{Use: "templates", Short: "List available smart-group templates (stub)"} + var ( + category string + format string + ) + cmd := &cobra.Command{ + Use: "templates", + Short: "List available smart-group templates", + Long: `List all curated smart-group templates. Use --category to filter +to one of: encryption, updates, mdm, compliance, lifecycle.`, + Example: ` # All templates grouped by category + jamf-cli pro smart-group templates + + # Just encryption templates + jamf-cli pro smart-group templates --category encryption + + # Machine-readable + jamf-cli pro smart-group templates -o json`, + RunE: func(cmd *cobra.Command, _ []string) error { + var tmpls []smartgroup.Template + if category != "" { + tmpls = smartgroup.ByCategory(category) + } else { + tmpls = smartgroup.All() + } + return renderTemplatesList(cmd, tmpls, format) + }, + } + cmd.Flags().StringVar(&category, "category", "", "Filter by category (encryption|updates|mdm|compliance|lifecycle)") + cmd.Flags().StringVarP(&format, "output", "o", "table", "Output format: table|json") + return cmd +} + +func renderTemplatesList(cmd *cobra.Command, tmpls []smartgroup.Template, format string) error { + out := cmd.OutOrStdout() + if format == "json" { + return writeTemplatesJSON(out, tmpls) + } + if len(tmpls) == 0 { + fmt.Fprintln(out, "0 templates match the filter.") + return nil + } + cats := uniqueCategories(tmpls) + noun := "category" + if len(cats) != 1 { + noun = "categories" + } + fmt.Fprintf(out, "Smart Group Templates — %d available across %d %s\n\n", len(tmpls), len(cats), noun) + for _, cat := range cats { + bucket := filterByCategory(tmpls, cat) + fmt.Fprintf(out, "Category: %s (%d)\n", cat, len(bucket)) + for _, t := range bucket { + suffix := "" + if len(t.Params) > 0 { + suffix = fmt.Sprintf(" (params: --%s)", t.Params[0].Name) + } + fmt.Fprintf(out, " %-40s %s%s\n", t.Slug, t.Description, suffix) + } + fmt.Fprintln(out) + } + return nil } +func writeTemplatesJSON(out io.Writer, tmpls []smartgroup.Template) error { + type paramOut struct { + Name string `json:"name"` + Type string `json:"type"` + Default any `json:"default,omitempty"` + Description string `json:"description"` + Required bool `json:"required"` + } + type tmplOut struct { + Slug string `json:"slug"` + Category string `json:"category"` + Description string `json:"description"` + Params []paramOut `json:"params"` + } + rows := make([]tmplOut, 0, len(tmpls)) + for _, t := range tmpls { + row := tmplOut{Slug: t.Slug, Category: t.Category, Description: t.Description, Params: []paramOut{}} + for _, p := range t.Params { + row.Params = append(row.Params, paramOut{ + Name: p.Name, Type: p.Type, Default: p.Default, + Description: p.Description, Required: p.Required, + }) + } + rows = append(rows, row) + } + enc := json.NewEncoder(out) + enc.SetIndent("", " ") + return enc.Encode(rows) +} + +func uniqueCategories(tmpls []smartgroup.Template) []string { + seen := make(map[string]struct{}, 5) + out := make([]string, 0, 5) + for _, t := range tmpls { + if _, ok := seen[t.Category]; ok { + continue + } + seen[t.Category] = struct{}{} + out = append(out, t.Category) + } + sort.Strings(out) + return out +} + +func filterByCategory(tmpls []smartgroup.Template, cat string) []smartgroup.Template { + out := make([]smartgroup.Template, 0) + for _, t := range tmpls { + if t.Category == cat { + out = append(out, t) + } + } + return out +} + +// Stubs for the remaining subcommands. Replaced in Tasks 13-15. + func newSmartGroupPreviewCmd(_ *registry.CLIContext) *cobra.Command { return &cobra.Command{Use: "preview", Short: "Preview a template (stub)"} } diff --git a/internal/commands/pro_smartgroup_test.go b/internal/commands/pro_smartgroup_test.go new file mode 100644 index 0000000..63fda1c --- /dev/null +++ b/internal/commands/pro_smartgroup_test.go @@ -0,0 +1,90 @@ +// Copyright 2026, Jamf Software LLC + +package commands + +import ( + "bytes" + "context" + "encoding/json" + "io" + "net/http" + "strings" + "testing" + + "github.com/Jamf-Concepts/jamf-cli/internal/registry" +) + +func runSmartGroupCmd(t *testing.T, args ...string) (string, string, error) { + t.Helper() + cliCtx := ®istry.CLIContext{} + root := newSmartGroupCmd(cliCtx) + stdout := &bytes.Buffer{} + stderr := &bytes.Buffer{} + root.SetOut(stdout) + root.SetErr(stderr) + root.SetArgs(args) + err := root.Execute() + return stdout.String(), stderr.String(), err +} + +func TestTemplates_TableDefault(t *testing.T) { + out, _, err := runSmartGroupCmd(t, "templates") + if err != nil { + t.Fatalf("execute: %v", err) + } + for _, want := range []string{ + "encryption/not-encrypted", + "updates/os-version-below", + "mdm/bootstrap-token-missing", + "compliance/gatekeeper-disabled", + "lifecycle/unsupervised", + } { + if !strings.Contains(out, want) { + t.Errorf("output missing %q\n%s", want, out) + } + } +} + +func TestTemplates_CategoryFilter(t *testing.T) { + out, _, err := runSmartGroupCmd(t, "templates", "--category", "encryption") + if err != nil { + t.Fatalf("execute: %v", err) + } + if !strings.Contains(out, "encryption/not-encrypted") { + t.Errorf("expected encryption templates: %s", out) + } + if strings.Contains(out, "lifecycle/unsupervised") { + t.Errorf("category filter should have excluded lifecycle: %s", out) + } +} + +func TestTemplates_JSONOutput(t *testing.T) { + out, _, err := runSmartGroupCmd(t, "templates", "-o", "json") + if err != nil { + t.Fatalf("execute: %v", err) + } + var parsed []map[string]any + if err := json.Unmarshal([]byte(out), &parsed); err != nil { + t.Fatalf("json output not parseable: %v\n%s", err, out) + } + if len(parsed) != 23 { + t.Errorf("expected 23 templates in json, got %d", len(parsed)) + } +} + +func TestTemplates_UnknownCategory(t *testing.T) { + out, _, err := runSmartGroupCmd(t, "templates", "--category", "nonexistent") + if err != nil { + t.Fatalf("execute: %v", err) + } + if !strings.Contains(out, "0 templates") && !strings.Contains(out, "No templates") { + t.Errorf("expected empty-result message, got: %s", out) + } +} + +// Suppress unused-import warnings for context/http/io used by later tasks. +var ( + _ = context.Background + _ = http.MethodGet + _ io.Reader = nil +) From 8c71e6aab400603126d0b0fab05c9238dbb2bf07 Mon Sep 17 00:00:00 2001 From: Keaton Svoma Date: Tue, 12 May 2026 21:21:58 -0500 Subject: [PATCH 14/23] feat(commands): implement pro smart-group preview with per-template param flags MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Replaces the preview stub with a real RunE that looks up the template, collects param flags (union of all template params registered via registerTemplateParamFlags), resolves defaults/required checks via ResolveOpts, and prints the POST header + indented JSON body without making an API call. Also introduces collectParamValues, unknownTemplateError (with fuzzy-match suggestions), and the flagReader interface — reused by apply (Task 14). Co-Authored-By: Claude Opus 4.7 (1M context) --- internal/commands/pro_smartgroup.go | 90 +++++++++++++++++++++++- internal/commands/pro_smartgroup_test.go | 43 +++++++++++ 2 files changed, 130 insertions(+), 3 deletions(-) diff --git a/internal/commands/pro_smartgroup.go b/internal/commands/pro_smartgroup.go index e62e821..46ffcc6 100644 --- a/internal/commands/pro_smartgroup.go +++ b/internal/commands/pro_smartgroup.go @@ -7,6 +7,7 @@ import ( "fmt" "io" "sort" + "strings" "github.com/spf13/cobra" @@ -154,12 +155,95 @@ func filterByCategory(tmpls []smartgroup.Template, cat string) []smartgroup.Temp return out } -// Stubs for the remaining subcommands. Replaced in Tasks 13-15. - func newSmartGroupPreviewCmd(_ *registry.CLIContext) *cobra.Command { - return &cobra.Command{Use: "preview", Short: "Preview a template (stub)"} + var slug string + cmd := &cobra.Command{ + Use: "preview", + Short: "Print the JSON body that would be POSTed (no API call)", + Long: `Preview the JSON request that 'apply' would POST to +/v2/computer-groups/smart-groups for the chosen template. Use this to inspect +criteria before creating a group.`, + Example: ` jamf-cli pro smart-group preview --template encryption/invalid-recovery-key + jamf-cli pro smart-group preview --template encryption/encryption-stalled --stalled-after 14`, + RunE: func(cmd *cobra.Command, _ []string) error { + tmpl, ok := smartgroup.Lookup(slug) + if !ok { + return unknownTemplateError(slug) + } + opts, err := collectParamValues(tmpl, cmd.Flags()) + if err != nil { + return err + } + resolved, err := tmpl.ResolveOpts(opts) + if err != nil { + return err + } + req, err := tmpl.Build(resolved) + if err != nil { + return err + } + req.Name = "<--name required when running apply>" + out := cmd.OutOrStdout() + fmt.Fprintln(out, "POST /v2/computer-groups/smart-groups") + enc := json.NewEncoder(out) + enc.SetIndent("", " ") + return enc.Encode(req) + }, + } + cmd.Flags().StringVar(&slug, "template", "", "Template slug (required) — e.g. encryption/invalid-recovery-key") + _ = cmd.MarkFlagRequired("template") + registerTemplateParamFlags(cmd) + return cmd +} + +// registerTemplateParamFlags declares the union of all per-template param +// flag names on the cobra command as generic string flags. collectParamValues +// reads only the flags the chosen template actually declares. +func registerTemplateParamFlags(cmd *cobra.Command) { + seen := make(map[string]bool) + for _, t := range smartgroup.All() { + for _, p := range t.Params { + if seen[p.Name] { + continue + } + seen[p.Name] = true + cmd.Flags().String(p.Name, "", p.Description) + } + } +} + +// flagReader is the minimal flag-access interface used by collectParamValues +// — satisfied by *pflag.FlagSet returned by cobra's cmd.Flags(). +type flagReader interface { + GetString(string) (string, error) + Changed(string) bool +} + +func collectParamValues(tmpl smartgroup.Template, flags flagReader) (map[string]any, error) { + out := make(map[string]any, len(tmpl.Params)) + for _, p := range tmpl.Params { + if !flags.Changed(p.Name) { + continue + } + v, err := flags.GetString(p.Name) + if err != nil { + return nil, err + } + out[p.Name] = v // ResolveOpts coerces strings to int when Type is "int". + } + return out, nil } +func unknownTemplateError(slug string) error { + suggestions := smartgroup.FuzzyMatch(slug) + if len(suggestions) == 0 { + return fmt.Errorf("unknown template %q (run 'pro smart-group templates' to list available)", slug) + } + return fmt.Errorf("unknown template %q — did you mean: %s?", slug, strings.Join(suggestions, ", ")) +} + +// Stubs for the remaining subcommands. Replaced in Tasks 14-15. + func newSmartGroupApplyCmd(_ *registry.CLIContext) *cobra.Command { return &cobra.Command{Use: "apply", Short: "Apply a template (stub)"} } diff --git a/internal/commands/pro_smartgroup_test.go b/internal/commands/pro_smartgroup_test.go index 63fda1c..7633c52 100644 --- a/internal/commands/pro_smartgroup_test.go +++ b/internal/commands/pro_smartgroup_test.go @@ -88,3 +88,46 @@ var ( _ = http.MethodGet _ io.Reader = nil ) + +func TestPreview_ZeroParam(t *testing.T) { + out, _, err := runSmartGroupCmd(t, "preview", "--template", "encryption/not-encrypted") + if err != nil { + t.Fatalf("execute: %v", err) + } + if !strings.Contains(out, "POST /v2/computer-groups/smart-groups") { + t.Errorf("expected POST header: %s", out) + } + if !strings.Contains(out, "FileVault 2 Status") || !strings.Contains(out, "Not Encrypted") { + t.Errorf("expected criterion in JSON body: %s", out) + } +} + +func TestPreview_WithParam(t *testing.T) { + out, _, err := runSmartGroupCmd(t, "preview", "--template", "encryption/encryption-stalled", "--stalled-after", "14") + if err != nil { + t.Fatalf("execute: %v", err) + } + if !strings.Contains(out, `"value": "14"`) { + t.Errorf("expected stalled-after=14 in output: %s", out) + } +} + +func TestPreview_UnknownTemplate(t *testing.T) { + _, _, err := runSmartGroupCmd(t, "preview", "--template", "encryption/typo") + if err == nil { + t.Fatal("expected error for unknown template, got nil") + } + if !strings.Contains(err.Error(), "encryption/") { + t.Errorf("expected fuzzy-match suggestion mentioning encryption: %v", err) + } +} + +func TestPreview_RequiredParamMissing(t *testing.T) { + _, _, err := runSmartGroupCmd(t, "preview", "--template", "updates/os-version-below") + if err == nil { + t.Fatal("expected error for missing --below-version, got nil") + } + if !strings.Contains(err.Error(), "below-version") { + t.Errorf("expected error to mention required param: %v", err) + } +} From cc969d9291986a56e03bf8e695d4877bfc7846af Mon Sep 17 00:00:00 2001 From: Keaton Svoma Date: Tue, 12 May 2026 21:24:52 -0500 Subject: [PATCH 15/23] feat(commands): implement pro smart-group apply with idempotent name-based create/update and post-apply membership check --- internal/commands/pro_smartgroup.go | 199 ++++++++++++++++++++++- internal/commands/pro_smartgroup_test.go | 153 +++++++++++++++++ 2 files changed, 349 insertions(+), 3 deletions(-) diff --git a/internal/commands/pro_smartgroup.go b/internal/commands/pro_smartgroup.go index 46ffcc6..e3ff8ad 100644 --- a/internal/commands/pro_smartgroup.go +++ b/internal/commands/pro_smartgroup.go @@ -3,9 +3,13 @@ package commands import ( + "bytes" + "context" "encoding/json" "fmt" "io" + "net/http" + "net/url" "sort" "strings" @@ -242,10 +246,199 @@ func unknownTemplateError(slug string) error { return fmt.Errorf("unknown template %q — did you mean: %s?", slug, strings.Join(suggestions, ", ")) } -// Stubs for the remaining subcommands. Replaced in Tasks 14-15. +func newSmartGroupApplyCmd(cliCtx *registry.CLIContext) *cobra.Command { + var ( + slug string + name string + recalculate bool + dryRun bool + yes bool + ) + cmd := &cobra.Command{ + Use: "apply", + Short: "Create or update a smart group from a template (idempotent by --name)", + Long: `Apply a template against the live tenant. If a smart group with the +given --name already exists, it is updated (PUT); otherwise it is created +(POST). After apply, the membership endpoint is consulted and the count is +logged. Use --dry-run to inspect the request body without calling the API.`, + Example: ` jamf-cli pro smart-group apply --template encryption/invalid-recovery-key --name "FV Invalid Recovery Keys" + jamf-cli pro sg apply --template mdm/stale-checkin --name "Stale 30d" --days 30 --recalculate + jamf-cli pro sg apply --template encryption/not-encrypted --name "Not Encrypted" --dry-run`, + RunE: func(cmd *cobra.Command, _ []string) error { + tmpl, ok := smartgroup.Lookup(slug) + if !ok { + return unknownTemplateError(slug) + } + opts, err := collectParamValues(tmpl, cmd.Flags()) + if err != nil { + return err + } + resolved, err := tmpl.ResolveOpts(opts) + if err != nil { + return err + } + req, err := tmpl.Build(resolved) + if err != nil { + return err + } + req.Name = name + if dryRun { + return printDryRun(cmd.OutOrStdout(), req) + } + if cliCtx.Client == nil { + return fmt.Errorf("not authenticated to a Jamf Pro tenant; run 'jamf-cli pro setup' first") + } + return runApplyFlow(cmd.Context(), cmd.OutOrStdout(), cliCtx.Client, req, recalculate, yes) + }, + } + cmd.Flags().StringVar(&slug, "template", "", "Template slug (required)") + cmd.Flags().StringVar(&name, "name", "", "Smart group name (required)") + cmd.Flags().BoolVar(&recalculate, "recalculate", false, "After apply, force smart-group recalculation") + cmd.Flags().BoolVar(&dryRun, "dry-run", false, "Print the request body without calling the API") + cmd.Flags().BoolVar(&yes, "yes", false, "Skip confirmation when updating an existing group") + _ = cmd.MarkFlagRequired("template") + _ = cmd.MarkFlagRequired("name") + registerTemplateParamFlags(cmd) + return cmd +} -func newSmartGroupApplyCmd(_ *registry.CLIContext) *cobra.Command { - return &cobra.Command{Use: "apply", Short: "Apply a template (stub)"} +func printDryRun(out io.Writer, req smartgroup.SmartGroupRequest) error { + fmt.Fprintln(out, "POST /v2/computer-groups/smart-groups") + enc := json.NewEncoder(out) + enc.SetIndent("", " ") + return enc.Encode(req) +} + +func runApplyFlow(ctx context.Context, out io.Writer, client registry.HTTPClient, req smartgroup.SmartGroupRequest, recalculate, yes bool) error { + existingID, err := lookupSmartGroupByName(ctx, client, req.Name) + if err != nil { + return err + } + + var id string + switch { + case existingID == "": + newID, err := createSmartGroup(ctx, client, req) + if err != nil { + return err + } + id = newID + fmt.Fprintf(out, "Created smart group %q (ID: %s)\n", req.Name, id) + default: + if !yes { + return fmt.Errorf("smart group %q already exists (ID %s); pass --yes to replace", req.Name, existingID) + } + if err := updateSmartGroup(ctx, client, existingID, req); err != nil { + return err + } + id = existingID + fmt.Fprintf(out, "Updated smart group %q (ID: %s)\n", req.Name, id) + } + + if recalculate { + if err := recalculateSmartGroup(ctx, client, id); err != nil { + fmt.Fprintf(out, "Warning: recalculate did not complete: %v\n", err) + } + } + + count, err := smartgroup.CountMembers(ctx, client, id) + if err != nil { + fmt.Fprintf(out, "Warning: membership check failed: %v\n", err) + return nil + } + fmt.Fprintf(out, "Membership: %d devices.\n", count) + if count == 0 { + fmt.Fprintln(out, "This template matched 0 devices. Run 'pro sg verify-templates' to check criterion compatibility with your tenant.") + } + return nil +} + +func lookupSmartGroupByName(ctx context.Context, client registry.HTTPClient, name string) (string, error) { + filter := url.QueryEscape(fmt.Sprintf(`name=="%s"`, name)) + path := "/v2/computer-groups/smart-groups?filter=" + filter + resp, err := client.Do(ctx, http.MethodGet, path, nil) + if err != nil { + return "", err + } + defer resp.Body.Close() + if resp.StatusCode != 200 { + body, _ := io.ReadAll(resp.Body) + return "", fmt.Errorf("lookup smart group: HTTP %d: %s", resp.StatusCode, string(body)) + } + var out struct { + TotalCount int `json:"totalCount"` + Results []struct { + ID string `json:"id"` + Name string `json:"name"` + } `json:"results"` + } + if err := json.NewDecoder(resp.Body).Decode(&out); err != nil { + return "", err + } + for _, r := range out.Results { + if r.Name == name { + return r.ID, nil + } + } + return "", nil +} + +func createSmartGroup(ctx context.Context, client registry.HTTPClient, req smartgroup.SmartGroupRequest) (string, error) { + body, err := json.Marshal(req) + if err != nil { + return "", err + } + resp, err := client.Do(ctx, http.MethodPost, "/v2/computer-groups/smart-groups", bytes.NewReader(body)) + if err != nil { + return "", err + } + defer resp.Body.Close() + if resp.StatusCode == 403 { + return "", fmt.Errorf("permission denied: the OAuth role is missing the 'Create Smart Computer Groups' privilege") + } + if resp.StatusCode < 200 || resp.StatusCode >= 300 { + buf, _ := io.ReadAll(resp.Body) + return "", fmt.Errorf("create smart group: HTTP %d: %s", resp.StatusCode, string(buf)) + } + var out struct { + ID string `json:"id"` + } + if err := json.NewDecoder(resp.Body).Decode(&out); err != nil { + return "", err + } + return out.ID, nil +} + +func updateSmartGroup(ctx context.Context, client registry.HTTPClient, id string, req smartgroup.SmartGroupRequest) error { + body, err := json.Marshal(req) + if err != nil { + return err + } + resp, err := client.Do(ctx, http.MethodPut, "/v2/computer-groups/smart-groups/"+id, bytes.NewReader(body)) + if err != nil { + return err + } + defer resp.Body.Close() + if resp.StatusCode == 403 { + return fmt.Errorf("permission denied: the OAuth role is missing the 'Update Smart Computer Groups' privilege") + } + if resp.StatusCode < 200 || resp.StatusCode >= 300 { + buf, _ := io.ReadAll(resp.Body) + return fmt.Errorf("update smart group: HTTP %d: %s", resp.StatusCode, string(buf)) + } + return nil +} + +func recalculateSmartGroup(ctx context.Context, client registry.HTTPClient, id string) error { + resp, err := client.Do(ctx, http.MethodPost, "/v1/smart-computer-groups/"+id+"/recalculate", nil) + if err != nil { + return err + } + defer resp.Body.Close() + if resp.StatusCode < 200 || resp.StatusCode >= 300 { + return fmt.Errorf("recalculate: HTTP %d", resp.StatusCode) + } + return nil } func newSmartGroupVerifyTemplatesCmd(_ *registry.CLIContext) *cobra.Command { diff --git a/internal/commands/pro_smartgroup_test.go b/internal/commands/pro_smartgroup_test.go index 7633c52..1952e5a 100644 --- a/internal/commands/pro_smartgroup_test.go +++ b/internal/commands/pro_smartgroup_test.go @@ -131,3 +131,156 @@ func TestPreview_RequiredParamMissing(t *testing.T) { t.Errorf("expected error to mention required param: %v", err) } } + +type fakeSGClient struct { + calls []recordedCall + queue []*http.Response +} + +type recordedCall struct { + method, url, body string +} + +func (f *fakeSGClient) Do(_ context.Context, method, url string, body io.Reader) (*http.Response, error) { + b := "" + if body != nil { + buf, _ := io.ReadAll(body) + b = string(buf) + } + f.calls = append(f.calls, recordedCall{method, url, b}) + if len(f.queue) == 0 { + return &http.Response{StatusCode: 500, Body: io.NopCloser(strings.NewReader("queue empty"))}, nil + } + resp := f.queue[0] + f.queue = f.queue[1:] + return resp, nil +} + +func newJSONResp(status int, payload any) *http.Response { + b, _ := json.Marshal(payload) + return &http.Response{StatusCode: status, Body: io.NopCloser(strings.NewReader(string(b)))} +} + +func runSmartGroupApply(t *testing.T, client *fakeSGClient, args ...string) (string, error) { + t.Helper() + cliCtx := ®istry.CLIContext{Client: client} + root := newSmartGroupCmd(cliCtx) + out := &bytes.Buffer{} + root.SetOut(out) + root.SetErr(out) + root.SetArgs(append([]string{"apply"}, args...)) + err := root.Execute() + return out.String(), err +} + +func TestApply_NewGroupCreated(t *testing.T) { + client := &fakeSGClient{ + queue: []*http.Response{ + newJSONResp(200, map[string]any{"totalCount": 0, "results": []any{}}), + newJSONResp(201, map[string]any{"id": "287", "href": "/.../287"}), + newJSONResp(200, map[string]any{"members": []int{1, 2, 3, 4, 5}}), + }, + } + out, err := runSmartGroupApply( + t, client, + "--template", "encryption/not-encrypted", + "--name", "Test FV Not Encrypted", + "--yes", + ) + if err != nil { + t.Fatalf("apply: %v\n%s", err, out) + } + if len(client.calls) != 3 { + t.Fatalf("expected 3 API calls, got %d", len(client.calls)) + } + if client.calls[1].method != "POST" { + t.Errorf("expected second call POST (create), got %s", client.calls[1].method) + } + if !strings.Contains(out, "Membership: 5") { + t.Errorf("expected membership log in output: %s", out) + } +} + +func TestApply_ExistingGroupUpdated(t *testing.T) { + client := &fakeSGClient{ + queue: []*http.Response{ + newJSONResp(200, map[string]any{"totalCount": 1, "results": []any{map[string]any{"id": "42", "name": "Test FV Not Encrypted"}}}), + newJSONResp(204, map[string]any{}), + newJSONResp(200, map[string]any{"members": []int{1, 2}}), + }, + } + out, err := runSmartGroupApply( + t, client, + "--template", "encryption/not-encrypted", + "--name", "Test FV Not Encrypted", + "--yes", + ) + if err != nil { + t.Fatalf("apply: %v\n%s", err, out) + } + if client.calls[1].method != "PUT" { + t.Errorf("expected PUT on existing group, got %s", client.calls[1].method) + } + if !strings.Contains(client.calls[1].url, "/42") { + t.Errorf("expected PUT URL with id=42: %s", client.calls[1].url) + } +} + +func TestApply_DryRunNoAPICalls(t *testing.T) { + client := &fakeSGClient{} + out, err := runSmartGroupApply( + t, client, + "--template", "encryption/not-encrypted", + "--name", "Test", + "--dry-run", + ) + if err != nil { + t.Fatalf("dry-run: %v\n%s", err, out) + } + if len(client.calls) != 0 { + t.Fatalf("expected 0 API calls in dry-run, got %d", len(client.calls)) + } + if !strings.Contains(out, "POST /v2/computer-groups/smart-groups") { + t.Errorf("expected dry-run output to show what would POST: %s", out) + } +} + +func TestApply_ZeroMembershipWarning(t *testing.T) { + client := &fakeSGClient{ + queue: []*http.Response{ + newJSONResp(200, map[string]any{"totalCount": 0, "results": []any{}}), + newJSONResp(201, map[string]any{"id": "99"}), + newJSONResp(200, map[string]any{"members": []int{}}), + }, + } + out, _ := runSmartGroupApply( + t, client, + "--template", "compliance/firewall-disabled", + "--name", "Test FW Off", + "--yes", + ) + if !strings.Contains(out, "matched 0 devices") { + t.Errorf("expected zero-match warning: %s", out) + } +} + +func TestApply_403MissingPrivilege(t *testing.T) { + client := &fakeSGClient{ + queue: []*http.Response{ + newJSONResp(200, map[string]any{"totalCount": 0, "results": []any{}}), + newJSONResp(403, map[string]any{"errors": []string{"forbidden"}}), + }, + } + _, err := runSmartGroupApply( + t, client, + "--template", "encryption/not-encrypted", + "--name", "Test", + "--yes", + ) + if err == nil { + t.Fatal("expected error on 403, got nil") + } + if !strings.Contains(err.Error(), "Create Smart Computer Groups") { + t.Errorf("expected privilege name in error: %v", err) + } +} From 45ccbcf680af5f320daf7e82f97655b10d825988 Mon Sep 17 00:00:00 2001 From: Keaton Svoma Date: Tue, 12 May 2026 21:28:04 -0500 Subject: [PATCH 16/23] feat(commands): implement pro smart-group verify-templates with live-tenant smoke test Adds RunOneVerification to internal/smartgroup (create temp group, recalc, count members, optional cleanup) and wires it into the verify-templates subcommand under pro smart-group with --category, --no-cleanup, and --json flags. Co-Authored-By: Claude Opus 4.7 (1M context) --- internal/commands/pro_smartgroup.go | 66 +++++++++- internal/commands/pro_smartgroup_test.go | 30 +++++ internal/smartgroup/verify.go | 147 +++++++++++++++++++++++ internal/smartgroup/verify_test.go | 103 ++++++++++++++++ 4 files changed, 344 insertions(+), 2 deletions(-) create mode 100644 internal/smartgroup/verify.go create mode 100644 internal/smartgroup/verify_test.go diff --git a/internal/commands/pro_smartgroup.go b/internal/commands/pro_smartgroup.go index e3ff8ad..515191a 100644 --- a/internal/commands/pro_smartgroup.go +++ b/internal/commands/pro_smartgroup.go @@ -441,6 +441,68 @@ func recalculateSmartGroup(ctx context.Context, client registry.HTTPClient, id s return nil } -func newSmartGroupVerifyTemplatesCmd(_ *registry.CLIContext) *cobra.Command { - return &cobra.Command{Use: "verify-templates", Short: "Verify templates against the live tenant (stub)"} +func newSmartGroupVerifyTemplatesCmd(cliCtx *registry.CLIContext) *cobra.Command { + var ( + category string + noCleanup bool + jsonOutput bool + ) + cmd := &cobra.Command{ + Use: "verify-templates", + Short: "Smoke-test every template against the live tenant", + Long: `Create one temporary smart group per template (prefixed "__verify_"), +recalculate it, read the membership count, and report. Temporary groups are +deleted on completion unless --no-cleanup is set. + +Use this on first run after install (and after any sync-specs that touches +JSS) to confirm criterion-name strings match your Jamf Pro version.`, + Example: ` jamf-cli pro smart-group verify-templates + jamf-cli pro sg verify-templates --category encryption + jamf-cli pro sg verify-templates --no-cleanup`, + RunE: func(cmd *cobra.Command, _ []string) error { + if cliCtx.Client == nil { + return fmt.Errorf("not authenticated to a Jamf Pro tenant; run 'jamf-cli pro setup' first") + } + var tmpls []smartgroup.Template + if category != "" { + tmpls = smartgroup.ByCategory(category) + } else { + tmpls = smartgroup.All() + } + results := make([]smartgroup.VerifyResult, 0, len(tmpls)) + for _, t := range tmpls { + results = append(results, smartgroup.RunOneVerification(cmd.Context(), cliCtx.Client, t, !noCleanup)) + } + return renderVerifyResults(cmd.OutOrStdout(), results, jsonOutput) + }, + } + cmd.Flags().StringVar(&category, "category", "", "Verify only one category") + cmd.Flags().BoolVar(&noCleanup, "no-cleanup", false, "Keep temporary groups instead of deleting them") + cmd.Flags().BoolVar(&jsonOutput, "json", false, "Output JSON instead of human-readable summary") + return cmd +} + +func renderVerifyResults(out io.Writer, results []smartgroup.VerifyResult, asJSON bool) error { + if asJSON { + enc := json.NewEncoder(out) + enc.SetIndent("", " ") + return enc.Encode(results) + } + ok, zero, errs := 0, 0, 0 + fmt.Fprintf(out, "Verifying %d templates...\n\n", len(results)) + for _, r := range results { + switch r.Outcome { + case smartgroup.VerifyOK: + ok++ + fmt.Fprintf(out, "✓ %-40s — %d devices match\n", r.Slug, r.MemberCount) + case smartgroup.VerifyZeroMatch: + zero++ + fmt.Fprintf(out, "⚠ %-40s — 0 devices match (possible criterion mismatch)\n", r.Slug) + case smartgroup.VerifyError: + errs++ + fmt.Fprintf(out, "✗ %-40s — ERROR: %s\n", r.Slug, r.Error) + } + } + fmt.Fprintf(out, "\nSummary: %d OK, %d zero-match warnings, %d errors.\n", ok, zero, errs) + return nil } diff --git a/internal/commands/pro_smartgroup_test.go b/internal/commands/pro_smartgroup_test.go index 1952e5a..39dc7dc 100644 --- a/internal/commands/pro_smartgroup_test.go +++ b/internal/commands/pro_smartgroup_test.go @@ -284,3 +284,33 @@ func TestApply_403MissingPrivilege(t *testing.T) { t.Errorf("expected privilege name in error: %v", err) } } + +func TestVerifyTemplates_CategoryRuns(t *testing.T) { + // Each template in the encryption category produces 4 HTTP calls + // (POST create + recalc + membership + DELETE cleanup). We queue 6 templates * 4 = 24 responses. + client := &fakeSGClient{} + for i := 0; i < 6; i++ { + client.queue = append( + client.queue, + newJSONResp(201, map[string]any{"id": "100"}), + newJSONResp(200, map[string]any{}), + newJSONResp(200, map[string]any{"members": []int{1, 2}}), + newJSONResp(204, map[string]any{}), + ) + } + cliCtx := ®istry.CLIContext{Client: client} + root := newSmartGroupCmd(cliCtx) + out := &bytes.Buffer{} + root.SetOut(out) + root.SetErr(out) + root.SetArgs([]string{"verify-templates", "--category", "encryption"}) + if err := root.Execute(); err != nil { + t.Fatalf("verify-templates: %v", err) + } + if !strings.Contains(out.String(), "Verifying 6 templates") { + t.Errorf("expected '6 templates' in output: %s", out.String()) + } + if !strings.Contains(out.String(), "Summary: 6 OK") { + t.Errorf("expected summary line: %s", out.String()) + } +} diff --git a/internal/smartgroup/verify.go b/internal/smartgroup/verify.go new file mode 100644 index 0000000..e1618dc --- /dev/null +++ b/internal/smartgroup/verify.go @@ -0,0 +1,147 @@ +// Copyright 2026, Jamf Software LLC + +package smartgroup + +import ( + "bytes" + "context" + "encoding/json" + "fmt" + "io" + "math/rand" + "net/http" +) + +// VerifyOutcome is the per-template result of a verify-templates pass. +type VerifyOutcome int + +const ( + VerifyOK VerifyOutcome = iota + VerifyZeroMatch + VerifyError +) + +func (o VerifyOutcome) String() string { + switch o { + case VerifyOK: + return "OK" + case VerifyZeroMatch: + return "ZERO_MATCH" + case VerifyError: + return "ERROR" + default: + return "UNKNOWN" + } +} + +// VerifyResult captures one template's verification outcome. +type VerifyResult struct { + Slug string + Outcome VerifyOutcome + MemberCount int + Error string +} + +// defaultVerifyOpts supplies sensible required-param values for verification. +// Update when adding new required-param templates. +var defaultVerifyOpts = map[string]map[string]any{ + "updates/os-version-below": {"below-version": "15.0"}, + "updates/major-version-behind": {"major-below": 15}, + "lifecycle/jamf-binary-outdated": {"below-version": "11.0.0"}, +} + +// RunOneVerification creates a temporary smart group from the template, +// recalculates membership, captures the count, and (if cleanup) deletes the +// temporary group. +func RunOneVerification(ctx context.Context, client HTTPDoer, tmpl Template, cleanup bool) VerifyResult { + opts, err := tmpl.ResolveOpts(defaultVerifyOpts[tmpl.Slug]) + if err != nil { + return VerifyResult{Slug: tmpl.Slug, Outcome: VerifyError, Error: fmt.Sprintf("ResolveOpts: %v", err)} + } + req, err := tmpl.Build(opts) + if err != nil { + return VerifyResult{Slug: tmpl.Slug, Outcome: VerifyError, Error: fmt.Sprintf("Build: %v", err)} + } + req.Name = fmt.Sprintf("__verify_%s_%06d", sanitizeSlug(tmpl.Slug), rand.Intn(1000000)) + + id, err := createTempGroup(ctx, client, req) + if err != nil { + return VerifyResult{Slug: tmpl.Slug, Outcome: VerifyError, Error: err.Error()} + } + + _ = recalcGroup(ctx, client, id) // recalc failure is non-fatal + + count, err := CountMembers(ctx, client, id) + if err != nil { + if cleanup { + _ = deleteGroup(ctx, client, id) + } + return VerifyResult{Slug: tmpl.Slug, Outcome: VerifyError, Error: err.Error()} + } + + if cleanup { + _ = deleteGroup(ctx, client, id) + } + + outcome := VerifyOK + if count == 0 { + outcome = VerifyZeroMatch + } + return VerifyResult{Slug: tmpl.Slug, Outcome: outcome, MemberCount: count} +} + +func sanitizeSlug(s string) string { + out := make([]byte, 0, len(s)) + for i := 0; i < len(s); i++ { + c := s[i] + switch { + case c >= 'a' && c <= 'z': + out = append(out, c) + case c >= '0' && c <= '9': + out = append(out, c) + case c == '-' || c == '_': + out = append(out, c) + case c == '/': + out = append(out, '_') + } + } + return string(out) +} + +func createTempGroup(ctx context.Context, client HTTPDoer, req SmartGroupRequest) (string, error) { + body, _ := json.Marshal(req) + resp, err := client.Do(ctx, http.MethodPost, "/v2/computer-groups/smart-groups", bytes.NewReader(body)) + if err != nil { + return "", err + } + defer resp.Body.Close() + if resp.StatusCode < 200 || resp.StatusCode >= 300 { + buf, _ := io.ReadAll(resp.Body) + return "", fmt.Errorf("HTTP %d: %s", resp.StatusCode, string(buf)) + } + var out struct { + ID string `json:"id"` + } + if err := json.NewDecoder(resp.Body).Decode(&out); err != nil { + return "", err + } + return out.ID, nil +} + +func recalcGroup(ctx context.Context, client HTTPDoer, id string) error { + resp, err := client.Do(ctx, http.MethodPost, "/v1/smart-computer-groups/"+id+"/recalculate", nil) + if err != nil { + return err + } + resp.Body.Close() + return nil +} + +func deleteGroup(ctx context.Context, client HTTPDoer, id string) error { + resp, err := client.Do(ctx, http.MethodDelete, "/v2/computer-groups/smart-groups/"+id, nil) + if err != nil { + return err + } + resp.Body.Close() + return nil +} diff --git a/internal/smartgroup/verify_test.go b/internal/smartgroup/verify_test.go new file mode 100644 index 0000000..efec2d3 --- /dev/null +++ b/internal/smartgroup/verify_test.go @@ -0,0 +1,103 @@ +// Copyright 2026, Jamf Software LLC + +package smartgroup + +import ( + "context" + "encoding/json" + "io" + "net/http" + "strings" + "testing" +) + +type seqClient struct { + queue []*http.Response + calls []string +} + +func (s *seqClient) Do(_ context.Context, method, url string, _ io.Reader) (*http.Response, error) { + s.calls = append(s.calls, method+" "+url) + if len(s.queue) == 0 { + return &http.Response{StatusCode: 500, Body: io.NopCloser(strings.NewReader("empty"))}, nil + } + r := s.queue[0] + s.queue = s.queue[1:] + return r, nil +} + +func jsonResp(status int, payload any) *http.Response { + b, _ := json.Marshal(payload) + return &http.Response{StatusCode: status, Body: io.NopCloser(strings.NewReader(string(b)))} +} + +func TestVerify_RunOneTemplate_OK(t *testing.T) { + tmpl, ok := Lookup("encryption/not-encrypted") + if !ok { + t.Fatal("template missing") + } + client := &seqClient{ + queue: []*http.Response{ + jsonResp(201, map[string]any{"id": "555"}), + jsonResp(200, map[string]any{}), + jsonResp(200, map[string]any{"members": []int{1, 2, 3}}), + jsonResp(204, map[string]any{}), + }, + } + result := RunOneVerification(context.Background(), client, tmpl, true) + if result.Outcome != VerifyOK { + t.Errorf("expected OK outcome, got %v (%s)", result.Outcome, result.Error) + } + if result.MemberCount != 3 { + t.Errorf("expected 3 members, got %d", result.MemberCount) + } +} + +func TestVerify_RunOneTemplate_ZeroMatch(t *testing.T) { + tmpl, _ := Lookup("compliance/firewall-disabled") + client := &seqClient{ + queue: []*http.Response{ + jsonResp(201, map[string]any{"id": "777"}), + jsonResp(200, map[string]any{}), + jsonResp(200, map[string]any{"members": []int{}}), + jsonResp(204, map[string]any{}), + }, + } + result := RunOneVerification(context.Background(), client, tmpl, true) + if result.Outcome != VerifyZeroMatch { + t.Errorf("expected ZeroMatch, got %v", result.Outcome) + } +} + +func TestVerify_RunOneTemplate_CreateError(t *testing.T) { + tmpl, _ := Lookup("encryption/not-encrypted") + client := &seqClient{ + queue: []*http.Response{ + jsonResp(400, map[string]any{"errors": []string{"invalid criterion name"}}), + }, + } + result := RunOneVerification(context.Background(), client, tmpl, true) + if result.Outcome != VerifyError { + t.Errorf("expected Error, got %v", result.Outcome) + } + if result.Error == "" { + t.Error("expected non-empty Error message") + } +} + +func TestVerify_NoCleanupSkipsDelete(t *testing.T) { + tmpl, _ := Lookup("encryption/not-encrypted") + client := &seqClient{ + queue: []*http.Response{ + jsonResp(201, map[string]any{"id": "888"}), + jsonResp(200, map[string]any{}), + jsonResp(200, map[string]any{"members": []int{1}}), + }, + } + _ = RunOneVerification(context.Background(), client, tmpl, false) + for _, c := range client.calls { + if strings.HasPrefix(c, "DELETE") { + t.Errorf("did not expect DELETE call with cleanup=false: %s", c) + } + } +} From 6cf4a111649ccaf213535cf7e2e7b892fcb2d534 Mon Sep 17 00:00:00 2001 From: Keaton Svoma Date: Tue, 12 May 2026 21:32:08 -0500 Subject: [PATCH 17/23] fix(smartgroup): resolve errcheck and staticcheck lint failures - Wrap fmt.Fprintf/Fprintln calls to io.Writer with _, _ = per project convention - Convert untagged switch to tagged switch existingID per staticcheck QF1002 - Wrap resp.Body.Close() in defer func closures to satisfy errcheck - Apply gofumpt formatting across touched and pre-existing files Co-Authored-By: Claude Opus 4.7 (1M context) --- generator/monolith/split.go | 3 +- internal/commands/completion.go | 3 +- internal/commands/pro_bulk_test.go | 39 ++++++++++++------ internal/commands/pro_device_actions.go | 33 ++++++++++------ internal/commands/pro_overview.go | 6 ++- internal/commands/pro_platform_devices.go | 3 +- internal/commands/pro_smartgroup.go | 48 +++++++++++------------ internal/profileconvert/ddm_converter.go | 3 +- internal/smartgroup/membership.go | 2 +- internal/smartgroup/verify.go | 6 +-- 10 files changed, 88 insertions(+), 58 deletions(-) diff --git a/generator/monolith/split.go b/generator/monolith/split.go index 62cbb1a..14056c2 100644 --- a/generator/monolith/split.go +++ b/generator/monolith/split.go @@ -769,7 +769,8 @@ func toNode(v any) *yaml.Node { sort.Strings(keys) n := &yaml.Node{Kind: yaml.MappingNode} for _, k := range keys { - n.Content = append(n.Content, + n.Content = append( + n.Content, &yaml.Node{Kind: yaml.ScalarNode, Value: k}, toNode(x[k]), ) diff --git a/internal/commands/completion.go b/internal/commands/completion.go index dbe0368..102466a 100644 --- a/internal/commands/completion.go +++ b/internal/commands/completion.go @@ -222,7 +222,8 @@ func zshCompletionCandidates(home string) []string { } } - candidates = append(candidates, + candidates = append( + candidates, filepath.Join(home, ".zsh", "completions"), filepath.Join(home, ".local", "share", "zsh", "site-functions"), "/usr/local/share/zsh/site-functions", diff --git a/internal/commands/pro_bulk_test.go b/internal/commands/pro_bulk_test.go index 9d71073..7776043 100644 --- a/internal/commands/pro_bulk_test.go +++ b/internal/commands/pro_bulk_test.go @@ -640,7 +640,8 @@ func TestAddToGroup_DryRunDefault(t *testing.T) { cliCtx := newBulkCLIContext(mock) cmd := newBulkCmd(cliCtx) - _, stderr, err := runCobraCmd(t, cmd, "add-to-group", + _, stderr, err := runCobraCmd( + t, cmd, "add-to-group", "--target-group", "Quarantine", "--group", "Lab Macs", ) @@ -673,7 +674,8 @@ func TestAddToGroup_YesDispatches(t *testing.T) { cliCtx := newBulkCLIContext(mock) cmd := newBulkCmd(cliCtx) - _, _, err := runCobraCmd(t, cmd, "add-to-group", + _, _, err := runCobraCmd( + t, cmd, "add-to-group", "--target-group", "Quarantine", "--group", "Lab Macs", "--yes", @@ -705,7 +707,8 @@ func TestRemoveFromGroup_YesDispatches(t *testing.T) { cliCtx := newBulkCLIContext(mock) cmd := newBulkCmd(cliCtx) - _, _, err := runCobraCmd(t, cmd, "remove-from-group", + _, _, err := runCobraCmd( + t, cmd, "remove-from-group", "--target-group", "Quarantine", "--group", "Lab Macs", "--yes", @@ -737,7 +740,8 @@ func TestAddToGroup_SmartGroupRejected(t *testing.T) { cliCtx := newBulkCLIContext(mock) cmd := newBulkCmd(cliCtx) - _, _, err := runCobraCmd(t, cmd, "add-to-group", + _, _, err := runCobraCmd( + t, cmd, "add-to-group", "--target-group", "SmartTarget", "--group", "Lab Macs", "--yes", @@ -779,7 +783,8 @@ func TestAddToGroup_FromFile(t *testing.T) { cliCtx := newBulkCLIContext(mock) cmd := newBulkCmd(cliCtx) - _, _, err := runCobraCmd(t, cmd, "add-to-group", + _, _, err := runCobraCmd( + t, cmd, "add-to-group", "--target-group", "Quarantine", "--from-file", filePath, "--yes", @@ -824,7 +829,8 @@ func TestFromFileMutualExclusion(t *testing.T) { cliCtx := newBulkCLIContext(mock) cmd := newBulkCmd(cliCtx) - _, _, err := runCobraCmd(t, cmd, "add-to-group", + _, _, err := runCobraCmd( + t, cmd, "add-to-group", "--target-group", "Quarantine", "--from-file", filePath, "--group", "Lab Macs", @@ -856,7 +862,8 @@ func TestSendCommand_DryRunDefault(t *testing.T) { cliCtx := newBulkCLIContext(mock) cmd := newBulkCmd(cliCtx) - _, stderr, err := runCobraCmd(t, cmd, "send-command", + _, stderr, err := runCobraCmd( + t, cmd, "send-command", "--command", "BlankPush", "--group", "Lab Macs", ) @@ -888,7 +895,8 @@ func TestSendCommand_YesDispatches(t *testing.T) { cliCtx := newBulkCLIContext(mock) cmd := newBulkCmd(cliCtx) - _, _, err := runCobraCmd(t, cmd, "send-command", + _, _, err := runCobraCmd( + t, cmd, "send-command", "--command", "BlankPush", "--group", "Lab Macs", "--yes", @@ -910,7 +918,8 @@ func TestSendCommand_DestructiveRequiresConfirm(t *testing.T) { for _, cmd2 := range []string{"EraseDevice", "DeviceLock"} { t.Run(cmd2, func(t *testing.T) { cmd := newBulkCmd(cliCtx) - _, _, err := runCobraCmd(t, cmd, "send-command", + _, _, err := runCobraCmd( + t, cmd, "send-command", "--command", cmd2, "--from-file", "/dev/null", "--yes", @@ -935,7 +944,8 @@ func TestSendCommand_DestructiveWithBothFlags(t *testing.T) { cliCtx := newBulkCLIContext(mock) cmd := newBulkCmd(cliCtx) - _, _, err := runCobraCmd(t, cmd, "send-command", + _, _, err := runCobraCmd( + t, cmd, "send-command", "--command", "EraseDevice", "--from-file", "/dev/null", "--yes", @@ -952,7 +962,8 @@ func TestSendCommand_DestructiveRequiresYesToo(t *testing.T) { cliCtx := newBulkCLIContext(mock) cmd := newBulkCmd(cliCtx) - _, _, err := runCobraCmd(t, cmd, "send-command", + _, _, err := runCobraCmd( + t, cmd, "send-command", "--command", "EraseDevice", "--from-file", "/dev/null", // --yes is NOT set, --confirm-destructive IS set @@ -972,7 +983,8 @@ func TestSendCommand_InvalidCommandName(t *testing.T) { cliCtx := newBulkCLIContext(mock) cmd := newBulkCmd(cliCtx) - _, _, err := runCobraCmd(t, cmd, "send-command", + _, _, err := runCobraCmd( + t, cmd, "send-command", "--command", "SelfDestruct", "--from-file", "/dev/null", "--yes", @@ -1040,7 +1052,8 @@ func TestSendCommand_PartialFailure(t *testing.T) { cliCtx := newBulkCLIContext(mock) cmd := newBulkCmd(cliCtx) - _, stderr, err := runCobraCmd(t, cmd, "send-command", + _, stderr, err := runCobraCmd( + t, cmd, "send-command", "--command", "BlankPush", "--group", "Lab Macs", "--yes", diff --git a/internal/commands/pro_device_actions.go b/internal/commands/pro_device_actions.go index debc8d8..29bc2ac 100644 --- a/internal/commands/pro_device_actions.go +++ b/internal/commands/pro_device_actions.go @@ -630,7 +630,8 @@ func newModernComputerMDMCmd(cliCtx *registry.CLIContext, name, commandType, sho } func newComputerLockCmd(cliCtx *registry.CLIContext) *cobra.Command { - return newModernComputerMDMCmd(cliCtx, "lock", "DEVICE_LOCK", + return newModernComputerMDMCmd( + cliCtx, "lock", "DEVICE_LOCK", "Lock a computer", "Lock a computer by serial number, name, or ID. This is a destructive operation.", ` jamf-cli pro comp lock --serial C02X1234 --yes @@ -640,7 +641,8 @@ func newComputerLockCmd(cliCtx *registry.CLIContext) *cobra.Command { } func newComputerEnableRemoteDesktopCmd(cliCtx *registry.CLIContext) *cobra.Command { - return newModernComputerMDMCmd(cliCtx, "enable-remote-desktop", "ENABLE_REMOTE_DESKTOP", + return newModernComputerMDMCmd( + cliCtx, "enable-remote-desktop", "ENABLE_REMOTE_DESKTOP", "Enable Remote Desktop on a computer", "Enable the Remote Desktop agent on a computer by serial number, name, or ID.", ` jamf-cli pro comp enable-remote-desktop --serial C02X1234 @@ -650,7 +652,8 @@ func newComputerEnableRemoteDesktopCmd(cliCtx *registry.CLIContext) *cobra.Comma } func newComputerDisableRemoteDesktopCmd(cliCtx *registry.CLIContext) *cobra.Command { - return newModernComputerMDMCmd(cliCtx, "disable-remote-desktop", "DISABLE_REMOTE_DESKTOP", + return newModernComputerMDMCmd( + cliCtx, "disable-remote-desktop", "DISABLE_REMOTE_DESKTOP", "Disable Remote Desktop on a computer", "Disable the Remote Desktop agent on a computer by serial number, name, or ID.", ` jamf-cli pro comp disable-remote-desktop --serial C02X1234 @@ -693,7 +696,8 @@ func newComputerRestartCmd(cliCtx *registry.CLIContext) *cobra.Command { } func newComputerShutdownCmd(cliCtx *registry.CLIContext) *cobra.Command { - return newModernComputerMDMCmd(cliCtx, "shutdown", "SHUT_DOWN_DEVICE", + return newModernComputerMDMCmd( + cliCtx, "shutdown", "SHUT_DOWN_DEVICE", "Shut down a computer", "Shut down a supervised computer by serial number, name, or ID.", ` jamf-cli pro comp shutdown --serial C02X1234 @@ -802,7 +806,8 @@ func newModernMobileMDMCmd(cliCtx *registry.CLIContext, name, commandType, short } func newMobileRestartCmd(cliCtx *registry.CLIContext) *cobra.Command { - return newModernMobileMDMCmd(cliCtx, "restart", "RESTART_DEVICE", + return newModernMobileMDMCmd( + cliCtx, "restart", "RESTART_DEVICE", "Restart a mobile device", "Restart a mobile device by serial number, name, or ID.", ` jamf-cli pro md restart --serial F4GH5678 @@ -812,7 +817,8 @@ func newMobileRestartCmd(cliCtx *registry.CLIContext) *cobra.Command { } func newMobileShutdownCmd(cliCtx *registry.CLIContext) *cobra.Command { - return newModernMobileMDMCmd(cliCtx, "shutdown", "SHUT_DOWN_DEVICE", + return newModernMobileMDMCmd( + cliCtx, "shutdown", "SHUT_DOWN_DEVICE", "Shut down a mobile device", "Shut down a mobile device by serial number, name, or ID.", ` jamf-cli pro md shutdown --serial F4GH5678`, @@ -986,7 +992,8 @@ func newMobileEnableLostModeCmd(cliCtx *registry.CLIContext) *cobra.Command { } func newMobileDisableLostModeCmd(cliCtx *registry.CLIContext) *cobra.Command { - return newModernMobileMDMCmd(cliCtx, "disable-lost-mode", "DISABLE_LOST_MODE", + return newModernMobileMDMCmd( + cliCtx, "disable-lost-mode", "DISABLE_LOST_MODE", "Disable Lost Mode on a mobile device", "Disable Lost Mode on a supervised mobile device that is currently in Lost Mode.", ` jamf-cli pro md disable-lost-mode --serial F4GH5678 --yes @@ -996,7 +1003,8 @@ func newMobileDisableLostModeCmd(cliCtx *registry.CLIContext) *cobra.Command { } func newMobilePlayLostModeSoundCmd(cliCtx *registry.CLIContext) *cobra.Command { - return newModernMobileMDMCmd(cliCtx, "play-lost-mode-sound", "PLAY_LOST_MODE_SOUND", + return newModernMobileMDMCmd( + cliCtx, "play-lost-mode-sound", "PLAY_LOST_MODE_SOUND", "Play a sound on a device in Lost Mode", "Play a sound on a supervised mobile device that is currently in Lost Mode.", ` jamf-cli pro md play-lost-mode-sound --serial F4GH5678 @@ -1006,7 +1014,8 @@ func newMobilePlayLostModeSoundCmd(cliCtx *registry.CLIContext) *cobra.Command { } func newMobileClearRestrictionsPasswordCmd(cliCtx *registry.CLIContext) *cobra.Command { - return newModernMobileMDMCmd(cliCtx, "clear-restrictions-password", "CLEAR_RESTRICTIONS_PASSWORD", + return newModernMobileMDMCmd( + cliCtx, "clear-restrictions-password", "CLEAR_RESTRICTIONS_PASSWORD", "Clear the restrictions password on a mobile device", "Clear the restrictions password on a supervised mobile device.", ` jamf-cli pro md clear-restrictions-password --serial F4GH5678 @@ -1243,7 +1252,8 @@ One of --destination-id or --destination-name is required.`, } func newMobileStopMirroringCmd(cliCtx *registry.CLIContext) *cobra.Command { - return newModernMobileMDMCmd(cliCtx, "stop-mirroring", "STOP_MIRRORING", + return newModernMobileMDMCmd( + cliCtx, "stop-mirroring", "STOP_MIRRORING", "Stop AirPlay mirroring on a mobile device", "Stop an active AirPlay mirroring session on a mobile device.", ` jamf-cli pro md stop-mirroring --serial F4GH5678`, @@ -1371,7 +1381,8 @@ This is a destructive operation.`, } func newMobileLogOutUserCmd(cliCtx *registry.CLIContext) *cobra.Command { - return newModernMobileMDMCmd(cliCtx, "log-out-user", "LOG_OUT_USER", + return newModernMobileMDMCmd( + cliCtx, "log-out-user", "LOG_OUT_USER", "Log out the current user on a Shared iPad", "Log out the currently signed-in user on a Shared iPad.", ` jamf-cli pro md log-out-user --serial F4GH5678 diff --git a/internal/commands/pro_overview.go b/internal/commands/pro_overview.go index 018e0a8..f12fa13 100644 --- a/internal/commands/pro_overview.go +++ b/internal/commands/pro_overview.go @@ -1278,7 +1278,8 @@ func buildEnrollmentItems( items = append(items, item("ADE Token Expires", "None configured")) } - items = append(items, + items = append( + items, item("ADE Sync Status", get("ade_sync_status")), item("Computer Prestages", get("computer_prestages")), item("Mobile Device Prestages", get("md_prestages")), @@ -1295,7 +1296,8 @@ func buildEnrollmentItems( items = append(items, overviewItem{label, display, t.Color}) } - items = append(items, + items = append( + items, overviewItem{}, // blank separator getItem("APNs Certificate", "apns_cert"), getItem("Built-in CA Expires", "ca_expires"), diff --git a/internal/commands/pro_platform_devices.go b/internal/commands/pro_platform_devices.go index 7a8310a..37bf51f 100644 --- a/internal/commands/pro_platform_devices.go +++ b/internal/commands/pro_platform_devices.go @@ -21,7 +21,8 @@ import ( // uuidPattern matches the standard UUID format (8-4-4-4-12 hex digits). var uuidPattern = regexp.MustCompile( - `^[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{12}$`) + `^[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{12}$`, +) func newPlatformDevicesCmd(cliCtx *registry.CLIContext) *cobra.Command { cmd := &cobra.Command{ diff --git a/internal/commands/pro_smartgroup.go b/internal/commands/pro_smartgroup.go index 515191a..9520ada 100644 --- a/internal/commands/pro_smartgroup.go +++ b/internal/commands/pro_smartgroup.go @@ -81,7 +81,7 @@ func renderTemplatesList(cmd *cobra.Command, tmpls []smartgroup.Template, format return writeTemplatesJSON(out, tmpls) } if len(tmpls) == 0 { - fmt.Fprintln(out, "0 templates match the filter.") + _, _ = fmt.Fprintln(out, "0 templates match the filter.") return nil } cats := uniqueCategories(tmpls) @@ -89,18 +89,18 @@ func renderTemplatesList(cmd *cobra.Command, tmpls []smartgroup.Template, format if len(cats) != 1 { noun = "categories" } - fmt.Fprintf(out, "Smart Group Templates — %d available across %d %s\n\n", len(tmpls), len(cats), noun) + _, _ = fmt.Fprintf(out, "Smart Group Templates — %d available across %d %s\n\n", len(tmpls), len(cats), noun) for _, cat := range cats { bucket := filterByCategory(tmpls, cat) - fmt.Fprintf(out, "Category: %s (%d)\n", cat, len(bucket)) + _, _ = fmt.Fprintf(out, "Category: %s (%d)\n", cat, len(bucket)) for _, t := range bucket { suffix := "" if len(t.Params) > 0 { suffix = fmt.Sprintf(" (params: --%s)", t.Params[0].Name) } - fmt.Fprintf(out, " %-40s %s%s\n", t.Slug, t.Description, suffix) + _, _ = fmt.Fprintf(out, " %-40s %s%s\n", t.Slug, t.Description, suffix) } - fmt.Fprintln(out) + _, _ = fmt.Fprintln(out) } return nil } @@ -188,7 +188,7 @@ criteria before creating a group.`, } req.Name = "<--name required when running apply>" out := cmd.OutOrStdout() - fmt.Fprintln(out, "POST /v2/computer-groups/smart-groups") + _, _ = fmt.Fprintln(out, "POST /v2/computer-groups/smart-groups") enc := json.NewEncoder(out) enc.SetIndent("", " ") return enc.Encode(req) @@ -303,7 +303,7 @@ logged. Use --dry-run to inspect the request body without calling the API.`, } func printDryRun(out io.Writer, req smartgroup.SmartGroupRequest) error { - fmt.Fprintln(out, "POST /v2/computer-groups/smart-groups") + _, _ = fmt.Fprintln(out, "POST /v2/computer-groups/smart-groups") enc := json.NewEncoder(out) enc.SetIndent("", " ") return enc.Encode(req) @@ -316,14 +316,14 @@ func runApplyFlow(ctx context.Context, out io.Writer, client registry.HTTPClient } var id string - switch { - case existingID == "": + switch existingID { + case "": newID, err := createSmartGroup(ctx, client, req) if err != nil { return err } id = newID - fmt.Fprintf(out, "Created smart group %q (ID: %s)\n", req.Name, id) + _, _ = fmt.Fprintf(out, "Created smart group %q (ID: %s)\n", req.Name, id) default: if !yes { return fmt.Errorf("smart group %q already exists (ID %s); pass --yes to replace", req.Name, existingID) @@ -332,23 +332,23 @@ func runApplyFlow(ctx context.Context, out io.Writer, client registry.HTTPClient return err } id = existingID - fmt.Fprintf(out, "Updated smart group %q (ID: %s)\n", req.Name, id) + _, _ = fmt.Fprintf(out, "Updated smart group %q (ID: %s)\n", req.Name, id) } if recalculate { if err := recalculateSmartGroup(ctx, client, id); err != nil { - fmt.Fprintf(out, "Warning: recalculate did not complete: %v\n", err) + _, _ = fmt.Fprintf(out, "Warning: recalculate did not complete: %v\n", err) } } count, err := smartgroup.CountMembers(ctx, client, id) if err != nil { - fmt.Fprintf(out, "Warning: membership check failed: %v\n", err) + _, _ = fmt.Fprintf(out, "Warning: membership check failed: %v\n", err) return nil } - fmt.Fprintf(out, "Membership: %d devices.\n", count) + _, _ = fmt.Fprintf(out, "Membership: %d devices.\n", count) if count == 0 { - fmt.Fprintln(out, "This template matched 0 devices. Run 'pro sg verify-templates' to check criterion compatibility with your tenant.") + _, _ = fmt.Fprintln(out, "This template matched 0 devices. Run 'pro sg verify-templates' to check criterion compatibility with your tenant.") } return nil } @@ -360,7 +360,7 @@ func lookupSmartGroupByName(ctx context.Context, client registry.HTTPClient, nam if err != nil { return "", err } - defer resp.Body.Close() + defer func() { _ = resp.Body.Close() }() if resp.StatusCode != 200 { body, _ := io.ReadAll(resp.Body) return "", fmt.Errorf("lookup smart group: HTTP %d: %s", resp.StatusCode, string(body)) @@ -392,7 +392,7 @@ func createSmartGroup(ctx context.Context, client registry.HTTPClient, req smart if err != nil { return "", err } - defer resp.Body.Close() + defer func() { _ = resp.Body.Close() }() if resp.StatusCode == 403 { return "", fmt.Errorf("permission denied: the OAuth role is missing the 'Create Smart Computer Groups' privilege") } @@ -418,7 +418,7 @@ func updateSmartGroup(ctx context.Context, client registry.HTTPClient, id string if err != nil { return err } - defer resp.Body.Close() + defer func() { _ = resp.Body.Close() }() if resp.StatusCode == 403 { return fmt.Errorf("permission denied: the OAuth role is missing the 'Update Smart Computer Groups' privilege") } @@ -434,7 +434,7 @@ func recalculateSmartGroup(ctx context.Context, client registry.HTTPClient, id s if err != nil { return err } - defer resp.Body.Close() + defer func() { _ = resp.Body.Close() }() if resp.StatusCode < 200 || resp.StatusCode >= 300 { return fmt.Errorf("recalculate: HTTP %d", resp.StatusCode) } @@ -489,20 +489,20 @@ func renderVerifyResults(out io.Writer, results []smartgroup.VerifyResult, asJSO return enc.Encode(results) } ok, zero, errs := 0, 0, 0 - fmt.Fprintf(out, "Verifying %d templates...\n\n", len(results)) + _, _ = fmt.Fprintf(out, "Verifying %d templates...\n\n", len(results)) for _, r := range results { switch r.Outcome { case smartgroup.VerifyOK: ok++ - fmt.Fprintf(out, "✓ %-40s — %d devices match\n", r.Slug, r.MemberCount) + _, _ = fmt.Fprintf(out, "✓ %-40s — %d devices match\n", r.Slug, r.MemberCount) case smartgroup.VerifyZeroMatch: zero++ - fmt.Fprintf(out, "⚠ %-40s — 0 devices match (possible criterion mismatch)\n", r.Slug) + _, _ = fmt.Fprintf(out, "⚠ %-40s — 0 devices match (possible criterion mismatch)\n", r.Slug) case smartgroup.VerifyError: errs++ - fmt.Fprintf(out, "✗ %-40s — ERROR: %s\n", r.Slug, r.Error) + _, _ = fmt.Fprintf(out, "✗ %-40s — ERROR: %s\n", r.Slug, r.Error) } } - fmt.Fprintf(out, "\nSummary: %d OK, %d zero-match warnings, %d errors.\n", ok, zero, errs) + _, _ = fmt.Fprintf(out, "\nSummary: %d OK, %d zero-match warnings, %d errors.\n", ok, zero, errs) return nil } diff --git a/internal/profileconvert/ddm_converter.go b/internal/profileconvert/ddm_converter.go index 3da91b0..fe075df 100644 --- a/internal/profileconvert/ddm_converter.go +++ b/internal/profileconvert/ddm_converter.go @@ -58,7 +58,8 @@ type ddmConverter struct { var converters []*ddmConverter func init() { - converters = append(converters, + converters = append( + converters, newPasscodeConverter(), newSafariConverter(), newSoftwareUpdateConverter(), diff --git a/internal/smartgroup/membership.go b/internal/smartgroup/membership.go index afa3a03..91a4e38 100644 --- a/internal/smartgroup/membership.go +++ b/internal/smartgroup/membership.go @@ -24,7 +24,7 @@ func CountMembers(ctx context.Context, client HTTPDoer, groupID string) (int, er if err != nil { return 0, fmt.Errorf("smart-group membership: %w", err) } - defer resp.Body.Close() + defer func() { _ = resp.Body.Close() }() if resp.StatusCode < 200 || resp.StatusCode >= 300 { body, _ := io.ReadAll(resp.Body) return 0, fmt.Errorf("smart-group membership: HTTP %d: %s", resp.StatusCode, string(body)) diff --git a/internal/smartgroup/verify.go b/internal/smartgroup/verify.go index e1618dc..0dc16f3 100644 --- a/internal/smartgroup/verify.go +++ b/internal/smartgroup/verify.go @@ -114,7 +114,7 @@ func createTempGroup(ctx context.Context, client HTTPDoer, req SmartGroupRequest if err != nil { return "", err } - defer resp.Body.Close() + defer func() { _ = resp.Body.Close() }() if resp.StatusCode < 200 || resp.StatusCode >= 300 { buf, _ := io.ReadAll(resp.Body) return "", fmt.Errorf("HTTP %d: %s", resp.StatusCode, string(buf)) @@ -133,7 +133,7 @@ func recalcGroup(ctx context.Context, client HTTPDoer, id string) error { if err != nil { return err } - resp.Body.Close() + _ = resp.Body.Close() return nil } @@ -142,6 +142,6 @@ func deleteGroup(ctx context.Context, client HTTPDoer, id string) error { if err != nil { return err } - resp.Body.Close() + _ = resp.Body.Close() return nil } From dde2a617acb2bc4570439e3345d0c461370c74b3 Mon Sep 17 00:00:00 2001 From: Keaton Svoma Date: Tue, 12 May 2026 22:07:34 -0500 Subject: [PATCH 18/23] fix(smartgroup): check recalcGroup HTTP status; add --yes guard regression test --- internal/commands/pro_smartgroup_test.go | 31 ++++++++++++++++++++++++ internal/smartgroup/verify.go | 5 +++- 2 files changed, 35 insertions(+), 1 deletion(-) diff --git a/internal/commands/pro_smartgroup_test.go b/internal/commands/pro_smartgroup_test.go index 39dc7dc..ed2d426 100644 --- a/internal/commands/pro_smartgroup_test.go +++ b/internal/commands/pro_smartgroup_test.go @@ -285,6 +285,37 @@ func TestApply_403MissingPrivilege(t *testing.T) { } } +func TestApply_ExistingGroupYesRequired(t *testing.T) { + // Without --yes, updating an existing group should error. + client := &fakeSGClient{ + queue: []*http.Response{ + newJSONResp(200, map[string]any{ + "totalCount": 1, + "results": []any{map[string]any{"id": "55", "name": "Existing Group"}}, + }), + }, + } + _, err := runSmartGroupApply( + t, client, + "--template", "encryption/not-encrypted", + "--name", "Existing Group", + // No --yes flag + ) + if err == nil { + t.Fatal("expected error when --yes is omitted on existing group, got nil") + } + if !strings.Contains(err.Error(), "--yes to replace") { + t.Errorf("expected error to mention '--yes to replace', got: %v", err) + } + // Should have made only one API call (the name lookup), no PUT. + if len(client.calls) != 1 { + t.Errorf("expected exactly 1 API call (name lookup), got %d", len(client.calls)) + } + if client.calls[0].method != "GET" { + t.Errorf("expected GET (lookup), got %s", client.calls[0].method) + } +} + func TestVerifyTemplates_CategoryRuns(t *testing.T) { // Each template in the encryption category produces 4 HTTP calls // (POST create + recalc + membership + DELETE cleanup). We queue 6 templates * 4 = 24 responses. diff --git a/internal/smartgroup/verify.go b/internal/smartgroup/verify.go index 0dc16f3..d44c02c 100644 --- a/internal/smartgroup/verify.go +++ b/internal/smartgroup/verify.go @@ -133,7 +133,10 @@ func recalcGroup(ctx context.Context, client HTTPDoer, id string) error { if err != nil { return err } - _ = resp.Body.Close() + defer func() { _ = resp.Body.Close() }() + if resp.StatusCode < 200 || resp.StatusCode >= 300 { + return fmt.Errorf("recalculate: HTTP %d", resp.StatusCode) + } return nil } From 7e57af03cad601e3435d653a92de6d28a8119cee Mon Sep 17 00:00:00 2001 From: Keaton Svoma Date: Tue, 12 May 2026 22:12:48 -0500 Subject: [PATCH 19/23] fix(smartgroup): encryption/not-encrypted uses 'No Partitions Encrypted' (verified via live tenant) --- internal/commands/pro_smartgroup_test.go | 2 +- internal/smartgroup/encryption.go | 2 +- internal/smartgroup/library_test.go | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/internal/commands/pro_smartgroup_test.go b/internal/commands/pro_smartgroup_test.go index ed2d426..6b771f7 100644 --- a/internal/commands/pro_smartgroup_test.go +++ b/internal/commands/pro_smartgroup_test.go @@ -97,7 +97,7 @@ func TestPreview_ZeroParam(t *testing.T) { if !strings.Contains(out, "POST /v2/computer-groups/smart-groups") { t.Errorf("expected POST header: %s", out) } - if !strings.Contains(out, "FileVault 2 Status") || !strings.Contains(out, "Not Encrypted") { + if !strings.Contains(out, "FileVault 2 Status") || !strings.Contains(out, "No Partitions Encrypted") { t.Errorf("expected criterion in JSON body: %s", out) } } diff --git a/internal/smartgroup/encryption.go b/internal/smartgroup/encryption.go index 3221b4a..31918a9 100644 --- a/internal/smartgroup/encryption.go +++ b/internal/smartgroup/encryption.go @@ -22,7 +22,7 @@ func encryptionNotEncrypted() Template { return SmartGroupRequest{ Description: "Auto-generated by jamf-cli (template: encryption/not-encrypted)", Criteria: []Criterion{ - {AndOr: "and", Priority: 0, Name: CriterionFV2Status, SearchType: "is", Value: "Not Encrypted"}, + {AndOr: "and", Priority: 0, Name: CriterionFV2Status, SearchType: "is", Value: "No Partitions Encrypted"}, }, }, nil }, diff --git a/internal/smartgroup/library_test.go b/internal/smartgroup/library_test.go index d87dfd6..f092c9e 100644 --- a/internal/smartgroup/library_test.go +++ b/internal/smartgroup/library_test.go @@ -72,7 +72,7 @@ func TestEncryption_NotEncrypted_Golden(t *testing.T) { t.Fatalf("expected 1 criterion, got %d", len(req.Criteria)) } c := req.Criteria[0] - if c.Name != "FileVault 2 Status" || c.SearchType != "is" || c.Value != "Not Encrypted" { + if c.Name != "FileVault 2 Status" || c.SearchType != "is" || c.Value != "No Partitions Encrypted" { t.Fatalf("unexpected criterion: %+v", c) } } From 438e94540edc517fa426ddf7894a8443f51d55f5 Mon Sep 17 00:00:00 2001 From: Keaton Svoma Date: Tue, 12 May 2026 22:17:49 -0500 Subject: [PATCH 20/23] =?UTF-8?q?fix(smartgroup):=20correct=204=20JSS-veri?= =?UTF-8?q?fied=20strings=20=E2=80=94=20JAMF=20caps,=20Gatekeeper=20'Off',?= =?UTF-8?q?=20MDM=20cert=20searchType?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- internal/smartgroup/compliance.go | 4 ++-- internal/smartgroup/criteria.go | 5 ++--- internal/smartgroup/library_test.go | 6 +++--- internal/smartgroup/mdm.go | 2 +- 4 files changed, 8 insertions(+), 9 deletions(-) diff --git a/internal/smartgroup/compliance.go b/internal/smartgroup/compliance.go index a9d1a09..86386a7 100644 --- a/internal/smartgroup/compliance.go +++ b/internal/smartgroup/compliance.go @@ -18,7 +18,7 @@ func complianceGatekeeperDisabled() Template { return SmartGroupRequest{ Description: "Auto-generated by jamf-cli (template: compliance/gatekeeper-disabled)", Criteria: []Criterion{ - {AndOr: "and", Priority: 0, Name: CriterionGatekeeper, SearchType: "is", Value: "Disabled"}, + {AndOr: "and", Priority: 0, Name: CriterionGatekeeper, SearchType: "is", Value: "Off"}, }, }, nil }, @@ -68,7 +68,7 @@ func complianceNonCompliantBaseline() Template { Criteria: []Criterion{ {AndOr: "and", Priority: 0, Name: CriterionFV2Enabled, SearchType: "is", Value: "Not Enabled"}, {AndOr: "or", Priority: 1, Name: CriterionSIP, SearchType: "is", Value: "Disabled"}, - {AndOr: "or", Priority: 2, Name: CriterionGatekeeper, SearchType: "is", Value: "Disabled"}, + {AndOr: "or", Priority: 2, Name: CriterionGatekeeper, SearchType: "is", Value: "Off"}, {AndOr: "or", Priority: 3, Name: CriterionFirewallEnabled, SearchType: "is", Value: "No"}, }, }, nil diff --git a/internal/smartgroup/criteria.go b/internal/smartgroup/criteria.go index d6e8a00..0d4b7bd 100644 --- a/internal/smartgroup/criteria.go +++ b/internal/smartgroup/criteria.go @@ -65,9 +65,8 @@ const ( // MatcherNameConstants.java:CD.APPLE_SILICON CriterionAppleSilicon = "Apple Silicon" - // Parallel inventory criterion; pro smart-group verify-templates is the - // empirical check against a live tenant. - CriterionJamfBinaryVersion = "Jamf Binary Version" + // JamfBinaryVersionMatcher.java:17 @Component("JAMF Binary Version") — note uppercase JAMF + CriterionJamfBinaryVersion = "JAMF Binary Version" ) // allCriterionConsts returns the full registry as a map for testing. diff --git a/internal/smartgroup/library_test.go b/internal/smartgroup/library_test.go index f092c9e..416d134 100644 --- a/internal/smartgroup/library_test.go +++ b/internal/smartgroup/library_test.go @@ -275,7 +275,7 @@ func TestMDM_MDMCertExpiring_GoldenDefault(t *testing.T) { t.Fatalf("Build: %v", err) } c := req.Criteria[0] - if c.Name != "MDM Profile Expiration Date" || c.SearchType != "less than x days from now" || c.Value != "30" { + if c.Name != "MDM Profile Expiration Date" || c.SearchType != "in less than x days" || c.Value != "30" { t.Fatalf("unexpected criterion: %+v", c) } } @@ -301,7 +301,7 @@ func TestCompliance_GatekeeperDisabled_Golden(t *testing.T) { t.Fatalf("Build: %v", err) } c := req.Criteria[0] - if c.Name != "Gatekeeper" || c.SearchType != "is" || c.Value != "Disabled" { + if c.Name != "Gatekeeper" || c.SearchType != "is" || c.Value != "Off" { t.Fatalf("unexpected criterion: %+v", c) } } @@ -389,7 +389,7 @@ func TestLifecycle_JamfBinaryOutdated_Golden(t *testing.T) { t.Fatalf("Build: %v", err) } c := req.Criteria[0] - if c.Name != "Jamf Binary Version" || c.SearchType != "less than" || c.Value != "11.0.0" { + if c.Name != "JAMF Binary Version" || c.SearchType != "less than" || c.Value != "11.0.0" { t.Fatalf("unexpected criterion: %+v", c) } } diff --git a/internal/smartgroup/mdm.go b/internal/smartgroup/mdm.go index dde209e..a6136ee 100644 --- a/internal/smartgroup/mdm.go +++ b/internal/smartgroup/mdm.go @@ -83,7 +83,7 @@ func mdmMDMCertExpiring() Template { return SmartGroupRequest{ Description: fmt.Sprintf("Auto-generated by jamf-cli (template: mdm/mdm-cert-expiring, within-days=%d)", n), Criteria: []Criterion{ - {AndOr: "and", Priority: 0, Name: CriterionMDMProfileExpirationDate, SearchType: "less than x days from now", Value: fmt.Sprintf("%d", n)}, + {AndOr: "and", Priority: 0, Name: CriterionMDMProfileExpirationDate, SearchType: "in less than x days", Value: fmt.Sprintf("%d", n)}, }, }, nil }, From 9d667181ad681f6d21f8afaa412b7b0280657f11 Mon Sep 17 00:00:00 2001 From: Keaton Svoma Date: Tue, 12 May 2026 22:24:30 -0500 Subject: [PATCH 21/23] fix(smartgroup): jamf-binary-outdated uses 'not current'; ade-enrolled uses correct ADE Boolean criterion --- internal/smartgroup/criteria.go | 4 ++++ internal/smartgroup/library_test.go | 16 +++++----------- internal/smartgroup/lifecycle.go | 19 +++++-------------- internal/smartgroup/verify.go | 5 ++--- 4 files changed, 16 insertions(+), 28 deletions(-) diff --git a/internal/smartgroup/criteria.go b/internal/smartgroup/criteria.go index 0d4b7bd..b999959 100644 --- a/internal/smartgroup/criteria.go +++ b/internal/smartgroup/criteria.go @@ -62,6 +62,9 @@ const ( // MatcherNameConstants.java:E.PRESTAGE CriterionEnrollmentMethodPrestage = "Enrollment Method: PreStage enrollment" + // EnrolledViaAutomatedDeviceEnrollmentMatcher.java:17 @Component("Enrolled via Automated Device Enrollment") + CriterionEnrolledViaADE = "Enrolled via Automated Device Enrollment" + // MatcherNameConstants.java:CD.APPLE_SILICON CriterionAppleSilicon = "Apple Silicon" @@ -91,6 +94,7 @@ func allCriterionConsts() map[string]string { "CriterionFirewallEnabled": CriterionFirewallEnabled, "CriterionSupervised": CriterionSupervised, "CriterionEnrollmentMethodPrestage": CriterionEnrollmentMethodPrestage, + "CriterionEnrolledViaADE": CriterionEnrolledViaADE, "CriterionAppleSilicon": CriterionAppleSilicon, "CriterionJamfBinaryVersion": CriterionJamfBinaryVersion, } diff --git a/internal/smartgroup/library_test.go b/internal/smartgroup/library_test.go index 416d134..57757f7 100644 --- a/internal/smartgroup/library_test.go +++ b/internal/smartgroup/library_test.go @@ -370,26 +370,22 @@ func TestLifecycle_ADEEnrolled_Golden(t *testing.T) { t.Fatalf("Build: %v", err) } c := req.Criteria[0] - if c.Name != "Enrollment Method: PreStage enrollment" || c.SearchType != "is" || c.Value != "Yes" { + if c.Name != "Enrolled via Automated Device Enrollment" || c.SearchType != "is" || c.Value != "Yes" { t.Fatalf("unexpected criterion: %+v", c) } } func TestLifecycle_JamfBinaryOutdated_Golden(t *testing.T) { tmpl, _ := Lookup("lifecycle/jamf-binary-outdated") - if _, err := tmpl.ResolveOpts(map[string]any{}); err == nil { - t.Fatal("expected error for missing required --below-version") + if len(tmpl.Params) != 0 { + t.Fatalf("expected 0 params (now using 'not current' operator), got %d", len(tmpl.Params)) } - opts, err := tmpl.ResolveOpts(map[string]any{"below-version": "11.0.0"}) - if err != nil { - t.Fatalf("ResolveOpts: %v", err) - } - req, err := tmpl.Build(opts) + req, err := tmpl.Build(map[string]any{}) if err != nil { t.Fatalf("Build: %v", err) } c := req.Criteria[0] - if c.Name != "JAMF Binary Version" || c.SearchType != "less than" || c.Value != "11.0.0" { + if c.Name != "JAMF Binary Version" || c.SearchType != "not current" || c.Value != "" { t.Fatalf("unexpected criterion: %+v", c) } } @@ -487,8 +483,6 @@ func defaultOptsForTest(t Template) map[string]any { out["below-version"] = "15.0" case "updates/major-version-behind": out["major-below"] = 15 - case "lifecycle/jamf-binary-outdated": - out["below-version"] = "11.0.0" } } return out diff --git a/internal/smartgroup/lifecycle.go b/internal/smartgroup/lifecycle.go index 25543de..58b27c1 100644 --- a/internal/smartgroup/lifecycle.go +++ b/internal/smartgroup/lifecycle.go @@ -2,8 +2,6 @@ package smartgroup -import "fmt" - func init() { Register(lifecycleUnsupervised()) Register(lifecycleADEEnrolled()) @@ -36,7 +34,7 @@ func lifecycleADEEnrolled() Template { return SmartGroupRequest{ Description: "Auto-generated by jamf-cli (template: lifecycle/ade-enrolled)", Criteria: []Criterion{ - {AndOr: "and", Priority: 0, Name: CriterionEnrollmentMethodPrestage, SearchType: "is", Value: "Yes"}, + {AndOr: "and", Priority: 0, Name: CriterionEnrolledViaADE, SearchType: "is", Value: "Yes"}, }, }, nil }, @@ -47,19 +45,12 @@ func lifecycleJamfBinaryOutdated() Template { return Template{ Slug: "lifecycle/jamf-binary-outdated", Category: "lifecycle", - Description: "Macs running an outdated Jamf binary", - Params: []ParamSpec{ - {Name: "below-version", Type: "version", Description: "Jamf binary version threshold (e.g. 11.0.0)", Required: true}, - }, - Build: func(opts map[string]any) (SmartGroupRequest, error) { - v, ok := opts["below-version"].(string) - if !ok { - return SmartGroupRequest{}, fmt.Errorf("expected string below-version, got %T", opts["below-version"]) - } + Description: "Macs whose Jamf binary is not on the latest known version", + Build: func(_ map[string]any) (SmartGroupRequest, error) { return SmartGroupRequest{ - Description: fmt.Sprintf("Auto-generated by jamf-cli (template: lifecycle/jamf-binary-outdated, below-version=%s)", v), + Description: "Auto-generated by jamf-cli (template: lifecycle/jamf-binary-outdated). Uses Jamf Pro's 'not current' operator — matches devices below the latest known Jamf binary version.", Criteria: []Criterion{ - {AndOr: "and", Priority: 0, Name: CriterionJamfBinaryVersion, SearchType: "less than", Value: v}, + {AndOr: "and", Priority: 0, Name: CriterionJamfBinaryVersion, SearchType: "not current", Value: ""}, }, }, nil }, diff --git a/internal/smartgroup/verify.go b/internal/smartgroup/verify.go index d44c02c..f82f21c 100644 --- a/internal/smartgroup/verify.go +++ b/internal/smartgroup/verify.go @@ -45,9 +45,8 @@ type VerifyResult struct { // defaultVerifyOpts supplies sensible required-param values for verification. // Update when adding new required-param templates. var defaultVerifyOpts = map[string]map[string]any{ - "updates/os-version-below": {"below-version": "15.0"}, - "updates/major-version-behind": {"major-below": 15}, - "lifecycle/jamf-binary-outdated": {"below-version": "11.0.0"}, + "updates/os-version-below": {"below-version": "15.0"}, + "updates/major-version-behind": {"major-below": 15}, } // RunOneVerification creates a temporary smart group from the template, From 477d591f62700a4ab7dd2a44902a5468c401bea3 Mon Sep 17 00:00:00 2001 From: Keaton Svoma Date: Tue, 12 May 2026 22:28:15 -0500 Subject: [PATCH 22/23] docs(design-patterns): add spec + plan for pro smart-group templates Captures the design rationale and step-by-step implementation plan for the pro smart-group namespace. Spec documents the 23-template inventory, verified criterion-name registry sourced from JSS, and the verify- templates safety net. Plan documents the 17-task TDD breakdown. Co-Authored-By: Claude Opus 4.7 (1M context) --- ...o-smart-group-templates-plan-2026-05-12.md | 3418 +++++++++++++++++ ...o-smart-group-templates-spec-2026-05-12.md | 573 +++ 2 files changed, 3991 insertions(+) create mode 100644 docs/solutions/design-patterns/pro-smart-group-templates-plan-2026-05-12.md create mode 100644 docs/solutions/design-patterns/pro-smart-group-templates-spec-2026-05-12.md diff --git a/docs/solutions/design-patterns/pro-smart-group-templates-plan-2026-05-12.md b/docs/solutions/design-patterns/pro-smart-group-templates-plan-2026-05-12.md new file mode 100644 index 0000000..9db25e6 --- /dev/null +++ b/docs/solutions/design-patterns/pro-smart-group-templates-plan-2026-05-12.md @@ -0,0 +1,3418 @@ +# pro smart-group templates — Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Ship a `pro smart-group` namespace with 4 commands (`templates`, `preview`, `apply`, `verify-templates`) backed by a curated, JSS-verified library of 23 smart-group templates across 5 categories. + +**Architecture:** New handwritten `internal/smartgroup/` package owns the template library, criterion-name constants, builder functions, membership-check helper, and verify-runner. New `internal/commands/pro_smartgroup.go` is the cobra command surface that wraps the library and talks to the existing `/v2/computer-groups/smart-groups` endpoints via `cliCtx.Client.Do`. No generator changes, no spec changes. + +**Tech Stack:** Go 1.21+, cobra, the project's existing `internal/registry.CLIContext` + `internal/output` packages, table-driven tests with golden JSON fixtures. + +**Spec:** `docs/solutions/design-patterns/pro-smart-group-templates-spec-2026-05-12.md` + +--- + +## File Structure + +``` +internal/smartgroup/ + types.go Template, ParamSpec, SmartGroupRequest, Criterion + types_test.go param validation + criteria.go Go consts for JSS-verified criterion names + criteria_test.go assert every const is non-empty and unique + library.go Library map + lookup + listing helpers + library_test.go registry sanity + per-template golden JSON + encryption.go 6 encryption templates + updates.go 4 software-updates templates + mdm.go 5 MDM-health templates + compliance.go 4 compliance templates + lifecycle.go 4 lifecycle templates + membership.go post-apply membership-count helper + membership_test.go membership-helper unit tests + verify.go verify-templates runner + verify_test.go verify-runner unit tests (mocked HTTP) + +internal/commands/ + pro_smartgroup.go namespace + 4 subcommands + pro_smartgroup_test.go golden output tests per command per format + +internal/commands/pro.go +1 line: cmd.AddCommand(newSmartGroupCmd(cliCtx)) +internal/commands/groups.go +1 entry in proGroupMap +internal/commands/aliases.go +1 entry in commandAliases +``` + +--- + +## Task 1: Foundation types (Template, ParamSpec, request struct) + +**Files:** +- Create: `internal/smartgroup/types.go` +- Create: `internal/smartgroup/types_test.go` + +- [ ] **Step 1: Write the failing test** + +Create `internal/smartgroup/types_test.go`: + +```go +// Copyright 2026, Jamf Software LLC + +package smartgroup + +import ( + "testing" +) + +func TestValidateOpts_RequiredMissing(t *testing.T) { + spec := ParamSpec{Name: "below-version", Type: "string", Required: true} + tmpl := Template{ + Slug: "test/required", + Params: []ParamSpec{spec}, + } + _, err := tmpl.ResolveOpts(map[string]any{}) + if err == nil { + t.Fatal("expected error for missing required param, got nil") + } +} + +func TestValidateOpts_TypeMismatch(t *testing.T) { + spec := ParamSpec{Name: "days", Type: "int"} + tmpl := Template{ + Slug: "test/typed", + Params: []ParamSpec{spec}, + } + _, err := tmpl.ResolveOpts(map[string]any{"days": "not-an-int"}) + if err == nil { + t.Fatal("expected error for type mismatch, got nil") + } +} + +func TestValidateOpts_DefaultApplied(t *testing.T) { + spec := ParamSpec{Name: "days", Type: "int", Default: 7} + tmpl := Template{ + Slug: "test/default", + Params: []ParamSpec{spec}, + } + opts, err := tmpl.ResolveOpts(map[string]any{}) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if opts["days"] != 7 { + t.Fatalf("expected default 7, got %v", opts["days"]) + } +} + +func TestValidateOpts_NoParamsAccepted(t *testing.T) { + tmpl := Template{Slug: "test/noparam", Params: nil} + opts, err := tmpl.ResolveOpts(map[string]any{}) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if len(opts) != 0 { + t.Fatalf("expected empty opts, got %v", opts) + } +} +``` + +- [ ] **Step 2: Run test to verify it fails** + +Run: `go test ./internal/smartgroup/ -run TestValidateOpts -v` +Expected: FAIL with "undefined: ParamSpec" or "undefined: Template". + +- [ ] **Step 3: Implement minimal types** + +Create `internal/smartgroup/types.go`: + +```go +// Copyright 2026, Jamf Software LLC + +// Package smartgroup curates a library of Jamf Pro smart-group templates +// admins can instantiate via the CLI. Criterion-name strings are sourced from +// the JSS server (see criteria.go for citations); the library is exercised +// against a live tenant via pro smart-group verify-templates. +package smartgroup + +import "fmt" + +// ParamSpec describes a single named parameter on a parameterized template. +// Templates have at most one ParamSpec; multi-param templates should be split +// into discrete variants. +type ParamSpec struct { + Name string // CLI flag name, e.g. "stalled-after" + Type string // "int" | "string" | "version" + Default any // applied when caller omits the param; nil iff Required + Description string // for --help + Required bool +} + +// Template is one curated smart-group recipe in the library. +type Template struct { + Slug string // e.g. "encryption/not-encrypted" + Category string // e.g. "encryption" + Description string // one-line for table listings + Params []ParamSpec // zero or one entry + Build func(opts map[string]any) (SmartGroupRequest, error) +} + +// SmartGroupRequest is the JSON body posted to /v2/computer-groups/smart-groups. +// We define our own type rather than importing the generated SmartComputerGroupV2 +// so the smartgroup package can be tested in isolation. +type SmartGroupRequest struct { + Name string `json:"name"` + Description string `json:"description,omitempty"` + Criteria []Criterion `json:"criteria,omitempty"` + SiteID string `json:"siteId,omitempty"` +} + +// Criterion is one row in SmartGroupRequest.Criteria; matches the +// SmartSearchCriterion schema from specs/_MonolithLibrary.yaml. +type Criterion struct { + AndOr string `json:"andOr"` + Name string `json:"name"` + SearchType string `json:"searchType"` + Value string `json:"value"` + Priority int `json:"priority"` + OpeningParen bool `json:"openingParen"` + ClosingParen bool `json:"closingParen"` +} + +// ResolveOpts validates and normalises a caller-supplied opts map against the +// template's ParamSpec list. Required params must be present. Type mismatches +// return an error. Defaults are filled in when omitted. +func (t Template) ResolveOpts(in map[string]any) (map[string]any, error) { + out := make(map[string]any, len(t.Params)) + for _, p := range t.Params { + raw, present := in[p.Name] + if !present { + if p.Required { + return nil, fmt.Errorf("template %s requires param --%s", t.Slug, p.Name) + } + if p.Default != nil { + out[p.Name] = p.Default + } + continue + } + val, err := coerceTo(p.Type, raw) + if err != nil { + return nil, fmt.Errorf("template %s: param --%s: %w", t.Slug, p.Name, err) + } + out[p.Name] = val + } + return out, nil +} + +func coerceTo(typ string, raw any) (any, error) { + switch typ { + case "int": + switch v := raw.(type) { + case int: + return v, nil + case int64: + return int(v), nil + case float64: + return int(v), nil + case string: + var n int + if _, err := fmt.Sscanf(v, "%d", &n); err != nil { + return nil, fmt.Errorf("expected int, got %q", v) + } + return n, nil + default: + return nil, fmt.Errorf("expected int, got %T", raw) + } + case "string", "version": + if s, ok := raw.(string); ok { + return s, nil + } + return nil, fmt.Errorf("expected string, got %T", raw) + default: + return nil, fmt.Errorf("unknown param type %q", typ) + } +} +``` + +- [ ] **Step 4: Run test to verify it passes** + +Run: `go test ./internal/smartgroup/ -run TestValidateOpts -v` +Expected: PASS (4 tests). + +- [ ] **Step 5: Commit** + +```bash +git add internal/smartgroup/types.go internal/smartgroup/types_test.go +git commit -m "feat(smartgroup): introduce Template + ParamSpec foundation types" +``` + +--- + +## Task 2: Criterion-name constants (JSS-verified) + +**Files:** +- Create: `internal/smartgroup/criteria.go` +- Create: `internal/smartgroup/criteria_test.go` + +- [ ] **Step 1: Write the failing test** + +Create `internal/smartgroup/criteria_test.go`: + +```go +// Copyright 2026, Jamf Software LLC + +package smartgroup + +import ( + "strings" + "testing" +) + +func TestCriterionConstsNotEmpty(t *testing.T) { + consts := allCriterionConsts() + for name, value := range consts { + if strings.TrimSpace(value) == "" { + t.Errorf("criterion const %s is empty", name) + } + } +} + +func TestCriterionConstsUnique(t *testing.T) { + consts := allCriterionConsts() + seen := make(map[string]string) + for name, value := range consts { + if other, ok := seen[value]; ok { + t.Errorf("criterion value %q used by both %s and %s", value, name, other) + } + seen[value] = name + } +} +``` + +- [ ] **Step 2: Run test to verify it fails** + +Run: `go test ./internal/smartgroup/ -run TestCriterion -v` +Expected: FAIL with "undefined: allCriterionConsts". + +- [ ] **Step 3: Implement the criterion-name registry** + +Create `internal/smartgroup/criteria.go`: + +```go +// Copyright 2026, Jamf Software LLC + +package smartgroup + +// Smart-group criterion names. These strings must match Jamf Pro's smart-group +// criterion UI exactly. The canonical source is the JSS server repo +// (jamf/jss). Each const cites the file:line it was sourced from. Re-verify +// after any sync-specs pass that includes JSS source updates. + +const ( + // FileVault2StatusMatcher.java:@Component("FileVault 2 Status") + CriterionFV2Status = "FileVault 2 Status" + + // MatcherNameConstants.java:CD.FILE_VAULT_2_ENABLED + CriterionFV2Enabled = "FileVault 2 Enabled" + + // ComputerInventoryValues.java:103 + CriterionFV2RecoveryKeyType = "FileVault 2 Recovery Key Type" + + // ComputerInventoryValues.java:104 + CriterionFV2IndividualKeyValidation = "FileVault 2 Individual Key Validation" + + // ComputerInventoryValues.java:106 + CriterionFV2PersonalRecoveryKey = "FileVault 2 Personal Recovery Key" + + // MatcherNameConstants.java:CD.OPERATING_SYSTEM_VERSION + CriterionOSVersion = "Operating System Version" + + // MatcherNameConstants.java:CD.OPERATING_SYSTEM_BUILD + CriterionOSBuild = "Operating System Build" + + // MatcherNameConstants.java:CD.OPERATING_SYSTEM_SUPPLEMENTAL_VERSION_EXTRA + CriterionOSRapidSecurityResponse = "Operating System Rapid Security Response" + + // MatcherNameConstants.java:MDD.LAST_INVENTORY_UPDATE + CriterionLastInventoryUpdate = "Last Inventory Update" + + // MatcherNameConstants.java:MDD.BOOTSTRAP_TOKEN_ESCROWED + CriterionBootstrapTokenEscrowed = "Bootstrap Token Escrowed" + + // UserApprovedMdmMatcher.java:@Component("User Approved MDM") + CriterionUserApprovedMDM = "User Approved MDM" + + // MatcherNameConstants.java:MDD.MDM_PROFILE_EXPIRATION_DATE + CriterionMDMProfileExpirationDate = "MDM Profile Expiration Date" + + // MatcherNameConstants.java:CD.DECLARATIVE_DEVICE_MANAGEMENT_ENABLED + CriterionDDMEnabled = "Declarative Device Management Enabled" + + // ComputerInventoryValues.java:118 + CriterionGatekeeper = "Gatekeeper" + + // ComputerInventoryValues.java:119 + CriterionSIP = "System Integrity Protection" + + // MatcherNameConstants.java:CD.FIREWALL_ENABLED + CriterionFirewallEnabled = "Firewall Enabled" + + // MatcherNameConstants.java:MDD.SUPERVISED + CriterionSupervised = "Supervised" + + // MatcherNameConstants.java:E.PRESTAGE + CriterionEnrollmentMethodPrestage = "Enrollment Method: PreStage enrollment" + + // MatcherNameConstants.java:CD.APPLE_SILICON + CriterionAppleSilicon = "Apple Silicon" + + // Parallel inventory criterion; pro smart-group verify-templates is the + // empirical check against a live tenant. + CriterionJamfBinaryVersion = "Jamf Binary Version" +) + +// allCriterionConsts returns the full registry as a map for testing. +// Keep in sync with the const block above. +func allCriterionConsts() map[string]string { + return map[string]string{ + "CriterionFV2Status": CriterionFV2Status, + "CriterionFV2Enabled": CriterionFV2Enabled, + "CriterionFV2RecoveryKeyType": CriterionFV2RecoveryKeyType, + "CriterionFV2IndividualKeyValidation": CriterionFV2IndividualKeyValidation, + "CriterionFV2PersonalRecoveryKey": CriterionFV2PersonalRecoveryKey, + "CriterionOSVersion": CriterionOSVersion, + "CriterionOSBuild": CriterionOSBuild, + "CriterionOSRapidSecurityResponse": CriterionOSRapidSecurityResponse, + "CriterionLastInventoryUpdate": CriterionLastInventoryUpdate, + "CriterionBootstrapTokenEscrowed": CriterionBootstrapTokenEscrowed, + "CriterionUserApprovedMDM": CriterionUserApprovedMDM, + "CriterionMDMProfileExpirationDate": CriterionMDMProfileExpirationDate, + "CriterionDDMEnabled": CriterionDDMEnabled, + "CriterionGatekeeper": CriterionGatekeeper, + "CriterionSIP": CriterionSIP, + "CriterionFirewallEnabled": CriterionFirewallEnabled, + "CriterionSupervised": CriterionSupervised, + "CriterionEnrollmentMethodPrestage": CriterionEnrollmentMethodPrestage, + "CriterionAppleSilicon": CriterionAppleSilicon, + "CriterionJamfBinaryVersion": CriterionJamfBinaryVersion, + } +} +``` + +- [ ] **Step 4: Run test to verify it passes** + +Run: `go test ./internal/smartgroup/ -run TestCriterion -v` +Expected: PASS (2 tests). + +- [ ] **Step 5: Commit** + +```bash +git add internal/smartgroup/criteria.go internal/smartgroup/criteria_test.go +git commit -m "feat(smartgroup): add JSS-verified criterion-name constants" +``` + +--- + +## Task 3: Library registry skeleton + +**Files:** +- Create: `internal/smartgroup/library.go` +- Create: `internal/smartgroup/library_test.go` + +- [ ] **Step 1: Write the failing test** + +Create `internal/smartgroup/library_test.go`: + +```go +// Copyright 2026, Jamf Software LLC + +package smartgroup + +import ( + "sort" + "testing" +) + +func TestLibraryEmptyByDefault(t *testing.T) { + _, ok := Lookup("nonexistent/slug") + if ok { + t.Fatal("expected Lookup of missing slug to return false") + } +} + +func TestRegisterDuplicateSlugPanics(t *testing.T) { + defer func() { + if r := recover(); r == nil { + t.Fatal("expected panic on duplicate slug, got none") + } + }() + tmpl := Template{Slug: "test/dup", Category: "test", Build: trivialBuild} + Register(tmpl) + defer Unregister("test/dup") + Register(tmpl) +} + +func TestCategoriesReturnsSortedUnique(t *testing.T) { + Register(Template{Slug: "alpha/one", Category: "alpha", Build: trivialBuild}) + defer Unregister("alpha/one") + Register(Template{Slug: "beta/one", Category: "beta", Build: trivialBuild}) + defer Unregister("beta/one") + Register(Template{Slug: "alpha/two", Category: "alpha", Build: trivialBuild}) + defer Unregister("alpha/two") + + got := Categories() + if !sort.StringsAreSorted(got) { + t.Fatalf("categories not sorted: %v", got) + } + foundAlpha, foundBeta := false, false + for _, c := range got { + if c == "alpha" { + foundAlpha = true + } + if c == "beta" { + foundBeta = true + } + } + if !foundAlpha || !foundBeta { + t.Fatalf("expected alpha and beta in %v", got) + } +} + +func trivialBuild(_ map[string]any) (SmartGroupRequest, error) { + return SmartGroupRequest{}, nil +} +``` + +- [ ] **Step 2: Run test to verify it fails** + +Run: `go test ./internal/smartgroup/ -run "TestLibrary|TestRegister|TestCategories" -v` +Expected: FAIL with "undefined: Lookup" / "undefined: Register" / "undefined: Categories". + +- [ ] **Step 3: Implement the registry** + +Create `internal/smartgroup/library.go`: + +```go +// Copyright 2026, Jamf Software LLC + +package smartgroup + +import ( + "fmt" + "sort" + "strings" + "sync" +) + +// library is the in-memory registry of all curated templates. +// Concrete templates are registered via init() in their category files +// (encryption.go, updates.go, etc.). +var ( + libraryMu sync.RWMutex + library = make(map[string]Template) +) + +// Register adds a template to the library. Panics on duplicate slug — +// duplicate slugs are a programming error, not a runtime condition. +func Register(t Template) { + libraryMu.Lock() + defer libraryMu.Unlock() + if _, exists := library[t.Slug]; exists { + panic(fmt.Sprintf("smartgroup: duplicate slug %q", t.Slug)) + } + library[t.Slug] = t +} + +// Unregister removes a template; used only in tests. +func Unregister(slug string) { + libraryMu.Lock() + defer libraryMu.Unlock() + delete(library, slug) +} + +// Lookup returns the template by slug. The second return value reports +// whether the slug exists in the library. +func Lookup(slug string) (Template, bool) { + libraryMu.RLock() + defer libraryMu.RUnlock() + t, ok := library[slug] + return t, ok +} + +// All returns all templates ordered first by category, then by slug. +func All() []Template { + libraryMu.RLock() + defer libraryMu.RUnlock() + out := make([]Template, 0, len(library)) + for _, t := range library { + out = append(out, t) + } + sort.Slice(out, func(i, j int) bool { + if out[i].Category != out[j].Category { + return out[i].Category < out[j].Category + } + return out[i].Slug < out[j].Slug + }) + return out +} + +// ByCategory returns templates in one category, sorted by slug. +func ByCategory(category string) []Template { + cat := strings.ToLower(category) + out := make([]Template, 0) + for _, t := range All() { + if t.Category == cat { + out = append(out, t) + } + } + return out +} + +// Categories returns the sorted, unique list of categories present in the library. +func Categories() []string { + libraryMu.RLock() + defer libraryMu.RUnlock() + seen := make(map[string]struct{}, len(library)) + for _, t := range library { + seen[t.Category] = struct{}{} + } + out := make([]string, 0, len(seen)) + for c := range seen { + out = append(out, c) + } + sort.Strings(out) + return out +} + +// FuzzyMatch returns slugs that are similar to the input — used by the CLI +// to suggest corrections on unknown-slug errors. Returns at most 3 matches. +func FuzzyMatch(input string) []string { + input = strings.ToLower(input) + all := All() + type scored struct { + slug string + score int + } + cands := make([]scored, 0, len(all)) + for _, t := range all { + score := simpleScore(strings.ToLower(t.Slug), input) + if score > 0 { + cands = append(cands, scored{t.Slug, score}) + } + } + sort.Slice(cands, func(i, j int) bool { return cands[i].score > cands[j].score }) + out := make([]string, 0, 3) + for i := 0; i < len(cands) && i < 3; i++ { + out = append(out, cands[i].slug) + } + return out +} + +func simpleScore(a, b string) int { + if strings.Contains(a, b) { + return 100 - len(a) + } + common := 0 + for i := 0; i < len(a) && i < len(b); i++ { + if a[i] == b[i] { + common++ + } + } + return common +} +``` + +- [ ] **Step 4: Run test to verify it passes** + +Run: `go test ./internal/smartgroup/ -run "TestLibrary|TestRegister|TestCategories" -v` +Expected: PASS (3 tests). + +- [ ] **Step 5: Commit** + +```bash +git add internal/smartgroup/library.go internal/smartgroup/library_test.go +git commit -m "feat(smartgroup): add template Library registry with Register/Lookup/All/ByCategory/Categories/FuzzyMatch" +``` + +--- + +## Task 4: Encryption category (6 templates) with golden tests + +**Files:** +- Create: `internal/smartgroup/encryption.go` +- Modify: `internal/smartgroup/library_test.go` (append 6 golden tests + 1 default-opts variant) + +- [ ] **Step 1: Write the failing tests** + +Append to `internal/smartgroup/library_test.go`: + +```go + +// ─── Encryption category golden tests ────────────────────────────────────── + +func TestEncryption_NotEncrypted_Golden(t *testing.T) { + tmpl, ok := Lookup("encryption/not-encrypted") + if !ok { + t.Fatal("template encryption/not-encrypted not registered") + } + req, err := tmpl.Build(map[string]any{}) + if err != nil { + t.Fatalf("Build failed: %v", err) + } + if len(req.Criteria) != 1 { + t.Fatalf("expected 1 criterion, got %d", len(req.Criteria)) + } + c := req.Criteria[0] + if c.Name != "FileVault 2 Status" || c.SearchType != "is" || c.Value != "Not Encrypted" { + t.Fatalf("unexpected criterion: %+v", c) + } +} + +func TestEncryption_InvalidRecoveryKey_Golden(t *testing.T) { + tmpl, _ := Lookup("encryption/invalid-recovery-key") + req, err := tmpl.Build(map[string]any{}) + if err != nil { + t.Fatalf("Build failed: %v", err) + } + c := req.Criteria[0] + if c.Name != "FileVault 2 Individual Key Validation" || c.SearchType != "is" || c.Value != "Not Valid" { + t.Fatalf("unexpected criterion: %+v", c) + } +} + +func TestEncryption_EscrowMissing_Golden(t *testing.T) { + tmpl, _ := Lookup("encryption/escrow-missing") + req, err := tmpl.Build(map[string]any{}) + if err != nil { + t.Fatalf("Build failed: %v", err) + } + c := req.Criteria[0] + if c.Name != "FileVault 2 Recovery Key Type" || c.SearchType != "is" || c.Value != "" { + t.Fatalf("unexpected criterion: %+v", c) + } +} + +func TestEncryption_IRKOnlyDeprecated_Golden(t *testing.T) { + tmpl, _ := Lookup("encryption/irk-only-deprecated") + req, err := tmpl.Build(map[string]any{}) + if err != nil { + t.Fatalf("Build failed: %v", err) + } + c := req.Criteria[0] + if c.Name != "FileVault 2 Recovery Key Type" || c.SearchType != "is" || c.Value != "Institutional" { + t.Fatalf("unexpected criterion: %+v", c) + } +} + +func TestEncryption_EncryptionStalled_GoldenDefault(t *testing.T) { + tmpl, _ := Lookup("encryption/encryption-stalled") + opts, err := tmpl.ResolveOpts(map[string]any{}) + if err != nil { + t.Fatalf("ResolveOpts: %v", err) + } + req, err := tmpl.Build(opts) + if err != nil { + t.Fatalf("Build: %v", err) + } + if len(req.Criteria) != 2 { + t.Fatalf("expected 2 criteria, got %d", len(req.Criteria)) + } + if req.Criteria[1].Value != "7" { + t.Fatalf("expected default 7 days, got %q", req.Criteria[1].Value) + } +} + +func TestEncryption_EncryptionStalled_GoldenCustom(t *testing.T) { + tmpl, _ := Lookup("encryption/encryption-stalled") + opts, err := tmpl.ResolveOpts(map[string]any{"stalled-after": 14}) + if err != nil { + t.Fatalf("ResolveOpts: %v", err) + } + req, err := tmpl.Build(opts) + if err != nil { + t.Fatalf("Build: %v", err) + } + if req.Criteria[1].Value != "14" { + t.Fatalf("expected 14 days, got %q", req.Criteria[1].Value) + } +} + +func TestEncryption_FVIneligible_Golden(t *testing.T) { + tmpl, _ := Lookup("encryption/fv-ineligible") + req, err := tmpl.Build(map[string]any{}) + if err != nil { + t.Fatalf("Build: %v", err) + } + c := req.Criteria[0] + if c.Name != "FileVault 2 Status" || c.SearchType != "is" || c.Value != "N/A" { + t.Fatalf("unexpected criterion: %+v", c) + } +} +``` + +- [ ] **Step 2: Run tests to verify they fail** + +Run: `go test ./internal/smartgroup/ -run TestEncryption -v` +Expected: FAIL — every test reports "template encryption/X not registered". + +- [ ] **Step 3: Implement the encryption category** + +Create `internal/smartgroup/encryption.go`: + +```go +// Copyright 2026, Jamf Software LLC + +package smartgroup + +import "fmt" + +func init() { + Register(encryptionNotEncrypted()) + Register(encryptionInvalidRecoveryKey()) + Register(encryptionEscrowMissing()) + Register(encryptionIRKOnlyDeprecated()) + Register(encryptionEncryptionStalled()) + Register(encryptionFVIneligible()) +} + +func encryptionNotEncrypted() Template { + return Template{ + Slug: "encryption/not-encrypted", + Category: "encryption", + Description: "Macs where FileVault 2 is not enabled", + Build: func(_ map[string]any) (SmartGroupRequest, error) { + return SmartGroupRequest{ + Description: "Auto-generated by jamf-cli (template: encryption/not-encrypted)", + Criteria: []Criterion{ + {AndOr: "and", Priority: 0, Name: CriterionFV2Status, SearchType: "is", Value: "Not Encrypted"}, + }, + }, nil + }, + } +} + +func encryptionInvalidRecoveryKey() Template { + return Template{ + Slug: "encryption/invalid-recovery-key", + Category: "encryption", + Description: "Macs with an INVALID escrowed recovery key (cannot unlock)", + Build: func(_ map[string]any) (SmartGroupRequest, error) { + return SmartGroupRequest{ + Description: "Auto-generated by jamf-cli (template: encryption/invalid-recovery-key)", + Criteria: []Criterion{ + {AndOr: "and", Priority: 0, Name: CriterionFV2IndividualKeyValidation, SearchType: "is", Value: "Not Valid"}, + }, + }, nil + }, + } +} + +func encryptionEscrowMissing() Template { + return Template{ + Slug: "encryption/escrow-missing", + Category: "encryption", + Description: "Macs without any escrowed recovery key", + Build: func(_ map[string]any) (SmartGroupRequest, error) { + return SmartGroupRequest{ + Description: "Auto-generated by jamf-cli (template: encryption/escrow-missing)", + Criteria: []Criterion{ + {AndOr: "and", Priority: 0, Name: CriterionFV2RecoveryKeyType, SearchType: "is", Value: ""}, + }, + }, nil + }, + } +} + +func encryptionIRKOnlyDeprecated() Template { + return Template{ + Slug: "encryption/irk-only-deprecated", + Category: "encryption", + Description: "Macs on the deprecated Institutional Recovery Key", + Build: func(_ map[string]any) (SmartGroupRequest, error) { + return SmartGroupRequest{ + Description: "Auto-generated by jamf-cli (template: encryption/irk-only-deprecated). IRK is deprecated for managed Macs; migrate to Personal Recovery Key escrow.", + Criteria: []Criterion{ + {AndOr: "and", Priority: 0, Name: CriterionFV2RecoveryKeyType, SearchType: "is", Value: "Institutional"}, + }, + }, nil + }, + } +} + +func encryptionEncryptionStalled() Template { + return Template{ + Slug: "encryption/encryption-stalled", + Category: "encryption", + Description: "Macs stuck mid-encryption (no inventory update in N days)", + Params: []ParamSpec{ + {Name: "stalled-after", Type: "int", Default: 7, Description: "Days since last inventory update"}, + }, + Build: func(opts map[string]any) (SmartGroupRequest, error) { + days, ok := opts["stalled-after"].(int) + if !ok { + return SmartGroupRequest{}, fmt.Errorf("expected int stalled-after, got %T", opts["stalled-after"]) + } + return SmartGroupRequest{ + Description: fmt.Sprintf("Auto-generated by jamf-cli (template: encryption/encryption-stalled, stalled-after=%d)", days), + Criteria: []Criterion{ + {AndOr: "and", Priority: 0, Name: CriterionFV2Status, SearchType: "is not", Value: "All Partitions Encrypted"}, + {AndOr: "and", Priority: 1, Name: CriterionLastInventoryUpdate, SearchType: "more than x days ago", Value: fmt.Sprintf("%d", days)}, + }, + }, nil + }, + } +} + +func encryptionFVIneligible() Template { + return Template{ + Slug: "encryption/fv-ineligible", + Category: "encryption", + Description: `Macs reporting FileVault 2 Status of "N/A" (ineligible hardware or never collected)`, + Build: func(_ map[string]any) (SmartGroupRequest, error) { + return SmartGroupRequest{ + Description: "Auto-generated by jamf-cli (template: encryption/fv-ineligible)", + Criteria: []Criterion{ + {AndOr: "and", Priority: 0, Name: CriterionFV2Status, SearchType: "is", Value: "N/A"}, + }, + }, nil + }, + } +} +``` + +- [ ] **Step 4: Run tests to verify they pass** + +Run: `go test ./internal/smartgroup/ -run TestEncryption -v` +Expected: PASS (7 tests). + +- [ ] **Step 5: Commit** + +```bash +git add internal/smartgroup/encryption.go internal/smartgroup/library_test.go +git commit -m "feat(smartgroup): add 6 encryption templates" +``` + +--- + +## Task 5: Software-updates category (4 templates) with golden tests + +**Files:** +- Create: `internal/smartgroup/updates.go` +- Modify: `internal/smartgroup/library_test.go` (append golden tests) + +- [ ] **Step 1: Write the failing tests** + +Append to `internal/smartgroup/library_test.go`: + +```go + +// ─── Updates category golden tests ───────────────────────────────────────── + +func TestUpdates_OSVersionBelow_Golden(t *testing.T) { + tmpl, ok := Lookup("updates/os-version-below") + if !ok { + t.Fatal("template updates/os-version-below not registered") + } + if _, err := tmpl.ResolveOpts(map[string]any{}); err == nil { + t.Fatal("expected error for missing required --below-version") + } + opts, err := tmpl.ResolveOpts(map[string]any{"below-version": "15.4"}) + if err != nil { + t.Fatalf("ResolveOpts: %v", err) + } + req, err := tmpl.Build(opts) + if err != nil { + t.Fatalf("Build: %v", err) + } + c := req.Criteria[0] + if c.Name != "Operating System Version" || c.SearchType != "less than" || c.Value != "15.4" { + t.Fatalf("unexpected criterion: %+v", c) + } +} + +func TestUpdates_MajorVersionBehind_Golden(t *testing.T) { + tmpl, _ := Lookup("updates/major-version-behind") + opts, err := tmpl.ResolveOpts(map[string]any{"major-below": 15}) + if err != nil { + t.Fatalf("ResolveOpts: %v", err) + } + req, err := tmpl.Build(opts) + if err != nil { + t.Fatalf("Build: %v", err) + } + c := req.Criteria[0] + if c.Name != "Operating System Version" || c.SearchType != "less than" || c.Value != "15.0" { + t.Fatalf("unexpected criterion: %+v", c) + } +} + +func TestUpdates_RSRNotApplied_Golden(t *testing.T) { + tmpl, _ := Lookup("updates/rsr-not-applied") + req, err := tmpl.Build(map[string]any{}) + if err != nil { + t.Fatalf("Build: %v", err) + } + c := req.Criteria[0] + if c.Name != "Operating System Rapid Security Response" || c.SearchType != "is" || c.Value != "" { + t.Fatalf("unexpected criterion: %+v", c) + } +} + +func TestUpdates_BetaOS_Golden(t *testing.T) { + tmpl, _ := Lookup("updates/beta-os") + req, err := tmpl.Build(map[string]any{}) + if err != nil { + t.Fatalf("Build: %v", err) + } + c := req.Criteria[0] + if c.Name != "Operating System Version" || c.SearchType != "like" || c.Value != "Beta" { + t.Fatalf("unexpected criterion: %+v", c) + } +} +``` + +- [ ] **Step 2: Run tests to verify they fail** + +Run: `go test ./internal/smartgroup/ -run TestUpdates -v` +Expected: FAIL. + +- [ ] **Step 3: Implement the updates category** + +Create `internal/smartgroup/updates.go`: + +```go +// Copyright 2026, Jamf Software LLC + +package smartgroup + +import "fmt" + +func init() { + Register(updatesOSVersionBelow()) + Register(updatesMajorVersionBehind()) + Register(updatesRSRNotApplied()) + Register(updatesBetaOS()) +} + +func updatesOSVersionBelow() Template { + return Template{ + Slug: "updates/os-version-below", + Category: "updates", + Description: "Macs running OS older than a specific version", + Params: []ParamSpec{ + {Name: "below-version", Type: "version", Description: "macOS version threshold (e.g. 15.4)", Required: true}, + }, + Build: func(opts map[string]any) (SmartGroupRequest, error) { + v, ok := opts["below-version"].(string) + if !ok { + return SmartGroupRequest{}, fmt.Errorf("expected string below-version, got %T", opts["below-version"]) + } + return SmartGroupRequest{ + Description: fmt.Sprintf("Auto-generated by jamf-cli (template: updates/os-version-below, below-version=%s)", v), + Criteria: []Criterion{ + {AndOr: "and", Priority: 0, Name: CriterionOSVersion, SearchType: "less than", Value: v}, + }, + }, nil + }, + } +} + +func updatesMajorVersionBehind() Template { + return Template{ + Slug: "updates/major-version-behind", + Category: "updates", + Description: "Macs behind a major macOS version (e.g. all running <15.x)", + Params: []ParamSpec{ + {Name: "major-below", Type: "int", Description: "Major macOS version threshold", Required: true}, + }, + Build: func(opts map[string]any) (SmartGroupRequest, error) { + n, ok := opts["major-below"].(int) + if !ok { + return SmartGroupRequest{}, fmt.Errorf("expected int major-below, got %T", opts["major-below"]) + } + return SmartGroupRequest{ + Description: fmt.Sprintf("Auto-generated by jamf-cli (template: updates/major-version-behind, major-below=%d)", n), + Criteria: []Criterion{ + {AndOr: "and", Priority: 0, Name: CriterionOSVersion, SearchType: "less than", Value: fmt.Sprintf("%d.0", n)}, + }, + }, nil + }, + } +} + +func updatesRSRNotApplied() Template { + return Template{ + Slug: "updates/rsr-not-applied", + Category: "updates", + Description: "Macs with no Rapid Security Response applied", + Build: func(_ map[string]any) (SmartGroupRequest, error) { + return SmartGroupRequest{ + Description: "Auto-generated by jamf-cli (template: updates/rsr-not-applied)", + Criteria: []Criterion{ + {AndOr: "and", Priority: 0, Name: CriterionOSRapidSecurityResponse, SearchType: "is", Value: ""}, + }, + }, nil + }, + } +} + +func updatesBetaOS() Template { + return Template{ + Slug: "updates/beta-os", + Category: "updates", + Description: `Macs whose OS version contains "Beta"`, + Build: func(_ map[string]any) (SmartGroupRequest, error) { + return SmartGroupRequest{ + Description: "Auto-generated by jamf-cli (template: updates/beta-os)", + Criteria: []Criterion{ + {AndOr: "and", Priority: 0, Name: CriterionOSVersion, SearchType: "like", Value: "Beta"}, + }, + }, nil + }, + } +} +``` + +- [ ] **Step 4: Run tests to verify they pass** + +Run: `go test ./internal/smartgroup/ -run TestUpdates -v` +Expected: PASS (4 tests). + +- [ ] **Step 5: Commit** + +```bash +git add internal/smartgroup/updates.go internal/smartgroup/library_test.go +git commit -m "feat(smartgroup): add 4 software-update templates" +``` + +--- + +## Task 6: MDM-health category (5 templates) with golden tests + +**Files:** +- Create: `internal/smartgroup/mdm.go` +- Modify: `internal/smartgroup/library_test.go` (append golden tests) + +- [ ] **Step 1: Write the failing tests** + +Append to `internal/smartgroup/library_test.go`: + +```go + +// ─── MDM-health category golden tests ────────────────────────────────────── + +func TestMDM_BootstrapTokenMissing_Golden(t *testing.T) { + tmpl, _ := Lookup("mdm/bootstrap-token-missing") + req, err := tmpl.Build(map[string]any{}) + if err != nil { + t.Fatalf("Build: %v", err) + } + c := req.Criteria[0] + if c.Name != "Bootstrap Token Escrowed" || c.SearchType != "is" || c.Value != "No" { + t.Fatalf("unexpected criterion: %+v", c) + } +} + +func TestMDM_UserApprovedMDMNo_Golden(t *testing.T) { + tmpl, _ := Lookup("mdm/user-approved-mdm-no") + req, err := tmpl.Build(map[string]any{}) + if err != nil { + t.Fatalf("Build: %v", err) + } + c := req.Criteria[0] + if c.Name != "User Approved MDM" || c.SearchType != "is" || c.Value != "No" { + t.Fatalf("unexpected criterion: %+v", c) + } +} + +func TestMDM_StaleCheckin_GoldenDefault(t *testing.T) { + tmpl, _ := Lookup("mdm/stale-checkin") + opts, err := tmpl.ResolveOpts(map[string]any{}) + if err != nil { + t.Fatalf("ResolveOpts: %v", err) + } + req, err := tmpl.Build(opts) + if err != nil { + t.Fatalf("Build: %v", err) + } + c := req.Criteria[0] + if c.Name != "Last Inventory Update" || c.SearchType != "more than x days ago" || c.Value != "7" { + t.Fatalf("unexpected criterion: %+v", c) + } +} + +func TestMDM_MDMCertExpiring_GoldenDefault(t *testing.T) { + tmpl, _ := Lookup("mdm/mdm-cert-expiring") + opts, err := tmpl.ResolveOpts(map[string]any{}) + if err != nil { + t.Fatalf("ResolveOpts: %v", err) + } + req, err := tmpl.Build(opts) + if err != nil { + t.Fatalf("Build: %v", err) + } + c := req.Criteria[0] + if c.Name != "MDM Profile Expiration Date" || c.SearchType != "less than x days from now" || c.Value != "30" { + t.Fatalf("unexpected criterion: %+v", c) + } +} + +func TestMDM_DDMDisabled_Golden(t *testing.T) { + tmpl, _ := Lookup("mdm/declarative-management-disabled") + req, err := tmpl.Build(map[string]any{}) + if err != nil { + t.Fatalf("Build: %v", err) + } + c := req.Criteria[0] + if c.Name != "Declarative Device Management Enabled" || c.SearchType != "is" || c.Value != "No" { + t.Fatalf("unexpected criterion: %+v", c) + } +} +``` + +- [ ] **Step 2: Run tests to verify they fail** + +Run: `go test ./internal/smartgroup/ -run TestMDM -v` +Expected: FAIL. + +- [ ] **Step 3: Implement the MDM-health category** + +Create `internal/smartgroup/mdm.go`: + +```go +// Copyright 2026, Jamf Software LLC + +package smartgroup + +import "fmt" + +func init() { + Register(mdmBootstrapTokenMissing()) + Register(mdmUserApprovedMDMNo()) + Register(mdmStaleCheckin()) + Register(mdmMDMCertExpiring()) + Register(mdmDDMDisabled()) +} + +func mdmBootstrapTokenMissing() Template { + return Template{ + Slug: "mdm/bootstrap-token-missing", + Category: "mdm", + Description: "Macs without an escrowed bootstrap token", + Build: func(_ map[string]any) (SmartGroupRequest, error) { + return SmartGroupRequest{ + Description: "Auto-generated by jamf-cli (template: mdm/bootstrap-token-missing)", + Criteria: []Criterion{ + {AndOr: "and", Priority: 0, Name: CriterionBootstrapTokenEscrowed, SearchType: "is", Value: "No"}, + }, + }, nil + }, + } +} + +func mdmUserApprovedMDMNo() Template { + return Template{ + Slug: "mdm/user-approved-mdm-no", + Category: "mdm", + Description: "Macs without User Approved MDM", + Build: func(_ map[string]any) (SmartGroupRequest, error) { + return SmartGroupRequest{ + Description: "Auto-generated by jamf-cli (template: mdm/user-approved-mdm-no)", + Criteria: []Criterion{ + {AndOr: "and", Priority: 0, Name: CriterionUserApprovedMDM, SearchType: "is", Value: "No"}, + }, + }, nil + }, + } +} + +func mdmStaleCheckin() Template { + return Template{ + Slug: "mdm/stale-checkin", + Category: "mdm", + Description: "Macs whose last inventory update is older than N days", + Params: []ParamSpec{ + {Name: "days", Type: "int", Default: 7, Description: "Days since last inventory update"}, + }, + Build: func(opts map[string]any) (SmartGroupRequest, error) { + n, ok := opts["days"].(int) + if !ok { + return SmartGroupRequest{}, fmt.Errorf("expected int days, got %T", opts["days"]) + } + return SmartGroupRequest{ + Description: fmt.Sprintf("Auto-generated by jamf-cli (template: mdm/stale-checkin, days=%d)", n), + Criteria: []Criterion{ + {AndOr: "and", Priority: 0, Name: CriterionLastInventoryUpdate, SearchType: "more than x days ago", Value: fmt.Sprintf("%d", n)}, + }, + }, nil + }, + } +} + +func mdmMDMCertExpiring() Template { + return Template{ + Slug: "mdm/mdm-cert-expiring", + Category: "mdm", + Description: "Macs whose MDM profile expires within N days", + Params: []ParamSpec{ + {Name: "within-days", Type: "int", Default: 30, Description: "Days from now"}, + }, + Build: func(opts map[string]any) (SmartGroupRequest, error) { + n, ok := opts["within-days"].(int) + if !ok { + return SmartGroupRequest{}, fmt.Errorf("expected int within-days, got %T", opts["within-days"]) + } + return SmartGroupRequest{ + Description: fmt.Sprintf("Auto-generated by jamf-cli (template: mdm/mdm-cert-expiring, within-days=%d)", n), + Criteria: []Criterion{ + {AndOr: "and", Priority: 0, Name: CriterionMDMProfileExpirationDate, SearchType: "less than x days from now", Value: fmt.Sprintf("%d", n)}, + }, + }, nil + }, + } +} + +func mdmDDMDisabled() Template { + return Template{ + Slug: "mdm/declarative-management-disabled", + Category: "mdm", + Description: "Macs without Declarative Device Management enabled", + Build: func(_ map[string]any) (SmartGroupRequest, error) { + return SmartGroupRequest{ + Description: "Auto-generated by jamf-cli (template: mdm/declarative-management-disabled)", + Criteria: []Criterion{ + {AndOr: "and", Priority: 0, Name: CriterionDDMEnabled, SearchType: "is", Value: "No"}, + }, + }, nil + }, + } +} +``` + +- [ ] **Step 4: Run tests to verify they pass** + +Run: `go test ./internal/smartgroup/ -run TestMDM -v` +Expected: PASS (5 tests). + +- [ ] **Step 5: Commit** + +```bash +git add internal/smartgroup/mdm.go internal/smartgroup/library_test.go +git commit -m "feat(smartgroup): add 5 MDM-health templates" +``` + +--- + +## Task 7: Compliance category (4 templates) with golden tests + +**Files:** +- Create: `internal/smartgroup/compliance.go` +- Modify: `internal/smartgroup/library_test.go` (append golden tests) + +- [ ] **Step 1: Write the failing tests** + +Append to `internal/smartgroup/library_test.go`: + +```go + +// ─── Compliance category golden tests ────────────────────────────────────── + +func TestCompliance_GatekeeperDisabled_Golden(t *testing.T) { + tmpl, _ := Lookup("compliance/gatekeeper-disabled") + req, err := tmpl.Build(map[string]any{}) + if err != nil { + t.Fatalf("Build: %v", err) + } + c := req.Criteria[0] + if c.Name != "Gatekeeper" || c.SearchType != "is" || c.Value != "Disabled" { + t.Fatalf("unexpected criterion: %+v", c) + } +} + +func TestCompliance_SIPDisabled_Golden(t *testing.T) { + tmpl, _ := Lookup("compliance/sip-disabled") + req, err := tmpl.Build(map[string]any{}) + if err != nil { + t.Fatalf("Build: %v", err) + } + c := req.Criteria[0] + if c.Name != "System Integrity Protection" || c.SearchType != "is" || c.Value != "Disabled" { + t.Fatalf("unexpected criterion: %+v", c) + } +} + +func TestCompliance_FirewallDisabled_Golden(t *testing.T) { + tmpl, _ := Lookup("compliance/firewall-disabled") + req, err := tmpl.Build(map[string]any{}) + if err != nil { + t.Fatalf("Build: %v", err) + } + c := req.Criteria[0] + if c.Name != "Firewall Enabled" || c.SearchType != "is" || c.Value != "No" { + t.Fatalf("unexpected criterion: %+v", c) + } +} + +func TestCompliance_NonCompliantBaseline_Golden(t *testing.T) { + tmpl, _ := Lookup("compliance/non-compliant-baseline") + req, err := tmpl.Build(map[string]any{}) + if err != nil { + t.Fatalf("Build: %v", err) + } + if len(req.Criteria) != 4 { + t.Fatalf("expected 4 criteria, got %d", len(req.Criteria)) + } + for i, c := range req.Criteria { + if i == 0 && c.AndOr != "and" { + t.Errorf("first criterion should be 'and', got %q", c.AndOr) + } + if i > 0 && c.AndOr != "or" { + t.Errorf("criterion %d should be 'or', got %q", i, c.AndOr) + } + } +} +``` + +- [ ] **Step 2: Run tests to verify they fail** + +Run: `go test ./internal/smartgroup/ -run TestCompliance -v` +Expected: FAIL. + +- [ ] **Step 3: Implement the compliance category** + +Create `internal/smartgroup/compliance.go`: + +```go +// Copyright 2026, Jamf Software LLC + +package smartgroup + +func init() { + Register(complianceGatekeeperDisabled()) + Register(complianceSIPDisabled()) + Register(complianceFirewallDisabled()) + Register(complianceNonCompliantBaseline()) +} + +func complianceGatekeeperDisabled() Template { + return Template{ + Slug: "compliance/gatekeeper-disabled", + Category: "compliance", + Description: "Macs with Gatekeeper disabled", + Build: func(_ map[string]any) (SmartGroupRequest, error) { + return SmartGroupRequest{ + Description: "Auto-generated by jamf-cli (template: compliance/gatekeeper-disabled)", + Criteria: []Criterion{ + {AndOr: "and", Priority: 0, Name: CriterionGatekeeper, SearchType: "is", Value: "Disabled"}, + }, + }, nil + }, + } +} + +func complianceSIPDisabled() Template { + return Template{ + Slug: "compliance/sip-disabled", + Category: "compliance", + Description: "Macs with System Integrity Protection disabled", + Build: func(_ map[string]any) (SmartGroupRequest, error) { + return SmartGroupRequest{ + Description: "Auto-generated by jamf-cli (template: compliance/sip-disabled)", + Criteria: []Criterion{ + {AndOr: "and", Priority: 0, Name: CriterionSIP, SearchType: "is", Value: "Disabled"}, + }, + }, nil + }, + } +} + +func complianceFirewallDisabled() Template { + return Template{ + Slug: "compliance/firewall-disabled", + Category: "compliance", + Description: "Macs with the application firewall disabled", + Build: func(_ map[string]any) (SmartGroupRequest, error) { + return SmartGroupRequest{ + Description: "Auto-generated by jamf-cli (template: compliance/firewall-disabled)", + Criteria: []Criterion{ + {AndOr: "and", Priority: 0, Name: CriterionFirewallEnabled, SearchType: "is", Value: "No"}, + }, + }, nil + }, + } +} + +func complianceNonCompliantBaseline() Template { + return Template{ + Slug: "compliance/non-compliant-baseline", + Category: "compliance", + Description: "Composite: any of FV2 disabled, SIP disabled, Gatekeeper disabled, Firewall disabled", + Build: func(_ map[string]any) (SmartGroupRequest, error) { + return SmartGroupRequest{ + Description: "Auto-generated by jamf-cli (template: compliance/non-compliant-baseline). OR-composite across four security primitives.", + Criteria: []Criterion{ + {AndOr: "and", Priority: 0, Name: CriterionFV2Enabled, SearchType: "is", Value: "Not Enabled"}, + {AndOr: "or", Priority: 1, Name: CriterionSIP, SearchType: "is", Value: "Disabled"}, + {AndOr: "or", Priority: 2, Name: CriterionGatekeeper, SearchType: "is", Value: "Disabled"}, + {AndOr: "or", Priority: 3, Name: CriterionFirewallEnabled, SearchType: "is", Value: "No"}, + }, + }, nil + }, + } +} +``` + +- [ ] **Step 4: Run tests to verify they pass** + +Run: `go test ./internal/smartgroup/ -run TestCompliance -v` +Expected: PASS (4 tests). + +- [ ] **Step 5: Commit** + +```bash +git add internal/smartgroup/compliance.go internal/smartgroup/library_test.go +git commit -m "feat(smartgroup): add 4 compliance-basics templates" +``` + +--- + +## Task 8: Lifecycle category (4 templates) with golden tests + +**Files:** +- Create: `internal/smartgroup/lifecycle.go` +- Modify: `internal/smartgroup/library_test.go` (append golden tests) + +- [ ] **Step 1: Write the failing tests** + +Append to `internal/smartgroup/library_test.go`: + +```go + +// ─── Lifecycle category golden tests ─────────────────────────────────────── + +func TestLifecycle_Unsupervised_Golden(t *testing.T) { + tmpl, _ := Lookup("lifecycle/unsupervised") + req, err := tmpl.Build(map[string]any{}) + if err != nil { + t.Fatalf("Build: %v", err) + } + c := req.Criteria[0] + if c.Name != "Supervised" || c.SearchType != "is" || c.Value != "No" { + t.Fatalf("unexpected criterion: %+v", c) + } +} + +func TestLifecycle_ADEEnrolled_Golden(t *testing.T) { + tmpl, _ := Lookup("lifecycle/ade-enrolled") + req, err := tmpl.Build(map[string]any{}) + if err != nil { + t.Fatalf("Build: %v", err) + } + c := req.Criteria[0] + if c.Name != "Enrollment Method: PreStage enrollment" || c.SearchType != "is" || c.Value != "Yes" { + t.Fatalf("unexpected criterion: %+v", c) + } +} + +func TestLifecycle_JamfBinaryOutdated_Golden(t *testing.T) { + tmpl, _ := Lookup("lifecycle/jamf-binary-outdated") + if _, err := tmpl.ResolveOpts(map[string]any{}); err == nil { + t.Fatal("expected error for missing required --below-version") + } + opts, err := tmpl.ResolveOpts(map[string]any{"below-version": "11.0.0"}) + if err != nil { + t.Fatalf("ResolveOpts: %v", err) + } + req, err := tmpl.Build(opts) + if err != nil { + t.Fatalf("Build: %v", err) + } + c := req.Criteria[0] + if c.Name != "Jamf Binary Version" || c.SearchType != "less than" || c.Value != "11.0.0" { + t.Fatalf("unexpected criterion: %+v", c) + } +} + +func TestLifecycle_FVIneligibleHardware_Golden(t *testing.T) { + tmpl, _ := Lookup("lifecycle/fv-ineligible-hardware") + req, err := tmpl.Build(map[string]any{}) + if err != nil { + t.Fatalf("Build: %v", err) + } + if len(req.Criteria) != 2 { + t.Fatalf("expected 2 criteria, got %d", len(req.Criteria)) + } + if req.Criteria[0].Name != "FileVault 2 Status" || req.Criteria[0].Value != "N/A" { + t.Fatalf("unexpected criterion 0: %+v", req.Criteria[0]) + } + if req.Criteria[1].Name != "Apple Silicon" || req.Criteria[1].Value != "No" { + t.Fatalf("unexpected criterion 1: %+v", req.Criteria[1]) + } +} +``` + +- [ ] **Step 2: Run tests to verify they fail** + +Run: `go test ./internal/smartgroup/ -run TestLifecycle -v` +Expected: FAIL. + +- [ ] **Step 3: Implement the lifecycle category** + +Create `internal/smartgroup/lifecycle.go`: + +```go +// Copyright 2026, Jamf Software LLC + +package smartgroup + +import "fmt" + +func init() { + Register(lifecycleUnsupervised()) + Register(lifecycleADEEnrolled()) + Register(lifecycleJamfBinaryOutdated()) + Register(lifecycleFVIneligibleHardware()) +} + +func lifecycleUnsupervised() Template { + return Template{ + Slug: "lifecycle/unsupervised", + Category: "lifecycle", + Description: "Macs that are not supervised", + Build: func(_ map[string]any) (SmartGroupRequest, error) { + return SmartGroupRequest{ + Description: "Auto-generated by jamf-cli (template: lifecycle/unsupervised)", + Criteria: []Criterion{ + {AndOr: "and", Priority: 0, Name: CriterionSupervised, SearchType: "is", Value: "No"}, + }, + }, nil + }, + } +} + +func lifecycleADEEnrolled() Template { + return Template{ + Slug: "lifecycle/ade-enrolled", + Category: "lifecycle", + Description: "Macs enrolled via Automated Device Enrollment (PreStage)", + Build: func(_ map[string]any) (SmartGroupRequest, error) { + return SmartGroupRequest{ + Description: "Auto-generated by jamf-cli (template: lifecycle/ade-enrolled)", + Criteria: []Criterion{ + {AndOr: "and", Priority: 0, Name: CriterionEnrollmentMethodPrestage, SearchType: "is", Value: "Yes"}, + }, + }, nil + }, + } +} + +func lifecycleJamfBinaryOutdated() Template { + return Template{ + Slug: "lifecycle/jamf-binary-outdated", + Category: "lifecycle", + Description: "Macs running an outdated Jamf binary", + Params: []ParamSpec{ + {Name: "below-version", Type: "version", Description: "Jamf binary version threshold (e.g. 11.0.0)", Required: true}, + }, + Build: func(opts map[string]any) (SmartGroupRequest, error) { + v, ok := opts["below-version"].(string) + if !ok { + return SmartGroupRequest{}, fmt.Errorf("expected string below-version, got %T", opts["below-version"]) + } + return SmartGroupRequest{ + Description: fmt.Sprintf("Auto-generated by jamf-cli (template: lifecycle/jamf-binary-outdated, below-version=%s)", v), + Criteria: []Criterion{ + {AndOr: "and", Priority: 0, Name: CriterionJamfBinaryVersion, SearchType: "less than", Value: v}, + }, + }, nil + }, + } +} + +func lifecycleFVIneligibleHardware() Template { + return Template{ + Slug: "lifecycle/fv-ineligible-hardware", + Category: "lifecycle", + Description: "Intel Macs reporting FileVault 2 Status N/A (hardware-refresh candidates)", + Build: func(_ map[string]any) (SmartGroupRequest, error) { + return SmartGroupRequest{ + Description: "Auto-generated by jamf-cli (template: lifecycle/fv-ineligible-hardware)", + Criteria: []Criterion{ + {AndOr: "and", Priority: 0, Name: CriterionFV2Status, SearchType: "is", Value: "N/A"}, + {AndOr: "and", Priority: 1, Name: CriterionAppleSilicon, SearchType: "is", Value: "No"}, + }, + }, nil + }, + } +} +``` + +- [ ] **Step 4: Run tests to verify they pass** + +Run: `go test ./internal/smartgroup/ -run TestLifecycle -v` +Expected: PASS (4 tests). + +- [ ] **Step 5: Commit** + +```bash +git add internal/smartgroup/lifecycle.go internal/smartgroup/library_test.go +git commit -m "feat(smartgroup): add 4 lifecycle-hygiene templates" +``` + +--- + +## Task 9: Whole-library integration tests + +**Files:** +- Modify: `internal/smartgroup/library_test.go` (add 4 integration tests + import "strings") + +- [ ] **Step 1: Write the failing tests** + +Add `"strings"` to the imports at the top of `library_test.go` if not already present. + +Append to `library_test.go`: + +```go + +// ─── Whole-library integration tests ─────────────────────────────────────── + +func TestLibrary_ExactlyTwentyThreeTemplates(t *testing.T) { + got := len(All()) + const want = 23 + if got != want { + t.Fatalf("expected %d templates registered, got %d", want, got) + } +} + +func TestLibrary_AllCategoriesPresent(t *testing.T) { + want := map[string]int{ + "encryption": 6, + "updates": 4, + "mdm": 5, + "compliance": 4, + "lifecycle": 4, + } + got := make(map[string]int) + for _, tt := range All() { + got[tt.Category]++ + } + for cat, n := range want { + if got[cat] != n { + t.Errorf("category %s: expected %d templates, got %d", cat, n, got[cat]) + } + } +} + +func TestLibrary_EveryTemplateProducesValidCriteria(t *testing.T) { + known := allCriterionConsts() + knownValues := make(map[string]struct{}, len(known)) + for _, v := range known { + knownValues[v] = struct{}{} + } + for _, tmpl := range All() { + t.Run(tmpl.Slug, func(t *testing.T) { + opts, err := tmpl.ResolveOpts(defaultOptsForTest(tmpl)) + if err != nil { + t.Fatalf("ResolveOpts: %v", err) + } + req, err := tmpl.Build(opts) + if err != nil { + t.Fatalf("Build: %v", err) + } + if len(req.Criteria) == 0 { + t.Fatal("template produced zero criteria") + } + for i, c := range req.Criteria { + if _, ok := knownValues[c.Name]; !ok { + t.Errorf("criterion %d uses unregistered name %q (must be one of the criteria.go consts)", i, c.Name) + } + if c.SearchType == "" { + t.Errorf("criterion %d has empty searchType", i) + } + if c.AndOr != "and" && c.AndOr != "or" { + t.Errorf("criterion %d has invalid andOr %q", i, c.AndOr) + } + } + }) + } +} + +// defaultOptsForTest supplies sensible values for required params during the +// whole-library scan. Keep this in sync with required-param templates. +func defaultOptsForTest(t Template) map[string]any { + out := make(map[string]any, len(t.Params)) + for _, p := range t.Params { + if !p.Required { + continue + } + switch t.Slug { + case "updates/os-version-below": + out["below-version"] = "15.0" + case "updates/major-version-behind": + out["major-below"] = 15 + case "lifecycle/jamf-binary-outdated": + out["below-version"] = "11.0.0" + } + } + return out +} + +func TestLibrary_AllSlugsUseCategoryPrefix(t *testing.T) { + for _, tmpl := range All() { + prefix := tmpl.Category + "/" + if !strings.HasPrefix(tmpl.Slug, prefix) { + t.Errorf("template %q (category %q): slug should start with %q", tmpl.Slug, tmpl.Category, prefix) + } + } +} +``` + +- [ ] **Step 2: Run tests to verify they pass** + +Run: `go test ./internal/smartgroup/ -run "TestLibrary_" -v` +Expected: PASS (4 tests). + +- [ ] **Step 3: Run the whole package test suite** + +Run: `go test ./internal/smartgroup/ -v` +Expected: PASS — every test across types, criteria, library, and 5 categories. + +- [ ] **Step 4: Commit** + +```bash +git add internal/smartgroup/library_test.go +git commit -m "test(smartgroup): add full-library integration tests (23 templates, 5 categories, criterion-name registry)" +``` + +--- + +## Task 10: Membership-check helper + +**Files:** +- Create: `internal/smartgroup/membership.go` +- Create: `internal/smartgroup/membership_test.go` + +- [ ] **Step 1: Write the failing test** + +Create `internal/smartgroup/membership_test.go`: + +```go +// Copyright 2026, Jamf Software LLC + +package smartgroup + +import ( + "context" + "encoding/json" + "io" + "net/http" + "strings" + "testing" +) + +type fakeHTTPClient struct { + resp *http.Response + err error + url string +} + +func (f *fakeHTTPClient) Do(_ context.Context, _ string, url string, _ io.Reader) (*http.Response, error) { + f.url = url + return f.resp, f.err +} + +func makeJSON(t *testing.T, v any) *http.Response { + t.Helper() + b, err := json.Marshal(v) + if err != nil { + t.Fatalf("marshal: %v", err) + } + return &http.Response{ + StatusCode: 200, + Body: io.NopCloser(strings.NewReader(string(b))), + } +} + +func TestCountMembers_PopulatedGroup(t *testing.T) { + resp := makeJSON(t, map[string]any{"members": []int{1, 2, 3, 4, 5}}) + client := &fakeHTTPClient{resp: resp} + n, err := CountMembers(context.Background(), client, "287") + if err != nil { + t.Fatalf("CountMembers: %v", err) + } + if n != 5 { + t.Fatalf("expected 5, got %d", n) + } + wantPath := "/v2/computer-groups/smart-group-membership/287" + if !strings.Contains(client.url, wantPath) { + t.Fatalf("expected URL to contain %q, got %q", wantPath, client.url) + } +} + +func TestCountMembers_EmptyGroup(t *testing.T) { + resp := makeJSON(t, map[string]any{"members": []int{}}) + n, err := CountMembers(context.Background(), &fakeHTTPClient{resp: resp}, "1") + if err != nil { + t.Fatalf("CountMembers: %v", err) + } + if n != 0 { + t.Fatalf("expected 0, got %d", n) + } +} + +func TestCountMembers_HTTPError(t *testing.T) { + resp := &http.Response{ + StatusCode: 404, + Body: io.NopCloser(strings.NewReader(`{"errors":["not found"]}`)), + } + _, err := CountMembers(context.Background(), &fakeHTTPClient{resp: resp}, "999") + if err == nil { + t.Fatal("expected error for 404, got nil") + } +} +``` + +- [ ] **Step 2: Run test to verify it fails** + +Run: `go test ./internal/smartgroup/ -run TestCountMembers -v` +Expected: FAIL with "undefined: CountMembers". + +- [ ] **Step 3: Implement the helper** + +Create `internal/smartgroup/membership.go`: + +```go +// Copyright 2026, Jamf Software LLC + +package smartgroup + +import ( + "context" + "encoding/json" + "fmt" + "io" + "net/http" +) + +// HTTPDoer is the minimal HTTP interface required by membership/verify helpers. +// Matches registry.HTTPClient's Do signature so the same value can be passed. +type HTTPDoer interface { + Do(ctx context.Context, method, url string, body io.Reader) (*http.Response, error) +} + +// CountMembers calls GET /v2/computer-groups/smart-group-membership/{id} and +// returns the length of the members array. +func CountMembers(ctx context.Context, client HTTPDoer, groupID string) (int, error) { + url := fmt.Sprintf("/v2/computer-groups/smart-group-membership/%s", groupID) + resp, err := client.Do(ctx, http.MethodGet, url, nil) + if err != nil { + return 0, fmt.Errorf("smart-group membership: %w", err) + } + defer resp.Body.Close() + if resp.StatusCode < 200 || resp.StatusCode >= 300 { + body, _ := io.ReadAll(resp.Body) + return 0, fmt.Errorf("smart-group membership: HTTP %d: %s", resp.StatusCode, string(body)) + } + var out struct { + Members []int `json:"members"` + } + if err := json.NewDecoder(resp.Body).Decode(&out); err != nil { + return 0, fmt.Errorf("smart-group membership: decode: %w", err) + } + return len(out.Members), nil +} +``` + +- [ ] **Step 4: Run test to verify it passes** + +Run: `go test ./internal/smartgroup/ -run TestCountMembers -v` +Expected: PASS (3 tests). + +- [ ] **Step 5: Commit** + +```bash +git add internal/smartgroup/membership.go internal/smartgroup/membership_test.go +git commit -m "feat(smartgroup): add CountMembers helper for post-apply membership check" +``` + +--- + +## Task 11: Command namespace skeleton + wire-up + +**Files:** +- Create: `internal/commands/pro_smartgroup.go` +- Modify: `internal/commands/pro.go` (add 1 line) +- Modify: `internal/commands/groups.go` (add 1 entry to proGroupMap) +- Modify: `internal/commands/aliases.go` (add 1 entry to commandAliases) + +- [ ] **Step 1: Write the skeleton** + +Create `internal/commands/pro_smartgroup.go`: + +```go +// Copyright 2026, Jamf Software LLC + +package commands + +import ( + "github.com/spf13/cobra" + + "github.com/Jamf-Concepts/jamf-cli/internal/registry" +) + +// newSmartGroupCmd is the entry point for the `pro smart-group` namespace. +// Subcommands are wired in subsequent tasks (templates, preview, apply, +// verify-templates). +func newSmartGroupCmd(cliCtx *registry.CLIContext) *cobra.Command { + cmd := &cobra.Command{ + Use: "smart-group", + Short: "Curated smart-group templates: list, preview, apply, verify", + Long: `Create useful Jamf Pro smart groups from a curated library of templates. + +Templates encode operationally-essential smart groups (devices not encrypted, +recovery keys invalid, OS versions behind, bootstrap tokens missing, etc.) so +admins don't have to assemble them by hand. + +Templates are sourced from JSS canonical criterion-name strings. Run +'pro smart-group verify-templates' once against your tenant to confirm each +template matches as expected.`, + } + + cmd.AddCommand(newSmartGroupTemplatesCmd(cliCtx)) + cmd.AddCommand(newSmartGroupPreviewCmd(cliCtx)) + cmd.AddCommand(newSmartGroupApplyCmd(cliCtx)) + cmd.AddCommand(newSmartGroupVerifyTemplatesCmd(cliCtx)) + + return cmd +} + +// Stubs so the skeleton compiles. Replaced in Tasks 12-15. + +func newSmartGroupTemplatesCmd(_ *registry.CLIContext) *cobra.Command { + return &cobra.Command{Use: "templates", Short: "List available smart-group templates (stub)"} +} + +func newSmartGroupPreviewCmd(_ *registry.CLIContext) *cobra.Command { + return &cobra.Command{Use: "preview", Short: "Preview a template (stub)"} +} + +func newSmartGroupApplyCmd(_ *registry.CLIContext) *cobra.Command { + return &cobra.Command{Use: "apply", Short: "Apply a template (stub)"} +} + +func newSmartGroupVerifyTemplatesCmd(_ *registry.CLIContext) *cobra.Command { + return &cobra.Command{Use: "verify-templates", Short: "Verify templates against the live tenant (stub)"} +} +``` + +- [ ] **Step 2: Wire into pro.go** + +Edit `internal/commands/pro.go`. Find the line: + +```go + cmd.AddCommand(newDeviceCmd(cliCtx)) +``` + +Insert immediately after it: + +```go + cmd.AddCommand(newSmartGroupCmd(cliCtx)) +``` + +- [ ] **Step 3: Wire into groups.go** + +Edit `internal/commands/groups.go`. Find the existing line in `proGroupMap`: + +```go + "smart-computer-groups": groupComputers, +``` + +Add this line immediately after it: + +```go + "smart-group": groupComputers, +``` + +- [ ] **Step 4: Wire into aliases.go** + +Edit `internal/commands/aliases.go`. In `commandAliases`, append: + +```go + "smart-group": {"sg"}, +``` + +- [ ] **Step 5: Build and smoke-check** + +Run: `go build ./...` +Expected: builds clean. + +Run: `go run ./cmd/jamf-cli pro smart-group --help` +Expected: prints "Curated smart-group templates: list, preview, apply, verify" plus four stub subcommands. + +Run: `go run ./cmd/jamf-cli pro sg --help` +Expected: same output (alias works). + +- [ ] **Step 6: Commit** + +```bash +git add internal/commands/pro_smartgroup.go internal/commands/pro.go internal/commands/groups.go internal/commands/aliases.go +git commit -m "feat(commands): scaffold pro smart-group namespace (alias sg) with stub subcommands" +``` + +--- + +## Task 12: `templates` subcommand + +**Files:** +- Modify: `internal/commands/pro_smartgroup.go` (replace `templates` stub) +- Create: `internal/commands/pro_smartgroup_test.go` + +- [ ] **Step 1: Write the failing tests** + +Create `internal/commands/pro_smartgroup_test.go`: + +```go +// Copyright 2026, Jamf Software LLC + +package commands + +import ( + "bytes" + "context" + "encoding/json" + "io" + "net/http" + "strings" + "testing" + + "github.com/Jamf-Concepts/jamf-cli/internal/registry" +) + +func runSmartGroupCmd(t *testing.T, args ...string) (string, string, error) { + t.Helper() + cliCtx := ®istry.CLIContext{} + root := newSmartGroupCmd(cliCtx) + stdout := &bytes.Buffer{} + stderr := &bytes.Buffer{} + root.SetOut(stdout) + root.SetErr(stderr) + root.SetArgs(args) + err := root.Execute() + return stdout.String(), stderr.String(), err +} + +func TestTemplates_TableDefault(t *testing.T) { + out, _, err := runSmartGroupCmd(t, "templates") + if err != nil { + t.Fatalf("execute: %v", err) + } + for _, want := range []string{ + "encryption/not-encrypted", + "updates/os-version-below", + "mdm/bootstrap-token-missing", + "compliance/gatekeeper-disabled", + "lifecycle/unsupervised", + } { + if !strings.Contains(out, want) { + t.Errorf("output missing %q\n%s", want, out) + } + } +} + +func TestTemplates_CategoryFilter(t *testing.T) { + out, _, err := runSmartGroupCmd(t, "templates", "--category", "encryption") + if err != nil { + t.Fatalf("execute: %v", err) + } + if !strings.Contains(out, "encryption/not-encrypted") { + t.Errorf("expected encryption templates: %s", out) + } + if strings.Contains(out, "lifecycle/unsupervised") { + t.Errorf("category filter should have excluded lifecycle: %s", out) + } +} + +func TestTemplates_JSONOutput(t *testing.T) { + out, _, err := runSmartGroupCmd(t, "templates", "-o", "json") + if err != nil { + t.Fatalf("execute: %v", err) + } + var parsed []map[string]any + if err := json.Unmarshal([]byte(out), &parsed); err != nil { + t.Fatalf("json output not parseable: %v\n%s", err, out) + } + if len(parsed) != 23 { + t.Errorf("expected 23 templates in json, got %d", len(parsed)) + } +} + +func TestTemplates_UnknownCategory(t *testing.T) { + out, _, err := runSmartGroupCmd(t, "templates", "--category", "nonexistent") + if err != nil { + t.Fatalf("execute: %v", err) + } + if !strings.Contains(out, "0 templates") && !strings.Contains(out, "No templates") { + t.Errorf("expected empty-result message, got: %s", out) + } +} + +// Suppress unused-import warnings for context/http/io used by later tasks. +var _ = context.Background +var _ = http.MethodGet +var _ io.Reader = nil +``` + +- [ ] **Step 2: Run tests to verify they fail** + +Run: `go test ./internal/commands/ -run TestTemplates -v` +Expected: FAIL — the current stub does nothing useful. + +- [ ] **Step 3: Implement the `templates` subcommand** + +Replace `newSmartGroupTemplatesCmd` in `internal/commands/pro_smartgroup.go` and add the helper functions. Replace the entire file with: + +```go +// Copyright 2026, Jamf Software LLC + +package commands + +import ( + "encoding/json" + "fmt" + "io" + "sort" + + "github.com/spf13/cobra" + + "github.com/Jamf-Concepts/jamf-cli/internal/registry" + "github.com/Jamf-Concepts/jamf-cli/internal/smartgroup" +) + +func newSmartGroupCmd(cliCtx *registry.CLIContext) *cobra.Command { + cmd := &cobra.Command{ + Use: "smart-group", + Short: "Curated smart-group templates: list, preview, apply, verify", + Long: `Create useful Jamf Pro smart groups from a curated library of templates. + +Templates encode operationally-essential smart groups (devices not encrypted, +recovery keys invalid, OS versions behind, bootstrap tokens missing, etc.) so +admins don't have to assemble them by hand. + +Templates are sourced from JSS canonical criterion-name strings. Run +'pro smart-group verify-templates' once against your tenant to confirm each +template matches as expected.`, + } + + cmd.AddCommand(newSmartGroupTemplatesCmd(cliCtx)) + cmd.AddCommand(newSmartGroupPreviewCmd(cliCtx)) + cmd.AddCommand(newSmartGroupApplyCmd(cliCtx)) + cmd.AddCommand(newSmartGroupVerifyTemplatesCmd(cliCtx)) + + return cmd +} + +func newSmartGroupTemplatesCmd(_ *registry.CLIContext) *cobra.Command { + var ( + category string + format string + ) + cmd := &cobra.Command{ + Use: "templates", + Short: "List available smart-group templates", + Long: `List all curated smart-group templates. Use --category to filter +to one of: encryption, updates, mdm, compliance, lifecycle.`, + Example: ` # All templates grouped by category + jamf-cli pro smart-group templates + + # Just encryption templates + jamf-cli pro smart-group templates --category encryption + + # Machine-readable + jamf-cli pro smart-group templates -o json`, + RunE: func(cmd *cobra.Command, _ []string) error { + var tmpls []smartgroup.Template + if category != "" { + tmpls = smartgroup.ByCategory(category) + } else { + tmpls = smartgroup.All() + } + return renderTemplatesList(cmd, tmpls, format) + }, + } + cmd.Flags().StringVar(&category, "category", "", "Filter by category (encryption|updates|mdm|compliance|lifecycle)") + cmd.Flags().StringVarP(&format, "output", "o", "table", "Output format: table|json") + return cmd +} + +func renderTemplatesList(cmd *cobra.Command, tmpls []smartgroup.Template, format string) error { + out := cmd.OutOrStdout() + if format == "json" { + return writeTemplatesJSON(out, tmpls) + } + if len(tmpls) == 0 { + fmt.Fprintln(out, "0 templates match the filter.") + return nil + } + cats := uniqueCategories(tmpls) + noun := "category" + if len(cats) != 1 { + noun = "categories" + } + fmt.Fprintf(out, "Smart Group Templates — %d available across %d %s\n\n", len(tmpls), len(cats), noun) + for _, cat := range cats { + bucket := filterByCategory(tmpls, cat) + fmt.Fprintf(out, "Category: %s (%d)\n", cat, len(bucket)) + for _, t := range bucket { + suffix := "" + if len(t.Params) > 0 { + suffix = fmt.Sprintf(" (params: --%s)", t.Params[0].Name) + } + fmt.Fprintf(out, " %-40s %s%s\n", t.Slug, t.Description, suffix) + } + fmt.Fprintln(out) + } + return nil +} + +func writeTemplatesJSON(out io.Writer, tmpls []smartgroup.Template) error { + type paramOut struct { + Name string `json:"name"` + Type string `json:"type"` + Default any `json:"default,omitempty"` + Description string `json:"description"` + Required bool `json:"required"` + } + type tmplOut struct { + Slug string `json:"slug"` + Category string `json:"category"` + Description string `json:"description"` + Params []paramOut `json:"params"` + } + rows := make([]tmplOut, 0, len(tmpls)) + for _, t := range tmpls { + row := tmplOut{Slug: t.Slug, Category: t.Category, Description: t.Description, Params: []paramOut{}} + for _, p := range t.Params { + row.Params = append(row.Params, paramOut{ + Name: p.Name, Type: p.Type, Default: p.Default, + Description: p.Description, Required: p.Required, + }) + } + rows = append(rows, row) + } + enc := json.NewEncoder(out) + enc.SetIndent("", " ") + return enc.Encode(rows) +} + +func uniqueCategories(tmpls []smartgroup.Template) []string { + seen := make(map[string]struct{}, 5) + out := make([]string, 0, 5) + for _, t := range tmpls { + if _, ok := seen[t.Category]; ok { + continue + } + seen[t.Category] = struct{}{} + out = append(out, t.Category) + } + sort.Strings(out) + return out +} + +func filterByCategory(tmpls []smartgroup.Template, cat string) []smartgroup.Template { + out := make([]smartgroup.Template, 0) + for _, t := range tmpls { + if t.Category == cat { + out = append(out, t) + } + } + return out +} + +// Stubs for the remaining subcommands. Replaced in Tasks 13-15. + +func newSmartGroupPreviewCmd(_ *registry.CLIContext) *cobra.Command { + return &cobra.Command{Use: "preview", Short: "Preview a template (stub)"} +} + +func newSmartGroupApplyCmd(_ *registry.CLIContext) *cobra.Command { + return &cobra.Command{Use: "apply", Short: "Apply a template (stub)"} +} + +func newSmartGroupVerifyTemplatesCmd(_ *registry.CLIContext) *cobra.Command { + return &cobra.Command{Use: "verify-templates", Short: "Verify templates against the live tenant (stub)"} +} +``` + +- [ ] **Step 4: Run tests to verify they pass** + +Run: `go test ./internal/commands/ -run TestTemplates -v` +Expected: PASS (4 tests). + +- [ ] **Step 5: Commit** + +```bash +git add internal/commands/pro_smartgroup.go internal/commands/pro_smartgroup_test.go +git commit -m "feat(commands): implement pro smart-group templates (list with --category, table/json output)" +``` + +--- + +## Task 13: `preview` subcommand + +**Files:** +- Modify: `internal/commands/pro_smartgroup.go` (replace `preview` stub + helper funcs) +- Modify: `internal/commands/pro_smartgroup_test.go` (append preview tests) + +- [ ] **Step 1: Write the failing tests** + +Append to `internal/commands/pro_smartgroup_test.go`: + +```go + +func TestPreview_ZeroParam(t *testing.T) { + out, _, err := runSmartGroupCmd(t, "preview", "--template", "encryption/not-encrypted") + if err != nil { + t.Fatalf("execute: %v", err) + } + if !strings.Contains(out, "POST /v2/computer-groups/smart-groups") { + t.Errorf("expected POST header: %s", out) + } + if !strings.Contains(out, "FileVault 2 Status") || !strings.Contains(out, "Not Encrypted") { + t.Errorf("expected criterion in JSON body: %s", out) + } +} + +func TestPreview_WithParam(t *testing.T) { + out, _, err := runSmartGroupCmd(t, "preview", "--template", "encryption/encryption-stalled", "--stalled-after", "14") + if err != nil { + t.Fatalf("execute: %v", err) + } + if !strings.Contains(out, `"value": "14"`) { + t.Errorf("expected stalled-after=14 in output: %s", out) + } +} + +func TestPreview_UnknownTemplate(t *testing.T) { + _, _, err := runSmartGroupCmd(t, "preview", "--template", "encryption/typo") + if err == nil { + t.Fatal("expected error for unknown template, got nil") + } + if !strings.Contains(err.Error(), "encryption/") { + t.Errorf("expected fuzzy-match suggestion mentioning encryption: %v", err) + } +} + +func TestPreview_RequiredParamMissing(t *testing.T) { + _, _, err := runSmartGroupCmd(t, "preview", "--template", "updates/os-version-below") + if err == nil { + t.Fatal("expected error for missing --below-version, got nil") + } + if !strings.Contains(err.Error(), "below-version") { + t.Errorf("expected error to mention required param: %v", err) + } +} +``` + +- [ ] **Step 2: Run tests to verify they fail** + +Run: `go test ./internal/commands/ -run TestPreview -v` +Expected: FAIL — preview stub does nothing useful. + +- [ ] **Step 3: Implement `preview` and supporting helpers** + +In `internal/commands/pro_smartgroup.go`, add `"strings"` to the imports (in addition to those already present). Replace the `newSmartGroupPreviewCmd` stub with the real implementation, and add the supporting helpers: + +```go +func newSmartGroupPreviewCmd(_ *registry.CLIContext) *cobra.Command { + var slug string + cmd := &cobra.Command{ + Use: "preview", + Short: "Print the JSON body that would be POSTed (no API call)", + Long: `Preview the JSON request that 'apply' would POST to +/v2/computer-groups/smart-groups for the chosen template. Use this to inspect +criteria before creating a group.`, + Example: ` jamf-cli pro smart-group preview --template encryption/invalid-recovery-key + jamf-cli pro smart-group preview --template encryption/encryption-stalled --stalled-after 14`, + RunE: func(cmd *cobra.Command, _ []string) error { + tmpl, ok := smartgroup.Lookup(slug) + if !ok { + return unknownTemplateError(slug) + } + opts, err := collectParamValues(tmpl, cmd.Flags()) + if err != nil { + return err + } + resolved, err := tmpl.ResolveOpts(opts) + if err != nil { + return err + } + req, err := tmpl.Build(resolved) + if err != nil { + return err + } + req.Name = "<--name required when running apply>" + out := cmd.OutOrStdout() + fmt.Fprintln(out, "POST /v2/computer-groups/smart-groups") + enc := json.NewEncoder(out) + enc.SetIndent("", " ") + return enc.Encode(req) + }, + } + cmd.Flags().StringVar(&slug, "template", "", "Template slug (required) — e.g. encryption/invalid-recovery-key") + _ = cmd.MarkFlagRequired("template") + registerTemplateParamFlags(cmd) + return cmd +} + +// registerTemplateParamFlags declares the union of all per-template param +// flag names on the cobra command as generic string flags. collectParamValues +// reads only the flags the chosen template actually declares. +func registerTemplateParamFlags(cmd *cobra.Command) { + seen := make(map[string]bool) + for _, t := range smartgroup.All() { + for _, p := range t.Params { + if seen[p.Name] { + continue + } + seen[p.Name] = true + cmd.Flags().String(p.Name, "", p.Description) + } + } +} + +// flagReader is the minimal flag-access interface used by collectParamValues +// — satisfied by *pflag.FlagSet returned by cobra's cmd.Flags(). +type flagReader interface { + GetString(string) (string, error) + Changed(string) bool +} + +func collectParamValues(tmpl smartgroup.Template, flags flagReader) (map[string]any, error) { + out := make(map[string]any, len(tmpl.Params)) + for _, p := range tmpl.Params { + if !flags.Changed(p.Name) { + continue + } + v, err := flags.GetString(p.Name) + if err != nil { + return nil, err + } + out[p.Name] = v // ResolveOpts coerces strings to int when Type is "int". + } + return out, nil +} + +func unknownTemplateError(slug string) error { + suggestions := smartgroup.FuzzyMatch(slug) + if len(suggestions) == 0 { + return fmt.Errorf("unknown template %q (run 'pro smart-group templates' to list available)", slug) + } + return fmt.Errorf("unknown template %q — did you mean: %s?", slug, strings.Join(suggestions, ", ")) +} +``` + +- [ ] **Step 4: Run tests to verify they pass** + +Run: `go test ./internal/commands/ -run TestPreview -v` +Expected: PASS (4 tests). + +- [ ] **Step 5: Commit** + +```bash +git add internal/commands/pro_smartgroup.go internal/commands/pro_smartgroup_test.go +git commit -m "feat(commands): implement pro smart-group preview with per-template param flags" +``` + +--- + +## Task 14: `apply` subcommand (idempotent create/update + membership check) + +**Files:** +- Modify: `internal/commands/pro_smartgroup.go` (replace `apply` stub + add helpers) +- Modify: `internal/commands/pro_smartgroup_test.go` (append apply tests) + +- [ ] **Step 1: Write the failing tests** + +Append to `internal/commands/pro_smartgroup_test.go`: + +```go + +type fakeSGClient struct { + calls []recordedCall + queue []*http.Response +} + +type recordedCall struct { + method, url, body string +} + +func (f *fakeSGClient) Do(_ context.Context, method, url string, body io.Reader) (*http.Response, error) { + b := "" + if body != nil { + buf, _ := io.ReadAll(body) + b = string(buf) + } + f.calls = append(f.calls, recordedCall{method, url, b}) + if len(f.queue) == 0 { + return &http.Response{StatusCode: 500, Body: io.NopCloser(strings.NewReader("queue empty"))}, nil + } + resp := f.queue[0] + f.queue = f.queue[1:] + return resp, nil +} + +func newJSONResp(status int, payload any) *http.Response { + b, _ := json.Marshal(payload) + return &http.Response{StatusCode: status, Body: io.NopCloser(strings.NewReader(string(b)))} +} + +func runSmartGroupApply(t *testing.T, client *fakeSGClient, args ...string) (string, error) { + t.Helper() + cliCtx := ®istry.CLIContext{Client: client} + root := newSmartGroupCmd(cliCtx) + out := &bytes.Buffer{} + root.SetOut(out) + root.SetErr(out) + root.SetArgs(append([]string{"apply"}, args...)) + err := root.Execute() + return out.String(), err +} + +func TestApply_NewGroupCreated(t *testing.T) { + client := &fakeSGClient{ + queue: []*http.Response{ + newJSONResp(200, map[string]any{"totalCount": 0, "results": []any{}}), + newJSONResp(201, map[string]any{"id": "287", "href": "/.../287"}), + newJSONResp(200, map[string]any{"members": []int{1, 2, 3, 4, 5}}), + }, + } + out, err := runSmartGroupApply(t, client, + "--template", "encryption/not-encrypted", + "--name", "Test FV Not Encrypted", + "--yes", + ) + if err != nil { + t.Fatalf("apply: %v\n%s", err, out) + } + if len(client.calls) != 3 { + t.Fatalf("expected 3 API calls, got %d", len(client.calls)) + } + if client.calls[1].method != "POST" { + t.Errorf("expected second call POST (create), got %s", client.calls[1].method) + } + if !strings.Contains(out, "Membership: 5") { + t.Errorf("expected membership log in output: %s", out) + } +} + +func TestApply_ExistingGroupUpdated(t *testing.T) { + client := &fakeSGClient{ + queue: []*http.Response{ + newJSONResp(200, map[string]any{"totalCount": 1, "results": []any{map[string]any{"id": "42", "name": "Test FV Not Encrypted"}}}), + newJSONResp(204, map[string]any{}), + newJSONResp(200, map[string]any{"members": []int{1, 2}}), + }, + } + out, err := runSmartGroupApply(t, client, + "--template", "encryption/not-encrypted", + "--name", "Test FV Not Encrypted", + "--yes", + ) + if err != nil { + t.Fatalf("apply: %v\n%s", err, out) + } + if client.calls[1].method != "PUT" { + t.Errorf("expected PUT on existing group, got %s", client.calls[1].method) + } + if !strings.Contains(client.calls[1].url, "/42") { + t.Errorf("expected PUT URL with id=42: %s", client.calls[1].url) + } +} + +func TestApply_DryRunNoAPICalls(t *testing.T) { + client := &fakeSGClient{} + out, err := runSmartGroupApply(t, client, + "--template", "encryption/not-encrypted", + "--name", "Test", + "--dry-run", + ) + if err != nil { + t.Fatalf("dry-run: %v\n%s", err, out) + } + if len(client.calls) != 0 { + t.Fatalf("expected 0 API calls in dry-run, got %d", len(client.calls)) + } + if !strings.Contains(out, "POST /v2/computer-groups/smart-groups") { + t.Errorf("expected dry-run output to show what would POST: %s", out) + } +} + +func TestApply_ZeroMembershipWarning(t *testing.T) { + client := &fakeSGClient{ + queue: []*http.Response{ + newJSONResp(200, map[string]any{"totalCount": 0, "results": []any{}}), + newJSONResp(201, map[string]any{"id": "99"}), + newJSONResp(200, map[string]any{"members": []int{}}), + }, + } + out, _ := runSmartGroupApply(t, client, + "--template", "compliance/firewall-disabled", + "--name", "Test FW Off", + "--yes", + ) + if !strings.Contains(out, "matched 0 devices") { + t.Errorf("expected zero-match warning: %s", out) + } +} + +func TestApply_403MissingPrivilege(t *testing.T) { + client := &fakeSGClient{ + queue: []*http.Response{ + newJSONResp(200, map[string]any{"totalCount": 0, "results": []any{}}), + newJSONResp(403, map[string]any{"errors": []string{"forbidden"}}), + }, + } + _, err := runSmartGroupApply(t, client, + "--template", "encryption/not-encrypted", + "--name", "Test", + "--yes", + ) + if err == nil { + t.Fatal("expected error on 403, got nil") + } + if !strings.Contains(err.Error(), "Create Smart Computer Groups") { + t.Errorf("expected privilege name in error: %v", err) + } +} +``` + +- [ ] **Step 2: Run tests to verify they fail** + +Run: `go test ./internal/commands/ -run TestApply -v` +Expected: FAIL. + +- [ ] **Step 3: Implement `apply` and the apply-flow helpers** + +In `internal/commands/pro_smartgroup.go`, add these imports to the import block: `"bytes"`, `"context"`, `"net/http"`, `"net/url"`. + +Replace the `newSmartGroupApplyCmd` stub with: + +```go +func newSmartGroupApplyCmd(cliCtx *registry.CLIContext) *cobra.Command { + var ( + slug string + name string + recalculate bool + dryRun bool + yes bool + ) + cmd := &cobra.Command{ + Use: "apply", + Short: "Create or update a smart group from a template (idempotent by --name)", + Long: `Apply a template against the live tenant. If a smart group with the +given --name already exists, it is updated (PUT); otherwise it is created +(POST). After apply, the membership endpoint is consulted and the count is +logged. Use --dry-run to inspect the request body without calling the API.`, + Example: ` jamf-cli pro smart-group apply --template encryption/invalid-recovery-key --name "FV Invalid Recovery Keys" + jamf-cli pro sg apply --template mdm/stale-checkin --name "Stale 30d" --days 30 --recalculate + jamf-cli pro sg apply --template encryption/not-encrypted --name "Not Encrypted" --dry-run`, + RunE: func(cmd *cobra.Command, _ []string) error { + tmpl, ok := smartgroup.Lookup(slug) + if !ok { + return unknownTemplateError(slug) + } + opts, err := collectParamValues(tmpl, cmd.Flags()) + if err != nil { + return err + } + resolved, err := tmpl.ResolveOpts(opts) + if err != nil { + return err + } + req, err := tmpl.Build(resolved) + if err != nil { + return err + } + req.Name = name + if dryRun { + return printDryRun(cmd.OutOrStdout(), req) + } + if cliCtx.Client == nil { + return fmt.Errorf("not authenticated to a Jamf Pro tenant; run 'jamf-cli pro setup' first") + } + return runApplyFlow(cmd.Context(), cmd.OutOrStdout(), cliCtx.Client, req, recalculate, yes) + }, + } + cmd.Flags().StringVar(&slug, "template", "", "Template slug (required)") + cmd.Flags().StringVar(&name, "name", "", "Smart group name (required)") + cmd.Flags().BoolVar(&recalculate, "recalculate", false, "After apply, force smart-group recalculation") + cmd.Flags().BoolVar(&dryRun, "dry-run", false, "Print the request body without calling the API") + cmd.Flags().BoolVar(&yes, "yes", false, "Skip confirmation when updating an existing group") + _ = cmd.MarkFlagRequired("template") + _ = cmd.MarkFlagRequired("name") + registerTemplateParamFlags(cmd) + return cmd +} + +func printDryRun(out io.Writer, req smartgroup.SmartGroupRequest) error { + fmt.Fprintln(out, "POST /v2/computer-groups/smart-groups") + enc := json.NewEncoder(out) + enc.SetIndent("", " ") + return enc.Encode(req) +} + +func runApplyFlow(ctx context.Context, out io.Writer, client registry.HTTPClient, req smartgroup.SmartGroupRequest, recalculate, yes bool) error { + existingID, err := lookupSmartGroupByName(ctx, client, req.Name) + if err != nil { + return err + } + + var id string + switch { + case existingID == "": + newID, err := createSmartGroup(ctx, client, req) + if err != nil { + return err + } + id = newID + fmt.Fprintf(out, "Created smart group %q (ID: %s)\n", req.Name, id) + default: + if !yes { + return fmt.Errorf("smart group %q already exists (ID %s); pass --yes to replace", req.Name, existingID) + } + if err := updateSmartGroup(ctx, client, existingID, req); err != nil { + return err + } + id = existingID + fmt.Fprintf(out, "Updated smart group %q (ID: %s)\n", req.Name, id) + } + + if recalculate { + if err := recalculateSmartGroup(ctx, client, id); err != nil { + fmt.Fprintf(out, "Warning: recalculate did not complete: %v\n", err) + } + } + + count, err := smartgroup.CountMembers(ctx, client, id) + if err != nil { + fmt.Fprintf(out, "Warning: membership check failed: %v\n", err) + return nil + } + fmt.Fprintf(out, "Membership: %d devices.\n", count) + if count == 0 { + fmt.Fprintln(out, "This template matched 0 devices. Run 'pro sg verify-templates' to check criterion compatibility with your tenant.") + } + return nil +} + +func lookupSmartGroupByName(ctx context.Context, client registry.HTTPClient, name string) (string, error) { + filter := url.QueryEscape(fmt.Sprintf(`name=="%s"`, name)) + path := "/v2/computer-groups/smart-groups?filter=" + filter + resp, err := client.Do(ctx, http.MethodGet, path, nil) + if err != nil { + return "", err + } + defer resp.Body.Close() + if resp.StatusCode != 200 { + body, _ := io.ReadAll(resp.Body) + return "", fmt.Errorf("lookup smart group: HTTP %d: %s", resp.StatusCode, string(body)) + } + var out struct { + TotalCount int `json:"totalCount"` + Results []struct { + ID string `json:"id"` + Name string `json:"name"` + } `json:"results"` + } + if err := json.NewDecoder(resp.Body).Decode(&out); err != nil { + return "", err + } + for _, r := range out.Results { + if r.Name == name { + return r.ID, nil + } + } + return "", nil +} + +func createSmartGroup(ctx context.Context, client registry.HTTPClient, req smartgroup.SmartGroupRequest) (string, error) { + body, err := json.Marshal(req) + if err != nil { + return "", err + } + resp, err := client.Do(ctx, http.MethodPost, "/v2/computer-groups/smart-groups", bytes.NewReader(body)) + if err != nil { + return "", err + } + defer resp.Body.Close() + if resp.StatusCode == 403 { + return "", fmt.Errorf("permission denied: the OAuth role is missing the 'Create Smart Computer Groups' privilege") + } + if resp.StatusCode < 200 || resp.StatusCode >= 300 { + buf, _ := io.ReadAll(resp.Body) + return "", fmt.Errorf("create smart group: HTTP %d: %s", resp.StatusCode, string(buf)) + } + var out struct { + ID string `json:"id"` + } + if err := json.NewDecoder(resp.Body).Decode(&out); err != nil { + return "", err + } + return out.ID, nil +} + +func updateSmartGroup(ctx context.Context, client registry.HTTPClient, id string, req smartgroup.SmartGroupRequest) error { + body, err := json.Marshal(req) + if err != nil { + return err + } + resp, err := client.Do(ctx, http.MethodPut, "/v2/computer-groups/smart-groups/"+id, bytes.NewReader(body)) + if err != nil { + return err + } + defer resp.Body.Close() + if resp.StatusCode == 403 { + return fmt.Errorf("permission denied: the OAuth role is missing the 'Update Smart Computer Groups' privilege") + } + if resp.StatusCode < 200 || resp.StatusCode >= 300 { + buf, _ := io.ReadAll(resp.Body) + return fmt.Errorf("update smart group: HTTP %d: %s", resp.StatusCode, string(buf)) + } + return nil +} + +func recalculateSmartGroup(ctx context.Context, client registry.HTTPClient, id string) error { + resp, err := client.Do(ctx, http.MethodPost, "/v1/smart-computer-groups/"+id+"/recalculate", nil) + if err != nil { + return err + } + defer resp.Body.Close() + if resp.StatusCode < 200 || resp.StatusCode >= 300 { + return fmt.Errorf("recalculate: HTTP %d", resp.StatusCode) + } + return nil +} +``` + +- [ ] **Step 4: Run tests to verify they pass** + +Run: `go test ./internal/commands/ -run TestApply -v` +Expected: PASS (5 tests). + +- [ ] **Step 5: Commit** + +```bash +git add internal/commands/pro_smartgroup.go internal/commands/pro_smartgroup_test.go +git commit -m "feat(commands): implement pro smart-group apply with idempotent name-based create/update and post-apply membership check" +``` + +--- + +## Task 15: `verify-templates` subcommand (live-tenant smoke test) + +**Files:** +- Create: `internal/smartgroup/verify.go` +- Create: `internal/smartgroup/verify_test.go` +- Modify: `internal/commands/pro_smartgroup.go` (replace `verify-templates` stub) +- Modify: `internal/commands/pro_smartgroup_test.go` (append minimal subcommand test) + +- [ ] **Step 1: Write the failing test for the verifier package** + +Create `internal/smartgroup/verify_test.go`: + +```go +// Copyright 2026, Jamf Software LLC + +package smartgroup + +import ( + "context" + "encoding/json" + "io" + "net/http" + "strings" + "testing" +) + +type seqClient struct { + queue []*http.Response + calls []string +} + +func (s *seqClient) Do(_ context.Context, method, url string, _ io.Reader) (*http.Response, error) { + s.calls = append(s.calls, method+" "+url) + if len(s.queue) == 0 { + return &http.Response{StatusCode: 500, Body: io.NopCloser(strings.NewReader("empty"))}, nil + } + r := s.queue[0] + s.queue = s.queue[1:] + return r, nil +} + +func jsonResp(status int, payload any) *http.Response { + b, _ := json.Marshal(payload) + return &http.Response{StatusCode: status, Body: io.NopCloser(strings.NewReader(string(b)))} +} + +func TestVerify_RunOneTemplate_OK(t *testing.T) { + tmpl, ok := Lookup("encryption/not-encrypted") + if !ok { + t.Fatal("template missing") + } + client := &seqClient{ + queue: []*http.Response{ + jsonResp(201, map[string]any{"id": "555"}), + jsonResp(200, map[string]any{}), + jsonResp(200, map[string]any{"members": []int{1, 2, 3}}), + jsonResp(204, map[string]any{}), + }, + } + result := RunOneVerification(context.Background(), client, tmpl, true) + if result.Outcome != VerifyOK { + t.Errorf("expected OK outcome, got %v (%s)", result.Outcome, result.Error) + } + if result.MemberCount != 3 { + t.Errorf("expected 3 members, got %d", result.MemberCount) + } +} + +func TestVerify_RunOneTemplate_ZeroMatch(t *testing.T) { + tmpl, _ := Lookup("compliance/firewall-disabled") + client := &seqClient{ + queue: []*http.Response{ + jsonResp(201, map[string]any{"id": "777"}), + jsonResp(200, map[string]any{}), + jsonResp(200, map[string]any{"members": []int{}}), + jsonResp(204, map[string]any{}), + }, + } + result := RunOneVerification(context.Background(), client, tmpl, true) + if result.Outcome != VerifyZeroMatch { + t.Errorf("expected ZeroMatch, got %v", result.Outcome) + } +} + +func TestVerify_RunOneTemplate_CreateError(t *testing.T) { + tmpl, _ := Lookup("encryption/not-encrypted") + client := &seqClient{ + queue: []*http.Response{ + jsonResp(400, map[string]any{"errors": []string{"invalid criterion name"}}), + }, + } + result := RunOneVerification(context.Background(), client, tmpl, true) + if result.Outcome != VerifyError { + t.Errorf("expected Error, got %v", result.Outcome) + } + if result.Error == "" { + t.Error("expected non-empty Error message") + } +} + +func TestVerify_NoCleanupSkipsDelete(t *testing.T) { + tmpl, _ := Lookup("encryption/not-encrypted") + client := &seqClient{ + queue: []*http.Response{ + jsonResp(201, map[string]any{"id": "888"}), + jsonResp(200, map[string]any{}), + jsonResp(200, map[string]any{"members": []int{1}}), + }, + } + _ = RunOneVerification(context.Background(), client, tmpl, false) + for _, c := range client.calls { + if strings.HasPrefix(c, "DELETE") { + t.Errorf("did not expect DELETE call with cleanup=false: %s", c) + } + } +} +``` + +- [ ] **Step 2: Run test to verify it fails** + +Run: `go test ./internal/smartgroup/ -run TestVerify -v` +Expected: FAIL with "undefined: RunOneVerification". + +- [ ] **Step 3: Implement the verifier** + +Create `internal/smartgroup/verify.go`: + +```go +// Copyright 2026, Jamf Software LLC + +package smartgroup + +import ( + "bytes" + "context" + "encoding/json" + "fmt" + "io" + "math/rand" + "net/http" +) + +// VerifyOutcome is the per-template result of a verify-templates pass. +type VerifyOutcome int + +const ( + VerifyOK VerifyOutcome = iota + VerifyZeroMatch + VerifyError +) + +func (o VerifyOutcome) String() string { + switch o { + case VerifyOK: + return "OK" + case VerifyZeroMatch: + return "ZERO_MATCH" + case VerifyError: + return "ERROR" + default: + return "UNKNOWN" + } +} + +// VerifyResult captures one template's verification outcome. +type VerifyResult struct { + Slug string + Outcome VerifyOutcome + MemberCount int + Error string +} + +// defaultVerifyOpts supplies sensible required-param values for verification. +// Update when adding new required-param templates. +var defaultVerifyOpts = map[string]map[string]any{ + "updates/os-version-below": {"below-version": "15.0"}, + "updates/major-version-behind": {"major-below": 15}, + "lifecycle/jamf-binary-outdated": {"below-version": "11.0.0"}, +} + +// RunOneVerification creates a temporary smart group from the template, +// recalculates membership, captures the count, and (if cleanup) deletes the +// temporary group. +func RunOneVerification(ctx context.Context, client HTTPDoer, tmpl Template, cleanup bool) VerifyResult { + opts, err := tmpl.ResolveOpts(defaultVerifyOpts[tmpl.Slug]) + if err != nil { + return VerifyResult{Slug: tmpl.Slug, Outcome: VerifyError, Error: fmt.Sprintf("ResolveOpts: %v", err)} + } + req, err := tmpl.Build(opts) + if err != nil { + return VerifyResult{Slug: tmpl.Slug, Outcome: VerifyError, Error: fmt.Sprintf("Build: %v", err)} + } + req.Name = fmt.Sprintf("__verify_%s_%06d", sanitizeSlug(tmpl.Slug), rand.Intn(1000000)) + + id, err := createTempGroup(ctx, client, req) + if err != nil { + return VerifyResult{Slug: tmpl.Slug, Outcome: VerifyError, Error: err.Error()} + } + + _ = recalcGroup(ctx, client, id) // recalc failure is non-fatal + + count, err := CountMembers(ctx, client, id) + if err != nil { + if cleanup { + _ = deleteGroup(ctx, client, id) + } + return VerifyResult{Slug: tmpl.Slug, Outcome: VerifyError, Error: err.Error()} + } + + if cleanup { + _ = deleteGroup(ctx, client, id) + } + + outcome := VerifyOK + if count == 0 { + outcome = VerifyZeroMatch + } + return VerifyResult{Slug: tmpl.Slug, Outcome: outcome, MemberCount: count} +} + +func sanitizeSlug(s string) string { + out := make([]byte, 0, len(s)) + for i := 0; i < len(s); i++ { + c := s[i] + switch { + case c >= 'a' && c <= 'z': + out = append(out, c) + case c >= '0' && c <= '9': + out = append(out, c) + case c == '-' || c == '_': + out = append(out, c) + case c == '/': + out = append(out, '_') + } + } + return string(out) +} + +func createTempGroup(ctx context.Context, client HTTPDoer, req SmartGroupRequest) (string, error) { + body, _ := json.Marshal(req) + resp, err := client.Do(ctx, http.MethodPost, "/v2/computer-groups/smart-groups", bytes.NewReader(body)) + if err != nil { + return "", err + } + defer resp.Body.Close() + if resp.StatusCode < 200 || resp.StatusCode >= 300 { + buf, _ := io.ReadAll(resp.Body) + return "", fmt.Errorf("HTTP %d: %s", resp.StatusCode, string(buf)) + } + var out struct { + ID string `json:"id"` + } + if err := json.NewDecoder(resp.Body).Decode(&out); err != nil { + return "", err + } + return out.ID, nil +} + +func recalcGroup(ctx context.Context, client HTTPDoer, id string) error { + resp, err := client.Do(ctx, http.MethodPost, "/v1/smart-computer-groups/"+id+"/recalculate", nil) + if err != nil { + return err + } + resp.Body.Close() + return nil +} + +func deleteGroup(ctx context.Context, client HTTPDoer, id string) error { + resp, err := client.Do(ctx, http.MethodDelete, "/v2/computer-groups/smart-groups/"+id, nil) + if err != nil { + return err + } + resp.Body.Close() + return nil +} +``` + +- [ ] **Step 4: Run package tests to confirm verifier passes** + +Run: `go test ./internal/smartgroup/ -run TestVerify -v` +Expected: PASS (4 tests). + +- [ ] **Step 5: Replace the `verify-templates` subcommand stub** + +In `internal/commands/pro_smartgroup.go`, replace `newSmartGroupVerifyTemplatesCmd`: + +```go +func newSmartGroupVerifyTemplatesCmd(cliCtx *registry.CLIContext) *cobra.Command { + var ( + category string + noCleanup bool + jsonOutput bool + ) + cmd := &cobra.Command{ + Use: "verify-templates", + Short: "Smoke-test every template against the live tenant", + Long: `Create one temporary smart group per template (prefixed "__verify_"), +recalculate it, read the membership count, and report. Temporary groups are +deleted on completion unless --no-cleanup is set. + +Use this on first run after install (and after any sync-specs that touches +JSS) to confirm criterion-name strings match your Jamf Pro version.`, + Example: ` jamf-cli pro smart-group verify-templates + jamf-cli pro sg verify-templates --category encryption + jamf-cli pro sg verify-templates --no-cleanup`, + RunE: func(cmd *cobra.Command, _ []string) error { + if cliCtx.Client == nil { + return fmt.Errorf("not authenticated to a Jamf Pro tenant; run 'jamf-cli pro setup' first") + } + var tmpls []smartgroup.Template + if category != "" { + tmpls = smartgroup.ByCategory(category) + } else { + tmpls = smartgroup.All() + } + results := make([]smartgroup.VerifyResult, 0, len(tmpls)) + for _, t := range tmpls { + results = append(results, smartgroup.RunOneVerification(cmd.Context(), cliCtx.Client, t, !noCleanup)) + } + return renderVerifyResults(cmd.OutOrStdout(), results, jsonOutput) + }, + } + cmd.Flags().StringVar(&category, "category", "", "Verify only one category") + cmd.Flags().BoolVar(&noCleanup, "no-cleanup", false, "Keep temporary groups instead of deleting them") + cmd.Flags().BoolVar(&jsonOutput, "json", false, "Output JSON instead of human-readable summary") + return cmd +} + +func renderVerifyResults(out io.Writer, results []smartgroup.VerifyResult, asJSON bool) error { + if asJSON { + enc := json.NewEncoder(out) + enc.SetIndent("", " ") + return enc.Encode(results) + } + ok, zero, errs := 0, 0, 0 + fmt.Fprintf(out, "Verifying %d templates...\n\n", len(results)) + for _, r := range results { + switch r.Outcome { + case smartgroup.VerifyOK: + ok++ + fmt.Fprintf(out, "✓ %-40s — %d devices match\n", r.Slug, r.MemberCount) + case smartgroup.VerifyZeroMatch: + zero++ + fmt.Fprintf(out, "⚠ %-40s — 0 devices match (possible criterion mismatch)\n", r.Slug) + case smartgroup.VerifyError: + errs++ + fmt.Fprintf(out, "✗ %-40s — ERROR: %s\n", r.Slug, r.Error) + } + } + fmt.Fprintf(out, "\nSummary: %d OK, %d zero-match warnings, %d errors.\n", ok, zero, errs) + return nil +} +``` + +- [ ] **Step 6: Add a minimal end-to-end test of the subcommand** + +Append to `internal/commands/pro_smartgroup_test.go`: + +```go + +func TestVerifyTemplates_CategoryRuns(t *testing.T) { + // Each template in the encryption category produces 4 HTTP calls + // (POST create + recalc + membership + DELETE cleanup). We queue 6 templates * 4 = 24 responses. + client := &fakeSGClient{} + for i := 0; i < 6; i++ { + client.queue = append(client.queue, + newJSONResp(201, map[string]any{"id": "100"}), + newJSONResp(200, map[string]any{}), + newJSONResp(200, map[string]any{"members": []int{1, 2}}), + newJSONResp(204, map[string]any{}), + ) + } + cliCtx := ®istry.CLIContext{Client: client} + root := newSmartGroupCmd(cliCtx) + out := &bytes.Buffer{} + root.SetOut(out) + root.SetErr(out) + root.SetArgs([]string{"verify-templates", "--category", "encryption"}) + if err := root.Execute(); err != nil { + t.Fatalf("verify-templates: %v", err) + } + if !strings.Contains(out.String(), "Verifying 6 templates") { + t.Errorf("expected '6 templates' in output: %s", out.String()) + } + if !strings.Contains(out.String(), "Summary: 6 OK") { + t.Errorf("expected summary line: %s", out.String()) + } +} +``` + +- [ ] **Step 7: Run all subcommand tests** + +Run: `go test ./internal/commands/ -run "TestTemplates|TestPreview|TestApply|TestVerifyTemplates" -v` +Expected: PASS for all. + +- [ ] **Step 8: Commit** + +```bash +git add internal/smartgroup/verify.go internal/smartgroup/verify_test.go internal/commands/pro_smartgroup.go internal/commands/pro_smartgroup_test.go +git commit -m "feat(commands): implement pro smart-group verify-templates with live-tenant smoke test" +``` + +--- + +## Task 16: Smoke seed integration (CI dispatch check) + +**Files:** +- Modify (only if needed): `internal/commands/smoke_seed_test.go` + +- [ ] **Step 1: Inspect the existing smoke seed** + +Run: `head -60 internal/commands/smoke_seed_test.go` +Expected: a Go test that exercises `--help` against every registered command. If the harness auto-walks the tree, no changes are required. + +- [ ] **Step 2: Run smoke tests** + +Run: `go test ./internal/commands/ -run TestSmoke -v 2>&1 | tail -20` +Expected: PASS. If smoke tests fail because the new commands need explicit registration, add the four new subcommand names (`smart-group`, `templates`, `preview`, `apply`, `verify-templates`) to whichever seed list the smoke harness uses. + +- [ ] **Step 3: Commit if changes were required** + +```bash +git add internal/commands/smoke_seed_test.go +git commit -m "test(commands): wire pro smart-group into smoke harness" +``` + +If no smoke updates were needed, skip the commit. + +--- + +## Task 17: Full build, lint, and final verification + +**Files:** none modified directly + +- [ ] **Step 1: Run the full test suite** + +Run: `go test ./...` +Expected: PASS. + +- [ ] **Step 2: Run the linter** + +Run: `make lint` +Expected: PASS. + +- [ ] **Step 3: Format** + +Run: `make fmt` +Expected: any formatter fixes applied. Re-run tests after. + +- [ ] **Step 4: Verify the binary builds and the new commands work end-to-end** + +Run: `make build` +Expected: builds cleanly to `bin/jamf-cli`. + +Run: `bin/jamf-cli pro smart-group --help` +Expected: prints the namespace help with four subcommands. + +Run: `bin/jamf-cli pro sg templates --category encryption` +Expected: prints 6 encryption templates in category-grouped form. + +Run: `bin/jamf-cli pro sg preview --template encryption/encryption-stalled --stalled-after 21` +Expected: prints "POST /v2/computer-groups/smart-groups" followed by a JSON body with value="21". + +- [ ] **Step 5: Commit any formatter-applied fixes** + +If `make fmt` produced changes: + +```bash +git add -u +git commit -m "style: gofmt/gofumpt fixes for pro smart-group package" +``` + +If no formatter changes, skip the commit. + +--- + +## Self-Review + +**Spec coverage:** every spec section has a task. + +| Spec section | Task(s) | +| --- | --- | +| Endpoint surface table | Tasks 10 (membership), 14 (apply), 15 (verify) | +| Request body schema (`SmartGroupRequest`, `Criterion`) | Task 1 | +| Verified Criterion-Name Registry | Task 2 | +| Verified enum value sets | Tasks 4-8 (templates use the correct value strings) | +| Template inventory (23 across 5 categories) | Tasks 4-8 | +| `pro smart-group templates` | Task 12 | +| `pro smart-group preview` | Task 13 | +| `pro smart-group apply` | Task 14 | +| `pro smart-group verify-templates` | Task 15 | +| Output and exit-code conventions | Tasks 12-15 (cobra error -> non-zero exit; explicit privilege messages) | +| Testing (golden JSON, output-flag matrix, smoke) | Tasks 4-9, 12-15, 16 | +| Wiki use policy | Implicit — no wiki content shipped in any file | + +**Placeholder scan:** no `TBD`/`TODO`. Every step contains runnable code. + +**Type consistency:** `SmartGroupRequest`, `Criterion`, `Template`, `ParamSpec`, `Library`, `Lookup`, `Register`, `All`, `ByCategory`, `Categories`, `FuzzyMatch`, `CountMembers`, `HTTPDoer`, `VerifyOutcome`, `VerifyResult`, `RunOneVerification` — all defined in their declaring task and referenced consistently in later tasks. + +**Open spec questions surfaced by the plan:** the spec's 6 open questions (display values, searchType strings, Apple Silicon value, empty-value semantics, required-param verify values, encryption-stalled precision) are all empirically resolved by `verify-templates` (Task 15), which probes every unverified string against the live tenant. + +--- + +## Execution Handoff + +Plan complete and saved to `docs/superpowers/plans/2026-05-12-pro-smart-group-templates.md`. Two execution options: + +**1. Subagent-Driven (recommended)** — fresh subagent per task, review between tasks, fast iteration. + +**2. Inline Execution** — execute tasks in this session using executing-plans, batch execution with checkpoints. + +**Which approach?** diff --git a/docs/solutions/design-patterns/pro-smart-group-templates-spec-2026-05-12.md b/docs/solutions/design-patterns/pro-smart-group-templates-spec-2026-05-12.md new file mode 100644 index 0000000..bb7197c --- /dev/null +++ b/docs/solutions/design-patterns/pro-smart-group-templates-spec-2026-05-12.md @@ -0,0 +1,573 @@ +--- +title: pro smart-group — wiki-driven smart-group template library for jamf-cli +date: 2026-05-12 +status: shipped +scope: first ship — 4 commands, 23 templates across 5 categories +module: internal/commands, internal/smartgroup +tags: [smart-groups, templates, jss-criteria, mac-wiki, verify-templates] +note: brainstorming initially pivoted from a FileVault read-side helper spec (key/status/escrow-audit) to this template-library design after deciding admins get more value from creating curated smart groups than from inspecting fleet state read-only +--- + +# pro smart-group — wiki-driven smart-group template library + +## Context + +Smart groups are the most-used building block in Jamf Pro. Admins target +policies with them, scope configuration profiles, drive compliance reports, +and trigger Self Service. Every Jamf admin in every tenant builds the same +operationally-essential smart groups by hand: + +- "Macs where FileVault is not encrypted" +- "Macs with an invalid recovery key" +- "Macs missing a bootstrap token" +- "Macs running OS older than X" +- "Macs that haven't checked in in N days" + +These are the same ~20 smart groups, recreated per-tenant, every time. They +are operational knowledge — the kind documented in mac-wiki — but every admin +re-derives them. + +This spec defines a curated library of 23 smart-group templates shipped as +first-class CLI commands. Admins go from "I need a smart group for X" to +working smart group in one command. The library lives in jamf-cli as +hardcoded Go (compile-time-safe) and POSTs to the existing +`/v2/computer-groups/smart-groups` endpoint. + +This is a follow-on to the `pro filevault` read-side spec (same date) — that +spec is on pause; this one is the new primary feature. + +## Audit findings (verified ground truth) + +### Endpoint surface + +| Endpoint | Purpose | +| --- | --- | +| `POST /v2/computer-groups/smart-groups` | Create a smart group. Required privilege: `Create Smart Computer Groups`. | +| `PUT /v2/computer-groups/smart-groups/{id}` | Update an existing smart group. Required privilege: `Update Smart Computer Groups`. | +| `GET /v2/computer-groups/smart-groups` | List/search (used for apply-pattern name lookup). | +| `POST /v1/smart-computer-groups/{id}/recalculate` | Force membership recalculation. Required privilege: `Update Smart Computer Groups`. | +| `GET /v2/computer-groups/smart-group-membership/{id}` | Read membership (member IDs). Required privilege: `Read Smart Computer Groups`. | + +All five endpoints are already exposed by the generator. This spec wraps them +with workflow commands; no spec changes, no generator changes. + +### Request body schema + +`SmartComputerGroupV2` (from `specs/SmartComputerGroups.yaml`): + +```yaml +SmartComputerGroupV2: + name: string (required, minLength 1) + description: string + criteria: [SmartSearchCriterion] + siteId: string (nullable, default "-1") +``` + +`SmartSearchCriterion` (from `specs/_MonolithLibrary.yaml`): + +```yaml +SmartSearchCriterion: + andOr: string (required, "and" | "or") + name: string (required, criterion name from JSS canonical list) + searchType: string (required, e.g. "is", "is not", "more than", "less than", "like", "greater than or equal") + value: string (required, display value matching the criterion's enum) + priority: integer + openingParen: boolean + closingParen: boolean +``` + +### Canonical criterion-name source + +Smart-group criterion names live in the JSS server source. The canonical +file is: + +``` +jamf-pro-server/SmartSearch/SmartSearchApi/src/main/java/com/jamfsoftware/smartsearch/service/matcher/MatcherNameConstants.java +``` + +Plus per-matcher `@Component("")` annotations on classes +like `FileVault2StatusMatcher.java` and `UserApprovedMdmMatcher.java`. The +`ComputerInventoryValues.java` file in `jss9` is the parallel source for +inventory-display field names. Both were consulted to derive the verified +strings used in the template library below. + +**Important:** the wiki's terminology for "Disk Encryption Recovery Key X" +is wrong. JSS uses the `FileVault 2 X` prefix consistently. The library +uses JSS-canonical names. + +### Verified criterion-name registry (used by this library) + +| Go const | Display string (JSS-canonical) | Source | +| --- | --- | --- | +| `CriterionFV2Status` | `FileVault 2 Status` | `FileVault2StatusMatcher.java:@Component` | +| `CriterionFV2Enabled` | `FileVault 2 Enabled` | `MatcherNameConstants.CD.FILE_VAULT_2_ENABLED` | +| `CriterionFV2RecoveryKeyType` | `FileVault 2 Recovery Key Type` | `ComputerInventoryValues.java:103` | +| `CriterionFV2IndividualKeyValidation` | `FileVault 2 Individual Key Validation` | `ComputerInventoryValues.java:104` | +| `CriterionFV2PersonalRecoveryKey` | `FileVault 2 Personal Recovery Key` | `ComputerInventoryValues.java:106` | +| `CriterionOSVersion` | `Operating System Version` | `MatcherNameConstants.CD.OPERATING_SYSTEM_VERSION` | +| `CriterionOSBuild` | `Operating System Build` | `MatcherNameConstants.CD.OPERATING_SYSTEM_BUILD` | +| `CriterionOSRapidSecurityResponse` | `Operating System Rapid Security Response` | `MatcherNameConstants.CD.OPERATING_SYSTEM_SUPPLEMENTAL_VERSION_EXTRA` | +| `CriterionLastInventoryUpdate` | `Last Inventory Update` | `MatcherNameConstants.MDD.LAST_INVENTORY_UPDATE` | +| `CriterionBootstrapTokenEscrowed` | `Bootstrap Token Escrowed` | `MatcherNameConstants.MDD.BOOTSTRAP_TOKEN_ESCROWED` | +| `CriterionUserApprovedMDM` | `User Approved MDM` | `UserApprovedMdmMatcher.java:@Component` | +| `CriterionMDMProfileExpirationDate` | `MDM Profile Expiration Date` | `MatcherNameConstants.MDD.MDM_PROFILE_EXPIRATION_DATE` | +| `CriterionDDMEnabled` | `Declarative Device Management Enabled` | `MatcherNameConstants.CD.DECLARATIVE_DEVICE_MANAGEMENT_ENABLED` | +| `CriterionGatekeeper` | `Gatekeeper` | `ComputerInventoryValues.java:118` | +| `CriterionSIP` | `System Integrity Protection` | `ComputerInventoryValues.java:119` | +| `CriterionFirewallEnabled` | `Firewall Enabled` | `MatcherNameConstants.CD.FIREWALL_ENABLED` | +| `CriterionSupervised` | `Supervised` | `MatcherNameConstants.MDD.SUPERVISED` | +| `CriterionEnrollmentMethodPrestage` | `Enrollment Method: PreStage enrollment` | `MatcherNameConstants.E.PRESTAGE` | +| `CriterionAppleSilicon` | `Apple Silicon` | `MatcherNameConstants.CD.APPLE_SILICON` | +| `CriterionJamfBinaryVersion` | `Jamf Binary Version` | (parallel inventory criterion; verify-templates will confirm) | + +All criterion strings are pulled from JSS source. Where a string is from a +`@Component` annotation it is verbatim; where from `MatcherNameConstants` +or `ComputerInventoryValues` it is verbatim from the right-hand side of +the assignment. + +### Verified enum value sets (for criterion values) + +| Enum class | Constants | Display strings (from getName) | +| --- | --- | --- | +| `FileVault2Status` | NOT_APPLICABLE, NOT_ENCRYPTED, BOOT_ENCRYPTED, SOME_ENCRYPTED, ALL_ENCRYPTED | `N/A`, `Not Encrypted`, `Boot Partitions Encrypted`, `Some Partitions Encrypted`, `All Partitions Encrypted` (confirmed via `FileVault2StatusMatcher.java`) | +| `GatekeeperStatus` | NOT_COLLECTED, DISABLED, APP_STORE_AND_IDENTIFIED_DEVELOPERS, APP_STORE | display strings need empirical verification — `verify-templates` will catch | +| `SipStatus` | NOT_COLLECTED, NOT_AVAILABLE, DISABLED, ENABLED | likewise | +| `FileVault2KeyType` | INDIVIDUAL, INSTITUTIONAL, BOTH | `Individual`, `Institutional`, `Both` | + +### APIs deliberately not in scope + +| Surface | Reason | +| --- | --- | +| Generated `pro smart-computer-groups` CRUD | Untouched. The new commands wrap the same endpoints with a workflow surface. | +| Mobile-device smart groups | Out of scope for first ship; same architectural pattern can be added in a follow-on. | +| User smart groups | Out of scope. | +| Editing criterion values per-call (admin-controlled criteria) | Templates are fixed-shape with at most one parameter; admins who need bespoke criteria use the generated CRUD command. | +| External (admin-authored) YAML templates | Locked to hardcoded Go for first ship. | + +## Scope + +### In scope (this ship) + +Four handwritten subcommands under a new `pro smart-group` namespace (alias +`pro sg`): + +1. **`pro smart-group templates [--category ]`** — list available + templates with descriptions and parameters. +2. **`pro smart-group preview --template [params]`** — print the + exact JSON body that would POST, no API call. +3. **`pro smart-group apply --template --name [params] [--recalculate] [--dry-run] [--yes]`** — + idempotent create/update, with post-apply membership check. +4. **`pro smart-group verify-templates [--category ] [--no-cleanup]`** — + smoke-test every template against the live tenant; report membership + count or error per template; cleanup temp groups. + +Plus a curated library of 23 templates across 5 categories: +encryption (6), software updates (4), MDM health (5), compliance basics +(4), lifecycle hygiene (4). + +### Out of scope (deferred) + +- External YAML / admin-authored templates. +- Multi-param templates (current constraint: zero or one param per + template). +- Mobile-device and user smart-group equivalents. +- `pro filevault` read-side commands (`key`, `status`, `escrow-audit`) — + deferred to a follow-on cycle per the pivot in + `2026-05-12-pro-filevault-design.md`. + +## Template inventory (23 templates) + +Format: slug · criteria · param. + +### Category: encryption (6) + +| Slug | Criteria | Param | +| --- | --- | --- | +| `encryption/not-encrypted` | `FileVault 2 Status` is `Not Encrypted` | — | +| `encryption/invalid-recovery-key` | `FileVault 2 Individual Key Validation` is `Not Valid` | — | +| `encryption/escrow-missing` | `FileVault 2 Recovery Key Type` is empty | — | +| `encryption/irk-only-deprecated` | `FileVault 2 Recovery Key Type` is `Institutional` | — | +| `encryption/encryption-stalled` | `FileVault 2 Status` is not `All Partitions Encrypted` AND `Last Inventory Update` more than N days | `--stalled-after ` (default 7) | +| `encryption/fv-ineligible` | `FileVault 2 Status` is `N/A` | — | + +### Category: software updates (4) + +| Slug | Criteria | Param | +| --- | --- | --- | +| `updates/os-version-below` | `Operating System Version` less than X | `--below-version ` (required) | +| `updates/major-version-behind` | `Operating System Version` less than `.0` | `--major-below ` (required, integer) | +| `updates/rsr-not-applied` | `Operating System Rapid Security Response` is empty | — | +| `updates/beta-os` | `Operating System Version` like `Beta` | — | + +### Category: MDM health (5) + +| Slug | Criteria | Param | +| --- | --- | --- | +| `mdm/bootstrap-token-missing` | `Bootstrap Token Escrowed` is `No` | — | +| `mdm/user-approved-mdm-no` | `User Approved MDM` is `No` | — | +| `mdm/stale-checkin` | `Last Inventory Update` more than N days | `--days ` (default 7) | +| `mdm/mdm-cert-expiring` | `MDM Profile Expiration Date` less than N days from now | `--within-days ` (default 30) | +| `mdm/declarative-management-disabled` | `Declarative Device Management Enabled` is `No` | — | + +### Category: compliance basics (4) + +| Slug | Criteria | Param | +| --- | --- | --- | +| `compliance/gatekeeper-disabled` | `Gatekeeper` is `Disabled` | — | +| `compliance/sip-disabled` | `System Integrity Protection` is `Disabled` | — | +| `compliance/firewall-disabled` | `Firewall Enabled` is `No` | — | +| `compliance/non-compliant-baseline` | OR composite: FV2 Enabled `No`, SIP `Disabled`, Gatekeeper `Disabled`, Firewall Enabled `No` | — | + +### Category: lifecycle hygiene (4) + +| Slug | Criteria | Param | +| --- | --- | --- | +| `lifecycle/unsupervised` | `Supervised` is `No` | — | +| `lifecycle/ade-enrolled` | `Enrollment Method: PreStage enrollment` is `Yes` | — | +| `lifecycle/jamf-binary-outdated` | `Jamf Binary Version` less than X | `--below-version ` (required) | +| `lifecycle/fv-ineligible-hardware` | `FileVault 2 Status` is `N/A` AND `Apple Silicon` is `No` | — | + +Total: 23 templates, 6 parameterized, 17 zero-param. + +## Architecture + +### File layout + +``` +internal/ + commands/ + pro_smartgroup.go ~400 LOC — namespace + 4 commands + pro_smartgroup_test.go unit tests + golden output tests + smartgroup/ new package + types.go Template, ParamSpec, TemplateOpts + library.go map[string]Template — all 23 by slug + criteria.go Go consts for the criterion-name strings (sourced from JSS, see Verified Criterion-Name Registry) + encryption.go 6 encryption template builders + updates.go 4 software-update template builders + mdm.go 5 MDM-health template builders + compliance.go 4 compliance template builders + lifecycle.go 4 lifecycle template builders + membership.go post-apply membership-count check + verify.go verify-templates command logic + types_test.go param validation + library_test.go golden JSON for each template's Build() + criteria_test.go sanity: every template references a known criterion const +internal/commands/pro.go +1: cmd.AddCommand(newSmartGroupCmd(cliCtx)) +internal/commands/groups.go +1: "Smart Groups:" help group entry +internal/commands/aliases.go alias: pro sg → pro smart-group +``` + +No generator changes. No new specs. Reuses generated `SmartComputerGroupV2` +type for the POST body. + +### Type shape + +```go +// internal/smartgroup/types.go + +type ParamSpec struct { + Name string // CLI flag name, e.g. "stalled-after" + Type string // "int" | "string" | "version" + Default any // nil if Required + Description string // for --help + Required bool +} + +type Template struct { + Slug string + Category string + Description string + Params []ParamSpec // zero or one entry + Build func(opts map[string]any) (SmartComputerGroupV2, error) +} +``` + +Each `Build` function returns a SmartComputerGroupV2 body. Implementation +references criterion-name Go consts from `criteria.go` for compile-time +safety against refactors. + +## Command specs + +### `pro smart-group templates [--category ]` + +List the library. Default `-o table` groups by category. + +```bash +pro smart-group templates # all 23 +pro smart-group templates --category encryption # 6 in encryption +pro smart-group templates -o json # machine-readable +``` + +#### Sample output (table) + +``` +Smart Group Templates — 23 available across 5 categories + +Category: encryption (6) + encryption/not-encrypted Macs where FileVault 2 is not enabled + encryption/invalid-recovery-key Macs with an INVALID escrowed recovery key + encryption/escrow-missing Macs without any escrowed recovery key + encryption/irk-only-deprecated Macs on the deprecated Institutional Recovery Key + encryption/encryption-stalled Macs stuck mid-encryption (params: --stalled-after) + encryption/fv-ineligible Macs with FV status "Not Applicable" + +Category: updates (4) + updates/os-version-below Macs running OS older than X (params: --below-version) + updates/major-version-behind Macs older than macOS .0 (params: --major-below) + updates/rsr-not-applied Macs with no Rapid Security Response applied + updates/beta-os Macs running a beta OS + +... etc. +``` + +### `pro smart-group preview --template [params]` + +Print the exact JSON body that would be POSTed, no API call. + +```bash +pro smart-group preview --template encryption/invalid-recovery-key +pro smart-group preview --template encryption/encryption-stalled --stalled-after 14 +``` + +#### Sample output + +``` +POST /v2/computer-groups/smart-groups +{ + "name": "<--name required when running apply>", + "description": "Auto-generated by jamf-cli (template: encryption/encryption-stalled, stalled-after=14)", + "criteria": [ + {"andOr": "and", "priority": 0, "name": "FileVault 2 Status", "searchType": "is not", "value": "All Partitions Encrypted"}, + {"andOr": "and", "priority": 1, "name": "Last Inventory Update", "searchType": "more than x days ago", "value": "14"} + ] +} +``` + +### `pro smart-group apply --template --name [params] [--recalculate] [--dry-run] [--yes]` + +Idempotent create or update by name. Follows existing jamf-cli apply-pattern. + +#### Behavior + +1. Validate template exists. +2. Validate required params present; type-check values. +3. Build the `SmartComputerGroupV2` body via the template's `Build()` func. +4. If `--dry-run`: print the request and return. No API call. +5. Lookup existing group by `--name` via + `GET /v2/computer-groups/smart-groups?filter=name==""`. +6. If found: PUT (replace). Confirmation prompt unless `--yes`. +7. If new: POST (create). +8. If `--recalculate`: trigger + `POST /v1/smart-computer-groups/{id}/recalculate`. +9. **Always: post-apply membership check.** Call + `GET /v2/computer-groups/smart-group-membership/{id}` and log the + member count. +10. If membership is zero and the template has known-fragile criteria: + warn — "This template matched 0 devices. Run + `pro sg verify-templates` to check criterion compatibility with your + tenant." + +#### Sample output + +``` +Created smart group 'FV Invalid Recovery Keys' (ID: 287) +Recalculated. Membership: 34 devices. +``` + +#### Errors and exit codes + +- Missing required param → exit 2 (usage), message identifies the param. +- Unknown template slug → exit 2 with fuzzy-match suggestion. +- 403 on POST/PUT → exit 5 with the missing privilege named. +- 409 / name collision without `--yes` → prompt; abort if declined. +- Recalculate timeout (60s default) → exit 0; the group was created; + warn that recalc may still be running. + +### `pro smart-group verify-templates [--category ] [--no-cleanup]` + +Smoke-test the library against the live tenant. Creates temporary groups +prefixed `__verify_`, recalculates, reads membership, deletes +(unless `--no-cleanup`). + +```bash +pro smart-group verify-templates # full library +pro smart-group verify-templates --category encryption # one category +pro smart-group verify-templates --no-cleanup # leave temp groups for inspection +``` + +#### Behavior + +For each template in scope: + +1. Build the body with default param values. +2. POST with name `__verify__`. +3. Recalculate. +4. Read membership count. +5. Categorize as: OK (non-zero match), zero-match warning, or error + (4xx/5xx response). +6. Delete the temp group unless `--no-cleanup`. + +Aggregate report at the end. + +#### Sample output + +``` +Verifying 23 templates against tenant prod-1... + +✓ encryption/not-encrypted — 12 devices match +✓ encryption/invalid-recovery-key — 34 devices match +✓ encryption/escrow-missing — 127 devices match +✓ encryption/irk-only-deprecated — 0 devices match (expected — IRK is rare) +✓ encryption/encryption-stalled — 2 devices match +✓ encryption/fv-ineligible — 4 devices match +✓ updates/os-version-below — 451 devices match (default --below-version=15.0) +... +⚠ compliance/firewall-disabled — 0 devices match + Possible criterion-name or value mismatch. Inspect via: + pro sg preview --template compliance/firewall-disabled + +Summary: 22 OK, 1 zero-match warning, 0 errors. +Cleaning up 23 temporary groups... +``` + +#### Privileges required + +- `Create Smart Computer Groups`, `Update Smart Computer Groups`, + `Delete Smart Computer Groups`, `Read Smart Computer Groups`. + +## Wiki use policy + +The mac-wiki was the *author-time* source for the operational concepts +this library encodes (which smart groups admins actually need). It is +**not** a runtime dependency: + +- No wiki fetching at runtime. +- No wiki content shipped in the binary. +- User-facing strings stand on their own as plain English. +- Internal Go comments reference wiki slugs where relevant (`// concept + from wiki: security/filevault`) — for maintainer traceability only, + never printed. + +The mac-wiki was also wrong in places — notably the "Disk Encryption +Recovery Key X" terminology. The canonical source for criterion names +is the JSS server source (`MatcherNameConstants.java`, `@Component` +annotations on each Matcher class, `ComputerInventoryValues.java`), +verified during the audit. + +## Output and exit-code conventions + +- Default output is `table` for all four commands. +- `templates` supports `-o {table, json, csv, yaml, plain}`. +- `preview` is single-record; supports `-o {table, json, plain}`. +- `apply` is single-result; supports `-o {table, json, plain}`. +- `verify-templates` supports `-o {table, json}`. +- Exit codes: + - `0` success + - `1` general error + - `2` invalid usage (missing/conflicting flags, unknown slug) + - `3` auth error + - `4` not found + - `5` permission denied (privilege missing) + - `6` rate limited + +## Testing + +### Unit tests + +- `internal/smartgroup/library_test.go` — for each of the 23 templates, + call `Build()` with defaults and assert the resulting JSON matches a + golden fixture. 23 fixtures. +- `internal/smartgroup/types_test.go` — param validation: required + missing → error, type mismatch → error, out-of-range int → error. +- `internal/smartgroup/criteria_test.go` — every `Build()` output's + criteria must reference at least one criterion-name const from + `criteria.go`. Catches refactor typos at test time. +- `internal/commands/pro_smartgroup_test.go` — table-driven, mock HTTP, + golden outputs per `-o` format per command. + +### Output-flag matrix + +Per the project memory note (`feedback_output_flag_matrix.md`), every +command must be exercised against `-o {table, json, csv, yaml, plain}`, +`--quiet`, `--no-color`, `--out-file`, and `--field` where applicable. + +### No live-tenant tests + +`verify-templates` is the manual smoke check against a real tenant; not +wired into automated CI. + +## Open questions + +1. **Display values vs enum constants.** JSS enum constants are + SCREAMING_SNAKE_CASE; display values are spaced strings (e.g. + `BOOT_ENCRYPTED` → `Boot Partitions Encrypted`). The smart-group + criterion `value` field expects display values. We have the canonical + strings for `FileVault2Status` (confirmed via `FileVault2StatusMatcher`) + but display strings for `GatekeeperStatus` and `SipStatus` need + empirical verification. `verify-templates` will catch any mismatches + on first run against a real tenant. +2. **`searchType` value strings.** Some are obvious (`is`, `is not`, + `like`). Date-relative ones (`more than x days ago`) and version- + compare ones (`less than`, `greater than or equal`) are confirmed from + `specs/Groups.yaml` examples but the full set of legal `searchType` + values per criterion type isn't enumerated in the OpenAPI spec. + `verify-templates` will surface any rejected combinations. +3. **`Apple Silicon` criterion value.** Used by + `lifecycle/fv-ineligible-hardware`. The criterion exists + (`MatcherNameConstants.CD.APPLE_SILICON`) but the value strings need + confirmation. Likely `Yes` / `No`. +4. **Empty-value semantics.** Templates like `encryption/escrow-missing` + ("Recovery Key Type is empty") and `updates/rsr-not-applied` ("RSR is + empty") rely on a specific searchType+value combination for "is + empty." The exact form (likely `searchType: "is"` with `value: ""`, + or possibly a dedicated `"is null"` searchType) needs empirical + verification. The implementation plan should pin this down with a + single test against a live tenant before the templates ship. +5. **Verifying required-param templates.** `verify-templates` needs a + value to test parameterized templates. Required-param templates + (`updates/os-version-below`, `updates/major-version-behind`, + `lifecycle/jamf-binary-outdated`) have no defaults. Decision: the + verify runner uses sensible test values it hardcodes per template + (e.g., `--below-version=15.0`, `--major-below=15`, + `--below-version=11.0.0`). These are inline in `verify.go`, not part + of the public template API. +6. **`encryption/encryption-stalled` precision.** Current criteria + match anything not `All Partitions Encrypted` — which includes + `Not Encrypted` and `N/A` devices, not just stalled ones. The + implementation plan should consider tightening to a specific value + list (`Boot Partitions Encrypted` OR `Some Partitions Encrypted`) + via two criteria joined with `andOr: "or"`. Trade-off: tighter + matching but a multi-criterion build path. + +## Success criteria + +- `pro smart-group templates` prints the full curated library grouped by + category, with parameter signatures shown for parameterized templates. +- `pro smart-group preview --template ` produces a JSON body + identical to what `apply` would POST, byte-for-byte (other than the + name field placeholder). +- `pro smart-group apply --template --name ` creates a + smart group whose membership count is reported to the user. + Idempotent across re-runs with the same name. +- `pro smart-group verify-templates` runs the full library against a + live tenant, reports OK/warning/error per template, and cleans up + temp groups by default. +- All criterion-name strings used by templates are sourced from JSS + canonical files and documented with file:line citations in + `criteria.go` comments. +- No changes to `generator/`, `specs/`, or any generated file. + +## Follow-on (deferred to a future cycle) + +- External (admin-authored) YAML templates loaded from + `~/.config/jamf-cli/smart-group-templates/`. Architecture leaves + space for this — the `Library` map can be extended by a loader at + startup. +- Mobile-device smart-group templates and user smart-group templates. +- Multi-parameter templates if a real need emerges that can't be split + into discrete templates. +- The `pro filevault` read-side commands (`key`, `status`, + `escrow-audit`) from the precursor spec. +- Generalization across verticals: when a template library grows to + cover MSU / PSSO / etc., consider factoring shared infrastructure + into a generic `pro template` namespace. From bba258f90140f1d7de014a59b4cbcd630306285d Mon Sep 17 00:00:00 2001 From: Keaton Svoma Date: Wed, 13 May 2026 09:03:15 -0500 Subject: [PATCH 23/23] =?UTF-8?q?fix(smartgroup):=20address=20#205=20revie?= =?UTF-8?q?w=20=E2=80=94=20input=20validation,=20cleanup=20errors,=20docs?= =?UTF-8?q?=20placement?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Five fixes from the PR #205 principal-engineer review: (1) Reject unknown template param flags. `registerTemplateParamFlags` declares the union of every template's params on every preview/apply command, so `apply --template encryption/not-encrypted --stalled-after 14` was silently dropping the bogus flag. Now `collectParamValues` walks the command's full flag set and errors on any Changed param-style flag that isn't in the chosen template's ParamSpec list. Tests cover apply, preview, and the "this param does belong to this template" happy path. (2) Reject smart-group names containing `"` or `\`. `lookupSmartGroupByName` built `name==""` as an RSQL filter without escaping internal quotes; a name with `"` produced a malformed filter the server may parse unpredictably. Validate at the top of `runApplyFlow` with a clear error rather than relying on RSQL escaping rules. (3) `deleteGroup` (verify.go) now checks HTTP status and returns errors. Previously returned nil regardless of status, so verify-templates could leave orphaned `__verify_*` smart groups silently. `RunOneVerification` surfaces the cleanup error via the result's Error field — the verify itself still passes; only the cleanup failed. (4) `verify.go:createTempGroup` now checks the json.Marshal error, matching the pattern used in `pro_smartgroup.go:createSmartGroup`. Style consistency fix; the error path is essentially impossible for the struct shape but the inconsistency was a real code smell. (5) Move spec + plan docs from `docs/solutions/design-patterns/` to `docs/plans/`. Per `docs/solutions/README.md`, solutions are postmortems and reusable patterns; these are implementation plans for one feature and belong with the other plans. Co-Authored-By: Claude Opus 4.7 (1M context) --- ...o-smart-group-templates-plan-2026-05-12.md | 0 ...o-smart-group-templates-spec-2026-05-12.md | 0 internal/commands/pro_smartgroup.go | 84 +++++++++++++++++++ internal/commands/pro_smartgroup_test.go | 76 +++++++++++++++++ internal/smartgroup/verify.go | 41 ++++++--- internal/smartgroup/verify_test.go | 28 +++++++ 6 files changed, 218 insertions(+), 11 deletions(-) rename docs/{solutions/design-patterns => plans}/pro-smart-group-templates-plan-2026-05-12.md (100%) rename docs/{solutions/design-patterns => plans}/pro-smart-group-templates-spec-2026-05-12.md (100%) diff --git a/docs/solutions/design-patterns/pro-smart-group-templates-plan-2026-05-12.md b/docs/plans/pro-smart-group-templates-plan-2026-05-12.md similarity index 100% rename from docs/solutions/design-patterns/pro-smart-group-templates-plan-2026-05-12.md rename to docs/plans/pro-smart-group-templates-plan-2026-05-12.md diff --git a/docs/solutions/design-patterns/pro-smart-group-templates-spec-2026-05-12.md b/docs/plans/pro-smart-group-templates-spec-2026-05-12.md similarity index 100% rename from docs/solutions/design-patterns/pro-smart-group-templates-spec-2026-05-12.md rename to docs/plans/pro-smart-group-templates-spec-2026-05-12.md diff --git a/internal/commands/pro_smartgroup.go b/internal/commands/pro_smartgroup.go index 9520ada..bad8716 100644 --- a/internal/commands/pro_smartgroup.go +++ b/internal/commands/pro_smartgroup.go @@ -14,11 +14,27 @@ import ( "strings" "github.com/spf13/cobra" + "github.com/spf13/pflag" "github.com/Jamf-Concepts/jamf-cli/internal/registry" "github.com/Jamf-Concepts/jamf-cli/internal/smartgroup" ) +// cliShapeFlags are the flag names that describe the command's CLI surface +// (not template params). validateNoUnknownTemplateParams skips these when +// checking for unknown-template-param errors. +var cliShapeFlags = map[string]struct{}{ + "template": {}, + "name": {}, + "recalculate": {}, + "dry-run": {}, + "yes": {}, + "category": {}, + "output": {}, + "no-cleanup": {}, + "json": {}, +} + func newSmartGroupCmd(cliCtx *registry.CLIContext) *cobra.Command { cmd := &cobra.Command{ Use: "smart-group", @@ -221,9 +237,13 @@ func registerTemplateParamFlags(cmd *cobra.Command) { type flagReader interface { GetString(string) (string, error) Changed(string) bool + VisitAll(func(*pflag.Flag)) } func collectParamValues(tmpl smartgroup.Template, flags flagReader) (map[string]any, error) { + if err := validateNoUnknownTemplateParams(tmpl, flags); err != nil { + return nil, err + } out := make(map[string]any, len(tmpl.Params)) for _, p := range tmpl.Params { if !flags.Changed(p.Name) { @@ -238,6 +258,67 @@ func collectParamValues(tmpl smartgroup.Template, flags flagReader) (map[string] return out, nil } +// validateNoUnknownTemplateParams errors if the user set a -- flag that +// belongs to a different template. registerTemplateParamFlags declares the +// union of every template's param flags on the command, so cobra silently +// accepts them; without this check the user's value would be dropped. +func validateNoUnknownTemplateParams(tmpl smartgroup.Template, flags flagReader) error { + own := make(map[string]struct{}, len(tmpl.Params)) + for _, p := range tmpl.Params { + own[p.Name] = struct{}{} + } + var bad []string + flags.VisitAll(func(f *pflag.Flag) { + if !f.Changed { + return + } + if _, ok := cliShapeFlags[f.Name]; ok { + return + } + if _, ok := own[f.Name]; ok { + return + } + bad = append(bad, f.Name) + }) + if len(bad) == 0 { + return nil + } + sort.Strings(bad) + first := bad[0] + hint := "this template has no params" + if len(tmpl.Params) > 0 { + names := make([]string, 0, len(tmpl.Params)) + for _, p := range tmpl.Params { + names = append(names, "--"+p.Name) + } + sort.Strings(names) + hint = "this template accepts: " + strings.Join(names, ", ") + } + owners := templatesAcceptingParam(first) + if len(owners) > 0 { + return fmt.Errorf("template %s does not accept --%s (%s; templates with --%s: %s)", + tmpl.Slug, first, hint, first, strings.Join(owners, ", ")) + } + return fmt.Errorf("template %s does not accept --%s (%s)", tmpl.Slug, first, hint) +} + +// templatesAcceptingParam returns the sorted slugs of templates whose +// ParamSpec contains the given flag name. Used to build actionable error +// messages. +func templatesAcceptingParam(name string) []string { + var out []string + for _, t := range smartgroup.All() { + for _, p := range t.Params { + if p.Name == name { + out = append(out, t.Slug) + break + } + } + } + sort.Strings(out) + return out +} + func unknownTemplateError(slug string) error { suggestions := smartgroup.FuzzyMatch(slug) if len(suggestions) == 0 { @@ -310,6 +391,9 @@ func printDryRun(out io.Writer, req smartgroup.SmartGroupRequest) error { } func runApplyFlow(ctx context.Context, out io.Writer, client registry.HTTPClient, req smartgroup.SmartGroupRequest, recalculate, yes bool) error { + if strings.ContainsAny(req.Name, `"\`) { + return fmt.Errorf(`smart group names containing %q or %q are not supported`, `"`, `\`) + } existingID, err := lookupSmartGroupByName(ctx, client, req.Name) if err != nil { return err diff --git a/internal/commands/pro_smartgroup_test.go b/internal/commands/pro_smartgroup_test.go index 6b771f7..4018b74 100644 --- a/internal/commands/pro_smartgroup_test.go +++ b/internal/commands/pro_smartgroup_test.go @@ -316,6 +316,82 @@ func TestApply_ExistingGroupYesRequired(t *testing.T) { } } +func TestApply_RejectsUnknownTemplateParam(t *testing.T) { + // --stalled-after belongs to encryption/encryption-stalled, not + // encryption/not-encrypted. Even with --dry-run (no HTTP), the validator + // in collectParamValues must reject before doing anything else. + client := &fakeSGClient{} + _, err := runSmartGroupApply( + t, client, + "--template", "encryption/not-encrypted", + "--name", "X", + "--stalled-after", "14", + "--dry-run", + ) + if err == nil { + t.Fatal("expected error for unknown template param, got nil") + } + if !strings.Contains(err.Error(), "stalled-after") { + t.Errorf("expected error to mention stalled-after, got: %v", err) + } + if len(client.calls) != 0 { + t.Errorf("expected no HTTP calls when validation fails, got %d", len(client.calls)) + } +} + +func TestApply_AcceptsTemplateOwnParam(t *testing.T) { + // Same flag, correct template — should NOT trigger the unknown-param + // validation error. --dry-run short-circuits before HTTP. + client := &fakeSGClient{} + out, err := runSmartGroupApply( + t, client, + "--template", "encryption/encryption-stalled", + "--name", "X", + "--stalled-after", "14", + "--dry-run", + ) + if err != nil { + t.Fatalf("apply: %v\n%s", err, out) + } + if !strings.Contains(out, `"value": "14"`) { + t.Errorf("expected stalled-after=14 in dry-run output: %s", out) + } +} + +func TestPreview_RejectsUnknownTemplateParam(t *testing.T) { + _, _, err := runSmartGroupCmd( + t, "preview", + "--template", "encryption/not-encrypted", + "--stalled-after", "14", + ) + if err == nil { + t.Fatal("expected error for unknown template param, got nil") + } + if !strings.Contains(err.Error(), "stalled-after") { + t.Errorf("expected error to mention stalled-after, got: %v", err) + } +} + +func TestApply_RejectsNameWithQuote(t *testing.T) { + // Names containing " or \ are rejected before any HTTP call. + client := &fakeSGClient{} + _, err := runSmartGroupApply( + t, client, + "--template", "encryption/not-encrypted", + "--name", `has "quote"`, + "--yes", + ) + if err == nil { + t.Fatal("expected error for name containing quote, got nil") + } + if !strings.Contains(err.Error(), `"`) || !strings.Contains(err.Error(), `\`) { + t.Errorf("expected error to mention unsupported chars, got: %v", err) + } + if len(client.calls) != 0 { + t.Errorf("expected no HTTP calls when name validation fails, got %d", len(client.calls)) + } +} + func TestVerifyTemplates_CategoryRuns(t *testing.T) { // Each template in the encryption category produces 4 HTTP calls // (POST create + recalc + membership + DELETE cleanup). We queue 6 templates * 4 = 24 responses. diff --git a/internal/smartgroup/verify.go b/internal/smartgroup/verify.go index f82f21c..f9d847e 100644 --- a/internal/smartgroup/verify.go +++ b/internal/smartgroup/verify.go @@ -70,23 +70,35 @@ func RunOneVerification(ctx context.Context, client HTTPDoer, tmpl Template, cle _ = recalcGroup(ctx, client, id) // recalc failure is non-fatal + result := VerifyResult{Slug: tmpl.Slug} count, err := CountMembers(ctx, client, id) if err != nil { - if cleanup { - _ = deleteGroup(ctx, client, id) + result.Outcome = VerifyError + result.Error = err.Error() + } else { + result.MemberCount = count + if count == 0 { + result.Outcome = VerifyZeroMatch + } else { + result.Outcome = VerifyOK } - return VerifyResult{Slug: tmpl.Slug, Outcome: VerifyError, Error: err.Error()} } if cleanup { - _ = deleteGroup(ctx, client, id) + if delErr := deleteGroup(ctx, client, id); delErr != nil { + // Verify itself succeeded (or had a CountMembers error already + // captured); surface the cleanup failure without changing the + // outcome. Append if there's already an error string. + cleanupMsg := fmt.Sprintf("verify OK but cleanup failed: %v", delErr) + if result.Error == "" { + result.Error = cleanupMsg + } else { + result.Error = result.Error + "; " + cleanupMsg + } + } } - outcome := VerifyOK - if count == 0 { - outcome = VerifyZeroMatch - } - return VerifyResult{Slug: tmpl.Slug, Outcome: outcome, MemberCount: count} + return result } func sanitizeSlug(s string) string { @@ -108,7 +120,10 @@ func sanitizeSlug(s string) string { } func createTempGroup(ctx context.Context, client HTTPDoer, req SmartGroupRequest) (string, error) { - body, _ := json.Marshal(req) + body, err := json.Marshal(req) + if err != nil { + return "", fmt.Errorf("marshal request: %w", err) + } resp, err := client.Do(ctx, http.MethodPost, "/v2/computer-groups/smart-groups", bytes.NewReader(body)) if err != nil { return "", err @@ -144,6 +159,10 @@ func deleteGroup(ctx context.Context, client HTTPDoer, id string) error { if err != nil { return err } - _ = resp.Body.Close() + defer func() { _ = resp.Body.Close() }() + if resp.StatusCode < 200 || resp.StatusCode >= 300 { + buf, _ := io.ReadAll(resp.Body) + return fmt.Errorf("delete smart group %s: HTTP %d: %s", id, resp.StatusCode, string(buf)) + } return nil } diff --git a/internal/smartgroup/verify_test.go b/internal/smartgroup/verify_test.go index efec2d3..f5f6427 100644 --- a/internal/smartgroup/verify_test.go +++ b/internal/smartgroup/verify_test.go @@ -85,6 +85,34 @@ func TestVerify_RunOneTemplate_CreateError(t *testing.T) { } } +func TestRunOneVerification_DeleteFailure(t *testing.T) { + // Create succeeds, recalc succeeds, count succeeds — verify is OK. + // DELETE returns 500 — Outcome stays VerifyOK, but Error captures the + // cleanup failure so the caller (the CLI) can surface it as a warning. + tmpl, _ := Lookup("encryption/not-encrypted") + client := &seqClient{ + queue: []*http.Response{ + jsonResp(201, map[string]any{"id": "999"}), + jsonResp(200, map[string]any{}), + jsonResp(200, map[string]any{"members": []int{1, 2}}), + jsonResp(500, map[string]any{"errors": []string{"boom"}}), + }, + } + result := RunOneVerification(context.Background(), client, tmpl, true) + if result.Outcome != VerifyOK { + t.Errorf("expected Outcome=OK (verify succeeded; only cleanup failed), got %v", result.Outcome) + } + if result.Error == "" { + t.Error("expected Error to capture cleanup failure") + } + if !strings.Contains(result.Error, "cleanup failed") { + t.Errorf("expected 'cleanup failed' in Error, got: %q", result.Error) + } + if result.MemberCount != 2 { + t.Errorf("expected MemberCount=2, got %d", result.MemberCount) + } +} + func TestVerify_NoCleanupSkipsDelete(t *testing.T) { tmpl, _ := Lookup("encryption/not-encrypted") client := &seqClient{