import 'dart:io'; import 'runtime_models.dart'; class RuntimeBootstrapConfig { const RuntimeBootstrapConfig({ required this.workspacePath, required this.remoteProjectRoot, required this.cliPath, required this.localGateway, required this.remoteGateway, }); final String? workspacePath; final String? remoteProjectRoot; final String? cliPath; final GatewayBootstrapTarget? localGateway; final GatewayBootstrapTarget? remoteGateway; static Future load() async { final env = await _loadEnvFile(); final workspaceRoot = _resolveWorkspaceRoot(); final openClawRoot = _resolveOpenClawRoot(workspaceRoot); return RuntimeBootstrapConfig( workspacePath: workspaceRoot?.path, remoteProjectRoot: workspaceRoot?.path, cliPath: _resolveCliPath(openClawRoot), localGateway: GatewayBootstrapTarget.tryParse( env['local'], token: env['local-token'], ), remoteGateway: GatewayBootstrapTarget.tryParse( env['remote'], token: env['remote-token'], ), ); } SettingsSnapshot mergeIntoSettings(SettingsSnapshot snapshot) { var next = snapshot; if (_isDefaultWorkspacePath(snapshot.workspacePath) && workspacePath != null && workspacePath!.trim().isNotEmpty) { next = next.copyWith(workspacePath: workspacePath); } if (_isDefaultRemoteRoot(snapshot.remoteProjectRoot) && remoteProjectRoot != null && remoteProjectRoot!.trim().isNotEmpty) { next = next.copyWith(remoteProjectRoot: 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.local => localGateway ?? remoteGateway, RuntimeConnectionMode.remote => remoteGateway ?? localGateway, RuntimeConnectionMode.unconfigured => remoteGateway ?? localGateway, }; } 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'; static bool _isDefaultCliPath(String value) => value.trim().isEmpty || value.trim() == 'openclaw'; } 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(); final isLocal = host == '127.0.0.1' || host == 'localhost'; return GatewayBootstrapTarget( mode: isLocal ? RuntimeConnectionMode.local : RuntimeConnectionMode.remote, url: trimmed, host: host, port: port, tls: tls, token: token?.trim() ?? '', ); } } Future> _loadEnvFile() async { final candidates = { File('${Directory.current.path}/.env'), ..._ancestorDirectories( Directory.current, ).map((directory) => File('${directory.path}/.env')), }.toList(growable: false); for (final file in candidates) { if (!await file.exists()) { continue; } final values = {}; for (final line in await file.readAsLines()) { 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() { final candidates = [ Directory.current, ..._ancestorDirectories(Directory.current), ]; 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) { 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; }