diff --git a/README.md b/README.md index a34b7e9..85fee3e 100644 --- a/README.md +++ b/README.md @@ -104,10 +104,17 @@ pzmod search hydrocraft pzmod mods add 2392709985 --resolve-deps pzmod validate # exits non-zero on errors (CI-friendly) pzmod backup list + +# Add --json to any command for machine-readable output +pzmod mods list --json | jq '.mods' ``` Run `pzmod --help` for the full command list. +With `--json`, any command prints its result as JSON on stdout; errors print +as `{"error":"..."}` to stderr and the exit code is preserved, so scripts can +read stdout and gate on the exit code. + ## Requirements - A Steam Web API key ([get one here](https://steamcommunity.com/dev/apikey)) diff --git a/internal/cli/apikey.go b/internal/cli/apikey.go index b550f5d..de606c5 100644 --- a/internal/cli/apikey.go +++ b/internal/cli/apikey.go @@ -19,7 +19,13 @@ func newAPIKeyCmd(st *store.Store) *cobra.Command { profile, _ := cmd.Flags().GetString("profile") if clear, _ := cmd.Flags().GetBool("clear"); clear { - return st.ClearKey(profile) + if err := st.ClearKey(profile); err != nil { + return err + } + if jsonEnabled(cmd) { + return emitJSON(cmd, map[string]bool{"cleared": true}) + } + return nil } if len(args) == 0 || len(args[0]) != 32 { return errors.New("a 32-character Steam Web API key is required") @@ -31,6 +37,9 @@ func newAPIKeyCmd(st *store.Store) *cobra.Command { } else if err := st.SetGlobalKey(args[0]); err != nil { return err } + if jsonEnabled(cmd) { + return emitJSON(cmd, map[string]bool{"saved": true}) + } cmd.Println(styleOK.Render("API key saved")) return nil }, diff --git a/internal/cli/backup.go b/internal/cli/backup.go index 17358dc..0689a8b 100644 --- a/internal/cli/backup.go +++ b/internal/cli/backup.go @@ -29,6 +29,12 @@ func newBackupListCmd(st *store.Store) *cobra.Command { if err != nil { return err } + if jsonEnabled(cmd) { + if entries == nil { + entries = []store.BackupEntry{} + } + return emitJSON(cmd, backupListJSON{Backups: entries}) + } if len(entries) == 0 { cmd.Println(styleMuted.Render("no backups yet")) return nil @@ -62,6 +68,9 @@ func newBackupSnapshotCmd(st *store.Store) *cobra.Command { if err != nil { return err } + if jsonEnabled(cmd) { + return emitJSON(cmd, entry) + } cmd.Println(styleOK.Render("snapshot created"), entry.ID) return nil }, @@ -84,6 +93,9 @@ func newBackupRestoreCmd(st *store.Store) *cobra.Command { if err := st.Restore(t.profileID(), args[0], t.iniPath()); err != nil { return err } + if jsonEnabled(cmd) { + return emitJSON(cmd, map[string]string{"restored": args[0]}) + } cmd.Println(styleOK.Render("restored"), args[0]) return nil }, diff --git a/internal/cli/copy.go b/internal/cli/copy.go index 03a4d77..cffd6f3 100644 --- a/internal/cli/copy.go +++ b/internal/cli/copy.go @@ -33,7 +33,13 @@ func newCopyCmd(st *store.Store) *cobra.Command { return fmt.Errorf("%s already exists (use --force to overwrite)", dest) } } - return cfg.SaveTo(dest) + if err := cfg.SaveTo(dest); err != nil { + return err + } + if jsonEnabled(cmd) { + return emitJSON(cmd, map[string]string{"copied": dest}) + } + return nil }, } cmd.Flags().BoolP("force", "F", false, "overwrite an existing destination") diff --git a/internal/cli/get.go b/internal/cli/get.go index bda64ff..667eca6 100644 --- a/internal/cli/get.go +++ b/internal/cli/get.go @@ -33,6 +33,9 @@ func newGetCmd(st *store.Store) *cobra.Command { Args: cobra.ExactArgs(1), RunE: func(cmd *cobra.Command, args []string) error { if args[0] == "list" { + if jsonEnabled(cmd) { + return emitJSON(cmd, map[string][]string{"keys": sortedAliases()}) + } cmd.Println("Available keys:", strings.Join(sortedAliases(), ", ")) return nil } @@ -48,7 +51,11 @@ func newGetCmd(st *store.Store) *cobra.Command { if !isAlias && !cfg.Document().Has(key) { return fmt.Errorf("unknown key %q (try `get list`)", args[0]) } - cmd.Println(cfg.GetOr(key, "")) + value := cfg.GetOr(key, "") + if jsonEnabled(cmd) { + return emitJSON(cmd, getJSON{Key: args[0], Value: value}) + } + cmd.Println(value) return nil }, } diff --git a/internal/cli/json_test.go b/internal/cli/json_test.go new file mode 100644 index 0000000..c68de8e --- /dev/null +++ b/internal/cli/json_test.go @@ -0,0 +1,178 @@ +package cli + +import ( + "encoding/json" + "testing" +) + +func TestModsListJSON(t *testing.T) { + st := testStore(t) + ini := writeINI(t, "WorkshopItems=100;200\nMods=CoreLib;Weapons\nMap=Springfield;Muldraugh, KY\n") + + out, err := run(t, st, "mods", "list", "--file", ini, "--json") + if err != nil { + t.Fatal(err) + } + var got modsListJSON + if err := json.Unmarshal([]byte(out), &got); err != nil { + t.Fatalf("unmarshal %q: %v", out, err) + } + if len(got.Mods) != 2 || got.Mods[0] != "CoreLib" { + t.Errorf("mods = %v", got.Mods) + } + if len(got.WorkshopItems) != 2 || !contains(got.WorkshopItems, "100") { + t.Errorf("workshopItems = %v", got.WorkshopItems) + } + // Map splits on ';' only, so "Muldraugh, KY" stays one entry. + if len(got.Maps) != 2 || got.Maps[1] != "Muldraugh, KY" { + t.Errorf("maps = %v", got.Maps) + } +} + +func TestModsListJSONEmptyArrays(t *testing.T) { + st := testStore(t) + ini := writeINI(t, "WorkshopItems=\nMods=\n") + + out, _ := run(t, st, "mods", "list", "--file", ini, "--json") + // nil slices must serialize as [] for machine consumers, not null. + var raw map[string]json.RawMessage + if err := json.Unmarshal([]byte(out), &raw); err != nil { + t.Fatalf("unmarshal %q: %v", out, err) + } + for _, k := range []string{"mods", "workshopItems", "maps"} { + if string(raw[k]) != "[]" { + t.Errorf("%s = %s; want []", k, raw[k]) + } + } +} + +func TestValidateJSONError(t *testing.T) { + st := testStore(t) + _ = st.SetGlobalKey("0123456789abcdef0123456789abcdef") + useFakeSteam(t, cannedFake()) + // 200 requires 100, which is not installed -> missing-dependency error. + ini := writeINI(t, "WorkshopItems=200\nMods=Weapons\n") + + out, err := run(t, st, "validate", "--file", ini, "--json") + if err == nil { + t.Errorf("validate --json should still exit non-zero on errors; out=%q", out) + } + var got validateJSON + if uerr := json.Unmarshal([]byte(out), &got); uerr != nil { + t.Fatalf("unmarshal %q: %v", out, uerr) + } + if got.OK { + t.Errorf("ok = true; want false") + } + if got.Summary.Errors < 1 { + t.Errorf("summary.errors = %d; want >= 1", got.Summary.Errors) + } + found := false + for _, f := range got.Findings { + if f.Code == "missing-dependency" { + found = true + if f.Severity != "ERROR" { + t.Errorf("severity = %q; want ERROR", f.Severity) + } + } + } + if !found { + t.Errorf("no missing-dependency finding in %+v", got.Findings) + } +} + +func TestSearchJSON(t *testing.T) { + st := testStore(t) + _ = st.SetGlobalKey("0123456789abcdef0123456789abcdef") + useFakeSteam(t, cannedFake()) + + out, err := run(t, st, "search", "Weapons", "--json") + if err != nil { + t.Fatal(err) + } + var got searchJSON + if err := json.Unmarshal([]byte(out), &got); err != nil { + t.Fatalf("unmarshal %q: %v", out, err) + } + if got.Total != 1 || len(got.Items) != 1 { + t.Fatalf("search = %+v; want 1 item", got) + } + if got.Items[0].ID != "200" || got.Items[0].Title != "Weapons" { + t.Errorf("item = %+v", got.Items[0]) + } +} + +func TestProfileListJSON(t *testing.T) { + st := testStore(t) + ini := writeINI(t, "PublicName=x\n") + if _, err := run(t, st, "profile", "add", "--name", "Alpha", "--file", ini, "--build", "b42"); err != nil { + t.Fatal(err) + } + + out, err := run(t, st, "profile", "list", "--json") + if err != nil { + t.Fatal(err) + } + var got profileListJSON + if err := json.Unmarshal([]byte(out), &got); err != nil { + t.Fatalf("unmarshal %q: %v", out, err) + } + if len(got.Profiles) != 1 { + t.Fatalf("profiles = %+v", got.Profiles) + } + p := got.Profiles[0] + if p.ID != "alpha" || p.Build != "b42" || !p.Default { + t.Errorf("profile = %+v", p) + } + if got.DefaultID != "alpha" { + t.Errorf("defaultId = %q; want alpha", got.DefaultID) + } +} + +func TestBackupListJSON(t *testing.T) { + st := testStore(t) + ini := writeINI(t, "PublicName=x\n") + + // Empty case normalizes to []. + out, _ := run(t, st, "backup", "list", "--file", ini, "--json") + var empty map[string]json.RawMessage + if err := json.Unmarshal([]byte(out), &empty); err != nil { + t.Fatalf("unmarshal %q: %v", out, err) + } + if string(empty["backups"]) != "[]" { + t.Errorf("empty backups = %s; want []", empty["backups"]) + } + + // After a snapshot, the list carries one entry. + if _, err := run(t, st, "backup", "snapshot", "--file", ini, "--note", "hi", "--json"); err != nil { + t.Fatal(err) + } + out, err := run(t, st, "backup", "list", "--file", ini, "--json") + if err != nil { + t.Fatal(err) + } + var got backupListJSON + if err := json.Unmarshal([]byte(out), &got); err != nil { + t.Fatalf("unmarshal %q: %v", out, err) + } + if len(got.Backups) != 1 || got.Backups[0].Kind != "manual" || got.Backups[0].Note != "hi" { + t.Errorf("backups = %+v", got.Backups) + } +} + +func TestGetJSON(t *testing.T) { + st := testStore(t) + ini := writeINI(t, "PublicName=Hello World\n") + + out, err := run(t, st, "get", "name", "--file", ini, "--json") + if err != nil { + t.Fatal(err) + } + var got getJSON + if err := json.Unmarshal([]byte(out), &got); err != nil { + t.Fatalf("unmarshal %q: %v", out, err) + } + if got.Key != "name" || got.Value != "Hello World" { + t.Errorf("get = %+v", got) + } +} diff --git a/internal/cli/jsondto.go b/internal/cli/jsondto.go new file mode 100644 index 0000000..40ad09a --- /dev/null +++ b/internal/cli/jsondto.go @@ -0,0 +1,117 @@ +package cli + +import ( + "github.com/kldzj/pzmod/pkg/service" + "github.com/kldzj/pzmod/pkg/store" +) + +// This file holds the JSON output shapes for --json mode. Keys are camelCase and +// each command emits its own natural object (no universal envelope). Errors are +// reported separately by main.go as {"error": "..."} on stderr. + +// modsListJSON is the shape of `mods list --json`. +type modsListJSON struct { + Mods []string `json:"mods"` + WorkshopItems []string `json:"workshopItems"` + Maps []string `json:"maps"` +} + +// getJSON is the shape of `get --json`. +type getJSON struct { + Key string `json:"key"` + Value string `json:"value"` +} + +// findingJSON is one validation finding in JSON form. +type findingJSON struct { + Severity string `json:"severity"` + Code string `json:"code"` + Subject string `json:"subject,omitempty"` + Message string `json:"message"` +} + +// validateJSON is the shape of `validate --json`. +type validateJSON struct { + Findings []findingJSON `json:"findings"` + Summary struct { + Errors int `json:"errors"` + Warnings int `json:"warnings"` + Info int `json:"info"` + } `json:"summary"` + OK bool `json:"ok"` +} + +// searchItemJSON is one Workshop search hit. It uses our own field names rather +// than steam.WorkshopItem's Steam-API json tags. +type searchItemJSON struct { + ID string `json:"id"` + Title string `json:"title"` + FileSize int64 `json:"fileSize"` +} + +// searchJSON is the shape of `search --json`. +type searchJSON struct { + Total int `json:"total"` + Items []searchItemJSON `json:"items"` +} + +// profileJSON embeds store.Profile (already json-tagged) and marks the default. +type profileJSON struct { + store.Profile + Default bool `json:"default"` +} + +// profileListJSON is the shape of `profile list --json`. +type profileListJSON struct { + Profiles []profileJSON `json:"profiles"` + DefaultID string `json:"defaultId,omitempty"` +} + +// backupListJSON is the shape of `backup list --json`. +type backupListJSON struct { + Backups []store.BackupEntry `json:"backups"` +} + +// multiModJSON mirrors domain.MultiModItem for output. +type multiModJSON struct { + ItemID string `json:"itemId"` + ModIDs []string `json:"modIds"` +} + +// resolveJSON is the shape of `mods add --resolve-deps --json`. +type resolveJSON struct { + Resolved bool `json:"resolved"` + AddWorkshopItems []string `json:"addWorkshopItems"` + AddMods []string `json:"addMods"` + AddMaps []string `json:"addMaps"` + Missing []string `json:"missing"` + MultiMod []multiModJSON `json:"multiMod"` + Cycles [][]string `json:"cycles"` +} + +// newResolveJSON builds a resolveJSON from a resolution plan. +func newResolveJSON(plan service.ResolvePlan) resolveJSON { + mm := make([]multiModJSON, 0, len(plan.MultiMod)) + for _, m := range plan.MultiMod { + mm = append(mm, multiModJSON{ItemID: m.ItemID, ModIDs: orEmpty(m.ModIDs)}) + } + cycles := plan.Cycles + if cycles == nil { + cycles = [][]string{} + } + return resolveJSON{ + Resolved: true, + AddWorkshopItems: orEmpty(plan.AddWorkshopItems), + AddMods: orEmpty(plan.AddMods), + AddMaps: orEmpty(plan.AddMaps), + Missing: orEmpty(plan.Missing), + MultiMod: mm, + Cycles: cycles, + } +} + +// shallowAddJSON is the shape of `mods add --json` without --resolve-deps. +type shallowAddJSON struct { + Added []string `json:"added"` + Missing []string `json:"missing"` +} diff --git a/internal/cli/mods.go b/internal/cli/mods.go index 61181eb..8ada94a 100644 --- a/internal/cli/mods.go +++ b/internal/cli/mods.go @@ -37,6 +37,13 @@ func newModsListCmd(st *store.Store) *cobra.Command { return err } sm := cfg.ServerMods() + if jsonEnabled(cmd) { + return emitJSON(cmd, modsListJSON{ + Mods: orEmpty(sm.Mods), + WorkshopItems: orEmpty(sm.WorkshopItems), + Maps: orEmpty(sm.Maps), + }) + } cmd.Printf("%s (%d)\n", styleInfo.Render("Mods"), len(sm.Mods)) for i, m := range sm.Mods { cmd.Printf(" %2d. %s\n", i+1, m) @@ -75,24 +82,38 @@ func newModsAddCmd(st *store.Store) *cobra.Command { } sm := cfg.ServerMods() + asJSON := jsonEnabled(cmd) resolveDeps, _ := cmd.Flags().GetBool("resolve-deps") var projected domain.ServerMods + var result any // JSON payload for the chosen path if resolveDeps { plan, err := svc.Resolve(cmd.Context(), args, sm) if err != nil { return err } projected = plan.Apply(sm, t.build() == build.B42) - printPlan(cmd, plan) + if asJSON { + result = newResolveJSON(plan) + } else { + printPlan(cmd, plan) + } } else { updated, missing, added, err := shallowAdd(cmd.Context(), svc, sm, args, t.build() == build.B42) if err != nil { return err } projected = updated - cmd.Printf("added %d item(s)\n", len(added)) - if len(missing) > 0 { - cmd.Println(styleWarn.Render("could not fetch:"), strings.Join(missing, ", ")) + if asJSON { + addedIDs := make([]string, len(added)) + for i, it := range added { + addedIDs[i] = it.PublishedFileID + } + result = shallowAddJSON{Added: addedIDs, Missing: orEmpty(missing)} + } else { + cmd.Printf("added %d item(s)\n", len(added)) + if len(missing) > 0 { + cmd.Println(styleWarn.Render("could not fetch:"), strings.Join(missing, ", ")) + } } } @@ -102,7 +123,13 @@ func newModsAddCmd(st *store.Store) *cobra.Command { return err } } - return cfg.Save() + if err := cfg.Save(); err != nil { + return err + } + if asJSON { + return emitJSON(cmd, result) + } + return nil }, } cmd.Flags().Bool("resolve-deps", false, "also add transitive dependencies") @@ -135,7 +162,13 @@ func newModsRemoveCmd(st *store.Store) *cobra.Command { return err } } - return cfg.Save() + if err := cfg.Save(); err != nil { + return err + } + if jsonEnabled(cmd) { + return emitJSON(cmd, map[string][]string{"removed": args}) + } + return nil }, } cmd.Flags().Bool("no-backup", false, "do not snapshot before saving") diff --git a/internal/cli/output.go b/internal/cli/output.go index a4bb001..832e8eb 100644 --- a/internal/cli/output.go +++ b/internal/cli/output.go @@ -1,10 +1,45 @@ package cli import ( + "encoding/json" + "github.com/charmbracelet/lipgloss" "github.com/kldzj/pzmod/pkg/domain" + "github.com/spf13/cobra" ) +// jsonEnabled reports whether the global --json flag is set. It is a persistent +// flag on the root command, so every subcommand inherits it via cmd.Flags(). +func jsonEnabled(cmd *cobra.Command) bool { + b, _ := cmd.Flags().GetBool("json") + return b +} + +// WantsJSON reports whether --json was requested, read from the root command's +// persistent flags. main.go uses this to format a failing command's error as a +// JSON envelope on stderr. +func WantsJSON(root *cobra.Command) bool { + b, _ := root.PersistentFlags().GetBool("json") + return b +} + +// emitJSON writes v as indented JSON to the command's stdout. +func emitJSON(cmd *cobra.Command, v any) error { + enc := json.NewEncoder(cmd.OutOrStdout()) + enc.SetIndent("", " ") + enc.SetEscapeHTML(false) + return enc.Encode(v) +} + +// orEmpty returns s, or an empty (non-nil) slice when s is nil, so it marshals +// as [] rather than null for machine consumers. +func orEmpty(s []string) []string { + if s == nil { + return []string{} + } + return s +} + // Severity styles shared by CLI output. lipgloss degrades gracefully on // non-color terminals and honors NO_COLOR. var ( diff --git a/internal/cli/profile.go b/internal/cli/profile.go index 4cde1c2..8be4677 100644 --- a/internal/cli/profile.go +++ b/internal/cli/profile.go @@ -33,11 +33,18 @@ func newProfileListCmd(st *store.Store) *cobra.Command { if err != nil { return err } + def, _ := st.DefaultProfile() + if jsonEnabled(cmd) { + out := profileListJSON{Profiles: make([]profileJSON, 0, len(profiles)), DefaultID: def.ID} + for _, p := range profiles { + out.Profiles = append(out.Profiles, profileJSON{Profile: p, Default: p.ID == def.ID}) + } + return emitJSON(cmd, out) + } if len(profiles) == 0 { cmd.Println(styleMuted.Render("no profiles yet - add one with `pzmod profile add`")) return nil } - def, _ := st.DefaultProfile() for _, p := range profiles { marker := " " if p.ID == def.ID { @@ -79,6 +86,9 @@ func newProfileAddCmd(st *store.Store) *cobra.Command { if err != nil { return err } + if jsonEnabled(cmd) { + return emitJSON(cmd, p) + } cmd.Println(styleOK.Render("added profile"), p.ID) return nil }, @@ -99,6 +109,9 @@ func newProfileRemoveCmd(st *store.Store) *cobra.Command { if err := st.RemoveProfile(args[0]); err != nil { return err } + if jsonEnabled(cmd) { + return emitJSON(cmd, map[string]string{"removed": args[0]}) + } cmd.Println(styleOK.Render("removed profile"), args[0]) return nil }, @@ -114,6 +127,9 @@ func newProfileUseCmd(st *store.Store) *cobra.Command { if err := st.SetDefaultProfile(args[0]); err != nil { return err } + if jsonEnabled(cmd) { + return emitJSON(cmd, map[string]string{"defaultId": args[0]}) + } cmd.Println(styleOK.Render("default profile is now"), args[0]) return nil }, @@ -136,6 +152,10 @@ func newProfileShowCmd(st *store.Store) *cobra.Command { if err != nil { return err } + if jsonEnabled(cmd) { + def, _ := st.DefaultProfile() + return emitJSON(cmd, profileJSON{Profile: p, Default: p.ID == def.ID}) + } cmd.Printf("ID: %s\n", p.ID) cmd.Printf("Name: %s\n", p.Name) cmd.Printf("Config: %s\n", pathutil.Abbreviate(p.IniPath)) diff --git a/internal/cli/root.go b/internal/cli/root.go index 06cfaa2..ec46f00 100644 --- a/internal/cli/root.go +++ b/internal/cli/root.go @@ -33,6 +33,7 @@ func NewRootCommand(st *store.Store, ver string) *cobra.Command { } addTargetFlags(root) + root.PersistentFlags().Bool("json", false, "output machine-readable JSON instead of styled text") root.Flags().Bool("mouse", false, "enable mouse support in the terminal app (wheel scroll; may affect text selection)") root.AddCommand( newGetCmd(st), diff --git a/internal/cli/search.go b/internal/cli/search.go index 8990c2a..e691f41 100644 --- a/internal/cli/search.go +++ b/internal/cli/search.go @@ -34,6 +34,18 @@ func newSearchCmd(st *store.Store) *cobra.Command { return err } + if jsonEnabled(cmd) { + out := searchJSON{Total: page.Total, Items: make([]searchItemJSON, 0, len(page.Items))} + for _, it := range page.Items { + out.Items = append(out.Items, searchItemJSON{ + ID: it.PublishedFileID, + Title: it.Title, + FileSize: int64(it.FileSize), + }) + } + return emitJSON(cmd, out) + } + cmd.Printf("%s\n", styleMuted.Render(humanize.Comma(int64(page.Total))+" results")) for _, it := range page.Items { cmd.Printf("%s %s %s\n", diff --git a/internal/cli/set.go b/internal/cli/set.go index 35e60e4..6fbc08c 100644 --- a/internal/cli/set.go +++ b/internal/cli/set.go @@ -47,10 +47,19 @@ func newSetCmd(st *store.Store) *cobra.Command { cfg.Set(key, value) if noSave, _ := cmd.Flags().GetBool("no-save"); noSave { + if jsonEnabled(cmd) { + return emitJSON(cmd, map[string]string{"config": cfg.String()}) + } cmd.Print(cfg.String()) return nil } - return cfg.Save() + if err := cfg.Save(); err != nil { + return err + } + if jsonEnabled(cmd) { + return emitJSON(cmd, map[string]any{"key": args[0], "value": value, "saved": true}) + } + return nil }, } cmd.Flags().BoolP("no-save", "n", false, "print the result instead of writing the file") diff --git a/internal/cli/update.go b/internal/cli/update.go index e32a810..df4e657 100644 --- a/internal/cli/update.go +++ b/internal/cli/update.go @@ -25,14 +25,26 @@ func newUpdateCmd() *cobra.Command { return err } if version.IsLatest(ver, latest) { + if jsonEnabled(cmd) { + return emitJSON(cmd, map[string]string{"status": "up-to-date"}) + } cmd.Println("pzmod is already up to date") return nil } if check, _ := cmd.Flags().GetBool("check"); check { + if jsonEnabled(cmd) { + return emitJSON(cmd, map[string]any{"latest": latest.Version(), "available": true}) + } cmd.Println("A new version is available:", latest.Version()) return nil } - return version.Update(ver, latest, updater) + if err := version.Update(ver, latest, updater); err != nil { + return err + } + if jsonEnabled(cmd) { + return emitJSON(cmd, map[string]any{"latest": latest.Version(), "updated": true}) + } + return nil }, } cmd.Flags().BoolP("check", "c", false, "only check for updates") diff --git a/internal/cli/validate.go b/internal/cli/validate.go index 88bda90..76d62a9 100644 --- a/internal/cli/validate.go +++ b/internal/cli/validate.go @@ -35,6 +35,29 @@ func newValidateCmd(st *store.Store) *cobra.Command { } findings := report.Sorted() + + if jsonEnabled(cmd) { + out := validateJSON{Findings: make([]findingJSON, 0, len(findings)), OK: !report.HasErrors()} + for _, f := range findings { + out.Findings = append(out.Findings, findingJSON{ + Severity: f.Severity.String(), + Code: f.Code, + Subject: f.Subject, + Message: f.Message, + }) + } + out.Summary.Errors = report.Count(domain.SeverityError) + out.Summary.Warnings = report.Count(domain.SeverityWarning) + out.Summary.Info = report.Count(domain.SeverityInfo) + if err := emitJSON(cmd, out); err != nil { + return err + } + if report.HasErrors() { + return fmt.Errorf("validation failed with %d error(s)", report.Count(domain.SeverityError)) + } + return nil + } + if len(findings) == 0 { cmd.Println(styleOK.Render("OK") + " no problems found") return nil diff --git a/main.go b/main.go index ef67d50..dba8f11 100644 --- a/main.go +++ b/main.go @@ -2,6 +2,7 @@ package main import ( "context" + "encoding/json" "fmt" "os" "os/signal" @@ -23,7 +24,14 @@ func main() { root := cli.NewRootCommand(st, version.Get()) if err := root.ExecuteContext(ctx); err != nil { - fmt.Fprintln(os.Stderr, "pzmod:", err) + if cli.WantsJSON(root) { + enc := json.NewEncoder(os.Stderr) + enc.SetIndent("", " ") + enc.SetEscapeHTML(false) + _ = enc.Encode(map[string]string{"error": err.Error()}) + } else { + fmt.Fprintln(os.Stderr, "pzmod:", err) + } os.Exit(1) } }