CLI / Server 启动链路 深度解析
这一页拆 `openscience` 从命令行进入本地浏览器工作台的全过程:命令注册、web handler、配置同步、server 绑定和退出清理。
导入链与契约
启动链的入口是 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 启动、凭证注入和浏览器工作台解耦。
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) => {
核心实现,逐段拆
▸命令 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 顺序被文件顶部注释强调。
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。它故意让“模型配置刷新”跑在浏览器首屏之前。
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)
关键流程
这个流程的工程价值在于避免首屏 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 串成整体;子页面只展开其中一个闭环,避免把所有源码证据挤成行号清单。
边界 / 失败模式
- 如果 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 子进程挂住。