Today we are going to see all the power that a CLI (Command line interface) can bring to development, a CLI can help us perform tasks more effectively and lightly through commands via terminal, without needing an interface. For example, git and Docker, we practically use their CLI all the time, when we execute a git commit -m "commit message"
or docker ps -a
we are using a CLI. I'm going to leave an article that details what a CLI is.
In this post we will create a boilerplate for Go projects, where with just 1 command via CLI, the entire project structure will be created.
Go e CLI
Well, Go is extremely powerful for building CLI, it is one of the most used languages for this, no wonder it is widely used among DevOps, precisely because it is so powerful and simple.
Just to give an example of the power of Go for CLI builds, you may have already used or at least heard of Docker, Kubernetes, Prometheus, Terraform, but what do they all have in common? They all have a large part of their usability via CLI and are developed in Go 🐿.
Starting a CLI with Go
Go has a package to handle CLI natively. But let's cover it quickly, the purpose of the post is to use the Cobra CLI package, which will facilitate the construction of our CLI.
Let's use the flag package
package main
import (
"flag"
"fmt"
"time"
)
func main() {
dateFlag := flag.Bool("date", false, "Display the current date")
flag.Parse()
if *dateFlag {
currentTime := time.Now()
fmt.Println("Current date", currentTime.Format("2006-01-02 15:04:05"))
}
}
In this example above, we created a date
flag, passing this flag returns the current date, something very simple, running the project with go run main.go --date
, we will have the value Current date 2023-11 -15 12:26:14
.
dateFlag := flag.Bool("date", false, "Display the current date")
In the code above, we create a flag, the first argument date
is the flag name, false as the default value means that if you run the program without explicitly specifying the --date
flag, the value associated with dateFlag
will be false
. This allows the program to have a specific default behavior if this flag is not provided when the program is executed, while the third argument Display the current date
is the detail of what this flag does.
If we run:
go run main.go -h
We received:
-date
Display the current date
We can use the flag with --date
or -date
, Go already does the automatic check.
We can make our entire boilerplate with this approach, but let's make it a little easier and use the Cobra CLI package.
Cobra CLI
This package is widely used for powerful CLI builds, it is used for example for Kubernetes CLI and GitHub CLI, in addition to offering some cool features such as automatic completion of shell, automatic recognition of flags (the tags) , and you can use -h
or -help
for example, among other facilities.
Creating the project
Our project will be very simple, we will only have main.go
and go.mod
and consequently our go.sum
, we will start the project with the command:
go mod init github.com/wiliamvj/boilerplate-cli-go
You can use whatever name you want, by convention we generally create the name of the project as the link to our repository.
ending up like this:
Now let's download the Cobra package with the command:
go get -u github.com/spf13/cobra@latest
The boilerplate will have a very simple structure, the idea is to create a structure widely used by the Go community, see how it will look:
-
cmd: here where we will leave the
main.go
that starts our app. -
internal: in this paste where all the code for our application should be located.
- handler: Here will be the files responsible for receiving our http requests, you may also know them as controllers.
- routes: Here we will organize our routes.
It's not the complete framework, we're just creating the basics for our example.
All of our code will focus on our main.go
.
package main
import (
"fmt"
"os"
"github.com/spf13/cobra"
)
func main() {
var rootCommand = &cobra.Command{}
var projectName, projectPath string
var cmd = &cobra.Command{
Use: "create",
Short: "Create boilerplate for a new project",
Run: func(cmd *cobra.Command, args []string) {
// validations
if projectName == "" {
fmt.Println("You must supply a project name.")
return
}
if projectPath == "" {
fmt.Println("You must supply a project path.")
return
}
fmt.Println("Creating project...")
},
}
cmd.Flags().StringVarP(&projectName, "name", "n", "", "Name of the project")
cmd.Flags().StringVarP(&projectPath, "path", "p", "", "Path where the project will be created")
rootCommand.AddCommand(cmd)
rootCommand.Execute()
}
The code above is just to start our CLI, we will only have two variables:
-
projectName
: it will be the name of our project that we will capture in the input of our CLI. -
projectPath
: will be the path where the boilerplate will be created, we will capture it in the CLI input. -
&cobra.Command{}
: starts the Cobra cli. -
Run:
It receives an anonymous function, it is in this function that we capture the user input entered in the CLI and validate it, our validation is simple, we just check if theprojectName
andprojectPath
are not null. -
cmd.Flags()
: Here we create the flags with flags, so you can use-name
or-n
, both will be accepted, we also include the description of what this flag does. -
rootCommand.AddCommand(cmd)
: We add ourcmd
to therootCommand
created at the beginning of ourmain.go
. -
rootCommand.Execute()
: Finally, we run our CLI.
That's all we need to get our CLI working, of course without our boilerplate logic, but with that we can use it via the terminal. Lets test!
We can build the project or use it without build
With build:
go build -o cli .
A file called cli
will be created at the root, we will run the binary from our CLI:
./cli --help
We will have an output like this:
Usage:
[command]
Available Commands:
completion Generate the autocompletion script for the specified shell
create Create boilerplate for a new project
help Help about any command
Flags:
-h, --help help for this command
Use " [command] --help" for more information about a command.
See that we already have tips on how to use the command we created create Create boilerplate for a new project
, if we run:
./cli create --help
We will have:
Create boilerplate for a new project
Usage:
create [flags]
Flags:
-h, --help help for create
-n, --name string Name of the project
-p, --path string Path where the project will be created
Let's run it now by passing our flags:
./cli create -n my-project -p ~/documents
We will have our Creating project...
message, indicating that it worked, but nothing happens yet, as we have not implemented the logic.
We can also create subcommands, new flags, new validations, but for now let's leave it like that, if you want you can create more options, see documentation of the Cobra package.
Creating the boilerplate
With our CLI ready, let's now go through the boilerplate logic, which is very simple, we will have to create the folders, then we will need to create the files and finally open the files and insert the code, for this we will use the os package a lot of Go, which allows you to access operating system resources.
Let's first get the main directory and validate if there already exists a folder with the name that will be used to create our project:
globalPath := filepath.Join(projectPath, projectName)
if _, err := os.Stat(globalPath); err == nil {
fmt.Println("Project directory already exists.")
return
}
If we pass the projectName
as test and the projectPath
as /documents
, this validates that there is no other folder in documents called test, if there is we return and return a error message.
You can modify and, if there is a folder with the same name, change the name of projectName
or delete the folder that already exists, but for now we will just return an error.
if err := os.Mkdir(globalPath, os.ModePerm); err != nil {
log.Fatal(err)
}
In this part, we will create the directory in the path that was entered using our -p
flag, if we use:
./cli create -n my-project -p ~/documents
Starting Go
A folder called my-project will be created in the documents directory.
startGo := exec.Command("go", "mod", "init", projectName)
startGo.Dir = globalPath
startGo.Stdout = os.Stdout
startGo.Stderr = os.Stderr
err := startGo.Run()
if err != nil {
log.Fatal(err)
}
In the code above we execute the command to start the project in Go, it will be created in the root directory we chose, in our example it will run within documents/my-project, this will create the file go.mod
and will set the module name to my-projects.
-
exec.Command
: Create the command that we will run in the terminal, in this case it will bego mod init my-project
. -
startGo.Dir
: Determine where this command will run, in the example it will run in documents/my-project. -
startGo.Stdout
: It will place the command return in the terminal, it will returngo: creating new go.mod: module my-project
. -
startGo.Stderr
: Redirects the output of a possible error to where the program is being executed. -
startGo.Run()
: Finally, we execute everything.
Creating the folders
Let's create our folders, they are cmd, internal, handler and routes.
cmdPath := filepath.Join(globalPath, "cmd")
if err := os.Mkdir(cmdPath, os.ModePerm); err != nil {
log.Fatal(err)
}
internalPath := filepath.Join(globalPath, "internal")
if err := os.Mkdir(internalPath, os.ModePerm); err != nil {
log.Fatal(err)
}
handlerPath := filepath.Join(internalPath, "handler")
if err := os.Mkdir(handlerPath, os.ModePerm); err != nil {
log.Fatal(err)
}
routesPath := filepath.Join(handlerPath, "routes")
if err := os.Mkdir(routesPath, os.ModePerm); err != nil {
log.Fatal(err)
}
This code above creates the necessary folders in sequence, using os.Mkdir
, (see the docs), for the handler and folders routes, we need to access the internal folder, as they will be created within internal, for this we use Join
to merge the path, resulting in:
-
handlerPath
: documents/my-project/internal -
routesPath
: documents/my-project/internal/handler
Creating the files
With the folders created, let's create the files, for example we will create main.go
of course and routes.go
, inside the routes folder.
mainPath := filepath.Join(cmdPath, "main.go")
mainFile, err := os.Create(mainPath)
if err != nil {
log.Fatal(err)
}
defer mainFile.Close()
routesFilePath := filepath.Join(routesPath, "routes.go")
routesFile, err := os.Create(routesFilePath)
if err != nil {
log.Fatal(err)
}
defer routesFile.Close()
Above we created the main.go
and routes.go
files.
-
mainPath
: we determine the path, using themainPath
used to create the cmd folder. -
os.Create(mainPath)
: we create the file, in the specified directory. (documents/my-project/cmd) -
routesFilePath
: we determine the path, using theroutesPath
used to create the routes folder. -
os.Create(routesFilePath)
: We create the file, in the specified directory. (documents/my-project/internal/handler/routes) -
defer routesFile.Close()
: We close the file,defer
, using this GO reserved word, we guarantee that the last thing to happen is to close the file. See more aboutdefer
here.
Writing to files
With the folders and files created, now let's write to the main.go
and routes.go
files, let's do something simple, just for example, to organize better, we will separate them into functions that write to each file.
func WriteMainFile(mainPath string) error {
packageContent := []byte(`package main
import "fmt"
func main() {
fmt.Println("Hello World!")
}
`)
mainFile, err := os.OpenFile(mainPath, os.O_WRONLY|os.O_APPEND, 0666)
if err != nil {
return err
}
defer mainFile.Close()
_, err = mainFile.Write(packageContent)
if err != nil {
return err
}
return nil
}
In the function above, we have mainPath
as a parameter, which is the file path, we will add a simple code, which will just log a Hello World.
-
packageContent
: We create the code that will be written to the file. -
os.OpenFile
: We open the file specified inmainPath
. -
defer mainFile.Close()
: We close the file last withdefer
. -
mainFile.Write
: Finally, we write it to the file, and handle the error if any.
O_WRONLY
and O_APPEND
, are constants used to define the opening mode of a file, O_WRONLY
indicates that the file will be opened only for writing, O_APPEND
means that the added content will be added to the end of the file, without overwrite existing content.
func WriteRoutesFile(routesFilePath string) error {
packageContent := []byte(`package routes
// your code here
`)
routesFile, err := os.OpenFile(routesFilePath, os.O_WRONLY|os.O_APPEND, 0666)
if err != nil {
return err
}
defer routesFile.Close()
_, err = routesFile.Write(packageContent)
if err != nil {
return err
}
return nil
}
We do the same for the routes.go
file.
Now just call the new functions in the main
function, looking like this:
mainPath := filepath.Join(cmdPath, "main.go")
mainFile, err := os.Create(mainPath)
if err != nil {
log.Fatal(err)
}
defer mainFile.Close()
if err := WriteMainFile(mainPath); err != nil {
log.Fatal(err)
}
routesFilePath := filepath.Join(routesPath, "routes.go")
routesFile, err := os.Create(routesFilePath)
if err != nil {
log.Fatal(err)
}
defer routesFile.Close()
if err := WriteRoutesFile(routesFilePath); err != nil {
log.Fatal(err)
}
Final code
package main
import (
"fmt"
"log"
"os"
"os/exec"
"path/filepath"
"github.com/spf13/cobra"
)
func main() {
var rootCommand = &cobra.Command{}
var projectName, projectPath string
var cmd = &cobra.Command{
Use: "create",
Short: "Create boilerplate for a new project",
Run: func(cmd *cobra.Command, args []string) {
if projectName == "" {
fmt.Println("You must supply a project name.")
return
}
if projectPath == "" {
fmt.Println("You must supply a project path.")
return
}
fmt.Println("Creating project...")
globalPath := filepath.Join(projectPath, projectName)
if _, err := os.Stat(globalPath); err == nil {
fmt.Println("Project directory already exists.")
return
}
if err := os.Mkdir(globalPath, os.ModePerm); err != nil {
log.Fatal(err)
}
startGo := exec.Command("go", "mod", "init", projectName)
startGo.Dir = globalPath
startGo.Stdout = os.Stdout
startGo.Stderr = os.Stderr
err := startGo.Run()
if err != nil {
log.Fatal(err)
}
cmdPath := filepath.Join(globalPath, "cmd")
if err := os.Mkdir(cmdPath, os.ModePerm); err != nil {
log.Fatal(err)
}
internalPath := filepath.Join(globalPath, "internal")
if err := os.Mkdir(internalPath, os.ModePerm); err != nil {
log.Fatal(err)
}
handlerPath := filepath.Join(internalPath, "handler")
if err := os.Mkdir(handlerPath, os.ModePerm); err != nil {
log.Fatal(err)
}
routesPath := filepath.Join(handlerPath, "routes")
fmt.Println(routesPath)
if err := os.Mkdir(routesPath, os.ModePerm); err != nil {
log.Fatal(err)
}
mainPath := filepath.Join(cmdPath, "main.go")
mainFile, err := os.Create(mainPath)
if err != nil {
log.Fatal(err)
}
defer mainFile.Close()
if err := WriteMainFile(mainPath); err != nil {
log.Fatal(err)
}
routesFilePath := filepath.Join(routesPath, "routes.go")
routesFile, err := os.Create(routesFilePath)
if err != nil {
log.Fatal(err)
}
defer routesFile.Close()
if err := WriteRoutesFile(routesFilePath); err != nil {
log.Fatal(err)
}
},
}
cmd.Flags().StringVarP(&projectName, "name", "n", "", "Name of the project")
cmd.Flags().StringVarP(&projectPath, "path", "p", "", "Path where the project will be created")
rootCommand.AddCommand(cmd)
rootCommand.Execute()
}
func WriteMainFile(mainPath string) error {
packageContent := []byte(`package main
import "fmt"
func main() {
fmt.Println("Hello World!")
}
`)
mainFile, err := os.OpenFile(mainPath, os.O_WRONLY|os.O_APPEND, 0666)
if err != nil {
return err
}
defer mainFile.Close()
_, err = mainFile.Write(packageContent)
if err != nil {
return err
}
return nil
}
func WriteRoutesFile(routesFilePath string) error {
packageContent := []byte(`package routes
// your code here
`)
routesFile, err := os.OpenFile(routesFilePath, os.O_WRONLY|os.O_APPEND, 0666)
if err != nil {
return err
}
defer routesFile.Close()
_, err = routesFile.Write(packageContent)
if err != nil {
return err
}
return nil
}
Testing the CLI
Well, with everything ready, let's test it! To do this, we will compile our code with the good old go build
.
go build -o cli .
Running the CLI:
./cli create -n my-project -p ~/documents
Let's get the answer:
Creating project...
go: creating new go.mod: module my-project
Accessing our project and opening it in Visual Studio Code with:
cd /documents/my-project && code .
We will have our boilerplate created:
If we run the project created via CLI, we can see that everything works.
go run cmd/main.go
output:
Hello World!
With this we finish creating our CLI that creates a boilerplate.
Final considerations
Our boilerplate could be even more automated, we could, for example, install a package like the Go Chi package, creating standard endpoints, all using the CLI, you can even create your own framework, have you ever thought that with just one command your initial project is already configured?
With knowledge in CLI creation, you have great power in your hands!
see my blog here
Repository link
repository of the project
Top comments (4)
TBH Cobra doesn't seem like it really reduces much code and using recursive argument eval is way more efficient.
What a good stuff. I stayed in my position but remained inspired with this post!
Thank you for the article!
Awesome explanation! It's been a pleasure to read you. Keep up with the good work.
Great post!