openscienceScience
§ · Nested detail page

CLI / Server 启动链路 深度解析

这一页拆 `openscience` 从命令行进入本地浏览器工作台的全过程:命令注册、web handler、配置同步、server 绑定和退出清理。

§ 00 · IMPORT CHAIN

导入链与契约

启动链的入口是 yargs 命令表,backend/cli/src/index.ts:55 创建 CLI,backend/cli/src/index.ts:72 注册 middleware,backend/cli/src/index.ts:122 把 WebCommand 作为默认工作台入口。随后 backend/cli/src/cli/cmd/web.ts:60 定义 `web/$0` 命令,backend/cli/src/cli/cmd/web.ts:147 才真正绑定 server。这个分层让 CLI 启动、凭证注入和浏览器工作台解耦。

src/backend/cli/src/index.ts · L55-72TypeScript
55const cli = yargs(hideBin(process.argv))
56  .parserConfiguration({ "populate--": true })
57  .scriptName("openscience")
58  .wrap(100)
59  .help("help", "show help")
60  .alias("help", "h")
61  .version("version", "show version number", Installation.VERSION)
62  .alias("version", "v")
63  .option("print-logs", {
64    describe: "print logs to stderr",
65    type: "boolean",
66  })
67  .option("log-level", {
68    describe: "log level",
69    type: "string",
70    choices: ["DEBUG", "INFO", "WARN", "ERROR"],
71  })
72  .middleware(async (opts) => {
§ 01 · CORE IMPL

核心实现,逐段拆

▸命令 middleware 先准备环境,再交给具体命令

middleware 在 backend/cli/src/index.ts:73 初始化日志,在 backend/cli/src/index.ts:83 设置 AGENT/OPENSCIENCE 标记,在 backend/cli/src/index.ts:96 刷新远端配置,再通过 backend/cli/src/index.ts:101 和 backend/cli/src/index.ts:105 注入凭证与 compute key。这解释了为什么 provider SDK import 顺序被文件顶部注释强调。

src/backend/cli/src/index.ts · L72-109TypeScript
72  .middleware(async (opts) => {
73    await Log.init({
74      print: process.argv.includes("--print-logs"),
75      dev: Installation.isLocal(),
76      level: (() => {
77        if (opts.logLevel) return opts.logLevel as Log.Level
78        if (Installation.isLocal()) return "DEBUG"
79        return "INFO"
80      })(),
81    })
82
83    process.env.AGENT = "1"
84    process.env.OPENSCIENCE = "1"
85
86    Log.Default.info("openscience", {
87      version: Installation.VERSION,
88      args: process.argv.slice(2),
89    })
90
91    // Cheap /sync/version probe (10s TTL). When the server-side version
92    // has changed, a full /api/cli/sync runs in the background so the new
93    // env applies to the NEXT command — the current one uses whatever is
94    // already cached on disk. Replaces a blocking 5s Promise.race that
95    // ran on every invocation regardless of staleness.
96    await OpenScience.refreshIfStale().catch(() => {})
97
98    // Inject decrypted service credentials (settings ▸ Credentials) into the
99    // process env so skills/tools/connectors actually use them. Dynamic import
100    // keeps the credential route module out of every command's static graph.
101    await import("./server/routes/settings/credentials").then((m) => m.applyCredentialEnv()).catch(() => {})
102
103    // Same for BYOK GPU provider keys (settings ▸ Compute) — decrypt and inject
104    // under the canonical env var names the compute skills read.
105    await import("./server/routes/settings/compute").then((m) => m.ComputeSettings.applyComputeEnv()).catch(() => {})
106
107    // Retry any failed usage reports from previous sessions
108    OpenScience.flushPendingUsage().catch(() => {})
109  })

▸web 命令在绑定前完成 onboarding 和同步预算

web handler 先在 backend/cli/src/cli/cmd/web.ts:79 解析网络选项,backend/cli/src/cli/cmd/web.ts:87 处理 onboarding,backend/cli/src/cli/cmd/web.ts:100 判断登录状态,backend/cli/src/cli/cmd/web.ts:121 设置 6 秒同步预算,最后 backend/cli/src/cli/cmd/web.ts:147 才 listen。它故意让“模型配置刷新”跑在浏览器首屏之前。

src/backend/cli/src/cli/cmd/web.ts · L79-154TypeScript
79    const opts = await resolveNetworkOptions(args)
80    UI.empty()
81    UI.println(UI.logo("  "))
82    UI.empty()
83
84    // First launch on this machine with nothing configured → walk the user
85    // through setup (managed vs BYOK vs skip) before we bind the server, so
86    // the browser's first request already sees a usable model.
87    if (await needsOnboarding()) {
88      await runOnboarding()
89      UI.empty()
90    }
91
92    // Run the dashboard sync BEFORE starting the server — and without
93    // the 5s race timeout the global middleware uses. The model picker
94    // and provider whitelist live in ~/.config/openscience/openscience-synced.json;
95    // Config.state() reads that file once on first request and caches
96    // for the process lifetime. If we start the HTTP server first, the
97    // browser can race the sync and the picker shows the previous run's
98    // catalogue. Doing it here, await-ed, guarantees the next browser
99    // request sees the freshly-synced whitelist.
100    const authed = await OpenScience.isAuthenticated()
101    if (!authed) {
102      // Only nudge when there's genuinely no way to run a model. A BYOK key
103      // (env var or `keys add`) is a first-class, account-free setup — don't
104      // badger those users to connect Atlas.
105      if (!(await isConfigured())) {
106        UI.println(UI.Style.TEXT_WARNING_BOLD + "  ⚠  No model configured", UI.Style.TEXT_NORMAL)
107        UI.println(
108          UI.Style.TEXT_NORMAL,
109          "  Run `openscience login` for Atlas managed models, or `openscience keys add` for your own key.",
110        )
111        UI.println(UI.Style.TEXT_DIM, "  Continuing with free demo models for now.")
112        UI.empty()
113      }
114    } else {
115      // Sync managed config before binding so the browser's first request sees
116      // the fresh provider whitelist. But cap the wait: syncServices() has no
117      // internal timeout, so a slow/unresponsive backend would otherwise hang
118      // the launch forever (the server never binds). If the sync outlasts the
119      // cap, bind anyway and let it finish in the background — the global
120      // middleware also syncs per-request as a backstop.
121      const SYNC_BUDGET_MS = 6000
122      const synced = OpenScience.syncServices().catch(() => null)
123      const result = await Promise.race([
124        synced,
125        new Promise<"timeout">((r) => setTimeout(() => r("timeout"), SYNC_BUDGET_MS)),
126      ])
127      if (result === "timeout") {
128        UI.println(
129          UI.Style.TEXT_DIM,
130          "  (managed-config sync is slow — continuing; the model picker will refresh shortly)",
131        )
132        UI.empty()
133      } else if (result) {
134        const noun = result.credentials === 1 ? "credential" : "credentials"
135        UI.println(
136          UI.Style.TEXT_INFO_BOLD + "  ✓ Synced",
137          UI.Style.TEXT_NORMAL,
138          `${result.credentials} ${noun} from connected services`,
139        )
140        UI.empty()
141      } else {
142        UI.println(UI.Style.TEXT_DIM, "  (sync skipped — using cached config from previous run)")
143        UI.empty()
144      }
145    }
146
147    const server = Server.listen(opts)
148
149    const base = `http://localhost:${server.port}`
150    UI.println(UI.Style.TEXT_INFO_BOLD + "  Web interface:    ", UI.Style.TEXT_NORMAL, base)
151    UI.empty()
152    UI.println(UI.Style.TEXT_DIM, "  Opening your browser… if it doesn't open, visit the URL above.")
153
154    openUrl(base)
§ 02 · KEY FLOW

关键流程

index.ts middleware → credential env → WebCommand → syncServices budget → Server.listen → openUrl

这个流程的工程价值在于避免首屏 race。backend/cli/src/cli/cmd/web.ts:92 的注释明确说 synced whitelist 被 Config.state 首次请求缓存;如果 backend/cli/src/cli/cmd/web.ts:147 先启动 server,浏览器可能看到上一轮模型目录。退出阶段也没有忘:backend/cli/src/cli/cmd/web.ts:164 等 SIGINT/SIGTERM,backend/cli/src/cli/cmd/web.ts:173 用 watchdog 防止 SSE 长连接卡死退出。

▸为什么这样拆

这个子模块的拆法不是按文件大小,而是按运行时责任边界来切。主页面已经把 backend/cli/src/index.ts:55、backend/cli/src/config/config.ts:75、backend/cli/src/provider/provider.ts:57、backend/cli/src/science/connectors/types.ts:76 和 tooling/plugin/src/index.ts:148 串成整体;子页面只展开其中一个闭环,避免把所有源码证据挤成行号清单。

§ 03 · EDGE CASES

边界 / 失败模式

  • 如果 syncServices 很慢,backend/cli/src/cli/cmd/web.ts:127 选择继续启动,风险是模型 picker 稍后刷新而不是阻断。
  • macOS TCC 检测在 backend/cli/src/cli/cmd/web.ts:17 使用 Desktop 读目录作为信号,空 Desktop 也被当成可能阻塞。
  • 顶层 finally 在 backend/cli/src/index.ts:198 到 backend/cli/src/index.ts:202 强制退出,避免 MCP 子进程挂住。