Skip to content

Commit 151671e

Browse files
chuongld20claude
andcommitted
feat: add custom error types, structured logging, and --verbose flag
Introduce internal/errors package with ConfigError, ConnectionError, and DockerError types carrying actionable suggestions. Add slog-based structured logging with --verbose/-v flag for debug output. Update config and ssh packages to use custom error types with user-friendly hints. Closes ISS-34 Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
1 parent 995cbaa commit 151671e

5 files changed

Lines changed: 266 additions & 11 deletions

File tree

cmd/devbox/main.go

Lines changed: 24 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,9 @@ package main
33
import (
44
"bufio"
55
"context"
6+
"errors"
67
"fmt"
8+
"log/slog"
79
"os"
810
"path/filepath"
911
"strings"
@@ -12,13 +14,17 @@ import (
1214

1315
"github.com/junixlabs/devbox/internal/config"
1416
"github.com/junixlabs/devbox/internal/doctor"
17+
devboxerr "github.com/junixlabs/devbox/internal/errors"
1518
devboxssh "github.com/junixlabs/devbox/internal/ssh"
1619
"github.com/junixlabs/devbox/internal/tailscale"
1720
"github.com/junixlabs/devbox/internal/workspace"
1821
"github.com/spf13/cobra"
1922
)
2023

21-
var version = "0.1.0-dev"
24+
var (
25+
version = "0.1.0-dev"
26+
verbose bool
27+
)
2228

2329
func main() {
2430
wm := workspace.NewManager()
@@ -29,7 +35,15 @@ func main() {
2935
Long: "devbox turns any Linux machine into a ready-to-use dev environment in one command.\nNo cloud, no DevOps required.",
3036
Version: version,
3137
SilenceUsage: true,
38+
PersistentPreRun: func(cmd *cobra.Command, args []string) {
39+
level := slog.LevelInfo
40+
if verbose {
41+
level = slog.LevelDebug
42+
}
43+
slog.SetDefault(slog.New(slog.NewTextHandler(os.Stderr, &slog.HandlerOptions{Level: level})))
44+
},
3245
}
46+
rootCmd.PersistentFlags().BoolVarP(&verbose, "verbose", "v", false, "Enable debug logging")
3347

3448
rootCmd.AddCommand(initCmd())
3549
rootCmd.AddCommand(upCmd(wm))
@@ -40,10 +54,19 @@ func main() {
4054
rootCmd.AddCommand(doctorCmd())
4155

4256
if err := rootCmd.Execute(); err != nil {
57+
printError(err)
4358
os.Exit(1)
4459
}
4560
}
4661

62+
// printError formats errors with suggestions when available.
63+
func printError(err error) {
64+
var s devboxerr.Suggestible
65+
if errors.As(err, &s) && s.GetSuggestion() != "" {
66+
fmt.Fprintf(os.Stderr, "Hint: %s\n", s.GetSuggestion())
67+
}
68+
}
69+
4770
// remoteRunner returns a tailscale.CommandRunner that executes commands on a
4871
// remote server via SSH.
4972
func remoteRunner(sshExec devboxssh.Executor, server string) tailscale.CommandRunner {

internal/config/config.go

Lines changed: 21 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@ import (
44
"fmt"
55
"os"
66

7+
devboxerr "github.com/junixlabs/devbox/internal/errors"
78
"gopkg.in/yaml.v3"
89
)
910

@@ -25,20 +26,36 @@ const DefaultConfigFile = "devbox.yaml"
2526
func Load(path string) (*DevboxConfig, error) {
2627
data, err := os.ReadFile(path)
2728
if err != nil {
28-
return nil, fmt.Errorf("failed to read config file %s: %w", path, err)
29+
return nil, devboxerr.NewConfigError(
30+
fmt.Sprintf("failed to read config file %s", path),
31+
"Run 'devbox init' to create a devbox.yaml",
32+
err,
33+
)
2934
}
3035

3136
var cfg DevboxConfig
3237
if err := yaml.Unmarshal(data, &cfg); err != nil {
33-
return nil, fmt.Errorf("failed to parse config file %s: %w", path, err)
38+
return nil, devboxerr.NewConfigError(
39+
fmt.Sprintf("failed to parse config file %s", path),
40+
"Check YAML syntax in devbox.yaml",
41+
err,
42+
)
3443
}
3544

3645
if cfg.Name == "" {
37-
return nil, fmt.Errorf("config file %s: 'name' is required", path)
46+
return nil, devboxerr.NewConfigError(
47+
fmt.Sprintf("config file %s: 'name' is required", path),
48+
"Add 'name: your-project' to devbox.yaml",
49+
nil,
50+
)
3851
}
3952

4053
if cfg.Server == "" {
41-
return nil, fmt.Errorf("config file %s: 'server' is required", path)
54+
return nil, devboxerr.NewConfigError(
55+
fmt.Sprintf("config file %s: 'server' is required", path),
56+
"Add 'server: your-server' to devbox.yaml",
57+
nil,
58+
)
4259
}
4360

4461
return &cfg, nil

internal/errors/errors.go

Lines changed: 76 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,76 @@
1+
package errors
2+
3+
import "fmt"
4+
5+
// ConfigError represents a configuration-related error with an actionable suggestion.
6+
type ConfigError struct {
7+
Message string
8+
Suggestion string
9+
Err error
10+
}
11+
12+
func (e *ConfigError) Error() string {
13+
if e.Err != nil {
14+
return fmt.Sprintf("%s: %v", e.Message, e.Err)
15+
}
16+
return e.Message
17+
}
18+
19+
func (e *ConfigError) Unwrap() error { return e.Err }
20+
21+
// NewConfigError creates a ConfigError with a user-facing suggestion.
22+
func NewConfigError(message, suggestion string, err error) *ConfigError {
23+
return &ConfigError{Message: message, Suggestion: suggestion, Err: err}
24+
}
25+
26+
// ConnectionError represents an SSH or network connectivity error.
27+
type ConnectionError struct {
28+
Message string
29+
Suggestion string
30+
Err error
31+
}
32+
33+
func (e *ConnectionError) Error() string {
34+
if e.Err != nil {
35+
return fmt.Sprintf("%s: %v", e.Message, e.Err)
36+
}
37+
return e.Message
38+
}
39+
40+
func (e *ConnectionError) Unwrap() error { return e.Err }
41+
42+
// NewConnectionError creates a ConnectionError with a user-facing suggestion.
43+
func NewConnectionError(message, suggestion string, err error) *ConnectionError {
44+
return &ConnectionError{Message: message, Suggestion: suggestion, Err: err}
45+
}
46+
47+
// DockerError represents a Docker-related error.
48+
type DockerError struct {
49+
Message string
50+
Suggestion string
51+
Err error
52+
}
53+
54+
func (e *DockerError) Error() string {
55+
if e.Err != nil {
56+
return fmt.Sprintf("%s: %v", e.Message, e.Err)
57+
}
58+
return e.Message
59+
}
60+
61+
func (e *DockerError) Unwrap() error { return e.Err }
62+
63+
// NewDockerError creates a DockerError with a user-facing suggestion.
64+
func NewDockerError(message, suggestion string, err error) *DockerError {
65+
return &DockerError{Message: message, Suggestion: suggestion, Err: err}
66+
}
67+
68+
// Suggestible is implemented by errors that carry an actionable suggestion.
69+
type Suggestible interface {
70+
error
71+
GetSuggestion() string
72+
}
73+
74+
func (e *ConfigError) GetSuggestion() string { return e.Suggestion }
75+
func (e *ConnectionError) GetSuggestion() string { return e.Suggestion }
76+
func (e *DockerError) GetSuggestion() string { return e.Suggestion }

internal/errors/errors_test.go

Lines changed: 108 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,108 @@
1+
package errors_test
2+
3+
import (
4+
"errors"
5+
"fmt"
6+
"testing"
7+
8+
devboxerr "github.com/junixlabs/devbox/internal/errors"
9+
)
10+
11+
func TestConfigError(t *testing.T) {
12+
inner := fmt.Errorf("file not found")
13+
err := devboxerr.NewConfigError("invalid config", "Run devbox init", inner)
14+
15+
if err.Error() != "invalid config: file not found" {
16+
t.Errorf("unexpected message: %s", err.Error())
17+
}
18+
19+
if !errors.Is(err, inner) {
20+
t.Error("errors.Is should match inner error")
21+
}
22+
23+
var ce *devboxerr.ConfigError
24+
if !errors.As(err, &ce) {
25+
t.Error("errors.As should match ConfigError")
26+
}
27+
28+
if ce.GetSuggestion() != "Run devbox init" {
29+
t.Errorf("unexpected suggestion: %s", ce.GetSuggestion())
30+
}
31+
}
32+
33+
func TestConfigErrorNoInner(t *testing.T) {
34+
err := devboxerr.NewConfigError("missing name", "Add 'name' to devbox.yaml", nil)
35+
36+
if err.Error() != "missing name" {
37+
t.Errorf("unexpected message: %s", err.Error())
38+
}
39+
40+
if err.Unwrap() != nil {
41+
t.Error("Unwrap should return nil")
42+
}
43+
}
44+
45+
func TestConnectionError(t *testing.T) {
46+
inner := fmt.Errorf("connection refused")
47+
err := devboxerr.NewConnectionError("ssh failed", "Check SSH connectivity", inner)
48+
49+
if err.Error() != "ssh failed: connection refused" {
50+
t.Errorf("unexpected message: %s", err.Error())
51+
}
52+
53+
var ce *devboxerr.ConnectionError
54+
if !errors.As(err, &ce) {
55+
t.Error("errors.As should match ConnectionError")
56+
}
57+
58+
if ce.GetSuggestion() != "Check SSH connectivity" {
59+
t.Errorf("unexpected suggestion: %s", ce.GetSuggestion())
60+
}
61+
}
62+
63+
func TestDockerError(t *testing.T) {
64+
err := devboxerr.NewDockerError("container failed", "Run: docker ps", nil)
65+
66+
if err.Error() != "container failed" {
67+
t.Errorf("unexpected message: %s", err.Error())
68+
}
69+
70+
var de *devboxerr.DockerError
71+
if !errors.As(err, &de) {
72+
t.Error("errors.As should match DockerError")
73+
}
74+
75+
if de.GetSuggestion() != "Run: docker ps" {
76+
t.Errorf("unexpected suggestion: %s", de.GetSuggestion())
77+
}
78+
}
79+
80+
func TestSuggestibleInterface(t *testing.T) {
81+
cases := []struct {
82+
name string
83+
err devboxerr.Suggestible
84+
want string
85+
}{
86+
{"ConfigError", devboxerr.NewConfigError("x", "fix config", nil), "fix config"},
87+
{"ConnectionError", devboxerr.NewConnectionError("x", "fix conn", nil), "fix conn"},
88+
{"DockerError", devboxerr.NewDockerError("x", "fix docker", nil), "fix docker"},
89+
}
90+
91+
for _, tc := range cases {
92+
t.Run(tc.name, func(t *testing.T) {
93+
if tc.err.GetSuggestion() != tc.want {
94+
t.Errorf("got %q, want %q", tc.err.GetSuggestion(), tc.want)
95+
}
96+
})
97+
}
98+
}
99+
100+
func TestUnwrapChain(t *testing.T) {
101+
root := fmt.Errorf("root cause")
102+
wrapped := fmt.Errorf("middle: %w", root)
103+
err := devboxerr.NewConnectionError("top level", "suggestion", wrapped)
104+
105+
if !errors.Is(err, root) {
106+
t.Error("errors.Is should traverse the full chain to root")
107+
}
108+
}

0 commit comments

Comments
 (0)