feat: add htsy CLI tool for Haitun private domain API
This commit is contained in:
@@ -0,0 +1,606 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"flag"
|
||||
"fmt"
|
||||
"os"
|
||||
"runtime"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
var version = "dev"
|
||||
|
||||
func main() {
|
||||
if err := run(os.Args[1:]); err != nil {
|
||||
fmt.Fprintln(os.Stderr, "error:", err)
|
||||
os.Exit(1)
|
||||
}
|
||||
}
|
||||
|
||||
func run(args []string) error {
|
||||
if len(args) == 0 {
|
||||
printUsage()
|
||||
return nil
|
||||
}
|
||||
|
||||
switch args[0] {
|
||||
case "login":
|
||||
return runLogin(args[1:], false)
|
||||
case "account-login":
|
||||
return runLogin(args[1:], true)
|
||||
case "register":
|
||||
return runRegister(args[1:])
|
||||
case "send-code":
|
||||
return runSendCode(args[1:])
|
||||
case "call":
|
||||
return runCall(args[1:])
|
||||
case "status":
|
||||
return runStatus(args[1:])
|
||||
case "logout":
|
||||
return runLogout(args[1:])
|
||||
case "config":
|
||||
return runConfig(args[1:])
|
||||
case "version", "--version", "-v":
|
||||
fmt.Printf("htsy %s %s/%s\n", version, runtime.GOOS, runtime.GOARCH)
|
||||
return nil
|
||||
case "help", "--help", "-h":
|
||||
printUsage()
|
||||
return nil
|
||||
default:
|
||||
return fmt.Errorf("unknown command %q", args[0])
|
||||
}
|
||||
}
|
||||
|
||||
func runLogin(args []string, accountLogin bool) error {
|
||||
fs := flag.NewFlagSet("login", flag.ContinueOnError)
|
||||
fs.SetOutput(os.Stderr)
|
||||
|
||||
var username, phone, password, code, baseURL, configPath string
|
||||
var timeout time.Duration
|
||||
var printToken bool
|
||||
fs.StringVar(&username, "username", "", "user login username")
|
||||
fs.StringVar(&phone, "phone", "", "account login phone")
|
||||
fs.StringVar(&password, "password", "", "password; defaults to HTSY_PASSWORD when omitted")
|
||||
fs.StringVar(&code, "code", "", "sms code for account phone login")
|
||||
fs.StringVar(&baseURL, "base-url", "", "API base URL")
|
||||
fs.StringVar(&configPath, "config", "", "config file path")
|
||||
fs.DurationVar(&timeout, "timeout", 30*time.Second, "request timeout")
|
||||
fs.BoolVar(&printToken, "print-token", false, "print token in command output")
|
||||
|
||||
if err := fs.Parse(args); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if password == "" && code == "" {
|
||||
password = os.Getenv("HTSY_PASSWORD")
|
||||
}
|
||||
if baseURL == "" {
|
||||
baseURL = os.Getenv("HTSY_BASE_URL")
|
||||
}
|
||||
|
||||
loginID := username
|
||||
if loginID == "" {
|
||||
loginID = phone
|
||||
}
|
||||
if loginID == "" && fs.NArg() > 0 {
|
||||
loginID = fs.Arg(0)
|
||||
}
|
||||
if password == "" && fs.NArg() > 1 {
|
||||
password = fs.Arg(1)
|
||||
}
|
||||
|
||||
if loginID == "" {
|
||||
if accountLogin {
|
||||
return fmt.Errorf("--phone is required")
|
||||
}
|
||||
return fmt.Errorf("--username or --phone is required")
|
||||
}
|
||||
|
||||
endpoint, payloadKey, kind := loginRoute(loginID, accountLogin)
|
||||
loginName := loginID
|
||||
payload := map[string]string{payloadKey: loginID}
|
||||
if accountLogin {
|
||||
if password == "" && code == "" {
|
||||
return fmt.Errorf("--password or --code is required")
|
||||
}
|
||||
} else if password == "" {
|
||||
return fmt.Errorf("--password is required or set HTSY_PASSWORD")
|
||||
}
|
||||
if password != "" {
|
||||
payload["password"] = password
|
||||
}
|
||||
if code != "" {
|
||||
payload["code"] = code
|
||||
}
|
||||
|
||||
body, err := json.Marshal(payload)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
resolvedBaseURL, err := baseURLFromConfigOrFlag(configPath, baseURL)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
resp, err := postJSON(context.Background(), requestOptions{
|
||||
BaseURL: resolvedBaseURL,
|
||||
Path: endpoint,
|
||||
Body: body,
|
||||
NoAuth: true,
|
||||
Timeout: timeout,
|
||||
})
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
token, err := tokenFromResponse(resp.Body)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
cfg := Config{
|
||||
BaseURL: resolvedBaseURL,
|
||||
Token: token,
|
||||
Username: loginName,
|
||||
LoginKind: kind,
|
||||
}
|
||||
if err := saveConfig(configPath, cfg); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
path, err := resolveConfigPath(configPath)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
out := map[string]any{
|
||||
"ok": true,
|
||||
"baseUrl": resolvedBaseURL,
|
||||
"configPath": path,
|
||||
"loginKind": kind,
|
||||
"username": loginName,
|
||||
"tokenSaved": true,
|
||||
}
|
||||
if printToken {
|
||||
out["token"] = token
|
||||
}
|
||||
return printObject(out)
|
||||
}
|
||||
|
||||
func runRegister(args []string) error {
|
||||
fs := flag.NewFlagSet("register", flag.ContinueOnError)
|
||||
fs.SetOutput(os.Stderr)
|
||||
|
||||
var username, password, smsCode, inviteToken, baseURL, configPath string
|
||||
var timeout time.Duration
|
||||
var printToken bool
|
||||
fs.StringVar(&username, "username", "", "username, normally the phone number")
|
||||
fs.StringVar(&password, "password", "", "password; defaults to HTSY_PASSWORD when omitted")
|
||||
fs.StringVar(&smsCode, "sms-code", "", "sms verification code")
|
||||
fs.StringVar(&inviteToken, "invite-token", "", "optional invite token")
|
||||
fs.StringVar(&baseURL, "base-url", "", "API base URL")
|
||||
fs.StringVar(&configPath, "config", "", "config file path")
|
||||
fs.DurationVar(&timeout, "timeout", 30*time.Second, "request timeout")
|
||||
fs.BoolVar(&printToken, "print-token", false, "print token in command output")
|
||||
if err := fs.Parse(args); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if username == "" && fs.NArg() > 0 {
|
||||
username = fs.Arg(0)
|
||||
}
|
||||
if password == "" && fs.NArg() > 1 {
|
||||
password = fs.Arg(1)
|
||||
}
|
||||
if smsCode == "" && fs.NArg() > 2 {
|
||||
smsCode = fs.Arg(2)
|
||||
}
|
||||
if password == "" {
|
||||
password = os.Getenv("HTSY_PASSWORD")
|
||||
}
|
||||
if username == "" {
|
||||
return fmt.Errorf("--username is required")
|
||||
}
|
||||
if password == "" {
|
||||
return fmt.Errorf("--password is required or set HTSY_PASSWORD")
|
||||
}
|
||||
if smsCode == "" {
|
||||
return fmt.Errorf("--sms-code is required")
|
||||
}
|
||||
if baseURL == "" {
|
||||
baseURL = os.Getenv("HTSY_BASE_URL")
|
||||
}
|
||||
resolvedBaseURL, err := baseURLFromConfigOrFlag(configPath, baseURL)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
payload := map[string]string{
|
||||
"username": username,
|
||||
"password": password,
|
||||
"smsCode": smsCode,
|
||||
}
|
||||
if inviteToken != "" {
|
||||
payload["inviteToken"] = inviteToken
|
||||
}
|
||||
body, err := json.Marshal(payload)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
resp, err := postJSON(context.Background(), requestOptions{
|
||||
BaseURL: resolvedBaseURL,
|
||||
Path: "/api/account/user/register",
|
||||
Body: body,
|
||||
NoAuth: true,
|
||||
Timeout: timeout,
|
||||
})
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
token, err := tokenFromResponse(resp.Body)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
cfg := Config{
|
||||
BaseURL: resolvedBaseURL,
|
||||
Token: token,
|
||||
Username: username,
|
||||
LoginKind: "user",
|
||||
}
|
||||
if err := saveConfig(configPath, cfg); err != nil {
|
||||
return err
|
||||
}
|
||||
path, err := resolveConfigPath(configPath)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
out := map[string]any{
|
||||
"ok": true,
|
||||
"baseUrl": resolvedBaseURL,
|
||||
"configPath": path,
|
||||
"loginKind": "user",
|
||||
"username": username,
|
||||
"tokenSaved": true,
|
||||
}
|
||||
if printToken {
|
||||
out["token"] = token
|
||||
}
|
||||
return printObject(out)
|
||||
}
|
||||
|
||||
func runSendCode(args []string) error {
|
||||
fs := flag.NewFlagSet("send-code", flag.ContinueOnError)
|
||||
fs.SetOutput(os.Stderr)
|
||||
|
||||
var phone, scene, baseURL, configPath string
|
||||
var timeout time.Duration
|
||||
var raw bool
|
||||
fs.StringVar(&phone, "phone", "", "phone number")
|
||||
fs.StringVar(&scene, "scene", "", "scene: register, resetPwd, login, bind")
|
||||
fs.StringVar(&baseURL, "base-url", "", "API base URL")
|
||||
fs.StringVar(&configPath, "config", "", "config file path")
|
||||
fs.DurationVar(&timeout, "timeout", 30*time.Second, "request timeout")
|
||||
fs.BoolVar(&raw, "raw", false, "print raw response")
|
||||
if err := fs.Parse(args); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if phone == "" && fs.NArg() > 0 {
|
||||
phone = fs.Arg(0)
|
||||
}
|
||||
if scene == "" && fs.NArg() > 1 {
|
||||
scene = fs.Arg(1)
|
||||
}
|
||||
if phone == "" {
|
||||
return fmt.Errorf("--phone is required")
|
||||
}
|
||||
if scene == "" {
|
||||
return fmt.Errorf("--scene is required")
|
||||
}
|
||||
if baseURL == "" {
|
||||
baseURL = os.Getenv("HTSY_BASE_URL")
|
||||
}
|
||||
resolvedBaseURL, err := baseURLFromConfigOrFlag(configPath, baseURL)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
body, err := json.Marshal(map[string]string{"phone": phone, "scene": scene})
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
resp, err := postJSON(context.Background(), requestOptions{
|
||||
BaseURL: resolvedBaseURL,
|
||||
Path: "/api/account/user/phone/sendCode",
|
||||
Body: body,
|
||||
NoAuth: true,
|
||||
Timeout: timeout,
|
||||
})
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return writeJSONResponse(os.Stdout, resp.Body, raw)
|
||||
}
|
||||
|
||||
func runCall(args []string) error {
|
||||
path, flagArgs, err := splitCallArgs(args)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
fs := flag.NewFlagSet("call", flag.ContinueOnError)
|
||||
fs.SetOutput(os.Stderr)
|
||||
|
||||
var data, baseURL, configPath, token string
|
||||
var timeout time.Duration
|
||||
var noAuth, raw bool
|
||||
fs.StringVar(&data, "d", "{}", "JSON request body, @file, or - for stdin")
|
||||
fs.StringVar(&data, "data", "{}", "JSON request body, @file, or - for stdin")
|
||||
fs.StringVar(&baseURL, "base-url", "", "API base URL")
|
||||
fs.StringVar(&configPath, "config", "", "config file path")
|
||||
fs.StringVar(&token, "token", "", "override bearer token")
|
||||
fs.DurationVar(&timeout, "timeout", 30*time.Second, "request timeout")
|
||||
fs.BoolVar(&noAuth, "no-auth", false, "do not attach bearer token")
|
||||
fs.BoolVar(&raw, "raw", false, "print raw response")
|
||||
if err := fs.Parse(flagArgs); err != nil {
|
||||
return err
|
||||
}
|
||||
if fs.NArg() > 0 {
|
||||
return fmt.Errorf("unexpected call arguments: %s", strings.Join(fs.Args(), " "))
|
||||
}
|
||||
|
||||
cfg, err := loadConfig(configPath)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if baseURL == "" {
|
||||
baseURL = os.Getenv("HTSY_BASE_URL")
|
||||
}
|
||||
if baseURL == "" {
|
||||
baseURL = cfg.BaseURL
|
||||
}
|
||||
if token == "" {
|
||||
token = os.Getenv("HTSY_TOKEN")
|
||||
}
|
||||
if token == "" {
|
||||
token = cfg.Token
|
||||
}
|
||||
if !noAuth && token == "" {
|
||||
return fmt.Errorf("not logged in; run htsy login first or pass --token")
|
||||
}
|
||||
body, err := readJSONData(data)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
resp, err := postJSON(context.Background(), requestOptions{
|
||||
BaseURL: baseURL,
|
||||
Path: path,
|
||||
Body: body,
|
||||
Token: token,
|
||||
NoAuth: noAuth,
|
||||
Timeout: timeout,
|
||||
})
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return writeJSONResponse(os.Stdout, resp.Body, raw)
|
||||
}
|
||||
|
||||
func splitCallArgs(args []string) (string, []string, error) {
|
||||
var endpoint string
|
||||
var flagArgs []string
|
||||
expectValue := false
|
||||
|
||||
for _, arg := range args {
|
||||
if expectValue {
|
||||
flagArgs = append(flagArgs, arg)
|
||||
expectValue = false
|
||||
continue
|
||||
}
|
||||
|
||||
if isCallValueFlag(arg) {
|
||||
flagArgs = append(flagArgs, arg)
|
||||
if !strings.Contains(arg, "=") {
|
||||
expectValue = true
|
||||
}
|
||||
continue
|
||||
}
|
||||
|
||||
if isCallBoolFlag(arg) || strings.HasPrefix(arg, "-") {
|
||||
flagArgs = append(flagArgs, arg)
|
||||
continue
|
||||
}
|
||||
|
||||
if endpoint == "" {
|
||||
endpoint = arg
|
||||
continue
|
||||
}
|
||||
|
||||
return "", nil, fmt.Errorf("unexpected call argument %q", arg)
|
||||
}
|
||||
|
||||
if expectValue {
|
||||
return "", nil, fmt.Errorf("missing value for call flag")
|
||||
}
|
||||
if endpoint == "" {
|
||||
return "", nil, fmt.Errorf("endpoint path is required")
|
||||
}
|
||||
|
||||
return endpoint, flagArgs, nil
|
||||
}
|
||||
|
||||
func isCallValueFlag(arg string) bool {
|
||||
for _, name := range []string{"-d", "--data", "-data", "--base-url", "-base-url", "--config", "-config", "--token", "-token", "--timeout", "-timeout"} {
|
||||
if arg == name || strings.HasPrefix(arg, name+"=") {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func isCallBoolFlag(arg string) bool {
|
||||
for _, name := range []string{"--no-auth", "-no-auth", "--raw", "-raw"} {
|
||||
if arg == name || strings.HasPrefix(arg, name+"=") {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func runStatus(args []string) error {
|
||||
fs := flag.NewFlagSet("status", flag.ContinueOnError)
|
||||
fs.SetOutput(os.Stderr)
|
||||
var configPath string
|
||||
fs.StringVar(&configPath, "config", "", "config file path")
|
||||
if err := fs.Parse(args); err != nil {
|
||||
return err
|
||||
}
|
||||
cfg, err := loadConfig(configPath)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
path, err := resolveConfigPath(configPath)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
tokenPrefix := ""
|
||||
if cfg.Token != "" {
|
||||
if len(cfg.Token) <= 8 {
|
||||
tokenPrefix = cfg.Token
|
||||
} else {
|
||||
tokenPrefix = cfg.Token[:8]
|
||||
}
|
||||
}
|
||||
return printObject(map[string]any{
|
||||
"baseUrl": cfg.BaseURL,
|
||||
"configPath": path,
|
||||
"loggedIn": cfg.Token != "",
|
||||
"loginKind": cfg.LoginKind,
|
||||
"username": cfg.Username,
|
||||
"tokenPrefix": tokenPrefix,
|
||||
"updatedAt": cfg.UpdatedAt,
|
||||
})
|
||||
}
|
||||
|
||||
func runLogout(args []string) error {
|
||||
fs := flag.NewFlagSet("logout", flag.ContinueOnError)
|
||||
fs.SetOutput(os.Stderr)
|
||||
var configPath string
|
||||
fs.StringVar(&configPath, "config", "", "config file path")
|
||||
if err := fs.Parse(args); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := clearLogin(configPath); err != nil {
|
||||
return err
|
||||
}
|
||||
return printObject(map[string]any{"ok": true})
|
||||
}
|
||||
|
||||
func runConfig(args []string) error {
|
||||
if len(args) == 0 {
|
||||
return fmt.Errorf("config subcommand is required: path or set-base-url")
|
||||
}
|
||||
switch args[0] {
|
||||
case "path":
|
||||
fs := flag.NewFlagSet("config path", flag.ContinueOnError)
|
||||
fs.SetOutput(os.Stderr)
|
||||
var configPath string
|
||||
fs.StringVar(&configPath, "config", "", "config file path")
|
||||
if err := fs.Parse(args[1:]); err != nil {
|
||||
return err
|
||||
}
|
||||
path, err := resolveConfigPath(configPath)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
fmt.Println(path)
|
||||
return nil
|
||||
case "set-base-url":
|
||||
fs := flag.NewFlagSet("config set-base-url", flag.ContinueOnError)
|
||||
fs.SetOutput(os.Stderr)
|
||||
var configPath string
|
||||
fs.StringVar(&configPath, "config", "", "config file path")
|
||||
if err := fs.Parse(args[1:]); err != nil {
|
||||
return err
|
||||
}
|
||||
if fs.NArg() < 1 {
|
||||
return fmt.Errorf("base url is required")
|
||||
}
|
||||
baseURL, err := normalizeBaseURL(fs.Arg(0))
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
cfg, err := loadConfig(configPath)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
cfg.BaseURL = baseURL
|
||||
if err := saveConfig(configPath, cfg); err != nil {
|
||||
return err
|
||||
}
|
||||
return printObject(map[string]any{"ok": true, "baseUrl": baseURL})
|
||||
default:
|
||||
return fmt.Errorf("unknown config subcommand %q", args[0])
|
||||
}
|
||||
}
|
||||
|
||||
func baseURLFromConfigOrFlag(configPath, baseURL string) (string, error) {
|
||||
if baseURL == "" {
|
||||
cfg, err := loadConfig(configPath)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
baseURL = cfg.BaseURL
|
||||
}
|
||||
return normalizeBaseURL(baseURL)
|
||||
}
|
||||
|
||||
func printObject(value any) error {
|
||||
data, err := json.MarshalIndent(value, "", " ")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
fmt.Println(string(data))
|
||||
return nil
|
||||
}
|
||||
|
||||
func printUsage() {
|
||||
fmt.Println(strings.TrimSpace(`
|
||||
htsy 是海豚私域接口命令行工具。
|
||||
|
||||
用法:
|
||||
htsy login --username USER --password PASS [--base-url URL]
|
||||
htsy account-login --phone PHONE --password PASS
|
||||
htsy send-code --phone PHONE --scene register
|
||||
htsy register --username PHONE --password PASS --sms-code CODE [--invite-token TOKEN]
|
||||
htsy call /api/account/user/info -d '{}'
|
||||
htsy status
|
||||
htsy logout
|
||||
htsy config path
|
||||
htsy config set-base-url https://htsy-gateway.cpshelp.cn
|
||||
|
||||
配置:
|
||||
登录信息会保存到当前用户主目录下的 ~/.htsy/config.json。
|
||||
可用 HTSY_CONFIG 覆盖配置文件路径。
|
||||
自动化场景可使用 HTSY_BASE_URL、HTSY_TOKEN、HTSY_PASSWORD。
|
||||
|
||||
登录规则:
|
||||
login 命令里带 @ 的用户名会走子账号登录,调用 /api/account/user/login/in。
|
||||
不带 @ 的用户名会走主账号登录,调用 /api/account/login/in。
|
||||
account-login 始终走主账号登录,调用 /api/account/login/in。
|
||||
`))
|
||||
}
|
||||
|
||||
func loginRoute(loginID string, accountLogin bool) (endpoint string, payloadKey string, kind string) {
|
||||
if accountLogin {
|
||||
return "/api/account/login/in", "phone", "account"
|
||||
}
|
||||
if strings.Contains(loginID, "@") {
|
||||
return "/api/account/user/login/in", "username", "user"
|
||||
}
|
||||
return "/api/account/login/in", "phone", "account"
|
||||
}
|
||||
Reference in New Issue
Block a user