Go produces statically compiled binaries that start in milliseconds, ship without runtime dependencies, and cross-compile for any platform from a single machine. These properties make it the language of choice for CLI tooling — it’s why Docker, kubectl, Terraform, Hugo, and hundreds of other tools are written in it.
This guide builds a realistic CLI tool from scratch: vcheck, a vulnerability checker that queries an API for CVEs affecting packages you specify. Along the way it covers every pattern you’ll need for a production-quality CLI: subcommands, persistent flags, config files, environment variables, structured output, graceful shutdown, testing, cross-compilation, and automated releases with GoReleaser.
Project Setup
1
2
3
4
5
6
7
8
9
|
mkdir vcheck && cd vcheck
go mod init github.com/yourname/vcheck
# Core dependencies
go get github.com/spf13/cobra@latest
go get github.com/spf13/viper@latest
go get github.com/fatih/color@latest
go get github.com/olekukonko/tablewriter@latest
go get go.uber.org/zap@latest
|
Directory Structure
vcheck/
├── cmd/
│ ├── root.go # root command, persistent flags, config init
│ ├── scan.go # `vcheck scan` subcommand
│ ├── report.go # `vcheck report` subcommand
│ └── version.go # `vcheck version` subcommand
├── internal/
│ ├── config/
│ │ └── config.go # config struct and loader
│ ├── api/
│ │ └── client.go # HTTP client for CVE API
│ ├── output/
│ │ └── formatter.go # table, JSON, plain text output
│ └── scanner/
│ └── scanner.go # core scanning logic
├── main.go
├── .goreleaser.yaml
└── Makefile
main.go — The Entry Point
Keep this minimal. All logic lives in cmd/:
1
2
3
4
5
6
7
8
|
// main.go
package main
import "github.com/yourname/vcheck/cmd"
func main() {
cmd.Execute()
}
|
The Root Command
The root command wires together Cobra and Viper, handles persistent flags (flags available on every subcommand), and initializes configuration.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
|
// cmd/root.go
package cmd
import (
"fmt"
"os"
"strings"
"github.com/spf13/cobra"
"github.com/spf13/viper"
"github.com/yourname/vcheck/internal/config"
"go.uber.org/zap"
)
var (
cfgFile string
cfg *config.Config
log *zap.SugaredLogger
)
var rootCmd = &cobra.Command{
Use: "vcheck",
Short: "Vulnerability checker for your dependencies",
Long: `vcheck queries vulnerability databases to find CVEs affecting
the packages and versions you specify. Supports JSON, table, and
plain text output for easy CI integration.`,
// PersistentPreRunE runs before every subcommand and is the right
// place to initialize shared state like logging and config.
PersistentPreRunE: func(cmd *cobra.Command, args []string) error {
return initRuntime()
},
}
func Execute() {
if err := rootCmd.Execute(); err != nil {
fmt.Fprintln(os.Stderr, err)
os.Exit(1)
}
}
func init() {
cobra.OnInitialize(initConfig)
// Persistent flags — available on root and all subcommands
rootCmd.PersistentFlags().StringVar(&cfgFile, "config", "",
"config file (default: $HOME/.vcheck.yaml or ./vcheck.yaml)")
rootCmd.PersistentFlags().String("output", "table",
"output format: table, json, plain")
rootCmd.PersistentFlags().Bool("verbose", false,
"enable verbose logging")
rootCmd.PersistentFlags().String("api-key", "",
"API key for vulnerability database (or set VCHECK_API_KEY)")
// Bind persistent flags to Viper so config file and env vars
// can override them
viper.BindPFlag("output", rootCmd.PersistentFlags().Lookup("output"))
viper.BindPFlag("verbose", rootCmd.PersistentFlags().Lookup("verbose"))
viper.BindPFlag("api_key", rootCmd.PersistentFlags().Lookup("api-key"))
}
// initConfig is called by cobra.OnInitialize — before any command runs.
// It sets up Viper to read from config file, env vars, and defaults.
func initConfig() {
if cfgFile != "" {
viper.SetConfigFile(cfgFile)
} else {
// Search order: current dir, then $HOME
viper.AddConfigPath(".")
viper.AddConfigPath("$HOME")
viper.SetConfigName(".vcheck")
viper.SetConfigType("yaml")
}
// Environment variable support: VCHECK_API_KEY → api_key
viper.SetEnvPrefix("VCHECK")
viper.SetEnvKeyReplacer(strings.NewReplacer("-", "_", ".", "_"))
viper.AutomaticEnv()
// Defaults
viper.SetDefault("output", "table")
viper.SetDefault("verbose", false)
viper.SetDefault("api_url", "https://api.osv.dev/v1")
viper.SetDefault("timeout_seconds", 30)
viper.SetDefault("max_concurrent", 5)
// Read config file — ignore "not found" errors (config is optional)
if err := viper.ReadInConfig(); err != nil {
if _, ok := err.(viper.ConfigFileNotFoundError); !ok {
fmt.Fprintf(os.Stderr, "Error reading config file: %v\n", err)
os.Exit(1)
}
}
}
// initRuntime initializes logger and config struct after Viper is ready.
// Called in PersistentPreRunE so it runs after flag parsing.
func initRuntime() error {
var err error
cfg, err = config.Load()
if err != nil {
return fmt.Errorf("loading config: %w", err)
}
// Set up structured logger
var zapCfg zap.Config
if cfg.Verbose {
zapCfg = zap.NewDevelopmentConfig()
} else {
zapCfg = zap.NewProductionConfig()
zapCfg.Level = zap.NewAtomicLevelAt(zap.WarnLevel)
}
logger, err := zapCfg.Build()
if err != nil {
return fmt.Errorf("building logger: %w", err)
}
log = logger.Sugar()
return nil
}
|
Configuration
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
|
// internal/config/config.go
package config
import (
"fmt"
"time"
"github.com/spf13/viper"
)
type Config struct {
Output string `mapstructure:"output"`
Verbose bool `mapstructure:"verbose"`
APIKey string `mapstructure:"api_key"`
APIURL string `mapstructure:"api_url"`
Timeout time.Duration
MaxConcurrent int `mapstructure:"max_concurrent"`
FailOnCritical bool `mapstructure:"fail_on_critical"`
IgnoreCVEs []string `mapstructure:"ignore_cves"`
}
func Load() (*Config, error) {
cfg := &Config{}
if err := viper.Unmarshal(cfg); err != nil {
return nil, fmt.Errorf("unmarshalling config: %w", err)
}
// Viper doesn't directly unmarshal time.Duration from int
cfg.Timeout = time.Duration(viper.GetInt("timeout_seconds")) * time.Second
if err := cfg.validate(); err != nil {
return nil, err
}
return cfg, nil
}
func (c *Config) validate() error {
validOutputs := map[string]bool{"table": true, "json": true, "plain": true}
if !validOutputs[c.Output] {
return fmt.Errorf("invalid output format %q: must be table, json, or plain", c.Output)
}
if c.MaxConcurrent < 1 || c.MaxConcurrent > 20 {
return fmt.Errorf("max_concurrent must be between 1 and 20, got %d", c.MaxConcurrent)
}
return nil
}
|
A sample config file users can place at ~/.vcheck.yaml or ./vcheck.yaml:
1
2
3
4
5
6
7
8
|
# ~/.vcheck.yaml
output: table
api_url: https://api.osv.dev/v1
timeout_seconds: 30
max_concurrent: 5
fail_on_critical: true
ignore_cves:
- CVE-2021-44228 # log4shell - already mitigated
|
Precedence order (highest to lowest):
- Explicit flag (
--output json)
- Environment variable (
VCHECK_OUTPUT=json)
- Config file (
output: json)
- Default (
"table")
The Scan Subcommand
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
|
// cmd/scan.go
package cmd
import (
"context"
"fmt"
"os"
"os/signal"
"syscall"
"time"
"github.com/spf13/cobra"
"github.com/spf13/viper"
"github.com/yourname/vcheck/internal/output"
"github.com/yourname/vcheck/internal/scanner"
)
var scanCmd = &cobra.Command{
Use: "scan [ecosystem] [package@version...]",
Short: "Scan packages for known vulnerabilities",
Long: `Scan one or more packages for known CVEs.
Examples:
vcheck scan go golang.org/x/net@0.0.0-20220722155237-a158d28d115b
vcheck scan npm lodash@4.17.20 express@4.18.0
vcheck scan pypi requests@2.28.0 flask@2.0.0
vcheck scan --file requirements.txt --ecosystem pypi`,
Args: func(cmd *cobra.Command, args []string) error {
fileFlag, _ := cmd.Flags().GetString("file")
if fileFlag == "" && len(args) < 2 {
return fmt.Errorf("requires an ecosystem and at least one package, or --file flag")
}
return nil
},
RunE: func(cmd *cobra.Command, args []string) error {
return runScan(cmd, args)
},
}
func init() {
rootCmd.AddCommand(scanCmd)
// Local flags — only available on `scan`
scanCmd.Flags().StringP("file", "f", "",
"read packages from file (requirements.txt, go.sum, package-lock.json)")
scanCmd.Flags().StringP("ecosystem", "e", "",
"package ecosystem when using --file (go, npm, pypi, cargo, maven)")
scanCmd.Flags().Int("severity", 0,
"minimum CVSS score to report (0-10, default 0 = all)")
scanCmd.Flags().Bool("fail-on-critical", false,
"exit with code 1 if any critical CVEs are found")
// Bind local flags to viper
viper.BindPFlag("severity_threshold", scanCmd.Flags().Lookup("severity"))
viper.BindPFlag("fail_on_critical", scanCmd.Flags().Lookup("fail-on-critical"))
}
func runScan(cmd *cobra.Command, args []string) error {
// Set up context with cancellation for graceful shutdown
ctx, cancel := context.WithCancel(context.Background())
defer cancel()
// Handle Ctrl+C and SIGTERM gracefully
sigCh := make(chan os.Signal, 1)
signal.Notify(sigCh, os.Interrupt, syscall.SIGTERM)
go func() {
<-sigCh
fmt.Fprintln(os.Stderr, "\nInterrupted. Finishing in-flight requests...")
cancel()
}()
fileFlag, _ := cmd.Flags().GetString("file")
ecosystemFlag, _ := cmd.Flags().GetString("ecosystem")
severityFlag, _ := cmd.Flags().GetInt("severity")
// Build the list of packages to scan
var packages []scanner.Package
var err error
if fileFlag != "" {
packages, err = scanner.ParseFile(fileFlag, ecosystemFlag)
if err != nil {
return fmt.Errorf("parsing file %s: %w", fileFlag, err)
}
if len(packages) == 0 {
fmt.Println("No packages found in file.")
return nil
}
} else {
ecosystem := args[0]
packages, err = scanner.ParseArgs(ecosystem, args[1:])
if err != nil {
return err
}
}
log.Infof("Scanning %d packages...", len(packages))
s := scanner.New(cfg, log)
results, err := s.Scan(ctx, packages)
if err != nil {
return fmt.Errorf("scan failed: %w", err)
}
// Filter by severity threshold
if severityFlag > 0 {
results = results.FilterBySeverity(float64(severityFlag))
}
// Render output
formatter := output.NewFormatter(viper.GetString("output"), os.Stdout)
if err := formatter.Render(results); err != nil {
return err
}
// Exit code 1 if critical CVEs found and --fail-on-critical
if cfg.FailOnCritical && results.HasCritical() {
return &ExitError{Code: 1, Msg: "critical vulnerabilities found"}
}
return nil
}
// ExitError lets us control the exit code without printing "exit status 1"
type ExitError struct {
Code int
Msg string
}
func (e *ExitError) Error() string { return e.Msg }
|
Handle the custom exit code in main.go:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
|
// main.go
package main
import (
"fmt"
"os"
"github.com/yourname/vcheck/cmd"
)
func main() {
if err := cmd.Execute(); err != nil {
var exitErr *cmd.ExitError
if errors.As(err, &exitErr) {
os.Exit(exitErr.Code)
}
fmt.Fprintln(os.Stderr, err)
os.Exit(1)
}
}
|
The Scanner Core
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
|
// internal/scanner/scanner.go
package scanner
import (
"context"
"fmt"
"strings"
"sync"
"github.com/yourname/vcheck/internal/api"
"github.com/yourname/vcheck/internal/config"
"go.uber.org/zap"
)
type Package struct {
Ecosystem string
Name string
Version string
}
type Vulnerability struct {
ID string
Summary string
Severity float64
CVSSv3 string
Affected []string
Fixed string
URL string
}
type PackageResult struct {
Package Package
Vulnerabilities []Vulnerability
Error error
}
type Results []PackageResult
func (r Results) HasCritical() bool {
for _, pr := range r {
for _, v := range pr.Vulnerabilities {
if v.Severity >= 9.0 {
return true
}
}
}
return false
}
func (r Results) FilterBySeverity(min float64) Results {
out := make(Results, 0, len(r))
for _, pr := range r {
filtered := make([]Vulnerability, 0)
for _, v := range pr.Vulnerabilities {
if v.Severity >= min {
filtered = append(filtered, v)
}
}
if len(filtered) > 0 || pr.Error != nil {
pr.Vulnerabilities = filtered
out = append(out, pr)
}
}
return out
}
type Scanner struct {
cfg *config.Config
client *api.Client
log *zap.SugaredLogger
}
func New(cfg *config.Config, log *zap.SugaredLogger) *Scanner {
return &Scanner{
cfg: cfg,
client: api.NewClient(cfg),
log: log,
}
}
func (s *Scanner) Scan(ctx context.Context, packages []Package) (Results, error) {
results := make(Results, len(packages))
// Semaphore to limit concurrency
sem := make(chan struct{}, s.cfg.MaxConcurrent)
var wg sync.WaitGroup
for i, pkg := range packages {
wg.Add(1)
go func(idx int, p Package) {
defer wg.Done()
select {
case sem <- struct{}{}:
defer func() { <-sem }()
case <-ctx.Done():
results[idx] = PackageResult{Package: p, Error: ctx.Err()}
return
}
s.log.Debugf("Scanning %s/%s@%s", p.Ecosystem, p.Name, p.Version)
vulns, err := s.client.Query(ctx, p)
results[idx] = PackageResult{
Package: p,
Vulnerabilities: vulns,
Error: err,
}
}(i, pkg)
}
wg.Wait()
return results, nil
}
// ParseArgs parses "ecosystem pkg@version pkg@version..." from CLI args
func ParseArgs(ecosystem string, args []string) ([]Package, error) {
packages := make([]Package, 0, len(args))
for _, arg := range args {
parts := strings.SplitN(arg, "@", 2)
if len(parts) != 2 || parts[1] == "" {
return nil, fmt.Errorf("invalid package format %q: expected name@version", arg)
}
packages = append(packages, Package{
Ecosystem: ecosystem,
Name: parts[0],
Version: parts[1],
})
}
return packages, nil
}
|
The API Client
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
|
// internal/api/client.go
package api
import (
"bytes"
"context"
"encoding/json"
"fmt"
"net/http"
"time"
"github.com/yourname/vcheck/internal/config"
"github.com/yourname/vcheck/internal/scanner"
)
type Client struct {
httpClient *http.Client
baseURL string
apiKey string
}
func NewClient(cfg *config.Config) *Client {
return &Client{
httpClient: &http.Client{
Timeout: cfg.Timeout,
},
baseURL: cfg.APIURL,
apiKey: cfg.APIKey,
}
}
// osvQueryRequest matches the OSV API (https://osv.dev/docs)
type osvQueryRequest struct {
Package osvPackage `json:"package"`
Version string `json:"version"`
}
type osvPackage struct {
Name string `json:"name"`
Ecosystem string `json:"ecosystem"`
}
func (c *Client) Query(ctx context.Context, pkg scanner.Package) ([]scanner.Vulnerability, error) {
body, err := json.Marshal(osvQueryRequest{
Package: osvPackage{Name: pkg.Name, Ecosystem: pkg.Ecosystem},
Version: pkg.Version,
})
if err != nil {
return nil, err
}
req, err := http.NewRequestWithContext(ctx, http.MethodPost,
c.baseURL+"/query", bytes.NewReader(body))
if err != nil {
return nil, err
}
req.Header.Set("Content-Type", "application/json")
if c.apiKey != "" {
req.Header.Set("Authorization", "Bearer "+c.apiKey)
}
resp, err := c.httpClient.Do(req)
if err != nil {
return nil, fmt.Errorf("API request failed: %w", err)
}
defer resp.Body.Close()
if resp.StatusCode == http.StatusTooManyRequests {
// Respect Retry-After header
if ra := resp.Header.Get("Retry-After"); ra != "" {
return nil, fmt.Errorf("rate limited (retry after %s)", ra)
}
return nil, fmt.Errorf("rate limited by API")
}
if resp.StatusCode != http.StatusOK {
return nil, fmt.Errorf("API returned status %d", resp.StatusCode)
}
var osvResp struct {
Vulns []struct {
ID string `json:"id"`
Summary string `json:"summary"`
Severity []struct {
Type string `json:"type"`
Score string `json:"score"`
} `json:"severity"`
References []struct {
URL string `json:"url"`
} `json:"references"`
Affected []struct {
Ranges []struct {
Events []struct {
Fixed string `json:"fixed,omitempty"`
} `json:"events"`
} `json:"ranges"`
} `json:"affected"`
} `json:"vulns"`
}
if err := json.NewDecoder(resp.Body).Decode(&osvResp); err != nil {
return nil, fmt.Errorf("decoding response: %w", err)
}
vulns := make([]scanner.Vulnerability, 0, len(osvResp.Vulns))
for _, v := range osvResp.Vulns {
vuln := scanner.Vulnerability{
ID: v.ID,
Summary: v.Summary,
}
// Extract CVSS score
for _, s := range v.Severity {
if s.Type == "CVSS_V3" {
vuln.CVSSv3 = s.Score
vuln.Severity = parseCVSSScore(s.Score)
}
}
// Get reference URL
if len(v.References) > 0 {
vuln.URL = v.References[0].URL
}
// Get fixed version
for _, affected := range v.Affected {
for _, r := range affected.Ranges {
for _, e := range r.Events {
if e.Fixed != "" {
vuln.Fixed = e.Fixed
}
}
}
}
vulns = append(vulns, vuln)
}
return vulns, nil
}
func parseCVSSScore(vector string) float64 {
// Parse base score from CVSS v3 vector string
// CVSS:3.1/AV:N/AC:L/PR:N/UI:N/S:C/C:H/I:H/A:H — score is computed, not embedded
// In practice you'd use a CVSS library. Returning 0 as a stub.
return 0
}
|
Good CLI output matters. Support multiple formats so the tool works for humans and machines alike.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
|
// internal/output/formatter.go
package output
import (
"encoding/json"
"fmt"
"io"
"os"
"strings"
"github.com/fatih/color"
"github.com/olekukonko/tablewriter"
"github.com/yourname/vcheck/internal/scanner"
)
type Formatter struct {
format string
writer io.Writer
}
func NewFormatter(format string, w io.Writer) *Formatter {
return &Formatter{format: format, writer: w}
}
func (f *Formatter) Render(results scanner.Results) error {
switch f.format {
case "json":
return f.renderJSON(results)
case "plain":
return f.renderPlain(results)
default:
return f.renderTable(results)
}
}
func (f *Formatter) renderTable(results scanner.Results) error {
red := color.New(color.FgRed, color.Bold).SprintFunc()
yellow := color.New(color.FgYellow).SprintFunc()
green := color.New(color.FgGreen).SprintFunc()
cyan := color.New(color.FgCyan).SprintFunc()
totalVulns := 0
for _, pr := range results {
totalVulns += len(pr.Vulnerabilities)
}
if totalVulns == 0 {
fmt.Fprintln(f.writer, green("✓ No vulnerabilities found"))
return nil
}
table := tablewriter.NewWriter(f.writer)
table.SetHeader([]string{"Package", "Version", "CVE ID", "Severity", "Summary", "Fix"})
table.SetBorder(false)
table.SetColumnSeparator(" ")
table.SetHeaderAlignment(tablewriter.ALIGN_LEFT)
table.SetAlignment(tablewriter.ALIGN_LEFT)
table.SetAutoWrapText(true)
table.SetColWidth(40)
for _, pr := range results {
if pr.Error != nil {
table.Append([]string{
pr.Package.Name,
pr.Package.Version,
red("ERROR"),
"",
pr.Error.Error(),
"",
})
continue
}
for _, v := range pr.Vulnerabilities {
severityStr := formatSeverity(v.Severity, red, yellow, cyan)
summary := v.Summary
if len(summary) > 60 {
summary = summary[:57] + "..."
}
fix := v.Fixed
if fix == "" {
fix = yellow("no fix")
}
table.Append([]string{
pr.Package.Name,
pr.Package.Version,
cyan(v.ID),
severityStr,
summary,
fix,
})
}
}
table.Render()
// Summary line
criticals := 0
highs := 0
for _, pr := range results {
for _, v := range pr.Vulnerabilities {
if v.Severity >= 9.0 {
criticals++
} else if v.Severity >= 7.0 {
highs++
}
}
}
fmt.Fprintln(f.writer)
summary := fmt.Sprintf("Found %d vulnerabilities", totalVulns)
if criticals > 0 {
summary += fmt.Sprintf(" (%s critical, %d high)", red(fmt.Sprintf("%d", criticals)), highs)
} else if highs > 0 {
summary += fmt.Sprintf(" (%s high)", yellow(fmt.Sprintf("%d", highs)))
}
fmt.Fprintln(f.writer, summary)
return nil
}
func (f *Formatter) renderJSON(results scanner.Results) error {
enc := json.NewEncoder(f.writer)
enc.SetIndent("", " ")
type jsonOutput struct {
Package string `json:"package"`
Version string `json:"version"`
Ecosystem string `json:"ecosystem"`
Vulnerabilities []scanner.Vulnerability `json:"vulnerabilities"`
Error string `json:"error,omitempty"`
}
out := make([]jsonOutput, 0, len(results))
for _, pr := range results {
row := jsonOutput{
Package: pr.Package.Name,
Version: pr.Package.Version,
Ecosystem: pr.Package.Ecosystem,
Vulnerabilities: pr.Vulnerabilities,
}
if pr.Error != nil {
row.Error = pr.Error.Error()
}
out = append(out, row)
}
return enc.Encode(out)
}
func (f *Formatter) renderPlain(results scanner.Results) error {
for _, pr := range results {
if pr.Error != nil {
fmt.Fprintf(f.writer, "ERROR %s@%s: %v\n", pr.Package.Name, pr.Package.Version, pr.Error)
continue
}
for _, v := range pr.Vulnerabilities {
fmt.Fprintf(f.writer, "%s %s@%s %.1f %s\n",
v.ID, pr.Package.Name, pr.Package.Version, v.Severity, v.Summary)
}
}
return nil
}
func formatSeverity(score float64, red, yellow, cyan func(...interface{}) string) string {
switch {
case score >= 9.0:
return red(fmt.Sprintf("CRITICAL (%.1f)", score))
case score >= 7.0:
return yellow(fmt.Sprintf("HIGH (%.1f)", score))
case score >= 4.0:
return cyan(fmt.Sprintf("MEDIUM (%.1f)", score))
default:
return fmt.Sprintf("LOW (%.1f)", score)
}
}
|
The Version Subcommand
A version command is table stakes for any distributable CLI tool. The trick is injecting build metadata at compile time:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
|
// cmd/version.go
package cmd
import (
"fmt"
"runtime"
"github.com/spf13/cobra"
)
// These are set by -ldflags at build time
var (
Version = "dev"
Commit = "none"
BuildDate = "unknown"
)
var versionCmd = &cobra.Command{
Use: "version",
Short: "Print version information",
Run: func(cmd *cobra.Command, args []string) {
fmt.Printf("vcheck %s\n", Version)
fmt.Printf(" commit: %s\n", Commit)
fmt.Printf(" built: %s\n", BuildDate)
fmt.Printf(" go version: %s\n", runtime.Version())
fmt.Printf(" os/arch: %s/%s\n", runtime.GOOS, runtime.GOARCH)
},
}
func init() {
rootCmd.AddCommand(versionCmd)
}
|
Build with injected version:
1
2
3
4
|
go build -ldflags="-X github.com/yourname/vcheck/cmd.Version=1.2.3 \
-X github.com/yourname/vcheck/cmd.Commit=$(git rev-parse --short HEAD) \
-X 'github.com/yourname/vcheck/cmd.BuildDate=$(date -u +%Y-%m-%dT%H:%M:%SZ)'" \
-o vcheck .
|
Shell Completion
Cobra generates completion scripts for free:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
|
// cmd/root.go — add in init()
rootCmd.AddCommand(&cobra.Command{
Use: "completion [bash|zsh|fish|powershell]",
Short: "Generate shell completion scripts",
Long: `Generate shell completion scripts for vcheck.
# Bash
source <(vcheck completion bash)
# Add to ~/.bashrc:
echo 'source <(vcheck completion bash)' >> ~/.bashrc
# Zsh
source <(vcheck completion zsh)
# Add to ~/.zshrc:
echo 'source <(vcheck completion zsh)' >> ~/.zshrc
# Fish
vcheck completion fish | source
`,
ValidArgs: []string{"bash", "zsh", "fish", "powershell"},
Args: cobra.ExactValidArgs(1),
RunE: func(cmd *cobra.Command, args []string) error {
switch args[0] {
case "bash":
return rootCmd.GenBashCompletion(os.Stdout)
case "zsh":
return rootCmd.GenZshCompletion(os.Stdout)
case "fish":
return rootCmd.GenFishCompletion(os.Stdout, true)
case "powershell":
return rootCmd.GenPowerShellCompletionWithDesc(os.Stdout)
}
return nil
},
})
|
Add completion to the scan command’s ValidArgs so tab-completing ecosystem names works:
1
2
3
4
5
6
|
scanCmd.ValidArgsFunction = func(cmd *cobra.Command, args []string, toComplete string) ([]string, cobra.ShellCompDirective) {
if len(args) == 0 {
return []string{"go", "npm", "pypi", "cargo", "maven", "nuget"}, cobra.ShellCompDirectiveNoFileComp
}
return nil, cobra.ShellCompDirectiveDefault
}
|
Testing
Good CLI tests cover three levels: unit tests for internal logic, integration tests for command execution, and golden file tests for output formatting.
Unit Tests
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
|
// internal/scanner/scanner_test.go
package scanner_test
import (
"testing"
"github.com/yourname/vcheck/internal/scanner"
)
func TestParseArgs(t *testing.T) {
tests := []struct {
name string
ecosystem string
args []string
want []scanner.Package
wantErr bool
}{
{
name: "valid packages",
ecosystem: "go",
args: []string{"golang.org/x/net@0.5.0", "github.com/gin-gonic/gin@1.8.0"},
want: []scanner.Package{
{Ecosystem: "go", Name: "golang.org/x/net", Version: "0.5.0"},
{Ecosystem: "go", Name: "github.com/gin-gonic/gin", Version: "1.8.0"},
},
},
{
name: "missing version",
ecosystem: "npm",
args: []string{"lodash"},
wantErr: true,
},
{
name: "empty version",
ecosystem: "pypi",
args: []string{"requests@"},
wantErr: true,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
got, err := scanner.ParseArgs(tt.ecosystem, tt.args)
if (err != nil) != tt.wantErr {
t.Errorf("ParseArgs() error = %v, wantErr %v", err, tt.wantErr)
return
}
if !tt.wantErr && len(got) != len(tt.want) {
t.Errorf("ParseArgs() got %d packages, want %d", len(got), len(tt.want))
}
})
}
}
func TestResultsHasCritical(t *testing.T) {
results := scanner.Results{
{Vulnerabilities: []scanner.Vulnerability{{Severity: 9.8, ID: "CVE-2021-44228"}}},
{Vulnerabilities: []scanner.Vulnerability{{Severity: 5.0, ID: "CVE-2022-12345"}}},
}
if !results.HasCritical() {
t.Error("HasCritical() = false, want true")
}
safeResults := scanner.Results{
{Vulnerabilities: []scanner.Vulnerability{{Severity: 6.5}}},
}
if safeResults.HasCritical() {
t.Error("HasCritical() = true, want false")
}
}
|
Command Integration Tests with cobra/command
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
|
// cmd/scan_test.go
package cmd_test
import (
"bytes"
"testing"
"github.com/yourname/vcheck/cmd"
)
func TestScanCommandHelp(t *testing.T) {
buf := new(bytes.Buffer)
rootCmd := cmd.NewRootCmd()
rootCmd.SetOut(buf)
rootCmd.SetErr(buf)
rootCmd.SetArgs([]string{"scan", "--help"})
if err := rootCmd.Execute(); err != nil {
t.Fatalf("Execute() unexpected error: %v", err)
}
output := buf.String()
if !bytes.Contains(buf.Bytes(), []byte("Scan one or more packages")) {
t.Errorf("help output missing expected content, got: %s", output)
}
}
func TestScanCommandInvalidArgs(t *testing.T) {
rootCmd := cmd.NewRootCmd()
rootCmd.SetArgs([]string{"scan"}) // missing ecosystem and packages
err := rootCmd.Execute()
if err == nil {
t.Error("Execute() expected error for missing args, got nil")
}
}
// For commands that call external APIs, use an httptest.Server mock:
func TestScanCommandWithMockAPI(t *testing.T) {
// Set up mock HTTP server
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(http.StatusOK)
// Return a mock OSV response
fmt.Fprintln(w, `{"vulns": []}`)
}))
defer server.Close()
buf := new(bytes.Buffer)
rootCmd := cmd.NewRootCmd()
rootCmd.SetOut(buf)
rootCmd.SetArgs([]string{
"--api-url", server.URL,
"scan", "go", "golang.org/x/net@0.5.0",
})
if err := rootCmd.Execute(); err != nil {
t.Fatalf("Execute() error: %v", err)
}
if !bytes.Contains(buf.Bytes(), []byte("No vulnerabilities found")) {
t.Errorf("unexpected output: %s", buf.String())
}
}
|
Golden File Tests for Output
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
|
// internal/output/formatter_test.go
package output_test
import (
"bytes"
"flag"
"os"
"path/filepath"
"testing"
"github.com/yourname/vcheck/internal/output"
"github.com/yourname/vcheck/internal/scanner"
)
var update = flag.Bool("update", false, "update golden files")
func TestFormatterJSON(t *testing.T) {
results := scanner.Results{
{
Package: scanner.Package{Ecosystem: "go", Name: "golang.org/x/net", Version: "0.0.1"},
Vulnerabilities: []scanner.Vulnerability{
{ID: "CVE-2022-00001", Severity: 9.8, Summary: "Remote code execution"},
},
},
}
var buf bytes.Buffer
f := output.NewFormatter("json", &buf)
if err := f.Render(results); err != nil {
t.Fatalf("Render() error: %v", err)
}
golden := filepath.Join("testdata", "json_output.golden")
if *update {
os.WriteFile(golden, buf.Bytes(), 0644)
}
want, err := os.ReadFile(golden)
if err != nil {
t.Fatalf("reading golden file: %v", err)
}
if !bytes.Equal(buf.Bytes(), want) {
t.Errorf("output mismatch\ngot:\n%s\nwant:\n%s", buf.String(), string(want))
}
}
|
Run with go test ./... -update to regenerate golden files after intentional output changes.
The Makefile
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
|
# Makefile
BINARY := vcheck
VERSION ?= $(shell git describe --tags --always --dirty)
COMMIT := $(shell git rev-parse --short HEAD)
BUILD_DATE := $(shell date -u +%Y-%m-%dT%H:%M:%SZ)
LDFLAGS := -ldflags "-X github.com/yourname/vcheck/cmd.Version=$(VERSION) \
-X github.com/yourname/vcheck/cmd.Commit=$(COMMIT) \
-X 'github.com/yourname/vcheck/cmd.BuildDate=$(BUILD_DATE)' \
-s -w"
.PHONY: build test lint clean install release snapshot
build:
go build $(LDFLAGS) -o $(BINARY) .
# Build for all platforms
build-all:
GOOS=linux GOARCH=amd64 go build $(LDFLAGS) -o dist/$(BINARY)-linux-amd64 .
GOOS=linux GOARCH=arm64 go build $(LDFLAGS) -o dist/$(BINARY)-linux-arm64 .
GOOS=darwin GOARCH=amd64 go build $(LDFLAGS) -o dist/$(BINARY)-darwin-amd64 .
GOOS=darwin GOARCH=arm64 go build $(LDFLAGS) -o dist/$(BINARY)-darwin-arm64 .
GOOS=windows GOARCH=amd64 go build $(LDFLAGS) -o dist/$(BINARY)-windows-amd64.exe .
test:
go test ./... -v -race -count=1 -timeout=60s
test-cover:
go test ./... -coverprofile=coverage.out
go tool cover -html=coverage.out -o coverage.html
lint:
golangci-lint run ./...
clean:
rm -f $(BINARY)
rm -rf dist/
install: build
install -m 0755 $(BINARY) /usr/local/bin/$(BINARY)
# GoReleaser snapshot (local dry run without publishing)
snapshot:
goreleaser release --snapshot --clean
# Full release (requires GITHUB_TOKEN)
release:
goreleaser release --clean
|
Cross-Compilation
Go’s cross-compilation is one of its superpowers — no toolchain required for the target platform:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
|
# Build for Linux from macOS (or any platform)
GOOS=linux GOARCH=amd64 go build -o vcheck-linux-amd64 .
# All common targets
for GOOS in linux darwin windows; do
for GOARCH in amd64 arm64; do
ext=""
[ "$GOOS" = "windows" ] && ext=".exe"
GOOS=$GOOS GOARCH=$GOARCH go build \
-ldflags="-s -w" \
-o "dist/vcheck-${GOOS}-${GOARCH}${ext}" .
done
done
# CGO must be disabled for true static binaries (default when cross-compiling)
# If your code uses CGO (e.g., SQLite via mattn/go-sqlite3):
CGO_ENABLED=0 GOOS=linux GOARCH=amd64 go build -o vcheck-linux-amd64 .
# Verify it's statically linked
file vcheck-linux-amd64
# vcheck-linux-amd64: ELF 64-bit LSB executable, x86-64, statically linked
|
The -s -w linker flags strip the symbol table and debug info, reducing binary size by 30-50%:
-s: strip symbol table
-w: strip DWARF debug info
For further size reduction, use upx:
1
2
|
upx --best vcheck-linux-amd64
# Reduces 8MB binary to ~3MB with no runtime penalty
|
GoReleaser — Automated Releases
GoReleaser builds, packages, signs, and publishes releases across platforms from a single config file.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
|
# .goreleaser.yaml
version: 2
project_name: vcheck
before:
hooks:
- go mod tidy
- go generate ./...
builds:
- id: vcheck
main: .
binary: vcheck
env:
- CGO_ENABLED=0
goos:
- linux
- darwin
- windows
goarch:
- amd64
- arm64
ignore:
- goos: windows
goarch: arm64
ldflags:
- -s -w
- -X github.com/yourname/vcheck/cmd.Version={{.Version}}
- -X github.com/yourname/vcheck/cmd.Commit={{.Commit}}
- -X github.com/yourname/vcheck/cmd.BuildDate={{.Date}}
archives:
- id: vcheck
name_template: "{{ .ProjectName }}_{{ .Os }}_{{ .Arch }}"
formats:
- tar.gz
format_overrides:
- goos: windows
format: zip
files:
- README.md
- LICENSE
checksum:
name_template: "checksums.txt"
algorithm: sha256
signs:
- artifacts: checksum
args:
- "--batch"
- "--local-user"
- "{{ .Env.GPG_FINGERPRINT }}"
- "--output"
- "${signature}"
- "--detach-sign"
- "${artifact}"
changelog:
sort: asc
filters:
exclude:
- "^docs:"
- "^test:"
- "^chore:"
- Merge pull request
- Merge branch
release:
github:
owner: yourname
name: vcheck
draft: false
prerelease: auto # tag v1.0.0-rc.1 becomes a prerelease automatically
name_template: "v{{.Version}}"
# Homebrew tap (optional)
brews:
- name: vcheck
repository:
owner: yourname
name: homebrew-tap
homepage: https://github.com/yourname/vcheck
description: "Vulnerability checker for your dependencies"
license: MIT
install: |
bin.install "vcheck"
bash_completion.install "completions/vcheck.bash" => "vcheck"
zsh_completion.install "completions/vcheck.zsh" => "_vcheck"
# Docker image (optional)
dockers:
- image_templates:
- "ghcr.io/yourname/vcheck:{{ .Tag }}"
- "ghcr.io/yourname/vcheck:latest"
dockerfile: Dockerfile
build_flag_templates:
- "--platform=linux/amd64"
- "--label=org.opencontainers.image.version={{.Version}}"
|
GitHub Actions Release Workflow
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
|
# .github/workflows/release.yml
name: Release
on:
push:
tags:
- "v*"
permissions:
contents: write
packages: write
jobs:
release:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
with:
fetch-depth: 0 # GoReleaser needs full history for changelog
- uses: actions/setup-go@v5
with:
go-version-file: go.mod
cache: true
- name: Import GPG key
uses: crazy-max/ghaction-import-gpg@v6
with:
gpg_private_key: ${{ secrets.GPG_PRIVATE_KEY }}
passphrase: ${{ secrets.GPG_PASSPHRASE }}
id: import_gpg
- name: Run GoReleaser
uses: goreleaser/goreleaser-action@v6
with:
version: latest
args: release --clean
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
GPG_FINGERPRINT: ${{ steps.import_gpg.outputs.fingerprint }}
|
CI Workflow for PRs
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
|
# .github/workflows/ci.yml
name: CI
on:
push:
branches: [main]
pull_request:
jobs:
test:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-go@v5
with:
go-version-file: go.mod
cache: true
- name: Test
run: go test ./... -race -coverprofile=coverage.out
- name: Upload coverage
uses: codecov/codecov-action@v4
lint:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-go@v5
with:
go-version-file: go.mod
- uses: golangci/golangci-lint-action@v6
with:
version: latest
build-snapshot:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
with:
fetch-depth: 0
- uses: actions/setup-go@v5
with:
go-version-file: go.mod
cache: true
- uses: goreleaser/goreleaser-action@v6
with:
args: release --snapshot --clean
|
UX Patterns Worth Stealing
Progress Indicators for Long Operations
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
|
import "github.com/schollz/progressbar/v3"
bar := progressbar.NewOptions(len(packages),
progressbar.OptionSetDescription("Scanning packages..."),
progressbar.OptionShowCount(),
progressbar.OptionSetTheme(progressbar.Theme{
Saucer: "=",
SaucerHead: ">",
SaucerPadding: " ",
BarStart: "[",
BarEnd: "]",
}),
)
for _, pkg := range packages {
result := scan(pkg)
bar.Add(1)
// ...
}
bar.Finish()
|
Confirm Dangerous Actions
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
|
import "github.com/AlecAivazis/survey/v2"
func confirmAction(msg string) bool {
if !isatty.IsTerminal(os.Stdin.Fd()) {
// Non-interactive (CI) — require explicit --yes flag
return viper.GetBool("yes")
}
var confirm bool
survey.AskOne(&survey.Confirm{Message: msg}, &confirm)
return confirm
}
// Usage
if !confirmAction(fmt.Sprintf("Delete %d records?", count)) {
fmt.Println("Aborted.")
return nil
}
|
Detect Non-Interactive Mode
1
2
3
4
5
6
7
8
9
10
|
import "github.com/mattn/go-isatty"
func isInteractive() bool {
return isatty.IsTerminal(os.Stdout.Fd())
}
// Disable colors in pipes and CI
if !isInteractive() {
color.NoColor = true
}
|
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
|
import "github.com/charmbracelet/glamour"
func renderWithPager(content string) error {
if !isInteractive() {
fmt.Print(content)
return nil
}
// Use $PAGER or fall back to less
pager := os.Getenv("PAGER")
if pager == "" {
pager = "less -R"
}
parts := strings.Fields(pager)
cmd := exec.Command(parts[0], parts[1:]...)
cmd.Stdin = strings.NewReader(content)
cmd.Stdout = os.Stdout
cmd.Stderr = os.Stderr
return cmd.Run()
}
|
Publishing
Install via go install (zero-friction for Go users)
1
|
go install github.com/yourname/vcheck@latest
|
This works once you’ve pushed a tagged release and your module is in the Go module proxy.
Homebrew (macOS and Linux)
GoReleaser generates the formula automatically if you configure the brews section. Users install with:
1
2
|
brew tap yourname/tap
brew install vcheck
|
Docker
1
2
3
4
5
6
7
8
9
10
11
12
13
14
|
# Dockerfile — minimal scratch image
FROM golang:1.22-alpine AS builder
WORKDIR /build
COPY go.mod go.sum ./
RUN go mod download
COPY . .
RUN CGO_ENABLED=0 GOOS=linux go build \
-ldflags="-s -w" \
-o vcheck .
FROM scratch
COPY --from=builder /build/vcheck /vcheck
COPY --from=builder /etc/ssl/certs/ca-certificates.crt /etc/ssl/certs/
ENTRYPOINT ["/vcheck"]
|
1
|
docker run --rm ghcr.io/yourname/vcheck:latest scan go golang.org/x/net@0.5.0
|
What Makes a Great CLI
Before closing, the principles that separate forgettable tools from ones people actually reach for:
Be predictable. Follow Unix conventions. Exit 0 on success, non-zero on failure. Write errors to stderr, data to stdout. Support --help on every command.
Be pipeable. vcheck scan go ./... --output plain | grep CRITICAL | wc -l should work naturally. Don’t pollute stdout with progress bars when output is being piped (isatty check).
Be quiet by default. Show what the user asked for. Put diagnostics behind --verbose. Nobody wants a wall of logs when they just want the result.
Fail clearly. A bad error message (“error: failed”) wastes the user’s time. A good one (“error: package lodash@4.17.20 not found in npm — check spelling or try lodash@4.17.21”) solves the problem.
Version everything. --version must exist and must be specific enough to reproduce the exact binary. Include commit hash.
Document with examples. The Long description on every Cobra command should have runnable examples. Examples teach faster than prose.
Related: Go for DevOps Engineers, CI/CD Pipelines, GitHub Actions Advanced Patterns
Comments