Skip to content
Merged
Show file tree
Hide file tree
Changes from 2 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
2 changes: 1 addition & 1 deletion Makefile
Original file line number Diff line number Diff line change
Expand Up @@ -95,7 +95,7 @@ mockery:

.PHONY: test_unit
test_unit:
go test ./cmd/...
go test ./cmd/... ./services/... ./output/...

# add binary and config to tests/cmd/bin/ before run test integration
.PHONY: test_integration
Expand Down
11 changes: 11 additions & 0 deletions cmd/cmd_root.go
Original file line number Diff line number Diff line change
@@ -1,8 +1,10 @@
package cmd

import (
"os"
"strings"

"github.com/Smartling/smartling-cli/services/helpers/rlog"
"github.com/spf13/cobra"
)

Expand All @@ -18,6 +20,7 @@ var (
insecure bool
proxy string
verbose int
showConfig bool

isInit bool
isFiles bool
Expand All @@ -34,13 +37,18 @@ func NewRootCmd() *cobra.Command {
Long: `Manage translation files using Smartling CLI.
Complete documentation is available at https://www.smartling.com`,
PersistentPreRun: func(cmd *cobra.Command, _ []string) {
ctx := cmd.Context()
configureLoggerVerbose()

path := cmd.CommandPath()
isInit = strings.HasPrefix(path, "smartling-cli init")
isFiles = strings.HasPrefix(path, "smartling-cli files")
isProjects = strings.HasPrefix(path, "smartling-cli projects")
isList = strings.HasPrefix(path, "smartling-cli list")
if err := ShowConfigBanner(ctx); err != nil {
rlog.Error(err)
os.Exit(1)
}
},
RunE: func(cmd *cobra.Command, args []string) error {
if len(args) == 0 && cmd.Flags().NFlag() == 0 {
Expand Down Expand Up @@ -74,6 +82,9 @@ executed for at most <number> of threads.`)
rootCmd.PersistentFlags().StringVar(&smartlingURL, "smartling-url", "", `Specify base Smartling URL, merely for testing
purposes.`)
rootCmd.PersistentFlags().CountVarP(&verbose, "verbose", "v", "Verbose logging")
rootCmd.PersistentFlags().BoolVar(&showConfig, "show-config", false,
`Print the resolved account, project, user, and config file path
to stderr before the command runs.`)

return rootCmd
}
77 changes: 77 additions & 0 deletions cmd/config_banner.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,77 @@
package cmd

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

output "github.com/Smartling/smartling-cli/output/projects"
projectconfig "github.com/Smartling/smartling-cli/services/projects/config"

"golang.org/x/term"
)

// ShowConfigBanner prints the --show-config banner to stdout when the
// flag is set, and — only on an interactive terminal — prompts the operator
// with "Continue? [y/N]:" before letting the command proceed. On a "no"
// answer the process exits with status 1; on any other path (non-TTY, no
// flag, init command, config resolution failure) the function returns
// silently and the command runs normally.
func ShowConfigBanner(ctx context.Context) error {
if !showConfig || isInit {
return nil
}
cfg, err := Config()
if err != nil {
return fmt.Errorf("failed to read config: %w", err)
}
client, err := Client(ctx)
if err != nil {
return fmt.Errorf("failed to create API client: %w", err)
}
extendedConfig, err := projectconfig.FetchExtendedConfig(ctx, cfg, client.GetProjectDetails)
if err != nil {
return err
}
Comment thread
az-smartling marked this conversation as resolved.
if !showConfigAndMaybePrompt(extendedConfig, os.Stdout, os.Stderr, os.Stdin, stdinIsTerminal()) {
Comment thread
az-smartling marked this conversation as resolved.
return errors.New("operation aborted by user")
}
return nil
}

func showConfigAndMaybePrompt(config projectconfig.Extended, stdoutW, stderrW io.Writer, stdinR io.Reader, stdinIsTerminal bool) bool {
_ = output.RenderPlain(stdoutW, config)

if !stdinIsTerminal {
return true
}

_, _ = fmt.Fprint(stderrW, "Continue? [y/N]: ")
if !confirmContinue(stdinR) {
_, _ = fmt.Fprintln(stderrW, "aborted")
return false
}
return true
}

func confirmContinue(r io.Reader) bool {
scanner := bufio.NewScanner(r)
if !scanner.Scan() {
return false
}
switch strings.ToLower(strings.TrimSpace(scanner.Text())) {
case "y", "yes":
return true
default:
return false
}
}

// stdinIsTerminal reports whether stdin is connected to an interactive terminal.
func stdinIsTerminal() bool {
return term.IsTerminal(int(os.Stdin.Fd()))
}
94 changes: 94 additions & 0 deletions cmd/config_banner_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,94 @@
package cmd

import (
"bytes"
"strings"
"testing"

projectconfig "github.com/Smartling/smartling-cli/services/projects/config"
)

func testExtendedConfig() projectconfig.Extended {
return projectconfig.Extended{
ProjectID: "p-789",
AccountUID: "a-456",
Name: "Acme Localization",
Locale: "en-US: English (United States)",
Status: "active",
UserID: "u-123",
ConfigFile: "/tmp/smartling.yml",
Sources: "project=flag account=config user=env",
}
}

func TestConfirmContinue(t *testing.T) {
tests := []struct {
name, input string
want bool
}{
{"y lowercase", "y\n", true},
{"Y uppercase", "Y\n", true},
{"yes", "yes\n", true},
{"YES", "YES\n", true},
{"n", "n\n", false},
{"N", "N\n", false},
{"no", "no\n", false},
{"empty line", "\n", false},
{"eof", "", false},
{"whitespace around y", " y \n", true},
{"whitespace around yes", " yes \n", true},
{"unrelated text", "foo\n", false},
{"y-prefixed word yeti", "yeti\n", false},
{"y-prefixed word yikes", "yikes\n", false},
{"yy", "yy\n", false},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
if got := confirmContinue(strings.NewReader(tt.input)); got != tt.want {
t.Errorf("confirmContinue(%q) = %v, want %v", tt.input, got, tt.want)
}
})
}
}

func TestShowConfigAndMaybePrompt_NonTTY(t *testing.T) {
var stdout, stderr bytes.Buffer
got := showConfigAndMaybePrompt(testExtendedConfig(), &stdout, &stderr, strings.NewReader(""), false)

if !got {
t.Errorf("showConfigAndMaybePrompt should return true in non-TTY case, got false")
}
if !strings.Contains(stdout.String(), "Smartling CLI configuration:") {
t.Errorf("stdout should contain banner, got: %q", stdout.String())
}
if stderr.String() != "" {
t.Errorf("stderr should be empty (no prompt) in non-TTY case, got: %q", stderr.String())
}
}

func TestShowConfigAndMaybePrompt_TTYConfirm(t *testing.T) {
var stdout, stderr bytes.Buffer
got := showConfigAndMaybePrompt(testExtendedConfig(), &stdout, &stderr, strings.NewReader("y\n"), true)

if !got {
t.Errorf("showConfigAndMaybePrompt should return true on 'y', got false")
}
if !strings.Contains(stdout.String(), "Smartling CLI configuration:") {
t.Errorf("stdout should contain banner, got: %q", stdout.String())
}
if !strings.Contains(stderr.String(), "Continue?") {
t.Errorf("stderr should contain prompt, got: %q", stderr.String())
}
}

func TestShowConfigAndMaybePrompt_TTYAbort(t *testing.T) {
var stdout, stderr bytes.Buffer
got := showConfigAndMaybePrompt(testExtendedConfig(), &stdout, &stderr, strings.NewReader("n\n"), true)

if got {
t.Errorf("showConfigAndMaybePrompt should return false on 'n', got true")
}
if !strings.Contains(stderr.String(), "aborted") {
t.Errorf("stderr should contain 'aborted' on abort, got: %q", stderr.String())
}
}
2 changes: 1 addition & 1 deletion cmd/docs/cmd_docs.go
Original file line number Diff line number Diff line change
Expand Up @@ -18,7 +18,7 @@ func NewDocsCmd() *cobra.Command {
RunE: func(cmd *cobra.Command, args []string) error {
err := doc.GenMarkdownTree(cmd.Root(), "./docs")
if err != nil {
return fmt.Errorf("failed to generate docs: %v", err)
return fmt.Errorf("failed to generate docs: %w", err)
}
rlog.Infof("markdown docs generated in ./docs/")
return nil
Expand Down
7 changes: 5 additions & 2 deletions cmd/jobs/cmd_jobs.go
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,8 @@ import (
"slices"
"strings"

"github.com/Smartling/smartling-cli/cmd"

"github.com/spf13/cobra"
)

Expand Down Expand Up @@ -50,11 +52,12 @@ Available options:
smartling-cli jobs progress aabbccdd --output json

`,
PersistentPreRunE: func(cmd *cobra.Command, args []string) error {
PersistentPreRunE: func(c *cobra.Command, args []string) error {
ctx := c.Context()
if !slices.Contains(allowedOutputs, outputFormat) {
return fmt.Errorf("invalid output: %s (allowed: %s)", outputFormat, joinedAllowedOutputs)
}
return nil
return cmd.ShowConfigBanner(ctx)
},
Comment thread
az-smartling marked this conversation as resolved.
RunE: func(cmd *cobra.Command, args []string) error {
if len(args) == 0 && cmd.Flags().NFlag() == 0 {
Expand Down
7 changes: 5 additions & 2 deletions cmd/mt/cmd_mt.go
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,8 @@ import (
"slices"
"strings"

"github.com/Smartling/smartling-cli/cmd"

"github.com/spf13/cobra"
)

Expand Down Expand Up @@ -35,14 +37,15 @@ func NewMTCmd() *cobra.Command {
Use: "mt",
Short: "File Machine Translations",
Long: `Machine Translations offers a simple way to upload files and execute actions on them without any complex setup required`,
PersistentPreRunE: func(cmd *cobra.Command, args []string) error {
PersistentPreRunE: func(c *cobra.Command, args []string) error {
ctx := c.Context()
if !slices.Contains(allowedOutputs, outputFormat) {
return fmt.Errorf("invalid output: %s (allowed: %s)", outputFormat, joinedAllowedOutputs)
}
if !slices.Contains(allowedOutputModes, outputMode) {
return fmt.Errorf("invalid output-mode: %s (allowed: %s)", outputMode, joinedAllowedOutputModes)
}
return nil
return cmd.ShowConfigBanner(ctx)
},
Comment thread
az-smartling marked this conversation as resolved.
RunE: func(cmd *cobra.Command, args []string) error {
if len(args) == 0 && cmd.Flags().NFlag() == 0 {
Expand Down
7 changes: 6 additions & 1 deletion cmd/projects/info/cmd_info.go
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import (
"os"

projectscmd "github.com/Smartling/smartling-cli/cmd/projects"
output "github.com/Smartling/smartling-cli/output/projects"
"github.com/Smartling/smartling-cli/services/helpers/help"
"github.com/Smartling/smartling-cli/services/helpers/rlog"

Expand Down Expand Up @@ -35,11 +36,15 @@ Available options:` + help.AuthenticationOptions,
rlog.Errorf("failed to get project service: %s", err)
os.Exit(1)
}
err = s.RunInfo(ctx)
infoOutput, err := s.RunInfo(ctx)
if err != nil {
rlog.Errorf("failed to run info: %s", err)
os.Exit(1)
}
if err := output.RenderTable(infoOutput); err != nil {
rlog.Errorf("failed to render info output: %s", err)
os.Exit(1)
}
},
}
return infoCmd
Expand Down
3 changes: 2 additions & 1 deletion cmd/projects/info/cmd_info_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ import (
"testing"

cmdmocks "github.com/Smartling/smartling-cli/cmd/projects/mocks"
projectconfig "github.com/Smartling/smartling-cli/services/projects/config"
srvmocks "github.com/Smartling/smartling-cli/services/projects/mocks"

"github.com/stretchr/testify/mock"
Expand All @@ -19,7 +20,7 @@ func TestNewInfoCmd(t *testing.T) {
if _, err := fmt.Fprintf(buf, "RunInfo was called with %d args\n", len(args)); err != nil {
t.Fatal(err)
}
}).Return(nil)
}).Return(projectconfig.Extended{}, nil)

initializer := cmdmocks.NewMockSrvInitializer(t)
initializer.On("InitProjectsSrv", mock.Anything).Return(projectsSrv, nil)
Expand Down
2 changes: 1 addition & 1 deletion go.mod
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@ require (
github.com/stretchr/testify v1.10.0
github.com/tcnksm/go-input v0.0.0-20180404061846-548a7d7a8ee8
golang.org/x/sync v0.20.0
golang.org/x/term v0.43.0
)

require (
Expand Down Expand Up @@ -47,7 +48,6 @@ require (
github.com/zazab/zhash v0.0.0-20221031090444-2b0d50417446 // indirect
golang.org/x/crypto v0.51.0 // indirect
golang.org/x/sys v0.44.0 // indirect
golang.org/x/term v0.43.0 // indirect
golang.org/x/text v0.37.0 // indirect
gopkg.in/yaml.v3 v3.0.1 // indirect
)
Loading