Build Cross Platform CLI Application With Go and Cobra | by Pavel Durov | Dec, 2022

Building Go applications for multiple targets

This article will cover the process and the components involved in manufacturing. command line interface (CLI) [1] The application is using the Go programming language. We’ll cover the required libraries, directory structure, configuration files, testing, and the cross-platform build process.

CLI means command line interface [1], CLI application receives input from the user, performs some computational work and produces output. than a Graphical User Interface (GUI) [2]CLI applications require fewer system resources because the interaction with them does not involve graphics.

One of the nice things (at least in my opinion) about CLI applications is when they are designed with composition in mind and have a well defined in/out interface. These applications can be built together (similar to function composition) as a solution.

For example, if we have two CLI applications, A and B, we can create a mixed solution AB with inputs to A and outputs to B.

cross-platform[5] Applications are designed to work on more than one computing platform. For example, we can build the same software but run it on Linux, Windows and Android devices. These applications are also known as multi-platform, platform-agnostic or platform-independent.

This is a great concept. Cause who wants to maintain an excessive amount of software? But it also needs to be considered in software design. Cross-platform applications need to consider the specifics of any operative system (OS). We’ll see examples of this when dealing with local filesystems in the sections below.

we are going to use cobra [3] CLI framework. cobra Is a very powerful, extensible and enjoyable framework to work with. You won’t regret it, I promise!

GO version used:

$ go version
go version go1.19 darwin/arm64

Initialize our project:

$ go mod init github.com/Pavel-Durov/cli-demo
go: creating new go.mod: module github.com/Pavel-Durov/cli-demo

establish Cobra And CobraCLI, CobraCLI Will build our application and add CLI commands.

$ go get -u github.com/spf13/cobra/cobra
$ go install github.com/spf13/cobra-cli@latest

now we can use CobraCLI to begin our CLI Application:

$ cobra-cli init

That’s it. We have a working app! It should have the following structure:

$ tree
├── cmd
│ └── root.go
├── go.mod
├── go.sum
└── main.go

we have main.go file, which is the main entry point of our application. and a lonely CLI command is called root This is the main entry point to the Cobra framework. It is also a common convention for Go projects to have an application entry point in cmd directory.

play our new CLI Application:

$ go run ./main.go
A longer description that spans multiple lines and likely contains
examples and usage of using your application. For example:
Cobra is a CLI library for Go that empowers applications.
This application is a tool to generate the needed files
to quickly create a Cobra application.

Nothing matches to see right now; We get the default message. Let’s add some functionality.

For this demo, we are going to create a calculator CLI application. I know, very exciting!

adding our first order

//file: ./cmd/add.go
package cmd

import (
"github.com/spf13/cobra"
)

var addCmd = &cobra.Command
Use: "add",
Short: "Add operator",
Long: `Add operator, adds two integers and prints the result.`,
Run: func(cmd *cobra.Command, args []string)
num1, _ := cmd.Flags().GetInt32("n1")
num2, _ := cmd.Flags().GetInt32("n2")
cmd.Printf("%d + %d = %d\n", num1, num2, num1+num2)
,

func init()
addCmd.Flags().Int32("n1", 0, "--n1 1")
addCmd.Flags().Int32("n2", 0, "--n1 2")
addCmd.MarkFlagRequired("n1")
addCmd.MarkFlagRequired("n2")

we could use CobraCLI For him too. But I decided to go manual here.

Note that we have defined use property, which means that to use add command, we need to specify first add In our CLI parameter.

Wire CLI order together:

//file: ./cmd/root.go
package cmd

import (
"os"

"github.com/spf13/cobra"
)

var rootCmd = &cobra.Command
Use: "[command]",
Short: "A CLI calculator",
Long: `A CLI calculator that can add and subtract two numbers.`,

func Execute()
err := rootCmd.Execute()
if err != nil
os.Exit(1)

func init()
rootCmd.AddCommand(addCmd) // adding add command to root

Since our orders are part of the same package called cmd – Import and configuration is very straight forward.

retry our order this time -h flag:

$ go run ./main.go  -h
A CLI calculator that can add and subtractwo numbers.

Usage:
calc [command]

Available Commands:
add Add operator
completion Generate the autocompletion script for the specified shell
help Help about any command

Flags:
-h, --help help for calc

Use "calc [command] --help" for more information about a command.

As you can see, Cobra did a lot of the work for us. It configured the parsing of flags, help messages, etc.

Run the actual command with the specified parameters:

$ go run ./main.go  add --n1=1 --n2=3
1 + 3 = 4

we have fully developed CLI Application that can add two numbers and print the result stdout,

Let’s add one more command! This time we’ll add replacement.

It would be as simple as:

//file: ./cmd/sub.go
package cmd

import (
"github.com/spf13/cobra"
)

var subCmd = &cobra.Command
Use: "sub",
Short: "Sub operator",
Long: `Sub operator, subtracts two integers and prints the result.`,
Run: func(cmd *cobra.Command, args []string)
num1, _ := cmd.Flags().GetInt32("n1")
num2, _ := cmd.Flags().GetInt32("n2")
cmd.Printf("%d - %d = %d\n", num1, num2, num1-num2)
,

func init()
subCmd.Flags().Int32("n1", 0, "--n1 1")
subCmd.Flags().Int32("n2", 0, "--n1 2")
subCmd.MarkFlagRequired("n1")
subCmd.MarkFlagRequired("n2")

As before, add the new command to Root,

...
rootCmd.AddCommand(subCmd)
...

give it a go:

$ go run ./main.go sub - n1=10 - n2=4
10–4 = 6

It works exactly as intended! You can imagine how we can extend our CLI application further using the same process.

I love tests, and I think you should too. Adding unit tests to Go is very straightforward; However, testing CLI commands like Cobra can be a bit tricky. That’s why I wanted to demonstrate how to do it.

Adding test to root CLI command:

// file: cmd/root_test.go
func TestTypeLocal(t *testing.T)
buf := new(bytes.Buffer)
rootCmd.SetOut(buf)
rootCmd.SetArgs([]string"sub", "--n1=10", "--n2=4")

err := rootCmd.Execute()
if err != nil
fmt.Println(err)

if buf.String() != "10 - 4 = 6\n"
t.Errorf("Expected 10 - 4 = 6, got %s", buf.String())

Here, we set a buffer as the out stream for the cobra command and pass CLI arguments (aka flags), then we assert the output result – nothing fancy.

run test:

$ go test ./…
ok github.com/Pavel-Durov/cli-demo/cmd 0.207s

What do we do if we want to store application configuration in sessions, or maybe we want to keep secrets like API keys defined outside our application code? Whatever the reason, Cobra has got your back! really, snake [4] got your back. Viper is a configuration management tool for Go applications. Viper and Cobra work great together.

establish snake:

$ go get github.com/spf13/viper

Configure Viper in our init function, root cmd:

// file: cmd/root.go
func initConfig()
home, err := os.UserHomeDir()
cobra.CheckErr(err)
viper.AddConfigPath(home)
viper.SetConfigType("yaml")
viper.SetConfigName(".calc")
viper.ReadInConfig()

func init()
cobra.OnInitialize(initConfig)
...

if we create a local YAML the file is called .calc In our $HOME Directory with content (because that’s what we configured):

$ cat ~/.calc
username: kimchi

Now we can read these values ​​in our application:

username := viper.Get("username")
if username != nil
fmt.Println("Hello", username)

we don’t have to use YAML either $HOME directory; This setup can be configured in a number of ways.

Note the $HOME directory

notice how we used os.UserHomeDir() To get the user’s home directory. This is important if we want to build cross-platform[6] application. We could have hardcoded the path to the file. But why should we? Go has excellent platform-agnostic library support – os.UserHomeDir() will get back on track $HOME Directory specific to the machine it’s running on without changing a single line of code!

  • On Unix (including macOS), this gives $HOME environment variable
  • on windows, it returns %USERPROFILE%
  • on plan 9, this gives $HOME environment variable

Go has an incredible build system that comes with everything we need.

We can easily build our application for multiple architectures and operating systems (OS):

linux target build

$ CGO_ENABLED=0 GOOS=linux GOARCH=arm64 go build -o out/linux-arm64-calc -ldflags="-extldflags=-static" # linux, arm64 arch
$ CGO_ENABLED=0 GOOS=linux GOARCH=amd64 go build -o out/linux-amd64-calc -ldflags="-extldflags=-static" # linux, amd64 arch

MAC (aka Darwin) target creation

$ CGO_ENABLED=0 GOOS=darwin GOARCH=arm64 go build -o out/darwin-arm64-calc -ldflags="-extldflags=-static" # mac, arm64 arch
$ CGO_ENABLED=0 GOOS=darwin GOARCH=amd64 go build -o out/darwin-amd64-calc -ldflags="-extldflags=-static" # mac, amd64 arch

windows target build

$ CGO_ENABLED=0 GOOS=windows GOARCH=arm64 go build -o out/windows-arm64-calc -ldflags="-extldflags=-static" # windows, arm64 arch
$ CGO_ENABLED=0 GOOS=windows GOARCH=amd64 go build -o out/windows-amd64-calc -ldflags="-extldflags=-static" # windows, amd64 arch

If we run all these build commands, we get these binaries:

$ ls -l ./out/
-rwxr-xr-x 1 ... darwin-amd64-calc
-rwxr-xr-x 1 ... darwin-arm64-calc
-rwxr-xr-x 1 ... linux-amd64-calc
-rwxr-xr-x 1 ... linux-arm64-calc
-rwxr-xr-x 1 ... windows-amd64-calc
-rwxr-xr-x 1 ... windows-arm64-calc

Environment Variables

GOOS

You probably noticed that the only thing that changed between build targets is GOOS environment variable. And you need to convert with go-build tooling! It’s seriously easy to use!

GOARCH

this is where we specify CPU The architecture we’re targeting.

see all supported GOOS And GOARCH Blending:

$ go tool dist list
aix/ppc64
android/386
android/amd64
android/arm
android/arm64
darwin/amd64
....The list goes on

CGO_ENABLED

we also used CGO_ENABLED environment variable. CGO_ENABLED=1 Leads to faster and smaller builds – it allows dynamic loading of the host OS’s native libraries. However, it depends on a host OS, a dependency we’d like to avoid! Otherwise, our code behavior may differ from machine to machine if our code depends on the host library.

linker flags – ldflags

we used flags ldflags , ld stands for linker [6] So ldflags stands for linker flags. A linker is a program that “links” pieces of compiled source code into a binary result. we are passing extldflags to our linker. According to the link tool documentation, these flags are passed to the external linker. Long story short, we are using these flags to indicate to the Go build tool to include all dependencies in the binary and not rely on the ones that are being provided by the environment. we set the flag as -static, indicating that the binary should include all of its dependencies. If not specified, our binary will be dynamically linked. We would like to avoid it here for the same reasons as CGO_ENABLED,

We have seen how to set up a Cobra-based CLI application from scratch. We touched on cross-platform application properties such as platform-agnostic file system paths. We combined the linker and Go build tooling to test and summarize with different target configurations.

Once you’ve got the basics down, it’s easy and fun to build Go applications for many goals.

This article was as much about understanding and organizing my own thoughts as it was about sharing knowledge. i hope it was helpful to you

Full source code can be found Here,

[1] https://en.wikipedia.org/wiki/Command-line_interface

[2] https://en.wikipedia.org/wiki/Graphical_user_interface

[3] https://cobra.dev/

[4] https://github.com/spf13/viper

[5] https://en.wikipedia.org/wiki/Cross-platform_software

[6]

Leave a Reply