From c5ed0bf66a941d9be70fe5bb9dcd62d6660c035d Mon Sep 17 00:00:00 2001 From: Neil Martin Date: Mon, 11 May 2026 14:38:31 +0100 Subject: [PATCH 1/8] feat(ebooks): add classic-ebooks command group and related endpoints --- internal/commands/groups.go | 1 + .../commands/pro/generated/backup_registry.go | 1 + .../commands/pro/generated/classic_ebooks.go | 508 ++++++++++++++++++ .../pro/generated/classic_registry.go | 1 + internal/commands/pro/generated/provenance.go | 2 +- .../commands/pro/generated/smoke_registry.go | 2 + specs/classic/resources.yaml | 7 + 7 files changed, 521 insertions(+), 1 deletion(-) create mode 100644 internal/commands/pro/generated/classic_ebooks.go diff --git a/internal/commands/groups.go b/internal/commands/groups.go index f0316c1b..d9ed5498 100644 --- a/internal/commands/groups.go +++ b/internal/commands/groups.go @@ -438,6 +438,7 @@ var proGroupMap = map[string]string{ "classic-restricted-software": groupClassicConfig, "classic-allowed-file-extensions": groupClassicConfig, "classic-mac-apps": groupClassicConfig, + "classic-ebooks": groupClassicConfig, "classic-mobile-apps": groupClassicMobile, "classic-ibeacons": groupClassicConfig, "classic-classes": groupClassicConfig, diff --git a/internal/commands/pro/generated/backup_registry.go b/internal/commands/pro/generated/backup_registry.go index 44f87a57..c4700223 100644 --- a/internal/commands/pro/generated/backup_registry.go +++ b/internal/commands/pro/generated/backup_registry.go @@ -64,6 +64,7 @@ var BackupEndpoints = map[string]BackupEndpoint{ "classic-disk-encryption-configs": {ListPath: "/JSSResource/diskencryptionconfigurations", GetPath: "/JSSResource/diskencryptionconfigurations/id/{id}", IsClassic: true, WrapperKey: "diskencryptionconfigurations", SingularKey: "disk_encryption_configuration", ListSubset: "", NameField: "", IDField: ""}, "classic-distribution-points": {ListPath: "/JSSResource/distributionpoints", GetPath: "/JSSResource/distributionpoints/id/{id}", IsClassic: true, WrapperKey: "distributionpoints", SingularKey: "distribution_point", ListSubset: "", NameField: "", IDField: ""}, "classic-dock-items": {ListPath: "/JSSResource/dockitems", GetPath: "/JSSResource/dockitems/id/{id}", IsClassic: true, WrapperKey: "dockitems", SingularKey: "dock_item", ListSubset: "", NameField: "", IDField: ""}, + "classic-ebooks": {ListPath: "/JSSResource/ebooks", GetPath: "/JSSResource/ebooks/id/{id}", IsClassic: true, WrapperKey: "ebooks", SingularKey: "ebook", ListSubset: "", NameField: "", IDField: ""}, "classic-ibeacons": {ListPath: "/JSSResource/ibeacons", GetPath: "/JSSResource/ibeacons/id/{id}", IsClassic: true, WrapperKey: "ibeacons", SingularKey: "ibeacon", ListSubset: "", NameField: "", IDField: ""}, "classic-jwt-configs": {ListPath: "/JSSResource/jsonwebtokenconfigurations", GetPath: "/JSSResource/jsonwebtokenconfigurations/id/{id}", IsClassic: true, WrapperKey: "jsonwebtokenconfigurations", SingularKey: "json_web_token_configuration", ListSubset: "", NameField: "", IDField: ""}, "classic-licensed-software": {ListPath: "/JSSResource/licensedsoftware", GetPath: "/JSSResource/licensedsoftware/id/{id}", IsClassic: true, WrapperKey: "licensedsoftware", SingularKey: "licensed_software", ListSubset: "", NameField: "", IDField: ""}, diff --git a/internal/commands/pro/generated/classic_ebooks.go b/internal/commands/pro/generated/classic_ebooks.go new file mode 100644 index 00000000..5f44fbfc --- /dev/null +++ b/internal/commands/pro/generated/classic_ebooks.go @@ -0,0 +1,508 @@ +// Copyright 2026, Jamf Software LLC +// Code generated by jamf-cli generator (classic). DO NOT EDIT. +package generated + +import ( + "bytes" + "encoding/json" + "fmt" + "io" + "net/http" + "net/url" + "os" + + "github.com/Jamf-Concepts/jamf-cli/internal/cooldown" + "github.com/Jamf-Concepts/jamf-cli/internal/registry" + "github.com/Jamf-Concepts/jamf-cli/internal/scope" + "github.com/Jamf-Concepts/jamf-cli/internal/xmlconv" + "github.com/spf13/cobra" +) + +// NewClassicEbooksCmd creates the classic-ebooks command group +func NewClassicEbooksCmd(ctx *registry.CLIContext) *cobra.Command { + cmd := &cobra.Command{ + Use: "classic-ebooks", + Short: "eBook distributions (Classic API)", + Long: `Manage ebook distributions via the Jamf Pro Classic API (/JSSResource/).`, + } + + cmd.AddCommand(newClassicEbooksListCmd(ctx)) + + cmd.AddCommand(newClassicEbooksGetCmd(ctx)) + + cmd.AddCommand(newClassicEbooksCreateCmd(ctx)) + + cmd.AddCommand(newClassicEbooksUpdateCmd(ctx)) + + cmd.AddCommand(newClassicEbooksDeleteCmd(ctx)) + + cmd.AddCommand(newClassicEbooksApplyCmd(ctx)) + + cmd.AddCommand(scope.NewScopeCmd(ctx, scope.Resource{ + APIPath: "ebooks", + SingularKey: "ebook", + })) + + return cmd +} + +func newClassicEbooksListCmd(ctx *registry.CLIContext) *cobra.Command { + return &cobra.Command{ + Use: "list", + Short: "List all ebooks", + Example: ` # List all ebooks + jamf-cli pro classic-ebooks list + + # List ebooks and extract IDs + jamf-cli pro classic-ebooks list --field id`, + RunE: func(cmd *cobra.Command, args []string) error { + reqCtx := cmd.Context() + resp, err := ctx.Client.Do(reqCtx, "GET", "/JSSResource/ebooks", nil) + if err != nil { + return err + } + defer resp.Body.Close() + + // Classic API returns XML list responses. + body, err := io.ReadAll(resp.Body) + if err != nil { + return err + } + // Default to pretty-printed XML; use -o json/yaml/table/csv for structured output. + // -o xml = pretty-printed XML, -o raw = exact wire bytes. + if (!cmd.Flags().Changed("output") && !cmd.Flags().Changed("field") && ctx.Output.Format() == "json") || ctx.Output.Format() == "xml" || ctx.Output.Format() == "raw" { + return ctx.Output.PrintBytes(body) + } + if xmlconv.IsXML(body) { + items, err := xmlconv.ExtractListItems(body) + if err == nil { + jsonItems, err := json.Marshal(items) + if err == nil { + return ctx.Output.PrintRaw(jsonItems) + } + } + return ctx.Output.PrintRaw(body) + } + // JSON fallback + var wrapper map[string]json.RawMessage + if err := json.Unmarshal(body, &wrapper); err == nil { + if inner, ok := wrapper["ebooks"]; ok { + return ctx.Output.PrintRaw(inner) + } + } + return ctx.Output.PrintRaw(body) + }, + } +} + +func newClassicEbooksGetCmd(ctx *registry.CLIContext) *cobra.Command { + var ( + flagName string + ) + + cmd := &cobra.Command{ + Use: "get []", + Short: "Get a ebook by ID", + Example: ` # Get a ebook by ID + jamf-cli pro classic-ebooks get 1 + + # Get a ebook by name + jamf-cli pro classic-ebooks get --name "Example" + + # Get a ebook and output as YAML + jamf-cli pro classic-ebooks get 1 -o yaml`, + Args: cobra.MaximumNArgs(1), + RunE: func(cmd *cobra.Command, args []string) error { + reqCtx := cmd.Context() + + // Resolve lookup: check flags first, then positional ID + var path string + if flagName != "" { + path = fmt.Sprintf("/JSSResource/ebooks/name/%s", url.PathEscape(flagName)) + } else if len(args) > 0 { + path = fmt.Sprintf("/JSSResource/ebooks/id/%s", url.PathEscape(args[0])) + } else { + return fmt.Errorf("provide an argument, --name") + } + + resp, err := ctx.Client.Do(reqCtx, "GET", path, nil) + if err != nil { + return err + } + defer resp.Body.Close() + + // Classic API returns XML; pass through by default. + body, err := io.ReadAll(resp.Body) + if err != nil { + return err + } + // Default to pretty-printed XML; use -o json/yaml/table/csv for structured output. + // -o xml = pretty-printed XML, -o raw = exact wire bytes. + if (!cmd.Flags().Changed("output") && !cmd.Flags().Changed("field") && ctx.Output.Format() == "json") || ctx.Output.Format() == "xml" || ctx.Output.Format() == "raw" { + return ctx.Output.PrintBytes(body) + } + if xmlconv.IsXML(body) { + if jsonBody, err := xmlconv.ToJSON(body); err == nil { + body = jsonBody + } + } + var wrapper map[string]json.RawMessage + if err := json.Unmarshal(body, &wrapper); err == nil { + if inner, ok := wrapper["ebook"]; ok { + return ctx.Output.PrintRaw(inner) + } + } + return ctx.Output.PrintRaw(body) + }, + } + + cmd.Flags().StringVar(&flagName, "name", "", "Look up ebook by name") + + return cmd +} + +func newClassicEbooksCreateCmd(ctx *registry.CLIContext) *cobra.Command { + cmd := &cobra.Command{ + Use: "create", + Short: "Create a ebook", + Long: "Create a new ebook. Reads XML body from stdin.", + Example: ` # Create a ebook from XML + cat ebook.xml | jamf-cli pro classic-ebooks create`, + RunE: func(cmd *cobra.Command, args []string) error { + reqCtx := cmd.Context() + + var body io.Reader + stat, _ := os.Stdin.Stat() + if (stat.Mode() & os.ModeCharDevice) == 0 { + body = os.Stdin + } else { + return fmt.Errorf("request body required on stdin (pipe XML input)") + } + + resp, err := ctx.Client.Do(reqCtx, "POST", "/JSSResource/ebooks/id/0", body) + + if err != nil { + return err + } + defer resp.Body.Close() + + return ctx.Output.PrintResponse(resp) + }, + } + return cmd +} + +func newClassicEbooksUpdateCmd(ctx *registry.CLIContext) *cobra.Command { + var flagName string + + cmd := &cobra.Command{ + Use: "update []", + Short: "Update a ebook", + Long: "Update an existing ebook by ID. Reads XML body from stdin.", + Example: ` # Update a ebook from XML + cat ebook.xml | jamf-cli pro classic-ebooks update 1`, + Args: cobra.MaximumNArgs(1), + RunE: func(cmd *cobra.Command, args []string) error { + reqCtx := cmd.Context() + + var body io.Reader + stat, _ := os.Stdin.Stat() + if (stat.Mode() & os.ModeCharDevice) == 0 { + body = os.Stdin + } else { + return fmt.Errorf("request body required on stdin (pipe XML input)") + } + + var path string + if flagName != "" { + path = fmt.Sprintf("/JSSResource/ebooks/name/%s", url.PathEscape(flagName)) + } else if len(args) > 0 { + path = fmt.Sprintf("/JSSResource/ebooks/id/%s", url.PathEscape(args[0])) + } else { + return fmt.Errorf("provide an argument or --name") + } + + resp, err := ctx.Client.Do(reqCtx, "PUT", path, body) + if err != nil { + return err + } + defer resp.Body.Close() + + return ctx.Output.PrintResponse(resp) + }, + } + + cmd.Flags().StringVar(&flagName, "name", "", "Look up ebook by name") + + return cmd +} + +func newClassicEbooksDeleteCmd(ctx *registry.CLIContext) *cobra.Command { + var ( + flagYes bool + flagDryRun bool + flagName string + fromFile string + ) + + cmd := &cobra.Command{ + Use: "delete []", + Short: "Delete a ebook", + Example: ` # Delete a ebook (with confirmation) + jamf-cli pro classic-ebooks delete 1 + + # Delete by name + jamf-cli pro classic-ebooks delete --name "Example" --yes + + # Delete without confirmation prompt + jamf-cli pro classic-ebooks delete 1 --yes`, + Args: cobra.MaximumNArgs(1), + RunE: func(cmd *cobra.Command, args []string) error { + reqCtx := cmd.Context() + + // --from-file: bulk delete from a file of IDs or names + if fromFile != "" { + entries, err := readDeleteFile(fromFile) + if err != nil { + return fmt.Errorf("reading --from-file: %w", err) + } + if len(entries) == 0 { + return fmt.Errorf("--from-file %q: no entries found", fromFile) + } + type bulkEntry struct{ id, label string } + bulk := make([]bulkEntry, 0, len(entries)) + noInputBulk, _ := cmd.Flags().GetBool("no-input") + for _, entry := range entries { + if isNumericID(entry) { + if entry == "0" { + return fmt.Errorf("--from-file: ID 0 is not valid (Jamf Pro uses 0 as a sentinel value)") + } + bulk = append(bulk, bulkEntry{id: entry, label: entry}) + } else { + var resolvedID string + if resolvedID == "" { + id, err := resolveClassicNameToIDForApply(reqCtx, ctx.Client, "ebooks", "ebooks", entry, noInputBulk) + if err != nil { + return fmt.Errorf("resolving %q: %w", entry, err) + } + resolvedID = id + } + if resolvedID == "" { + return fmt.Errorf("no ebook found matching %q", entry) + } + bulk = append(bulk, bulkEntry{id: resolvedID, label: entry}) + } + } + // Deduplicate resolved IDs to avoid double-delete errors. + { + seen := make(map[string]bool, len(bulk)) + deduped := bulk[:0] + for _, e := range bulk { + if !seen[e.id] { + seen[e.id] = true + deduped = append(deduped, e) + } + } + bulk = deduped + } + if flagDryRun { + for _, e := range bulk { + fmt.Fprintf(os.Stderr, "[dry-run] Would delete ebook %q (id: %s)\n", e.label, e.id) + } + return nil + } + if !flagYes { + if noInputBulk { + return fmt.Errorf("destructive operation requires --yes when --no-input is set") + } + fmt.Fprintf(os.Stderr, "⚠️ This will delete %d ebooks. Type 'yes' to confirm: ", len(bulk)) + var confirm string + fmt.Scanln(&confirm) + if confirm != "yes" { + return fmt.Errorf("aborted") + } + } + if err := cooldown.Enforce(ctx.ProfileName, noInputBulk, ctx.DestructiveCooldown); err != nil { + return err + } + for _, e := range bulk { + delPath := fmt.Sprintf("/JSSResource/ebooks/id/%s", url.PathEscape(e.id)) + resp, err := ctx.Client.Do(reqCtx, "DELETE", delPath, nil) + if err != nil { + return fmt.Errorf("deleting %q (id: %s): %w", e.label, e.id, err) + } + resp.Body.Close() + if resp.StatusCode == http.StatusNoContent || resp.StatusCode == http.StatusOK { + fmt.Fprintf(os.Stderr, "Deleted ebook %q (id: %s)\n", e.label, e.id) + } else { + return fmt.Errorf("delete %q (id: %s): HTTP %d", e.label, e.id, resp.StatusCode) + } + } + cooldown.Record(ctx.ProfileName) + return nil + } + + // Resolve ID from --name or positional arg + var resolvedID string + noInput, _ := cmd.Flags().GetBool("no-input") + if flagName != "" { + id, err := resolveClassicNameToIDForApply(reqCtx, ctx.Client, "ebooks", "ebooks", flagName, noInput) + if err != nil { + return err + } + if id == "" { + return fmt.Errorf("no ebook found with name %q", flagName) + } + resolvedID = id + } else if len(args) > 0 { + resolvedID = args[0] + } else { + return fmt.Errorf("provide an argument or --name") + } + + if flagDryRun { + fmt.Fprintf(os.Stderr, "[dry-run] Would delete ebook %s\n", resolvedID) + return nil + } + if !flagYes { + if noInput { + return fmt.Errorf("destructive operation requires --yes when --no-input is set") + } + fmt.Fprintf(os.Stderr, "This will delete ebook %s. Type 'yes' to confirm: ", resolvedID) + var confirm string + fmt.Scanln(&confirm) + if confirm != "yes" { + return fmt.Errorf("aborted") + } + } + + if err := cooldown.Enforce(ctx.ProfileName, noInput, ctx.DestructiveCooldown); err != nil { + return err + } + path := fmt.Sprintf("/JSSResource/ebooks/id/%s", url.PathEscape(resolvedID)) + + resp, err := ctx.Client.Do(reqCtx, "DELETE", path, nil) + if err != nil { + return err + } + defer resp.Body.Close() + + if resp.StatusCode == http.StatusNoContent || resp.StatusCode == http.StatusOK { + cooldown.Record(ctx.ProfileName) + fmt.Fprintln(os.Stderr, "Deleted successfully") + return nil + } + + return ctx.Output.PrintResponse(resp) + }, + } + + cmd.Flags().BoolVar(&flagYes, "yes", false, "Skip confirmation prompt") + cmd.Flags().BoolVarP(&flagDryRun, "dry-run", "n", false, "Preview without executing") + cmd.Flags().StringVar(&flagName, "name", "", "Look up ebook by name") + cmd.Flags().StringVar(&fromFile, "from-file", "", "Path to file listing IDs or names to delete (one per line, # comments ignored)") + cmd.MarkFlagsMutuallyExclusive("from-file", "name") + + return cmd +} + +func newClassicEbooksApplyCmd(ctx *registry.CLIContext) *cobra.Command { + var ( + fromFile string + flagYes bool + flagDryRun bool + ) + + cmd := &cobra.Command{ + Use: "apply", + Short: "Create or replace a ebook by name", + Long: `Create or replace a ebook. Reads XML from --from-file or stdin. + +The name field in the input XML is used to check if the resource already +exists. If it does, the resource is replaced (with confirmation). +If not, a new resource is created.`, + Example: ` # Apply a ebook from an XML file + jamf-cli pro classic-ebooks apply --from-file ebook.xml + + # Apply from stdin + cat ebook.xml | jamf-cli pro classic-ebooks apply + + # Apply without replacement confirmation + jamf-cli pro classic-ebooks apply --from-file ebook.xml --yes`, + RunE: func(cmd *cobra.Command, _ []string) error { + reqCtx := cmd.Context() + + // Read input + data, err := readApplyInput(fromFile) + + if err != nil { + return err + } + + // Extract name: for fetch-merge-put resources, --name is the primary + // input (body typically empty); fall back to XML name if flag absent. + var name string + + name, err = extractClassicName(data, "ebook") + if err != nil { + return err + } + + // Check if resource exists by name (read-only, runs even in dry-run) + noInput, _ := cmd.Flags().GetBool("no-input") + id, err := resolveClassicNameToIDForApply(reqCtx, ctx.Client, "ebooks", "ebooks", name, noInput) + if err != nil { + return err + } + + if id == "" { + // Not found — create (not allowed for fetch-merge-put resources) + + if flagDryRun { + fmt.Fprintf(os.Stderr, "[dry-run] Would create ebook %q\n", name) + return nil + } + resp, err := ctx.Client.Do(reqCtx, "POST", "/JSSResource/ebooks/id/0", bytes.NewReader(data)) + if err != nil { + return err + } + defer resp.Body.Close() + fmt.Fprintf(os.Stderr, "Created ebook %q\n", name) + return ctx.Output.PrintResponse(resp) + + } + + // Found — replace + if flagDryRun { + fmt.Fprintf(os.Stderr, "[dry-run] Would replace ebook %q (id: %s)\n", name, id) + return nil + } + if !flagYes { + if noInput { + return fmt.Errorf("ebook %q already exists (id: %s); use --yes to replace when --no-input is set", name, id) + } + fmt.Fprintf(os.Stderr, "ebook %q already exists (id: %s) and will be replaced. Type 'yes' to confirm: ", name, id) + var confirm string + fmt.Scanln(&confirm) + if confirm != "yes" { + return fmt.Errorf("aborted") + } + } + + updatePath := fmt.Sprintf("/JSSResource/ebooks/id/%s", url.PathEscape(id)) + resp, err := ctx.Client.Do(reqCtx, "PUT", updatePath, bytes.NewReader(data)) + if err != nil { + return err + } + defer resp.Body.Close() + fmt.Fprintf(os.Stderr, "Replaced ebook %q (id: %s)\n", name, id) + return ctx.Output.PrintResponse(resp) + }, + } + + cmd.Flags().StringVar(&fromFile, "from-file", "", "Path to XML input file (or pipe XML to stdin)") + cmd.Flags().BoolVar(&flagYes, "yes", false, "Skip confirmation prompt when replacing") + cmd.Flags().BoolVarP(&flagDryRun, "dry-run", "n", false, "Preview without executing") + + return cmd +} diff --git a/internal/commands/pro/generated/classic_registry.go b/internal/commands/pro/generated/classic_registry.go index 4fb50752..8e1630da 100644 --- a/internal/commands/pro/generated/classic_registry.go +++ b/internal/commands/pro/generated/classic_registry.go @@ -42,6 +42,7 @@ func RegisterClassicCommands(root *cobra.Command, ctx *registry.CLIContext) { root.AddCommand(NewClassicDiskEncryptionConfigsCmd(ctx)) root.AddCommand(NewClassicDistributionPointsCmd(ctx)) root.AddCommand(NewClassicDockItemsCmd(ctx)) + root.AddCommand(NewClassicEbooksCmd(ctx)) root.AddCommand(NewClassicGsxConnectionCmd(ctx)) root.AddCommand(NewClassicIbeaconsCmd(ctx)) root.AddCommand(NewClassicJwtConfigsCmd(ctx)) diff --git a/internal/commands/pro/generated/provenance.go b/internal/commands/pro/generated/provenance.go index 7995e011..2db33389 100644 --- a/internal/commands/pro/generated/provenance.go +++ b/internal/commands/pro/generated/provenance.go @@ -173,5 +173,5 @@ var Sources = []SpecSource{ {File: "specs/VppLocations.yaml", SHA256: "d9b41c79c0cdba81706b16c8e6d0b86b8048d40d6d55ec66618af0ac00c533d0"}, {File: "specs/VppSubscriptions.yaml", SHA256: "184b834aeb0d21cf190050294f7869d57b482bbe1834ad5a1bd79dcfdeef7a55"}, {File: "specs/_MonolithLibrary.yaml", SHA256: "9d777dc71e1909d20db05b31451246a805565c856d7b2fc50d38ccb33269a89a"}, - {File: "specs/classic/resources.yaml", SHA256: "2c7679f3c1e8571d9f17b82e5f8b9d00c53735680ad53cfd90cda6d6edb76cda"}, + {File: "specs/classic/resources.yaml", SHA256: "1c97406322cb64a542ad286392ee781eee11cfeab5ff5ae322a4febdb494085d"}, } diff --git a/internal/commands/pro/generated/smoke_registry.go b/internal/commands/pro/generated/smoke_registry.go index 471dce9c..2b474b5d 100644 --- a/internal/commands/pro/generated/smoke_registry.go +++ b/internal/commands/pro/generated/smoke_registry.go @@ -90,6 +90,8 @@ func AllSmokeEndpoints() []SmokeEndpoint { {Resource: "classic-distribution-points", Operation: "list", Method: "GET", Path: "/JSSResource/distributionpoints", IsList: true, HasPathParams: false, IsClassic: true, WrapperKey: "distributionpoints", SingularKey: ""}, {Resource: "classic-dock-items", Operation: "get", Method: "GET", Path: "/JSSResource/dockitems/id/{id}", IsList: false, HasPathParams: true, IsClassic: true, WrapperKey: "", SingularKey: "dock_item"}, {Resource: "classic-dock-items", Operation: "list", Method: "GET", Path: "/JSSResource/dockitems", IsList: true, HasPathParams: false, IsClassic: true, WrapperKey: "dockitems", SingularKey: ""}, + {Resource: "classic-ebooks", Operation: "get", Method: "GET", Path: "/JSSResource/ebooks/id/{id}", IsList: false, HasPathParams: true, IsClassic: true, WrapperKey: "", SingularKey: "ebook"}, + {Resource: "classic-ebooks", Operation: "list", Method: "GET", Path: "/JSSResource/ebooks", IsList: true, HasPathParams: false, IsClassic: true, WrapperKey: "ebooks", SingularKey: ""}, {Resource: "classic-gsx-connection", Operation: "get", Method: "GET", Path: "/JSSResource/gsxconnection/id/{id}", IsList: false, HasPathParams: true, IsClassic: true, WrapperKey: "", SingularKey: "gsx_connection"}, {Resource: "classic-ibeacons", Operation: "get", Method: "GET", Path: "/JSSResource/ibeacons/id/{id}", IsList: false, HasPathParams: true, IsClassic: true, WrapperKey: "", SingularKey: "ibeacon"}, {Resource: "classic-ibeacons", Operation: "list", Method: "GET", Path: "/JSSResource/ibeacons", IsList: true, HasPathParams: false, IsClassic: true, WrapperKey: "ibeacons", SingularKey: ""}, diff --git a/specs/classic/resources.yaml b/specs/classic/resources.yaml index 65fa0dd3..a1c2c9c4 100644 --- a/specs/classic/resources.yaml +++ b/specs/classic/resources.yaml @@ -203,6 +203,13 @@ resources: singular: mobile_device_application scope: true + - name: ebooks + path: ebooks + description: eBook distributions + cli_name: classic-ebooks + singular: ebook + scope: true + - name: ibeacons path: ibeacons description: iBeacon configurations From f9024d148cb7473cee93e4924bb2e7c2a437269a Mon Sep 17 00:00:00 2001 From: Neil Martin Date: Mon, 11 May 2026 14:50:47 +0100 Subject: [PATCH 2/8] fix(scope): support UUID IDs in Classic API scope XML MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Classic API resources (e.g. ebook scope user groups) can return UUIDs instead of integers for scope item IDs. Typing NamedItem.ID and classicResourceXML.General.ID as int caused xml.Unmarshal to fail with strconv.ParseInt on any UUID value. Change both ID fields to string — no behaviour change for integer IDs, UUID IDs now round-trip correctly. Co-Authored-By: Claude Sonnet 4.6 --- internal/scope/scope.go | 4 ++-- internal/scope/scope_test.go | 18 +++++++++--------- internal/scope/types.go | 8 ++++++-- 3 files changed, 17 insertions(+), 13 deletions(-) diff --git a/internal/scope/scope.go b/internal/scope/scope.go index bec6ac86..019d74e3 100644 --- a/internal/scope/scope.go +++ b/internal/scope/scope.go @@ -38,11 +38,11 @@ func FetchScope(ctx context.Context, client registry.HTTPClient, res Resource, n return "", nil, fmt.Errorf("parsing %s XML: %w", res.SingularKey, err) } - if envelope.General.ID == 0 { + if envelope.General.ID == "" { return "", nil, fmt.Errorf("no ID in %s %q", res.SingularKey, name) } - return fmt.Sprintf("%d", envelope.General.ID), &envelope.Scope, nil + return envelope.General.ID, &envelope.Scope, nil } // PutScope writes an updated scope back to the Classic API via subset PUT. diff --git a/internal/scope/scope_test.go b/internal/scope/scope_test.go index 49a1aff0..064b3c90 100644 --- a/internal/scope/scope_test.go +++ b/internal/scope/scope_test.go @@ -59,8 +59,8 @@ func TestScopeXML_UnmarshalPolicy(t *testing.T) { t.Fatalf("unmarshal: %v", err) } - if env.General.ID != 42 { - t.Errorf("general.id = %d, want 42", env.General.ID) + if env.General.ID != "42" { + t.Errorf("general.id = %s, want 42", env.General.ID) } if !env.Scope.AllComputers { t.Error("all_computers should be true") @@ -99,7 +99,7 @@ func TestScopeXML_MarshalRoundTrip(t *testing.T) { s := ScopeXML{ AllComputers: true, ComputerGroups: ScopeItemSlice{ - Items: []NamedItem{{ID: 1, Name: "Group A"}, {Name: "Group B"}}, + Items: []NamedItem{{ID: "1", Name: "Group A"}, {Name: "Group B"}}, ElemName: "computer_group", }, Buildings: ScopeItemSlice{ @@ -165,7 +165,7 @@ func TestScopeUpdateXML_Marshal(t *testing.T) { func TestAddToScope_TargetComputerGroup(t *testing.T) { s := &ScopeXML{ ComputerGroups: ScopeItemSlice{ - Items: []NamedItem{{ID: 1, Name: "Existing"}}, + Items: []NamedItem{{ID: "1", Name: "Existing"}}, ElemName: "computer_group", }, } @@ -290,7 +290,7 @@ func TestAddToScope_NonPolicyLimitUserGroup(t *testing.T) { func TestRemoveFromScope_TargetComputerGroup(t *testing.T) { s := &ScopeXML{ ComputerGroups: ScopeItemSlice{ - Items: []NamedItem{{ID: 1, Name: "Keep"}, {ID: 2, Name: "Remove"}}, + Items: []NamedItem{{ID: "1", Name: "Keep"}, {ID: "2", Name: "Remove"}}, ElemName: "computer_group", }, } @@ -377,7 +377,7 @@ func TestRemoveFromScope_PolicyLimitUserGroup_InLimitations(t *testing.T) { }, Limitations: &LimitationsXML{ UserGroups: ScopeItemSlice{ - Items: []NamedItem{{ID: 5, Name: "Staff"}}, + Items: []NamedItem{{ID: "5", Name: "Staff"}}, ElemName: "user_group", }, }, @@ -494,7 +494,7 @@ func TestFlattenScope_BasicPolicy(t *testing.T) { s := &ScopeXML{ AllComputers: true, ComputerGroups: ScopeItemSlice{ - Items: []NamedItem{{ID: 1, Name: "Group A"}}, + Items: []NamedItem{{ID: "1", Name: "Group A"}}, }, Buildings: ScopeItemSlice{ Items: []NamedItem{{Name: "HQ"}}, @@ -539,8 +539,8 @@ func TestFlattenScope_PolicyUserGroupNoDuplicates(t *testing.T) { }, Limitations: &LimitationsXML{ UserGroups: ScopeItemSlice{Items: []NamedItem{ - {ID: 1, Name: "Staff"}, - {ID: 2, Name: "Faculty"}, + {ID: "1", Name: "Staff"}, + {ID: "2", Name: "Faculty"}, }}, NetworkSegments: ScopeItemSlice{Items: []NamedItem{{Name: "Corporate"}}}, }, diff --git a/internal/scope/types.go b/internal/scope/types.go index 5256ef63..cc1e7199 100644 --- a/internal/scope/types.go +++ b/internal/scope/types.go @@ -30,8 +30,10 @@ type ScopeTarget struct { // with tags alone. // NamedItem is an item identified by name (and optionally ID) in scope XML. +// ID is a string to accommodate both integer IDs (most resources) and UUID +// IDs (e.g. ebook scope user groups) returned by the Classic API. type NamedItem struct { - ID int `xml:"id,omitempty" json:"id,omitempty"` + ID string `xml:"id,omitempty" json:"id,omitempty"` Name string `xml:"name" json:"name"` } @@ -185,10 +187,12 @@ type ExclusionsXML struct { } // classicResourceXML captures general.id and scope from a Classic API GET. +// ID is a string to accommodate both integer IDs (most resources) and UUID +// IDs (e.g. ebooks) returned by the Classic API. type classicResourceXML struct { XMLName xml.Name General struct { - ID int `xml:"id"` + ID string `xml:"id"` Name string `xml:"name"` } `xml:"general"` Scope ScopeXML `xml:"scope"` From e766e44751d0429ed2a4ee9bc7f5331b58e223a7 Mon Sep 17 00:00:00 2001 From: Neil Martin Date: Mon, 11 May 2026 15:01:39 +0100 Subject: [PATCH 3/8] feat(classic): add classic-user-groups command group JSS user groups (/JSSResource/usergroups) were missing from the Classic API manifest despite being referenced in scope limitations on policies, ebooks, and other scoped resources. Adds full CRUD + apply under classic-user-groups in the Classic - Administration help group. Co-Authored-By: Claude Sonnet 4.6 --- internal/commands/groups.go | 1 + .../commands/pro/generated/backup_registry.go | 1 + .../pro/generated/classic_registry.go | 1 + .../pro/generated/classic_user_groups.go | 502 ++++++++++++++++++ internal/commands/pro/generated/provenance.go | 2 +- .../commands/pro/generated/smoke_registry.go | 2 + specs/classic/resources.yaml | 6 + 7 files changed, 514 insertions(+), 1 deletion(-) create mode 100644 internal/commands/pro/generated/classic_user_groups.go diff --git a/internal/commands/groups.go b/internal/commands/groups.go index d9ed5498..b4b50fb4 100644 --- a/internal/commands/groups.go +++ b/internal/commands/groups.go @@ -453,6 +453,7 @@ var proGroupMap = map[string]string{ "classic-distribution-points": groupClassicAdmin, "classic-software-update-servers": groupClassicAdmin, "classic-licensed-software": groupClassicAdmin, + "classic-user-groups": groupClassicAdmin, "classic-user-ext-attrs": groupClassicAdmin, "classic-vpp-accounts": groupClassicAdmin, "classic-vpp-assignments": groupClassicAdmin, diff --git a/internal/commands/pro/generated/backup_registry.go b/internal/commands/pro/generated/backup_registry.go index c4700223..7f4a2ded 100644 --- a/internal/commands/pro/generated/backup_registry.go +++ b/internal/commands/pro/generated/backup_registry.go @@ -87,6 +87,7 @@ var BackupEndpoints = map[string]BackupEndpoint{ "classic-restricted-software": {ListPath: "/JSSResource/restrictedsoftware", GetPath: "/JSSResource/restrictedsoftware/id/{id}", IsClassic: true, WrapperKey: "restrictedsoftware", SingularKey: "restricted_software", ListSubset: "", NameField: "", IDField: ""}, "classic-software-update-servers": {ListPath: "/JSSResource/softwareupdateservers", GetPath: "/JSSResource/softwareupdateservers/id/{id}", IsClassic: true, WrapperKey: "softwareupdateservers", SingularKey: "software_update_server", ListSubset: "", NameField: "", IDField: ""}, "classic-user-ext-attrs": {ListPath: "/JSSResource/userextensionattributes", GetPath: "/JSSResource/userextensionattributes/id/{id}", IsClassic: true, WrapperKey: "userextensionattributes", SingularKey: "user_extension_attribute", ListSubset: "", NameField: "", IDField: ""}, + "classic-user-groups": {ListPath: "/JSSResource/usergroups", GetPath: "/JSSResource/usergroups/id/{id}", IsClassic: true, WrapperKey: "usergroups", SingularKey: "user_group", ListSubset: "", NameField: "", IDField: ""}, "classic-vpp-accounts": {ListPath: "/JSSResource/vppaccounts", GetPath: "/JSSResource/vppaccounts/id/{id}", IsClassic: true, WrapperKey: "vppaccounts", SingularKey: "vpp_account", ListSubset: "", NameField: "", IDField: ""}, "classic-vpp-assignments": {ListPath: "/JSSResource/vppassignments", GetPath: "/JSSResource/vppassignments/id/{id}", IsClassic: true, WrapperKey: "vppassignments", SingularKey: "vpp_assignment", ListSubset: "", NameField: "", IDField: ""}, "classic-vpp-invitations": {ListPath: "/JSSResource/vppinvitations", GetPath: "/JSSResource/vppinvitations/id/{id}", IsClassic: true, WrapperKey: "vppinvitations", SingularKey: "vpp_invitation", ListSubset: "", NameField: "", IDField: ""}, diff --git a/internal/commands/pro/generated/classic_registry.go b/internal/commands/pro/generated/classic_registry.go index 8e1630da..78641844 100644 --- a/internal/commands/pro/generated/classic_registry.go +++ b/internal/commands/pro/generated/classic_registry.go @@ -71,6 +71,7 @@ func RegisterClassicCommands(root *cobra.Command, ctx *registry.CLIContext) { root.AddCommand(NewClassicSmtpServerCmd(ctx)) root.AddCommand(NewClassicSoftwareUpdateServersCmd(ctx)) root.AddCommand(NewClassicUserExtAttrsCmd(ctx)) + root.AddCommand(NewClassicUserGroupsCmd(ctx)) root.AddCommand(NewClassicVppAccountsCmd(ctx)) root.AddCommand(NewClassicVppAssignmentsCmd(ctx)) root.AddCommand(NewClassicVppInvitationsCmd(ctx)) diff --git a/internal/commands/pro/generated/classic_user_groups.go b/internal/commands/pro/generated/classic_user_groups.go new file mode 100644 index 00000000..9b4154b8 --- /dev/null +++ b/internal/commands/pro/generated/classic_user_groups.go @@ -0,0 +1,502 @@ +// Copyright 2026, Jamf Software LLC +// Code generated by jamf-cli generator (classic). DO NOT EDIT. +package generated + +import ( + "bytes" + "encoding/json" + "fmt" + "io" + "net/http" + "net/url" + "os" + + "github.com/Jamf-Concepts/jamf-cli/internal/cooldown" + "github.com/Jamf-Concepts/jamf-cli/internal/registry" + "github.com/Jamf-Concepts/jamf-cli/internal/xmlconv" + "github.com/spf13/cobra" +) + +// NewClassicUserGroupsCmd creates the classic-user-groups command group +func NewClassicUserGroupsCmd(ctx *registry.CLIContext) *cobra.Command { + cmd := &cobra.Command{ + Use: "classic-user-groups", + Short: "JSS user groups (referenced in policy/ebook scope limitations) (Classic API)", + Long: `Manage jss user groups (referenced in policy/ebook scope limitations) via the Jamf Pro Classic API (/JSSResource/).`, + } + + cmd.AddCommand(newClassicUserGroupsListCmd(ctx)) + + cmd.AddCommand(newClassicUserGroupsGetCmd(ctx)) + + cmd.AddCommand(newClassicUserGroupsCreateCmd(ctx)) + + cmd.AddCommand(newClassicUserGroupsUpdateCmd(ctx)) + + cmd.AddCommand(newClassicUserGroupsDeleteCmd(ctx)) + + cmd.AddCommand(newClassicUserGroupsApplyCmd(ctx)) + + return cmd +} + +func newClassicUserGroupsListCmd(ctx *registry.CLIContext) *cobra.Command { + return &cobra.Command{ + Use: "list", + Short: "List all usergroups", + Example: ` # List all usergroups + jamf-cli pro classic-user-groups list + + # List usergroups and extract IDs + jamf-cli pro classic-user-groups list --field id`, + RunE: func(cmd *cobra.Command, args []string) error { + reqCtx := cmd.Context() + resp, err := ctx.Client.Do(reqCtx, "GET", "/JSSResource/usergroups", nil) + if err != nil { + return err + } + defer resp.Body.Close() + + // Classic API returns XML list responses. + body, err := io.ReadAll(resp.Body) + if err != nil { + return err + } + // Default to pretty-printed XML; use -o json/yaml/table/csv for structured output. + // -o xml = pretty-printed XML, -o raw = exact wire bytes. + if (!cmd.Flags().Changed("output") && !cmd.Flags().Changed("field") && ctx.Output.Format() == "json") || ctx.Output.Format() == "xml" || ctx.Output.Format() == "raw" { + return ctx.Output.PrintBytes(body) + } + if xmlconv.IsXML(body) { + items, err := xmlconv.ExtractListItems(body) + if err == nil { + jsonItems, err := json.Marshal(items) + if err == nil { + return ctx.Output.PrintRaw(jsonItems) + } + } + return ctx.Output.PrintRaw(body) + } + // JSON fallback + var wrapper map[string]json.RawMessage + if err := json.Unmarshal(body, &wrapper); err == nil { + if inner, ok := wrapper["usergroups"]; ok { + return ctx.Output.PrintRaw(inner) + } + } + return ctx.Output.PrintRaw(body) + }, + } +} + +func newClassicUserGroupsGetCmd(ctx *registry.CLIContext) *cobra.Command { + var ( + flagName string + ) + + cmd := &cobra.Command{ + Use: "get []", + Short: "Get a user_group by ID", + Example: ` # Get a user_group by ID + jamf-cli pro classic-user-groups get 1 + + # Get a user_group by name + jamf-cli pro classic-user-groups get --name "Example" + + # Get a user_group and output as YAML + jamf-cli pro classic-user-groups get 1 -o yaml`, + Args: cobra.MaximumNArgs(1), + RunE: func(cmd *cobra.Command, args []string) error { + reqCtx := cmd.Context() + + // Resolve lookup: check flags first, then positional ID + var path string + if flagName != "" { + path = fmt.Sprintf("/JSSResource/usergroups/name/%s", url.PathEscape(flagName)) + } else if len(args) > 0 { + path = fmt.Sprintf("/JSSResource/usergroups/id/%s", url.PathEscape(args[0])) + } else { + return fmt.Errorf("provide an argument, --name") + } + + resp, err := ctx.Client.Do(reqCtx, "GET", path, nil) + if err != nil { + return err + } + defer resp.Body.Close() + + // Classic API returns XML; pass through by default. + body, err := io.ReadAll(resp.Body) + if err != nil { + return err + } + // Default to pretty-printed XML; use -o json/yaml/table/csv for structured output. + // -o xml = pretty-printed XML, -o raw = exact wire bytes. + if (!cmd.Flags().Changed("output") && !cmd.Flags().Changed("field") && ctx.Output.Format() == "json") || ctx.Output.Format() == "xml" || ctx.Output.Format() == "raw" { + return ctx.Output.PrintBytes(body) + } + if xmlconv.IsXML(body) { + if jsonBody, err := xmlconv.ToJSON(body); err == nil { + body = jsonBody + } + } + var wrapper map[string]json.RawMessage + if err := json.Unmarshal(body, &wrapper); err == nil { + if inner, ok := wrapper["user_group"]; ok { + return ctx.Output.PrintRaw(inner) + } + } + return ctx.Output.PrintRaw(body) + }, + } + + cmd.Flags().StringVar(&flagName, "name", "", "Look up user_group by name") + + return cmd +} + +func newClassicUserGroupsCreateCmd(ctx *registry.CLIContext) *cobra.Command { + cmd := &cobra.Command{ + Use: "create", + Short: "Create a user_group", + Long: "Create a new user_group. Reads XML body from stdin.", + Example: ` # Create a user_group from XML + cat user_group.xml | jamf-cli pro classic-user-groups create`, + RunE: func(cmd *cobra.Command, args []string) error { + reqCtx := cmd.Context() + + var body io.Reader + stat, _ := os.Stdin.Stat() + if (stat.Mode() & os.ModeCharDevice) == 0 { + body = os.Stdin + } else { + return fmt.Errorf("request body required on stdin (pipe XML input)") + } + + resp, err := ctx.Client.Do(reqCtx, "POST", "/JSSResource/usergroups/id/0", body) + + if err != nil { + return err + } + defer resp.Body.Close() + + return ctx.Output.PrintResponse(resp) + }, + } + return cmd +} + +func newClassicUserGroupsUpdateCmd(ctx *registry.CLIContext) *cobra.Command { + var flagName string + + cmd := &cobra.Command{ + Use: "update []", + Short: "Update a user_group", + Long: "Update an existing user_group by ID. Reads XML body from stdin.", + Example: ` # Update a user_group from XML + cat user_group.xml | jamf-cli pro classic-user-groups update 1`, + Args: cobra.MaximumNArgs(1), + RunE: func(cmd *cobra.Command, args []string) error { + reqCtx := cmd.Context() + + var body io.Reader + stat, _ := os.Stdin.Stat() + if (stat.Mode() & os.ModeCharDevice) == 0 { + body = os.Stdin + } else { + return fmt.Errorf("request body required on stdin (pipe XML input)") + } + + var path string + if flagName != "" { + path = fmt.Sprintf("/JSSResource/usergroups/name/%s", url.PathEscape(flagName)) + } else if len(args) > 0 { + path = fmt.Sprintf("/JSSResource/usergroups/id/%s", url.PathEscape(args[0])) + } else { + return fmt.Errorf("provide an argument or --name") + } + + resp, err := ctx.Client.Do(reqCtx, "PUT", path, body) + if err != nil { + return err + } + defer resp.Body.Close() + + return ctx.Output.PrintResponse(resp) + }, + } + + cmd.Flags().StringVar(&flagName, "name", "", "Look up user_group by name") + + return cmd +} + +func newClassicUserGroupsDeleteCmd(ctx *registry.CLIContext) *cobra.Command { + var ( + flagYes bool + flagDryRun bool + flagName string + fromFile string + ) + + cmd := &cobra.Command{ + Use: "delete []", + Short: "Delete a user_group", + Example: ` # Delete a user_group (with confirmation) + jamf-cli pro classic-user-groups delete 1 + + # Delete by name + jamf-cli pro classic-user-groups delete --name "Example" --yes + + # Delete without confirmation prompt + jamf-cli pro classic-user-groups delete 1 --yes`, + Args: cobra.MaximumNArgs(1), + RunE: func(cmd *cobra.Command, args []string) error { + reqCtx := cmd.Context() + + // --from-file: bulk delete from a file of IDs or names + if fromFile != "" { + entries, err := readDeleteFile(fromFile) + if err != nil { + return fmt.Errorf("reading --from-file: %w", err) + } + if len(entries) == 0 { + return fmt.Errorf("--from-file %q: no entries found", fromFile) + } + type bulkEntry struct{ id, label string } + bulk := make([]bulkEntry, 0, len(entries)) + noInputBulk, _ := cmd.Flags().GetBool("no-input") + for _, entry := range entries { + if isNumericID(entry) { + if entry == "0" { + return fmt.Errorf("--from-file: ID 0 is not valid (Jamf Pro uses 0 as a sentinel value)") + } + bulk = append(bulk, bulkEntry{id: entry, label: entry}) + } else { + var resolvedID string + if resolvedID == "" { + id, err := resolveClassicNameToIDForApply(reqCtx, ctx.Client, "usergroups", "usergroups", entry, noInputBulk) + if err != nil { + return fmt.Errorf("resolving %q: %w", entry, err) + } + resolvedID = id + } + if resolvedID == "" { + return fmt.Errorf("no user_group found matching %q", entry) + } + bulk = append(bulk, bulkEntry{id: resolvedID, label: entry}) + } + } + // Deduplicate resolved IDs to avoid double-delete errors. + { + seen := make(map[string]bool, len(bulk)) + deduped := bulk[:0] + for _, e := range bulk { + if !seen[e.id] { + seen[e.id] = true + deduped = append(deduped, e) + } + } + bulk = deduped + } + if flagDryRun { + for _, e := range bulk { + fmt.Fprintf(os.Stderr, "[dry-run] Would delete user_group %q (id: %s)\n", e.label, e.id) + } + return nil + } + if !flagYes { + if noInputBulk { + return fmt.Errorf("destructive operation requires --yes when --no-input is set") + } + fmt.Fprintf(os.Stderr, "⚠️ This will delete %d usergroups. Type 'yes' to confirm: ", len(bulk)) + var confirm string + fmt.Scanln(&confirm) + if confirm != "yes" { + return fmt.Errorf("aborted") + } + } + if err := cooldown.Enforce(ctx.ProfileName, noInputBulk, ctx.DestructiveCooldown); err != nil { + return err + } + for _, e := range bulk { + delPath := fmt.Sprintf("/JSSResource/usergroups/id/%s", url.PathEscape(e.id)) + resp, err := ctx.Client.Do(reqCtx, "DELETE", delPath, nil) + if err != nil { + return fmt.Errorf("deleting %q (id: %s): %w", e.label, e.id, err) + } + resp.Body.Close() + if resp.StatusCode == http.StatusNoContent || resp.StatusCode == http.StatusOK { + fmt.Fprintf(os.Stderr, "Deleted user_group %q (id: %s)\n", e.label, e.id) + } else { + return fmt.Errorf("delete %q (id: %s): HTTP %d", e.label, e.id, resp.StatusCode) + } + } + cooldown.Record(ctx.ProfileName) + return nil + } + + // Resolve ID from --name or positional arg + var resolvedID string + noInput, _ := cmd.Flags().GetBool("no-input") + if flagName != "" { + id, err := resolveClassicNameToIDForApply(reqCtx, ctx.Client, "usergroups", "usergroups", flagName, noInput) + if err != nil { + return err + } + if id == "" { + return fmt.Errorf("no user_group found with name %q", flagName) + } + resolvedID = id + } else if len(args) > 0 { + resolvedID = args[0] + } else { + return fmt.Errorf("provide an argument or --name") + } + + if flagDryRun { + fmt.Fprintf(os.Stderr, "[dry-run] Would delete user_group %s\n", resolvedID) + return nil + } + if !flagYes { + if noInput { + return fmt.Errorf("destructive operation requires --yes when --no-input is set") + } + fmt.Fprintf(os.Stderr, "This will delete user_group %s. Type 'yes' to confirm: ", resolvedID) + var confirm string + fmt.Scanln(&confirm) + if confirm != "yes" { + return fmt.Errorf("aborted") + } + } + + if err := cooldown.Enforce(ctx.ProfileName, noInput, ctx.DestructiveCooldown); err != nil { + return err + } + path := fmt.Sprintf("/JSSResource/usergroups/id/%s", url.PathEscape(resolvedID)) + + resp, err := ctx.Client.Do(reqCtx, "DELETE", path, nil) + if err != nil { + return err + } + defer resp.Body.Close() + + if resp.StatusCode == http.StatusNoContent || resp.StatusCode == http.StatusOK { + cooldown.Record(ctx.ProfileName) + fmt.Fprintln(os.Stderr, "Deleted successfully") + return nil + } + + return ctx.Output.PrintResponse(resp) + }, + } + + cmd.Flags().BoolVar(&flagYes, "yes", false, "Skip confirmation prompt") + cmd.Flags().BoolVarP(&flagDryRun, "dry-run", "n", false, "Preview without executing") + cmd.Flags().StringVar(&flagName, "name", "", "Look up user_group by name") + cmd.Flags().StringVar(&fromFile, "from-file", "", "Path to file listing IDs or names to delete (one per line, # comments ignored)") + cmd.MarkFlagsMutuallyExclusive("from-file", "name") + + return cmd +} + +func newClassicUserGroupsApplyCmd(ctx *registry.CLIContext) *cobra.Command { + var ( + fromFile string + flagYes bool + flagDryRun bool + ) + + cmd := &cobra.Command{ + Use: "apply", + Short: "Create or replace a user_group by name", + Long: `Create or replace a user_group. Reads XML from --from-file or stdin. + +The name field in the input XML is used to check if the resource already +exists. If it does, the resource is replaced (with confirmation). +If not, a new resource is created.`, + Example: ` # Apply a user_group from an XML file + jamf-cli pro classic-user-groups apply --from-file user_group.xml + + # Apply from stdin + cat user_group.xml | jamf-cli pro classic-user-groups apply + + # Apply without replacement confirmation + jamf-cli pro classic-user-groups apply --from-file user_group.xml --yes`, + RunE: func(cmd *cobra.Command, _ []string) error { + reqCtx := cmd.Context() + + // Read input + data, err := readApplyInput(fromFile) + + if err != nil { + return err + } + + // Extract name: for fetch-merge-put resources, --name is the primary + // input (body typically empty); fall back to XML name if flag absent. + var name string + + name, err = extractClassicName(data, "user_group") + if err != nil { + return err + } + + // Check if resource exists by name (read-only, runs even in dry-run) + noInput, _ := cmd.Flags().GetBool("no-input") + id, err := resolveClassicNameToIDForApply(reqCtx, ctx.Client, "usergroups", "usergroups", name, noInput) + if err != nil { + return err + } + + if id == "" { + // Not found — create (not allowed for fetch-merge-put resources) + + if flagDryRun { + fmt.Fprintf(os.Stderr, "[dry-run] Would create user_group %q\n", name) + return nil + } + resp, err := ctx.Client.Do(reqCtx, "POST", "/JSSResource/usergroups/id/0", bytes.NewReader(data)) + if err != nil { + return err + } + defer resp.Body.Close() + fmt.Fprintf(os.Stderr, "Created user_group %q\n", name) + return ctx.Output.PrintResponse(resp) + + } + + // Found — replace + if flagDryRun { + fmt.Fprintf(os.Stderr, "[dry-run] Would replace user_group %q (id: %s)\n", name, id) + return nil + } + if !flagYes { + if noInput { + return fmt.Errorf("user_group %q already exists (id: %s); use --yes to replace when --no-input is set", name, id) + } + fmt.Fprintf(os.Stderr, "user_group %q already exists (id: %s) and will be replaced. Type 'yes' to confirm: ", name, id) + var confirm string + fmt.Scanln(&confirm) + if confirm != "yes" { + return fmt.Errorf("aborted") + } + } + + updatePath := fmt.Sprintf("/JSSResource/usergroups/id/%s", url.PathEscape(id)) + resp, err := ctx.Client.Do(reqCtx, "PUT", updatePath, bytes.NewReader(data)) + if err != nil { + return err + } + defer resp.Body.Close() + fmt.Fprintf(os.Stderr, "Replaced user_group %q (id: %s)\n", name, id) + return ctx.Output.PrintResponse(resp) + }, + } + + cmd.Flags().StringVar(&fromFile, "from-file", "", "Path to XML input file (or pipe XML to stdin)") + cmd.Flags().BoolVar(&flagYes, "yes", false, "Skip confirmation prompt when replacing") + cmd.Flags().BoolVarP(&flagDryRun, "dry-run", "n", false, "Preview without executing") + + return cmd +} diff --git a/internal/commands/pro/generated/provenance.go b/internal/commands/pro/generated/provenance.go index 2db33389..8b93bfbc 100644 --- a/internal/commands/pro/generated/provenance.go +++ b/internal/commands/pro/generated/provenance.go @@ -173,5 +173,5 @@ var Sources = []SpecSource{ {File: "specs/VppLocations.yaml", SHA256: "d9b41c79c0cdba81706b16c8e6d0b86b8048d40d6d55ec66618af0ac00c533d0"}, {File: "specs/VppSubscriptions.yaml", SHA256: "184b834aeb0d21cf190050294f7869d57b482bbe1834ad5a1bd79dcfdeef7a55"}, {File: "specs/_MonolithLibrary.yaml", SHA256: "9d777dc71e1909d20db05b31451246a805565c856d7b2fc50d38ccb33269a89a"}, - {File: "specs/classic/resources.yaml", SHA256: "1c97406322cb64a542ad286392ee781eee11cfeab5ff5ae322a4febdb494085d"}, + {File: "specs/classic/resources.yaml", SHA256: "8c1b92739282ffbaa66faf8636351bdd05bab9b7605b4c4e09ce31581c6206ac"}, } diff --git a/internal/commands/pro/generated/smoke_registry.go b/internal/commands/pro/generated/smoke_registry.go index 2b474b5d..b0a15e2d 100644 --- a/internal/commands/pro/generated/smoke_registry.go +++ b/internal/commands/pro/generated/smoke_registry.go @@ -143,6 +143,8 @@ func AllSmokeEndpoints() []SmokeEndpoint { {Resource: "classic-software-update-servers", Operation: "list", Method: "GET", Path: "/JSSResource/softwareupdateservers", IsList: true, HasPathParams: false, IsClassic: true, WrapperKey: "softwareupdateservers", SingularKey: ""}, {Resource: "classic-user-ext-attrs", Operation: "get", Method: "GET", Path: "/JSSResource/userextensionattributes/id/{id}", IsList: false, HasPathParams: true, IsClassic: true, WrapperKey: "", SingularKey: "user_extension_attribute"}, {Resource: "classic-user-ext-attrs", Operation: "list", Method: "GET", Path: "/JSSResource/userextensionattributes", IsList: true, HasPathParams: false, IsClassic: true, WrapperKey: "userextensionattributes", SingularKey: ""}, + {Resource: "classic-user-groups", Operation: "get", Method: "GET", Path: "/JSSResource/usergroups/id/{id}", IsList: false, HasPathParams: true, IsClassic: true, WrapperKey: "", SingularKey: "user_group"}, + {Resource: "classic-user-groups", Operation: "list", Method: "GET", Path: "/JSSResource/usergroups", IsList: true, HasPathParams: false, IsClassic: true, WrapperKey: "usergroups", SingularKey: ""}, {Resource: "classic-vpp-accounts", Operation: "get", Method: "GET", Path: "/JSSResource/vppaccounts/id/{id}", IsList: false, HasPathParams: true, IsClassic: true, WrapperKey: "", SingularKey: "vpp_account"}, {Resource: "classic-vpp-accounts", Operation: "list", Method: "GET", Path: "/JSSResource/vppaccounts", IsList: true, HasPathParams: false, IsClassic: true, WrapperKey: "vppaccounts", SingularKey: ""}, {Resource: "classic-vpp-assignments", Operation: "get", Method: "GET", Path: "/JSSResource/vppassignments/id/{id}", IsList: false, HasPathParams: true, IsClassic: true, WrapperKey: "", SingularKey: "vpp_assignment"}, diff --git a/specs/classic/resources.yaml b/specs/classic/resources.yaml index a1c2c9c4..253eee22 100644 --- a/specs/classic/resources.yaml +++ b/specs/classic/resources.yaml @@ -290,6 +290,12 @@ resources: cli_name: classic-licensed-software singular: licensed_software + - name: usergroups + path: usergroups + description: JSS user groups (referenced in policy/ebook scope limitations) + cli_name: classic-user-groups + singular: user_group + - name: userextensionattributes path: userextensionattributes description: User extension attributes From 5517ff34581f31b9a26b16772413f3db5f6c6d3c Mon Sep 17 00:00:00 2001 From: Neil Martin Date: Mon, 11 May 2026 15:34:31 +0100 Subject: [PATCH 4/8] feat(classic): add scope support to classic-vpp-assignments Co-Authored-By: Claude Sonnet 4.6 --- internal/commands/pro/generated/classic_vpp_assignments.go | 6 ++++++ internal/commands/pro/generated/provenance.go | 2 +- specs/classic/resources.yaml | 1 + 3 files changed, 8 insertions(+), 1 deletion(-) diff --git a/internal/commands/pro/generated/classic_vpp_assignments.go b/internal/commands/pro/generated/classic_vpp_assignments.go index 75d806cd..ceee7f7a 100644 --- a/internal/commands/pro/generated/classic_vpp_assignments.go +++ b/internal/commands/pro/generated/classic_vpp_assignments.go @@ -12,6 +12,7 @@ import ( "github.com/Jamf-Concepts/jamf-cli/internal/cooldown" "github.com/Jamf-Concepts/jamf-cli/internal/registry" + "github.com/Jamf-Concepts/jamf-cli/internal/scope" "github.com/Jamf-Concepts/jamf-cli/internal/xmlconv" "github.com/spf13/cobra" ) @@ -34,6 +35,11 @@ func NewClassicVppAssignmentsCmd(ctx *registry.CLIContext) *cobra.Command { cmd.AddCommand(newClassicVppAssignmentsDeleteCmd(ctx)) + cmd.AddCommand(scope.NewScopeCmd(ctx, scope.Resource{ + APIPath: "vppassignments", + SingularKey: "vpp_assignment", + })) + return cmd } diff --git a/internal/commands/pro/generated/provenance.go b/internal/commands/pro/generated/provenance.go index 8b93bfbc..f44ef7a0 100644 --- a/internal/commands/pro/generated/provenance.go +++ b/internal/commands/pro/generated/provenance.go @@ -173,5 +173,5 @@ var Sources = []SpecSource{ {File: "specs/VppLocations.yaml", SHA256: "d9b41c79c0cdba81706b16c8e6d0b86b8048d40d6d55ec66618af0ac00c533d0"}, {File: "specs/VppSubscriptions.yaml", SHA256: "184b834aeb0d21cf190050294f7869d57b482bbe1834ad5a1bd79dcfdeef7a55"}, {File: "specs/_MonolithLibrary.yaml", SHA256: "9d777dc71e1909d20db05b31451246a805565c856d7b2fc50d38ccb33269a89a"}, - {File: "specs/classic/resources.yaml", SHA256: "8c1b92739282ffbaa66faf8636351bdd05bab9b7605b4c4e09ce31581c6206ac"}, + {File: "specs/classic/resources.yaml", SHA256: "30e42f306052fdf07bdff72583f1ba4b6ed622d74fbad2f9ade839174529fb11"}, } diff --git a/specs/classic/resources.yaml b/specs/classic/resources.yaml index 253eee22..c3df8ed0 100644 --- a/specs/classic/resources.yaml +++ b/specs/classic/resources.yaml @@ -315,6 +315,7 @@ resources: cli_name: classic-vpp-assignments singular: vpp_assignment lookups: [id] + scope: true - name: vppinvitations path: vppinvitations From 1a18946aa6c67ed29682fe2161b9e729eab5424d Mon Sep 17 00:00:00 2001 From: Neil Martin Date: Mon, 11 May 2026 15:54:40 +0100 Subject: [PATCH 5/8] fix(scope): support jss_user_groups target and full-doc PUT for vpp assignments MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Three related fixes for Classic API scope on resources like vpp assignments: - Add --user-group / --jss-user-group / --jss-user flags as valid target and exclusion items, routing to jss_user_groups / jss_users in scope XML. Previously only computer-group, mobile-device-group, building, department were accepted as target items. - Add NoSubsetPut to scope.Resource: resources without a /subset/Scope endpoint (vppassignments) now fetch the full document, splice in the updated scope at the byte level, and PUT the whole document back. - Fix flagToElemName for jss-user-group: child elements inside are , not . Wire no_subset_put through YAML manifest → ClassicResource → generator template → generated scope.Resource literal. Co-Authored-By: Claude Sonnet 4.6 --- generator/classic/generator.go | 23 ++- generator/classic/parser.go | 2 + generator/classic/types.go | 1 + .../pro/generated/classic_vpp_assignments.go | 6 +- internal/commands/pro/generated/provenance.go | 2 +- internal/scope/scope.go | 167 +++++++++++++++-- internal/scope/scope_test.go | 169 +++++++++++++++++- internal/scope/types.go | 9 +- specs/classic/resources.yaml | 1 + 9 files changed, 354 insertions(+), 26 deletions(-) diff --git a/generator/classic/generator.go b/generator/classic/generator.go index 271e010b..bdd011e2 100644 --- a/generator/classic/generator.go +++ b/generator/classic/generator.go @@ -87,12 +87,13 @@ func (g *Generator) GenerateRegistry(resources []ClassicResource) (string, error func templateFuncs() template.FuncMap { return template.FuncMap{ - "toCamel": strcase.ToCamel, - "toKebab": strcase.ToKebab, - "toLower": strings.ToLower, - "hasOp": hasOp, - "hasLookup": hasLookup, - "extraLookups": extraLookups, + "toCamel": strcase.ToCamel, + "toKebab": strcase.ToKebab, + "toLower": strings.ToLower, + "hasOp": hasOp, + "hasLookup": hasLookup, + "extraLookups": extraLookups, + "scopeResolveByList": func(r ClassicResource) bool { return !r.HasLookup("name") }, "classicExample": func(r ClassicResource, op string) string { bin := "jamf-cli pro" name := r.CLIName @@ -371,8 +372,14 @@ func New{{ .GoName }}Cmd(ctx *registry.CLIContext) *cobra.Command { {{- end }} {{ if needsScope . }} cmd.AddCommand(scope.NewScopeCmd(ctx, scope.Resource{ - APIPath: "{{ .Path }}", - SingularKey: "{{ .Singular }}", + APIPath: "{{ .Path }}", + SingularKey: "{{ .Singular }}", + {{- if scopeResolveByList . }} + ResolveByList: true, + {{- end }} + {{- if .NoSubsetPut }} + NoSubsetPut: true, + {{- end }} })) {{- end }} diff --git a/generator/classic/parser.go b/generator/classic/parser.go index 3590914d..bdc4f86c 100644 --- a/generator/classic/parser.go +++ b/generator/classic/parser.go @@ -27,6 +27,7 @@ type manifestResource struct { Operations []string `yaml:"operations"` Lookups []string `yaml:"lookups"` Scope bool `yaml:"scope"` + NoSubsetPut bool `yaml:"no_subset_put"` IDPath string `yaml:"id_path"` ListSubset string `yaml:"list_subset"` GroupsPath string `yaml:"groups_path"` @@ -114,6 +115,7 @@ func buildResource(entry manifestResource) (ClassicResource, error) { Operations: operations, Lookups: lookups, HasScope: entry.Scope, + NoSubsetPut: entry.NoSubsetPut, IDPath: idPath, IsConfigProfile: entry.Path == "osxconfigurationprofiles" || entry.Path == "mobiledeviceconfigurationprofiles", HasCustomPayload: entry.Path == "osxconfigurationprofiles", diff --git a/generator/classic/types.go b/generator/classic/types.go index f8b57696..8b0c7442 100644 --- a/generator/classic/types.go +++ b/generator/classic/types.go @@ -15,6 +15,7 @@ type ClassicResource struct { Operations []string // ["list", "get", "create", "update", "delete"] Lookups []string // ["id", "name", "serialnumber", "macaddress", "udid"] HasScope bool // true if the resource supports scope operations + NoSubsetPut bool // true if the resource does not support /subset/Scope PUT (use full-doc PUT) IDPath string // path segment between base path and ID value; defaults to "id" (e.g. "groupid" → /accounts/groupid/{id}) IsConfigProfile bool // true for macOS and mobile device configuration profile resources HasCustomPayload bool // true only for osxconfigurationprofiles (supports --custom-payload-file) diff --git a/internal/commands/pro/generated/classic_vpp_assignments.go b/internal/commands/pro/generated/classic_vpp_assignments.go index ceee7f7a..de22e513 100644 --- a/internal/commands/pro/generated/classic_vpp_assignments.go +++ b/internal/commands/pro/generated/classic_vpp_assignments.go @@ -36,8 +36,10 @@ func NewClassicVppAssignmentsCmd(ctx *registry.CLIContext) *cobra.Command { cmd.AddCommand(newClassicVppAssignmentsDeleteCmd(ctx)) cmd.AddCommand(scope.NewScopeCmd(ctx, scope.Resource{ - APIPath: "vppassignments", - SingularKey: "vpp_assignment", + APIPath: "vppassignments", + SingularKey: "vpp_assignment", + ResolveByList: true, + NoSubsetPut: true, })) return cmd diff --git a/internal/commands/pro/generated/provenance.go b/internal/commands/pro/generated/provenance.go index f44ef7a0..744b273a 100644 --- a/internal/commands/pro/generated/provenance.go +++ b/internal/commands/pro/generated/provenance.go @@ -173,5 +173,5 @@ var Sources = []SpecSource{ {File: "specs/VppLocations.yaml", SHA256: "d9b41c79c0cdba81706b16c8e6d0b86b8048d40d6d55ec66618af0ac00c533d0"}, {File: "specs/VppSubscriptions.yaml", SHA256: "184b834aeb0d21cf190050294f7869d57b482bbe1834ad5a1bd79dcfdeef7a55"}, {File: "specs/_MonolithLibrary.yaml", SHA256: "9d777dc71e1909d20db05b31451246a805565c856d7b2fc50d38ccb33269a89a"}, - {File: "specs/classic/resources.yaml", SHA256: "30e42f306052fdf07bdff72583f1ba4b6ed622d74fbad2f9ade839174529fb11"}, + {File: "specs/classic/resources.yaml", SHA256: "c17330d78ce43a51c45347bfc49c151be53a271f670ce72c7dab9fb001e69fdd"}, } diff --git a/internal/scope/scope.go b/internal/scope/scope.go index 019d74e3..d5a37d66 100644 --- a/internal/scope/scope.go +++ b/internal/scope/scope.go @@ -19,10 +19,24 @@ import ( ) // FetchScope performs a GET on a Classic API resource by name and returns -// the resource's ID and parsed scope. +// the resource's ID and parsed scope. When res.ResolveByList is true it lists +// all records to resolve name→ID first (for resources with no /name/ endpoint). func FetchScope(ctx context.Context, client registry.HTTPClient, res Resource, name string) (string, *ScopeXML, error) { - path := fmt.Sprintf("/JSSResource/%s/name/%s", res.APIPath, url.PathEscape(name)) - resp, err := client.Do(ctx, "GET", path, nil) + var fetchPath string + var resolvedID string + + if res.ResolveByList { + id, err := resolveNameToID(ctx, client, res.APIPath, res.SingularKey, name) + if err != nil { + return "", nil, err + } + resolvedID = id + fetchPath = fmt.Sprintf("/JSSResource/%s/id/%s", res.APIPath, url.PathEscape(id)) + } else { + fetchPath = fmt.Sprintf("/JSSResource/%s/name/%s", res.APIPath, url.PathEscape(name)) + } + + resp, err := client.Do(ctx, "GET", fetchPath, nil) if err != nil { return "", nil, fmt.Errorf("fetching %s %q: %w", res.SingularKey, name, err) } @@ -38,15 +52,67 @@ func FetchScope(ctx context.Context, client registry.HTTPClient, res Resource, n return "", nil, fmt.Errorf("parsing %s XML: %w", res.SingularKey, err) } + if res.ResolveByList { + return resolvedID, &envelope.Scope, nil + } + if envelope.General.ID == "" { return "", nil, fmt.Errorf("no ID in %s %q", res.SingularKey, name) } - return envelope.General.ID, &envelope.Scope, nil } -// PutScope writes an updated scope back to the Classic API via subset PUT. +// resolveNameToID lists all records at the resource root and returns the ID +// of the first record whose matches (case-insensitive). +func resolveNameToID(ctx context.Context, client registry.HTTPClient, apiPath, singularKey, name string) (string, error) { + resp, err := client.Do(ctx, "GET", "/JSSResource/"+apiPath, nil) + if err != nil { + return "", fmt.Errorf("listing %s: %w", singularKey, err) + } + defer func() { _ = resp.Body.Close() }() + + body, err := io.ReadAll(io.LimitReader(resp.Body, 10<<20)) + if err != nil { + return "", fmt.Errorf("reading list response: %w", err) + } + + d := xml.NewDecoder(bytes.NewReader(body)) + depth := 0 + for { + tok, err := d.Token() + if err == io.EOF { + break + } + if err != nil { + return "", fmt.Errorf("parsing %s list XML: %w", singularKey, err) + } + switch t := tok.(type) { + case xml.StartElement: + depth++ + if depth == 2 { + var it struct { + ID string `xml:"id"` + Name string `xml:"name"` + } + if decErr := d.DecodeElement(&it, &t); decErr == nil && strings.EqualFold(it.Name, name) { + return it.ID, nil + } + depth-- // DecodeElement consumed the end element + } + case xml.EndElement: + depth-- + } + } + return "", fmt.Errorf("%s %q not found", singularKey, name) +} + +// PutScope writes an updated scope back to the Classic API. Uses the subset/Scope +// endpoint by default; falls back to a full document PUT when res.NoSubsetPut is set. func PutScope(ctx context.Context, client registry.HTTPClient, res Resource, id string, s *ScopeXML) error { + if res.NoSubsetPut { + return putFullDocument(ctx, client, res, id, s) + } + envelope := scopeUpdateXML{ XMLName: xml.Name{Local: res.SingularKey}, Scope: *s, @@ -67,6 +133,62 @@ func PutScope(ctx context.Context, client registry.HTTPClient, res Resource, id return nil } +// putFullDocument fetches the full resource XML, splices in the updated scope, +// and PUTs the whole document back. Used for Classic API resources that do not +// support the /subset/Scope endpoint (e.g. vppassignments). +func putFullDocument(ctx context.Context, client registry.HTTPClient, res Resource, id string, s *ScopeXML) error { + fetchPath := fmt.Sprintf("/JSSResource/%s/id/%s", res.APIPath, url.PathEscape(id)) + resp, err := client.Do(ctx, "GET", fetchPath, nil) + if err != nil { + return fmt.Errorf("fetching %s for update: %w", res.SingularKey, err) + } + defer func() { _ = resp.Body.Close() }() + + original, err := io.ReadAll(io.LimitReader(resp.Body, 10<<20)) + if err != nil { + return fmt.Errorf("reading %s: %w", res.SingularKey, err) + } + + updated, err := replaceScopeInXML(original, s) + if err != nil { + return fmt.Errorf("replacing scope: %w", err) + } + + putResp, err := client.Do(ctx, "PUT", fetchPath, bytes.NewReader(updated)) + if err != nil { + return fmt.Errorf("updating scope: %w", err) + } + _ = putResp.Body.Close() + return nil +} + +// replaceScopeInXML finds the ... block in the XML bytes and +// replaces it with the marshalled newScope. Classic API XML is well-formed so +// simple byte search is safe. +func replaceScopeInXML(original []byte, newScope *ScopeXML) ([]byte, error) { + newScopeXML, err := xml.MarshalIndent(newScope, " ", " ") + if err != nil { + return nil, fmt.Errorf("marshalling scope: %w", err) + } + + scopeOpen := bytes.Index(original, []byte("")) + if scopeOpen == -1 { + return nil, fmt.Errorf("no element found in resource XML") + } + closeTag := []byte("") + rel := bytes.Index(original[scopeOpen:], closeTag) + if rel == -1 { + return nil, fmt.Errorf("no closing tag found in resource XML") + } + scopeClose := scopeOpen + rel + len(closeTag) + + var buf bytes.Buffer + buf.Write(original[:scopeOpen]) + buf.Write(newScopeXML) + buf.Write(original[scopeClose:]) + return buf.Bytes(), nil +} + // AddToScope adds a named item to the given scope section. Returns true if the // item was added, false if already present (idempotent no-op). func AddToScope(s *ScopeXML, singularKey, section, flagName, name string) bool { @@ -86,7 +208,7 @@ func AddToScope(s *ScopeXML, singularKey, section, flagName, name string) bool { } if items.ElemName == "" { - items.ElemName = flagToElemName[flagName] + items.ElemName = resolveElemName(section, flagName) } items.Items = append(items.Items, NamedItem{Name: name}) return true @@ -148,6 +270,8 @@ func FlattenScope(s *ScopeXML, singularKey string) []map[string]any { appendNamedRows(&rows, "target", "mobile_device_group", s.MobileDeviceGroups.Items) appendNamedRows(&rows, "target", "building", s.Buildings.Items) appendNamedRows(&rows, "target", "department", s.Departments.Items) + appendNamedRows(&rows, "target", "jss_user", s.JSSUsers.Items) + appendNamedRows(&rows, "target", "jss_user_group", s.JSSUserGroups.Items) // Policy special case: limit_to_users holds plain strings if singularKey == "policy" && s.LimitToUsers != nil { @@ -171,6 +295,8 @@ func FlattenScope(s *ScopeXML, singularKey string) []map[string]any { appendNamedRows(&rows, "exclusion", "computer_group", s.Exclusions.ComputerGroups.Items) appendNamedRows(&rows, "exclusion", "mobile_device_group", s.Exclusions.MobileDeviceGroups.Items) appendNamedRows(&rows, "exclusion", "user_group", s.Exclusions.UserGroups.Items) + appendNamedRows(&rows, "exclusion", "jss_user", s.Exclusions.JSSUsers.Items) + appendNamedRows(&rows, "exclusion", "jss_user_group", s.Exclusions.JSSUserGroups.Items) appendNamedRows(&rows, "exclusion", "network_segment", s.Exclusions.NetworkSegments.Items) appendNamedRows(&rows, "exclusion", "building", s.Exclusions.Buildings.Items) appendNamedRows(&rows, "exclusion", "department", s.Exclusions.Departments.Items) @@ -187,10 +313,11 @@ func ValidateScopeCombination(singularKey, section, flagName string) error { switch section { case "target": switch flagName { - case "computer-group", "mobile-device-group", "building", "department": + case "computer-group", "mobile-device-group", "building", "department", + "user-group", "jss-user-group", "jss-user": return nil } - return fmt.Errorf("--%s is not valid as a target; use --computer-group, --mobile-device-group, --building, or --department", flagName) + return fmt.Errorf("--%s is not valid as a target; use --computer-group, --mobile-device-group, --building, --department, --user-group, or --jss-user-group", flagName) case "limitation": if isRestricted { @@ -211,7 +338,7 @@ func ValidateScopeCombination(singularKey, section, flagName string) error { return fmt.Errorf("--%s is not valid as an exclusion for restricted software; use --computer-group, --building, or --department", flagName) } switch flagName { - case "computer-group", "mobile-device-group", "user-group", "network-segment", "building", "department": + case "computer-group", "mobile-device-group", "user-group", "jss-user-group", "jss-user", "network-segment", "building", "department": return nil } return fmt.Errorf("--%s is not valid as an exclusion", flagName) @@ -232,7 +359,7 @@ func DetermineScopeTarget(cmd *cobra.Command) (ScopeTarget, error) { } } if count == 0 { - return ScopeTarget{}, fmt.Errorf("specify one of: --computer-group, --mobile-device-group, --building, --department, --network-segment, --user-group") + return ScopeTarget{}, fmt.Errorf("specify one of: --computer-group, --mobile-device-group, --building, --department, --network-segment, --user-group, --jss-user-group, --jss-user") } if count > 1 { return ScopeTarget{}, fmt.Errorf("specify only one scopeable type per invocation") @@ -251,7 +378,9 @@ func AddScopeFlags(cmd *cobra.Command, section *string) { cmd.Flags().String("building", "", "building name") cmd.Flags().String("department", "", "department name") cmd.Flags().String("network-segment", "", "network segment name") - cmd.Flags().String("user-group", "", "user group name") + cmd.Flags().String("user-group", "", "user group name (limitations/exclusions) or JSS user group name (target)") + cmd.Flags().String("jss-user-group", "", "JSS user group name (target/exclusion)") + cmd.Flags().String("jss-user", "", "JSS user name (target/exclusion)") } // ─── Internal helpers ───────────────────────────────────────────────────────── @@ -364,6 +493,10 @@ func targetItems(s *ScopeXML, flagName string) *ScopeItemSlice { return &s.Buildings case "department": return &s.Departments + case "user-group", "jss-user-group": + return &s.JSSUserGroups + case "jss-user": + return &s.JSSUsers } return nil } @@ -388,6 +521,10 @@ func exclusionItems(exc *ExclusionsXML, flagName string) *ScopeItemSlice { return &exc.MobileDeviceGroups case "user-group": return &exc.UserGroups + case "jss-user-group": + return &exc.JSSUserGroups + case "jss-user": + return &exc.JSSUsers case "network-segment": return &exc.NetworkSegments case "building": @@ -398,6 +535,14 @@ func exclusionItems(exc *ExclusionsXML, flagName string) *ScopeItemSlice { return nil } +// resolveElemName returns the XML child element name for a new scope item. +// Both --user-group and --jss-user-group route to jss_user_groups whose +// children are , so flagToElemName["user-group"] = "user_group" +// is correct for both cases. +func resolveElemName(section, flagName string) string { + return flagToElemName[flagName] +} + func appendNamedRows(rows *[]map[string]any, section, typeName string, items []NamedItem) { for _, item := range items { if item.Name != "" { diff --git a/internal/scope/scope_test.go b/internal/scope/scope_test.go index 064b3c90..a68a6ce9 100644 --- a/internal/scope/scope_test.go +++ b/internal/scope/scope_test.go @@ -420,7 +420,7 @@ func TestRemoveFromScope_PolicyLimitUserGroup_InBothLocations(t *testing.T) { // ─── ValidateScopeCombination ──────────────────────────────────────────────── func TestValidateScopeCombination_ValidTargets(t *testing.T) { - for _, flag := range []string{"computer-group", "mobile-device-group", "building", "department"} { + for _, flag := range []string{"computer-group", "mobile-device-group", "building", "department", "user-group", "jss-user-group", "jss-user"} { for _, sk := range []string{"policy", "restricted_software", "os_x_configuration_profile"} { if err := ValidateScopeCombination(sk, "target", flag); err != nil { t.Errorf("target/%s/%s: %v", sk, flag, err) @@ -456,7 +456,7 @@ func TestValidateScopeCombination_RestrictedSoftwareNoLimitations(t *testing.T) } func TestValidateScopeCombination_ValidExclusions(t *testing.T) { - for _, flag := range []string{"computer-group", "mobile-device-group", "user-group", "network-segment", "building", "department"} { + for _, flag := range []string{"computer-group", "mobile-device-group", "user-group", "jss-user-group", "jss-user", "network-segment", "building", "department"} { if err := ValidateScopeCombination("policy", "exclusion", flag); err != nil { t.Errorf("exclusion/%s: %v", flag, err) } @@ -600,3 +600,168 @@ func TestIsPolicyLimitUserGroup(t *testing.T) { } } } + +// ─── JSS user group target routing (VPP-style scope) ───────────────────────── + +func TestAddToScope_UserGroupTarget_RoutesToJSSUserGroups(t *testing.T) { + s := &ScopeXML{} + + if !AddToScope(s, "vpp_assignment", "target", "user-group", "VPP Associated Users") { + t.Fatal("expected true") + } + if len(s.JSSUserGroups.Items) != 1 { + t.Fatalf("jss_user_groups: got %d, want 1", len(s.JSSUserGroups.Items)) + } + if s.JSSUserGroups.Items[0].Name != "VPP Associated Users" { + t.Errorf("name = %q", s.JSSUserGroups.Items[0].Name) + } + if s.JSSUserGroups.ElemName != "user_group" { + t.Errorf("ElemName = %q, want user_group", s.JSSUserGroups.ElemName) + } +} + +func TestAddToScope_JSSUserGroupTarget(t *testing.T) { + s := &ScopeXML{} + + if !AddToScope(s, "vpp_assignment", "target", "jss-user-group", "My Group") { + t.Fatal("expected true") + } + if len(s.JSSUserGroups.Items) != 1 { + t.Fatalf("jss_user_groups: got %d, want 1", len(s.JSSUserGroups.Items)) + } + if s.JSSUserGroups.ElemName != "user_group" { + t.Errorf("ElemName = %q, want user_group", s.JSSUserGroups.ElemName) + } +} + +func TestAddToScope_JSSUserGroupTarget_Idempotent(t *testing.T) { + s := &ScopeXML{ + JSSUserGroups: ScopeItemSlice{ + Items: []NamedItem{{Name: "VPP Associated Users"}}, + ElemName: "jss_user_group", + }, + } + + if AddToScope(s, "vpp_assignment", "target", "user-group", "vpp associated users") { + t.Fatal("expected false for case-insensitive duplicate") + } +} + +func TestRemoveFromScope_UserGroupTarget_RoutesToJSSUserGroups(t *testing.T) { + s := &ScopeXML{ + JSSUserGroups: ScopeItemSlice{ + Items: []NamedItem{{Name: "VPP Associated Users"}, {Name: "Other Group"}}, + ElemName: "jss_user_group", + }, + } + + if !RemoveFromScope(s, "vpp_assignment", "target", "user-group", "VPP Associated Users") { + t.Fatal("expected true") + } + if len(s.JSSUserGroups.Items) != 1 { + t.Fatalf("got %d, want 1", len(s.JSSUserGroups.Items)) + } + if s.JSSUserGroups.Items[0].Name != "Other Group" { + t.Errorf("remaining = %q", s.JSSUserGroups.Items[0].Name) + } +} + +func TestRemoveFromScope_JSSUserGroupExclusion(t *testing.T) { + s := &ScopeXML{ + Exclusions: &ExclusionsXML{ + JSSUserGroups: ScopeItemSlice{ + Items: []NamedItem{{Name: "Excluded Group"}}, + ElemName: "jss_user_group", + }, + }, + } + + if !RemoveFromScope(s, "vpp_assignment", "exclusion", "jss-user-group", "Excluded Group") { + t.Fatal("expected true") + } + if len(s.Exclusions.JSSUserGroups.Items) != 0 { + t.Error("should be empty after remove") + } +} + +func TestFlattenScope_VPPAssignment_JSSUserGroups(t *testing.T) { + s := &ScopeXML{ + JSSUserGroups: ScopeItemSlice{ + Items: []NamedItem{{ID: "1", Name: "VPP Associated Users"}}, + }, + Limitations: &LimitationsXML{ + UserGroups: ScopeItemSlice{ + Items: []NamedItem{{Name: "COB-iosgrade1"}}, + }, + }, + } + + rows := FlattenScope(s, "vpp_assignment") + + expected := []struct{ section, typ, name string }{ + {"target", "jss_user_group", "VPP Associated Users"}, + {"limitation", "user_group", "COB-iosgrade1"}, + } + if len(rows) != len(expected) { + t.Fatalf("got %d rows, want %d: %v", len(rows), len(expected), rows) + } + for i, want := range expected { + got := rows[i] + if got["section"] != want.section || got["type"] != want.typ || got["name"] != want.name { + t.Errorf("row %d: got %v, want %s/%s/%s", i, got, want.section, want.typ, want.name) + } + } +} + +func TestResolveElemName(t *testing.T) { + tests := []struct { + section, flag, want string + }{ + {"target", "user-group", "user_group"}, + {"target", "jss-user-group", "user_group"}, + {"target", "computer-group", "computer_group"}, + {"limitation", "user-group", "user_group"}, + {"exclusion", "user-group", "user_group"}, + {"exclusion", "jss-user-group", "user_group"}, + } + for _, tt := range tests { + if got := resolveElemName(tt.section, tt.flag); got != tt.want { + t.Errorf("resolveElemName(%q,%q) = %q, want %q", tt.section, tt.flag, got, tt.want) + } + } +} + +func TestReplaceScopeInXML(t *testing.T) { + original := []byte(`11false1Old Group`) + + newScope := &ScopeXML{ + JSSUserGroups: ScopeItemSlice{ + Items: []NamedItem{{ID: "2", Name: "New Group"}}, + ElemName: "user_group", + }, + } + + updated, err := replaceScopeInXML(original, newScope) + if err != nil { + t.Fatalf("replaceScopeInXML: %v", err) + } + + s := string(updated) + if strings.Contains(s, "Old Group") { + t.Error("old scope content should be replaced") + } + if !strings.Contains(s, "New Group") { + t.Error("new scope content should be present") + } + if !strings.Contains(s, "") { + t.Error("non-scope content should be preserved") + } +} + +func TestReplaceScopeInXML_MissingScope(t *testing.T) { + original := []byte(`1`) + _, err := replaceScopeInXML(original, &ScopeXML{}) + if err == nil { + t.Error("expected error for missing ") + } +} diff --git a/internal/scope/types.go b/internal/scope/types.go index cc1e7199..932566a4 100644 --- a/internal/scope/types.go +++ b/internal/scope/types.go @@ -13,8 +13,10 @@ import ( // Resource identifies a Classic API resource that supports scope operations. type Resource struct { - APIPath string // URL segment under /JSSResource/, e.g. "policies" - SingularKey string // XML root key for a single object, e.g. "policy" + APIPath string // URL segment under /JSSResource/, e.g. "policies" + SingularKey string // XML root key for a single object, e.g. "policy" + ResolveByList bool // when true, resolve name→ID by listing all (no /name/ endpoint) + NoSubsetPut bool // when true, PUT full document instead of /subset/Scope } // ScopeTarget holds a resolved flag name and value from a scope add/remove command. @@ -213,10 +215,13 @@ var flagToElemName = map[string]string{ "department": "department", "network-segment": "network_segment", "user-group": "user_group", + "jss-user-group": "user_group", + "jss-user": "jss_user", } // scopeFlagNames is the ordered list of scope item flags. var scopeFlagNames = []string{ "computer-group", "mobile-device-group", "building", "department", "network-segment", "user-group", + "jss-user-group", "jss-user", } diff --git a/specs/classic/resources.yaml b/specs/classic/resources.yaml index c3df8ed0..6076595c 100644 --- a/specs/classic/resources.yaml +++ b/specs/classic/resources.yaml @@ -316,6 +316,7 @@ resources: singular: vpp_assignment lookups: [id] scope: true + no_subset_put: true - name: vppinvitations path: vppinvitations From 1e11ddfe446304276c314efc81f42d4daaee0b62 Mon Sep 17 00:00:00 2001 From: Neil Martin Date: Mon, 11 May 2026 15:57:32 +0100 Subject: [PATCH 6/8] feat(classic): add scope support to classic-vpp-invitations Co-Authored-By: Claude Sonnet 4.6 --- .../commands/pro/generated/classic_vpp_invitations.go | 8 ++++++++ internal/commands/pro/generated/provenance.go | 2 +- specs/classic/resources.yaml | 2 ++ 3 files changed, 11 insertions(+), 1 deletion(-) diff --git a/internal/commands/pro/generated/classic_vpp_invitations.go b/internal/commands/pro/generated/classic_vpp_invitations.go index 691e1d65..39845bfb 100644 --- a/internal/commands/pro/generated/classic_vpp_invitations.go +++ b/internal/commands/pro/generated/classic_vpp_invitations.go @@ -12,6 +12,7 @@ import ( "github.com/Jamf-Concepts/jamf-cli/internal/cooldown" "github.com/Jamf-Concepts/jamf-cli/internal/registry" + "github.com/Jamf-Concepts/jamf-cli/internal/scope" "github.com/Jamf-Concepts/jamf-cli/internal/xmlconv" "github.com/spf13/cobra" ) @@ -32,6 +33,13 @@ func NewClassicVppInvitationsCmd(ctx *registry.CLIContext) *cobra.Command { cmd.AddCommand(newClassicVppInvitationsDeleteCmd(ctx)) + cmd.AddCommand(scope.NewScopeCmd(ctx, scope.Resource{ + APIPath: "vppinvitations", + SingularKey: "vpp_invitation", + ResolveByList: true, + NoSubsetPut: true, + })) + return cmd } diff --git a/internal/commands/pro/generated/provenance.go b/internal/commands/pro/generated/provenance.go index 744b273a..bec26cbe 100644 --- a/internal/commands/pro/generated/provenance.go +++ b/internal/commands/pro/generated/provenance.go @@ -173,5 +173,5 @@ var Sources = []SpecSource{ {File: "specs/VppLocations.yaml", SHA256: "d9b41c79c0cdba81706b16c8e6d0b86b8048d40d6d55ec66618af0ac00c533d0"}, {File: "specs/VppSubscriptions.yaml", SHA256: "184b834aeb0d21cf190050294f7869d57b482bbe1834ad5a1bd79dcfdeef7a55"}, {File: "specs/_MonolithLibrary.yaml", SHA256: "9d777dc71e1909d20db05b31451246a805565c856d7b2fc50d38ccb33269a89a"}, - {File: "specs/classic/resources.yaml", SHA256: "c17330d78ce43a51c45347bfc49c151be53a271f670ce72c7dab9fb001e69fdd"}, + {File: "specs/classic/resources.yaml", SHA256: "f7c66d8dd52828acfd324d99d6bd31e0cf34d3d789bb94a9e39ef09e5040dfc4"}, } diff --git a/specs/classic/resources.yaml b/specs/classic/resources.yaml index 6076595c..4799f9a4 100644 --- a/specs/classic/resources.yaml +++ b/specs/classic/resources.yaml @@ -325,6 +325,8 @@ resources: singular: vpp_invitation operations: [list, get, create, delete] lookups: [id] + scope: true + no_subset_put: true - name: gsxconnection path: gsxconnection From a245c6787d860fe32efcd1def422f530903228bb Mon Sep 17 00:00:00 2001 From: Neil Martin Date: Tue, 12 May 2026 14:44:59 +0100 Subject: [PATCH 7/8] fix(scope): tighten validation, add --computer/--mobile-device/--user, post-PUT verify MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Live testing of the full scope-acceptance matrix on a real Jamf Pro tenant revealed several issues: - restricted_software has no /subset/Scope endpoint (server returns 404). Set no_subset_put: true so the CLI falls back to a full-document PUT. - ScopeXML was missing fields for individual , , and . Unmarshal silently dropped them, so any CLI scope add wiped those entries on the subsequent PUT. Adding the fields preserves them on round-trip even though no CLI flag mutates them. - Target validation accepted --user-group, --jss-user-group, --jss-user, and --mobile-device-group on restricted_software; the server silently dropped them. Gate restricted_software to computer-only flags (matches the existing exclusion gate). - --user-group was overloaded: target meant (JSS user group), limitation/exclusion meant (LDAP/AD group). Drop the target meaning entirely — use --jss-user-group explicitly for that. - Added --computer / --mobile-device (target+exclusion, name|id|udid all resolved server-side) and --user (limitation+exclusion, free-text inventory username). Closes coverage gap against the Classic API scope model. - Added VerifyItemInScope: after every PUT, re-fetch and assert the item is (or is not) present. Surfaces server silent drops (e.g. --building target on a vpp_assignment) as a clear CLI error instead of a false-success "Added X" message. Tests updated to match the new validation matrix; added round-trip preservation tests for and , plus per-flag tests for the new --computer/--mobile-device/--user flags. Co-Authored-By: Claude Opus 4.7 (1M context) --- .../generated/classic_restricted_software.go | 1 + internal/commands/pro/generated/provenance.go | 2 +- internal/scope/commands.go | 8 + internal/scope/scope.go | 138 +++++++++-- internal/scope/scope_test.go | 228 ++++++++++++++++-- internal/scope/types.go | 28 ++- specs/classic/resources.yaml | 3 + 7 files changed, 363 insertions(+), 45 deletions(-) diff --git a/internal/commands/pro/generated/classic_restricted_software.go b/internal/commands/pro/generated/classic_restricted_software.go index 53f1b55c..a9025be9 100644 --- a/internal/commands/pro/generated/classic_restricted_software.go +++ b/internal/commands/pro/generated/classic_restricted_software.go @@ -41,6 +41,7 @@ func NewClassicRestrictedSoftwareCmd(ctx *registry.CLIContext) *cobra.Command { cmd.AddCommand(scope.NewScopeCmd(ctx, scope.Resource{ APIPath: "restrictedsoftware", SingularKey: "restricted_software", + NoSubsetPut: true, })) return cmd diff --git a/internal/commands/pro/generated/provenance.go b/internal/commands/pro/generated/provenance.go index bec26cbe..4973b925 100644 --- a/internal/commands/pro/generated/provenance.go +++ b/internal/commands/pro/generated/provenance.go @@ -173,5 +173,5 @@ var Sources = []SpecSource{ {File: "specs/VppLocations.yaml", SHA256: "d9b41c79c0cdba81706b16c8e6d0b86b8048d40d6d55ec66618af0ac00c533d0"}, {File: "specs/VppSubscriptions.yaml", SHA256: "184b834aeb0d21cf190050294f7869d57b482bbe1834ad5a1bd79dcfdeef7a55"}, {File: "specs/_MonolithLibrary.yaml", SHA256: "9d777dc71e1909d20db05b31451246a805565c856d7b2fc50d38ccb33269a89a"}, - {File: "specs/classic/resources.yaml", SHA256: "f7c66d8dd52828acfd324d99d6bd31e0cf34d3d789bb94a9e39ef09e5040dfc4"}, + {File: "specs/classic/resources.yaml", SHA256: "95d378ff42e37d98eecdd780702fac7c332b8de2aa0b5b20ce3795e009d93e73"}, } diff --git a/internal/scope/commands.go b/internal/scope/commands.go index 1ccaef63..e2318672 100644 --- a/internal/scope/commands.go +++ b/internal/scope/commands.go @@ -88,6 +88,10 @@ func newScopeAddCmd(ctx *registry.CLIContext, res Resource) *cobra.Command { return err } + if err := VerifyItemInScope(cmd.Context(), ctx.Client, res, args[0], section, target.FlagName, target.Name, true); err != nil { + return err + } + fmt.Fprintf(os.Stderr, "Added %s %q to %s scope of %q\n", target.FlagName, target.Name, section, args[0]) return OutputScope(ctx.Output, s, res.SingularKey, outputFormat(cmd)) @@ -135,6 +139,10 @@ func newScopeRemoveCmd(ctx *registry.CLIContext, res Resource) *cobra.Command { return err } + if err := VerifyItemInScope(cmd.Context(), ctx.Client, res, args[0], section, target.FlagName, target.Name, false); err != nil { + return err + } + fmt.Fprintf(os.Stderr, "Removed %s %q from %s scope of %q\n", target.FlagName, target.Name, section, args[0]) return OutputScope(ctx.Output, s, res.SingularKey, outputFormat(cmd)) diff --git a/internal/scope/scope.go b/internal/scope/scope.go index d5a37d66..a59eebac 100644 --- a/internal/scope/scope.go +++ b/internal/scope/scope.go @@ -189,6 +189,66 @@ func replaceScopeInXML(original []byte, newScope *ScopeXML) ([]byte, error) { return buf.Bytes(), nil } +// VerifyItemInScope refetches scope after a PUT and confirms the given item is +// (or is not) present, depending on `expectPresent`. Catches the silent-drop +// case where the server returns 200/201 but discards a scope element that +// doesn't apply to the resource type (e.g. computer_groups limitation on a +// policy, network_segment on a VPP assignment). +func VerifyItemInScope(ctx context.Context, client registry.HTTPClient, res Resource, name, section, flagName, itemName string, expectPresent bool) error { + _, s, err := FetchScope(ctx, client, res, name) + if err != nil { + return fmt.Errorf("verifying scope: %w", err) + } + + if isPolicyLimitUserGroup(res.SingularKey, section, flagName) { + ltuPresent := false + if s.LimitToUsers != nil { + for _, g := range s.LimitToUsers.UserGroups.Items { + if strings.EqualFold(g, itemName) { + ltuPresent = true + break + } + } + } + limPresent := false + if s.Limitations != nil { + for _, item := range s.Limitations.UserGroups.Items { + if strings.EqualFold(item.Name, itemName) { + limPresent = true + break + } + } + } + present := ltuPresent || limPresent + if present != expectPresent { + return silentDropError(res.SingularKey, section, flagName, itemName, expectPresent) + } + return nil + } + + items := readScopeItems(s, section, flagName) + present := false + if items != nil { + for _, item := range items.Items { + if strings.EqualFold(item.Name, itemName) { + present = true + break + } + } + } + if present != expectPresent { + return silentDropError(res.SingularKey, section, flagName, itemName, expectPresent) + } + return nil +} + +func silentDropError(singularKey, section, flagName, itemName string, expectedPresent bool) error { + if expectedPresent { + return fmt.Errorf("server accepted PUT but did not persist --%s %q in %s scope (resource type %q does not support this scope element)", flagName, itemName, section, singularKey) + } + return fmt.Errorf("server accepted PUT but did not remove --%s %q from %s scope (resource type %q may not allow modification of this scope element)", flagName, itemName, section, singularKey) +} + // AddToScope adds a named item to the given scope section. Returns true if the // item was added, false if already present (idempotent no-op). func AddToScope(s *ScopeXML, singularKey, section, flagName, name string) bool { @@ -261,17 +321,22 @@ func FlattenScope(s *ScopeXML, singularKey string) []map[string]any { if s.AllComputers { rows = append(rows, map[string]any{"section": "target", "type": "all_computers", "name": "true"}) } + if s.AllMobileDevices { + rows = append(rows, map[string]any{"section": "target", "type": "all_mobile_devices", "name": "true"}) + } if s.AllJSSUsers { rows = append(rows, map[string]any{"section": "target", "type": "all_jss_users", "name": "true"}) } appendNamedRows(&rows, "target", "computer", s.Computers.Items) appendNamedRows(&rows, "target", "computer_group", s.ComputerGroups.Items) + appendNamedRows(&rows, "target", "mobile_device", s.MobileDevices.Items) appendNamedRows(&rows, "target", "mobile_device_group", s.MobileDeviceGroups.Items) appendNamedRows(&rows, "target", "building", s.Buildings.Items) appendNamedRows(&rows, "target", "department", s.Departments.Items) appendNamedRows(&rows, "target", "jss_user", s.JSSUsers.Items) appendNamedRows(&rows, "target", "jss_user_group", s.JSSUserGroups.Items) + appendNamedRows(&rows, "target", "class", s.Classes.Items) // Policy special case: limit_to_users holds plain strings if singularKey == "policy" && s.LimitToUsers != nil { @@ -281,64 +346,92 @@ func FlattenScope(s *ScopeXML, singularKey string) []map[string]any { } if s.Limitations != nil { + appendNamedRows(&rows, "limitation", "user", s.Limitations.Users.Items) appendNamedRows(&rows, "limitation", "network_segment", s.Limitations.NetworkSegments.Items) // For policies, user groups are already emitted from limit_to_users above; // limitations/user_groups is a server-side mirror, so skip it to avoid duplicates. if singularKey != "policy" { appendNamedRows(&rows, "limitation", "user_group", s.Limitations.UserGroups.Items) } - appendNamedRows(&rows, "limitation", "computer_group", s.Limitations.ComputerGroups.Items) + appendNamedRows(&rows, "limitation", "ibeacon", s.Limitations.IBeacons.Items) } if s.Exclusions != nil { appendNamedRows(&rows, "exclusion", "computer", s.Exclusions.Computers.Items) appendNamedRows(&rows, "exclusion", "computer_group", s.Exclusions.ComputerGroups.Items) + appendNamedRows(&rows, "exclusion", "mobile_device", s.Exclusions.MobileDevices.Items) appendNamedRows(&rows, "exclusion", "mobile_device_group", s.Exclusions.MobileDeviceGroups.Items) + appendNamedRows(&rows, "exclusion", "user", s.Exclusions.Users.Items) appendNamedRows(&rows, "exclusion", "user_group", s.Exclusions.UserGroups.Items) appendNamedRows(&rows, "exclusion", "jss_user", s.Exclusions.JSSUsers.Items) appendNamedRows(&rows, "exclusion", "jss_user_group", s.Exclusions.JSSUserGroups.Items) appendNamedRows(&rows, "exclusion", "network_segment", s.Exclusions.NetworkSegments.Items) appendNamedRows(&rows, "exclusion", "building", s.Exclusions.Buildings.Items) appendNamedRows(&rows, "exclusion", "department", s.Exclusions.Departments.Items) + appendNamedRows(&rows, "exclusion", "ibeacon", s.Exclusions.IBeacons.Items) } return rows } // ValidateScopeCombination checks that the given section/flag combination is valid -// for the resource type. +// for the resource type. The acceptance rules below reflect live testing of every +// flag against every scope-enabled Classic resource (see docs/solutions/). +// +// Notable rules: +// - `--user-group` is only valid in limitation/exclusion (LDAP/AD group). Use +// `--jss-user-group` to target a JSS user group; the previous overload of +// `--user-group` for target was ambiguous and made wrong-section mistakes +// undetectable until a GET roundtrip. +// - Restricted software is computer-only: it accepts no limitations, and only +// computer-group / building / department for target and exclusion. The +// server silently drops jss_user_groups / all_jss_users / user_groups for +// this resource type. +// - `--computer` is only valid in target/exclusion (no concept of a "computer +// limitation"). `--mobile-device` same. +// - `--user` (inventory username, free-text) is only valid in +// limitation/exclusion. func ValidateScopeCombination(singularKey, section, flagName string) error { isRestricted := singularKey == "restricted_software" switch section { case "target": + if isRestricted { + switch flagName { + case "computer", "computer-group", "building", "department": + return nil + } + return fmt.Errorf("--%s is not valid as a target for restricted software; use --computer, --computer-group, --building, or --department", flagName) + } switch flagName { - case "computer-group", "mobile-device-group", "building", "department", - "user-group", "jss-user-group", "jss-user": + case "computer", "computer-group", "mobile-device", "mobile-device-group", + "building", "department", "jss-user-group", "jss-user": return nil } - return fmt.Errorf("--%s is not valid as a target; use --computer-group, --mobile-device-group, --building, --department, --user-group, or --jss-user-group", flagName) + return fmt.Errorf("--%s is not valid as a target; use --computer, --computer-group, --mobile-device, --mobile-device-group, --building, --department, --jss-user-group, or --jss-user", flagName) case "limitation": if isRestricted { return fmt.Errorf("restricted software does not support limitations") } switch flagName { - case "network-segment", "user-group", "computer-group": + case "network-segment", "user", "user-group": return nil } - return fmt.Errorf("--%s is not valid as a limitation; use --network-segment, --user-group, or --computer-group", flagName) + return fmt.Errorf("--%s is not valid as a limitation; use --network-segment, --user, or --user-group", flagName) case "exclusion": if isRestricted { switch flagName { - case "computer-group", "building", "department": + case "computer", "computer-group", "building", "department": return nil } - return fmt.Errorf("--%s is not valid as an exclusion for restricted software; use --computer-group, --building, or --department", flagName) + return fmt.Errorf("--%s is not valid as an exclusion for restricted software; use --computer, --computer-group, --building, or --department", flagName) } switch flagName { - case "computer-group", "mobile-device-group", "user-group", "jss-user-group", "jss-user", "network-segment", "building", "department": + case "computer", "computer-group", "mobile-device", "mobile-device-group", + "user", "user-group", "jss-user-group", "jss-user", + "network-segment", "building", "department": return nil } return fmt.Errorf("--%s is not valid as an exclusion", flagName) @@ -359,7 +452,7 @@ func DetermineScopeTarget(cmd *cobra.Command) (ScopeTarget, error) { } } if count == 0 { - return ScopeTarget{}, fmt.Errorf("specify one of: --computer-group, --mobile-device-group, --building, --department, --network-segment, --user-group, --jss-user-group, --jss-user") + return ScopeTarget{}, fmt.Errorf("specify one of: --computer, --computer-group, --mobile-device, --mobile-device-group, --building, --department, --network-segment, --user, --user-group, --jss-user-group, --jss-user") } if count > 1 { return ScopeTarget{}, fmt.Errorf("specify only one scopeable type per invocation") @@ -373,12 +466,15 @@ func AddScopeFlags(cmd *cobra.Command, section *string) { _ = cmd.RegisterFlagCompletionFunc("section", func(_ *cobra.Command, _ []string, _ string) ([]string, cobra.ShellCompDirective) { return []string{"target", "limitation", "exclusion"}, cobra.ShellCompDirectiveNoFileComp }) + cmd.Flags().String("computer", "", "individual computer (id, name, or UDID); target/exclusion only") cmd.Flags().String("computer-group", "", "computer group name") + cmd.Flags().String("mobile-device", "", "individual mobile device (id, name, or UDID); target/exclusion only") cmd.Flags().String("mobile-device-group", "", "mobile device group name") cmd.Flags().String("building", "", "building name") cmd.Flags().String("department", "", "department name") - cmd.Flags().String("network-segment", "", "network segment name") - cmd.Flags().String("user-group", "", "user group name (limitations/exclusions) or JSS user group name (target)") + cmd.Flags().String("network-segment", "", "network segment name (limitations/exclusions)") + cmd.Flags().String("user", "", "inventory username (free-text, limitations/exclusions only)") + cmd.Flags().String("user-group", "", "LDAP/directory user group name (limitations/exclusions only — use --jss-user-group for target)") cmd.Flags().String("jss-user-group", "", "JSS user group name (target/exclusion)") cmd.Flags().String("jss-user", "", "JSS user name (target/exclusion)") } @@ -485,15 +581,19 @@ func readScopeItems(s *ScopeXML, section, flagName string) *ScopeItemSlice { func targetItems(s *ScopeXML, flagName string) *ScopeItemSlice { switch flagName { + case "computer": + return &s.Computers case "computer-group": return &s.ComputerGroups + case "mobile-device": + return &s.MobileDevices case "mobile-device-group": return &s.MobileDeviceGroups case "building": return &s.Buildings case "department": return &s.Departments - case "user-group", "jss-user-group": + case "jss-user-group": return &s.JSSUserGroups case "jss-user": return &s.JSSUsers @@ -505,20 +605,26 @@ func limitationItems(lim *LimitationsXML, flagName string) *ScopeItemSlice { switch flagName { case "network-segment": return &lim.NetworkSegments + case "user": + return &lim.Users case "user-group": return &lim.UserGroups - case "computer-group": - return &lim.ComputerGroups } return nil } func exclusionItems(exc *ExclusionsXML, flagName string) *ScopeItemSlice { switch flagName { + case "computer": + return &exc.Computers case "computer-group": return &exc.ComputerGroups + case "mobile-device": + return &exc.MobileDevices case "mobile-device-group": return &exc.MobileDeviceGroups + case "user": + return &exc.Users case "user-group": return &exc.UserGroups case "jss-user-group": diff --git a/internal/scope/scope_test.go b/internal/scope/scope_test.go index a68a6ce9..e2ec7ed0 100644 --- a/internal/scope/scope_test.go +++ b/internal/scope/scope_test.go @@ -420,8 +420,8 @@ func TestRemoveFromScope_PolicyLimitUserGroup_InBothLocations(t *testing.T) { // ─── ValidateScopeCombination ──────────────────────────────────────────────── func TestValidateScopeCombination_ValidTargets(t *testing.T) { - for _, flag := range []string{"computer-group", "mobile-device-group", "building", "department", "user-group", "jss-user-group", "jss-user"} { - for _, sk := range []string{"policy", "restricted_software", "os_x_configuration_profile"} { + for _, flag := range []string{"computer", "computer-group", "mobile-device", "mobile-device-group", "building", "department", "jss-user-group", "jss-user"} { + for _, sk := range []string{"policy", "os_x_configuration_profile"} { if err := ValidateScopeCombination(sk, "target", flag); err != nil { t.Errorf("target/%s/%s: %v", sk, flag, err) } @@ -429,14 +429,34 @@ func TestValidateScopeCombination_ValidTargets(t *testing.T) { } } +func TestValidateScopeCombination_UserGroupTargetRejected(t *testing.T) { + // --user-group must not be valid for target; --jss-user-group is the explicit alternative. + if err := ValidateScopeCombination("policy", "target", "user-group"); err == nil { + t.Error("expected error: --user-group as target should be rejected (use --jss-user-group)") + } +} + func TestValidateScopeCombination_InvalidTarget(t *testing.T) { if err := ValidateScopeCombination("policy", "target", "network-segment"); err == nil { t.Error("expected error for network-segment as target") } } +func TestValidateScopeCombination_RestrictedSoftwareTargets(t *testing.T) { + for _, flag := range []string{"computer", "computer-group", "building", "department"} { + if err := ValidateScopeCombination("restricted_software", "target", flag); err != nil { + t.Errorf("restricted target/%s: %v", flag, err) + } + } + for _, flag := range []string{"mobile-device", "mobile-device-group", "jss-user-group", "jss-user", "user-group"} { + if err := ValidateScopeCombination("restricted_software", "target", flag); err == nil { + t.Errorf("expected error: restricted software target + %s", flag) + } + } +} + func TestValidateScopeCombination_ValidLimitations(t *testing.T) { - for _, flag := range []string{"network-segment", "user-group", "computer-group"} { + for _, flag := range []string{"network-segment", "user", "user-group"} { if err := ValidateScopeCombination("policy", "limitation", flag); err != nil { t.Errorf("limitation/%s: %v", flag, err) } @@ -444,7 +464,7 @@ func TestValidateScopeCombination_ValidLimitations(t *testing.T) { } func TestValidateScopeCombination_RestrictedSoftwareNoLimitations(t *testing.T) { - for _, flag := range []string{"network-segment", "user-group", "computer-group"} { + for _, flag := range []string{"network-segment", "user-group", "user"} { err := ValidateScopeCombination("restricted_software", "limitation", flag) if err == nil { t.Errorf("expected error: restricted software + limitation + %s", flag) @@ -456,7 +476,7 @@ func TestValidateScopeCombination_RestrictedSoftwareNoLimitations(t *testing.T) } func TestValidateScopeCombination_ValidExclusions(t *testing.T) { - for _, flag := range []string{"computer-group", "mobile-device-group", "user-group", "jss-user-group", "jss-user", "network-segment", "building", "department"} { + for _, flag := range []string{"computer", "computer-group", "mobile-device", "mobile-device-group", "user", "user-group", "jss-user-group", "jss-user", "network-segment", "building", "department"} { if err := ValidateScopeCombination("policy", "exclusion", flag); err != nil { t.Errorf("exclusion/%s: %v", flag, err) } @@ -464,12 +484,12 @@ func TestValidateScopeCombination_ValidExclusions(t *testing.T) { } func TestValidateScopeCombination_RestrictedSoftwareExclusions(t *testing.T) { - for _, flag := range []string{"computer-group", "building", "department"} { + for _, flag := range []string{"computer", "computer-group", "building", "department"} { if err := ValidateScopeCombination("restricted_software", "exclusion", flag); err != nil { t.Errorf("restricted exclusion/%s: %v", flag, err) } } - for _, flag := range []string{"mobile-device-group", "user-group", "network-segment"} { + for _, flag := range []string{"mobile-device", "mobile-device-group", "user", "user-group", "jss-user-group", "jss-user", "network-segment"} { if err := ValidateScopeCombination("restricted_software", "exclusion", flag); err == nil { t.Errorf("expected error: restricted software exclusion + %s", flag) } @@ -488,6 +508,19 @@ func TestValidateScopeCombination_InvalidLimitationFlag(t *testing.T) { } } +func TestValidateScopeCombination_ComputerOnlyInTargetExclusion(t *testing.T) { + // --computer / --mobile-device / --user must be rejected outside their sections. + if err := ValidateScopeCombination("policy", "limitation", "computer"); err == nil { + t.Error("expected error: --computer as limitation") + } + if err := ValidateScopeCombination("policy", "limitation", "mobile-device"); err == nil { + t.Error("expected error: --mobile-device as limitation") + } + if err := ValidateScopeCombination("policy", "target", "user"); err == nil { + t.Error("expected error: --user as target") + } +} + // ─── FlattenScope ──────────────────────────────────────────────────────────── func TestFlattenScope_BasicPolicy(t *testing.T) { @@ -603,20 +636,16 @@ func TestIsPolicyLimitUserGroup(t *testing.T) { // ─── JSS user group target routing (VPP-style scope) ───────────────────────── -func TestAddToScope_UserGroupTarget_RoutesToJSSUserGroups(t *testing.T) { +func TestAddToScope_UserGroupTarget_NoLongerRoutes(t *testing.T) { + // --user-group is LDAP-only (limitation/exclusion). For target users must use + // --jss-user-group explicitly. AddToScope should refuse to add it. s := &ScopeXML{} - if !AddToScope(s, "vpp_assignment", "target", "user-group", "VPP Associated Users") { - t.Fatal("expected true") - } - if len(s.JSSUserGroups.Items) != 1 { - t.Fatalf("jss_user_groups: got %d, want 1", len(s.JSSUserGroups.Items)) + if AddToScope(s, "vpp_assignment", "target", "user-group", "VPP Associated Users") { + t.Fatal("expected false: --user-group is not a valid target flag") } - if s.JSSUserGroups.Items[0].Name != "VPP Associated Users" { - t.Errorf("name = %q", s.JSSUserGroups.Items[0].Name) - } - if s.JSSUserGroups.ElemName != "user_group" { - t.Errorf("ElemName = %q, want user_group", s.JSSUserGroups.ElemName) + if len(s.JSSUserGroups.Items) != 0 { + t.Errorf("jss_user_groups should remain empty, got %d items", len(s.JSSUserGroups.Items)) } } @@ -638,24 +667,24 @@ func TestAddToScope_JSSUserGroupTarget_Idempotent(t *testing.T) { s := &ScopeXML{ JSSUserGroups: ScopeItemSlice{ Items: []NamedItem{{Name: "VPP Associated Users"}}, - ElemName: "jss_user_group", + ElemName: "user_group", }, } - if AddToScope(s, "vpp_assignment", "target", "user-group", "vpp associated users") { + if AddToScope(s, "vpp_assignment", "target", "jss-user-group", "vpp associated users") { t.Fatal("expected false for case-insensitive duplicate") } } -func TestRemoveFromScope_UserGroupTarget_RoutesToJSSUserGroups(t *testing.T) { +func TestRemoveFromScope_JSSUserGroupTarget(t *testing.T) { s := &ScopeXML{ JSSUserGroups: ScopeItemSlice{ Items: []NamedItem{{Name: "VPP Associated Users"}, {Name: "Other Group"}}, - ElemName: "jss_user_group", + ElemName: "user_group", }, } - if !RemoveFromScope(s, "vpp_assignment", "target", "user-group", "VPP Associated Users") { + if !RemoveFromScope(s, "vpp_assignment", "target", "jss-user-group", "VPP Associated Users") { t.Fatal("expected true") } if len(s.JSSUserGroups.Items) != 1 { @@ -717,10 +746,16 @@ func TestResolveElemName(t *testing.T) { tests := []struct { section, flag, want string }{ - {"target", "user-group", "user_group"}, {"target", "jss-user-group", "user_group"}, + {"target", "computer", "computer"}, {"target", "computer-group", "computer_group"}, + {"target", "mobile-device", "mobile_device"}, + {"target", "mobile-device-group", "mobile_device_group"}, + {"limitation", "user", "user"}, {"limitation", "user-group", "user_group"}, + {"exclusion", "computer", "computer"}, + {"exclusion", "mobile-device", "mobile_device"}, + {"exclusion", "user", "user"}, {"exclusion", "user-group", "user_group"}, {"exclusion", "jss-user-group", "user_group"}, } @@ -765,3 +800,148 @@ func TestReplaceScopeInXML_MissingScope(t *testing.T) { t.Error("expected error for missing ") } } + +// ─── Round-trip preserves fields server returned but CLI doesn't expose ───── + +func TestScopeXML_PreservesMobileDevicesOnRoundTrip(t *testing.T) { + // Mobile config profiles return (individual devices). CLI's + // scope add only manipulates groups, but the unmarshal/marshal round-trip + // must preserve these or they get wiped on subset/Scope PUT. + data := ` + 1Profile + + false + + 18G6TDK43P0D4Y00008101-000170490151003A + + + ` + var env classicResourceXML + if err := xml.Unmarshal([]byte(data), &env); err != nil { + t.Fatalf("unmarshal: %v", err) + } + if len(env.Scope.MobileDevices.Items) != 1 { + t.Fatalf("mobile_devices: got %d, want 1", len(env.Scope.MobileDevices.Items)) + } + if env.Scope.MobileDevices.Items[0].ID != "18" { + t.Errorf("mobile_devices[0].id = %q, want 18", env.Scope.MobileDevices.Items[0].ID) + } + // Marshal back and verify still present. + out, err := xml.Marshal(env.Scope) + if err != nil { + t.Fatalf("marshal: %v", err) + } + if !strings.Contains(string(out), "") { + t.Error("round-trip lost data") + } +} + +func TestScopeXML_PreservesClassesOnRoundTrip(t *testing.T) { + // Ebooks return . CLI doesn't expose, but round-trip must preserve. + data := ` + 2Book + + + 510A + + + ` + var env classicResourceXML + if err := xml.Unmarshal([]byte(data), &env); err != nil { + t.Fatalf("unmarshal: %v", err) + } + if len(env.Scope.Classes.Items) != 1 || env.Scope.Classes.Items[0].Name != "10A" { + t.Fatalf("classes: got %+v", env.Scope.Classes.Items) + } + out, err := xml.Marshal(env.Scope) + if err != nil { + t.Fatalf("marshal: %v", err) + } + if !strings.Contains(string(out), "") { + t.Error("round-trip lost data") + } +} + +// ─── New scope flags ────────────────────────────────────────────────────────── + +func TestAddToScope_ComputerTarget(t *testing.T) { + s := &ScopeXML{} + if !AddToScope(s, "policy", "target", "computer", "ZTNR9F6XJ0") { + t.Fatal("expected true") + } + if len(s.Computers.Items) != 1 || s.Computers.Items[0].Name != "ZTNR9F6XJ0" { + t.Errorf("computers = %+v", s.Computers.Items) + } +} + +func TestAddToScope_MobileDeviceTarget(t *testing.T) { + s := &ScopeXML{} + if !AddToScope(s, "configuration_profile", "target", "mobile-device", "G6TDK43P0D4Y") { + t.Fatal("expected true") + } + if len(s.MobileDevices.Items) != 1 || s.MobileDevices.Items[0].Name != "G6TDK43P0D4Y" { + t.Errorf("mobile_devices = %+v", s.MobileDevices.Items) + } + if s.MobileDevices.ElemName != "mobile_device" { + t.Errorf("ElemName = %q", s.MobileDevices.ElemName) + } +} + +func TestAddToScope_UserLimitation(t *testing.T) { + s := &ScopeXML{} + if !AddToScope(s, "policy", "limitation", "user", "alice") { + t.Fatal("expected true") + } + if s.Limitations == nil || len(s.Limitations.Users.Items) != 1 { + t.Fatalf("limitations.users = %+v", s.Limitations) + } + if s.Limitations.Users.Items[0].Name != "alice" { + t.Errorf("user name = %q", s.Limitations.Users.Items[0].Name) + } +} + +func TestAddToScope_UserExclusion(t *testing.T) { + s := &ScopeXML{} + if !AddToScope(s, "policy", "exclusion", "user", "bob") { + t.Fatal("expected true") + } + if s.Exclusions == nil || len(s.Exclusions.Users.Items) != 1 { + t.Fatalf("exclusions.users = %+v", s.Exclusions) + } +} + +func TestFlattenScope_NewFields(t *testing.T) { + s := &ScopeXML{ + AllMobileDevices: true, + Computers: ScopeItemSlice{Items: []NamedItem{{ID: "28", Name: "Mac-X"}}}, + MobileDevices: ScopeItemSlice{Items: []NamedItem{{ID: "18", Name: "iPad-Y"}}}, + Limitations: &LimitationsXML{ + Users: ScopeItemSlice{Items: []NamedItem{{Name: "alice"}}}, + }, + Exclusions: &ExclusionsXML{ + MobileDevices: ScopeItemSlice{Items: []NamedItem{{Name: "iPad-Z"}}}, + Users: ScopeItemSlice{Items: []NamedItem{{Name: "bob"}}}, + }, + } + rows := FlattenScope(s, "configuration_profile") + + want := map[string]bool{ + "target:all_mobile_devices:true": false, + "target:computer:Mac-X": false, + "target:mobile_device:iPad-Y": false, + "limitation:user:alice": false, + "exclusion:mobile_device:iPad-Z": false, + "exclusion:user:bob": false, + } + for _, r := range rows { + key := r["section"].(string) + ":" + r["type"].(string) + ":" + r["name"].(string) + if _, ok := want[key]; ok { + want[key] = true + } + } + for k, found := range want { + if !found { + t.Errorf("missing flatten row: %s", k) + } + } +} diff --git a/internal/scope/types.go b/internal/scope/types.go index 932566a4..598c479a 100644 --- a/internal/scope/types.go +++ b/internal/scope/types.go @@ -143,17 +143,25 @@ func (s ScopeStringSlice) MarshalJSON() ([]byte, error) { } // ScopeXML models the complete section of a Classic API resource. +// +// All scopeable item slices are present unconditionally so that an unmarshal → +// modify → marshal round-trip preserves every section the server returned. A +// missing field here would cause CLI scope add/remove to silently wipe data +// the user set elsewhere (e.g. individual mobile devices added via UI). type ScopeXML struct { XMLName xml.Name `xml:"scope" json:"-"` AllComputers bool `xml:"all_computers" json:"all_computers"` + AllMobileDevices bool `xml:"all_mobile_devices,omitempty" json:"all_mobile_devices,omitempty"` AllJSSUsers bool `xml:"all_jss_users" json:"all_jss_users"` Computers ScopeItemSlice `xml:"computers" json:"computers"` ComputerGroups ScopeItemSlice `xml:"computer_groups" json:"computer_groups"` + MobileDevices ScopeItemSlice `xml:"mobile_devices" json:"mobile_devices"` + MobileDeviceGroups ScopeItemSlice `xml:"mobile_device_groups" json:"mobile_device_groups"` JSSUsers ScopeItemSlice `xml:"jss_users" json:"jss_users"` JSSUserGroups ScopeItemSlice `xml:"jss_user_groups" json:"jss_user_groups"` - MobileDeviceGroups ScopeItemSlice `xml:"mobile_device_groups" json:"mobile_device_groups"` Buildings ScopeItemSlice `xml:"buildings" json:"buildings"` Departments ScopeItemSlice `xml:"departments" json:"departments"` + Classes ScopeItemSlice `xml:"classes" json:"classes,omitempty"` LimitToUsers *LimitToUsersXML `xml:"limit_to_users,omitempty" json:"limit_to_users,omitempty"` Limitations *LimitationsXML `xml:"limitations,omitempty" json:"limitations,omitempty"` Exclusions *ExclusionsXML `xml:"exclusions,omitempty" json:"exclusions,omitempty"` @@ -177,6 +185,7 @@ type LimitationsXML struct { type ExclusionsXML struct { Computers ScopeItemSlice `xml:"computers" json:"computers"` ComputerGroups ScopeItemSlice `xml:"computer_groups" json:"computer_groups"` + MobileDevices ScopeItemSlice `xml:"mobile_devices" json:"mobile_devices"` MobileDeviceGroups ScopeItemSlice `xml:"mobile_device_groups" json:"mobile_device_groups"` Users ScopeItemSlice `xml:"users" json:"users"` UserGroups ScopeItemSlice `xml:"user_groups" json:"user_groups"` @@ -208,12 +217,23 @@ type scopeUpdateXML struct { // flagToElemName maps a CLI flag to the XML child element name used when // adding new items to a scope list. +// +// `--user-group` and `--jss-user-group` both serialize as children +// — their parent element ( in limitations/exclusions vs +// in target/exclusion) is what disambiguates the semantics. +// +// `--jss-user` writes children; the server's GET response returns +// the same items as children of instead. The server accepts +// both shapes on PUT, so the asymmetric write is harmless. var flagToElemName = map[string]string{ + "computer": "computer", "computer-group": "computer_group", + "mobile-device": "mobile_device", "mobile-device-group": "mobile_device_group", "building": "building", "department": "department", "network-segment": "network_segment", + "user": "user", "user-group": "user_group", "jss-user-group": "user_group", "jss-user": "jss_user", @@ -221,7 +241,7 @@ var flagToElemName = map[string]string{ // scopeFlagNames is the ordered list of scope item flags. var scopeFlagNames = []string{ - "computer-group", "mobile-device-group", "building", - "department", "network-segment", "user-group", - "jss-user-group", "jss-user", + "computer", "computer-group", "mobile-device", "mobile-device-group", + "building", "department", "network-segment", + "user", "user-group", "jss-user-group", "jss-user", } diff --git a/specs/classic/resources.yaml b/specs/classic/resources.yaml index 4799f9a4..f4b04d66 100644 --- a/specs/classic/resources.yaml +++ b/specs/classic/resources.yaml @@ -180,6 +180,9 @@ resources: cli_name: classic-restricted-software singular: restricted_software scope: true + # Classic API has no /subset/Scope endpoint for restricted_software (404). + # Fetch the full document, splice in the updated scope, PUT the whole doc. + no_subset_put: true - name: allowedfileextensions path: allowedfileextensions From c08d06c7ea3c448abe32016b5805e1e2549490a1 Mon Sep 17 00:00:00 2001 From: Neil Martin Date: Tue, 12 May 2026 19:18:59 +0100 Subject: [PATCH 8/8] fix(scope): match VerifyItemInScope by id/UDID for --computer/--mobile-device NamedItem gains a UDID field (captured from in Classic XML). VerifyItemInScope now accepts name, numeric id, or UDID so passing --computer 10 or --mobile-device 00008101-... no longer false-fires the silent-drop error after a successful PUT. Also: soften silentDropError message to list both failure causes; drop stale resolveElemName comment. Co-Authored-By: Claude Sonnet 4.6 --- internal/scope/scope.go | 9 ++++---- internal/scope/scope_test.go | 43 ++++++++++++++++++++++++++++++++++++ internal/scope/types.go | 4 +++- 3 files changed, 50 insertions(+), 6 deletions(-) diff --git a/internal/scope/scope.go b/internal/scope/scope.go index a59eebac..9baa89bb 100644 --- a/internal/scope/scope.go +++ b/internal/scope/scope.go @@ -230,7 +230,9 @@ func VerifyItemInScope(ctx context.Context, client registry.HTTPClient, res Reso present := false if items != nil { for _, item := range items.Items { - if strings.EqualFold(item.Name, itemName) { + if strings.EqualFold(item.Name, itemName) || + item.ID == itemName || + strings.EqualFold(item.UDID, itemName) { present = true break } @@ -244,7 +246,7 @@ func VerifyItemInScope(ctx context.Context, client registry.HTTPClient, res Reso func silentDropError(singularKey, section, flagName, itemName string, expectedPresent bool) error { if expectedPresent { - return fmt.Errorf("server accepted PUT but did not persist --%s %q in %s scope (resource type %q does not support this scope element)", flagName, itemName, section, singularKey) + return fmt.Errorf("server accepted PUT but did not persist --%s %q in %s scope (identifier did not resolve to an existing record, or resource type %q does not support this scope element)", flagName, itemName, section, singularKey) } return fmt.Errorf("server accepted PUT but did not remove --%s %q from %s scope (resource type %q may not allow modification of this scope element)", flagName, itemName, section, singularKey) } @@ -642,9 +644,6 @@ func exclusionItems(exc *ExclusionsXML, flagName string) *ScopeItemSlice { } // resolveElemName returns the XML child element name for a new scope item. -// Both --user-group and --jss-user-group route to jss_user_groups whose -// children are , so flagToElemName["user-group"] = "user_group" -// is correct for both cases. func resolveElemName(section, flagName string) string { return flagToElemName[flagName] } diff --git a/internal/scope/scope_test.go b/internal/scope/scope_test.go index e2ec7ed0..ddbaf4d0 100644 --- a/internal/scope/scope_test.go +++ b/internal/scope/scope_test.go @@ -945,3 +945,46 @@ func TestFlattenScope_NewFields(t *testing.T) { } } } + +// ─── VerifyItemInScope id/UDID matching ────────────────────────────────────── + +func TestVerifyItemInScope_MatchesByNameIDAndUDID(t *testing.T) { + // Simulates the post-PUT GET response where the server returns the canonical + // name alongside the id/udid. The user may have supplied any of the three + // forms to the CLI, so VerifyItemInScope must accept all three. + s := &ScopeXML{ + Computers: ScopeItemSlice{ + Items: []NamedItem{{ID: "10", Name: "Mac-Build-01", UDID: "AAA-BBB"}}, + }, + MobileDevices: ScopeItemSlice{ + Items: []NamedItem{{ID: "18", Name: "G6TDK43P0D4Y", UDID: "00008101-000170490151003A"}}, + }, + } + + for _, tc := range []struct { + flagName string + input string + items *ScopeItemSlice + }{ + {"computer", "Mac-Build-01", &s.Computers}, + {"computer", "10", &s.Computers}, + {"computer", "AAA-BBB", &s.Computers}, + {"computer", "aaa-bbb", &s.Computers}, // UDID case-insensitive + {"mobile-device", "G6TDK43P0D4Y", &s.MobileDevices}, + {"mobile-device", "18", &s.MobileDevices}, + {"mobile-device", "00008101-000170490151003A", &s.MobileDevices}, + } { + present := false + for _, item := range tc.items.Items { + if strings.EqualFold(item.Name, tc.input) || + item.ID == tc.input || + strings.EqualFold(item.UDID, tc.input) { + present = true + break + } + } + if !present { + t.Errorf("flag --%s value %q: expected match, got none", tc.flagName, tc.input) + } + } +} diff --git a/internal/scope/types.go b/internal/scope/types.go index 598c479a..c62a928e 100644 --- a/internal/scope/types.go +++ b/internal/scope/types.go @@ -31,12 +31,14 @@ type ScopeTarget struct { // multiple elements) that Go's built-in encoding cannot express // with tags alone. -// NamedItem is an item identified by name (and optionally ID) in scope XML. +// NamedItem is an item identified by name (and optionally ID or UDID) in scope XML. // ID is a string to accommodate both integer IDs (most resources) and UUID // IDs (e.g. ebook scope user groups) returned by the Classic API. +// UDID is populated for individual mobile devices and computers. type NamedItem struct { ID string `xml:"id,omitempty" json:"id,omitempty"` Name string `xml:"name" json:"name"` + UDID string `xml:"udid,omitempty" json:"udid,omitempty"` } // ScopeItemSlice is a list of NamedItem elements under a single XML parent.