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
33 changes: 33 additions & 0 deletions .goreleaser.yaml
Original file line number Diff line number Diff line change
@@ -0,0 +1,33 @@
version: 2

builds:
- main: ./cmd/solactl
binary: solactl
ldflags:
- -s -w
env:
- CGO_ENABLED=0
goos:
- darwin
- linux
- windows
goarch:
- amd64
- arm64

archives:
- format: tar.gz
name_template: "{{ .ProjectName }}_{{ .Version }}_{{ .Os }}_{{ .Arch }}"
format_overrides:
- goos: windows
format: zip

checksum:
name_template: "checksums.txt"

changelog:
sort: asc
filters:
exclude:
- "^docs:"
- "^test:"
19 changes: 19 additions & 0 deletions Makefile
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
.PHONY: build test test-race lint clean fuzz

build:
go build -o bin/solactl ./cmd/solactl

test:
go test ./... -count=1

test-race:
go test ./... -race -count=1

fuzz:
go test ./pkg/validation/... -fuzz=. -fuzztime=10s

lint:
go vet ./...

clean:
rm -rf bin/
2 changes: 1 addition & 1 deletion cmd/configure_show.go
Original file line number Diff line number Diff line change
Expand Up @@ -27,7 +27,7 @@ func init() {
)

if err := cfg.Validate(); err != nil {
_, _ = fmt.Fprintf(out(), "\n⚠ %v\n", err)
_, _ = fmt.Fprintf(errOut(), "\n⚠ %v\n", err)
}
return nil
},
Expand Down
28 changes: 20 additions & 8 deletions cmd/configure_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -90,7 +90,9 @@ func TestConfigureShow_NoConfig(t *testing.T) {

var buf bytes.Buffer
outWriter = &buf
t.Cleanup(func() { outWriter = nil })
var errBuf bytes.Buffer
errWriter = &errBuf
t.Cleanup(func() { outWriter = nil; errWriter = nil })

rootCmd.SetArgs([]string{"configure", "show"})
err := rootCmd.Execute()
Expand All @@ -102,9 +104,13 @@ func TestConfigureShow_NoConfig(t *testing.T) {
if !strings.Contains(output, "API Key") {
t.Errorf("expected API Key label, got: %s", output)
}
// Should show warning about missing config
if !strings.Contains(output, "⚠") {
t.Errorf("expected warning for empty config, got: %s", output)
// Warning should go to stderr, not stdout
errOutput := errBuf.String()
if !strings.Contains(errOutput, "⚠") {
t.Errorf("expected warning for empty config on stderr, got: %s", errOutput)
}
if strings.Contains(output, "⚠") {
t.Errorf("warning should not appear on stdout, got: %s", output)
}
resetFlags()
}
Expand Down Expand Up @@ -211,17 +217,23 @@ func TestConfigureShow_ValidationWarning(t *testing.T) {

var buf bytes.Buffer
outWriter = &buf
t.Cleanup(func() { outWriter = nil })
var errBuf bytes.Buffer
errWriter = &errBuf
t.Cleanup(func() { outWriter = nil; errWriter = nil })

rootCmd.SetArgs([]string{"configure", "show"})
err := rootCmd.Execute()
if err != nil {
t.Fatalf("unexpected error: %v", err)
}

output := buf.String()
if !strings.Contains(output, "⚠") {
t.Errorf("expected validation warning (⚠) for empty API Key, got: %s", output)
errOutput := errBuf.String()
if !strings.Contains(errOutput, "⚠") {
t.Errorf("expected validation warning (⚠) for empty API Key on stderr, got: %s", errOutput)
}
stdoutOutput := buf.String()
if strings.Contains(stdoutOutput, "⚠") {
t.Errorf("warning should not appear on stdout, got: %s", stdoutOutput)
}
resetFlags()
}
Expand Down
18 changes: 17 additions & 1 deletion cmd/root.go
Original file line number Diff line number Diff line change
Expand Up @@ -5,11 +5,13 @@ import (
"io"
"os"
"os/signal"
"runtime"
"syscall"
"time"

"github.com/spf13/cobra"

"github.com/solapi/solactl/internal/version"
"github.com/solapi/solactl/pkg/client"
"github.com/solapi/solactl/pkg/config"
"github.com/solapi/solactl/pkg/logger"
Expand All @@ -32,6 +34,10 @@ var (
// outWriter is the destination for command output. Tests override this.
var outWriter io.Writer

// errWriter is the destination for informational/diagnostic output (stderr).
// Tests override this to capture stderr messages.
var errWriter io.Writer

// clientOverride is set by tests to bypass loadConfig and use a test client.
var clientOverride *client.Client

Expand Down Expand Up @@ -103,7 +109,9 @@ func newClient() (*client.Client, error) {
return nil, err
}
logger.Debug("SOLAPI 클라이언트 생성: %s", client.BaseURL)
return client.New(cfg.APIKey, cfg.APISecret), nil
c := client.New(cfg.APIKey, cfg.APISecret)
c.UserAgent = "solactl/" + version.Version + " (" + runtime.GOOS + "/" + runtime.GOARCH + ")"
return c, nil
}

// out returns the current output writer, falling back to os.Stdout.
Expand All @@ -114,6 +122,14 @@ func out() io.Writer {
return os.Stdout
}

// errOut returns the current error/diagnostic writer, falling back to os.Stderr.
func errOut() io.Writer {
if errWriter != nil {
return errWriter
}
return os.Stderr
}

// printer returns a configured output printer.
func printer() *output.Printer {
return &output.Printer{Writer: out(), JSONMode: flagJSON}
Expand Down
75 changes: 73 additions & 2 deletions cmd/send.go
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,9 @@ import (

"github.com/solapi/solactl/internal/version"
"github.com/solapi/solactl/pkg/client"
"github.com/solapi/solactl/pkg/output"
"github.com/solapi/solactl/pkg/types"
"github.com/solapi/solactl/pkg/validation"
)

var sendCmd = &cobra.Command{
Expand All @@ -25,7 +27,9 @@ var (
sendFlagFrom string
sendFlagText string
sendFlagScheduled string
sendFlagFile string // CSV file for bulk sending
sendFlagFile string // CSV file for bulk sending
sendFlagSkipValidation bool
sendFlagStrict bool
)

func init() {
Expand All @@ -34,6 +38,8 @@ func init() {
sendCmd.PersistentFlags().StringVar(&sendFlagText, "text", "", "메시지 내용")
sendCmd.PersistentFlags().StringVar(&sendFlagScheduled, "scheduled", "", "예약 발송 시간 (ISO 8601)")
sendCmd.PersistentFlags().StringVar(&sendFlagFile, "file", "", "수신자 CSV 파일 경로")
sendCmd.PersistentFlags().BoolVar(&sendFlagSkipValidation, "skip-validation", false, "클라이언트 사이드 검증 건너뛰기")
sendCmd.PersistentFlags().BoolVar(&sendFlagStrict, "strict", false, "엄격 검증 모드 활성화")

rootCmd.AddCommand(sendCmd)
}
Expand All @@ -56,18 +62,49 @@ func parseRecipients(to string) []string {
return result
}

// sendMessages builds a SendRequest with the agent and posts to send-many/detail.
// sendMessages validates and sends messages via the API.
// If the message list exceeds maxBatchSize, it auto-splits into multiple API calls.
func sendMessages(c *client.Client, msgs []types.Message) error {
// Client-side validation (skip with --skip-validation)
if !sendFlagSkipValidation {
opts := validation.Options{
Strict: sendFlagStrict,
AutoTypeDetect: true,
}
if errs := validation.ValidateMessages(msgs, opts); errs != nil {
p := &output.Printer{Writer: errOut()}
fmt.Fprintf(errOut(), "검증 오류 %d건:\n", len(errs))
headers := []string{"번호", "필드", "오류코드", "메시지"}
var rows [][]string
for _, e := range errs {
rows = append(rows, []string{
fmt.Sprintf("[%d]", e.Index+1),
e.Field,
e.Code,
e.Message,
})
}
p.FormatTable(headers, rows)
return fmt.Errorf("검증 실패: %d건의 오류가 발견되었습니다", len(errs))
}
}

showList := true
agent := types.DefaultAgent(version.Version)
totalBatches := (len(msgs) + maxBatchSize - 1) / maxBatchSize
batchNum := 0

for start := 0; start < len(msgs); start += maxBatchSize {
end := start + maxBatchSize
if end > len(msgs) {
end = len(msgs)
}
batch := msgs[start:end]
batchNum++

if totalBatches > 1 {
fmt.Fprintf(errOut(), "[%d/%d] %d건 발송 중...\n", batchNum, totalBatches, len(batch))
}

req := types.SendRequest{
Messages: batch,
Expand Down Expand Up @@ -202,5 +239,39 @@ func buildMessagesFromFlags(msgBuilder func(to string) types.Message) ([]types.M
return msgs, nil
}

// resolveFrom returns the --from value if set, otherwise queries the senderid API
// and auto-selects: 1 active → use it, 0 or 2+ → error with guidance.
func resolveFrom(c *client.Client) (string, error) {
if sendFlagFrom != "" {
return sendFlagFrom, nil
}

raw, err := c.Get(ctx(), "senderid/v1/numbers/active", nil)
if err != nil {
return "", fmt.Errorf("발신번호(--from)를 입력하세요 (발신번호 조회 실패: %w)", err)
}

var senders []types.SenderID
if err := json.Unmarshal(raw, &senders); err != nil {
return "", fmt.Errorf("발신번호 자동 선택 실패 (응답 파싱 오류: %w). --from으로 직접 지정하세요", err)
}

switch len(senders) {
case 0:
return "", fmt.Errorf("등록된 활성 발신번호가 없습니다. solactl senderid list로 확인하세요")
case 1:
selected := senders[0].PhoneNumber
fmt.Fprintf(errOut(), "발신번호 자동 선택: %s\n", selected)
return selected, nil
default:
var lines []string
for _, s := range senders {
lines = append(lines, " "+s.PhoneNumber)
}
return "", fmt.Errorf("활성 발신번호가 %d개입니다. --from으로 지정하세요:\n%s",
len(senders), strings.Join(lines, "\n"))
}
}

// boolPtr returns a pointer to the given bool value.
func boolPtr(v bool) *bool { return &v }
18 changes: 11 additions & 7 deletions cmd/send_lms.go
Original file line number Diff line number Diff line change
Expand Up @@ -30,15 +30,16 @@ func runSendLMS(cmd *cobra.Command, args []string) error {
var msgs []types.Message

if sendFlagFile != "" {
if sendFlagFrom == "" {
return fmt.Errorf("발신번호(--from)를 입력하세요")
from, err := resolveFrom(c)
if err != nil {
return err
}
msgs, err = loadCSVMessages(sendFlagFile, sendFlagFrom, sendFlagText)
msgs, err = loadCSVMessages(sendFlagFile, from, sendFlagText)
if err != nil {
return err
}
// Set subject on all CSV-loaded messages.
for i := range msgs {
msgs[i].Type = "LMS"
msgs[i].Subject = sendLMSFlagSubject
}
} else {
Expand All @@ -48,14 +49,17 @@ func runSendLMS(cmd *cobra.Command, args []string) error {
if sendFlagText == "" {
return fmt.Errorf("메시지 내용(--text)을 입력하세요")
}
if sendFlagFrom == "" {
return fmt.Errorf("발신번호(--from)를 입력하세요")

from, err := resolveFrom(c)
if err != nil {
return err
}

msgs, err = buildMessagesFromFlags(func(to string) types.Message {
return types.Message{
To: to,
From: sendFlagFrom,
From: from,
Type: "LMS",
Text: sendFlagText,
Subject: sendLMSFlagSubject,
}
Expand Down
9 changes: 6 additions & 3 deletions cmd/send_mms.go
Original file line number Diff line number Diff line change
Expand Up @@ -43,8 +43,10 @@ func runSendMMS(cmd *cobra.Command, args []string) error {
if sendFlagText == "" {
return fmt.Errorf("메시지 내용(--text)을 입력하세요")
}
if sendFlagFrom == "" {
return fmt.Errorf("발신번호(--from)를 입력하세요")

from, err := resolveFrom(c)
if err != nil {
return err
}

imageID, err := uploadImage(c, sendMMSFlagImage, "MMS")
Expand All @@ -55,7 +57,8 @@ func runSendMMS(cmd *cobra.Command, args []string) error {
msgs, err := buildMessagesFromFlags(func(to string) types.Message {
return types.Message{
To: to,
From: sendFlagFrom,
From: from,
Type: "MMS",
Text: sendFlagText,
Subject: sendMMSFlagSubject,
ImageID: imageID,
Expand Down
12 changes: 7 additions & 5 deletions cmd/send_rcs.go
Original file line number Diff line number Diff line change
Expand Up @@ -44,16 +44,18 @@ func runSendRCS(cmd *cobra.Command, args []string) error {
if sendFlagTo == "" && sendFlagFile == "" {
return fmt.Errorf("수신번호(--to)를 입력하세요")
}
if sendFlagFrom == "" {
return fmt.Errorf("발신번호(--from)를 입력하세요")
}
if sendRCSFlagBrandID == "" {
return fmt.Errorf("RCS 브랜드 ID(--brand-id)를 입력하세요")
}
if sendFlagText == "" && sendRCSFlagTemplateID == "" {
return fmt.Errorf("메시지 내용(--text)을 입력하세요 (또는 --template-id 사용)")
}

from, err := resolveFrom(c)
if err != nil {
return err
}

variables, err := parseVariables(sendRCSFlagVariables)
if err != nil {
return err
Expand Down Expand Up @@ -83,7 +85,7 @@ func runSendRCS(cmd *cobra.Command, args []string) error {
var msgs []types.Message

if sendFlagFile != "" {
msgs, err = loadCSVMessages(sendFlagFile, sendFlagFrom, sendFlagText)
msgs, err = loadCSVMessages(sendFlagFile, from, sendFlagText)
if err != nil {
return err
}
Expand All @@ -98,7 +100,7 @@ func runSendRCS(cmd *cobra.Command, args []string) error {
msgs, err = buildMessagesFromFlags(func(to string) types.Message {
return types.Message{
To: to,
From: sendFlagFrom,
From: from,
Text: sendFlagText,
Subject: sendRCSFlagSubject,
ImageID: imageID,
Expand Down
Loading