From 2cb61caac2582eb43852da69de51ace0219c2698 Mon Sep 17 00:00:00 2001 From: Keaton Svoma Date: Sun, 31 May 2026 01:40:25 -0500 Subject: [PATCH 01/15] feat(output): add IsTerminal and pure ResolveFormat resolver Co-Authored-By: Claude Opus 4.8 (1M context) --- internal/output/output.go | 23 +++++++++++++++++++++++ internal/output/output_test.go | 25 +++++++++++++++++++++++++ 2 files changed, 48 insertions(+) diff --git a/internal/output/output.go b/internal/output/output.go index 87994d7b..ca625873 100644 --- a/internal/output/output.go +++ b/internal/output/output.go @@ -15,6 +15,7 @@ import ( "strings" "time" + "golang.org/x/term" "gopkg.in/yaml.v3" "github.com/Jamf-Concepts/jamf-cli/internal/xmlconv" @@ -905,3 +906,25 @@ func (f *Formatter) formatStatusValue(value string) string { return value } + +// IsTerminal reports whether the given file descriptor is a character device. +// Used to choose human (table) vs machine (json) defaults and to gate color. +func IsTerminal(fd uintptr) bool { + return term.IsTerminal(int(fd)) +} + +// ResolveFormat decides the effective output format. Precedence: +// explicit --output flag > config default_output > auto (TTY -> table, +// otherwise json). Writing to a file is treated as non-interactive. +func ResolveFormat(flagChanged bool, current, configDefault string, isTTY, hasOutFile bool) string { + if flagChanged { + return current + } + if configDefault != "" { + return configDefault + } + if isTTY && !hasOutFile { + return string(FormatTable) + } + return string(FormatJSON) +} diff --git a/internal/output/output_test.go b/internal/output/output_test.go index 01a156fc..a15e23ea 100644 --- a/internal/output/output_test.go +++ b/internal/output/output_test.go @@ -1617,3 +1617,28 @@ func TestPrintRaw_RawFormat_ExactBytes(t *testing.T) { t.Errorf("FormatRaw should write exact bytes, got: %q", buf.String()) } } + +func TestResolveFormat(t *testing.T) { + cases := []struct { + name string + flagChanged bool + current string + configDefault string + isTTY, hasOutFile bool + want string + }{ + {"explicit flag wins", true, "csv", "yaml", true, false, "csv"}, + {"config default when unset", false, "json", "yaml", true, false, "yaml"}, + {"tty -> table", false, "json", "", true, false, "table"}, + {"piped -> json", false, "json", "", false, false, "json"}, + {"out-file -> json even on tty", false, "json", "", true, true, "json"}, + } + for _, c := range cases { + t.Run(c.name, func(t *testing.T) { + got := ResolveFormat(c.flagChanged, c.current, c.configDefault, c.isTTY, c.hasOutFile) + if got != c.want { + t.Fatalf("ResolveFormat = %q, want %q", got, c.want) + } + }) + } +} From f5e4c96b82b55fa654d01e5d037868b54708b7a1 Mon Sep 17 00:00:00 2001 From: Keaton Svoma Date: Sun, 31 May 2026 01:34:54 -0500 Subject: [PATCH 02/15] feat(exitcode): add PartialFailure code, Hint/Details, PartialOrPropagate Co-Authored-By: Claude Opus 4.8 (1M context) --- internal/exitcode/exitcode.go | 33 +++++++++++++++++++++++- internal/exitcode/exitcode_test.go | 40 ++++++++++++++++++++++++++++++ 2 files changed, 72 insertions(+), 1 deletion(-) diff --git a/internal/exitcode/exitcode.go b/internal/exitcode/exitcode.go index eae77ae9..70ca07e3 100644 --- a/internal/exitcode/exitcode.go +++ b/internal/exitcode/exitcode.go @@ -16,13 +16,16 @@ const ( NotFound = 4 PermissionDenied = 5 RateLimited = 6 + PartialFailure = 7 ) // Error is an error that carries a specific exit code. type Error struct { Code int Message string - Err error // optional wrapped error + Err error // optional wrapped error + Hint string // optional one-line remediation, surfaced separately + Details map[string]any // optional structured extras for the JSON envelope } func (e *Error) Error() string { @@ -67,6 +70,8 @@ func CodeName(code int) string { return "permission_denied" case RateLimited: return "rate_limited" + case PartialFailure: + return "partial_failure" default: return "general" } @@ -81,3 +86,29 @@ func CodeFrom(err error) int { } return General } + +// WithHint attaches a one-line remediation hint and returns e for chaining. +func (e *Error) WithHint(hint string) *Error { e.Hint = hint; return e } + +// WithDetails attaches structured extras and returns e for chaining. +func (e *Error) WithDetails(d map[string]any) *Error { e.Details = d; return e } + +// PartialOrPropagate maps a batch tally to an exit error: +// - failed == 0 -> nil +// - succeeded > 0 && failed > 0 -> PartialFailure (7) carrying msg + counts +// - succeeded == 0 && failed > 0-> firstErr's exit code (propagated), or General +func PartialOrPropagate(succeeded, failed int, firstErr error, msg string) error { + if failed == 0 { + return nil + } + if succeeded > 0 { + return New(PartialFailure, msg).WithDetails(map[string]any{ + "succeeded": succeeded, + "failed": failed, + }) + } + if firstErr != nil { + return Wrap(CodeFrom(firstErr), firstErr) + } + return New(General, msg) +} diff --git a/internal/exitcode/exitcode_test.go b/internal/exitcode/exitcode_test.go index 4ec4d43e..70dd980e 100644 --- a/internal/exitcode/exitcode_test.go +++ b/internal/exitcode/exitcode_test.go @@ -114,3 +114,43 @@ func TestWrap(t *testing.T) { t.Error("expected wrapped error to be unwrappable") } } + +func TestCodeName_PartialFailure(t *testing.T) { + if got := CodeName(PartialFailure); got != "partial_failure" { + t.Fatalf("CodeName(PartialFailure) = %q, want %q", got, "partial_failure") + } + if PartialFailure != 7 { + t.Fatalf("PartialFailure = %d, want 7", PartialFailure) + } +} + +func TestWithHintAndDetails(t *testing.T) { + err := New(NotFound, "missing").WithHint("run list").WithDetails(map[string]any{"id": "5"}) + if err.Hint != "run list" { + t.Fatalf("Hint = %q", err.Hint) + } + if err.Details["id"] != "5" { + t.Fatalf("Details = %v", err.Details) + } + if got := err.Error(); got != "missing" { + t.Fatalf("Error() = %q, want %q", got, "missing") + } +} + +func TestPartialOrPropagate(t *testing.T) { + err := PartialOrPropagate(3, 2, New(Authentication, "401"), "2 of 5 failed") + if CodeFrom(err) != PartialFailure { + t.Fatalf("partial: code = %d, want %d", CodeFrom(err), PartialFailure) + } + err = PartialOrPropagate(0, 5, New(Authentication, "401"), "5 of 5 failed") + if CodeFrom(err) != Authentication { + t.Fatalf("total: code = %d, want %d", CodeFrom(err), Authentication) + } + err = PartialOrPropagate(0, 5, nil, "all failed") + if CodeFrom(err) != General { + t.Fatalf("total/nil: code = %d, want %d", CodeFrom(err), General) + } + if PartialOrPropagate(5, 0, nil, "") != nil { + t.Fatal("no failures should return nil") + } +} From d3ccf08929b6678fd3095f7b6d447387df711466 Mon Sep 17 00:00:00 2001 From: Keaton Svoma Date: Sun, 31 May 2026 01:45:02 -0500 Subject: [PATCH 03/15] feat(output): TTY-aware default format and color gate Resolve the effective output format in PersistentPreRunE: explicit --output wins, then config default_output, then auto (terminal -> table, piped -> json). Disable color whenever stdout is not a terminal so ANSI never leaks into a pipe. Co-Authored-By: Claude Opus 4.8 (1M context) --- internal/commands/root.go | 14 +++++++++++--- 1 file changed, 11 insertions(+), 3 deletions(-) diff --git a/internal/commands/root.go b/internal/commands/root.go index cb6c8f69..d3b3b418 100644 --- a/internal/commands/root.go +++ b/internal/commands/root.go @@ -573,9 +573,17 @@ spinner and progress output (narrower than --quiet).`, return fmt.Errorf("loading config: %w", err) } - // Apply default output from config if flag not explicitly set - if !cmd.Flags().Changed("output") && cfg.DefaultOutput != "" { - outputFmt = cfg.DefaultOutput + // Resolve effective output format: explicit --output wins, then + // config default_output, then auto (TTY -> table, piped -> json). + // Color is disabled whenever stdout is not an interactive terminal + // so ANSI never leaks into a pipe. + stdoutTTY := output.IsTerminal(os.Stdout.Fd()) + outputFmt = output.ResolveFormat( + cmd.Flags().Changed("output"), outputFmt, cfg.DefaultOutput, + stdoutTTY, outFile != "", + ) + if !stdoutTTY { + noColor = true } if outputFmt == string(output.FormatJSONMulti) && os.Getenv("JAMF_CLI_MULTI_CAPTURE") == "" { From 58b3fb7d5e6b43eee75b7c6e8fa5fd33e44c5969 Mon Sep 17 00:00:00 2001 From: Keaton Svoma Date: Sun, 31 May 2026 01:47:31 -0500 Subject: [PATCH 04/15] feat(output): vertical detail view for single objects in table mode A single-object response now renders as a FIELD/VALUE detail layout instead of an unreadable 1-row table (and fixes Print(map) previously emitting Go's %v map repr). Arrays, even of length one, still render as tables. Co-Authored-By: Claude Opus 4.8 (1M context) --- internal/output/detail_test.go | 55 ++++++++++++++++++++++++++++++++++ internal/output/output.go | 52 +++++++++++++++++++++++++++++++- internal/output/output_test.go | 9 ++++-- 3 files changed, 112 insertions(+), 4 deletions(-) create mode 100644 internal/output/detail_test.go diff --git a/internal/output/detail_test.go b/internal/output/detail_test.go new file mode 100644 index 00000000..17b543ff --- /dev/null +++ b/internal/output/detail_test.go @@ -0,0 +1,55 @@ +// Copyright 2026, Jamf Software LLC + +package output + +import ( + "bytes" + "strings" + "testing" +) + +func TestPrintDetail_SingleObjectTable(t *testing.T) { + var buf bytes.Buffer + f := New("table", true /*noColor*/, false) + f.SetWriter(&buf) + if err := f.Print(map[string]any{"id": "42", "name": "Lab Mac", "managed": true}); err != nil { + t.Fatal(err) + } + out := buf.String() + if !strings.Contains(out, "id") || !strings.Contains(out, "42") { + t.Fatalf("missing id row:\n%s", out) + } + if !strings.Contains(out, "name") || !strings.Contains(out, "Lab Mac") { + t.Fatalf("missing name row:\n%s", out) + } + if strings.Contains(out, "map[") { + t.Fatalf("rendered Go map repr instead of detail view:\n%s", out) + } +} + +func TestPrintRaw_ArrayOfOneStaysTable(t *testing.T) { + var buf bytes.Buffer + f := New("table", true, false) + f.SetWriter(&buf) + if err := f.PrintRaw([]byte(`[{"id":"1","name":"a"}]`)); err != nil { + t.Fatal(err) + } + if !strings.Contains(buf.String(), "total)") { + t.Fatalf("array-of-one should render as table:\n%s", buf.String()) + } +} + +func TestPrintRaw_ObjectGetsDetail(t *testing.T) { + var buf bytes.Buffer + f := New("table", true, false) + f.SetWriter(&buf) + if err := f.PrintRaw([]byte(`{"id":"7","name":"solo"}`)); err != nil { + t.Fatal(err) + } + if strings.Contains(buf.String(), "total)") { + t.Fatalf("single object should NOT render as a table:\n%s", buf.String()) + } + if !strings.Contains(buf.String(), "solo") { + t.Fatalf("missing value:\n%s", buf.String()) + } +} diff --git a/internal/output/output.go b/internal/output/output.go index ca625873..70690f00 100644 --- a/internal/output/output.go +++ b/internal/output/output.go @@ -182,7 +182,13 @@ func (f *Formatter) Print(data any) error { case FormatPlain: err = f.printPlain(data) default: - err = f.printTable(data) + // Table mode: a single object renders as a vertical detail view, not + // an unreadable 1-row table. + if obj, ok := data.(map[string]any); ok { + err = f.printDetail(obj) + } else { + err = f.printTable(data) + } } if err == nil { @@ -408,6 +414,43 @@ func (f *Formatter) printTable(data any) error { return nil } +// printDetail renders a single object as a vertical FIELD / VALUE layout, used +// in table mode so a `get` does not become an unreadable 1-row table. Reuses +// table date/status colorization. +func (f *Formatter) printDetail(obj map[string]any) error { + flat := flattenRows([]map[string]any{obj}) + if len(flat) == 0 { + return nil + } + row := flat[0] + keys := sortedKeys(row) + + fieldW := len("FIELD") + for _, k := range keys { + if len(k) > fieldW { + fieldW = len(k) + } + } + + _, _ = fmt.Fprintf(f.writer, "%s\n\n", f.colorize("DETAILS", colorBold)) + for _, k := range keys { + val := FormatValue(row[k]) + display := val + switch { + case isDateColumn(k): + formatted, isRecent := formatDateValue(val, f.wide) + display = formatted + if isRecent { + display = f.colorize(formatted, colorGreen) + } + case isStatusColumn(k): + display = f.formatStatusValue(val) + } + _, _ = fmt.Fprintf(f.writer, " %-*s %s\n", fieldW, k, display) + } + return nil +} + // PrintRaw outputs raw bytes (usually JSON from the API). // XML responses (from Classic API) are converted to JSON before formatting, // unless the format is FormatXML (pretty-printed) or FormatRaw (exact wire bytes). @@ -462,6 +505,13 @@ func (f *Formatter) PrintRaw(data []byte) error { case FormatJSON, FormatJSONMulti, FormatYAML: return f.Print(parsed) default: + // Table mode: a single object renders as a detail view, not a 1-row + // table. Arrays (even length 1) still normalize to rows. + if f.format == FormatTable || f.format == "" { + if obj, ok := parsed.(map[string]any); ok { + return f.Print(obj) + } + } return f.Print(normalizeForTabular(parsed)) } } diff --git a/internal/output/output_test.go b/internal/output/output_test.go index a15e23ea..35370d0f 100644 --- a/internal/output/output_test.go +++ b/internal/output/output_test.go @@ -132,9 +132,12 @@ func TestPrintRaw_Table_SingleObject(t *testing.T) { t.Fatalf("unexpected error: %v", err) } out := buf.String() - // New format: summary + blank + header + separator + data row - if !strings.Contains(out, "(1 total)") { - t.Errorf("expected summary header with count, got:\n%s", out) + // A single object renders as a vertical detail view, not a 1-row table. + if !strings.Contains(out, "DETAILS") { + t.Errorf("expected DETAILS detail-view header, got:\n%s", out) + } + if strings.Contains(out, "total)") { + t.Errorf("single object should not render the list-table summary, got:\n%s", out) } if !strings.Contains(out, "42") { t.Errorf("expected '42' in output, got:\n%s", out) From 83ab6ccef069a4eef0fc9132de00f42b5953b30b Mon Sep 17 00:00:00 2001 From: Keaton Svoma Date: Sun, 31 May 2026 01:48:55 -0500 Subject: [PATCH 05/15] feat(output): make --compact semantic (allowlist + row-frequency keep) --compact now keeps high-signal scalars only: an identity/allowlist of leaf fields plus any scalar present in >=80% of list rows; single objects keep scalars except a verbose blocklist. Rare noise fields are dropped (use --select for specific fields). Co-Authored-By: Claude Opus 4.8 (1M context) --- internal/commands/root.go | 2 +- internal/output/compact_test.go | 40 ++++++++++++++++ internal/output/project.go | 81 +++++++++++++++++++++++++++++---- 3 files changed, 114 insertions(+), 9 deletions(-) create mode 100644 internal/output/compact_test.go diff --git a/internal/commands/root.go b/internal/commands/root.go index d3b3b418..5a95610a 100644 --- a/internal/commands/root.go +++ b/internal/commands/root.go @@ -737,7 +737,7 @@ spinner and progress output (narrower than --quiet).`, cmd.PersistentFlags().BoolVar(&noColor, "no-color", false, "disable colored output") cmd.PersistentFlags().BoolVarP(&dryRun, "dry-run", "n", false, "preview changes without executing") cmd.PersistentFlags().BoolVarP(&wide, "wide", "w", false, "show all columns in table output") - cmd.PersistentFlags().BoolVar(&compact, "compact", false, "drop arrays and nested objects, keep only scalar fields (smaller payloads for agents; ignored when --field is set)") + cmd.PersistentFlags().BoolVar(&compact, "compact", false, "keep only high-signal scalar fields (identity + fields common across rows); smaller payloads for agents; ignored when --field is set") cmd.PersistentFlags().StringSliceVar(&selectFields, "select", nil, "project output to these dot-path fields only, e.g., --select id,general.name,udid (ignored when --field is set)") cmd.PersistentFlags().StringVar(&outFile, "out-file", "", "write output to file instead of stdout") cmd.PersistentFlags().StringVar(&fieldName, "field", "", "extract a single field from JSON response (e.g., --field id)") diff --git a/internal/output/compact_test.go b/internal/output/compact_test.go new file mode 100644 index 00000000..33bae5d0 --- /dev/null +++ b/internal/output/compact_test.go @@ -0,0 +1,40 @@ +// Copyright 2026, Jamf Software LLC + +package output + +import "testing" + +func TestProjectCompact_ListAllowlistAndFrequency(t *testing.T) { + rows := []map[string]any{ + {"id": "1", "name": "a", "rareNote": "x", "lastSeen": "t1"}, + {"id": "2", "name": "b", "lastSeen": "t2"}, + {"id": "3", "name": "c", "lastSeen": "t3"}, + } + out := Projector{Compact: true}.Apply(rows) + for _, r := range out { + if _, ok := r["id"]; !ok { + t.Fatalf("id dropped: %v", r) + } + } + // rareNote present in 1/3 (33%) and not allowlisted -> dropped. + if _, ok := out[0]["rareNote"]; ok { + t.Fatalf("rare non-allowlisted scalar should be dropped: %v", out[0]) + } + // lastSeen present in all rows -> kept by frequency rule. + if _, ok := out[0]["lastSeen"]; !ok { + t.Fatalf("frequent scalar should be kept: %v", out[0]) + } +} + +func TestProjectCompact_SingleObjectBlocklist(t *testing.T) { + rows := []map[string]any{ + {"id": "1", "name": "a", "description": "long blob", "udid": "U-1"}, + } + out := Projector{Compact: true}.Apply(rows) + if _, ok := out[0]["description"]; ok { + t.Fatalf("blocklisted field should be dropped: %v", out[0]) + } + if _, ok := out[0]["udid"]; !ok { + t.Fatalf("allowlisted/scalar field should be kept: %v", out[0]) + } +} diff --git a/internal/output/project.go b/internal/output/project.go index 1dca7335..897572c7 100644 --- a/internal/output/project.go +++ b/internal/output/project.go @@ -77,20 +77,85 @@ func projectSelect(rows []map[string]any, paths []string) []map[string]any { return out } -// projectCompact keeps only scalar values from flattened rows. -// flattenRows already lifts nested objects to dot keys, so what remains -// non-scalar here is arrays — which are the bulk of the token cost in -// Jamf Pro list responses. +// compactAllowKeys are leaf field names (lowercased) always kept by --compact, +// regardless of how often they appear: identity and high-signal fields. +var compactAllowKeys = map[string]bool{ + "id": true, "name": true, "displayname": true, "udid": true, + "serialnumber": true, "serial": true, "managementid": true, + "username": true, "email": true, "status": true, "state": true, + "enabled": true, "managed": true, "supervised": true, "model": true, + "osversion": true, "version": true, "type": true, "category": true, +} + +// compactBlockKeys are verbose leaf fields dropped from a single-object +// --compact (where the frequency rule is meaningless at n=1). +var compactBlockKeys = map[string]bool{ + "description": true, "notes": true, "body": true, "content": true, + "payloads": true, "payload": true, "scriptcontents": true, + "html": true, "base64": true, +} + +// leafKey returns the last dot-segment of a flattened key. +func leafKey(k string) string { + if i := strings.LastIndex(k, "."); i >= 0 { + return k[i+1:] + } + return k +} + +// compactAllowed reports whether a leaf key is always kept: in the allowlist, +// or an identity field (suffix "Id"). +func compactAllowed(leaf string) bool { + if compactAllowKeys[strings.ToLower(leaf)] { + return true + } + return strings.HasSuffix(leaf, "Id") +} + +// projectCompact keeps high-signal scalars. flattenRows already lifts nested +// objects to dot keys and drops arrays, so this trims the remaining scalar +// noise. For a single object it keeps every scalar except a verbose blocklist. +// For a list it keeps a scalar when the leaf is allowlisted OR the key is +// present (non-null scalar) in >=80% of rows. func projectCompact(rows []map[string]any) []map[string]any { + if len(rows) == 0 { + return rows + } + if len(rows) == 1 { + row := rows[0] + out := make(map[string]any, len(row)) + for k, v := range row { + if !isScalar(v) { + continue + } + if compactBlockKeys[strings.ToLower(leafKey(k))] { + continue + } + out[k] = v + } + return []map[string]any{out} + } + + counts := make(map[string]int) + for _, row := range rows { + for k, v := range row { + if isScalar(v) { + counts[k]++ + } + } + } out := make([]map[string]any, len(rows)) for i, row := range rows { - compact := make(map[string]any, len(row)) + c := make(map[string]any, len(row)) for k, v := range row { - if isScalar(v) { - compact[k] = v + if !isScalar(v) { + continue + } + if compactAllowed(leafKey(k)) || counts[k]*100 >= len(rows)*80 { + c[k] = v } } - out[i] = compact + out[i] = c } return out } From 309f6fce46817a669bad935b83396deaefc14212 Mon Sep 17 00:00:00 2001 From: Keaton Svoma Date: Sun, 31 May 2026 01:51:53 -0500 Subject: [PATCH 06/15] feat(bulk): exit 7 on partial failure with --allow-partial-failure opt-out Handwritten bulk operations (group membership, policy enable/disable, MDM send-command) now return exit code 7 when some items succeed and some fail, distinguishing partial from total failure. --allow-partial-failure downgrades to a warning + exit 0. Total failures propagate the underlying error code. Co-Authored-By: Claude Opus 4.8 (1M context) --- internal/commands/pro_bulk.go | 15 ++++++ internal/commands/pro_bulk_commands.go | 9 ++-- internal/commands/pro_bulk_groups.go | 9 ++-- internal/commands/pro_bulk_partial_test.go | 54 ++++++++++++++++++++++ internal/commands/pro_bulk_policies.go | 9 ++-- internal/commands/root.go | 44 +++++++++--------- 6 files changed, 107 insertions(+), 33 deletions(-) create mode 100644 internal/commands/pro_bulk_partial_test.go diff --git a/internal/commands/pro_bulk.go b/internal/commands/pro_bulk.go index 4248f8f6..bb576cfc 100644 --- a/internal/commands/pro_bulk.go +++ b/internal/commands/pro_bulk.go @@ -13,10 +13,25 @@ import ( "github.com/spf13/cobra" + "github.com/Jamf-Concepts/jamf-cli/internal/exitcode" "github.com/Jamf-Concepts/jamf-cli/internal/output" "github.com/Jamf-Concepts/jamf-cli/internal/registry" ) +// finishBatch maps a bulk tally to the process result. When some items +// succeeded and some failed it returns a PartialFailure (exit 7), unless +// --allow-partial-failure is set, in which case it warns and returns nil. +// A total failure propagates firstErr's exit code. +func finishBatch(stderr io.Writer, noun string, succeeded, failed int, firstErr error) error { + if failed > 0 && succeeded > 0 && allowPartialFailure { + _, _ = fmt.Fprintf(stderr, "warning: %d of %d %s failed; continuing (--allow-partial-failure)\n", + failed, succeeded+failed, noun) + return nil + } + msg := fmt.Sprintf("%d of %d %s failed", failed, succeeded+failed, noun) + return exitcode.PartialOrPropagate(succeeded, failed, firstErr, msg) +} + // newBulkCmd builds the "bulk" parent command with all subcommands attached. func newBulkCmd(cliCtx *registry.CLIContext) *cobra.Command { cmd := &cobra.Command{ diff --git a/internal/commands/pro_bulk_commands.go b/internal/commands/pro_bulk_commands.go index 9ff07670..8a067c98 100644 --- a/internal/commands/pro_bulk_commands.go +++ b/internal/commands/pro_bulk_commands.go @@ -128,6 +128,7 @@ func runSendCommand( _, _ = fmt.Fprintf(stderr, "Sending %q to %d computers...\n", command, len(targets)) var successCount, failCount int + var firstErr error for _, t := range targets { if destructiveMDMCommands[command] { if err := cooldown.Enforce(cliCtx.ProfileName, noInput, cliCtx.DestructiveCooldown); err != nil { @@ -136,6 +137,9 @@ func runSendCommand( } if err := sendMDMCommand(ctx, client, t["id"], command); err != nil { bulkLogW(stderr, "send-command", t["name"], "ERROR: "+err.Error()) + if firstErr == nil { + firstErr = err + } failCount++ } else { if destructiveMDMCommands[command] { @@ -147,10 +151,7 @@ func runSendCommand( } _, _ = fmt.Fprintf(stderr, "Command %q sent: %d succeeded, %d failed.\n", command, successCount, failCount) - if failCount > 0 { - return fmt.Errorf("%d of %d send-command operations failed", failCount, successCount+failCount) - } - return nil + return finishBatch(stderr, "send-command operations", successCount, failCount, firstErr) } // sortedKeys returns the keys of a map[string]bool in sorted order. diff --git a/internal/commands/pro_bulk_groups.go b/internal/commands/pro_bulk_groups.go index 1021da44..577dae7c 100644 --- a/internal/commands/pro_bulk_groups.go +++ b/internal/commands/pro_bulk_groups.go @@ -119,9 +119,13 @@ func runGroupMutation( _, _ = fmt.Fprintf(stderr, "Applying group membership changes to %d computers...\n", len(targets)) var successCount, failCount int + var firstErr error for _, t := range targets { if err := applyStaticGroupMutation(ctx, client, groupID, t["id"], add); err != nil { bulkLogW(stderr, verb+" group", t["name"], "ERROR: "+err.Error()) + if firstErr == nil { + firstErr = err + } failCount++ } else { bulkLogW(stderr, verb+" group", t["name"], "ok") @@ -130,10 +134,7 @@ func runGroupMutation( } _, _ = fmt.Fprintf(stderr, "Group update complete: %d succeeded, %d failed.\n", successCount, failCount) - if failCount > 0 { - return fmt.Errorf("%d of %d group membership operations failed", failCount, successCount+failCount) - } - return nil + return finishBatch(stderr, "group membership operations", successCount, failCount, firstErr) } // directionWord returns "to" or "from" for logging messages. diff --git a/internal/commands/pro_bulk_partial_test.go b/internal/commands/pro_bulk_partial_test.go new file mode 100644 index 00000000..09917e14 --- /dev/null +++ b/internal/commands/pro_bulk_partial_test.go @@ -0,0 +1,54 @@ +// Copyright 2026, Jamf Software LLC + +package commands + +import ( + "testing" + + "github.com/Jamf-Concepts/jamf-cli/internal/exitcode" +) + +// partialSendMock dispatches BlankPush to two computers; device 2's command +// POST returns 500 so one succeeds and one fails. +func partialSendMock() *bulkMockClient { + return &bulkMockClient{ + responses: map[string]overviewMockResponse{ + "GET /JSSResource/computergroups": {200, `{"computer_groups":[{"id":100,"name":"Lab Macs"}]}`}, + "GET /JSSResource/computergroups/id/100": {200, staticGroupDetailJSON}, + "GET /JSSResource/computers/id/1": {200, `{"computer":{"id":1,"name":"Mac-01"}}`}, + "GET /JSSResource/computers/id/2": {200, `{"computer":{"id":2,"name":"Mac-02"}}`}, + "POST /JSSResource/computercommands/command/BlankPush/id/1": {200, ``}, + "POST /JSSResource/computercommands/command/BlankPush/id/2": {500, `boom`}, + }, + } +} + +func TestSendCommand_PartialFailure_ExitCode7(t *testing.T) { + cmd := newBulkCmd(newBulkCLIContext(partialSendMock())) + _, _, err := runCobraCmd(t, cmd, "send-command", "--command", "BlankPush", "--group", "Lab Macs", "--yes") + if err == nil { + t.Fatal("expected a partial-failure error, got nil") + } + if got := exitcode.CodeFrom(err); got != exitcode.PartialFailure { + t.Fatalf("exit code = %d, want PartialFailure(%d) (err=%v)", got, exitcode.PartialFailure, err) + } +} + +func TestSendCommand_PartialFailure_AllowOptOut(t *testing.T) { + allowPartialFailure = true + defer func() { allowPartialFailure = false }() + + cmd := newBulkCmd(newBulkCLIContext(partialSendMock())) + _, _, err := runCobraCmd(t, cmd, "send-command", "--command", "BlankPush", "--group", "Lab Macs", "--yes") + if err != nil { + t.Fatalf("--allow-partial-failure should downgrade to exit 0, got err=%v", err) + } +} + +func TestFinishBatch_TotalFailurePropagatesCode(t *testing.T) { + // All failed -> propagate the underlying error's code, not PartialFailure. + err := finishBatch(nil, "ops", 0, 3, exitcode.New(exitcode.Authentication, "401")) + if got := exitcode.CodeFrom(err); got != exitcode.Authentication { + t.Fatalf("total failure code = %d, want Authentication(%d)", got, exitcode.Authentication) + } +} diff --git a/internal/commands/pro_bulk_policies.go b/internal/commands/pro_bulk_policies.go index cfe739f3..275a8db8 100644 --- a/internal/commands/pro_bulk_policies.go +++ b/internal/commands/pro_bulk_policies.go @@ -215,11 +215,15 @@ func runTogglePolicies( _, _ = fmt.Fprintf(stderr, "%sing %d policies...\n", capitalize(verb), len(matched)) var successCount, failCount int + var firstErr error for _, p := range matched { general, _ := p.detail["general"].(map[string]any) name, _ := general["name"].(string) if err := doClassicPolicyUpdate(ctx, client, p.id, enable); err != nil { bulkLogW(stderr, verb+" policy", name, "ERROR: "+err.Error()) + if firstErr == nil { + firstErr = err + } failCount++ } else { bulkLogW(stderr, verb+" policy", name, "ok") @@ -228,10 +232,7 @@ func runTogglePolicies( } _, _ = fmt.Fprintf(stderr, "%sd %d policies; %d failed.\n", capitalize(verb), successCount, failCount) - if failCount > 0 { - return fmt.Errorf("%d of %d policy %s operations failed", failCount, successCount+failCount, verb) - } - return nil + return finishBatch(stderr, fmt.Sprintf("policy %s operations", verb), successCount, failCount, firstErr) } // capitalize returns the string with its first letter uppercased. diff --git a/internal/commands/root.go b/internal/commands/root.go index 5a95610a..82edf6a2 100644 --- a/internal/commands/root.go +++ b/internal/commands/root.go @@ -34,27 +34,28 @@ import ( // Global flags var ( - profile string - outputFmt string - quiet bool - noHints bool - verboseLevel int - noInput bool - noColor bool - dryRun bool - wide bool - compact bool - selectFields []string - outFile string - fieldName string - serverURL string - token string - tokenFile string - clientID string - clientSecret string - tenantID string - cliVersion string // set by NewRootCmd for use by power commands - noVersionCheck bool // skip tenant version compatibility probe + profile string + outputFmt string + quiet bool + noHints bool + verboseLevel int + noInput bool + noColor bool + dryRun bool + wide bool + compact bool + allowPartialFailure bool + selectFields []string + outFile string + fieldName string + serverURL string + token string + tokenFile string + clientID string + clientSecret string + tenantID string + cliVersion string // set by NewRootCmd for use by power commands + noVersionCheck bool // skip tenant version compatibility probe ) // cliClient wraps our client to implement registry.HTTPClient @@ -741,6 +742,7 @@ spinner and progress output (narrower than --quiet).`, cmd.PersistentFlags().StringSliceVar(&selectFields, "select", nil, "project output to these dot-path fields only, e.g., --select id,general.name,udid (ignored when --field is set)") cmd.PersistentFlags().StringVar(&outFile, "out-file", "", "write output to file instead of stdout") cmd.PersistentFlags().StringVar(&fieldName, "field", "", "extract a single field from JSON response (e.g., --field id)") + cmd.PersistentFlags().BoolVar(&allowPartialFailure, "allow-partial-failure", false, "downgrade a partial batch failure (some items failed) to a warning and exit 0") // Connection flags cmd.PersistentFlags().StringVar(&serverURL, "url", "", "Jamf Pro server URL (or JAMF_URL env)") From df2d7ec19e55612be26478758d3fc2c829c5f1ce Mon Sep 17 00:00:00 2001 From: Keaton Svoma Date: Sun, 31 May 2026 01:53:05 -0500 Subject: [PATCH 07/15] feat(backup): exit 7 on partial failure honoring --allow-partial-failure When some objects export and some fail, backup now returns exit code 7 instead of a generic error. --allow-partial-failure downgrades to a warning + exit 0. Co-Authored-By: Claude Opus 4.8 (1M context) --- internal/commands/pro_backup.go | 7 ++++++- internal/commands/pro_backup_test.go | 6 +++++- 2 files changed, 11 insertions(+), 2 deletions(-) diff --git a/internal/commands/pro_backup.go b/internal/commands/pro_backup.go index 53ba1813..a78cfc43 100644 --- a/internal/commands/pro_backup.go +++ b/internal/commands/pro_backup.go @@ -316,7 +316,12 @@ func runBackup(ctx context.Context, cliCtx *registry.CLIContext, opts backupOpti fmt.Fprintln(os.Stderr) if len(failures) > 0 { - return fmt.Errorf("backup completed with %d failures (see _failures%s)", len(failures), ext) + if allowPartialFailure && totalExported > 0 { + fmt.Fprintf(os.Stderr, "warning: backup completed with %d failures; continuing (--allow-partial-failure)\n", len(failures)) + return nil + } + msg := fmt.Sprintf("backup completed with %d failures (see _failures%s)", len(failures), ext) + return exitcode.PartialOrPropagate(totalExported, len(failures), nil, msg) } return nil } diff --git a/internal/commands/pro_backup_test.go b/internal/commands/pro_backup_test.go index efa9d684..41840356 100644 --- a/internal/commands/pro_backup_test.go +++ b/internal/commands/pro_backup_test.go @@ -13,6 +13,7 @@ import ( "strings" "testing" + "github.com/Jamf-Concepts/jamf-cli/internal/exitcode" "github.com/Jamf-Concepts/jamf-cli/internal/registry" "gopkg.in/yaml.v3" ) @@ -177,11 +178,14 @@ func TestBackup_PartialFailure(t *testing.T) { Resources: "policies", Concurrency: 2, }) - // Should return error indicating failures occurred + // Partial failure: some exported, some failed -> exit code 7. if err == nil { t.Fatal("runBackup should return error when failures exist") return } + if got := exitcode.CodeFrom(err); got != exitcode.PartialFailure { + t.Fatalf("exit code = %d, want PartialFailure(%d)", got, exitcode.PartialFailure) + } // Good policy should be exported if _, err := os.Stat(filepath.Join(outDir, "policies", "good.yaml")); os.IsNotExist(err) { From f92f918fd675d8734d7726ba6aac29615b15c4d3 Mon Sep 17 00:00:00 2001 From: Keaton Svoma Date: Sun, 31 May 2026 01:57:27 -0500 Subject: [PATCH 08/15] feat(generated): bulk-delete continues on error and exits 7 on partial failure Both modern and classic generated bulk-delete loops (--from-file and --group) now continue past per-item failures, tally results, and return exit code 7 via a package-local batchDeleteError helper when some succeed and some fail. Previously they aborted at the first failure. --allow-partial-failure downgrades to a warning + exit 0. Co-Authored-By: Claude Opus 4.8 (1M context) --- generator/classic/generator.go | 36 ++++++++++-- generator/parser/generator.go | 56 +++++++++++++++++-- .../commands/pro/generated/adcs_settings.go | 19 ++++++- .../advanced_mobile_device_searches.go | 19 ++++++- .../advanced_user_content_searches.go | 19 ++++++- .../pro/generated/api_integrations.go | 19 ++++++- internal/commands/pro/generated/api_roles.go | 19 ++++++- .../generated/app_installer_deployments.go | 19 ++++++- .../commands/pro/generated/app_requests.go | 19 ++++++- internal/commands/pro/generated/buildings.go | 19 ++++++- .../pro/generated/bulk_delete_partial_test.go | 40 +++++++++++++ internal/commands/pro/generated/categories.go | 19 ++++++- .../classic_advanced_computer_searches.go | 18 +++++- ...classic_advanced_mobile_device_searches.go | 18 +++++- .../commands/pro/generated/classic_classes.go | 18 +++++- .../pro/generated/classic_computer_configs.go | 18 +++++- .../generated/classic_computer_ext_attrs.go | 18 +++++- .../pro/generated/classic_computer_groups.go | 18 +++++- .../generated/classic_computer_invitations.go | 18 +++++- .../generated/classic_directory_bindings.go | 18 +++++- .../classic_disk_encryption_configs.go | 18 +++++- .../generated/classic_distribution_points.go | 18 +++++- .../pro/generated/classic_dock_items.go | 18 +++++- .../commands/pro/generated/classic_ebooks.go | 18 +++++- .../pro/generated/classic_ibeacons.go | 18 +++++- .../generated/classic_licensed_software.go | 18 +++++- .../pro/generated/classic_mac_apps.go | 18 +++++- .../classic_macos_config_profiles.go | 18 +++++- .../pro/generated/classic_mobile_apps.go | 18 +++++- .../classic_mobile_config_profiles.go | 18 +++++- .../generated/classic_mobile_device_groups.go | 18 +++++- .../pro/generated/classic_mobile_devices.go | 36 ++++++++++-- .../classic_mobile_provisioning_profiles.go | 18 +++++- .../pro/generated/classic_network_segments.go | 18 +++++- .../pro/generated/classic_packages.go | 18 +++++- .../classic_patch_external_sources.go | 18 +++++- .../pro/generated/classic_patch_titles.go | 18 +++++- .../pro/generated/classic_policies.go | 18 +++++- .../pro/generated/classic_printers.go | 18 +++++- .../classic_removable_mac_addresses.go | 18 +++++- .../generated/classic_restricted_software.go | 18 +++++- .../classic_software_update_servers.go | 18 +++++- .../pro/generated/classic_user_ext_attrs.go | 18 +++++- .../pro/generated/classic_user_groups.go | 18 +++++- .../pro/generated/classic_webhooks.go | 18 +++++- .../commands/pro/generated/cloud_azures.go | 19 ++++++- .../commands/pro/generated/cloud_ldaps.go | 19 ++++++- .../computer_extension_attributes.go | 19 ++++++- .../generated/computer_groups_smart_groups.go | 19 ++++++- .../computer_groups_static_groups.go | 19 ++++++- .../computer_inventory_collection_settings.go | 19 ++++++- .../pro/generated/computer_prestages.go | 19 ++++++- .../pro/generated/computers_inventory.go | 38 +++++++++++-- .../commands/pro/generated/departments.go | 19 ++++++- .../generated/device_enrollment_instances.go | 19 ++++++- .../pro/generated/digi_cert_settings.go | 19 ++++++- .../pro/generated/distribution_points.go | 19 ++++++- internal/commands/pro/generated/dock_items.go | 19 ++++++- .../generated/enrollment_customizations.go | 19 ++++++- .../pro/generated/enrollment_languages.go | 19 ++++++- .../pro/generated/enrollment_settings.go | 19 ++++++- internal/commands/pro/generated/groups.go | 19 ++++++- .../pro/generated/inventory_preloads.go | 19 ++++++- internal/commands/pro/generated/jcds.go | 19 ++++++- .../commands/pro/generated/log_flushings.go | 19 ++++++- .../commands/pro/generated/mdm_renewals.go | 19 ++++++- .../mobile_device_extension_attributes.go | 19 ++++++- .../pro/generated/mobile_device_groups.go | 19 ++++++- .../mobile_device_groups_smart_groups.go | 19 ++++++- .../mobile_device_groups_static_groups.go | 19 ++++++- .../pro/generated/mobile_device_prestages.go | 38 +++++++++++-- .../commands/pro/generated/notifications.go | 19 ++++++- internal/commands/pro/generated/packages.go | 19 ++++++- .../commands/pro/generated/patch_policies.go | 19 ++++++- .../patch_software_title_configurations.go | 19 ++++++- internal/commands/pro/generated/registry.go | 18 ++++++ .../return_to_service_configurations.go | 19 ++++++- internal/commands/pro/generated/scripts.go | 19 ++++++- .../self_service_branding_ios_resource.go | 19 ++++++- .../generated/self_service_branding_macos.go | 19 ++++++- .../pro/generated/static_computer_groups.go | 19 ++++++- .../pro/generated/supervision_identities.go | 19 ++++++- .../team_viewer_remote_administrations.go | 19 ++++++- .../commands/pro/generated/user_accounts.go | 19 ++++++- .../pro/generated/user_preferences.go | 19 ++++++- internal/commands/pro/generated/users.go | 19 ++++++- internal/commands/pro/generated/venafis.go | 19 ++++++- .../commands/pro/generated/vpp_locations.go | 19 ++++++- .../pro/generated/vpp_subscriptions.go | 19 ++++++- 89 files changed, 1512 insertions(+), 276 deletions(-) create mode 100644 internal/commands/pro/generated/bulk_delete_partial_test.go diff --git a/generator/classic/generator.go b/generator/classic/generator.go index b1c4c018..c759a59a 100644 --- a/generator/classic/generator.go +++ b/generator/classic/generator.go @@ -901,21 +901,33 @@ func new{{ .GoName }}DeleteCmd(ctx *registry.CLIContext) *cobra.Command { if err := cooldown.Enforce(ctx.ProfileName, noInputBulk, ctx.DestructiveCooldown); err != nil { return err } + var okCount, failCount int + var firstErr error for _, e := range bulk { delPath := fmt.Sprintf("/JSSResource/{{ .Path }}/{{ idPath . }}/%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) + fmt.Fprintf(os.Stderr, "delete {{ .Singular }} %q (id: %s) failed: %v\n", e.label, e.id, err) + if firstErr == nil { + firstErr = err + } + failCount++ + continue } resp.Body.Close() if resp.StatusCode == http.StatusNoContent || resp.StatusCode == http.StatusOK { fmt.Fprintf(os.Stderr, "Deleted {{ .Singular }} %q (id: %s)\n", e.label, e.id) + okCount++ } else { - return fmt.Errorf("delete %q (id: %s): HTTP %d", e.label, e.id, resp.StatusCode) + fmt.Fprintf(os.Stderr, "delete {{ .Singular }} %q (id: %s) failed: HTTP %d\n", e.label, e.id, resp.StatusCode) + if firstErr == nil { + firstErr = fmt.Errorf("HTTP %d", resp.StatusCode) + } + failCount++ } } cooldown.Record(ctx.ProfileName) - return nil + return batchDeleteError(cmd, okCount, failCount, firstErr, "{{ .Name }} deletes") } {{ if .GroupPath }} // --group: delete all members of the named mobile device group @@ -953,21 +965,33 @@ func new{{ .GoName }}DeleteCmd(ctx *registry.CLIContext) *cobra.Command { if err := cooldown.Enforce(ctx.ProfileName, noInputGroup, ctx.DestructiveCooldown); err != nil { return err } + var okCount, failCount int + var firstErr error for _, e := range bulk { delPath := fmt.Sprintf("/JSSResource/{{ .Path }}/{{ idPath . }}/%s", url.PathEscape(e.id)) resp, err := ctx.Client.Do(reqCtx, "DELETE", delPath, nil) if err != nil { - return fmt.Errorf("deleting id %s: %w", e.id, err) + fmt.Fprintf(os.Stderr, "delete {{ .Singular }} id %s failed: %v\n", e.id, err) + if firstErr == nil { + firstErr = err + } + failCount++ + continue } resp.Body.Close() if resp.StatusCode == http.StatusNoContent || resp.StatusCode == http.StatusOK { fmt.Fprintf(os.Stderr, "Deleted {{ .Singular }} id: %s\n", e.id) + okCount++ } else { - return fmt.Errorf("delete id %s: HTTP %d", e.id, resp.StatusCode) + fmt.Fprintf(os.Stderr, "delete {{ .Singular }} id %s failed: HTTP %d\n", e.id, resp.StatusCode) + if firstErr == nil { + firstErr = fmt.Errorf("HTTP %d", resp.StatusCode) + } + failCount++ } } cooldown.Record(ctx.ProfileName) - return nil + return batchDeleteError(cmd, okCount, failCount, firstErr, "{{ .Name }} deletes") } {{ end }} // Resolve ID from --name or positional arg diff --git a/generator/parser/generator.go b/generator/parser/generator.go index 4fb48e95..22725ef0 100644 --- a/generator/parser/generator.go +++ b/generator/parser/generator.go @@ -1545,20 +1545,33 @@ func new{{ $.GoName }}{{ toCamel .Name }}Cmd(ctx *registry.CLIContext) *cobra.Co if err := cooldown.Enforce(ctx.ProfileName, noInputBulk, ctx.DestructiveCooldown); err != nil { return err } + var okCount, failCount int + var firstErr error for _, e := range bulk { delPath := strings.Replace("{{ .Path }}", "{{ pathParamName . }}", url.PathEscape(e.id), 1) 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) + fmt.Fprintf(os.Stderr, "delete {{ $.NameSingular }} %q (id: %s) failed: %v\n", e.label, e.id, err) + if firstErr == nil { + firstErr = err + } + failCount++ + continue } resp.Body.Close() if resp.StatusCode < 200 || resp.StatusCode >= 300 { - return fmt.Errorf("delete %q (id: %s): HTTP %d", e.label, e.id, resp.StatusCode) + fmt.Fprintf(os.Stderr, "delete {{ $.NameSingular }} %q (id: %s) failed: HTTP %d\n", e.label, e.id, resp.StatusCode) + if firstErr == nil { + firstErr = fmt.Errorf("HTTP %d", resp.StatusCode) + } + failCount++ + continue } fmt.Fprintf(os.Stderr, "Deleted {{ $.NameSingular }} %q (id: %s)\n", e.label, e.id) + okCount++ } cooldown.Record(ctx.ProfileName) - return nil + return batchDeleteError(cmd, okCount, failCount, firstErr, "{{ $.Name }} deletes") } {{- end }} {{- if and .IsDestructive $.GroupsClassicPath }} @@ -1598,20 +1611,33 @@ func new{{ $.GoName }}{{ toCamel .Name }}Cmd(ctx *registry.CLIContext) *cobra.Co if err := cooldown.Enforce(ctx.ProfileName, noInputGrp, ctx.DestructiveCooldown); err != nil { return err } + var okCount, failCount int + var firstErr error for _, e := range bulk { delPath := strings.Replace("{{ .Path }}", "{{ pathParamName . }}", url.PathEscape(e.id), 1) resp, err := ctx.Client.Do(reqCtx, "DELETE", delPath, nil) if err != nil { - return fmt.Errorf("deleting id %s: %w", e.id, err) + fmt.Fprintf(os.Stderr, "delete {{ $.NameSingular }} id %s failed: %v\n", e.id, err) + if firstErr == nil { + firstErr = err + } + failCount++ + continue } resp.Body.Close() if resp.StatusCode < 200 || resp.StatusCode >= 300 { - return fmt.Errorf("delete id %s: HTTP %d", e.id, resp.StatusCode) + fmt.Fprintf(os.Stderr, "delete {{ $.NameSingular }} id %s failed: HTTP %d\n", e.id, resp.StatusCode) + if firstErr == nil { + firstErr = fmt.Errorf("HTTP %d", resp.StatusCode) + } + failCount++ + continue } fmt.Fprintf(os.Stderr, "Deleted {{ $.NameSingular }} id: %s\n", e.id) + okCount++ } cooldown.Record(ctx.ProfileName) - return nil + return batchDeleteError(cmd, okCount, failCount, firstErr, "{{ $.Name }} deletes") } {{- end }} {{- if and .IsDestructive (not (opHasNameLookup . $)) }} @@ -2567,6 +2593,24 @@ func RegisterCommands(root *cobra.Command, ctx *registry.CLIContext) { {{- end }} } +// batchDeleteError maps a bulk-delete tally to the process result. When some +// items succeeded and some failed it returns a PartialFailure (exit 7), unless +// --allow-partial-failure is set, in which case it warns and returns nil. A +// total failure propagates firstErr's exit code. +func batchDeleteError(cmd *cobra.Command, succeeded, failed int, firstErr error, noun string) error { + if failed == 0 { + return nil + } + allow, _ := cmd.Flags().GetBool("allow-partial-failure") + if succeeded > 0 && allow { + fmt.Fprintf(os.Stderr, "warning: %d of %d %s failed; continuing (--allow-partial-failure)\n", + failed, succeeded+failed, noun) + return nil + } + return exitcode.PartialOrPropagate(succeeded, failed, firstErr, + fmt.Sprintf("%d of %d %s failed", failed, succeeded+failed, noun)) +} + // resolveNameToID looks up a resource by name using a filtered list call and // returns its ID. This enables --name as an alternative to positional ID args // on get commands. The nameField parameter specifies the filter field diff --git a/internal/commands/pro/generated/adcs_settings.go b/internal/commands/pro/generated/adcs_settings.go index 10d05473..e6473ab3 100644 --- a/internal/commands/pro/generated/adcs_settings.go +++ b/internal/commands/pro/generated/adcs_settings.go @@ -265,20 +265,33 @@ func newAdcsSettingsDeleteCmd(ctx *registry.CLIContext) *cobra.Command { if err := cooldown.Enforce(ctx.ProfileName, noInputBulk, ctx.DestructiveCooldown); err != nil { return err } + var okCount, failCount int + var firstErr error for _, e := range bulk { delPath := strings.Replace("/v1/pki/adcs-settings/{id}", "{id}", url.PathEscape(e.id), 1) 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) + fmt.Fprintf(os.Stderr, "delete adcs-setting %q (id: %s) failed: %v\n", e.label, e.id, err) + if firstErr == nil { + firstErr = err + } + failCount++ + continue } resp.Body.Close() if resp.StatusCode < 200 || resp.StatusCode >= 300 { - return fmt.Errorf("delete %q (id: %s): HTTP %d", e.label, e.id, resp.StatusCode) + fmt.Fprintf(os.Stderr, "delete adcs-setting %q (id: %s) failed: HTTP %d\n", e.label, e.id, resp.StatusCode) + if firstErr == nil { + firstErr = fmt.Errorf("HTTP %d", resp.StatusCode) + } + failCount++ + continue } fmt.Fprintf(os.Stderr, "Deleted adcs-setting %q (id: %s)\n", e.label, e.id) + okCount++ } cooldown.Record(ctx.ProfileName) - return nil + return batchDeleteError(cmd, okCount, failCount, firstErr, "adcs-settings deletes") } // Resolve resource ID from positional arg, --name, or lookup flags diff --git a/internal/commands/pro/generated/advanced_mobile_device_searches.go b/internal/commands/pro/generated/advanced_mobile_device_searches.go index d65cec18..64ec2d34 100644 --- a/internal/commands/pro/generated/advanced_mobile_device_searches.go +++ b/internal/commands/pro/generated/advanced_mobile_device_searches.go @@ -391,20 +391,33 @@ func newAdvancedMobileDeviceSearchesDeleteCmd(ctx *registry.CLIContext) *cobra.C if err := cooldown.Enforce(ctx.ProfileName, noInputBulk, ctx.DestructiveCooldown); err != nil { return err } + var okCount, failCount int + var firstErr error for _, e := range bulk { delPath := strings.Replace("/v1/advanced-mobile-device-searches/{id}", "{id}", url.PathEscape(e.id), 1) 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) + fmt.Fprintf(os.Stderr, "delete advanced-mobile-device-searche %q (id: %s) failed: %v\n", e.label, e.id, err) + if firstErr == nil { + firstErr = err + } + failCount++ + continue } resp.Body.Close() if resp.StatusCode < 200 || resp.StatusCode >= 300 { - return fmt.Errorf("delete %q (id: %s): HTTP %d", e.label, e.id, resp.StatusCode) + fmt.Fprintf(os.Stderr, "delete advanced-mobile-device-searche %q (id: %s) failed: HTTP %d\n", e.label, e.id, resp.StatusCode) + if firstErr == nil { + firstErr = fmt.Errorf("HTTP %d", resp.StatusCode) + } + failCount++ + continue } fmt.Fprintf(os.Stderr, "Deleted advanced-mobile-device-searche %q (id: %s)\n", e.label, e.id) + okCount++ } cooldown.Record(ctx.ProfileName) - return nil + return batchDeleteError(cmd, okCount, failCount, firstErr, "advanced-mobile-device-searches deletes") } // Resolve resource ID from positional arg, --name, or lookup flags diff --git a/internal/commands/pro/generated/advanced_user_content_searches.go b/internal/commands/pro/generated/advanced_user_content_searches.go index b63f2f0a..42e703e1 100644 --- a/internal/commands/pro/generated/advanced_user_content_searches.go +++ b/internal/commands/pro/generated/advanced_user_content_searches.go @@ -388,20 +388,33 @@ func newAdvancedUserContentSearchesDeleteCmd(ctx *registry.CLIContext) *cobra.Co if err := cooldown.Enforce(ctx.ProfileName, noInputBulk, ctx.DestructiveCooldown); err != nil { return err } + var okCount, failCount int + var firstErr error for _, e := range bulk { delPath := strings.Replace("/v1/advanced-user-content-searches/{id}", "{id}", url.PathEscape(e.id), 1) 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) + fmt.Fprintf(os.Stderr, "delete advanced-user-content-searche %q (id: %s) failed: %v\n", e.label, e.id, err) + if firstErr == nil { + firstErr = err + } + failCount++ + continue } resp.Body.Close() if resp.StatusCode < 200 || resp.StatusCode >= 300 { - return fmt.Errorf("delete %q (id: %s): HTTP %d", e.label, e.id, resp.StatusCode) + fmt.Fprintf(os.Stderr, "delete advanced-user-content-searche %q (id: %s) failed: HTTP %d\n", e.label, e.id, resp.StatusCode) + if firstErr == nil { + firstErr = fmt.Errorf("HTTP %d", resp.StatusCode) + } + failCount++ + continue } fmt.Fprintf(os.Stderr, "Deleted advanced-user-content-searche %q (id: %s)\n", e.label, e.id) + okCount++ } cooldown.Record(ctx.ProfileName) - return nil + return batchDeleteError(cmd, okCount, failCount, firstErr, "advanced-user-content-searches deletes") } // Resolve resource ID from positional arg, --name, or lookup flags diff --git a/internal/commands/pro/generated/api_integrations.go b/internal/commands/pro/generated/api_integrations.go index 00fe4d31..1bf0b3e1 100644 --- a/internal/commands/pro/generated/api_integrations.go +++ b/internal/commands/pro/generated/api_integrations.go @@ -482,20 +482,33 @@ func newApiIntegrationsDeleteCmd(ctx *registry.CLIContext) *cobra.Command { if err := cooldown.Enforce(ctx.ProfileName, noInputBulk, ctx.DestructiveCooldown); err != nil { return err } + var okCount, failCount int + var firstErr error for _, e := range bulk { delPath := strings.Replace("/v1/api-integrations/{id}", "{id}", url.PathEscape(e.id), 1) 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) + fmt.Fprintf(os.Stderr, "delete api-integration %q (id: %s) failed: %v\n", e.label, e.id, err) + if firstErr == nil { + firstErr = err + } + failCount++ + continue } resp.Body.Close() if resp.StatusCode < 200 || resp.StatusCode >= 300 { - return fmt.Errorf("delete %q (id: %s): HTTP %d", e.label, e.id, resp.StatusCode) + fmt.Fprintf(os.Stderr, "delete api-integration %q (id: %s) failed: HTTP %d\n", e.label, e.id, resp.StatusCode) + if firstErr == nil { + firstErr = fmt.Errorf("HTTP %d", resp.StatusCode) + } + failCount++ + continue } fmt.Fprintf(os.Stderr, "Deleted api-integration %q (id: %s)\n", e.label, e.id) + okCount++ } cooldown.Record(ctx.ProfileName) - return nil + return batchDeleteError(cmd, okCount, failCount, firstErr, "api-integrations deletes") } // Resolve resource ID from positional arg, --name, or lookup flags diff --git a/internal/commands/pro/generated/api_roles.go b/internal/commands/pro/generated/api_roles.go index f95f51d6..80848301 100644 --- a/internal/commands/pro/generated/api_roles.go +++ b/internal/commands/pro/generated/api_roles.go @@ -471,20 +471,33 @@ func newApiRolesDeleteCmd(ctx *registry.CLIContext) *cobra.Command { if err := cooldown.Enforce(ctx.ProfileName, noInputBulk, ctx.DestructiveCooldown); err != nil { return err } + var okCount, failCount int + var firstErr error for _, e := range bulk { delPath := strings.Replace("/v1/api-roles/{id}", "{id}", url.PathEscape(e.id), 1) 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) + fmt.Fprintf(os.Stderr, "delete api-role %q (id: %s) failed: %v\n", e.label, e.id, err) + if firstErr == nil { + firstErr = err + } + failCount++ + continue } resp.Body.Close() if resp.StatusCode < 200 || resp.StatusCode >= 300 { - return fmt.Errorf("delete %q (id: %s): HTTP %d", e.label, e.id, resp.StatusCode) + fmt.Fprintf(os.Stderr, "delete api-role %q (id: %s) failed: HTTP %d\n", e.label, e.id, resp.StatusCode) + if firstErr == nil { + firstErr = fmt.Errorf("HTTP %d", resp.StatusCode) + } + failCount++ + continue } fmt.Fprintf(os.Stderr, "Deleted api-role %q (id: %s)\n", e.label, e.id) + okCount++ } cooldown.Record(ctx.ProfileName) - return nil + return batchDeleteError(cmd, okCount, failCount, firstErr, "api-roles deletes") } // Resolve resource ID from positional arg, --name, or lookup flags diff --git a/internal/commands/pro/generated/app_installer_deployments.go b/internal/commands/pro/generated/app_installer_deployments.go index d91d2b11..bb7be8ab 100644 --- a/internal/commands/pro/generated/app_installer_deployments.go +++ b/internal/commands/pro/generated/app_installer_deployments.go @@ -399,20 +399,33 @@ func newAppInstallerDeploymentsDeleteCmd(ctx *registry.CLIContext) *cobra.Comman if err := cooldown.Enforce(ctx.ProfileName, noInputBulk, ctx.DestructiveCooldown); err != nil { return err } + var okCount, failCount int + var firstErr error for _, e := range bulk { delPath := strings.Replace("/v1/app-installers/deployments/{id}", "{id}", url.PathEscape(e.id), 1) 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) + fmt.Fprintf(os.Stderr, "delete app-installer-deployment %q (id: %s) failed: %v\n", e.label, e.id, err) + if firstErr == nil { + firstErr = err + } + failCount++ + continue } resp.Body.Close() if resp.StatusCode < 200 || resp.StatusCode >= 300 { - return fmt.Errorf("delete %q (id: %s): HTTP %d", e.label, e.id, resp.StatusCode) + fmt.Fprintf(os.Stderr, "delete app-installer-deployment %q (id: %s) failed: HTTP %d\n", e.label, e.id, resp.StatusCode) + if firstErr == nil { + firstErr = fmt.Errorf("HTTP %d", resp.StatusCode) + } + failCount++ + continue } fmt.Fprintf(os.Stderr, "Deleted app-installer-deployment %q (id: %s)\n", e.label, e.id) + okCount++ } cooldown.Record(ctx.ProfileName) - return nil + return batchDeleteError(cmd, okCount, failCount, firstErr, "app-installer-deployments deletes") } // Resolve resource ID from positional arg, --name, or lookup flags diff --git a/internal/commands/pro/generated/app_requests.go b/internal/commands/pro/generated/app_requests.go index f6e76b11..5519b622 100644 --- a/internal/commands/pro/generated/app_requests.go +++ b/internal/commands/pro/generated/app_requests.go @@ -349,20 +349,33 @@ func newAppRequestsDeleteCmd(ctx *registry.CLIContext) *cobra.Command { if err := cooldown.Enforce(ctx.ProfileName, noInputBulk, ctx.DestructiveCooldown); err != nil { return err } + var okCount, failCount int + var firstErr error for _, e := range bulk { delPath := strings.Replace("/v1/app-request/form-input-fields/{id}", "{id}", url.PathEscape(e.id), 1) 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) + fmt.Fprintf(os.Stderr, "delete app-request %q (id: %s) failed: %v\n", e.label, e.id, err) + if firstErr == nil { + firstErr = err + } + failCount++ + continue } resp.Body.Close() if resp.StatusCode < 200 || resp.StatusCode >= 300 { - return fmt.Errorf("delete %q (id: %s): HTTP %d", e.label, e.id, resp.StatusCode) + fmt.Fprintf(os.Stderr, "delete app-request %q (id: %s) failed: HTTP %d\n", e.label, e.id, resp.StatusCode) + if firstErr == nil { + firstErr = fmt.Errorf("HTTP %d", resp.StatusCode) + } + failCount++ + continue } fmt.Fprintf(os.Stderr, "Deleted app-request %q (id: %s)\n", e.label, e.id) + okCount++ } cooldown.Record(ctx.ProfileName) - return nil + return batchDeleteError(cmd, okCount, failCount, firstErr, "app-requests deletes") } // Resolve resource ID from positional arg, --name, or lookup flags diff --git a/internal/commands/pro/generated/buildings.go b/internal/commands/pro/generated/buildings.go index 4c0382be..57c4d969 100644 --- a/internal/commands/pro/generated/buildings.go +++ b/internal/commands/pro/generated/buildings.go @@ -486,20 +486,33 @@ func newBuildingsDeleteCmd(ctx *registry.CLIContext) *cobra.Command { if err := cooldown.Enforce(ctx.ProfileName, noInputBulk, ctx.DestructiveCooldown); err != nil { return err } + var okCount, failCount int + var firstErr error for _, e := range bulk { delPath := strings.Replace("/v1/buildings/{id}", "{id}", url.PathEscape(e.id), 1) 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) + fmt.Fprintf(os.Stderr, "delete building %q (id: %s) failed: %v\n", e.label, e.id, err) + if firstErr == nil { + firstErr = err + } + failCount++ + continue } resp.Body.Close() if resp.StatusCode < 200 || resp.StatusCode >= 300 { - return fmt.Errorf("delete %q (id: %s): HTTP %d", e.label, e.id, resp.StatusCode) + fmt.Fprintf(os.Stderr, "delete building %q (id: %s) failed: HTTP %d\n", e.label, e.id, resp.StatusCode) + if firstErr == nil { + firstErr = fmt.Errorf("HTTP %d", resp.StatusCode) + } + failCount++ + continue } fmt.Fprintf(os.Stderr, "Deleted building %q (id: %s)\n", e.label, e.id) + okCount++ } cooldown.Record(ctx.ProfileName) - return nil + return batchDeleteError(cmd, okCount, failCount, firstErr, "buildings deletes") } // Resolve resource ID from positional arg, --name, or lookup flags diff --git a/internal/commands/pro/generated/bulk_delete_partial_test.go b/internal/commands/pro/generated/bulk_delete_partial_test.go new file mode 100644 index 00000000..1c964235 --- /dev/null +++ b/internal/commands/pro/generated/bulk_delete_partial_test.go @@ -0,0 +1,40 @@ +// Copyright 2026, Jamf Software LLC + +package generated + +import ( + "io" + "os" + "path/filepath" + "testing" + + "github.com/Jamf-Concepts/jamf-cli/internal/exitcode" + "github.com/Jamf-Concepts/jamf-cli/internal/registry" +) + +// A bulk delete where one ID succeeds and one fails must continue past the +// failure and return exit code 7 (partial failure), not abort at the first one. +func TestGeneratedBulkDelete_PartialFailure(t *testing.T) { + dir := t.TempDir() + listFile := filepath.Join(dir, "ids.txt") + if err := os.WriteFile(listFile, []byte("1\n2\n"), 0o600); err != nil { + t.Fatal(err) + } + + mock := &mockHTTPClient{responses: map[string]mockResponse{ + "/v1/buildings/1": {status: 204}, + "/v1/buildings/2": {status: 500, body: []byte("boom")}, + }} + cmd := newBuildingsDeleteCmd(®istry.CLIContext{Client: mock}) + cmd.SetArgs([]string{"--from-file", listFile, "--yes"}) + cmd.SetOut(io.Discard) + cmd.SetErr(io.Discard) + + err := cmd.Execute() + if err == nil { + t.Fatal("expected a partial-failure error, got nil") + } + if got := exitcode.CodeFrom(err); got != exitcode.PartialFailure { + t.Fatalf("exit code = %d, want PartialFailure(%d) (err=%v)", got, exitcode.PartialFailure, err) + } +} diff --git a/internal/commands/pro/generated/categories.go b/internal/commands/pro/generated/categories.go index c1470aad..cd8713a5 100644 --- a/internal/commands/pro/generated/categories.go +++ b/internal/commands/pro/generated/categories.go @@ -474,20 +474,33 @@ func newCategoriesDeleteCmd(ctx *registry.CLIContext) *cobra.Command { if err := cooldown.Enforce(ctx.ProfileName, noInputBulk, ctx.DestructiveCooldown); err != nil { return err } + var okCount, failCount int + var firstErr error for _, e := range bulk { delPath := strings.Replace("/v1/categories/{id}", "{id}", url.PathEscape(e.id), 1) 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) + fmt.Fprintf(os.Stderr, "delete category %q (id: %s) failed: %v\n", e.label, e.id, err) + if firstErr == nil { + firstErr = err + } + failCount++ + continue } resp.Body.Close() if resp.StatusCode < 200 || resp.StatusCode >= 300 { - return fmt.Errorf("delete %q (id: %s): HTTP %d", e.label, e.id, resp.StatusCode) + fmt.Fprintf(os.Stderr, "delete category %q (id: %s) failed: HTTP %d\n", e.label, e.id, resp.StatusCode) + if firstErr == nil { + firstErr = fmt.Errorf("HTTP %d", resp.StatusCode) + } + failCount++ + continue } fmt.Fprintf(os.Stderr, "Deleted category %q (id: %s)\n", e.label, e.id) + okCount++ } cooldown.Record(ctx.ProfileName) - return nil + return batchDeleteError(cmd, okCount, failCount, firstErr, "categories deletes") } // Resolve resource ID from positional arg, --name, or lookup flags diff --git a/internal/commands/pro/generated/classic_advanced_computer_searches.go b/internal/commands/pro/generated/classic_advanced_computer_searches.go index a938005a..2fe73922 100644 --- a/internal/commands/pro/generated/classic_advanced_computer_searches.go +++ b/internal/commands/pro/generated/classic_advanced_computer_searches.go @@ -320,21 +320,33 @@ func newClassicAdvancedComputerSearchesDeleteCmd(ctx *registry.CLIContext) *cobr if err := cooldown.Enforce(ctx.ProfileName, noInputBulk, ctx.DestructiveCooldown); err != nil { return err } + var okCount, failCount int + var firstErr error for _, e := range bulk { delPath := fmt.Sprintf("/JSSResource/advancedcomputersearches/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) + fmt.Fprintf(os.Stderr, "delete advanced_computer_search %q (id: %s) failed: %v\n", e.label, e.id, err) + if firstErr == nil { + firstErr = err + } + failCount++ + continue } resp.Body.Close() if resp.StatusCode == http.StatusNoContent || resp.StatusCode == http.StatusOK { fmt.Fprintf(os.Stderr, "Deleted advanced_computer_search %q (id: %s)\n", e.label, e.id) + okCount++ } else { - return fmt.Errorf("delete %q (id: %s): HTTP %d", e.label, e.id, resp.StatusCode) + fmt.Fprintf(os.Stderr, "delete advanced_computer_search %q (id: %s) failed: HTTP %d\n", e.label, e.id, resp.StatusCode) + if firstErr == nil { + firstErr = fmt.Errorf("HTTP %d", resp.StatusCode) + } + failCount++ } } cooldown.Record(ctx.ProfileName) - return nil + return batchDeleteError(cmd, okCount, failCount, firstErr, "advancedcomputersearches deletes") } // Resolve ID from --name or positional arg 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 e0da37a4..2a5d7d7d 100644 --- a/internal/commands/pro/generated/classic_advanced_mobile_device_searches.go +++ b/internal/commands/pro/generated/classic_advanced_mobile_device_searches.go @@ -320,21 +320,33 @@ func newClassicAdvancedMobileDeviceSearchesDeleteCmd(ctx *registry.CLIContext) * if err := cooldown.Enforce(ctx.ProfileName, noInputBulk, ctx.DestructiveCooldown); err != nil { return err } + var okCount, failCount int + var firstErr error for _, e := range bulk { delPath := fmt.Sprintf("/JSSResource/advancedmobiledevicesearches/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) + fmt.Fprintf(os.Stderr, "delete advanced_mobile_device_search %q (id: %s) failed: %v\n", e.label, e.id, err) + if firstErr == nil { + firstErr = err + } + failCount++ + continue } resp.Body.Close() if resp.StatusCode == http.StatusNoContent || resp.StatusCode == http.StatusOK { fmt.Fprintf(os.Stderr, "Deleted advanced_mobile_device_search %q (id: %s)\n", e.label, e.id) + okCount++ } else { - return fmt.Errorf("delete %q (id: %s): HTTP %d", e.label, e.id, resp.StatusCode) + fmt.Fprintf(os.Stderr, "delete advanced_mobile_device_search %q (id: %s) failed: HTTP %d\n", e.label, e.id, resp.StatusCode) + if firstErr == nil { + firstErr = fmt.Errorf("HTTP %d", resp.StatusCode) + } + failCount++ } } cooldown.Record(ctx.ProfileName) - return nil + return batchDeleteError(cmd, okCount, failCount, firstErr, "advancedmobiledevicesearches deletes") } // Resolve ID from --name or positional arg diff --git a/internal/commands/pro/generated/classic_classes.go b/internal/commands/pro/generated/classic_classes.go index 6555c16b..56b857cc 100644 --- a/internal/commands/pro/generated/classic_classes.go +++ b/internal/commands/pro/generated/classic_classes.go @@ -320,21 +320,33 @@ func newClassicClassesDeleteCmd(ctx *registry.CLIContext) *cobra.Command { if err := cooldown.Enforce(ctx.ProfileName, noInputBulk, ctx.DestructiveCooldown); err != nil { return err } + var okCount, failCount int + var firstErr error for _, e := range bulk { delPath := fmt.Sprintf("/JSSResource/classes/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) + fmt.Fprintf(os.Stderr, "delete class %q (id: %s) failed: %v\n", e.label, e.id, err) + if firstErr == nil { + firstErr = err + } + failCount++ + continue } resp.Body.Close() if resp.StatusCode == http.StatusNoContent || resp.StatusCode == http.StatusOK { fmt.Fprintf(os.Stderr, "Deleted class %q (id: %s)\n", e.label, e.id) + okCount++ } else { - return fmt.Errorf("delete %q (id: %s): HTTP %d", e.label, e.id, resp.StatusCode) + fmt.Fprintf(os.Stderr, "delete class %q (id: %s) failed: HTTP %d\n", e.label, e.id, resp.StatusCode) + if firstErr == nil { + firstErr = fmt.Errorf("HTTP %d", resp.StatusCode) + } + failCount++ } } cooldown.Record(ctx.ProfileName) - return nil + return batchDeleteError(cmd, okCount, failCount, firstErr, "classes deletes") } // Resolve ID from --name or positional arg diff --git a/internal/commands/pro/generated/classic_computer_configs.go b/internal/commands/pro/generated/classic_computer_configs.go index 6e7f1118..d2525b37 100644 --- a/internal/commands/pro/generated/classic_computer_configs.go +++ b/internal/commands/pro/generated/classic_computer_configs.go @@ -320,21 +320,33 @@ func newClassicComputerConfigsDeleteCmd(ctx *registry.CLIContext) *cobra.Command if err := cooldown.Enforce(ctx.ProfileName, noInputBulk, ctx.DestructiveCooldown); err != nil { return err } + var okCount, failCount int + var firstErr error for _, e := range bulk { delPath := fmt.Sprintf("/JSSResource/computerconfigurations/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) + fmt.Fprintf(os.Stderr, "delete computer_configuration %q (id: %s) failed: %v\n", e.label, e.id, err) + if firstErr == nil { + firstErr = err + } + failCount++ + continue } resp.Body.Close() if resp.StatusCode == http.StatusNoContent || resp.StatusCode == http.StatusOK { fmt.Fprintf(os.Stderr, "Deleted computer_configuration %q (id: %s)\n", e.label, e.id) + okCount++ } else { - return fmt.Errorf("delete %q (id: %s): HTTP %d", e.label, e.id, resp.StatusCode) + fmt.Fprintf(os.Stderr, "delete computer_configuration %q (id: %s) failed: HTTP %d\n", e.label, e.id, resp.StatusCode) + if firstErr == nil { + firstErr = fmt.Errorf("HTTP %d", resp.StatusCode) + } + failCount++ } } cooldown.Record(ctx.ProfileName) - return nil + return batchDeleteError(cmd, okCount, failCount, firstErr, "computerconfigurations deletes") } // Resolve ID from --name or positional arg diff --git a/internal/commands/pro/generated/classic_computer_ext_attrs.go b/internal/commands/pro/generated/classic_computer_ext_attrs.go index d0ab2389..6d0dcbe3 100644 --- a/internal/commands/pro/generated/classic_computer_ext_attrs.go +++ b/internal/commands/pro/generated/classic_computer_ext_attrs.go @@ -320,21 +320,33 @@ func newClassicComputerExtAttrsDeleteCmd(ctx *registry.CLIContext) *cobra.Comman if err := cooldown.Enforce(ctx.ProfileName, noInputBulk, ctx.DestructiveCooldown); err != nil { return err } + var okCount, failCount int + var firstErr error for _, e := range bulk { delPath := fmt.Sprintf("/JSSResource/computerextensionattributes/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) + fmt.Fprintf(os.Stderr, "delete computer_extension_attribute %q (id: %s) failed: %v\n", e.label, e.id, err) + if firstErr == nil { + firstErr = err + } + failCount++ + continue } resp.Body.Close() if resp.StatusCode == http.StatusNoContent || resp.StatusCode == http.StatusOK { fmt.Fprintf(os.Stderr, "Deleted computer_extension_attribute %q (id: %s)\n", e.label, e.id) + okCount++ } else { - return fmt.Errorf("delete %q (id: %s): HTTP %d", e.label, e.id, resp.StatusCode) + fmt.Fprintf(os.Stderr, "delete computer_extension_attribute %q (id: %s) failed: HTTP %d\n", e.label, e.id, resp.StatusCode) + if firstErr == nil { + firstErr = fmt.Errorf("HTTP %d", resp.StatusCode) + } + failCount++ } } cooldown.Record(ctx.ProfileName) - return nil + return batchDeleteError(cmd, okCount, failCount, firstErr, "computerextensionattributes deletes") } // Resolve ID from --name or positional arg diff --git a/internal/commands/pro/generated/classic_computer_groups.go b/internal/commands/pro/generated/classic_computer_groups.go index c3a9a0bd..1fd6c5f2 100644 --- a/internal/commands/pro/generated/classic_computer_groups.go +++ b/internal/commands/pro/generated/classic_computer_groups.go @@ -320,21 +320,33 @@ func newClassicComputerGroupsDeleteCmd(ctx *registry.CLIContext) *cobra.Command if err := cooldown.Enforce(ctx.ProfileName, noInputBulk, ctx.DestructiveCooldown); err != nil { return err } + var okCount, failCount int + var firstErr error for _, e := range bulk { delPath := fmt.Sprintf("/JSSResource/computergroups/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) + fmt.Fprintf(os.Stderr, "delete computer_group %q (id: %s) failed: %v\n", e.label, e.id, err) + if firstErr == nil { + firstErr = err + } + failCount++ + continue } resp.Body.Close() if resp.StatusCode == http.StatusNoContent || resp.StatusCode == http.StatusOK { fmt.Fprintf(os.Stderr, "Deleted computer_group %q (id: %s)\n", e.label, e.id) + okCount++ } else { - return fmt.Errorf("delete %q (id: %s): HTTP %d", e.label, e.id, resp.StatusCode) + fmt.Fprintf(os.Stderr, "delete computer_group %q (id: %s) failed: HTTP %d\n", e.label, e.id, resp.StatusCode) + if firstErr == nil { + firstErr = fmt.Errorf("HTTP %d", resp.StatusCode) + } + failCount++ } } cooldown.Record(ctx.ProfileName) - return nil + return batchDeleteError(cmd, okCount, failCount, firstErr, "computergroups deletes") } // Resolve ID from --name or positional arg diff --git a/internal/commands/pro/generated/classic_computer_invitations.go b/internal/commands/pro/generated/classic_computer_invitations.go index 0e776e9b..cef1b181 100644 --- a/internal/commands/pro/generated/classic_computer_invitations.go +++ b/internal/commands/pro/generated/classic_computer_invitations.go @@ -281,21 +281,33 @@ func newClassicComputerInvitationsDeleteCmd(ctx *registry.CLIContext) *cobra.Com if err := cooldown.Enforce(ctx.ProfileName, noInputBulk, ctx.DestructiveCooldown); err != nil { return err } + var okCount, failCount int + var firstErr error for _, e := range bulk { delPath := fmt.Sprintf("/JSSResource/computerinvitations/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) + fmt.Fprintf(os.Stderr, "delete computer_invitation %q (id: %s) failed: %v\n", e.label, e.id, err) + if firstErr == nil { + firstErr = err + } + failCount++ + continue } resp.Body.Close() if resp.StatusCode == http.StatusNoContent || resp.StatusCode == http.StatusOK { fmt.Fprintf(os.Stderr, "Deleted computer_invitation %q (id: %s)\n", e.label, e.id) + okCount++ } else { - return fmt.Errorf("delete %q (id: %s): HTTP %d", e.label, e.id, resp.StatusCode) + fmt.Fprintf(os.Stderr, "delete computer_invitation %q (id: %s) failed: HTTP %d\n", e.label, e.id, resp.StatusCode) + if firstErr == nil { + firstErr = fmt.Errorf("HTTP %d", resp.StatusCode) + } + failCount++ } } cooldown.Record(ctx.ProfileName) - return nil + return batchDeleteError(cmd, okCount, failCount, firstErr, "computerinvitations deletes") } // Resolve ID from --name or positional arg diff --git a/internal/commands/pro/generated/classic_directory_bindings.go b/internal/commands/pro/generated/classic_directory_bindings.go index 0b194fb1..f4414b93 100644 --- a/internal/commands/pro/generated/classic_directory_bindings.go +++ b/internal/commands/pro/generated/classic_directory_bindings.go @@ -320,21 +320,33 @@ func newClassicDirectoryBindingsDeleteCmd(ctx *registry.CLIContext) *cobra.Comma if err := cooldown.Enforce(ctx.ProfileName, noInputBulk, ctx.DestructiveCooldown); err != nil { return err } + var okCount, failCount int + var firstErr error for _, e := range bulk { delPath := fmt.Sprintf("/JSSResource/directorybindings/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) + fmt.Fprintf(os.Stderr, "delete directory_binding %q (id: %s) failed: %v\n", e.label, e.id, err) + if firstErr == nil { + firstErr = err + } + failCount++ + continue } resp.Body.Close() if resp.StatusCode == http.StatusNoContent || resp.StatusCode == http.StatusOK { fmt.Fprintf(os.Stderr, "Deleted directory_binding %q (id: %s)\n", e.label, e.id) + okCount++ } else { - return fmt.Errorf("delete %q (id: %s): HTTP %d", e.label, e.id, resp.StatusCode) + fmt.Fprintf(os.Stderr, "delete directory_binding %q (id: %s) failed: HTTP %d\n", e.label, e.id, resp.StatusCode) + if firstErr == nil { + firstErr = fmt.Errorf("HTTP %d", resp.StatusCode) + } + failCount++ } } cooldown.Record(ctx.ProfileName) - return nil + return batchDeleteError(cmd, okCount, failCount, firstErr, "directorybindings deletes") } // Resolve ID from --name or positional arg diff --git a/internal/commands/pro/generated/classic_disk_encryption_configs.go b/internal/commands/pro/generated/classic_disk_encryption_configs.go index a14fe65b..76c9198a 100644 --- a/internal/commands/pro/generated/classic_disk_encryption_configs.go +++ b/internal/commands/pro/generated/classic_disk_encryption_configs.go @@ -320,21 +320,33 @@ func newClassicDiskEncryptionConfigsDeleteCmd(ctx *registry.CLIContext) *cobra.C if err := cooldown.Enforce(ctx.ProfileName, noInputBulk, ctx.DestructiveCooldown); err != nil { return err } + var okCount, failCount int + var firstErr error for _, e := range bulk { delPath := fmt.Sprintf("/JSSResource/diskencryptionconfigurations/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) + fmt.Fprintf(os.Stderr, "delete disk_encryption_configuration %q (id: %s) failed: %v\n", e.label, e.id, err) + if firstErr == nil { + firstErr = err + } + failCount++ + continue } resp.Body.Close() if resp.StatusCode == http.StatusNoContent || resp.StatusCode == http.StatusOK { fmt.Fprintf(os.Stderr, "Deleted disk_encryption_configuration %q (id: %s)\n", e.label, e.id) + okCount++ } else { - return fmt.Errorf("delete %q (id: %s): HTTP %d", e.label, e.id, resp.StatusCode) + fmt.Fprintf(os.Stderr, "delete disk_encryption_configuration %q (id: %s) failed: HTTP %d\n", e.label, e.id, resp.StatusCode) + if firstErr == nil { + firstErr = fmt.Errorf("HTTP %d", resp.StatusCode) + } + failCount++ } } cooldown.Record(ctx.ProfileName) - return nil + return batchDeleteError(cmd, okCount, failCount, firstErr, "diskencryptionconfigurations deletes") } // Resolve ID from --name or positional arg diff --git a/internal/commands/pro/generated/classic_distribution_points.go b/internal/commands/pro/generated/classic_distribution_points.go index a50f236d..dab473b4 100644 --- a/internal/commands/pro/generated/classic_distribution_points.go +++ b/internal/commands/pro/generated/classic_distribution_points.go @@ -320,21 +320,33 @@ func newClassicDistributionPointsDeleteCmd(ctx *registry.CLIContext) *cobra.Comm if err := cooldown.Enforce(ctx.ProfileName, noInputBulk, ctx.DestructiveCooldown); err != nil { return err } + var okCount, failCount int + var firstErr error for _, e := range bulk { delPath := fmt.Sprintf("/JSSResource/distributionpoints/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) + fmt.Fprintf(os.Stderr, "delete distribution_point %q (id: %s) failed: %v\n", e.label, e.id, err) + if firstErr == nil { + firstErr = err + } + failCount++ + continue } resp.Body.Close() if resp.StatusCode == http.StatusNoContent || resp.StatusCode == http.StatusOK { fmt.Fprintf(os.Stderr, "Deleted distribution_point %q (id: %s)\n", e.label, e.id) + okCount++ } else { - return fmt.Errorf("delete %q (id: %s): HTTP %d", e.label, e.id, resp.StatusCode) + fmt.Fprintf(os.Stderr, "delete distribution_point %q (id: %s) failed: HTTP %d\n", e.label, e.id, resp.StatusCode) + if firstErr == nil { + firstErr = fmt.Errorf("HTTP %d", resp.StatusCode) + } + failCount++ } } cooldown.Record(ctx.ProfileName) - return nil + return batchDeleteError(cmd, okCount, failCount, firstErr, "distributionpoints deletes") } // Resolve ID from --name or positional arg diff --git a/internal/commands/pro/generated/classic_dock_items.go b/internal/commands/pro/generated/classic_dock_items.go index 0a22a366..b09a6fce 100644 --- a/internal/commands/pro/generated/classic_dock_items.go +++ b/internal/commands/pro/generated/classic_dock_items.go @@ -320,21 +320,33 @@ func newClassicDockItemsDeleteCmd(ctx *registry.CLIContext) *cobra.Command { if err := cooldown.Enforce(ctx.ProfileName, noInputBulk, ctx.DestructiveCooldown); err != nil { return err } + var okCount, failCount int + var firstErr error for _, e := range bulk { delPath := fmt.Sprintf("/JSSResource/dockitems/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) + fmt.Fprintf(os.Stderr, "delete dock_item %q (id: %s) failed: %v\n", e.label, e.id, err) + if firstErr == nil { + firstErr = err + } + failCount++ + continue } resp.Body.Close() if resp.StatusCode == http.StatusNoContent || resp.StatusCode == http.StatusOK { fmt.Fprintf(os.Stderr, "Deleted dock_item %q (id: %s)\n", e.label, e.id) + okCount++ } else { - return fmt.Errorf("delete %q (id: %s): HTTP %d", e.label, e.id, resp.StatusCode) + fmt.Fprintf(os.Stderr, "delete dock_item %q (id: %s) failed: HTTP %d\n", e.label, e.id, resp.StatusCode) + if firstErr == nil { + firstErr = fmt.Errorf("HTTP %d", resp.StatusCode) + } + failCount++ } } cooldown.Record(ctx.ProfileName) - return nil + return batchDeleteError(cmd, okCount, failCount, firstErr, "dockitems deletes") } // Resolve ID from --name or positional arg diff --git a/internal/commands/pro/generated/classic_ebooks.go b/internal/commands/pro/generated/classic_ebooks.go index ff390164..2863a133 100644 --- a/internal/commands/pro/generated/classic_ebooks.go +++ b/internal/commands/pro/generated/classic_ebooks.go @@ -326,21 +326,33 @@ func newClassicEbooksDeleteCmd(ctx *registry.CLIContext) *cobra.Command { if err := cooldown.Enforce(ctx.ProfileName, noInputBulk, ctx.DestructiveCooldown); err != nil { return err } + var okCount, failCount int + var firstErr error 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) + fmt.Fprintf(os.Stderr, "delete ebook %q (id: %s) failed: %v\n", e.label, e.id, err) + if firstErr == nil { + firstErr = err + } + failCount++ + continue } 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) + okCount++ } else { - return fmt.Errorf("delete %q (id: %s): HTTP %d", e.label, e.id, resp.StatusCode) + fmt.Fprintf(os.Stderr, "delete ebook %q (id: %s) failed: HTTP %d\n", e.label, e.id, resp.StatusCode) + if firstErr == nil { + firstErr = fmt.Errorf("HTTP %d", resp.StatusCode) + } + failCount++ } } cooldown.Record(ctx.ProfileName) - return nil + return batchDeleteError(cmd, okCount, failCount, firstErr, "ebooks deletes") } // Resolve ID from --name or positional arg diff --git a/internal/commands/pro/generated/classic_ibeacons.go b/internal/commands/pro/generated/classic_ibeacons.go index 8b6dda6b..c7e29bb0 100644 --- a/internal/commands/pro/generated/classic_ibeacons.go +++ b/internal/commands/pro/generated/classic_ibeacons.go @@ -320,21 +320,33 @@ func newClassicIbeaconsDeleteCmd(ctx *registry.CLIContext) *cobra.Command { if err := cooldown.Enforce(ctx.ProfileName, noInputBulk, ctx.DestructiveCooldown); err != nil { return err } + var okCount, failCount int + var firstErr error for _, e := range bulk { delPath := fmt.Sprintf("/JSSResource/ibeacons/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) + fmt.Fprintf(os.Stderr, "delete ibeacon %q (id: %s) failed: %v\n", e.label, e.id, err) + if firstErr == nil { + firstErr = err + } + failCount++ + continue } resp.Body.Close() if resp.StatusCode == http.StatusNoContent || resp.StatusCode == http.StatusOK { fmt.Fprintf(os.Stderr, "Deleted ibeacon %q (id: %s)\n", e.label, e.id) + okCount++ } else { - return fmt.Errorf("delete %q (id: %s): HTTP %d", e.label, e.id, resp.StatusCode) + fmt.Fprintf(os.Stderr, "delete ibeacon %q (id: %s) failed: HTTP %d\n", e.label, e.id, resp.StatusCode) + if firstErr == nil { + firstErr = fmt.Errorf("HTTP %d", resp.StatusCode) + } + failCount++ } } cooldown.Record(ctx.ProfileName) - return nil + return batchDeleteError(cmd, okCount, failCount, firstErr, "ibeacons deletes") } // Resolve ID from --name or positional arg diff --git a/internal/commands/pro/generated/classic_licensed_software.go b/internal/commands/pro/generated/classic_licensed_software.go index 77eacece..aba365eb 100644 --- a/internal/commands/pro/generated/classic_licensed_software.go +++ b/internal/commands/pro/generated/classic_licensed_software.go @@ -320,21 +320,33 @@ func newClassicLicensedSoftwareDeleteCmd(ctx *registry.CLIContext) *cobra.Comman if err := cooldown.Enforce(ctx.ProfileName, noInputBulk, ctx.DestructiveCooldown); err != nil { return err } + var okCount, failCount int + var firstErr error for _, e := range bulk { delPath := fmt.Sprintf("/JSSResource/licensedsoftware/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) + fmt.Fprintf(os.Stderr, "delete licensed_software %q (id: %s) failed: %v\n", e.label, e.id, err) + if firstErr == nil { + firstErr = err + } + failCount++ + continue } resp.Body.Close() if resp.StatusCode == http.StatusNoContent || resp.StatusCode == http.StatusOK { fmt.Fprintf(os.Stderr, "Deleted licensed_software %q (id: %s)\n", e.label, e.id) + okCount++ } else { - return fmt.Errorf("delete %q (id: %s): HTTP %d", e.label, e.id, resp.StatusCode) + fmt.Fprintf(os.Stderr, "delete licensed_software %q (id: %s) failed: HTTP %d\n", e.label, e.id, resp.StatusCode) + if firstErr == nil { + firstErr = fmt.Errorf("HTTP %d", resp.StatusCode) + } + failCount++ } } cooldown.Record(ctx.ProfileName) - return nil + return batchDeleteError(cmd, okCount, failCount, firstErr, "licensedsoftware deletes") } // Resolve ID from --name or positional arg diff --git a/internal/commands/pro/generated/classic_mac_apps.go b/internal/commands/pro/generated/classic_mac_apps.go index 7e38e808..acdbd01b 100644 --- a/internal/commands/pro/generated/classic_mac_apps.go +++ b/internal/commands/pro/generated/classic_mac_apps.go @@ -395,21 +395,33 @@ func newClassicMacAppsDeleteCmd(ctx *registry.CLIContext) *cobra.Command { if err := cooldown.Enforce(ctx.ProfileName, noInputBulk, ctx.DestructiveCooldown); err != nil { return err } + var okCount, failCount int + var firstErr error for _, e := range bulk { delPath := fmt.Sprintf("/JSSResource/macapplications/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) + fmt.Fprintf(os.Stderr, "delete mac_application %q (id: %s) failed: %v\n", e.label, e.id, err) + if firstErr == nil { + firstErr = err + } + failCount++ + continue } resp.Body.Close() if resp.StatusCode == http.StatusNoContent || resp.StatusCode == http.StatusOK { fmt.Fprintf(os.Stderr, "Deleted mac_application %q (id: %s)\n", e.label, e.id) + okCount++ } else { - return fmt.Errorf("delete %q (id: %s): HTTP %d", e.label, e.id, resp.StatusCode) + fmt.Fprintf(os.Stderr, "delete mac_application %q (id: %s) failed: HTTP %d\n", e.label, e.id, resp.StatusCode) + if firstErr == nil { + firstErr = fmt.Errorf("HTTP %d", resp.StatusCode) + } + failCount++ } } cooldown.Record(ctx.ProfileName) - return nil + return batchDeleteError(cmd, okCount, failCount, firstErr, "macapplications deletes") } // Resolve ID from --name or positional arg diff --git a/internal/commands/pro/generated/classic_macos_config_profiles.go b/internal/commands/pro/generated/classic_macos_config_profiles.go index d79e6706..6a8139ee 100644 --- a/internal/commands/pro/generated/classic_macos_config_profiles.go +++ b/internal/commands/pro/generated/classic_macos_config_profiles.go @@ -423,21 +423,33 @@ func newClassicMacosConfigProfilesDeleteCmd(ctx *registry.CLIContext) *cobra.Com if err := cooldown.Enforce(ctx.ProfileName, noInputBulk, ctx.DestructiveCooldown); err != nil { return err } + var okCount, failCount int + var firstErr error for _, e := range bulk { delPath := fmt.Sprintf("/JSSResource/osxconfigurationprofiles/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) + fmt.Fprintf(os.Stderr, "delete os_x_configuration_profile %q (id: %s) failed: %v\n", e.label, e.id, err) + if firstErr == nil { + firstErr = err + } + failCount++ + continue } resp.Body.Close() if resp.StatusCode == http.StatusNoContent || resp.StatusCode == http.StatusOK { fmt.Fprintf(os.Stderr, "Deleted os_x_configuration_profile %q (id: %s)\n", e.label, e.id) + okCount++ } else { - return fmt.Errorf("delete %q (id: %s): HTTP %d", e.label, e.id, resp.StatusCode) + fmt.Fprintf(os.Stderr, "delete os_x_configuration_profile %q (id: %s) failed: HTTP %d\n", e.label, e.id, resp.StatusCode) + if firstErr == nil { + firstErr = fmt.Errorf("HTTP %d", resp.StatusCode) + } + failCount++ } } cooldown.Record(ctx.ProfileName) - return nil + return batchDeleteError(cmd, okCount, failCount, firstErr, "osxconfigurationprofiles deletes") } // Resolve ID from --name or positional arg diff --git a/internal/commands/pro/generated/classic_mobile_apps.go b/internal/commands/pro/generated/classic_mobile_apps.go index e0e99c32..632714a3 100644 --- a/internal/commands/pro/generated/classic_mobile_apps.go +++ b/internal/commands/pro/generated/classic_mobile_apps.go @@ -395,21 +395,33 @@ func newClassicMobileAppsDeleteCmd(ctx *registry.CLIContext) *cobra.Command { if err := cooldown.Enforce(ctx.ProfileName, noInputBulk, ctx.DestructiveCooldown); err != nil { return err } + var okCount, failCount int + var firstErr error for _, e := range bulk { delPath := fmt.Sprintf("/JSSResource/mobiledeviceapplications/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) + fmt.Fprintf(os.Stderr, "delete mobile_device_application %q (id: %s) failed: %v\n", e.label, e.id, err) + if firstErr == nil { + firstErr = err + } + failCount++ + continue } resp.Body.Close() if resp.StatusCode == http.StatusNoContent || resp.StatusCode == http.StatusOK { fmt.Fprintf(os.Stderr, "Deleted mobile_device_application %q (id: %s)\n", e.label, e.id) + okCount++ } else { - return fmt.Errorf("delete %q (id: %s): HTTP %d", e.label, e.id, resp.StatusCode) + fmt.Fprintf(os.Stderr, "delete mobile_device_application %q (id: %s) failed: HTTP %d\n", e.label, e.id, resp.StatusCode) + if firstErr == nil { + firstErr = fmt.Errorf("HTTP %d", resp.StatusCode) + } + failCount++ } } cooldown.Record(ctx.ProfileName) - return nil + return batchDeleteError(cmd, okCount, failCount, firstErr, "mobiledeviceapplications deletes") } // Resolve ID from --name or positional arg diff --git a/internal/commands/pro/generated/classic_mobile_config_profiles.go b/internal/commands/pro/generated/classic_mobile_config_profiles.go index 4ff9c693..1afe5265 100644 --- a/internal/commands/pro/generated/classic_mobile_config_profiles.go +++ b/internal/commands/pro/generated/classic_mobile_config_profiles.go @@ -377,21 +377,33 @@ func newClassicMobileConfigProfilesDeleteCmd(ctx *registry.CLIContext) *cobra.Co if err := cooldown.Enforce(ctx.ProfileName, noInputBulk, ctx.DestructiveCooldown); err != nil { return err } + var okCount, failCount int + var firstErr error for _, e := range bulk { delPath := fmt.Sprintf("/JSSResource/mobiledeviceconfigurationprofiles/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) + fmt.Fprintf(os.Stderr, "delete configuration_profile %q (id: %s) failed: %v\n", e.label, e.id, err) + if firstErr == nil { + firstErr = err + } + failCount++ + continue } resp.Body.Close() if resp.StatusCode == http.StatusNoContent || resp.StatusCode == http.StatusOK { fmt.Fprintf(os.Stderr, "Deleted configuration_profile %q (id: %s)\n", e.label, e.id) + okCount++ } else { - return fmt.Errorf("delete %q (id: %s): HTTP %d", e.label, e.id, resp.StatusCode) + fmt.Fprintf(os.Stderr, "delete configuration_profile %q (id: %s) failed: HTTP %d\n", e.label, e.id, resp.StatusCode) + if firstErr == nil { + firstErr = fmt.Errorf("HTTP %d", resp.StatusCode) + } + failCount++ } } cooldown.Record(ctx.ProfileName) - return nil + return batchDeleteError(cmd, okCount, failCount, firstErr, "mobiledeviceconfigurationprofiles deletes") } // Resolve ID from --name or positional arg diff --git a/internal/commands/pro/generated/classic_mobile_device_groups.go b/internal/commands/pro/generated/classic_mobile_device_groups.go index a58ffed1..7a1424cb 100644 --- a/internal/commands/pro/generated/classic_mobile_device_groups.go +++ b/internal/commands/pro/generated/classic_mobile_device_groups.go @@ -320,21 +320,33 @@ func newClassicMobileDeviceGroupsDeleteCmd(ctx *registry.CLIContext) *cobra.Comm if err := cooldown.Enforce(ctx.ProfileName, noInputBulk, ctx.DestructiveCooldown); err != nil { return err } + var okCount, failCount int + var firstErr error for _, e := range bulk { delPath := fmt.Sprintf("/JSSResource/mobiledevicegroups/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) + fmt.Fprintf(os.Stderr, "delete mobile_device_group %q (id: %s) failed: %v\n", e.label, e.id, err) + if firstErr == nil { + firstErr = err + } + failCount++ + continue } resp.Body.Close() if resp.StatusCode == http.StatusNoContent || resp.StatusCode == http.StatusOK { fmt.Fprintf(os.Stderr, "Deleted mobile_device_group %q (id: %s)\n", e.label, e.id) + okCount++ } else { - return fmt.Errorf("delete %q (id: %s): HTTP %d", e.label, e.id, resp.StatusCode) + fmt.Fprintf(os.Stderr, "delete mobile_device_group %q (id: %s) failed: HTTP %d\n", e.label, e.id, resp.StatusCode) + if firstErr == nil { + firstErr = fmt.Errorf("HTTP %d", resp.StatusCode) + } + failCount++ } } cooldown.Record(ctx.ProfileName) - return nil + return batchDeleteError(cmd, okCount, failCount, firstErr, "mobiledevicegroups deletes") } // Resolve ID from --name or positional arg diff --git a/internal/commands/pro/generated/classic_mobile_devices.go b/internal/commands/pro/generated/classic_mobile_devices.go index f146f2cb..4ba2b2a5 100644 --- a/internal/commands/pro/generated/classic_mobile_devices.go +++ b/internal/commands/pro/generated/classic_mobile_devices.go @@ -354,21 +354,33 @@ func newClassicMobileDevicesDeleteCmd(ctx *registry.CLIContext) *cobra.Command { if err := cooldown.Enforce(ctx.ProfileName, noInputBulk, ctx.DestructiveCooldown); err != nil { return err } + var okCount, failCount int + var firstErr error for _, e := range bulk { delPath := fmt.Sprintf("/JSSResource/mobiledevices/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) + fmt.Fprintf(os.Stderr, "delete mobile_device %q (id: %s) failed: %v\n", e.label, e.id, err) + if firstErr == nil { + firstErr = err + } + failCount++ + continue } resp.Body.Close() if resp.StatusCode == http.StatusNoContent || resp.StatusCode == http.StatusOK { fmt.Fprintf(os.Stderr, "Deleted mobile_device %q (id: %s)\n", e.label, e.id) + okCount++ } else { - return fmt.Errorf("delete %q (id: %s): HTTP %d", e.label, e.id, resp.StatusCode) + fmt.Fprintf(os.Stderr, "delete mobile_device %q (id: %s) failed: HTTP %d\n", e.label, e.id, resp.StatusCode) + if firstErr == nil { + firstErr = fmt.Errorf("HTTP %d", resp.StatusCode) + } + failCount++ } } cooldown.Record(ctx.ProfileName) - return nil + return batchDeleteError(cmd, okCount, failCount, firstErr, "mobiledevices deletes") } // --group: delete all members of the named mobile device group @@ -406,21 +418,33 @@ func newClassicMobileDevicesDeleteCmd(ctx *registry.CLIContext) *cobra.Command { if err := cooldown.Enforce(ctx.ProfileName, noInputGroup, ctx.DestructiveCooldown); err != nil { return err } + var okCount, failCount int + var firstErr error for _, e := range bulk { delPath := fmt.Sprintf("/JSSResource/mobiledevices/id/%s", url.PathEscape(e.id)) resp, err := ctx.Client.Do(reqCtx, "DELETE", delPath, nil) if err != nil { - return fmt.Errorf("deleting id %s: %w", e.id, err) + fmt.Fprintf(os.Stderr, "delete mobile_device id %s failed: %v\n", e.id, err) + if firstErr == nil { + firstErr = err + } + failCount++ + continue } resp.Body.Close() if resp.StatusCode == http.StatusNoContent || resp.StatusCode == http.StatusOK { fmt.Fprintf(os.Stderr, "Deleted mobile_device id: %s\n", e.id) + okCount++ } else { - return fmt.Errorf("delete id %s: HTTP %d", e.id, resp.StatusCode) + fmt.Fprintf(os.Stderr, "delete mobile_device id %s failed: HTTP %d\n", e.id, resp.StatusCode) + if firstErr == nil { + firstErr = fmt.Errorf("HTTP %d", resp.StatusCode) + } + failCount++ } } cooldown.Record(ctx.ProfileName) - return nil + return batchDeleteError(cmd, okCount, failCount, firstErr, "mobiledevices deletes") } // Resolve ID from --name or positional arg diff --git a/internal/commands/pro/generated/classic_mobile_provisioning_profiles.go b/internal/commands/pro/generated/classic_mobile_provisioning_profiles.go index 32df35d4..fb678562 100644 --- a/internal/commands/pro/generated/classic_mobile_provisioning_profiles.go +++ b/internal/commands/pro/generated/classic_mobile_provisioning_profiles.go @@ -320,21 +320,33 @@ func newClassicMobileProvisioningProfilesDeleteCmd(ctx *registry.CLIContext) *co if err := cooldown.Enforce(ctx.ProfileName, noInputBulk, ctx.DestructiveCooldown); err != nil { return err } + var okCount, failCount int + var firstErr error for _, e := range bulk { delPath := fmt.Sprintf("/JSSResource/mobiledeviceprovisioningprofiles/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) + fmt.Fprintf(os.Stderr, "delete mobile_device_provisioning_profile %q (id: %s) failed: %v\n", e.label, e.id, err) + if firstErr == nil { + firstErr = err + } + failCount++ + continue } resp.Body.Close() if resp.StatusCode == http.StatusNoContent || resp.StatusCode == http.StatusOK { fmt.Fprintf(os.Stderr, "Deleted mobile_device_provisioning_profile %q (id: %s)\n", e.label, e.id) + okCount++ } else { - return fmt.Errorf("delete %q (id: %s): HTTP %d", e.label, e.id, resp.StatusCode) + fmt.Fprintf(os.Stderr, "delete mobile_device_provisioning_profile %q (id: %s) failed: HTTP %d\n", e.label, e.id, resp.StatusCode) + if firstErr == nil { + firstErr = fmt.Errorf("HTTP %d", resp.StatusCode) + } + failCount++ } } cooldown.Record(ctx.ProfileName) - return nil + return batchDeleteError(cmd, okCount, failCount, firstErr, "mobiledeviceprovisioningprofiles deletes") } // Resolve ID from --name or positional arg diff --git a/internal/commands/pro/generated/classic_network_segments.go b/internal/commands/pro/generated/classic_network_segments.go index e2f8b4c7..1de46dac 100644 --- a/internal/commands/pro/generated/classic_network_segments.go +++ b/internal/commands/pro/generated/classic_network_segments.go @@ -320,21 +320,33 @@ func newClassicNetworkSegmentsDeleteCmd(ctx *registry.CLIContext) *cobra.Command if err := cooldown.Enforce(ctx.ProfileName, noInputBulk, ctx.DestructiveCooldown); err != nil { return err } + var okCount, failCount int + var firstErr error for _, e := range bulk { delPath := fmt.Sprintf("/JSSResource/networksegments/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) + fmt.Fprintf(os.Stderr, "delete network_segment %q (id: %s) failed: %v\n", e.label, e.id, err) + if firstErr == nil { + firstErr = err + } + failCount++ + continue } resp.Body.Close() if resp.StatusCode == http.StatusNoContent || resp.StatusCode == http.StatusOK { fmt.Fprintf(os.Stderr, "Deleted network_segment %q (id: %s)\n", e.label, e.id) + okCount++ } else { - return fmt.Errorf("delete %q (id: %s): HTTP %d", e.label, e.id, resp.StatusCode) + fmt.Fprintf(os.Stderr, "delete network_segment %q (id: %s) failed: HTTP %d\n", e.label, e.id, resp.StatusCode) + if firstErr == nil { + firstErr = fmt.Errorf("HTTP %d", resp.StatusCode) + } + failCount++ } } cooldown.Record(ctx.ProfileName) - return nil + return batchDeleteError(cmd, okCount, failCount, firstErr, "networksegments deletes") } // Resolve ID from --name or positional arg diff --git a/internal/commands/pro/generated/classic_packages.go b/internal/commands/pro/generated/classic_packages.go index 330bd0e5..532eb0ba 100644 --- a/internal/commands/pro/generated/classic_packages.go +++ b/internal/commands/pro/generated/classic_packages.go @@ -320,21 +320,33 @@ func newClassicPackagesDeleteCmd(ctx *registry.CLIContext) *cobra.Command { if err := cooldown.Enforce(ctx.ProfileName, noInputBulk, ctx.DestructiveCooldown); err != nil { return err } + var okCount, failCount int + var firstErr error for _, e := range bulk { delPath := fmt.Sprintf("/JSSResource/packages/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) + fmt.Fprintf(os.Stderr, "delete package %q (id: %s) failed: %v\n", e.label, e.id, err) + if firstErr == nil { + firstErr = err + } + failCount++ + continue } resp.Body.Close() if resp.StatusCode == http.StatusNoContent || resp.StatusCode == http.StatusOK { fmt.Fprintf(os.Stderr, "Deleted package %q (id: %s)\n", e.label, e.id) + okCount++ } else { - return fmt.Errorf("delete %q (id: %s): HTTP %d", e.label, e.id, resp.StatusCode) + fmt.Fprintf(os.Stderr, "delete package %q (id: %s) failed: HTTP %d\n", e.label, e.id, resp.StatusCode) + if firstErr == nil { + firstErr = fmt.Errorf("HTTP %d", resp.StatusCode) + } + failCount++ } } cooldown.Record(ctx.ProfileName) - return nil + return batchDeleteError(cmd, okCount, failCount, firstErr, "packages deletes") } // Resolve ID from --name or positional arg diff --git a/internal/commands/pro/generated/classic_patch_external_sources.go b/internal/commands/pro/generated/classic_patch_external_sources.go index a34e6f1d..19713337 100644 --- a/internal/commands/pro/generated/classic_patch_external_sources.go +++ b/internal/commands/pro/generated/classic_patch_external_sources.go @@ -320,21 +320,33 @@ func newClassicPatchExternalSourcesDeleteCmd(ctx *registry.CLIContext) *cobra.Co if err := cooldown.Enforce(ctx.ProfileName, noInputBulk, ctx.DestructiveCooldown); err != nil { return err } + var okCount, failCount int + var firstErr error for _, e := range bulk { delPath := fmt.Sprintf("/JSSResource/patchexternalsources/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) + fmt.Fprintf(os.Stderr, "delete patch_external_source %q (id: %s) failed: %v\n", e.label, e.id, err) + if firstErr == nil { + firstErr = err + } + failCount++ + continue } resp.Body.Close() if resp.StatusCode == http.StatusNoContent || resp.StatusCode == http.StatusOK { fmt.Fprintf(os.Stderr, "Deleted patch_external_source %q (id: %s)\n", e.label, e.id) + okCount++ } else { - return fmt.Errorf("delete %q (id: %s): HTTP %d", e.label, e.id, resp.StatusCode) + fmt.Fprintf(os.Stderr, "delete patch_external_source %q (id: %s) failed: HTTP %d\n", e.label, e.id, resp.StatusCode) + if firstErr == nil { + firstErr = fmt.Errorf("HTTP %d", resp.StatusCode) + } + failCount++ } } cooldown.Record(ctx.ProfileName) - return nil + return batchDeleteError(cmd, okCount, failCount, firstErr, "patchexternalsources deletes") } // Resolve ID from --name or positional arg diff --git a/internal/commands/pro/generated/classic_patch_titles.go b/internal/commands/pro/generated/classic_patch_titles.go index c4a8e593..fe9770af 100644 --- a/internal/commands/pro/generated/classic_patch_titles.go +++ b/internal/commands/pro/generated/classic_patch_titles.go @@ -320,21 +320,33 @@ func newClassicPatchTitlesDeleteCmd(ctx *registry.CLIContext) *cobra.Command { if err := cooldown.Enforce(ctx.ProfileName, noInputBulk, ctx.DestructiveCooldown); err != nil { return err } + var okCount, failCount int + var firstErr error for _, e := range bulk { delPath := fmt.Sprintf("/JSSResource/patchsoftwaretitles/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) + fmt.Fprintf(os.Stderr, "delete patch_software_title %q (id: %s) failed: %v\n", e.label, e.id, err) + if firstErr == nil { + firstErr = err + } + failCount++ + continue } resp.Body.Close() if resp.StatusCode == http.StatusNoContent || resp.StatusCode == http.StatusOK { fmt.Fprintf(os.Stderr, "Deleted patch_software_title %q (id: %s)\n", e.label, e.id) + okCount++ } else { - return fmt.Errorf("delete %q (id: %s): HTTP %d", e.label, e.id, resp.StatusCode) + fmt.Fprintf(os.Stderr, "delete patch_software_title %q (id: %s) failed: HTTP %d\n", e.label, e.id, resp.StatusCode) + if firstErr == nil { + firstErr = fmt.Errorf("HTTP %d", resp.StatusCode) + } + failCount++ } } cooldown.Record(ctx.ProfileName) - return nil + return batchDeleteError(cmd, okCount, failCount, firstErr, "patchsoftwaretitles deletes") } // Resolve ID from --name or positional arg diff --git a/internal/commands/pro/generated/classic_policies.go b/internal/commands/pro/generated/classic_policies.go index 7ad363b6..847b688e 100644 --- a/internal/commands/pro/generated/classic_policies.go +++ b/internal/commands/pro/generated/classic_policies.go @@ -326,21 +326,33 @@ func newClassicPoliciesDeleteCmd(ctx *registry.CLIContext) *cobra.Command { if err := cooldown.Enforce(ctx.ProfileName, noInputBulk, ctx.DestructiveCooldown); err != nil { return err } + var okCount, failCount int + var firstErr error for _, e := range bulk { delPath := fmt.Sprintf("/JSSResource/policies/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) + fmt.Fprintf(os.Stderr, "delete policy %q (id: %s) failed: %v\n", e.label, e.id, err) + if firstErr == nil { + firstErr = err + } + failCount++ + continue } resp.Body.Close() if resp.StatusCode == http.StatusNoContent || resp.StatusCode == http.StatusOK { fmt.Fprintf(os.Stderr, "Deleted policy %q (id: %s)\n", e.label, e.id) + okCount++ } else { - return fmt.Errorf("delete %q (id: %s): HTTP %d", e.label, e.id, resp.StatusCode) + fmt.Fprintf(os.Stderr, "delete policy %q (id: %s) failed: HTTP %d\n", e.label, e.id, resp.StatusCode) + if firstErr == nil { + firstErr = fmt.Errorf("HTTP %d", resp.StatusCode) + } + failCount++ } } cooldown.Record(ctx.ProfileName) - return nil + return batchDeleteError(cmd, okCount, failCount, firstErr, "policies deletes") } // Resolve ID from --name or positional arg diff --git a/internal/commands/pro/generated/classic_printers.go b/internal/commands/pro/generated/classic_printers.go index de60e156..734da0ea 100644 --- a/internal/commands/pro/generated/classic_printers.go +++ b/internal/commands/pro/generated/classic_printers.go @@ -320,21 +320,33 @@ func newClassicPrintersDeleteCmd(ctx *registry.CLIContext) *cobra.Command { if err := cooldown.Enforce(ctx.ProfileName, noInputBulk, ctx.DestructiveCooldown); err != nil { return err } + var okCount, failCount int + var firstErr error for _, e := range bulk { delPath := fmt.Sprintf("/JSSResource/printers/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) + fmt.Fprintf(os.Stderr, "delete printer %q (id: %s) failed: %v\n", e.label, e.id, err) + if firstErr == nil { + firstErr = err + } + failCount++ + continue } resp.Body.Close() if resp.StatusCode == http.StatusNoContent || resp.StatusCode == http.StatusOK { fmt.Fprintf(os.Stderr, "Deleted printer %q (id: %s)\n", e.label, e.id) + okCount++ } else { - return fmt.Errorf("delete %q (id: %s): HTTP %d", e.label, e.id, resp.StatusCode) + fmt.Fprintf(os.Stderr, "delete printer %q (id: %s) failed: HTTP %d\n", e.label, e.id, resp.StatusCode) + if firstErr == nil { + firstErr = fmt.Errorf("HTTP %d", resp.StatusCode) + } + failCount++ } } cooldown.Record(ctx.ProfileName) - return nil + return batchDeleteError(cmd, okCount, failCount, firstErr, "printers deletes") } // Resolve ID from --name or positional arg diff --git a/internal/commands/pro/generated/classic_removable_mac_addresses.go b/internal/commands/pro/generated/classic_removable_mac_addresses.go index 4d778c6c..a650f7ab 100644 --- a/internal/commands/pro/generated/classic_removable_mac_addresses.go +++ b/internal/commands/pro/generated/classic_removable_mac_addresses.go @@ -320,21 +320,33 @@ func newClassicRemovableMacAddressesDeleteCmd(ctx *registry.CLIContext) *cobra.C if err := cooldown.Enforce(ctx.ProfileName, noInputBulk, ctx.DestructiveCooldown); err != nil { return err } + var okCount, failCount int + var firstErr error for _, e := range bulk { delPath := fmt.Sprintf("/JSSResource/removablemacaddresses/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) + fmt.Fprintf(os.Stderr, "delete removable_mac_address %q (id: %s) failed: %v\n", e.label, e.id, err) + if firstErr == nil { + firstErr = err + } + failCount++ + continue } resp.Body.Close() if resp.StatusCode == http.StatusNoContent || resp.StatusCode == http.StatusOK { fmt.Fprintf(os.Stderr, "Deleted removable_mac_address %q (id: %s)\n", e.label, e.id) + okCount++ } else { - return fmt.Errorf("delete %q (id: %s): HTTP %d", e.label, e.id, resp.StatusCode) + fmt.Fprintf(os.Stderr, "delete removable_mac_address %q (id: %s) failed: HTTP %d\n", e.label, e.id, resp.StatusCode) + if firstErr == nil { + firstErr = fmt.Errorf("HTTP %d", resp.StatusCode) + } + failCount++ } } cooldown.Record(ctx.ProfileName) - return nil + return batchDeleteError(cmd, okCount, failCount, firstErr, "removablemacaddresses deletes") } // Resolve ID from --name or positional arg diff --git a/internal/commands/pro/generated/classic_restricted_software.go b/internal/commands/pro/generated/classic_restricted_software.go index ba5f4219..81b9a790 100644 --- a/internal/commands/pro/generated/classic_restricted_software.go +++ b/internal/commands/pro/generated/classic_restricted_software.go @@ -327,21 +327,33 @@ func newClassicRestrictedSoftwareDeleteCmd(ctx *registry.CLIContext) *cobra.Comm if err := cooldown.Enforce(ctx.ProfileName, noInputBulk, ctx.DestructiveCooldown); err != nil { return err } + var okCount, failCount int + var firstErr error for _, e := range bulk { delPath := fmt.Sprintf("/JSSResource/restrictedsoftware/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) + fmt.Fprintf(os.Stderr, "delete restricted_software %q (id: %s) failed: %v\n", e.label, e.id, err) + if firstErr == nil { + firstErr = err + } + failCount++ + continue } resp.Body.Close() if resp.StatusCode == http.StatusNoContent || resp.StatusCode == http.StatusOK { fmt.Fprintf(os.Stderr, "Deleted restricted_software %q (id: %s)\n", e.label, e.id) + okCount++ } else { - return fmt.Errorf("delete %q (id: %s): HTTP %d", e.label, e.id, resp.StatusCode) + fmt.Fprintf(os.Stderr, "delete restricted_software %q (id: %s) failed: HTTP %d\n", e.label, e.id, resp.StatusCode) + if firstErr == nil { + firstErr = fmt.Errorf("HTTP %d", resp.StatusCode) + } + failCount++ } } cooldown.Record(ctx.ProfileName) - return nil + return batchDeleteError(cmd, okCount, failCount, firstErr, "restrictedsoftware deletes") } // Resolve ID from --name or positional arg diff --git a/internal/commands/pro/generated/classic_software_update_servers.go b/internal/commands/pro/generated/classic_software_update_servers.go index 152f31a4..9945058d 100644 --- a/internal/commands/pro/generated/classic_software_update_servers.go +++ b/internal/commands/pro/generated/classic_software_update_servers.go @@ -320,21 +320,33 @@ func newClassicSoftwareUpdateServersDeleteCmd(ctx *registry.CLIContext) *cobra.C if err := cooldown.Enforce(ctx.ProfileName, noInputBulk, ctx.DestructiveCooldown); err != nil { return err } + var okCount, failCount int + var firstErr error for _, e := range bulk { delPath := fmt.Sprintf("/JSSResource/softwareupdateservers/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) + fmt.Fprintf(os.Stderr, "delete software_update_server %q (id: %s) failed: %v\n", e.label, e.id, err) + if firstErr == nil { + firstErr = err + } + failCount++ + continue } resp.Body.Close() if resp.StatusCode == http.StatusNoContent || resp.StatusCode == http.StatusOK { fmt.Fprintf(os.Stderr, "Deleted software_update_server %q (id: %s)\n", e.label, e.id) + okCount++ } else { - return fmt.Errorf("delete %q (id: %s): HTTP %d", e.label, e.id, resp.StatusCode) + fmt.Fprintf(os.Stderr, "delete software_update_server %q (id: %s) failed: HTTP %d\n", e.label, e.id, resp.StatusCode) + if firstErr == nil { + firstErr = fmt.Errorf("HTTP %d", resp.StatusCode) + } + failCount++ } } cooldown.Record(ctx.ProfileName) - return nil + return batchDeleteError(cmd, okCount, failCount, firstErr, "softwareupdateservers deletes") } // Resolve ID from --name or positional arg diff --git a/internal/commands/pro/generated/classic_user_ext_attrs.go b/internal/commands/pro/generated/classic_user_ext_attrs.go index bfe46a3c..5a82795a 100644 --- a/internal/commands/pro/generated/classic_user_ext_attrs.go +++ b/internal/commands/pro/generated/classic_user_ext_attrs.go @@ -320,21 +320,33 @@ func newClassicUserExtAttrsDeleteCmd(ctx *registry.CLIContext) *cobra.Command { if err := cooldown.Enforce(ctx.ProfileName, noInputBulk, ctx.DestructiveCooldown); err != nil { return err } + var okCount, failCount int + var firstErr error for _, e := range bulk { delPath := fmt.Sprintf("/JSSResource/userextensionattributes/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) + fmt.Fprintf(os.Stderr, "delete user_extension_attribute %q (id: %s) failed: %v\n", e.label, e.id, err) + if firstErr == nil { + firstErr = err + } + failCount++ + continue } resp.Body.Close() if resp.StatusCode == http.StatusNoContent || resp.StatusCode == http.StatusOK { fmt.Fprintf(os.Stderr, "Deleted user_extension_attribute %q (id: %s)\n", e.label, e.id) + okCount++ } else { - return fmt.Errorf("delete %q (id: %s): HTTP %d", e.label, e.id, resp.StatusCode) + fmt.Fprintf(os.Stderr, "delete user_extension_attribute %q (id: %s) failed: HTTP %d\n", e.label, e.id, resp.StatusCode) + if firstErr == nil { + firstErr = fmt.Errorf("HTTP %d", resp.StatusCode) + } + failCount++ } } cooldown.Record(ctx.ProfileName) - return nil + return batchDeleteError(cmd, okCount, failCount, firstErr, "userextensionattributes deletes") } // Resolve ID from --name or positional arg diff --git a/internal/commands/pro/generated/classic_user_groups.go b/internal/commands/pro/generated/classic_user_groups.go index 2ecc5b8e..1d24cc06 100644 --- a/internal/commands/pro/generated/classic_user_groups.go +++ b/internal/commands/pro/generated/classic_user_groups.go @@ -320,21 +320,33 @@ func newClassicUserGroupsDeleteCmd(ctx *registry.CLIContext) *cobra.Command { if err := cooldown.Enforce(ctx.ProfileName, noInputBulk, ctx.DestructiveCooldown); err != nil { return err } + var okCount, failCount int + var firstErr error 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) + fmt.Fprintf(os.Stderr, "delete user_group %q (id: %s) failed: %v\n", e.label, e.id, err) + if firstErr == nil { + firstErr = err + } + failCount++ + continue } 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) + okCount++ } else { - return fmt.Errorf("delete %q (id: %s): HTTP %d", e.label, e.id, resp.StatusCode) + fmt.Fprintf(os.Stderr, "delete user_group %q (id: %s) failed: HTTP %d\n", e.label, e.id, resp.StatusCode) + if firstErr == nil { + firstErr = fmt.Errorf("HTTP %d", resp.StatusCode) + } + failCount++ } } cooldown.Record(ctx.ProfileName) - return nil + return batchDeleteError(cmd, okCount, failCount, firstErr, "usergroups deletes") } // Resolve ID from --name or positional arg diff --git a/internal/commands/pro/generated/classic_webhooks.go b/internal/commands/pro/generated/classic_webhooks.go index 28160702..79ccef69 100644 --- a/internal/commands/pro/generated/classic_webhooks.go +++ b/internal/commands/pro/generated/classic_webhooks.go @@ -320,21 +320,33 @@ func newClassicWebhooksDeleteCmd(ctx *registry.CLIContext) *cobra.Command { if err := cooldown.Enforce(ctx.ProfileName, noInputBulk, ctx.DestructiveCooldown); err != nil { return err } + var okCount, failCount int + var firstErr error for _, e := range bulk { delPath := fmt.Sprintf("/JSSResource/webhooks/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) + fmt.Fprintf(os.Stderr, "delete webhook %q (id: %s) failed: %v\n", e.label, e.id, err) + if firstErr == nil { + firstErr = err + } + failCount++ + continue } resp.Body.Close() if resp.StatusCode == http.StatusNoContent || resp.StatusCode == http.StatusOK { fmt.Fprintf(os.Stderr, "Deleted webhook %q (id: %s)\n", e.label, e.id) + okCount++ } else { - return fmt.Errorf("delete %q (id: %s): HTTP %d", e.label, e.id, resp.StatusCode) + fmt.Fprintf(os.Stderr, "delete webhook %q (id: %s) failed: HTTP %d\n", e.label, e.id, resp.StatusCode) + if firstErr == nil { + firstErr = fmt.Errorf("HTTP %d", resp.StatusCode) + } + failCount++ } } cooldown.Record(ctx.ProfileName) - return nil + return batchDeleteError(cmd, okCount, failCount, firstErr, "webhooks deletes") } // Resolve ID from --name or positional arg diff --git a/internal/commands/pro/generated/cloud_azures.go b/internal/commands/pro/generated/cloud_azures.go index 36602649..6dd85cdc 100644 --- a/internal/commands/pro/generated/cloud_azures.go +++ b/internal/commands/pro/generated/cloud_azures.go @@ -339,20 +339,33 @@ func newCloudAzuresDeleteCmd(ctx *registry.CLIContext) *cobra.Command { if err := cooldown.Enforce(ctx.ProfileName, noInputBulk, ctx.DestructiveCooldown); err != nil { return err } + var okCount, failCount int + var firstErr error for _, e := range bulk { delPath := strings.Replace("/v1/cloud-azure/{id}", "{id}", url.PathEscape(e.id), 1) 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) + fmt.Fprintf(os.Stderr, "delete cloud-azure %q (id: %s) failed: %v\n", e.label, e.id, err) + if firstErr == nil { + firstErr = err + } + failCount++ + continue } resp.Body.Close() if resp.StatusCode < 200 || resp.StatusCode >= 300 { - return fmt.Errorf("delete %q (id: %s): HTTP %d", e.label, e.id, resp.StatusCode) + fmt.Fprintf(os.Stderr, "delete cloud-azure %q (id: %s) failed: HTTP %d\n", e.label, e.id, resp.StatusCode) + if firstErr == nil { + firstErr = fmt.Errorf("HTTP %d", resp.StatusCode) + } + failCount++ + continue } fmt.Fprintf(os.Stderr, "Deleted cloud-azure %q (id: %s)\n", e.label, e.id) + okCount++ } cooldown.Record(ctx.ProfileName) - return nil + return batchDeleteError(cmd, okCount, failCount, firstErr, "cloud-azures deletes") } // Resolve resource ID from positional arg, --name, or lookup flags diff --git a/internal/commands/pro/generated/cloud_ldaps.go b/internal/commands/pro/generated/cloud_ldaps.go index b0c9a97b..7e171247 100644 --- a/internal/commands/pro/generated/cloud_ldaps.go +++ b/internal/commands/pro/generated/cloud_ldaps.go @@ -341,20 +341,33 @@ func newCloudLdapsDeleteCmd(ctx *registry.CLIContext) *cobra.Command { if err := cooldown.Enforce(ctx.ProfileName, noInputBulk, ctx.DestructiveCooldown); err != nil { return err } + var okCount, failCount int + var firstErr error for _, e := range bulk { delPath := strings.Replace("/v2/cloud-ldaps/{id}", "{id}", url.PathEscape(e.id), 1) 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) + fmt.Fprintf(os.Stderr, "delete cloud-ldap %q (id: %s) failed: %v\n", e.label, e.id, err) + if firstErr == nil { + firstErr = err + } + failCount++ + continue } resp.Body.Close() if resp.StatusCode < 200 || resp.StatusCode >= 300 { - return fmt.Errorf("delete %q (id: %s): HTTP %d", e.label, e.id, resp.StatusCode) + fmt.Fprintf(os.Stderr, "delete cloud-ldap %q (id: %s) failed: HTTP %d\n", e.label, e.id, resp.StatusCode) + if firstErr == nil { + firstErr = fmt.Errorf("HTTP %d", resp.StatusCode) + } + failCount++ + continue } fmt.Fprintf(os.Stderr, "Deleted cloud-ldap %q (id: %s)\n", e.label, e.id) + okCount++ } cooldown.Record(ctx.ProfileName) - return nil + return batchDeleteError(cmd, okCount, failCount, firstErr, "cloud-ldaps deletes") } // Resolve resource ID from positional arg, --name, or lookup flags diff --git a/internal/commands/pro/generated/computer_extension_attributes.go b/internal/commands/pro/generated/computer_extension_attributes.go index de76b3f4..d94f1229 100644 --- a/internal/commands/pro/generated/computer_extension_attributes.go +++ b/internal/commands/pro/generated/computer_extension_attributes.go @@ -529,20 +529,33 @@ func newComputerExtensionAttributesDeleteCmd(ctx *registry.CLIContext) *cobra.Co if err := cooldown.Enforce(ctx.ProfileName, noInputBulk, ctx.DestructiveCooldown); err != nil { return err } + var okCount, failCount int + var firstErr error for _, e := range bulk { delPath := strings.Replace("/v1/computer-extension-attributes/{id}", "{id}", url.PathEscape(e.id), 1) 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) + fmt.Fprintf(os.Stderr, "delete computer-extension-attribute %q (id: %s) failed: %v\n", e.label, e.id, err) + if firstErr == nil { + firstErr = err + } + failCount++ + continue } resp.Body.Close() if resp.StatusCode < 200 || resp.StatusCode >= 300 { - return fmt.Errorf("delete %q (id: %s): HTTP %d", e.label, e.id, resp.StatusCode) + fmt.Fprintf(os.Stderr, "delete computer-extension-attribute %q (id: %s) failed: HTTP %d\n", e.label, e.id, resp.StatusCode) + if firstErr == nil { + firstErr = fmt.Errorf("HTTP %d", resp.StatusCode) + } + failCount++ + continue } fmt.Fprintf(os.Stderr, "Deleted computer-extension-attribute %q (id: %s)\n", e.label, e.id) + okCount++ } cooldown.Record(ctx.ProfileName) - return nil + return batchDeleteError(cmd, okCount, failCount, firstErr, "computer-extension-attributes deletes") } // Resolve resource ID from positional arg, --name, or lookup flags diff --git a/internal/commands/pro/generated/computer_groups_smart_groups.go b/internal/commands/pro/generated/computer_groups_smart_groups.go index da59b196..0749bae0 100644 --- a/internal/commands/pro/generated/computer_groups_smart_groups.go +++ b/internal/commands/pro/generated/computer_groups_smart_groups.go @@ -482,20 +482,33 @@ func newComputerGroupsSmartGroupsDeleteCmd(ctx *registry.CLIContext) *cobra.Comm if err := cooldown.Enforce(ctx.ProfileName, noInputBulk, ctx.DestructiveCooldown); err != nil { return err } + var okCount, failCount int + var firstErr error for _, e := range bulk { delPath := strings.Replace("/v3/computer-groups/smart-groups/{id}", "{id}", url.PathEscape(e.id), 1) 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) + fmt.Fprintf(os.Stderr, "delete computer-groups-smart-groups %q (id: %s) failed: %v\n", e.label, e.id, err) + if firstErr == nil { + firstErr = err + } + failCount++ + continue } resp.Body.Close() if resp.StatusCode < 200 || resp.StatusCode >= 300 { - return fmt.Errorf("delete %q (id: %s): HTTP %d", e.label, e.id, resp.StatusCode) + fmt.Fprintf(os.Stderr, "delete computer-groups-smart-groups %q (id: %s) failed: HTTP %d\n", e.label, e.id, resp.StatusCode) + if firstErr == nil { + firstErr = fmt.Errorf("HTTP %d", resp.StatusCode) + } + failCount++ + continue } fmt.Fprintf(os.Stderr, "Deleted computer-groups-smart-groups %q (id: %s)\n", e.label, e.id) + okCount++ } cooldown.Record(ctx.ProfileName) - return nil + return batchDeleteError(cmd, okCount, failCount, firstErr, "computer-groups-smart-groups deletes") } // Resolve resource ID from positional arg, --name, or lookup flags diff --git a/internal/commands/pro/generated/computer_groups_static_groups.go b/internal/commands/pro/generated/computer_groups_static_groups.go index 6cd862be..079e9f3f 100644 --- a/internal/commands/pro/generated/computer_groups_static_groups.go +++ b/internal/commands/pro/generated/computer_groups_static_groups.go @@ -480,20 +480,33 @@ func newComputerGroupsStaticGroupsDeleteCmd(ctx *registry.CLIContext) *cobra.Com if err := cooldown.Enforce(ctx.ProfileName, noInputBulk, ctx.DestructiveCooldown); err != nil { return err } + var okCount, failCount int + var firstErr error for _, e := range bulk { delPath := strings.Replace("/v3/computer-groups/static-groups/{id}", "{id}", url.PathEscape(e.id), 1) 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) + fmt.Fprintf(os.Stderr, "delete computer-groups-static-groups %q (id: %s) failed: %v\n", e.label, e.id, err) + if firstErr == nil { + firstErr = err + } + failCount++ + continue } resp.Body.Close() if resp.StatusCode < 200 || resp.StatusCode >= 300 { - return fmt.Errorf("delete %q (id: %s): HTTP %d", e.label, e.id, resp.StatusCode) + fmt.Fprintf(os.Stderr, "delete computer-groups-static-groups %q (id: %s) failed: HTTP %d\n", e.label, e.id, resp.StatusCode) + if firstErr == nil { + firstErr = fmt.Errorf("HTTP %d", resp.StatusCode) + } + failCount++ + continue } fmt.Fprintf(os.Stderr, "Deleted computer-groups-static-groups %q (id: %s)\n", e.label, e.id) + okCount++ } cooldown.Record(ctx.ProfileName) - return nil + return batchDeleteError(cmd, okCount, failCount, firstErr, "computer-groups-static-groups deletes") } // Resolve resource ID from positional arg, --name, or lookup flags diff --git a/internal/commands/pro/generated/computer_inventory_collection_settings.go b/internal/commands/pro/generated/computer_inventory_collection_settings.go index c7fa2053..3cf0efdb 100644 --- a/internal/commands/pro/generated/computer_inventory_collection_settings.go +++ b/internal/commands/pro/generated/computer_inventory_collection_settings.go @@ -229,20 +229,33 @@ func newComputerInventoryCollectionSettingsDeleteCmd(ctx *registry.CLIContext) * if err := cooldown.Enforce(ctx.ProfileName, noInputBulk, ctx.DestructiveCooldown); err != nil { return err } + var okCount, failCount int + var firstErr error for _, e := range bulk { delPath := strings.Replace("/v2/computer-inventory-collection-settings/custom-path/{id}", "{id}", url.PathEscape(e.id), 1) 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) + fmt.Fprintf(os.Stderr, "delete computer-inventory-collection-setting %q (id: %s) failed: %v\n", e.label, e.id, err) + if firstErr == nil { + firstErr = err + } + failCount++ + continue } resp.Body.Close() if resp.StatusCode < 200 || resp.StatusCode >= 300 { - return fmt.Errorf("delete %q (id: %s): HTTP %d", e.label, e.id, resp.StatusCode) + fmt.Fprintf(os.Stderr, "delete computer-inventory-collection-setting %q (id: %s) failed: HTTP %d\n", e.label, e.id, resp.StatusCode) + if firstErr == nil { + firstErr = fmt.Errorf("HTTP %d", resp.StatusCode) + } + failCount++ + continue } fmt.Fprintf(os.Stderr, "Deleted computer-inventory-collection-setting %q (id: %s)\n", e.label, e.id) + okCount++ } cooldown.Record(ctx.ProfileName) - return nil + return batchDeleteError(cmd, okCount, failCount, firstErr, "computer-inventory-collection-settings deletes") } // Resolve resource ID from positional arg, --name, or lookup flags diff --git a/internal/commands/pro/generated/computer_prestages.go b/internal/commands/pro/generated/computer_prestages.go index 704c3043..58a80f4d 100644 --- a/internal/commands/pro/generated/computer_prestages.go +++ b/internal/commands/pro/generated/computer_prestages.go @@ -559,20 +559,33 @@ func newComputerPrestagesDeleteCmd(ctx *registry.CLIContext) *cobra.Command { if err := cooldown.Enforce(ctx.ProfileName, noInputBulk, ctx.DestructiveCooldown); err != nil { return err } + var okCount, failCount int + var firstErr error for _, e := range bulk { delPath := strings.Replace("/v3/computer-prestages/{id}", "{id}", url.PathEscape(e.id), 1) 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) + fmt.Fprintf(os.Stderr, "delete computer-prestage %q (id: %s) failed: %v\n", e.label, e.id, err) + if firstErr == nil { + firstErr = err + } + failCount++ + continue } resp.Body.Close() if resp.StatusCode < 200 || resp.StatusCode >= 300 { - return fmt.Errorf("delete %q (id: %s): HTTP %d", e.label, e.id, resp.StatusCode) + fmt.Fprintf(os.Stderr, "delete computer-prestage %q (id: %s) failed: HTTP %d\n", e.label, e.id, resp.StatusCode) + if firstErr == nil { + firstErr = fmt.Errorf("HTTP %d", resp.StatusCode) + } + failCount++ + continue } fmt.Fprintf(os.Stderr, "Deleted computer-prestage %q (id: %s)\n", e.label, e.id) + okCount++ } cooldown.Record(ctx.ProfileName) - return nil + return batchDeleteError(cmd, okCount, failCount, firstErr, "computer-prestages deletes") } // Resolve resource ID from positional arg, --name, or lookup flags diff --git a/internal/commands/pro/generated/computers_inventory.go b/internal/commands/pro/generated/computers_inventory.go index 9afd3cba..2023d840 100644 --- a/internal/commands/pro/generated/computers_inventory.go +++ b/internal/commands/pro/generated/computers_inventory.go @@ -468,20 +468,33 @@ func newComputersInventoryDeleteCmd(ctx *registry.CLIContext) *cobra.Command { if err := cooldown.Enforce(ctx.ProfileName, noInputBulk, ctx.DestructiveCooldown); err != nil { return err } + var okCount, failCount int + var firstErr error for _, e := range bulk { delPath := strings.Replace("/v3/computers-inventory/{id}", "{id}", url.PathEscape(e.id), 1) 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) + fmt.Fprintf(os.Stderr, "delete computers-inventory %q (id: %s) failed: %v\n", e.label, e.id, err) + if firstErr == nil { + firstErr = err + } + failCount++ + continue } resp.Body.Close() if resp.StatusCode < 200 || resp.StatusCode >= 300 { - return fmt.Errorf("delete %q (id: %s): HTTP %d", e.label, e.id, resp.StatusCode) + fmt.Fprintf(os.Stderr, "delete computers-inventory %q (id: %s) failed: HTTP %d\n", e.label, e.id, resp.StatusCode) + if firstErr == nil { + firstErr = fmt.Errorf("HTTP %d", resp.StatusCode) + } + failCount++ + continue } fmt.Fprintf(os.Stderr, "Deleted computers-inventory %q (id: %s)\n", e.label, e.id) + okCount++ } cooldown.Record(ctx.ProfileName) - return nil + return batchDeleteError(cmd, okCount, failCount, firstErr, "computers-inventory deletes") } // --group: delete all members of a Classic API group @@ -519,20 +532,33 @@ func newComputersInventoryDeleteCmd(ctx *registry.CLIContext) *cobra.Command { if err := cooldown.Enforce(ctx.ProfileName, noInputGrp, ctx.DestructiveCooldown); err != nil { return err } + var okCount, failCount int + var firstErr error for _, e := range bulk { delPath := strings.Replace("/v3/computers-inventory/{id}", "{id}", url.PathEscape(e.id), 1) resp, err := ctx.Client.Do(reqCtx, "DELETE", delPath, nil) if err != nil { - return fmt.Errorf("deleting id %s: %w", e.id, err) + fmt.Fprintf(os.Stderr, "delete computers-inventory id %s failed: %v\n", e.id, err) + if firstErr == nil { + firstErr = err + } + failCount++ + continue } resp.Body.Close() if resp.StatusCode < 200 || resp.StatusCode >= 300 { - return fmt.Errorf("delete id %s: HTTP %d", e.id, resp.StatusCode) + fmt.Fprintf(os.Stderr, "delete computers-inventory id %s failed: HTTP %d\n", e.id, resp.StatusCode) + if firstErr == nil { + firstErr = fmt.Errorf("HTTP %d", resp.StatusCode) + } + failCount++ + continue } fmt.Fprintf(os.Stderr, "Deleted computers-inventory id: %s\n", e.id) + okCount++ } cooldown.Record(ctx.ProfileName) - return nil + return batchDeleteError(cmd, okCount, failCount, firstErr, "computers-inventory deletes") } // Resolve resource ID from positional arg, --name, or lookup flags diff --git a/internal/commands/pro/generated/departments.go b/internal/commands/pro/generated/departments.go index 37a38d65..8fb252da 100644 --- a/internal/commands/pro/generated/departments.go +++ b/internal/commands/pro/generated/departments.go @@ -472,20 +472,33 @@ func newDepartmentsDeleteCmd(ctx *registry.CLIContext) *cobra.Command { if err := cooldown.Enforce(ctx.ProfileName, noInputBulk, ctx.DestructiveCooldown); err != nil { return err } + var okCount, failCount int + var firstErr error for _, e := range bulk { delPath := strings.Replace("/v1/departments/{id}", "{id}", url.PathEscape(e.id), 1) 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) + fmt.Fprintf(os.Stderr, "delete department %q (id: %s) failed: %v\n", e.label, e.id, err) + if firstErr == nil { + firstErr = err + } + failCount++ + continue } resp.Body.Close() if resp.StatusCode < 200 || resp.StatusCode >= 300 { - return fmt.Errorf("delete %q (id: %s): HTTP %d", e.label, e.id, resp.StatusCode) + fmt.Fprintf(os.Stderr, "delete department %q (id: %s) failed: HTTP %d\n", e.label, e.id, resp.StatusCode) + if firstErr == nil { + firstErr = fmt.Errorf("HTTP %d", resp.StatusCode) + } + failCount++ + continue } fmt.Fprintf(os.Stderr, "Deleted department %q (id: %s)\n", e.label, e.id) + okCount++ } cooldown.Record(ctx.ProfileName) - return nil + return batchDeleteError(cmd, okCount, failCount, firstErr, "departments deletes") } // Resolve resource ID from positional arg, --name, or lookup flags diff --git a/internal/commands/pro/generated/device_enrollment_instances.go b/internal/commands/pro/generated/device_enrollment_instances.go index 5fbfaf54..af1b0914 100644 --- a/internal/commands/pro/generated/device_enrollment_instances.go +++ b/internal/commands/pro/generated/device_enrollment_instances.go @@ -555,20 +555,33 @@ func newDeviceEnrollmentInstancesDeleteCmd(ctx *registry.CLIContext) *cobra.Comm if err := cooldown.Enforce(ctx.ProfileName, noInputBulk, ctx.DestructiveCooldown); err != nil { return err } + var okCount, failCount int + var firstErr error for _, e := range bulk { delPath := strings.Replace("/v1/device-enrollments/{id}", "{id}", url.PathEscape(e.id), 1) 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) + fmt.Fprintf(os.Stderr, "delete device-enrollment-instance %q (id: %s) failed: %v\n", e.label, e.id, err) + if firstErr == nil { + firstErr = err + } + failCount++ + continue } resp.Body.Close() if resp.StatusCode < 200 || resp.StatusCode >= 300 { - return fmt.Errorf("delete %q (id: %s): HTTP %d", e.label, e.id, resp.StatusCode) + fmt.Fprintf(os.Stderr, "delete device-enrollment-instance %q (id: %s) failed: HTTP %d\n", e.label, e.id, resp.StatusCode) + if firstErr == nil { + firstErr = fmt.Errorf("HTTP %d", resp.StatusCode) + } + failCount++ + continue } fmt.Fprintf(os.Stderr, "Deleted device-enrollment-instance %q (id: %s)\n", e.label, e.id) + okCount++ } cooldown.Record(ctx.ProfileName) - return nil + return batchDeleteError(cmd, okCount, failCount, firstErr, "device-enrollment-instances deletes") } // Resolve resource ID from positional arg, --name, or lookup flags diff --git a/internal/commands/pro/generated/digi_cert_settings.go b/internal/commands/pro/generated/digi_cert_settings.go index c6ed00fb..19c984ad 100644 --- a/internal/commands/pro/generated/digi_cert_settings.go +++ b/internal/commands/pro/generated/digi_cert_settings.go @@ -257,20 +257,33 @@ func newDigiCertSettingsDeleteCmd(ctx *registry.CLIContext) *cobra.Command { if err := cooldown.Enforce(ctx.ProfileName, noInputBulk, ctx.DestructiveCooldown); err != nil { return err } + var okCount, failCount int + var firstErr error for _, e := range bulk { delPath := strings.Replace("/v1/pki/digicert/trust-lifecycle-manager/{id}", "{id}", url.PathEscape(e.id), 1) 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) + fmt.Fprintf(os.Stderr, "delete digi-cert-setting %q (id: %s) failed: %v\n", e.label, e.id, err) + if firstErr == nil { + firstErr = err + } + failCount++ + continue } resp.Body.Close() if resp.StatusCode < 200 || resp.StatusCode >= 300 { - return fmt.Errorf("delete %q (id: %s): HTTP %d", e.label, e.id, resp.StatusCode) + fmt.Fprintf(os.Stderr, "delete digi-cert-setting %q (id: %s) failed: HTTP %d\n", e.label, e.id, resp.StatusCode) + if firstErr == nil { + firstErr = fmt.Errorf("HTTP %d", resp.StatusCode) + } + failCount++ + continue } fmt.Fprintf(os.Stderr, "Deleted digi-cert-setting %q (id: %s)\n", e.label, e.id) + okCount++ } cooldown.Record(ctx.ProfileName) - return nil + return batchDeleteError(cmd, okCount, failCount, firstErr, "digi-cert-settings deletes") } // Resolve resource ID from positional arg, --name, or lookup flags diff --git a/internal/commands/pro/generated/distribution_points.go b/internal/commands/pro/generated/distribution_points.go index 9c22d0a3..282f7a8f 100644 --- a/internal/commands/pro/generated/distribution_points.go +++ b/internal/commands/pro/generated/distribution_points.go @@ -515,20 +515,33 @@ func newDistributionPointsDeleteCmd(ctx *registry.CLIContext) *cobra.Command { if err := cooldown.Enforce(ctx.ProfileName, noInputBulk, ctx.DestructiveCooldown); err != nil { return err } + var okCount, failCount int + var firstErr error for _, e := range bulk { delPath := strings.Replace("/v1/distribution-points/{id}", "{id}", url.PathEscape(e.id), 1) 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) + fmt.Fprintf(os.Stderr, "delete distribution-point %q (id: %s) failed: %v\n", e.label, e.id, err) + if firstErr == nil { + firstErr = err + } + failCount++ + continue } resp.Body.Close() if resp.StatusCode < 200 || resp.StatusCode >= 300 { - return fmt.Errorf("delete %q (id: %s): HTTP %d", e.label, e.id, resp.StatusCode) + fmt.Fprintf(os.Stderr, "delete distribution-point %q (id: %s) failed: HTTP %d\n", e.label, e.id, resp.StatusCode) + if firstErr == nil { + firstErr = fmt.Errorf("HTTP %d", resp.StatusCode) + } + failCount++ + continue } fmt.Fprintf(os.Stderr, "Deleted distribution-point %q (id: %s)\n", e.label, e.id) + okCount++ } cooldown.Record(ctx.ProfileName) - return nil + return batchDeleteError(cmd, okCount, failCount, firstErr, "distribution-points deletes") } // Resolve resource ID from positional arg, --name, or lookup flags diff --git a/internal/commands/pro/generated/dock_items.go b/internal/commands/pro/generated/dock_items.go index b0be5913..4c4f11ae 100644 --- a/internal/commands/pro/generated/dock_items.go +++ b/internal/commands/pro/generated/dock_items.go @@ -341,20 +341,33 @@ func newDockItemsDeleteCmd(ctx *registry.CLIContext) *cobra.Command { if err := cooldown.Enforce(ctx.ProfileName, noInputBulk, ctx.DestructiveCooldown); err != nil { return err } + var okCount, failCount int + var firstErr error for _, e := range bulk { delPath := strings.Replace("/v1/dock-items/{id}", "{id}", url.PathEscape(e.id), 1) 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) + fmt.Fprintf(os.Stderr, "delete dock-item %q (id: %s) failed: %v\n", e.label, e.id, err) + if firstErr == nil { + firstErr = err + } + failCount++ + continue } resp.Body.Close() if resp.StatusCode < 200 || resp.StatusCode >= 300 { - return fmt.Errorf("delete %q (id: %s): HTTP %d", e.label, e.id, resp.StatusCode) + fmt.Fprintf(os.Stderr, "delete dock-item %q (id: %s) failed: HTTP %d\n", e.label, e.id, resp.StatusCode) + if firstErr == nil { + firstErr = fmt.Errorf("HTTP %d", resp.StatusCode) + } + failCount++ + continue } fmt.Fprintf(os.Stderr, "Deleted dock-item %q (id: %s)\n", e.label, e.id) + okCount++ } cooldown.Record(ctx.ProfileName) - return nil + return batchDeleteError(cmd, okCount, failCount, firstErr, "dock-items deletes") } // Resolve resource ID from positional arg, --name, or lookup flags diff --git a/internal/commands/pro/generated/enrollment_customizations.go b/internal/commands/pro/generated/enrollment_customizations.go index 6da284ba..5be21ba2 100644 --- a/internal/commands/pro/generated/enrollment_customizations.go +++ b/internal/commands/pro/generated/enrollment_customizations.go @@ -476,20 +476,33 @@ func newEnrollmentCustomizationsDeleteCmd(ctx *registry.CLIContext) *cobra.Comma if err := cooldown.Enforce(ctx.ProfileName, noInputBulk, ctx.DestructiveCooldown); err != nil { return err } + var okCount, failCount int + var firstErr error for _, e := range bulk { delPath := strings.Replace("/v2/enrollment-customizations/{id}", "{id}", url.PathEscape(e.id), 1) 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) + fmt.Fprintf(os.Stderr, "delete enrollment-customization %q (id: %s) failed: %v\n", e.label, e.id, err) + if firstErr == nil { + firstErr = err + } + failCount++ + continue } resp.Body.Close() if resp.StatusCode < 200 || resp.StatusCode >= 300 { - return fmt.Errorf("delete %q (id: %s): HTTP %d", e.label, e.id, resp.StatusCode) + fmt.Fprintf(os.Stderr, "delete enrollment-customization %q (id: %s) failed: HTTP %d\n", e.label, e.id, resp.StatusCode) + if firstErr == nil { + firstErr = fmt.Errorf("HTTP %d", resp.StatusCode) + } + failCount++ + continue } fmt.Fprintf(os.Stderr, "Deleted enrollment-customization %q (id: %s)\n", e.label, e.id) + okCount++ } cooldown.Record(ctx.ProfileName) - return nil + return batchDeleteError(cmd, okCount, failCount, firstErr, "enrollment-customizations deletes") } // Resolve resource ID from positional arg, --name, or lookup flags diff --git a/internal/commands/pro/generated/enrollment_languages.go b/internal/commands/pro/generated/enrollment_languages.go index ac50c430..f8f6ae5e 100644 --- a/internal/commands/pro/generated/enrollment_languages.go +++ b/internal/commands/pro/generated/enrollment_languages.go @@ -437,20 +437,33 @@ func newEnrollmentLanguagesDeleteCmd(ctx *registry.CLIContext) *cobra.Command { if err := cooldown.Enforce(ctx.ProfileName, noInputBulk, ctx.DestructiveCooldown); err != nil { return err } + var okCount, failCount int + var firstErr error for _, e := range bulk { delPath := strings.Replace("/v3/enrollment/languages/{languageId}", "{languageId}", url.PathEscape(e.id), 1) 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) + fmt.Fprintf(os.Stderr, "delete enrollment-language %q (id: %s) failed: %v\n", e.label, e.id, err) + if firstErr == nil { + firstErr = err + } + failCount++ + continue } resp.Body.Close() if resp.StatusCode < 200 || resp.StatusCode >= 300 { - return fmt.Errorf("delete %q (id: %s): HTTP %d", e.label, e.id, resp.StatusCode) + fmt.Fprintf(os.Stderr, "delete enrollment-language %q (id: %s) failed: HTTP %d\n", e.label, e.id, resp.StatusCode) + if firstErr == nil { + firstErr = fmt.Errorf("HTTP %d", resp.StatusCode) + } + failCount++ + continue } fmt.Fprintf(os.Stderr, "Deleted enrollment-language %q (id: %s)\n", e.label, e.id) + okCount++ } cooldown.Record(ctx.ProfileName) - return nil + return batchDeleteError(cmd, okCount, failCount, firstErr, "enrollment-languages deletes") } // Resolve resource ID from positional arg, --name, or lookup flags diff --git a/internal/commands/pro/generated/enrollment_settings.go b/internal/commands/pro/generated/enrollment_settings.go index b947eda6..d9d463d2 100644 --- a/internal/commands/pro/generated/enrollment_settings.go +++ b/internal/commands/pro/generated/enrollment_settings.go @@ -488,20 +488,33 @@ func newEnrollmentSettingsDeleteCmd(ctx *registry.CLIContext) *cobra.Command { if err := cooldown.Enforce(ctx.ProfileName, noInputBulk, ctx.DestructiveCooldown); err != nil { return err } + var okCount, failCount int + var firstErr error for _, e := range bulk { delPath := strings.Replace("/v3/enrollment/access-groups/{id}", "{id}", url.PathEscape(e.id), 1) 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) + fmt.Fprintf(os.Stderr, "delete enrollment-setting %q (id: %s) failed: %v\n", e.label, e.id, err) + if firstErr == nil { + firstErr = err + } + failCount++ + continue } resp.Body.Close() if resp.StatusCode < 200 || resp.StatusCode >= 300 { - return fmt.Errorf("delete %q (id: %s): HTTP %d", e.label, e.id, resp.StatusCode) + fmt.Fprintf(os.Stderr, "delete enrollment-setting %q (id: %s) failed: HTTP %d\n", e.label, e.id, resp.StatusCode) + if firstErr == nil { + firstErr = fmt.Errorf("HTTP %d", resp.StatusCode) + } + failCount++ + continue } fmt.Fprintf(os.Stderr, "Deleted enrollment-setting %q (id: %s)\n", e.label, e.id) + okCount++ } cooldown.Record(ctx.ProfileName) - return nil + return batchDeleteError(cmd, okCount, failCount, firstErr, "enrollment-settings deletes") } // Resolve resource ID from positional arg, --name, or lookup flags diff --git a/internal/commands/pro/generated/groups.go b/internal/commands/pro/generated/groups.go index f57c8cd2..81db5d62 100644 --- a/internal/commands/pro/generated/groups.go +++ b/internal/commands/pro/generated/groups.go @@ -316,20 +316,33 @@ func newGroupsDeleteCmd(ctx *registry.CLIContext) *cobra.Command { if err := cooldown.Enforce(ctx.ProfileName, noInputBulk, ctx.DestructiveCooldown); err != nil { return err } + var okCount, failCount int + var firstErr error for _, e := range bulk { delPath := strings.Replace("/v2/groups/{id}", "{id}", url.PathEscape(e.id), 1) 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) + fmt.Fprintf(os.Stderr, "delete group %q (id: %s) failed: %v\n", e.label, e.id, err) + if firstErr == nil { + firstErr = err + } + failCount++ + continue } resp.Body.Close() if resp.StatusCode < 200 || resp.StatusCode >= 300 { - return fmt.Errorf("delete %q (id: %s): HTTP %d", e.label, e.id, resp.StatusCode) + fmt.Fprintf(os.Stderr, "delete group %q (id: %s) failed: HTTP %d\n", e.label, e.id, resp.StatusCode) + if firstErr == nil { + firstErr = fmt.Errorf("HTTP %d", resp.StatusCode) + } + failCount++ + continue } fmt.Fprintf(os.Stderr, "Deleted group %q (id: %s)\n", e.label, e.id) + okCount++ } cooldown.Record(ctx.ProfileName) - return nil + return batchDeleteError(cmd, okCount, failCount, firstErr, "groups deletes") } // Resolve resource ID from positional arg, --name, or lookup flags diff --git a/internal/commands/pro/generated/inventory_preloads.go b/internal/commands/pro/generated/inventory_preloads.go index 82af4ab6..67a2b7d5 100644 --- a/internal/commands/pro/generated/inventory_preloads.go +++ b/internal/commands/pro/generated/inventory_preloads.go @@ -525,20 +525,33 @@ func newInventoryPreloadsDeleteCmd(ctx *registry.CLIContext) *cobra.Command { if err := cooldown.Enforce(ctx.ProfileName, noInputBulk, ctx.DestructiveCooldown); err != nil { return err } + var okCount, failCount int + var firstErr error for _, e := range bulk { delPath := strings.Replace("/v2/inventory-preload/records/{id}", "{id}", url.PathEscape(e.id), 1) 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) + fmt.Fprintf(os.Stderr, "delete inventory-preload %q (id: %s) failed: %v\n", e.label, e.id, err) + if firstErr == nil { + firstErr = err + } + failCount++ + continue } resp.Body.Close() if resp.StatusCode < 200 || resp.StatusCode >= 300 { - return fmt.Errorf("delete %q (id: %s): HTTP %d", e.label, e.id, resp.StatusCode) + fmt.Fprintf(os.Stderr, "delete inventory-preload %q (id: %s) failed: HTTP %d\n", e.label, e.id, resp.StatusCode) + if firstErr == nil { + firstErr = fmt.Errorf("HTTP %d", resp.StatusCode) + } + failCount++ + continue } fmt.Fprintf(os.Stderr, "Deleted inventory-preload %q (id: %s)\n", e.label, e.id) + okCount++ } cooldown.Record(ctx.ProfileName) - return nil + return batchDeleteError(cmd, okCount, failCount, firstErr, "inventory-preloads deletes") } // Resolve resource ID from positional arg, --name, or lookup flags diff --git a/internal/commands/pro/generated/jcds.go b/internal/commands/pro/generated/jcds.go index 3ef2cffe..a2b54086 100644 --- a/internal/commands/pro/generated/jcds.go +++ b/internal/commands/pro/generated/jcds.go @@ -223,20 +223,33 @@ func newJcdsDeleteCmd(ctx *registry.CLIContext) *cobra.Command { if err := cooldown.Enforce(ctx.ProfileName, noInputBulk, ctx.DestructiveCooldown); err != nil { return err } + var okCount, failCount int + var firstErr error for _, e := range bulk { delPath := strings.Replace("/v1/jcds/files/{fileName}", "{fileName}", url.PathEscape(e.id), 1) 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) + fmt.Fprintf(os.Stderr, "delete jcd %q (id: %s) failed: %v\n", e.label, e.id, err) + if firstErr == nil { + firstErr = err + } + failCount++ + continue } resp.Body.Close() if resp.StatusCode < 200 || resp.StatusCode >= 300 { - return fmt.Errorf("delete %q (id: %s): HTTP %d", e.label, e.id, resp.StatusCode) + fmt.Fprintf(os.Stderr, "delete jcd %q (id: %s) failed: HTTP %d\n", e.label, e.id, resp.StatusCode) + if firstErr == nil { + firstErr = fmt.Errorf("HTTP %d", resp.StatusCode) + } + failCount++ + continue } fmt.Fprintf(os.Stderr, "Deleted jcd %q (id: %s)\n", e.label, e.id) + okCount++ } cooldown.Record(ctx.ProfileName) - return nil + return batchDeleteError(cmd, okCount, failCount, firstErr, "jcds deletes") } // Resolve resource ID from positional arg, --name, or lookup flags diff --git a/internal/commands/pro/generated/log_flushings.go b/internal/commands/pro/generated/log_flushings.go index f226bc6b..2297e9e3 100644 --- a/internal/commands/pro/generated/log_flushings.go +++ b/internal/commands/pro/generated/log_flushings.go @@ -222,20 +222,33 @@ func newLogFlushingsDeleteCmd(ctx *registry.CLIContext) *cobra.Command { if err := cooldown.Enforce(ctx.ProfileName, noInputBulk, ctx.DestructiveCooldown); err != nil { return err } + var okCount, failCount int + var firstErr error for _, e := range bulk { delPath := strings.Replace("/v1/log-flushing/task/{id}", "{id}", url.PathEscape(e.id), 1) 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) + fmt.Fprintf(os.Stderr, "delete log-flushing %q (id: %s) failed: %v\n", e.label, e.id, err) + if firstErr == nil { + firstErr = err + } + failCount++ + continue } resp.Body.Close() if resp.StatusCode < 200 || resp.StatusCode >= 300 { - return fmt.Errorf("delete %q (id: %s): HTTP %d", e.label, e.id, resp.StatusCode) + fmt.Fprintf(os.Stderr, "delete log-flushing %q (id: %s) failed: HTTP %d\n", e.label, e.id, resp.StatusCode) + if firstErr == nil { + firstErr = fmt.Errorf("HTTP %d", resp.StatusCode) + } + failCount++ + continue } fmt.Fprintf(os.Stderr, "Deleted log-flushing %q (id: %s)\n", e.label, e.id) + okCount++ } cooldown.Record(ctx.ProfileName) - return nil + return batchDeleteError(cmd, okCount, failCount, firstErr, "log-flushings deletes") } // Resolve resource ID from positional arg, --name, or lookup flags diff --git a/internal/commands/pro/generated/mdm_renewals.go b/internal/commands/pro/generated/mdm_renewals.go index 0bb4005e..a0d121a4 100644 --- a/internal/commands/pro/generated/mdm_renewals.go +++ b/internal/commands/pro/generated/mdm_renewals.go @@ -182,20 +182,33 @@ func newMdmRenewalsDeleteCmd(ctx *registry.CLIContext) *cobra.Command { if err := cooldown.Enforce(ctx.ProfileName, noInputBulk, ctx.DestructiveCooldown); err != nil { return err } + var okCount, failCount int + var firstErr error for _, e := range bulk { delPath := strings.Replace("/v1/mdm-renewal/renewal-strategies/{clientManagementId}", "{clientManagementId}", url.PathEscape(e.id), 1) 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) + fmt.Fprintf(os.Stderr, "delete mdm-renewal %q (id: %s) failed: %v\n", e.label, e.id, err) + if firstErr == nil { + firstErr = err + } + failCount++ + continue } resp.Body.Close() if resp.StatusCode < 200 || resp.StatusCode >= 300 { - return fmt.Errorf("delete %q (id: %s): HTTP %d", e.label, e.id, resp.StatusCode) + fmt.Fprintf(os.Stderr, "delete mdm-renewal %q (id: %s) failed: HTTP %d\n", e.label, e.id, resp.StatusCode) + if firstErr == nil { + firstErr = fmt.Errorf("HTTP %d", resp.StatusCode) + } + failCount++ + continue } fmt.Fprintf(os.Stderr, "Deleted mdm-renewal %q (id: %s)\n", e.label, e.id) + okCount++ } cooldown.Record(ctx.ProfileName) - return nil + return batchDeleteError(cmd, okCount, failCount, firstErr, "mdm-renewals deletes") } // Resolve resource ID from positional arg, --name, or lookup flags diff --git a/internal/commands/pro/generated/mobile_device_extension_attributes.go b/internal/commands/pro/generated/mobile_device_extension_attributes.go index d0ef80bd..0863f2b9 100644 --- a/internal/commands/pro/generated/mobile_device_extension_attributes.go +++ b/internal/commands/pro/generated/mobile_device_extension_attributes.go @@ -492,20 +492,33 @@ func newMobileDeviceExtensionAttributesDeleteCmd(ctx *registry.CLIContext) *cobr if err := cooldown.Enforce(ctx.ProfileName, noInputBulk, ctx.DestructiveCooldown); err != nil { return err } + var okCount, failCount int + var firstErr error for _, e := range bulk { delPath := strings.Replace("/v1/mobile-device-extension-attributes/{id}", "{id}", url.PathEscape(e.id), 1) 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) + fmt.Fprintf(os.Stderr, "delete mobile-device-extension-attribute %q (id: %s) failed: %v\n", e.label, e.id, err) + if firstErr == nil { + firstErr = err + } + failCount++ + continue } resp.Body.Close() if resp.StatusCode < 200 || resp.StatusCode >= 300 { - return fmt.Errorf("delete %q (id: %s): HTTP %d", e.label, e.id, resp.StatusCode) + fmt.Fprintf(os.Stderr, "delete mobile-device-extension-attribute %q (id: %s) failed: HTTP %d\n", e.label, e.id, resp.StatusCode) + if firstErr == nil { + firstErr = fmt.Errorf("HTTP %d", resp.StatusCode) + } + failCount++ + continue } fmt.Fprintf(os.Stderr, "Deleted mobile-device-extension-attribute %q (id: %s)\n", e.label, e.id) + okCount++ } cooldown.Record(ctx.ProfileName) - return nil + return batchDeleteError(cmd, okCount, failCount, firstErr, "mobile-device-extension-attributes deletes") } // Resolve resource ID from positional arg, --name, or lookup flags diff --git a/internal/commands/pro/generated/mobile_device_groups.go b/internal/commands/pro/generated/mobile_device_groups.go index 9bc77170..1b1b3a49 100644 --- a/internal/commands/pro/generated/mobile_device_groups.go +++ b/internal/commands/pro/generated/mobile_device_groups.go @@ -245,20 +245,33 @@ func newMobileDeviceGroupsEraseCmd(ctx *registry.CLIContext) *cobra.Command { if err := cooldown.Enforce(ctx.ProfileName, noInputBulk, ctx.DestructiveCooldown); err != nil { return err } + var okCount, failCount int + var firstErr error for _, e := range bulk { delPath := strings.Replace("/v2/mobile-device-groups/{id}/erase", "{id}", url.PathEscape(e.id), 1) 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) + fmt.Fprintf(os.Stderr, "delete mobile-device-group %q (id: %s) failed: %v\n", e.label, e.id, err) + if firstErr == nil { + firstErr = err + } + failCount++ + continue } resp.Body.Close() if resp.StatusCode < 200 || resp.StatusCode >= 300 { - return fmt.Errorf("delete %q (id: %s): HTTP %d", e.label, e.id, resp.StatusCode) + fmt.Fprintf(os.Stderr, "delete mobile-device-group %q (id: %s) failed: HTTP %d\n", e.label, e.id, resp.StatusCode) + if firstErr == nil { + firstErr = fmt.Errorf("HTTP %d", resp.StatusCode) + } + failCount++ + continue } fmt.Fprintf(os.Stderr, "Deleted mobile-device-group %q (id: %s)\n", e.label, e.id) + okCount++ } cooldown.Record(ctx.ProfileName) - return nil + return batchDeleteError(cmd, okCount, failCount, firstErr, "mobile-device-groups deletes") } // Resolve resource ID from positional arg, --name, or lookup flags diff --git a/internal/commands/pro/generated/mobile_device_groups_smart_groups.go b/internal/commands/pro/generated/mobile_device_groups_smart_groups.go index d31cbfd5..3cb6914a 100644 --- a/internal/commands/pro/generated/mobile_device_groups_smart_groups.go +++ b/internal/commands/pro/generated/mobile_device_groups_smart_groups.go @@ -482,20 +482,33 @@ func newMobileDeviceGroupsSmartGroupsDeleteCmd(ctx *registry.CLIContext) *cobra. if err := cooldown.Enforce(ctx.ProfileName, noInputBulk, ctx.DestructiveCooldown); err != nil { return err } + var okCount, failCount int + var firstErr error for _, e := range bulk { delPath := strings.Replace("/v2/mobile-device-groups/smart-groups/{id}", "{id}", url.PathEscape(e.id), 1) 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) + fmt.Fprintf(os.Stderr, "delete mobile-device-groups-smart-groups %q (id: %s) failed: %v\n", e.label, e.id, err) + if firstErr == nil { + firstErr = err + } + failCount++ + continue } resp.Body.Close() if resp.StatusCode < 200 || resp.StatusCode >= 300 { - return fmt.Errorf("delete %q (id: %s): HTTP %d", e.label, e.id, resp.StatusCode) + fmt.Fprintf(os.Stderr, "delete mobile-device-groups-smart-groups %q (id: %s) failed: HTTP %d\n", e.label, e.id, resp.StatusCode) + if firstErr == nil { + firstErr = fmt.Errorf("HTTP %d", resp.StatusCode) + } + failCount++ + continue } fmt.Fprintf(os.Stderr, "Deleted mobile-device-groups-smart-groups %q (id: %s)\n", e.label, e.id) + okCount++ } cooldown.Record(ctx.ProfileName) - return nil + return batchDeleteError(cmd, okCount, failCount, firstErr, "mobile-device-groups-smart-groups deletes") } // Resolve resource ID from positional arg, --name, or lookup flags diff --git a/internal/commands/pro/generated/mobile_device_groups_static_groups.go b/internal/commands/pro/generated/mobile_device_groups_static_groups.go index c51a57f0..c6c7d116 100644 --- a/internal/commands/pro/generated/mobile_device_groups_static_groups.go +++ b/internal/commands/pro/generated/mobile_device_groups_static_groups.go @@ -393,20 +393,33 @@ func newMobileDeviceGroupsStaticGroupsDeleteCmd(ctx *registry.CLIContext) *cobra if err := cooldown.Enforce(ctx.ProfileName, noInputBulk, ctx.DestructiveCooldown); err != nil { return err } + var okCount, failCount int + var firstErr error for _, e := range bulk { delPath := strings.Replace("/v2/mobile-device-groups/static-groups/{id}", "{id}", url.PathEscape(e.id), 1) 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) + fmt.Fprintf(os.Stderr, "delete mobile-device-groups-static-groups %q (id: %s) failed: %v\n", e.label, e.id, err) + if firstErr == nil { + firstErr = err + } + failCount++ + continue } resp.Body.Close() if resp.StatusCode < 200 || resp.StatusCode >= 300 { - return fmt.Errorf("delete %q (id: %s): HTTP %d", e.label, e.id, resp.StatusCode) + fmt.Fprintf(os.Stderr, "delete mobile-device-groups-static-groups %q (id: %s) failed: HTTP %d\n", e.label, e.id, resp.StatusCode) + if firstErr == nil { + firstErr = fmt.Errorf("HTTP %d", resp.StatusCode) + } + failCount++ + continue } fmt.Fprintf(os.Stderr, "Deleted mobile-device-groups-static-groups %q (id: %s)\n", e.label, e.id) + okCount++ } cooldown.Record(ctx.ProfileName) - return nil + return batchDeleteError(cmd, okCount, failCount, firstErr, "mobile-device-groups-static-groups deletes") } // Resolve resource ID from positional arg, --name, or lookup flags diff --git a/internal/commands/pro/generated/mobile_device_prestages.go b/internal/commands/pro/generated/mobile_device_prestages.go index 7a39ebda..bc7ad565 100644 --- a/internal/commands/pro/generated/mobile_device_prestages.go +++ b/internal/commands/pro/generated/mobile_device_prestages.go @@ -579,20 +579,33 @@ func newMobileDevicePrestagesDeleteCmd(ctx *registry.CLIContext) *cobra.Command if err := cooldown.Enforce(ctx.ProfileName, noInputBulk, ctx.DestructiveCooldown); err != nil { return err } + var okCount, failCount int + var firstErr error for _, e := range bulk { delPath := strings.Replace("/v3/mobile-device-prestages/{id}", "{id}", url.PathEscape(e.id), 1) 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) + fmt.Fprintf(os.Stderr, "delete mobile-device-prestage %q (id: %s) failed: %v\n", e.label, e.id, err) + if firstErr == nil { + firstErr = err + } + failCount++ + continue } resp.Body.Close() if resp.StatusCode < 200 || resp.StatusCode >= 300 { - return fmt.Errorf("delete %q (id: %s): HTTP %d", e.label, e.id, resp.StatusCode) + fmt.Fprintf(os.Stderr, "delete mobile-device-prestage %q (id: %s) failed: HTTP %d\n", e.label, e.id, resp.StatusCode) + if firstErr == nil { + firstErr = fmt.Errorf("HTTP %d", resp.StatusCode) + } + failCount++ + continue } fmt.Fprintf(os.Stderr, "Deleted mobile-device-prestage %q (id: %s)\n", e.label, e.id) + okCount++ } cooldown.Record(ctx.ProfileName) - return nil + return batchDeleteError(cmd, okCount, failCount, firstErr, "mobile-device-prestages deletes") } // Resolve resource ID from positional arg, --name, or lookup flags @@ -780,20 +793,33 @@ func newMobileDevicePrestagesDeleteMultipleCmd(ctx *registry.CLIContext) *cobra. if err := cooldown.Enforce(ctx.ProfileName, noInputBulk, ctx.DestructiveCooldown); err != nil { return err } + var okCount, failCount int + var firstErr error for _, e := range bulk { delPath := strings.Replace("/v3/mobile-device-prestages/{id}/attachments/delete-multiple", "{id}", url.PathEscape(e.id), 1) 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) + fmt.Fprintf(os.Stderr, "delete mobile-device-prestage %q (id: %s) failed: %v\n", e.label, e.id, err) + if firstErr == nil { + firstErr = err + } + failCount++ + continue } resp.Body.Close() if resp.StatusCode < 200 || resp.StatusCode >= 300 { - return fmt.Errorf("delete %q (id: %s): HTTP %d", e.label, e.id, resp.StatusCode) + fmt.Fprintf(os.Stderr, "delete mobile-device-prestage %q (id: %s) failed: HTTP %d\n", e.label, e.id, resp.StatusCode) + if firstErr == nil { + firstErr = fmt.Errorf("HTTP %d", resp.StatusCode) + } + failCount++ + continue } fmt.Fprintf(os.Stderr, "Deleted mobile-device-prestage %q (id: %s)\n", e.label, e.id) + okCount++ } cooldown.Record(ctx.ProfileName) - return nil + return batchDeleteError(cmd, okCount, failCount, firstErr, "mobile-device-prestages deletes") } // Resolve resource ID from positional arg, --name, or lookup flags diff --git a/internal/commands/pro/generated/notifications.go b/internal/commands/pro/generated/notifications.go index 4a4a3cb7..5a2ce9da 100644 --- a/internal/commands/pro/generated/notifications.go +++ b/internal/commands/pro/generated/notifications.go @@ -153,20 +153,33 @@ func newNotificationsDeleteCmd(ctx *registry.CLIContext) *cobra.Command { if err := cooldown.Enforce(ctx.ProfileName, noInputBulk, ctx.DestructiveCooldown); err != nil { return err } + var okCount, failCount int + var firstErr error for _, e := range bulk { delPath := strings.Replace("/v1/notifications/{type}/{id}", "{id}", url.PathEscape(e.id), 1) 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) + fmt.Fprintf(os.Stderr, "delete notification %q (id: %s) failed: %v\n", e.label, e.id, err) + if firstErr == nil { + firstErr = err + } + failCount++ + continue } resp.Body.Close() if resp.StatusCode < 200 || resp.StatusCode >= 300 { - return fmt.Errorf("delete %q (id: %s): HTTP %d", e.label, e.id, resp.StatusCode) + fmt.Fprintf(os.Stderr, "delete notification %q (id: %s) failed: HTTP %d\n", e.label, e.id, resp.StatusCode) + if firstErr == nil { + firstErr = fmt.Errorf("HTTP %d", resp.StatusCode) + } + failCount++ + continue } fmt.Fprintf(os.Stderr, "Deleted notification %q (id: %s)\n", e.label, e.id) + okCount++ } cooldown.Record(ctx.ProfileName) - return nil + return batchDeleteError(cmd, okCount, failCount, firstErr, "notifications deletes") } // Resolve resource ID from positional arg, --name, or lookup flags diff --git a/internal/commands/pro/generated/packages.go b/internal/commands/pro/generated/packages.go index 66332473..82b48eee 100644 --- a/internal/commands/pro/generated/packages.go +++ b/internal/commands/pro/generated/packages.go @@ -538,20 +538,33 @@ func newPackagesDeleteCmd(ctx *registry.CLIContext) *cobra.Command { if err := cooldown.Enforce(ctx.ProfileName, noInputBulk, ctx.DestructiveCooldown); err != nil { return err } + var okCount, failCount int + var firstErr error for _, e := range bulk { delPath := strings.Replace("/v1/packages/{id}", "{id}", url.PathEscape(e.id), 1) 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) + fmt.Fprintf(os.Stderr, "delete package %q (id: %s) failed: %v\n", e.label, e.id, err) + if firstErr == nil { + firstErr = err + } + failCount++ + continue } resp.Body.Close() if resp.StatusCode < 200 || resp.StatusCode >= 300 { - return fmt.Errorf("delete %q (id: %s): HTTP %d", e.label, e.id, resp.StatusCode) + fmt.Fprintf(os.Stderr, "delete package %q (id: %s) failed: HTTP %d\n", e.label, e.id, resp.StatusCode) + if firstErr == nil { + firstErr = fmt.Errorf("HTTP %d", resp.StatusCode) + } + failCount++ + continue } fmt.Fprintf(os.Stderr, "Deleted package %q (id: %s)\n", e.label, e.id) + okCount++ } cooldown.Record(ctx.ProfileName) - return nil + return batchDeleteError(cmd, okCount, failCount, firstErr, "packages deletes") } // Resolve resource ID from positional arg, --name, or lookup flags diff --git a/internal/commands/pro/generated/patch_policies.go b/internal/commands/pro/generated/patch_policies.go index 092d08a7..15517182 100644 --- a/internal/commands/pro/generated/patch_policies.go +++ b/internal/commands/pro/generated/patch_policies.go @@ -254,20 +254,33 @@ func newPatchPoliciesDeleteCmd(ctx *registry.CLIContext) *cobra.Command { if err := cooldown.Enforce(ctx.ProfileName, noInputBulk, ctx.DestructiveCooldown); err != nil { return err } + var okCount, failCount int + var firstErr error for _, e := range bulk { delPath := strings.Replace("/v2/patch-policies/{id}/dashboard", "{id}", url.PathEscape(e.id), 1) 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) + fmt.Fprintf(os.Stderr, "delete patch-policy %q (id: %s) failed: %v\n", e.label, e.id, err) + if firstErr == nil { + firstErr = err + } + failCount++ + continue } resp.Body.Close() if resp.StatusCode < 200 || resp.StatusCode >= 300 { - return fmt.Errorf("delete %q (id: %s): HTTP %d", e.label, e.id, resp.StatusCode) + fmt.Fprintf(os.Stderr, "delete patch-policy %q (id: %s) failed: HTTP %d\n", e.label, e.id, resp.StatusCode) + if firstErr == nil { + firstErr = fmt.Errorf("HTTP %d", resp.StatusCode) + } + failCount++ + continue } fmt.Fprintf(os.Stderr, "Deleted patch-policy %q (id: %s)\n", e.label, e.id) + okCount++ } cooldown.Record(ctx.ProfileName) - return nil + return batchDeleteError(cmd, okCount, failCount, firstErr, "patch-policies deletes") } // Resolve resource ID from positional arg, --name, or lookup flags diff --git a/internal/commands/pro/generated/patch_software_title_configurations.go b/internal/commands/pro/generated/patch_software_title_configurations.go index 27a99da8..d6601d2c 100644 --- a/internal/commands/pro/generated/patch_software_title_configurations.go +++ b/internal/commands/pro/generated/patch_software_title_configurations.go @@ -308,20 +308,33 @@ func newPatchSoftwareTitleConfigurationsDeleteCmd(ctx *registry.CLIContext) *cob if err := cooldown.Enforce(ctx.ProfileName, noInputBulk, ctx.DestructiveCooldown); err != nil { return err } + var okCount, failCount int + var firstErr error for _, e := range bulk { delPath := strings.Replace("/v2/patch-software-title-configurations/{id}", "{id}", url.PathEscape(e.id), 1) 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) + fmt.Fprintf(os.Stderr, "delete patch-software-title-configuration %q (id: %s) failed: %v\n", e.label, e.id, err) + if firstErr == nil { + firstErr = err + } + failCount++ + continue } resp.Body.Close() if resp.StatusCode < 200 || resp.StatusCode >= 300 { - return fmt.Errorf("delete %q (id: %s): HTTP %d", e.label, e.id, resp.StatusCode) + fmt.Fprintf(os.Stderr, "delete patch-software-title-configuration %q (id: %s) failed: HTTP %d\n", e.label, e.id, resp.StatusCode) + if firstErr == nil { + firstErr = fmt.Errorf("HTTP %d", resp.StatusCode) + } + failCount++ + continue } fmt.Fprintf(os.Stderr, "Deleted patch-software-title-configuration %q (id: %s)\n", e.label, e.id) + okCount++ } cooldown.Record(ctx.ProfileName) - return nil + return batchDeleteError(cmd, okCount, failCount, firstErr, "patch-software-title-configurations deletes") } // Resolve resource ID from positional arg, --name, or lookup flags diff --git a/internal/commands/pro/generated/registry.go b/internal/commands/pro/generated/registry.go index 14856703..7cc7b5f1 100644 --- a/internal/commands/pro/generated/registry.go +++ b/internal/commands/pro/generated/registry.go @@ -190,6 +190,24 @@ func RegisterCommands(root *cobra.Command, ctx *registry.CLIContext) { root.AddCommand(NewVppSubscriptionsCmd(ctx)) } +// batchDeleteError maps a bulk-delete tally to the process result. When some +// items succeeded and some failed it returns a PartialFailure (exit 7), unless +// --allow-partial-failure is set, in which case it warns and returns nil. A +// total failure propagates firstErr's exit code. +func batchDeleteError(cmd *cobra.Command, succeeded, failed int, firstErr error, noun string) error { + if failed == 0 { + return nil + } + allow, _ := cmd.Flags().GetBool("allow-partial-failure") + if succeeded > 0 && allow { + fmt.Fprintf(os.Stderr, "warning: %d of %d %s failed; continuing (--allow-partial-failure)\n", + failed, succeeded+failed, noun) + return nil + } + return exitcode.PartialOrPropagate(succeeded, failed, firstErr, + fmt.Sprintf("%d of %d %s failed", failed, succeeded+failed, noun)) +} + // resolveNameToID looks up a resource by name using a filtered list call and // returns its ID. This enables --name as an alternative to positional ID args // on get commands. The nameField parameter specifies the filter field diff --git a/internal/commands/pro/generated/return_to_service_configurations.go b/internal/commands/pro/generated/return_to_service_configurations.go index 1d3a9703..75e81ea5 100644 --- a/internal/commands/pro/generated/return_to_service_configurations.go +++ b/internal/commands/pro/generated/return_to_service_configurations.go @@ -378,20 +378,33 @@ func newReturnToServiceConfigurationsDeleteCmd(ctx *registry.CLIContext) *cobra. if err := cooldown.Enforce(ctx.ProfileName, noInputBulk, ctx.DestructiveCooldown); err != nil { return err } + var okCount, failCount int + var firstErr error for _, e := range bulk { delPath := strings.Replace("/v1/return-to-service/{id}", "{id}", url.PathEscape(e.id), 1) 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) + fmt.Fprintf(os.Stderr, "delete return-to-service-configuration %q (id: %s) failed: %v\n", e.label, e.id, err) + if firstErr == nil { + firstErr = err + } + failCount++ + continue } resp.Body.Close() if resp.StatusCode < 200 || resp.StatusCode >= 300 { - return fmt.Errorf("delete %q (id: %s): HTTP %d", e.label, e.id, resp.StatusCode) + fmt.Fprintf(os.Stderr, "delete return-to-service-configuration %q (id: %s) failed: HTTP %d\n", e.label, e.id, resp.StatusCode) + if firstErr == nil { + firstErr = fmt.Errorf("HTTP %d", resp.StatusCode) + } + failCount++ + continue } fmt.Fprintf(os.Stderr, "Deleted return-to-service-configuration %q (id: %s)\n", e.label, e.id) + okCount++ } cooldown.Record(ctx.ProfileName) - return nil + return batchDeleteError(cmd, okCount, failCount, firstErr, "return-to-service-configurations deletes") } // Resolve resource ID from positional arg, --name, or lookup flags diff --git a/internal/commands/pro/generated/scripts.go b/internal/commands/pro/generated/scripts.go index 0729b4f9..1b9ed7e1 100644 --- a/internal/commands/pro/generated/scripts.go +++ b/internal/commands/pro/generated/scripts.go @@ -529,20 +529,33 @@ func newScriptsDeleteCmd(ctx *registry.CLIContext) *cobra.Command { if err := cooldown.Enforce(ctx.ProfileName, noInputBulk, ctx.DestructiveCooldown); err != nil { return err } + var okCount, failCount int + var firstErr error for _, e := range bulk { delPath := strings.Replace("/v1/scripts/{id}", "{id}", url.PathEscape(e.id), 1) 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) + fmt.Fprintf(os.Stderr, "delete script %q (id: %s) failed: %v\n", e.label, e.id, err) + if firstErr == nil { + firstErr = err + } + failCount++ + continue } resp.Body.Close() if resp.StatusCode < 200 || resp.StatusCode >= 300 { - return fmt.Errorf("delete %q (id: %s): HTTP %d", e.label, e.id, resp.StatusCode) + fmt.Fprintf(os.Stderr, "delete script %q (id: %s) failed: HTTP %d\n", e.label, e.id, resp.StatusCode) + if firstErr == nil { + firstErr = fmt.Errorf("HTTP %d", resp.StatusCode) + } + failCount++ + continue } fmt.Fprintf(os.Stderr, "Deleted script %q (id: %s)\n", e.label, e.id) + okCount++ } cooldown.Record(ctx.ProfileName) - return nil + return batchDeleteError(cmd, okCount, failCount, firstErr, "scripts deletes") } // Resolve resource ID from positional arg, --name, or lookup flags diff --git a/internal/commands/pro/generated/self_service_branding_ios_resource.go b/internal/commands/pro/generated/self_service_branding_ios_resource.go index 25511f6c..d26286b0 100644 --- a/internal/commands/pro/generated/self_service_branding_ios_resource.go +++ b/internal/commands/pro/generated/self_service_branding_ios_resource.go @@ -474,20 +474,33 @@ func newSelfServiceBrandingIosDeleteCmd(ctx *registry.CLIContext) *cobra.Command if err := cooldown.Enforce(ctx.ProfileName, noInputBulk, ctx.DestructiveCooldown); err != nil { return err } + var okCount, failCount int + var firstErr error for _, e := range bulk { delPath := strings.Replace("/v1/self-service/branding/ios/{id}", "{id}", url.PathEscape(e.id), 1) 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) + fmt.Fprintf(os.Stderr, "delete self-service-branding-ios %q (id: %s) failed: %v\n", e.label, e.id, err) + if firstErr == nil { + firstErr = err + } + failCount++ + continue } resp.Body.Close() if resp.StatusCode < 200 || resp.StatusCode >= 300 { - return fmt.Errorf("delete %q (id: %s): HTTP %d", e.label, e.id, resp.StatusCode) + fmt.Fprintf(os.Stderr, "delete self-service-branding-ios %q (id: %s) failed: HTTP %d\n", e.label, e.id, resp.StatusCode) + if firstErr == nil { + firstErr = fmt.Errorf("HTTP %d", resp.StatusCode) + } + failCount++ + continue } fmt.Fprintf(os.Stderr, "Deleted self-service-branding-ios %q (id: %s)\n", e.label, e.id) + okCount++ } cooldown.Record(ctx.ProfileName) - return nil + return batchDeleteError(cmd, okCount, failCount, firstErr, "self-service-branding-ios deletes") } // Resolve resource ID from positional arg, --name, or lookup flags diff --git a/internal/commands/pro/generated/self_service_branding_macos.go b/internal/commands/pro/generated/self_service_branding_macos.go index 0d73255e..1cfc0ddd 100644 --- a/internal/commands/pro/generated/self_service_branding_macos.go +++ b/internal/commands/pro/generated/self_service_branding_macos.go @@ -476,20 +476,33 @@ func newSelfServiceBrandingMacosDeleteCmd(ctx *registry.CLIContext) *cobra.Comma if err := cooldown.Enforce(ctx.ProfileName, noInputBulk, ctx.DestructiveCooldown); err != nil { return err } + var okCount, failCount int + var firstErr error for _, e := range bulk { delPath := strings.Replace("/v1/self-service/branding/macos/{id}", "{id}", url.PathEscape(e.id), 1) 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) + fmt.Fprintf(os.Stderr, "delete self-service-branding-macos %q (id: %s) failed: %v\n", e.label, e.id, err) + if firstErr == nil { + firstErr = err + } + failCount++ + continue } resp.Body.Close() if resp.StatusCode < 200 || resp.StatusCode >= 300 { - return fmt.Errorf("delete %q (id: %s): HTTP %d", e.label, e.id, resp.StatusCode) + fmt.Fprintf(os.Stderr, "delete self-service-branding-macos %q (id: %s) failed: HTTP %d\n", e.label, e.id, resp.StatusCode) + if firstErr == nil { + firstErr = fmt.Errorf("HTTP %d", resp.StatusCode) + } + failCount++ + continue } fmt.Fprintf(os.Stderr, "Deleted self-service-branding-macos %q (id: %s)\n", e.label, e.id) + okCount++ } cooldown.Record(ctx.ProfileName) - return nil + return batchDeleteError(cmd, okCount, failCount, firstErr, "self-service-branding-macos deletes") } // Resolve resource ID from positional arg, --name, or lookup flags diff --git a/internal/commands/pro/generated/static_computer_groups.go b/internal/commands/pro/generated/static_computer_groups.go index e22d4e58..6170b529 100644 --- a/internal/commands/pro/generated/static_computer_groups.go +++ b/internal/commands/pro/generated/static_computer_groups.go @@ -480,20 +480,33 @@ func newStaticComputerGroupsDeleteCmd(ctx *registry.CLIContext) *cobra.Command { if err := cooldown.Enforce(ctx.ProfileName, noInputBulk, ctx.DestructiveCooldown); err != nil { return err } + var okCount, failCount int + var firstErr error for _, e := range bulk { delPath := strings.Replace("/v2/computer-groups/static-groups/{id}", "{id}", url.PathEscape(e.id), 1) 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) + fmt.Fprintf(os.Stderr, "delete static-computer-group %q (id: %s) failed: %v\n", e.label, e.id, err) + if firstErr == nil { + firstErr = err + } + failCount++ + continue } resp.Body.Close() if resp.StatusCode < 200 || resp.StatusCode >= 300 { - return fmt.Errorf("delete %q (id: %s): HTTP %d", e.label, e.id, resp.StatusCode) + fmt.Fprintf(os.Stderr, "delete static-computer-group %q (id: %s) failed: HTTP %d\n", e.label, e.id, resp.StatusCode) + if firstErr == nil { + firstErr = fmt.Errorf("HTTP %d", resp.StatusCode) + } + failCount++ + continue } fmt.Fprintf(os.Stderr, "Deleted static-computer-group %q (id: %s)\n", e.label, e.id) + okCount++ } cooldown.Record(ctx.ProfileName) - return nil + return batchDeleteError(cmd, okCount, failCount, firstErr, "static-computer-groups deletes") } // Resolve resource ID from positional arg, --name, or lookup flags diff --git a/internal/commands/pro/generated/supervision_identities.go b/internal/commands/pro/generated/supervision_identities.go index d1e6ccd4..0a735af1 100644 --- a/internal/commands/pro/generated/supervision_identities.go +++ b/internal/commands/pro/generated/supervision_identities.go @@ -475,20 +475,33 @@ func newSupervisionIdentitiesDeleteCmd(ctx *registry.CLIContext) *cobra.Command if err := cooldown.Enforce(ctx.ProfileName, noInputBulk, ctx.DestructiveCooldown); err != nil { return err } + var okCount, failCount int + var firstErr error for _, e := range bulk { delPath := strings.Replace("/v1/supervision-identities/{id}", "{id}", url.PathEscape(e.id), 1) 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) + fmt.Fprintf(os.Stderr, "delete supervision-identity %q (id: %s) failed: %v\n", e.label, e.id, err) + if firstErr == nil { + firstErr = err + } + failCount++ + continue } resp.Body.Close() if resp.StatusCode < 200 || resp.StatusCode >= 300 { - return fmt.Errorf("delete %q (id: %s): HTTP %d", e.label, e.id, resp.StatusCode) + fmt.Fprintf(os.Stderr, "delete supervision-identity %q (id: %s) failed: HTTP %d\n", e.label, e.id, resp.StatusCode) + if firstErr == nil { + firstErr = fmt.Errorf("HTTP %d", resp.StatusCode) + } + failCount++ + continue } fmt.Fprintf(os.Stderr, "Deleted supervision-identity %q (id: %s)\n", e.label, e.id) + okCount++ } cooldown.Record(ctx.ProfileName) - return nil + return batchDeleteError(cmd, okCount, failCount, firstErr, "supervision-identities deletes") } // Resolve resource ID from positional arg, --name, or lookup flags diff --git a/internal/commands/pro/generated/team_viewer_remote_administrations.go b/internal/commands/pro/generated/team_viewer_remote_administrations.go index 8376cf12..9b1d6424 100644 --- a/internal/commands/pro/generated/team_viewer_remote_administrations.go +++ b/internal/commands/pro/generated/team_viewer_remote_administrations.go @@ -260,20 +260,33 @@ func newTeamViewerRemoteAdministrationsDeleteCmd(ctx *registry.CLIContext) *cobr if err := cooldown.Enforce(ctx.ProfileName, noInputBulk, ctx.DestructiveCooldown); err != nil { return err } + var okCount, failCount int + var firstErr error for _, e := range bulk { delPath := strings.Replace("/preview/remote-administration-configurations/team-viewer/{id}", "{id}", url.PathEscape(e.id), 1) 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) + fmt.Fprintf(os.Stderr, "delete team-viewer-remote-administration %q (id: %s) failed: %v\n", e.label, e.id, err) + if firstErr == nil { + firstErr = err + } + failCount++ + continue } resp.Body.Close() if resp.StatusCode < 200 || resp.StatusCode >= 300 { - return fmt.Errorf("delete %q (id: %s): HTTP %d", e.label, e.id, resp.StatusCode) + fmt.Fprintf(os.Stderr, "delete team-viewer-remote-administration %q (id: %s) failed: HTTP %d\n", e.label, e.id, resp.StatusCode) + if firstErr == nil { + firstErr = fmt.Errorf("HTTP %d", resp.StatusCode) + } + failCount++ + continue } fmt.Fprintf(os.Stderr, "Deleted team-viewer-remote-administration %q (id: %s)\n", e.label, e.id) + okCount++ } cooldown.Record(ctx.ProfileName) - return nil + return batchDeleteError(cmd, okCount, failCount, firstErr, "team-viewer-remote-administrations deletes") } // Resolve resource ID from positional arg, --name, or lookup flags diff --git a/internal/commands/pro/generated/user_accounts.go b/internal/commands/pro/generated/user_accounts.go index 9e5b6e10..aa84d52a 100644 --- a/internal/commands/pro/generated/user_accounts.go +++ b/internal/commands/pro/generated/user_accounts.go @@ -493,20 +493,33 @@ func newUserAccountsDeleteCmd(ctx *registry.CLIContext) *cobra.Command { if err := cooldown.Enforce(ctx.ProfileName, noInputBulk, ctx.DestructiveCooldown); err != nil { return err } + var okCount, failCount int + var firstErr error for _, e := range bulk { delPath := strings.Replace("/v1/accounts/{id}", "{id}", url.PathEscape(e.id), 1) 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) + fmt.Fprintf(os.Stderr, "delete user-account %q (id: %s) failed: %v\n", e.label, e.id, err) + if firstErr == nil { + firstErr = err + } + failCount++ + continue } resp.Body.Close() if resp.StatusCode < 200 || resp.StatusCode >= 300 { - return fmt.Errorf("delete %q (id: %s): HTTP %d", e.label, e.id, resp.StatusCode) + fmt.Fprintf(os.Stderr, "delete user-account %q (id: %s) failed: HTTP %d\n", e.label, e.id, resp.StatusCode) + if firstErr == nil { + firstErr = fmt.Errorf("HTTP %d", resp.StatusCode) + } + failCount++ + continue } fmt.Fprintf(os.Stderr, "Deleted user-account %q (id: %s)\n", e.label, e.id) + okCount++ } cooldown.Record(ctx.ProfileName) - return nil + return batchDeleteError(cmd, okCount, failCount, firstErr, "user-accounts deletes") } // Resolve resource ID from positional arg, --name, or lookup flags diff --git a/internal/commands/pro/generated/user_preferences.go b/internal/commands/pro/generated/user_preferences.go index e24757d5..92ceb2b2 100644 --- a/internal/commands/pro/generated/user_preferences.go +++ b/internal/commands/pro/generated/user_preferences.go @@ -260,20 +260,33 @@ func newUserPreferencesDeleteCmd(ctx *registry.CLIContext) *cobra.Command { if err := cooldown.Enforce(ctx.ProfileName, noInputBulk, ctx.DestructiveCooldown); err != nil { return err } + var okCount, failCount int + var firstErr error for _, e := range bulk { delPath := strings.Replace("/v1/user/preferences/{keyId}", "{keyId}", url.PathEscape(e.id), 1) 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) + fmt.Fprintf(os.Stderr, "delete user-preference %q (id: %s) failed: %v\n", e.label, e.id, err) + if firstErr == nil { + firstErr = err + } + failCount++ + continue } resp.Body.Close() if resp.StatusCode < 200 || resp.StatusCode >= 300 { - return fmt.Errorf("delete %q (id: %s): HTTP %d", e.label, e.id, resp.StatusCode) + fmt.Fprintf(os.Stderr, "delete user-preference %q (id: %s) failed: HTTP %d\n", e.label, e.id, resp.StatusCode) + if firstErr == nil { + firstErr = fmt.Errorf("HTTP %d", resp.StatusCode) + } + failCount++ + continue } fmt.Fprintf(os.Stderr, "Deleted user-preference %q (id: %s)\n", e.label, e.id) + okCount++ } cooldown.Record(ctx.ProfileName) - return nil + return batchDeleteError(cmd, okCount, failCount, firstErr, "user-preferences deletes") } // Resolve resource ID from positional arg, --name, or lookup flags diff --git a/internal/commands/pro/generated/users.go b/internal/commands/pro/generated/users.go index 7cecb41b..5622ce35 100644 --- a/internal/commands/pro/generated/users.go +++ b/internal/commands/pro/generated/users.go @@ -498,20 +498,33 @@ func newUsersDeleteCmd(ctx *registry.CLIContext) *cobra.Command { if err := cooldown.Enforce(ctx.ProfileName, noInputBulk, ctx.DestructiveCooldown); err != nil { return err } + var okCount, failCount int + var firstErr error for _, e := range bulk { delPath := strings.Replace("/v1/users/{id}", "{id}", url.PathEscape(e.id), 1) 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) + fmt.Fprintf(os.Stderr, "delete user %q (id: %s) failed: %v\n", e.label, e.id, err) + if firstErr == nil { + firstErr = err + } + failCount++ + continue } resp.Body.Close() if resp.StatusCode < 200 || resp.StatusCode >= 300 { - return fmt.Errorf("delete %q (id: %s): HTTP %d", e.label, e.id, resp.StatusCode) + fmt.Fprintf(os.Stderr, "delete user %q (id: %s) failed: HTTP %d\n", e.label, e.id, resp.StatusCode) + if firstErr == nil { + firstErr = fmt.Errorf("HTTP %d", resp.StatusCode) + } + failCount++ + continue } fmt.Fprintf(os.Stderr, "Deleted user %q (id: %s)\n", e.label, e.id) + okCount++ } cooldown.Record(ctx.ProfileName) - return nil + return batchDeleteError(cmd, okCount, failCount, firstErr, "users deletes") } // Resolve resource ID from positional arg, --name, or lookup flags diff --git a/internal/commands/pro/generated/venafis.go b/internal/commands/pro/generated/venafis.go index 20e96b3d..1f2ab0b6 100644 --- a/internal/commands/pro/generated/venafis.go +++ b/internal/commands/pro/generated/venafis.go @@ -263,20 +263,33 @@ func newVenafisDeleteCmd(ctx *registry.CLIContext) *cobra.Command { if err := cooldown.Enforce(ctx.ProfileName, noInputBulk, ctx.DestructiveCooldown); err != nil { return err } + var okCount, failCount int + var firstErr error for _, e := range bulk { delPath := strings.Replace("/v1/pki/venafi/{id}", "{id}", url.PathEscape(e.id), 1) 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) + fmt.Fprintf(os.Stderr, "delete venafi %q (id: %s) failed: %v\n", e.label, e.id, err) + if firstErr == nil { + firstErr = err + } + failCount++ + continue } resp.Body.Close() if resp.StatusCode < 200 || resp.StatusCode >= 300 { - return fmt.Errorf("delete %q (id: %s): HTTP %d", e.label, e.id, resp.StatusCode) + fmt.Fprintf(os.Stderr, "delete venafi %q (id: %s) failed: HTTP %d\n", e.label, e.id, resp.StatusCode) + if firstErr == nil { + firstErr = fmt.Errorf("HTTP %d", resp.StatusCode) + } + failCount++ + continue } fmt.Fprintf(os.Stderr, "Deleted venafi %q (id: %s)\n", e.label, e.id) + okCount++ } cooldown.Record(ctx.ProfileName) - return nil + return batchDeleteError(cmd, okCount, failCount, firstErr, "venafis deletes") } // Resolve resource ID from positional arg, --name, or lookup flags diff --git a/internal/commands/pro/generated/vpp_locations.go b/internal/commands/pro/generated/vpp_locations.go index 8a7d7b42..66408894 100644 --- a/internal/commands/pro/generated/vpp_locations.go +++ b/internal/commands/pro/generated/vpp_locations.go @@ -413,20 +413,33 @@ func newVppLocationsDeleteCmd(ctx *registry.CLIContext) *cobra.Command { if err := cooldown.Enforce(ctx.ProfileName, noInputBulk, ctx.DestructiveCooldown); err != nil { return err } + var okCount, failCount int + var firstErr error for _, e := range bulk { delPath := strings.Replace("/v1/volume-purchasing-locations/{id}", "{id}", url.PathEscape(e.id), 1) 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) + fmt.Fprintf(os.Stderr, "delete vpp-location %q (id: %s) failed: %v\n", e.label, e.id, err) + if firstErr == nil { + firstErr = err + } + failCount++ + continue } resp.Body.Close() if resp.StatusCode < 200 || resp.StatusCode >= 300 { - return fmt.Errorf("delete %q (id: %s): HTTP %d", e.label, e.id, resp.StatusCode) + fmt.Fprintf(os.Stderr, "delete vpp-location %q (id: %s) failed: HTTP %d\n", e.label, e.id, resp.StatusCode) + if firstErr == nil { + firstErr = fmt.Errorf("HTTP %d", resp.StatusCode) + } + failCount++ + continue } fmt.Fprintf(os.Stderr, "Deleted vpp-location %q (id: %s)\n", e.label, e.id) + okCount++ } cooldown.Record(ctx.ProfileName) - return nil + return batchDeleteError(cmd, okCount, failCount, firstErr, "vpp-locations deletes") } // Resolve resource ID from positional arg, --name, or lookup flags diff --git a/internal/commands/pro/generated/vpp_subscriptions.go b/internal/commands/pro/generated/vpp_subscriptions.go index 95950bcb..7b403f02 100644 --- a/internal/commands/pro/generated/vpp_subscriptions.go +++ b/internal/commands/pro/generated/vpp_subscriptions.go @@ -478,20 +478,33 @@ func newVppSubscriptionsDeleteCmd(ctx *registry.CLIContext) *cobra.Command { if err := cooldown.Enforce(ctx.ProfileName, noInputBulk, ctx.DestructiveCooldown); err != nil { return err } + var okCount, failCount int + var firstErr error for _, e := range bulk { delPath := strings.Replace("/v1/volume-purchasing-subscriptions/{id}", "{id}", url.PathEscape(e.id), 1) 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) + fmt.Fprintf(os.Stderr, "delete vpp-subscription %q (id: %s) failed: %v\n", e.label, e.id, err) + if firstErr == nil { + firstErr = err + } + failCount++ + continue } resp.Body.Close() if resp.StatusCode < 200 || resp.StatusCode >= 300 { - return fmt.Errorf("delete %q (id: %s): HTTP %d", e.label, e.id, resp.StatusCode) + fmt.Fprintf(os.Stderr, "delete vpp-subscription %q (id: %s) failed: HTTP %d\n", e.label, e.id, resp.StatusCode) + if firstErr == nil { + firstErr = fmt.Errorf("HTTP %d", resp.StatusCode) + } + failCount++ + continue } fmt.Fprintf(os.Stderr, "Deleted vpp-subscription %q (id: %s)\n", e.label, e.id) + okCount++ } cooldown.Record(ctx.ProfileName) - return nil + return batchDeleteError(cmd, okCount, failCount, firstErr, "vpp-subscriptions deletes") } // Resolve resource ID from positional arg, --name, or lookup flags From 4a3e93ef4f021587c7fa0a6c61c09c760f07b757 Mon Sep 17 00:00:00 2001 From: Keaton Svoma Date: Sun, 31 May 2026 02:00:22 -0500 Subject: [PATCH 09/15] feat(errors): structured Hint on HTTP errors, surfaced on stderr and in JSON Move per-status remediation out of the message and into exitcode.Error.Hint via a pure httpStatusError helper. main prints a "hint:" line for human output; the JSON envelope gains hint, exitCodeName, and any structured details. Co-Authored-By: Claude Opus 4.8 (1M context) --- cmd/jamf-cli/main.go | 2 +- internal/client/client.go | 39 +++++++++++------ internal/client/status_error_test.go | 45 ++++++++++++++++++++ internal/commands/error_surface_test.go | 56 +++++++++++++++++++++++++ internal/commands/root.go | 35 ++++++++++++++-- 5 files changed, 160 insertions(+), 17 deletions(-) create mode 100644 internal/client/status_error_test.go create mode 100644 internal/commands/error_surface_test.go diff --git a/cmd/jamf-cli/main.go b/cmd/jamf-cli/main.go index 274ec670..b2ed324f 100644 --- a/cmd/jamf-cli/main.go +++ b/cmd/jamf-cli/main.go @@ -46,7 +46,7 @@ func main() { cmd := commands.NewRootCmd(version, commit, date, specProVersion) if err := cmd.Execute(); err != nil { if !commands.FormatError(err) { - fmt.Fprintln(os.Stderr, err) + commands.FprintError(os.Stderr, err) } os.Exit(exitcode.CodeFrom(err)) } diff --git a/internal/client/client.go b/internal/client/client.go index 7750f3e8..26831839 100644 --- a/internal/client/client.go +++ b/internal/client/client.go @@ -161,18 +161,7 @@ func (c *Client) Do(ctx context.Context, method, path string, body io.Reader) (* if c.verboseLevel >= 3 { logBody(os.Stderr, body) } - switch resp.StatusCode { - case http.StatusUnauthorized: - return nil, exitcode.New(exitcode.Authentication, fmt.Sprintf("authentication failed (HTTP 401): %s\nCheck your credentials with: jamf-cli config validate", string(body))) - case http.StatusForbidden: - return nil, exitcode.New(exitcode.PermissionDenied, fmt.Sprintf("permission denied (HTTP 403): %s\nThe authenticated account lacks the required API privileges.", string(body))) - case http.StatusNotFound: - return nil, exitcode.New(exitcode.NotFound, fmt.Sprintf("resource not found (HTTP 404): %s %s", method, path)) - case http.StatusTooManyRequests: - return nil, exitcode.New(exitcode.RateLimited, "rate limited (HTTP 429): server is throttling requests, wait a moment and try again") - default: - return nil, exitcode.Wrap(exitcode.General, fmt.Errorf("request failed (HTTP %d): %s", resp.StatusCode, string(body))) - } + return nil, httpStatusError(resp.StatusCode, method, path, body) } if c.verboseLevel >= 3 { @@ -416,6 +405,32 @@ func logHeaders(w io.Writer, h http.Header, redactAuth bool) { } } +// httpStatusError maps an HTTP error response to a structured exit error with a +// short message and a separate remediation Hint (surfaced on stderr and in the +// JSON error envelope). +func httpStatusError(status int, method, path string, body []byte) error { + switch status { + case http.StatusUnauthorized: + return exitcode.New(exitcode.Authentication, + fmt.Sprintf("authentication failed (HTTP 401): %s", string(body))). + WithHint("run 'jamf-cli config validate', or check JAMF_TOKEN / client credentials") + case http.StatusForbidden: + return exitcode.New(exitcode.PermissionDenied, + fmt.Sprintf("permission denied (HTTP 403): %s", string(body))). + WithHint("the authenticated account lacks the required API privileges; check its API role") + case http.StatusNotFound: + return exitcode.New(exitcode.NotFound, + fmt.Sprintf("resource not found (HTTP 404): %s %s", method, path)). + WithHint("run the matching 'list' command to see valid IDs/names") + case http.StatusTooManyRequests: + return exitcode.New(exitcode.RateLimited, + "rate limited (HTTP 429): server is throttling requests"). + WithHint("retry shortly, or lower batch concurrency") + default: + return exitcode.Wrap(exitcode.General, fmt.Errorf("request failed (HTTP %d): %s", status, string(body))) + } +} + // logBody prints body bytes to w indented by four spaces. Truncates at bodyLogLimit // and notes when truncation occurs. func logBody(w io.Writer, data []byte) { diff --git a/internal/client/status_error_test.go b/internal/client/status_error_test.go new file mode 100644 index 00000000..b293c3c1 --- /dev/null +++ b/internal/client/status_error_test.go @@ -0,0 +1,45 @@ +// Copyright 2026, Jamf Software LLC + +package client + +import ( + "errors" + "strings" + "testing" + + "github.com/Jamf-Concepts/jamf-cli/internal/exitcode" +) + +func TestHTTPStatusError_Hints(t *testing.T) { + cases := []struct { + status int + code int + hint string // substring (lowercased) + }{ + {401, exitcode.Authentication, "config validate"}, + {403, exitcode.PermissionDenied, "api privileges"}, + {404, exitcode.NotFound, "list"}, + {429, exitcode.RateLimited, "retry"}, + } + for _, c := range cases { + err := httpStatusError(c.status, "GET", "/api/v1/x", []byte("body")) + var e *exitcode.Error + if !errors.As(err, &e) { + t.Fatalf("status %d: not an *exitcode.Error", c.status) + } + if e.Code != c.code { + t.Fatalf("status %d: code = %d, want %d", c.status, e.Code, c.code) + } + if !strings.Contains(strings.ToLower(e.Hint), c.hint) { + t.Fatalf("status %d: hint %q missing %q", c.status, e.Hint, c.hint) + } + } + + // Unmapped status -> General, no hint. + var e *exitcode.Error + if errors.As(httpStatusError(500, "GET", "/x", []byte("boom")), &e) { + if e.Code != exitcode.General { + t.Fatalf("500 code = %d, want General(%d)", e.Code, exitcode.General) + } + } +} diff --git a/internal/commands/error_surface_test.go b/internal/commands/error_surface_test.go new file mode 100644 index 00000000..8481dc54 --- /dev/null +++ b/internal/commands/error_surface_test.go @@ -0,0 +1,56 @@ +// Copyright 2026, Jamf Software LLC + +package commands + +import ( + "bytes" + "strings" + "testing" + + "github.com/Jamf-Concepts/jamf-cli/internal/exitcode" +) + +func TestFormatErrorTo_IncludesHint(t *testing.T) { + old := outputFmt + outputFmt = "json" + defer func() { outputFmt = old }() + + var buf bytes.Buffer + err := exitcode.New(exitcode.NotFound, "missing").WithHint("run list") + if !formatErrorTo(&buf, err) { + t.Fatal("formatErrorTo returned false") + } + out := buf.String() + if !strings.Contains(out, `"hint"`) || !strings.Contains(out, "run list") { + t.Fatalf("hint not in envelope:\n%s", out) + } + if !strings.Contains(out, `"exitCodeName"`) { + t.Fatalf("exitCodeName not in envelope:\n%s", out) + } +} + +func TestFormatErrorTo_NonJSONReturnsFalse(t *testing.T) { + old := outputFmt + outputFmt = "table" + defer func() { outputFmt = old }() + if formatErrorTo(&bytes.Buffer{}, exitcode.New(exitcode.General, "x")) { + t.Fatal("formatErrorTo should return false when output is not json") + } +} + +func TestFprintError_PlainHintLine(t *testing.T) { + var buf bytes.Buffer + FprintError(&buf, exitcode.New(exitcode.Authentication, "auth failed").WithHint("re-auth")) + out := buf.String() + if !strings.Contains(out, "auth failed") || !strings.Contains(out, "hint: re-auth") { + t.Fatalf("plain error missing message or hint line:\n%s", out) + } +} + +func TestFprintError_NoHintNoLine(t *testing.T) { + var buf bytes.Buffer + FprintError(&buf, exitcode.New(exitcode.General, "plain error")) + if strings.Contains(buf.String(), "hint:") { + t.Fatalf("should not print hint line when none set:\n%s", buf.String()) + } +} diff --git a/internal/commands/root.go b/internal/commands/root.go index 82edf6a2..7a76d35b 100644 --- a/internal/commands/root.go +++ b/internal/commands/root.go @@ -5,6 +5,7 @@ package commands import ( "context" "encoding/json" + "errors" "fmt" "io" "net/http" @@ -1230,19 +1231,45 @@ func resolveSchoolClient(cfg *config.Config, cliCtx *registry.CLIContext) error // is "json". Returns true if the error was handled, false otherwise (caller // should fall back to plain stderr). func FormatError(err error) bool { + return formatErrorTo(os.Stdout, err) +} + +// formatErrorTo writes the JSON error envelope to w when output is "json", +// including the remediation hint and any structured details when present. +func formatErrorTo(w io.Writer, err error) bool { if outputFmt != "json" { return false } code := exitcode.CodeFrom(err) envelope := map[string]any{ - "error": exitcode.CodeName(code), - "message": err.Error(), - "exitCode": code, + "error": exitcode.CodeName(code), + "message": err.Error(), + "exitCode": code, + "exitCodeName": exitcode.CodeName(code), + } + var e *exitcode.Error + if errors.As(err, &e) { + if e.Hint != "" { + envelope["hint"] = e.Hint + } + for k, v := range e.Details { + envelope[k] = v + } } - enc := json.NewEncoder(os.Stdout) + enc := json.NewEncoder(w) enc.SetIndent("", " ") if err := enc.Encode(envelope); err != nil { return false // stdout broken (e.g. SIGPIPE); fall back to stderr } return true } + +// FprintError writes a human-facing error (and a "hint:" line when present) to +// w. Used by main when the JSON envelope path does not apply. +func FprintError(w io.Writer, err error) { + fmt.Fprintln(w, err) + var e *exitcode.Error + if errors.As(err, &e) && e.Hint != "" { + fmt.Fprintf(w, "hint: %s\n", e.Hint) + } +} From 581d76a190ce49be31d13412f8e55f900af754b0 Mon Sep 17 00:00:00 2001 From: Keaton Svoma Date: Sun, 31 May 2026 02:01:51 -0500 Subject: [PATCH 10/15] feat(errors): suggest nearest flag on unknown-flag typos Enable cobra command suggestions (SuggestionsMinimumDistance) and add a FlagErrorFunc that prints "hint: did you mean --X?" for the nearest known flag, then classifies the failure as a usage error (exit 2). Co-Authored-By: Claude Opus 4.8 (1M context) --- internal/commands/flag_suggest_test.go | 18 ++++++++++ internal/commands/root.go | 47 ++++++++++++++++++++++++++ 2 files changed, 65 insertions(+) create mode 100644 internal/commands/flag_suggest_test.go diff --git a/internal/commands/flag_suggest_test.go b/internal/commands/flag_suggest_test.go new file mode 100644 index 00000000..ea535251 --- /dev/null +++ b/internal/commands/flag_suggest_test.go @@ -0,0 +1,18 @@ +// Copyright 2026, Jamf Software LLC + +package commands + +import "testing" + +func TestSuggestFlag(t *testing.T) { + known := []string{"compact", "output", "quiet", "verbose"} + if got := suggestFlag("compcat", known); got != "compact" { + t.Fatalf("suggestFlag(compcat) = %q, want compact", got) + } + if got := suggestFlag("ouput", known); got != "output" { + t.Fatalf("suggestFlag(ouput) = %q, want output", got) + } + if got := suggestFlag("zzzzzz", known); got != "" { + t.Fatalf("suggestFlag(zzzzzz) = %q, want empty (too far)", got) + } +} diff --git a/internal/commands/root.go b/internal/commands/root.go index 7a76d35b..1e79146f 100644 --- a/internal/commands/root.go +++ b/internal/commands/root.go @@ -729,6 +729,23 @@ spinner and progress output (narrower than --quiet).`, // Custom version template so --version matches the `version` subcommand output cmd.SetVersionTemplate(fmt.Sprintf("jamf-cli %s\n commit: %s\n built: %s\n", version, commit, date)) + // Suggest the nearest command for typos ("did you mean ..."). + cmd.SuggestionsMinimumDistance = 2 + // Suggest the nearest flag for unknown-flag typos, then classify as a usage + // error (exit 2) so the exit code matches the helpers.go contract. + cmd.SetFlagErrorFunc(func(c *cobra.Command, ferr error) error { + const marker = "unknown flag: --" + if i := strings.Index(ferr.Error(), marker); i >= 0 { + bad := strings.SplitN(ferr.Error()[i+len(marker):], " ", 2)[0] + var known []string + c.Flags().VisitAll(func(f *pflag.Flag) { known = append(known, f.Name) }) + if s := suggestFlag(bad, known); s != "" { + fmt.Fprintf(os.Stderr, "hint: did you mean --%s?\n", s) + } + } + return exitcode.Wrap(exitcode.Usage, ferr) + }) + // Global flags cmd.PersistentFlags().StringVarP(&profile, "profile", "p", "", "config profile to use (or JAMF_PROFILE env)") cmd.PersistentFlags().StringVarP(&outputFmt, "output", "o", "json", "output format: table, json, csv, yaml, plain, xml (pretty), raw (classic commands default to xml)") @@ -1273,3 +1290,33 @@ func FprintError(w io.Writer, err error) { fmt.Fprintf(w, "hint: %s\n", e.Hint) } } + +// suggestFlag returns the closest known flag name within edit distance 2, or "". +func suggestFlag(unknown string, known []string) string { + best, bestDist := "", 3 + for _, k := range known { + if d := levenshtein(unknown, k); d < bestDist { + best, bestDist = k, d + } + } + return best +} + +func levenshtein(a, b string) int { + prev := make([]int, len(b)+1) + for j := range prev { + prev[j] = j + } + for i := 1; i <= len(a); i++ { + cur := []int{i} + for j := 1; j <= len(b); j++ { + cost := 1 + if a[i-1] == b[j-1] { + cost = 0 + } + cur = append(cur, min(min(prev[j]+1, cur[j-1]+1), prev[j-1]+cost)) + } + prev = cur + } + return prev[len(b)] +} From bcfba973dc47ca4eba0f57dc72bcee0eeb023d91 Mon Sep 17 00:00:00 2001 From: Keaton Svoma Date: Sun, 31 May 2026 02:06:09 -0500 Subject: [PATCH 11/15] style(errors): check Fprint return values in FprintError (errcheck) Co-Authored-By: Claude Opus 4.8 (1M context) --- internal/commands/root.go | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/internal/commands/root.go b/internal/commands/root.go index 1e79146f..b6d05718 100644 --- a/internal/commands/root.go +++ b/internal/commands/root.go @@ -1284,10 +1284,10 @@ func formatErrorTo(w io.Writer, err error) bool { // FprintError writes a human-facing error (and a "hint:" line when present) to // w. Used by main when the JSON envelope path does not apply. func FprintError(w io.Writer, err error) { - fmt.Fprintln(w, err) + _, _ = fmt.Fprintln(w, err) var e *exitcode.Error if errors.As(err, &e) && e.Hint != "" { - fmt.Fprintf(w, "hint: %s\n", e.Hint) + _, _ = fmt.Fprintf(w, "hint: %s\n", e.Hint) } } From ebd5495713d1a4584056de2e76f101e2d9e1869c Mon Sep 17 00:00:00 2001 From: Keaton Svoma Date: Sun, 31 May 2026 09:14:25 -0500 Subject: [PATCH 12/15] docs(readme): add Changelog section linking to GitHub Releases Releases are the single source of truth for version history; point readers there rather than maintaining a duplicate CHANGELOG.md that would drift. Co-Authored-By: Claude Opus 4.8 (1M context) --- README.md | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/README.md b/README.md index db3e951f..a4088179 100644 --- a/README.md +++ b/README.md @@ -313,6 +313,10 @@ jamf-cli pro buildings apply --from-file building.json --dry-run Please file an issue in [GitHub Issues](https://github.com/Jamf-Concepts/jamf-cli/issues). +## Changelog + +See [GitHub Releases](https://github.com/Jamf-Concepts/jamf-cli/releases) for release notes and version history. + ## License Copyright (c) 2026 Jamf Software LLC. From 56e1141160aa82acead5e00a9c4147ee2de7fa2d Mon Sep 17 00:00:00 2001 From: Keaton Svoma Date: Sun, 31 May 2026 09:52:10 -0500 Subject: [PATCH 13/15] fix(errors): sharpen flag suggestions and exit-code unknown commands as usage Two fixes to the actionable-error feature, found during live testing: - suggestFlag gated only on edit distance < 3, so short typos matched unrelated flags by alphabetical tie-break (--fld -> --all, --id -> --wide). Now require a shared first letter and cap distance relative to the typo length, so --fld -> --field and --id produces no misleading hint. - Unknown commands returned a plain cobra error (exit 1) while unknown flags exited 2. Add ClassifyError to wrap unknown-command errors as usage (exit 2), applied in main before formatting so the JSON envelope and exit code agree; cobra's "did you mean" suggestion is preserved. Co-Authored-By: Claude Opus 4.8 (1M context) --- cmd/jamf-cli/main.go | 1 + internal/commands/flag_suggest_test.go | 20 +++++++++++++ internal/commands/root.go | 39 ++++++++++++++++++++++++-- internal/commands/root_test.go | 21 ++++++++++++++ 4 files changed, 78 insertions(+), 3 deletions(-) diff --git a/cmd/jamf-cli/main.go b/cmd/jamf-cli/main.go index b2ed324f..3c3ad479 100644 --- a/cmd/jamf-cli/main.go +++ b/cmd/jamf-cli/main.go @@ -45,6 +45,7 @@ func main() { cmd := commands.NewRootCmd(version, commit, date, specProVersion) if err := cmd.Execute(); err != nil { + err = commands.ClassifyError(err) if !commands.FormatError(err) { commands.FprintError(os.Stderr, err) } diff --git a/internal/commands/flag_suggest_test.go b/internal/commands/flag_suggest_test.go index ea535251..fc41f2b3 100644 --- a/internal/commands/flag_suggest_test.go +++ b/internal/commands/flag_suggest_test.go @@ -16,3 +16,23 @@ func TestSuggestFlag(t *testing.T) { t.Fatalf("suggestFlag(zzzzzz) = %q, want empty (too far)", got) } } + +func TestSuggestFlag_ShortTypos(t *testing.T) { + // Regression: short typos must anchor on the first letter and pick the + // intended flag, not an unrelated one at the same edit distance. + flags := []string{"all", "compact", "field", "output", "quiet", "wide"} + + // --fld and --all are both distance 2 from "fld"; only "field" shares the + // first letter, so it must win instead of losing on alphabetical order. + if got := suggestFlag("fld", flags); got != "field" { + t.Errorf("suggestFlag(fld) = %q, want field", got) + } + // No flag starts with 'i'; --id must produce no hint rather than --wide. + if got := suggestFlag("id", flags); got != "" { + t.Errorf("suggestFlag(id) = %q, want empty", got) + } + // A first-letter mismatch is intentionally not suggested. + if got := suggestFlag("xompact", flags); got != "" { + t.Errorf("suggestFlag(xompact) = %q, want empty", got) + } +} diff --git a/internal/commands/root.go b/internal/commands/root.go index b6d05718..7c40e81f 100644 --- a/internal/commands/root.go +++ b/internal/commands/root.go @@ -1291,11 +1291,44 @@ func FprintError(w io.Writer, err error) { } } -// suggestFlag returns the closest known flag name within edit distance 2, or "". +// ClassifyError normalizes framework errors that carry no explicit exit code so +// they map to the documented codes. Cobra reports an unknown command as a plain +// error (default exit 1); classify it as a usage error (2) to match the +// unknown-flag path handled by SetFlagErrorFunc. Errors that already carry an +// exit code (including wrapped unknown-flag errors) pass through unchanged. +func ClassifyError(err error) error { + if err == nil { + return nil + } + var e *exitcode.Error + if errors.As(err, &e) { + return err + } + if strings.HasPrefix(err.Error(), "unknown command") { + return exitcode.Wrap(exitcode.Usage, err) + } + return err +} + +// suggestFlag returns the closest known flag name to unknown, or "" when none +// is a plausible match. It anchors on a shared first letter and keeps the edit +// distance small relative to the typo length, so a short typo resolves to the +// intended flag (--fld -> --field) rather than an unrelated one at the same +// distance (--all), and a typo with no real match (--id) yields no hint at all. func suggestFlag(unknown string, known []string) string { - best, bestDist := "", 3 + if unknown == "" { + return "" + } + best, bestDist := "", 0 for _, k := range known { - if d := levenshtein(unknown, k); d < bestDist { + if k == "" || k[0] != unknown[0] { + continue + } + d := levenshtein(unknown, k) + if d > 2 || d >= len(unknown) { + continue + } + if best == "" || d < bestDist { best, bestDist = k, d } } diff --git a/internal/commands/root_test.go b/internal/commands/root_test.go index 80e7c4f9..3578bda1 100644 --- a/internal/commands/root_test.go +++ b/internal/commands/root_test.go @@ -6,6 +6,7 @@ import ( "bytes" "context" "encoding/json" + "errors" "io" "net/http" "os" @@ -1172,3 +1173,23 @@ type failReader struct{} func (f *failReader) Read(p []byte) (int, error) { return 0, io.ErrUnexpectedEOF } + +func TestClassifyError(t *testing.T) { + // Unknown-command errors (plain, from cobra) become usage errors (2). + unknownCmd := errors.New(`unknown command "proo" for "jamf-cli"`) + if got := exitcode.CodeFrom(ClassifyError(unknownCmd)); got != exitcode.Usage { + t.Errorf("unknown command -> exit %d, want %d (usage)", got, exitcode.Usage) + } + // Errors that already carry a code are left untouched. + authErr := exitcode.New(exitcode.Authentication, "nope") + if got := exitcode.CodeFrom(ClassifyError(authErr)); got != exitcode.Authentication { + t.Errorf("pre-coded error -> exit %d, want %d", got, exitcode.Authentication) + } + // Unrelated plain errors stay general (1). + if got := exitcode.CodeFrom(ClassifyError(errors.New("boom"))); got != exitcode.General { + t.Errorf("generic error -> exit %d, want %d (general)", got, exitcode.General) + } + if ClassifyError(nil) != nil { + t.Error("ClassifyError(nil) should return nil") + } +} From cc6b4e82cb48f89545fc90447287ac08b6ca74f6 Mon Sep 17 00:00:00 2001 From: Keaton Svoma Date: Sun, 31 May 2026 10:35:29 -0500 Subject: [PATCH 14/15] fix(errors): reject unknown subcommands on group parents (exit 2 + hint) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Cobra rejects unknown subcommands only on the root command (legacyArgs in Find), so `pro buildings lst` printed help and exited 0 rather than erroring. A non-runnable command also short-circuits to help before arg validation, so an Args validator never runs on a pure group. guardUnknownSubcommands walks the tree and attaches a RunE to every non-root group parent: a bare invocation still shows help, while an unknown subcommand returns a usage error (exit 2) with a "did you mean" suggestion, matching the root behavior. Guarded parents are annotated so PersistentPreRunE skips auth — a group parent never calls an API itself. Co-Authored-By: Claude Opus 4.8 (1M context) --- internal/commands/root.go | 55 ++++++++++++++++++++++++++++++++++ internal/commands/root_test.go | 29 ++++++++++++++++++ 2 files changed, 84 insertions(+) diff --git a/internal/commands/root.go b/internal/commands/root.go index 7c40e81f..61544cbb 100644 --- a/internal/commands/root.go +++ b/internal/commands/root.go @@ -611,6 +611,13 @@ spinner and progress output (narrower than --quiet).`, formatter.SetNoHints(noHints) cliCtx.Output = &cliOutput{formatter} + // Group parent commands (made runnable only to reject unknown + // subcommands) never call an API; skip auth so `pro buildings` + // help and typos work without credentials. + if cmd.Annotations[groupParentAnnotation] == "true" { + return nil + } + // Skip auth for commands that don't need it. Most are matched // anywhere in the chain (e.g. "config" covers all subcommands, // "setup" covers both "pro setup" and "protect setup"). @@ -802,6 +809,11 @@ spinner and progress output (narrower than --quiet).`, applyRootAliases(cmd) applyRootGroups(cmd) + // cobra only rejects unknown subcommands at the root; extend that to every + // non-runnable parent so typos like `pro buildings lst` error with a hint + // instead of silently printing help and exiting 0. + guardUnknownSubcommands(cmd) + return cmd } @@ -1310,6 +1322,49 @@ func ClassifyError(err error) error { return err } +// groupParentAnnotation marks a parent command that guardUnknownSubcommands made +// runnable solely to reject unknown subcommands. PersistentPreRunE skips auth for +// these — a group parent never calls an API itself. +const groupParentAnnotation = "jamfcli/group-parent" + +// guardUnknownSubcommands makes every non-root group parent reject an unknown +// subcommand with a "did you mean" hint and a usage exit code. Cobra applies +// this only to the root command (via legacyArgs in Find); a child parent would +// otherwise silently print help and exit 0. +// +// We attach a RunE rather than an Args validator because cobra short-circuits a +// non-runnable command to help before arguments are ever validated, so an Args +// validator on a pure group never runs. Making the parent runnable routes it +// through RunE, where args are available — and the annotation lets the auth +// pre-run skip it. +func guardUnknownSubcommands(cmd *cobra.Command) { + for _, c := range cmd.Commands() { + guardUnknownSubcommands(c) + } + if !cmd.HasParent() || !cmd.HasSubCommands() || cmd.Runnable() { + return + } + // SuggestionsFor reads this directly with no default; child commands leave + // it at 0, which would suppress all but exact matches. + if cmd.SuggestionsMinimumDistance <= 0 { + cmd.SuggestionsMinimumDistance = 2 + } + if cmd.Annotations == nil { + cmd.Annotations = map[string]string{} + } + cmd.Annotations[groupParentAnnotation] = "true" + cmd.RunE = func(c *cobra.Command, args []string) error { + if len(args) == 0 { + return c.Help() // bare parent (e.g. `pro buildings`) shows help + } + msg := fmt.Sprintf("unknown command %q for %q", args[0], c.CommandPath()) + if s := c.SuggestionsFor(args[0]); len(s) > 0 { + msg += "\n\nDid you mean this?\n\t" + strings.Join(s, "\n\t") + } + return exitcode.Wrap(exitcode.Usage, errors.New(msg)) + } +} + // suggestFlag returns the closest known flag name to unknown, or "" when none // is a plausible match. It anchors on a shared first letter and keeps the edit // distance small relative to the typo length, so a short typo resolves to the diff --git a/internal/commands/root_test.go b/internal/commands/root_test.go index 3578bda1..ba8e3633 100644 --- a/internal/commands/root_test.go +++ b/internal/commands/root_test.go @@ -1193,3 +1193,32 @@ func TestClassifyError(t *testing.T) { t.Error("ClassifyError(nil) should return nil") } } + +func TestGuardUnknownSubcommands(t *testing.T) { + // A typo at a group-parent level must error with a usage code and a + // suggestion, not silently print help and exit 0 (cobra's default). + root := NewRootCmd("test", "none", "none", "none") + root.SetArgs([]string{"pro", "buildings", "lst"}) + root.SetOut(io.Discard) + root.SetErr(io.Discard) + + err := root.Execute() + if err == nil { + t.Fatal("expected error for unknown subcommand, got nil") + } + if code := exitcode.CodeFrom(err); code != exitcode.Usage { + t.Errorf("exit code = %d, want %d (usage)", code, exitcode.Usage) + } + if !strings.Contains(err.Error(), "list") { + t.Errorf("error should suggest 'list', got %q", err.Error()) + } + + // A bare parent with no subcommand still shows help and succeeds. + root = NewRootCmd("test", "none", "none", "none") + root.SetArgs([]string{"pro", "buildings"}) + root.SetOut(io.Discard) + root.SetErr(io.Discard) + if err := root.Execute(); err != nil { + t.Errorf("bare parent should not error, got %v", err) + } +} From 254c846f3dd3f4d491ce6701aa464014748dfd25 Mon Sep 17 00:00:00 2001 From: Keaton Svoma Date: Sun, 31 May 2026 11:01:11 -0500 Subject: [PATCH 15/15] test(errors): assert every group parent rejects unknown subcommands MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Walks the full command tree and verifies each group parent (279 today) is guarded: it carries the group-parent annotation and returns a usage exit code on an unknown subcommand. This covers new command groups automatically — a future generated or hand-written group that slips past guardUnknownSubcommands fails this test instead of silently printing help and exiting 0. Co-Authored-By: Claude Opus 4.8 (1M context) --- internal/commands/root_test.go | 44 ++++++++++++++++++++++++++++++++++ 1 file changed, 44 insertions(+) diff --git a/internal/commands/root_test.go b/internal/commands/root_test.go index ba8e3633..d9d25ebb 100644 --- a/internal/commands/root_test.go +++ b/internal/commands/root_test.go @@ -18,6 +18,7 @@ import ( "github.com/Jamf-Concepts/jamf-cli/internal/config" "github.com/Jamf-Concepts/jamf-cli/internal/exitcode" "github.com/Jamf-Concepts/jamf-cli/internal/output" + "github.com/spf13/cobra" ) func TestCommandsSubcommand_JSON(t *testing.T) { @@ -1222,3 +1223,46 @@ func TestGuardUnknownSubcommands(t *testing.T) { t.Errorf("bare parent should not error, got %v", err) } } + +// TestGuardUnknownSubcommands_CoversAllGroupParents walks the entire command +// tree and asserts every group parent (a command that owns subcommands) is +// guarded: it must carry the group-parent annotation and reject an unknown +// subcommand with a usage exit code. Because it walks the whole tree, a new +// command group — generated or hand-written, at any depth — is covered +// automatically; if one is ever left unguarded, this fails. +func TestGuardUnknownSubcommands_CoversAllGroupParents(t *testing.T) { + root := NewRootCmd("test", "none", "none", "none") + + const bogus = "zzdefinitelynotacommand" + parents := 0 + + var walk func(c *cobra.Command, path string) + walk = func(c *cobra.Command, path string) { + for _, sub := range c.Commands() { + subPath := strings.TrimSpace(path + " " + sub.Name()) + if sub.HasSubCommands() { + parents++ + switch { + case sub.Annotations[groupParentAnnotation] != "true": + t.Errorf("group parent %q is not guarded: a typo would print help and exit 0", subPath) + case sub.RunE == nil: + t.Errorf("group parent %q is annotated but has no RunE", subPath) + default: + err := sub.RunE(sub, []string{bogus}) + if err == nil { + t.Errorf("group parent %q accepted unknown subcommand without error", subPath) + } else if code := exitcode.CodeFrom(err); code != exitcode.Usage { + t.Errorf("group parent %q: unknown subcommand exit %d, want %d (usage)", subPath, code, exitcode.Usage) + } + } + } + walk(sub, subPath) + } + } + walk(root, "") + + if parents < 10 { + t.Fatalf("found only %d group parents — tree walk likely broken", parents) + } + t.Logf("verified %d group parents reject unknown subcommands", parents) +}