527 lines
16 KiB
TypeScript
527 lines
16 KiB
TypeScript
/**
|
|
* Path Utilities
|
|
* Cross-platform path resolution helpers
|
|
*/
|
|
import { createRequire } from 'node:module';
|
|
import { dirname, isAbsolute, join, normalize } from 'path';
|
|
import { homedir } from 'os';
|
|
import { cpSync, existsSync, mkdirSync, readFileSync, realpathSync, rmSync } from 'fs';
|
|
|
|
const require = createRequire(import.meta.url);
|
|
|
|
type ElectronAppLike = Pick<typeof import('electron').app, 'isPackaged' | 'getPath' | 'getAppPath'>;
|
|
type OpenClawRuntimeSource = 'dev' | 'external' | 'managed' | 'bundled' | 'missing';
|
|
|
|
type OpenClawRuntimeResolution = {
|
|
dir: string;
|
|
source: OpenClawRuntimeSource;
|
|
version?: string;
|
|
bundledDir?: string;
|
|
managedDir?: string;
|
|
installedFromBundled?: boolean;
|
|
};
|
|
|
|
let cachedOpenClawRuntime: OpenClawRuntimeResolution | null = null;
|
|
|
|
// Modules that Zhinian loads from the OpenClaw package context at main-process
|
|
// module initialization time. Some user-installed or previously managed
|
|
// OpenClaw packages are valid CLIs but do not include these app-side
|
|
// integration dependencies; selecting them would crash before the UI opens.
|
|
const REQUIRED_OPENCLAW_CONTEXT_MODULES = [
|
|
'@whiskeysockets/baileys/package.json',
|
|
'qrcode-terminal/package.json',
|
|
'qrcode-terminal/vendor/QRCode/index.js',
|
|
'qrcode-terminal/vendor/QRCode/QRErrorCorrectLevel.js',
|
|
] as const;
|
|
|
|
export {
|
|
quoteForCmd,
|
|
needsWinShell,
|
|
prepareWinSpawn,
|
|
normalizeNodeRequirePathForNodeOptions,
|
|
appendNodeRequireToNodeOptions,
|
|
} from './win-shell';
|
|
|
|
function getElectronApp() {
|
|
if (process.versions?.electron) {
|
|
return (require('electron') as typeof import('electron')).app;
|
|
}
|
|
|
|
const fallbackUserData = process.env.CLAWX_USER_DATA_DIR?.trim() || join(homedir(), '.clawx');
|
|
const fallbackAppPath = process.cwd();
|
|
const fallbackApp: ElectronAppLike = {
|
|
isPackaged: false,
|
|
getPath: (name) => {
|
|
if (name === 'userData') return fallbackUserData;
|
|
return fallbackUserData;
|
|
},
|
|
getAppPath: () => fallbackAppPath,
|
|
};
|
|
return fallbackApp;
|
|
}
|
|
|
|
/**
|
|
* Expand ~ to home directory
|
|
*/
|
|
export function expandPath(path: string): string {
|
|
if (path.startsWith('~')) {
|
|
return path.replace('~', homedir());
|
|
}
|
|
return path;
|
|
}
|
|
|
|
/**
|
|
* Get OpenClaw config directory
|
|
*/
|
|
export function getOpenClawConfigDir(): string {
|
|
return join(homedir(), '.openclaw');
|
|
}
|
|
|
|
/**
|
|
* Get OpenClaw skills directory
|
|
*/
|
|
export function getOpenClawSkillsDir(): string {
|
|
return join(getOpenClawConfigDir(), 'skills');
|
|
}
|
|
|
|
/**
|
|
* Get ClawX config directory
|
|
*/
|
|
export function getClawXConfigDir(): string {
|
|
return join(homedir(), '.clawx');
|
|
}
|
|
|
|
/**
|
|
* Get ClawX logs directory
|
|
*/
|
|
export function getLogsDir(): string {
|
|
return join(getElectronApp().getPath('userData'), 'logs');
|
|
}
|
|
|
|
/**
|
|
* Get ClawX data directory
|
|
*/
|
|
export function getDataDir(): string {
|
|
return getElectronApp().getPath('userData');
|
|
}
|
|
|
|
/**
|
|
* Ensure directory exists
|
|
*/
|
|
export function ensureDir(dir: string): void {
|
|
if (!existsSync(dir)) {
|
|
mkdirSync(dir, { recursive: true });
|
|
}
|
|
}
|
|
|
|
function fsPath(filePath: string): string {
|
|
if (process.platform !== 'win32') return filePath;
|
|
if (!filePath) return filePath;
|
|
if (filePath.startsWith('\\\\?\\')) return filePath;
|
|
const windowsPath = filePath.replace(/\//g, '\\');
|
|
if (!isAbsolute(windowsPath)) return windowsPath;
|
|
if (windowsPath.startsWith('\\\\')) {
|
|
return `\\\\?\\UNC\\${windowsPath.slice(2)}`;
|
|
}
|
|
return `\\\\?\\${windowsPath}`;
|
|
}
|
|
|
|
function normalizeCandidatePath(dir: string): string {
|
|
return normalize(expandPath(dir.trim()));
|
|
}
|
|
|
|
function readOpenClawVersion(dir: string): string | undefined {
|
|
try {
|
|
const pkgPath = join(dir, 'package.json');
|
|
if (existsSync(fsPath(pkgPath))) {
|
|
const pkg = JSON.parse(readFileSync(fsPath(pkgPath), 'utf-8')) as { version?: string };
|
|
return pkg.version;
|
|
}
|
|
} catch {
|
|
// Ignore version read errors.
|
|
}
|
|
return undefined;
|
|
}
|
|
|
|
function isValidOpenClawPackageDir(dir: string): boolean {
|
|
return Boolean(dir)
|
|
&& existsSync(fsPath(dir))
|
|
&& existsSync(fsPath(join(dir, 'package.json')))
|
|
&& existsSync(fsPath(join(dir, 'openclaw.mjs')));
|
|
}
|
|
|
|
function hasRequiredOpenClawContextModules(dir: string): boolean {
|
|
if (!isValidOpenClawPackageDir(dir)) return false;
|
|
|
|
try {
|
|
const runtimeRequire = createRequire(join(realpathSync(fsPath(dir)), 'package.json'));
|
|
for (const specifier of REQUIRED_OPENCLAW_CONTEXT_MODULES) {
|
|
runtimeRequire.resolve(specifier);
|
|
}
|
|
return true;
|
|
} catch {
|
|
try {
|
|
const runtimeRequire = createRequire(join(dir, 'package.json'));
|
|
for (const specifier of REQUIRED_OPENCLAW_CONTEXT_MODULES) {
|
|
runtimeRequire.resolve(specifier);
|
|
}
|
|
return true;
|
|
} catch {
|
|
return false;
|
|
}
|
|
}
|
|
}
|
|
|
|
function samePath(left: string, right: string): boolean {
|
|
try {
|
|
return realpathSync(fsPath(left)) === realpathSync(fsPath(right));
|
|
} catch {
|
|
return normalize(left) === normalize(right);
|
|
}
|
|
}
|
|
|
|
function logOpenClawRuntime(message: string, extra?: unknown): void {
|
|
try {
|
|
const { logger } = require('./logger') as typeof import('./logger');
|
|
if (extra === undefined) {
|
|
logger.info(message);
|
|
} else {
|
|
logger.info(message, extra);
|
|
}
|
|
} catch {
|
|
// Logger may be unavailable in unit tests or non-Electron contexts.
|
|
}
|
|
}
|
|
|
|
function getBundledOpenClawDir(): string {
|
|
if (getElectronApp().isPackaged) {
|
|
return join(process.resourcesPath, 'openclaw');
|
|
}
|
|
return join(__dirname, '../../node_modules/openclaw');
|
|
}
|
|
|
|
function getManagedOpenClawDir(): string {
|
|
return join(getOpenClawConfigDir(), 'runtime', 'openclaw');
|
|
}
|
|
|
|
function getEnvOpenClawCandidates(): string[] {
|
|
return [
|
|
process.env.YINIAN_OPENCLAW_DIR,
|
|
process.env.CLAWX_OPENCLAW_DIR,
|
|
process.env.OPENCLAW_DIR,
|
|
].filter((value): value is string => Boolean(value?.trim()));
|
|
}
|
|
|
|
function getStandardOpenClawCandidates(): string[] {
|
|
const home = homedir();
|
|
const candidates = [
|
|
join(home, '.openclaw', 'openclaw'),
|
|
join(home, '.openclaw', 'node_modules', 'openclaw'),
|
|
join(home, '.npm-global', 'lib', 'node_modules', 'openclaw'),
|
|
join(home, '.local', 'lib', 'node_modules', 'openclaw'),
|
|
];
|
|
|
|
if (process.platform === 'darwin') {
|
|
candidates.push(
|
|
'/usr/local/lib/node_modules/openclaw',
|
|
'/opt/homebrew/lib/node_modules/openclaw',
|
|
);
|
|
} else if (process.platform === 'linux') {
|
|
candidates.push(
|
|
'/usr/local/lib/node_modules/openclaw',
|
|
'/usr/lib/node_modules/openclaw',
|
|
);
|
|
} else if (process.platform === 'win32') {
|
|
const appData = process.env.APPDATA;
|
|
const programFiles = process.env.ProgramFiles;
|
|
const programFilesX86 = process.env['ProgramFiles(x86)'];
|
|
if (appData) candidates.push(join(appData, 'npm', 'node_modules', 'openclaw'));
|
|
if (programFiles) candidates.push(join(programFiles, 'nodejs', 'node_modules', 'openclaw'));
|
|
if (programFilesX86) candidates.push(join(programFilesX86, 'nodejs', 'node_modules', 'openclaw'));
|
|
}
|
|
|
|
return candidates;
|
|
}
|
|
|
|
function findExternalOpenClawDir(excludedDirs: string[]): string | null {
|
|
const seen = new Set<string>();
|
|
const candidates = [...getEnvOpenClawCandidates(), ...getStandardOpenClawCandidates()];
|
|
|
|
for (const rawCandidate of candidates) {
|
|
const candidate = normalizeCandidatePath(rawCandidate);
|
|
if (seen.has(candidate)) continue;
|
|
seen.add(candidate);
|
|
if (excludedDirs.some((excluded) => samePath(candidate, excluded))) continue;
|
|
if (!isValidOpenClawPackageDir(candidate)) continue;
|
|
if (hasRequiredOpenClawContextModules(candidate)) return candidate;
|
|
logOpenClawRuntime('[openclaw-runtime] Ignoring external OpenClaw installation because required app dependencies are missing', {
|
|
candidate,
|
|
requiredModules: REQUIRED_OPENCLAW_CONTEXT_MODULES,
|
|
});
|
|
}
|
|
|
|
return null;
|
|
}
|
|
|
|
function installBundledOpenClawToManagedRuntime(bundledDir: string, managedDir: string): boolean {
|
|
if (!isValidOpenClawPackageDir(bundledDir)) return false;
|
|
|
|
const bundledVersion = readOpenClawVersion(bundledDir);
|
|
const managedVersion = isValidOpenClawPackageDir(managedDir)
|
|
? readOpenClawVersion(managedDir)
|
|
: undefined;
|
|
|
|
if (managedVersion && bundledVersion && managedVersion === bundledVersion && hasRequiredOpenClawContextModules(managedDir)) {
|
|
return false;
|
|
}
|
|
|
|
const tempDir = `${managedDir}.tmp-${Date.now()}`;
|
|
rmSync(fsPath(tempDir), { recursive: true, force: true });
|
|
mkdirSync(fsPath(dirname(tempDir)), { recursive: true });
|
|
cpSync(fsPath(bundledDir), fsPath(tempDir), { recursive: true, dereference: true });
|
|
rmSync(fsPath(managedDir), { recursive: true, force: true });
|
|
cpSync(fsPath(tempDir), fsPath(managedDir), { recursive: true, dereference: true });
|
|
rmSync(fsPath(tempDir), { recursive: true, force: true });
|
|
return true;
|
|
}
|
|
|
|
function resolveOpenClawRuntime(): OpenClawRuntimeResolution {
|
|
if (cachedOpenClawRuntime) return cachedOpenClawRuntime;
|
|
|
|
const app = getElectronApp();
|
|
const bundledDir = getBundledOpenClawDir();
|
|
const managedDir = getManagedOpenClawDir();
|
|
|
|
if (!app.isPackaged) {
|
|
cachedOpenClawRuntime = {
|
|
dir: bundledDir,
|
|
source: isValidOpenClawPackageDir(bundledDir) ? 'dev' : 'missing',
|
|
version: readOpenClawVersion(bundledDir),
|
|
bundledDir,
|
|
managedDir,
|
|
};
|
|
return cachedOpenClawRuntime;
|
|
}
|
|
|
|
if (hasRequiredOpenClawContextModules(managedDir)) {
|
|
cachedOpenClawRuntime = {
|
|
dir: managedDir,
|
|
source: 'managed',
|
|
version: readOpenClawVersion(managedDir),
|
|
bundledDir,
|
|
managedDir,
|
|
};
|
|
logOpenClawRuntime('[openclaw-runtime] Using managed OpenClaw runtime', cachedOpenClawRuntime);
|
|
return cachedOpenClawRuntime;
|
|
}
|
|
|
|
const externalDir = findExternalOpenClawDir([bundledDir, managedDir]);
|
|
if (externalDir) {
|
|
cachedOpenClawRuntime = {
|
|
dir: externalDir,
|
|
source: 'external',
|
|
version: readOpenClawVersion(externalDir),
|
|
bundledDir,
|
|
managedDir,
|
|
};
|
|
logOpenClawRuntime('[openclaw-runtime] Using existing OpenClaw installation before managed runtime is installed', cachedOpenClawRuntime);
|
|
return cachedOpenClawRuntime;
|
|
}
|
|
|
|
let installedFromBundled = false;
|
|
if (isValidOpenClawPackageDir(bundledDir)) {
|
|
try {
|
|
installedFromBundled = installBundledOpenClawToManagedRuntime(bundledDir, managedDir);
|
|
} catch (error) {
|
|
logOpenClawRuntime('[openclaw-runtime] Failed to install bundled OpenClaw runtime, falling back to bundled resources', error);
|
|
}
|
|
}
|
|
|
|
if (hasRequiredOpenClawContextModules(managedDir)) {
|
|
cachedOpenClawRuntime = {
|
|
dir: managedDir,
|
|
source: 'managed',
|
|
version: readOpenClawVersion(managedDir),
|
|
bundledDir,
|
|
managedDir,
|
|
installedFromBundled,
|
|
};
|
|
logOpenClawRuntime(
|
|
installedFromBundled
|
|
? '[openclaw-runtime] Installed bundled OpenClaw runtime'
|
|
: '[openclaw-runtime] Using managed OpenClaw runtime',
|
|
cachedOpenClawRuntime,
|
|
);
|
|
return cachedOpenClawRuntime;
|
|
}
|
|
|
|
if (isValidOpenClawPackageDir(managedDir)) {
|
|
logOpenClawRuntime('[openclaw-runtime] Ignoring managed OpenClaw runtime because required app dependencies are missing', {
|
|
managedDir,
|
|
requiredModules: REQUIRED_OPENCLAW_CONTEXT_MODULES,
|
|
});
|
|
}
|
|
|
|
cachedOpenClawRuntime = {
|
|
dir: bundledDir,
|
|
source: isValidOpenClawPackageDir(bundledDir) ? 'bundled' : 'missing',
|
|
version: readOpenClawVersion(bundledDir),
|
|
bundledDir,
|
|
managedDir,
|
|
};
|
|
return cachedOpenClawRuntime;
|
|
}
|
|
|
|
/**
|
|
* Get resources directory (for bundled assets)
|
|
*/
|
|
export function getResourcesDir(): string {
|
|
if (getElectronApp().isPackaged) {
|
|
return join(process.resourcesPath, 'resources');
|
|
}
|
|
return join(__dirname, '../../resources');
|
|
}
|
|
|
|
/**
|
|
* Get preload script path
|
|
*/
|
|
export function getPreloadPath(): string {
|
|
return join(__dirname, '../preload/index.js');
|
|
}
|
|
|
|
/**
|
|
* Get OpenClaw package directory.
|
|
*
|
|
* Runtime resolution policy:
|
|
* 1. Development uses workspace node_modules/openclaw.
|
|
* 2. Packaged builds first adapt an existing user/system OpenClaw package.
|
|
* 3. If none exists, copy the bundled OpenClaw package into
|
|
* ~/.openclaw/runtime/openclaw and run from there.
|
|
* 4. If the managed install fails, fall back to packaged resources/openclaw.
|
|
*/
|
|
export function getOpenClawDir(): string {
|
|
return resolveOpenClawRuntime().dir;
|
|
}
|
|
|
|
export function reinstallManagedOpenClawRuntime(): OpenClawRuntimeResolution {
|
|
cachedOpenClawRuntime = null;
|
|
|
|
const bundledDir = getBundledOpenClawDir();
|
|
const managedDir = getManagedOpenClawDir();
|
|
rmSync(fsPath(managedDir), { recursive: true, force: true });
|
|
|
|
let installedFromBundled = false;
|
|
if (isValidOpenClawPackageDir(bundledDir)) {
|
|
installedFromBundled = installBundledOpenClawToManagedRuntime(bundledDir, managedDir);
|
|
}
|
|
|
|
cachedOpenClawRuntime = {
|
|
dir: hasRequiredOpenClawContextModules(managedDir) ? managedDir : bundledDir,
|
|
source: hasRequiredOpenClawContextModules(managedDir) ? 'managed' : isValidOpenClawPackageDir(bundledDir) ? 'bundled' : 'missing',
|
|
version: readOpenClawVersion(hasRequiredOpenClawContextModules(managedDir) ? managedDir : bundledDir),
|
|
bundledDir,
|
|
managedDir,
|
|
installedFromBundled,
|
|
};
|
|
logOpenClawRuntime('[openclaw-runtime] Reinstalled managed OpenClaw runtime for first-run initialization', cachedOpenClawRuntime);
|
|
return cachedOpenClawRuntime;
|
|
}
|
|
|
|
/**
|
|
* Get OpenClaw package directory resolved to a real path.
|
|
* Useful when consumers need deterministic module resolution under pnpm symlinks.
|
|
*/
|
|
export function getOpenClawResolvedDir(): string {
|
|
const dir = getOpenClawDir();
|
|
if (!existsSync(dir)) {
|
|
return dir;
|
|
}
|
|
try {
|
|
return realpathSync(dir);
|
|
} catch {
|
|
return dir;
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Get OpenClaw entry script path (openclaw.mjs)
|
|
*/
|
|
export function getOpenClawEntryPath(): string {
|
|
return join(getOpenClawDir(), 'openclaw.mjs');
|
|
}
|
|
|
|
/**
|
|
* Get ClawHub CLI entry script path (clawdhub.js)
|
|
*/
|
|
export function getClawHubCliEntryPath(): string {
|
|
return join(getElectronApp().getAppPath(), 'node_modules', 'clawhub', 'bin', 'clawdhub.js');
|
|
}
|
|
|
|
/**
|
|
* Get ClawHub CLI binary path (node_modules/.bin)
|
|
*/
|
|
export function getClawHubCliBinPath(): string {
|
|
const binName = process.platform === 'win32' ? 'clawhub.cmd' : 'clawhub';
|
|
return join(getElectronApp().getAppPath(), 'node_modules', '.bin', binName);
|
|
}
|
|
|
|
/**
|
|
* Check if OpenClaw package exists
|
|
*/
|
|
export function isOpenClawPresent(): boolean {
|
|
const dir = getOpenClawDir();
|
|
const pkgJsonPath = join(dir, 'package.json');
|
|
return existsSync(dir) && existsSync(pkgJsonPath);
|
|
}
|
|
|
|
/**
|
|
* Check if OpenClaw is built (has dist folder)
|
|
* For the npm package, this should always be true since npm publishes the built dist.
|
|
*/
|
|
export function isOpenClawBuilt(): boolean {
|
|
const dir = getOpenClawDir();
|
|
const distDir = join(dir, 'dist');
|
|
const hasDist = existsSync(distDir);
|
|
return hasDist;
|
|
}
|
|
|
|
/**
|
|
* Get OpenClaw status for environment check
|
|
*/
|
|
export interface OpenClawStatus {
|
|
packageExists: boolean;
|
|
isBuilt: boolean;
|
|
entryPath: string;
|
|
dir: string;
|
|
version?: string;
|
|
source?: OpenClawRuntimeSource;
|
|
bundledDir?: string;
|
|
managedDir?: string;
|
|
installedFromBundled?: boolean;
|
|
}
|
|
|
|
export function getOpenClawStatus(): OpenClawStatus {
|
|
const runtime = resolveOpenClawRuntime();
|
|
const dir = runtime.dir;
|
|
|
|
const status: OpenClawStatus = {
|
|
packageExists: isOpenClawPresent(),
|
|
isBuilt: isOpenClawBuilt(),
|
|
entryPath: getOpenClawEntryPath(),
|
|
dir,
|
|
version: runtime.version,
|
|
source: runtime.source,
|
|
bundledDir: runtime.bundledDir,
|
|
managedDir: runtime.managedDir,
|
|
installedFromBundled: runtime.installedFromBundled,
|
|
};
|
|
|
|
try {
|
|
const { logger } = require('./logger') as typeof import('./logger');
|
|
logger.info('OpenClaw status:', status);
|
|
} catch {
|
|
// Ignore logger bootstrap issues in non-Electron contexts such as unit tests.
|
|
}
|
|
return status;
|
|
}
|