@pippit-dev/cli 0.0.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,146 @@
1
+ package auth
2
+
3
+ import (
4
+ "context"
5
+ "errors"
6
+ "fmt"
7
+ "net/http"
8
+ "sync"
9
+ "time"
10
+
11
+ "code.byted.org/passport/auth_client/go/authsdk"
12
+
13
+ "github.com/Pippit-dev/pippit-cli/internal/config"
14
+ )
15
+
16
+ type Authorizer interface {
17
+ Refresh(ctx context.Context, ensureTTL time.Duration) error
18
+ Inject(ctx context.Context, req *http.Request) error
19
+ NewLoginFlow(ctx context.Context) (*LoginFlow, error)
20
+ CheckLogin(ctx context.Context, deviceCode string) (*State, error)
21
+ State(ctx context.Context) (*State, error)
22
+ Logout(ctx context.Context) error
23
+ }
24
+
25
+ type OAuthManager struct {
26
+ cfg *config.Config
27
+ mu sync.Mutex
28
+ client *authsdk.Client
29
+ }
30
+
31
+ type LoginFlow struct {
32
+ DeviceCode string `json:"device_code"`
33
+ UserCode string `json:"user_code"`
34
+ VerificationURI string `json:"verification_uri"`
35
+ }
36
+
37
+ type State struct {
38
+ LoggedIn bool `json:"logged_in"`
39
+ ExpiresAt time.Time `json:"expires_at,omitempty"`
40
+ }
41
+
42
+ func NewManager(cfg *config.Config) *OAuthManager {
43
+ return &OAuthManager{cfg: cfg}
44
+ }
45
+
46
+ func (m *OAuthManager) NewLoginFlow(ctx context.Context) (*LoginFlow, error) {
47
+ client, err := m.clientInstance()
48
+ if err != nil {
49
+ return nil, err
50
+ }
51
+ flow, err := client.Authenticator().NewLoginFlow(ctx)
52
+ if err != nil {
53
+ return nil, err
54
+ }
55
+ return &LoginFlow{
56
+ DeviceCode: flow.DeviceCode,
57
+ UserCode: flow.UserCode,
58
+ VerificationURI: flow.VerificationURI,
59
+ }, nil
60
+ }
61
+
62
+ func (m *OAuthManager) CheckLogin(ctx context.Context, deviceCode string) (*State, error) {
63
+ client, err := m.clientInstance()
64
+ if err != nil {
65
+ return nil, err
66
+ }
67
+ state, err := client.Authenticator().CheckLogin(ctx, deviceCode)
68
+ if err != nil {
69
+ return nil, err
70
+ }
71
+ return authState(state), nil
72
+ }
73
+
74
+ func (m *OAuthManager) State(ctx context.Context) (*State, error) {
75
+ client, err := m.clientInstance()
76
+ if err != nil {
77
+ return nil, err
78
+ }
79
+ state, err := client.Authorizer().State(ctx)
80
+ if err != nil {
81
+ return nil, err
82
+ }
83
+ return authState(state), nil
84
+ }
85
+
86
+ func (m *OAuthManager) Refresh(ctx context.Context, ensureTTL time.Duration) error {
87
+ client, err := m.clientInstance()
88
+ if err != nil {
89
+ return err
90
+ }
91
+ _, err = client.Authorizer().Refresh(ctx, ensureTTL)
92
+ return err
93
+ }
94
+
95
+ func (m *OAuthManager) Inject(ctx context.Context, req *http.Request) error {
96
+ client, err := m.clientInstance()
97
+ if err != nil {
98
+ return err
99
+ }
100
+ return client.Authorizer().Inject(ctx, req)
101
+ }
102
+
103
+ func (m *OAuthManager) Logout(ctx context.Context) error {
104
+ client, err := m.clientInstance()
105
+ if err != nil {
106
+ return err
107
+ }
108
+ return client.Authorizer().Logout(ctx)
109
+ }
110
+
111
+ func IsLoginPending(err error) bool {
112
+ return errors.Is(err, authsdk.ErrLoginPending)
113
+ }
114
+
115
+ func (m *OAuthManager) clientInstance() (*authsdk.Client, error) {
116
+ m.mu.Lock()
117
+ defer m.mu.Unlock()
118
+ if m.client != nil {
119
+ return m.client, nil
120
+ }
121
+ if m.cfg == nil || m.cfg.OAuth == nil {
122
+ return nil, errors.New("oauth config is required")
123
+ }
124
+ oauth := m.cfg.OAuth
125
+ client, err := authsdk.NewClient(authsdk.Config{
126
+ ClientKey: oauth.ClientKey,
127
+ BaseURL: oauth.BaseURL,
128
+ StoreServiceName: oauth.StoreServiceName,
129
+ Scopes: oauth.Scopes,
130
+ })
131
+ if err != nil {
132
+ return nil, fmt.Errorf("initialize oauth client: %w", err)
133
+ }
134
+ m.client = client
135
+ return client, nil
136
+ }
137
+
138
+ func authState(state *authsdk.AuthStateView) *State {
139
+ if state == nil {
140
+ return &State{}
141
+ }
142
+ return &State{
143
+ LoggedIn: state.LoggedIn,
144
+ ExpiresAt: state.ExpiresAt,
145
+ }
146
+ }
@@ -0,0 +1,29 @@
1
+ package common
2
+
3
+ import (
4
+ "context"
5
+ "fmt"
6
+ "net/http"
7
+ "strings"
8
+
9
+ "github.com/Pippit-dev/pippit-cli/internal/config"
10
+ )
11
+
12
+ type accessKeyAuthorizer struct {
13
+ accessKey string
14
+ }
15
+
16
+ func NewAccessKeyAuthorizer(accessKey string) RequestAuthorizer {
17
+ return &accessKeyAuthorizer{accessKey: strings.TrimSpace(accessKey)}
18
+ }
19
+
20
+ func (a *accessKeyAuthorizer) Inject(ctx context.Context, req *http.Request) error {
21
+ if err := ctx.Err(); err != nil {
22
+ return err
23
+ }
24
+ if a.accessKey == "" {
25
+ return fmt.Errorf("%s is required for authenticated requests", config.EnvXYQAccessKey)
26
+ }
27
+ req.Header.Set("Authorization", "Bearer "+a.accessKey)
28
+ return nil
29
+ }
@@ -0,0 +1,137 @@
1
+ package common
2
+
3
+ import (
4
+ "bytes"
5
+ "context"
6
+ "fmt"
7
+ "io"
8
+ "net/http"
9
+ "net/url"
10
+ "strings"
11
+ "time"
12
+
13
+ "github.com/bytedance/sonic"
14
+ )
15
+
16
+ type Client interface {
17
+ SendRequest(ctx context.Context, path string, body any, out any) error
18
+ }
19
+
20
+ type RequestAuthorizer interface {
21
+ Inject(ctx context.Context, req *http.Request) error
22
+ }
23
+
24
+ type httpClient struct {
25
+ baseURL string
26
+ httpClient *http.Client
27
+ headers http.Header
28
+ authorizer RequestAuthorizer
29
+ }
30
+
31
+ func NewHTTPClient(baseURL string, timeout time.Duration, authorizer RequestAuthorizer) Client {
32
+ return &httpClient{
33
+ baseURL: strings.TrimRight(baseURL, "/"),
34
+ httpClient: &http.Client{
35
+ Timeout: timeout,
36
+ },
37
+ headers: make(http.Header),
38
+ authorizer: authorizer,
39
+ }
40
+ }
41
+
42
+ func (c *httpClient) SendRequest(ctx context.Context, path string, body any, out any) error {
43
+ if c.authorizer == nil {
44
+ return fmt.Errorf("authorized request requires authorizer")
45
+ }
46
+ req, err := c.newJSONRequest(ctx, path, body)
47
+ if err != nil {
48
+ return err
49
+ }
50
+ if err := c.authorizer.Inject(ctx, req); err != nil {
51
+ return fmt.Errorf("inject auth headers: %w", err)
52
+ }
53
+ return c.do(req, out)
54
+ }
55
+
56
+ func (c *httpClient) newJSONRequest(ctx context.Context, path string, body any) (*http.Request, error) {
57
+ reqURL, err := c.resolveURL(path, nil)
58
+ if err != nil {
59
+ return nil, err
60
+ }
61
+ var reader io.Reader
62
+ if body != nil {
63
+ payload, err := sonic.Marshal(body)
64
+ if err != nil {
65
+ return nil, fmt.Errorf("encode POST body: %w", err)
66
+ }
67
+ reader = bytes.NewReader(payload)
68
+ }
69
+ req, err := http.NewRequestWithContext(ctx, http.MethodPost, reqURL, reader)
70
+ if err != nil {
71
+ return nil, fmt.Errorf("build POST request: %w", err)
72
+ }
73
+ if body != nil {
74
+ req.Header.Set("Content-Type", "application/json")
75
+ }
76
+ return req, nil
77
+ }
78
+
79
+ func (c *httpClient) do(req *http.Request, out any) error {
80
+ for k, values := range c.headers {
81
+ for _, v := range values {
82
+ req.Header.Add(k, v)
83
+ }
84
+ }
85
+ req.Header.Set("Accept", "application/json")
86
+
87
+ resp, err := c.httpClient.Do(req)
88
+ if err != nil {
89
+ return fmt.Errorf("%s %s failed: %w", req.Method, req.URL.String(), err)
90
+ }
91
+ defer resp.Body.Close()
92
+
93
+ data, err := io.ReadAll(resp.Body)
94
+ if err != nil {
95
+ return fmt.Errorf("read response body: %w", err)
96
+ }
97
+ if resp.StatusCode >= 400 {
98
+ msg := strings.TrimSpace(string(data))
99
+ if msg == "" {
100
+ msg = http.StatusText(resp.StatusCode)
101
+ }
102
+ return fmt.Errorf("%s %s returned HTTP %d: %s", req.Method, req.URL.String(), resp.StatusCode, msg)
103
+ }
104
+ if out == nil || len(data) == 0 {
105
+ return nil
106
+ }
107
+ if err := sonic.Unmarshal(data, out); err != nil {
108
+ return fmt.Errorf("decode response body: %w", err)
109
+ }
110
+ return nil
111
+ }
112
+
113
+ func (c *httpClient) resolveURL(path string, query map[string]string) (string, error) {
114
+ if strings.HasPrefix(path, "http://") || strings.HasPrefix(path, "https://") {
115
+ return appendQuery(path, query)
116
+ }
117
+ if c.baseURL == "" {
118
+ return "", fmt.Errorf("base URL is required for relative path %q", path)
119
+ }
120
+ if !strings.HasPrefix(path, "/") {
121
+ path = "/" + path
122
+ }
123
+ return appendQuery(c.baseURL+path, query)
124
+ }
125
+
126
+ func appendQuery(raw string, query map[string]string) (string, error) {
127
+ u, err := url.Parse(raw)
128
+ if err != nil {
129
+ return "", fmt.Errorf("parse URL: %w", err)
130
+ }
131
+ values := u.Query()
132
+ for k, v := range query {
133
+ values.Set(k, v)
134
+ }
135
+ u.RawQuery = values.Encode()
136
+ return u.String(), nil
137
+ }
@@ -0,0 +1,243 @@
1
+ package common
2
+
3
+ import (
4
+ "context"
5
+ "errors"
6
+ "fmt"
7
+ "io"
8
+ "net/http"
9
+ "net/url"
10
+ "os"
11
+ "path/filepath"
12
+ "sort"
13
+ "strings"
14
+ "sync"
15
+ "time"
16
+ )
17
+
18
+ // DownloadResultOptions is the command-facing shape for downloading one result URL.
19
+ type DownloadResultOptions struct {
20
+ URL string `json:"url"`
21
+ OutputDir string `json:"output_dir"`
22
+ Workers int `json:"workers"`
23
+ }
24
+
25
+ type DownloadResultError struct {
26
+ File string `json:"file"`
27
+ Error string `json:"error"`
28
+ }
29
+
30
+ // DownloadResultResponse is the JSON envelope printed by `pippit-cli short-drama +download-result`.
31
+ type DownloadResultResponse struct {
32
+ OutputDir string `json:"output_dir"`
33
+ Downloaded []string `json:"downloaded"`
34
+ AlreadyExist []string `json:"already_exist,omitempty"`
35
+ Total int `json:"total"`
36
+ Errors []*DownloadResultError `json:"errors,omitempty"`
37
+ }
38
+
39
+ const (
40
+ maxDownloadRetries = 3
41
+ retryBaseDelay = 500 * time.Millisecond
42
+ )
43
+
44
+ type downloadTask struct {
45
+ url string
46
+ filepath string
47
+ }
48
+
49
+ type downloadTaskResult struct {
50
+ filepath string
51
+ err error
52
+ }
53
+
54
+ func DownloadResult(ctx context.Context, opts DownloadResultOptions, _ *Runner) (*DownloadResultResponse, error) {
55
+ if err := ctx.Err(); err != nil {
56
+ return nil, err
57
+ }
58
+ rawURL := strings.TrimSpace(opts.URL)
59
+ if rawURL == "" {
60
+ return nil, fmt.Errorf("download url is required")
61
+ }
62
+
63
+ outputPath := strings.TrimSpace(opts.OutputDir)
64
+ if outputPath == "" {
65
+ return nil, fmt.Errorf("output_dir is required")
66
+ }
67
+ // check if the output path exists and is a file
68
+ if info, err := os.Stat(outputPath); err == nil {
69
+ if info.IsDir() {
70
+ return nil, fmt.Errorf("output path %q is a directory", outputPath)
71
+ }
72
+ return &DownloadResultResponse{
73
+ OutputDir: outputPath,
74
+ AlreadyExist: []string{outputPath},
75
+ Total: 0,
76
+ }, nil
77
+ } else if !os.IsNotExist(err) {
78
+ return nil, fmt.Errorf("stat output path: %w", err)
79
+ }
80
+ outputDir := filepath.Dir(outputPath)
81
+ if err := os.MkdirAll(outputDir, 0o755); err != nil {
82
+ return nil, fmt.Errorf("create output dir: %w", err)
83
+ }
84
+
85
+ parsed, err := url.Parse(rawURL)
86
+ if err != nil {
87
+ return nil, fmt.Errorf("invalid url %q: %w", rawURL, err)
88
+ }
89
+ if parsed.Scheme != "http" && parsed.Scheme != "https" {
90
+ return nil, fmt.Errorf("invalid url scheme %q in %q, only http and https are allowed", parsed.Scheme, rawURL)
91
+ }
92
+ tasks := []downloadTask{
93
+ {
94
+ url: rawURL,
95
+ filepath: outputPath,
96
+ },
97
+ }
98
+
99
+ workers := opts.Workers
100
+ if workers <= 0 {
101
+ workers = 5
102
+ }
103
+ if workers > len(tasks) {
104
+ workers = len(tasks)
105
+ }
106
+
107
+ client := &http.Client{Timeout: 600 * time.Second}
108
+ taskCh := make(chan downloadTask)
109
+ resultCh := make(chan downloadTaskResult, len(tasks))
110
+
111
+ var wg sync.WaitGroup
112
+ for i := 0; i < workers; i++ {
113
+ wg.Add(1)
114
+ go func() {
115
+ defer wg.Done()
116
+ for task := range taskCh {
117
+ resultCh <- downloadTaskResult{
118
+ filepath: task.filepath,
119
+ err: downloadFileWithRetry(ctx, client, task.url, task.filepath),
120
+ }
121
+ }
122
+ }()
123
+ }
124
+
125
+ go func() {
126
+ defer close(taskCh)
127
+ for _, task := range tasks {
128
+ select {
129
+ case <-ctx.Done():
130
+ return
131
+ case taskCh <- task:
132
+ }
133
+ }
134
+ }()
135
+
136
+ go func() {
137
+ wg.Wait()
138
+ close(resultCh)
139
+ }()
140
+
141
+ downloaded := make([]string, 0, len(tasks))
142
+ errorList := make([]*DownloadResultError, 0)
143
+ for result := range resultCh {
144
+ if result.err != nil {
145
+ errorList = append(errorList, &DownloadResultError{
146
+ File: result.filepath,
147
+ Error: result.err.Error(),
148
+ })
149
+ continue
150
+ }
151
+ downloaded = append(downloaded, result.filepath)
152
+ }
153
+ sort.Strings(downloaded)
154
+ sort.Slice(errorList, func(i, j int) bool {
155
+ return errorList[i].File < errorList[j].File
156
+ })
157
+
158
+ result := &DownloadResultResponse{
159
+ OutputDir: outputPath,
160
+ Downloaded: downloaded,
161
+ Total: len(downloaded),
162
+ Errors: errorList,
163
+ }
164
+ if len(downloaded) == 0 && len(errorList) > 0 {
165
+ return result, fmt.Errorf("all %d download(s) failed", len(errorList))
166
+ }
167
+ return result, nil
168
+ }
169
+
170
+ func downloadFileWithRetry(ctx context.Context, client *http.Client, rawURL string, targetPath string) error {
171
+ var lastErr error
172
+ for attempt := 0; attempt <= maxDownloadRetries; attempt++ {
173
+ if attempt > 0 {
174
+ delay := retryBaseDelay * time.Duration(1<<(attempt-1))
175
+ timer := time.NewTimer(delay)
176
+ select {
177
+ case <-ctx.Done():
178
+ timer.Stop()
179
+ return ctx.Err()
180
+ case <-timer.C:
181
+ }
182
+ }
183
+ err := downloadFile(ctx, client, rawURL, targetPath)
184
+ if err == nil {
185
+ return nil
186
+ }
187
+ lastErr = err
188
+ if !isRetryableError(err) {
189
+ return err
190
+ }
191
+ }
192
+ return fmt.Errorf("failed after %d retries: %w", maxDownloadRetries, lastErr)
193
+ }
194
+
195
+ func isRetryableError(err error) bool {
196
+ if errors.Is(err, context.Canceled) || errors.Is(err, context.DeadlineExceeded) {
197
+ return false
198
+ }
199
+ var urlErr *url.Error
200
+ if errors.As(err, &urlErr) {
201
+ return urlErr.Timeout() || urlErr.Temporary()
202
+ }
203
+ return false
204
+ }
205
+
206
+ func downloadFile(ctx context.Context, client *http.Client, rawURL string, targetPath string) error {
207
+ req, err := http.NewRequestWithContext(ctx, http.MethodGet, rawURL, nil)
208
+ if err != nil {
209
+ return fmt.Errorf("build download request: %w", err)
210
+ }
211
+ req.Header.Set("User-Agent", "Pippit-CLI/1.0")
212
+
213
+ resp, err := client.Do(req)
214
+ if err != nil {
215
+ return err
216
+ }
217
+ defer resp.Body.Close()
218
+
219
+ if resp.StatusCode >= 400 {
220
+ return fmt.Errorf("HTTP %d: %s", resp.StatusCode, http.StatusText(resp.StatusCode))
221
+ }
222
+
223
+ tmpPath := targetPath + ".tmp"
224
+ out, err := os.Create(tmpPath)
225
+ if err != nil {
226
+ return fmt.Errorf("create temp file: %w", err)
227
+ }
228
+ _, copyErr := io.Copy(out, resp.Body)
229
+ closeErr := out.Close()
230
+ if copyErr != nil {
231
+ _ = os.Remove(tmpPath)
232
+ return fmt.Errorf("write temp file: %w", copyErr)
233
+ }
234
+ if closeErr != nil {
235
+ _ = os.Remove(tmpPath)
236
+ return fmt.Errorf("close temp file: %w", closeErr)
237
+ }
238
+ if err := os.Rename(tmpPath, targetPath); err != nil {
239
+ _ = os.Remove(tmpPath)
240
+ return fmt.Errorf("replace target file: %w", err)
241
+ }
242
+ return nil
243
+ }
@@ -0,0 +1,104 @@
1
+ package common
2
+
3
+ import (
4
+ "context"
5
+ "fmt"
6
+ "path/filepath"
7
+ "strings"
8
+
9
+ "github.com/Pippit-dev/pippit-cli/internal/config"
10
+ )
11
+
12
+ // ListThreadFileOptions is the command-facing request shape for listing thread files.
13
+ type ListThreadFileOptions struct {
14
+ ThreadID string `json:"thread_id"`
15
+ PageSize int `json:"page_size,omitempty"`
16
+ PageNum int `json:"page_num,omitempty"`
17
+ }
18
+
19
+ // ThreadFile is a file entry in a thread.
20
+ type ThreadFile struct {
21
+ FilePath string `json:"file_path"`
22
+ DownloadURL string `json:"download_url"`
23
+ }
24
+
25
+ // ListThreadFileResult is the JSON envelope printed by `pippit-cli short-drama +list-thread-file`.
26
+ type ListThreadFileResult struct {
27
+ Files []*ThreadFile `json:"files"`
28
+ Total int64 `json:"total"`
29
+ }
30
+
31
+ type listThreadFileResponse struct {
32
+ Ret string `json:"ret"`
33
+ Errmsg string `json:"errmsg"`
34
+ SvrTime int64 `json:"svr_time"`
35
+ LogID string `json:"log_id"`
36
+ Data struct {
37
+ Files []*threadFileResponse `json:"files"`
38
+ Total int64 `json:"total"`
39
+ } `json:"data"`
40
+ }
41
+
42
+ type threadFileResponse struct {
43
+ FilePath string `json:"file_path"`
44
+ DownloadURL string `json:"download_url"`
45
+ }
46
+
47
+ func ListThreadFile(ctx context.Context, opts *ListThreadFileOptions, runner *Runner) (*ListThreadFileResult, error) {
48
+ if runner == nil || runner.Client == nil {
49
+ return nil, fmt.Errorf("list_thread_file runner client is required")
50
+ }
51
+
52
+ body := map[string]any{
53
+ "thread_id": opts.ThreadID,
54
+ }
55
+ if opts.PageSize > 0 {
56
+ body["page_size"] = opts.PageSize
57
+ }
58
+ if opts.PageNum > 0 {
59
+ body["page_num"] = opts.PageNum
60
+ }
61
+
62
+ var resp listThreadFileResponse
63
+ if err := runner.Client.SendRequest(ctx, listThreadFilePath(runner), body, &resp); err != nil {
64
+ return nil, fmt.Errorf("list_thread_file request failed: %w", err)
65
+ }
66
+ if resp.Ret != "0" {
67
+ if resp.Errmsg == "" {
68
+ resp.Errmsg = "unknown error"
69
+ }
70
+ return nil, fmt.Errorf("list_thread_file failed: ret=%s errmsg=%s", resp.Ret, resp.Errmsg)
71
+ }
72
+
73
+ files := make([]*ThreadFile, 0, len(resp.Data.Files))
74
+ for _, file := range resp.Data.Files {
75
+ if file == nil {
76
+ continue
77
+ }
78
+ files = append(files, &ThreadFile{
79
+ FilePath: threadFilePath(opts.ThreadID, file.FilePath),
80
+ DownloadURL: file.DownloadURL,
81
+ })
82
+ }
83
+
84
+ return &ListThreadFileResult{
85
+ Files: files,
86
+ Total: resp.Data.Total,
87
+ }, nil
88
+ }
89
+
90
+ func threadFilePath(threadID string, filePath string) string {
91
+ parts := []string{strings.TrimSpace(threadID)}
92
+ trimmedFilePath := strings.Trim(strings.TrimSpace(filePath), `/\`)
93
+ if trimmedFilePath != "" {
94
+ parts = append(parts, trimmedFilePath)
95
+ }
96
+ return "." + string(filepath.Separator) + filepath.Join(parts...)
97
+ }
98
+
99
+ func listThreadFilePath(runner *Runner) string {
100
+ if runner != nil && runner.Config != nil && runner.Config.Paths != nil && runner.Config.Paths.ListThreadFile != "" {
101
+ return runner.Config.Paths.ListThreadFile
102
+ }
103
+ return config.ListThreadFilePath
104
+ }
@@ -0,0 +1,21 @@
1
+ package common
2
+
3
+ import (
4
+ "github.com/Pippit-dev/pippit-cli/internal/auth"
5
+ "github.com/Pippit-dev/pippit-cli/internal/config"
6
+ )
7
+
8
+ // Runner carries runtime dependencies for command execution.
9
+ type Runner struct {
10
+ Config *config.Config
11
+ Client Client
12
+ AuthAuthorizer auth.Authorizer
13
+ }
14
+
15
+ func NewRunner(cfg *config.Config, client Client, authAuthorizer auth.Authorizer) *Runner {
16
+ return &Runner{
17
+ Config: cfg,
18
+ Client: client,
19
+ AuthAuthorizer: authAuthorizer,
20
+ }
21
+ }