diff --git a/CHANGELOG.md b/CHANGELOG.md index 38d85bf8..929a8ffd 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -11,9 +11,12 @@ command reports the bare version (e.g. `0.1.0`). ### Added * Docs: The roadmap now lives as GitHub issues. `ROADMAP.md` is a pointer to the issue-driven process, with labels (`priority:critical`, `priority:high`, `priority:low`, `chore`, `upstream`) and filtered search links. +* A global `--quiet`/`-q` flag suppresses stdout output (results and table output that `--log-level` does not gate). It can also be set via the `quiet` config key and `OPENCODE_SANDBOX_QUIET` env var. ### Changed +* **Breaking** CLI: the global `--verbose`/`--error` flags are replaced by a single monotonic `--log-level` flag (`error` | `warning` | `info` | `verbose`, default `info`, short `-l`). The `verbose`/`error` launcher-config keys and `OPENCODE_SANDBOX_VERBOSE`/`OPENCODE_SANDBOX_ERROR` env vars are replaced by `log-level` and `OPENCODE_SANDBOX_LOG_LEVEL`. The level selects the minimum severity shown on the console; `error < warning < info < verbose`, so a higher level is never hidden while a lower one is shown. +* **Breaking** CLI: `sandbox list --quiet`/`-q` (names-only mode) is renamed to the long-only `--names` flag. The `-q` shorthand now selects the global `--quiet` stdout-suppression flag. * Docs: Pages are now published only after a successful release, rebuilding from the published tag rather than in parallel on tag push. Each page footer and the home page display the release (or branch) they were built from. * Docs: Support for dark mode; follows the OS/browser color-scheme preference by default and expose a sun/moon toggle in the header (next to the GitHub link) that overrides and persists the choice. * Docs: The README's Documentation section now links directly to the hosted GitHub Pages docs. diff --git a/README.md b/README.md index 8839123c..a756761c 100644 --- a/README.md +++ b/README.md @@ -99,13 +99,6 @@ See [Getting Started](/docs/getting-started.md) for prerequisites and a full set Full [Commands Reference](/docs/commands.md). -> **Breaking change:** the global `-q/--quiet` flag was renamed to `--error`. `sandbox list` -> now supports `--label`, `--limit`, `--running`, `--stopped`, `-q/--quiet` (names only), -> and `--format json`. - -opencode is pinned into the runner image at build time and does not auto-update inside sandboxes; rebuild the image -with `opencode-sandbox build` to upgrade (optionally pinning a specific version with `--opencode-version`). - ## Documentation The docs are also published to [GitHub Pages](https://inoio.github.io/opencode-sandbox/). diff --git a/cmd/opencode-sandbox/cli.go b/cmd/opencode-sandbox/cli.go index 8a55eb66..af34e18f 100644 --- a/cmd/opencode-sandbox/cli.go +++ b/cmd/opencode-sandbox/cli.go @@ -37,29 +37,21 @@ func execute(args []string, ui termio.UI) error { } // applyCLISettings sets the terminal output level and assume-yes state on the UI -// based on the effective --verbose/--error/--yes flags of the running command. +// based on the effective --log-level/--yes flags of the running command. // // It must run after cobra parses the real command tree and after launcher // config has been merged, so that flags work regardless of position or how -// short shorthands are grouped (e.g. "-nv"). -func applyCLISettings(cmd *cobra.Command, ui termio.UI, r *launcherconfig.Resolver) { +// short shorthands are grouped (e.g. "-ny"). +func applyCLISettings(cmd *cobra.Command, ui termio.UI, r *launcherconfig.Resolver) error { if cmd == nil || r == nil { - return + return nil } - quiet := r.Error() - verbose := r.Verbose() - yes := r.Yes() - ui.SetLevel(levelFrom(quiet, verbose)) - ui.SetAssumeYes(yes) -} - -func levelFrom(quiet, verbose bool) termio.Level { - switch { - case quiet: - return termio.LevelQuiet - case verbose: - return termio.LevelVerbose - default: - return termio.LevelNormal + level, err := termio.ParseLevel(r.LogLevel()) + if err != nil { + return err } + ui.SetLevel(level) + ui.SetAssumeYes(r.Yes()) + ui.SetQuiet(r.Quiet()) + return nil } diff --git a/cmd/opencode-sandbox/cli_help_test.go b/cmd/opencode-sandbox/cli_help_test.go index d240e501..2a972b92 100644 --- a/cmd/opencode-sandbox/cli_help_test.go +++ b/cmd/opencode-sandbox/cli_help_test.go @@ -48,7 +48,7 @@ func TestRootHelpDescribesImpliedRun(t *testing.T) { func TestRootHelpListsGlobalFlags(t *testing.T) { out := commandOut(t, "--help") - for _, flag := range []string{"--yes", "--verbose", "--error", "--dry-run"} { + for _, flag := range []string{"--yes", "--quiet", "--log-level", "--dry-run"} { if !strings.Contains(out, flag) { t.Errorf("expected root help to list flag %q:\n%s", flag, out) } diff --git a/cmd/opencode-sandbox/cli_list_subcommand_test.go b/cmd/opencode-sandbox/cli_list_subcommand_test.go index 41eee1e1..53f9f96d 100644 --- a/cmd/opencode-sandbox/cli_list_subcommand_test.go +++ b/cmd/opencode-sandbox/cli_list_subcommand_test.go @@ -366,7 +366,7 @@ func TestListSandboxesQuietNames(t *testing.T) { projectSandbox("opencode-sandbox-vm-alpha", nil), projectSandbox("opencode-sandbox-vm-beta", nil), } - cmd, ui := setupCommandFixtures(t, cmdList, "-q") + cmd, ui := setupCommandFixtures(t, cmdList, "--names") sandboxmsb.WithMsbMock(t, mock) if err := cmd.Execute(); err != nil { t.Fatalf("unexpected error: %v", err) @@ -417,8 +417,8 @@ func TestListSandboxesFormatJSON(t *testing.T) { } } -func TestListSandboxesFormatJSONAndQuietConflict(t *testing.T) { - runListCmdTest(t, []string{cmdList, "-q", "--format", "json"}, +func TestListSandboxesFormatJSONAndNamesConflict(t *testing.T) { + runListCmdTest(t, []string{cmdList, "--names", "--format", "json"}, func(_ *sandboxmsb.MockMsbClient) {}, nil, nil, true, "mutually exclusive") } diff --git a/cmd/opencode-sandbox/cli_settings_test.go b/cmd/opencode-sandbox/cli_settings_test.go index f218e94f..83e1bccb 100644 --- a/cmd/opencode-sandbox/cli_settings_test.go +++ b/cmd/opencode-sandbox/cli_settings_test.go @@ -9,51 +9,58 @@ import ( launcherconfig "github.com/inoio/opencode-sandbox/internal/viperconfig" ) -func TestLevelFrom(t *testing.T) { - tests := []struct { - name string - quiet bool - verbose bool - want termio.Level - }{ - {"quiet wins over verbose", true, true, termio.LevelQuiet}, - {"quiet", true, false, termio.LevelQuiet}, - {"verbose", false, true, termio.LevelVerbose}, - {"normal default", false, false, termio.LevelNormal}, - } - for _, tc := range tests { - t.Run(tc.name, func(t *testing.T) { - if got := levelFrom(tc.quiet, tc.verbose); got != tc.want { - t.Errorf("levelFrom(%v, %v) = %v, want %v", tc.quiet, tc.verbose, got, tc.want) - } - }) - } -} - func TestApplyCLISettingsAppliesResolverValues(t *testing.T) { ui := termio.NewTestMock(t) r := launcherconfig.NewResolverWithConfig(launcherconfig.Config{ - Error: true, - Verbose: true, - Yes: true, + LogLevel: "error", + Yes: true, + Quiet: true, }) - applyCLISettings(&cobra.Command{}, &ui, r) + if err := applyCLISettings(&cobra.Command{}, &ui, r); err != nil { + t.Fatalf("applyCLISettings: %v", err) + } - if ui.Level() != termio.LevelQuiet { - t.Errorf("Level() = %v, want LevelQuiet", ui.Level()) + if ui.Level() != termio.LevelError { + t.Errorf("Level() = %v, want LevelError", ui.Level()) } if !ui.AssumeYes() { t.Error("AssumeYes() = false, want true") } + if !ui.Quiet() { + t.Error("Quiet() = false, want true") + } +} + +func TestApplyCLISettingsSetsWarningLevel(t *testing.T) { + ui := termio.NewTestMock(t) + r := launcherconfig.NewResolverWithConfig(launcherconfig.Config{LogLevel: "warning"}) + + if err := applyCLISettings(&cobra.Command{}, &ui, r); err != nil { + t.Fatalf("applyCLISettings: %v", err) + } + if ui.Level() != termio.LevelWarning { + t.Errorf("Level() = %v, want LevelWarning", ui.Level()) + } +} + +func TestApplyCLISettingsRejectsInvalidLevel(t *testing.T) { + ui := termio.NewTestMock(t) + r := launcherconfig.NewResolverWithConfig(launcherconfig.Config{LogLevel: "bogus"}) + + if err := applyCLISettings(&cobra.Command{}, &ui, r); err == nil { + t.Fatal("applyCLISettings with invalid level should error") + } } func TestApplyCLISettingsNilCommandOrResolverNoop(t *testing.T) { ui := termio.NewTestMock(t) - applyCLISettings(nil, &ui, nil) + if err := applyCLISettings(nil, &ui, nil); err != nil { + t.Fatalf("applyCLISettings(nil): unexpected error %v", err) + } - // No panic, and the UI keeps its defaults. - if ui.Level() != termio.LevelNormal { - t.Errorf("Level() = %v, want default LevelNormal", ui.Level()) + // No panic, and the UI level is left untouched. + if ui.Level() != termio.LevelError { + t.Errorf("Level() = %v, want untouched LevelError", ui.Level()) } } diff --git a/cmd/opencode-sandbox/cli_test.go b/cmd/opencode-sandbox/cli_test.go index 8b1359f2..2254dd9d 100644 --- a/cmd/opencode-sandbox/cli_test.go +++ b/cmd/opencode-sandbox/cli_test.go @@ -18,7 +18,7 @@ import ( func TestRootHasGlobalFlags(t *testing.T) { testUI := termio.NewTestMock(t) root := buildRootCmd(&testUI) - flags := []string{"yes", "verbose", "error"} + flags := []string{"yes", "log-level", "quiet"} for _, f := range flags { if root.PersistentFlags().Lookup(f) == nil { t.Errorf("expected persistent flag --%s on root", f) @@ -51,7 +51,7 @@ func TestRunCommandFlagShortcuts(t *testing.T) { shortcuts := map[string]string{ "w": "worktree", "c": "cpus", "m": "memory", "r": "rebuild", "n": "dry-run", "y": "yes", - "v": "verbose", + "l": "log-level", "q": "quiet", } for short, long := range shortcuts { f := runCmd.Flags().ShorthandLookup(short) @@ -77,11 +77,10 @@ func TestImageBuildNounFormExists(t *testing.T) { } } -func TestCLICombinedShortFlagsActivateVerbose(t *testing.T) { +func TestCLILogLevelFlagSetsLevel(t *testing.T) { for _, args := range [][]string{ - {"prune", "--age", "1m", "-nv"}, - {"prune", "--age", "1m", "-n", "-v"}, - {"prune", "--age", "1m", "--dry-run", "--verbose"}, + {"prune", "--age", "1m", "--log-level", "verbose"}, + {"prune", "--age", "1m", "-l", "verbose"}, } { t.Run(strings.Join(args, "_"), func(t *testing.T) { configpaths.WithMockConfigPaths(t) @@ -121,6 +120,49 @@ func TestCLIPersistentYesAffectsUIAfterSubcommand(t *testing.T) { } } +func TestCLIQuietFlagSetsQuiet(t *testing.T) { + for _, args := range [][]string{ + {"prune", "--age", "1m", "--quiet"}, + {"prune", "--age", "1m", "-q"}, + } { + t.Run(strings.Join(args, "_"), func(t *testing.T) { + configpaths.WithMockConfigPaths(t) + ui := &termio.Mock{} + mock := &msb.MockMsbClient{} + msb.WithMsbMock(t, mock) + docker.WithNoopDockerMock(t) + + root := buildRootCmd(ui) + root.SetArgs(args) + + if err := root.Execute(); err != nil { + t.Fatalf("unexpected error: %v", err) + } + if !ui.Quiet() { + t.Errorf("expected Quiet=true, got %v", ui.Quiet()) + } + }) + } +} + +func TestCLIQuietFlagFalseByDefault(t *testing.T) { + configpaths.WithMockConfigPaths(t) + ui := &termio.Mock{} + mock := &msb.MockMsbClient{} + msb.WithMsbMock(t, mock) + docker.WithNoopDockerMock(t) + + root := buildRootCmd(ui) + root.SetArgs([]string{"prune", "--age", "1m"}) + + if err := root.Execute(); err != nil { + t.Fatalf("unexpected error: %v", err) + } + if ui.Quiet() { + t.Error("expected Quiet=false by default") + } +} + func TestNewConfigSetsUserDirs(t *testing.T) { t.Setenv("HOME", "/testhome") t.Setenv("XDG_CONFIG_HOME", "") diff --git a/cmd/opencode-sandbox/cli_tree_test.go b/cmd/opencode-sandbox/cli_tree_test.go index f523cd7e..51cfc61d 100644 --- a/cmd/opencode-sandbox/cli_tree_test.go +++ b/cmd/opencode-sandbox/cli_tree_test.go @@ -40,8 +40,7 @@ func TestTree(t *testing.T) { out := strings.Join(testUI.InfoCalls, "\n") descs := []string{ "Assume yes to all prompts", - "Show debug-level output", - "Only show error output", + "Minimum log level to show (error, warning, info, verbose)", "Run in an isolated opencode worktree named , optionally starting from the local base ref :", "Rebuild the runner image before starting", "Dry run without starting anything", @@ -128,8 +127,6 @@ func TestPrintTreeBoolFlagsHaveNoValuePlaceholders(t *testing.T) { out := strings.Join(testUI.InfoCalls, "\n") notExpected := []string{ "--yes ", - "--verbose ", - "--error ", "--tree ", "--version ", "--rebuild ", @@ -148,8 +145,8 @@ func TestPrintTreeFlagShortcuts(t *testing.T) { out := strings.Join(testUI.InfoCalls, "\n") expected := []string{ "-y, --yes", - "-v, --verbose", - "--error", + "-q, --quiet", + "-l, --log-level ", "-w, --worktree ", "-r, --rebuild", "-n, --dry-run", diff --git a/cmd/opencode-sandbox/commands.go b/cmd/opencode-sandbox/commands.go index 071b8339..fabb63e3 100644 --- a/cmd/opencode-sandbox/commands.go +++ b/cmd/opencode-sandbox/commands.go @@ -135,8 +135,9 @@ func buildMinimalRootFlagsCmd() *cobra.Command { } rootFlagsCmd.PersistentFlags().BoolP(pFlagYes, pFlagYes[:1], false, "Assume yes to all prompts") - rootFlagsCmd.PersistentFlags().BoolP(pFlagVerbose, pFlagVerbose[:1], false, "Show debug-level output") - rootFlagsCmd.PersistentFlags().BoolP(pFlagError, "", false, "Only show error output") + rootFlagsCmd.PersistentFlags().BoolP(pFlagQuiet, pFlagQuiet[:1], false, "Suppress stdout output") + rootFlagsCmd.PersistentFlags(). + StringP(pFlagLogLevel, pFlagLogLevel[:1], "info", "Minimum log level to show (error, warning, info, verbose)") return rootFlagsCmd } @@ -161,8 +162,7 @@ func buildRootCmd(ui termio.UI) *cobra.Command { return err } cmd.SetContext(context.WithValue(cmd.Context(), (*launcherConfigKey)(nil), r)) - applyCLISettings(cmd, ui, r) - return nil + return applyCLISettings(cmd, ui, r) } extendRunCmd(ui, rootCmd) diff --git a/cmd/opencode-sandbox/commands_system.go b/cmd/opencode-sandbox/commands_system.go index 719dfbfd..540189ed 100644 --- a/cmd/opencode-sandbox/commands_system.go +++ b/cmd/opencode-sandbox/commands_system.go @@ -153,7 +153,7 @@ func buildListCmd(ui termio.UI) *cobra.Command { }, RunE: func(cmd *cobra.Command, _ []string) error { if namesOnly && format != "" { - return errors.New("--quiet and --format are mutually exclusive") + return errors.New("--names and --format are mutually exclusive") } if format != "" && format != formatJSON { return fmt.Errorf("unsupported format %q: only %q is supported", format, formatJSON) @@ -194,7 +194,7 @@ func buildListCmd(ui termio.UI) *cobra.Command { return nil }, } - cmd.Flags().BoolVarP(&namesOnly, pFlagQuiet, pFlagQuiet[:1], false, "Print only sandbox names") + cmd.Flags().BoolVar(&namesOnly, pFlagNames, false, "Print only sandbox names") cmd.Flags(). StringArrayVar(&labelsStr, flagLabel, nil, "Only show sandboxes carrying this label KEY=VALUE (repeatable, all must match)") cmd.Flags().Uint32Var(&limit, flagLimit, 0, "Limit the number of sandboxes shown") diff --git a/cmd/opencode-sandbox/constants.go b/cmd/opencode-sandbox/constants.go index 858b1853..22be0ec2 100644 --- a/cmd/opencode-sandbox/constants.go +++ b/cmd/opencode-sandbox/constants.go @@ -3,10 +3,10 @@ package main import "github.com/inoio/opencode-sandbox/internal/sandbox/naming" const ( - pFlagYes = "yes" - pFlagVerbose = "verbose" - pFlagError = "error" - pFlagQuiet = "quiet" + pFlagYes = "yes" + pFlagLogLevel = "log-level" + pFlagQuiet = "quiet" + pFlagNames = "names" cmdRun = "run" cmdShell = "shell" diff --git a/cmd/opencode-sandbox/main.go b/cmd/opencode-sandbox/main.go index 1e7b5d9d..5b4315b4 100644 --- a/cmd/opencode-sandbox/main.go +++ b/cmd/opencode-sandbox/main.go @@ -13,7 +13,7 @@ import ( func main() { args := os.Args[1:] ui := termio.New(os.Stdin, os.Stdout, os.Stderr, - term.IsTerminal(int(os.Stderr.Fd())), termio.LevelNormal, false) + term.IsTerminal(int(os.Stderr.Fd())), termio.LevelInfo, false, false) if err := execute(args, ui); err != nil { var exitErr *sandbox.ExitError diff --git a/docs/commands.md b/docs/commands.md index 8407140a..7bdb410d 100644 --- a/docs/commands.md +++ b/docs/commands.md @@ -11,11 +11,11 @@ This document lists all opencode-sandbox subcommands, aliases, and flags. These flags are available on every command. -| Flag | Short | Default | Purpose | -|--------------|-------|---------|--------------------------------| -| `--yes` | `-y` | `false` | Assume yes to all prompts | -| `--verbose` | `-v` | `false` | Show debug-level output | -| `--error` | | `false` | Only show error output | +| Flag | Short | Default | Purpose | +|---------------|-------|---------|-----------------------------------------------------------------| +| `--yes` | `-y` | `false` | Assume yes to all prompts | +| `--quiet` | `-q` | `false` | Suppress stdout output | +| `--log-level` | `-l` | `info` | Minimum log level to show (`error`, `warning`, `info`, `verbose`) | ## Commands @@ -240,7 +240,7 @@ plain text. | `--limit` | — | `0` | Limit the number of sandboxes listed (`0` = no limit). | | `--running` | — | `false` | Only list running sandboxes. | | `--stopped` | — | `false` | Only list stopped sandboxes. | -| `--quiet` | `-q` | `false` | Print names only (no header, no status, image, or created columns). | +| `--names` | — | `false` | Print names only (no header, no status, image, or created columns). | | `--format` | — | `""` | Output format. `json` prints a top-level array of `{name,status,image,created,updated,labels}` objects. | `--running` wins over `--stopped` when both are set. diff --git a/docs/configuration.md b/docs/configuration.md index c80b03c6..c38f3105 100644 --- a/docs/configuration.md +++ b/docs/configuration.md @@ -59,29 +59,29 @@ Configuration is resolved in this order (later entries override earlier ones): ## Configuration file -| Field | Corresponding CLI flag | Description | -|---------------------------------|------------------------|------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------| -| `yes` | `--yes` / `-y` | Assume yes to all prompts | -| `verbose` | `--verbose` / `-v` | Show debug-level output | -| `error` | `--error` | Only show error output | -| `cpus` | `--cpus` / `-c` | Number of vCPUs for the VM | -| `memory` | `--memory` / `-m` | Memory limit (e.g. `8G`) | -| `disk-size` | `--disk-size` | Project VM root disk size (e.g. `16G`). Empty = microsandbox runtime default (~4 GiB). Applied at VM creation; a change triggers recreation. An invalid value is rejected with an error. | -| `tmp-size` | `--tmp-size` | Size of `/tmp` tmpfs in the sandbox. An invalid value is rejected with an error. | +| Field | Corresponding CLI flag | Description | +|---------------------------------|------------------------|---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------| +| `yes` | `--yes` / `-y` | Assume yes to all prompts | +| `quiet` | `--quiet` / `-q` | Suppress stdout output | +| `log-level` | `--log-level` / `-l` | Minimum log level to show on the console: `error`, `warning`, `info`, `verbose` (default `info`) | +| `cpus` | `--cpus` / `-c` | Number of vCPUs for the VM | +| `memory` | `--memory` / `-m` | Memory limit (e.g. `8G`) | +| `disk-size` | `--disk-size` | Project VM root disk size (e.g. `16G`). Empty = microsandbox runtime default (~4 GiB). Applied at VM creation; a change triggers recreation. An invalid value is rejected with an error. | +| `tmp-size` | `--tmp-size` | Size of `/tmp` tmpfs in the sandbox. An invalid value is rejected with an error. | | `workspace-quota` | `--workspace-quota` | Guest-write quota for the `/workspace` bind mount (e.g. `32G`), bounding writes on top of the host repo. Default `16G`. Applied at VM creation; a change triggers recreation. An invalid value is rejected with an error. | -| `auto-prune-age` | — | Auto-prune threshold, runs before every command (default: 30d, only in config). Applies to VMs, volumes, and images alike. | -| `manual-prune-age` | `--age` | Default prune age threshold for `prune`, `image prune`, `volume prune`, and `sandbox prune` | -| `auto-stop-on-active-sessions` | — | Stop VM immediately on client detach without waiting for active sessions (default: false, only in config; `busy` sessions are never cut off) | -| `auto-stop-timeout` | — | Idle timeout after last client detaches (default: 10s, only in config) | -| `auto-stop-max-session-retries` | — | Retries to tolerate for a session stuck in `retry` before stopping (default: 10, only in config) | -| `network.profile` | `--network` | Network profile: `public`, `private`, `host`, or `none` (see [Networking](#networking)) | -| `network.egress-allow` | — | Egress destinations to allow: `host`, a CIDR, or a `.suffix` (see [Networking](#networking)) | -| `network.egress-deny` | — | Egress carve-outs, emitted before allow rules (see [Networking](#networking)) | +| `auto-prune-age` | — | Auto-prune threshold, runs before every command (default: 30d, only in config). Applies to VMs, volumes, and images alike. | +| `manual-prune-age` | `--age` | Default prune age threshold for `prune`, `image prune`, `volume prune`, and `sandbox prune` | +| `auto-stop-on-active-sessions` | — | Stop VM immediately on client detach without waiting for active sessions (default: false, only in config; `busy` sessions are never cut off) | +| `auto-stop-timeout` | — | Idle timeout after last client detaches (default: 10s, only in config) | +| `auto-stop-max-session-retries` | — | Retries to tolerate for a session stuck in `retry` before stopping (default: 10, only in config) | +| `network.profile` | `--network` | Network profile: `public`, `private`, `host`, or `none` (see [Networking](#networking)) | +| `network.egress-allow` | — | Egress destinations to allow: `host`, a CIDR, or a `.suffix` (see [Networking](#networking)) | +| `network.egress-deny` | — | Egress carve-outs, emitted before allow rules (see [Networking](#networking)) | Example `~/.config/opencode-sandbox/config.yaml`: ```yaml -verbose: true +log-level: verbose cpus: 4 memory: 8G disk-size: 16G @@ -152,8 +152,8 @@ precedence over config files but lose to an explicitly passed CLI flag. The pref | Field | Environment variable | |---------------------------------|-------------------------------------------------------------------| | `yes` | `OPENCODE_SANDBOX_YES` | -| `verbose` | `OPENCODE_SANDBOX_VERBOSE` | -| `error` | `OPENCODE_SANDBOX_ERROR` | +| `quiet` | `OPENCODE_SANDBOX_QUIET` | +| `log-level` | `OPENCODE_SANDBOX_LOG_LEVEL` | | `cpus` | `OPENCODE_SANDBOX_CPUS` | | `memory` | `OPENCODE_SANDBOX_MEMORY` | | `disk-size` | `OPENCODE_SANDBOX_DISK_SIZE` | diff --git a/docs/troubleshooting.md b/docs/troubleshooting.md index dc97dd33..b65572e4 100644 --- a/docs/troubleshooting.md +++ b/docs/troubleshooting.md @@ -64,10 +64,10 @@ When a VM won't start, check the general troubleshooting steps first. ### "create sandbox: ..." errors -Try `--verbose` to see the full error: +Try `--log-level verbose` to see the full error: ```console -opencode-sandbox run --verbose +opencode-sandbox run --log-level verbose ``` Common causes: @@ -177,10 +177,10 @@ If your config files aren't being picked up: 3. Check that CLI flags aren't overriding your config (flags always win). -4. Use `--verbose` to see which config files were loaded: +4. Use `--log-level verbose` to see which config files were loaded: ```console - opencode-sandbox run --verbose + opencode-sandbox run --log-level verbose ``` ## Image build fails diff --git a/internal/opencode/opencode.go b/internal/opencode/opencode.go index 82de8651..2a2626ad 100644 --- a/internal/opencode/opencode.go +++ b/internal/opencode/opencode.go @@ -24,9 +24,12 @@ type githubRelease struct { TagName string `json:"tag_name"` } +//nolint:gochecknoglobals // test seam +var LatestVersion = latestVersion + // LatestVersion returns the newest stable opencode release string (leading "v" // stripped) by querying the GitHub releases/latest endpoint. -func LatestVersion(ctx context.Context) (string, error) { +func latestVersion(ctx context.Context) (string, error) { req, err := http.NewRequestWithContext(ctx, http.MethodGet, gitHubLatestURL, nil) if err != nil { return "", fmt.Errorf("build latest-version request: %w", err) diff --git a/internal/sandbox/image/image_test.go b/internal/sandbox/image/image_test.go index ffbb8fea..0b993924 100644 --- a/internal/sandbox/image/image_test.go +++ b/internal/sandbox/image/image_test.go @@ -17,6 +17,8 @@ import ( "github.com/moby/moby/client" ocispec "github.com/opencontainers/image-spec/specs-go/v1" + "github.com/inoio/opencode-sandbox/internal/opencode" + "github.com/inoio/opencode-sandbox/internal/configpaths" "github.com/inoio/opencode-sandbox/internal/sandbox/docker" "github.com/inoio/opencode-sandbox/internal/sandbox/msb" @@ -301,6 +303,10 @@ func runEnsureImageTagTest(t *testing.T, dockerfile []byte, force bool, wantTags builtTags = append(builtTags, opts.Tags...) return client.ImageBuildResult{Body: io.NopCloser(bytes.NewReader(nil))}, nil } + + opencode.LatestVersion = func(_ context.Context) (string, error) { + return "2.3.4", nil + } docker.WithDockerMock(t, m) _, err := EnsureImageWithClient( diff --git a/internal/sandbox/vm/upgrade.go b/internal/sandbox/vm/upgrade.go index ef7a30b1..e84d5e01 100644 --- a/internal/sandbox/vm/upgrade.go +++ b/internal/sandbox/vm/upgrade.go @@ -25,7 +25,7 @@ const ( // openCodeUpgradeInfo returns the latest opencode release version string. // -//nolint:gochecknoglobals,gocritic // test seam +//nolint:gochecknoglobals // test seam var openCodeUpgradeInfo = func(ctx context.Context) (string, error) { return opencode.LatestVersion(ctx) } diff --git a/internal/termio/coverage_extra_test.go b/internal/termio/coverage_extra_test.go index c4b3f91a..65d20806 100644 --- a/internal/termio/coverage_extra_test.go +++ b/internal/termio/coverage_extra_test.go @@ -11,7 +11,7 @@ import ( func TestPrinterNewTableReturnsEmptyTable(t *testing.T) { var stdout bytes.Buffer - ui := New(nil, &stdout, &bytes.Buffer{}, false, LevelNormal, false).(*printer) + ui := New(nil, &stdout, &bytes.Buffer{}, false, LevelInfo, false, false).(*printer) tbl := ui.NewTable("A", "B") if tbl == nil { t.Fatal("NewTable returned nil") @@ -26,7 +26,7 @@ func TestPrinterNewTableReturnsEmptyTable(t *testing.T) { } func TestPrinterNewTableWithNoHeaders(t *testing.T) { - ui := New(nil, &bytes.Buffer{}, &bytes.Buffer{}, false, LevelNormal, false).(*printer) + ui := New(nil, &bytes.Buffer{}, &bytes.Buffer{}, false, LevelInfo, false, false).(*printer) tbl := ui.NewTable() if tbl == nil { t.Fatal("NewTable returned nil") @@ -35,7 +35,7 @@ func TestPrinterNewTableWithNoHeaders(t *testing.T) { func TestPrinterSpinnerf(t *testing.T) { var stderr bytes.Buffer - ui := New(nil, &bytes.Buffer{}, &stderr, false, LevelNormal, false) + ui := New(nil, &bytes.Buffer{}, &stderr, false, LevelInfo, false, false) spin := ui.Spinnerf("Building %s", "image") spin.Stop() out := stderr.String() @@ -46,7 +46,7 @@ func TestPrinterSpinnerf(t *testing.T) { func TestPrinterStdOutStdErr(t *testing.T) { var stdout, stderr bytes.Buffer - ui := New(nil, &stdout, &stderr, false, LevelNormal, false).(*printer) + ui := New(nil, &stdout, &stderr, false, LevelInfo, false, false).(*printer) if got := ui.StdOut(); got != &stdout { t.Errorf("StdOut() = %v, want %v", got, &stdout) } @@ -57,7 +57,7 @@ func TestPrinterStdOutStdErr(t *testing.T) { func TestInfofHiddenAtQuietLevel(t *testing.T) { var stderr bytes.Buffer - ui := New(nil, &bytes.Buffer{}, &stderr, false, LevelQuiet, false) + ui := New(nil, &bytes.Buffer{}, &stderr, false, LevelError, false, false) ui.Infof("using default %q", "y") if stderr.String() != "" { t.Errorf("expected no infof output at quiet level, got %q", stderr.String()) @@ -66,7 +66,7 @@ func TestInfofHiddenAtQuietLevel(t *testing.T) { func TestVerbosefHiddenAtNormalLevel(t *testing.T) { var stderr bytes.Buffer - ui := New(nil, &bytes.Buffer{}, &stderr, false, LevelNormal, false) + ui := New(nil, &bytes.Buffer{}, &stderr, false, LevelInfo, false, false) ui.Verbosef("workspace: %s", "/p") if stderr.String() != "" { t.Errorf("expected no verbosef output at normal level, got %q", stderr.String()) @@ -75,7 +75,7 @@ func TestVerbosefHiddenAtNormalLevel(t *testing.T) { func TestOutfHiddenAtQuietLevel(t *testing.T) { var stdout bytes.Buffer - ui := New(nil, &stdout, &bytes.Buffer{}, false, LevelQuiet, false) + ui := New(nil, &stdout, &bytes.Buffer{}, false, LevelError, true, false) ui.Outf("%s", "data") if stdout.String() != "" { t.Errorf("expected no outf output at quiet level, got %q", stdout.String()) @@ -106,7 +106,7 @@ func TestOutToVerboseRedirectOutf(t *testing.T) { func TestSpinnerColorAnimateAndDone(t *testing.T) { var stderr bytes.Buffer - ui := New(nil, &bytes.Buffer{}, &stderr, true, LevelNormal, false) + ui := New(nil, &bytes.Buffer{}, &stderr, true, LevelInfo, false, false) spin := ui.Spinner("Working") spin.Stop() out := stderr.String() @@ -120,7 +120,7 @@ func TestSpinnerColorAnimateAndDone(t *testing.T) { func TestSpinnerFinishDefaultResult(t *testing.T) { var stderr bytes.Buffer - s := newSpinner(&stderr, false, LevelNormal, "step") + s := newSpinner(&stderr, false, LevelInfo, "step") s.finish("custom result") out := stderr.String() if !strings.Contains(out, "custom result (") { @@ -130,7 +130,7 @@ func TestSpinnerFinishDefaultResult(t *testing.T) { func TestSpinnerFinishDefaultResultColor(t *testing.T) { var stderr bytes.Buffer - s := newSpinner(&stderr, true, LevelNormal, "step") + s := newSpinner(&stderr, true, LevelInfo, "step") s.finish("custom result") out := stderr.String() if !strings.Contains(out, "custom result (") { @@ -140,7 +140,7 @@ func TestSpinnerFinishDefaultResultColor(t *testing.T) { func TestSpinnerFinishDoneNonColor(t *testing.T) { var stderr bytes.Buffer - s := newSpinner(&stderr, false, LevelNormal, "step") + s := newSpinner(&stderr, false, LevelInfo, "step") s.finish("done") out := stderr.String() if !strings.Contains(out, "✓(") { @@ -150,7 +150,7 @@ func TestSpinnerFinishDoneNonColor(t *testing.T) { func TestSpinnerFinishFailedNonColor(t *testing.T) { var stderr bytes.Buffer - s := newSpinner(&stderr, false, LevelNormal, "step") + s := newSpinner(&stderr, false, LevelInfo, "step") s.finish("failed: nope") out := stderr.String() if !strings.Contains(out, "failed (") || !strings.Contains(out, ": nope") { @@ -174,6 +174,14 @@ func TestMockHelperCoverage(t *testing.T) { if !m.AssumeYes() { t.Error("AssumeYes() = false, want true") } + m.SetQuiet(true) + if !m.Quiet() { + t.Error("Quiet() = false, want true") + } + m.SetQuiet(false) + if m.Quiet() { + t.Error("Quiet() = true after SetQuiet(false), want false") + } m.IsInteractiveResult = true if !m.IsInteractive() { diff --git a/internal/termio/level_test.go b/internal/termio/level_test.go new file mode 100644 index 00000000..696f0472 --- /dev/null +++ b/internal/termio/level_test.go @@ -0,0 +1,64 @@ +package termio + +import ( + "strings" + "testing" +) + +func TestLevelString(t *testing.T) { + tests := []struct { + level Level + want string + }{ + {LevelError, "error"}, + {LevelWarning, "warning"}, + {LevelInfo, "info"}, + {LevelVerbose, "verbose"}, + } + for _, tc := range tests { + if got := tc.level.String(); got != tc.want { + t.Errorf("(%d).String() = %q, want %q", tc.level, got, tc.want) + } + } + if got := Level(99).String(); got != "info" { + t.Errorf("unknown level String() = %q, want %q", got, "info") + } +} + +func TestParseLevel(t *testing.T) { + tests := []struct { + in string + want Level + }{ + {"error", LevelError}, + {"warning", LevelWarning}, + {"info", LevelInfo}, + {"verbose", LevelVerbose}, + {"ERROR", LevelError}, + {"Info", LevelInfo}, + } + for _, tc := range tests { + got, err := ParseLevel(tc.in) + if err != nil { + t.Errorf("ParseLevel(%q) unexpected error: %v", tc.in, err) + continue + } + if got != tc.want { + t.Errorf("ParseLevel(%q) = %v, want %v", tc.in, got, tc.want) + } + } +} + +func TestParseLevelInvalid(t *testing.T) { + if _, err := ParseLevel("bogus"); err == nil { + t.Error("ParseLevel(bogus) should error") + } else if !strings.Contains(err.Error(), "info") { + t.Errorf("expected error to mention valid levels, got %q", err.Error()) + } +} + +func TestLevelsAreMonotonic(t *testing.T) { + if LevelError >= LevelWarning || LevelWarning >= LevelInfo || LevelInfo >= LevelVerbose { + t.Error("levels must be strictly increasing: error < warning < info < verbose") + } +} diff --git a/internal/termio/mock.go b/internal/termio/mock.go index 0288abc3..98b705d5 100644 --- a/internal/termio/mock.go +++ b/internal/termio/mock.go @@ -30,6 +30,7 @@ type Mock struct { level Level assumeYes bool + quiet bool IsInteractiveResult bool SelectFn func(prompt string, choices []Choice, defaultKey string) (string, error) @@ -112,6 +113,10 @@ func (m *Mock) SetAssumeYes(assumeYes bool) { m.assumeYes = assumeYes } +func (m *Mock) SetQuiet(quiet bool) { + m.quiet = quiet +} + func (m *Mock) Level() Level { return m.level } @@ -120,6 +125,10 @@ func (m *Mock) AssumeYes() bool { return m.assumeYes } +func (m *Mock) Quiet() bool { + return m.quiet +} + func (m *Mock) IsInteractive() bool { return m.IsInteractiveResult } diff --git a/internal/termio/printer.go b/internal/termio/printer.go index 15dca20f..550dba7c 100644 --- a/internal/termio/printer.go +++ b/internal/termio/printer.go @@ -27,6 +27,7 @@ type printer struct { level Level color bool assumeYes bool + quiet bool isTerminal func(int) bool } @@ -45,24 +46,30 @@ func (p *printer) format(format string, args ...any) string { } func (p *printer) Info(msg string) { - if p.level == LevelQuiet { + if p.level < LevelInfo { return } p.write(p.stderr, "", msg) } func (p *printer) Infof(format string, args ...any) { - if p.level == LevelQuiet { + if p.level < LevelInfo { return } p.write(p.stderr, "", p.format(format, args...)) } func (p *printer) Warn(msg string) { + if p.level < LevelWarning { + return + } p.write(p.stderr, ansiYellow, msg) } func (p *printer) Warnf(format string, args ...any) { + if p.level < LevelWarning { + return + } p.write(p.stderr, ansiYellow, p.format(format, args...)) } @@ -89,7 +96,7 @@ func (p *printer) Verbosef(format string, args ...any) { } func (p *printer) Out(msg string) { - if p.level == LevelQuiet { + if p.quiet { return } p.write(p.stdout, "", msg) @@ -98,7 +105,7 @@ func (p *printer) Out(msg string) { // Header writes a table header line to stdout. When color is enabled the // whole line is rendered bold and cyan, matching microsandbox table headers. func (p *printer) Header(msg string) { - if p.level == LevelQuiet { + if p.quiet { return } p.write(p.stdout, ansiCyanBold, msg) @@ -111,7 +118,7 @@ func (p *printer) NewTable(headers ...string) *Table { } func (p *printer) Outf(format string, args ...any) { - if p.level == LevelQuiet { + if p.quiet { return } p.write(p.stdout, "", p.format(format, args...)) @@ -125,6 +132,10 @@ func (p *printer) SetAssumeYes(assumeYes bool) { p.assumeYes = assumeYes } +func (p *printer) SetQuiet(quiet bool) { + p.quiet = quiet +} + func (p *printer) Spinner(msg string) Spinner { return newSpinner(p.stderr, p.color, p.level, msg) } diff --git a/internal/termio/printer_test.go b/internal/termio/printer_test.go index e30bdedb..a343bab2 100644 --- a/internal/termio/printer_test.go +++ b/internal/termio/printer_test.go @@ -9,7 +9,7 @@ import ( func TestSuccessWritesWithoutColor(t *testing.T) { var stderr bytes.Buffer - ui := New(nil, &bytes.Buffer{}, &stderr, false, LevelNormal, false) + ui := New(nil, &bytes.Buffer{}, &stderr, false, LevelInfo, false, false) ui.Info("hello") out := stderr.String() if !strings.Contains(out, "hello") { @@ -22,7 +22,7 @@ func TestSuccessWritesWithoutColor(t *testing.T) { func TestSuccessWritesToStderr(t *testing.T) { var stdout, stderr bytes.Buffer - ui := New(nil, &stdout, &stderr, false, LevelNormal, false) + ui := New(nil, &stdout, &stderr, false, LevelInfo, false, false) ui.Info("status") if stderr.String() != "status\n" { t.Errorf("expected stderr status, got stdout=%q stderr=%q", stdout.String(), stderr.String()) @@ -34,7 +34,7 @@ func TestSuccessWritesToStderr(t *testing.T) { func TestOutWritesToStdout(t *testing.T) { var stdout, stderr bytes.Buffer - ui := New(nil, &stdout, &stderr, false, LevelNormal, false) + ui := New(nil, &stdout, &stderr, false, LevelInfo, false, false) ui.Out("data") if stdout.String() != "data\n" { t.Errorf("expected stdout data, got %q", stdout.String()) @@ -46,7 +46,7 @@ func TestOutWritesToStdout(t *testing.T) { func TestWarnWritesWithYellow(t *testing.T) { var stderr bytes.Buffer - ui := New(nil, &bytes.Buffer{}, &stderr, true, LevelNormal, false) + ui := New(nil, &bytes.Buffer{}, &stderr, true, LevelInfo, false, false) ui.Warn("danger") out := stderr.String() if !strings.Contains(out, "danger") { @@ -59,7 +59,7 @@ func TestWarnWritesWithYellow(t *testing.T) { func TestErrorWritesWithRed(t *testing.T) { var stderr bytes.Buffer - ui := New(nil, &bytes.Buffer{}, &stderr, true, LevelNormal, false) + ui := New(nil, &bytes.Buffer{}, &stderr, true, LevelInfo, false, false) ui.Error("boom", errors.New("nope")) out := stderr.String() if !strings.Contains(out, "\x1b[31m") { @@ -70,18 +70,18 @@ func TestErrorWritesWithRed(t *testing.T) { } } -func TestVerboseHiddenAtNormalLevel(t *testing.T) { +func TestVerboseHiddenAtInfoLevel(t *testing.T) { var stderr bytes.Buffer - ui := New(nil, &bytes.Buffer{}, &stderr, false, LevelNormal, false) + ui := New(nil, &bytes.Buffer{}, &stderr, false, LevelInfo, false, false) ui.Verbose("secret") if stderr.String() != "" { t.Errorf("expected no verbose output at normal level, got %q", stderr.String()) } } -func TestVerboseShownAtVerboseLevel(t *testing.T) { +func TestVerboseShownAtDebugLevel(t *testing.T) { var stderr bytes.Buffer - ui := New(nil, &bytes.Buffer{}, &stderr, false, LevelVerbose, false) + ui := New(nil, &bytes.Buffer{}, &stderr, false, LevelVerbose, false, false) ui.Verbose("secret") out := stderr.String() if !strings.Contains(out, "secret") { @@ -89,47 +89,65 @@ func TestVerboseShownAtVerboseLevel(t *testing.T) { } } -func TestSuccessHiddenAtQuietLevel(t *testing.T) { +func TestSuccessHiddenAtErrorLevel(t *testing.T) { var stderr bytes.Buffer - ui := New(nil, &bytes.Buffer{}, &stderr, false, LevelQuiet, false) + ui := New(nil, &bytes.Buffer{}, &stderr, false, LevelError, false, false) ui.Info("hello") if stderr.String() != "" { - t.Errorf("expected no success output at quiet level, got %q", stderr.String()) + t.Errorf("expected no success output at error level, got %q", stderr.String()) } } -func TestOutHiddenAtQuietLevel(t *testing.T) { +func TestOutAlwaysShownAtErrorLevel(t *testing.T) { var stdout bytes.Buffer - ui := New(nil, &stdout, &bytes.Buffer{}, false, LevelQuiet, false) + ui := New(nil, &stdout, &bytes.Buffer{}, false, LevelError, false, false) ui.Out("hello") - if stdout.String() != "" { - t.Errorf("expected no stdout output at quiet level, got %q", stdout.String()) + if stdout.String() != "hello\n" { + t.Errorf("expected stdout output even at error level, got %q", stdout.String()) } } -func TestWarnShownAtQuietLevel(t *testing.T) { +func TestWarnShownAtWarningLevel(t *testing.T) { var stderr bytes.Buffer - ui := New(nil, &bytes.Buffer{}, &stderr, false, LevelQuiet, false) + ui := New(nil, &bytes.Buffer{}, &stderr, false, LevelWarning, false, false) ui.Warn("danger") out := stderr.String() if !strings.Contains(out, "danger") { - t.Errorf("expected warn at quiet level, got %q", out) + t.Errorf("expected warn at warning level, got %q", out) + } +} + +func TestWarnHiddenAtErrorLevel(t *testing.T) { + var stderr bytes.Buffer + ui := New(nil, &bytes.Buffer{}, &stderr, false, LevelError, false, false) + ui.Warn("danger") + if stderr.String() != "" { + t.Errorf("expected no warn output at error level, got %q", stderr.String()) + } +} + +func TestWarnfHiddenAtErrorLevel(t *testing.T) { + var stderr bytes.Buffer + ui := New(nil, &bytes.Buffer{}, &stderr, false, LevelError, false, false) + ui.Warnf("danger %s", "x") + if stderr.String() != "" { + t.Errorf("expected no warnf output at error level, got %q", stderr.String()) } } -func TestErrorShownAtQuietLevel(t *testing.T) { +func TestErrorShownAtErrorLevel(t *testing.T) { var stderr bytes.Buffer - ui := New(nil, &bytes.Buffer{}, &stderr, false, LevelQuiet, false) + ui := New(nil, &bytes.Buffer{}, &stderr, false, LevelError, false, false) ui.Errorf("boom") out := stderr.String() if !strings.Contains(out, "boom") { - t.Errorf("expected error at quiet level, got %q", out) + t.Errorf("expected error at error level, got %q", out) } } func TestErrorRendersSingleColonBeforeErr(t *testing.T) { var stderr bytes.Buffer - ui := New(nil, &bytes.Buffer{}, &stderr, false, LevelNormal, false) + ui := New(nil, &bytes.Buffer{}, &stderr, false, LevelInfo, false, false) ui.Error("Failed", errors.New("the error")) out := stderr.String() if !strings.Contains(out, "Failed: the error") { @@ -142,7 +160,7 @@ func TestErrorRendersSingleColonBeforeErr(t *testing.T) { func TestErrorFormatsArgs(t *testing.T) { var stderr bytes.Buffer - ui := New(nil, &bytes.Buffer{}, &stderr, false, LevelNormal, false) + ui := New(nil, &bytes.Buffer{}, &stderr, false, LevelInfo, false, false) ui.Errorf("msb not found: %v", errors.New("nope")) out := stderr.String() if !strings.Contains(out, "msb not found: nope") { @@ -152,7 +170,7 @@ func TestErrorFormatsArgs(t *testing.T) { func TestWarnFormatsArgs(t *testing.T) { var stderr bytes.Buffer - ui := New(nil, &bytes.Buffer{}, &stderr, false, LevelNormal, false) + ui := New(nil, &bytes.Buffer{}, &stderr, false, LevelInfo, false, false) ui.Warnf("kept repo %s on branch %s", "/p", "feat") out := stderr.String() if !strings.Contains(out, "kept repo /p on branch feat") { @@ -162,7 +180,7 @@ func TestWarnFormatsArgs(t *testing.T) { func TestSuccessFormatsArgs(t *testing.T) { var stderr bytes.Buffer - ui := New(nil, &bytes.Buffer{}, &stderr, false, LevelNormal, false) + ui := New(nil, &bytes.Buffer{}, &stderr, false, LevelInfo, false, false) ui.Infof("using default %q", "y") out := stderr.String() if !strings.Contains(out, `using default "y"`) { @@ -170,9 +188,9 @@ func TestSuccessFormatsArgs(t *testing.T) { } } -func TestVerboseFormatsArgsAtVerbose(t *testing.T) { +func TestVerboseFormatsArgsAtDebug(t *testing.T) { var stderr bytes.Buffer - ui := New(nil, &bytes.Buffer{}, &stderr, false, LevelVerbose, false) + ui := New(nil, &bytes.Buffer{}, &stderr, false, LevelVerbose, false, false) ui.Verbosef("workspace: %s (branch=%s)", "/p", "feat") out := stderr.String() if !strings.Contains(out, "workspace: /p (branch=feat)") { @@ -180,9 +198,9 @@ func TestVerboseFormatsArgsAtVerbose(t *testing.T) { } } -func TestSetLevelRaisesVerbose(t *testing.T) { +func TestSetLevelRaisesToDebug(t *testing.T) { var stderr bytes.Buffer - ui := New(nil, &bytes.Buffer{}, &stderr, false, LevelNormal, false) + ui := New(nil, &bytes.Buffer{}, &stderr, false, LevelInfo, false, false) ui.Verbose("secret") if stderr.String() != "" { t.Fatalf("verbose output at normal level must be suppressed, got %q", stderr.String()) @@ -194,19 +212,19 @@ func TestSetLevelRaisesVerbose(t *testing.T) { } } -func TestSetLevelQuiets(t *testing.T) { +func TestSetLevelLowersSeverity(t *testing.T) { var stderr bytes.Buffer - ui := New(nil, &bytes.Buffer{}, &stderr, false, LevelNormal, false) - ui.SetLevel(LevelQuiet) + ui := New(nil, &bytes.Buffer{}, &stderr, false, LevelInfo, false, false) + ui.SetLevel(LevelError) ui.Info("hidden") if stderr.String() != "" { - t.Errorf("expected no info output at quiet level, got %q", stderr.String()) + t.Errorf("expected no info output at error level, got %q", stderr.String()) } } func TestOutfFormatsArgs(t *testing.T) { var stdout bytes.Buffer - ui := New(nil, &stdout, &bytes.Buffer{}, false, LevelNormal, false) + ui := New(nil, &stdout, &bytes.Buffer{}, false, LevelInfo, false, false) ui.Outf("%-40s %s", "name", "status") out := stdout.String() if !strings.Contains(out, "name"+strings.Repeat(" ", 37)+"status") { @@ -216,7 +234,7 @@ func TestOutfFormatsArgs(t *testing.T) { func TestHeaderWritesToStdout(t *testing.T) { var stdout, stderr bytes.Buffer - ui := New(nil, &stdout, &stderr, false, LevelNormal, false) + ui := New(nil, &stdout, &stderr, false, LevelInfo, false, false) ui.Header("NAME STATUS") if stdout.String() != "NAME STATUS\n" { t.Errorf("expected plain header on stdout, got %q", stdout.String()) @@ -228,7 +246,7 @@ func TestHeaderWritesToStdout(t *testing.T) { func TestHeaderStyledBoldCyanWhenColor(t *testing.T) { var stdout bytes.Buffer - ui := New(nil, &stdout, &bytes.Buffer{}, true, LevelNormal, false) + ui := New(nil, &stdout, &bytes.Buffer{}, true, LevelInfo, false, false) ui.Header("NAME STATUS") out := stdout.String() if !strings.Contains(out, "\x1b[1;36m") { @@ -242,18 +260,76 @@ func TestHeaderStyledBoldCyanWhenColor(t *testing.T) { } } -func TestHeaderHiddenAtQuietLevel(t *testing.T) { +func TestHeaderAlwaysShownAtErrorLevel(t *testing.T) { var stdout bytes.Buffer - ui := New(nil, &stdout, &bytes.Buffer{}, false, LevelQuiet, false) + ui := New(nil, &stdout, &bytes.Buffer{}, false, LevelError, false, false) ui.Header("NAME") + if stdout.String() != "NAME\n" { + t.Errorf("expected header even at error level, got %q", stdout.String()) + } +} + +func TestQuietSuppressesOut(t *testing.T) { + var stdout, stderr bytes.Buffer + ui := New(nil, &stdout, &stderr, false, LevelInfo, false, false) + ui.SetQuiet(true) + ui.Out("data") if stdout.String() != "" { - t.Errorf("expected no header at quiet level, got %q", stdout.String()) + t.Errorf("quiet Out should write nothing to stdout, got %q", stdout.String()) + } + if stderr.String() != "" { + t.Errorf("quiet Out should not touch stderr, got %q", stderr.String()) + } +} + +func TestQuietSuppressesOutf(t *testing.T) { + var stdout bytes.Buffer + ui := New(nil, &stdout, &bytes.Buffer{}, false, LevelInfo, true, false) + ui.Outf("name=%s", "alpha") + if stdout.String() != "" { + t.Errorf("quiet Outf should write nothing to stdout, got %q", stdout.String()) + } +} + +func TestQuietSuppressesHeader(t *testing.T) { + var stdout bytes.Buffer + ui := New(nil, &stdout, &bytes.Buffer{}, false, LevelInfo, true, false) + ui.Header("NAME STATUS") + if stdout.String() != "" { + t.Errorf("quiet Header should write nothing to stdout, got %q", stdout.String()) + } +} + +func TestQuietDoesNotSuppressStderrLogs(t *testing.T) { + var stdout, stderr bytes.Buffer + ui := New(nil, &stdout, &stderr, false, LevelInfo, true, false) + ui.Info("progress") + ui.Warn("danger") + if stderr.String() == "" { + t.Error("quiet must not suppress Info/Warn on stderr") + } + if stdout.String() != "" { + t.Errorf("quiet should still suppress stdout, got %q", stdout.String()) + } +} + +func TestQuietCanBeDisabledAgain(t *testing.T) { + var stdout bytes.Buffer + ui := New(nil, &stdout, &bytes.Buffer{}, false, LevelInfo, true, false) + ui.Out("hidden") + if stdout.String() != "" { + t.Fatalf("expected quiet suppression, got %q", stdout.String()) + } + ui.SetQuiet(false) + ui.Out("visible") + if stdout.String() != "visible\n" { + t.Errorf("expected stdout after SetQuiet(false), got %q", stdout.String()) } } func TestOutStripsANSIIfColorDisabled(t *testing.T) { var stdout bytes.Buffer - ui := New(nil, &stdout, &bytes.Buffer{}, false, LevelNormal, false) + ui := New(nil, &stdout, &bytes.Buffer{}, false, LevelInfo, false, false) ui.Out(StyleStatus("running")) out := stdout.String() if out != "running\n" { @@ -263,7 +339,7 @@ func TestOutStripsANSIIfColorDisabled(t *testing.T) { func TestOutKeepsANSIIfColorEnabled(t *testing.T) { var stdout bytes.Buffer - ui := New(nil, &stdout, &bytes.Buffer{}, true, LevelNormal, false) + ui := New(nil, &stdout, &bytes.Buffer{}, true, LevelInfo, false, false) ui.Out(StyleStatus("running")) out := stdout.String() if !strings.Contains(out, "\x1b[1;32mrunning\x1b[0m") { diff --git a/internal/termio/prompt_test.go b/internal/termio/prompt_test.go index 51cafe75..8ec88e74 100644 --- a/internal/termio/prompt_test.go +++ b/internal/termio/prompt_test.go @@ -9,7 +9,7 @@ import ( func TestIsInteractive(t *testing.T) { t.Run("returns false when stdin is not a terminal", func(t *testing.T) { - ui := New(nil, &bytes.Buffer{}, &bytes.Buffer{}, false, LevelNormal, false) + ui := New(nil, &bytes.Buffer{}, &bytes.Buffer{}, false, LevelInfo, false, false) p := ui.(*printer) p.isTerminal = func(int) bool { return false } if p.IsInteractive() { @@ -18,7 +18,7 @@ func TestIsInteractive(t *testing.T) { }) t.Run("returns false when yes flag is set", func(t *testing.T) { - ui := New(nil, &bytes.Buffer{}, &bytes.Buffer{}, false, LevelNormal, true) + ui := New(nil, &bytes.Buffer{}, &bytes.Buffer{}, false, LevelInfo, false, true) p := ui.(*printer) p.isTerminal = func(int) bool { return true } if p.IsInteractive() { @@ -27,7 +27,7 @@ func TestIsInteractive(t *testing.T) { }) t.Run("returns true when stdin is a terminal and yes flag is not set", func(t *testing.T) { - ui := New(nil, &bytes.Buffer{}, &bytes.Buffer{}, false, LevelNormal, false) + ui := New(nil, &bytes.Buffer{}, &bytes.Buffer{}, false, LevelInfo, false, false) p := ui.(*printer) p.isTerminal = func(int) bool { return true } if !p.IsInteractive() { @@ -36,7 +36,7 @@ func TestIsInteractive(t *testing.T) { }) t.Run("SetAssumeYes makes it non-interactive", func(t *testing.T) { - ui := New(nil, &bytes.Buffer{}, &bytes.Buffer{}, false, LevelNormal, false) + ui := New(nil, &bytes.Buffer{}, &bytes.Buffer{}, false, LevelInfo, false, false) p := ui.(*printer) p.isTerminal = func(int) bool { return true } if !p.IsInteractive() { @@ -57,7 +57,7 @@ func TestSelect(t *testing.T) { t.Run("returns default in non-interactive mode", func(t *testing.T) { var stderr bytes.Buffer - ui := New(strings.NewReader("r\n"), &bytes.Buffer{}, &stderr, false, LevelNormal, false) + ui := New(strings.NewReader("r\n"), &bytes.Buffer{}, &stderr, false, LevelInfo, false, false) p := ui.(*printer) p.isTerminal = func(int) bool { return false } @@ -74,7 +74,7 @@ func TestSelect(t *testing.T) { }) t.Run("returns matched key in interactive mode", func(t *testing.T) { - ui := New(strings.NewReader("r\n"), &bytes.Buffer{}, &bytes.Buffer{}, false, LevelNormal, false) + ui := New(strings.NewReader("r\n"), &bytes.Buffer{}, &bytes.Buffer{}, false, LevelInfo, false, false) p := ui.(*printer) p.isTerminal = func(int) bool { return true } @@ -88,7 +88,7 @@ func TestSelect(t *testing.T) { }) t.Run("matches keys case-insensitively", func(t *testing.T) { - ui := New(strings.NewReader("R\n"), &bytes.Buffer{}, &bytes.Buffer{}, false, LevelNormal, false) + ui := New(strings.NewReader("R\n"), &bytes.Buffer{}, &bytes.Buffer{}, false, LevelInfo, false, false) p := ui.(*printer) p.isTerminal = func(int) bool { return true } @@ -103,7 +103,7 @@ func TestSelect(t *testing.T) { t.Run("marks the default choice inline", func(t *testing.T) { var stderr bytes.Buffer - ui := New(strings.NewReader("\n"), &bytes.Buffer{}, &stderr, false, LevelNormal, false) + ui := New(strings.NewReader("\n"), &bytes.Buffer{}, &stderr, false, LevelInfo, false, false) p := ui.(*printer) p.isTerminal = func(int) bool { return true } @@ -120,7 +120,7 @@ func TestSelect(t *testing.T) { }) t.Run("uses default when user presses enter", func(t *testing.T) { - ui := New(strings.NewReader("\n"), &bytes.Buffer{}, &bytes.Buffer{}, false, LevelNormal, false) + ui := New(strings.NewReader("\n"), &bytes.Buffer{}, &bytes.Buffer{}, false, LevelInfo, false, false) p := ui.(*printer) p.isTerminal = func(int) bool { return true } @@ -134,7 +134,7 @@ func TestSelect(t *testing.T) { }) t.Run("retries on invalid input", func(t *testing.T) { - ui := New(strings.NewReader("x\nfoo\nr\n"), &bytes.Buffer{}, &bytes.Buffer{}, false, LevelNormal, false) + ui := New(strings.NewReader("x\nfoo\nr\n"), &bytes.Buffer{}, &bytes.Buffer{}, false, LevelInfo, false, false) p := ui.(*printer) p.isTerminal = func(int) bool { return true } @@ -148,7 +148,15 @@ func TestSelect(t *testing.T) { }) t.Run("returns error after too many retries", func(t *testing.T) { - ui := New(strings.NewReader("x\nx\nx\nx\nx\nx\n"), &bytes.Buffer{}, &bytes.Buffer{}, false, LevelNormal, false) + ui := New( + strings.NewReader("x\nx\nx\nx\nx\nx\n"), + &bytes.Buffer{}, + &bytes.Buffer{}, + false, + LevelInfo, + false, + false, + ) p := ui.(*printer) p.isTerminal = func(int) bool { return true } @@ -159,7 +167,7 @@ func TestSelect(t *testing.T) { }) t.Run("returns error when reading fails", func(t *testing.T) { - ui := New(&failingReader{}, &bytes.Buffer{}, &bytes.Buffer{}, false, LevelNormal, false) + ui := New(&failingReader{}, &bytes.Buffer{}, &bytes.Buffer{}, false, LevelInfo, false, false) p := ui.(*printer) p.isTerminal = func(int) bool { return true } @@ -172,7 +180,7 @@ func TestSelect(t *testing.T) { func TestInput(t *testing.T) { t.Run("returns default in non-interactive mode", func(t *testing.T) { - ui := New(nil, &bytes.Buffer{}, &bytes.Buffer{}, false, LevelNormal, false) + ui := New(nil, &bytes.Buffer{}, &bytes.Buffer{}, false, LevelInfo, false, false) p := ui.(*printer) p.isTerminal = func(int) bool { return false } @@ -186,7 +194,7 @@ func TestInput(t *testing.T) { }) t.Run("returns user input", func(t *testing.T) { - ui := New(strings.NewReader("feature\n"), &bytes.Buffer{}, &bytes.Buffer{}, false, LevelNormal, false) + ui := New(strings.NewReader("feature\n"), &bytes.Buffer{}, &bytes.Buffer{}, false, LevelInfo, false, false) p := ui.(*printer) p.isTerminal = func(int) bool { return true } @@ -200,7 +208,7 @@ func TestInput(t *testing.T) { }) t.Run("returns default on empty input", func(t *testing.T) { - ui := New(strings.NewReader("\n"), &bytes.Buffer{}, &bytes.Buffer{}, false, LevelNormal, false) + ui := New(strings.NewReader("\n"), &bytes.Buffer{}, &bytes.Buffer{}, false, LevelInfo, false, false) p := ui.(*printer) p.isTerminal = func(int) bool { return true } @@ -214,7 +222,7 @@ func TestInput(t *testing.T) { }) t.Run("returns error when reading fails", func(t *testing.T) { - ui := New(&failingReader{}, &bytes.Buffer{}, &bytes.Buffer{}, false, LevelNormal, false) + ui := New(&failingReader{}, &bytes.Buffer{}, &bytes.Buffer{}, false, LevelInfo, false, false) p := ui.(*printer) p.isTerminal = func(int) bool { return true } diff --git a/internal/termio/spinner.go b/internal/termio/spinner.go index 6282e884..6d47dbd8 100644 --- a/internal/termio/spinner.go +++ b/internal/termio/spinner.go @@ -34,7 +34,7 @@ func newSpinner(w io.Writer, color bool, level Level, msg string) *spinner { func (s *spinner) Start(msg string) { s.mu.Lock() - if s.active || s.level == LevelQuiet { + if s.active || s.level < LevelInfo { s.mu.Unlock() return } @@ -86,7 +86,7 @@ func (s *spinner) finish(result string) { elapsed := time.Since(s.start) s.mu.Unlock() - if s.level == LevelQuiet { + if s.level < LevelInfo { return } diff --git a/internal/termio/spinner_test.go b/internal/termio/spinner_test.go index b7ce1ecd..872e4a9c 100644 --- a/internal/termio/spinner_test.go +++ b/internal/termio/spinner_test.go @@ -45,7 +45,7 @@ func TestFormatElapsedDone(t *testing.T) { func TestSpinnerNonTerminalStop(t *testing.T) { var stderr bytes.Buffer - ui := New(nil, &bytes.Buffer{}, &stderr, false, LevelNormal, false) + ui := New(nil, &bytes.Buffer{}, &stderr, false, LevelInfo, false, false) spin := ui.Spinner("Building image") spin.Stop() @@ -60,7 +60,7 @@ func TestSpinnerNonTerminalStop(t *testing.T) { func TestSpinnerNonTerminalError(t *testing.T) { var stderr bytes.Buffer - ui := New(nil, &bytes.Buffer{}, &stderr, false, LevelNormal, false) + ui := New(nil, &bytes.Buffer{}, &stderr, false, LevelInfo, false, false) spin := ui.Spinner("Building image") spin.StopError(errors.New("build failed")) @@ -75,26 +75,35 @@ func TestSpinnerNonTerminalError(t *testing.T) { func TestSpinnerStopTwiceNoPanic(_ *testing.T) { var stderr bytes.Buffer - ui := New(nil, &bytes.Buffer{}, &stderr, false, LevelNormal, false) + ui := New(nil, &bytes.Buffer{}, &stderr, false, LevelInfo, false, false) spin := ui.Spinner("Building image") spin.Stop() spin.Stop() spin.StopError(errors.New("err")) } -func TestSpinnerHiddenAtQuietLevel(t *testing.T) { +func TestSpinnerHiddenAtErrorLevel(t *testing.T) { var stderr bytes.Buffer - ui := New(nil, &bytes.Buffer{}, &stderr, false, LevelQuiet, false) + ui := New(nil, &bytes.Buffer{}, &stderr, false, LevelError, false, false) spin := ui.Spinner("Building image") spin.Stop() if stderr.String() != "" { - t.Errorf("expected no spinner output at quiet level, got %q", stderr.String()) + t.Errorf("expected no spinner output at error level, got %q", stderr.String()) + } +} + +func TestSpinnerFinishSuppressedAtErrorLevel(t *testing.T) { + var stderr bytes.Buffer + s := &spinner{w: &stderr, level: LevelError, active: true} + s.finish("done") + if stderr.String() != "" { + t.Errorf("expected finish suppressed at error level, got %q", stderr.String()) } } func TestSpinnerColorErrorEmitsRed(t *testing.T) { var stderr bytes.Buffer - ui := New(nil, &bytes.Buffer{}, &stderr, true, LevelNormal, false) + ui := New(nil, &bytes.Buffer{}, &stderr, true, LevelInfo, false, false) spin := ui.Spinner("Failing step") spin.StopError(errors.New("boom")) out := stderr.String() @@ -103,9 +112,9 @@ func TestSpinnerColorErrorEmitsRed(t *testing.T) { } } -func TestSpinnerVerboseSameAsNormal(t *testing.T) { +func TestSpinnerShownAtDebugLevel(t *testing.T) { var stderr bytes.Buffer - ui := New(nil, &bytes.Buffer{}, &stderr, false, LevelVerbose, false) + ui := New(nil, &bytes.Buffer{}, &stderr, false, LevelVerbose, false, false) spin := ui.Spinner("Building image") spin.Stop() diff --git a/internal/termio/termio.go b/internal/termio/termio.go index 93b6f6dd..612799d0 100644 --- a/internal/termio/termio.go +++ b/internal/termio/termio.go @@ -1,19 +1,64 @@ package termio import ( + "fmt" "io" + "strings" "golang.org/x/term" ) +// Level selects the minimum severity shown on the console. Levels are +// monotonic: showing a level also shows every more severe level, so a higher +// level is never hidden while a lower one is shown. type Level int const ( - LevelNormal Level = iota - LevelQuiet + LevelError Level = iota + LevelWarning + LevelInfo LevelVerbose ) +const ( + verboseString = "verbose" + infoString = "info" + warningString = "warning" + errorString = "error" +) + +// String returns the canonical lower-case name of the level. +func (l Level) String() string { + switch l { + case LevelError: + return errorString + case LevelWarning: + return warningString + case LevelVerbose: + return verboseString + case LevelInfo: + return infoString + default: + return infoString + } +} + +// ParseLevel maps a case-insensitive level name to a Level. +func ParseLevel(s string) (Level, error) { + switch strings.ToLower(s) { + case errorString: + return LevelError, nil + case warningString: + return LevelWarning, nil + case infoString: + return LevelInfo, nil + case verboseString: + return LevelVerbose, nil + default: + return LevelInfo, fmt.Errorf("invalid log level %q (want error, warning, info or verbose)", s) + } +} + type Choice struct { Label string Key string @@ -43,6 +88,7 @@ type UI interface { SetLevel(level Level) SetAssumeYes(assumeYes bool) + SetQuiet(quiet bool) StdOut() io.Writer StdErr() io.Writer @@ -53,7 +99,7 @@ type UI interface { } // New creates a production ui backed by the given streams. -func New(stdin io.Reader, stdout, stderr io.Writer, color bool, level Level, assumeYes bool) UI { +func New(stdin io.Reader, stdout, stderr io.Writer, color bool, level Level, quiet bool, assumeYes bool) UI { //nolint:exhaustruct // stdinReader not needed in production return &printer{ stdin: stdin, @@ -61,6 +107,7 @@ func New(stdin io.Reader, stdout, stderr io.Writer, color bool, level Level, ass stderr: stderr, color: color, level: level, + quiet: quiet, assumeYes: assumeYes, isTerminal: term.IsTerminal, } diff --git a/internal/viperconfig/human_duration_test.go b/internal/viperconfig/human_duration_test.go index df4f782c..85ddfb06 100644 --- a/internal/viperconfig/human_duration_test.go +++ b/internal/viperconfig/human_duration_test.go @@ -110,6 +110,15 @@ func TestFlagTypedDefault(t *testing.T) { t.Errorf("flagTypedDefault(yes) = %#v, want true", got) } + quietFlag := &pflag.Flag{Name: "quiet", DefValue: "true"} + if got := flagTypedDefault("quiet", quietFlag); got != true { + t.Errorf("flagTypedDefault(quiet) = %#v, want true", got) + } + quietFlagFalse := &pflag.Flag{Name: "quiet", DefValue: "false"} + if got := flagTypedDefault("quiet", quietFlagFalse); got != false { + t.Errorf("flagTypedDefault(quiet,false) = %#v, want false", got) + } + strFlag := &pflag.Flag{Name: "memory", DefValue: "8G"} if got := flagTypedDefault("memory", strFlag); got != "8G" { t.Errorf("flagTypedDefault(memory) = %#v, want 8G", got) diff --git a/internal/viperconfig/viperconfig.go b/internal/viperconfig/viperconfig.go index 0e2c912b..f0f4e544 100644 --- a/internal/viperconfig/viperconfig.go +++ b/internal/viperconfig/viperconfig.go @@ -32,8 +32,8 @@ type Config struct { DiskSize string `mapstructure:"disk-size"` WorkspaceQuota string `mapstructure:"workspace-quota"` Yes bool `mapstructure:"yes"` - Verbose bool `mapstructure:"verbose"` - Error bool `mapstructure:"error"` + LogLevel string `mapstructure:"log-level"` + Quiet bool `mapstructure:"quiet"` CPUs uint8 `mapstructure:"cpus"` AutoStopOnActiveSessions bool `mapstructure:"auto-stop-on-active-sessions"` @@ -113,6 +113,7 @@ func NewResolverWithConfig(cfg Config) *Resolver { } const ( + strTrue = "true" extJSON5 = ".json5" extJSONC = ".jsonc" ctJSON = "json" @@ -134,7 +135,7 @@ var supportedExts = []string{".yaml", ".yml", ".json", extJSONC, extJSON5} //nolint:gochecknoglobals,goconst // package-level constant slice var configFlagKeys = []string{ "cpus", "memory", "tmp-size", "disk-size", "workspace-quota", - "yes", "verbose", "error", + "yes", "quiet", "log-level", } // configEnvKeys are all launcher config keys bound to OPENCODE_SANDBOX_ env vars. @@ -142,7 +143,7 @@ var configFlagKeys = []string{ //nolint:gochecknoglobals // package-level constant slice var configEnvKeys = []string{ "cpus", "memory", "tmp-size", "disk-size", "workspace-quota", - "yes", "verbose", "error", + "yes", "quiet", "log-level", keyAutoPruneAge, keyManualPruneAge, keyAutoStopOnActiveSessions, keyAutoStopTimeout, keyAutoStopMaxSessionRetries, keyNetworkProfile, @@ -177,8 +178,10 @@ func flagTypedDefault(key string, flag *pflag.Flag) any { case "cpus": n, _ := strconv.ParseUint(flag.DefValue, 10, 8) return uint8(n) - case "yes", "verbose", "error": - return flag.DefValue == "true" + case "yes": + return flag.DefValue == strTrue + case "quiet": + return flag.DefValue == strTrue default: return flag.DefValue } @@ -388,8 +391,8 @@ func (r *Resolver) TmpSize() string { return r.cfg.TmpSize } func (r *Resolver) DiskSize() string { return r.cfg.DiskSize } func (r *Resolver) WorkspaceQuota() string { return r.cfg.WorkspaceQuota } func (r *Resolver) Yes() bool { return r.cfg.Yes } -func (r *Resolver) Verbose() bool { return r.cfg.Verbose } -func (r *Resolver) Error() bool { return r.cfg.Error } +func (r *Resolver) Quiet() bool { return r.cfg.Quiet } +func (r *Resolver) LogLevel() string { return r.cfg.LogLevel } func (r *Resolver) AutoPruneAge() time.Duration { return r.cfg.AutoPruneAge } func (r *Resolver) ManualPruneAge() time.Duration { return r.cfg.ManualPruneAge } func (r *Resolver) AutoStopOnActiveSessions() bool { return r.cfg.AutoStopOnActiveSessions } diff --git a/internal/viperconfig/viperconfig_test.go b/internal/viperconfig/viperconfig_test.go index 2c7e9c02..640c4bcb 100644 --- a/internal/viperconfig/viperconfig_test.go +++ b/internal/viperconfig/viperconfig_test.go @@ -19,7 +19,7 @@ func TestResolverGettersReturnConfig(t *testing.T) { configpaths.WithMockConfigPaths(t) cfg := Config{ CPUs: 4, Memory: "8G", TmpSize: "4G", DiskSize: "32G", WorkspaceQuota: "64G", - Yes: true, Verbose: true, + Yes: true, LogLevel: "verbose", Quiet: true, AutoPruneAge: 7 * 24 * time.Hour, ManualPruneAge: 14 * 24 * time.Hour, AutoStopOnActiveSessions: true, AutoStopTimeout: 30 * time.Second, AutoStopMaxSessionRetries: 5, } @@ -28,7 +28,7 @@ func TestResolverGettersReturnConfig(t *testing.T) { r.WorkspaceQuota() != "64G" { t.Errorf("resource getters mismatch: %+v", cfg) } - if !r.Yes() || !r.Verbose() || r.Error() { + if !r.Yes() || r.LogLevel() != "verbose" || !r.Quiet() { t.Error("UI getters mismatch") } if r.AutoPruneAge() != 7*24*time.Hour || r.ManualPruneAge() != 14*24*time.Hour {