Route direct-stdout command output through internal/ui (closes #149) #201
@@ -645,17 +645,26 @@ priority.
|
|||||||
|
|
||||||
## output style
|
## output style
|
||||||
|
|
||||||
The operational narration of the long-running commands — the Begin,
|
Every command's user-facing output is governed by `internal/ui`, in one
|
||||||
Complete, Progress, and status lines of `snapshot create`, `prune`,
|
of two ways. Color is enabled when stdout is a TTY and the `NO_COLOR`
|
||||||
`snapshot restore`, and the like — goes through helpers in `internal/ui`
|
environment variable is unset (https://no-color.org/).
|
||||||
and conforms to the uniform style below. Some commands instead write
|
|
||||||
plain text straight to stdout (`version`, `info`, `config`, the
|
* **Status, progress, warnings, and errors** go through the `internal/ui`
|
||||||
`database delete` prompt, and the `snapshot list` table); that output is
|
message methods below: marker-prefixed, colored on a TTY, and — except
|
||||||
unstyled and does not honor `--quiet`. Routing it through `internal/ui`
|
warnings and errors — silenced by `--quiet`. This is the operational
|
||||||
is tracked in
|
narration of the long-running commands (`snapshot create`, `prune`,
|
||||||
[issue #149](https://git.eeqj.de/sneak/vaultik/issues/149). Color is
|
`snapshot restore`, and the like) and the confirmations of
|
||||||
enabled when stdout is a TTY and the `NO_COLOR` environment variable is
|
`config init`, `config set`, and `database delete`.
|
||||||
unset (https://no-color.org/).
|
* **The data a command exists to produce** is written plain, with no
|
||||||
|
marker and no color, because a marker would corrupt a table or a
|
||||||
|
parsed document. This covers the `version`, `info`, and `remote info`
|
||||||
|
reports, the `snapshot list` table, `config get` values, and every
|
||||||
|
`--json` document. `--quiet` silences the human reports and tables
|
||||||
|
(`version`, `info`, `remote info`, `snapshot list`) but never the
|
||||||
|
machine-consumed `config get` value or the `--json` documents, which a
|
||||||
|
script depends on. The `database delete` confirmation prompt is also
|
||||||
|
written this way and always shown: it is an interactive exchange the
|
||||||
|
operator must see.
|
||||||
|
|
||||||
`internal/ui` writes to stdout; it is the output the user asked for.
|
`internal/ui` writes to stdout; it is the output the user asked for.
|
||||||
Structured log records are a different thing and go through
|
Structured log records are a different thing and go through
|
||||||
|
|||||||
@@ -25,6 +25,21 @@ release" is exactly the contradiction
|
|||||||
|
|
||||||
# Completed Steps
|
# Completed Steps
|
||||||
|
|
||||||
|
- 2026-09-22: Routed the last direct-to-stdout command output through
|
||||||
|
`internal/ui`
|
||||||
|
([issue #149](https://git.eeqj.de/sneak/vaultik/issues/149)). The
|
||||||
|
`version`, `info`, `remote info`, `config`, and `database delete`
|
||||||
|
commands wrote plain text straight to stdout, so they were unstyled and
|
||||||
|
ignored `--quiet`. Output now falls in two buckets, both governed by
|
||||||
|
`internal/ui`: status lines and confirmations go through its message
|
||||||
|
methods (styled, and `--quiet` silences them), while the data a command
|
||||||
|
exists to produce — the reports, the `snapshot list` table, `config get`
|
||||||
|
values, and the `--json` documents — is written plain. `--quiet`
|
||||||
|
silences the human reports and tables but never the `config get` value
|
||||||
|
or the `--json` documents, which a script depends on, and the
|
||||||
|
`database delete` confirmation prompt is always shown. The README
|
||||||
|
output-style section now states this rule.
|
||||||
|
|
||||||
- 2026-09-22: Validated blob hashes, offsets and lengths read back from
|
- 2026-09-22: Validated blob hashes, offsets and lengths read back from
|
||||||
the destination before using them
|
the destination before using them
|
||||||
([issue #155](https://git.eeqj.de/sneak/vaultik/issues/155)). A blob
|
([issue #155](https://git.eeqj.de/sneak/vaultik/issues/155)). A blob
|
||||||
|
|||||||
+25
-17
@@ -4,7 +4,6 @@ import (
|
|||||||
"bytes"
|
"bytes"
|
||||||
"errors"
|
"errors"
|
||||||
"fmt"
|
"fmt"
|
||||||
"io"
|
|
||||||
"os"
|
"os"
|
||||||
"os/exec"
|
"os/exec"
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
@@ -13,6 +12,7 @@ import (
|
|||||||
|
|
||||||
"github.com/spf13/cobra"
|
"github.com/spf13/cobra"
|
||||||
"gopkg.in/yaml.v3"
|
"gopkg.in/yaml.v3"
|
||||||
|
"sneak.berlin/go/vaultik/internal/ui"
|
||||||
)
|
)
|
||||||
|
|
||||||
// configFileMode is the permission set for freshly written config files;
|
// configFileMode is the permission set for freshly written config files;
|
||||||
@@ -265,7 +265,7 @@ The config is written to the path from --config, $VAULTIK_CONFIG, or
|
|||||||
the platform default config directory (e.g. ~/Library/Application Support/
|
the platform default config directory (e.g. ~/Library/Application Support/
|
||||||
on macOS, ~/.config/ on Linux, /etc/vaultik/ as root).`,
|
on macOS, ~/.config/ on Linux, /etc/vaultik/ as root).`,
|
||||||
Args: cobra.NoArgs,
|
Args: cobra.NoArgs,
|
||||||
RunE: func(_ *cobra.Command, _ []string) error {
|
RunE: func(cmd *cobra.Command, _ []string) error {
|
||||||
path := configPathForInit()
|
path := configPathForInit()
|
||||||
|
|
||||||
_, err := os.Stat(path)
|
_, err := os.Stat(path)
|
||||||
@@ -285,8 +285,11 @@ on macOS, ~/.config/ on Linux, /etc/vaultik/ as root).`,
|
|||||||
return fmt.Errorf("writing config file: %w", err)
|
return fmt.Errorf("writing config file: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
_, _ = fmt.Fprintf(os.Stdout, "Config written to %s\n", path)
|
// A written-confirmation, not scriptable output: route it
|
||||||
_, _ = fmt.Fprintln(os.Stdout,
|
// through the UI so it is styled and --quiet silences it.
|
||||||
|
out := commandUI(cmd)
|
||||||
|
out.Infof("Config written to %s.", path)
|
||||||
|
out.Infof(
|
||||||
"Edit it to set your age_recipients, snapshots, and storage_url.")
|
"Edit it to set your age_recipients, snapshots, and storage_url.")
|
||||||
|
|
||||||
return nil
|
return nil
|
||||||
@@ -328,7 +331,7 @@ func newConfigGetCommand() *cobra.Command {
|
|||||||
Use: "get <key>",
|
Use: "get <key>",
|
||||||
Short: "Print a config value by dotted path (e.g. storage_url, compression_level)",
|
Short: "Print a config value by dotted path (e.g. storage_url, compression_level)",
|
||||||
Args: cobra.ExactArgs(1),
|
Args: cobra.ExactArgs(1),
|
||||||
RunE: func(_ *cobra.Command, args []string) error {
|
RunE: func(cmd *cobra.Command, args []string) error {
|
||||||
path, err := ResolveConfigPath()
|
path, err := ResolveConfigPath()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
@@ -344,8 +347,13 @@ func newConfigGetCommand() *cobra.Command {
|
|||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// The value is scriptable output: it must stay machine-plain
|
||||||
|
// (no marker, no color) and is never silenced by --quiet, so it
|
||||||
|
// is written straight to stdout rather than through the UI.
|
||||||
|
w := cmd.OutOrStdout()
|
||||||
|
|
||||||
if node.Kind == yaml.ScalarNode {
|
if node.Kind == yaml.ScalarNode {
|
||||||
_, _ = fmt.Fprintln(os.Stdout, node.Value)
|
_, _ = fmt.Fprintln(w, node.Value)
|
||||||
|
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
@@ -355,7 +363,7 @@ func newConfigGetCommand() *cobra.Command {
|
|||||||
return fmt.Errorf("marshaling value: %w", err)
|
return fmt.Errorf("marshaling value: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
_, _ = fmt.Fprint(os.Stdout, string(out))
|
_, _ = fmt.Fprint(w, string(out))
|
||||||
|
|
||||||
return nil
|
return nil
|
||||||
},
|
},
|
||||||
@@ -377,23 +385,23 @@ Examples:
|
|||||||
vaultik config set compression_level 9
|
vaultik config set compression_level 9
|
||||||
vaultik config set s3.bucket mybucket # legacy S3 fields still supported`,
|
vaultik config set s3.bucket mybucket # legacy S3 fields still supported`,
|
||||||
Args: cobra.ExactArgs(configSetArgs),
|
Args: cobra.ExactArgs(configSetArgs),
|
||||||
RunE: func(_ *cobra.Command, args []string) error {
|
RunE: func(cmd *cobra.Command, args []string) error {
|
||||||
path, err := ResolveConfigPath()
|
path, err := ResolveConfigPath()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|
||||||
return writeConfigSet(os.Stdout, path, args[0], args[1])
|
return writeConfigSet(commandUI(cmd), path, args[0], args[1])
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// writeConfigSet applies key=value to the config at path, writes it back
|
// writeConfigSet applies key=value to the config at path, writes it back
|
||||||
// owner-only, and confirms the write by printing just the key name to w.
|
// owner-only, and confirms the write by naming just the key through the
|
||||||
// The value is never echoed: it may be a secret such as
|
// UI writer (styled, and silenced by --quiet). The value is never
|
||||||
// s3.secret_access_key, and captured stdout or a pasted terminal would
|
// echoed: it may be a secret such as s3.secret_access_key, and captured
|
||||||
// then leak it.
|
// stdout or a pasted terminal would then leak it.
|
||||||
func writeConfigSet(w io.Writer, path, key, value string) error {
|
func writeConfigSet(out *ui.Writer, path, key, value string) error {
|
||||||
root, err := loadYAMLFile(path)
|
root, err := loadYAMLFile(path)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
@@ -404,12 +412,12 @@ func writeConfigSet(w io.Writer, path, key, value string) error {
|
|||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|
||||||
out, err := marshalConfigYAML(root)
|
data, err := marshalConfigYAML(root)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return fmt.Errorf("marshaling config: %w", err)
|
return fmt.Errorf("marshaling config: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
err = os.WriteFile(path, out, configFileMode)
|
err = os.WriteFile(path, data, configFileMode)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return fmt.Errorf("writing config file: %w", err)
|
return fmt.Errorf("writing config file: %w", err)
|
||||||
}
|
}
|
||||||
@@ -425,7 +433,7 @@ func writeConfigSet(w io.Writer, path, key, value string) error {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
_, _ = fmt.Fprintln(w, key)
|
out.Infof("Set %s.", key)
|
||||||
|
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -9,6 +9,7 @@ import (
|
|||||||
|
|
||||||
"gopkg.in/yaml.v3"
|
"gopkg.in/yaml.v3"
|
||||||
"sneak.berlin/go/vaultik/internal/config"
|
"sneak.berlin/go/vaultik/internal/config"
|
||||||
|
"sneak.berlin/go/vaultik/internal/ui"
|
||||||
)
|
)
|
||||||
|
|
||||||
// TestDefaultConfigTemplateParses ensures the init template is valid YAML
|
// TestDefaultConfigTemplateParses ensures the init template is valid YAML
|
||||||
@@ -246,19 +247,20 @@ func TestWriteConfigSetHidesSecret(t *testing.T) {
|
|||||||
t.Fatalf("seed config: %v", err)
|
t.Fatalf("seed config: %v", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
var out bytes.Buffer
|
var buf bytes.Buffer
|
||||||
|
|
||||||
err = writeConfigSet(&out, path, "s3.secret_access_key", secret)
|
err = writeConfigSet(ui.NewWithColor(&buf, false), path,
|
||||||
|
"s3.secret_access_key", secret)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("writeConfigSet: %v", err)
|
t.Fatalf("writeConfigSet: %v", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
if strings.Contains(out.String(), secret) {
|
if strings.Contains(buf.String(), secret) {
|
||||||
t.Errorf("output echoed the secret value: %q", out.String())
|
t.Errorf("output echoed the secret value: %q", buf.String())
|
||||||
}
|
}
|
||||||
|
|
||||||
if !strings.Contains(out.String(), "s3.secret_access_key") {
|
if !strings.Contains(buf.String(), "s3.secret_access_key") {
|
||||||
t.Errorf("output did not confirm the key name: %q", out.String())
|
t.Errorf("output did not confirm the key name: %q", buf.String())
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -277,9 +279,10 @@ func TestWriteConfigSetTightensMode(t *testing.T) {
|
|||||||
t.Fatalf("seed config: %v", err)
|
t.Fatalf("seed config: %v", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
var out bytes.Buffer
|
var buf bytes.Buffer
|
||||||
|
|
||||||
err = writeConfigSet(&out, path, "compression_level", "9")
|
err = writeConfigSet(ui.NewWithColor(&buf, false), path,
|
||||||
|
"compression_level", "9")
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("writeConfigSet: %v", err)
|
t.Fatalf("writeConfigSet: %v", err)
|
||||||
}
|
}
|
||||||
|
|||||||
+12
-11
@@ -48,7 +48,7 @@ storage destination on that run.
|
|||||||
|
|
||||||
Use --force to skip the confirmation prompt.`,
|
Use --force to skip the confirmation prompt.`,
|
||||||
Args: cobra.NoArgs,
|
Args: cobra.NoArgs,
|
||||||
RunE: func(_ *cobra.Command, _ []string) error {
|
RunE: func(cmd *cobra.Command, _ []string) error {
|
||||||
// Resolve config path
|
// Resolve config path
|
||||||
configPath, err := ResolveConfigPath()
|
configPath, err := ResolveConfigPath()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -62,26 +62,31 @@ Use --force to skip the confirmation prompt.`,
|
|||||||
}
|
}
|
||||||
|
|
||||||
dbPath := cfg.IndexPath
|
dbPath := cfg.IndexPath
|
||||||
|
out := commandUI(cmd)
|
||||||
|
|
||||||
// Check if database exists
|
// Check if database exists
|
||||||
_, err = os.Stat(dbPath)
|
_, err = os.Stat(dbPath)
|
||||||
if os.IsNotExist(err) {
|
if os.IsNotExist(err) {
|
||||||
_, _ = fmt.Fprintf(os.Stdout, "Database does not exist: %s\n", dbPath)
|
out.Infof("Local state database does not exist: %s.", dbPath)
|
||||||
|
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// Confirm unless --force
|
// Confirm unless --force. The prompt and its immediate result
|
||||||
|
// are an interactive exchange the operator must see, so they go
|
||||||
|
// straight to stdout rather than through the UI and --quiet does
|
||||||
|
// not silence them.
|
||||||
if !force {
|
if !force {
|
||||||
_, _ = fmt.Fprintf(os.Stdout,
|
w := cmd.OutOrStdout()
|
||||||
|
_, _ = fmt.Fprintf(w,
|
||||||
"This will delete the local state database at:\n %s\n\n", dbPath)
|
"This will delete the local state database at:\n %s\n\n", dbPath)
|
||||||
_, _ = fmt.Fprint(os.Stdout, "Are you sure? Type 'yes' to confirm: ")
|
_, _ = fmt.Fprint(w, "Are you sure? Type 'yes' to confirm: ")
|
||||||
|
|
||||||
var confirm string
|
var confirm string
|
||||||
|
|
||||||
_, err = fmt.Scanln(&confirm)
|
_, err = fmt.Scanln(&confirm)
|
||||||
if err != nil || confirm != "yes" {
|
if err != nil || confirm != "yes" {
|
||||||
_, _ = fmt.Fprintln(os.Stdout, "Aborted.")
|
_, _ = fmt.Fprintln(w, "Aborted.")
|
||||||
|
|
||||||
//nolint:nilerr // a failed/aborted confirmation is a clean abort
|
//nolint:nilerr // a failed/aborted confirmation is a clean abort
|
||||||
return nil
|
return nil
|
||||||
@@ -100,11 +105,7 @@ Use --force to skip the confirmation prompt.`,
|
|||||||
_ = os.Remove(walPath) // Ignore errors - files may not exist
|
_ = os.Remove(walPath) // Ignore errors - files may not exist
|
||||||
_ = os.Remove(shmPath)
|
_ = os.Remove(shmPath)
|
||||||
|
|
||||||
rootFlags := GetRootFlags()
|
out.Infof("Local state database deleted: %s.", dbPath)
|
||||||
if !rootFlags.Quiet {
|
|
||||||
_, _ = fmt.Fprintf(os.Stdout, "Database deleted: %s\n", dbPath)
|
|
||||||
}
|
|
||||||
|
|
||||||
log.Info("Local state database deleted", "path", dbPath)
|
log.Info("Local state database deleted", "path", dbPath)
|
||||||
|
|
||||||
return nil
|
return nil
|
||||||
|
|||||||
@@ -0,0 +1,206 @@
|
|||||||
|
package cli //nolint:testpackage // sets the unexported rootFlags directly
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bytes"
|
||||||
|
"fmt"
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"github.com/spf13/cobra"
|
||||||
|
)
|
||||||
|
|
||||||
|
// setRootFlags overrides the global rootFlags for the duration of one
|
||||||
|
// test and restores it afterward. These tests must not run in parallel:
|
||||||
|
// the flags are process-global, so the whole struct is saved and put
|
||||||
|
// back rather than left mutated for the next test.
|
||||||
|
func setRootFlags(t *testing.T, f RootFlags) {
|
||||||
|
t.Helper()
|
||||||
|
|
||||||
|
old := rootFlags
|
||||||
|
rootFlags = f
|
||||||
|
|
||||||
|
t.Cleanup(func() { rootFlags = old })
|
||||||
|
}
|
||||||
|
|
||||||
|
// seedFile writes content to a fresh file and returns its path.
|
||||||
|
func seedFile(t *testing.T, dir, name, content string) string {
|
||||||
|
t.Helper()
|
||||||
|
|
||||||
|
path := filepath.Join(dir, name)
|
||||||
|
|
||||||
|
err := os.WriteFile(path, []byte(content), 0o600)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("seeding %s: %v", name, err)
|
||||||
|
}
|
||||||
|
|
||||||
|
return path
|
||||||
|
}
|
||||||
|
|
||||||
|
// mustExecute runs a command with its output captured and fails the test
|
||||||
|
// if it errors, returning what the command printed.
|
||||||
|
func mustExecute(t *testing.T, cmd *cobra.Command, args ...string) string {
|
||||||
|
t.Helper()
|
||||||
|
|
||||||
|
var out bytes.Buffer
|
||||||
|
|
||||||
|
cmd.SetOut(&out)
|
||||||
|
cmd.SetArgs(args)
|
||||||
|
|
||||||
|
err := cmd.Execute()
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("%s failed: %v", cmd.Name(), err)
|
||||||
|
}
|
||||||
|
|
||||||
|
return out.String()
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestVersionQuietSuppressesReport checks that --quiet silences the whole
|
||||||
|
// version report: it is human-facing output, not a scriptable value.
|
||||||
|
//
|
||||||
|
//nolint:paralleltest // mutates the process-global rootFlags
|
||||||
|
func TestVersionQuietSuppressesReport(t *testing.T) {
|
||||||
|
setRootFlags(t, RootFlags{Quiet: true})
|
||||||
|
|
||||||
|
out := mustExecute(t, NewVersionCommand())
|
||||||
|
|
||||||
|
if out != "" {
|
||||||
|
t.Errorf("--quiet version printed %q, want nothing", out)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestConfigGetIgnoresQuiet checks that a config value is printed even
|
||||||
|
// under --quiet: it is scriptable output a caller depends on, so --quiet
|
||||||
|
// must not suppress it, and it stays machine-plain (no marker, no color).
|
||||||
|
//
|
||||||
|
//nolint:paralleltest // mutates the process-global rootFlags
|
||||||
|
func TestConfigGetIgnoresQuiet(t *testing.T) {
|
||||||
|
dir := t.TempDir()
|
||||||
|
path := seedFile(t, dir, "config.yml", "storage_url: file:///mnt/x\n")
|
||||||
|
|
||||||
|
setRootFlags(t, RootFlags{Quiet: true, ConfigPath: path})
|
||||||
|
|
||||||
|
out := mustExecute(t, newConfigGetCommand(), "storage_url")
|
||||||
|
|
||||||
|
if out != "file:///mnt/x\n" {
|
||||||
|
t.Errorf("config get --quiet = %q, want the plain value", out)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestConfigSetQuietSuppressesConfirmation checks that --quiet silences
|
||||||
|
// the confirmation line while still writing the value to the file.
|
||||||
|
//
|
||||||
|
//nolint:paralleltest // mutates the process-global rootFlags
|
||||||
|
func TestConfigSetQuietSuppressesConfirmation(t *testing.T) {
|
||||||
|
dir := t.TempDir()
|
||||||
|
path := seedFile(t, dir, "config.yml", "compression_level: 3\n")
|
||||||
|
|
||||||
|
setRootFlags(t, RootFlags{Quiet: true, ConfigPath: path})
|
||||||
|
|
||||||
|
out := mustExecute(t, newConfigSetCommand(), "compression_level", "9")
|
||||||
|
|
||||||
|
if out != "" {
|
||||||
|
t.Errorf("--quiet config set printed %q, want nothing", out)
|
||||||
|
}
|
||||||
|
|
||||||
|
data, err := os.ReadFile(path) //nolint:gosec // G304: test-controlled path
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("reading config back: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if !strings.Contains(string(data), "compression_level: 9") {
|
||||||
|
t.Errorf("config set did not write the value under --quiet:\n%s", data)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestConfigSetConfirmsWhenNotQuiet checks that the confirmation names
|
||||||
|
// the key (styled) when --quiet is not set.
|
||||||
|
//
|
||||||
|
//nolint:paralleltest // mutates the process-global rootFlags
|
||||||
|
func TestConfigSetConfirmsWhenNotQuiet(t *testing.T) {
|
||||||
|
dir := t.TempDir()
|
||||||
|
path := seedFile(t, dir, "config.yml", "compression_level: 3\n")
|
||||||
|
|
||||||
|
setRootFlags(t, RootFlags{ConfigPath: path})
|
||||||
|
|
||||||
|
out := mustExecute(t, newConfigSetCommand(), "compression_level", "9")
|
||||||
|
|
||||||
|
if !strings.Contains(out, "compression_level") {
|
||||||
|
t.Errorf("config set did not confirm the key: %q", out)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestConfigInitQuietSuppressesConfirmation checks that --quiet silences
|
||||||
|
// the "config written" confirmation while still writing the file.
|
||||||
|
//
|
||||||
|
//nolint:paralleltest // mutates the process-global rootFlags
|
||||||
|
func TestConfigInitQuietSuppressesConfirmation(t *testing.T) {
|
||||||
|
dir := t.TempDir()
|
||||||
|
path := filepath.Join(dir, "new-config.yml")
|
||||||
|
|
||||||
|
setRootFlags(t, RootFlags{Quiet: true, ConfigPath: path})
|
||||||
|
|
||||||
|
out := mustExecute(t, newConfigInitCommand())
|
||||||
|
|
||||||
|
if out != "" {
|
||||||
|
t.Errorf("--quiet config init printed %q, want nothing", out)
|
||||||
|
}
|
||||||
|
|
||||||
|
_, err := os.Stat(path)
|
||||||
|
if err != nil {
|
||||||
|
t.Errorf("config init did not write the file under --quiet: %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// seedDatabaseDeleteConfig writes a valid config whose index_path is a
|
||||||
|
// seeded database file, and returns both paths.
|
||||||
|
func seedDatabaseDeleteConfig(t *testing.T, dir string) (string, string) {
|
||||||
|
t.Helper()
|
||||||
|
|
||||||
|
dbPath := seedFile(t, dir, "index.sqlite", "not-a-real-db")
|
||||||
|
cfg := fmt.Sprintf(hermeticConfig,
|
||||||
|
filepath.Join(dir, "source"), filepath.Join(dir, "store"), dbPath)
|
||||||
|
cfgPath := seedFile(t, dir, "config.yml", cfg)
|
||||||
|
|
||||||
|
return dbPath, cfgPath
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestDatabaseDeleteQuietSuppressesMessage checks that --quiet silences
|
||||||
|
// the "database deleted" line while still removing the file.
|
||||||
|
//
|
||||||
|
//nolint:paralleltest // mutates the process-global rootFlags
|
||||||
|
func TestDatabaseDeleteQuietSuppressesMessage(t *testing.T) {
|
||||||
|
dir := t.TempDir()
|
||||||
|
dbPath, cfgPath := seedDatabaseDeleteConfig(t, dir)
|
||||||
|
|
||||||
|
setRootFlags(t, RootFlags{Quiet: true, ConfigPath: cfgPath})
|
||||||
|
|
||||||
|
out := mustExecute(t, newDatabaseDeleteCommand(), "--force")
|
||||||
|
|
||||||
|
if out != "" {
|
||||||
|
t.Errorf("--quiet database delete printed %q, want nothing", out)
|
||||||
|
}
|
||||||
|
|
||||||
|
_, err := os.Stat(dbPath)
|
||||||
|
if !os.IsNotExist(err) {
|
||||||
|
t.Errorf("database delete did not remove the file: stat err = %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestDatabaseDeleteReportsWhenNotQuiet checks that the deletion is
|
||||||
|
// reported when --quiet is not set.
|
||||||
|
//
|
||||||
|
//nolint:paralleltest // mutates the process-global rootFlags
|
||||||
|
func TestDatabaseDeleteReportsWhenNotQuiet(t *testing.T) {
|
||||||
|
dir := t.TempDir()
|
||||||
|
_, cfgPath := seedDatabaseDeleteConfig(t, dir)
|
||||||
|
|
||||||
|
setRootFlags(t, RootFlags{ConfigPath: cfgPath})
|
||||||
|
|
||||||
|
out := mustExecute(t, newDatabaseDeleteCommand(), "--force")
|
||||||
|
|
||||||
|
if !strings.Contains(out, "deleted") {
|
||||||
|
t.Errorf("database delete did not report the deletion: %q", out)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -9,6 +9,7 @@ import (
|
|||||||
|
|
||||||
"github.com/adrg/xdg"
|
"github.com/adrg/xdg"
|
||||||
"github.com/spf13/cobra"
|
"github.com/spf13/cobra"
|
||||||
|
"sneak.berlin/go/vaultik/internal/ui"
|
||||||
)
|
)
|
||||||
|
|
||||||
// errConfigNotFound is wrapped by all config-resolution failures.
|
// errConfigNotFound is wrapped by all config-resolution failures.
|
||||||
@@ -81,6 +82,20 @@ func GetRootFlags() RootFlags {
|
|||||||
return rootFlags
|
return rootFlags
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// commandUI returns a UI writer for a command's stdout, in quiet mode
|
||||||
|
// when the global --quiet flag is set. This is how the pure-cli
|
||||||
|
// commands (version, config, database) reach internal/ui: color follows
|
||||||
|
// the writer (a TTY gets color, a captured test buffer does not), and
|
||||||
|
// --quiet silences the same message classes it silences everywhere else.
|
||||||
|
func commandUI(cmd *cobra.Command) *ui.Writer {
|
||||||
|
w := ui.New(cmd.OutOrStdout())
|
||||||
|
if GetRootFlags().Quiet {
|
||||||
|
w.SetQuiet(true)
|
||||||
|
}
|
||||||
|
|
||||||
|
return w
|
||||||
|
}
|
||||||
|
|
||||||
// ResolveConfigPath resolves the config file path from flags, environment, or default.
|
// ResolveConfigPath resolves the config file path from flags, environment, or default.
|
||||||
// Search order: --config flag, VAULTIK_CONFIG env, XDG config dir,
|
// Search order: --config flag, VAULTIK_CONFIG env, XDG config dir,
|
||||||
// /etc/vaultik/config.yml.
|
// /etc/vaultik/config.yml.
|
||||||
|
|||||||
+14
-5
@@ -2,11 +2,11 @@ package cli
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"fmt"
|
"fmt"
|
||||||
"io"
|
|
||||||
"runtime"
|
"runtime"
|
||||||
|
|
||||||
"github.com/spf13/cobra"
|
"github.com/spf13/cobra"
|
||||||
"sneak.berlin/go/vaultik/internal/globals"
|
"sneak.berlin/go/vaultik/internal/globals"
|
||||||
|
"sneak.berlin/go/vaultik/internal/ui"
|
||||||
)
|
)
|
||||||
|
|
||||||
// NewVersionCommand creates the version command
|
// NewVersionCommand creates the version command
|
||||||
@@ -17,16 +17,25 @@ func NewVersionCommand() *cobra.Command {
|
|||||||
Long: `Print version, git commit, and build information for vaultik.`,
|
Long: `Print version, git commit, and build information for vaultik.`,
|
||||||
Args: cobra.NoArgs,
|
Args: cobra.NoArgs,
|
||||||
Run: func(cmd *cobra.Command, _ []string) {
|
Run: func(cmd *cobra.Command, _ []string) {
|
||||||
writeVersion(cmd.OutOrStdout())
|
writeVersion(commandUI(cmd))
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
return cmd
|
return cmd
|
||||||
}
|
}
|
||||||
|
|
||||||
// writeVersion prints the version report. It takes a writer rather than
|
// writeVersion prints the version report through the UI writer. The
|
||||||
// using os.Stdout directly so the output can be asserted on in tests.
|
// report is the output this command exists to produce, so it is written
|
||||||
func writeVersion(w io.Writer) {
|
// plain (markers would corrupt the aligned report) via the writer's
|
||||||
|
// underlying stdout; --quiet silences it like any other non-error
|
||||||
|
// output.
|
||||||
|
func writeVersion(out *ui.Writer) {
|
||||||
|
if out.Quiet() {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
w := out.Out()
|
||||||
|
|
||||||
_, _ = fmt.Fprintf(w, "vaultik %s\n", globals.Version)
|
_, _ = fmt.Fprintf(w, "vaultik %s\n", globals.Version)
|
||||||
_, _ = fmt.Fprintf(w, " commit: %s\n", globals.Commit)
|
_, _ = fmt.Fprintf(w, " commit: %s\n", globals.Commit)
|
||||||
_, _ = fmt.Fprintf(w, " build date: %s\n", globals.CommitDate)
|
_, _ = fmt.Fprintf(w, " build date: %s\n", globals.CommitDate)
|
||||||
|
|||||||
@@ -34,9 +34,9 @@ func runVersionCommand(t *testing.T) string {
|
|||||||
// the report is the version the binary was actually built with. The
|
// the report is the version the binary was actually built with. The
|
||||||
// test binary carries no -ldflags, so that is the "dev" default -- the
|
// test binary carries no -ldflags, so that is the "dev" default -- the
|
||||||
// same string an untagged `make vaultik` build stamps a prefix of.
|
// same string an untagged `make vaultik` build stamps a prefix of.
|
||||||
|
//
|
||||||
|
//nolint:paralleltest // executes a command that reads the global rootFlags
|
||||||
func TestVersionCommandReportsBuildVersion(t *testing.T) {
|
func TestVersionCommandReportsBuildVersion(t *testing.T) {
|
||||||
t.Parallel()
|
|
||||||
|
|
||||||
out := runVersionCommand(t)
|
out := runVersionCommand(t)
|
||||||
|
|
||||||
wantFirst := "vaultik " + globals.Version
|
wantFirst := "vaultik " + globals.Version
|
||||||
@@ -55,9 +55,9 @@ func TestVersionCommandReportsBuildVersion(t *testing.T) {
|
|||||||
// being exactly "dev", so once untagged builds started carrying their
|
// being exactly "dev", so once untagged builds started carrying their
|
||||||
// commit sha it would have gone silent and an unreleased binary would
|
// commit sha it would have gone silent and an unreleased binary would
|
||||||
// have looked like a release.
|
// have looked like a release.
|
||||||
|
//
|
||||||
|
//nolint:paralleltest // executes a command that reads the global rootFlags
|
||||||
func TestVersionCommandFlagsDevelopmentBuild(t *testing.T) {
|
func TestVersionCommandFlagsDevelopmentBuild(t *testing.T) {
|
||||||
t.Parallel()
|
|
||||||
|
|
||||||
if !globals.IsDevVersion(globals.Version) {
|
if !globals.IsDevVersion(globals.Version) {
|
||||||
t.Skipf("test binary was stamped with release version %q",
|
t.Skipf("test binary was stamped with release version %q",
|
||||||
globals.Version)
|
globals.Version)
|
||||||
|
|||||||
@@ -13,6 +13,14 @@ import (
|
|||||||
|
|
||||||
// ShowInfo displays system and configuration information
|
// ShowInfo displays system and configuration information
|
||||||
func (v *Vaultik) ShowInfo() error {
|
func (v *Vaultik) ShowInfo() error {
|
||||||
|
// The info report is the output this command exists to produce, so it
|
||||||
|
// is written plain (markers would corrupt the aligned report) through
|
||||||
|
// the UI writer's stdout; --quiet silences it like any other
|
||||||
|
// non-error output.
|
||||||
|
if v.UI.Quiet() {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
// System Information
|
// System Information
|
||||||
v.stdoutf("=== System Information ===\n")
|
v.stdoutf("=== System Information ===\n")
|
||||||
v.stdoutf("OS/Architecture: %s/%s\n", runtime.GOOS, runtime.GOARCH)
|
v.stdoutf("OS/Architecture: %s/%s\n", runtime.GOOS, runtime.GOARCH)
|
||||||
@@ -213,7 +221,12 @@ func (v *Vaultik) RemoteInfo(jsonOutput bool) error {
|
|||||||
result.StorageType = storageInfo.Type
|
result.StorageType = storageInfo.Type
|
||||||
result.StorageLocation = storageInfo.Location
|
result.StorageLocation = storageInfo.Location
|
||||||
|
|
||||||
if !jsonOutput {
|
// The human report is written only when it is neither the --json
|
||||||
|
// document (which needs stdout to itself) nor silenced by --quiet. The
|
||||||
|
// scan still runs in both cases so --json still gets a full result.
|
||||||
|
showText := !jsonOutput && !v.UI.Quiet()
|
||||||
|
|
||||||
|
if showText {
|
||||||
v.stdoutf("=== Remote Storage ===\n")
|
v.stdoutf("=== Remote Storage ===\n")
|
||||||
v.stdoutf("Type: %s\n", storageInfo.Type)
|
v.stdoutf("Type: %s\n", storageInfo.Type)
|
||||||
v.stdoutf("Location: %s\n", storageInfo.Location)
|
v.stdoutf("Location: %s\n", storageInfo.Location)
|
||||||
@@ -226,7 +239,7 @@ func (v *Vaultik) RemoteInfo(jsonOutput bool) error {
|
|||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|
||||||
if !jsonOutput {
|
if showText {
|
||||||
v.stdoutf("Downloading %d manifest(s)...\n", len(snapshotIDs))
|
v.stdoutf("Downloading %d manifest(s)...\n", len(snapshotIDs))
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -234,7 +247,7 @@ func (v *Vaultik) RemoteInfo(jsonOutput bool) error {
|
|||||||
|
|
||||||
v.populateRemoteInfoResult(result, snapshotMetadata, snapshotIDs, referencedBlobs)
|
v.populateRemoteInfoResult(result, snapshotMetadata, snapshotIDs, referencedBlobs)
|
||||||
|
|
||||||
err = v.scanRemoteBlobStorage(result, referencedBlobs, jsonOutput)
|
err = v.scanRemoteBlobStorage(result, referencedBlobs, showText)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
@@ -252,7 +265,9 @@ func (v *Vaultik) RemoteInfo(jsonOutput bool) error {
|
|||||||
return enc.Encode(result)
|
return enc.Encode(result)
|
||||||
}
|
}
|
||||||
|
|
||||||
v.printRemoteInfoTable(result)
|
if showText {
|
||||||
|
v.printRemoteInfoTable(result)
|
||||||
|
}
|
||||||
|
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
@@ -362,11 +377,13 @@ func (v *Vaultik) populateRemoteInfoResult(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// scanRemoteBlobStorage lists all blobs on remote and computes orphan stats
|
// scanRemoteBlobStorage lists all blobs on remote and computes orphan
|
||||||
|
// stats. showText is true only when the human report is being printed
|
||||||
|
// (not --json, not --quiet), gating the progress line.
|
||||||
func (v *Vaultik) scanRemoteBlobStorage(
|
func (v *Vaultik) scanRemoteBlobStorage(
|
||||||
result *RemoteInfoResult, referencedBlobs map[string]int64, jsonOutput bool,
|
result *RemoteInfoResult, referencedBlobs map[string]int64, showText bool,
|
||||||
) error {
|
) error {
|
||||||
if !jsonOutput {
|
if showText {
|
||||||
v.stdoutf("Scanning blobs...\n")
|
v.stdoutf("Scanning blobs...\n")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,30 @@
|
|||||||
|
package vaultik_test
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bytes"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"github.com/stretchr/testify/require"
|
||||||
|
"sneak.berlin/go/vaultik/internal/ui"
|
||||||
|
"sneak.berlin/go/vaultik/internal/vaultik"
|
||||||
|
)
|
||||||
|
|
||||||
|
// TestShowInfo_QuietSuppressesReport checks that --quiet silences the
|
||||||
|
// whole info report. The report is human-facing status, not a scriptable
|
||||||
|
// value, so under --quiet the command produces nothing (and touches none
|
||||||
|
// of its dependencies, which is why this minimal instance suffices).
|
||||||
|
func TestShowInfo_QuietSuppressesReport(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
var out bytes.Buffer
|
||||||
|
|
||||||
|
v := &vaultik.Vaultik{
|
||||||
|
Stdout: &out,
|
||||||
|
UI: ui.NewWithColor(&out, false),
|
||||||
|
}
|
||||||
|
v.UI.SetQuiet(true)
|
||||||
|
|
||||||
|
require.NoError(t, v.ShowInfo())
|
||||||
|
require.Empty(t, out.String(),
|
||||||
|
"the info report must be suppressed under --quiet")
|
||||||
|
}
|
||||||
@@ -114,9 +114,15 @@ func (v *Vaultik) ListSnapshots(jsonOutput bool) error {
|
|||||||
return encoder.Encode(snapshots)
|
return encoder.Encode(snapshots)
|
||||||
}
|
}
|
||||||
|
|
||||||
err = v.printSnapshotTable(snapshots)
|
// The table is the output this command exists to produce, so it is
|
||||||
if err != nil {
|
// written plain (markers would corrupt the columns) to the UI writer's
|
||||||
return err
|
// stdout; --quiet silences it. Reconciliation notes below go through
|
||||||
|
// the UI methods, so their warnings still emit under --quiet.
|
||||||
|
if !v.UI.Quiet() {
|
||||||
|
err = v.printSnapshotTable(snapshots)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if remoteErr == nil {
|
if remoteErr == nil {
|
||||||
|
|||||||
@@ -0,0 +1,41 @@
|
|||||||
|
package vaultik_test
|
||||||
|
|
||||||
|
import (
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/stretchr/testify/require"
|
||||||
|
"sneak.berlin/go/vaultik/internal/log"
|
||||||
|
)
|
||||||
|
|
||||||
|
// TestListSnapshots_QuietSuppressesTableNotJSON is the --quiet contract
|
||||||
|
// for `snapshot list`: the human table is silenced, but the --json
|
||||||
|
// document a script depends on still emits. A local snapshot with its
|
||||||
|
// remote counterpart present is used so there are no drift notes, whose
|
||||||
|
// warnings would emit even under --quiet.
|
||||||
|
func TestListSnapshots_QuietSuppressesTableNotJSON(t *testing.T) {
|
||||||
|
log.Initialize(log.Config{})
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
env := newListEnv(t)
|
||||||
|
|
||||||
|
ts := time.Date(2026, 3, 1, 10, 0, 0, 0, time.UTC)
|
||||||
|
env.addLocal(t, listLocalID, ts)
|
||||||
|
env.addRemote(t, listLocalID, ts)
|
||||||
|
|
||||||
|
env.v.UI.SetQuiet(true)
|
||||||
|
|
||||||
|
// Table mode: nothing on stdout.
|
||||||
|
require.NoError(t, env.v.ListSnapshots(false))
|
||||||
|
require.Empty(t, env.stdout.String(),
|
||||||
|
"the table must be suppressed under --quiet")
|
||||||
|
|
||||||
|
// JSON mode: the document is still written despite the quiet UI.
|
||||||
|
env.stdout.Reset()
|
||||||
|
require.NoError(t, env.v.ListSnapshots(true))
|
||||||
|
|
||||||
|
rows := decodeListJSON(t, env.stdout.String())
|
||||||
|
require.Len(t, rows, 1)
|
||||||
|
require.Equal(t, listLocalID, rows[0].ID,
|
||||||
|
"the --json document must still emit under --quiet")
|
||||||
|
}
|
||||||
@@ -215,6 +215,7 @@ func NewForTesting(storage storage.Storer) *TestVaultik {
|
|||||||
Stdout: stdout,
|
Stdout: stdout,
|
||||||
Stderr: stderr,
|
Stderr: stderr,
|
||||||
Stdin: stdin,
|
Stdin: stdin,
|
||||||
|
UI: ui.NewWithColor(stdout, false),
|
||||||
},
|
},
|
||||||
Stdout: stdout,
|
Stdout: stdout,
|
||||||
Stderr: stderr,
|
Stderr: stderr,
|
||||||
|
|||||||
Reference in New Issue
Block a user