/** * Locator — 定位 OpenClaw control-ui 目录和文件 * * 唯一定位方法:require.resolve('openclaw') * 插件通过 symlink 依赖宿主 openclaw 包,如果 symlink 不存在, * 插件的 import 语句就会失败,根本执行不到这里。 */ import { existsSync, readdirSync } from 'node:fs'; import { dirname, join, resolve } from 'node:path'; import { createRequire } from 'node:module'; import { logger } from '../../util/logger.js'; /** * 定位 control-ui 目录。 * * require.resolve('openclaw') * → /usr/lib/node_modules/openclaw/dist/index.js * → dirname → dist/ * → join('control-ui') → dist/control-ui/ */ export function resolveControlUiDir(): string | null { const candidates: string[] = []; try { const require = createRequire(import.meta.url); const entryPoint = require.resolve('openclaw'); const distDir = dirname(entryPoint); candidates.push( // npm/global install: openclaw/dist/index.js → openclaw/dist/control-ui join(distDir, 'control-ui'), // package root entry fallback: openclaw/index.js → openclaw/dist/control-ui join(dirname(distDir), 'dist', 'control-ui'), ); } catch { // Docker installs the plugin outside /app/node_modules, so resolving the // host package from the plugin path can fail even though OpenClaw is // running from /app. Fall through to cwd-based candidates below. } // Docker image layout: process cwd is /app, UI lives at /app/dist/control-ui. candidates.push( join(process.cwd(), 'dist', 'control-ui'), join(resolve(process.cwd(), '..'), 'dist', 'control-ui'), ); for (const candidate of candidates) { if (existsSync(candidate)) { logger.debug(`[ui-ext] control-ui located: ${candidate}`); return candidate; } } logger.warn('[ui-ext] Could not locate control-ui directory'); return null; } /** * 定位 OpenClaw dist 目录。 * * control-ui 是 dist/control-ui 的子目录,后端 gateway-cli-*.js 也在同一个 * dist 下。这里优先从已解析出的 control-ui 反推,避免 Docker 场景下 * require.resolve('openclaw') 失效。 */ export function resolveOpenClawDistDir(controlUiDir?: string | null): string | null { const candidates: string[] = []; if (controlUiDir) { candidates.push(dirname(controlUiDir)); } try { const require = createRequire(import.meta.url); const entryPoint = require.resolve('openclaw'); candidates.push(dirname(entryPoint)); candidates.push(join(dirname(dirname(entryPoint)), 'dist')); } catch { // Docker 插件路径下可能无法 resolve 宿主 openclaw;继续走 cwd 兜底。 } candidates.push( join(process.cwd(), 'dist'), join(resolve(process.cwd(), '..'), 'dist'), ); for (const candidate of candidates) { if (existsSync(candidate)) { logger.debug(`[ui-ext] openclaw dist located: ${candidate}`); return candidate; } } logger.warn('[ui-ext] Could not locate OpenClaw dist directory'); return null; } /** * 在目录中查找匹配前缀的第一个文件。 * 用于定位 Vite code-split 后的文件(如 index-D7mg1IkY.js)。 * @param suffix 文件后缀,默认 '.js',传 '.js.map' 可找 source map */ export function findFileByPrefix(dir: string, prefix: string, suffix = '.js'): string | null { try { const entries = readdirSync(dir); const match = entries.find(e => e.startsWith(prefix) && e.endsWith(suffix)); if (match) { logger.debug(`[ui-ext] Found file: ${match}`); } return match ? join(dir, match) : null; } catch { return null; } } /** * 获取 bundle 文件名中的 hash 部分,用作 bundleHash。 * index-D7mg1IkY.js → D7mg1IkY */ export function extractBundleHash(bundlePath: string): string { const filename = bundlePath.split('/').pop() ?? ''; const match = filename.match(/^index-(.+)\.js$/); return match?.[1] ?? filename; }