Skip to content

How Build a Terminal App in Go to Find Available Usernames (no third-party dependencies)

TL;DR

The code referenced in this blog post can be found on GitHub.

Introduction

Let's build a terminal app called "Don't @ Me" to check various social media platforms (Instagram, TikTok, GitHub, etc) to see if a given username is either available or taken, which would be particularly useful if you're trying to build a brand. Instead of navigating to each site in your browser and spending minutes of your time checking each site for a username, we'll be able to use tool to check the availability in the terminal and get results for multiple platforms in under 1 second.

Don't @ Me

Prerequisites

The only things we need for this tutorial is Go and an editor. We'll be able to build this app by using just the Go standard library for prompting the user for input, taking that input and concurrently checking for the given username on social media platforms, and displaying the result of the http request. We'll also be using ANSI escape codes, but we won't be depending on any third-party libraries.

Getting Started

Create a new project called dont_at_me with the following file structure. From here, we'll go over the code in each package, accompanied by additional comments to clarify the code.

dont_at_me
├── cmd
│   └── main.go
├── go.mod
└── pkg
    ├── components
    │   └── components.go
    ├── cursor
    │   └── cursor.go
    ├── social
    │   └── social.go
    ├── style
    │   └── style.go
    ├── tui
    │   └── tui.go
    └── username
        └── username.go


go.mod

module dont_at_me

go 1.20


main.go

I prefer to keep the main.go file fairly lean. Its primary purpose is to configure and initialize the app. Our Run function doesn't return any errors, but if it did then we could log it here and exit the app with a non-zero exit code.

// main.go
package main

import (
	"dont_at_me/pkg/cli"
	"os"
)

func main() {
	tui.Run()
	os.Exit(0)
}

cursor

The cursor pkg contains a list of the ANSI escape codes related to cursor positioning on the screen. This will be used by the tui package to navigate the terminal behind the scenes so that we can always revert our cursor back to the text input after search results have returned.

We could run our app in the command line with arguments and/or flags, but we'd lose the interactivity of our users being able to search multiple usernames in a single session. If we added a -u or --username flag, we'd have to continually run the app which wouldn't be optimal.

For reference, you can see the ANSI codes used in this project, and many others, in this Wikipedia article.

// cursor.go
package cursor

const (
	Up         = "\033[1A"
	Down       = "\033[2B"
	ClearLine  = "\033[2K"
	ClearAfter = "\033[J"
	Hide       = "\033[?25l"
	Show       = "\033[?25h"
)


components.go

The TUI components are stored in this file. I prefer to keep these strings separate so they can be easily modified without touching any of the business logic in the tui package.

Since the terminal is all text, we can think of strings we print to the stdout as component views. For this app, we have components for the header, prompt, and text input indicator.

// components.go
package components

import (
	"dont_at_me/pkg/style"
	"fmt"
)

var Header = `
      |               /        ____                   
    __|   __   _  _    _|_    / __,\    _  _  _    _  
   /  |  /  \_/ |/ |    |    | /  | |  / |/ |/ |  |/  
   \_/|_/\__/   |  |_/  |_/  | \_/|/     |  |  |_/|__/
                              \____/
`

var Prompt = fmt.Sprintf("  Enter a username to check availability %s", style.Dim.Colorize("[q to quit]"))

var TextInput = "  @ "


social

All business logic related to the social media platforms we support (Instagram, TikTok, GitHub, Snapchat, Twitch, YouTube, Mastodon) are located in this file.

We can use the Platform type as the receiver type in order to encapsulate some helpers like getting the string and URL for each platform.

We'll also utilize a Status type for the different outcomes of our http request - Unavailable, Unknown, and Available.

The brains of this app is the GetAvailability function. It makes http requests to each URL and parses the html response for specific strings based on the platform. This was determined by inspecting the webpage for accounts that were available and unavailable in the browser and diffing the results.

One thing we could consider is recursively calling GetAvailability with a retry limit if the result is Unknown, but we'd also have to make sure we don't make too many requests to the same URL in a short period of time.

// social.go
package social

import (
	"context"
	"dont_at_me/pkg/style"
	"io"
	"net/http"
	"strings"
	"time"
)

type Platform int

var Platforms = []Platform{Instagram, TikTok, GitHub, Snapchat, Twitch, YouTube, Mastodon}

const (
	Instagram Platform = iota + 1
	TikTok
	GitHub
	Snapchat
	Twitch
	YouTube
	Mastodon
)

var PlatformStrings = map[Platform]string{
	Instagram: "Instagram",
	TikTok:    "TikTok",
	GitHub:    "GitHub",
	Snapchat:  "Snapchat",
	Twitch:    "Twitch",
	YouTube:   "YouTube",
	Mastodon:  "Mastodon",
}

var PlatformBaseUrls = map[Platform]string{
	Instagram: "https://instagram.com/",
	TikTok:    "https://us.tiktok.com/@",
	GitHub:    "https://github.com/",
	Snapchat:  "https://www.snapchat.com/add/",
	Twitch:    "https://www.twitch.tv/",
	YouTube:   "https://youtube.com/@",
	Mastodon:  "https://mastodon.social/@",
}

func (p Platform) String() string {
	return PlatformStrings[p]
}

func (p Platform) BaseUrl() string {
	return PlatformBaseUrls[p]
}

// Spacer nicely formats the space between the platform name and the username status symbol so that they line up similarly to table columns.
func (p Platform) Spacer() int {
	return style.MaxCharWidth - len(p.String())
}

type Status int

const (
	Unavailable Status = iota - 1
	Unknown
	Available
)

var StatusMessages = map[Status]string{
	Unavailable: style.Red.Colorize("✖️"),
	Unknown:     style.Red.Colorize("?"),
	Available:   style.Green.Colorize("✔️"),
}

func (s Status) String() string {
	return StatusMessages[s]
}

func (p Platform) GetAvailability(username string) Status {
	ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
	defer cancel()

	url := p.BaseUrl() + username

	req, err := http.NewRequestWithContext(ctx, http.MethodGet, url, nil)
	if err != nil {
		return Unknown
	}

	res, err := http.DefaultClient.Do(req)
	if err != nil {
		return Unknown
	}

	defer res.Body.Close()

    // We'll be able to deduce from the status code whether the usernames on these platforms are available.
	if res.StatusCode == http.StatusNotFound {
		switch p {
		case GitHub, YouTube:
			return Available
		}
	}

	body, err := io.ReadAll(res.Body)
	if err != nil {
		return Unknown
	}

    // If we have to parse the html response, we can brute force a search for these strings which will let us know if the username is available.
	switch p {
	case Instagram:
		if strings.Contains(string(body), "<title>Instagram</title>") {
			return Available
		}
	case TikTok:
		if strings.Contains(string(body), "Watch the latest video from .") {
			return Available
		}
	case Snapchat:
		if strings.Contains(string(body), "content=\"Not_Found\"") {
			return Available
		}
	case Twitch:
		if strings.Contains(string(body), "content='Twitch is the world") {
			return Available
		}
	case YouTube:
		if strings.Contains(string(body), "<title>404 Not Found</title>") {
			return Available
		}
	case Mastodon:
		if strings.Contains(string(body), "<title>The page you are looking for") || strings.Contains(string(body), "<title>The page you were looking for") {
			return Available
		}
	}

	return Unavailable
}


style

Anything related to how we style our app will go in the style package. This includes all of our ANSI escape codes and coloring logic.

In the Colorize method, we could also use string concatenation with the += operator, but strings.Builder is typically more performant in speed and memory for concatenating 3 or more strings together. This is a micro-optimization here and hardly matters in this use case, but I should note every string concatenated beyond the third gets exponentially more expensive using the += concatenation method.

// style.go
package style

import "strings"

const MaxCharWidth = 16
const Reset = "\033[0m"

type Color int

const (
	Cyan Color = iota + 1
	Green
	Red
	Dim
)

var ColorAnsiCodes = map[Color]string{
	Cyan:  "\033[36m",
	Green: "\033[32m",
	Red:   "\033[31m",
	Dim:   "\033[2m",
}

func (c Color) Ansi() string {
	return ColorAnsiCodes[c]
}

func (c Color) Colorize(s string) string {
	b := strings.Builder{}
	b.WriteString(c.Ansi())
	b.WriteString(s)
	b.WriteString(Reset)

	return b.String()
}


tui.go

The tui package represents the components of our Text User Interface (TUI). It's responsible for rendering the components by printing strings and a series of ANSI escape codes to handle styling and cursor positioning to the stdout.

Wrapping the text input logic in a while loop allows us to offer a better UX since the user can rapidly check for more usernames after each series of results is returned.

We offer an escape hatch to our users if they enter q in the input field, which breaks out of the while loop and allows the Run function to complete execution.

// tui.go
package tui

import (
	"bufio"
	"dont_at_me/pkg/components"
	"dont_at_me/pkg/style"
	"dont_at_me/pkg/username"
	"fmt"
	"os"
	"time"
)

// Run renders the TUI, accepts user input, and kicks off the check to see if the given username is available on various social media platforms.
func Run() {
    // Render the TUI header and prompt.
    // The header and prompt will have a left margin of two spaces, while the results list will have a left margin of four spaces.
	fmt.Printf("%s\n%s\n", style.Cyan.Colorize(components.Header), components.Prompt)

    // Wrap this in a while loop so user can continue to search for usernames after the first entry.
    // This is a much better experience than letting the user pass in the username as an argument to the program.
	for {
        // Render the TUI text input. We put it inside the while loop so the user can keep searching after the initial results.
		fmt.Printf("\n%s", components.TextInput)

        // Pause the program until the user has entered in their desired username and presses the enter key.
		userInput := getUserInput()
        fmt.Print(cursor.Hide)
		fmt.Print(cursor.Up)  
		fmt.Print(cursor.ClearLine)  
		fmt.Print("  @ ")     
		fmt.Print(cursor.Down)  
		fmt.Printf(cursor.ClearLine)

        // If user quits, break out of while loop and let the program exit gracefully.
		if userInput == "q" {
            fmt.Printf(cursor.ClearAfter) // Clear screen after cursor so exit code is shown properly.
			break
		}		

		fmt.Printf("Results for \"%s\"\n\n", userInput)

        // We're going to benchmark the difference between fetching each platform serially vs concurrently.
        // This is done to demonstrate how much more efficient concurrency is.
        t := time.Now()

        // Swap this out for `CheckAvailabilitySerial` to see the difference in round-trip time.
		username.CheckAvailabilityConcurrent(userInput)

		fmt.Printf("\n    completed search for \"%s\" in %v\n\n", userInput, time.Since(t))
		fmt.Print(strings.Repeat(cursor.Up, 15)) // Move cursor back up to the text input component.
        fmt.Print(cursor.Show)
	}
}

func getUserInput() string {
	scanner := bufio.NewScanner(os.Stdin)
	scanner.Scan()
	return scanner.Text()
}


username

This package handles the logic for checking the given username's availability, both in serial and concurrently for demonstration. Since no http calls depend on each other, it makes sense to do these concurrently and return the results as they're received.

For reference, you can try both since we're benchmarking the time. As we add more platforms, the use of concurrency gets exponentially stronger.

// username.go
package username

import (
	"dont_at_me/pkg/social"
	"dont_at_me/pkg/style"
	"fmt"
	"strings"
	"sync"
)

// CheckAvailabilitySerial loops through each platform, in order, and returns the current platform's availability.
func CheckAvailabilitySerial(username string) error {
	for i := 0; i < len(social.Platforms); i++ {
		status := social.Platforms[i].GetAvailability(username)
		spacer := style.MaxCharWidth - len(social.PlatformStrings[social.Platforms[i]])
		fmt.Printf("    %s%s%s\n", social.Platforms[i].String(), strings.Repeat(" ", spacer), status.String())
	}

	return nil
}

// CheckAvailabilitySerial spins up a new goroutine for each platform and returns in the order in which it's determined the username's availability.
func CheckAvailabilityConcurrent(username string) {
	wg := sync.WaitGroup{}
	results := make(chan string)

	for i := 0; i < len(social.Platforms); i++ {
		wg.Add(1)
		go func(platform social.Platform) {
			defer wg.Done()
			status := platform.GetAvailability(username)
			results <- fmt.Sprintf("%s%s%s\n", platform.String(), strings.Repeat(" ", style.MaxCharWidth-len(social.PlatformStrings[platform])), status.String())
		}(social.Platforms[i])
	}

	go func() {
		wg.Wait()
		close(results)
	}()

	for result := range results {
		fmt.Printf("    %s", result)
	}

	return
}

Running the App

cd dont_at_me
go run cmd/main.go

Building the Binary

cd dont_at_me/cmd/
go build -o dont-at-me

Conclusion

While there are many awesome third-party libraries for building TUI apps, we've demonstrated that the Go standard library provides all the essential tools we need. By leveraging packages already available to us, we can achieve our goals without the need for external dependencies. Of course, there are strong use cases to use third-party libraries, but it's important to evaluate our options and understand the tradeoffs. Overall, the key is to understand how far the standard library can take us before introducing any libraries that may add more complexity and bloat to our apps.