@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
@@ -0,0 +1,303 @@
1
+ package auth
2
+
3
+ import (
4
+ "context"
5
+ "encoding/json"
6
+ "errors"
7
+ "fmt"
8
+ "os"
9
+ "path/filepath"
10
+ "strings"
11
+
12
+ "github.com/zalando/go-keyring"
13
+ )
14
+
15
+ const (
16
+ defaultKeyringAccount = "browser-login"
17
+ credentialFileName = "browser-credential.json"
18
+ maxCredentialBytes = 64 << 10
19
+ )
20
+
21
+ type keyringBackend interface {
22
+ Get(service, user string) (string, error)
23
+ Set(service, user, password string) error
24
+ Delete(service, user string) error
25
+ }
26
+
27
+ type systemKeyring struct{}
28
+
29
+ func (systemKeyring) Get(service, user string) (string, error) {
30
+ return keyring.Get(service, user)
31
+ }
32
+
33
+ func (systemKeyring) Set(service, user, password string) error {
34
+ return keyring.Set(service, user, password)
35
+ }
36
+
37
+ func (systemKeyring) Delete(service, user string) error {
38
+ return keyring.Delete(service, user)
39
+ }
40
+
41
+ type keyringCredentialStore struct {
42
+ backend keyringBackend
43
+ service string
44
+ account string
45
+ }
46
+
47
+ func (s *keyringCredentialStore) Load(ctx context.Context) (*Credential, error) {
48
+ if err := ctx.Err(); err != nil {
49
+ return nil, err
50
+ }
51
+ value, err := s.backend.Get(s.service, s.account)
52
+ if errors.Is(err, keyring.ErrNotFound) {
53
+ return nil, ErrCredentialNotFound
54
+ }
55
+ if err != nil {
56
+ return nil, fmt.Errorf("读取系统钥匙串失败: %w", ErrSecureStore)
57
+ }
58
+ credential, err := decodeCredential([]byte(value))
59
+ if err != nil {
60
+ return nil, err
61
+ }
62
+ return credential, nil
63
+ }
64
+
65
+ func (s *keyringCredentialStore) Save(ctx context.Context, credential *Credential) error {
66
+ if err := ctx.Err(); err != nil {
67
+ return err
68
+ }
69
+ payload, err := encodeCredential(credential)
70
+ if err != nil {
71
+ return err
72
+ }
73
+ if err := s.backend.Set(s.service, s.account, string(payload)); err != nil {
74
+ return fmt.Errorf("写入系统钥匙串失败: %w", ErrSecureStore)
75
+ }
76
+ return nil
77
+ }
78
+
79
+ func (s *keyringCredentialStore) Delete(ctx context.Context) error {
80
+ if err := ctx.Err(); err != nil {
81
+ return err
82
+ }
83
+ err := s.backend.Delete(s.service, s.account)
84
+ if errors.Is(err, keyring.ErrNotFound) {
85
+ return ErrCredentialNotFound
86
+ }
87
+ if err != nil {
88
+ return fmt.Errorf("删除系统钥匙串凭证失败: %w", ErrSecureStore)
89
+ }
90
+ return nil
91
+ }
92
+
93
+ type resilientCredentialStore struct {
94
+ primary CredentialStore
95
+ fallback CredentialStore
96
+ }
97
+
98
+ type storedCredential struct {
99
+ Version int `json:"version"`
100
+ DeviceID string `json:"device_id"`
101
+ CredentialScope string `json:"credential_scope"`
102
+ // LegacyTokenName is decoded only for compatibility with credentials written
103
+ // by the first browser-auth beta. New records intentionally omit it.
104
+ LegacyTokenName string `json:"token_name,omitempty"`
105
+ AccessKey string `json:"access_key,omitempty"`
106
+ TokenID string `json:"token_id,omitempty"`
107
+ UID string `json:"uid,omitempty"`
108
+ ExpiredAt int64 `json:"expired_at,omitempty"`
109
+ }
110
+
111
+ // NewDefaultCredentialStore prefers the operating system keyring. Unix uses
112
+ // a private no-follow file as a fallback; Windows deliberately has no file
113
+ // fallback because an equivalent ACL guarantee is not provided here.
114
+ func NewDefaultCredentialStore(serviceName string) CredentialStore {
115
+ serviceName = strings.TrimSpace(serviceName)
116
+ if serviceName == "" {
117
+ serviceName = "pippit-cli"
118
+ }
119
+ primary := &keyringCredentialStore{
120
+ backend: systemKeyring{},
121
+ service: serviceName,
122
+ account: defaultKeyringAccount,
123
+ }
124
+ return &resilientCredentialStore{
125
+ primary: primary,
126
+ fallback: newPlatformFallbackStore(),
127
+ }
128
+ }
129
+
130
+ func (s *resilientCredentialStore) Load(ctx context.Context) (*Credential, error) {
131
+ credential, primaryErr := s.primary.Load(ctx)
132
+ if primaryErr == nil {
133
+ return credential, nil
134
+ }
135
+ if err := ctx.Err(); err != nil {
136
+ return nil, err
137
+ }
138
+ if !errors.Is(primaryErr, ErrCredentialNotFound) && !errors.Is(primaryErr, ErrSecureStore) {
139
+ // A decodable-but-invalid primary record may indicate corruption or
140
+ // tampering. Never mask it with an older fallback credential.
141
+ return nil, primaryErr
142
+ }
143
+ if s.fallback == nil {
144
+ if errors.Is(primaryErr, ErrCredentialNotFound) {
145
+ return nil, ErrCredentialNotFound
146
+ }
147
+ return nil, primaryErr
148
+ }
149
+ credential, fallbackErr := s.fallback.Load(ctx)
150
+ if fallbackErr == nil {
151
+ return credential, nil
152
+ }
153
+ if errors.Is(fallbackErr, ErrCredentialNotFound) {
154
+ // An available, empty fallback is the active store when the primary
155
+ // keyring is unavailable. Report a fresh login state so Manager can
156
+ // create the device identity and Save can persist it to that fallback.
157
+ return nil, ErrCredentialNotFound
158
+ }
159
+ if !errors.Is(fallbackErr, ErrCredentialNotFound) {
160
+ return nil, fallbackErr
161
+ }
162
+ return nil, primaryErr
163
+ }
164
+
165
+ func (s *resilientCredentialStore) Save(ctx context.Context, credential *Credential) error {
166
+ primaryErr := s.primary.Save(ctx, credential)
167
+ if primaryErr == nil {
168
+ if s.fallback != nil {
169
+ if err := ignoreNotFound(s.fallback.Delete(ctx)); err != nil {
170
+ return fmt.Errorf("系统钥匙串已更新,但清理旧的备用凭证失败: %w", err)
171
+ }
172
+ }
173
+ return nil
174
+ }
175
+ if err := ctx.Err(); err != nil {
176
+ return err
177
+ }
178
+ if !errors.Is(primaryErr, ErrCredentialNotFound) && !errors.Is(primaryErr, ErrSecureStore) {
179
+ return primaryErr
180
+ }
181
+ if s.fallback == nil {
182
+ return primaryErr
183
+ }
184
+ if err := s.fallback.Save(ctx, credential); err != nil {
185
+ return err
186
+ }
187
+ return nil
188
+ }
189
+
190
+ func (s *resilientCredentialStore) Delete(ctx context.Context) error {
191
+ primaryErr := ignoreNotFound(s.primary.Delete(ctx))
192
+ var fallbackErr error
193
+ if s.fallback != nil {
194
+ fallbackErr = ignoreNotFound(s.fallback.Delete(ctx))
195
+ }
196
+ if primaryErr != nil {
197
+ return primaryErr
198
+ }
199
+ return fallbackErr
200
+ }
201
+
202
+ func ignoreNotFound(err error) error {
203
+ if errors.Is(err, ErrCredentialNotFound) {
204
+ return nil
205
+ }
206
+ return err
207
+ }
208
+
209
+ func encodeCredential(credential *Credential) ([]byte, error) {
210
+ if err := validateCredential(credential); err != nil {
211
+ return nil, err
212
+ }
213
+ payload, err := json.Marshal(storedCredential{
214
+ Version: credential.Version,
215
+ DeviceID: credential.DeviceID,
216
+ CredentialScope: credential.CredentialScope,
217
+ AccessKey: credential.AccessKey,
218
+ TokenID: credential.TokenID,
219
+ UID: credential.UID,
220
+ ExpiredAt: credential.ExpiredAt,
221
+ })
222
+ if err != nil {
223
+ return nil, errors.New("编码本机登录凭证失败")
224
+ }
225
+ return payload, nil
226
+ }
227
+
228
+ func decodeCredential(payload []byte) (*Credential, error) {
229
+ if len(payload) == 0 || len(payload) > maxCredentialBytes {
230
+ return nil, errors.New("本机登录凭证格式无效")
231
+ }
232
+ record := &storedCredential{}
233
+ if err := json.Unmarshal(payload, record); err != nil {
234
+ return nil, errors.New("本机登录凭证格式无效")
235
+ }
236
+ credential := &Credential{
237
+ Version: record.Version,
238
+ DeviceID: record.DeviceID,
239
+ CredentialScope: record.CredentialScope,
240
+ AccessKey: record.AccessKey,
241
+ TokenID: record.TokenID,
242
+ UID: record.UID,
243
+ ExpiredAt: record.ExpiredAt,
244
+ }
245
+ if err := validateCredential(credential); err != nil {
246
+ return nil, err
247
+ }
248
+ return credential, nil
249
+ }
250
+
251
+ func defaultCredentialPath() string {
252
+ dir, err := os.UserConfigDir()
253
+ if err != nil || strings.TrimSpace(dir) == "" {
254
+ return ""
255
+ }
256
+ return filepath.Join(dir, "pippit-cli", credentialFileName)
257
+ }
258
+
259
+ type unavailableCredentialStore struct{}
260
+
261
+ func (unavailableCredentialStore) Load(context.Context) (*Credential, error) {
262
+ return nil, ErrSecureStore
263
+ }
264
+
265
+ func (unavailableCredentialStore) Save(context.Context, *Credential) error {
266
+ return ErrSecureStore
267
+ }
268
+
269
+ func (unavailableCredentialStore) Delete(context.Context) error {
270
+ return ErrSecureStore
271
+ }
272
+
273
+ func validateCredential(credential *Credential) error {
274
+ if credential == nil {
275
+ return errors.New("本机登录凭证不能为空")
276
+ }
277
+ if credential.Version != credentialVersion {
278
+ return errors.New("本机登录凭证版本不受支持")
279
+ }
280
+ if !validDeviceID(credential.DeviceID) {
281
+ return errors.New("本机登录设备标识无效")
282
+ }
283
+ if credential.AccessKey == "" {
284
+ // TokenID is non-secret. Keeping it lets an explicit force login select
285
+ // exactly this device token without guessing by display name.
286
+ if (credential.TokenID != "" && !validTokenID(credential.TokenID)) || credential.UID != "" ||
287
+ credential.ExpiredAt != 0 || credential.CredentialScope != "" {
288
+ return errors.New("本机登录凭证不完整")
289
+ }
290
+ return nil
291
+ }
292
+ if strings.TrimSpace(credential.AccessKey) != credential.AccessKey || len(credential.AccessKey) > 4096 ||
293
+ !validTokenID(credential.TokenID) ||
294
+ credential.UID == "" || len(credential.UID) > 256 || strings.TrimSpace(credential.UID) != credential.UID || credential.ExpiredAt <= 0 {
295
+ return errors.New("本机登录凭证不完整")
296
+ }
297
+ expectedScope := credentialScope(credential.UID, credential.DeviceID)
298
+ if !constantTimeEqual(credential.CredentialScope, expectedScope) &&
299
+ !constantTimeEqual(credential.CredentialScope, legacyCredentialScope(credential.DeviceID)) {
300
+ return errors.New("本机登录凭证作用域无效")
301
+ }
302
+ return nil
303
+ }
@@ -0,0 +1,176 @@
1
+ //go:build !windows
2
+
3
+ package auth
4
+
5
+ import (
6
+ "context"
7
+ "errors"
8
+ "fmt"
9
+ "io"
10
+ "os"
11
+ "path/filepath"
12
+ "strings"
13
+
14
+ "golang.org/x/sys/unix"
15
+ )
16
+
17
+ type fileCredentialStore struct {
18
+ path string
19
+ }
20
+
21
+ func NewFileCredentialStore(path string) CredentialStore {
22
+ return &fileCredentialStore{path: filepath.Clean(path)}
23
+ }
24
+
25
+ func newPlatformFallbackStore() CredentialStore {
26
+ path := defaultCredentialPath()
27
+ if path == "" {
28
+ return nil
29
+ }
30
+ return NewFileCredentialStore(path)
31
+ }
32
+
33
+ func (s *fileCredentialStore) Load(ctx context.Context) (*Credential, error) {
34
+ if err := ctx.Err(); err != nil {
35
+ return nil, err
36
+ }
37
+ dirFD, name, err := s.openPrivateDirectory(false)
38
+ if errors.Is(err, os.ErrNotExist) {
39
+ return nil, ErrCredentialNotFound
40
+ }
41
+ if err != nil {
42
+ return nil, err
43
+ }
44
+ defer unix.Close(dirFD)
45
+
46
+ fd, err := unix.Openat(dirFD, name, unix.O_RDONLY|unix.O_CLOEXEC|unix.O_NOFOLLOW, 0)
47
+ if errors.Is(err, unix.ENOENT) {
48
+ return nil, ErrCredentialNotFound
49
+ }
50
+ if err != nil {
51
+ return nil, fmt.Errorf("安全打开本机登录凭证失败: %w", ErrSecureStore)
52
+ }
53
+ file := os.NewFile(uintptr(fd), name)
54
+ defer file.Close()
55
+ if err := verifyPrivateRegularFile(fd); err != nil {
56
+ return nil, err
57
+ }
58
+ payload, err := io.ReadAll(io.LimitReader(file, maxCredentialBytes+1))
59
+ if err != nil {
60
+ return nil, fmt.Errorf("读取本机登录凭证失败: %w", ErrSecureStore)
61
+ }
62
+ return decodeCredential(payload)
63
+ }
64
+
65
+ func (s *fileCredentialStore) Save(ctx context.Context, credential *Credential) error {
66
+ if err := ctx.Err(); err != nil {
67
+ return err
68
+ }
69
+ payload, err := encodeCredential(credential)
70
+ if err != nil {
71
+ return err
72
+ }
73
+ dirFD, name, err := s.openPrivateDirectory(true)
74
+ if err != nil {
75
+ return err
76
+ }
77
+ defer unix.Close(dirFD)
78
+
79
+ tempName, err := randomTempName()
80
+ if err != nil {
81
+ return errors.New("创建本机登录凭证临时文件失败")
82
+ }
83
+ fd, err := unix.Openat(dirFD, tempName, unix.O_WRONLY|unix.O_CREAT|unix.O_EXCL|unix.O_CLOEXEC|unix.O_NOFOLLOW, 0o600)
84
+ if err != nil {
85
+ return fmt.Errorf("创建本机登录凭证临时文件失败: %w", ErrSecureStore)
86
+ }
87
+ cleanup := true
88
+ defer func() {
89
+ if cleanup {
90
+ _ = unix.Unlinkat(dirFD, tempName, 0)
91
+ }
92
+ }()
93
+ file := os.NewFile(uintptr(fd), tempName)
94
+ if _, err := file.Write(payload); err != nil {
95
+ _ = file.Close()
96
+ return fmt.Errorf("写入本机登录凭证失败: %w", ErrSecureStore)
97
+ }
98
+ if err := file.Sync(); err != nil {
99
+ _ = file.Close()
100
+ return fmt.Errorf("同步本机登录凭证失败: %w", ErrSecureStore)
101
+ }
102
+ if err := file.Close(); err != nil {
103
+ return fmt.Errorf("关闭本机登录凭证失败: %w", ErrSecureStore)
104
+ }
105
+ if err := unix.Renameat(dirFD, tempName, dirFD, name); err != nil {
106
+ return fmt.Errorf("原子保存本机登录凭证失败: %w", ErrSecureStore)
107
+ }
108
+ cleanup = false
109
+ if err := unix.Fsync(dirFD); err != nil {
110
+ return fmt.Errorf("同步本机凭证目录失败: %w", ErrSecureStore)
111
+ }
112
+ return nil
113
+ }
114
+
115
+ func (s *fileCredentialStore) Delete(ctx context.Context) error {
116
+ if err := ctx.Err(); err != nil {
117
+ return err
118
+ }
119
+ dirFD, name, err := s.openPrivateDirectory(false)
120
+ if errors.Is(err, os.ErrNotExist) {
121
+ return ErrCredentialNotFound
122
+ }
123
+ if err != nil {
124
+ return err
125
+ }
126
+ defer unix.Close(dirFD)
127
+ if err := unix.Unlinkat(dirFD, name, 0); errors.Is(err, unix.ENOENT) {
128
+ return ErrCredentialNotFound
129
+ } else if err != nil {
130
+ return fmt.Errorf("删除本机登录凭证失败: %w", ErrSecureStore)
131
+ }
132
+ if err := unix.Fsync(dirFD); err != nil {
133
+ return fmt.Errorf("同步本机凭证目录失败: %w", ErrSecureStore)
134
+ }
135
+ return nil
136
+ }
137
+
138
+ func (s *fileCredentialStore) openPrivateDirectory(create bool) (int, string, error) {
139
+ if s == nil || strings.TrimSpace(s.path) == "" || filepath.Base(s.path) == "." {
140
+ return -1, "", ErrSecureStore
141
+ }
142
+ dir := filepath.Dir(s.path)
143
+ if create {
144
+ if err := os.MkdirAll(dir, 0o700); err != nil {
145
+ return -1, "", fmt.Errorf("创建本机凭证目录失败: %w", ErrSecureStore)
146
+ }
147
+ }
148
+ info, err := os.Lstat(dir)
149
+ if err != nil {
150
+ return -1, "", err
151
+ }
152
+ if info.Mode()&os.ModeSymlink != 0 || !info.IsDir() || info.Mode().Perm()&0o077 != 0 {
153
+ return -1, "", fmt.Errorf("本机凭证目录必须是权限 0700 的真实目录: %w", ErrSecureStore)
154
+ }
155
+ dirFD, err := unix.Open(dir, unix.O_RDONLY|unix.O_CLOEXEC|unix.O_DIRECTORY|unix.O_NOFOLLOW, 0)
156
+ if err != nil {
157
+ return -1, "", fmt.Errorf("安全打开本机凭证目录失败: %w", ErrSecureStore)
158
+ }
159
+ var stat unix.Stat_t
160
+ if err := unix.Fstat(dirFD, &stat); err != nil || stat.Uid != uint32(os.Geteuid()) {
161
+ unix.Close(dirFD)
162
+ return -1, "", fmt.Errorf("本机凭证目录所有者无效: %w", ErrSecureStore)
163
+ }
164
+ return dirFD, filepath.Base(s.path), nil
165
+ }
166
+
167
+ func verifyPrivateRegularFile(fd int) error {
168
+ var stat unix.Stat_t
169
+ if err := unix.Fstat(fd, &stat); err != nil {
170
+ return fmt.Errorf("检查本机登录凭证失败: %w", ErrSecureStore)
171
+ }
172
+ if stat.Uid != uint32(os.Geteuid()) || stat.Mode&unix.S_IFMT != unix.S_IFREG || stat.Mode&0o077 != 0 {
173
+ return fmt.Errorf("本机登录凭证必须是当前用户拥有的权限 0600 文件: %w", ErrSecureStore)
174
+ }
175
+ return nil
176
+ }
@@ -0,0 +1,11 @@
1
+ //go:build windows
2
+
3
+ package auth
4
+
5
+ func NewFileCredentialStore(string) CredentialStore {
6
+ return unavailableCredentialStore{}
7
+ }
8
+
9
+ func newPlatformFallbackStore() CredentialStore {
10
+ return nil
11
+ }
@@ -0,0 +1,72 @@
1
+ package auth
2
+
3
+ import (
4
+ "context"
5
+ "errors"
6
+ "io"
7
+ "time"
8
+ )
9
+
10
+ const (
11
+ loginPagePath = "/cli/pippit-tool-login"
12
+ callbackPath = "/pippit-tool/callback"
13
+ loginSource = "pippit-tool-cli"
14
+ credentialVersion = 1
15
+ deviceIDBytes = 32
16
+ randomBindingBytes = 32
17
+
18
+ DefaultLoginTimeout = 5 * time.Minute
19
+ )
20
+
21
+ var (
22
+ ErrCredentialNotFound = errors.New("未找到本机小云雀 CLI 登录凭证")
23
+ ErrCredentialExpired = errors.New("本机小云雀 CLI 登录凭证已过期")
24
+ ErrSecureStore = errors.New("安全凭证存储不可用")
25
+ ErrCredentialAccountMismatch = errors.New("网页授权账号与当前任务账号不一致")
26
+ ErrRemoteRevokeUnsupported = errors.New("当前版本不支持在 CLI 中安全撤销远程 Access Key")
27
+ )
28
+
29
+ // Credential is the dedicated, device-scoped Access Key managed by this CLI.
30
+ // AccessKey is secret and must never be printed, logged, or written to journals.
31
+ type Credential struct {
32
+ Version int `json:"version"`
33
+ DeviceID string `json:"device_id"`
34
+ CredentialScope string `json:"credential_scope"`
35
+ AccessKey string `json:"-"`
36
+ TokenID string `json:"token_id,omitempty"`
37
+ UID string `json:"uid,omitempty"`
38
+ ExpiredAt int64 `json:"expired_at,omitempty"`
39
+ }
40
+
41
+ // CredentialStore persists a credential without exposing its serialized form.
42
+ type CredentialStore interface {
43
+ Load(context.Context) (*Credential, error)
44
+ Save(context.Context, *Credential) error
45
+ Delete(context.Context) error
46
+ }
47
+
48
+ type LoginOptions struct {
49
+ // OpenURL should open the URL without persisting it. Login writes the
50
+ // one-time URL to Progress so the user can continue manually when automatic
51
+ // opening is unavailable. When nil, the platform's standard browser opener
52
+ // is used with credential-bearing env vars removed.
53
+ OpenURL func(string) error
54
+ Progress io.Writer
55
+ Timeout time.Duration
56
+ // ForceRefresh asks the browser page to rotate this device's rejected AK.
57
+ // The CLI never calls QueryAk, DeleteAk, or GenerateAk itself.
58
+ ForceRefresh bool
59
+ // ExpectedCredentialScope binds reauthentication to the UID and device that
60
+ // started a durable operation. A different browser account fails before the
61
+ // returned Access Key is saved.
62
+ ExpectedCredentialScope string
63
+ }
64
+
65
+ type Status struct {
66
+ LoggedIn bool `json:"logged_in"`
67
+ Source string `json:"source,omitempty"`
68
+ UID string `json:"uid,omitempty"`
69
+ TokenID string `json:"token_id,omitempty"`
70
+ CredentialScope string `json:"credential_scope,omitempty"`
71
+ ExpiresAt time.Time `json:"expires_at,omitempty"`
72
+ }
@@ -0,0 +1,70 @@
1
+ package canvas
2
+
3
+ import (
4
+ "context"
5
+ "encoding/json"
6
+ "fmt"
7
+ "strings"
8
+
9
+ "github.com/Pippit-dev/pippit-cli/internal/common"
10
+ )
11
+
12
+ const MaxAllocateCount = 5000
13
+
14
+ type AllocateResult struct {
15
+ AssetIDs []string `json:"asset_ids"`
16
+ LogID string `json:"log_id,omitempty"`
17
+ }
18
+
19
+ type allocateRequest struct {
20
+ Count int `json:"count"`
21
+ Base map[string]any `json:"Base"`
22
+ }
23
+
24
+ // Allocate reserves unique IDs for assets that a later canvas apply transaction creates.
25
+ func Allocate(ctx context.Context, count int, runner *common.Runner) (*AllocateResult, error) {
26
+ client, err := runnerClient(runner, "canvas allocate")
27
+ if err != nil {
28
+ return nil, err
29
+ }
30
+ if count <= 0 || count > MaxAllocateCount {
31
+ return nil, fmt.Errorf("canvas allocate count must be between 1 and %d", MaxAllocateCount)
32
+ }
33
+ var envelope responseEnvelope
34
+ if err := client.SendRequest(ctx, AllocatePath, allocateRequest{Count: count, Base: base()}, &envelope); err != nil {
35
+ return nil, fmt.Errorf("canvas allocate request failed: %w", err)
36
+ }
37
+ if err := envelope.validate("canvas allocate"); err != nil {
38
+ return nil, err
39
+ }
40
+ var data map[string]json.RawMessage
41
+ if err := json.Unmarshal(envelope.Data, &data); err != nil {
42
+ return nil, common.NewLogIDError(fmt.Sprintf("canvas allocate returned invalid data: %v", err), envelope.LogID)
43
+ }
44
+ idsRaw, ok := rawField(data, "ids", "IDs", "asset_ids")
45
+ if !ok {
46
+ return nil, common.NewLogIDError("canvas allocate response is missing data.ids", envelope.LogID)
47
+ }
48
+ var ids []string
49
+ if err := json.Unmarshal(idsRaw, &ids); err != nil {
50
+ return nil, common.NewLogIDError("canvas allocate data.ids must contain JSON strings", envelope.LogID)
51
+ }
52
+ if len(ids) != count {
53
+ return nil, common.NewLogIDError(
54
+ fmt.Sprintf("canvas allocate returned %d ids, want %d", len(ids), count),
55
+ envelope.LogID,
56
+ )
57
+ }
58
+ seen := make(map[string]struct{}, len(ids))
59
+ for index, id := range ids {
60
+ ids[index] = strings.TrimSpace(id)
61
+ if ids[index] == "" {
62
+ return nil, common.NewLogIDError(fmt.Sprintf("canvas allocate returned empty id at index %d", index), envelope.LogID)
63
+ }
64
+ if _, duplicate := seen[ids[index]]; duplicate {
65
+ return nil, common.NewLogIDError(fmt.Sprintf("canvas allocate returned duplicate id %q", ids[index]), envelope.LogID)
66
+ }
67
+ seen[ids[index]] = struct{}{}
68
+ }
69
+ return &AllocateResult{AssetIDs: ids, LogID: strings.TrimSpace(envelope.LogID)}, nil
70
+ }