@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.
- package/bin/cli.js +236 -48
- package/lib/config.js +64 -43
- package/lib/remote.js +250 -0
- package/package.json +8 -1
- package/remote-server/README.md +56 -0
- package/remote-server/go.mod +3 -0
- package/remote-server/main.go +1436 -0
- package/.claude/settings.local.json +0 -10
|
@@ -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
|
+
`
|