Download cmd/sync/main.go from a3216/gcli2api: direct link, hf CLI and curl.
- Browser
- Download file 14.3 kB
-
https://huggingface.co/spaces/a3216/gcli2api/resolve/main/cmd/sync/main.go
- Command line
-
hf download hf://spaces/a3216/gcli2api/cmd/sync/main.go
-
curl -L -o main.go https://huggingface.co/spaces/a3216/gcli2api/resolve/main/cmd/sync/main.go
14.3 kB
| // 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 | |
| } | |