Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
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
7 changes: 7 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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))
Expand Down
11 changes: 10 additions & 1 deletion internal/cli/apikey.go
Original file line number Diff line number Diff line change
Expand Up @@ -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")
Expand All @@ -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
},
Expand Down
12 changes: 12 additions & 0 deletions internal/cli/backup.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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
},
Expand All @@ -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
},
Expand Down
8 changes: 7 additions & 1 deletion internal/cli/copy.go
Original file line number Diff line number Diff line change
Expand Up @@ -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")
Expand Down
9 changes: 8 additions & 1 deletion internal/cli/get.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
}
Expand All @@ -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
},
}
Expand Down
178 changes: 178 additions & 0 deletions internal/cli/json_test.go
Original file line number Diff line number Diff line change
@@ -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)
}
}
Loading
Loading