Skip to content

入口與啟動

源码版本v2026.6.11

職責

openclaw 指令按下 Enter 後,到第一條業務指令真正開始執行前,中間所有的事都歸「入口 (entry)」管:Node 版本檢查、compile cache 路徑選擇、help/version 快速路徑、信號轉發,最後把控制權交給 dist/entry.jsentry.js 接手後再做一次環境歸一化、profile/container 選項解析、respawn 判定,然後呼叫 runMainOrRootHelp 把 argv 交給 CLI 分派

入口分兩層是有原因的:openclaw.mjs 是純 Node ESM、無任何 TypeScript 依賴、可獨立執行,它解決的是「在 TS 編譯產物載入之前就要決定的事」;src/entry.ts 是 TS 編譯產物的入口,它假定 dist/ 已經存在,可以做更複雜的動態 import 與 trace。

設計動機

為什麼把入口拆成兩層、還要在第一層塞這麼多 fast path?根因是冷啟動延遲。OpenClaw 是個 CLI,使用者對 openclaw --version 這種指令的延遲容忍度在百毫秒級,但完整載入 Commander + 外掛 + 設定要幾百毫秒甚至上秒。如果每次 --version 都走完整啟動鏈路,體驗會很差。

入口層的策略是:能不載入就別載入。版本號直接從 package.json 讀,help 文本從一份預編譯的 cli-startup-metadata.json 讀,只有真正要執行指令時才 dynamic import dist/entry.js。這讓 openclaw --version 在幾十毫秒內就能輸出。

第二層 entry.ts 也有自己的 fast path:它先嘗試 tryHandleRootHelpFastPathtryHandlePrecomputedCommandHelpFastPath,都不命中才 dynamic import ./cli/run-main.js。這條 import 鏈每一步都 await,所以前面的 fast path 命中時後面的程式碼根本不載入。

另一個動機是 compile cache 隔離。Node 22+ 的 module.enableCompileCache() 能顯著加速 ESM 啟動,但原始碼 checkout 和 Windows 上的 Node 24 早期版本有死結 bug。入口層專門有一段 respawn 邏輯:先判斷當前進程是不是原始碼 checkout (isSourceCheckoutLauncher)、是不是受影響的 Node 24 版本,如果是就 respawn 一個停用 compile cache 的子進程,把 NODE_DISABLE_COMPILE_CACHE=1 設進 env。這個判定必須在 TS 入口載入前做完,否則快取已經污染了。

關鍵檔案

資料流

第一層 openclaw.mjs 頂部的版本守護(openclaw.mjs:L38):

typescript
const ensureSupportedNodeVersion = () => {
  if (isSupportedNodeVersion(parseNodeVersion(process.versions.node))) {
    return;
  }

  process.stderr.write(
    `openclaw: Node.js v${MIN_NODE_VERSION}+ is required (current: v${process.versions.node}).\n` +
      "If you use nvm, run:\n" +
      `  nvm install ${MIN_NODE_MAJOR}\n` +
      `  nvm use ${MIN_NODE_MAJOR}\n` +
      `  nvm alias default ${MIN_NODE_MAJOR}\n`,
  );
  process.exit(1);
};

MIN_NODE_MAJOR=22MIN_NODE_MINOR=19 是硬門檻,低於直接退出。版本守護之後是 compile cache 的 respawn 決策(openclaw.mjs:L207):

typescript
const respawnWithoutCompileCacheIfNeeded = () => {
  const needsDisabledCompileCacheRespawn =
    isSourceCheckoutLauncher() || shouldSkipCompileCacheForWindowsNode24();
  if (!needsDisabledCompileCacheRespawn) {
    return false;
  }
  if (process.env[COMPILE_CACHE_DISABLED_RESPAWNED_ENV] === "1") {
    return false;
  }
  // ...
  return runRespawnedChild(
    process.execPath,
    [...process.execArgv, fileURLToPath(import.meta.url), ...process.argv.slice(2)],
    env,
  );
};

注意 COMPILE_CACHE_DISABLED_RESPAWNED_ENV 這個自循環守護:respawn 出來的子進程會再跑一遍這段邏輯,但環境變數已經是 "1" 了,所以最多只 respawn 一次,不會無限遞迴。

第一層 fallback 到 dist/entry.js(openclaw.mjs:L765):fast path 全不命中才 tryImport("./dist/entry.js"),失敗再試 .mjs,都失敗才 buildMissingEntryErrorMessage 給出「這是未建置的原始碼樹,跑 pnpm install && pnpm build」的提示。tryImport 只吞「直接的 module not found」,其他錯誤原樣拋——這樣 dist/entry.js 存在但其內部某段 import 失敗時,錯誤不會被吞掉偽裝成「missing dist」。

第二層 entry.ts 的入口分派核心(entry.ts:L130):

typescript
if (!tryHandleRootVersionFastPath(process.argv)) {
  await runMainOrRootHelp(process.argv);
}

runMainOrRootHelp 自己也先嘗試 help fast path(entry.ts:L271):

typescript
async function runMainOrRootHelp(argv: string[]): Promise<void> {
  if (await tryHandleRootHelpFastPath(argv)) {
    return;
  }
  if (await tryHandlePrecomputedCommandHelpFastPath(argv)) {
    return;
  }
  try {
    const { runCli } = await gatewayEntryStartupTrace.measure(
      "run-main-import",
      () => import("./cli/run-main.js"),
    );
    await runCli(argv);
  } catch (error) {
    const { formatCliFailureLines } = await import("./cli/failure-output.js");
    for (const line of formatCliFailureLines({
      title: "Could not start the CLI.",
      error,
      argv,
    })) {
      console.error(line);
    }
    process.exit(1);
  }
}

注意 gatewayEntryStartupTrace.measure("run-main-import", ...)——run-main.js 這一次 dynamic import 被顯式計時,因為它是冷啟動裡最重的一步(幾百毫秒)。runCli 拋錯時不直接 console.error(error.stack),而是統一走 formatCliFailureLines 給出一個使用者友善的標題 + trace,然後 process.exit(1)

entry.ts 頂部還有個關鍵的 isMainModule 守護(entry.ts:L56):

typescript
if (
  !isMainModule({
    currentFile: fileURLToPath(import.meta.url),
    wrapperEntryPairs: [...ENTRY_WRAPPER_PAIRS],
  })
) {
  // Imported as a dependency — skip all entry-point side effects.
} else {
  // ... 完整啟動邏輯
}

註解說出了原因:bundler 可能把 entry.js 當成共享依賴被 dist/index.js import,沒有這個守護就會啟動兩個 gateway,第二個搶連接埠失敗導致崩潰。

邊界與失敗

  • respawn 信號轉發:runRespawnedChild(openclaw.mjs:L102)給子進程掛 SIGTERM/SIGINT/SIGHUP/SIGQUIT 監聽,收到信號後轉發給子進程,並在 1 秒後兜底 process.exit。這樣防止子進程忽略信號導致 launcher 永遠掛住。Windows 平台信號集不同(只有 SIGTERM/SIGINT/SIGBREAK)。
  • compile cache 路徑污染:如果 NODE_COMPILE_CACHE 環境變數指向一個不可寫目錄,module.enableCompileCache 會靜默失敗(openclaw.mjs:L267),try/catch 吞掉錯誤——這是有意為之,快取失敗不應該阻止啟動。
  • 原始碼 checkout 檢測:isSourceCheckoutLauncher() 透過 .gitsrc/entry.ts 是否存在來判斷,如果是原始碼樹就強制停用 compile cache(因為原始碼可能隨時被改,快取的編譯產物會過期)。
  • missing dist 報錯:buildMissingEntryErrorMessage(openclaw.mjs:L354)會檢測 src/entry.ts 是否存在,存在就提示「這是未建置的原始碼樹,跑 pnpm install && pnpm build」,而不是簡單說「file not found」——這個區分能省使用者幾分鐘排查時間。
  • isMainModule 雙重啟動:entry.ts 頂部守護(entry.ts:L56)的註解指出,沒有這個守護 bundler 會把 entry.js 作為共享依賴,導致 runCli 被呼叫兩次,第二個實例搶 gateway 鎖/連接埠失敗崩潰。
  • respawn 循環防護:COMPILE_CACHE_DISABLED_RESPAWNED_ENVOPENCLAW_PACKAGED_COMPILE_CACHE_RESPAWNED 兩個環境變數分別守護兩類 respawn,保證最多只 respawn 一次。

小結

入口層做的是「在 TS 載入之前就能解決的事」:版本守護、compile cache 決策、help/version fast path、信號轉發。openclaw.mjs 是純 Node 腳本,不依賴任何編譯產物;entry.ts 是 TS 編譯產物的入口,補上 respawn 計畫、profile 解析、help fast path 兜底,最後才把 argv 交給 runCli 真正進入 CLI 指令分派。分派鏈路怎麼把 argv 路由到具體的 subcli、怎麼走 gateway hot path、怎麼 lazy-register 指令,看下一頁;gateway 本身的啟動序列看 閘道核心

對照官方資料:OpenClaw 文件 · README