#StackBounty: #go CLI Utility Go – First Go Program

Bounty: 50

TLDR

Python developer; first project in Go; looking for feedback 🙂

Repo

Overview

I just started learning Go (need to use it at work).
As a fan of project-based learning, I put together a small program to practice writing Go and setting up a project from scratch.

As a Python developer, many of Go’s elements are new to me (structs, interfaces, etc) – not to mention static typing and compilation.

I was hoping someone could review the code & project organization and share some constructive feedback. Thanks in advance!

What I am interested in:

  • Improvements to overall program structure
  • Improvements to non-idiomatic uses of the language
  • Improvements to usage of types
  • Error handling?
  • Any other feedback you might have!

Not interested in:

  • Replacing code with libraries – I know there some libraries that help with CLI interface, args parsing etc. but I wanted to build this the hard way since this is primarily a learning exercise 🙂

The Program

A simple CLI tool to help you manage and jump around your local projects.

Usage

Create Projects

$ pm add newproject ~/code/repos/newproject
$ pm add another ~/code/go/src/github.com/username/another

Remove project

$ pm remove newproject

pm add and pm remove is used to implicitly manage a json file with those configs. This config file is used by other commands to track “projects”.

~/.pm.json

{
 "projects": [
  {
   "path": "~/code/repos/newproject",
   "name": "newproject"
  },
  {
   "path": "~/code/go/src/github.com/username/another",
   "name": "another"
  }
 ]
}

Go to project (finds project in config and starts new shell at config location

$ pm go newproject
Starting Shell...
~/code/repos/newproject $

$ pm go another
Starting Shell...
~/code/go/src/github.com/username/another $

List project (shows list of projects)

$ pm list
newproject
another

Code

The full program is on this repo but I copied all relevant files below.

Package Structure

pm/
 + main.go
 + cmd/     
   + main.go 
 + internal/
   + commands/
   + cli/
   + config/

Entry Point

// main.go
package main

import (
    "github.com/gtalarico/pm/cmd"
)

func main() {
    cmd.Run()
}

Cmd Entry Point

// cmd/main.go
package cmd

import (
    "os"

    "github.com/gtalarico/pm/internal/cli"
    "github.com/gtalarico/pm/internal/commands"
    "github.com/gtalarico/pm/internal/config"
)

func Run() {

    // Get all args, excluding binary name
    var args []string = os.Args[1:]
    cmdName, posArgs, err := cli.ValidateArgs(args)

    // No Args
    if err != nil {
        cli.ShowUsage()
        os.Exit(1)
    }

    // Checks for invalid command name
    // and number of args for a given command
    cmd, err := commands.GetCommand(cmdName)
    if err != nil {
        cli.Abort(err)
    }
    if cmd.NumArgs != len(posArgs) {
        cli.ShowCmdUsage(cmd.UsageMsg)
    }

    // Get Config
    config, err := config.ReadConfig()
    if err != nil {
        cli.Abort(err)
    }

    // Run Command
    cmd.Run(posArgs, config)

}

Commands Module

Types, command functions and definitions

Command Type

// internal/commands/types.go
package commands

import "github.com/gtalarico/pm/internal/config"

type Command struct {
    Name     string
    NumArgs  int
    UsageMsg string
    Run      func([]string, config.Config)
}

Commands Main

// internal/commands/commands.go
package commands

import (
    "fmt"
    "os"
    "path/filepath"

    "github.com/gtalarico/pm/internal/cli"
    "github.com/gtalarico/pm/internal/config"
    "github.com/pkg/errors"
)

func GetCommand(cmdName string) (command Command, err error) {
    for _, cmd := range Commands {
        if cmd.Name == cmdName {
            command = cmd
            return
        }
    }
    err = errors.New("invalid command")
    return
}

func CommandList(args []string, config config.Config) {
    cli.PrintProjects(config.Projects)
}

func CommandGo(args []string, cfg config.Config) {
    projectName := args[0]
    project, err := config.GetOneProject(projectName, cfg)
    if err != nil {
        cli.Abort(errors.Wrap(err, projectName))
    } else {
        cli.Shell(project.Path)
    }
}

func CommandAdd(args []string, cfg config.Config) {
    projectName := args[0]
    projectPath := args[1]
    absPath, err := filepath.Abs(projectPath)
    if err != nil {
        cli.Abort(errors.Wrap(err, "invalid path"))
    }
    newProject := config.Project{
        Name: projectName,
        Path: absPath,
    }
    projects := config.SearchProjects(projectName, cfg)
    if len(projects) == 0 {
        cfg.Projects = append(cfg.Projects, newProject)
    } else {
        for i, project := range cfg.Projects {
            if project.Name == newProject.Name {
                cfg.Projects[i] = newProject
            }
        }
    }
    writeError := config.WriteConfig(cfg)
    if writeError != nil {
        cli.Abort(writeError)
    }
    cli.PrintProjects(cfg.Projects)
}

func CommandRemove(args []string, cfg config.Config) {
    var projectToKeep []config.Project
    projectName := args[0]
    matchedProject, err := config.GetOneProject(projectName, cfg)
    if err != nil {
        cli.Abort(errors.Wrap(err, projectName))
    } else {
        for _, project := range cfg.Projects {
            if project.Name != matchedProject.Name {
                projectToKeep = append(projectToKeep, project)
            }
        }
        cfg.Projects = projectToKeep

        promptMsg := fmt.Sprintf("Delete '%s' [Y/n]? ", matchedProject.Name)
        confirm := cli.ConfirmPrompt(promptMsg, true)
        if confirm == false {
            os.Exit(0)
        }

        writeError := config.WriteConfig(cfg)
        if writeError != nil {
            cli.Abort(writeError)
        }
        cli.PrintProjects(projectToKeep)
    }
}

var Commands = [...]Command{
    Command{
        Name:     "list",
        NumArgs:  0, // pm list
        UsageMsg: "list",
        Run:      CommandList,
    },
    Command{
        Name:     "add",
        NumArgs:  2, // pm add <name> <path>
        UsageMsg: "add <project-name> <path>",
        Run:      CommandAdd,
    },
    Command{
        Name:     "remove",
        NumArgs:  1, // pm remove <query>
        UsageMsg: "remove <project-name>",
        Run:      CommandRemove,
    },
    Command{
        Name:     "go",
        NumArgs:  1, // pm go <project-name>
        UsageMsg: "go <project-name>",
        Run:      CommandGo,
    },
}

Cli Module

Usage

// internal/commands/usage.go
package cli

import (
    "fmt"
    "os"
)

// Shows usage of all available commands and exits
func ShowUsage() {
    usage :=
        `Usage:
    pm list
    pm go <project-name>
    pm add <project-name> <project-path>
    pm remove <project-name>`
    fmt.Fprintln(os.Stderr, usage)
}

// Shows usage of a command and exits
func ShowCmdUsage(usageMsg string) {
    fmt.Fprint(os.Stderr, fmt.Sprintf("Usage: pm %s", usageMsg))
    os.Exit(1)
}

Args

// internal/commands/args.go
package cli

import "github.com/pkg/errors"

func ValidateArgs(args []string) (cmdName string, posArgs []string, err error) {

    if len(args) == 0 {
        err = errors.New("missing command name")
        return
    }

    if len(args) > 1 {
        posArgs = args[1:]
    }
    cmdName = args[0]
    return

}

Output

// internal/cli/output.go
// Poorly named file...
package cli

import (
    "fmt"
    "log"
    "os"
    "strings"

    "github.com/gtalarico/pm/internal/config"
)

func Abort(err error) {
    // Show error message and exit with error
    fmt.Fprint(os.Stderr, err.Error())
    os.Exit(1)
}

// Prompts user to confirm a "y" or "n" and returns as boolean
func ConfirmPrompt(promptMsg string, default_ bool) bool {
    fmt.Print(promptMsg)

    var response string
    _, err := fmt.Scanln(&response)
    if err != nil {
        log.Fatal(err)
    }

    if response == "" {
        return default_
    }

    r := (strings.ToLower(response) == "y")
    return r
}

func PrintProjects(projects []config.Project) {
    for _, project := range projects {
        fmt.Println(project.Name)
    }
}

Shell
Handles New Shell Process Spawning

// internal/cli/shell.go
package cli

import (
    "fmt"
    "os"
    "os/user"

    "github.com/pkg/errors"
)

func handleShellError() {
    shellError := recover()
    if shellError != nil {
        err := errors.New("shell error")
        Abort(err)
    }
}

func Shell(cwd string) {
    //technosophos.com/2014/07/11/start-an-interactive-shell-from-within-go.html
    defer handleShellError()

    fmt.Println("Starting new shell")
    fmt.Println("Use 'CTRL + C' or '$ exit' to terminate child shell")

    // Get the current user.
    me, err := user.Current()
    if err != nil {
        panic(err)
    }

    // If needed: sets env vars
    // os.Setenv("SOME_VAR", "1")

    // Transfer stdin, stdout, and stderr to the new process
    // and also set target directory for the shell to start in.
    pa := os.ProcAttr{
        Files: []*os.File{os.Stdin, os.Stdout, os.Stderr},
        Dir:   cwd,
    }

    // Start up a new shell.
    // Note that we supply "login" twice.
    // -fpl means "don't prompt for PW and pass through environment."
    // fmt.Print(">> Starting a new interactive shell")
    proc, err := os.StartProcess("/usr/bin/login", []string{"login", "-fpl", me.Username}, &pa)
    if err != nil {
        panic(err)
    }

    // Wait until user exits the shell
    state, err := proc.Wait()
    if err != nil {
        panic(err)
    }

    fmt.Printf("Exited Go Sub Shelln %sn", state.String())
}

Config Module

Handles reading and writing of config file, including types for json encoding/decoding.

Types

// internal/config/types.go
package config

type Config struct {
    Projects []Project `json:"projects"`
}

type Project struct {
    Path string `json:"path"`
    Name string `json:"name"`
}

Config – Main config functions

// internal/config/config.go
package config

import (
    "encoding/json"
    "fmt"
    "io/ioutil"
    "os"

    "github.com/pkg/errors"
)

const CFG_FILENAME = ".pm.json"

func WriteConfig(config Config) (err error) {
    path := ConfigFilepath()
    configJson, _ := json.MarshalIndent(config, "", " ")
    writeErr := ioutil.WriteFile(path, configJson, 0644)
    err = errors.Wrap(writeErr, path)
    return
}

func CreateConfig(path string) (cfg Config, err error) {
    projects := []Project{}
    cfg = Config{projects}
    err = WriteConfig(cfg)
    return
}

func ReadConfig() (cfg Config, err error) {
    path := ConfigFilepath()
    configBytes, readErr := ioutil.ReadFile(path)
    if readErr != nil {
        // Try creating new file in case of read error (eg. file not found)
        cfg, err = CreateConfig(path)
        return
    }
    parsingError := json.Unmarshal(configBytes, &cfg)
    if parsingError != nil {
        err = errors.Wrap(parsingError, path)
        return
    }
    return
}

// Gets user home path using environment variable '$HOME'
func UserHomePath() (path string) {
    path = os.Getenv("HOME")
    if path == "" {
        err, _ := fmt.Printf("Could not get home directory")
        panic(err)
    }
    return
}

// Gets the full config filepath
func ConfigFilepath() string {
    return UserHomePath() + fmt.Sprintf("/%s", CFG_FILENAME)
}

Search

// internal/config/search.go
package config

import (
    "errors"
    "fmt"
    "strings"
)

func SearchProjects(query string, config Config) []Project {
    var projects []Project
    for _, project := range config.Projects {
        if strings.Contains(project.Name, query) {
            projects = append(projects, project)
        }
    }
    return projects
}

func GetOneProject(query string, config Config) (project Project, err error) {
    projects := SearchProjects(query, config)
    numProjects := len(projects)

    if numProjects == 1 {
        project = projects[0]
    }
    if numProjects == 0 {
        err = errors.New("no match")
    }
    if numProjects > 1 {
        for _, project := range projects {
            fmt.Println(project.Name)
        }
        err = errors.New("multiple matches")
    }
    return
}


Get this bounty!!!

Leave a Reply

This site uses Akismet to reduce spam. Learn how your comment data is processed.