orch-code 0.1.4 → 0.1.5

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.
@@ -25,14 +25,24 @@ type State struct {
25
25
  }
26
26
 
27
27
  type Credential struct {
28
- Type string `json:"type"`
29
- Key string `json:"key,omitempty"`
30
- AccessToken string `json:"accessToken,omitempty"`
31
- RefreshToken string `json:"refreshToken,omitempty"`
32
- ExpiresAt time.Time `json:"expiresAt,omitempty"`
33
- AccountID string `json:"accountId,omitempty"`
34
- Email string `json:"email,omitempty"`
35
- UpdatedAt time.Time `json:"updatedAt"`
28
+ ID string `json:"id,omitempty"`
29
+ Label string `json:"label,omitempty"`
30
+ Type string `json:"type"`
31
+ Key string `json:"key,omitempty"`
32
+ AccessToken string `json:"accessToken,omitempty"`
33
+ RefreshToken string `json:"refreshToken,omitempty"`
34
+ ExpiresAt time.Time `json:"expiresAt,omitempty"`
35
+ AccountID string `json:"accountId,omitempty"`
36
+ Email string `json:"email,omitempty"`
37
+ CooldownUntil time.Time `json:"cooldownUntil,omitempty"`
38
+ LastError string `json:"lastError,omitempty"`
39
+ LastUsedAt time.Time `json:"lastUsedAt,omitempty"`
40
+ UpdatedAt time.Time `json:"updatedAt"`
41
+ }
42
+
43
+ type ProviderCredentials struct {
44
+ ActiveID string `json:"activeId,omitempty"`
45
+ Credentials []Credential `json:"credentials"`
36
46
  }
37
47
 
38
48
  func Load(repoRoot string) (*State, error) {
@@ -67,82 +77,31 @@ func Load(repoRoot string) (*State, error) {
67
77
  }
68
78
 
69
79
  func LoadAll(repoRoot string) (map[string]Credential, error) {
70
- path := filepath.Join(repoRoot, config.OrchDir, authFile)
71
- data, err := os.ReadFile(path)
80
+ stores, err := readProviderStores(repoRoot)
72
81
  if err != nil {
73
- if os.IsNotExist(err) {
74
- return map[string]Credential{}, nil
75
- }
76
- return nil, fmt.Errorf("read auth state: %w", err)
82
+ return nil, err
77
83
  }
78
84
 
79
- parsed := map[string]Credential{}
80
- if err := json.Unmarshal(data, &parsed); err == nil {
81
- clean := map[string]Credential{}
82
- for provider, cred := range parsed {
83
- id := strings.ToLower(strings.TrimSpace(provider))
84
- if id == "" {
85
- continue
86
- }
87
-
88
- cred.Type = strings.ToLower(strings.TrimSpace(cred.Type))
89
- cred.Key = strings.TrimSpace(cred.Key)
90
- cred.AccessToken = strings.TrimSpace(cred.AccessToken)
91
- cred.RefreshToken = strings.TrimSpace(cred.RefreshToken)
92
- cred.AccountID = strings.TrimSpace(cred.AccountID)
93
- cred.Email = strings.TrimSpace(cred.Email)
94
-
95
- if cred.Type == "" {
96
- continue
97
- }
98
- if cred.Type == "api_key" {
99
- cred.Type = "api"
100
- }
101
- if cred.Type == "account" {
102
- cred.Type = "oauth"
103
- }
104
- clean[id] = cred
105
- }
106
- if len(clean) > 0 {
107
- return clean, nil
85
+ active := map[string]Credential{}
86
+ for provider, store := range stores {
87
+ cred, ok := activeCredential(store)
88
+ if ok {
89
+ active[provider] = cred
108
90
  }
109
91
  }
92
+ return active, nil
93
+ }
110
94
 
111
- var state State
112
- if err := json.Unmarshal(data, &state); err != nil {
113
- return nil, fmt.Errorf("parse auth state: %w", err)
114
- }
115
-
116
- provider := strings.ToLower(strings.TrimSpace(state.Provider))
117
- if provider == "" {
118
- provider = "openai"
119
- }
120
-
121
- mode := strings.ToLower(strings.TrimSpace(state.Mode))
122
- cred := Credential{
123
- RefreshToken: strings.TrimSpace(state.RefreshToken),
124
- ExpiresAt: state.ExpiresAt,
125
- AccountID: strings.TrimSpace(state.AccountID),
126
- Email: strings.TrimSpace(state.Email),
127
- UpdatedAt: state.UpdatedAt,
128
- }
129
- if mode == "account" {
130
- cred.Type = "oauth"
131
- cred.AccessToken = strings.TrimSpace(state.AccessToken)
132
- }
133
- if mode == "api_key" {
134
- cred.Type = "api"
135
- cred.Key = strings.TrimSpace(state.AccessToken)
136
- }
137
- if cred.Type == "" {
138
- cred.Type = mode
95
+ func List(repoRoot, provider string) ([]Credential, string, error) {
96
+ store, err := loadProviderCredentials(repoRoot, provider)
97
+ if err != nil {
98
+ return nil, "", err
139
99
  }
140
-
141
- if cred.Type == "" {
142
- return map[string]Credential{}, nil
100
+ if store == nil {
101
+ return []Credential{}, "", nil
143
102
  }
144
-
145
- return map[string]Credential{provider: cred}, nil
103
+ creds := append([]Credential(nil), store.Credentials...)
104
+ return creds, strings.TrimSpace(store.ActiveID), nil
146
105
  }
147
106
 
148
107
  func Save(repoRoot string, state *State) error {
@@ -186,15 +145,14 @@ func Clear(repoRoot string) error {
186
145
  }
187
146
 
188
147
  func Get(repoRoot, provider string) (*Credential, error) {
189
- all, err := LoadAll(repoRoot)
148
+ store, err := loadProviderCredentials(repoRoot, provider)
190
149
  if err != nil {
191
150
  return nil, err
192
151
  }
193
- id := strings.ToLower(strings.TrimSpace(provider))
194
- if id == "" {
152
+ if store == nil {
195
153
  return nil, nil
196
154
  }
197
- cred, ok := all[id]
155
+ cred, ok := activeCredential(*store)
198
156
  if !ok {
199
157
  return nil, nil
200
158
  }
@@ -207,57 +165,131 @@ func Set(repoRoot, provider string, cred Credential) error {
207
165
  return fmt.Errorf("provider is required")
208
166
  }
209
167
 
210
- kind := strings.ToLower(strings.TrimSpace(cred.Type))
211
- if kind == "api_key" {
212
- kind = "api"
168
+ stores, err := readProviderStores(repoRoot)
169
+ if err != nil {
170
+ return err
213
171
  }
214
- if kind == "account" {
215
- kind = "oauth"
172
+
173
+ store := stores[id]
174
+ cred = normalizeCredential(cred)
175
+ if err := validateCredential(cred); err != nil {
176
+ return err
216
177
  }
217
- if kind != "api" && kind != "oauth" && kind != "wellknown" {
218
- return fmt.Errorf("unsupported credential type: %s", cred.Type)
178
+
179
+ index := matchCredential(store.Credentials, cred)
180
+ if index >= 0 {
181
+ if cred.ID == "" {
182
+ cred.ID = store.Credentials[index].ID
183
+ }
184
+ store.Credentials[index] = mergeCredential(store.Credentials[index], cred)
185
+ } else {
186
+ cred.ID = ensureCredentialID(store.Credentials, cred)
187
+ store.Credentials = append(store.Credentials, cred)
219
188
  }
220
- if kind == "api" && strings.TrimSpace(cred.Key) == "" {
221
- return fmt.Errorf("api credential key cannot be empty")
189
+ store.ActiveID = cred.ID
190
+ stores[id] = normalizeProviderStore(store)
191
+
192
+ return writeProviderStores(repoRoot, stores)
193
+ }
194
+
195
+ func SetActive(repoRoot, provider, credentialID string) error {
196
+ store, err := loadProviderCredentials(repoRoot, provider)
197
+ if err != nil {
198
+ return err
222
199
  }
223
- if kind == "oauth" && strings.TrimSpace(cred.AccessToken) == "" {
224
- return fmt.Errorf("oauth access token cannot be empty")
200
+ if store == nil {
201
+ return fmt.Errorf("no stored credentials for provider %s", provider)
202
+ }
203
+ credentialID = strings.TrimSpace(credentialID)
204
+ if credentialID == "" {
205
+ return fmt.Errorf("credential id is required")
225
206
  }
226
207
 
227
- if err := config.EnsureOrchDir(repoRoot); err != nil {
228
- return err
208
+ for _, cred := range store.Credentials {
209
+ if cred.ID == credentialID {
210
+ store.ActiveID = credentialID
211
+ return saveProviderCredentials(repoRoot, provider, *store)
212
+ }
229
213
  }
230
214
 
231
- all, err := LoadAll(repoRoot)
215
+ return fmt.Errorf("credential %s not found for provider %s", credentialID, provider)
216
+ }
217
+
218
+ func Remove(repoRoot, provider string) error {
219
+ stores, err := readProviderStores(repoRoot)
232
220
  if err != nil {
233
221
  return err
234
222
  }
223
+ id := strings.ToLower(strings.TrimSpace(provider))
224
+ if id == "" {
225
+ return fmt.Errorf("provider is required")
226
+ }
227
+ if _, ok := stores[id]; !ok {
228
+ return nil
229
+ }
230
+ delete(stores, id)
235
231
 
236
- cred.Type = kind
237
- cred.Key = strings.TrimSpace(cred.Key)
238
- cred.AccessToken = strings.TrimSpace(cred.AccessToken)
239
- cred.RefreshToken = strings.TrimSpace(cred.RefreshToken)
240
- cred.AccountID = strings.TrimSpace(cred.AccountID)
241
- cred.Email = strings.TrimSpace(cred.Email)
242
- cred.UpdatedAt = time.Now().UTC()
243
-
244
- all[id] = cred
232
+ if len(stores) == 0 {
233
+ return Clear(repoRoot)
234
+ }
235
+ return writeProviderStores(repoRoot, stores)
236
+ }
245
237
 
246
- data, err := json.MarshalIndent(all, "", " ")
238
+ func RemoveCredential(repoRoot, provider, credentialID string) error {
239
+ store, err := loadProviderCredentials(repoRoot, provider)
247
240
  if err != nil {
248
- return fmt.Errorf("serialize auth state: %w", err)
241
+ return err
242
+ }
243
+ if store == nil {
244
+ return fmt.Errorf("no stored credentials for provider %s", provider)
245
+ }
246
+ credentialID = strings.TrimSpace(credentialID)
247
+ if credentialID == "" {
248
+ return fmt.Errorf("credential id is required")
249
249
  }
250
250
 
251
- path := filepath.Join(repoRoot, config.OrchDir, authFile)
252
- if err := os.WriteFile(path, data, 0o600); err != nil {
253
- return fmt.Errorf("write auth state: %w", err)
251
+ updated := make([]Credential, 0, len(store.Credentials))
252
+ removed := false
253
+ for _, cred := range store.Credentials {
254
+ if cred.ID == credentialID {
255
+ removed = true
256
+ continue
257
+ }
258
+ updated = append(updated, cred)
259
+ }
260
+ if !removed {
261
+ return fmt.Errorf("credential %s not found for provider %s", credentialID, provider)
262
+ }
263
+ if len(updated) == 0 {
264
+ return Remove(repoRoot, provider)
254
265
  }
255
266
 
256
- return nil
267
+ store.Credentials = updated
268
+ if strings.TrimSpace(store.ActiveID) == credentialID {
269
+ store.ActiveID = updated[0].ID
270
+ }
271
+ return saveProviderCredentials(repoRoot, provider, *store)
257
272
  }
258
273
 
259
- func Remove(repoRoot, provider string) error {
260
- all, err := LoadAll(repoRoot)
274
+ func loadProviderCredentials(repoRoot, provider string) (*ProviderCredentials, error) {
275
+ stores, err := readProviderStores(repoRoot)
276
+ if err != nil {
277
+ return nil, err
278
+ }
279
+ id := strings.ToLower(strings.TrimSpace(provider))
280
+ if id == "" {
281
+ return nil, fmt.Errorf("provider is required")
282
+ }
283
+ store, ok := stores[id]
284
+ if !ok {
285
+ return nil, nil
286
+ }
287
+ store = normalizeProviderStore(store)
288
+ return &store, nil
289
+ }
290
+
291
+ func saveProviderCredentials(repoRoot, provider string, store ProviderCredentials) error {
292
+ stores, err := readProviderStores(repoRoot)
261
293
  if err != nil {
262
294
  return err
263
295
  }
@@ -265,16 +297,125 @@ func Remove(repoRoot, provider string) error {
265
297
  if id == "" {
266
298
  return fmt.Errorf("provider is required")
267
299
  }
268
- if _, ok := all[id]; !ok {
269
- return nil
300
+ stores[id] = normalizeProviderStore(store)
301
+ return writeProviderStores(repoRoot, stores)
302
+ }
303
+
304
+ func mutateCredential(repoRoot, provider, credentialID string, mutator func(*Credential) error) error {
305
+ store, err := loadProviderCredentials(repoRoot, provider)
306
+ if err != nil {
307
+ return err
308
+ }
309
+ if store == nil {
310
+ return fmt.Errorf("no stored credentials for provider %s", provider)
311
+ }
312
+ credentialID = strings.TrimSpace(credentialID)
313
+ if credentialID == "" {
314
+ return fmt.Errorf("credential id is required")
315
+ }
316
+
317
+ for i := range store.Credentials {
318
+ if store.Credentials[i].ID != credentialID {
319
+ continue
320
+ }
321
+ if err := mutator(&store.Credentials[i]); err != nil {
322
+ return err
323
+ }
324
+ return saveProviderCredentials(repoRoot, provider, *store)
325
+ }
326
+
327
+ return fmt.Errorf("credential %s not found for provider %s", credentialID, provider)
328
+ }
329
+
330
+ func readProviderStores(repoRoot string) (map[string]ProviderCredentials, error) {
331
+ path := filepath.Join(repoRoot, config.OrchDir, authFile)
332
+ data, err := os.ReadFile(path)
333
+ if err != nil {
334
+ if os.IsNotExist(err) {
335
+ return map[string]ProviderCredentials{}, nil
336
+ }
337
+ return nil, fmt.Errorf("read auth state: %w", err)
270
338
  }
271
- delete(all, id)
272
339
 
273
- if len(all) == 0 {
340
+ stores := map[string]ProviderCredentials{}
341
+ if err := json.Unmarshal(data, &stores); err == nil {
342
+ stores = normalizeProviderStores(stores)
343
+ if len(stores) > 0 {
344
+ return stores, nil
345
+ }
346
+ }
347
+
348
+ legacy, legacyErr := loadLegacyCredentials(data)
349
+ if legacyErr == nil && len(legacy) > 0 {
350
+ return normalizeProviderStores(legacy), nil
351
+ }
352
+
353
+ var state State
354
+ if err := json.Unmarshal(data, &state); err != nil {
355
+ return nil, fmt.Errorf("parse auth state: %w", err)
356
+ }
357
+
358
+ provider := strings.ToLower(strings.TrimSpace(state.Provider))
359
+ if provider == "" {
360
+ provider = "openai"
361
+ }
362
+ mode := strings.ToLower(strings.TrimSpace(state.Mode))
363
+ cred := Credential{
364
+ RefreshToken: strings.TrimSpace(state.RefreshToken),
365
+ ExpiresAt: state.ExpiresAt,
366
+ AccountID: strings.TrimSpace(state.AccountID),
367
+ Email: strings.TrimSpace(state.Email),
368
+ UpdatedAt: state.UpdatedAt,
369
+ }
370
+ if mode == "account" {
371
+ cred.Type = "oauth"
372
+ cred.AccessToken = strings.TrimSpace(state.AccessToken)
373
+ }
374
+ if mode == "api_key" {
375
+ cred.Type = "api"
376
+ cred.Key = strings.TrimSpace(state.AccessToken)
377
+ }
378
+ if cred.Type == "" {
379
+ cred.Type = mode
380
+ }
381
+ if strings.TrimSpace(cred.Type) == "" {
382
+ return map[string]ProviderCredentials{}, nil
383
+ }
384
+
385
+ return normalizeProviderStores(map[string]ProviderCredentials{provider: {Credentials: []Credential{cred}}}), nil
386
+ }
387
+
388
+ func loadLegacyCredentials(data []byte) (map[string]ProviderCredentials, error) {
389
+ parsed := map[string]Credential{}
390
+ if err := json.Unmarshal(data, &parsed); err != nil {
391
+ return nil, err
392
+ }
393
+
394
+ stores := map[string]ProviderCredentials{}
395
+ for provider, cred := range parsed {
396
+ id := strings.ToLower(strings.TrimSpace(provider))
397
+ if id == "" {
398
+ continue
399
+ }
400
+ cred = normalizeCredential(cred)
401
+ if strings.TrimSpace(cred.Type) == "" {
402
+ continue
403
+ }
404
+ stores[id] = ProviderCredentials{Credentials: []Credential{cred}}
405
+ }
406
+ return stores, nil
407
+ }
408
+
409
+ func writeProviderStores(repoRoot string, stores map[string]ProviderCredentials) error {
410
+ if len(stores) == 0 {
274
411
  return Clear(repoRoot)
275
412
  }
413
+ if err := config.EnsureOrchDir(repoRoot); err != nil {
414
+ return err
415
+ }
276
416
 
277
- data, err := json.MarshalIndent(all, "", " ")
417
+ stores = normalizeProviderStores(stores)
418
+ data, err := json.MarshalIndent(stores, "", " ")
278
419
  if err != nil {
279
420
  return fmt.Errorf("serialize auth state: %w", err)
280
421
  }
@@ -285,3 +426,223 @@ func Remove(repoRoot, provider string) error {
285
426
  }
286
427
  return nil
287
428
  }
429
+
430
+ func normalizeProviderStores(stores map[string]ProviderCredentials) map[string]ProviderCredentials {
431
+ normalized := map[string]ProviderCredentials{}
432
+ for provider, store := range stores {
433
+ id := strings.ToLower(strings.TrimSpace(provider))
434
+ if id == "" {
435
+ continue
436
+ }
437
+ store = normalizeProviderStore(store)
438
+ if len(store.Credentials) == 0 {
439
+ continue
440
+ }
441
+ normalized[id] = store
442
+ }
443
+ return normalized
444
+ }
445
+
446
+ func normalizeProviderStore(store ProviderCredentials) ProviderCredentials {
447
+ seen := map[string]struct{}{}
448
+ creds := make([]Credential, 0, len(store.Credentials))
449
+ for _, cred := range store.Credentials {
450
+ cred = normalizeCredential(cred)
451
+ if strings.TrimSpace(cred.Type) == "" {
452
+ continue
453
+ }
454
+ cred.ID = ensureCredentialID(creds, cred)
455
+ if _, ok := seen[cred.ID]; ok {
456
+ continue
457
+ }
458
+ seen[cred.ID] = struct{}{}
459
+ creds = append(creds, cred)
460
+ }
461
+ store.Credentials = creds
462
+ store.ActiveID = strings.TrimSpace(store.ActiveID)
463
+ if len(store.Credentials) == 0 {
464
+ store.ActiveID = ""
465
+ return store
466
+ }
467
+ if store.ActiveID == "" || indexOfCredential(store.Credentials, store.ActiveID) < 0 {
468
+ store.ActiveID = store.Credentials[0].ID
469
+ }
470
+ return store
471
+ }
472
+
473
+ func normalizeCredential(cred Credential) Credential {
474
+ cred.ID = strings.TrimSpace(cred.ID)
475
+ cred.Label = strings.TrimSpace(cred.Label)
476
+ cred.Type = strings.ToLower(strings.TrimSpace(cred.Type))
477
+ cred.Key = strings.TrimSpace(cred.Key)
478
+ cred.AccessToken = strings.TrimSpace(cred.AccessToken)
479
+ cred.RefreshToken = strings.TrimSpace(cred.RefreshToken)
480
+ cred.AccountID = strings.TrimSpace(cred.AccountID)
481
+ cred.Email = strings.TrimSpace(cred.Email)
482
+ cred.LastError = strings.TrimSpace(cred.LastError)
483
+ if cred.Type == "api_key" {
484
+ cred.Type = "api"
485
+ }
486
+ if cred.Type == "account" {
487
+ cred.Type = "oauth"
488
+ }
489
+ if cred.Label == "" {
490
+ switch {
491
+ case cred.Email != "":
492
+ cred.Label = cred.Email
493
+ case cred.AccountID != "":
494
+ cred.Label = cred.AccountID
495
+ default:
496
+ cred.Label = cred.Type
497
+ }
498
+ }
499
+ if cred.UpdatedAt.IsZero() {
500
+ cred.UpdatedAt = time.Now().UTC()
501
+ }
502
+ return cred
503
+ }
504
+
505
+ func validateCredential(cred Credential) error {
506
+ if cred.Type != "api" && cred.Type != "oauth" && cred.Type != "wellknown" {
507
+ return fmt.Errorf("unsupported credential type: %s", cred.Type)
508
+ }
509
+ if cred.Type == "api" && strings.TrimSpace(cred.Key) == "" {
510
+ return fmt.Errorf("api credential key cannot be empty")
511
+ }
512
+ if cred.Type == "oauth" && strings.TrimSpace(cred.AccessToken) == "" {
513
+ return fmt.Errorf("oauth access token cannot be empty")
514
+ }
515
+ return nil
516
+ }
517
+
518
+ func activeCredential(store ProviderCredentials) (Credential, bool) {
519
+ store = normalizeProviderStore(store)
520
+ if len(store.Credentials) == 0 {
521
+ return Credential{}, false
522
+ }
523
+ index := indexOfCredential(store.Credentials, store.ActiveID)
524
+ if index < 0 {
525
+ index = 0
526
+ }
527
+ return store.Credentials[index], true
528
+ }
529
+
530
+ func indexOfCredential(creds []Credential, credentialID string) int {
531
+ for i, cred := range creds {
532
+ if cred.ID == credentialID {
533
+ return i
534
+ }
535
+ }
536
+ return -1
537
+ }
538
+
539
+ func matchCredential(creds []Credential, incoming Credential) int {
540
+ if incoming.ID != "" {
541
+ return indexOfCredential(creds, incoming.ID)
542
+ }
543
+ if incoming.Type == "oauth" {
544
+ for i, cred := range creds {
545
+ if cred.Type != "oauth" {
546
+ continue
547
+ }
548
+ if incoming.AccountID != "" && incoming.AccountID == cred.AccountID {
549
+ return i
550
+ }
551
+ if incoming.Email != "" && strings.EqualFold(incoming.Email, cred.Email) {
552
+ return i
553
+ }
554
+ }
555
+ }
556
+ if incoming.Type == "api" {
557
+ for i, cred := range creds {
558
+ if cred.Type == "api" {
559
+ return i
560
+ }
561
+ }
562
+ }
563
+ return -1
564
+ }
565
+
566
+ func mergeCredential(existing, incoming Credential) Credential {
567
+ merged := existing
568
+ merged.Type = incoming.Type
569
+ merged.Label = incoming.Label
570
+ if incoming.Key != "" {
571
+ merged.Key = incoming.Key
572
+ }
573
+ if incoming.AccessToken != "" {
574
+ merged.AccessToken = incoming.AccessToken
575
+ }
576
+ if incoming.RefreshToken != "" {
577
+ merged.RefreshToken = incoming.RefreshToken
578
+ }
579
+ if !incoming.ExpiresAt.IsZero() {
580
+ merged.ExpiresAt = incoming.ExpiresAt
581
+ }
582
+ if incoming.AccountID != "" {
583
+ merged.AccountID = incoming.AccountID
584
+ }
585
+ if incoming.Email != "" {
586
+ merged.Email = incoming.Email
587
+ }
588
+ if !incoming.CooldownUntil.IsZero() || !merged.CooldownUntil.IsZero() {
589
+ merged.CooldownUntil = incoming.CooldownUntil
590
+ }
591
+ if incoming.LastError != "" || merged.LastError != "" {
592
+ merged.LastError = incoming.LastError
593
+ }
594
+ if !incoming.LastUsedAt.IsZero() || !merged.LastUsedAt.IsZero() {
595
+ merged.LastUsedAt = incoming.LastUsedAt
596
+ }
597
+ merged.UpdatedAt = incoming.UpdatedAt
598
+ return normalizeCredential(merged)
599
+ }
600
+
601
+ func ensureCredentialID(existing []Credential, cred Credential) string {
602
+ if cred.ID != "" && indexOfCredential(existing, cred.ID) < 0 {
603
+ return cred.ID
604
+ }
605
+ base := credentialIDBase(cred)
606
+ if base == "" {
607
+ base = cred.Type
608
+ }
609
+ base = sanitizeCredentialID(base)
610
+ if base == "" {
611
+ base = "credential"
612
+ }
613
+ id := base
614
+ for suffix := 2; indexOfCredential(existing, id) >= 0; suffix++ {
615
+ id = fmt.Sprintf("%s-%d", base, suffix)
616
+ }
617
+ return id
618
+ }
619
+
620
+ func credentialIDBase(cred Credential) string {
621
+ switch {
622
+ case cred.AccountID != "":
623
+ return cred.AccountID
624
+ case cred.Email != "":
625
+ return strings.ToLower(cred.Email)
626
+ case cred.Label != "":
627
+ return strings.ToLower(cred.Label)
628
+ case !cred.UpdatedAt.IsZero():
629
+ return fmt.Sprintf("%s-%d", cred.Type, cred.UpdatedAt.Unix())
630
+ default:
631
+ return cred.Type
632
+ }
633
+ }
634
+
635
+ func sanitizeCredentialID(value string) string {
636
+ var b strings.Builder
637
+ for _, r := range strings.ToLower(strings.TrimSpace(value)) {
638
+ switch {
639
+ case r >= 'a' && r <= 'z', r >= '0' && r <= '9':
640
+ b.WriteRune(r)
641
+ case r == '-', r == '_', r == '.':
642
+ b.WriteRune(r)
643
+ case r == '@':
644
+ b.WriteRune('-')
645
+ }
646
+ }
647
+ return strings.Trim(b.String(), "-._")
648
+ }