sq/cli/cli.go
2022-12-30 10:38:49 -07:00

947 lines
27 KiB
Go

// Package cli implements sq's CLI. The spf13/cobra library
// is used, with some notable modifications. Although cobra
// provides excellent functionality, it has some issues.
// Most prominently, its documentation suggests reliance
// upon package-level constructs for initializing the
// command tree (bad for testing).
//
// Thus, this cmd package deviates from cobra's suggested
// usage pattern by eliminating all pkg-level constructs
// (which makes testing easier), and also replaces cobra's
// Command.RunE func signature with a signature that accepts
// as its first argument the RunContext type.
//
// RunContext is similar to context.Context (and contains
// an instance of that), but also encapsulates injectable
// resources such as config and logging.
//
// Update (Dec 2020): recent releases of cobra now support
// accessing Context from the cobra.Command. At some point
// it may make sense to revisit the way commands are
// constructed, to use this now-standard cobra mechanism.
//
// The entry point to this pkg is the Execute function.
package cli
import (
"context"
"errors"
"fmt"
"io"
"os"
"path/filepath"
"strconv"
"strings"
"sync"
"github.com/fatih/color"
"github.com/mattn/go-colorable"
"github.com/mitchellh/go-homedir"
"github.com/spf13/cobra"
"github.com/spf13/pflag"
"github.com/neilotoole/lg"
"github.com/neilotoole/lg/zaplg"
"github.com/neilotoole/sq/cli/buildinfo"
"github.com/neilotoole/sq/cli/config"
"github.com/neilotoole/sq/cli/output"
"github.com/neilotoole/sq/cli/output/csvw"
"github.com/neilotoole/sq/cli/output/htmlw"
"github.com/neilotoole/sq/cli/output/jsonw"
"github.com/neilotoole/sq/cli/output/markdownw"
"github.com/neilotoole/sq/cli/output/raww"
"github.com/neilotoole/sq/cli/output/tablew"
"github.com/neilotoole/sq/cli/output/xlsxw"
"github.com/neilotoole/sq/cli/output/xmlw"
"github.com/neilotoole/sq/drivers/csv"
"github.com/neilotoole/sq/drivers/json"
"github.com/neilotoole/sq/drivers/mysql"
"github.com/neilotoole/sq/drivers/postgres"
"github.com/neilotoole/sq/drivers/sqlite3"
"github.com/neilotoole/sq/drivers/sqlserver"
"github.com/neilotoole/sq/drivers/userdriver"
"github.com/neilotoole/sq/drivers/userdriver/xmlud"
"github.com/neilotoole/sq/drivers/xlsx"
"github.com/neilotoole/sq/libsq/core/cleanup"
"github.com/neilotoole/sq/libsq/core/errz"
"github.com/neilotoole/sq/libsq/driver"
"github.com/neilotoole/sq/libsq/source"
)
func init() { //nolint:gochecknoinits
cobra.EnableCommandSorting = false
}
// errNoMsg is a sentinel error indicating that a command
// has failed, but that no error message should be printed.
// This is useful in the case where any error information may
// already have been printed as part of the command output.
var errNoMsg = errors.New("")
// Execute builds a RunContext using ctx and default
// settings, and invokes ExecuteWith.
func Execute(ctx context.Context, stdin *os.File, stdout, stderr io.Writer, args []string) error {
rc, err := newDefaultRunContext(stdin, stdout, stderr)
if err != nil {
printError(rc, err)
return err
}
defer rc.Close() // ok to call rc.Close on nil rc
return ExecuteWith(ctx, rc, args)
}
// ExecuteWith invokes the cobra CLI framework, ultimately
// resulting in a command being executed. The caller must
// invoke rc.Close.
func ExecuteWith(ctx context.Context, rc *RunContext, args []string) error {
rc.Log.Debugf("EXECUTE: %s", strings.Join(args, " "))
rc.Log.Debugf("Build: %s %s %s", buildinfo.Version, buildinfo.Commit, buildinfo.Timestamp)
rc.Log.Debugf("Config (cfg version %q) from: %s", rc.Config.Version, rc.ConfigStore.Location())
ctx = WithRunContext(ctx, rc)
rootCmd := newCommandTree(rc)
var err error
// The following is a workaround for the fact that cobra doesn't
// currently (as of 2017) support executing the root command with
// arbitrary args. That is to say, if you execute:
//
// $ sq @sakila_sl3.actor
//
// then cobra will look for a command named "@sakila_sl3.actor",
// and when it doesn't find such a command, it returns
// an "unknown command" error.
//
// NOTE: This entire mechanism is ancient. Perhaps cobra
// now handles this situation?
// We need to perform handling for autocomplete
if len(args) > 0 && args[0] == "__complete" {
if hasMatchingChildCommand(rootCmd, args[1]) {
// If there is a matching child command, we let rootCmd
// handle it, as per normal.
rootCmd.SetArgs(args)
} else {
// There's no command matching the first argument to __complete.
// Therefore, we assume that we want to perform completion
// for the "slq" command (which is the pseudo-root command).
effectiveArgs := append([]string{"__complete", "slq"}, args[1:]...)
rootCmd.SetArgs(effectiveArgs)
}
} else {
var cmd *cobra.Command
cmd, _, err = rootCmd.Find(args)
if err != nil {
// This err will be the "unknown command" error.
// cobra still returns cmd though. It should be
// the root cmd.
if cmd == nil || cmd.Name() != rootCmd.Name() {
// Not sure if this can happen anymore? Can prob delete?
panic(fmt.Sprintf("bad cobra cmd state: %v", cmd))
}
// If we have args [sq, arg1, arg2] then we redirect
// to the "slq" command by modifying args to
// look like: [query, arg1, arg2] -- noting that SetArgs
// doesn't want the first args element.
effectiveArgs := append([]string{"slq"}, args...)
rootCmd.SetArgs(effectiveArgs)
} else {
if cmd.Name() == rootCmd.Name() {
// Not sure why we have two paths to this, but it appears
// that we've found the root cmd again, so again
// we redirect to "slq" cmd.
a := append([]string{"slq"}, args...)
rootCmd.SetArgs(a)
} else {
// It's just a normal command like "sq ls" or such.
// Explicitly set the args on rootCmd as this makes
// cobra happy when this func is executed via tests.
// Haven't explored the reason why.
rootCmd.SetArgs(args)
}
}
}
// Execute rootCmd; cobra will find the appropriate
// sub-command, and ultimately execute that command.
err = rootCmd.ExecuteContext(ctx)
if err != nil {
printError(rc, err)
}
return err
}
// cobraMu exists because cobra relies upon package-level
// constructs. This does not sit well with parallel tests.
var cobraMu sync.Mutex
// newCommandTree builds sq's command tree, returning
// the root cobra command.
func newCommandTree(rc *RunContext) (rootCmd *cobra.Command) {
cobraMu.Lock()
defer cobraMu.Unlock()
rootCmd = newRootCmd()
rootCmd.DisableAutoGenTag = true
rootCmd.SetOut(rc.Out)
rootCmd.SetErr(rc.ErrOut)
rootCmd.Flags().SortFlags = false
// The --help flag must be explicitly added to rootCmd,
// or else cobra tries to do its own (unwanted) thing.
// The behavior of cobra in this regard seems to have
// changed? This particular incantation currently does the trick.
rootCmd.Flags().Bool(flagHelp, false, "Show sq help")
helpCmd := addCmd(rc, rootCmd, newHelpCmd())
rootCmd.SetHelpCommand(helpCmd)
// From the end user's perspective, slqCmd is *effectively* the
// root cmd. We need to perform some trickery to make it output help
// such that "sq help" and "sq --help" output the same thing.
slqCmd := newSLQCmd()
slqCmd.SetHelpFunc(func(command *cobra.Command, i []string) {
panicOn(rootCmd.Help())
})
addCmd(rc, rootCmd, slqCmd)
addCmd(rc, rootCmd, newSQLCmd())
addCmd(rc, rootCmd, newSrcCommand())
addCmd(rc, rootCmd, newSrcAddCmd())
addCmd(rc, rootCmd, newSrcListCmd())
addCmd(rc, rootCmd, newSrcRemoveCmd())
addCmd(rc, rootCmd, newScratchCmd())
addCmd(rc, rootCmd, newInspectCmd())
addCmd(rc, rootCmd, newPingCmd())
addCmd(rc, rootCmd, newVersionCmd())
driverCmd := addCmd(rc, rootCmd, newDriverCmd())
addCmd(rc, driverCmd, newDriverListCmd())
tblCmd := addCmd(rc, rootCmd, newTblCmd())
addCmd(rc, tblCmd, newTblCopyCmd())
addCmd(rc, tblCmd, newTblTruncateCmd())
addCmd(rc, tblCmd, newTblDropCmd())
addCmd(rc, rootCmd, newCompletionCmd())
addCmd(rc, rootCmd, newManCmd())
return rootCmd
}
// hasMatchingChildCommand returns true if s is a full or prefix
// match for any of cmd's children. For example, if cmd has
// children [inspect, ls, rm], then "insp" or "ls" would return true.
func hasMatchingChildCommand(cmd *cobra.Command, s string) bool {
for _, child := range cmd.Commands() {
if strings.HasPrefix(child.Name(), s) {
return true
}
}
return false
}
// addCmd adds the command returned by cmdFn to parentCmd.
func addCmd(rc *RunContext, parentCmd, cmd *cobra.Command) *cobra.Command {
cmd.Flags().SortFlags = false
if cmd.Name() != "help" {
// Don't add the --help flag to the help command.
cmd.Flags().Bool(flagHelp, false, "help for "+cmd.Name())
}
cmd.DisableAutoGenTag = true
cmd.PreRunE = func(cmd *cobra.Command, args []string) error {
rc.Cmd = cmd
rc.Args = args
err := rc.init()
return err
}
runE := cmd.RunE
cmd.RunE = func(cmd *cobra.Command, args []string) error {
if cmd.Flags().Changed(flagVersion) {
// Bit of a hack: flag --version on any command
// results in execVersion being invoked
return execVersion(cmd, args)
}
return runE(cmd, args)
}
// We handle the errors ourselves (rather than let cobra do it)
cmd.SilenceErrors = true
cmd.SilenceUsage = true
parentCmd.AddCommand(cmd)
return cmd
}
type runContextKey struct{}
// WithRunContext returns ctx with rc added as a value.
func WithRunContext(ctx context.Context, rc *RunContext) context.Context {
if ctx == nil {
ctx = context.Background()
}
return context.WithValue(ctx, runContextKey{}, rc)
}
// RunContextFrom extracts the RunContext added to ctx via WithRunContext.
func RunContextFrom(ctx context.Context) *RunContext {
return ctx.Value(runContextKey{}).(*RunContext)
}
// RunContext is a container for injectable resources passed
// to all execX funcs. The Close method should be invoked when
// the RunContext is no longer needed.
type RunContext struct {
// Stdin typically is os.Stdin, but can be changed for testing.
Stdin *os.File
// Out is the output destination.
// If nil, default to stdout.
Out io.Writer
// ErrOut is the error output destination.
// If nil, default to stderr.
ErrOut io.Writer
// Cmd is the command instance provided by cobra for
// the currently executing command. This field will
// be set before the command's runFunc is invoked.
Cmd *cobra.Command
// Args is the arg slice supplied by cobra for
// the currently executing command. This field will
// be set before the command's runFunc is invoked.
Args []string
// Config is the run's config.
Config *config.Config
// ConfigStore is run's config store.
ConfigStore config.Store
// Log is the run's logger.
Log lg.Log
initOnce sync.Once
initErr error
// writers holds the various writer types that
// the CLI uses to print output.
writers *writers
registry *driver.Registry
files *source.Files
databases *driver.Databases
clnup *cleanup.Cleanup
}
// newDefaultRunContext returns a RunContext configured
// with standard values for logging, config, etc. This
// effectively is the bootstrap mechanism for sq.
//
// Note: This func always returns a RunContext, even if
// an error occurs during bootstrap of the RunContext (for
// example if there's a config error). We do this to provide
// enough framework so that such an error can be logged or
// printed per the normal mechanisms if at all possible.
func newDefaultRunContext(stdin *os.File, stdout, stderr io.Writer) (*RunContext, error) {
rc := &RunContext{
Stdin: stdin,
Out: stdout,
ErrOut: stderr,
}
log, clnup, loggingErr := defaultLogging()
rc.Log = log
rc.clnup = clnup
cfg, cfgStore, configErr := defaultConfig()
rc.ConfigStore = cfgStore
rc.Config = cfg
switch {
case rc.Log == nil:
rc.Log = lg.Discard()
case rc.clnup == nil:
rc.clnup = cleanup.New()
case rc.Config == nil:
rc.Config = config.New()
}
if configErr != nil {
// configErr is more important, return that first
return rc, configErr
}
if loggingErr != nil {
return rc, loggingErr
}
return rc, nil
}
// init is invoked by cobra prior to the command RunE being
// invoked. It sets up the registry, databases, writers and related
// fundamental components. Subsequent invocations of this method
// are no-op.
func (rc *RunContext) init() error {
rc.initOnce.Do(func() {
rc.initErr = rc.doInit()
})
return rc.initErr
}
// doInit performs the actual work of initializing rc.
// It must only be invoked once.
func (rc *RunContext) doInit() error {
rc.clnup = cleanup.New()
log, cfg := rc.Log, rc.Config
// If the --output=/some/file flag is set, then we need to
// override rc.Out (which is typically stdout) to point it at
// the output destination file.
if cmdFlagChanged(rc.Cmd, flagOutput) {
fpath, _ := rc.Cmd.Flags().GetString(flagOutput)
fpath, err := filepath.Abs(fpath)
if err != nil {
return errz.Wrapf(err, "failed to get absolute path for --%s", flagOutput)
}
// Ensure the parent dir exists
err = os.MkdirAll(filepath.Dir(fpath), os.ModePerm)
if err != nil {
return errz.Wrapf(err, "failed to make parent dir for --%s", flagOutput)
}
f, err := os.Create(fpath)
if err != nil {
return errz.Wrapf(err, "failed to open file specified by flag --%s", flagOutput)
}
rc.clnup.AddC(f) // Make sure the file gets closed eventually
rc.Out = f
}
rc.writers, rc.Out, rc.ErrOut = newWriters(rc.Log, rc.Cmd, rc.Config.Defaults, rc.Out, rc.ErrOut)
var scratchSrcFunc driver.ScratchSrcFunc
// scratchSrc could be nil, and that's ok
scratchSrc := cfg.Sources.Scratch()
if scratchSrc == nil {
scratchSrcFunc = sqlite3.NewScratchSource
} else {
scratchSrcFunc = func(log lg.Log, name string) (src *source.Source, clnup func() error, err error) {
return scratchSrc, nil, nil
}
}
var err error
rc.files, err = source.NewFiles(log)
if err != nil {
log.WarnIfFuncError(rc.clnup.Run)
return err
}
// Note: it's important that files.Close is invoked
// after databases.Close (hence added to clnup first),
// because databases could depend upon the existence of
// files (such as a sqlite db file).
rc.clnup.AddE(rc.files.Close)
rc.files.AddTypeDetectors(source.DetectMagicNumber)
rc.registry = driver.NewRegistry(log)
rc.databases = driver.NewDatabases(log, rc.registry, scratchSrcFunc)
rc.clnup.AddC(rc.databases)
rc.registry.AddProvider(sqlite3.Type, &sqlite3.Provider{Log: log})
rc.registry.AddProvider(postgres.Type, &postgres.Provider{Log: log})
rc.registry.AddProvider(sqlserver.Type, &sqlserver.Provider{Log: log})
rc.registry.AddProvider(mysql.Type, &mysql.Provider{Log: log})
csvp := &csv.Provider{Log: log, Scratcher: rc.databases, Files: rc.files}
rc.registry.AddProvider(csv.TypeCSV, csvp)
rc.registry.AddProvider(csv.TypeTSV, csvp)
rc.files.AddTypeDetectors(csv.DetectCSV, csv.DetectTSV)
jsonp := &json.Provider{Log: log, Scratcher: rc.databases, Files: rc.files}
rc.registry.AddProvider(json.TypeJSON, jsonp)
rc.registry.AddProvider(json.TypeJSONA, jsonp)
rc.registry.AddProvider(json.TypeJSONL, jsonp)
rc.files.AddTypeDetectors(json.DetectJSON, json.DetectJSONA, json.DetectJSONL)
rc.registry.AddProvider(xlsx.Type, &xlsx.Provider{Log: log, Scratcher: rc.databases, Files: rc.files})
rc.files.AddTypeDetectors(xlsx.DetectXLSX)
// One day we may have more supported user driver genres.
userDriverImporters := map[string]userdriver.ImportFunc{
xmlud.Genre: xmlud.Import,
}
for i, userDriverDef := range cfg.Ext.UserDrivers {
userDriverDef := userDriverDef
errs := userdriver.ValidateDriverDef(userDriverDef)
if len(errs) > 0 {
err := errz.Combine(errs...)
err = errz.Wrapf(err, "failed validation of user driver definition [%d] (%q) from config",
i, userDriverDef.Name)
return err
}
importFn, ok := userDriverImporters[userDriverDef.Genre]
if !ok {
return errz.Errorf("unsupported genre %q for user driver %q specified via config",
userDriverDef.Genre, userDriverDef.Name)
}
// For each user driver definition, we register a
// distinct userdriver.Provider instance.
udp := &userdriver.Provider{
Log: log,
DriverDef: userDriverDef,
ImportFn: importFn,
Scratcher: rc.databases,
Files: rc.files,
}
rc.registry.AddProvider(source.Type(userDriverDef.Name), udp)
rc.files.AddTypeDetectors(udp.TypeDetectors()...)
}
return nil
}
// Close should be invoked to dispose of any open resources
// held by rc. If an error occurs during Close and rc.Log
// is not nil, that error is logged at WARN level before
// being returned.
func (rc *RunContext) Close() error {
if rc == nil {
return nil
}
err := rc.clnup.Run()
if err != nil && rc.Log != nil {
rc.Log.Warnf("failed to close RunContext: %v", err)
}
return err
}
// writers is a container for the various output writers.
type writers struct {
fm *output.Formatting
recordw output.RecordWriter
metaw output.MetadataWriter
srcw output.SourceWriter
errw output.ErrorWriter
pingw output.PingWriter
versionw output.VersionWriter
}
// newWriters returns a writers instance configured per defaults and/or
// flags from cmd. The returned out2/errOut2 values may differ
// from the out/errOut args (e.g. decorated to support colorization).
func newWriters(log lg.Log, cmd *cobra.Command, defaults config.Defaults, out, errOut io.Writer) (w *writers,
out2, errOut2 io.Writer,
) {
var fm *output.Formatting
fm, out2, errOut2 = getWriterFormatting(cmd, out, errOut)
// Package tablew has writer impls for each of the writer interfaces,
// so we use its writers as the baseline. Later we check the format
// flags and set the various writer fields depending upon which
// writers the format implements.
w = &writers{
fm: fm,
recordw: tablew.NewRecordWriter(out2, fm),
metaw: tablew.NewMetadataWriter(out2, fm),
srcw: tablew.NewSourceWriter(out2, fm),
pingw: tablew.NewPingWriter(out2, fm),
errw: tablew.NewErrorWriter(errOut2, fm),
versionw: tablew.NewVersionWriter(out2, fm),
}
// Invoke getFormat to see if the format was specified
// via config or flag.
format := getFormat(cmd, defaults)
switch format { //nolint:exhaustive
default:
// No format specified, use JSON
w.recordw = jsonw.NewStdRecordWriter(out2, fm)
w.metaw = jsonw.NewMetadataWriter(out2, fm)
w.errw = jsonw.NewErrorWriter(log, errOut2, fm)
w.versionw = jsonw.NewVersionWriter(out2, fm)
case config.FormatTable:
// Table is the base format, already set above, no need to do anything.
case config.FormatTSV:
w.recordw = csvw.NewRecordWriter(out2, fm.ShowHeader, csvw.Tab)
w.pingw = csvw.NewPingWriter(out2, csvw.Tab)
case config.FormatCSV:
w.recordw = csvw.NewRecordWriter(out2, fm.ShowHeader, csvw.Comma)
w.pingw = csvw.NewPingWriter(out2, csvw.Comma)
case config.FormatXML:
w.recordw = xmlw.NewRecordWriter(out2, fm)
case config.FormatXLSX:
w.recordw = xlsxw.NewRecordWriter(out2, fm.ShowHeader)
case config.FormatRaw:
w.recordw = raww.NewRecordWriter(out2)
case config.FormatHTML:
w.recordw = htmlw.NewRecordWriter(out2)
case config.FormatMarkdown:
w.recordw = markdownw.NewRecordWriter(out2)
case config.FormatJSONA:
w.recordw = jsonw.NewArrayRecordWriter(out2, fm)
case config.FormatJSONL:
w.recordw = jsonw.NewObjectRecordWriter(out2, fm)
}
return w, out2, errOut2
}
// getWriterFormatting returns a Formatting instance and
// colorable or non-colorable writers. It is permissible
// for the cmd arg to be nil.
func getWriterFormatting(cmd *cobra.Command, out, errOut io.Writer) (fm *output.Formatting, out2, errOut2 io.Writer) {
fm = output.NewFormatting()
if cmdFlagChanged(cmd, flagPretty) {
fm.Pretty, _ = cmd.Flags().GetBool(flagPretty)
}
if cmdFlagChanged(cmd, flagVerbose) {
fm.Verbose, _ = cmd.Flags().GetBool(flagVerbose)
}
if cmdFlagChanged(cmd, flagHeader) {
fm.ShowHeader, _ = cmd.Flags().GetBool(flagHeader)
}
// TODO: Should get this default value from config
colorize := true
if cmdFlagChanged(cmd, flagOutput) {
// We're outputting to a file, thus no color.
colorize = false
} else if cmdFlagChanged(cmd, flagMonochrome) {
if mono, _ := cmd.Flags().GetBool(flagMonochrome); mono {
colorize = false
}
}
if !colorize {
color.NoColor = true // TODO: shouldn't rely on package-level var
fm.EnableColor(false)
out2 = out
errOut2 = errOut
return fm, out2, errOut2
}
// We do want to colorize
if !isColorTerminal(out) {
// But out can't be colorized.
color.NoColor = true
fm.EnableColor(false)
out2, errOut2 = out, errOut
return fm, out2, errOut2
}
// out can be colorized.
color.NoColor = false
fm.EnableColor(true)
out2 = colorable.NewColorable(out.(*os.File))
// Check if we can colorize errOut
if isColorTerminal(errOut) {
errOut2 = colorable.NewColorable(errOut.(*os.File))
} else {
// errOut2 can't be colorized, but since we're colorizing
// out, we'll apply the non-colorable filter to errOut.
errOut2 = colorable.NewNonColorable(errOut)
}
return fm, out2, errOut2
}
func getFormat(cmd *cobra.Command, defaults config.Defaults) config.Format {
var format config.Format
switch {
// cascade through the format flags in low-to-high order of precedence.
case cmdFlagChanged(cmd, flagTSV):
format = config.FormatTSV
case cmdFlagChanged(cmd, flagCSV):
format = config.FormatCSV
case cmdFlagChanged(cmd, flagXLSX):
format = config.FormatXLSX
case cmdFlagChanged(cmd, flagXML):
format = config.FormatXML
case cmdFlagChanged(cmd, flagRaw):
format = config.FormatRaw
case cmdFlagChanged(cmd, flagHTML):
format = config.FormatHTML
case cmdFlagChanged(cmd, flagMarkdown):
format = config.FormatMarkdown
case cmdFlagChanged(cmd, flagTable):
format = config.FormatTable
case cmdFlagChanged(cmd, flagJSONL):
format = config.FormatJSONL
case cmdFlagChanged(cmd, flagJSONA):
format = config.FormatJSONA
case cmdFlagChanged(cmd, flagJSON):
format = config.FormatJSON
default:
// no format flag, use the config value
format = defaults.Format
}
return format
}
// defaultLogging returns a log (and its associated closer) if
// logging has been enabled via envars.
func defaultLogging() (lg.Log, *cleanup.Cleanup, error) {
truncate, _ := strconv.ParseBool(os.Getenv(envarLogTruncate))
logFilePath, ok := os.LookupEnv(envarLogPath)
if !ok || logFilePath == "" || strings.TrimSpace(logFilePath) == "" {
return lg.Discard(), nil, nil
}
// Let's try to create the dir holding the logfile... if it already exists,
// then os.MkdirAll will just no-op
parent := filepath.Dir(logFilePath)
err := os.MkdirAll(parent, 0o750)
if err != nil {
return lg.Discard(), nil, errz.Wrapf(err, "failed to create parent dir of log file %s", logFilePath)
}
flag := os.O_APPEND
if truncate {
flag = os.O_TRUNC
}
logFile, err := os.OpenFile(logFilePath, os.O_RDWR|os.O_CREATE|flag, 0o600)
if err != nil {
return lg.Discard(), nil, errz.Wrapf(err, "unable to open log file %q", logFilePath)
}
clnup := cleanup.New().AddE(logFile.Close)
log := zaplg.NewWith(logFile, "json", true, true, true, 0)
clnup.AddE(log.Sync)
return log, clnup, nil
}
// defaultConfig loads sq config from the default location
// (~/.config/sq/sq.yml) or the location specified in envars.
func defaultConfig() (*config.Config, config.Store, error) {
cfgDir, ok := os.LookupEnv(envarConfigDir)
if !ok {
// envar not set, let's use the default
home, err := homedir.Dir()
if err != nil {
// TODO: we should be able to run without the homedir... revisit this
return nil, nil, errz.Wrap(err, "unable to get user home dir for config purposes")
}
cfgDir = filepath.Join(home, ".config", "sq")
}
cfgPath := filepath.Join(cfgDir, "sq.yml")
extDir := filepath.Join(cfgDir, "ext")
cfgStore := &config.YAMLFileStore{Path: cfgPath, ExtPaths: []string{extDir}}
if !cfgStore.FileExists() {
cfg := config.New()
return cfg, cfgStore, nil
}
// file does exist, let's try to load it
cfg, err := cfgStore.Load()
if err != nil {
return nil, nil, err
}
return cfg, cfgStore, nil
}
// printError is the centralized function for printing
// and logging errors. This func has a lot of (possibly needless)
// redundancy; ultimately err will print if non-nil (even if
// rc or any of its fields are nil).
func printError(rc *RunContext, err error) {
log := lg.Discard()
if rc != nil && rc.Log != nil {
log = rc.Log
}
if err == nil {
log.Warnf("printError called with nil error")
return
}
if errors.Is(err, errNoMsg) {
// errNoMsg is a sentinel err that sq doesn't want to print
return
}
switch {
default:
case errors.Is(err, context.Canceled):
err = errz.New("stopped")
case errors.Is(err, context.DeadlineExceeded):
err = errz.New("timeout")
}
var cmd *cobra.Command
if rc != nil {
cmd = rc.Cmd
cmdName := "unknown"
if cmd != nil {
cmdName = fmt.Sprintf("[cmd:%s] ", cmd.Name())
}
log.Errorf("%s [%T] %+v", cmdName, err, err)
wrtrs := rc.writers
if wrtrs != nil && wrtrs.errw != nil {
// If we have an errorWriter, we print to it
// and return.
wrtrs.errw.Error(err)
return
}
// Else we don't have an errorWriter, so we fall through
}
// If we get this far, something went badly wrong in bootstrap
// (probably the config is corrupt).
// At this point, we could just print err to os.Stderr and be done.
// However, our philosophy is to always provide the ability
// to output errors in json if possible. So, even though cobra
// may not have initialized and our own config may be borked, we
// will still try to determine if the user wants the error
// in json, specified via flags (by directly using the pflag
// package) or via sq config's default output format.
// getWriterFormatting works even if cmd is nil
fm, _, errOut := getWriterFormatting(cmd, os.Stdout, os.Stderr)
if bootstrapIsFormatJSON(rc) {
// The user wants JSON, either via defaults or flags.
jw := jsonw.NewErrorWriter(log, errOut, fm)
jw.Error(err)
return
}
// The user didn't want JSON, so we just print to stderr.
if isColorTerminal(os.Stderr) {
fm.Error.Fprintln(os.Stderr, "sq: "+err.Error())
} else {
fmt.Fprintln(os.Stderr, "sq: "+err.Error())
}
}
// cmdFlagChanged returns true if cmd is non-nil and
// has the named flag and that flag been changed.
func cmdFlagChanged(cmd *cobra.Command, name string) bool {
if cmd == nil {
return false
}
flag := cmd.Flag(name)
if flag == nil {
return false
}
return flag.Changed
}
// cmdFlagTrue returns true if flag name has been changed
// and the flag value is true.
func cmdFlagTrue(cmd *cobra.Command, name string) bool {
if !cmdFlagChanged(cmd, name) {
return false
}
b, err := cmd.Flags().GetBool(name)
if err != nil {
panic(err) // Should never happen
}
return b
}
// bootstrapIsFormatJSON is a last-gasp attempt to check if the user
// supplied --json=true on the command line, to determine if a
// bootstrap error (hopefully rare) should be output in JSON.
func bootstrapIsFormatJSON(rc *RunContext) bool {
// If no RunContext, assume false
if rc == nil {
return false
}
defaultFormat := config.FormatTable
if rc.Config != nil {
defaultFormat = rc.Config.Defaults.Format
}
// If args were provided, create a new flag set and check
// for the --json flag.
if len(rc.Args) > 0 {
flags := pflag.NewFlagSet("bootstrap", pflag.ContinueOnError)
jsonFlag := flags.BoolP(flagJSON, flagJSONShort, false, flagJSONUsage)
err := flags.Parse(rc.Args)
if err != nil {
return false
}
// No --json flag, return true if the config file default is JSON
if jsonFlag == nil {
return defaultFormat == config.FormatJSON
}
return *jsonFlag
}
// No args, return true if the config file default is JSON
return defaultFormat == config.FormatJSON
}
func panicOn(err error) {
if err != nil {
panic(err)
}
}