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" }