diff --git a/docs/solutions/conventions/classic-profile-payload-ampersand-escaping-2026-07-30.md b/docs/solutions/conventions/classic-profile-payload-ampersand-escaping-2026-07-30.md
new file mode 100644
index 00000000..c1164386
--- /dev/null
+++ b/docs/solutions/conventions/classic-profile-payload-ampersand-escaping-2026-07-30.md
@@ -0,0 +1,109 @@
+---
+title: "Classic API profile payloads: escape every & once inside CDATA, then verify — the server's entity handling is per-payload-type (PI-827)"
+date: 2026-07-30
+category: conventions
+module: generator/classic
+problem_type: server-quirk
+severity: high
+applies_when:
+ - "Sending content to POST/PUT /JSSResource/osxconfigurationprofiles or mobiledeviceconfigurationprofiles"
+ - "A profile upload fails with HTTP 409 'Unable to update the database'"
+ - "A stored profile value shows & / > as literal text on a device"
+ - "Adding a new xml-cdata file field to the classic generator"
+tags:
+ - classic-api
+ - config-profiles
+ - cdata
+ - xml-escaping
+ - PI-827
+---
+
+## Problem
+
+`pro classic-macos-config-profiles apply --mobileconfig-file X.mobileconfig`
+failed with an opaque HTTP 409 "Unable to update the database" whenever the
+mobileconfig contained any XML entity in a value — e.g.
+`R&D`. Since plists must encode `&` and `<`, any
+profile with `&` or `<` in a string value could not be uploaded. Backup
+round-trips (GET XML piped back into `apply`) failed identically.
+
+## The server model (every clause wire-verified 2026-07-30, EU + US tenants)
+
+1. **Validation**: the server entity-decodes the submitted payload content
+ once and rejects the write with 409 when the result contains a bare `&`
+ or `<`. Consequence: spec-correct raw CDATA is rejected for ANY plist
+ containing `&`/`<`, regardless of payload types. The escaped form
+ (every `&` escaped once more) is the only submittable form for such
+ profiles.
+2. **Storage is per-payload-type** (proven with a mixed profile — one wire
+ body, two different treatments):
+ - Fragments of payload types the server re-renders —
+ `com.apple.ManagedClient.preferences` custom settings (values AND dict
+ keys), `com.apple.notificationsettings` — are entity-decoded once.
+ The escape stores them **byte-exact**.
+ - Fragments of every other payload type (TCC, direct
+ `com.apple.loginwindow`, and all payloads on
+ `mobiledeviceconfigurationprofiles`) are stored **verbatim**, keeping
+ one extra entity layer. Values with `&`/`<` in those types cannot be
+ stored faithfully by any client — the device sees `&` where `&`
+ was meant. This matches PI-827's history: Jamf's own UI-created
+ exports cannot be re-uploaded intact.
+3. GET responses are escaped correctly (ingest-only quirk); the server also
+ canonicalizes representation (entities for `& < >`, literals for `" '`,
+ sorted keys) and trims leading/trailing whitespace inside string values.
+
+Do not re-derive this from theory: a raw-first/retry-on-409 design was
+implemented and reverted in this branch's history because probes showed the
+409 is validation (fires for all payload types), not a per-type signal —
+raw never succeeds for entity-bearing profiles, so retrying costs a round
+trip and buys nothing.
+
+## Solution
+
+`normalizeClassicProfilePayloadsForSend` (classic registry template in
+`generator/classic/generator.go`), applied as the final body transform in
+create/update/apply for both profile resources:
+
+1. Recover the true plist: CDATA content is already true form; text-form
+ content (GET/backup XML piped back in) is entity-decoded once.
+2. Guard `]]>` → `]]>`, then escape every `&` once and wrap in CDATA.
+
+`verifyClassicProfileStored` then GETs the stored payload after every
+successful write and compares it against the submitted plist
+(`profileconvert.DiffPayloadValues` — parse-based, masks the
+`Payload*` metadata keys the server rewrites, trims string edges). When the
+server stored values differently (case 2 above), the exact dotted paths are
+printed as a stderr warning. Silent corruption is never acceptable;
+a warning that names `PayloadContent[3].LoginwindowText` is.
+
+Wrap-time (`injectClassicFileFields`): CMS-signed mobileconfigs are
+detected (`profileconvert.IsSignedProfile`) and their inner plist extracted
+(`ExtractSignedProfile`, smallstep/pkcs7) with a stderr note — the
+signature cannot survive this API anyway. Binary plists (`bplist0`) are
+converted to XML (`NormalizeXML`). Embedded CDATA sections are rewritten as
+escaped character data by `stripCDATASections` — a **textual** rewrite,
+deliberately not a plist re-serialise, because `howett.net/plist` drops
+trailing whitespace in CDATA text.
+
+## Verification
+
+Live matrix on both tenants: all five reserved characters in every legal
+representation (raw/named/decimal/hex), literal entity text, embedded CDATA
+sections, dict keys with entities, CEL expressions, signed profiles, binary
+plists, GET→apply round-trips — byte-exact storage for MCX-family content
+through create/update/apply, with deterministic warnings for the
+server-unfixable combinations (mobile profiles and typed macOS payloads
+carrying `&`/`<` in values).
+
+## Scope notes
+
+- `macapplications`/`mobiledeviceapplications` `--appconfig-file` also
+ embeds xml-cdata content — the wrap-time handling applies, but the
+ escape/verify path does not (untested against the server; probe before
+ extending).
+- Related: terraform-provider-jamfpro PR #1103 and jamfplatform-go-sdk's
+ `PayloadsXMLText` — the SDK needs the same wire form; its acceptance
+ matrix in `acc_proclassic_profile_payloads_test.go` encodes the same
+ server model.
+- Jira: PI-827 (Triaged since 2024), PI-777, PI-690 (closed Not Going To
+ Do). The per-payload-type evidence here is a ready-made escalation kit.
diff --git a/generator/classic/generator.go b/generator/classic/generator.go
index 6c1419dd..c20a6191 100644
--- a/generator/classic/generator.go
+++ b/generator/classic/generator.go
@@ -622,7 +622,28 @@ func new{{ .GoName }}CreateCmd(ctx *registry.CLIContext) *cobra.Command {
return err
}
{{- end }}
+{{ if .IsConfigProfile }}
+ bodyBytes = normalizeClassicProfilePayloadsForSend(bodyBytes)
resp, err := ctx.Client.Do(reqCtx, "POST", "/JSSResource/{{ .Path }}/{{ idPath . }}/0", bytes.NewReader(bodyBytes))
+ if err != nil {
+ return err
+ }
+ defer resp.Body.Close()
+ respBody, readErr := io.ReadAll(resp.Body)
+ if readErr != nil {
+ return readErr
+ }
+ verifyClassicProfileStored(reqCtx, ctx.Client, "{{ .Path }}", classicCreatedResourceID(respBody), bodyBytes)
+ return ctx.Output.PrintRaw(respBody)
+{{ else -}}
+ resp, err := ctx.Client.Do(reqCtx, "POST", "/JSSResource/{{ .Path }}/{{ idPath . }}/0", bytes.NewReader(bodyBytes))
+ if err != nil {
+ return err
+ }
+ defer resp.Body.Close()
+
+ return ctx.Output.PrintResponse(resp)
+{{ end -}}
{{ else }}
var body io.Reader
stat, _ := os.Stdin.Stat()
@@ -633,13 +654,13 @@ func new{{ .GoName }}CreateCmd(ctx *registry.CLIContext) *cobra.Command {
}
resp, err := ctx.Client.Do(reqCtx, "POST", "/JSSResource/{{ .Path }}/{{ idPath . }}/0", body)
-{{ end }}
if err != nil {
return err
}
defer resp.Body.Close()
return ctx.Output.PrintResponse(resp)
+{{ end }}
},
}
{{ if .FileFields }}
@@ -807,10 +828,17 @@ func new{{ .GoName }}UpdateCmd(ctx *registry.CLIContext) *cobra.Command {
{{ if .IsConfigProfile }}
bodyBytes = injectClassicProfilePayloadUUIDs(bodyBytes, existingPayload)
bodyBytes = injectClassicRedeployOnUpdate(bodyBytes)
-{{ end }}
+ bodyBytes = normalizeClassicProfilePayloadsForSend(bodyBytes)
path := fmt.Sprintf("/JSSResource/{{ .Path }}/{{ idPath . }}/%s", url.PathEscape(resolvedID))
resp, err := ctx.Client.Do(reqCtx, "PUT", path, bytes.NewReader(bodyBytes))
+ if err == nil {
+ verifyClassicProfileStored(reqCtx, ctx.Client, "{{ .Path }}", resolvedID, bodyBytes)
+ }
+{{ else }}
+ path := fmt.Sprintf("/JSSResource/{{ .Path }}/{{ idPath . }}/%s", url.PathEscape(resolvedID))
+ resp, err := ctx.Client.Do(reqCtx, "PUT", path, bytes.NewReader(bodyBytes))
+{{ end }}
{{ else }}
var body io.Reader
stat, _ := os.Stdin.Stat()
@@ -1239,6 +1267,21 @@ If not, a new resource is created.` + "`" + `,
fmt.Fprintf(os.Stderr, "[dry-run] Would create {{ .Singular }} %q\n", name)
return nil
}
+{{ if .IsConfigProfile }}
+ data = normalizeClassicProfilePayloadsForSend(data)
+ resp, err := ctx.Client.Do(reqCtx, "POST", "/JSSResource/{{ .Path }}/{{ idPath . }}/0", bytes.NewReader(data))
+ if err != nil {
+ return err
+ }
+ defer resp.Body.Close()
+ respBody, readErr := io.ReadAll(resp.Body)
+ if readErr != nil {
+ return readErr
+ }
+ verifyClassicProfileStored(reqCtx, ctx.Client, "{{ .Path }}", classicCreatedResourceID(respBody), data)
+ fmt.Fprintf(os.Stderr, "Created {{ .Singular }} %q\n", name)
+ return ctx.Output.PrintRaw(respBody)
+{{ else -}}
resp, err := ctx.Client.Do(reqCtx, "POST", "/JSSResource/{{ .Path }}/{{ idPath . }}/0", bytes.NewReader(data))
if err != nil {
return err
@@ -1246,6 +1289,7 @@ If not, a new resource is created.` + "`" + `,
defer resp.Body.Close()
fmt.Fprintf(os.Stderr, "Created {{ .Singular }} %q\n", name)
return ctx.Output.PrintResponse(resp)
+{{ end -}}
{{ end }}
}
@@ -1287,12 +1331,21 @@ If not, a new resource is created.` + "`" + `,
existingPayload := fetchClassicProfilePayloadPlist(reqCtx, ctx.Client, "{{ .Path }}", id)
data = injectClassicProfilePayloadUUIDs(data, existingPayload)
data = injectClassicRedeployOnUpdate(data)
-{{ end }}
+ data = normalizeClassicProfilePayloadsForSend(data)
+
+ updatePath := fmt.Sprintf("/JSSResource/{{ .Path }}/{{ idPath . }}/%s", url.PathEscape(id))
+ resp, err := ctx.Client.Do(reqCtx, "PUT", updatePath, bytes.NewReader(data))
+ if err != nil {
+ return err
+ }
+ verifyClassicProfileStored(reqCtx, ctx.Client, "{{ .Path }}", id, data)
+{{ else }}
updatePath := fmt.Sprintf("/JSSResource/{{ .Path }}/{{ idPath . }}/%s", url.PathEscape(id))
resp, err := ctx.Client.Do(reqCtx, "PUT", updatePath, bytes.NewReader(data))
if err != nil {
return err
}
+{{ end }}
defer resp.Body.Close()
fmt.Fprintf(os.Stderr, "Replaced {{ .Singular }} %q (id: %s)\n", name, id)
return ctx.Output.PrintResponse(resp)
@@ -1350,7 +1403,7 @@ import (
{{- if or (anyNeedsClassicNameResolve .) (anyClassicFileFields .) (anyListSubset .) }}
"github.com/Jamf-Concepts/jamf-cli/internal/xmlconv"
{{- end }}
-{{- if anyIsConfigProfile . }}
+{{- if or (anyIsConfigProfile .) (anyClassicFileFields .) }}
"github.com/Jamf-Concepts/jamf-cli/internal/profileconvert"
{{- end }}
{{- if anyHasCustomPayload . }}
@@ -1611,7 +1664,9 @@ func replaceClassicProfilePayload(xmlBody, newPayload []byte) []byte {
var buf bytes.Buffer
buf.Write(xmlBody[:si])
buf.WriteString(openTag + "" so the payload can never terminate the CDATA section early
+ // (XML 1.0 §2.4/§2.7).
+ buf.Write(bytes.ReplaceAll(newPayload, []byte("]]>"), []byte("]]>")))
buf.WriteString("]]>" + closeTag)
buf.Write(xmlBody[ei+len(closeTag):])
return buf.Bytes()
@@ -1669,6 +1724,153 @@ func injectClassicRedeployOnUpdate(body []byte) []byte {
insertAt := gOpen + len("")
return []byte(s[:insertAt] + "All" + s[insertAt:])
}
+
+// stripCDATASections rewrites every section in an XML
+// document as equivalent escaped character data. The parsed document is
+// unchanged (CDATA content is literal text by definition — XML 1.0 §2.7),
+// but the literal "]]>" terminators disappear, so the result can itself be
+// CDATA-wrapped. Content after an unterminated ", ">")
+ var b strings.Builder
+ for {
+ si := strings.Index(s, "")
+ if ei < 0 {
+ break
+ }
+ b.WriteString(s[:si])
+ b.WriteString(esc.Replace(s[si+len(""):]
+ }
+ if b.Len() == 0 {
+ return data
+ }
+ b.WriteString(s)
+ return []byte(b.String())
+}
+
+// normalizeClassicProfilePayloadsForSend rewrites the element
+// into the only wire form the Classic API accepts for entity-bearing
+// plists, immediately before the request is sent: the true plist inside a
+// single CDATA section, "]]>" entity-guarded, with every "&" escaped once.
+// Text-form payloads (e.g. a GET/backup response piped back in) are
+// entity-decoded once to recover the plist first. Bodies without a
+// non-empty element are returned unchanged.
+//
+// Why the escape (PI-827, wire-verified 2026-07-30 on two tenants): the
+// server validates payload content after one entity decode and 409s
+// ("Unable to update the database") when that yields a bare "&" or "<" —
+// so spec-correct raw CDATA is rejected for ANY plist containing "&"
+// or "<", whatever the payload types. Storage is then per-payload-type:
+// fragments of types the server re-renders (com.apple.ManagedClient
+// .preferences custom settings, notification settings) are entity-decoded
+// once — the escape stores them byte-exact — while other payload types'
+// fragments are stored verbatim, keeping the extra layer. Profiles with
+// "&"/"<" in values of those types cannot be stored faithfully by any
+// client (verifyClassicProfileStored warns after the write).
+func normalizeClassicProfilePayloadsForSend(body []byte) []byte {
+ const openTag = ""
+ s := string(body)
+ si := strings.Index(s, openTag)
+ if si < 0 {
+ return body
+ }
+ ci := si + len(openTag)
+ var inner string
+ var restAt int // index into s of the first byte after
+ if strings.HasPrefix(s[ci:], "")
+ if end < 0 {
+ return body
+ }
+ inner = s[ci+len("")
+ } else {
+ end := strings.Index(s[ci:], "")
+ if end < 0 {
+ return body
+ }
+ var decoded struct {
+ Data string ` + "`" + `xml:",chardata"` + "`" + `
+ }
+ if err := xml.Unmarshal([]byte(openTag+s[ci:ci+end]+""), &decoded); err != nil {
+ return body
+ }
+ inner = decoded.Data
+ restAt = ci + end + len("")
+ }
+ if inner == "" {
+ return body
+ }
+ inner = strings.ReplaceAll(inner, "]]>", "]]>")
+ inner = strings.ReplaceAll(inner, "&", "&")
+ return []byte(s[:ci] + "" + s[restAt:])
+}
+
+// classicProfilePayloadFromBody returns the true plist inside the request
+// body's CDATA block, or nil. The block carries the normalizer's
+// escaped form (every "&" escaped once); undoing that single escape — our
+// own, exactly invertible — recovers the submitted plist for comparison
+// against what the server stored.
+func classicProfilePayloadFromBody(body []byte) []byte {
+ s := string(body)
+ si := strings.Index(s, "")
+ if ei < 0 {
+ return nil
+ }
+ return []byte(strings.ReplaceAll(s[ci:ci+ei], "&", "&"))
+}
+
+// classicCreatedResourceID extracts the numeric from a Classic create
+// response body, or "" when absent.
+func classicCreatedResourceID(respBody []byte) string {
+ s := string(respBody)
+ si := strings.Index(s, "")
+ if si < 0 {
+ return ""
+ }
+ ei := strings.Index(s[si:], "")
+ if ei < 0 {
+ return ""
+ }
+ return s[si+len("") : si+ei]
+}
+
+// verifyClassicProfileStored fetches the stored payload after a successful
+// write and warns on stderr when the server did not store the submitted
+// values faithfully. This is reachable for profiles that mix payload types
+// with entities on the source-level side (PI-827) — no wire form can store
+// those correctly, so surfacing the exact paths is the only honest
+// behaviour. Best-effort: silent on any verification failure.
+func verifyClassicProfileStored(ctx context.Context, client registry.HTTPClient, apiPath, id string, sentBody []byte) {
+ intended := classicProfilePayloadFromBody(sentBody)
+ if len(intended) == 0 || id == "" {
+ return
+ }
+ stored := fetchClassicProfilePayloadPlist(ctx, client, apiPath, id)
+ if len(stored) == 0 {
+ return
+ }
+ diffs, err := profileconvert.DiffPayloadValues(intended, stored)
+ if err != nil || len(diffs) == 0 {
+ return
+ }
+ total := len(diffs)
+ if total > 5 {
+ diffs = append(diffs[:5], "…")
+ }
+ fmt.Fprintf(os.Stderr, "warning: the server stored %d payload value(s) differently than submitted (Jamf PI-827 — no wire format stores this mix of payload types faithfully): %s\n", total, strings.Join(diffs, ", "))
+}
{{ end }}
{{ if anyClassicFileFields . }}
// classicFileFieldSpec describes one Classic XML field sourced from a local file or in-memory bytes.
@@ -1722,6 +1924,30 @@ func injectClassicFileFields(body []byte, rootName string, specs []classicFileFi
}
var inner string
if s.Encoding == "xml-cdata" {
+ if profileconvert.IsSignedProfile(data) {
+ // CMS-signed mobileconfig — extract the inner plist. The
+ // signature cannot survive this API anyway: the server
+ // re-serialises the plist on ingest.
+ if unsigned, err := profileconvert.ExtractSignedProfile(data); err == nil {
+ fmt.Fprintln(os.Stderr, "note: input is a signed mobileconfig; uploading the inner profile (the Classic API cannot preserve the signature)")
+ data = unsigned
+ }
+ }
+ if bytes.HasPrefix(data, []byte("bplist0")) {
+ // Binary plist — convert to XML so it can be embedded at all.
+ if normalized, err := profileconvert.NormalizeXML(data); err == nil {
+ data = normalized
+ }
+ }
+ if bytes.Contains(data, []byte("]]>")) {
+ // "]]>" would terminate the CDATA wrapper early (XML 1.0
+ // §2.4/§2.7). Embedded CDATA sections are the one way a valid
+ // plist contains it — rewrite them as escaped character data
+ // (byte-faithful, no plist parsing), then entity-guard any
+ // stray remainder from malformed input.
+ data = stripCDATASections(data)
+ data = bytes.ReplaceAll(data, []byte("]]>"), []byte("]]>"))
+ }
inner = ""
} else {
inner = string(data)
diff --git a/go.mod b/go.mod
index 49a7facb..43739722 100644
--- a/go.mod
+++ b/go.mod
@@ -11,6 +11,7 @@ require (
github.com/hashicorp/go-retryablehttp v0.7.8
github.com/iancoleman/strcase v0.3.0
github.com/modelcontextprotocol/go-sdk v1.6.1
+ github.com/smallstep/pkcs7 v0.2.3
github.com/spf13/cobra v1.10.2
github.com/spf13/pflag v1.0.10
github.com/zalando/go-keyring v0.2.8
diff --git a/go.sum b/go.sum
index 04152b90..63de69f6 100644
--- a/go.sum
+++ b/go.sum
@@ -69,6 +69,8 @@ github.com/segmentio/asm v1.1.3 h1:WM03sfUOENvvKexOLp+pCqgb/WDjsi7EK8gIsICtzhc=
github.com/segmentio/asm v1.1.3/go.mod h1:Ld3L4ZXGNcSLRg4JBsZ3//1+f/TjYl0Mzen/DQy1EJg=
github.com/segmentio/encoding v0.5.4 h1:OW1VRern8Nw6ITAtwSZ7Idrl3MXCFwXHPgqESYfvNt0=
github.com/segmentio/encoding v0.5.4/go.mod h1:HS1ZKa3kSN32ZHVZ7ZLPLXWvOVIiZtyJnO1gPH1sKt0=
+github.com/smallstep/pkcs7 v0.2.3 h1:bhoQ3TeZmdoXTatcwxCbk+FMcdsyr0gYrrW2Xq2qr+s=
+github.com/smallstep/pkcs7 v0.2.3/go.mod h1:7STkdKhZaZe4xNEXTtY4j1NGeST1gYM4GA40kC5iqr8=
github.com/spf13/cobra v1.10.2 h1:DMTTonx5m65Ic0GOoRY2c16WCbHxOOw6xxezuLaBpcU=
github.com/spf13/cobra v1.10.2/go.mod h1:7C1pvHqHw5A4vrJfjNwvOdzYu0Gml16OCs2GRiTUUS4=
github.com/spf13/pflag v1.0.9/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg=
diff --git a/internal/commands/pro/generated/classic_account_groups.go b/internal/commands/pro/generated/classic_account_groups.go
index a156bda0..acf337bd 100644
--- a/internal/commands/pro/generated/classic_account_groups.go
+++ b/internal/commands/pro/generated/classic_account_groups.go
@@ -163,13 +163,13 @@ func newClassicAccountGroupsCreateCmd(ctx *registry.CLIContext) *cobra.Command {
}
resp, err := ctx.Client.Do(reqCtx, "POST", "/JSSResource/accounts/groupid/0", body)
-
if err != nil {
return err
}
defer resp.Body.Close()
return ctx.Output.PrintResponse(resp)
+
},
}
return cmd
diff --git a/internal/commands/pro/generated/classic_account_users.go b/internal/commands/pro/generated/classic_account_users.go
index 11572320..6a47013d 100644
--- a/internal/commands/pro/generated/classic_account_users.go
+++ b/internal/commands/pro/generated/classic_account_users.go
@@ -163,13 +163,13 @@ func newClassicAccountUsersCreateCmd(ctx *registry.CLIContext) *cobra.Command {
}
resp, err := ctx.Client.Do(reqCtx, "POST", "/JSSResource/accounts/userid/0", body)
-
if err != nil {
return err
}
defer resp.Body.Close()
return ctx.Output.PrintResponse(resp)
+
},
}
return cmd
diff --git a/internal/commands/pro/generated/classic_advanced_computer_searches.go b/internal/commands/pro/generated/classic_advanced_computer_searches.go
index 6833b32a..19bf9924 100644
--- a/internal/commands/pro/generated/classic_advanced_computer_searches.go
+++ b/internal/commands/pro/generated/classic_advanced_computer_searches.go
@@ -174,13 +174,13 @@ func newClassicAdvancedComputerSearchesCreateCmd(ctx *registry.CLIContext) *cobr
}
resp, err := ctx.Client.Do(reqCtx, "POST", "/JSSResource/advancedcomputersearches/id/0", body)
-
if err != nil {
return err
}
defer resp.Body.Close()
return ctx.Output.PrintResponse(resp)
+
},
}
return cmd
@@ -501,6 +501,7 @@ If not, a new resource is created.`,
if err != nil {
return err
}
+
defer resp.Body.Close()
fmt.Fprintf(os.Stderr, "Replaced advanced_computer_search %q (id: %s)\n", name, id)
return ctx.Output.PrintResponse(resp)
diff --git a/internal/commands/pro/generated/classic_advanced_mobile_device_searches.go b/internal/commands/pro/generated/classic_advanced_mobile_device_searches.go
index 84b2f422..fcfb8fe1 100644
--- a/internal/commands/pro/generated/classic_advanced_mobile_device_searches.go
+++ b/internal/commands/pro/generated/classic_advanced_mobile_device_searches.go
@@ -174,13 +174,13 @@ func newClassicAdvancedMobileDeviceSearchesCreateCmd(ctx *registry.CLIContext) *
}
resp, err := ctx.Client.Do(reqCtx, "POST", "/JSSResource/advancedmobiledevicesearches/id/0", body)
-
if err != nil {
return err
}
defer resp.Body.Close()
return ctx.Output.PrintResponse(resp)
+
},
}
return cmd
@@ -501,6 +501,7 @@ If not, a new resource is created.`,
if err != nil {
return err
}
+
defer resp.Body.Close()
fmt.Fprintf(os.Stderr, "Replaced advanced_mobile_device_search %q (id: %s)\n", name, id)
return ctx.Output.PrintResponse(resp)
diff --git a/internal/commands/pro/generated/classic_allowed_file_extensions.go b/internal/commands/pro/generated/classic_allowed_file_extensions.go
index fbefff87..56419e08 100644
--- a/internal/commands/pro/generated/classic_allowed_file_extensions.go
+++ b/internal/commands/pro/generated/classic_allowed_file_extensions.go
@@ -166,13 +166,13 @@ func newClassicAllowedFileExtensionsCreateCmd(ctx *registry.CLIContext) *cobra.C
}
resp, err := ctx.Client.Do(reqCtx, "POST", "/JSSResource/allowedfileextensions/id/0", body)
-
if err != nil {
return err
}
defer resp.Body.Close()
return ctx.Output.PrintResponse(resp)
+
},
}
return cmd
diff --git a/internal/commands/pro/generated/classic_classes.go b/internal/commands/pro/generated/classic_classes.go
index 34c86c47..183266ff 100644
--- a/internal/commands/pro/generated/classic_classes.go
+++ b/internal/commands/pro/generated/classic_classes.go
@@ -174,13 +174,13 @@ func newClassicClassesCreateCmd(ctx *registry.CLIContext) *cobra.Command {
}
resp, err := ctx.Client.Do(reqCtx, "POST", "/JSSResource/classes/id/0", body)
-
if err != nil {
return err
}
defer resp.Body.Close()
return ctx.Output.PrintResponse(resp)
+
},
}
return cmd
@@ -501,6 +501,7 @@ If not, a new resource is created.`,
if err != nil {
return err
}
+
defer resp.Body.Close()
fmt.Fprintf(os.Stderr, "Replaced class %q (id: %s)\n", name, id)
return ctx.Output.PrintResponse(resp)
diff --git a/internal/commands/pro/generated/classic_computer_configs.go b/internal/commands/pro/generated/classic_computer_configs.go
index 3fef3358..0af52119 100644
--- a/internal/commands/pro/generated/classic_computer_configs.go
+++ b/internal/commands/pro/generated/classic_computer_configs.go
@@ -174,13 +174,13 @@ func newClassicComputerConfigsCreateCmd(ctx *registry.CLIContext) *cobra.Command
}
resp, err := ctx.Client.Do(reqCtx, "POST", "/JSSResource/computerconfigurations/id/0", body)
-
if err != nil {
return err
}
defer resp.Body.Close()
return ctx.Output.PrintResponse(resp)
+
},
}
return cmd
@@ -501,6 +501,7 @@ If not, a new resource is created.`,
if err != nil {
return err
}
+
defer resp.Body.Close()
fmt.Fprintf(os.Stderr, "Replaced computer_configuration %q (id: %s)\n", name, id)
return ctx.Output.PrintResponse(resp)
diff --git a/internal/commands/pro/generated/classic_computer_ext_attrs.go b/internal/commands/pro/generated/classic_computer_ext_attrs.go
index 910d68d8..57fb66d2 100644
--- a/internal/commands/pro/generated/classic_computer_ext_attrs.go
+++ b/internal/commands/pro/generated/classic_computer_ext_attrs.go
@@ -174,13 +174,13 @@ func newClassicComputerExtAttrsCreateCmd(ctx *registry.CLIContext) *cobra.Comman
}
resp, err := ctx.Client.Do(reqCtx, "POST", "/JSSResource/computerextensionattributes/id/0", body)
-
if err != nil {
return err
}
defer resp.Body.Close()
return ctx.Output.PrintResponse(resp)
+
},
}
return cmd
@@ -501,6 +501,7 @@ If not, a new resource is created.`,
if err != nil {
return err
}
+
defer resp.Body.Close()
fmt.Fprintf(os.Stderr, "Replaced computer_extension_attribute %q (id: %s)\n", name, id)
return ctx.Output.PrintResponse(resp)
diff --git a/internal/commands/pro/generated/classic_computer_groups.go b/internal/commands/pro/generated/classic_computer_groups.go
index 0579c151..c7cad22d 100644
--- a/internal/commands/pro/generated/classic_computer_groups.go
+++ b/internal/commands/pro/generated/classic_computer_groups.go
@@ -174,13 +174,13 @@ func newClassicComputerGroupsCreateCmd(ctx *registry.CLIContext) *cobra.Command
}
resp, err := ctx.Client.Do(reqCtx, "POST", "/JSSResource/computergroups/id/0", body)
-
if err != nil {
return err
}
defer resp.Body.Close()
return ctx.Output.PrintResponse(resp)
+
},
}
return cmd
@@ -501,6 +501,7 @@ If not, a new resource is created.`,
if err != nil {
return err
}
+
defer resp.Body.Close()
fmt.Fprintf(os.Stderr, "Replaced computer_group %q (id: %s)\n", name, id)
return ctx.Output.PrintResponse(resp)
diff --git a/internal/commands/pro/generated/classic_computer_invitations.go b/internal/commands/pro/generated/classic_computer_invitations.go
index cecb6a47..532ac874 100644
--- a/internal/commands/pro/generated/classic_computer_invitations.go
+++ b/internal/commands/pro/generated/classic_computer_invitations.go
@@ -173,13 +173,13 @@ func newClassicComputerInvitationsCreateCmd(ctx *registry.CLIContext) *cobra.Com
}
resp, err := ctx.Client.Do(reqCtx, "POST", "/JSSResource/computerinvitations/id/0", body)
-
if err != nil {
return err
}
defer resp.Body.Close()
return ctx.Output.PrintResponse(resp)
+
},
}
return cmd
diff --git a/internal/commands/pro/generated/classic_directory_bindings.go b/internal/commands/pro/generated/classic_directory_bindings.go
index 1e3029eb..980d3229 100644
--- a/internal/commands/pro/generated/classic_directory_bindings.go
+++ b/internal/commands/pro/generated/classic_directory_bindings.go
@@ -174,13 +174,13 @@ func newClassicDirectoryBindingsCreateCmd(ctx *registry.CLIContext) *cobra.Comma
}
resp, err := ctx.Client.Do(reqCtx, "POST", "/JSSResource/directorybindings/id/0", body)
-
if err != nil {
return err
}
defer resp.Body.Close()
return ctx.Output.PrintResponse(resp)
+
},
}
return cmd
@@ -501,6 +501,7 @@ If not, a new resource is created.`,
if err != nil {
return err
}
+
defer resp.Body.Close()
fmt.Fprintf(os.Stderr, "Replaced directory_binding %q (id: %s)\n", name, id)
return ctx.Output.PrintResponse(resp)
diff --git a/internal/commands/pro/generated/classic_disk_encryption_configs.go b/internal/commands/pro/generated/classic_disk_encryption_configs.go
index 02673e62..32a15ec4 100644
--- a/internal/commands/pro/generated/classic_disk_encryption_configs.go
+++ b/internal/commands/pro/generated/classic_disk_encryption_configs.go
@@ -174,13 +174,13 @@ func newClassicDiskEncryptionConfigsCreateCmd(ctx *registry.CLIContext) *cobra.C
}
resp, err := ctx.Client.Do(reqCtx, "POST", "/JSSResource/diskencryptionconfigurations/id/0", body)
-
if err != nil {
return err
}
defer resp.Body.Close()
return ctx.Output.PrintResponse(resp)
+
},
}
return cmd
@@ -501,6 +501,7 @@ If not, a new resource is created.`,
if err != nil {
return err
}
+
defer resp.Body.Close()
fmt.Fprintf(os.Stderr, "Replaced disk_encryption_configuration %q (id: %s)\n", name, id)
return ctx.Output.PrintResponse(resp)
diff --git a/internal/commands/pro/generated/classic_distribution_points.go b/internal/commands/pro/generated/classic_distribution_points.go
index 8c2c801a..c362a39b 100644
--- a/internal/commands/pro/generated/classic_distribution_points.go
+++ b/internal/commands/pro/generated/classic_distribution_points.go
@@ -174,13 +174,13 @@ func newClassicDistributionPointsCreateCmd(ctx *registry.CLIContext) *cobra.Comm
}
resp, err := ctx.Client.Do(reqCtx, "POST", "/JSSResource/distributionpoints/id/0", body)
-
if err != nil {
return err
}
defer resp.Body.Close()
return ctx.Output.PrintResponse(resp)
+
},
}
return cmd
@@ -501,6 +501,7 @@ If not, a new resource is created.`,
if err != nil {
return err
}
+
defer resp.Body.Close()
fmt.Fprintf(os.Stderr, "Replaced distribution_point %q (id: %s)\n", name, id)
return ctx.Output.PrintResponse(resp)
diff --git a/internal/commands/pro/generated/classic_dock_items.go b/internal/commands/pro/generated/classic_dock_items.go
index 04f82a4f..0a099790 100644
--- a/internal/commands/pro/generated/classic_dock_items.go
+++ b/internal/commands/pro/generated/classic_dock_items.go
@@ -174,13 +174,13 @@ func newClassicDockItemsCreateCmd(ctx *registry.CLIContext) *cobra.Command {
}
resp, err := ctx.Client.Do(reqCtx, "POST", "/JSSResource/dockitems/id/0", body)
-
if err != nil {
return err
}
defer resp.Body.Close()
return ctx.Output.PrintResponse(resp)
+
},
}
return cmd
@@ -501,6 +501,7 @@ If not, a new resource is created.`,
if err != nil {
return err
}
+
defer resp.Body.Close()
fmt.Fprintf(os.Stderr, "Replaced dock_item %q (id: %s)\n", name, id)
return ctx.Output.PrintResponse(resp)
diff --git a/internal/commands/pro/generated/classic_ebooks.go b/internal/commands/pro/generated/classic_ebooks.go
index f71cee8e..7742aa4f 100644
--- a/internal/commands/pro/generated/classic_ebooks.go
+++ b/internal/commands/pro/generated/classic_ebooks.go
@@ -180,13 +180,13 @@ func newClassicEbooksCreateCmd(ctx *registry.CLIContext) *cobra.Command {
}
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
@@ -507,6 +507,7 @@ If not, a new resource is created.`,
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)
diff --git a/internal/commands/pro/generated/classic_ibeacons.go b/internal/commands/pro/generated/classic_ibeacons.go
index ba86ecca..05c5a663 100644
--- a/internal/commands/pro/generated/classic_ibeacons.go
+++ b/internal/commands/pro/generated/classic_ibeacons.go
@@ -174,13 +174,13 @@ func newClassicIbeaconsCreateCmd(ctx *registry.CLIContext) *cobra.Command {
}
resp, err := ctx.Client.Do(reqCtx, "POST", "/JSSResource/ibeacons/id/0", body)
-
if err != nil {
return err
}
defer resp.Body.Close()
return ctx.Output.PrintResponse(resp)
+
},
}
return cmd
@@ -501,6 +501,7 @@ If not, a new resource is created.`,
if err != nil {
return err
}
+
defer resp.Body.Close()
fmt.Fprintf(os.Stderr, "Replaced ibeacon %q (id: %s)\n", name, id)
return ctx.Output.PrintResponse(resp)
diff --git a/internal/commands/pro/generated/classic_jwt_configs.go b/internal/commands/pro/generated/classic_jwt_configs.go
index c56d10b9..5563ee99 100644
--- a/internal/commands/pro/generated/classic_jwt_configs.go
+++ b/internal/commands/pro/generated/classic_jwt_configs.go
@@ -154,13 +154,13 @@ func newClassicJwtConfigsCreateCmd(ctx *registry.CLIContext) *cobra.Command {
}
resp, err := ctx.Client.Do(reqCtx, "POST", "/JSSResource/jsonwebtokenconfigurations/id/0", body)
-
if err != nil {
return err
}
defer resp.Body.Close()
return ctx.Output.PrintResponse(resp)
+
},
}
return cmd
diff --git a/internal/commands/pro/generated/classic_ldap_servers.go b/internal/commands/pro/generated/classic_ldap_servers.go
index 53829c8f..50fb288b 100644
--- a/internal/commands/pro/generated/classic_ldap_servers.go
+++ b/internal/commands/pro/generated/classic_ldap_servers.go
@@ -174,13 +174,13 @@ func newClassicLdapServersCreateCmd(ctx *registry.CLIContext) *cobra.Command {
}
resp, err := ctx.Client.Do(reqCtx, "POST", "/JSSResource/ldapservers/id/0", body)
-
if err != nil {
return err
}
defer resp.Body.Close()
return ctx.Output.PrintResponse(resp)
+
},
}
return cmd
@@ -501,6 +501,7 @@ If not, a new resource is created.`,
if err != nil {
return err
}
+
defer resp.Body.Close()
fmt.Fprintf(os.Stderr, "Replaced ldap_server %q (id: %s)\n", name, id)
return ctx.Output.PrintResponse(resp)
diff --git a/internal/commands/pro/generated/classic_licensed_software.go b/internal/commands/pro/generated/classic_licensed_software.go
index 6ed42859..cfc7f66b 100644
--- a/internal/commands/pro/generated/classic_licensed_software.go
+++ b/internal/commands/pro/generated/classic_licensed_software.go
@@ -174,13 +174,13 @@ func newClassicLicensedSoftwareCreateCmd(ctx *registry.CLIContext) *cobra.Comman
}
resp, err := ctx.Client.Do(reqCtx, "POST", "/JSSResource/licensedsoftware/id/0", body)
-
if err != nil {
return err
}
defer resp.Body.Close()
return ctx.Output.PrintResponse(resp)
+
},
}
return cmd
@@ -501,6 +501,7 @@ If not, a new resource is created.`,
if err != nil {
return err
}
+
defer resp.Body.Close()
fmt.Fprintf(os.Stderr, "Replaced licensed_software %q (id: %s)\n", name, id)
return ctx.Output.PrintResponse(resp)
diff --git a/internal/commands/pro/generated/classic_mac_apps.go b/internal/commands/pro/generated/classic_mac_apps.go
index 733dae5a..fa536527 100644
--- a/internal/commands/pro/generated/classic_mac_apps.go
+++ b/internal/commands/pro/generated/classic_mac_apps.go
@@ -194,13 +194,13 @@ func newClassicMacAppsCreateCmd(ctx *registry.CLIContext) *cobra.Command {
return err
}
resp, err := ctx.Client.Do(reqCtx, "POST", "/JSSResource/macapplications/id/0", bytes.NewReader(bodyBytes))
-
if err != nil {
return err
}
defer resp.Body.Close()
return ctx.Output.PrintResponse(resp)
+
},
}
@@ -598,6 +598,7 @@ If not, a new resource is created.`,
if err != nil {
return err
}
+
defer resp.Body.Close()
fmt.Fprintf(os.Stderr, "Replaced mac_application %q (id: %s)\n", name, id)
return ctx.Output.PrintResponse(resp)
diff --git a/internal/commands/pro/generated/classic_macos_config_profiles.go b/internal/commands/pro/generated/classic_macos_config_profiles.go
index f2241919..195a12cd 100644
--- a/internal/commands/pro/generated/classic_macos_config_profiles.go
+++ b/internal/commands/pro/generated/classic_macos_config_profiles.go
@@ -216,14 +216,19 @@ func newClassicMacosConfigProfilesCreateCmd(ctx *registry.CLIContext) *cobra.Com
return err
}
+ bodyBytes = normalizeClassicProfilePayloadsForSend(bodyBytes)
resp, err := ctx.Client.Do(reqCtx, "POST", "/JSSResource/osxconfigurationprofiles/id/0", bytes.NewReader(bodyBytes))
-
if err != nil {
return err
}
defer resp.Body.Close()
+ respBody, readErr := io.ReadAll(resp.Body)
+ if readErr != nil {
+ return readErr
+ }
+ verifyClassicProfileStored(reqCtx, ctx.Client, "osxconfigurationprofiles", classicCreatedResourceID(respBody), bodyBytes)
+ return ctx.Output.PrintRaw(respBody)
- return ctx.Output.PrintResponse(resp)
},
}
@@ -313,9 +318,13 @@ func newClassicMacosConfigProfilesUpdateCmd(ctx *registry.CLIContext) *cobra.Com
bodyBytes = injectClassicProfilePayloadUUIDs(bodyBytes, existingPayload)
bodyBytes = injectClassicRedeployOnUpdate(bodyBytes)
+ bodyBytes = normalizeClassicProfilePayloadsForSend(bodyBytes)
path := fmt.Sprintf("/JSSResource/osxconfigurationprofiles/id/%s", url.PathEscape(resolvedID))
resp, err := ctx.Client.Do(reqCtx, "PUT", path, bytes.NewReader(bodyBytes))
+ if err == nil {
+ verifyClassicProfileStored(reqCtx, ctx.Client, "osxconfigurationprofiles", resolvedID, bodyBytes)
+ }
if err != nil {
return err
@@ -610,13 +619,20 @@ If not, a new resource is created.`,
fmt.Fprintf(os.Stderr, "[dry-run] Would create os_x_configuration_profile %q\n", name)
return nil
}
+
+ data = normalizeClassicProfilePayloadsForSend(data)
resp, err := ctx.Client.Do(reqCtx, "POST", "/JSSResource/osxconfigurationprofiles/id/0", bytes.NewReader(data))
if err != nil {
return err
}
defer resp.Body.Close()
+ respBody, readErr := io.ReadAll(resp.Body)
+ if readErr != nil {
+ return readErr
+ }
+ verifyClassicProfileStored(reqCtx, ctx.Client, "osxconfigurationprofiles", classicCreatedResourceID(respBody), data)
fmt.Fprintf(os.Stderr, "Created os_x_configuration_profile %q\n", name)
- return ctx.Output.PrintResponse(resp)
+ return ctx.Output.PrintRaw(respBody)
}
@@ -641,12 +657,15 @@ If not, a new resource is created.`,
existingPayload := fetchClassicProfilePayloadPlist(reqCtx, ctx.Client, "osxconfigurationprofiles", id)
data = injectClassicProfilePayloadUUIDs(data, existingPayload)
data = injectClassicRedeployOnUpdate(data)
+ data = normalizeClassicProfilePayloadsForSend(data)
updatePath := fmt.Sprintf("/JSSResource/osxconfigurationprofiles/id/%s", url.PathEscape(id))
resp, err := ctx.Client.Do(reqCtx, "PUT", updatePath, bytes.NewReader(data))
if err != nil {
return err
}
+ verifyClassicProfileStored(reqCtx, ctx.Client, "osxconfigurationprofiles", id, data)
+
defer resp.Body.Close()
fmt.Fprintf(os.Stderr, "Replaced os_x_configuration_profile %q (id: %s)\n", name, id)
return ctx.Output.PrintResponse(resp)
diff --git a/internal/commands/pro/generated/classic_mobile_apps.go b/internal/commands/pro/generated/classic_mobile_apps.go
index 128e70e1..f0c5c02c 100644
--- a/internal/commands/pro/generated/classic_mobile_apps.go
+++ b/internal/commands/pro/generated/classic_mobile_apps.go
@@ -194,13 +194,13 @@ func newClassicMobileAppsCreateCmd(ctx *registry.CLIContext) *cobra.Command {
return err
}
resp, err := ctx.Client.Do(reqCtx, "POST", "/JSSResource/mobiledeviceapplications/id/0", bytes.NewReader(bodyBytes))
-
if err != nil {
return err
}
defer resp.Body.Close()
return ctx.Output.PrintResponse(resp)
+
},
}
@@ -598,6 +598,7 @@ If not, a new resource is created.`,
if err != nil {
return err
}
+
defer resp.Body.Close()
fmt.Fprintf(os.Stderr, "Replaced mobile_device_application %q (id: %s)\n", name, id)
return ctx.Output.PrintResponse(resp)
diff --git a/internal/commands/pro/generated/classic_mobile_config_profiles.go b/internal/commands/pro/generated/classic_mobile_config_profiles.go
index 625c48c9..1cdf8c88 100644
--- a/internal/commands/pro/generated/classic_mobile_config_profiles.go
+++ b/internal/commands/pro/generated/classic_mobile_config_profiles.go
@@ -193,14 +193,20 @@ func newClassicMobileConfigProfilesCreateCmd(ctx *registry.CLIContext) *cobra.Co
if err != nil {
return err
}
- resp, err := ctx.Client.Do(reqCtx, "POST", "/JSSResource/mobiledeviceconfigurationprofiles/id/0", bytes.NewReader(bodyBytes))
+ bodyBytes = normalizeClassicProfilePayloadsForSend(bodyBytes)
+ resp, err := ctx.Client.Do(reqCtx, "POST", "/JSSResource/mobiledeviceconfigurationprofiles/id/0", bytes.NewReader(bodyBytes))
if err != nil {
return err
}
defer resp.Body.Close()
+ respBody, readErr := io.ReadAll(resp.Body)
+ if readErr != nil {
+ return readErr
+ }
+ verifyClassicProfileStored(reqCtx, ctx.Client, "mobiledeviceconfigurationprofiles", classicCreatedResourceID(respBody), bodyBytes)
+ return ctx.Output.PrintRaw(respBody)
- return ctx.Output.PrintResponse(resp)
},
}
@@ -269,9 +275,13 @@ func newClassicMobileConfigProfilesUpdateCmd(ctx *registry.CLIContext) *cobra.Co
bodyBytes = injectClassicProfilePayloadUUIDs(bodyBytes, existingPayload)
bodyBytes = injectClassicRedeployOnUpdate(bodyBytes)
+ bodyBytes = normalizeClassicProfilePayloadsForSend(bodyBytes)
path := fmt.Sprintf("/JSSResource/mobiledeviceconfigurationprofiles/id/%s", url.PathEscape(resolvedID))
resp, err := ctx.Client.Do(reqCtx, "PUT", path, bytes.NewReader(bodyBytes))
+ if err == nil {
+ verifyClassicProfileStored(reqCtx, ctx.Client, "mobiledeviceconfigurationprofiles", resolvedID, bodyBytes)
+ }
if err != nil {
return err
@@ -538,13 +548,20 @@ If not, a new resource is created.`,
fmt.Fprintf(os.Stderr, "[dry-run] Would create configuration_profile %q\n", name)
return nil
}
+
+ data = normalizeClassicProfilePayloadsForSend(data)
resp, err := ctx.Client.Do(reqCtx, "POST", "/JSSResource/mobiledeviceconfigurationprofiles/id/0", bytes.NewReader(data))
if err != nil {
return err
}
defer resp.Body.Close()
+ respBody, readErr := io.ReadAll(resp.Body)
+ if readErr != nil {
+ return readErr
+ }
+ verifyClassicProfileStored(reqCtx, ctx.Client, "mobiledeviceconfigurationprofiles", classicCreatedResourceID(respBody), data)
fmt.Fprintf(os.Stderr, "Created configuration_profile %q\n", name)
- return ctx.Output.PrintResponse(resp)
+ return ctx.Output.PrintRaw(respBody)
}
@@ -569,12 +586,15 @@ If not, a new resource is created.`,
existingPayload := fetchClassicProfilePayloadPlist(reqCtx, ctx.Client, "mobiledeviceconfigurationprofiles", id)
data = injectClassicProfilePayloadUUIDs(data, existingPayload)
data = injectClassicRedeployOnUpdate(data)
+ data = normalizeClassicProfilePayloadsForSend(data)
updatePath := fmt.Sprintf("/JSSResource/mobiledeviceconfigurationprofiles/id/%s", url.PathEscape(id))
resp, err := ctx.Client.Do(reqCtx, "PUT", updatePath, bytes.NewReader(data))
if err != nil {
return err
}
+ verifyClassicProfileStored(reqCtx, ctx.Client, "mobiledeviceconfigurationprofiles", id, data)
+
defer resp.Body.Close()
fmt.Fprintf(os.Stderr, "Replaced configuration_profile %q (id: %s)\n", name, id)
return ctx.Output.PrintResponse(resp)
diff --git a/internal/commands/pro/generated/classic_mobile_device_groups.go b/internal/commands/pro/generated/classic_mobile_device_groups.go
index d45a5895..97e4407d 100644
--- a/internal/commands/pro/generated/classic_mobile_device_groups.go
+++ b/internal/commands/pro/generated/classic_mobile_device_groups.go
@@ -174,13 +174,13 @@ func newClassicMobileDeviceGroupsCreateCmd(ctx *registry.CLIContext) *cobra.Comm
}
resp, err := ctx.Client.Do(reqCtx, "POST", "/JSSResource/mobiledevicegroups/id/0", body)
-
if err != nil {
return err
}
defer resp.Body.Close()
return ctx.Output.PrintResponse(resp)
+
},
}
return cmd
@@ -501,6 +501,7 @@ If not, a new resource is created.`,
if err != nil {
return err
}
+
defer resp.Body.Close()
fmt.Fprintf(os.Stderr, "Replaced mobile_device_group %q (id: %s)\n", name, id)
return ctx.Output.PrintResponse(resp)
diff --git a/internal/commands/pro/generated/classic_mobile_devices.go b/internal/commands/pro/generated/classic_mobile_devices.go
index d341e3bf..059bde5f 100644
--- a/internal/commands/pro/generated/classic_mobile_devices.go
+++ b/internal/commands/pro/generated/classic_mobile_devices.go
@@ -187,13 +187,13 @@ func newClassicMobileDevicesCreateCmd(ctx *registry.CLIContext) *cobra.Command {
}
resp, err := ctx.Client.Do(reqCtx, "POST", "/JSSResource/mobiledevices/id/0", body)
-
if err != nil {
return err
}
defer resp.Body.Close()
return ctx.Output.PrintResponse(resp)
+
},
}
return cmd
@@ -603,6 +603,7 @@ If not, a new resource is created.`,
if err != nil {
return err
}
+
defer resp.Body.Close()
fmt.Fprintf(os.Stderr, "Replaced mobile_device %q (id: %s)\n", name, id)
return ctx.Output.PrintResponse(resp)
diff --git a/internal/commands/pro/generated/classic_mobile_invitations.go b/internal/commands/pro/generated/classic_mobile_invitations.go
index 2c7ad27a..c0d4f09c 100644
--- a/internal/commands/pro/generated/classic_mobile_invitations.go
+++ b/internal/commands/pro/generated/classic_mobile_invitations.go
@@ -166,13 +166,13 @@ func newClassicMobileInvitationsCreateCmd(ctx *registry.CLIContext) *cobra.Comma
}
resp, err := ctx.Client.Do(reqCtx, "POST", "/JSSResource/mobiledeviceinvitations/id/0", body)
-
if err != nil {
return err
}
defer resp.Body.Close()
return ctx.Output.PrintResponse(resp)
+
},
}
return cmd
diff --git a/internal/commands/pro/generated/classic_mobile_provisioning_profiles.go b/internal/commands/pro/generated/classic_mobile_provisioning_profiles.go
index 0bb5ddeb..b6af256f 100644
--- a/internal/commands/pro/generated/classic_mobile_provisioning_profiles.go
+++ b/internal/commands/pro/generated/classic_mobile_provisioning_profiles.go
@@ -174,13 +174,13 @@ func newClassicMobileProvisioningProfilesCreateCmd(ctx *registry.CLIContext) *co
}
resp, err := ctx.Client.Do(reqCtx, "POST", "/JSSResource/mobiledeviceprovisioningprofiles/id/0", body)
-
if err != nil {
return err
}
defer resp.Body.Close()
return ctx.Output.PrintResponse(resp)
+
},
}
return cmd
@@ -501,6 +501,7 @@ If not, a new resource is created.`,
if err != nil {
return err
}
+
defer resp.Body.Close()
fmt.Fprintf(os.Stderr, "Replaced mobile_device_provisioning_profile %q (id: %s)\n", name, id)
return ctx.Output.PrintResponse(resp)
diff --git a/internal/commands/pro/generated/classic_network_segments.go b/internal/commands/pro/generated/classic_network_segments.go
index 62f5d2ec..84fb5f7a 100644
--- a/internal/commands/pro/generated/classic_network_segments.go
+++ b/internal/commands/pro/generated/classic_network_segments.go
@@ -174,13 +174,13 @@ func newClassicNetworkSegmentsCreateCmd(ctx *registry.CLIContext) *cobra.Command
}
resp, err := ctx.Client.Do(reqCtx, "POST", "/JSSResource/networksegments/id/0", body)
-
if err != nil {
return err
}
defer resp.Body.Close()
return ctx.Output.PrintResponse(resp)
+
},
}
return cmd
@@ -501,6 +501,7 @@ If not, a new resource is created.`,
if err != nil {
return err
}
+
defer resp.Body.Close()
fmt.Fprintf(os.Stderr, "Replaced network_segment %q (id: %s)\n", name, id)
return ctx.Output.PrintResponse(resp)
diff --git a/internal/commands/pro/generated/classic_packages.go b/internal/commands/pro/generated/classic_packages.go
index 50f56120..c0ff6f5e 100644
--- a/internal/commands/pro/generated/classic_packages.go
+++ b/internal/commands/pro/generated/classic_packages.go
@@ -174,13 +174,13 @@ func newClassicPackagesCreateCmd(ctx *registry.CLIContext) *cobra.Command {
}
resp, err := ctx.Client.Do(reqCtx, "POST", "/JSSResource/packages/id/0", body)
-
if err != nil {
return err
}
defer resp.Body.Close()
return ctx.Output.PrintResponse(resp)
+
},
}
return cmd
@@ -501,6 +501,7 @@ If not, a new resource is created.`,
if err != nil {
return err
}
+
defer resp.Body.Close()
fmt.Fprintf(os.Stderr, "Replaced package %q (id: %s)\n", name, id)
return ctx.Output.PrintResponse(resp)
diff --git a/internal/commands/pro/generated/classic_patch_external_sources.go b/internal/commands/pro/generated/classic_patch_external_sources.go
index d0a1e6af..0ddc5584 100644
--- a/internal/commands/pro/generated/classic_patch_external_sources.go
+++ b/internal/commands/pro/generated/classic_patch_external_sources.go
@@ -174,13 +174,13 @@ func newClassicPatchExternalSourcesCreateCmd(ctx *registry.CLIContext) *cobra.Co
}
resp, err := ctx.Client.Do(reqCtx, "POST", "/JSSResource/patchexternalsources/id/0", body)
-
if err != nil {
return err
}
defer resp.Body.Close()
return ctx.Output.PrintResponse(resp)
+
},
}
return cmd
@@ -501,6 +501,7 @@ If not, a new resource is created.`,
if err != nil {
return err
}
+
defer resp.Body.Close()
fmt.Fprintf(os.Stderr, "Replaced patch_external_source %q (id: %s)\n", name, id)
return ctx.Output.PrintResponse(resp)
diff --git a/internal/commands/pro/generated/classic_patch_policies.go b/internal/commands/pro/generated/classic_patch_policies.go
index 057a1894..89abe9f1 100644
--- a/internal/commands/pro/generated/classic_patch_policies.go
+++ b/internal/commands/pro/generated/classic_patch_policies.go
@@ -154,13 +154,13 @@ func newClassicPatchPoliciesCreateCmd(ctx *registry.CLIContext) *cobra.Command {
}
resp, err := ctx.Client.Do(reqCtx, "POST", "/JSSResource/patchpolicies/id/0", body)
-
if err != nil {
return err
}
defer resp.Body.Close()
return ctx.Output.PrintResponse(resp)
+
},
}
return cmd
diff --git a/internal/commands/pro/generated/classic_patch_titles.go b/internal/commands/pro/generated/classic_patch_titles.go
index a95de20f..dc07d855 100644
--- a/internal/commands/pro/generated/classic_patch_titles.go
+++ b/internal/commands/pro/generated/classic_patch_titles.go
@@ -174,13 +174,13 @@ func newClassicPatchTitlesCreateCmd(ctx *registry.CLIContext) *cobra.Command {
}
resp, err := ctx.Client.Do(reqCtx, "POST", "/JSSResource/patchsoftwaretitles/id/0", body)
-
if err != nil {
return err
}
defer resp.Body.Close()
return ctx.Output.PrintResponse(resp)
+
},
}
return cmd
@@ -501,6 +501,7 @@ If not, a new resource is created.`,
if err != nil {
return err
}
+
defer resp.Body.Close()
fmt.Fprintf(os.Stderr, "Replaced patch_software_title %q (id: %s)\n", name, id)
return ctx.Output.PrintResponse(resp)
diff --git a/internal/commands/pro/generated/classic_payload_escape_test.go b/internal/commands/pro/generated/classic_payload_escape_test.go
new file mode 100644
index 00000000..0f3bbba9
--- /dev/null
+++ b/internal/commands/pro/generated/classic_payload_escape_test.go
@@ -0,0 +1,170 @@
+// Copyright 2026, Jamf Software LLC
+
+package generated
+
+import (
+ "strings"
+ "testing"
+)
+
+// ─── normalizeClassicProfilePayloadsForSend ───────────────────────────────────
+//
+// The Classic API validates payload content after one entity decode and
+// 409s when that yields a bare "&" or "<" — so raw CDATA is rejected for
+// any plist containing "&"/"<" and the escaped form is the only
+// submittable one (PI-827). Storage is then per-payload-type: MCX custom
+// settings and notification settings fragments are entity-decoded once
+// (escape stores them byte-exact); other payload types keep the extra
+// layer, which verifyClassicProfileStored surfaces as a warning.
+
+func cdataContent(t *testing.T, body string) string {
+ t.Helper()
+ const openMark = ""
+ si := strings.Index(body, openMark)
+ ei := strings.LastIndex(body, closeMark)
+ if si < 0 || ei < 0 {
+ t.Fatalf("body has no CDATA payloads block: %s", body)
+ }
+ inner := body[si+len(openMark) : ei]
+ if strings.Contains(inner, "]]>") {
+ t.Fatalf("CDATA content contains a raw terminator — malformed XML: %s", body)
+ }
+ return inner
+}
+
+func TestNormalizePayloads_CDATA_EscapedOnce(t *testing.T) {
+ // The wire form carries every "&" escaped once; the server's single
+ // decode of MCX-family fragments restores the plist byte-exact.
+ plists := []string{
+ `R&D`,
+ `a < b > c`,
+ `raw > and ' and "`,
+ `literal & entity text`,
+ }
+ for _, p := range plists {
+ body := ``
+ escaped := cdataContent(t, string(normalizeClassicProfilePayloadsForSend([]byte(body))))
+ if decoded := strings.ReplaceAll(escaped, "&", "&"); decoded != p {
+ t.Errorf("server-side decode would store %q, want %q", decoded, p)
+ }
+ }
+}
+
+func TestNormalizePayloads_TextForm_ConvertedToCDATA(t *testing.T) {
+ // A GET/backup response carries payloads as entity-escaped text. The
+ // normalizer decodes it once to the true plist and re-wraps as CDATA.
+ truePlist := `kR&D > 'x'`
+ escapedOnce := strings.NewReplacer("&", "&", "<", "<", ">", ">").Replace(truePlist)
+ body := `` + escapedOnce + ``
+ escaped := cdataContent(t, string(normalizeClassicProfilePayloadsForSend([]byte(body))))
+ if decoded := strings.ReplaceAll(escaped, "&", "&"); decoded != truePlist {
+ t.Errorf("text-form round-trip: server would store %q, want %q", decoded, truePlist)
+ }
+}
+
+func TestNormalizePayloads_TextForm_NumericEntities(t *testing.T) {
+ body := `<string>A & B & C</string>`
+ escaped := cdataContent(t, string(normalizeClassicProfilePayloadsForSend([]byte(body))))
+ want := `A & B & C`
+ if decoded := strings.ReplaceAll(escaped, "&", "&"); decoded != want {
+ t.Errorf("server would store %q, want %q", decoded, want)
+ }
+}
+
+func TestNormalizePayloads_CDATATerminatorGuarded(t *testing.T) {
+ body := `a]]>b]]>`
+ escaped := cdataContent(t, string(normalizeClassicProfilePayloadsForSend([]byte(body))))
+ if decoded := strings.ReplaceAll(escaped, "&", "&"); decoded != `a]]>b` {
+ t.Errorf("server would store %q", decoded)
+ }
+}
+
+func TestNormalizePayloads_OutsideContentUntouched(t *testing.T) {
+ body := `R&D Profilea & b`
+ got := string(normalizeClassicProfilePayloadsForSend([]byte(body)))
+ if !strings.Contains(got, `R&D Profile`) || !strings.Contains(got, `a & b`) {
+ t.Errorf("content outside payloads was modified: %s", got)
+ }
+ if !strings.Contains(got, ``) {
+ t.Errorf("payloads content not escaped: %s", got)
+ }
+}
+
+func TestNormalizePayloads_NoPayloads(t *testing.T) {
+ body := `a & b`
+ if got := string(normalizeClassicProfilePayloadsForSend([]byte(body))); got != body {
+ t.Errorf("body without payloads changed: got %s", got)
+ }
+}
+
+func TestNormalizePayloads_EmptyPayloads(t *testing.T) {
+ body := ``
+ if got := string(normalizeClassicProfilePayloadsForSend([]byte(body))); got != body {
+ t.Errorf("empty payloads changed: got %s", got)
+ }
+}
+
+func TestNormalizePayloads_UnterminatedCDATA(t *testing.T) {
+ body := `R&D]]>`
+ if got := string(classicProfilePayloadFromBody([]byte(body))); got != "R&D" {
+ t.Errorf("got %q", got)
+ }
+ if got := classicProfilePayloadFromBody([]byte(`text`)); got != nil {
+ t.Errorf("text-form should return nil, got %q", got)
+ }
+}
+
+func TestClassicCreatedResourceID(t *testing.T) {
+ if got := classicCreatedResourceID([]byte(`7105`)); got != "7105" {
+ t.Errorf("got %q", got)
+ }
+ if got := classicCreatedResourceID([]byte(`error`)); got != "" {
+ t.Errorf("want empty, got %q", got)
+ }
+}
+
+// ─── stripCDATASections ───────────────────────────────────────────────────────
+
+func TestStripCDATASections_PreservesContentExactly(t *testing.T) {
+ // Trailing/leading whitespace inside CDATA is significant — this is why
+ // the rewrite is textual instead of a plist parse/re-serialise round trip.
+ in := ` " ]]>`
+ want := `cdata & raw < > " `
+ if got := string(stripCDATASections([]byte(in))); got != want {
+ t.Errorf("got %s, want %s", got, want)
+ }
+}
+
+func TestStripCDATASections_MultipleSections(t *testing.T) {
+ in := `plain & kept`
+ want := `x & yplain & keptz < w`
+ if got := string(stripCDATASections([]byte(in))); got != want {
+ t.Errorf("got %s, want %s", got, want)
+ }
+}
+
+func TestStripCDATASections_NoCDATA(t *testing.T) {
+ in := `plain & text`
+ if got := string(stripCDATASections([]byte(in))); got != in {
+ t.Errorf("content without CDATA changed: got %s", got)
+ }
+}
+
+func TestStripCDATASections_Unterminated(t *testing.T) {
+ in := `" so the payload can never terminate the CDATA section early
+ // (XML 1.0 §2.4/§2.7).
+ buf.Write(bytes.ReplaceAll(newPayload, []byte("]]>"), []byte("]]>")))
buf.WriteString("]]>" + closeTag)
buf.Write(xmlBody[ei+len(closeTag):])
return buf.Bytes()
@@ -385,6 +387,153 @@ func injectClassicRedeployOnUpdate(body []byte) []byte {
return []byte(s[:insertAt] + "All" + s[insertAt:])
}
+// stripCDATASections rewrites every section in an XML
+// document as equivalent escaped character data. The parsed document is
+// unchanged (CDATA content is literal text by definition — XML 1.0 §2.7),
+// but the literal "]]>" terminators disappear, so the result can itself be
+// CDATA-wrapped. Content after an unterminated ", ">")
+ var b strings.Builder
+ for {
+ si := strings.Index(s, "")
+ if ei < 0 {
+ break
+ }
+ b.WriteString(s[:si])
+ b.WriteString(esc.Replace(s[si+len(""):]
+ }
+ if b.Len() == 0 {
+ return data
+ }
+ b.WriteString(s)
+ return []byte(b.String())
+}
+
+// normalizeClassicProfilePayloadsForSend rewrites the element
+// into the only wire form the Classic API accepts for entity-bearing
+// plists, immediately before the request is sent: the true plist inside a
+// single CDATA section, "]]>" entity-guarded, with every "&" escaped once.
+// Text-form payloads (e.g. a GET/backup response piped back in) are
+// entity-decoded once to recover the plist first. Bodies without a
+// non-empty element are returned unchanged.
+//
+// Why the escape (PI-827, wire-verified 2026-07-30 on two tenants): the
+// server validates payload content after one entity decode and 409s
+// ("Unable to update the database") when that yields a bare "&" or "<" —
+// so spec-correct raw CDATA is rejected for ANY plist containing "&"
+// or "<", whatever the payload types. Storage is then per-payload-type:
+// fragments of types the server re-renders (com.apple.ManagedClient
+// .preferences custom settings, notification settings) are entity-decoded
+// once — the escape stores them byte-exact — while other payload types'
+// fragments are stored verbatim, keeping the extra layer. Profiles with
+// "&"/"<" in values of those types cannot be stored faithfully by any
+// client (verifyClassicProfileStored warns after the write).
+func normalizeClassicProfilePayloadsForSend(body []byte) []byte {
+ const openTag = ""
+ s := string(body)
+ si := strings.Index(s, openTag)
+ if si < 0 {
+ return body
+ }
+ ci := si + len(openTag)
+ var inner string
+ var restAt int // index into s of the first byte after
+ if strings.HasPrefix(s[ci:], "")
+ if end < 0 {
+ return body
+ }
+ inner = s[ci+len("")
+ } else {
+ end := strings.Index(s[ci:], "")
+ if end < 0 {
+ return body
+ }
+ var decoded struct {
+ Data string `xml:",chardata"`
+ }
+ if err := xml.Unmarshal([]byte(openTag+s[ci:ci+end]+""), &decoded); err != nil {
+ return body
+ }
+ inner = decoded.Data
+ restAt = ci + end + len("")
+ }
+ if inner == "" {
+ return body
+ }
+ inner = strings.ReplaceAll(inner, "]]>", "]]>")
+ inner = strings.ReplaceAll(inner, "&", "&")
+ return []byte(s[:ci] + "" + s[restAt:])
+}
+
+// classicProfilePayloadFromBody returns the true plist inside the request
+// body's CDATA block, or nil. The block carries the normalizer's
+// escaped form (every "&" escaped once); undoing that single escape — our
+// own, exactly invertible — recovers the submitted plist for comparison
+// against what the server stored.
+func classicProfilePayloadFromBody(body []byte) []byte {
+ s := string(body)
+ si := strings.Index(s, "")
+ if ei < 0 {
+ return nil
+ }
+ return []byte(strings.ReplaceAll(s[ci:ci+ei], "&", "&"))
+}
+
+// classicCreatedResourceID extracts the numeric from a Classic create
+// response body, or "" when absent.
+func classicCreatedResourceID(respBody []byte) string {
+ s := string(respBody)
+ si := strings.Index(s, "")
+ if si < 0 {
+ return ""
+ }
+ ei := strings.Index(s[si:], "")
+ if ei < 0 {
+ return ""
+ }
+ return s[si+len("") : si+ei]
+}
+
+// verifyClassicProfileStored fetches the stored payload after a successful
+// write and warns on stderr when the server did not store the submitted
+// values faithfully. This is reachable for profiles that mix payload types
+// with entities on the source-level side (PI-827) — no wire form can store
+// those correctly, so surfacing the exact paths is the only honest
+// behaviour. Best-effort: silent on any verification failure.
+func verifyClassicProfileStored(ctx context.Context, client registry.HTTPClient, apiPath, id string, sentBody []byte) {
+ intended := classicProfilePayloadFromBody(sentBody)
+ if len(intended) == 0 || id == "" {
+ return
+ }
+ stored := fetchClassicProfilePayloadPlist(ctx, client, apiPath, id)
+ if len(stored) == 0 {
+ return
+ }
+ diffs, err := profileconvert.DiffPayloadValues(intended, stored)
+ if err != nil || len(diffs) == 0 {
+ return
+ }
+ total := len(diffs)
+ if total > 5 {
+ diffs = append(diffs[:5], "…")
+ }
+ fmt.Fprintf(os.Stderr, "warning: the server stored %d payload value(s) differently than submitted (Jamf PI-827 — no wire format stores this mix of payload types faithfully): %s\n", total, strings.Join(diffs, ", "))
+}
+
// classicFileFieldSpec describes one Classic XML field sourced from a local file or in-memory bytes.
type classicFileFieldSpec struct {
FilePath string // user-supplied path (empty = skip when FileBytes also nil)
@@ -436,6 +585,30 @@ func injectClassicFileFields(body []byte, rootName string, specs []classicFileFi
}
var inner string
if s.Encoding == "xml-cdata" {
+ if profileconvert.IsSignedProfile(data) {
+ // CMS-signed mobileconfig — extract the inner plist. The
+ // signature cannot survive this API anyway: the server
+ // re-serialises the plist on ingest.
+ if unsigned, err := profileconvert.ExtractSignedProfile(data); err == nil {
+ fmt.Fprintln(os.Stderr, "note: input is a signed mobileconfig; uploading the inner profile (the Classic API cannot preserve the signature)")
+ data = unsigned
+ }
+ }
+ if bytes.HasPrefix(data, []byte("bplist0")) {
+ // Binary plist — convert to XML so it can be embedded at all.
+ if normalized, err := profileconvert.NormalizeXML(data); err == nil {
+ data = normalized
+ }
+ }
+ if bytes.Contains(data, []byte("]]>")) {
+ // "]]>" would terminate the CDATA wrapper early (XML 1.0
+ // §2.4/§2.7). Embedded CDATA sections are the one way a valid
+ // plist contains it — rewrite them as escaped character data
+ // (byte-faithful, no plist parsing), then entity-guard any
+ // stray remainder from malformed input.
+ data = stripCDATASections(data)
+ data = bytes.ReplaceAll(data, []byte("]]>"), []byte("]]>"))
+ }
inner = ""
} else {
inner = string(data)
diff --git a/internal/commands/pro/generated/classic_removable_mac_addresses.go b/internal/commands/pro/generated/classic_removable_mac_addresses.go
index 19fd132b..cd931944 100644
--- a/internal/commands/pro/generated/classic_removable_mac_addresses.go
+++ b/internal/commands/pro/generated/classic_removable_mac_addresses.go
@@ -174,13 +174,13 @@ func newClassicRemovableMacAddressesCreateCmd(ctx *registry.CLIContext) *cobra.C
}
resp, err := ctx.Client.Do(reqCtx, "POST", "/JSSResource/removablemacaddresses/id/0", body)
-
if err != nil {
return err
}
defer resp.Body.Close()
return ctx.Output.PrintResponse(resp)
+
},
}
return cmd
@@ -501,6 +501,7 @@ If not, a new resource is created.`,
if err != nil {
return err
}
+
defer resp.Body.Close()
fmt.Fprintf(os.Stderr, "Replaced removable_mac_address %q (id: %s)\n", name, id)
return ctx.Output.PrintResponse(resp)
diff --git a/internal/commands/pro/generated/classic_restricted_software.go b/internal/commands/pro/generated/classic_restricted_software.go
index 5ff0b3ca..19fca065 100644
--- a/internal/commands/pro/generated/classic_restricted_software.go
+++ b/internal/commands/pro/generated/classic_restricted_software.go
@@ -180,13 +180,13 @@ func newClassicRestrictedSoftwareCreateCmd(ctx *registry.CLIContext) *cobra.Comm
}
resp, err := ctx.Client.Do(reqCtx, "POST", "/JSSResource/restrictedsoftware/id/0", body)
-
if err != nil {
return err
}
defer resp.Body.Close()
return ctx.Output.PrintResponse(resp)
+
},
}
return cmd
@@ -507,6 +507,7 @@ If not, a new resource is created.`,
if err != nil {
return err
}
+
defer resp.Body.Close()
fmt.Fprintf(os.Stderr, "Replaced restricted_software %q (id: %s)\n", name, id)
return ctx.Output.PrintResponse(resp)
diff --git a/internal/commands/pro/generated/classic_software_update_servers.go b/internal/commands/pro/generated/classic_software_update_servers.go
index f7d0a413..8e10752c 100644
--- a/internal/commands/pro/generated/classic_software_update_servers.go
+++ b/internal/commands/pro/generated/classic_software_update_servers.go
@@ -174,13 +174,13 @@ func newClassicSoftwareUpdateServersCreateCmd(ctx *registry.CLIContext) *cobra.C
}
resp, err := ctx.Client.Do(reqCtx, "POST", "/JSSResource/softwareupdateservers/id/0", body)
-
if err != nil {
return err
}
defer resp.Body.Close()
return ctx.Output.PrintResponse(resp)
+
},
}
return cmd
@@ -501,6 +501,7 @@ If not, a new resource is created.`,
if err != nil {
return err
}
+
defer resp.Body.Close()
fmt.Fprintf(os.Stderr, "Replaced software_update_server %q (id: %s)\n", name, id)
return ctx.Output.PrintResponse(resp)
diff --git a/internal/commands/pro/generated/classic_user_ext_attrs.go b/internal/commands/pro/generated/classic_user_ext_attrs.go
index fc36bea4..6ded1c4f 100644
--- a/internal/commands/pro/generated/classic_user_ext_attrs.go
+++ b/internal/commands/pro/generated/classic_user_ext_attrs.go
@@ -174,13 +174,13 @@ func newClassicUserExtAttrsCreateCmd(ctx *registry.CLIContext) *cobra.Command {
}
resp, err := ctx.Client.Do(reqCtx, "POST", "/JSSResource/userextensionattributes/id/0", body)
-
if err != nil {
return err
}
defer resp.Body.Close()
return ctx.Output.PrintResponse(resp)
+
},
}
return cmd
@@ -501,6 +501,7 @@ If not, a new resource is created.`,
if err != nil {
return err
}
+
defer resp.Body.Close()
fmt.Fprintf(os.Stderr, "Replaced user_extension_attribute %q (id: %s)\n", name, id)
return ctx.Output.PrintResponse(resp)
diff --git a/internal/commands/pro/generated/classic_user_groups.go b/internal/commands/pro/generated/classic_user_groups.go
index a97fb82c..01609daa 100644
--- a/internal/commands/pro/generated/classic_user_groups.go
+++ b/internal/commands/pro/generated/classic_user_groups.go
@@ -174,13 +174,13 @@ func newClassicUserGroupsCreateCmd(ctx *registry.CLIContext) *cobra.Command {
}
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
@@ -501,6 +501,7 @@ If not, a new resource is created.`,
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)
diff --git a/internal/commands/pro/generated/classic_vpp_accounts.go b/internal/commands/pro/generated/classic_vpp_accounts.go
index 044a29b1..8a56d399 100644
--- a/internal/commands/pro/generated/classic_vpp_accounts.go
+++ b/internal/commands/pro/generated/classic_vpp_accounts.go
@@ -154,13 +154,13 @@ func newClassicVppAccountsCreateCmd(ctx *registry.CLIContext) *cobra.Command {
}
resp, err := ctx.Client.Do(reqCtx, "POST", "/JSSResource/vppaccounts/id/0", body)
-
if err != nil {
return err
}
defer resp.Body.Close()
return ctx.Output.PrintResponse(resp)
+
},
}
return cmd
diff --git a/internal/commands/pro/generated/classic_vpp_assignments.go b/internal/commands/pro/generated/classic_vpp_assignments.go
index 296aa7e1..a43c7895 100644
--- a/internal/commands/pro/generated/classic_vpp_assignments.go
+++ b/internal/commands/pro/generated/classic_vpp_assignments.go
@@ -161,13 +161,13 @@ func newClassicVppAssignmentsCreateCmd(ctx *registry.CLIContext) *cobra.Command
}
resp, err := ctx.Client.Do(reqCtx, "POST", "/JSSResource/vppassignments/id/0", body)
-
if err != nil {
return err
}
defer resp.Body.Close()
return ctx.Output.PrintResponse(resp)
+
},
}
return cmd
diff --git a/internal/commands/pro/generated/classic_vpp_invitations.go b/internal/commands/pro/generated/classic_vpp_invitations.go
index 14a5e921..98048d5b 100644
--- a/internal/commands/pro/generated/classic_vpp_invitations.go
+++ b/internal/commands/pro/generated/classic_vpp_invitations.go
@@ -159,13 +159,13 @@ func newClassicVppInvitationsCreateCmd(ctx *registry.CLIContext) *cobra.Command
}
resp, err := ctx.Client.Do(reqCtx, "POST", "/JSSResource/vppinvitations/id/0", body)
-
if err != nil {
return err
}
defer resp.Body.Close()
return ctx.Output.PrintResponse(resp)
+
},
}
return cmd
diff --git a/internal/commands/pro/generated/classic_webhooks.go b/internal/commands/pro/generated/classic_webhooks.go
index 798b289d..e2f1de35 100644
--- a/internal/commands/pro/generated/classic_webhooks.go
+++ b/internal/commands/pro/generated/classic_webhooks.go
@@ -174,13 +174,13 @@ func newClassicWebhooksCreateCmd(ctx *registry.CLIContext) *cobra.Command {
}
resp, err := ctx.Client.Do(reqCtx, "POST", "/JSSResource/webhooks/id/0", body)
-
if err != nil {
return err
}
defer resp.Body.Close()
return ctx.Output.PrintResponse(resp)
+
},
}
return cmd
@@ -501,6 +501,7 @@ If not, a new resource is created.`,
if err != nil {
return err
}
+
defer resp.Body.Close()
fmt.Fprintf(os.Stderr, "Replaced webhook %q (id: %s)\n", name, id)
return ctx.Output.PrintResponse(resp)
diff --git a/internal/profileconvert/classic_uuid.go b/internal/profileconvert/classic_uuid.go
index 493aed89..75d834a8 100644
--- a/internal/profileconvert/classic_uuid.go
+++ b/internal/profileconvert/classic_uuid.go
@@ -59,6 +59,23 @@ func InjectIdentifiers(newPlist, existingPlist []byte) ([]byte, error) {
return out, nil
}
+// NormalizeXML parses a plist (XML or binary) and re-serialises it as XML
+// with standard entity escaping. Used to eliminate constructs that cannot
+// survive CDATA-wrapping for the Classic API — most notably embedded CDATA
+// sections, whose literal "]]>" terminator would end the wrapper early.
+// Values round-trip exactly; only the byte representation changes.
+func NormalizeXML(data []byte) ([]byte, error) {
+ var v any
+ if _, err := plist.Unmarshal(data, &v); err != nil {
+ return nil, fmt.Errorf("parsing plist: %w", err)
+ }
+ out, err := plist.MarshalIndent(v, plist.XMLFormat, "\t")
+ if err != nil {
+ return nil, fmt.Errorf("re-serialising plist: %w", err)
+ }
+ return out, nil
+}
+
// ExtractProfileIdentifiers extracts the top-level PayloadUUID and
// PayloadIdentifier from a mobileconfig plist. Returns empty strings when
// the fields are absent (not an error condition).
diff --git a/internal/profileconvert/classic_verify.go b/internal/profileconvert/classic_verify.go
new file mode 100644
index 00000000..004f09a6
--- /dev/null
+++ b/internal/profileconvert/classic_verify.go
@@ -0,0 +1,145 @@
+// Copyright 2026, Jamf Software LLC
+
+package profileconvert
+
+import (
+ "bytes"
+ "fmt"
+ "strings"
+
+ "github.com/smallstep/pkcs7"
+ "howett.net/plist"
+)
+
+// IsSignedProfile reports whether data looks like a CMS/DER-signed
+// mobileconfig (ASN.1 SEQUENCE header) rather than plist XML or a binary
+// plist. A false positive is harmless — ExtractSignedProfile fails cleanly
+// and callers fall back to the original bytes.
+func IsSignedProfile(data []byte) bool {
+ if len(data) < 2 || data[0] != 0x30 {
+ return false
+ }
+ switch data[1] {
+ case 0x80, 0x81, 0x82, 0x83, 0x84:
+ return true
+ }
+ return false
+}
+
+// ExtractSignedProfile returns the inner plist of a CMS-signed (DER/BER)
+// mobileconfig. The signature cannot survive a Classic API upload anyway —
+// the server re-serialises the plist on ingest — so callers extract and
+// send the content, surfacing a note to the user.
+func ExtractSignedProfile(data []byte) ([]byte, error) {
+ p7, err := pkcs7.Parse(data)
+ if err != nil {
+ return nil, fmt.Errorf("parsing CMS envelope: %w", err)
+ }
+ if len(p7.Content) == 0 {
+ return nil, fmt.Errorf("CMS envelope has no content")
+ }
+ return p7.Content, nil
+}
+
+// maskedPayloadMetaKeys are profile-metadata keys the Classic API rewrites
+// or injects on ingest (identifiers, org branding, enablement flags, display
+// names of payloads it re-renders). Differences there are expected server
+// behaviour, not content corruption, and are excluded from verification.
+var maskedPayloadMetaKeys = map[string]bool{
+ "PayloadUUID": true,
+ "PayloadIdentifier": true,
+ "PayloadOrganization": true,
+ "PayloadDisplayName": true,
+ "PayloadDescription": true,
+ "PayloadEnabled": true,
+ "PayloadRemovalDisallowed": true,
+ "PayloadScope": true,
+ "PayloadVersion": true,
+}
+
+// DiffPayloadValues compares an intended mobileconfig plist against the
+// server-stored form and returns the dotted paths of setting values the
+// server did not store faithfully. Metadata keys the server rewrites by
+// design are masked (maskedPayloadMetaKeys); string comparison ignores
+// leading/trailing whitespace because the server trims value edges on
+// ingest. Keys present only in the stored form (server-injected defaults)
+// are ignored — only the caller's intent is checked.
+func DiffPayloadValues(intended, stored []byte) ([]string, error) {
+ var want, got map[string]any
+ if _, err := plist.Unmarshal(intended, &want); err != nil {
+ return nil, fmt.Errorf("parsing intended plist: %w", err)
+ }
+ if _, err := plist.Unmarshal(stored, &got); err != nil {
+ return nil, fmt.Errorf("parsing stored plist: %w", err)
+ }
+ var diffs []string
+ diffPayloadValues(want, got, "", &diffs)
+ return diffs, nil
+}
+
+func diffPayloadValues(intended, stored any, path string, diffs *[]string) {
+ switch want := intended.(type) {
+ case map[string]any:
+ got, ok := stored.(map[string]any)
+ if !ok {
+ *diffs = append(*diffs, path)
+ return
+ }
+ for k, v := range want {
+ if maskedPayloadMetaKeys[k] {
+ continue
+ }
+ p := k
+ if path != "" {
+ p = path + "." + k
+ }
+ s, present := got[k]
+ if !present {
+ // A missing empty container is a harmless server omission.
+ if isEmptyContainer(v) {
+ continue
+ }
+ *diffs = append(*diffs, p)
+ continue
+ }
+ diffPayloadValues(v, s, p, diffs)
+ }
+ case []any:
+ got, ok := stored.([]any)
+ if !ok || len(got) != len(want) {
+ *diffs = append(*diffs, path)
+ return
+ }
+ for i := range want {
+ diffPayloadValues(want[i], got[i], fmt.Sprintf("%s[%d]", path, i), diffs)
+ }
+ case string:
+ got, ok := stored.(string)
+ if !ok || strings.TrimSpace(got) != strings.TrimSpace(want) {
+ *diffs = append(*diffs, path)
+ }
+ case []byte:
+ got, ok := stored.([]byte)
+ if !ok || !bytes.Equal(got, want) {
+ *diffs = append(*diffs, path)
+ }
+ default:
+ // Numbers, bools, dates: same parser both sides, but be lenient on
+ // integer width differences via the printed form.
+ if fmt.Sprint(intended) != fmt.Sprint(stored) {
+ *diffs = append(*diffs, path)
+ }
+ }
+}
+
+func isEmptyContainer(v any) bool {
+ switch t := v.(type) {
+ case map[string]any:
+ return len(t) == 0
+ case []any:
+ return len(t) == 0
+ case string:
+ return t == ""
+ }
+ return false
+}
diff --git a/internal/profileconvert/classic_verify_test.go b/internal/profileconvert/classic_verify_test.go
new file mode 100644
index 00000000..e8c8059c
--- /dev/null
+++ b/internal/profileconvert/classic_verify_test.go
@@ -0,0 +1,107 @@
+// Copyright 2026, Jamf Software LLC
+
+package profileconvert
+
+import (
+ "os"
+ "strings"
+ "testing"
+)
+
+func plistDoc(inner string) []byte {
+ return []byte(`` + inner + ``)
+}
+
+func TestDiffPayloadValues_Identical(t *testing.T) {
+ a := plistDoc(`kR&D`)
+ diffs, err := DiffPayloadValues(a, a)
+ if err != nil || len(diffs) != 0 {
+ t.Fatalf("want no diffs, got %v (err %v)", diffs, err)
+ }
+}
+
+func TestDiffPayloadValues_CorruptedValue(t *testing.T) {
+ intended := plistDoc(`LoginwindowTextHere is an &`)
+ stored := plistDoc(`LoginwindowTextHere is an &`)
+ diffs, err := DiffPayloadValues(intended, stored)
+ if err != nil {
+ t.Fatal(err)
+ }
+ if len(diffs) != 1 || diffs[0] != "LoginwindowText" {
+ t.Fatalf("want [LoginwindowText], got %v", diffs)
+ }
+}
+
+func TestDiffPayloadValues_MasksServerRewrittenMeta(t *testing.T) {
+ intended := plistDoc(`PayloadUUIDAAAPayloadDisplayNameminekv`)
+ stored := plistDoc(`PayloadUUIDBBBPayloadDisplayNameCustom SettingsPayloadOrganizationOrgkv`)
+ diffs, err := DiffPayloadValues(intended, stored)
+ if err != nil || len(diffs) != 0 {
+ t.Fatalf("meta rewrites must be masked, got %v (err %v)", diffs, err)
+ }
+}
+
+func TestDiffPayloadValues_EdgeWhitespaceTrimIgnored(t *testing.T) {
+ intended := plistDoc(`k padded `)
+ stored := plistDoc(`kpadded`)
+ diffs, err := DiffPayloadValues(intended, stored)
+ if err != nil || len(diffs) != 0 {
+ t.Fatalf("server edge-trim must not diff, got %v (err %v)", diffs, err)
+ }
+}
+
+func TestDiffPayloadValues_NestedArrayAndDict(t *testing.T) {
+ intended := plistDoc(`ax1`)
+ stored := plistDoc(`ax2`)
+ diffs, err := DiffPayloadValues(intended, stored)
+ if err != nil {
+ t.Fatal(err)
+ }
+ if len(diffs) != 1 || !strings.Contains(diffs[0], "a[0].x") {
+ t.Fatalf("want nested path diff, got %v", diffs)
+ }
+}
+
+func TestDiffPayloadValues_MissingEmptyContainerIgnored(t *testing.T) {
+ intended := plistDoc(`AllowListkv`)
+ stored := plistDoc(`kv`)
+ diffs, err := DiffPayloadValues(intended, stored)
+ if err != nil || len(diffs) != 0 {
+ t.Fatalf("missing empty containers must not diff, got %v (err %v)", diffs, err)
+ }
+}
+
+func TestIsSignedProfile(t *testing.T) {
+ if IsSignedProfile([]byte(``)) {
+ t.Fatal("XML misdetected as signed")
+ }
+ if IsSignedProfile([]byte("bplist00xyz")) {
+ t.Fatal("binary plist misdetected as signed")
+ }
+ if !IsSignedProfile([]byte{0x30, 0x80, 0x06, 0x09}) {
+ t.Fatal("BER SignedData not detected")
+ }
+ if !IsSignedProfile([]byte{0x30, 0x82, 0x0a, 0x00}) {
+ t.Fatal("DER SignedData not detected")
+ }
+}
+
+// TestExtractSignedProfile_RealFile exercises CMS extraction against a real
+// signed mobileconfig when one is available locally; skipped otherwise.
+func TestExtractSignedProfile_RealFile(t *testing.T) {
+ path := os.Getenv("JAMF_CLI_TEST_SIGNED_PROFILE")
+ if path == "" {
+ t.Skip("set JAMF_CLI_TEST_SIGNED_PROFILE to a signed .mobileconfig to run")
+ }
+ data, err := os.ReadFile(path)
+ if err != nil {
+ t.Fatal(err)
+ }
+ inner, err := ExtractSignedProfile(data)
+ if err != nil {
+ t.Fatal(err)
+ }
+ if !strings.Contains(string(inner), "