gcli2api / cmd /sync /main.go
a3216's picture
chore: 同步到上游 1.12.0-panel + 凭证同步/独立启动器/保活
6d60378 verified
Raw History Blame Contribute Delete
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
}