diff --git a/lib/features/workspace_management/playbook_runner.dart b/lib/features/workspace_management/playbook_runner.dart index b1a19fa3..f879e37f 100644 --- a/lib/features/workspace_management/playbook_runner.dart +++ b/lib/features/workspace_management/playbook_runner.dart @@ -1,5 +1,3 @@ -import 'dart:async'; - import '../../i18n/app_language.dart'; import 'server_detector.dart'; import 'ssh_executor.dart'; @@ -8,9 +6,8 @@ import 'workspace_provision_models.dart'; class PlaybookRunner { const PlaybookRunner(this.executor); - static const String playbookRepoUrl = 'https://github.com/x-evor/playbooks.git'; - static const String createPlaybook = 'setup-ai-workspace-all-in-one.yml'; - static const String upgradePlaybook = 'upgrade-ai-workspace.yml'; + static const String setupScriptUrl = + 'https://raw.githubusercontent.com/ai-workspace-lab/xworkspace-console/main/scripts/setup-ai-workspace-all-in-one.sh'; final WorkspaceSshExecutor executor; @@ -20,74 +17,44 @@ class PlaybookRunner { required String workspaceDomain, required String bridgeDomain, required String bridgeToken, - required String installPath, - required bool installMissingPrerequisites, required ServerInfo? serverInfo, List? extraConfigs, required void Function(String stepId, StepStatus status, String? message) onStepUpdate, required void Function(String logLine) onLog, }) async { - if (action == 'upgrade') { - throw PlaybookRunException( - appText( - 'playbooks 仓库尚未提供 $upgradePlaybook。', - 'The playbooks repository does not provide $upgradePlaybook yet.', - ), - ); - } - var info = serverInfo; if (info == null) { onStepUpdate('ssh_connect', StepStatus.running, null); - info = await ServerDetector(executor).detect( - ssh, - workspaceDomain, - bridgeDomain, - ); + info = await ServerDetector( + executor, + ).detect(ssh, workspaceDomain, bridgeDomain); onStepUpdate('ssh_connect', StepStatus.success, null); onStepUpdate('detect_env', StepStatus.success, info.displaySummary); } - if (info.hasMissingPrerequisites) { - if (!installMissingPrerequisites) { - throw PlaybookRunException( - appText( - '目标服务器缺少 git 或 ansible。', - 'The target server is missing git or ansible.', - ), - ); - } - onStepUpdate('install_deps', StepStatus.running, appText('安装 git/ansible', 'Installing git/ansible')); - await _executeChecked(ssh, _preflightInstallCommand(ssh), onLog); - onStepUpdate('install_deps', StepStatus.success, appText('基础依赖已安装', 'Base dependencies installed')); - } + onStepUpdate( + 'install_deps', + StepStatus.running, + appText('检查 curl', 'Checking curl'), + ); + await _executeChecked(ssh, _preflightInstallCommand(ssh), onLog); + onStepUpdate( + 'install_deps', + StepStatus.success, + appText('curl 已就绪', 'curl is ready'), + ); - onStepUpdate('install_deps', StepStatus.running, appText('拉取 playbooks', 'Fetching playbooks')); - await _executeChecked(ssh, _cloneOrPullCommand(installPath), onLog); - - final inventoryPath = '/tmp/xworkspace-inventory.ini'; - final varsPath = '/tmp/xworkspace-vars.yml'; - await _executeChecked( - ssh, - _writeInventoryAndVarsCommand( - inventoryPath: inventoryPath, - varsPath: varsPath, + await _runSetupScript( + ssh: ssh, + command: setupScriptCommand( + ssh: ssh, + action: action, workspaceDomain: workspaceDomain, bridgeDomain: bridgeDomain, bridgeToken: bridgeToken, extraConfigs: extraConfigs, ), - onLog, - ); - - await _runAnsible( - ssh: ssh, - command: _ansibleCommand( - installPath: installPath, - inventoryPath: inventoryPath, - varsPath: varsPath, - ), onStepUpdate: onStepUpdate, onLog: onLog, ); @@ -109,15 +76,20 @@ class PlaybookRunner { } } - Future _runAnsible({ + Future _runSetupScript({ required SshConfig ssh, required String command, required void Function(String stepId, StepStatus status, String? message) onStepUpdate, required void Function(String logLine) onLog, }) async { - final parser = AnsibleOutputParser(); + final parser = SetupScriptOutputParser(); var failed = false; + onStepUpdate( + 'deploy_webrtc', + StepStatus.running, + appText('执行远程安装脚本', 'Running remote setup script'), + ); await for (final chunk in executor.executeStreaming(ssh, command)) { for (final raw in chunk.split(RegExp(r'\r?\n'))) { final line = raw.trimRight(); @@ -136,7 +108,9 @@ class PlaybookRunner { } } if (failed) { - throw PlaybookRunException(appText('Playbook 执行失败。', 'Playbook execution failed.')); + throw PlaybookRunException( + appText('远程安装脚本执行失败。', 'Remote setup script failed.'), + ); } for (final id in [ 'install_deps', @@ -151,29 +125,24 @@ class PlaybookRunner { } static String _preflightInstallCommand(SshConfig ssh) { - final apt = 'DEBIAN_FRONTEND=noninteractive apt-get update && ' - 'DEBIAN_FRONTEND=noninteractive apt-get install -y git ansible'; + final apt = + 'DEBIAN_FRONTEND=noninteractive apt-get update && ' + 'DEBIAN_FRONTEND=noninteractive apt-get install -y curl ca-certificates'; if (ssh.username == 'root') { - return apt; + return 'if command -v curl >/dev/null 2>&1; then echo curl ready; else $apt; fi'; } final sudoPassword = ssh.sudoPassword?.trim(); if (sudoPassword != null && sudoPassword.isNotEmpty) { - return "printf '%s\\n' ${shellQuote(sudoPassword)} | sudo -S sh -lc ${shellQuote(apt)}"; + return 'if command -v curl >/dev/null 2>&1; then echo curl ready; ' + "else printf '%s\\n' ${shellQuote(sudoPassword)} | sudo -S sh -lc ${shellQuote(apt)}; fi"; } - return 'sudo -n sh -lc ${shellQuote(apt)}'; + return 'if command -v curl >/dev/null 2>&1; then echo curl ready; ' + 'else sudo -n sh -lc ${shellQuote(apt)}; fi'; } - static String _cloneOrPullCommand(String installPath) { - final path = shellQuote(installPath.trim()); - final repo = shellQuote(playbookRepoUrl); - return 'mkdir -p $path && cd $path && ' - 'if [ -d .git ]; then git pull --ff-only origin main; ' - 'else git clone $repo .; fi'; - } - - static String _writeInventoryAndVarsCommand({ - required String inventoryPath, - required String varsPath, + static String setupScriptCommand({ + required SshConfig ssh, + required String action, required String workspaceDomain, required String bridgeDomain, required String bridgeToken, @@ -182,91 +151,96 @@ class PlaybookRunner { final domain = workspaceDomain.trim(); final bridge = bridgeDomain.trim(); final bridgeUrl = 'https://$bridge'; - final extraEnvVars = []; + final env = { + 'XWORKSPACE_SETUP_ACTION': action.trim().isEmpty + ? 'create' + : action.trim(), + 'WORKSPACE_DOMAIN': domain, + 'XWORKSPACE_CONSOLE_DOMAIN': domain, + 'XWORKMATE_BRIDGE_DOMAIN': bridge, + 'XWORKMATE_BRIDGE_PUBLIC_BASE_URL': bridgeUrl, + 'XWORKMATE_BRIDGE_SERVICE_DOMAIN': bridge, + 'XWORKMATE_BRIDGE_SERVICE_PUBLIC_BASE_URL': bridgeUrl, + 'XWORKMATE_BRIDGE_AUTH_TOKEN': bridgeToken.trim(), + 'TOKEN': bridgeToken.trim(), + }; for (final config in extraConfigs ?? const []) { final key = config.key.trim(); final value = config.value.trim(); if (key.isEmpty || value.isEmpty) { continue; } - extraEnvVars.add('$key: ${shellQuote(value)}'); - final note = config.note.trim(); - if (note.isNotEmpty) { - extraEnvVars.add('# ${note.length > 20 ? note.substring(0, 20) : note}'); - } + env[key] = value; } - final extraEnvBlock = - extraEnvVars.isEmpty ? '' : '${extraEnvVars.join('\n')}\n'; - return ''' -cat > ${shellQuote(inventoryPath)} <<'EOF' -[all] -localhost ansible_connection=local -EOF -cat > ${shellQuote(varsPath)} <<'EOF' -workspace_domain: $domain -xworkmate_bridge_domain: $bridge -xworkmate_bridge_public_base_url: $bridgeUrl -xworkmate_bridge_service_domain: $bridge -xworkmate_bridge_service_public_base_url: $bridgeUrl -xworkmate_bridge_auth_token: ${bridgeToken.trim()} -${extraEnvBlock}EOF -'''; + final envArgs = env.entries + .where((entry) => _isValidEnvKey(entry.key)) + .map((entry) => '${entry.key}=${shellQuote(entry.value)}') + .join(' '); + final script = + 'set -o pipefail; curl -sfL ${shellQuote(setupScriptUrl)} | bash -'; + final command = 'env $envArgs bash -lc ${shellQuote(script)} 2>&1'; + if (ssh.username == 'root') { + return command; + } + final sudoPassword = ssh.sudoPassword?.trim(); + if (sudoPassword != null && sudoPassword.isNotEmpty) { + return "printf '%s\\n' ${shellQuote(sudoPassword)} | sudo -S $command"; + } + return 'sudo -n $command'; } - static String _ansibleCommand({ - required String installPath, - required String inventoryPath, - required String varsPath, - }) { - return 'cd ${shellQuote(installPath.trim())} && ' - 'ANSIBLE_FORCE_COLOR=0 ansible-playbook ' - '-i ${shellQuote(inventoryPath)} ' - '${shellQuote(createPlaybook)} ' - '-e @${shellQuote(varsPath)} 2>&1'; + static bool _isValidEnvKey(String key) { + return RegExp(r'^[A-Za-z_][A-Za-z0-9_]*$').hasMatch(key); } } -class AnsibleStepEvent { - const AnsibleStepEvent(this.stepId, this.status, this.message); +class SetupScriptStepEvent { + const SetupScriptStepEvent(this.stepId, this.status, this.message); final String stepId; final StepStatus status; final String? message; } -class AnsibleOutputParser { - String? _currentStepId; - String? _currentTask; - - AnsibleStepEvent? parseLine(String line) { - final taskMatch = RegExp(r'^TASK \[(.+?)\]').firstMatch(line); - if (taskMatch != null) { - _currentTask = taskMatch.group(1); - _currentStepId = stepIdForTask(_currentTask ?? ''); - return AnsibleStepEvent(_currentStepId!, StepStatus.running, _currentTask); - } - if (_currentStepId == null) { - return null; - } +class SetupScriptOutputParser { + SetupScriptStepEvent? parseLine(String line) { final lower = line.toLowerCase(); - if (lower.startsWith('fatal:') || lower.contains(' failed=')) { - return AnsibleStepEvent(_currentStepId!, StepStatus.failed, line); + if (lower.contains('error') || + lower.contains('failed') || + lower.contains('fatal')) { + return SetupScriptStepEvent( + stepIdForText(lower), + StepStatus.failed, + line, + ); } - if (lower.startsWith('ok:') || lower.startsWith('changed:')) { - return AnsibleStepEvent(_currentStepId!, StepStatus.success, _currentTask); + final stepId = stepIdForText(lower); + if (lower.contains('install') || + lower.contains('deploy') || + lower.contains('config') || + lower.contains('start') || + lower.contains('enable') || + lower.contains('caddy') || + lower.contains('gateway') || + lower.contains('bridge') || + lower.contains('xworkspace')) { + return SetupScriptStepEvent(stepId, StepStatus.running, line); } - if (lower.startsWith('skipping:')) { - return AnsibleStepEvent(_currentStepId!, StepStatus.skipped, _currentTask); + if (lower.contains('done') || + lower.contains('success') || + lower.contains('complete')) { + return SetupScriptStepEvent(stepId, StepStatus.success, line); } return null; } - static String stepIdForTask(String task) { - final text = task.toLowerCase(); + static String stepIdForText(String text) { if (text.contains('bridge') || text.contains('acp_server')) { return 'deploy_bridge'; } - if (text.contains('caddy') || text.contains('tls') || text.contains('cert')) { + if (text.contains('caddy') || + text.contains('tls') || + text.contains('cert')) { return 'config_caddy'; } if (text.contains('gateway') || text.contains('openclaw')) { diff --git a/lib/features/workspace_management/workspace_management_form.dart b/lib/features/workspace_management/workspace_management_form.dart index 7f87bb58..8a905bc2 100644 --- a/lib/features/workspace_management/workspace_management_form.dart +++ b/lib/features/workspace_management/workspace_management_form.dart @@ -1,6 +1,7 @@ import 'package:flutter/material.dart'; import '../../i18n/app_language.dart'; +import 'playbook_runner.dart'; import 'workspace_provision_controller.dart'; import 'workspace_provision_models.dart'; @@ -10,11 +11,13 @@ class WorkspaceManagementForm extends StatefulWidget { required this.controller, required this.onDetect, required this.onCreate, + required this.onUpgrade, }); final WorkspaceProvisionController controller; final VoidCallback onDetect; final VoidCallback onCreate; + final VoidCallback onUpgrade; @override State createState() => @@ -47,7 +50,9 @@ class _WorkspaceManagementFormState extends State { _keyPathController = TextEditingController(text: c.sshKeyPath ?? ''); _portController = TextEditingController(text: c.sshPort.toString()); _sudoController = TextEditingController(text: c.sudoPassword ?? ''); - _installPathController = TextEditingController(text: c.installPath); + _installPathController = TextEditingController( + text: PlaybookRunner.setupScriptUrl, + ); for (final row in c.extraConfigs) { _extraRows.add( _ExtraRowControllers( @@ -73,7 +78,7 @@ class _WorkspaceManagementFormState extends State { _syncText(_keyPathController, widget.controller.sshKeyPath ?? ''); _syncText(_portController, widget.controller.sshPort.toString()); _syncText(_sudoController, widget.controller.sudoPassword ?? ''); - _syncText(_installPathController, widget.controller.installPath); + _syncText(_installPathController, PlaybookRunner.setupScriptUrl); _syncExtraRows(widget.controller.extraConfigs); } finally { _syncingFromController = false; @@ -145,9 +150,7 @@ class _WorkspaceManagementFormState extends State { sshKeyPath: _keyPathController.text.trim(), sshPort: int.tryParse(_portController.text.trim()) ?? 22, sudoPassword: _sudoController.text, - installPath: _installPathController.text.trim().isEmpty - ? '/opt/xworkspace/playbooks' - : _installPathController.text.trim(), + installPath: '', extraConfigs: _extraRows .map( (row) => WorkspaceExtraConfig( @@ -311,8 +314,8 @@ class _WorkspaceManagementFormState extends State { _field( width: 320, controller: _installPathController, - enabled: !disabled, - label: appText('安装路径', 'Install path'), + enabled: false, + label: appText('执行脚本', 'Setup script'), icon: Icons.storage_outlined, ), _ExtraConfigEditor( @@ -367,17 +370,16 @@ class _WorkspaceManagementFormState extends State { icon: const Icon(Icons.rocket_launch_outlined), label: Text(appText('创建工作空间', 'Create workspace')), ), - Tooltip( - message: appText( - '等待 playbooks 仓库提供 upgrade-ai-workspace.yml 后启用', - 'Enabled after playbooks provides upgrade-ai-workspace.yml', - ), - child: FilledButton.tonalIcon( - key: const Key('workspace-management-upgrade-button'), - onPressed: null, - icon: const Icon(Icons.system_update_alt_outlined), - label: Text(appText('升级工作空间', 'Upgrade workspace')), - ), + FilledButton.tonalIcon( + key: const Key('workspace-management-upgrade-button'), + onPressed: disabled + ? null + : () { + _sync(); + widget.onUpgrade(); + }, + icon: const Icon(Icons.system_update_alt_outlined), + label: Text(appText('升级工作空间', 'Upgrade workspace')), ), ], ), @@ -438,7 +440,7 @@ class _ExtraConfigEditor extends StatelessWidget { Row( children: [ Text( - appText('额外配置', 'Extra configs'), + appText('脚本参数', 'Script parameters'), style: theme.textTheme.titleSmall?.copyWith( fontWeight: FontWeight.w700, ), @@ -569,7 +571,9 @@ class _ExtraRowControllers { bool get isSensitiveKey { final key = keyController.text.trim().toUpperCase(); - return key.contains('KEY') || key.contains('TOKEN') || key.contains('SECRET'); + return key.contains('KEY') || + key.contains('TOKEN') || + key.contains('SECRET'); } void dispose() { diff --git a/lib/features/workspace_management/workspace_management_panel.dart b/lib/features/workspace_management/workspace_management_panel.dart index b5540a25..18fc8507 100644 --- a/lib/features/workspace_management/workspace_management_panel.dart +++ b/lib/features/workspace_management/workspace_management_panel.dart @@ -62,7 +62,9 @@ class _WorkspaceManagementPanelState extends State { final connection = widget.appController.connection; if (connection.status == RuntimeConnectionStatus.connected) { final remote = connection.remoteAddress?.trim() ?? ''; - final parsed = Uri.tryParse(remote.contains('://') ? remote : 'https://$remote'); + final parsed = Uri.tryParse( + remote.contains('://') ? remote : 'https://$remote', + ); if (parsed != null && parsed.host.trim().isNotEmpty) { return parsed.host.trim(); } @@ -104,7 +106,45 @@ class _WorkspaceManagementPanelState extends State { ), ); if (confirmed == true) { - unawaited(_controller.createWorkspace(installMissingPrerequisites: true)); + unawaited(_controller.createWorkspace()); + } + } + + Future _confirmUpgrade() async { + if (!_controller.canSubmit) { + await _controller.upgradeWorkspace(); + return; + } + final confirmed = await showDialog( + context: context, + builder: (context) => AlertDialog( + title: Text(appText('确认升级工作空间', 'Confirm workspace upgrade')), + content: Text( + appText( + '即将在 ${_controller.serverAddress} 上执行远程 all-in-one 脚本升级 AI 工作空间。\n\n' + '域名: ${_controller.workspaceDomain}\n' + 'SSH 用户: ${_controller.sshUsername}\n\n' + '该操作会通过 curl 下载脚本并在远程主机执行,请确认这是你自己的服务器。', + 'XWorkmate will run the remote all-in-one script to upgrade the AI Workspace on ${_controller.serverAddress}.\n\n' + 'Domain: ${_controller.workspaceDomain}\n' + 'SSH user: ${_controller.sshUsername}\n\n' + 'This downloads the script with curl and runs it on the remote host. Confirm this is your own server.', + ), + ), + actions: [ + TextButton( + onPressed: () => Navigator.of(context).pop(false), + child: Text(appText('取消', 'Cancel')), + ), + FilledButton( + onPressed: () => Navigator.of(context).pop(true), + child: Text(appText('确认升级', 'Upgrade')), + ), + ], + ), + ); + if (confirmed == true) { + unawaited(_controller.upgradeWorkspace()); } } @@ -234,8 +274,10 @@ class _WorkspaceManagementPanelState extends State { children: [ WorkspaceManagementForm( controller: _controller, - onDetect: () => unawaited(_controller.detectServer()), + onDetect: () => + unawaited(_controller.detectServer()), onCreate: () => unawaited(_confirmCreate()), + onUpgrade: () => unawaited(_confirmUpgrade()), ), const SizedBox(height: 20), WorkspaceManagementSteps(steps: _controller.steps), @@ -275,9 +317,8 @@ class _LogPanel extends StatelessWidget { children: [ TextButton.icon( key: const Key('workspace-management-log-toggle'), - onPressed: () => controller.updateForm( - logsExpanded: !controller.logsExpanded, - ), + onPressed: () => + controller.updateForm(logsExpanded: !controller.logsExpanded), icon: Icon( controller.logsExpanded ? Icons.expand_less : Icons.expand_more, ), diff --git a/lib/features/workspace_management/workspace_provision_controller.dart b/lib/features/workspace_management/workspace_provision_controller.dart index b2a11728..cae871d7 100644 --- a/lib/features/workspace_management/workspace_provision_controller.dart +++ b/lib/features/workspace_management/workspace_provision_controller.dart @@ -32,12 +32,40 @@ class WorkspaceProvisionController extends ChangeNotifier { String? sshKeyPath; int sshPort = 22; String? sudoPassword; - String installPath = '/opt/xworkspace/playbooks'; + String installPath = ''; final List extraConfigs = [ - WorkspaceExtraConfig(key: 'DEEPSEEK_API_KEY', value: '', note: ''), - WorkspaceExtraConfig(key: 'NVIDIA_API_KEY', value: '', note: ''), - WorkspaceExtraConfig(key: 'OLLAMA_API_KEY', value: '', note: ''), - WorkspaceExtraConfig(key: 'OPENCLAW_GATEWAY_TOKEN', value: '', note: ''), + WorkspaceExtraConfig( + key: 'AI_WORKSPACE_SECURITY_LEVEL', + value: 'strict', + note: '', + ), + WorkspaceExtraConfig( + key: 'XWORKSPACE_CONSOLE_ENABLE_XRDP', + value: 'true', + note: '', + ), + WorkspaceExtraConfig( + key: 'XWORKSPACE_CONSOLE_PUBLIC_ACCESS', + value: 'true', + note: '', + ), + WorkspaceExtraConfig( + key: 'XWORKMATE_BRIDGE_PUBLIC_ACCESS', + value: 'true', + note: '', + ), + WorkspaceExtraConfig( + key: 'GATEWAY_OPENCLAW_PUBLIC_ACCESS', + value: 'false', + note: '', + ), + WorkspaceExtraConfig(key: 'VAULT_PUBLIC_ACCESS', value: 'false', note: ''), + WorkspaceExtraConfig( + key: 'LITELLM_API_CADDY_STRICT_WHITELIST', + value: 'true', + note: '', + ), + WorkspaceExtraConfig(key: 'TOKEN', value: '', note: ''), ]; bool showAdvanced = false; bool logsExpanded = false; @@ -96,11 +124,9 @@ class WorkspaceProvisionController extends ChangeNotifier { _prepareRun(ProvisionPhase.checking); _setStep('ssh_connect', StepStatus.running, null); try { - final detected = await ServerDetector(executor).detect( - sshConfig(), - workspaceDomain.trim(), - bridgeDomain, - ); + final detected = await ServerDetector( + executor, + ).detect(sshConfig(), workspaceDomain.trim(), bridgeDomain); serverInfo = detected; _setStep('ssh_connect', StepStatus.success, null); final blockingIssue = validatePrecheckBlockingIssueFor(detected); @@ -122,7 +148,7 @@ class WorkspaceProvisionController extends ChangeNotifier { } } - Future createWorkspace({bool installMissingPrerequisites = false}) async { + Future createWorkspace() async { if (!canSubmit) { _fail(WorkspaceProvisionValidationException()); return; @@ -130,11 +156,9 @@ class WorkspaceProvisionController extends ChangeNotifier { _prepareRun(ProvisionPhase.running, keepDetection: true); try { if (serverInfo == null) { - final detected = await ServerDetector(executor).detect( - sshConfig(), - workspaceDomain.trim(), - bridgeDomain, - ); + final detected = await ServerDetector( + executor, + ).detect(sshConfig(), workspaceDomain.trim(), bridgeDomain); serverInfo = detected; } final blockingIssue = validatePrecheckBlockingIssue(); @@ -154,15 +178,14 @@ class WorkspaceProvisionController extends ChangeNotifier { bridgeDomain: bridgeDomain, bridgeToken: bridgeToken, extraConfigs: extraConfigs, - installPath: installPath.trim(), - installMissingPrerequisites: installMissingPrerequisites, serverInfo: serverInfo, onStepUpdate: _setStep, onLog: _appendLog, ); await _verifyDeploymentReadiness(); for (final step in steps) { - if (step.status == StepStatus.pending || step.status == StepStatus.running) { + if (step.status == StepStatus.pending || + step.status == StepStatus.running) { _setStep(step.id, StepStatus.success, null); } } @@ -180,14 +203,57 @@ class WorkspaceProvisionController extends ChangeNotifier { } Future upgradeWorkspace() async { - _fail( - PlaybookRunException( - appText( - '升级功能等待 playbooks 仓库提供 upgrade-ai-workspace.yml 后启用。', - 'Upgrade waits for upgrade-ai-workspace.yml in the playbooks repository.', - ), - ), - ); + if (!canSubmit) { + _fail(WorkspaceProvisionValidationException()); + return; + } + _prepareRun(ProvisionPhase.running, keepDetection: true); + try { + if (serverInfo == null) { + final detected = await ServerDetector( + executor, + ).detect(sshConfig(), workspaceDomain.trim(), bridgeDomain); + serverInfo = detected; + } + final blockingIssue = validatePrecheckBlockingIssue(); + if (blockingIssue != null) { + _setStep('detect_env', StepStatus.failed, blockingIssue); + throw WorkspaceProvisionPrecheckException(blockingIssue); + } + if (serverInfo != null) { + _setStep('ssh_connect', StepStatus.success, null); + _setStep('detect_env', StepStatus.success, serverInfo!.displaySummary); + } + final bridgeToken = ensureBridgeToken(); + await PlaybookRunner(executor).run( + ssh: sshConfig(), + action: 'upgrade', + workspaceDomain: workspaceDomain.trim(), + bridgeDomain: bridgeDomain, + bridgeToken: bridgeToken, + extraConfigs: extraConfigs, + serverInfo: serverInfo, + onStepUpdate: _setStep, + onLog: _appendLog, + ); + await _verifyDeploymentReadiness(); + for (final step in steps) { + if (step.status == StepStatus.pending || + step.status == StepStatus.running) { + _setStep(step.id, StepStatus.success, null); + } + } + phase = ProvisionPhase.success; + deploymentResult = WorkspaceDeploymentResult( + url: bridgeBaseUrl, + bridgeToken: bridgeToken, + ); + errorMessage = null; + _appendLog(appText('工作空间升级完成。', 'Workspace upgrade completed.')); + notifyListeners(); + } catch (error) { + _fail(error); + } } void reset() { @@ -243,7 +309,7 @@ class WorkspaceProvisionController extends ChangeNotifier { MapEntry('ssh_username', sshUsername.trim()), MapEntry('auth_method', authMethod.name), MapEntry('ssh_port', sshPort), - MapEntry('install_path', installPath.trim()), + MapEntry('setup_script_url', PlaybookRunner.setupScriptUrl), MapEntry('show_advanced', showAdvanced), MapEntry('logs_expanded', logsExpanded), MapEntry('ssh_password', redact(sshPassword)), @@ -421,8 +487,12 @@ class WorkspaceProvisionController extends ChangeNotifier { for (final service in services) { final unit = shellQuote('$service.service'); final envKey = service.toUpperCase().replaceAll('-', '_'); - buffer.writeln('if systemctl list-unit-files $unit >/dev/null 2>&1 || systemctl status $unit >/dev/null 2>&1; then'); - buffer.writeln(' STATE=\$(systemctl is-active $unit 2>/dev/null || echo inactive)'); + buffer.writeln( + 'if systemctl list-unit-files $unit >/dev/null 2>&1 || systemctl status $unit >/dev/null 2>&1; then', + ); + buffer.writeln( + ' STATE=\$(systemctl is-active $unit 2>/dev/null || echo inactive)', + ); buffer.writeln(' echo SERVICE_$envKey=\$STATE'); buffer.writeln('fi'); } @@ -493,7 +563,10 @@ class WorkspaceProvisionController extends ChangeNotifier { if (text.isEmpty) { return '""'; } - if (text == redactedValue || text.contains(RegExp(r'[:#\n\r\t]')) || text.startsWith(' ') || text.endsWith(' ')) { + if (text == redactedValue || + text.contains(RegExp(r'[:#\n\r\t]')) || + text.startsWith(' ') || + text.endsWith(' ')) { return '"${text.replaceAll('"', '\\"')}"'; } return text; @@ -544,9 +617,7 @@ class WorkspaceProvisionController extends ChangeNotifier { Object? value, List current, ) { - final existing = { - for (final config in current) config.key.trim(): config, - }; + final existing = {for (final config in current) config.key.trim(): config}; final parsed = []; if (value is YamlList) { for (final item in value) { diff --git a/test/features/workspace_management/workspace_management_unit_test.dart b/test/features/workspace_management/workspace_management_unit_test.dart index 70d52a32..ac2ad322 100644 --- a/test/features/workspace_management/workspace_management_unit_test.dart +++ b/test/features/workspace_management/workspace_management_unit_test.dart @@ -78,16 +78,37 @@ BRIDGE_PORT_443_OPEN=yes expect(info.displaySummary, contains('桥接 443 端口当前空闲')); }); - test('ansible parser maps human readable output to step events', () { - final parser = AnsibleOutputParser(); + test('setup script command passes env to remote bash', () { + final command = PlaybookRunner.setupScriptCommand( + ssh: const SshConfig( + host: '203.0.113.10', + port: 22, + username: 'root', + authMethod: AuthMethod.sshKey, + ), + action: 'create', + workspaceDomain: 'workspace.example.com', + bridgeDomain: 'xworkmate-bridge.workspace.example.com', + bridgeToken: "tok'en", + extraConfigs: [ + WorkspaceExtraConfig( + key: 'AI_WORKSPACE_SECURITY_LEVEL', + value: 'strict', + ), + WorkspaceExtraConfig( + key: 'XWORKSPACE_CONSOLE_ENABLE_XRDP', + value: 'true', + ), + ], + ); - final start = parser.parseLine('TASK [Configure caddy TLS]'); - final ok = parser.parseLine('changed: [localhost]'); - - expect(start?.stepId, 'config_caddy'); - expect(start?.status, StepStatus.running); - expect(ok?.stepId, 'config_caddy'); - expect(ok?.status, StepStatus.success); + expect(command, contains('curl -sfL')); + expect(command, contains(PlaybookRunner.setupScriptUrl)); + expect(command, contains('AI_WORKSPACE_SECURITY_LEVEL=')); + expect(command, contains('XWORKSPACE_CONSOLE_ENABLE_XRDP=')); + expect(command, contains('TOKEN=')); + expect(command, contains("'tok'\"'\"'en'")); + expect(command, contains('bash -lc')); }); test('detection command quotes workspace domain', () { @@ -103,7 +124,9 @@ BRIDGE_PORT_443_OPEN=yes test('bridge domain uses user input when already a bridge host', () { expect( - WorkspaceProvisionController.deriveBridgeDomain('acp-bridge.onwalk.net'), + WorkspaceProvisionController.deriveBridgeDomain( + 'acp-bridge.onwalk.net', + ), 'acp-bridge.onwalk.net', ); }); @@ -190,11 +213,10 @@ BRIDGE_PORT_443_OPEN=yes ); }); - test('createWorkspace runs playbook flow with fake SSH', () async { + test('createWorkspace runs remote setup script with fake SSH', () async { final executor = _FakeSshExecutor( commandResults: [ - const SshResult(exitCode: 0, stdout: 'pulled', stderr: ''), - const SshResult(exitCode: 0, stdout: 'wrote', stderr: ''), + const SshResult(exitCode: 0, stdout: 'curl ready', stderr: ''), const SshResult( exitCode: 0, stdout: ''' @@ -207,8 +229,8 @@ SERVICE_HERMES_GATEWAY=active ), ], streamingChunks: [ - 'TASK [Install desktop packages]\nok: [localhost]\n', - 'TASK [Configure caddy TLS]\nchanged: [localhost]\n', + 'Installing desktop packages\n', + 'Configuring caddy TLS\n', ], ); final controller = WorkspaceProvisionController( @@ -250,11 +272,21 @@ SERVICE_HERMES_GATEWAY=active 'https://xworkmate-bridge.workspace.example.com', ); expect(controller.deploymentResult?.bridgeToken, isNotEmpty); - expect(executor.commands.join('\n'), contains('ansible-playbook')); + expect(executor.commands.join('\n'), contains('curl -sfL')); + expect( + executor.commands.join('\n'), + contains('setup-ai-workspace-all-in-one.sh'), + ); + expect( + executor.commands.join('\n'), + contains('AI_WORKSPACE_SECURITY_LEVEL='), + ); }); test('precheck does not block when 443 is not open', () async { - final controller = WorkspaceProvisionController(executor: _FakeSshExecutor()); + final controller = WorkspaceProvisionController( + executor: _FakeSshExecutor(), + ); addTearDown(controller.dispose); controller.updateForm( serverAddress: '203.0.113.10', @@ -286,7 +318,9 @@ SERVICE_HERMES_GATEWAY=active }); test('precheck blocks when bridge DNS is missing', () async { - final controller = WorkspaceProvisionController(executor: _FakeSshExecutor()); + final controller = WorkspaceProvisionController( + executor: _FakeSshExecutor(), + ); addTearDown(controller.dispose); controller.updateForm( serverAddress: '203.0.113.10', @@ -314,14 +348,13 @@ SERVICE_HERMES_GATEWAY=active bridgePort443Open: true, ); - expect( - controller.validatePrecheckBlockingIssue(), - contains('A 记录'), - ); + expect(controller.validatePrecheckBlockingIssue(), contains('A 记录')); }); test('precheck allows debian family systems', () async { - final controller = WorkspaceProvisionController(executor: _FakeSshExecutor()); + final controller = WorkspaceProvisionController( + executor: _FakeSshExecutor(), + ); addTearDown(controller.dispose); controller.updateForm( serverAddress: '203.0.113.10', @@ -352,18 +385,20 @@ SERVICE_HERMES_GATEWAY=active expect(controller.validatePrecheckBlockingIssue(), isNull); }); - test('import yaml restores editable state without leaking redacted values', () { - final controller = WorkspaceProvisionController(); - addTearDown(controller.dispose); - controller.updateForm( - serverAddress: 'old.example.com', - workspaceDomain: 'old.net', - sshUsername: 'root', - sshPassword: 'keep-secret', - showAdvanced: false, - ); + test( + 'import yaml restores editable state without leaking redacted values', + () { + final controller = WorkspaceProvisionController(); + addTearDown(controller.dispose); + controller.updateForm( + serverAddress: 'old.example.com', + workspaceDomain: 'old.net', + sshUsername: 'root', + sshPassword: 'keep-secret', + showAdvanced: false, + ); - controller.importYaml(''' + controller.importYaml(''' server_address: 167.179.110.129 workspace_domain: onwalk.net ssh_username: root @@ -382,69 +417,70 @@ extra_configs: note: "OpenClaw" '''); - expect(controller.serverAddress, '167.179.110.129'); - expect(controller.workspaceDomain, 'onwalk.net'); - expect(controller.showAdvanced, isTrue); - expect(controller.sshPassword, 'keep-secret'); - expect(controller.extraConfigs.first.value, 'deepseek-new'); - expect(controller.extraConfigs.last.value, ''); - }); + expect(controller.serverAddress, '167.179.110.129'); + expect(controller.workspaceDomain, 'onwalk.net'); + expect(controller.showAdvanced, isTrue); + expect(controller.sshPassword, 'keep-secret'); + expect(controller.extraConfigs.first.value, 'deepseek-new'); + expect(controller.extraConfigs.last.value, ''); + }, + ); - test('post deploy verification fails when external probe does not connect', () async { - final controller = WorkspaceProvisionController( - executor: _FakeSshExecutor( - commandResults: [ - const SshResult(exitCode: 0, stdout: 'pulled', stderr: ''), - const SshResult(exitCode: 0, stdout: 'wrote', stderr: ''), - const SshResult( - exitCode: 0, - stdout: ''' + test( + 'post deploy verification fails when external probe does not connect', + () async { + final controller = WorkspaceProvisionController( + executor: _FakeSshExecutor( + commandResults: [ + const SshResult(exitCode: 0, stdout: 'curl ready', stderr: ''), + const SshResult( + exitCode: 0, + stdout: ''' SERVICE_CADDY=active SERVICE_XWORKMATE_BRIDGE=active ''', - stderr: '', - ), - ], - streamingChunks: [ - 'TASK [Configure caddy TLS]\nchanged: [localhost]\n', - ], - ), - externalPortProbe: (host) async { - throw PlaybookRunException('probe failed for $host'); - }, - ); - addTearDown(controller.dispose); - controller.updateForm( - serverAddress: '203.0.113.10', - workspaceDomain: 'workspace.example.com', - sshKeyContent: 'key', - ); - controller.serverInfo = const ServerInfo( - os: 'Ubuntu 22.04', - arch: 'x86_64', - sudoAvailable: true, - dockerVersion: 'missing', - systemdVersion: 'systemd 249', - caddyVersion: 'missing', - ansibleVersion: 'ansible [core 2.14]', - gitVersion: 'git version 2.34.1', - dnsAddressCount: 1, - port80ListenerCount: 0, - port80Open: true, - port443ListenerCount: 0, - port443Open: true, - bridgeDnsAddressCount: 1, - bridgePort80ListenerCount: 0, - bridgePort80Open: true, - bridgePort443ListenerCount: 0, - bridgePort443Open: true, - ); + stderr: '', + ), + ], + streamingChunks: ['Configuring caddy TLS\n'], + ), + externalPortProbe: (host) async { + throw PlaybookRunException('probe failed for $host'); + }, + ); + addTearDown(controller.dispose); + controller.updateForm( + serverAddress: '203.0.113.10', + workspaceDomain: 'workspace.example.com', + sshKeyContent: 'key', + ); + controller.serverInfo = const ServerInfo( + os: 'Ubuntu 22.04', + arch: 'x86_64', + sudoAvailable: true, + dockerVersion: 'missing', + systemdVersion: 'systemd 249', + caddyVersion: 'missing', + ansibleVersion: 'ansible [core 2.14]', + gitVersion: 'git version 2.34.1', + dnsAddressCount: 1, + port80ListenerCount: 0, + port80Open: true, + port443ListenerCount: 0, + port443Open: true, + bridgeDnsAddressCount: 1, + bridgePort80ListenerCount: 0, + bridgePort80Open: true, + bridgePort443ListenerCount: 0, + bridgePort443Open: true, + ); - await controller.createWorkspace(); + await controller.createWorkspace(); - expect(controller.phase, ProvisionPhase.failed); - expect(controller.errorMessage, contains('probe failed')); - }); + expect(controller.phase, ProvisionPhase.failed); + expect(controller.errorMessage, contains('probe failed')); + }, + ); }); } diff --git a/test/features/workspace_management/workspace_management_widget_test.dart b/test/features/workspace_management/workspace_management_widget_test.dart index d4a13cb5..e17a4f2d 100644 --- a/test/features/workspace_management/workspace_management_widget_test.dart +++ b/test/features/workspace_management/workspace_management_widget_test.dart @@ -10,7 +10,7 @@ import 'package:xworkmate/runtime/secure_config_store.dart'; import 'package:xworkmate/theme/app_theme.dart'; void main() { - testWidgets('panel renders form controls and keeps upgrade disabled', ( + testWidgets('panel renders form controls and enables upgrade', ( tester, ) async { final appController = _NoopAppController(store: _MemorySecureConfigStore()); @@ -44,13 +44,13 @@ void main() { find.byKey(const Key('workspace-management-upgrade-button')), ) .onPressed, - isNull, + isNotNull, ); await tester.tap(find.text('高级选项')); await tester.pumpAndSettle(); - expect(find.text('安装路径'), findsOneWidget); + expect(find.text('执行脚本'), findsOneWidget); }); testWidgets('panel switches auth method and expands logs', (tester) async {