@pippit-dev/cli 1.0.17 → 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.
- package/README.md +23 -2
- package/checksums.txt +6 -6
- package/cmd/auth/auth.go +136 -142
- package/cmd/auth/auth_test.go +134 -0
- package/cmd/canvas/canvas.go +235 -0
- package/cmd/canvas/canvas_test.go +126 -0
- package/cmd/root.go +29 -4
- package/cmd/root_test.go +60 -0
- package/cmd/short_drama_test.go +4 -7
- package/cmd/update/update.go +34 -5
- package/cmd/update/update_test.go +84 -0
- package/internal/auth/auth_test.go +813 -0
- package/internal/auth/browser_darwin.go +14 -0
- package/internal/auth/browser_env.go +35 -0
- package/internal/auth/browser_linux.go +14 -0
- package/internal/auth/browser_windows.go +14 -0
- package/internal/auth/identity.go +89 -0
- package/internal/auth/loopback.go +324 -0
- package/internal/auth/manager.go +338 -144
- package/internal/auth/store.go +303 -0
- package/internal/auth/store_file_unix.go +176 -0
- package/internal/auth/store_file_windows.go +11 -0
- package/internal/auth/types.go +72 -0
- package/internal/canvas/allocate.go +70 -0
- package/internal/canvas/apply.go +203 -0
- package/internal/canvas/canvas_test.go +446 -0
- package/internal/canvas/create.go +380 -0
- package/internal/canvas/get.go +156 -0
- package/internal/canvas/types.go +68 -0
- package/internal/canvas/upload.go +250 -0
- package/internal/common/access_key.go +42 -6
- package/internal/common/access_key_test.go +113 -0
- package/internal/common/client.go +119 -21
- package/internal/common/client_test.go +213 -0
- package/internal/common/runner.go +15 -0
- package/internal/config/config.go +0 -20
- package/internal/config/config_test.go +0 -18
- package/package.json +1 -1
- package/skills/short-drama/SKILL.md +4 -4
- package/skills/xyq-nest-skill/SKILL.md +11 -1
package/internal/auth/manager.go
CHANGED
|
@@ -1,146 +1,340 @@
|
|
|
1
1
|
package auth
|
|
2
2
|
|
|
3
|
-
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
//
|
|
39
|
-
//
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
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 ©
|
|
340
|
+
}
|