File size: 14,301 Bytes
6d60378
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
// sync 是本地 ↔ Hugging Face 中转之间的凭证/状态同步工具。
//
// 用法(典型):
//
//	wb2api-sync status          # 只看差异,不写任何东西(有差异时退出码 2)
//	wb2api-sync pull            # 远端 → 本地(本地启动器在拉起网关前先跑这个)
//	wb2api-sync push            # 本地 → 远端
//	wb2api-sync sync            # 双向对账(缺的补上,冲突按判据取新的一方)
//	wb2api-sync watch -interval 5m   # 常驻,周期性 sync
//
// 配置来源:默认读 ./config.json 的 auth_dir / state_file 与 sync 段;
// 命令行标志可逐项覆盖(未给 config.json 时也能用标志 + 环境变量跑起来)。
//
// 为什么读取走桶、写入可走网关:见 internal/syncer 包注释(Xet 上传协议)。
package main

import (
	"context"
	"encoding/json"
	"flag"
	"fmt"
	"os"
	"os/signal"
	"strings"
	"syscall"
	"time"

	"github.com/linguo2625469/workbuddy2api-panel/internal/syncer"
)

// 退出码:0 一切就绪/已对齐;1 出错;2 有差异但未应用(status 模式,供脚本判分支)。
const (
	exitOK      = 0
	exitError   = 1
	exitPending = 2
)

// fileConfig 已移到 internal/syncer.LoadConfigFile:启动器(cmd/launcher)与
// 本工具必须用完全同一套"从 config.json 取同步配置"的规则,否则两边对
// auth_dir 的理解一旦漂移,就会出现"同步写 A 目录、网关读 B 目录"的诡异故障。

func main() {
	if err := run(os.Args[1:]); err != nil {
		fmt.Fprintf(os.Stderr, "[wb2api-sync] 失败: %v\n", err)
		os.Exit(exitError)
	}
}

// options 是解析后的运行参数。
type options struct {
	mode       syncer.Mode
	config     syncer.Config
	dryRun     bool
	reportOnly bool // status/diff/plan:按双向算一遍但不落地
	jsonOut    bool
	quiet      bool
	watch      bool
	interval   time.Duration
	loaded     string // 实际读到的 config.json 路径(空 = 没读)
}

func run(args []string) error {
	// 子命令可以是第一个位置参数;也允许省略(默认 sync)。
	cmd := "sync"
	if len(args) > 0 && !strings.HasPrefix(args[0], "-") {
		cmd = args[0]
		args = args[1:]
	}

	fs := flag.NewFlagSet("wb2api-sync", flag.ContinueOnError)
	fs.SetOutput(os.Stderr)

	var (
		configPath = fs.String("config", "config.json", "配置文件路径(读 auth_dir/state_file 与 sync 段)")
		noConfig   = fs.Bool("no-config", false, "不读配置文件,只用标志/环境变量")

		bucket    = fs.String("bucket", "", "HF 桶 ID,如 a3216/wb2api-storage")
		endpoint  = fs.String("endpoint", "", "HF 端点(默认 https://huggingface.co)")
		token     = fs.String("token", "", "HF token(建议改用 -token-file 或 HF_TOKEN 环境变量)")
		tokenFile = fs.String("token-file", "", "存放 HF token 的文件")

		authDir = fs.String("auth-dir", "", "本地凭证目录(默认取 config.json 的 auth_dir)")
		dataDir = fs.String("data-dir", "", "本地数据目录(默认取 state_file 所在目录)")
		include = fs.String("include-data", "", "额外同步的 data/ 文件名,逗号分隔(默认只同步凭证)")

		writer   = fs.String("writer", "", "写入通道:auto(默认)/ hf / relay")
		hfBin    = fs.String("hf-bin", "", "hf 可执行文件,默认 hf")
		relayURL = fs.String("relay-url", "", "中转网关地址(relay 通道用)")
		relayKey = fs.String("relay-key", "", "中转网关 api_key(留空时自动从桶的 config.json 读)")
		restart  = fs.String("restart-space", "", "推了新凭证后重启该 Space(如 a3216/gcli2api),让它加载新账号")
		caBundle = fs.String("ca-bundle", "", "额外 CA 证书包(PEM);本机跑抓包代理时给 hf CLI 用")

		timeout  = fs.String("timeout", "", "单次网络超时,默认 60s")
		insecure = fs.Bool("insecure-skip-verify", false, "跳过 TLS 校验(仅供本地抓包代理排障)")

		dryRun   = fs.Bool("dry-run", false, "只展示将要做什么,不写任何东西")
		jsonOut  = fs.Bool("json", false, "以 JSON 输出结果")
		quiet    = fs.Bool("quiet", false, "只输出汇总行")
		watch    = fs.Bool("watch", false, "常驻循环同步")
		interval = fs.Duration("interval", 5*time.Minute, "watch 模式的同步间隔")
	)

	if cmd == "help" || cmd == "-h" || cmd == "--help" {
		printHelp(fs)
		return nil
	}
	if err := fs.Parse(args); err != nil {
		return err
	}

	mode, reportOnly, err := syncer.ParseCommand(cmd)
	if err != nil {
		return err
	}

	opt := options{
		mode: mode, dryRun: *dryRun || reportOnly, jsonOut: *jsonOut, quiet: *quiet,
		watch: *watch || cmd == "watch", interval: *interval, reportOnly: reportOnly,
	}
	if opt.watch {
		// watch 必须是双向的:单方向常驻没有意义。
		opt.mode = syncer.ModeSync
		opt.reportOnly = false
	}

	// 1) 读配置文件打底
	if !*noConfig && *configPath != "" {
		fc, err := syncer.LoadConfigFile(*configPath)
		switch {
		case err == nil:
			opt.loaded = *configPath
			opt.config = fc
		case os.IsNotExist(err):
			// 没有 config.json 不是错误:可以用标志 + 环境变量把同步跑起来。
		default:
			return fmt.Errorf("读取 %s 失败: %w", *configPath, err)
		}
	}

	// 2) 标志覆盖
	if *bucket != "" {
		opt.config.Bucket = *bucket
	}
	if *endpoint != "" {
		opt.config.Endpoint = *endpoint
	}
	if *token != "" {
		opt.config.Token = *token
	}
	if *tokenFile != "" {
		opt.config.TokenFile = *tokenFile
	}
	if *authDir != "" {
		opt.config.AuthDir = *authDir
	}
	if *dataDir != "" {
		opt.config.DataDir = *dataDir
	}
	if *include != "" {
		opt.config.IncludeData = splitCSV(*include)
	}
	if *writer != "" {
		opt.config.Writer = *writer
	}
	if *hfBin != "" {
		opt.config.HFBin = *hfBin
	}
	if *relayURL != "" {
		opt.config.RelayURL = *relayURL
	}
	if *relayKey != "" {
		opt.config.RelayKey = *relayKey
	}
	if *restart != "" {
		opt.config.RestartSpace = *restart
	}
	if *caBundle != "" {
		opt.config.CABundle = *caBundle
	}
	if *timeout != "" {
		opt.config.Timeout = *timeout
	}
	if *insecure {
		opt.config.InsecureSkipVerify = true
	}
	// 环境变量兜底(方便 CI / 临时用,不必把 token 落盘)
	if opt.config.Token == "" && opt.config.TokenFile == "" {
		if t := strings.TrimSpace(os.Getenv("HF_TOKEN")); t != "" {
			opt.config.Token = t
		}
	}
	opt.config.Normalize()

	if opt.config.AuthDir == "" {
		opt.config.AuthDir = "auths"
	}
	if opt.config.DataDir == "" {
		opt.config.DataDir = "data"
	}
	if opt.config.Bucket == "" {
		return fmt.Errorf("未配置桶:请在 config.json 的 sync.bucket 填桶 ID(形如 a3216/wb2api-storage),或用 -bucket")
	}

	ctx, cancel := signal.NotifyContext(context.Background(), os.Interrupt, syscall.SIGTERM)
	defer cancel()

	eng, err := syncer.New(opt.config)
	if err != nil {
		return err
	}
	opt.config = eng.Config() // 拿回归一化后的值(含 token_file 解出来的 token)

	// relay 通道缺 api_key 时,从桶里的 config.json 自动取——省掉手工抄一遍密钥。
	if needsRelayKey(eng) {
		if key, kerr := eng.FetchRemoteAPIKey(ctx); kerr == nil {
			opt.config.RelayKey = key
			if eng, err = syncer.New(opt.config); err != nil {
				return err
			}
		}
	}

	if opt.watch {
		return runWatch(ctx, eng, opt)
	}
	return runOnce(ctx, eng, opt)
}

// needsRelayKey 报告当前是否"选了 relay 通道但还没 key"。
func needsRelayKey(eng *syncer.Engine) bool {
	return eng.WriterName() == "relay" && eng.Config().RelayKey == ""
}

// runOnce 跑一轮并输出结果。
func runOnce(ctx context.Context, eng *syncer.Engine, opt options) error {
	plan, res, err := eng.Sync(ctx, opt.mode, opt.dryRun)
	if err != nil {
		return err
	}
	if opt.jsonOut {
		printJSON(plan, res, opt)
	} else {
		printHuman(plan, res, opt, eng)
	}
	if res.Failed > 0 {
		return fmt.Errorf("%d 个文件同步失败", res.Failed)
	}
	// 推了新账号 → 让 Space 重启一次,否则它只在启动时扫凭证目录,加载不到新号。
	if !opt.dryRun && opt.config.RestartSpace != "" {
		if n := uploadedAuths(res); n > 0 {
			if err := syncer.RestartSpace(ctx, opt.config.Endpoint, opt.config.RestartSpace,
				opt.config.Token, 90*time.Second, opt.config.InsecureSkipVerify); err != nil {
				return fmt.Errorf("凭证已上传,但重启 Space 失败(新账号要等它下次重启才生效): %w", err)
			}
			if !opt.jsonOut {
				fmt.Printf("\n已请求重启 Space %s(推送了 %d 个凭证)——冷启动约 30~60s 后新账号生效。\n",
					opt.config.RestartSpace, n)
			}
		}
	}
	// status 模式:有差异时用退出码 2 告诉脚本"尚未对齐"(与 git diff --exit-code 同风格)。
	if opt.reportOnly && plan.Changes() > 0 {
		os.Exit(exitPending)
	}
	return nil
}

// uploadedAuths 统计本轮真正写成功的 auths/ 文件数——只有这些才需要触发 Space 重启。
func uploadedAuths(res *syncer.Result) int {
	n := 0
	for _, a := range res.Actions {
		if a.Op == syncer.OpUpload && a.Applied && a.Err == "" && strings.HasPrefix(a.Path, "auths/") {
			n++
		}
	}
	return n
}

// runWatch 常驻循环。只有发生变化时才打印,避免刷屏。
func runWatch(ctx context.Context, eng *syncer.Engine, opt options) error {
	if !opt.jsonOut {
		fmt.Printf("[wb2api-sync] watch 启动:每 %s 同步一次(Ctrl-C 退出)\n", opt.interval)
	}
	t := time.NewTicker(opt.interval)
	defer t.Stop()
	for {
		plan, res, err := eng.Sync(ctx, syncer.ModeSync, false)
		switch {
		case err != nil:
			fmt.Fprintf(os.Stderr, "[wb2api-sync] %s 同步出错: %v\n", time.Now().Format("15:04:05"), err)
		case plan.Changes() > 0 || res.Failed > 0:
			if opt.jsonOut {
				printJSON(plan, res, opt)
			} else {
				fmt.Printf("[wb2api-sync] %s 上传 %d / 下载 %d / 失败 %d\n",
					time.Now().Format("15:04:05"), res.Uploaded, res.Downloaded, res.Failed)
			}
		}
		select {
		case <-ctx.Done():
			return nil
		case <-t.C:
		}
	}
}

// ---------------------------------------------------------------------------
// 输出
// ---------------------------------------------------------------------------

func printHuman(plan *syncer.Plan, res *syncer.Result, opt options, eng *syncer.Engine) {
	cfg := eng.Config()
	mode := string(opt.mode)
	if opt.dryRun {
		mode += "(dry-run,未写入)"
	}
	if !opt.quiet {
		fmt.Printf("[wb2api-sync] 桶 %s  端点 %s\n", cfg.Bucket, cfg.Endpoint)
		fmt.Printf("  模式 %s   写入通道 %s   凭证目录 %s\n", mode, writerLabel(eng), cfg.AuthDir)
		if len(cfg.IncludeData) > 0 {
			fmt.Printf("  额外同步 data/: %s\n", strings.Join(cfg.IncludeData, ", "))
		} else {
			fmt.Printf("  额外同步 data/: 无(只同步 auths/ 凭证;需要时用 -include-data state.json 开启)\n")
		}
		fmt.Printf("  本地 %d 个文件 / 远端 %d 个文件\n\n", plan.LocalFiles, plan.RemoteFiles)
	}

	for _, a := range plan.Actions {
		if a.Op == syncer.OpNoop && !a.Conflict {
			continue // 一致的文件不刷屏
		}
		icon := "="
		switch a.Op {
		case syncer.OpUpload:
			icon = "↑"
		case syncer.OpDownload:
			icon = "↓"
		}
		tag := ""
		if a.Conflict {
			tag = " [冲突]"
		}
		status := ""
		if a.Err != "" {
			status = "  ✗ " + a.Err
		}
		fmt.Printf("  %s %-8s %s%s\n      %s%s\n", icon, a.Op, a.Path, tag, a.Reason, status)
	}

	if n := len(plan.Conflicts()); n > 0 && !opt.quiet {
		fmt.Printf("\n  本轮有 %d 个真冲突(两端都有且内容不同),已按「accessToken 新鲜度 → refreshToken 有无 → mtime」裁决。\n", n)
	}
	fmt.Printf("\n汇总: 上传 %d / 下载 %d / 跳过 %d / 失败 %d\n",
		res.Uploaded, res.Downloaded, res.Skipped, res.Failed)
	for _, e := range res.Errors {
		fmt.Fprintf(os.Stderr, "  ! %s\n", e)
	}
}

func writerLabel(eng *syncer.Engine) string {
	if n := eng.WriterName(); n != "" {
		return n
	}
	return "(只读:未配置写入通道)"
}

// jsonReport 是 -json 的输出结构。
type jsonReport struct {
	OK       bool            `json:"ok"`
	Mode     string          `json:"mode"`
	DryRun   bool            `json:"dry_run"`
	Bucket   string          `json:"bucket"`
	Writer   string          `json:"writer"`
	Local    int             `json:"local_files"`
	Remote   int             `json:"remote_files"`
	Changes  int             `json:"changes"`
	Uploaded int             `json:"uploaded"`
	Downld   int             `json:"downloaded"`
	Skipped  int             `json:"skipped"`
	Failed   int             `json:"failed"`
	Actions  []syncer.Action `json:"actions"`
	Errors   []string        `json:"errors,omitempty"`
}

func printJSON(plan *syncer.Plan, res *syncer.Result, opt options) {
	rep := jsonReport{
		OK: res.Failed == 0, Mode: string(opt.mode), DryRun: opt.dryRun,
		Bucket: plan.Bucket, Writer: plan.Writer,
		Local: plan.LocalFiles, Remote: plan.RemoteFiles, Changes: plan.Changes(),
		Uploaded: res.Uploaded, Downld: res.Downloaded, Skipped: res.Skipped, Failed: res.Failed,
		Actions: plan.Actions, Errors: res.Errors,
	}
	raw, _ := json.MarshalIndent(rep, "", "  ")
	fmt.Println(string(raw))
}

func printHelp(fs *flag.FlagSet) {
	fmt.Print(`wb2api-sync — 本地与 Hugging Face 中转之间的凭证/状态同步

用法:
  wb2api-sync status [标志]   只看差异(有差异退出码 2),不写任何东西
  wb2api-sync pull   [标志]   远端 → 本地
  wb2api-sync push   [标志]   本地 → 远端
  wb2api-sync sync   [标志]   双向对账(默认)
  wb2api-sync watch  [标志]   常驻,周期性 sync

退出码: 0 已对齐 / 1 出错 / 2 status 模式下存在差异

标志:
`)
	fs.PrintDefaults()
	fmt.Print(`
示例:
  # 本地启动前先拉一次远端凭证
  wb2api-sync pull -config config.json

  # 看两边差在哪(不写盘)
  wb2api-sync status -bucket a3216/wb2api-storage -token-file ./data/hf_token.txt

  # 常驻,每 5 分钟对账一次
  wb2api-sync watch -interval 5m
`)
}

// ---------------------------------------------------------------------------
// 小工具
// ---------------------------------------------------------------------------

// splitCSV 切分逗号列表并去掉空项。
func splitCSV(s string) []string {
	var out []string
	for _, part := range strings.Split(s, ",") {
		if p := strings.TrimSpace(part); p != "" {
			out = append(out, p)
		}
	}
	return out
}