Skip to content
Merged
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
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
26 changes: 18 additions & 8 deletions cmd/cmd_root.go
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@ var (
insecure bool
proxy string
verbose int
showConfig bool

isInit bool
isFiles bool
Expand All @@ -33,14 +34,8 @@ func NewRootCmd() *cobra.Command {
Version: "3.1",
Long: `Manage translation files using Smartling CLI.
Complete documentation is available at https://www.smartling.com`,
PersistentPreRun: func(cmd *cobra.Command, _ []string) {
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")
PersistentPreRunE: func(cmd *cobra.Command, _ []string) error {
return RunRootPersistentPreRun(cmd)
},
RunE: func(cmd *cobra.Command, args []string) error {
if len(args) == 0 && cmd.Flags().NFlag() == 0 {
Expand Down Expand Up @@ -74,6 +69,21 @@ 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
}

func RunRootPersistentPreRun(cmd *cobra.Command) error {
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")

return ShowConfigBanner(cmd.Context())
}
81 changes: 81 additions & 0 deletions cmd/config_banner.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,81 @@
package cmd

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

output "github.com/Smartling/smartling-cli/output/projects"
"github.com/Smartling/smartling-cli/services/helpers/rlog"
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.
func ShowConfigBanner(ctx context.Context) error {
if !showConfig || isInit {
return nil
}
config, err := Config()
if err != nil {
return fmt.Errorf("failed to read config: %w", err)
}
var extendedConfig projectconfig.Extended
extendedConfig.InjectConfig(config)
client, err := Client(ctx)
if err != nil {
rlog.Errorf("failed to create API client: %s", err)
} else if config.ProjectID != "" {
projectDetails, err := client.GetProjectDetails(ctx, config.ProjectID)
if err != nil {
rlog.Errorf("failed to fetch project details: %s", err)
}
if projectDetails != nil {
extendedConfig.InjectProject(*projectDetails)
}
}
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: 6 additions & 1 deletion 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,7 +52,10 @@ 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 {
if err := cmd.RunRootPersistentPreRun(c); err != nil {
return err
}
if !slices.Contains(allowedOutputs, outputFormat) {
return fmt.Errorf("invalid output: %s (allowed: %s)", outputFormat, joinedAllowedOutputs)
}
Expand Down
7 changes: 6 additions & 1 deletion 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,7 +37,10 @@ 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 {
if err := cmd.RunRootPersistentPreRun(c); err != nil {
return err
}
if !slices.Contains(allowedOutputs, outputFormat) {
return fmt.Errorf("invalid output: %s (allowed: %s)", outputFormat, joinedAllowedOutputs)
}
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
)
56 changes: 56 additions & 0 deletions output/projects/info.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,56 @@
package projects

import (
"fmt"
"io"
"os"

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

func RenderTable(config projectconfig.Extended) error {
tableWriter := table.NewTableWriter(os.Stdout)
for _, row := range buildInfoRows(config) {
if _, err := fmt.Fprintf(tableWriter, "%s\t%s\n", row...); err != nil {
return err
}
}
return table.Render(tableWriter)
}

// RenderPlain writes a multi-line summary of the resolved configuration to w.
// Used by the --show-config persistent flag. Every line is prefixed with "> "
// so the banner is easy to grep out of mixed output if needed.
func RenderPlain(w io.Writer, config projectconfig.Extended) error {
lines := []string{
"Smartling CLI configuration:",
fmt.Sprintf(" Config file: %s", config.ConfigFile),
fmt.Sprintf(" User: %s", config.UserID),
fmt.Sprintf(" Account: %s", config.AccountUID),
fmt.Sprintf(" Project: %s", config.ProjectID),
fmt.Sprintf(" Project Name: %s", config.Name),
fmt.Sprintf(" Locale: %s", config.Locale),
fmt.Sprintf(" Status: %s", config.Status),
fmt.Sprintf(" Sources: %s", config.Sources),
}
for _, line := range lines {
if _, err := fmt.Fprintf(w, "> %s\n", line); err != nil {
return err
}
}
return nil
}

func buildInfoRows(config projectconfig.Extended) [][]any {
return [][]any{
{"ID", config.ProjectID},
{"ACCOUNT", config.AccountUID},
{"NAME", config.Name},
{"LOCALE", config.Locale},
{"STATUS", config.Status},
{"USER", config.UserID},
{"CONFIG", config.ConfigFile},
{"SOURCES", config.Sources},
}
}
Loading