DEV Community

Cover image for Build a CLI TODO App with Goravel Console
kkumar-gcc for goravel

Posted on • Edited on

Build a CLI TODO App with Goravel Console

Hello Everyone! It's been a while since I last wrote a blog post, but I'm back with something fresh(at least for me) post. In this post, we'll be building a command-line TODO app using Goravel console.

But before we dive in, let me introduce you to a framework called Goravel.

What is Goravel?

You may have used Laravel (personally, I’ve used it a lot!). I love its developer-friendly experience and straightforward architecture. However, for the Go community, something similar was missing. Enter Goravel—a framework that makes life easier for PHP developers like me who want to work with Go.

Goravel borrows best practices and features from Laravel, bringing them into the Go ecosystem.

That’s enough introductions. Let’s dive into what we’re building today!

Prerequisites

Before we dive in, make sure you have:

  • Go 1.21 or higher installed
  • Basic understanding of Go syntax
  • Familiarity with command-line interfaces
  • SQLite (we'll use it for simplicity, but you can swap it for any database)

Don't worry if you're new to Goravel—I'll explain everything as we go!

What are we building?

This article focuses on learning Goravel's console, so we will implement only the following two features in about command line todo application:

  • Add a new task.
  • View all tasks

You can checkout source code of the todo application at kkumar-gcc/todo.

Project Structure

Project will be structured in following way where each folders servers their respective purpose as name sounds:

constants/           # Store constants or config for the application
  └── version.go

console/
  ├── commands/      # Implement commands
  │   └── add_task.go
  └── kernel.go      # Register all commands

models/
  └── task.go        # Model definition for a task

services/            # Handle business logic
  └── task_service.go # Service for CRUD operations on tasks

repositories/        # Layer between the database and business logic
  └── task_repository.go

database/
  └── database.go    # Logic for interacting with and resolving the database

main.go              # Application entry point
Enter fullscreen mode Exit fullscreen mode

Project Initialization

In the last section, we have outlined the project structure. Now, let's create these folders (files) and initialize a Go project named github.com/kkumar-gcc/todo.

mkdir todo
cd todo

go mod init github.com/kkumar-gcc/todo
Enter fullscreen mode Exit fullscreen mode

Install the Goravel framework:

go get github.com/goravel/framework
Enter fullscreen mode Exit fullscreen mode

Let's create, the entry point of the application main.go where all magical thing will happen.

package main

import (
    "os"

    goravelconsole "github.com/goravel/framework/console"
    "github.com/goravel/framework/support/color"

    "github.com/kkumar-gcc/todo/constants"
)

func main() {
    name := "todo"
    usage := "TODO"
    usageText := "todo [global options] command [command options] [arguments...]"

  // The last argument is false, which indicates that it is not an artisan command.
    cli := goravelconsole.NewApplication(name, usage, usageText, constants.Version, false)

  // run the any command called in os.Args
    if err := cli.Run(os.Args, false); err != nil {
        color.Red().Println(err)
    }
}
Enter fullscreen mode Exit fullscreen mode

Here, we’re creating and running a Goravel console instance with custom metadata for the TODO application. Running go run . will show the default help message.

Create your first Goravel command.

Now that the initial setup is complete, let's create our first Goravel command by adding a file named add_task_command.go inside the console/commands directory. Every Goravel command must implement the github.com/goravel/framework/contracts/console/command.Command interface.
Here's how you can define the AddTaskCommand:

package commands

import (
    "github.com/goravel/framework/contracts/console"
    "github.com/goravel/framework/contracts/console/command"
)

type AddTaskCommand struct {}

// Signature The name and signature of the console command.
func (r *AddTaskCommand) Signature() string {
    return "task:add"
}

// Description The console command description.
func (r *AddTaskCommand) Description() string {
    return "Create a new task"
}

// Extend The console command extend.
func (r *AddTaskCommand) Extend() command.Extend {
    return command.Extend{}
}

// Handle Execute the console command.
func (r *AddTaskCommand) Handle(ctx console.Context) (err error) {
    ctx.Success("Hello world!")
    return nil
}
Enter fullscreen mode Exit fullscreen mode

Let's understand the command structure:

  • Signature : Serves as a identifier for the command (must be unique)
  • Description: Displays the description when the help flag is used.
  • Extend: Defines the Category and Flags for the command.
  • Handle: The entry point for the command. It accepts an instance of github.com/goravel/framework/console.Context, which offers many convenient methods for command I/O (Read More).

Register the command

To register a command, open main.go and use the Register method provided by the Goravel console:

// main.go
package main

import (
    "os"

    goravelconsole "github.com/goravel/framework/console"
    "github.com/goravel/framework/contracts/console"
    "github.com/goravel/framework/support/color"

    "github.com/kkumar-gcc/todo/console/commands"
    "github.com/kkumar-gcc/todo/constants"
)

func main() {
    ...

    cli := goravelconsole.NewApplication(name, usage, usageText, constants.Version, false)

  cli.Register([]console.Command{
        &commands.AddTaskCommand{},
  })

  ...
}
Enter fullscreen mode Exit fullscreen mode

We have successfully registered the command. Let’s test it out by running the following in the terminal:

go run . task:add
Enter fullscreen mode Exit fullscreen mode

Output:

Hurray!! We've created our first command successfully. However, as your application grows, you will likely have many commands - or commands that accepts dependencies. To keep things clean and maintainable, it's a good idea to centralize command registration in a dedicated console/kernel.go file and then use the kernel in main.go.

Define the Kernel

Create the console/kernel.go file and register commands there:

package console

import (
    "github.com/goravel/framework/contracts/console"

    "github.com/kkumar-gcc/todo/console/commands"
)

type Kernel struct {
}

func (kernel *Kernel) Commands() []console.Command {
    return []console.Command{
        &commands.AddTaskCommand{},
    }
}
Enter fullscreen mode Exit fullscreen mode

Now let's update main.go to use the kernel:

package main

...

func main() {
     ...

     kernel := &console.Kernel{}

     cli.Register(kernel.Commands())

     ...

}
Enter fullscreen mode Exit fullscreen mode

Now that we have setup everything, we are ready to implement the actual logic of the task:add command. But before we do that, let's define the Task model, which will represent and store task data in the database.

package models

import "time"

type Task struct {
    ID          int        `json:"id"`
    Title       string     `json:"title"`
    Status      int        `json:"status"` // Use constants: constants.StatusPending(1), constants.StatusInProgress(2), constants.StatusCompleted(3)
    CreatedAt   time.Time  `json:"created_at"`
    CompletedAt *time.Time `json:"completed_at,omitempty"`
    Priority    int        `json:"priority"` // Use constants: constants.PriorityLow(1), constants.PriorityMedium(2), constants.PriorityHigh(3)
    Tags        string     `json:"tags"`     // Tags for categorization
}
Enter fullscreen mode Exit fullscreen mode

Handling User Input

Goravel console offers two ways to take named input from the terminal: the first is by defining Flags (Options and the second is by prompting For Input in terminal. We will use both - flags as the primary input method and interactive prompts as a fallback.

We require the user to provide title, status, priority and tags(Optional) to create a task. Let's define flags for each input inside the Extend method of AddTaskCommand.

// add_task_command.go
package commands

...

func (r *AddTaskCommand) Extend() command.Extend {
    return command.Extend{
        Category: "tasks",
        Flags: []command.Flag{
            &command.StringFlag{
                Name:    "title",
                Aliases: []string{"t"},
                Usage:   "The title of the task",
            },
            &command.StringFlag{
                Name:    "priority",
                Aliases: []string{"p"},
                Usage:   "The priority of the task (low, medium, high)",
            },
            &command.StringFlag{
                Name:    "status",
                Aliases: []string{"s"},
                Usage:   "The status of the task (pending, in-progress, completed)",
            },
            &command.StringFlag{
                Name:    "tags",
                Aliases: []string{"g"},
                Usage:   "Tags for the task, separated by commas",
            },
        },
    }
} 

...
Enter fullscreen mode Exit fullscreen mode

Accessing Flag Values

You can pass these flags using (--{name} {value} or -{alias} {value}) when executing the command . To access them, use ctx.Option("{name}") in Handle method.

package commands

...

func (r *AddTaskCommand) Handle(ctx console.Context) (err error) {
    title := ctx.Option("title")
    priority := ctx.Option("priority")
    status := ctx.Option("status")
    tags := ctx.Option("tags")

    println(title, priority, status, tags)
    return nil
}
Enter fullscreen mode Exit fullscreen mode

Try running:

go run . task:add --title "write article" -p 1 -s 2
Enter fullscreen mode Exit fullscreen mode

If you want the flag value in a specific type, Goravel provides methods like OptionInt, OptionSlice etc.

Interactive Prompts for Missing Input

We have handled input via flags, but what if some required fields are missing? Let's use interactive prompts to ensures title, priority and status are always provided.

Goravel console provides various convenient methods to prompt the user.

package commands

...

func (r *AddTaskCommand) Handle(ctx console.Context) (err error) {
    title := ctx.Option("title")
    priority := ctx.Option("priority")
    status := ctx.Option("status")
    tags := ctx.Option("tags")

    if title == "" {
        title, err = ctx.Ask("What is the title of the task?", console.AskOption{
            Placeholder: "E.g., Write article",
            Prompt:      "> ",
            Validate: func(value string) error {
                if strings.TrimSpace(value) == "" {
                    return errors.New("the task title is required")
                }
                return nil
            },
        })
        if err != nil {
            ctx.Error(err.Error())
            return nil
        }
    }

    if priority == "" {
        choices := []console.Choice{
            {Key: constants.PriorityColors[constants.PriorityLow], Value: strconv.Itoa(constants.PriorityLow)},
            {Key: constants.PriorityColors[constants.PriorityMedium], Value: strconv.Itoa(constants.PriorityMedium)},
            {Key: constants.PriorityColors[constants.PriorityHigh], Value: strconv.Itoa(constants.PriorityHigh)},
        }
        priority, err = ctx.Choice("Select the priority of the task:", choices, console.ChoiceOption{
            Default:     strconv.Itoa(constants.PriorityLow),
            Description: "Choose a priority for the task",
        })
        if err != nil {
            ctx.Error(err.Error())
            return nil
        }
    }

    if status == "" {
        choices := []console.Choice{
            {Key: constants.StatusColors[constants.StatusPending], Value: strconv.Itoa(constants.StatusPending)},
            {Key: constants.StatusColors[constants.StatusInProgress], Value: strconv.Itoa(constants.StatusInProgress)},
            {Key: constants.StatusColors[constants.StatusCompleted], Value: strconv.Itoa(constants.StatusCompleted)},
        }
        status, err = ctx.Choice("Select the status of the task:", choices, console.ChoiceOption{
            Default:     strconv.Itoa(constants.StatusPending),
            Description: "Choose a priority for the task",
        })
        if err != nil {
            ctx.Error(err.Error())
            return nil
        }
    }

    if tags == "" {
        tags, err = ctx.Ask("Enter tags for the task (comma-separated):", console.AskOption{
            Placeholder: "E.g., work,urgent",
            Prompt:      "> ",
        })
        if err != nil {
            ctx.Error(err.Error())
            return nil
        }
    }

priorityInt, err := strconv.Atoi(priority)
    if err != nil {
        ctx.Error(err.Error())
        return nil
    }

    statusInt, err := strconv.Atoi(status)
    if err != nil {
        ctx.Error(err.Error())
        return nil
    }
       println(priorityInt, statusInt)

return nil
}
Enter fullscreen mode Exit fullscreen mode

You can refer to the docs for each method. For example, Ask accepts a question and an optional second argument console.AskOption struct. In our case, we added a Validate function that runs whenever the user presses Enter key. If the input is invalid, it will shows an error and waits for valid input.

Integrating the Service Layer

Now that we have collected all required fields, let's define a services.TaskService interface to handle task creation and listing. Let's accept TaskService as a struct level argument in AddTaskCommand. Implementation of TaskService is not part of this article if you want to check you can read source code.

// task_service.go
package services

type TaskService interface {
    CreateTask(ctx context.Context, title string, status, priority int, tags string) error
    GetAllTasks(ctx context.Context, status, priority int, sort string) ([]models.Task, error)
}

// update add_task_command.go
...

type AddTaskCommand struct {
    TaskService services.TaskService
}

// update kernel.go

...

func (kernel *Kernel) Commands() []console.Command {
        // init the service
        taskService := services.NewTaskService()
    return []console.Command{
        &commands.AddTaskCommand{
                       TaskService: taskService,                                      
                },
    }
}

Enter fullscreen mode Exit fullscreen mode

Completing the Add Task Command

Now we can call TaskService.CreateTask() to create the task using the collected input

package commands

...
func (r *AddTaskCommand) Handle(ctx console.Context) (err error) {
        ...

if err := r.TaskService.CreateTask(context.Background(), title, statusInt, priorityInt, tags); err != nil {
        ctx.Error(err.Error())
        return nil
    }

    ctx.Success("Task created successfully!")
    return nil
}
Enter fullscreen mode Exit fullscreen mode

Creating the List Tasks Command

Hurray!! we have completed our first full implementation of command. Now let's create another command, task:list, to view tasks. We'll explore how to beautifully display output using Goravel's console methods.

// list_tasks_command.go
package commands

import (
    "context"
    "time"

    "github.com/goravel/framework/contracts/console"
    "github.com/goravel/framework/contracts/console/command"
    "github.com/goravel/framework/support/color"

    "github.com/kkumar-gcc/todo/constants"
    "github.com/kkumar-gcc/todo/models"
    "github.com/kkumar-gcc/todo/services"
)

type ListTasksCommand struct {
    TaskService services.TaskService
}

// Signature The name and signature of the console command.
func (r *ListTasksCommand) Signature() string {
    return "task:list"
}

// Description The console command description.
func (r *ListTasksCommand) Description() string {
    return "List all tasks"
}

// Extend The console command extend.
func (r *ListTasksCommand) Extend() command.Extend {
    return command.Extend{
        Category: "tasks",
    Flags: []command.Flag{
            &command.StringFlag{
                Name:    "sort",
                Aliases: []string{"s"},
                Usage:   "Sort tasks by field (status or priority)",
            },
            &command.StringFlag{
                Name:    "status",
                Aliases: []string{"st"},
                Usage:   "Filter tasks by status (pending, in_progress, completed)",
            },
            &command.StringFlag{
                Name:    "priority",
                Aliases: []string{"p"},
                Usage:   "Filter tasks by priority (low, medium, high)",
            },
        },
    }
}

// Handle Execute the console command.
func (r *ListTasksCommand) Handle(ctx console.Context) (err error) {
    sort, status, priority := ctx.Option("sort"), ctx.Option("status"), ctx.Option("priority")
   tasks, err := r.TaskService.GetAllTasks(context.Background(), constants.StatusMap[status], constants.PriorityMap[priority], sort)
    if err != nil {
        ctx.Error(err.Error())
        return nil
    }
    if len(tasks) == 0 {
        ctx.Info("No tasks found matching the given criteria.")
        return nil
    }


    ctx.NewLine()
    color.Println("<fg=blue;op=bold>Task List:</>")
    ctx.NewLine()

    ctx.TwoColumnDetail(color.Sprintf("<fg=cyan;op=bold>%s</>", "tasks"), "Details")
    for _, task := range tasks {
        idLabel := color.Sprintf("<fg=white;op=bold>%d</>", task.ID)
        statusLabel := constants.StatusColors[task.Status]
        priorityLabel := constants.PriorityColors[task.Priority]
        tagsAndCreatedAt := color.Sprintf("<fg=gray>Tags: %s, Created At: %s</>", task.Tags, task.CreatedAt.Format(time.RFC822))
        ctx.TwoColumnDetail(task.Title+" ("+idLabel+") "+tagsAndCreatedAt, statusLabel+" | "+priorityLabel)
    }
    ctx.NewLine()

    return nil
}
Enter fullscreen mode Exit fullscreen mode

console.Context provides convenient methods like TwoColumnDetail and Info, Spinner, WithProgressBar etc. to display rich output in the terminal.

That's a wrap!

We've built a working TODO CLI app with Goravel Console. Pretty neat how easy it was, right? From commands to interactive prompts to colorful output - Goravel handled it all beautifully.

Want to take it further? Try adding update/delete commands or maybe due dates.

Check out the complete code at kkumar-gcc/todo.

Top comments (0)