Download cmd/launcher/main.go from a3216/gcli2api: direct link, hf CLI and curl.
- Browser
- Download file 14 kB
-
https://huggingface.co/spaces/a3216/gcli2api/resolve/main/cmd/launcher/main.go
- Command line
-
hf download hf://spaces/a3216/gcli2api/cmd/launcher/main.go
-
curl -L -o main.go https://huggingface.co/spaces/a3216/gcli2api/resolve/main/cmd/launcher/main.go
14 kB
| // wb2api-launcher —— WorkBuddy2API 的**独立启动器**。 | |
| // | |
| // 为什么是一个独立 exe 而不是 shell 脚本: | |
| // | |
| // - 上游作者更新时,你只需要替换 wb2api.exe(网关本体),启动器完全不动。 | |
| // 启动器只做三件事——同步凭证、拉起网关、转发信号——不依赖网关的任何内部实现, | |
| // 也不依赖 go / python / bash / PowerShell 环境。 | |
| // - 同步引擎(internal/syncer)直接**编进这个 exe**,不再需要第二个 wb2api-sync | |
| // 二进制;后台定时对账也在同一个进程里跑,不会留下孤儿进程。 | |
| // | |
| // 启动顺序刻意是「先同步、后启动」:网关只在启动时扫一遍 auths/(没有文件监听), | |
| // 在它扫描之前把远端凭证落下来,网关启动时就自然加载到最新的一批账号。 | |
| // | |
| // 用法: | |
| // | |
| // wb2api-launcher.exe # 同步 + 启动网关 + 后台对账 | |
| // wb2api-launcher.exe -status # 只看两边差异,不启动网关 | |
| // wb2api-launcher.exe -sync-only # 同步一次就退出 | |
| // wb2api-launcher.exe -no-sync # 离线启动(跳过同步) | |
| // wb2api-launcher.exe -no-watch # 不起后台对账 | |
| // wb2api-launcher.exe -mode pull # 只拉不推 | |
| // | |
| // 退出码:网关的退出码原样透出;启动阶段失败返回 1;-status 有差异返回 2。 | |
| package main | |
| import ( | |
| "context" | |
| "errors" | |
| "fmt" | |
| "os" | |
| "os/exec" | |
| "os/signal" | |
| "path/filepath" | |
| "runtime" | |
| "strings" | |
| "syscall" | |
| "time" | |
| "github.com/linguo2625469/workbuddy2api-panel/internal/syncer" | |
| ) | |
| // version 版本号(-version)。做成变量便于 -ldflags 注入。 | |
| var version = "1.0.0" | |
| // 退出码约定。 | |
| const ( | |
| exitOK = 0 | |
| exitError = 1 | |
| exitPending = 2 // -status 模式下存在未同步的差异 | |
| ) | |
| func main() { | |
| if err := run(); err != nil { | |
| fmt.Fprintf(os.Stderr, "\n[launcher] 失败: %v\n", err) | |
| os.Exit(exitError) | |
| } | |
| } | |
| // options 是解析后的启动参数。 | |
| type options struct { | |
| configPath string | |
| serverBin string | |
| mode syncer.Mode | |
| reportOnly bool | |
| noSync bool | |
| noWatch bool | |
| syncOnly bool | |
| quiet bool | |
| interval time.Duration | |
| shutdownGrace time.Duration | |
| // sync 覆盖项(不写 config.json 也能用) | |
| bucket string | |
| token string | |
| endpoint string | |
| include string | |
| writer string | |
| restart string | |
| caBundle string | |
| relayURL string | |
| dryRun bool | |
| } | |
| func run() error { | |
| opt, showVersion, err := parseFlags(os.Args[1:]) | |
| if err != nil { | |
| return err | |
| } | |
| if showVersion { | |
| fmt.Printf("wb2api-launcher %s (%s/%s)\n", version, runtime.GOOS, runtime.GOARCH) | |
| return nil | |
| } | |
| exePath, err := os.Executable() | |
| if err != nil { | |
| return fmt.Errorf("无法定位启动器自身路径: %w", err) | |
| } | |
| exeDir := filepath.Dir(exePath) | |
| // 配置文件默认放在启动器旁边——"解压到目录、双击"即可用。 | |
| if opt.configPath == "" { | |
| opt.configPath = filepath.Join(exeDir, "config.json") | |
| } else if !filepath.IsAbs(opt.configPath) { | |
| opt.configPath = filepath.Join(exeDir, opt.configPath) | |
| } | |
| absCfg, err := filepath.Abs(opt.configPath) | |
| if err != nil { | |
| return err | |
| } | |
| cfgDir := filepath.Dir(absCfg) | |
| ensureConfig(absCfg, exeDir, cfgDir) | |
| // 关键:把工作目录切到 config.json 所在目录,再启动网关。 | |
| // 这样配置里的相对路径(./auths、./data/state.json)对"启动器的同步"和 | |
| // "网关子进程"是**同一个**目录,不会出现同步写 A、网关读 B 的错位。 | |
| if err := os.Chdir(cfgDir); err != nil { | |
| return fmt.Errorf("切换工作目录到 %s 失败: %w", cfgDir, err) | |
| } | |
| syncCfg, err := loadSyncConfig(absCfg, opt) | |
| if err != nil { | |
| return err | |
| } | |
| ctx, stop := signal.NotifyContext(context.Background(), os.Interrupt, syscall.SIGTERM) | |
| defer stop() | |
| var engine *syncer.Engine | |
| if !opt.noSync { | |
| engine, err = newEngine(ctx, syncCfg, opt) | |
| if err != nil { | |
| // 同步配置不全不是致命错误:照样能把网关拉起来(离线自用)。 | |
| info("同步未启用:%v", err) | |
| } | |
| } | |
| if engine != nil { | |
| if code := preflightSync(ctx, engine, opt); code != exitOK { | |
| if opt.syncOnly || opt.reportOnly { | |
| os.Exit(code) | |
| } | |
| // 启动阶段同步失败不阻断启动:断网/中转挂了不该让你连本地网关都用不了。 | |
| } | |
| } | |
| if opt.syncOnly || opt.reportOnly { | |
| return nil | |
| } | |
| serverBin, err := findServer(opt.serverBin, exeDir) | |
| if err != nil { | |
| return err | |
| } | |
| info("网关二进制:%s", serverBin) | |
| info("配置:%s", absCfg) | |
| info("运行目录:%s", cfgDir) | |
| child := exec.Command(serverBin, "-config", absCfg) | |
| child.Stdin = os.Stdin | |
| child.Stdout = os.Stdout | |
| child.Stderr = os.Stderr | |
| child.Dir = cfgDir | |
| if err := child.Start(); err != nil { | |
| return fmt.Errorf("启动网关失败 (%s): %w", serverBin, err) | |
| } | |
| info("网关已启动 (PID %d)", child.Process.Pid) | |
| // 后台对账:跑在同一个进程里,不会留下孤儿进程。 | |
| if engine != nil && !opt.noWatch { | |
| go watchLoop(ctx, engine, opt) | |
| } | |
| // 等子进程退出。Ctrl-C 在这个控制台里会同时送到启动器与网关, | |
| // 网关收到后会 Flush() 落盘再做优雅停机——所以这里给一段宽限期让它自己收尾。 | |
| done := make(chan error, 1) | |
| go func() { done <- child.Wait() }() | |
| select { | |
| case werr := <-done: | |
| return exitCodeOf(werr) | |
| case <-ctx.Done(): | |
| info("收到中断信号,等待网关优雅退出(最多 %s)...", opt.shutdownGrace) | |
| if err := waitOrKill(child, done, opt.shutdownGrace); err != nil { | |
| return err | |
| } | |
| return nil | |
| } | |
| } | |
| // waitOrKill 给子进程一段宽限期自行退出,超时才强杀。 | |
| // | |
| // 为什么优先"等"而不是直接 Kill:网关在收到 SIGINT/SIGTERM 时会先 | |
| // Pool.Flush() 把内存状态落盘再 Shutdown;直接杀掉会丢掉最后一次状态落盘 | |
| // (积分/冷却/连登计数都是攒在内存里定时刷的)。 | |
| func waitOrKill(child *exec.Cmd, done <-chan error, grace time.Duration) error { | |
| select { | |
| case <-done: | |
| info("网关已优雅退出") | |
| case <-time.After(grace): | |
| info("宽限期已到,强制结束网关进程") | |
| if child.Process != nil { | |
| _ = child.Process.Kill() | |
| } | |
| <-done | |
| } | |
| return nil | |
| } | |
| // exitCodeOf 把 Wait 的错误翻译成退出码。 | |
| func exitCodeOf(err error) error { | |
| if err == nil { | |
| return nil | |
| } | |
| var ee *exec.ExitError | |
| if errors.As(err, &ee) { | |
| // 原样透出子进程退出码,便于外层脚本判断。 | |
| os.Exit(ee.ExitCode()) | |
| } | |
| return err | |
| } | |
| // preflightSync 启动前同步一次。返回退出码。 | |
| func preflightSync(ctx context.Context, engine *syncer.Engine, opt options) int { | |
| fmt.Println() | |
| info("━━ 启动前同步(模式 %s)━━", opt.mode) | |
| plan, res, err := engine.Sync(ctx, opt.mode, opt.dryRun) | |
| if err != nil { | |
| warn("同步失败:%v", err) | |
| warn("按本地已有凭证继续启动,本次不做远端对账。") | |
| return exitError | |
| } | |
| printPlan(plan, res, opt) | |
| if res.Failed > 0 { | |
| warn("%d 个文件同步失败,按本地已有凭证继续启动。", res.Failed) | |
| return exitError | |
| } | |
| if opt.reportOnly && plan.Changes() > 0 { | |
| return exitPending | |
| } | |
| if plan.Changes() == 0 { | |
| info("已与中转一致(无改动)") | |
| } | |
| // 本地一个凭证都没有时,网关起来是空池(/healthz 503),把原因说清楚。 | |
| if n := countAuthFiles(engine.Config().AuthDir); n == 0 { | |
| warn("auths/ 里没有任何账号 —— 网关会以空池启动(/healthz 会返回 503)。") | |
| warn(" 检查 config.json 的 sync.bucket / sync.token。") | |
| } | |
| return exitOK | |
| } | |
| // watchLoop 常驻后台对账:把本地运行期刷新出来的新 token 持续推回中转。 | |
| func watchLoop(ctx context.Context, engine *syncer.Engine, opt options) { | |
| t := time.NewTicker(opt.interval) | |
| defer t.Stop() | |
| for { | |
| select { | |
| case <-ctx.Done(): | |
| return | |
| case <-t.C: | |
| plan, res, err := engine.Sync(ctx, syncer.ModeSync, false) | |
| switch { | |
| case err != nil: | |
| warn("[后台对账] 出错:%v", err) | |
| case res.Failed > 0: | |
| warn("[后台对账] 上传 %d / 下载 %d / 失败 %d", res.Uploaded, res.Downloaded, res.Failed) | |
| case plan.Changes() > 0: | |
| info("[后台对账] 上传 %d / 下载 %d", res.Uploaded, res.Downloaded) | |
| for _, a := range plan.Actions { | |
| if a.Op == syncer.OpNoop { | |
| continue | |
| } | |
| info(" %s %s (%s)", opIcon(a.Op), a.Path, a.Reason) | |
| } | |
| } | |
| } | |
| } | |
| } | |
| // --------------------------------------------------------------------------- | |
| // 配置 | |
| // --------------------------------------------------------------------------- | |
| // loadSyncConfig 读取 config.json 的 sync 段并叠加命令行覆盖。 | |
| func loadSyncConfig(cfgPath string, opt options) (syncer.Config, error) { | |
| cfg, err := syncer.LoadConfigFile(cfgPath) | |
| if err != nil && !os.IsNotExist(err) { | |
| return syncer.Config{}, fmt.Errorf("读取 %s 失败: %w", cfgPath, err) | |
| } | |
| if opt.bucket != "" { | |
| cfg.Bucket = opt.bucket | |
| } | |
| if opt.token != "" { | |
| cfg.Token = opt.token | |
| } | |
| if opt.endpoint != "" { | |
| cfg.Endpoint = opt.endpoint | |
| } | |
| if opt.writer != "" { | |
| cfg.Writer = opt.writer | |
| } | |
| if opt.relayURL != "" { | |
| cfg.RelayURL = opt.relayURL | |
| } | |
| if opt.restart != "" { | |
| cfg.RestartSpace = opt.restart | |
| } | |
| if opt.caBundle != "" { | |
| cfg.CABundle = opt.caBundle | |
| } | |
| if opt.include != "" { | |
| var list []string | |
| for _, p := range strings.Split(opt.include, ",") { | |
| if p = strings.TrimSpace(p); p != "" { | |
| list = append(list, p) | |
| } | |
| } | |
| cfg.IncludeData = list | |
| } | |
| if cfg.Token == "" && cfg.TokenFile == "" { | |
| if t := strings.TrimSpace(os.Getenv("HF_TOKEN")); t != "" { | |
| cfg.Token = t | |
| } | |
| } | |
| cfg.Normalize() | |
| if cfg.AuthDir == "" { | |
| cfg.AuthDir = "auths" | |
| } | |
| if cfg.DataDir == "" { | |
| cfg.DataDir = "data" | |
| } | |
| return cfg, nil | |
| } | |
| // newEngine 构造同步引擎;必要时先从桶里取中转的 api_key(relay 写入通道用)。 | |
| func newEngine(ctx context.Context, cfg syncer.Config, opt options) (*syncer.Engine, error) { | |
| if cfg.Bucket == "" { | |
| return nil, fmt.Errorf("config.json 的 sync.bucket 为空(未配置中转桶)") | |
| } | |
| eng, err := syncer.New(cfg) | |
| if err != nil { | |
| return nil, err | |
| } | |
| if eng.WriterName() == "relay" && eng.Config().RelayKey == "" { | |
| if key, kerr := eng.FetchRemoteAPIKey(ctx); kerr == nil { | |
| cfg.RelayKey = key | |
| if eng, err = syncer.New(cfg); err != nil { | |
| return nil, err | |
| } | |
| } | |
| } | |
| return eng, nil | |
| } | |
| // ensureConfig 首启兜底:没有 config.json 时从 config.example.json 复制一份。 | |
| // | |
| // 刻意不在这里生成带随机 api_key 的最小配置:网关自己首启就会生成推荐配置 | |
| // (含 crypto/rand 的 api_key),启动器不该抢着替它写一份半成品。 | |
| func ensureConfig(cfgPath, exeDir, cfgDir string) { | |
| if _, err := os.Stat(cfgPath); err == nil { | |
| return | |
| } | |
| for _, cand := range []string{ | |
| filepath.Join(cfgDir, "config.example.json"), | |
| filepath.Join(exeDir, "config.example.json"), | |
| } { | |
| if raw, err := os.ReadFile(cand); err == nil { | |
| if werr := os.WriteFile(cfgPath, raw, 0o600); werr == nil { | |
| warn("未找到 config.json,已从 %s 复制一份。请按需修改(尤其是 api_key 与 sync 段)。", filepath.Base(cand)) | |
| return | |
| } | |
| } | |
| } | |
| info("未找到 config.json,也没有 config.example.json —— 交给网关自行生成默认配置。") | |
| } | |
| // findServer 定位网关二进制。 | |
| // | |
| // 查找顺序:-server 显式指定 → 启动器同目录(Windows 下 wb2api.exe)→ PATH。 | |
| // 这样"把新下载的 wb2api.exe 丢到启动器旁边"就是完整的升级动作。 | |
| func findServer(explicit, exeDir string) (string, error) { | |
| names := []string{"wb2api"} | |
| if runtime.GOOS == "windows" { | |
| names = []string{"wb2api.exe", "wb2api"} | |
| } | |
| if explicit != "" { | |
| p := explicit | |
| if !filepath.IsAbs(p) { | |
| p = filepath.Join(exeDir, explicit) | |
| } | |
| if st, err := os.Stat(p); err == nil && !st.IsDir() { | |
| return p, nil | |
| } | |
| return "", fmt.Errorf("-server 指定的文件不存在: %s", p) | |
| } | |
| for _, n := range names { | |
| p := filepath.Join(exeDir, n) | |
| if st, err := os.Stat(p); err == nil && !st.IsDir() { | |
| return p, nil | |
| } | |
| } | |
| for _, n := range names { | |
| if p, err := exec.LookPath(n); err == nil { | |
| return p, nil | |
| } | |
| } | |
| return "", fmt.Errorf("找不到网关二进制 wb2api(找了 %s 与 PATH)。\n"+ | |
| " 把 wb2api.exe 放在启动器同目录,或用 -server 指定路径", strings.Join(names, " / ")) | |
| } | |
| // countAuthFiles 数本地凭证文件个数。 | |
| func countAuthFiles(dir string) int { | |
| entries, err := os.ReadDir(dir) | |
| if err != nil { | |
| return 0 | |
| } | |
| n := 0 | |
| for _, e := range entries { | |
| if !e.IsDir() && strings.HasSuffix(strings.ToLower(e.Name()), ".json") { | |
| n++ | |
| } | |
| } | |
| return n | |
| } | |
| // --------------------------------------------------------------------------- | |
| // 输出 | |
| // --------------------------------------------------------------------------- | |
| func info(format string, a ...any) { | |
| fmt.Printf("[launcher] "+format+"\n", a...) | |
| } | |
| func warn(format string, a ...any) { | |
| fmt.Printf("[launcher] ⚠ "+format+"\n", a...) | |
| } | |
| func opIcon(op syncer.Op) string { | |
| switch op { | |
| case syncer.OpUpload: | |
| return "↑" | |
| case syncer.OpDownload: | |
| return "↓" | |
| default: | |
| return "=" | |
| } | |
| } | |
| // printPlan 打印对账结果(只列有动作的行,一致的不刷屏)。 | |
| func printPlan(plan *syncer.Plan, res *syncer.Result, opt options) { | |
| cfgWriter := "" | |
| if plan.Writer != "" { | |
| cfgWriter = plan.Writer | |
| } else { | |
| cfgWriter = "(只读)" | |
| } | |
| info("桶 %s · 写入通道 %s · 本地 %d / 远端 %d 个文件", | |
| plan.Bucket, cfgWriter, plan.LocalFiles, plan.RemoteFiles) | |
| for _, a := range plan.Actions { | |
| if a.Op == syncer.OpNoop && !a.Conflict { | |
| continue | |
| } | |
| line := fmt.Sprintf(" %s %-8s %s", opIcon(a.Op), a.Op, a.Path) | |
| if a.Conflict { | |
| line += " [冲突]" | |
| } | |
| fmt.Println(line) | |
| fmt.Printf(" %s\n", a.Reason) | |
| if a.Err != "" { | |
| fmt.Printf(" ✗ %s\n", a.Err) | |
| } | |
| } | |
| info("汇总:上传 %d / 下载 %d / 跳过 %d / 失败 %d", | |
| res.Uploaded, res.Downloaded, res.Skipped, res.Failed) | |
| for _, e := range res.Errors { | |
| warn("%s", e) | |
| } | |
| } | |