@pippit-dev/cli 1.0.16 → 1.0.18

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.
Files changed (41) hide show
  1. package/README.md +24 -3
  2. package/checksums.txt +6 -6
  3. package/cmd/auth/auth.go +136 -142
  4. package/cmd/auth/auth_test.go +134 -0
  5. package/cmd/canvas/canvas.go +235 -0
  6. package/cmd/canvas/canvas_test.go +126 -0
  7. package/cmd/generate_video/generate_video.go +1 -1
  8. package/cmd/root.go +29 -4
  9. package/cmd/root_test.go +60 -0
  10. package/cmd/short_drama_test.go +4 -7
  11. package/cmd/update/update.go +34 -5
  12. package/cmd/update/update_test.go +84 -0
  13. package/internal/auth/auth_test.go +813 -0
  14. package/internal/auth/browser_darwin.go +14 -0
  15. package/internal/auth/browser_env.go +35 -0
  16. package/internal/auth/browser_linux.go +14 -0
  17. package/internal/auth/browser_windows.go +14 -0
  18. package/internal/auth/identity.go +89 -0
  19. package/internal/auth/loopback.go +324 -0
  20. package/internal/auth/manager.go +338 -144
  21. package/internal/auth/store.go +303 -0
  22. package/internal/auth/store_file_unix.go +176 -0
  23. package/internal/auth/store_file_windows.go +11 -0
  24. package/internal/auth/types.go +72 -0
  25. package/internal/canvas/allocate.go +70 -0
  26. package/internal/canvas/apply.go +203 -0
  27. package/internal/canvas/canvas_test.go +446 -0
  28. package/internal/canvas/create.go +380 -0
  29. package/internal/canvas/get.go +156 -0
  30. package/internal/canvas/types.go +68 -0
  31. package/internal/canvas/upload.go +250 -0
  32. package/internal/common/access_key.go +42 -6
  33. package/internal/common/access_key_test.go +113 -0
  34. package/internal/common/client.go +119 -21
  35. package/internal/common/client_test.go +213 -0
  36. package/internal/common/runner.go +15 -0
  37. package/internal/config/config.go +0 -20
  38. package/internal/config/config_test.go +0 -18
  39. package/package.json +1 -1
  40. package/skills/short-drama/SKILL.md +4 -4
  41. package/skills/xyq-nest-skill/SKILL.md +17 -6
@@ -1,146 +1,340 @@
1
1
  package auth
2
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
- //}
3
+ import (
4
+ "context"
5
+ "crypto/rand"
6
+ "errors"
7
+ "fmt"
8
+ "io"
9
+ "net/url"
10
+ "strings"
11
+ "sync"
12
+ "time"
13
+
14
+ "github.com/Pippit-dev/pippit-cli/internal/config"
15
+ )
16
+
17
+ type Manager struct {
18
+ cfg *config.Config
19
+ store CredentialStore
20
+ authBaseURL *url.URL
21
+ random io.Reader
22
+ now func() time.Time
23
+ credentialMu sync.Mutex
24
+ cachedCredential *Credential
25
+ credentialCacheLoaded bool
26
+ }
27
+
28
+ type ManagerOption func(*Manager)
29
+
30
+ func WithCredentialStore(store CredentialStore) ManagerOption {
31
+ return func(manager *Manager) {
32
+ if store != nil {
33
+ manager.store = store
34
+ }
35
+ }
36
+ }
37
+
38
+ // NewManager always uses the canonical production login page. Runtime business
39
+ // API configuration does not affect the browser identity origin.
40
+ func NewManager(cfg *config.Config, options ...ManagerOption) *Manager {
41
+ serviceName := config.DefaultAuthStoreServiceName
42
+ authBaseURL, _ := url.Parse(config.DefaultBaseURL)
43
+ manager := &Manager{
44
+ cfg: cfg,
45
+ store: NewDefaultCredentialStore(serviceName),
46
+ authBaseURL: authBaseURL,
47
+ random: rand.Reader,
48
+ now: time.Now,
49
+ }
50
+ for _, option := range options {
51
+ if option != nil {
52
+ option(manager)
53
+ }
54
+ }
55
+ return manager
56
+ }
57
+
58
+ func (m *Manager) ResolveAccessKey(ctx context.Context) (string, error) {
59
+ if m != nil && m.cfg != nil {
60
+ if accessKey := strings.TrimSpace(m.cfg.AccessKey); accessKey != "" {
61
+ return accessKey, nil
62
+ }
63
+ }
64
+ credential, err := m.loadCredential(ctx)
65
+ if err != nil {
66
+ return "", err
67
+ }
68
+ if credential.AccessKey == "" {
69
+ return "", ErrCredentialNotFound
70
+ }
71
+ if credential.ExpiredAt <= m.now().Add(m.ensureTTL()).Unix() {
72
+ return "", ErrCredentialExpired
73
+ }
74
+ return credential.AccessKey, nil
75
+ }
76
+
77
+ func (m *Manager) Login(ctx context.Context, options LoginOptions) (*Credential, error) {
78
+ if err := m.validate(); err != nil {
79
+ return nil, err
80
+ }
81
+ identity, err := m.ensureIdentity(ctx)
82
+ if err != nil {
83
+ return nil, err
84
+ }
85
+ forceRefresh := options.ForceRefresh && strings.TrimSpace(identity.TokenID) != ""
86
+ expectedAccount := ""
87
+ if identity.UID != "" {
88
+ expectedAccount = accountBinding(identity.UID)
89
+ }
90
+ if expectedScope := strings.TrimSpace(options.ExpectedCredentialScope); expectedScope != "" {
91
+ scopeAccount, ok := accountBindingFromCredentialScope(expectedScope, identity.DeviceID)
92
+ if !ok || (expectedAccount != "" && !constantTimeEqual(expectedAccount, scopeAccount)) {
93
+ return nil, ErrCredentialAccountMismatch
94
+ }
95
+ expectedAccount = scopeAccount
96
+ }
97
+ flow, err := startBrowserFlow(
98
+ m.authBaseURL, m.random, identity.DeviceID, identity.TokenID, expectedAccount, forceRefresh,
99
+ )
100
+ if err != nil {
101
+ return nil, err
102
+ }
103
+ defer flow.close()
104
+
105
+ writeProgress(options.Progress, "小云雀网页授权地址(如未自动打开,请复制到浏览器):")
106
+ writeProgress(options.Progress, flow.loginURL)
107
+ writeProgress(options.Progress, "正在尝试自动打开浏览器…")
108
+ opener := options.OpenURL
109
+ if opener == nil {
110
+ opener = OpenBrowser
111
+ }
112
+ if err := opener(flow.loginURL); err != nil {
113
+ writeProgress(options.Progress, "未能自动打开浏览器,请复制上方授权地址到浏览器继续;CLI 将继续等待授权回调…")
114
+ }
115
+ writeProgress(options.Progress, "请在浏览器中完成登录和授权,CLI 会自动继续…")
116
+
117
+ timeout := options.Timeout
118
+ if timeout <= 0 {
119
+ timeout = DefaultLoginTimeout
120
+ }
121
+ waitCtx, cancel := context.WithTimeout(ctx, timeout)
122
+ defer cancel()
123
+ payload, err := flow.wait(waitCtx)
124
+ if err != nil {
125
+ return nil, err
126
+ }
127
+ writeProgress(options.Progress, "网页授权已完成,正在安全保存本机 CLI 凭证…")
128
+ credential := credentialFromCallback(identity, payload)
129
+ if credential.ExpiredAt <= m.now().Add(m.ensureTTL()).Unix() {
130
+ return nil, ErrCredentialExpired
131
+ }
132
+ if expected := strings.TrimSpace(options.ExpectedCredentialScope); expected != "" &&
133
+ !constantTimeEqual(expected, credential.CredentialScope) {
134
+ return nil, ErrCredentialAccountMismatch
135
+ }
136
+ if forceRefresh &&
137
+ ((identity.AccessKey != "" && constantTimeEqual(identity.AccessKey, credential.AccessKey)) ||
138
+ constantTimeEqual(identity.TokenID, credential.TokenID)) {
139
+ return nil, errors.New("网页授权未轮换已失效的 CLI Access Key,已拒绝继续使用旧凭证")
140
+ }
141
+ if err := validateCredential(credential); err != nil {
142
+ return nil, errors.New("网页返回的 CLI 凭证格式无效")
143
+ }
144
+ if err := m.saveCredential(waitCtx, credential); err != nil {
145
+ return nil, err
146
+ }
147
+ writeProgress(options.Progress, "小云雀 CLI 登录成功。")
148
+ return cloneCredential(credential), nil
149
+ }
150
+
151
+ func credentialFromCallback(identity *Credential, payload accessKeyPayload) *Credential {
152
+ credential := cloneCredential(identity)
153
+ credential.AccessKey = payload.AccessKey
154
+ credential.TokenID = payload.TokenID
155
+ credential.UID = payload.UID
156
+ credential.ExpiredAt = payload.ExpiredAt
157
+ credential.CredentialScope = credentialScope(payload.UID, identity.DeviceID)
158
+ return credential
159
+ }
160
+
161
+ func (m *Manager) Status(ctx context.Context) (*Status, error) {
162
+ if m != nil && m.cfg != nil && strings.TrimSpace(m.cfg.AccessKey) != "" {
163
+ return &Status{LoggedIn: true, Source: "environment"}, nil
164
+ }
165
+ credential, err := m.loadCredential(ctx)
166
+ if errors.Is(err, ErrCredentialNotFound) {
167
+ return &Status{}, nil
168
+ }
169
+ if err != nil {
170
+ return nil, err
171
+ }
172
+ status := &Status{
173
+ Source: "browser",
174
+ UID: credential.UID,
175
+ TokenID: credential.TokenID,
176
+ CredentialScope: credential.CredentialScope,
177
+ }
178
+ if credential.ExpiredAt > 0 {
179
+ status.ExpiresAt = time.Unix(credential.ExpiredAt, 0)
180
+ }
181
+ status.LoggedIn = credential.AccessKey != "" && credential.ExpiredAt > m.now().Add(m.ensureTTL()).Unix()
182
+ return status, nil
183
+ }
184
+
185
+ func (m *Manager) Logout(ctx context.Context, revoke bool) error {
186
+ if revoke {
187
+ // The current AK management endpoint requires a browser/team session and
188
+ // cannot be safely called using the Access Key that would be revoked.
189
+ return ErrRemoteRevokeUnsupported
190
+ }
191
+ credential, err := m.loadCredential(ctx)
192
+ if errors.Is(err, ErrCredentialNotFound) {
193
+ m.clearCredentialCache()
194
+ return nil
195
+ }
196
+ if err != nil {
197
+ return err
198
+ }
199
+ // Keep the non-secret device identity so a later login can safely reuse the
200
+ // same remote token instead of consuming another per-user AK slot. Logout
201
+ // only clears the local secret and account binding; it does not revoke the
202
+ // remote Access Key.
203
+ if err := m.saveCredential(ctx, identityOnly(credential)); err != nil {
204
+ return err
205
+ }
206
+ m.clearCredentialCache()
207
+ return nil
208
+ }
209
+
210
+ func (m *Manager) CredentialScope(ctx context.Context) (string, error) {
211
+ credential, err := m.loadCredential(ctx)
212
+ if err != nil {
213
+ return "", err
214
+ }
215
+ if credential.AccessKey == "" || strings.TrimSpace(credential.UID) == "" {
216
+ return "", ErrCredentialNotFound
217
+ }
218
+ return credentialScope(credential.UID, credential.DeviceID), nil
219
+ }
220
+
221
+ func (m *Manager) ensureIdentity(ctx context.Context) (*Credential, error) {
222
+ credential, err := m.loadCredential(ctx)
223
+ if err == nil {
224
+ return credential, nil
225
+ }
226
+ if !errors.Is(err, ErrCredentialNotFound) {
227
+ return nil, err
228
+ }
229
+ identity, err := newIdentity(m.random)
230
+ if err != nil {
231
+ return nil, err
232
+ }
233
+ if err := m.saveCredential(ctx, identity); err != nil {
234
+ return nil, err
235
+ }
236
+ return identity, nil
237
+ }
238
+
239
+ func (m *Manager) loadCredential(ctx context.Context) (*Credential, error) {
240
+ if err := m.validate(); err != nil {
241
+ return nil, err
242
+ }
243
+ m.credentialMu.Lock()
244
+ defer m.credentialMu.Unlock()
245
+ if m.credentialCacheLoaded {
246
+ if m.cachedCredential == nil {
247
+ return nil, ErrCredentialNotFound
248
+ }
249
+ return cloneCredential(m.cachedCredential), nil
250
+ }
251
+ credential, err := m.store.Load(ctx)
252
+ if err != nil {
253
+ if errors.Is(err, ErrCredentialNotFound) {
254
+ m.credentialCacheLoaded = true
255
+ m.cachedCredential = nil
256
+ }
257
+ return nil, err
258
+ }
259
+ credential = normalizeCredential(credential)
260
+ m.cachedCredential = cloneCredential(credential)
261
+ m.credentialCacheLoaded = true
262
+ return cloneCredential(credential), nil
263
+ }
264
+
265
+ func (m *Manager) saveCredential(ctx context.Context, credential *Credential) error {
266
+ if err := m.validate(); err != nil {
267
+ return err
268
+ }
269
+ credential = normalizeCredential(credential)
270
+ if err := m.store.Save(ctx, credential); err != nil {
271
+ m.clearCredentialCache()
272
+ return err
273
+ }
274
+ m.credentialMu.Lock()
275
+ m.cachedCredential = cloneCredential(credential)
276
+ m.credentialCacheLoaded = true
277
+ m.credentialMu.Unlock()
278
+ return nil
279
+ }
280
+
281
+ func (m *Manager) clearCredentialCache() {
282
+ if m == nil {
283
+ return
284
+ }
285
+ m.credentialMu.Lock()
286
+ m.cachedCredential = nil
287
+ m.credentialCacheLoaded = false
288
+ m.credentialMu.Unlock()
289
+ }
290
+
291
+ func normalizeCredential(credential *Credential) *Credential {
292
+ credential = cloneCredential(credential)
293
+ if credential == nil {
294
+ return nil
295
+ }
296
+ if credential.AccessKey == "" {
297
+ credential.CredentialScope = ""
298
+ return credential
299
+ }
300
+ credential.CredentialScope = credentialScope(credential.UID, credential.DeviceID)
301
+ return credential
302
+ }
303
+
304
+ func (m *Manager) validate() error {
305
+ if m == nil || m.store == nil || m.authBaseURL == nil || m.random == nil || m.now == nil {
306
+ return errors.New("小云雀 CLI 授权管理器未正确初始化")
307
+ }
308
+ if m.authBaseURL.Scheme != "https" && !(m.authBaseURL.Scheme == "http" && isLoopbackHost(m.authBaseURL.Hostname())) {
309
+ return errors.New("小云雀授权地址必须使用 HTTPS")
310
+ }
311
+ if m.authBaseURL.Host == "" || m.authBaseURL.RawQuery != "" || m.authBaseURL.Fragment != "" {
312
+ return errors.New("小云雀授权地址无效")
313
+ }
314
+ return nil
315
+ }
316
+
317
+ func (m *Manager) ensureTTL() time.Duration {
318
+ if m != nil && m.cfg != nil && m.cfg.AuthTTL > 0 {
319
+ return m.cfg.AuthTTL
320
+ }
321
+ return config.DefaultAuthTTL
322
+ }
323
+
324
+ func isLoopbackHost(host string) bool {
325
+ return host == "127.0.0.1" || strings.EqualFold(host, "localhost") || host == "::1"
326
+ }
327
+
328
+ func writeProgress(writer io.Writer, message string) {
329
+ if writer != nil {
330
+ _, _ = fmt.Fprintln(writer, message)
331
+ }
332
+ }
333
+
334
+ func cloneCredential(credential *Credential) *Credential {
335
+ if credential == nil {
336
+ return nil
337
+ }
338
+ copy := *credential
339
+ return &copy
340
+ }