@liuzijian625/code-cli 1.0.6 → 1.0.7

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,1436 @@
1
+ package main
2
+
3
+ import (
4
+ "bytes"
5
+ "crypto/aes"
6
+ "crypto/cipher"
7
+ "crypto/hmac"
8
+ "crypto/rand"
9
+ "crypto/sha256"
10
+ "crypto/subtle"
11
+ "encoding/base64"
12
+ "encoding/json"
13
+ "flag"
14
+ "fmt"
15
+ "io"
16
+ "log"
17
+ "net/http"
18
+ "os"
19
+ "path/filepath"
20
+ "strings"
21
+ "sync"
22
+ "time"
23
+ )
24
+
25
+ const (
26
+ sessionCookieName = "code_cli_remote_session"
27
+ authKDFName = "pbkdf2-sha256"
28
+ defaultIterations = 200_000
29
+ keyLenBytes = 32
30
+ )
31
+
32
+ type config struct {
33
+ Version int `json:"version"`
34
+ AuthKDF string `json:"auth_kdf"`
35
+ AuthIterations int `json:"auth_iterations"`
36
+ AuthSalt string `json:"auth_salt"`
37
+ AuthHash string `json:"auth_hash"`
38
+ }
39
+
40
+ type preset struct {
41
+ Name string `json:"name"`
42
+ URL string `json:"url"`
43
+ Key string `json:"key"`
44
+ }
45
+
46
+ type presets struct {
47
+ Codex []preset `json:"codex"`
48
+ Claude []preset `json:"claude"`
49
+ Gemini []preset `json:"gemini"`
50
+ }
51
+
52
+ type presetsRaw struct {
53
+ Codex json.RawMessage `json:"codex"`
54
+ Claude json.RawMessage `json:"claude"`
55
+ Gemini json.RawMessage `json:"gemini"`
56
+ }
57
+
58
+ type encryptedPayload struct {
59
+ V int `json:"v"`
60
+ KDF string `json:"kdf,omitempty"`
61
+ Iterations int `json:"iterations,omitempty"`
62
+ Cipher string `json:"cipher,omitempty"`
63
+ Salt string `json:"salt"`
64
+ IV string `json:"iv"`
65
+ CT string `json:"ct"`
66
+ Tag string `json:"tag"`
67
+ }
68
+
69
+ type session struct {
70
+ password string
71
+ expiresAt time.Time
72
+ }
73
+
74
+ type sessionStore struct {
75
+ mu sync.Mutex
76
+ sessions map[string]session
77
+ ttl time.Duration
78
+ }
79
+
80
+ func newSessionStore(ttl time.Duration) *sessionStore {
81
+ return &sessionStore{
82
+ sessions: map[string]session{},
83
+ ttl: ttl,
84
+ }
85
+ }
86
+
87
+ func (s *sessionStore) create(password string) (token string, expiresAt time.Time, err error) {
88
+ random := make([]byte, 32)
89
+ if _, err := rand.Read(random); err != nil {
90
+ return "", time.Time{}, err
91
+ }
92
+ token = base64.RawURLEncoding.EncodeToString(random)
93
+ expiresAt = time.Now().Add(s.ttl)
94
+
95
+ s.mu.Lock()
96
+ s.sessions[token] = session{password: password, expiresAt: expiresAt}
97
+ s.mu.Unlock()
98
+
99
+ return token, expiresAt, nil
100
+ }
101
+
102
+ func (s *sessionStore) get(token string) (password string, ok bool) {
103
+ s.mu.Lock()
104
+ defer s.mu.Unlock()
105
+ entry, ok := s.sessions[token]
106
+ if !ok {
107
+ return "", false
108
+ }
109
+ if time.Now().After(entry.expiresAt) {
110
+ delete(s.sessions, token)
111
+ return "", false
112
+ }
113
+ return entry.password, true
114
+ }
115
+
116
+ func (s *sessionStore) delete(token string) {
117
+ s.mu.Lock()
118
+ delete(s.sessions, token)
119
+ s.mu.Unlock()
120
+ }
121
+
122
+ type app struct {
123
+ cfgPath string
124
+ dataPath string
125
+ cfgMu sync.Mutex
126
+ cfg *config
127
+
128
+ sessions *sessionStore
129
+ fileMu sync.Mutex
130
+ }
131
+
132
+ func main() {
133
+ var (
134
+ listenAddr = flag.String("listen", ":8080", "listen address, e.g. :8080")
135
+ cfgPath = flag.String("config", "", "config file path (default: next to executable)")
136
+ dataPath = flag.String("data", "", "encrypted presets file path (default: next to executable)")
137
+ initMode = flag.Bool("init", false, "initialize config/password and create empty encrypted presets file")
138
+ forceInit = flag.Bool("force", false, "overwrite existing config/data when used with -init")
139
+ password = flag.String("password", "", "password (use with -init; prefer env CODECLI_REMOTE_PASSWORD)")
140
+ )
141
+ flag.Parse()
142
+
143
+ if *password == "" {
144
+ *password = os.Getenv("CODECLI_REMOTE_PASSWORD")
145
+ }
146
+
147
+ exeDir := "."
148
+ if exePath, err := os.Executable(); err == nil {
149
+ exeDir = filepath.Dir(exePath)
150
+ }
151
+
152
+ resolvedCfgPath := strings.TrimSpace(*cfgPath)
153
+ if resolvedCfgPath == "" {
154
+ resolvedCfgPath = filepath.Join(exeDir, "code-cli-remote.json")
155
+ }
156
+ resolvedDataPath := strings.TrimSpace(*dataPath)
157
+ if resolvedDataPath == "" {
158
+ resolvedDataPath = filepath.Join(exeDir, "presets.enc")
159
+ }
160
+
161
+ if *initMode {
162
+ if err := runInit(resolvedCfgPath, resolvedDataPath, *password, *forceInit); err != nil {
163
+ log.Fatalf("init failed: %v", err)
164
+ }
165
+ log.Printf("initialized. presets url: /presets.enc")
166
+ return
167
+ }
168
+
169
+ var cfgPtr *config
170
+ cfgVal, err := loadConfig(resolvedCfgPath)
171
+ if err != nil {
172
+ if !os.IsNotExist(err) {
173
+ log.Fatalf("load config failed: %v", err)
174
+ }
175
+ } else {
176
+ cfgPtr = &cfgVal
177
+ }
178
+
179
+ a := &app{
180
+ cfgPath: resolvedCfgPath,
181
+ dataPath: resolvedDataPath,
182
+ cfg: cfgPtr,
183
+ sessions: newSessionStore(12 * time.Hour),
184
+ }
185
+
186
+ mux := http.NewServeMux()
187
+ mux.HandleFunc("/", a.handleIndex)
188
+ mux.HandleFunc("/presets.enc", a.handlePresetsEnc)
189
+ mux.HandleFunc("/api/status", a.handleStatus)
190
+ mux.HandleFunc("/api/setup", a.handleSetup)
191
+ mux.HandleFunc("/api/login", a.handleLogin)
192
+ mux.HandleFunc("/api/logout", a.handleLogout)
193
+ mux.HandleFunc("/api/presets", a.handlePresets)
194
+ mux.HandleFunc("/healthz", func(w http.ResponseWriter, _ *http.Request) {
195
+ w.Header().Set("Content-Type", "text/plain; charset=utf-8")
196
+ _, _ = w.Write([]byte("ok\n"))
197
+ })
198
+
199
+ server := &http.Server{
200
+ Addr: *listenAddr,
201
+ Handler: loggingMiddleware(mux),
202
+ ReadHeaderTimeout: 5 * time.Second,
203
+ }
204
+
205
+ log.Printf("listening on %s", *listenAddr)
206
+ log.Printf("config path: %s", a.cfgPath)
207
+ log.Printf("data path: %s", a.dataPath)
208
+ log.Fatal(server.ListenAndServe())
209
+ }
210
+
211
+ func runInit(cfgPath, dataPath, password string, force bool) error {
212
+ if strings.TrimSpace(password) == "" {
213
+ return fmt.Errorf("password is required (flag -password or env CODECLI_REMOTE_PASSWORD)")
214
+ }
215
+
216
+ if !force {
217
+ if _, err := os.Stat(cfgPath); err == nil {
218
+ return fmt.Errorf("config already exists: %s (use -force to overwrite)", cfgPath)
219
+ }
220
+ if _, err := os.Stat(dataPath); err == nil {
221
+ return fmt.Errorf("data already exists: %s (use -force to overwrite)", dataPath)
222
+ }
223
+ }
224
+
225
+ salt := make([]byte, 16)
226
+ if _, err := rand.Read(salt); err != nil {
227
+ return err
228
+ }
229
+ hash := pbkdf2Key([]byte(password), salt, defaultIterations, keyLenBytes)
230
+
231
+ cfg := config{
232
+ Version: 1,
233
+ AuthKDF: authKDFName,
234
+ AuthIterations: defaultIterations,
235
+ AuthSalt: base64.StdEncoding.EncodeToString(salt),
236
+ AuthHash: base64.StdEncoding.EncodeToString(hash),
237
+ }
238
+ cfgBytes, err := json.MarshalIndent(cfg, "", " ")
239
+ if err != nil {
240
+ return err
241
+ }
242
+ cfgBytes = append(cfgBytes, '\n')
243
+
244
+ if err := writeFile0600(cfgPath, cfgBytes); err != nil {
245
+ return err
246
+ }
247
+
248
+ empty := presets{Codex: []preset{}, Claude: []preset{}, Gemini: []preset{}}
249
+ payload, err := encryptPresets(empty, password)
250
+ if err != nil {
251
+ return err
252
+ }
253
+ if err := writeFile0600(dataPath, payload); err != nil {
254
+ return err
255
+ }
256
+
257
+ return nil
258
+ }
259
+
260
+ func loadConfig(cfgPath string) (config, error) {
261
+ raw, err := os.ReadFile(cfgPath)
262
+ if err != nil {
263
+ return config{}, err
264
+ }
265
+ var cfg config
266
+ if err := json.Unmarshal(raw, &cfg); err != nil {
267
+ return config{}, err
268
+ }
269
+ if cfg.Version != 1 {
270
+ return config{}, fmt.Errorf("unsupported config version: %d", cfg.Version)
271
+ }
272
+ if cfg.AuthKDF != authKDFName {
273
+ return config{}, fmt.Errorf("unsupported auth_kdf: %s", cfg.AuthKDF)
274
+ }
275
+ if cfg.AuthIterations <= 0 {
276
+ return config{}, fmt.Errorf("invalid auth_iterations")
277
+ }
278
+ if strings.TrimSpace(cfg.AuthSalt) == "" || strings.TrimSpace(cfg.AuthHash) == "" {
279
+ return config{}, fmt.Errorf("missing auth_salt/auth_hash in config")
280
+ }
281
+ return cfg, nil
282
+ }
283
+
284
+ func writeFile0600(path string, content []byte) error {
285
+ if err := os.MkdirAll(filepath.Dir(path), 0o700); err != nil {
286
+ return err
287
+ }
288
+ if err := os.WriteFile(path, content, 0o600); err != nil {
289
+ return err
290
+ }
291
+ return nil
292
+ }
293
+
294
+ func (a *app) isInitialized() bool {
295
+ a.cfgMu.Lock()
296
+ defer a.cfgMu.Unlock()
297
+ return a.cfg != nil
298
+ }
299
+
300
+ func (a *app) getConfig() (config, bool) {
301
+ a.cfgMu.Lock()
302
+ defer a.cfgMu.Unlock()
303
+ if a.cfg == nil {
304
+ return config{}, false
305
+ }
306
+ return *a.cfg, true
307
+ }
308
+
309
+ func (a *app) setConfig(cfg config) {
310
+ a.cfgMu.Lock()
311
+ a.cfg = &cfg
312
+ a.cfgMu.Unlock()
313
+ }
314
+
315
+ func (a *app) handleIndex(w http.ResponseWriter, r *http.Request) {
316
+ if r.URL.Path != "/" {
317
+ http.NotFound(w, r)
318
+ return
319
+ }
320
+ w.Header().Set("Content-Type", "text/html; charset=utf-8")
321
+ w.Header().Set("Cache-Control", "no-store")
322
+ _, _ = w.Write([]byte(indexHTML))
323
+ }
324
+
325
+ func (a *app) handlePresetsEnc(w http.ResponseWriter, r *http.Request) {
326
+ if r.Method != http.MethodGet && r.Method != http.MethodHead {
327
+ w.Header().Set("Allow", "GET, HEAD")
328
+ http.Error(w, "method not allowed", http.StatusMethodNotAllowed)
329
+ return
330
+ }
331
+
332
+ a.fileMu.Lock()
333
+ defer a.fileMu.Unlock()
334
+
335
+ f, err := os.Open(a.dataPath)
336
+ if err != nil {
337
+ if os.IsNotExist(err) {
338
+ http.NotFound(w, r)
339
+ return
340
+ }
341
+ http.Error(w, "failed to open data file", http.StatusInternalServerError)
342
+ return
343
+ }
344
+ defer f.Close()
345
+
346
+ w.Header().Set("Content-Type", "application/json; charset=utf-8")
347
+ w.Header().Set("Cache-Control", "no-store")
348
+ _, _ = io.Copy(w, f)
349
+ }
350
+
351
+ func (a *app) handleStatus(w http.ResponseWriter, r *http.Request) {
352
+ if r.Method != http.MethodGet {
353
+ w.Header().Set("Allow", "GET")
354
+ http.Error(w, "method not allowed", http.StatusMethodNotAllowed)
355
+ return
356
+ }
357
+ writeJSON(w, http.StatusOK, map[string]any{
358
+ "initialized": a.isInitialized(),
359
+ })
360
+ }
361
+
362
+ func (a *app) handleSetup(w http.ResponseWriter, r *http.Request) {
363
+ if r.Method != http.MethodPost {
364
+ w.Header().Set("Allow", "POST")
365
+ http.Error(w, "method not allowed", http.StatusMethodNotAllowed)
366
+ return
367
+ }
368
+
369
+ if a.isInitialized() {
370
+ writeJSONError(w, http.StatusConflict, "already initialized")
371
+ return
372
+ }
373
+
374
+ var req struct {
375
+ Password string `json:"password"`
376
+ }
377
+ if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
378
+ writeJSONError(w, http.StatusBadRequest, "invalid json")
379
+ return
380
+ }
381
+ req.Password = strings.TrimSpace(req.Password)
382
+ if len(req.Password) < 8 {
383
+ writeJSONError(w, http.StatusBadRequest, "password must be at least 8 characters")
384
+ return
385
+ }
386
+
387
+ // If presets file exists, try to decrypt with provided password; otherwise create an empty encrypted file.
388
+ var existingPresets presets
389
+ dataExists := false
390
+ if raw, err := os.ReadFile(a.dataPath); err == nil {
391
+ dataExists = true
392
+ p, err := decryptPresets(raw, req.Password)
393
+ if err != nil {
394
+ writeJSONError(w, http.StatusBadRequest, "data file exists but password cannot decrypt it")
395
+ return
396
+ }
397
+ existingPresets = p
398
+ }
399
+
400
+ salt := make([]byte, 16)
401
+ if _, err := rand.Read(salt); err != nil {
402
+ writeJSONError(w, http.StatusInternalServerError, "failed to init")
403
+ return
404
+ }
405
+ hash := pbkdf2Key([]byte(req.Password), salt, defaultIterations, keyLenBytes)
406
+
407
+ cfg := config{
408
+ Version: 1,
409
+ AuthKDF: authKDFName,
410
+ AuthIterations: defaultIterations,
411
+ AuthSalt: base64.StdEncoding.EncodeToString(salt),
412
+ AuthHash: base64.StdEncoding.EncodeToString(hash),
413
+ }
414
+ cfgBytes, err := json.MarshalIndent(cfg, "", " ")
415
+ if err != nil {
416
+ writeJSONError(w, http.StatusInternalServerError, "failed to init")
417
+ return
418
+ }
419
+ cfgBytes = append(cfgBytes, '\n')
420
+
421
+ a.fileMu.Lock()
422
+ defer a.fileMu.Unlock()
423
+
424
+ if _, err := os.Stat(a.cfgPath); err == nil {
425
+ writeJSONError(w, http.StatusConflict, "config already exists")
426
+ return
427
+ } else if err != nil && !os.IsNotExist(err) {
428
+ writeJSONError(w, http.StatusInternalServerError, "failed to init")
429
+ return
430
+ }
431
+
432
+ if err := writeFile0600(a.cfgPath, cfgBytes); err != nil {
433
+ writeJSONError(w, http.StatusInternalServerError, "failed to write config")
434
+ return
435
+ }
436
+
437
+ if !dataExists {
438
+ empty := presets{Codex: []preset{}, Claude: []preset{}, Gemini: []preset{}}
439
+ payload, err := encryptPresets(empty, req.Password)
440
+ if err != nil {
441
+ writeJSONError(w, http.StatusInternalServerError, "failed to init")
442
+ return
443
+ }
444
+ if err := writeFile0600(a.dataPath, payload); err != nil {
445
+ writeJSONError(w, http.StatusInternalServerError, "failed to write presets")
446
+ return
447
+ }
448
+ } else {
449
+ // Normalize existing presets file by re-encrypting with current format/iterations.
450
+ payload, err := encryptPresets(existingPresets, req.Password)
451
+ if err != nil {
452
+ writeJSONError(w, http.StatusInternalServerError, "failed to init")
453
+ return
454
+ }
455
+ if err := writeFile0600(a.dataPath, payload); err != nil {
456
+ writeJSONError(w, http.StatusInternalServerError, "failed to write presets")
457
+ return
458
+ }
459
+ }
460
+
461
+ a.setConfig(cfg)
462
+
463
+ token, expiresAt, err := a.sessions.create(req.Password)
464
+ if err != nil {
465
+ writeJSONError(w, http.StatusInternalServerError, "failed to create session")
466
+ return
467
+ }
468
+ http.SetCookie(w, &http.Cookie{
469
+ Name: sessionCookieName,
470
+ Value: token,
471
+ Path: "/",
472
+ HttpOnly: true,
473
+ SameSite: http.SameSiteLaxMode,
474
+ Secure: r.TLS != nil,
475
+ Expires: expiresAt,
476
+ })
477
+
478
+ writeJSON(w, http.StatusOK, map[string]any{"ok": true})
479
+ }
480
+
481
+ func (a *app) handleLogin(w http.ResponseWriter, r *http.Request) {
482
+ if r.Method != http.MethodPost {
483
+ w.Header().Set("Allow", "POST")
484
+ http.Error(w, "method not allowed", http.StatusMethodNotAllowed)
485
+ return
486
+ }
487
+
488
+ var req struct {
489
+ Password string `json:"password"`
490
+ }
491
+ if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
492
+ writeJSONError(w, http.StatusBadRequest, "invalid json")
493
+ return
494
+ }
495
+ req.Password = strings.TrimSpace(req.Password)
496
+ if req.Password == "" {
497
+ writeJSONError(w, http.StatusBadRequest, "password required")
498
+ return
499
+ }
500
+
501
+ cfg, ok := a.getConfig()
502
+ if !ok {
503
+ writeJSONError(w, http.StatusConflict, "server not initialized")
504
+ return
505
+ }
506
+
507
+ salt, err := base64.StdEncoding.DecodeString(cfg.AuthSalt)
508
+ if err != nil {
509
+ writeJSONError(w, http.StatusInternalServerError, "invalid server config")
510
+ return
511
+ }
512
+ expectedHash, err := base64.StdEncoding.DecodeString(cfg.AuthHash)
513
+ if err != nil {
514
+ writeJSONError(w, http.StatusInternalServerError, "invalid server config")
515
+ return
516
+ }
517
+ actualHash := pbkdf2Key([]byte(req.Password), salt, cfg.AuthIterations, len(expectedHash))
518
+ if subtle.ConstantTimeCompare(actualHash, expectedHash) != 1 {
519
+ writeJSONError(w, http.StatusUnauthorized, "invalid password")
520
+ return
521
+ }
522
+
523
+ token, expiresAt, err := a.sessions.create(req.Password)
524
+ if err != nil {
525
+ writeJSONError(w, http.StatusInternalServerError, "failed to create session")
526
+ return
527
+ }
528
+
529
+ http.SetCookie(w, &http.Cookie{
530
+ Name: sessionCookieName,
531
+ Value: token,
532
+ Path: "/",
533
+ HttpOnly: true,
534
+ SameSite: http.SameSiteLaxMode,
535
+ Secure: r.TLS != nil,
536
+ Expires: expiresAt,
537
+ })
538
+
539
+ writeJSON(w, http.StatusOK, map[string]any{"ok": true})
540
+ }
541
+
542
+ func (a *app) handleLogout(w http.ResponseWriter, r *http.Request) {
543
+ if r.Method != http.MethodPost {
544
+ w.Header().Set("Allow", "POST")
545
+ http.Error(w, "method not allowed", http.StatusMethodNotAllowed)
546
+ return
547
+ }
548
+
549
+ if token, ok := readSessionCookie(r); ok {
550
+ a.sessions.delete(token)
551
+ }
552
+
553
+ http.SetCookie(w, &http.Cookie{
554
+ Name: sessionCookieName,
555
+ Value: "",
556
+ Path: "/",
557
+ HttpOnly: true,
558
+ SameSite: http.SameSiteLaxMode,
559
+ Secure: r.TLS != nil,
560
+ MaxAge: -1,
561
+ })
562
+
563
+ writeJSON(w, http.StatusOK, map[string]any{"ok": true})
564
+ }
565
+
566
+ func (a *app) handlePresets(w http.ResponseWriter, r *http.Request) {
567
+ password, ok := a.requireSession(w, r)
568
+ if !ok {
569
+ return
570
+ }
571
+
572
+ switch r.Method {
573
+ case http.MethodGet:
574
+ presets, err := a.loadPresets(password)
575
+ if err != nil {
576
+ writeJSONError(w, http.StatusBadRequest, err.Error())
577
+ return
578
+ }
579
+ writeJSON(w, http.StatusOK, presets)
580
+ case http.MethodPut:
581
+ r.Body = http.MaxBytesReader(w, r.Body, 1<<20)
582
+ body, err := io.ReadAll(r.Body)
583
+ if err != nil {
584
+ writeJSONError(w, http.StatusBadRequest, "failed to read body")
585
+ return
586
+ }
587
+ presets, err := normalizePresets(body)
588
+ if err != nil {
589
+ writeJSONError(w, http.StatusBadRequest, err.Error())
590
+ return
591
+ }
592
+ if err := a.savePresets(presets, password); err != nil {
593
+ writeJSONError(w, http.StatusInternalServerError, "failed to save presets")
594
+ return
595
+ }
596
+ writeJSON(w, http.StatusOK, map[string]any{"ok": true})
597
+ default:
598
+ w.Header().Set("Allow", "GET, PUT")
599
+ http.Error(w, "method not allowed", http.StatusMethodNotAllowed)
600
+ }
601
+ }
602
+
603
+ func (a *app) requireSession(w http.ResponseWriter, r *http.Request) (password string, ok bool) {
604
+ token, ok := readSessionCookie(r)
605
+ if !ok {
606
+ writeJSONError(w, http.StatusUnauthorized, "not logged in")
607
+ return "", false
608
+ }
609
+ password, ok = a.sessions.get(token)
610
+ if !ok {
611
+ writeJSONError(w, http.StatusUnauthorized, "session expired")
612
+ return "", false
613
+ }
614
+ return password, true
615
+ }
616
+
617
+ func readSessionCookie(r *http.Request) (string, bool) {
618
+ c, err := r.Cookie(sessionCookieName)
619
+ if err != nil || strings.TrimSpace(c.Value) == "" {
620
+ return "", false
621
+ }
622
+ return c.Value, true
623
+ }
624
+
625
+ func (a *app) loadPresets(password string) (presets, error) {
626
+ a.fileMu.Lock()
627
+ defer a.fileMu.Unlock()
628
+
629
+ raw, err := os.ReadFile(a.dataPath)
630
+ if err != nil {
631
+ if os.IsNotExist(err) {
632
+ return presets{Codex: []preset{}, Claude: []preset{}, Gemini: []preset{}}, nil
633
+ }
634
+ return presets{}, err
635
+ }
636
+
637
+ p, err := decryptPresets(raw, password)
638
+ if err != nil {
639
+ return presets{}, err
640
+ }
641
+ return p, nil
642
+ }
643
+
644
+ func (a *app) savePresets(p presets, password string) error {
645
+ a.fileMu.Lock()
646
+ defer a.fileMu.Unlock()
647
+
648
+ payload, err := encryptPresets(p, password)
649
+ if err != nil {
650
+ return err
651
+ }
652
+ return writeFile0600(a.dataPath, payload)
653
+ }
654
+
655
+ func normalizePresets(raw []byte) (presets, error) {
656
+ var root presetsRaw
657
+ if err := json.Unmarshal(raw, &root); err != nil {
658
+ return presets{}, fmt.Errorf("预设文件格式错误:%v", err)
659
+ }
660
+
661
+ out := presets{Codex: []preset{}, Claude: []preset{}, Gemini: []preset{}}
662
+
663
+ var err error
664
+ if root.Codex != nil {
665
+ out.Codex, err = normalizePresetList("codex", root.Codex)
666
+ if err != nil {
667
+ return presets{}, err
668
+ }
669
+ }
670
+ if root.Claude != nil {
671
+ out.Claude, err = normalizePresetList("claude", root.Claude)
672
+ if err != nil {
673
+ return presets{}, err
674
+ }
675
+ }
676
+ if root.Gemini != nil {
677
+ out.Gemini, err = normalizePresetList("gemini", root.Gemini)
678
+ if err != nil {
679
+ return presets{}, err
680
+ }
681
+ }
682
+
683
+ return out, nil
684
+ }
685
+
686
+ func normalizePresetList(tool string, raw json.RawMessage) ([]preset, error) {
687
+ if bytes.Equal(bytes.TrimSpace(raw), []byte("null")) {
688
+ return nil, fmt.Errorf("预设文件格式错误:%s 必须是数组", tool)
689
+ }
690
+ var list []preset
691
+ if err := json.Unmarshal(raw, &list); err != nil {
692
+ return nil, fmt.Errorf("预设文件格式错误:%s 必须是数组", tool)
693
+ }
694
+ for i := range list {
695
+ list[i].Name = strings.TrimSpace(list[i].Name)
696
+ list[i].URL = strings.TrimSpace(list[i].URL)
697
+ list[i].Key = strings.TrimSpace(list[i].Key)
698
+ if list[i].Name == "" {
699
+ return nil, fmt.Errorf("预设文件格式错误:%s[%d].name 必须是非空字符串", tool, i)
700
+ }
701
+ if list[i].URL == "" {
702
+ return nil, fmt.Errorf("预设文件格式错误:%s[%d].url 必须是非空字符串", tool, i)
703
+ }
704
+ if list[i].Key == "" {
705
+ return nil, fmt.Errorf("预设文件格式错误:%s[%d].key 必须是非空字符串", tool, i)
706
+ }
707
+ }
708
+ return list, nil
709
+ }
710
+
711
+ func looksLikeEncryptedPayload(root map[string]any) bool {
712
+ if root == nil {
713
+ return false
714
+ }
715
+ v, ok := root["v"].(float64)
716
+ if !ok || (int(v) != 1 && int(v) != 2) {
717
+ return false
718
+ }
719
+ _, hasSalt := root["salt"].(string)
720
+ _, hasIV := root["iv"].(string)
721
+ _, hasCT := root["ct"].(string)
722
+ _, hasTag := root["tag"].(string)
723
+ return hasSalt && hasIV && hasCT && hasTag
724
+ }
725
+
726
+ func decryptPresets(payloadBytes []byte, password string) (presets, error) {
727
+ var probe map[string]any
728
+ if err := json.Unmarshal(payloadBytes, &probe); err != nil {
729
+ return presets{}, fmt.Errorf("远程预设解析失败:%v", err)
730
+ }
731
+ if !looksLikeEncryptedPayload(probe) {
732
+ normalized, err := normalizePresets(payloadBytes)
733
+ if err != nil {
734
+ return presets{}, err
735
+ }
736
+ return normalized, nil
737
+ }
738
+
739
+ var payload encryptedPayload
740
+ if err := json.Unmarshal(payloadBytes, &payload); err != nil {
741
+ return presets{}, fmt.Errorf("远程预设解析失败:%v", err)
742
+ }
743
+ if payload.V != 2 {
744
+ return presets{}, fmt.Errorf("远程预设格式不兼容:仅支持 v=2")
745
+ }
746
+ if payload.Iterations <= 0 {
747
+ return presets{}, fmt.Errorf("远程预设解析失败:invalid iterations")
748
+ }
749
+
750
+ salt, err := base64.StdEncoding.DecodeString(payload.Salt)
751
+ if err != nil {
752
+ return presets{}, fmt.Errorf("远程预设解析失败:invalid salt")
753
+ }
754
+ iv, err := base64.StdEncoding.DecodeString(payload.IV)
755
+ if err != nil {
756
+ return presets{}, fmt.Errorf("远程预设解析失败:invalid iv")
757
+ }
758
+ ct, err := base64.StdEncoding.DecodeString(payload.CT)
759
+ if err != nil {
760
+ return presets{}, fmt.Errorf("远程预设解析失败:invalid ct")
761
+ }
762
+ tag, err := base64.StdEncoding.DecodeString(payload.Tag)
763
+ if err != nil {
764
+ return presets{}, fmt.Errorf("远程预设解析失败:invalid tag")
765
+ }
766
+
767
+ key := pbkdf2Key([]byte(password), salt, payload.Iterations, keyLenBytes)
768
+ block, err := aes.NewCipher(key)
769
+ if err != nil {
770
+ return presets{}, fmt.Errorf("远程预设解密失败:%v", err)
771
+ }
772
+ gcm, err := cipher.NewGCM(block)
773
+ if err != nil {
774
+ return presets{}, fmt.Errorf("远程预设解密失败:%v", err)
775
+ }
776
+
777
+ ciphertext := append(ct, tag...)
778
+ plaintext, err := gcm.Open(nil, iv, ciphertext, nil)
779
+ if err != nil {
780
+ return presets{}, fmt.Errorf("远程预设解密失败:密码错误或文件已损坏")
781
+ }
782
+
783
+ normalized, err := normalizePresets(plaintext)
784
+ if err != nil {
785
+ return presets{}, err
786
+ }
787
+ return normalized, nil
788
+ }
789
+
790
+ func encryptPresets(p presets, password string) ([]byte, error) {
791
+ normalizedBytes, err := json.Marshal(p)
792
+ if err != nil {
793
+ return nil, err
794
+ }
795
+
796
+ salt := make([]byte, 16)
797
+ if _, err := rand.Read(salt); err != nil {
798
+ return nil, err
799
+ }
800
+ iv := make([]byte, 12)
801
+ if _, err := rand.Read(iv); err != nil {
802
+ return nil, err
803
+ }
804
+
805
+ key := pbkdf2Key([]byte(password), salt, defaultIterations, keyLenBytes)
806
+ block, err := aes.NewCipher(key)
807
+ if err != nil {
808
+ return nil, err
809
+ }
810
+ gcm, err := cipher.NewGCM(block)
811
+ if err != nil {
812
+ return nil, err
813
+ }
814
+
815
+ ciphertextWithTag := gcm.Seal(nil, iv, normalizedBytes, nil)
816
+ tagLen := gcm.Overhead()
817
+ ct := ciphertextWithTag[:len(ciphertextWithTag)-tagLen]
818
+ tag := ciphertextWithTag[len(ciphertextWithTag)-tagLen:]
819
+
820
+ payload := encryptedPayload{
821
+ V: 2,
822
+ KDF: authKDFName,
823
+ Iterations: defaultIterations,
824
+ Cipher: "aes-256-gcm",
825
+ Salt: base64.StdEncoding.EncodeToString(salt),
826
+ IV: base64.StdEncoding.EncodeToString(iv),
827
+ CT: base64.StdEncoding.EncodeToString(ct),
828
+ Tag: base64.StdEncoding.EncodeToString(tag),
829
+ }
830
+
831
+ out, err := json.MarshalIndent(payload, "", " ")
832
+ if err != nil {
833
+ return nil, err
834
+ }
835
+ out = append(out, '\n')
836
+ return out, nil
837
+ }
838
+
839
+ func writeJSON(w http.ResponseWriter, status int, payload any) {
840
+ w.Header().Set("Content-Type", "application/json; charset=utf-8")
841
+ w.Header().Set("Cache-Control", "no-store")
842
+ w.WriteHeader(status)
843
+ _ = json.NewEncoder(w).Encode(payload)
844
+ }
845
+
846
+ func writeJSONError(w http.ResponseWriter, status int, message string) {
847
+ writeJSON(w, status, map[string]any{"ok": false, "error": message})
848
+ }
849
+
850
+ func loggingMiddleware(next http.Handler) http.Handler {
851
+ return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
852
+ start := time.Now()
853
+ next.ServeHTTP(w, r)
854
+ log.Printf("%s %s %s", r.Method, r.URL.Path, time.Since(start).Truncate(time.Millisecond))
855
+ })
856
+ }
857
+
858
+ func pbkdf2Key(password, salt []byte, iterations, keyLen int) []byte {
859
+ if iterations <= 0 || keyLen <= 0 {
860
+ return nil
861
+ }
862
+
863
+ hLen := sha256.Size
864
+ numBlocks := (keyLen + hLen - 1) / hLen
865
+ out := make([]byte, 0, numBlocks*hLen)
866
+
867
+ var blockNum [4]byte
868
+ mac := hmac.New(sha256.New, password)
869
+
870
+ for block := 1; block <= numBlocks; block++ {
871
+ blockNum[0] = byte(block >> 24)
872
+ blockNum[1] = byte(block >> 16)
873
+ blockNum[2] = byte(block >> 8)
874
+ blockNum[3] = byte(block)
875
+
876
+ mac.Reset()
877
+ _, _ = mac.Write(salt)
878
+ _, _ = mac.Write(blockNum[:])
879
+ u := mac.Sum(nil)
880
+
881
+ t := make([]byte, len(u))
882
+ copy(t, u)
883
+
884
+ for i := 1; i < iterations; i++ {
885
+ mac.Reset()
886
+ _, _ = mac.Write(u)
887
+ u = mac.Sum(nil)
888
+ for j := 0; j < len(t); j++ {
889
+ t[j] ^= u[j]
890
+ }
891
+ }
892
+
893
+ out = append(out, t...)
894
+ }
895
+
896
+ return out[:keyLen]
897
+ }
898
+
899
+ const indexHTML = `<!doctype html>
900
+ <html lang="zh-CN">
901
+ <head>
902
+ <meta charset="utf-8" />
903
+ <meta name="viewport" content="width=device-width, initial-scale=1" />
904
+ <title>code-cli 远程预设</title>
905
+ <style>
906
+ :root { color-scheme: light dark; }
907
+ body { font-family: ui-sans-serif, system-ui, -apple-system, Segoe UI, Roboto, Helvetica, Arial; margin: 0; padding: 24px; }
908
+ .container { max-width: 980px; margin: 0 auto; }
909
+ h1 { margin: 0 0 16px; font-size: 22px; }
910
+ .card { border: 1px solid rgba(127,127,127,.35); border-radius: 12px; padding: 14px; margin: 12px 0; }
911
+ label { display: block; font-size: 12px; opacity: .8; margin-bottom: 6px; }
912
+ input, textarea { width: 100%; box-sizing: border-box; padding: 10px; border-radius: 10px; border: 1px solid rgba(127,127,127,.35); background: transparent; }
913
+ textarea { font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono", monospace; }
914
+ .row { display: grid; grid-template-columns: 1fr auto; gap: 10px; align-items: center; }
915
+ .actions { display: flex; gap: 10px; flex-wrap: wrap; }
916
+ button { padding: 10px 12px; border-radius: 10px; border: 1px solid rgba(127,127,127,.35); background: transparent; cursor: pointer; }
917
+ button.primary { background: rgba(80, 140, 255, .18); border-color: rgba(80, 140, 255, .5); }
918
+ .hidden { display: none; }
919
+ .msg { margin-top: 10px; font-size: 13px; }
920
+ .ok { color: #3cb371; }
921
+ .err { color: #ff6b6b; }
922
+ .muted { opacity: .7; }
923
+ .mono { font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono", monospace; }
924
+ .hint { font-size: 12px; opacity: .75; margin-top: 6px; }
925
+
926
+ .tabs { display: flex; gap: 8px; flex-wrap: wrap; margin-top: 14px; }
927
+ .tab { padding: 8px 10px; border-radius: 999px; border: 1px solid rgba(127,127,127,.35); background: transparent; cursor: pointer; }
928
+ .tab.active { background: rgba(80, 140, 255, .18); border-color: rgba(80, 140, 255, .5); }
929
+ .toolPanel { margin-top: 12px; }
930
+
931
+ table { width: 100%; border-collapse: collapse; margin-top: 10px; }
932
+ th, td { border-bottom: 1px solid rgba(127,127,127,.25); padding: 8px; vertical-align: top; }
933
+ th { text-align: left; font-size: 12px; opacity: .85; }
934
+ table input { padding: 8px; }
935
+ table button { padding: 8px 10px; }
936
+ .actionsCell { white-space: nowrap; }
937
+ </style>
938
+ </head>
939
+ <body>
940
+ <div class="container">
941
+ <h1>code-cli 远程预设</h1>
942
+
943
+ <div class="card">
944
+ <div class="row">
945
+ <div>
946
+ <label>远程拉取 URL(配置到各台机器的 code-cli)</label>
947
+ <input id="remoteUrl" class="mono" readonly />
948
+ </div>
949
+ <button id="copyUrl">复制</button>
950
+ </div>
951
+ <div id="msg" class="msg muted"></div>
952
+ </div>
953
+
954
+ <div class="card hidden" id="setupCard">
955
+ <label>首次使用:设置密码(同一套密码:登录 + 加密文件)</label>
956
+ <div class="row">
957
+ <input id="setupPassword" type="password" placeholder="至少 8 位" />
958
+ <button id="setupBtn" class="primary">初始化</button>
959
+ </div>
960
+ <div style="margin-top:10px;">
961
+ <input id="setupPassword2" type="password" placeholder="再次输入密码" />
962
+ </div>
963
+ <div class="msg muted">建议配合 HTTPS 使用。</div>
964
+ </div>
965
+
966
+ <div class="card hidden" id="loginCard">
967
+ <label>密码(同一套密码:登录 + 加密文件)</label>
968
+ <div class="row">
969
+ <input id="password" type="password" placeholder="输入密码" />
970
+ <button id="loginBtn" class="primary">登录</button>
971
+ </div>
972
+ <div class="msg muted">建议配合 HTTPS 使用。</div>
973
+ </div>
974
+
975
+ <div class="card hidden" id="appCard">
976
+ <div class="actions">
977
+ <button id="loadBtn" class="primary">加载</button>
978
+ <button id="saveBtn" class="primary">保存</button>
979
+ <button id="logoutBtn">退出登录</button>
980
+ </div>
981
+
982
+ <div class="hint muted">Key 默认隐藏;保存后会写入加密的 <span class="mono">presets.enc</span>。</div>
983
+
984
+ <div class="tabs" id="tabs">
985
+ <button class="tab active" data-tool="codex" type="button">Codex</button>
986
+ <button class="tab" data-tool="claude" type="button">Claude Code</button>
987
+ <button class="tab" data-tool="gemini" type="button">Gemini CLI</button>
988
+ </div>
989
+
990
+ <div id="panel-codex" class="toolPanel">
991
+ <div class="row" style="margin-top:12px;">
992
+ <div class="muted">Codex 预设</div>
993
+ <button id="add_codex" type="button">添加预设</button>
994
+ </div>
995
+ <table>
996
+ <thead>
997
+ <tr>
998
+ <th style="width: 22%;">名称</th>
999
+ <th>URL</th>
1000
+ <th style="width: 26%;">Key</th>
1001
+ <th class="actionsCell" style="width: 10%;">操作</th>
1002
+ </tr>
1003
+ </thead>
1004
+ <tbody id="codexBody"></tbody>
1005
+ </table>
1006
+ <div id="codexEmpty" class="hint muted">暂无预设,点击“添加预设”开始。</div>
1007
+ </div>
1008
+
1009
+ <div id="panel-claude" class="toolPanel hidden">
1010
+ <div class="row" style="margin-top:12px;">
1011
+ <div class="muted">Claude Code 预设</div>
1012
+ <button id="add_claude" type="button">添加预设</button>
1013
+ </div>
1014
+ <table>
1015
+ <thead>
1016
+ <tr>
1017
+ <th style="width: 22%;">名称</th>
1018
+ <th>URL</th>
1019
+ <th style="width: 26%;">Key</th>
1020
+ <th class="actionsCell" style="width: 10%;">操作</th>
1021
+ </tr>
1022
+ </thead>
1023
+ <tbody id="claudeBody"></tbody>
1024
+ </table>
1025
+ <div id="claudeEmpty" class="hint muted">暂无预设,点击“添加预设”开始。</div>
1026
+ </div>
1027
+
1028
+ <div id="panel-gemini" class="toolPanel hidden">
1029
+ <div class="row" style="margin-top:12px;">
1030
+ <div class="muted">Gemini CLI 预设</div>
1031
+ <button id="add_gemini" type="button">添加预设</button>
1032
+ </div>
1033
+ <table>
1034
+ <thead>
1035
+ <tr>
1036
+ <th style="width: 22%;">名称</th>
1037
+ <th>URL</th>
1038
+ <th style="width: 26%;">Key</th>
1039
+ <th class="actionsCell" style="width: 10%;">操作</th>
1040
+ </tr>
1041
+ </thead>
1042
+ <tbody id="geminiBody"></tbody>
1043
+ </table>
1044
+ <div id="geminiEmpty" class="hint muted">暂无预设,点击“添加预设”开始。</div>
1045
+ </div>
1046
+ </div>
1047
+ </div>
1048
+
1049
+ <script>
1050
+ const msgEl = document.getElementById('msg');
1051
+ const remoteUrlEl = document.getElementById('remoteUrl');
1052
+ const copyBtn = document.getElementById('copyUrl');
1053
+ const setupCard = document.getElementById('setupCard');
1054
+ const loginCard = document.getElementById('loginCard');
1055
+ const appCard = document.getElementById('appCard');
1056
+ const setupPasswordEl = document.getElementById('setupPassword');
1057
+ const setupPassword2El = document.getElementById('setupPassword2');
1058
+ const setupBtn = document.getElementById('setupBtn');
1059
+ const passwordEl = document.getElementById('password');
1060
+ const loginBtn = document.getElementById('loginBtn');
1061
+ const logoutBtn = document.getElementById('logoutBtn');
1062
+ const loadBtn = document.getElementById('loadBtn');
1063
+ const saveBtn = document.getElementById('saveBtn');
1064
+ const tabsEl = document.getElementById('tabs');
1065
+
1066
+ const panels = {
1067
+ codex: document.getElementById('panel-codex'),
1068
+ claude: document.getElementById('panel-claude'),
1069
+ gemini: document.getElementById('panel-gemini'),
1070
+ };
1071
+
1072
+ const bodies = {
1073
+ codex: document.getElementById('codexBody'),
1074
+ claude: document.getElementById('claudeBody'),
1075
+ gemini: document.getElementById('geminiBody'),
1076
+ };
1077
+
1078
+ const empties = {
1079
+ codex: document.getElementById('codexEmpty'),
1080
+ claude: document.getElementById('claudeEmpty'),
1081
+ gemini: document.getElementById('geminiEmpty'),
1082
+ };
1083
+
1084
+ const addButtons = {
1085
+ codex: document.getElementById('add_codex'),
1086
+ claude: document.getElementById('add_claude'),
1087
+ gemini: document.getElementById('add_gemini'),
1088
+ };
1089
+
1090
+ function remoteUrl() {
1091
+ return window.location.origin + '/presets.enc';
1092
+ }
1093
+
1094
+ function setMsg(text, kind) {
1095
+ msgEl.textContent = text || '';
1096
+ msgEl.className = 'msg ' + (kind === 'ok' ? 'ok' : kind === 'err' ? 'err' : 'muted');
1097
+ }
1098
+
1099
+ async function api(path, options) {
1100
+ const res = await fetch(path, Object.assign({
1101
+ headers: { 'Content-Type': 'application/json' },
1102
+ credentials: 'same-origin'
1103
+ }, options || {}));
1104
+ const text = await res.text();
1105
+ let data = null;
1106
+ try { data = text ? JSON.parse(text) : null; } catch {}
1107
+ if (!res.ok) {
1108
+ const err = data && data.error ? data.error : (text || res.statusText);
1109
+ throw new Error(err);
1110
+ }
1111
+ return data;
1112
+ }
1113
+
1114
+ function showSetup() {
1115
+ setupCard.classList.remove('hidden');
1116
+ loginCard.classList.add('hidden');
1117
+ appCard.classList.add('hidden');
1118
+ }
1119
+
1120
+ function showLogin() {
1121
+ setupCard.classList.add('hidden');
1122
+ loginCard.classList.remove('hidden');
1123
+ appCard.classList.add('hidden');
1124
+ }
1125
+
1126
+ function showApp() {
1127
+ setupCard.classList.add('hidden');
1128
+ loginCard.classList.add('hidden');
1129
+ appCard.classList.remove('hidden');
1130
+ }
1131
+
1132
+ function setActiveTool(tool) {
1133
+ for (const t of ['codex', 'claude', 'gemini']) {
1134
+ const tab = tabsEl.querySelector('[data-tool=\"' + t + '\"]');
1135
+ if (tab) tab.classList.toggle('active', t === tool);
1136
+ panels[t].classList.toggle('hidden', t !== tool);
1137
+ }
1138
+ }
1139
+
1140
+ function normalizeData(data) {
1141
+ if (!data || typeof data !== 'object') return { codex: [], claude: [], gemini: [] };
1142
+ return {
1143
+ codex: Array.isArray(data.codex) ? data.codex : [],
1144
+ claude: Array.isArray(data.claude) ? data.claude : [],
1145
+ gemini: Array.isArray(data.gemini) ? data.gemini : [],
1146
+ };
1147
+ }
1148
+
1149
+ function updateEmpty(tool) {
1150
+ const hasRows = bodies[tool].children.length > 0;
1151
+ empties[tool].classList.toggle('hidden', hasRows);
1152
+ }
1153
+
1154
+ function updateTabCounts(data) {
1155
+ const normalized = normalizeData(data);
1156
+ const counts = {
1157
+ codex: normalized.codex.length,
1158
+ claude: normalized.claude.length,
1159
+ gemini: normalized.gemini.length,
1160
+ };
1161
+ for (const t of ['codex', 'claude', 'gemini']) {
1162
+ const tab = tabsEl.querySelector('[data-tool=\"' + t + '\"]');
1163
+ if (!tab) continue;
1164
+ const label = t === 'codex' ? 'Codex' : (t === 'claude' ? 'Claude Code' : 'Gemini CLI');
1165
+ tab.textContent = label + ' (' + counts[t] + ')';
1166
+ }
1167
+ }
1168
+
1169
+ function createPresetRow(preset) {
1170
+ const p = preset || {};
1171
+
1172
+ const tr = document.createElement('tr');
1173
+
1174
+ const tdName = document.createElement('td');
1175
+ const nameInput = document.createElement('input');
1176
+ nameInput.placeholder = '名称';
1177
+ nameInput.value = typeof p.name === 'string' ? p.name : '';
1178
+ nameInput.dataset.field = 'name';
1179
+ tdName.appendChild(nameInput);
1180
+
1181
+ const tdUrl = document.createElement('td');
1182
+ const urlRow = document.createElement('div');
1183
+ urlRow.className = 'row';
1184
+ const urlInput = document.createElement('input');
1185
+ urlInput.className = 'mono';
1186
+ urlInput.placeholder = 'https://...';
1187
+ urlInput.value = typeof p.url === 'string' ? p.url : '';
1188
+ urlInput.dataset.field = 'url';
1189
+ const copyUrlBtn = document.createElement('button');
1190
+ copyUrlBtn.type = 'button';
1191
+ copyUrlBtn.textContent = '复制';
1192
+ copyUrlBtn.addEventListener('click', async () => {
1193
+ const val = (urlInput.value || '').trim();
1194
+ if (!val) {
1195
+ setMsg('URL 为空,无法复制', 'err');
1196
+ return;
1197
+ }
1198
+ try {
1199
+ await navigator.clipboard.writeText(val);
1200
+ setMsg('已复制 URL', 'ok');
1201
+ } catch (e) {
1202
+ setMsg('复制失败:' + (e && e.message ? e.message : e), 'err');
1203
+ }
1204
+ });
1205
+ urlRow.appendChild(urlInput);
1206
+ urlRow.appendChild(copyUrlBtn);
1207
+ tdUrl.appendChild(urlRow);
1208
+
1209
+ const tdKey = document.createElement('td');
1210
+ const keyRow = document.createElement('div');
1211
+ keyRow.className = 'row';
1212
+ const keyInput = document.createElement('input');
1213
+ keyInput.className = 'mono';
1214
+ keyInput.type = 'password';
1215
+ keyInput.placeholder = 'API Key';
1216
+ keyInput.value = typeof p.key === 'string' ? p.key : '';
1217
+ keyInput.dataset.field = 'key';
1218
+ const toggleKeyBtn = document.createElement('button');
1219
+ toggleKeyBtn.type = 'button';
1220
+ toggleKeyBtn.textContent = '显示';
1221
+ toggleKeyBtn.addEventListener('click', () => {
1222
+ if (keyInput.type === 'password') {
1223
+ keyInput.type = 'text';
1224
+ toggleKeyBtn.textContent = '隐藏';
1225
+ } else {
1226
+ keyInput.type = 'password';
1227
+ toggleKeyBtn.textContent = '显示';
1228
+ }
1229
+ });
1230
+ keyRow.appendChild(keyInput);
1231
+ keyRow.appendChild(toggleKeyBtn);
1232
+ tdKey.appendChild(keyRow);
1233
+
1234
+ const tdActions = document.createElement('td');
1235
+ tdActions.className = 'actionsCell';
1236
+ const deleteBtn = document.createElement('button');
1237
+ deleteBtn.type = 'button';
1238
+ deleteBtn.textContent = '删除';
1239
+ deleteBtn.addEventListener('click', () => {
1240
+ tr.remove();
1241
+ updateEmpty(currentTool());
1242
+ });
1243
+ tdActions.appendChild(deleteBtn);
1244
+
1245
+ tr.appendChild(tdName);
1246
+ tr.appendChild(tdUrl);
1247
+ tr.appendChild(tdKey);
1248
+ tr.appendChild(tdActions);
1249
+
1250
+ return tr;
1251
+ }
1252
+
1253
+ function currentTool() {
1254
+ const active = tabsEl.querySelector('.tab.active');
1255
+ return active ? active.dataset.tool : 'codex';
1256
+ }
1257
+
1258
+ function setTables(data) {
1259
+ const normalized = normalizeData(data);
1260
+ for (const tool of ['codex', 'claude', 'gemini']) {
1261
+ bodies[tool].textContent = '';
1262
+ normalized[tool].forEach((p) => {
1263
+ bodies[tool].appendChild(createPresetRow(p));
1264
+ });
1265
+ updateEmpty(tool);
1266
+ }
1267
+ updateTabCounts(normalized);
1268
+ }
1269
+
1270
+ function readTable(tool) {
1271
+ const rows = Array.from(bodies[tool].querySelectorAll('tr'));
1272
+ const out = [];
1273
+ const names = new Set();
1274
+
1275
+ for (let i = 0; i < rows.length; i++) {
1276
+ const row = rows[i];
1277
+ const name = (row.querySelector('[data-field=\"name\"]')?.value || '').trim();
1278
+ const url = (row.querySelector('[data-field=\"url\"]')?.value || '').trim();
1279
+ const key = (row.querySelector('[data-field=\"key\"]')?.value || '').trim();
1280
+
1281
+ if (!name && !url && !key) continue;
1282
+ if (!name || !url || !key) {
1283
+ throw new Error(tool + ' 第 ' + (i + 1) + ' 行:name/url/key 都必须填写');
1284
+ }
1285
+ if (names.has(name)) {
1286
+ throw new Error(tool + ' 预设名称重复:' + name);
1287
+ }
1288
+ names.add(name);
1289
+ out.push({ name, url, key });
1290
+ }
1291
+
1292
+ return out;
1293
+ }
1294
+
1295
+ async function loadPresets() {
1296
+ const data = await api('/api/presets');
1297
+ setTables(data);
1298
+ }
1299
+
1300
+ remoteUrlEl.value = remoteUrl();
1301
+ copyBtn.addEventListener('click', async () => {
1302
+ try {
1303
+ await navigator.clipboard.writeText(remoteUrl());
1304
+ setMsg('已复制 URL', 'ok');
1305
+ } catch (e) {
1306
+ setMsg('复制失败:' + (e && e.message ? e.message : e), 'err');
1307
+ }
1308
+ });
1309
+
1310
+ (async () => {
1311
+ try {
1312
+ const status = await api('/api/status');
1313
+ if (status && status.initialized) {
1314
+ showLogin();
1315
+ } else {
1316
+ showSetup();
1317
+ }
1318
+ } catch (e) {
1319
+ showLogin();
1320
+ setMsg('无法获取服务状态', 'err');
1321
+ }
1322
+ })();
1323
+
1324
+ tabsEl.addEventListener('click', (e) => {
1325
+ const target = e.target;
1326
+ if (!target || !target.dataset || !target.dataset.tool) return;
1327
+ setActiveTool(target.dataset.tool);
1328
+ });
1329
+
1330
+ for (const tool of ['codex', 'claude', 'gemini']) {
1331
+ addButtons[tool].addEventListener('click', () => {
1332
+ bodies[tool].appendChild(createPresetRow({}));
1333
+ updateEmpty(tool);
1334
+ });
1335
+ }
1336
+
1337
+ setupBtn.addEventListener('click', async () => {
1338
+ try {
1339
+ const password = (setupPasswordEl.value || '').trim();
1340
+ const password2 = (setupPassword2El.value || '').trim();
1341
+ if (!password) {
1342
+ setMsg('请输入密码', 'err');
1343
+ return;
1344
+ }
1345
+ if (password.length < 8) {
1346
+ setMsg('密码至少 8 位', 'err');
1347
+ return;
1348
+ }
1349
+ if (password !== password2) {
1350
+ setMsg('两次密码不一致', 'err');
1351
+ return;
1352
+ }
1353
+ await api('/api/setup', { method: 'POST', body: JSON.stringify({ password }) });
1354
+ setupPasswordEl.value = '';
1355
+ setupPassword2El.value = '';
1356
+ passwordEl.value = '';
1357
+ showApp();
1358
+ setActiveTool('codex');
1359
+ await loadPresets();
1360
+ setMsg('初始化成功', 'ok');
1361
+ } catch (e) {
1362
+ setMsg('初始化失败:' + (e && e.message ? e.message : e), 'err');
1363
+ }
1364
+ });
1365
+
1366
+ loginBtn.addEventListener('click', async () => {
1367
+ try {
1368
+ const password = (passwordEl.value || '').trim();
1369
+ if (!password) {
1370
+ setMsg('请输入密码', 'err');
1371
+ return;
1372
+ }
1373
+ await api('/api/login', { method: 'POST', body: JSON.stringify({ password }) });
1374
+ showApp();
1375
+ setActiveTool('codex');
1376
+ await loadPresets();
1377
+ setMsg('登录成功', 'ok');
1378
+ } catch (e) {
1379
+ const message = e && e.message ? e.message : e;
1380
+ if (String(message).includes('not initialized')) {
1381
+ showSetup();
1382
+ }
1383
+ setMsg('登录失败:' + message, 'err');
1384
+ }
1385
+ });
1386
+
1387
+ logoutBtn.addEventListener('click', async () => {
1388
+ try {
1389
+ await api('/api/logout', { method: 'POST', body: '{}' });
1390
+ } catch {}
1391
+ showLogin();
1392
+ for (const tool of ['codex', 'claude', 'gemini']) {
1393
+ bodies[tool].textContent = '';
1394
+ updateEmpty(tool);
1395
+ }
1396
+ passwordEl.value = '';
1397
+ setupPasswordEl.value = '';
1398
+ setupPassword2El.value = '';
1399
+ setMsg('已退出登录', 'ok');
1400
+ });
1401
+
1402
+ loadBtn.addEventListener('click', async () => {
1403
+ try {
1404
+ await loadPresets();
1405
+ setMsg('加载成功', 'ok');
1406
+ } catch (e) {
1407
+ const message = e && e.message ? e.message : e;
1408
+ if (String(message).includes('not logged in') || String(message).includes('session expired')) {
1409
+ showLogin();
1410
+ }
1411
+ setMsg('加载失败:' + message, 'err');
1412
+ }
1413
+ });
1414
+
1415
+ saveBtn.addEventListener('click', async () => {
1416
+ try {
1417
+ const data = {
1418
+ codex: readTable('codex'),
1419
+ claude: readTable('claude'),
1420
+ gemini: readTable('gemini'),
1421
+ };
1422
+ await api('/api/presets', { method: 'PUT', body: JSON.stringify(data) });
1423
+ updateTabCounts(data);
1424
+ setMsg('保存成功', 'ok');
1425
+ } catch (e) {
1426
+ const message = e && e.message ? e.message : e;
1427
+ if (String(message).includes('not logged in') || String(message).includes('session expired')) {
1428
+ showLogin();
1429
+ }
1430
+ setMsg('保存失败:' + message, 'err');
1431
+ }
1432
+ });
1433
+ </script>
1434
+ </body>
1435
+ </html>
1436
+ `