improve cli code

This commit is contained in:
Zoe
2025-04-13 00:53:23 -05:00
parent d501775ae6
commit 79322c4c5e
13 changed files with 265 additions and 193 deletions

View File

@@ -1,92 +1,121 @@
package commands
import (
"bytes"
"flag"
"fmt"
"io"
"net/http"
"os"
"strings"
"github.com/briandowns/spinner"
"github.com/juls0730/flux/cmd/flux/models"
"github.com/juls0730/flux/pkg"
)
func DeleteCommand(seekingHelp bool, config models.Config, info pkg.Info, loadingSpinner *spinner.Spinner, spinnerWriter *models.CustomSpinnerWriter, args []string) error {
if seekingHelp {
fmt.Println(`Usage:
flux delete [project-name | all]
var usage = `Usage:
flux delete [project-name | all]
Options:
project-name: The name of the project to delete
all: Delete all projects
Flux will delete the deployment of the app in the current directory or the specified project.`)
return nil
}
Options:
project-name: The name of the project to delete
all: Delete all projects
if len(args) == 1 {
if args[0] == "all" {
var response string
fmt.Print("Are you sure you want to delete all projects? this will delete all volumes and containers associated and cannot be undone. \n[y/N] ")
fmt.Scanln(&response)
Flags:
%s
if strings.ToLower(response) != "y" {
fmt.Println("Aborting...")
return nil
}
Flux will delete the deployment of the app in the current directory or the specified project.
`
response = ""
func deleteAll(ctx models.CommandCtx, noConfirm *bool) error {
if !*noConfirm {
var response string
fmt.Print("Are you sure you want to delete all projects? this will delete all volumes and containers associated and cannot be undone. [y/N] ")
fmt.Scanln(&response)
fmt.Printf("Are you really sure you want to delete all projects? \n[y/N] ")
fmt.Scanln(&response)
if strings.ToLower(response) != "y" {
fmt.Println("Aborting...")
return nil
}
if strings.ToLower(response) != "y" {
fmt.Println("Aborting...")
return nil
}
response = ""
req, err := http.NewRequest("DELETE", config.DeamonURL+"/deployments", nil)
if err != nil {
return fmt.Errorf("failed to delete deployments: %v", err)
}
resp, err := http.DefaultClient.Do(req)
if err != nil {
return fmt.Errorf("failed to delete deployments: %v", err)
}
defer resp.Body.Close()
// since we are deleting **all** projects, I feel better asking for confirmation twice
fmt.Printf("Are you really sure you want to delete all projects? [y/N] ")
fmt.Scanln(&response)
if resp.StatusCode != http.StatusOK {
responseBody, err := io.ReadAll(resp.Body)
if err != nil {
return fmt.Errorf("error reading response body: %v", err)
}
responseBody = []byte(strings.TrimSuffix(string(responseBody), "\n"))
return fmt.Errorf("delete failed: %s", responseBody)
}
fmt.Printf("Successfully deleted all projects\n")
if strings.ToLower(response) != "y" {
fmt.Println("Aborting...")
return nil
}
}
req, err := http.NewRequest("DELETE", ctx.Config.DeamonURL+"/deployments", nil)
if err != nil {
return fmt.Errorf("failed to delete deployments: %v", err)
}
resp, err := http.DefaultClient.Do(req)
if err != nil {
return fmt.Errorf("failed to delete deployments: %v", err)
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
responseBody, err := io.ReadAll(resp.Body)
if err != nil {
return fmt.Errorf("error reading response body: %v", err)
}
responseBody = []byte(strings.TrimSuffix(string(responseBody), "\n"))
return fmt.Errorf("delete failed: %s", responseBody)
}
fmt.Printf("Successfully deleted all projects\n")
return nil
}
func DeleteCommand(ctx models.CommandCtx, args []string) error {
fs := flag.NewFlagSet("delete", flag.ExitOnError)
fs.Usage = func() {
var buf bytes.Buffer
// Redirect flagset to print to buffer instead of stdout
fs.SetOutput(&buf)
fs.PrintDefaults()
fmt.Printf(usage, strings.TrimRight(buf.String(), "\n"))
}
noConfirm := fs.Bool("no-confirm", false, "Skip confirmation prompt")
err := fs.Parse(args)
if err != nil {
fmt.Println(err)
os.Exit(1)
}
args = fs.Args()
if len(args) == 1 && args[0] == "all" {
return deleteAll(ctx, noConfirm)
}
projectName, err := GetProjectName("delete", args)
if err != nil {
return err
return fmt.Errorf("\tfailed to get project name: %v.\n\tSee flux delete --help for more information", err)
}
// ask for confirmation
fmt.Printf("Are you sure you want to delete %s? this will delete all volumes and containers associated with the deployment, and cannot be undone. \n[y/N] ", projectName)
var response string
fmt.Scanln(&response)
if !*noConfirm {
fmt.Printf("Are you sure you want to delete %s? this will delete all volumes and containers associated with the deployment, and cannot be undone. \n[y/N] ", projectName)
var response string
fmt.Scanln(&response)
if strings.ToLower(response) != "y" {
fmt.Println("Aborting...")
return nil
if strings.ToLower(response) != "y" {
fmt.Println("Aborting...")
return nil
}
}
req, err := http.NewRequest("DELETE", config.DeamonURL+"/deployments/"+projectName, nil)
req, err := http.NewRequest("DELETE", ctx.Config.DeamonURL+"/deployments/"+projectName, nil)
if err != nil {
return fmt.Errorf("failed to delete app: %v", err)
}

View File

@@ -1,4 +1,3 @@
package commands
import (
@@ -12,9 +11,11 @@ import (
"mime/multipart"
"net/http"
"os"
"os/signal"
"path/filepath"
"regexp"
"strings"
"time"
"github.com/briandowns/spinner"
"github.com/juls0730/flux/cmd/flux/models"
@@ -152,23 +153,35 @@ func compressDirectory(compression pkg.Compression) ([]byte, error) {
return buf.Bytes(), nil
}
func DeployCommand(seekingHelp bool, config models.Config, info pkg.Info, loadingSpinner *spinner.Spinner, spinnerWriter *models.CustomSpinnerWriter, args []string) error {
if seekingHelp {
fmt.Println(`Usage:
flux deploy
Flux will deploy the app in the current directory, and start routing traffic to it.`)
return nil
}
func DeployCommand(ctx models.CommandCtx, args []string) error {
if _, err := os.Stat("flux.json"); err != nil {
return fmt.Errorf("no flux.json found, please run flux init first")
}
spinnerWriter := models.NewCustomSpinnerWriter()
loadingSpinner := spinner.New(spinner.CharSets[14], 100*time.Millisecond, spinner.WithWriter(spinnerWriter))
defer func() {
if loadingSpinner.Active() {
loadingSpinner.Stop()
}
}()
signalChannel := make(chan os.Signal, 1)
signal.Notify(signalChannel, os.Interrupt)
go func() {
<-signalChannel
if loadingSpinner.Active() {
loadingSpinner.Stop()
}
os.Exit(0)
}()
loadingSpinner.Suffix = " Deploying"
loadingSpinner.Start()
buf, err := compressDirectory(info.Compression)
buf, err := compressDirectory(ctx.Info.Compression)
if err != nil {
return fmt.Errorf("failed to compress directory: %v", err)
}
@@ -204,7 +217,7 @@ func DeployCommand(seekingHelp bool, config models.Config, info pkg.Info, loadin
return fmt.Errorf("failed to close writer: %v", err)
}
req, err := http.NewRequest("POST", config.DeamonURL+"/deploy", body)
req, err := http.NewRequest("POST", ctx.Config.DeamonURL+"/deploy", body)
req.Header.Set("Content-Type", writer.FormDataContentType())
if err != nil {

View File

@@ -7,22 +7,21 @@ import (
"strconv"
"strings"
"github.com/briandowns/spinner"
"github.com/juls0730/flux/cmd/flux/models"
"github.com/juls0730/flux/pkg"
)
func InitCommand(seekingHelp bool, config models.Config, info pkg.Info, loadingSpinner *spinner.Spinner, spinnerWriter *models.CustomSpinnerWriter, args []string) error {
if seekingHelp {
fmt.Println(`Usage:
flux init [project-name]
Options:
project-name: The name of the project to initialize
Flux will initialize a new project in the current directory or the specified project.`)
return nil
}
func InitCommand(ctx models.CommandCtx, args []string) error {
// if seekingHelp {
// fmt.Println(`Usage:
// flux init [project-name]
// Options:
// project-name: The name of the project to initialize
// Flux will initialize a new project in the current directory or the specified project.`)
// return nil
// }
var projectConfig pkg.ProjectConfig

View File

@@ -7,21 +7,12 @@ import (
"net/http"
"strings"
"github.com/briandowns/spinner"
"github.com/juls0730/flux/cmd/flux/models"
"github.com/juls0730/flux/pkg"
)
func ListCommand(seekingHelp bool, config models.Config, info pkg.Info, loadingSpinner *spinner.Spinner, spinnerWriter *models.CustomSpinnerWriter, args []string) error {
if seekingHelp {
fmt.Println(`Usage:
flux list
Flux will list all the apps in the daemon.`)
return nil
}
resp, err := http.Get(config.DeamonURL + "/apps")
func ListCommand(ctx models.CommandCtx, args []string) error {
resp, err := http.Get(ctx.Config.DeamonURL + "/apps")
if err != nil {
return fmt.Errorf("failed to get apps: %v", err)
}

View File

@@ -13,7 +13,7 @@ func GetProjectName(command string, args []string) (string, error) {
if len(args) == 0 {
if _, err := os.Stat("flux.json"); err != nil {
return "", fmt.Errorf("usage: flux %[1]s <app name>, or run flux %[1]s in the project directory", command)
return "", fmt.Errorf("the current directory is not a flux project, please run flux %[1]s in the project directory", command)
}
fluxConfigFile, err := os.Open("flux.json")

View File

@@ -6,26 +6,16 @@ import (
"net/http"
"strings"
"github.com/briandowns/spinner"
"github.com/juls0730/flux/cmd/flux/models"
"github.com/juls0730/flux/pkg"
)
func StartCommand(seekingHelp bool, config models.Config, info pkg.Info, loadingSpinner *spinner.Spinner, spinnerWriter *models.CustomSpinnerWriter, args []string) error {
if seekingHelp {
fmt.Println(`Usage:
flux start
Flux will start the deployment of the app in the current directory.`)
return nil
}
func StartCommand(ctx models.CommandCtx, args []string) error {
projectName, err := GetProjectName("start", args)
if err != nil {
return err
}
req, err := http.Post(config.DeamonURL+"/start/"+projectName, "application/json", nil)
req, err := http.Post(ctx.Config.DeamonURL+"/start/"+projectName, "application/json", nil)
if err != nil {
return fmt.Errorf("failed to start app: %v", err)
}

View File

@@ -6,26 +6,16 @@ import (
"net/http"
"strings"
"github.com/briandowns/spinner"
"github.com/juls0730/flux/cmd/flux/models"
"github.com/juls0730/flux/pkg"
)
func StopCommand(seekingHelp bool, config models.Config, info pkg.Info, loadingSpinner *spinner.Spinner, spinnerWriter *models.CustomSpinnerWriter, args []string) error {
if seekingHelp {
fmt.Println(`Usage:
flux stop
Flux will stop the deployment of the app in the current directory.`)
return nil
}
func StopCommand(ctx models.CommandCtx, args []string) error {
projectName, err := GetProjectName("stop", args)
if err != nil {
return err
}
req, err := http.Post(config.DeamonURL+"/stop/"+projectName, "application/json", nil)
req, err := http.Post(ctx.Config.DeamonURL+"/stop/"+projectName, "application/json", nil)
if err != nil {
return fmt.Errorf("failed to stop app: %v", err)
}

View File

@@ -3,16 +3,14 @@ package main
import (
_ "embed"
"encoding/json"
"flag"
"fmt"
"net/http"
"os"
"os/signal"
"path/filepath"
"strings"
"time"
"github.com/agnivade/levenshtein"
"github.com/briandowns/spinner"
"github.com/juls0730/flux/cmd/flux/commands"
"github.com/juls0730/flux/cmd/flux/models"
"github.com/juls0730/flux/pkg"
@@ -29,62 +27,93 @@ var helpStr = `Usage:
flux <command>
Available Commands:
init Initialize a new project
deploy Deploy a new version of the app
stop Stop a container
start Start a container
delete Delete a container
list List all containers
%s
Flags:
-h, --help help for flux
Available Flags:
--help, -h: Show this help message
Use "flux <command> --help" for more information about a command.`
Use "flux <command> --help" for more information about a command.
`
var maxDistance = 3
type CommandFunc func(models.CommandCtx, []string) error
type Command struct {
Help string
HandlerFunc CommandFunc
}
type CommandHandler struct {
commands map[string]func(bool, models.Config, pkg.Info, *spinner.Spinner, *models.CustomSpinnerWriter, []string) error
commands map[string]Command
aliases map[string]string
}
func (h *CommandHandler) RegisterCmd(name string, handler func(bool, models.Config, pkg.Info, *spinner.Spinner, *models.CustomSpinnerWriter, []string) error) {
h.commands[name] = handler
func NewCommandHandler() CommandHandler {
return CommandHandler{
commands: make(map[string]Command),
aliases: make(map[string]string),
}
}
func runCommand(command string, args []string, config models.Config, info pkg.Info, cmdHandler CommandHandler, try int) error {
if try == 2 {
return fmt.Errorf("unknown command: %s", command)
func (h *CommandHandler) RegisterCmd(name string, handler CommandFunc, help string) {
coomand := Command{
Help: help,
HandlerFunc: handler,
}
seekingHelp := false
if len(args) > 0 && (args[len(args)-1] == "--help" || args[len(args)-1] == "-h") {
seekingHelp = true
args = args[:len(args)-1]
h.commands[name] = coomand
}
func (h *CommandHandler) RegisterAlias(alias string, command string) {
h.aliases[alias] = command
}
// returns the command and whether or not it exists
func (h *CommandHandler) GetCommand(command string) (Command, bool) {
if command, ok := h.aliases[command]; ok {
return h.commands[command], true
}
spinnerWriter := models.NewCustomSpinnerWriter()
commandStruct, ok := h.commands[command]
return commandStruct, ok
}
loadingSpinner := spinner.New(spinner.CharSets[14], 100*time.Millisecond, spinner.WithWriter(spinnerWriter))
defer func() {
if loadingSpinner.Active() {
loadingSpinner.Stop()
}
}()
var helpPadding = 13
signalChannel := make(chan os.Signal, 1)
signal.Notify(signalChannel, os.Interrupt)
go func() {
<-signalChannel
if loadingSpinner.Active() {
loadingSpinner.Stop()
func (h *CommandHandler) GetHelp() {
commandsStr := ""
for command := range h.commands {
curLine := ""
curLine += command
for alias, aliasCommand := range h.aliases {
if aliasCommand == command {
curLine += fmt.Sprintf(", %s", alias)
}
}
os.Exit(0)
}()
curLine += strings.Repeat(" ", helpPadding-(len(curLine)-2))
commandsStr += fmt.Sprintf(" %s %s\n", curLine, h.commands[command].Help)
}
handler, ok := cmdHandler.commands[command]
fmt.Printf(helpStr, strings.TrimRight(commandsStr, "\n"))
}
func (h *CommandHandler) GetHelpCmd(models.CommandCtx, []string) error {
h.GetHelp()
return nil
}
func runCommand(command string, args []string, config models.Config, info pkg.Info, cmdHandler CommandHandler) error {
commandCtx := models.CommandCtx{
Config: config,
Info: info,
}
commandStruct, ok := cmdHandler.commands[command]
if ok {
return handler(seekingHelp, config, info, loadingSpinner, spinnerWriter, args)
return commandStruct.HandlerFunc(commandCtx, args)
}
// diff the command against the list of commands and if we find a command that is more than 80% similar, ask if that's what the user meant
@@ -108,7 +137,8 @@ func runCommand(command string, args []string, config models.Config, info pkg.In
}
var response string
fmt.Printf("No command found with the name '%s'. Did you mean '%s'?\n", command, closestMatch.name)
// new line ommitted because it will be produced when the user presses enter to submit their response
fmt.Printf("No command found with the name '%s'. Did you mean '%s'? (y/N)", command, closestMatch.name)
fmt.Scanln(&response)
if strings.ToLower(response) == "y" || strings.ToLower(response) == "yes" {
@@ -117,18 +147,34 @@ func runCommand(command string, args []string, config models.Config, info pkg.In
return nil
}
return runCommand(command, args, config, info, cmdHandler, try+1)
// re-run command after accepting the suggestion
return runCommand(command, args, config, info, cmdHandler)
}
func main() {
if len(os.Args) < 2 {
fmt.Println(helpStr)
cmdHandler := NewCommandHandler()
cmdHandler.RegisterCmd("init", commands.InitCommand, "Initialize a new project")
cmdHandler.RegisterCmd("deploy", commands.DeployCommand, "Deploy a new version of the app")
cmdHandler.RegisterCmd("start", commands.StartCommand, "Start the app")
cmdHandler.RegisterCmd("stop", commands.StopCommand, "Stop the app")
cmdHandler.RegisterCmd("list", commands.ListCommand, "List all the apps")
cmdHandler.RegisterCmd("delete", commands.DeleteCommand, "Delete the app")
fs := flag.NewFlagSet("flux", flag.ExitOnError)
fs.Usage = func() {
cmdHandler.GetHelp()
}
err := fs.Parse(os.Args[1:])
if err != nil {
fmt.Println(err)
os.Exit(1)
}
if os.Args[1] == "--help" || os.Args[1] == "-h" {
fmt.Println(helpStr)
os.Exit(0)
if len(os.Args) < 2 {
cmdHandler.GetHelp()
os.Exit(1)
}
if _, err := os.Stat(filepath.Join(configPath, "config.json")); err != nil {
@@ -155,9 +201,6 @@ func main() {
os.Exit(1)
}
command := os.Args[1]
args := os.Args[2:]
resp, err := http.Get(config.DeamonURL + "/heartbeat")
if err != nil {
fmt.Println("Failed to connect to daemon")
@@ -186,19 +229,9 @@ func main() {
os.Exit(1)
}
cmdHandler := CommandHandler{
commands: make(map[string]func(bool, models.Config, pkg.Info, *spinner.Spinner, *models.CustomSpinnerWriter, []string) error),
}
cmdHandler.RegisterCmd("deploy", commands.DeployCommand)
cmdHandler.RegisterCmd("stop", commands.StopCommand)
cmdHandler.RegisterCmd("start", commands.StartCommand)
cmdHandler.RegisterCmd("delete", commands.DeleteCommand)
cmdHandler.RegisterCmd("init", commands.InitCommand)
err = runCommand(command, args, config, info, cmdHandler, 0)
err = runCommand(os.Args[1], fs.Args()[1:], config, info, cmdHandler)
if err != nil {
fmt.Printf("%v\n", err)
fmt.Printf("Error: %v\n", err)
os.Exit(1)
}
}

8
cmd/flux/models/cmd.go Normal file
View File

@@ -0,0 +1,8 @@
package models
import "github.com/juls0730/flux/pkg"
type CommandCtx struct {
Config Config
Info pkg.Info
}

View File

@@ -44,11 +44,14 @@ func NewCustomStdout(spinner *CustomSpinnerWriter) *CustomStdout {
}
}
// We have this custom writer because we want to have a spinner at the bottom of the terminal, but we dont want to have
// it interfere with the output of the command
func (w *CustomStdout) Write(p []byte) (n int, err error) {
w.lock.Lock()
defer w.lock.Unlock()
n, err = os.Stdout.Write([]byte(fmt.Sprintf("\033[2K\r%s", p)))
// clear line and carriage return
n, err = os.Stdout.Write(fmt.Appendf(nil, "\033[2K\r%s", p))
if err != nil {
return n, err
}