Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
16 commits
Select commit Hold shift + click to select a range
2cb61ca
feat(output): add IsTerminal and pure ResolveFormat resolver
ktn-jamf May 31, 2026
f5e4c96
feat(exitcode): add PartialFailure code, Hint/Details, PartialOrPropa…
ktn-jamf May 31, 2026
d3ccf08
feat(output): TTY-aware default format and color gate
ktn-jamf May 31, 2026
58b3fb7
feat(output): vertical detail view for single objects in table mode
ktn-jamf May 31, 2026
83ab6cc
feat(output): make --compact semantic (allowlist + row-frequency keep)
ktn-jamf May 31, 2026
309f6fc
feat(bulk): exit 7 on partial failure with --allow-partial-failure op…
ktn-jamf May 31, 2026
df2d7ec
feat(backup): exit 7 on partial failure honoring --allow-partial-failure
ktn-jamf May 31, 2026
f92f918
feat(generated): bulk-delete continues on error and exits 7 on partia…
ktn-jamf May 31, 2026
4a3e93e
feat(errors): structured Hint on HTTP errors, surfaced on stderr and …
ktn-jamf May 31, 2026
581d76a
feat(errors): suggest nearest flag on unknown-flag typos
ktn-jamf May 31, 2026
bcfba97
style(errors): check Fprint return values in FprintError (errcheck)
ktn-jamf May 31, 2026
ebd5495
docs(readme): add Changelog section linking to GitHub Releases
ktn-jamf May 31, 2026
56e1141
fix(errors): sharpen flag suggestions and exit-code unknown commands …
ktn-jamf May 31, 2026
cc6b4e8
fix(errors): reject unknown subcommands on group parents (exit 2 + hint)
ktn-jamf May 31, 2026
254c846
test(errors): assert every group parent rejects unknown subcommands
ktn-jamf May 31, 2026
0a7e194
Merge branch 'main' into worktree-output-contract-polish
neilmartin83 May 31, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
3 changes: 2 additions & 1 deletion cmd/jamf-cli/main.go
Original file line number Diff line number Diff line change
Expand Up @@ -45,8 +45,9 @@ func main() {

cmd := commands.NewRootCmd(version, commit, date, specProVersion)
if err := cmd.Execute(); err != nil {
err = commands.ClassifyError(err)
if !commands.FormatError(err) {
fmt.Fprintln(os.Stderr, err)
commands.FprintError(os.Stderr, err)
}
os.Exit(exitcode.CodeFrom(err))
}
Expand Down
36 changes: 30 additions & 6 deletions generator/classic/generator.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down
56 changes: 50 additions & 6 deletions generator/parser/generator.go
Original file line number Diff line number Diff line change
Expand Up @@ -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 }}
Expand Down Expand Up @@ -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 . $)) }}
Expand Down Expand Up @@ -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
Expand Down
39 changes: 27 additions & 12 deletions internal/client/client.go
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down Expand Up @@ -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) {
Expand Down
45 changes: 45 additions & 0 deletions internal/client/status_error_test.go
Original file line number Diff line number Diff line change
@@ -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)
}
}
}
56 changes: 56 additions & 0 deletions internal/commands/error_surface_test.go
Original file line number Diff line number Diff line change
@@ -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())
}
}
38 changes: 38 additions & 0 deletions internal/commands/flag_suggest_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,38 @@
// 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)
}
}

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)
}
}
Loading