Files
Tosone c9ac979c95 refactor: migrate json encoding to json/v2 package across codebase
Replace all usages of standard encoding/json with encoding/json/v2 and jsontext
where applicable, update decoding logic to use json.UnmarshalRead and
json.MarshalWrite instead of manual encoder/decoder instances.

Verification:
go test -race ./pkg/... ./cmd/...
2026-08-27 14:28:01 +08:00

329 lines
9.1 KiB
Go

// Copyright 2026 sigma
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
package main
import (
"context"
"encoding/json/jsontext"
"encoding/json/v2"
"fmt"
"net/http"
"net/url"
"os"
"path/filepath"
"strings"
"time"
"github.com/spf13/cobra"
"resty.dev/v3"
"github.com/go-sigma/sigma/pkg/api/enums"
)
const defaultTimeout = 30 * time.Second
type options struct {
server string
credentialsFile string
timeout time.Duration
debug bool
client *client
}
type client struct {
baseURL string
username string
password string
cli *resty.Client
}
type credentials struct {
Server string `json:"server"`
Username string `json:"username,omitempty"`
Password string `json:"password,omitempty"`
}
func newRootCmd() *cobra.Command {
opts := &options{}
command := &cobra.Command{
Use: "sigma-cli",
Short: "Operate a remote sigma server through HTTP APIs",
SilenceUsage: true,
PersistentPreRunE: func(command *cobra.Command, _ []string) error {
if command.Name() == "login" {
return nil
}
return opts.initialize(command.Context(), command)
},
}
command.PersistentFlags().StringVar(&opts.server, "server", envOrDefault("SIGMA_SERVER", "http://localhost:3000"), "sigma server address")
command.PersistentFlags().StringVar(&opts.credentialsFile, "credentials-file", defaultCredentialsFile(), "credentials file")
command.PersistentFlags().DurationVar(&opts.timeout, "timeout", defaultTimeout, "HTTP request timeout")
command.PersistentFlags().BoolVar(&opts.debug, "debug", false, "enable HTTP request and response debug logging")
command.AddCommand(
newLoginCmd(opts),
newNamespaceCmd(opts),
newRepositoryCmd(opts),
newTagCmd(opts),
newArtifactCmd(opts),
newMemberCmd(opts),
newUserCmd(opts),
)
return command
}
func (opts *options) initialize(_ context.Context, command *cobra.Command) error {
cred, err := loadCredentials(opts.credentialsFile)
if err != nil {
return err
}
server := strings.TrimRight(strings.TrimSpace(opts.server), "/")
if !command.Root().PersistentFlags().Changed("server") && strings.TrimSpace(os.Getenv("SIGMA_SERVER")) == "" && cred.Server != "" {
server = strings.TrimRight(cred.Server, "/")
}
username := strings.TrimSpace(cred.Username)
password := cred.Password
if server == "" {
return fmt.Errorf("server is required")
}
if username == "" || password == "" {
return fmt.Errorf("basic auth credentials are required; run `sigma-cli login --server <addr> -u <username> -p <password>` first")
}
opts.client = newClient(server, username, password, opts.timeout, opts.debug)
return nil
}
func newLoginCmd(opts *options) *cobra.Command {
var username string
var password string
command := &cobra.Command{
Use: "login",
Short: "Login and save credentials locally",
RunE: func(command *cobra.Command, _ []string) error {
server := strings.TrimRight(strings.TrimSpace(opts.server), "/")
if server == "" {
return fmt.Errorf("server is required")
}
loginClient := newClient(server, "", "", opts.timeout, opts.debug)
if err := loginClient.do(command.Context(), http.MethodPost, "/api/v1/users/login", nil, nil, nil, basicAuth{
username: username,
password: password,
}); err != nil {
return fmt.Errorf("login failed: %w", err)
}
cred := credentials{
Server: server,
Username: username,
Password: password,
}
if err := saveCredentials(opts.credentialsFile, cred); err != nil {
return err
}
return printJSON(map[string]string{
"status": "logged_in",
"credentials_file": opts.credentialsFile,
"server": server,
})
},
}
command.Flags().StringVarP(&username, "username", "u", os.Getenv("SIGMA_USERNAME"), "login username")
command.Flags().StringVarP(&password, "password", "p", os.Getenv("SIGMA_PASSWORD"), "login password")
_ = command.MarkFlagRequired("username")
_ = command.MarkFlagRequired("password")
return command
}
func newClient(baseURL, username, password string, timeout time.Duration, debug bool) *client {
cli := resty.New().
SetTimeout(timeout).
SetDebug(debug)
return &client{
baseURL: baseURL,
username: username,
password: password,
cli: cli,
}
}
func (cli *client) do(ctx context.Context, method, endpoint string, query url.Values, body any, out any, auth basicAuth) error {
requestURL := cli.baseURL + endpoint
request := cli.cli.R().
SetContext(ctx).
SetHeader("Accept", "application/json")
if len(query) > 0 {
request.SetQueryParamsFromValues(query)
}
if body != nil {
request.SetHeader("Content-Type", "application/json").
SetBody(body)
}
if auth.username != "" || auth.password != "" {
request.SetBasicAuth(auth.username, auth.password)
} else if cli.username != "" || cli.password != "" {
request.SetBasicAuth(cli.username, cli.password)
}
response, err := request.Execute(method, requestURL)
if err != nil {
return err
}
if response.StatusCode() < http.StatusOK || response.StatusCode() >= http.StatusMultipleChoices {
data := response.Bytes()
return fmt.Errorf("%s %s: %s", method, endpoint, formatErrorBody(data))
}
data := response.Bytes()
if out == nil || len(data) == 0 {
return nil
}
if err := json.Unmarshal(data, out); err != nil {
return fmt.Errorf("decode response: %w", err)
}
return nil
}
type basicAuth struct {
username string
password string
}
func formatErrorBody(data []byte) string {
pretty := jsontext.Value(data).Clone()
if pretty.Indent(jsontext.WithIndent(" ")) == nil {
return strings.TrimSpace(string(pretty))
}
return strings.TrimSpace(string(data))
}
func printJSON(value any) error {
data, err := json.Marshal(value, jsontext.WithIndent(" "))
if err != nil {
return err
}
_, err = os.Stdout.Write(append(data, '\n'))
return err
}
func envOrDefault(name, fallback string) string {
if value := strings.TrimSpace(os.Getenv(name)); value != "" {
return value
}
return fallback
}
func defaultCredentialsFile() string {
if value := strings.TrimSpace(os.Getenv("SIGMA_CREDENTIALS_FILE")); value != "" {
return value
}
home, err := os.UserHomeDir()
if err != nil || strings.TrimSpace(home) == "" {
return ".sigma-cli-credentials.json"
}
return filepath.Join(home, ".sigma", "cli-credentials.json")
}
func loadCredentials(path string) (credentials, error) {
data, err := os.ReadFile(path)
if err != nil {
if os.IsNotExist(err) {
return credentials{}, nil
}
return credentials{}, fmt.Errorf("read credentials file: %w", err)
}
var cred credentials
if err := json.Unmarshal(data, &cred); err != nil {
return credentials{}, fmt.Errorf("decode credentials file: %w", err)
}
return cred, nil
}
func saveCredentials(path string, cred credentials) error {
if err := os.MkdirAll(filepath.Dir(path), 0o700); err != nil {
return fmt.Errorf("create credentials directory: %w", err)
}
// #nosec G117 -- The CLI intentionally persists Basic Auth credentials with 0600 permissions.
data, err := json.Marshal(cred, jsontext.WithIndent(" "))
if err != nil {
return err
}
data = append(data, '\n')
if err := os.WriteFile(path, data, 0o600); err != nil {
return fmt.Errorf("write credentials file: %w", err)
}
return nil
}
func commonListFlags(command *cobra.Command, name *string, page, limit *int, sort, method *string) {
command.Flags().StringVar(name, "name", "", "filter by name")
command.Flags().IntVar(page, "page", 1, "page number")
command.Flags().IntVar(limit, "limit", 10, "page size")
command.Flags().StringVar(sort, "sort", "", "sort field")
command.Flags().StringVar(method, "method", "", "sort method: asc or desc")
}
func commonListQuery(name string, page, limit int, sort, method string) url.Values {
query := url.Values{}
if name != "" {
query.Set("name", name)
}
if page > 0 {
query.Set("page", fmt.Sprintf("%d", page))
}
if limit > 0 {
query.Set("limit", fmt.Sprintf("%d", limit))
}
if sort != "" {
query.Set("sort", sort)
}
if method != "" {
query.Set("method", method)
}
return query
}
func optionalString(command *cobra.Command, name string) *string {
if !command.Flags().Changed(name) {
return nil
}
value, _ := command.Flags().GetString(name)
return &value
}
func optionalInt64(command *cobra.Command, name string) *int64 {
if !command.Flags().Changed(name) {
return nil
}
value, _ := command.Flags().GetInt64(name)
return &value
}
func optionalVisibility(command *cobra.Command) (*enums.Visibility, error) {
if !command.Flags().Changed("visibility") {
return nil, nil
}
value, _ := command.Flags().GetString("visibility")
visibility, err := enums.ParseVisibility(value)
if err != nil {
return nil, err
}
return &visibility, nil
}
func endpoint(format string, args ...any) string {
return fmt.Sprintf(format, args...)
}