import 'dart:io'; import 'runtime_models.dart'; class RuntimeBootstrapConfig { const RuntimeBootstrapConfig({ required this.workspacePath, required this.remoteProjectRoot, required this.cliPath, required this.remoteGateway, }); final String? workspacePath; final String? remoteProjectRoot; final String? cliPath; final GatewayBootstrapTarget? remoteGateway; static Future load({ String? workspacePathHint, String? cliPathHint, }) async { final workspaceRoot = _resolveWorkspaceRoot(workspacePathHint); final openClawRoot = _resolveOpenClawRoot( workspaceRoot, cliPathHint: cliPathHint, ); final env = _loadEnvFile( workspacePathHint: workspacePathHint, cliPathHint: cliPathHint, workspaceRoot: workspaceRoot, openClawRoot: openClawRoot, ); return RuntimeBootstrapConfig( workspacePath: workspaceRoot?.path, remoteProjectRoot: workspaceRoot?.path, cliPath: _resolveCliPath(openClawRoot), remoteGateway: GatewayBootstrapTarget.tryParse( env['remote'], token: env['remote-token'], ), ); } SettingsSnapshot mergeIntoSettings(SettingsSnapshot snapshot) { var next = snapshot; final resolvedWorkspacePath = workspacePath?.trim() ?? ''; final resolvedRemoteProjectRoot = remoteProjectRoot?.trim() ?? ''; final replaceWorkspacePath = _isDefaultWorkspacePath(snapshot.workspacePath) || _isMissingTransientWorkspacePath(snapshot.workspacePath); final replaceRemoteProjectRoot = _isDefaultRemoteRoot(snapshot.remoteProjectRoot) || _isMissingTransientWorkspacePath(snapshot.remoteProjectRoot); if (replaceWorkspacePath) { next = next.copyWith( workspacePath: resolvedWorkspacePath.isNotEmpty ? resolvedWorkspacePath : SettingsSnapshot.defaults().workspacePath, ); } if (replaceRemoteProjectRoot) { next = next.copyWith( remoteProjectRoot: resolvedRemoteProjectRoot.isNotEmpty ? resolvedRemoteProjectRoot : (resolvedWorkspacePath.isNotEmpty ? resolvedWorkspacePath : SettingsSnapshot.defaults().remoteProjectRoot), ); } if (_isDefaultCliPath(snapshot.cliPath) && cliPath != null && cliPath!.trim().isNotEmpty) { next = next.copyWith(cliPath: cliPath); } return next; } GatewayBootstrapTarget? preferredGatewayFor(RuntimeConnectionMode mode) { return switch (mode) { RuntimeConnectionMode.remote => remoteGateway, RuntimeConnectionMode.unconfigured => remoteGateway, }; } static bool _isDefaultWorkspacePath(String value) => value.trim().isEmpty || value.trim() == '/opt/data'; static bool _isDefaultRemoteRoot(String value) => value.trim().isEmpty || value.trim() == '/opt/data/workspace' || value.trim() == '/opt/data'; static bool _isDefaultCliPath(String value) => value.trim().isEmpty || value.trim() == 'openclaw'; static bool _isMissingTransientWorkspacePath(String value) { final trimmed = value.trim(); if (trimmed.isEmpty) { return false; } if (_isLikelyTransientPath(trimmed) && FileSystemEntity.typeSync(trimmed) == FileSystemEntityType.notFound) { return true; } return false; } static bool _isLikelyTransientPath(String path) { final normalized = path.trim(); if (normalized.isEmpty) { return false; } final systemTemp = Directory.systemTemp.path; if (normalized == systemTemp || normalized.startsWith('$systemTemp/')) { return true; } if (normalized.startsWith('/tmp/') || normalized.startsWith('/private/tmp/')) { return true; } return false; } } class GatewayBootstrapTarget { const GatewayBootstrapTarget({ required this.mode, required this.url, required this.host, required this.port, required this.tls, required this.token, }); final RuntimeConnectionMode mode; final String url; final String host; final int port; final bool tls; final String token; static GatewayBootstrapTarget? tryParse(String? raw, {String? token}) { final trimmed = raw?.trim() ?? ''; if (trimmed.isEmpty) { return null; } final uri = Uri.tryParse(trimmed); if (uri == null || !uri.hasScheme || (uri.host).trim().isEmpty) { return null; } final scheme = uri.scheme.toLowerCase(); final tls = scheme == 'wss' || scheme == 'https'; final port = uri.hasPort ? uri.port : (tls ? 443 : 18789); final host = uri.host.trim(); return GatewayBootstrapTarget( mode: RuntimeConnectionMode.remote, url: trimmed, host: host, port: port, tls: tls, token: token?.trim() ?? '', ); } } Map _loadEnvFile({ String? workspacePathHint, String? cliPathHint, Directory? workspaceRoot, Directory? openClawRoot, }) { final candidateDirectories = { Directory.current, ..._ancestorDirectories(Directory.current), ..._pathCandidates(workspacePathHint), ..._pathCandidates( cliPathHint == null ? null : File(cliPathHint).parent.path, ), ...?workspaceRoot == null ? null : [workspaceRoot], ...?workspaceRoot == null ? null : _ancestorDirectories(workspaceRoot), ...?openClawRoot == null ? null : [openClawRoot], ...?openClawRoot == null ? null : _ancestorDirectories(openClawRoot), }; final candidates = candidateDirectories .map((directory) => File('${directory.path}/.env')) .toList(growable: false); for (final file in candidates) { if (!file.existsSync()) { continue; } final values = {}; for (final line in file.readAsLinesSync()) { final trimmed = line.trim(); if (trimmed.isEmpty || trimmed.startsWith('#')) { continue; } final separator = trimmed.indexOf(':'); if (separator <= 0) { continue; } final key = trimmed.substring(0, separator).trim(); final value = trimmed.substring(separator + 1).trim(); if (key.isNotEmpty && value.isNotEmpty) { values[key] = value; } } if (values.isNotEmpty) { return values; } } return const {}; } Directory? _resolveWorkspaceRoot(String? workspacePathHint) { final candidates = { ..._pathCandidates(workspacePathHint), Directory.current, ..._ancestorDirectories(Directory.current), }.toList(growable: false); for (final candidate in candidates) { if (File('${candidate.path}/pubspec.yaml').existsSync() && File('${candidate.path}/lib/main.dart').existsSync()) { return candidate; } } return null; } Directory? _resolveOpenClawRoot( Directory? workspaceRoot, { String? cliPathHint, }) { final cliFile = cliPathHint == null ? null : File(cliPathHint); if (cliFile != null && cliFile.existsSync()) { final cliParent = cliFile.parent; if (File('${cliParent.path}/openclaw.mjs').existsSync()) { return cliParent; } } if (workspaceRoot == null) { return null; } final sibling = Directory('${workspaceRoot.parent.path}/openclaw.svc.plus'); if (File('${sibling.path}/openclaw.mjs').existsSync()) { return sibling; } return null; } String? _resolveCliPath(Directory? openClawRoot) { if (openClawRoot == null) { return null; } final candidate = File('${openClawRoot.path}/openclaw.mjs'); if (!candidate.existsSync()) { return null; } return candidate.path; } List _ancestorDirectories(Directory start) { final ancestors = []; var current = start.absolute; while (true) { final parent = current.parent; if (parent.path == current.path) { break; } ancestors.add(parent); current = parent; } return ancestors; } List _pathCandidates(String? rawPath) { final trimmed = rawPath?.trim() ?? ''; if (trimmed.isEmpty) { return const []; } final fileSystemEntityType = FileSystemEntity.typeSync(trimmed); final directory = switch (fileSystemEntityType) { FileSystemEntityType.directory => Directory(trimmed), FileSystemEntityType.file => File(trimmed).parent, _ => Directory(trimmed), }; if (!directory.existsSync()) { return const []; } return [directory, ..._ancestorDirectories(directory)]; }