From dadc0d66914c0cf2caec327223fbbee3f08f387a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Lo=C3=AFc=20Hermann?= Date: Thu, 2 Apr 2026 12:50:36 +0200 Subject: [PATCH 1/2] feat: add consistent CLI JSON output --- cmd/cloudstic/cmd_backup.go | 3 + cmd/cloudstic/cmd_breaklock.go | 3 + cmd/cloudstic/cmd_breaklock_test.go | 23 ++++++++ cmd/cloudstic/cmd_cat.go | 12 ++-- cmd/cloudstic/cmd_cat_test.go | 38 +++++++++++-- cmd/cloudstic/cmd_check.go | 9 +++ cmd/cloudstic/cmd_check_test.go | 27 +++++++++ cmd/cloudstic/cmd_diff.go | 3 + cmd/cloudstic/cmd_diff_test.go | 27 +++++++++ cmd/cloudstic/cmd_forget.go | 9 +++ cmd/cloudstic/cmd_init.go | 3 + cmd/cloudstic/cmd_key.go | 9 +++ cmd/cloudstic/cmd_list.go | 3 + cmd/cloudstic/cmd_list_test.go | 25 ++++++++ cmd/cloudstic/cmd_ls.go | 3 + cmd/cloudstic/cmd_prune.go | 3 + cmd/cloudstic/cmd_restore.go | 9 +++ cmd/cloudstic/completion.go | 10 ++-- cmd/cloudstic/flags.go | 6 ++ cmd/cloudstic/json_output.go | 88 +++++++++++++++++++++++++++++ cmd/cloudstic/store.go | 2 +- cmd/cloudstic/usage.go | 4 +- docs/user-guide.md | 9 ++- 23 files changed, 307 insertions(+), 21 deletions(-) create mode 100644 cmd/cloudstic/json_output.go diff --git a/cmd/cloudstic/cmd_backup.go b/cmd/cloudstic/cmd_backup.go index 6f62330..5a7d760 100644 --- a/cmd/cloudstic/cmd_backup.go +++ b/cmd/cloudstic/cmd_backup.go @@ -174,6 +174,9 @@ func (r *runner) runSingleBackup(a *backupArgs) int { if err != nil { return r.fail("Backup failed: %v", err) } + if a.g.jsonEnabled() { + return r.writeJSON(result) + } r.printBackupSummary(result) return 0 } diff --git a/cmd/cloudstic/cmd_breaklock.go b/cmd/cloudstic/cmd_breaklock.go index 297f032..ede6a0b 100644 --- a/cmd/cloudstic/cmd_breaklock.go +++ b/cmd/cloudstic/cmd_breaklock.go @@ -29,6 +29,9 @@ func (r *runner) runBreakLock(ctx context.Context) int { if err != nil { return r.fail("Failed to break lock: %v", err) } + if a.g.jsonEnabled() { + return r.writeJSON(&breakLockJSONResult{Locks: removed}) + } r.printBreakLockResult(removed) return 0 } diff --git a/cmd/cloudstic/cmd_breaklock_test.go b/cmd/cloudstic/cmd_breaklock_test.go index 63cefc0..7a06f35 100644 --- a/cmd/cloudstic/cmd_breaklock_test.go +++ b/cmd/cloudstic/cmd_breaklock_test.go @@ -2,6 +2,7 @@ package main import ( "context" + "encoding/json" "os" "strings" "testing" @@ -21,6 +22,28 @@ func TestRunBreakLock_NoLock(t *testing.T) { } } +func TestRunBreakLock_JSON(t *testing.T) { + os.Args = []string{"cloudstic", "break-lock", "-json"} + var out strings.Builder + r := &runner{out: &out, errOut: &strings.Builder{}, client: &stubClient{ + breakLockResult: []*cloudstic.RepoLock{ + {Operation: "backup", Holder: "worker-1"}, + }, + }} + + if exit := r.runBreakLock(context.Background()); exit != 0 { + t.Fatalf("runBreakLock() exit = %d, want 0", exit) + } + + var got map[string]any + if err := json.Unmarshal([]byte(out.String()), &got); err != nil { + t.Fatalf("json unmarshal: %v\noutput:\n%s", err, out.String()) + } + if _, ok := got["locks"]; !ok { + t.Fatalf("expected locks key in JSON output, got: %v", got) + } +} + func TestRunBreakLock_LocksRemoved(t *testing.T) { os.Args = []string{"cloudstic", "break-lock"} var errOut strings.Builder diff --git a/cmd/cloudstic/cmd_cat.go b/cmd/cloudstic/cmd_cat.go index b6423cc..4a97a50 100644 --- a/cmd/cloudstic/cmd_cat.go +++ b/cmd/cloudstic/cmd_cat.go @@ -14,7 +14,6 @@ import ( type catArgs struct { g *globalFlags keys []string - json bool raw bool } @@ -22,7 +21,6 @@ func parseCatArgs() *catArgs { fs := flag.NewFlagSet("cat", flag.ExitOnError) a := &catArgs{} a.g = addGlobalFlags(fs) - jsonFlag := fs.Bool("json", false, "Suppress non-JSON output (alias for -quiet)") rawFlag := fs.Bool("raw", false, "Output raw, unformatted data (useful for hashing)") mustParse(fs) if fs.NArg() < 1 { @@ -36,7 +34,6 @@ func parseCatArgs() *catArgs { fmt.Fprintln(os.Stderr, " cloudstic cat -raw filemeta/def456... | sha256sum") os.Exit(1) } - a.json = *jsonFlag a.raw = *rawFlag a.keys = fs.Args() return a @@ -48,13 +45,20 @@ func (r *runner) runCat(ctx context.Context) int { return r.fail("Failed to init store: %v", err) } - quiet := *a.g.quiet || a.json + if a.g.jsonEnabled() && a.raw { + return r.failJSONFlagConflict("-json", "-raw") + } + + quiet := *a.g.quiet || a.g.jsonEnabled() results, err := r.client.Cat(context.Background(), a.keys...) if err != nil { return r.fail("Failed to fetch objects: %v", err) } + if a.g.jsonEnabled() { + return r.writeJSON(makeCatJSONResults(results)) + } r.printCatResult(results, quiet, a.raw) return 0 } diff --git a/cmd/cloudstic/cmd_cat_test.go b/cmd/cloudstic/cmd_cat_test.go index 9dcd0a9..329064b 100644 --- a/cmd/cloudstic/cmd_cat_test.go +++ b/cmd/cloudstic/cmd_cat_test.go @@ -2,6 +2,7 @@ package main import ( "context" + "encoding/json" "os" "strings" "testing" @@ -47,20 +48,34 @@ func TestRunCat_MultipleKeys_HeadersShown(t *testing.T) { } } -func TestRunCat_QuietMode_NoHeaders(t *testing.T) { +func TestRunCat_JSON_NoHeadersAndStructuredOutput(t *testing.T) { os.Args = []string{"cloudstic", "cat", "--json", "config", "index/latest"} + var out strings.Builder var errOut strings.Builder - r := &runner{out: &strings.Builder{}, errOut: &errOut, client: &stubClient{ + r := &runner{out: &out, errOut: &errOut, client: &stubClient{ catResults: []*cloudstic.CatResult{ - {Key: "config", Data: []byte(`{}`)}, + {Key: "config", Data: []byte(`{"version":1}`)}, {Key: "index/latest", Data: []byte(`{}`)}, }, }} - r.runCat(context.Background()) + if exit := r.runCat(context.Background()); exit != 0 { + t.Fatalf("runCat() exit = %d, want 0", exit) + } if strings.Contains(errOut.String(), "==>") { - t.Errorf("quiet mode should not show headers, got:\n%s", errOut.String()) + t.Errorf("json mode should not show headers, got:\n%s", errOut.String()) + } + + var got []map[string]any + if err := json.Unmarshal([]byte(out.String()), &got); err != nil { + t.Fatalf("json unmarshal: %v\noutput:\n%s", err, out.String()) + } + if len(got) != 2 { + t.Fatalf("len(got) = %d, want 2", len(got)) + } + if got[0]["key"] != "config" { + t.Fatalf("first key = %v, want config", got[0]["key"]) } } @@ -92,3 +107,16 @@ func TestRunCat_InvalidJSON_PrintsRaw(t *testing.T) { t.Errorf("expected raw fallback output, got:\n%s", out.String()) } } + +func TestRunCat_JSONAndRawConflict(t *testing.T) { + os.Args = []string{"cloudstic", "cat", "-json", "-raw", "config"} + var errOut strings.Builder + r := &runner{out: &strings.Builder{}, errOut: &errOut, client: &stubClient{}} + + if exit := r.runCat(context.Background()); exit != 1 { + t.Fatalf("runCat() exit = %d, want 1", exit) + } + if !strings.Contains(errOut.String(), "-json cannot be combined with -raw") { + t.Fatalf("expected conflict error, got:\n%s", errOut.String()) + } +} diff --git a/cmd/cloudstic/cmd_check.go b/cmd/cloudstic/cmd_check.go index 67c76e1..0b60a0d 100644 --- a/cmd/cloudstic/cmd_check.go +++ b/cmd/cloudstic/cmd_check.go @@ -40,6 +40,15 @@ func (r *runner) runCheck(ctx context.Context) int { if err != nil { return r.fail("Check failed: %v", err) } + if a.g.jsonEnabled() { + if exit := r.writeJSON(result); exit != 0 { + return exit + } + if len(result.Errors) > 0 { + return 1 + } + return 0 + } if r.printCheckResult(result) { return 1 } diff --git a/cmd/cloudstic/cmd_check_test.go b/cmd/cloudstic/cmd_check_test.go index 52861e8..56f79ed 100644 --- a/cmd/cloudstic/cmd_check_test.go +++ b/cmd/cloudstic/cmd_check_test.go @@ -2,6 +2,7 @@ package main import ( "context" + "encoding/json" "os" "strings" "testing" @@ -32,6 +33,32 @@ func TestRunCheck_Healthy(t *testing.T) { } } +func TestRunCheck_JSONWithErrorsReturnsExitOne(t *testing.T) { + os.Args = []string{"cloudstic", "check", "-json"} + var out strings.Builder + r := &runner{out: &out, errOut: &strings.Builder{}, client: &stubClient{ + checkResult: &cloudstic.CheckResult{ + SnapshotsChecked: 1, + ObjectsVerified: 2, + Errors: []engine.CheckError{ + {Type: "corrupt", Key: "content/xyz", Message: "checksum mismatch"}, + }, + }, + }} + + if exit := r.runCheck(context.Background()); exit != 1 { + t.Fatalf("runCheck() exit = %d, want 1", exit) + } + + var got map[string]any + if err := json.Unmarshal([]byte(out.String()), &got); err != nil { + t.Fatalf("json unmarshal: %v\noutput:\n%s", err, out.String()) + } + if _, ok := got["Errors"]; !ok { + t.Fatalf("expected Errors key in JSON output, got: %v", got) + } +} + func TestPrintCheckResult_Healthy(t *testing.T) { var errOut strings.Builder r := &runner{out: &strings.Builder{}, errOut: &errOut} diff --git a/cmd/cloudstic/cmd_diff.go b/cmd/cloudstic/cmd_diff.go index 0f9462c..76b546c 100644 --- a/cmd/cloudstic/cmd_diff.go +++ b/cmd/cloudstic/cmd_diff.go @@ -42,6 +42,9 @@ func (r *runner) runDiff(ctx context.Context) int { if err != nil { return r.fail("Diff failed: %v", err) } + if a.g.jsonEnabled() { + return r.writeJSON(result) + } r.printDiffResult(result) return 0 } diff --git a/cmd/cloudstic/cmd_diff_test.go b/cmd/cloudstic/cmd_diff_test.go index 5857888..1e0888d 100644 --- a/cmd/cloudstic/cmd_diff_test.go +++ b/cmd/cloudstic/cmd_diff_test.go @@ -2,6 +2,7 @@ package main import ( "context" + "encoding/json" "os" "strings" "testing" @@ -42,6 +43,32 @@ func TestRunDiff_Success(t *testing.T) { } } +func TestRunDiff_JSON(t *testing.T) { + os.Args = []string{"cloudstic", "diff", "-json", "aaa", "bbb"} + var out strings.Builder + r := &runner{out: &out, errOut: &strings.Builder{}, client: &stubClient{ + diffResult: &cloudstic.DiffResult{ + Ref1: "snapshot/aaa", + Ref2: "snapshot/bbb", + Changes: []engine.FileChange{ + {Type: "A", Path: "docs/readme.md"}, + }, + }, + }} + + if exit := r.runDiff(context.Background()); exit != 0 { + t.Fatalf("runDiff() exit = %d, want 0", exit) + } + + var got map[string]any + if err := json.Unmarshal([]byte(out.String()), &got); err != nil { + t.Fatalf("json unmarshal: %v\noutput:\n%s", err, out.String()) + } + if got["Ref1"] != "snapshot/aaa" { + t.Fatalf("Ref1 = %v, want snapshot/aaa", got["Ref1"]) + } +} + func TestRunDiff_NoChanges(t *testing.T) { os.Args = []string{"cloudstic", "diff", "aaa", "bbb"} var out strings.Builder diff --git a/cmd/cloudstic/cmd_forget.go b/cmd/cloudstic/cmd_forget.go index 37d648c..9ff4ab2 100644 --- a/cmd/cloudstic/cmd_forget.go +++ b/cmd/cloudstic/cmd_forget.go @@ -178,6 +178,12 @@ func (r *runner) execForgetSingle(a *forgetArgs) int { if err != nil { return r.fail("Forget failed: %v", err) } + if a.g.jsonEnabled() { + return r.writeJSON(&forgetSingleJSONResult{ + SnapshotID: a.snapshotID, + Prune: result.Prune, + }) + } _, _ = fmt.Fprintln(r.out) _, _ = fmt.Fprintln(r.out, "Snapshot removed.") if result.Prune != nil { @@ -192,6 +198,9 @@ func (r *runner) execForgetPolicy(a *forgetArgs) int { if err != nil { return r.fail("Forget failed: %v", err) } + if a.g.jsonEnabled() { + return r.writeJSON(makeForgetPolicyJSONResult(result, a.dryRun)) + } r.printPolicyResult(result, a.dryRun) return 0 } diff --git a/cmd/cloudstic/cmd_init.go b/cmd/cloudstic/cmd_init.go index b921397..aaa54c8 100644 --- a/cmd/cloudstic/cmd_init.go +++ b/cmd/cloudstic/cmd_init.go @@ -70,6 +70,9 @@ func (r *runner) runInit(ctx context.Context) int { return r.fail("Init failed: %v", err) } + if a.g.jsonEnabled() { + return r.writeJSON(result) + } r.printInitResult(result) return 0 } diff --git a/cmd/cloudstic/cmd_key.go b/cmd/cloudstic/cmd_key.go index 0331fdb..da35f73 100644 --- a/cmd/cloudstic/cmd_key.go +++ b/cmd/cloudstic/cmd_key.go @@ -63,6 +63,9 @@ func (r *runner) runKeyList(ctx context.Context) int { return r.fail("Failed to list key slots: %v", err) } + if a.g.jsonEnabled() { + return r.writeJSON(slots) + } r.printKeySlots(slots) return 0 } @@ -133,6 +136,9 @@ func (r *runner) runKeyPasswd(ctx context.Context) int { return r.fail("Failed to change password: %v", err) } + if a.g.jsonEnabled() { + return r.writeJSON(&keyPasswordJSONResult{Changed: true}) + } _, _ = fmt.Fprintln(r.errOut, "Repository password has been changed.") return 0 } @@ -166,6 +172,9 @@ func (r *runner) runAddRecoveryKey(ctx context.Context) int { return r.fail("Failed to create recovery key: %v", err) } + if a.g.jsonEnabled() { + return r.writeJSON(&recoveryKeyJSONResult{RecoveryKey: mnemonic}) + } r.printRecoveryKey(mnemonic) _, _ = fmt.Fprintln(r.errOut, "Recovery key slot has been added to the repository.") return 0 diff --git a/cmd/cloudstic/cmd_list.go b/cmd/cloudstic/cmd_list.go index 4550316..1d2c489 100644 --- a/cmd/cloudstic/cmd_list.go +++ b/cmd/cloudstic/cmd_list.go @@ -35,6 +35,9 @@ func (r *runner) runList(ctx context.Context) int { if err != nil { return r.fail("List failed: %v", err) } + if a.g.jsonEnabled() { + return r.writeJSON(result) + } r.printListResult(result, *a.group) return 0 } diff --git a/cmd/cloudstic/cmd_list_test.go b/cmd/cloudstic/cmd_list_test.go index 465e8c4..35c893f 100644 --- a/cmd/cloudstic/cmd_list_test.go +++ b/cmd/cloudstic/cmd_list_test.go @@ -2,6 +2,7 @@ package main import ( "context" + "encoding/json" "os" "strings" "testing" @@ -31,6 +32,30 @@ func TestRunList_Success(t *testing.T) { } } +func TestRunList_JSON(t *testing.T) { + os.Args = []string{"cloudstic", "list", "-json"} + var out strings.Builder + r := &runner{out: &out, errOut: &strings.Builder{}, client: &stubClient{ + listResult: &cloudstic.ListResult{ + Snapshots: []engine.SnapshotEntry{ + {Ref: "snapshot/abc", Snap: core.Snapshot{Seq: 1, Created: "2024-01-01"}}, + }, + }, + }} + + if exit := r.runList(context.Background()); exit != 0 { + t.Fatalf("runList() exit = %d, want 0", exit) + } + + var got map[string]any + if err := json.Unmarshal([]byte(out.String()), &got); err != nil { + t.Fatalf("json unmarshal: %v\noutput:\n%s", err, out.String()) + } + if _, ok := got["Snapshots"]; !ok { + t.Fatalf("expected Snapshots key in JSON output, got: %v", got) + } +} + func TestRunList_Empty(t *testing.T) { os.Args = []string{"cloudstic", "list"} var out strings.Builder diff --git a/cmd/cloudstic/cmd_ls.go b/cmd/cloudstic/cmd_ls.go index cdbe75e..01c20d4 100644 --- a/cmd/cloudstic/cmd_ls.go +++ b/cmd/cloudstic/cmd_ls.go @@ -43,6 +43,9 @@ func (r *runner) runLsSnapshot(ctx context.Context) int { if err != nil { return r.fail("Ls failed: %v", err) } + if a.g.jsonEnabled() { + return r.writeJSON(result) + } r.printLsResult(result, time.Since(start)) return 0 } diff --git a/cmd/cloudstic/cmd_prune.go b/cmd/cloudstic/cmd_prune.go index 062a91f..b54ac49 100644 --- a/cmd/cloudstic/cmd_prune.go +++ b/cmd/cloudstic/cmd_prune.go @@ -36,6 +36,9 @@ func (r *runner) runPrune(ctx context.Context) int { if err != nil { return r.fail("Prune failed: %v", err) } + if a.g.jsonEnabled() { + return r.writeJSON(result) + } _, _ = fmt.Fprintln(r.out) r.printPruneStats(result) return 0 diff --git a/cmd/cloudstic/cmd_restore.go b/cmd/cloudstic/cmd_restore.go index 43e4f78..3cefbd4 100644 --- a/cmd/cloudstic/cmd_restore.go +++ b/cmd/cloudstic/cmd_restore.go @@ -66,6 +66,9 @@ func (r *runner) execRestore(a *restoreArgs, opts []cloudstic.RestoreOption) int if err != nil { return r.fail("Restore failed: %v", err) } + if a.g.jsonEnabled() { + return r.writeJSON(result) + } r.printRestoreSummary(result, "") return 0 } @@ -75,6 +78,9 @@ func (r *runner) execRestore(a *restoreArgs, opts []cloudstic.RestoreOption) int if err != nil { return r.fail("Restore failed: %v", err) } + if a.g.jsonEnabled() { + return r.writeJSON(result) + } r.printRestoreSummary(result, a.output) return 0 } @@ -90,6 +96,9 @@ func (r *runner) execRestore(a *restoreArgs, opts []cloudstic.RestoreOption) int _ = os.Remove(a.output) return r.fail("Restore failed: %v", err) } + if a.g.jsonEnabled() { + return r.writeJSON(result) + } r.printRestoreSummary(result, a.output) return 0 } diff --git a/cmd/cloudstic/completion.go b/cmd/cloudstic/completion.go index 66b8f5a..78e8c33 100644 --- a/cmd/cloudstic/completion.go +++ b/cmd/cloudstic/completion.go @@ -43,7 +43,7 @@ _cloudstic() { local commands="init backup auth profile store restore list ls prune forget diff break-lock key cat completion version help" - local global_flags="-store -profile -profiles-file -s3-endpoint -s3-region -s3-profile -s3-access-key -s3-secret-key -source-sftp-password -source-sftp-key -source-sftp-known-hosts -source-sftp-insecure -store-sftp-password -store-sftp-key -store-sftp-known-hosts -store-sftp-insecure -encryption-key -password -recovery-key -kms-key-arn -kms-region -kms-endpoint -disable-packfile -prompt -no-prompt -verbose -quiet -debug" + local global_flags="-store -profile -profiles-file -s3-endpoint -s3-region -s3-profile -s3-access-key -s3-secret-key -source-sftp-password -source-sftp-key -source-sftp-known-hosts -source-sftp-insecure -store-sftp-password -store-sftp-key -store-sftp-known-hosts -store-sftp-insecure -encryption-key -password -recovery-key -kms-key-arn -kms-region -kms-endpoint -disable-packfile -prompt -no-prompt -verbose -quiet -json -debug" # Identify the subcommand local cmd="" @@ -52,7 +52,7 @@ _cloudstic() { case "${words[i]}" in -*) # skip flags and their values - -store|-profile|-profiles-file|-s3-endpoint|-s3-region|-s3-profile|-s3-access-key|-s3-secret-key|-source-sftp-password|-source-sftp-key|-source-sftp-known-hosts|-store-sftp-password|-store-sftp-key|-store-sftp-known-hosts|-encryption-key|-password|-recovery-key|-kms-key-arn|-kms-region|-kms-endpoint|-source|-all-profiles|-auth-ref|-google-credentials|-google-credentials-ref|-google-credentials-json|-google-token-file|-google-token-ref|-onedrive-client-id|-onedrive-token-file|-onedrive-token-ref|-tag|-output|-keep-last|-keep-hourly|-keep-daily|-keep-weekly|-keep-monthly|-keep-yearly|-group-by|-account|-json|-xattr-namespaces) + -store|-profile|-profiles-file|-s3-endpoint|-s3-region|-s3-profile|-s3-access-key|-s3-secret-key|-source-sftp-password|-source-sftp-key|-source-sftp-known-hosts|-store|-store-sftp-password|-store-sftp-key|-store-sftp-known-hosts|-encryption-key|-password|-recovery-key|-kms-key-arn|-kms-region|-kms-endpoint|-source|-all-profiles|-auth-ref|-google-credentials|-google-credentials-ref|-google-credentials-json|-google-token-file|-google-token-ref|-onedrive-client-id|-onedrive-token-file|-onedrive-token-ref|-tag|-output|-keep-last|-keep-hourly|-keep-daily|-keep-weekly|-keep-monthly|-keep-yearly|-group-by|-account|-xattr-namespaces) ((i++)) ;; esac ;; @@ -83,7 +83,7 @@ _cloudstic() { forget) cmd_flags="-prune -dry-run -keep-last -keep-hourly -keep-daily -keep-weekly -keep-monthly -keep-yearly -tag -source -account -group-by" ;; cat) - cmd_flags="-json -raw" ;; + cmd_flags="-raw" ;; completion) COMPREPLY=($(compgen -W "bash zsh fish" -- "$cur")) return ;; @@ -275,6 +275,7 @@ _cloudstic() { '-no-prompt[Disable interactive prompts (for scripts and CI)]' '-verbose[Log detailed operations]' '-quiet[Suppress progress bars]' + '-json[Write command result as JSON to stdout]' '-debug[Log every store request]' ) @@ -579,7 +580,6 @@ _cloudstic() { ;; cat) _arguments $global_flags \ - '-json[Suppress non-JSON output]' \ '-raw[Output raw, unformatted data]' \ '*:object key:' ;; @@ -646,6 +646,7 @@ complete -c cloudstic -l prompt -d 'Prompt for password interactively' complete -c cloudstic -l no-prompt -d 'Disable interactive prompts (for scripts and CI)' complete -c cloudstic -l verbose -d 'Log detailed operations' complete -c cloudstic -l quiet -d 'Suppress progress bars' +complete -c cloudstic -l json -d 'Write command result as JSON to stdout' complete -c cloudstic -l debug -d 'Log every store request' # init @@ -770,7 +771,6 @@ complete -c cloudstic -n '__fish_seen_subcommand_from key; and not __fish_seen_s complete -c cloudstic -n '__fish_seen_subcommand_from key; and __fish_seen_subcommand_from passwd' -l new-password -x -d 'New repository password' # cat -complete -c cloudstic -n '__fish_seen_subcommand_from cat' -l json -d 'Suppress non-JSON output' complete -c cloudstic -n '__fish_seen_subcommand_from cat' -l raw -d 'Output raw, unformatted data' # completion diff --git a/cmd/cloudstic/flags.go b/cmd/cloudstic/flags.go index 1ce45d9..57e35cd 100644 --- a/cmd/cloudstic/flags.go +++ b/cmd/cloudstic/flags.go @@ -38,6 +38,7 @@ type globalFlags struct { kmsKeyARN, kmsRegion, kmsEndpoint *string disablePackfile *bool prompt, verbose, quiet, debug *bool + json *bool debugLog *ui.SafeLogWriter } @@ -76,10 +77,15 @@ func addGlobalFlags(fs *flag.FlagSet) *globalFlags { g.prompt = fs.Bool("prompt", false, "Prompt for password interactively (use alongside --encryption-key or --kms-key-arn to add a password layer)") g.verbose = fs.Bool("verbose", false, "Log detailed file-level operations") g.quiet = fs.Bool("quiet", false, "Suppress progress bars (keeps final summary)") + g.json = fs.Bool("json", false, "Write command result as JSON to stdout") g.debug = fs.Bool("debug", false, "Log every store request (network calls, timing, sizes)") return g } +func (g *globalFlags) jsonEnabled() bool { + return g != nil && g.json != nil && *g.json +} + func cliFlagProvided(name string) bool { for _, arg := range os.Args[1:] { if arg == "-"+name || arg == "--"+name { diff --git a/cmd/cloudstic/json_output.go b/cmd/cloudstic/json_output.go new file mode 100644 index 0000000..bc2dc66 --- /dev/null +++ b/cmd/cloudstic/json_output.go @@ -0,0 +1,88 @@ +package main + +import ( + "encoding/base64" + "encoding/json" + "unicode/utf8" + + cloudstic "github.com/cloudstic/cli" + "github.com/cloudstic/cli/internal/engine" +) + +type forgetSingleJSONResult struct { + SnapshotID string `json:"snapshot_id"` + Prune *cloudstic.PruneResult `json:"prune,omitempty"` +} + +type forgetPolicyJSONResult struct { + DryRun bool `json:"dry_run"` + Groups []engine.PolicyGroupResult `json:"groups"` + Prune *cloudstic.PruneResult `json:"prune,omitempty"` +} + +type breakLockJSONResult struct { + Locks []*cloudstic.RepoLock `json:"locks"` +} + +type keyPasswordJSONResult struct { + Changed bool `json:"changed"` +} + +type recoveryKeyJSONResult struct { + RecoveryKey string `json:"recovery_key"` +} + +type catJSONResult struct { + Key string `json:"key"` + Data any `json:"data"` + Encoding string `json:"encoding,omitempty"` +} + +func (r *runner) writeJSON(v any) int { + enc := json.NewEncoder(r.out) + enc.SetIndent("", " ") + enc.SetEscapeHTML(false) + if err := enc.Encode(v); err != nil { + return r.fail("Failed to write JSON output: %v", err) + } + return 0 +} + +func makeForgetPolicyJSONResult(result *cloudstic.PolicyResult, dryRun bool) *forgetPolicyJSONResult { + if result == nil { + return nil + } + return &forgetPolicyJSONResult{ + DryRun: dryRun, + Groups: result.Groups, + Prune: result.Prune, + } +} + +func makeCatJSONResults(results []*cloudstic.CatResult) []catJSONResult { + items := make([]catJSONResult, 0, len(results)) + for _, result := range results { + items = append(items, makeCatJSONResult(result)) + } + return items +} + +func makeCatJSONResult(result *cloudstic.CatResult) catJSONResult { + item := catJSONResult{Key: result.Key} + var decoded any + if err := json.Unmarshal(result.Data, &decoded); err == nil { + item.Data = decoded + return item + } + if utf8.Valid(result.Data) { + item.Data = string(result.Data) + return item + } + item.Data = base64.StdEncoding.EncodeToString(result.Data) + item.Encoding = "base64" + return item +} + +func (r *runner) failJSONFlagConflict(flagA, flagB string) int { + return r.fail("%s cannot be combined with %s", flagA, flagB) +} diff --git a/cmd/cloudstic/store.go b/cmd/cloudstic/store.go index a85710d..dda27d8 100644 --- a/cmd/cloudstic/store.go +++ b/cmd/cloudstic/store.go @@ -58,7 +58,7 @@ func (g *globalFlags) openClient(ctx context.Context) (*cloudstic.Client, error) packfileEnabled := g.disablePackfile == nil || !*g.disablePackfile var reporter cloudstic.Reporter - if *g.quiet { + if *g.quiet || g.jsonEnabled() { reporter = ui.NewNoOpReporter() } else { cr := ui.NewConsoleReporter() diff --git a/cmd/cloudstic/usage.go b/cmd/cloudstic/usage.go index df7f7d1..5ec383f 100644 --- a/cmd/cloudstic/usage.go +++ b/cmd/cloudstic/usage.go @@ -66,6 +66,7 @@ func printUsage() { {"-no-prompt", "Disable interactive prompts (for scripts and CI)"}, {"-verbose", "Log detailed file-level operations"}, {"-quiet", "Suppress progress bars (keeps final summary)"}, + {"-json", "Write command result as JSON to stdout"}, {"-debug", "Log every store request (network calls, timing, sizes)"}, }) t.Blank() @@ -354,9 +355,6 @@ func printUsage() { t.Blank() t.Command("cat", " [object_key...]") - t.Flags([][2]string{ - {"-json", "Suppress non-JSON output (alias for -quiet)"}, - }) t.Note(" Display raw JSON for one or more repository objects.", " Object keys: snapshot/, filemeta/, content/,", " node/, chunk/, config, index/latest, keys/") diff --git a/docs/user-guide.md b/docs/user-guide.md index fe0b68e..cd1d58e 100644 --- a/docs/user-guide.md +++ b/docs/user-guide.md @@ -222,11 +222,14 @@ These flags apply to all commands: |------|-------------| | `-verbose` | Log detailed file-level operations (files scanned, written, deleted) | | `-quiet` | Suppress progress bars (keeps final summary output) | +| `-json` | Write the command result as JSON to stdout | | `-debug` | Log every store request (network calls, timing, sizes) | | `-disable-packfile` | Disable bundling small objects into 8MB packs (packfile is on by default) — env: `CLOUDSTIC_DISABLE_PACKFILE=1` | `-verbose` and `-quiet` are mutually exclusive. If both are set, `-quiet` takes precedence. +`-json` is available on the operational commands that return structured results, including `init`, `backup`, `restore`, `list`, `ls`, `diff`, `forget`, `prune`, `break-lock`, `check`, `key list`, `key add-recovery`, `key passwd`, and `cat`. When `-json` is set, Cloudstic suppresses progress output and writes a single JSON document to stdout instead of the usual human-readable summary. + ### init Initialize a new repository. Encryption is **required by default**. @@ -1052,7 +1055,7 @@ cloudstic cat filemeta/789abc... # Display a HAMT node cloudstic cat node/def456... -# Suppress non-JSON output for scripting +# Emit a machine-readable JSON result cloudstic cat config -json # Output raw, unformatted data for hashing @@ -1077,10 +1080,10 @@ cloudstic cat -raw filemeta/789abc... | sha256sum | Flag | Description | |------|-------------| -| `-json` | Suppress non-JSON output (alias for `-quiet`) | +| `-json` | Write a JSON array of fetched objects to stdout | | `-raw` | Output raw, unformatted data (useful for hashing) | -The output is pretty-printed JSON by default. Use `-json` or `-quiet` to suppress header messages when fetching multiple objects, which is useful for piping to `jq` or other JSON processors. +The output is pretty-printed object content by default. With `-json`, Cloudstic returns a single JSON array where each item includes the object key and decoded data. Use `-raw` when you need the exact stored bytes instead of a structured JSON document. **Examples:** From 94dc12fc7467def753a848084bb6490cb237c7fb Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Lo=C3=AFc=20Hermann?= Date: Thu, 2 Apr 2026 13:31:57 +0200 Subject: [PATCH 2/2] test: cover cat json output in e2e --- e2e/feature_cat_test.go | 58 +++++++++++++++++++++++------------------ 1 file changed, 32 insertions(+), 26 deletions(-) diff --git a/e2e/feature_cat_test.go b/e2e/feature_cat_test.go index 06603f9..e1c6889 100644 --- a/e2e/feature_cat_test.go +++ b/e2e/feature_cat_test.go @@ -5,8 +5,13 @@ import ( "testing" ) -// TestCLI_Feature_CatConfig verifies that `cloudstic cat config` returns valid -// JSON with expected fields after repository init. +type catJSONItem struct { + Key string `json:"key"` + Data map[string]interface{} `json:"data"` +} + +// TestCLI_Feature_CatConfig verifies that `cloudstic cat config -json` +// returns a structured JSON array containing the config object. func TestCLI_Feature_CatConfig(t *testing.T) { runFeatureMatrix(t, featureSpec{ name: "cat_config", @@ -15,24 +20,29 @@ func TestCLI_Feature_CatConfig(t *testing.T) { test: func(t *testing.T, h *harness, entry matrixEntry) { r := h.MustInitEncrypted() - var cfg map[string]interface{} - r.Cat("-json", "config").MustUnmarshalJSON(&cfg) + var items []catJSONItem + out := r.Cat("-json", "config").MustUnmarshalJSON(&items).Raw() + if len(items) != 1 { + t.Fatalf("cat config: expected 1 result, got %d\noutput:\n%s", len(items), out) + } + if items[0].Key != "config" { + t.Fatalf("cat config: key = %q, want %q", items[0].Key, "config") + } - // An encrypted repo must declare encrypted: true. - encrypted, ok := cfg["encrypted"] + encrypted, ok := items[0].Data["encrypted"] if !ok { - t.Error("cat config: missing 'encrypted' field") + t.Fatal("cat config: missing 'encrypted' field") } if enc, _ := encrypted.(bool); !enc { - t.Errorf("cat config: expected encrypted=true, got %v", encrypted) + t.Fatalf("cat config: expected encrypted=true, got %v", encrypted) } }, }) } -// TestCLI_Feature_CatIndexLatest verifies that after a backup, `cat index/latest` -// returns a JSON object pointing to a snapshot. -// The index/latest object has a "latest_snapshot" field (not "ref"). +// TestCLI_Feature_CatIndexLatest verifies that after a backup, +// `cloudstic cat index/latest -json` returns a structured JSON array +// containing an object with a latest snapshot reference. func TestCLI_Feature_CatIndexLatest(t *testing.T) { runFeatureMatrix(t, featureSpec{ name: "cat_index_latest", @@ -42,27 +52,23 @@ func TestCLI_Feature_CatIndexLatest(t *testing.T) { r := h.WithFile("file.txt", "content").MustInitEncrypted() r.Backup() - var idx map[string]interface{} - out := r.Cat("-json", "index/latest").MustUnmarshalJSON(&idx).Raw() + var items []catJSONItem + out := r.Cat("-json", "index/latest").MustUnmarshalJSON(&items).Raw() + if len(items) != 1 { + t.Fatalf("cat index/latest: expected 1 result, got %d\noutput:\n%s", len(items), out) + } + if items[0].Key != "index/latest" { + t.Fatalf("cat index/latest: key = %q, want %q", items[0].Key, "index/latest") + } - // The field name is "latest_snapshot" (full snapshot key). - ref, ok := idx["latest_snapshot"] + ref, ok := items[0].Data["latest_snapshot"] if !ok { - t.Errorf("cat index/latest: missing 'latest_snapshot' field; got keys: %v\nraw output:\n%s", mapKeys(idx), out) - return + t.Fatalf("cat index/latest: missing 'latest_snapshot' field\noutput:\n%s", out) } refStr, _ := ref.(string) if !strings.HasPrefix(refStr, "snapshot/") { - t.Errorf("cat index/latest: expected 'latest_snapshot' to start with 'snapshot/', got %q", refStr) + t.Fatalf("cat index/latest: expected latest_snapshot to start with 'snapshot/', got %q", refStr) } }, }) } - -func mapKeys(m map[string]interface{}) []string { - keys := make([]string, 0, len(m)) - for k := range m { - keys = append(keys, k) - } - return keys -}