Files
clawX/electron/utils/paths.ts
2026-04-28 15:04:19 +08:00

460 lines
13 KiB
TypeScript

/**
* Path Utilities
* Cross-platform path resolution helpers
*/
import { createRequire } from 'node:module';
import { createHash } from 'node:crypto';
import { dirname, join } from 'path';
import { homedir } from 'os';
import {
cpSync,
accessSync,
constants,
existsSync,
mkdirSync,
readFileSync,
realpathSync,
readdirSync,
renameSync,
rmSync,
statSync,
writeFileSync,
} from 'fs';
const require = createRequire(import.meta.url);
const OPENCLAW_RUNTIME_MANIFEST = 'clawx-runtime-deps.json';
const OPENCLAW_RUNTIME_READY_MARKER = '.clawx-runtime-ready.json';
const OPENCLAW_RUNTIME_KEEP_COUNT = 2;
let materializedOpenClawDir: string | null = null;
type ElectronAppLike = Pick<typeof import('electron').app, 'isPackaged' | 'getPath' | 'getAppPath'>;
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 toFsPath(filePath: string): string {
if (process.platform !== 'win32') return filePath;
if (!filePath || filePath.startsWith('\\\\?\\')) return filePath;
const windowsPath = filePath.replace(/\//g, '\\');
if (!windowsPath.match(/^[a-zA-Z]:\\/u) && !windowsPath.startsWith('\\\\')) return windowsPath;
if (windowsPath.startsWith('\\\\')) return `\\\\?\\UNC\\${windowsPath.slice(2)}`;
return `\\\\?\\${windowsPath}`;
}
function readJsonFile<T extends Record<string, unknown>>(filePath: string): T | null {
try {
return JSON.parse(readFileSync(toFsPath(filePath), 'utf-8')) as T;
} catch {
return null;
}
}
function hashOpenClawRuntime(sourceDir: string): { key: string; version: string; manifestHash: string } {
const packageJsonPath = join(sourceDir, 'package.json');
const manifestPath = join(sourceDir, OPENCLAW_RUNTIME_MANIFEST);
const packageJsonRaw = existsSync(toFsPath(packageJsonPath)) ? readFileSync(toFsPath(packageJsonPath), 'utf-8') : '{}';
const manifestRaw = existsSync(toFsPath(manifestPath)) ? readFileSync(toFsPath(manifestPath), 'utf-8') : '';
const packageJson = JSON.parse(packageJsonRaw) as { version?: string };
const version = packageJson.version || 'unknown';
const manifestHash = createHash('sha256')
.update(packageJsonRaw)
.update('\0')
.update(manifestRaw)
.digest('hex')
.slice(0, 12);
const safeVersion = version.replace(/[^a-zA-Z0-9._-]/gu, '_');
return {
key: `openclaw-${safeVersion}-${manifestHash}`,
version,
manifestHash,
};
}
function isMaterializedRuntimeReady(targetDir: string, version: string, manifestHash: string): boolean {
const marker = readJsonFile<{ version?: unknown; manifestHash?: unknown }>(
join(targetDir, OPENCLAW_RUNTIME_READY_MARKER),
);
if (marker?.version !== version || marker?.manifestHash !== manifestHash) return false;
return existsSync(toFsPath(join(targetDir, 'openclaw.mjs')))
&& existsSync(toFsPath(join(targetDir, 'package.json')))
&& existsSync(toFsPath(join(targetDir, 'dist')))
&& existsSync(toFsPath(join(targetDir, 'node_modules')));
}
function isPackagedRuntimeReady(targetDir: string): boolean {
const marker = readJsonFile<{ version?: unknown; manifestHash?: unknown }>(
join(targetDir, OPENCLAW_RUNTIME_READY_MARKER),
);
if (typeof marker?.version !== 'string' || typeof marker?.manifestHash !== 'string') return false;
return existsSync(toFsPath(join(targetDir, 'openclaw.mjs')))
&& existsSync(toFsPath(join(targetDir, 'package.json')))
&& existsSync(toFsPath(join(targetDir, 'dist')))
&& existsSync(toFsPath(join(targetDir, 'node_modules')));
}
function isOpenClawRuntimeWritable(runtimeDir: string): boolean {
try {
accessSync(toFsPath(runtimeDir), constants.W_OK);
accessSync(toFsPath(join(runtimeDir, 'dist', 'extensions')), constants.W_OK);
return true;
} catch {
return false;
}
}
function cleanupOldOpenClawRuntimes(runtimeRoot: string, keepKey: string): void {
let entries;
try {
entries = readdirSync(toFsPath(runtimeRoot), { withFileTypes: true })
.filter((entry) => entry.isDirectory() && entry.name.startsWith('openclaw-'))
.map((entry) => {
const fullPath = join(runtimeRoot, entry.name);
let mtimeMs = 0;
try {
mtimeMs = statSync(toFsPath(fullPath)).mtimeMs;
} catch {
// Keep unknown entries until a later cleanup pass.
}
return { name: entry.name, fullPath, mtimeMs };
})
.sort((left, right) => right.mtimeMs - left.mtimeMs);
} catch {
return;
}
const keep = new Set(entries.slice(0, OPENCLAW_RUNTIME_KEEP_COUNT).map((entry) => entry.name));
keep.add(keepKey);
for (const entry of entries) {
if (keep.has(entry.name)) continue;
try {
rmSync(toFsPath(entry.fullPath), { recursive: true, force: true });
} catch {
// Cleanup is best-effort; stale runtimes are harmless.
}
}
}
function getPackagedOpenClawSourceDir(): string {
return join(process.resourcesPath, 'openclaw');
}
function getPackagedOpenClawRuntimeRoot(): string {
return join(process.resourcesPath, 'openclaw-runtime');
}
function findPackagedOpenClawRuntimeDir(): string | null {
const runtimeRoot = getPackagedOpenClawRuntimeRoot();
let entries;
try {
entries = readdirSync(toFsPath(runtimeRoot), { withFileTypes: true });
} catch {
return null;
}
const candidates = entries
.filter((entry) => entry.isDirectory() && entry.name.startsWith('openclaw-'))
.map((entry) => join(runtimeRoot, entry.name))
.filter((candidate) => isPackagedRuntimeReady(candidate))
.sort((left, right) => right.localeCompare(left));
return candidates[0] ?? null;
}
function getPackagedOpenClawFallbackSourceDir(): string {
return findPackagedOpenClawRuntimeDir() ?? getPackagedOpenClawSourceDir();
}
function materializePackagedOpenClawRuntime(): string {
if (materializedOpenClawDir) return materializedOpenClawDir;
const sourceDir = getPackagedOpenClawFallbackSourceDir();
if (!existsSync(toFsPath(sourceDir)) || !existsSync(toFsPath(join(sourceDir, 'package.json')))) {
return sourceDir;
}
const { key, version, manifestHash } = hashOpenClawRuntime(sourceDir);
const runtimeRoot = join(getElectronApp().getPath('userData'), 'openclaw-runtime');
const targetDir = join(runtimeRoot, key);
if (isMaterializedRuntimeReady(targetDir, version, manifestHash)) {
materializedOpenClawDir = targetDir;
cleanupOldOpenClawRuntimes(runtimeRoot, key);
return targetDir;
}
mkdirSync(toFsPath(runtimeRoot), { recursive: true });
const tempDir = join(runtimeRoot, `.tmp-${key}-${process.pid}-${Date.now()}`);
rmSync(toFsPath(tempDir), { recursive: true, force: true });
try {
cpSync(toFsPath(sourceDir), toFsPath(tempDir), {
recursive: true,
dereference: true,
force: true,
});
writeFileSync(
toFsPath(join(tempDir, OPENCLAW_RUNTIME_READY_MARKER)),
`${JSON.stringify({
version,
manifestHash,
source: sourceDir,
createdAt: new Date().toISOString(),
}, null, 2)}\n`,
'utf-8',
);
rmSync(toFsPath(targetDir), { recursive: true, force: true });
renameSync(toFsPath(tempDir), toFsPath(targetDir));
} catch (error) {
rmSync(toFsPath(tempDir), { recursive: true, force: true });
throw error;
}
materializedOpenClawDir = targetDir;
cleanupOldOpenClawRuntimes(runtimeRoot, key);
return targetDir;
}
/**
* 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
* - Production (packaged): from resources/openclaw-runtime/openclaw-* (staged by afterPack)
* - Development: from node_modules/openclaw
*/
export function getOpenClawDir(): string {
if (getElectronApp().isPackaged) {
return getPackagedOpenClawFallbackSourceDir();
}
// Development: use node_modules/openclaw
return join(__dirname, '../../node_modules/openclaw');
}
/**
* Get the OpenClaw runtime directory used for spawned OpenClaw processes.
*
* In packaged builds, this prefers the afterPack-staged resources/openclaw-runtime
* directory. If the app was installed somewhere non-writable, it falls back to a
* userData copy so OpenClaw's runtime-deps checks can still create lock files.
*/
export function getOpenClawRuntimeDir(): string {
if (getElectronApp().isPackaged) {
const packagedRuntimeDir = findPackagedOpenClawRuntimeDir();
if (packagedRuntimeDir && isOpenClawRuntimeWritable(packagedRuntimeDir)) {
return packagedRuntimeDir;
}
return materializePackagedOpenClawRuntime();
}
return getOpenClawDir();
}
/**
* 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 OpenClaw entry script path for spawned OpenClaw processes.
*/
export function getOpenClawRuntimeEntryPath(): string {
return join(getOpenClawRuntimeDir(), 'openclaw.mjs');
}
/**
* Get the external runtime dependency staging directory for OpenClaw.
*
* In packaged mode this intentionally points at the parent of the materialized
* runtime directory. OpenClaw recognizes an existing openclaw-* package root
* under OPENCLAW_PLUGIN_STAGE_DIR and reuses its node_modules instead of
* running npm install into ~/.openclaw/plugin-runtime-deps.
*/
export function getOpenClawPluginStageDir(openclawDir = getOpenClawRuntimeDir()): string | null {
if (!getElectronApp().isPackaged) return null;
return dirname(openclawDir);
}
/**
* 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;
}
export function getOpenClawStatus(): OpenClawStatus {
const dir = getOpenClawDir();
let version: string | undefined;
// Try to read version from package.json
try {
const pkgPath = join(dir, 'package.json');
if (existsSync(pkgPath)) {
const pkg = JSON.parse(readFileSync(pkgPath, 'utf-8'));
version = pkg.version;
}
} catch {
// Ignore version read errors
}
const status: OpenClawStatus = {
packageExists: isOpenClawPresent(),
isBuilt: isOpenClawBuilt(),
entryPath: getOpenClawEntryPath(),
dir,
version,
};
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;
}