Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions .gitattributes
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
# Check text files out with LF everywhere, including Windows, so golden
# files compare byte for byte with the CLI's output.
* text=auto eol=lf
9 changes: 8 additions & 1 deletion cmd/devlyft/main.go
Original file line number Diff line number Diff line change
Expand Up @@ -7,11 +7,18 @@ import (
"os/signal"

"github.com/devlyft/cli/internal/cli"
"github.com/devlyft/cli/internal/iostreams"
)

func main() {
ctx, stop := signal.NotifyContext(context.Background(), os.Interrupt)
code := cli.Execute(ctx, os.Args[1:], os.Stdout, os.Stderr)
go func() {
// After the first Ctrl-C, restore the default handler so a second
// one terminates immediately.
<-ctx.Done()
stop()
}()
code := cli.Execute(ctx, iostreams.System(), os.Args[1:])
stop()
os.Exit(code)
}
12 changes: 10 additions & 2 deletions go.mod
Original file line number Diff line number Diff line change
@@ -1,10 +1,18 @@
module github.com/devlyft/cli

go 1.26
go 1.26.0

require github.com/spf13/cobra v1.10.2
require (
github.com/itchyny/gojq v0.12.19
github.com/spf13/cobra v1.10.2
golang.org/x/term v0.47.0
sigs.k8s.io/yaml v1.6.0
)

require (
github.com/inconshreveable/mousetrap v1.1.0 // indirect
github.com/itchyny/timefmt-go v0.1.8 // indirect
github.com/spf13/pflag v1.0.9 // indirect
go.yaml.in/yaml/v2 v2.4.2 // indirect
golang.org/x/sys v0.49.0 // indirect
)
16 changes: 16 additions & 0 deletions go.sum
Original file line number Diff line number Diff line change
@@ -1,10 +1,26 @@
github.com/cpuguy83/go-md2man/v2 v2.0.6/go.mod h1:oOW0eioCTA6cOiMLiUPZOpcVxMig6NIQQ7OS05n1F4g=
github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8=
github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU=
github.com/inconshreveable/mousetrap v1.1.0 h1:wN+x4NVGpMsO7ErUn/mUI3vEoE6Jt13X2s0bqwp9tc8=
github.com/inconshreveable/mousetrap v1.1.0/go.mod h1:vpF70FUmC8bwa3OWnCshd2FqLfsEA9PFc4w1p2J65bw=
github.com/itchyny/gojq v0.12.19 h1:ttXA0XCLEMoaLOz5lSeFOZ6u6Q3QxmG46vfgI4O0DEs=
github.com/itchyny/gojq v0.12.19/go.mod h1:5galtVPDywX8SPSOrqjGxkBeDhSxEW1gSxoy7tn1iZY=
github.com/itchyny/timefmt-go v0.1.8 h1:1YEo1JvfXeAHKdjelbYr/uCuhkybaHCeTkH8Bo791OI=
github.com/itchyny/timefmt-go v0.1.8/go.mod h1:5E46Q+zj7vbTgWY8o5YkMeYb4I6GeWLFnetPy5oBrAI=
github.com/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM=
github.com/spf13/cobra v1.10.2 h1:DMTTonx5m65Ic0GOoRY2c16WCbHxOOw6xxezuLaBpcU=
github.com/spf13/cobra v1.10.2/go.mod h1:7C1pvHqHw5A4vrJfjNwvOdzYu0Gml16OCs2GRiTUUS4=
github.com/spf13/pflag v1.0.9 h1:9exaQaMOCwffKiiiYk6/BndUBv+iRViNW+4lEMi0PvY=
github.com/spf13/pflag v1.0.9/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg=
go.yaml.in/yaml/v2 v2.4.2 h1:DzmwEr2rDGHl7lsFgAHxmNz/1NlQ7xLIrlN2h5d1eGI=
go.yaml.in/yaml/v2 v2.4.2/go.mod h1:081UH+NErpNdqlCXm3TtEran0rJZGxAYx9hb/ELlsPU=
go.yaml.in/yaml/v3 v3.0.4 h1:tfq32ie2Jv2UxXFdLJdh3jXuOzWiL1fo0bu/FbuKpbc=
go.yaml.in/yaml/v3 v3.0.4/go.mod h1:DhzuOOF2ATzADvBadXxruRBLzYTpT36CKvDb3+aBEFg=
golang.org/x/sys v0.49.0 h1:XbzkgYJHdqh/8m2Uu0W/dQv8nktxx4BFHp1M0gROTXA=
golang.org/x/sys v0.49.0/go.mod h1:hNLxWAXmnKAxqDtdwIYC4bM9oQPEecfsnNMuSxOs3og=
golang.org/x/term v0.47.0 h1:cpyOwQkr2Nzxe2p6KnrTG7Ei2wtzjsuSjhpXXepZ/BM=
golang.org/x/term v0.47.0/go.mod h1:efAfAuG6RKABvg1ArGebRkvX6LXul14SM67o2e4mUCY=
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405 h1:yhCVgyC4o1eVCa2tZl7eS0r+SDo693bJlVdllGtEeKM=
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
sigs.k8s.io/yaml v1.6.0 h1:G8fkbMSAFqgEFgh4b1wmtzDnioxFCUgTZhlbj5P9QYs=
sigs.k8s.io/yaml v1.6.0/go.mod h1:796bPqUfzR/0jLAl6XjHl3Ck7MiyVv8dbTdyT3/pMf4=
147 changes: 136 additions & 11 deletions internal/cli/root.go
Original file line number Diff line number Diff line change
Expand Up @@ -5,39 +5,164 @@ package cli

import (
"context"
"errors"
"fmt"
"io"
"os"
"strings"

"github.com/spf13/cobra"

"github.com/devlyft/cli/internal/clierr"
"github.com/devlyft/cli/internal/cmdutil"
"github.com/devlyft/cli/internal/exitcode"
"github.com/devlyft/cli/internal/iostreams"
"github.com/devlyft/cli/internal/output"
)

// globalFlags are the persistent flags every command accepts.
type globalFlags struct {
format string
jq string
quiet bool
noColor bool
verbosity string
}

// NewRootCmd returns the root `devlyft` command with all subcommands attached.
func NewRootCmd(stdout, stderr io.Writer) *cobra.Command {
// f is populated in the root's PersistentPreRunE, before any RunE.
func NewRootCmd(f *cmdutil.Factory) *cobra.Command {
var flags globalFlags

root := &cobra.Command{
Use: "devlyft",
Short: "Deploy and operate apps on DevLyft",
Long: "devlyft is the official command-line interface for DevLyft.",
SilenceUsage: true,
SilenceErrors: true,
PersistentPreRunE: func(*cobra.Command, []string) error {
return setup(f, &flags)
},
}
root.SetOut(stdout)
root.SetErr(stderr)
root.SetIn(f.IO.In)
root.SetOut(f.IO.Out)
root.SetErr(f.IO.ErrOut)
root.SetFlagErrorFunc(func(_ *cobra.Command, err error) error {
return clierr.Usagef("%v", err)
})

pf := root.PersistentFlags()
pf.StringVar(&flags.format, "format", "", "Output format: table, tsv, json or yaml (default: table at a terminal, tsv otherwise)")
pf.StringVar(&flags.jq, "jq", "", "Filter JSON output with a jq expression")
pf.BoolVarP(&flags.quiet, "quiet", "q", false, "Print only resource IDs")
pf.BoolVar(&flags.noColor, "no-color", false, "Disable colour (also NO_COLOR)")
pf.StringVar(&flags.verbosity, "verbosity", "warning", "Log level for diagnostics on stderr: "+strings.Join(cmdutil.Verbosities, ", ")+" (DEVLYFT_DEBUG=1 for debug)")

root.AddCommand(newVersionCmd())

return root
}

// Execute runs the root command with args and returns the process exit code.
func Execute(ctx context.Context, args []string, stdout, stderr io.Writer) int {
root := NewRootCmd(stdout, stderr)
// setup validates the global flags and builds the printer and logger.
func setup(f *cmdutil.Factory, flags *globalFlags) error {
format, err := output.ParseFormat(flags.format)
if err != nil {
return err
}
opts := output.Options{Format: format, JQ: flags.jq, Quiet: flags.quiet}
f.Printer = output.NewPrinter(f.IO, opts)
if err := opts.Validate(); err != nil {
return err
}

if flags.noColor {
f.IO.DisableColor()
}

verbosity := flags.verbosity
if os.Getenv("DEVLYFT_DEBUG") == "1" {
verbosity = "debug"
}
level, enabled, err := cmdutil.ParseVerbosity(verbosity)
if err != nil {
return err
}
f.Log = cmdutil.NewLogger(f.IO.ErrOut, level, enabled)
return nil
}

// Execute runs the CLI with args and returns the process exit code. It is
// the only place errors are printed and mapped to exit codes.
func Execute(ctx context.Context, ios *iostreams.IOStreams, args []string) int {
f := &cmdutil.Factory{IO: ios}
return execute(ctx, f, NewRootCmd(f), args)
}

func execute(ctx context.Context, f *cmdutil.Factory, root *cobra.Command, args []string) int {
ios := f.IO
root.SetArgs(args)

if err := root.ExecuteContext(ctx); err != nil {
_, _ = fmt.Fprintf(stderr, "Error: %v\n", err)
return exitcode.Error
// Errors returned before the root's PersistentPreRunE ran come from
// Cobra itself: unknown commands, bad flags and argument validation.
ranPreRun := false
preRun := root.PersistentPreRunE
root.PersistentPreRunE = func(cmd *cobra.Command, a []string) error {
ranPreRun = true
return preRun(cmd, a)
}

err := root.ExecuteContext(ctx)
if err == nil {
return exitcode.OK
}

if ctx.Err() != nil && errors.Is(ctx.Err(), context.Canceled) {
// Ctrl-C: no error object, even under --format json; the exit
// code says it all.
_, _ = fmt.Fprintln(ios.ErrOut, "Interrupted.")
return exitcode.Interrupted
}

e := clierr.As(err)
if !ranPreRun && e.Code == clierr.CodeInternal {
e = &clierr.Error{Code: clierr.CodeInvalidArgument, Message: err.Error(), Usage: true, Err: err}
}
if e.Usage && e.Hint == "" {
e.Hint = fmt.Sprintf("Run '%s --help' for usage.", commandPath(root, args))
}

jsonErrors := wantsJSON(args)
if f.Printer != nil {
jsonErrors = f.Printer.Options().IsJSON()
}
if jsonErrors {
_ = clierr.WriteJSON(ios.ErrOut, e)
} else {
_ = clierr.WriteHuman(ios.ErrOut, e, ios.ErrColorEnabled())
}
return e.ExitCode()
}

// commandPath returns the deepest command args resolve to, for hints.
func commandPath(root *cobra.Command, args []string) string {
if cmd, _, err := root.Find(args); err == nil {
return cmd.CommandPath()
}
return root.CommandPath()
}

// wantsJSON reports whether args ask for JSON output. It is used when an
// error happened before the flags could be validated, so that errors under
// --format json are still JSON.
func wantsJSON(args []string) bool {
for i, a := range args {
switch {
case a == "--":
return false
case a == "--format=json", strings.HasPrefix(a, "--jq="):
return true
case (a == "--format" && i+1 < len(args) && args[i+1] == "json") || a == "--jq":
return true
}
}
return exitcode.OK
return false
}
Loading
Loading