diff --git a/lib/commands/bootstrap/bootstarp_command.dart b/lib/commands/bootstrap/bootstarp_command.dart index 6deb84f..051b3ff 100644 --- a/lib/commands/bootstrap/bootstarp_command.dart +++ b/lib/commands/bootstrap/bootstarp_command.dart @@ -2,14 +2,12 @@ import 'dart:async'; -import 'package:flutter_tools/src/base/io.dart'; -import 'package:flutter_tools/src/custom_devices/custom_device_config.dart'; -import 'package:snapp_cli/commands/base_command.dart'; -import 'package:snapp_cli/configs/predefined_devices.dart'; -import 'package:snapp_cli/host_runner/host_runner_platform.dart'; +import 'package:snapp_cli/service/custom_device_builder/custom_device_builder.dart'; import 'package:snapp_cli/service/remote_controller_service.dart'; +import 'package:snapp_cli/service/setup_device/device_setup.dart'; +import 'package:snapp_cli/service/setup_device/src/custom_embedder_provider.dart'; +import 'package:snapp_cli/service/setup_device/src/install_dependency_provider.dart'; import 'package:snapp_cli/service/ssh_service.dart'; -import 'package:snapp_cli/utils/common.dart'; /// Perform a comprehensive setup for a device.(add custom device,ssh connection, install flutter,...) class BootstrapCommand extends BaseSnappCommand { @@ -32,6 +30,8 @@ class BootstrapCommand extends BaseSnappCommand { @override Future run() async { + logger.info('new version'); + logger.spaces(); logger.info(''' @@ -39,251 +39,55 @@ Bootstrap command is a way to setup a device from scratch. It will add a new device to custom devices, create a ssh connection to the device, install flutter on the device and finally help you to run your app on the device. +some changes + let's start! \n '''); logger.spaces(); - final addCommandOptions = [ - 'Express (recommended)', - 'Custom', - ]; - - final commandIndex = interaction.selectIndex( - 'Please select the type of device you want to setup.', - options: addCommandOptions, - ); - - if (commandIndex == 0) { - return _setupPredefinedDevice(); - } - - return _setupCustomDevice(); - } - - Future _setupPredefinedDevice() async { - logger.spaces(); + DeviceSetup deviceSetup = DeviceSetup( + steps: [ + /// Receives information about the target device like id, name and type. + /// Example: Raspberry Pi 4b + DeviceTypeProvider(customDevicesConfig: customDevicesConfig), + + /// Receives connection information about the target device like ip, port, username. + DeviceHostProvider(), + + /// Checks if we can have a passwordless ssh connection to the target device. + /// If not, it will help you to create one. + /// It will also check if the device is reachable. + SshConnectionProvider(sshService), + + /// Checks what kind of embedder user wants to use. + /// Example: Flutter, Flutter-pi ... + CustomEmbedderProvider(), + + /// Installs dependencies required to run the app on the remote device. + /// Regarding to the embedder type + /// for example for Flutter-pi, it will install flutterpi_tool global package + InstallDependencyProvider( + remoteControllerService: remoteControllerService, + flutterSdkManager: flutterSdkManager, + ), - final deviceKey = interaction.select( - 'Select your device', - options: predefinedDevices.keys.toList(), + /// installs custom embedder chosen by the user. + AppExecuterProvider(remoteControllerService: remoteControllerService), + ], ); - var predefinedDeviceConfig = predefinedDevices[deviceKey]; - - if (predefinedDeviceConfig == null) { - throwToolExit( - 'Something went wrong while trying to setup predefined $deviceKey device.'); - } - - /// check if the device id already exists in the config file - /// update the id if it - if (_isDuplicatedDeviceId(predefinedDeviceConfig.id)) { - predefinedDeviceConfig = predefinedDeviceConfig.copyWith( - id: _suggestIdForDuplicatedDeviceId(predefinedDeviceConfig.id), - ); - } + final deviceContext = await deviceSetup.setup(); - return _setupCustomDevice(predefinedConfig: predefinedDeviceConfig); - } - - Future _setupCustomDevice({CustomDeviceConfig? predefinedConfig}) async { - logger.spaces(); - - // get remote device id and label from the user - final id = predefinedConfig?.id.isNotEmpty == true - ? predefinedConfig!.id - : interaction.readDeviceId(customDevicesConfig); - - final label = predefinedConfig?.label.isNotEmpty == true - ? predefinedConfig!.label - : interaction.readDeviceLabel(); - - // get remote device ip and username from the user - logger.spaces(); - - logger.info('to setup a new device, we need an IP address and a username.'); - - final targetIp = interaction.readDeviceIp( - description: - 'Please enter the IP-address of the device. (example: 192.168.1.101)'); - - final username = interaction.readDeviceUsername( - description: - 'Please enter the username used for ssh-ing into the remote device. (example: pi)', + final deviceBuilder = CustomDeviceBuilder.create( + embedder: deviceContext.embedder!, + flutterSdkManager: flutterSdkManager, + hostPlatform: hostPlatform, ); - final bool ipv6 = targetIp.isIpv6; - - final InternetAddress loopbackIp = - ipv6 ? InternetAddress.loopbackIPv6 : InternetAddress.loopbackIPv4; - - // SSH expects IPv6 addresses to use the bracket syntax like URIs do too, - // but the IPv6 the user enters is a raw IPv6 address, so we need to wrap it. - final String sshTarget = targetIp.sshTarget(username); - - final String formattedLoopbackIp = - ipv6 ? '[${loopbackIp.address}]' : loopbackIp.address; + final newDeviceConfig = await deviceBuilder.buildDevice(deviceContext); - logger.spaces(); - - bool remoteHasSshConnection = - await sshService.testPasswordLessSshConnection(username, targetIp); - - if (!remoteHasSshConnection) { - logger.fail( - 'could not establish a password-less ssh connection to the remote device. \n', - ); - - logger.info( - 'We can create a ssh connection with the remote device, do you want to try it?'); - - final continueWithoutPing = interaction.confirm( - 'Create a ssh connection?', - defaultValue: true, - ); - - if (!continueWithoutPing) { - logger.spaces(); - logger.info( - 'Check your ssh connection with the remote device and try again.'); - return 1; - } - - logger.spaces(); - - final sshConnectionCreated = - await sshService.createPasswordLessSshConnection(username, targetIp); - - if (sshConnectionCreated) { - logger.success('SSH connection to the remote device is created!'); - remoteHasSshConnection = true; - } else { - logger.fail('Could not create SSH connection to the remote device!'); - return 1; - } - } - - logger.spaces(); - - logger.info( - 'We need the exact path of your flutter command line tools on the remote device. ' - 'We will use this path to run flutter commands on the remote device like "flutter build linux --debug". \n', - ); - - String remoteRunnerCommand = - await _provideFlutterCommandRunner(username, targetIp); - - /// path to the icu data file on the host machine - final hostIcuDataPath = flutterSdkManager.icuDataPath; - - /// path to the build artifacts on the remote machine - const hostBuildClonePath = 'snapp_embedded'; - - /// path to the icu data file on the remote machine - const hostIcuDataClone = '$hostBuildClonePath/engine'; - - CustomDeviceConfig config = CustomDeviceConfig( - id: id, - label: label, - sdkNameAndVersion: label, - enabled: true, - - // host-platform specific, filled out later - pingCommand: - hostPlatform.pingCommand(ipv6: ipv6, pingTarget: targetIp.address), - pingSuccessRegex: hostPlatform.pingSuccessRegex, - postBuildCommand: const [], - - // just install to /tmp/${appName} by default - // returns the command runner for the current platform - // for example: - // on windows it returns "powershell -c" - // on linux and macOS it returns "bash -c" - installCommand: hostPlatform.commandRunner( - [ - // create the necessary directories in the remote machine - hostPlatform - .sshCommand( - ipv6: ipv6, - sshTarget: sshTarget, - command: 'mkdir -p /tmp/\${appName}/$hostIcuDataClone', - ) - .asString, - - // copy the current project files from host to the remote - hostPlatform - .scpCommand( - ipv6: ipv6, - source: '${hostPlatform.currentSourcePath}*', - dest: '$sshTarget:/tmp/\${appName}', - ) - .asString, - - // copy the build artifacts from host to the remote - hostPlatform - .scpCommand( - ipv6: ipv6, - source: r'${localPath}', - dest: '$sshTarget:/tmp/\${appName}/$hostBuildClonePath', - ) - .asString, - - // copy the icu data file from host to the remote - hostPlatform - .scpCommand( - ipv6: ipv6, - source: hostIcuDataPath, - dest: '$sshTarget:/tmp/\${appName}/$hostIcuDataClone', - lastCommand: true, - ) - .asString, - ], - ), - // just uninstall app by removing the /tmp/${appName} directory on the remote - uninstallCommand: hostPlatform.sshCommand( - ipv6: ipv6, - sshTarget: sshTarget, - command: r'rm -rf "/tmp/${appName}"', - lastCommand: true, - ), - - // run the app on the remote - runDebugCommand: hostPlatform.sshMultiCommand( - ipv6: ipv6, - sshTarget: sshTarget, - commands: [ - 'cd /tmp/\${appName} ;', - '$remoteRunnerCommand build linux --debug ;', - // remove remote build artifacts - 'rm -rf "/tmp/\${appName}/build/flutter_assets/*" ;', - 'rm -rf "/tmp/\${appName}/build/linux/arm64/debug/bundle/data/flutter_assets/*" ;', - 'rm -rf "/tmp/\${appName}/build/linux/arm64/debug/bundle/data/icudtl.dat" ;', - // and replace them by host build artifacts - 'cp /tmp/\${appName}/$hostBuildClonePath/flutter_assets/* /tmp/\${appName}/build/flutter_assets ;', - 'cp /tmp/\${appName}/$hostBuildClonePath/flutter_assets/* /tmp/\${appName}/build/linux/arm64/debug/bundle/data/flutter_assets ;', - 'cp /tmp/\${appName}/$hostIcuDataClone/icudtl.dat /tmp/\${appName}/build/linux/arm64/debug/bundle/data ;', - // finally run the app - r'DISPLAY=:0 /tmp/\${appName}/build/linux/arm64/debug/bundle/\${appName} ;' - ], - ), - forwardPortCommand: [ - 'ssh', - '-o', - 'BatchMode=yes', - '-o', - 'ExitOnForwardFailure=yes', - if (ipv6) '-6', - '-L', - '$formattedLoopbackIp:\${hostPort}:$formattedLoopbackIp:\${devicePort}', - sshTarget, - "echo 'Port forwarding success'; read", - ], - forwardPortSuccessRegex: RegExp('Port forwarding success'), - screenshotCommand: null, - ); - - customDevicesConfig.add(config); + customDevicesConfig.add(newDeviceConfig); logger.spaces(); @@ -295,177 +99,4 @@ let's start! \n return 0; } - - bool _isDuplicatedDeviceId(String s) { - return customDevicesConfig.devices.any((element) => element.id == s); - } - - /// returns new device id by adding a number to the end of it - /// - /// for example: if the id is "pi-4" and this id already exists - /// then we update the id to "pi-4-1" and check again - /// if the new id already exists, then we update it to "pi-4-2" and so on - String _suggestIdForDuplicatedDeviceId(String s) { - int i = 1; - - while (_isDuplicatedDeviceId('$s-$i')) { - i++; - } - - return '$s-$i'; - } - - Future _provideFlutterCommandRunner( - String username, - InternetAddress targetIp, - ) async { - final hostFlutterVersion = - flutterSdkManager.flutterVersion.frameworkVersion; - - final possibleFlutterPath = - await remoteControllerService.findFlutterPath(username, targetIp); - - // if we found the flutter path on the remote machine - // then we need to check if the version of the remote flutter is the same as the host flutter - if (possibleFlutterPath != null) { - final remoteFlutterVersion = - await remoteControllerService.findFlutterVersion( - username, - targetIp, - possibleFlutterPath, - ); - - logger.detail('remote flutter version: $remoteFlutterVersion'); - logger.detail('host flutter version: $hostFlutterVersion'); - - if (remoteFlutterVersion == hostFlutterVersion) { - logger.success( - 'You have flutter installed on the remote machine with the same version as your host machine.'); - logger.spaces(); - - return possibleFlutterPath; - } else { - return _fixConflictVersions( - username, - targetIp, - hostFlutterVersion, - remoteFlutterVersion!, - ); - } - } - - logger.info( - 'Could not find flutter in the remote machine automatically. \n\n' - 'We need the exact path of your flutter command line tools on the remote device. \n' - 'Now you have two options: \n' - '1. You can enter the path to flutter manually. \n' - '2. We can install flutter on the remote machine for you. \n'); - - logger.spaces(); - - final provideFlutterPathOption = interaction.selectIndex( - 'Please select one of the options:', - options: [ - 'Enter the path to flutter manually', - 'Install flutter on the remote machine', - ], - ); - - logger.spaces(); - - if (provideFlutterPathOption == 0) { - return interaction.readFlutterManualPath(); - } - - return _installFlutterOnRemote(username, targetIp, hostFlutterVersion); - } - - Future _fixConflictVersions( - String username, - InternetAddress targetIp, - String hostFlutterVersion, - String remoteFlutterVersion, - ) { - logger.info( - 'To be able to run your app on the remote device, You need to have the same version of flutter on both machines. \n\n' - 'Currently, you have a different version of flutter on the remote machine. \n' - 'Remote flutter version: $remoteFlutterVersion \n' - 'Host flutter version: $hostFlutterVersion \n\n' - 'Now you have two options: \n' - '1. You can manually update your host machine to the same version as the remote machine. \n' - '2. We can install the same version of flutter on the remote machine for you. \n', - ); - - logger.spaces(); - - final provideFlutterPathOption = interaction.selectIndex( - 'Please select one of the options:', - options: [ - 'Manually update your host', - 'Install the same version on the remote', - ], - ); - - logger.spaces(); - - if (provideFlutterPathOption == 0) { - logger.info( - 'Please update your host machine to the same version as the remote machine and try again.', - ); - - throwToolExit('Host flutter version is different from the remote.'); - } - - return _installFlutterOnRemote(username, targetIp, hostFlutterVersion); - } - - Future _installFlutterOnRemote( - String username, - InternetAddress ip, - String version, - ) async { - final snappInstallerPath = await remoteControllerService - .findSnappInstallerPathInteractive(username, ip); - - if (snappInstallerPath == null) { - logger.info( - ''' -snapp_installer is not installed on the device -but don't worry, we will install it for you. -''', - ); - - logger.spaces(); - - final snappInstallerInstalled = await remoteControllerService - .installSnappInstallerOnRemote(username, ip); - - if (!snappInstallerInstalled) { - throwToolExit('Could not install snapp_installer on the device!'); - } - - logger.success( - ''' -snapp_installer is installed on the device! -Now we can install flutter on the device with the help of snapp_installer. -''', - ); - } - - logger.spaces(); - - final flutterInstalled = await remoteControllerService - .installFlutterOnRemote(username, ip, version: version); - - if (!flutterInstalled) { - throwToolExit('Could not install flutter on the device!'); - } - - logger.success('Flutter is installed on the device!'); - - return (await remoteControllerService.findFlutterPathInteractive( - username, - ip, - ))!; - } } diff --git a/lib/configs/embedder.dart b/lib/configs/embedder.dart new file mode 100644 index 0000000..b2ed0e3 --- /dev/null +++ b/lib/configs/embedder.dart @@ -0,0 +1,13 @@ +/// Flutter embedders which we are supporting in snapp_cli +enum FlutterEmbedder { + /// Flutter official embedder for Linux + flutter('Flutter Linux', 'Flutter Linux Official'), + + /// Flutter-pi embedder: [https://github.com/ardera/flutter-pi] + flutterPi('Flutter-pi', 'Flutter-pi Embedder'); + + const FlutterEmbedder(this.label, this.sdkName); + + final String label; + final String sdkName; +} diff --git a/lib/service/custom_device_builder/custom_device_builder.dart b/lib/service/custom_device_builder/custom_device_builder.dart new file mode 100644 index 0000000..da0c9d3 --- /dev/null +++ b/lib/service/custom_device_builder/custom_device_builder.dart @@ -0,0 +1,52 @@ +import 'package:snapp_cli/configs/embedder.dart'; +import 'package:snapp_cli/host_runner/host_runner_platform.dart'; +import 'package:snapp_cli/service/custom_device_builder/src/flutter.dart'; +import 'package:snapp_cli/service/custom_device_builder/src/flutter_pi.dart'; +import 'package:snapp_cli/service/setup_device/device_config_context.dart'; +import 'package:snapp_cli/service/setup_device/device_setup.dart'; +// ignore: implementation_imports +import 'package:flutter_tools/src/custom_devices/custom_device_config.dart'; +import 'package:snapp_cli/snapp_cli.dart'; + +abstract class CustomDeviceBuilder { + const CustomDeviceBuilder({ + required this.flutterSdkManager, + required this.hostPlatform, + }); + + factory CustomDeviceBuilder.create({ + required FlutterEmbedder embedder, + required FlutterSdkManager flutterSdkManager, + required HostRunnerPlatform hostPlatform, + }) { + switch (embedder) { + case FlutterEmbedder.flutter: + return FlutterCustomDeviceBuilder( + flutterSdkManager: flutterSdkManager, + hostPlatform: hostPlatform, + ); + + case FlutterEmbedder.flutterPi: + return FlutterPiCustomDeviceBuilder( + flutterSdkManager: flutterSdkManager, + hostPlatform: hostPlatform, + ); + } + } + + final FlutterSdkManager flutterSdkManager; + final HostRunnerPlatform hostPlatform; + + Future buildDevice( + final DeviceConfigContext context, + ); + + bool isContextValid(DeviceConfigContext context) { + return context.id != null && + context.label != null && + context.targetIp != null && + context.username != null && + context.remoteHasSshConnection && + context.appExecuterPath != null; + } +} diff --git a/lib/service/custom_device_builder/src/flutter.dart b/lib/service/custom_device_builder/src/flutter.dart new file mode 100644 index 0000000..8eafdb1 --- /dev/null +++ b/lib/service/custom_device_builder/src/flutter.dart @@ -0,0 +1,141 @@ +import 'package:snapp_cli/host_runner/host_runner_platform.dart'; +import 'package:snapp_cli/service/custom_device_builder/custom_device_builder.dart'; +import 'package:snapp_cli/service/setup_device/device_setup.dart'; +// ignore: implementation_imports +import 'package:flutter_tools/src/custom_devices/custom_device_config.dart'; + +class FlutterCustomDeviceBuilder extends CustomDeviceBuilder { + const FlutterCustomDeviceBuilder({ + required super.flutterSdkManager, + required super.hostPlatform, + }); + + @override + Future buildDevice( + final DeviceConfigContext context, + ) async { + if (!isContextValid(context)) { + logger.err('Device context: $context'); + throw Exception("Device setup did not produce a valid configuration."); + } + + /// path to the icu data file on the host machine + final hostIcuDataPath = flutterSdkManager.icuDataPath; + + /// path to the build artifacts on the remote machine + const hostBuildClonePath = 'snapp_embedded'; + + /// path to the icu data file on the remote machine + const hostIcuDataClone = '$hostBuildClonePath/engine'; + + final ipv6 = context.ipv6!; + + final sshTarget = context.sshTarget!; + + final formattedLoopbackIp = context.formattedLoopbackIp!; + + final remoteAppExecuter = context.appExecuterPath!; + + return CustomDeviceConfig( + id: context.id!, + label: context.formattedLabel, + sdkNameAndVersion: context.sdkName!, + enabled: true, + + // host-platform specific, filled out later + pingCommand: hostPlatform.pingCommand( + ipv6: ipv6, + pingTarget: context.targetIp!.address, + ), + pingSuccessRegex: hostPlatform.pingSuccessRegex, + postBuildCommand: const [], + + // just install to /tmp/${appName} by default + // returns the command runner for the current platform + // for example: + // on windows it returns "powershell -c" + // on linux and macOS it returns "bash -c" + installCommand: hostPlatform.commandRunner( + [ + // create the necessary directories in the remote machine + hostPlatform + .sshCommand( + ipv6: ipv6, + sshTarget: sshTarget, + command: 'mkdir -p /tmp/\${appName}/$hostIcuDataClone', + ) + .asString, + + // copy the current project files from host to the remote + hostPlatform + .scpCommand( + ipv6: ipv6, + source: '${hostPlatform.currentSourcePath}*', + dest: '$sshTarget:/tmp/\${appName}', + ) + .asString, + + // copy the build artifacts from host to the remote + hostPlatform + .scpCommand( + ipv6: ipv6, + source: r'${localPath}', + dest: '$sshTarget:/tmp/\${appName}/$hostBuildClonePath', + ) + .asString, + + // copy the icu data file from host to the remote + hostPlatform + .scpCommand( + ipv6: ipv6, + source: hostIcuDataPath, + dest: '$sshTarget:/tmp/\${appName}/$hostIcuDataClone', + lastCommand: true, + ) + .asString, + ], + ), + // just uninstall app by removing the /tmp/${appName} directory on the remote + uninstallCommand: hostPlatform.sshCommand( + ipv6: ipv6, + sshTarget: sshTarget, + command: r'rm -rf "/tmp/${appName}"', + lastCommand: true, + ), + + // run the app on the remote + runDebugCommand: hostPlatform.sshMultiCommand( + ipv6: ipv6, + sshTarget: sshTarget, + commands: [ + 'cd /tmp/\${appName} ;', + '$remoteAppExecuter build linux --debug ;', + // remove remote build artifacts + 'rm -rf "/tmp/\${appName}/build/flutter_assets/*" ;', + 'rm -rf "/tmp/\${appName}/build/linux/arm64/debug/bundle/data/flutter_assets/*" ;', + 'rm -rf "/tmp/\${appName}/build/linux/arm64/debug/bundle/data/icudtl.dat" ;', + // and replace them by host build artifacts + 'cp /tmp/\${appName}/$hostBuildClonePath/flutter_assets/* /tmp/\${appName}/build/flutter_assets ;', + 'cp /tmp/\${appName}/$hostBuildClonePath/flutter_assets/* /tmp/\${appName}/build/linux/arm64/debug/bundle/data/flutter_assets ;', + 'cp /tmp/\${appName}/$hostIcuDataClone/icudtl.dat /tmp/\${appName}/build/linux/arm64/debug/bundle/data ;', + // finally run the app + r'DISPLAY=:0 /tmp/\${appName}/build/linux/arm64/debug/bundle/\${appName} ;' + ], + ), + forwardPortCommand: [ + 'ssh', + '-o', + 'BatchMode=yes', + '-o', + 'ExitOnForwardFailure=yes', + if (ipv6) '-6', + '-L', + '$formattedLoopbackIp:\${hostPort}:$formattedLoopbackIp:\${devicePort}', + sshTarget, + "echo 'Port forwarding success'; read", + ], + forwardPortSuccessRegex: RegExp('Port forwarding success'), + screenshotCommand: null, + ); + } +} diff --git a/lib/service/custom_device_builder/src/flutter_pi.dart b/lib/service/custom_device_builder/src/flutter_pi.dart new file mode 100644 index 0000000..4c1246b --- /dev/null +++ b/lib/service/custom_device_builder/src/flutter_pi.dart @@ -0,0 +1,108 @@ +import 'package:snapp_cli/host_runner/host_runner_platform.dart'; +import 'package:snapp_cli/service/custom_device_builder/custom_device_builder.dart'; +import 'package:snapp_cli/service/setup_device/device_setup.dart'; +// ignore: implementation_imports +import 'package:flutter_tools/src/custom_devices/custom_device_config.dart'; + +class FlutterPiCustomDeviceBuilder extends CustomDeviceBuilder { + const FlutterPiCustomDeviceBuilder({ + required super.flutterSdkManager, + required super.hostPlatform, + }); + + @override + Future buildDevice( + final DeviceConfigContext context, + ) async { + if (!isContextValid(context)) { + logger.err('Device context: $context'); + throw Exception("Device setup did not produce a valid configuration."); + } + + final ipv6 = context.ipv6!; + + final sshTarget = context.sshTarget!; + + final formattedLoopbackIp = context.formattedLoopbackIp!; + + final remoteAppExecuter = context.appExecuterPath!; + + return CustomDeviceConfig( + id: context.id!, + label: context.formattedLabel, + sdkNameAndVersion: context.sdkName!, + enabled: true, + + // host-platform specific, filled out later + pingCommand: hostPlatform.pingCommand( + ipv6: ipv6, + pingTarget: context.targetIp!.address, + ), + pingSuccessRegex: hostPlatform.pingSuccessRegex, + postBuildCommand: const [], + installCommand: hostPlatform.commandRunner( + [ + // Copy bundle folder to remote + hostPlatform + .scpCommand( + ipv6: ipv6, + source: r'${localPath}', + dest: '$sshTarget:/tmp/\${appName}', + ) + .asString, + + // Build using flutterpi_tool to be able to use libflutter_engine.so and icudtl.dat + 'flutterpi_tool build --arch=arm64 --debug ;', + + // Copy libflutter_engine.so to remote + hostPlatform + .scpCommand( + ipv6: ipv6, + source: r'${localPath}/libflutter_engine.so', + dest: '$sshTarget:/tmp/\${appName}', + ) + .asString, + + // Copy icudtl.dat to remote + hostPlatform + .scpCommand( + ipv6: ipv6, + source: r'${localPath}/icudtl.dat', + dest: '$sshTarget:/tmp/\${appName}', + lastCommand: true, + ) + .asString, + ], + ), + // just uninstall app by removing the /tmp/${appName} directory on the remote + uninstallCommand: hostPlatform.sshCommand( + ipv6: ipv6, + sshTarget: sshTarget, + command: + 'PID=\$(ps aux | grep \'$remoteAppExecuter /tmp/\${appName}\' | grep -v grep | awk \'{print \$2}\'); [ -n "\$PID" ] && kill \$PID && echo "Process $remoteAppExecuter /tmp/\${appName} (PID: \$PID) has been killed." || echo "Process not found.";', + lastCommand: true, + ), + // run the app on the remote + runDebugCommand: hostPlatform.sshCommand( + ipv6: ipv6, + sshTarget: sshTarget, + lastCommand: true, + command: '$remoteAppExecuter /tmp/\${appName} &', + ), + forwardPortCommand: [ + 'ssh', + '-o', + 'BatchMode=yes', + '-o', + 'ExitOnForwardFailure=yes', + if (ipv6) '-6', + '-L', + '$formattedLoopbackIp:\${hostPort}:$formattedLoopbackIp:\${devicePort}', + sshTarget, + "echo 'Port forwarding success'; read", + ], + forwardPortSuccessRegex: RegExp('Port forwarding success'), + screenshotCommand: null, + ); + } +} diff --git a/lib/service/dependency_installer/dependency_installer.dart b/lib/service/dependency_installer/dependency_installer.dart new file mode 100644 index 0000000..524fea4 --- /dev/null +++ b/lib/service/dependency_installer/dependency_installer.dart @@ -0,0 +1,70 @@ +import 'package:snapp_cli/configs/embedder.dart'; +import 'package:snapp_cli/flutter_sdk.dart'; +import 'package:snapp_cli/service/dependency_installer/src/flutter_pi_dependency_installer.dart'; +import 'package:snapp_cli/service/remote_controller_service.dart'; + +abstract class DependencyInstaller { + const DependencyInstaller({ + required this.flutterSdkManager, + required this.remoteControllerService, + }); + + final FlutterSdkManager flutterSdkManager; + final RemoteControllerService remoteControllerService; + + factory DependencyInstaller.create( + FlutterEmbedder embedder, { + required FlutterSdkManager flutterSdkManager, + required RemoteControllerService remoteControllerService, + }) { + switch (embedder) { + case FlutterEmbedder.flutter: + return NoOpDependencyInstaller( + flutterSdkManager: flutterSdkManager, + remoteControllerService: remoteControllerService, + ); + case FlutterEmbedder.flutterPi: + return FlutterPiDependencyInstaller( + flutterSdkManager: flutterSdkManager, + remoteControllerService: remoteControllerService, + ); + } + } + + Future install() async { + final isHostDependenciesInstalled = await installDependenciesOnHost(); + if (!isHostDependenciesInstalled) { + return false; + } + + final isRemoteDependenciesInstalled = await installDependenciesOnRemote(); + if (!isRemoteDependenciesInstalled) { + return false; + } + + return true; + } + + /// Install host dependencies that is required to run the app on the remote device. + Future installDependenciesOnHost(); + + /// Install dependencies on the remote device. + Future installDependenciesOnRemote(); +} + +class NoOpDependencyInstaller extends DependencyInstaller { + const NoOpDependencyInstaller({ + required super.flutterSdkManager, + required super.remoteControllerService, + }); + + @override + Future installDependenciesOnHost() async { + return true; + } + + @override + Future installDependenciesOnRemote() async { + return true; + } +} diff --git a/lib/service/dependency_installer/src/flutter_pi_dependency_installer.dart b/lib/service/dependency_installer/src/flutter_pi_dependency_installer.dart new file mode 100644 index 0000000..c175c7f --- /dev/null +++ b/lib/service/dependency_installer/src/flutter_pi_dependency_installer.dart @@ -0,0 +1,50 @@ +import 'package:snapp_cli/service/dependency_installer/dependency_installer.dart'; + +// ignore: implementation_imports +import 'package:flutter_tools/src/base/process.dart'; +import 'package:snapp_cli/service/interaction_service.dart'; +import 'package:snapp_cli/service/logger_service.dart'; +import 'package:snapp_cli/utils/process.dart'; + +export 'package:flutter_tools/src/base/common.dart'; +export 'package:snapp_cli/service/logger_service.dart'; +export 'package:snapp_cli/service/interaction_service.dart'; + +class FlutterPiDependencyInstaller extends DependencyInstaller { + const FlutterPiDependencyInstaller({ + required super.flutterSdkManager, + required super.remoteControllerService, + }); + + @override + Future installDependenciesOnHost() { + return _installFlutterPiTool(); + } + + @override + Future installDependenciesOnRemote() async => true; + + Future _installFlutterPiTool() async { + final processRunner = ProcessUtils( + processManager: flutterSdkManager.processManager, + logger: flutterSdkManager.logger, + ); + + final result = await processRunner.runCommand( + ['flutter', 'pub', 'global', 'activate', 'flutterpi_tool'], + parseResult: (result) => result, + spinner: interaction.spinner( + inProgressMessage: 'Installing flutterpi_tool...', + doneMessage: 'flutterpi_tool installed successfully!', + failedMessage: 'Failed to install flutterpi_tool!', + ), + ); + + logger.spaces(); + + logger.info(result!.stdout); + logger.detail(result.stderr); + + return result.exitCode == 0; + } +} diff --git a/lib/service/embedder_provider/embedder_provider.dart b/lib/service/embedder_provider/embedder_provider.dart new file mode 100644 index 0000000..c6baf87 --- /dev/null +++ b/lib/service/embedder_provider/embedder_provider.dart @@ -0,0 +1,40 @@ +import 'package:snapp_cli/configs/embedder.dart'; +import 'package:snapp_cli/service/embedder_provider/src/flutter_linux_provider.dart'; +import 'package:snapp_cli/service/embedder_provider/src/flutter_pi_provider.dart'; +import 'package:snapp_cli/service/remote_controller_service.dart'; +import 'package:snapp_cli/service/setup_device/device_setup.dart'; + +/// EmbedderProvider is an abstract class that provides the embedder which is responsible to execute the flutter app +/// +/// The embedder can be the official Flutter Linux embedder +/// or a custom embedders like Flutter-pi or ivi-homescreen +abstract class EmbedderProvider { + const EmbedderProvider({ + required this.context, + required this.remoteControllerService, + }); + + final DeviceConfigContext context; + final RemoteControllerService remoteControllerService; + + factory EmbedderProvider.create( + FlutterEmbedder embedder, + DeviceConfigContext context, + RemoteControllerService remoteControllerService, + ) { + switch (embedder) { + case FlutterEmbedder.flutter: + return FlutterLinuxEmbedderProvider( + context: context, + remoteControllerService: remoteControllerService, + ); + case FlutterEmbedder.flutterPi: + return FlutterPiEmbedderProvider( + context: context, + remoteControllerService: remoteControllerService, + ); + } + } + + Future provideEmbedderPath(); +} diff --git a/lib/service/embedder_provider/src/flutter_linux_provider.dart b/lib/service/embedder_provider/src/flutter_linux_provider.dart new file mode 100644 index 0000000..d9b6e7a --- /dev/null +++ b/lib/service/embedder_provider/src/flutter_linux_provider.dart @@ -0,0 +1,173 @@ +import 'dart:io'; + +import 'package:snapp_cli/flutter_sdk.dart'; +import 'package:snapp_cli/service/embedder_provider/embedder_provider.dart'; +import 'package:snapp_cli/service/setup_device/device_setup.dart'; + +class FlutterLinuxEmbedderProvider extends EmbedderProvider { + const FlutterLinuxEmbedderProvider({ + required super.context, + required super.remoteControllerService, + }); + + @override + Future provideEmbedderPath() async { + if (context.targetIp == null || context.username == null) { + throwToolExit( + 'Target IP and username are required to provide the embedder path.'); + } + + final username = context.username!; + final targetIp = context.targetIp!; + + final hostFlutterVersion = + FlutterSdkManager().flutterVersion.frameworkVersion; + + final possibleFlutterPath = + await remoteControllerService.findFlutterPath(username, targetIp); + + // if we found the flutter path on the remote machine + // then we need to check if the version of the remote flutter is the same as the host flutter + if (possibleFlutterPath != null) { + final remoteFlutterVersion = + await remoteControllerService.findFlutterVersion( + username, + targetIp, + possibleFlutterPath, + ); + + logger.detail('remote flutter version: $remoteFlutterVersion'); + logger.detail('host flutter version: $hostFlutterVersion'); + + if (remoteFlutterVersion == hostFlutterVersion) { + logger.success( + 'You have flutter installed on the remote machine with the same version as your host machine.'); + logger.spaces(); + + return possibleFlutterPath; + } else { + return _fixConflictVersions( + username, + targetIp, + hostFlutterVersion, + remoteFlutterVersion!, + ); + } + } + + logger.info( + 'Could not find flutter in the remote machine automatically. \n\n' + 'We need the exact path of your flutter command line tools on the remote device. \n' + 'Now you have two options: \n' + '1. You can enter the path to flutter manually. \n' + '2. We can install flutter on the remote machine for you. \n'); + + logger.spaces(); + + final provideFlutterPathOption = interaction.selectIndex( + 'Please select one of the options:', + options: [ + 'Enter Flutter path manually', + 'Install Flutter on the remote machine', + ], + ); + + logger.spaces(); + + if (provideFlutterPathOption == 0) { + return interaction.readFlutterManualPath(); + } + + return _installFlutterOnRemote(username, targetIp, hostFlutterVersion); + } + + Future _fixConflictVersions( + String username, + InternetAddress targetIp, + String hostFlutterVersion, + String remoteFlutterVersion, + ) async { + logger.info( + 'To run your app on the remote device, you need the same version of Flutter on both machines. \n\n' + 'Currently, you have a different version of Flutter on the remote machine. \n' + 'Remote Flutter version: $remoteFlutterVersion \n' + 'Host Flutter version: $hostFlutterVersion \n\n' + 'You have two options: \n' + '1. Manually update your host machine to the same version as the remote machine. \n' + '2. Install the same version of Flutter on the remote machine for you. \n', + ); + + logger.spaces(); + + final provideFlutterPathOption = interaction.selectIndex( + 'Please select one of the options:', + options: [ + 'Manually update your host', + 'Install the same version on the remote', + ], + ); + + logger.spaces(); + + if (provideFlutterPathOption == 0) { + logger.info( + 'Please update your host machine to the same version as the remote machine and try again.', + ); + + throw Exception('Host Flutter version is different from the remote.'); + } + + return await _installFlutterOnRemote( + username, targetIp, hostFlutterVersion); + } + + Future _installFlutterOnRemote( + String username, + InternetAddress ip, + String version, + ) async { + final snappInstallerPath = await remoteControllerService + .findSnappInstallerPathInteractive(username, ip); + + if (snappInstallerPath == null) { + logger.info( + ''' +snapp_installer is not installed on the device +but don't worry, we will install it for you. +''', + ); + + logger.spaces(); + + final snappInstallerInstalled = await remoteControllerService + .installSnappInstallerOnRemote(username, ip); + + if (!snappInstallerInstalled) { + throw Exception('Could not install snapp_installer on the device!'); + } + + logger.success( + ''' +snapp_installer is installed on the device! +Now we can install Flutter on the device with the help of snapp_installer. +''', + ); + } + + logger.spaces(); + + final flutterInstalled = await remoteControllerService + .installFlutterOnRemote(username, ip, version: version); + + if (!flutterInstalled) { + throw Exception('Could not install Flutter on the device!'); + } + + logger.success('Flutter is installed on the device!'); + + return (await remoteControllerService.findFlutterPathInteractive( + username, + ip, + ))!; + } +} diff --git a/lib/service/embedder_provider/src/flutter_pi_provider.dart b/lib/service/embedder_provider/src/flutter_pi_provider.dart new file mode 100644 index 0000000..9eae69d --- /dev/null +++ b/lib/service/embedder_provider/src/flutter_pi_provider.dart @@ -0,0 +1,134 @@ +import 'dart:io'; + +import 'package:snapp_cli/service/embedder_provider/embedder_provider.dart'; +import 'package:snapp_cli/service/setup_device/device_setup.dart'; + +class FlutterPiEmbedderProvider extends EmbedderProvider { + const FlutterPiEmbedderProvider({ + required super.context, + required super.remoteControllerService, + }); + + @override + Future provideEmbedderPath() async { + if (context.targetIp == null || context.username == null) { + throwToolExit( + 'Target IP and username are required to provide the embedder path.'); + } + + final username = context.username!; + final targetIp = context.targetIp!; + + logger.info('Searching for flutter-pi on the remote machine...'); + logger.spaces(); + + final possibleFlutterPath = await remoteControllerService.findToolPath( + username: username, + ip: targetIp, + toolName: 'flutter-pi', + preferredPaths: [ + '/usr/local/bin', + ]); + + if (possibleFlutterPath?.isNotEmpty == true) { + logger.detail('flutter-pi found path: $possibleFlutterPath'); + + logger.success('flutter-pi found on the remote machine.'); + logger.spaces(); + + return possibleFlutterPath!; + } + + logger.info(''' +Could not find flutter-pi in the remote machine automatically. +We need the exact path of your flutter-pi command line tools on the remote device. +Now you have two options: +1. You can enter the path to flutter manually. +2 We can install flutter-pi on the remote machine for you. +'''); + + logger.spaces(); + + final provideFlutterPiPathOption = interaction.selectIndex( + 'Please select one of the options:', + options: [ + 'Enter flutter-pi path manually', + 'Install flutter-pi on the remote machine', + ], + ); + + logger.spaces(); + + if (provideFlutterPiPathOption == 0) { + return interaction.readToolManualPath( + toolName: 'flutter-pi', + examplePath: '/usr/local/bin/flutter-pi', + ); + } + + return _installFlutterPiOnRemote(username, targetIp); + } + + Future _installFlutterPiOnRemote( + String username, + InternetAddress ip, + ) async { + final snappInstallerPath = await remoteControllerService + .findSnappInstallerPathInteractive(username, ip); + + if (snappInstallerPath == null) { + logger.info( + ''' +snapp_installer is not installed on the device +but don't worry, we will install it for you. +''', + ); + + logger.spaces(); + + final snappInstallerInstalled = await remoteControllerService + .installSnappInstallerOnRemote(username, ip); + + if (!snappInstallerInstalled) { + throw Exception('Could not install snapp_installer on the device!'); + } + + logger.success( + ''' +snapp_installer is installed on the device! +Now we can install flutter-pi on the device with the help of snapp_installer. +''', + ); + } + + logger.spaces(); + + final flutterPiInstalled = + await remoteControllerService.installFlutterPiOnRemote( + username, + ip, + ); + + if (!flutterPiInstalled) { + throw Exception('Could not install flutter-pi on the device!'); + } + + logger.success('flutter-pi is installed on the device!'); + + logger.spaces(); + + logger.warn('flutter-pi needs cli-auto login to run.'); + logger.warn('Please REBOOT your remote device to apply the changes.'); + logger.warn('After rebooting, you can run your app with flutter-pi.'); + + logger.spaces(); + return (await remoteControllerService.findToolPathInteractive( + username: username, + ip: ip, + toolName: 'flutter-pi', + preferredPaths: [ + '/usr/local/bin', + ], + ))!; + } +} diff --git a/lib/service/interaction_service.dart b/lib/service/interaction_service.dart index 0c4ca47..7f880be 100644 --- a/lib/service/interaction_service.dart +++ b/lib/service/interaction_service.dart @@ -255,32 +255,41 @@ ${errorDescription ?? 'Before you can select a device, you need to add one first return label; } - Future readFlutterManualPath({String? description}) async { + Future readToolManualPath({ + required String toolName, + String? examplePath, + String? description, + }) async { logger.info( description ?? - '''You can use which command to find it in your remote machine: "which flutter" -*NOTE: if you added flutter to one of directories in \$PATH variables, you can just enter "flutter" here. -(example: /home/pi/sdk/flutter/bin/flutter)''', + '''You can use which command to find it in your remote machine: "which $toolName" +*NOTE: if you added $toolName to one of directories in \$PATH variables, you can just enter "$toolName" here. +${examplePath == null ? '' : '(example: $examplePath)'}''', ); - final manualFlutterPath = inputWithValidation( - 'Flutter path on device:', + final manualEnteredPath = inputWithValidation( + '$toolName path on device:', validator: (s) { if (s.isValidPath) { return null; } - return 'Invalid Path to flutter. Please try again.'; + return 'Invalid Path to $toolName. Please try again.'; }, ); - /// check if [manualFlutterPath] is a valid file path - if (!manualFlutterPath.isValidPath) { + /// check if [manualEnteredPath] is a valid file path + if (!manualEnteredPath.isValidPath) { throwToolExit( - 'Invalid Path to flutter. Please make sure about flutter path on the remote machine and try again.'); + 'Invalid Path to $toolName. Please make sure about $toolName path on the remote machine and try again.'); } - return manualFlutterPath; + return manualEnteredPath; } + + Future readFlutterManualPath() => readToolManualPath( + toolName: 'flutter', + examplePath: '/usr/local/bin/flutter', + ); } class Spinner { diff --git a/lib/service/logger_service.dart b/lib/service/logger_service.dart index 5c89015..7afcd94 100644 --- a/lib/service/logger_service.dart +++ b/lib/service/logger_service.dart @@ -1,5 +1,4 @@ import 'package:mason_logger/mason_logger.dart'; -import 'package:tint/tint.dart'; /// Sets default logger mode final LoggerService logger = LoggerService._(); @@ -36,14 +35,8 @@ class LoggerService { void info(String message) => _logger.info(message); void err(String message) => _logger.err(message); void detail(String message) => _logger.detail(message); - void fail(String message) => - info(icons.failure.red().bold() + message.bold()); - void success(String message) => - info(icons.success.green().bold() + message.bold()); - - String get stdout { - return logger.stdout; - } + void fail(String message) => err(icons.failure + message); + void success(String message) => _logger.success(icons.success + message); void get divider { _logger.info( diff --git a/lib/service/remote_controller_service.dart b/lib/service/remote_controller_service.dart index f00e634..e878665 100644 --- a/lib/service/remote_controller_service.dart +++ b/lib/service/remote_controller_service.dart @@ -29,26 +29,59 @@ class RemoteControllerService { final ProcessManager processManager; final ProcessUtils processRunner; - /// finds flutter in the remote machine using ssh connection - /// returns the path of flutter if found it - /// otherwise returns null - Future findFlutterPath( - String username, - InternetAddress ip, { + /// Searches for the specified tool on a remote machine using SSH and returns the path if found. + /// + /// This function connects to a remote machine via SSH and attempts to find the specified tool. + /// If the tool is found, the function will return its path. If multiple paths are found, + /// the user will be prompted to select one. It also optionally checks for preferred paths. + /// + /// Parameters: + /// - `username`: The SSH username for the remote machine. + /// - `ip`: The IP address of the remote machine. Supports both IPv4 and IPv6. + /// - `toolName`: The name of the tool to search for on the remote machine. + /// - `toolPath`: (Optional) A specific path pattern to search within for the tool. + /// - `preferredPaths`: (Optional) A list of preferred paths to prioritize if found in the search results. + /// - `addHostToKnownHosts`: (Optional) Whether to add the remote host to the known hosts list. Default is `true`. + /// + /// Example: + /// ```dart + /// final flutterPath = await findToolPath( + /// username: 'user', + /// ip: InternetAddress('192.168.1.100'), + /// toolName: 'flutter', + /// toolPath: '*/flutter/bin/*', // Optional specific path pattern + /// preferredPaths: ['/usr/local/flutter/bin/flutter'], // Optional preferred paths + /// ); + /// ``` + /// + /// Notes: + /// - If `toolPath` is provided, the function will search within this specific path pattern. + /// - If multiple paths are found, the user will be prompted to select one from the list. + /// - If `preferredPaths` are provided, they will be prioritized in the selection process if found. + /// - This function uses a spinner to indicate progress and logs detailed messages during the execution. + Future findToolPath({ + required String username, + required InternetAddress ip, + required String toolName, + String? toolPath, + List? preferredPaths, bool addHostToKnownHosts = true, }) async { final spinner = interaction.spinner( - inProgressMessage: 'search for flutter path on remote device.', - doneMessage: 'search for flutter path completed', - failedMessage: 'search for flutter path failed', + inProgressMessage: 'search for $toolName path on remote device.', + doneMessage: 'search for $toolName path completed', + failedMessage: 'search for $toolName path failed', ); + final searchCommand = toolPath != null + ? 'find / -type f -name "$toolName" -path "$toolPath" 2>/dev/null' + : 'find / -type f -name "$toolName" 2>/dev/null'; + final output = await processRunner.runCommand( hostPlatform.sshCommand( ipv6: ip.isIpv6, sshTarget: ip.sshTarget(username), - command: - 'find / -type f -name "flutter" -path "*/flutter/bin/*" 2>/dev/null', + command: searchCommand, addHostToKnownHosts: addHostToKnownHosts, ), timeout: const Duration(seconds: 30), @@ -66,7 +99,7 @@ class RemoteControllerService { }, parseFail: (e, s) { logger.detail( - 'Something went wrong while trying to find flutter. \n $e \n $s', + 'Something went wrong while trying to find $toolName. \n $e \n $s', ); logger.spaces(); @@ -74,11 +107,11 @@ class RemoteControllerService { return null; }, spinner: spinner, - label: 'Find Flutter', + label: 'Find $toolName', logger: logger, ); - logger.detail('Find Flutter output: $output'); + logger.detail('Find $toolName output: $output'); logger.spaces(); @@ -88,47 +121,68 @@ class RemoteControllerService { final isOutputMultipleLines = outputLinesLength > 1; if (!isOutputMultipleLines) { - logger.info('We found flutter in "$output" in the remote machine. '); + logger.info('We found $toolName in "$output" in the remote machine. '); - final flutterSdkPathConfirmation = interaction.confirm( + final toolPathConfirmation = interaction.confirm( 'Do you want to use this path?', defaultValue: true, // this is optional ); - return flutterSdkPathConfirmation ? output : null; + return toolPathConfirmation ? output : null; } else { + logger.info('We found multiple $toolName paths in the remote machine. '); + final outputLines = output .split('\n') .map((e) => e.trim()) .toList() .sublist(0, min(2, outputLinesLength)); - logger.info('We found multiple flutter paths in the remote machine. '); + if (preferredPaths != null) { + final preferredPathsSet = preferredPaths.toSet(); + + final preferredPathsInOutput = outputLines + .where( + (line) => preferredPathsSet.any( + (path) => line.contains(path), + ), + ) + .toList(); + + if (preferredPathsInOutput.isNotEmpty) { + if (preferredPathsInOutput.length == 1) { + return preferredPathsInOutput.first; + } + + return interaction.select( + 'Please select the path of $toolName you want to use.', + options: preferredPathsInOutput, + ); + } + } return interaction.select( - 'Please select the path of flutter you want to use.', + 'Please select the path of $toolName you want to use.', options: outputLines, ); } } - Future findFlutterVersion( - String username, - InternetAddress ip, - String flutterRunnerPath, { + Future findToolPathInteractive({ + required String username, + required InternetAddress ip, + required String toolName, + String? toolPath, + List? preferredPaths, bool addHostToKnownHosts = true, }) async { - final spinner = interaction.spinner( - inProgressMessage: 'Search for flutter version on remote device.', - doneMessage: 'Search for flutter version completed', - failedMessage: 'Search for flutter version failed', - ); - - final output = await processRunner.runCommand( + return processRunner.runCommand( hostPlatform.sshCommand( ipv6: ip.isIpv6, sshTarget: ip.sshTarget(username), - command: '$flutterRunnerPath --version --machine', + command: toolPath != null + ? 'find / -type f -name "$toolName" -path "$toolPath" 2>/dev/null' + : 'find / -type f -name "$toolName" 2>/dev/null', addHostToKnownHosts: addHostToKnownHosts, ), throwOnError: false, @@ -136,100 +190,125 @@ class RemoteControllerService { final output = runResult.stdout.trim(); if (runResult.exitCode != 0 && output.isEmpty) { - logger.spaces(); - return null; } - final jsonOutput = jsonDecode(output); - - logger.detail('Find Flutter Version jsonOutput: $jsonOutput'); - - logger.detail( - 'Find Flutter Version jsonOutput[flutterVersion]: ${jsonOutput['flutterVersion']}'); + final outputLines = output.split('\n').map((e) => e.trim()).toList(); - return jsonOutput['flutterVersion'] as String; - }, - parseFail: (e, s) { logger.detail( - 'Something went wrong while trying to find flutter version. \n $e \n $s', - ); - - logger.spaces(); - - return null; - }, - spinner: spinner, - label: 'Find Flutter Version', - logger: logger, - ); - - logger.detail('Find Flutter Version output: $output'); - - logger.spaces(); - - return output; - } + 'findToolPathInteractive $toolName outputLines: $outputLines'); - Future findFlutterPathInteractive( - String username, - InternetAddress ip, { - bool addHostToKnownHosts = true, - }) async { - return processRunner.runCommand( - hostPlatform.sshCommand( - ipv6: ip.isIpv6, - sshTarget: ip.sshTarget(username), - command: - 'find / -type f -name "flutter" -path "*/flutter/bin/*" 2>/dev/null', - addHostToKnownHosts: addHostToKnownHosts, - ), - throwOnError: false, - parseResult: (runResult) { - final output = runResult.stdout.trim(); + final outputLinesLength = outputLines.length; + final isOutputMultipleLines = outputLinesLength > 1; - if (runResult.exitCode != 0 && output.isEmpty) { - return null; + final preferredPathsSet = preferredPaths?.toSet(); + + if (preferredPathsSet != null) { + final preferredPathsInOutput = outputLines + .where( + (line) => preferredPathsSet.any( + (path) => line.contains(path), + ), + ) + .toList(); + + if (preferredPathsInOutput.isNotEmpty) { + logger.detail('Find $toolName in Preferred Paths'); + logger.detail('Preferred Paths in Output: $preferredPathsInOutput'); + return preferredPathsInOutput.first; + } } - final outputLines = output.split('\n').map((e) => e.trim()).toList(); - final outputLinesLength = outputLines.length; - final isOutputMultipleLines = outputLinesLength > 1; + logger.detail('Find $toolName in Output'); + logger.detail('Output: $outputLines'); return isOutputMultipleLines ? outputLines.first : output; }, parseFail: (e, s) { logger.detail( - 'Something went wrong while trying to find flutter. \n $e \n $s', + 'Something went wrong while trying to find $toolName. \n $e \n $s', ); return null; }, - label: 'Find Flutter', + label: 'Find $toolName', logger: logger, ); } - /// finds snapp_installer in the remote machine using ssh connection - /// returns the path of snapp_installer if found it + /// finds flutter in the remote machine using ssh connection + /// returns the path of flutter if found it /// otherwise returns null + Future findFlutterPath( + String username, + InternetAddress ip, + ) => + findToolPath( + username: username, + ip: ip, + toolName: 'flutter', + toolPath: '*/flutter/bin/*', + ); + + /// 'find / -type f -name "flutter" -path "*/flutter/bin/*" 2>/dev/null', + Future findFlutterPathInteractive( + String username, + InternetAddress ip, { + bool addHostToKnownHosts = true, + }) => + findToolPathInteractive( + username: username, + ip: ip, + toolName: 'flutter', + toolPath: '*/flutter/bin/*', + addHostToKnownHosts: addHostToKnownHosts, + ); + Future findSnappInstallerPath( String username, InternetAddress ip, { bool addHostToKnownHosts = true, + }) => + findToolPath( + username: username, + ip: ip, + toolName: 'snapp_installer', + toolPath: '*/snapp_installer/bin/*', + ); + + /// finds snapp_installer in the remote machine using ssh connection interactively + /// + /// this method is not communicating with the user directly + Future findSnappInstallerPathInteractive( + String username, + InternetAddress ip, { + bool addHostToKnownHosts = true, + }) => + findToolPathInteractive( + username: username, + ip: ip, + toolName: 'snapp_installer', + toolPath: '*/snapp_installer/bin/*', + addHostToKnownHosts: addHostToKnownHosts, + ); + + Future findFlutterVersion( + String username, + InternetAddress ip, + String flutterRunnerPath, { + bool addHostToKnownHosts = true, }) async { final spinner = interaction.spinner( - inProgressMessage: 'search for snapp_installer path on remote device.', - doneMessage: 'search for snapp_installer path completed', - failedMessage: 'search for snapp_installer path failed', + inProgressMessage: 'Search for flutter version on remote device.', + doneMessage: 'Search for flutter version completed', + failedMessage: 'Search for flutter version failed', ); final output = await processRunner.runCommand( hostPlatform.sshCommand( ipv6: ip.isIpv6, sshTarget: ip.sshTarget(username), - command: - 'find / -type f -name "snapp_installer" -path "*/snapp_installer/bin/*" 2>/dev/null', + command: '$flutterRunnerPath --version --machine', addHostToKnownHosts: addHostToKnownHosts, ), throwOnError: false, @@ -242,11 +321,18 @@ class RemoteControllerService { return null; } - return output; + final jsonOutput = jsonDecode(output); + + logger.detail('Find Flutter Version jsonOutput: $jsonOutput'); + + logger.detail( + 'Find Flutter Version jsonOutput[flutterVersion]: ${jsonOutput['flutterVersion']}'); + + return jsonOutput['flutterVersion'] as String; }, parseFail: (e, s) { logger.detail( - 'Something went wrong while trying to find flutter. \n $e \n $s', + 'Something went wrong while trying to find flutter version. \n $e \n $s', ); logger.spaces(); @@ -254,72 +340,15 @@ class RemoteControllerService { return null; }, spinner: spinner, - label: 'Find Snapp Installer', + label: 'Find Flutter Version', logger: logger, ); - logger.spaces(); - - if (output == null) return null; - - final outputLinesLength = output.split('\n').length; - final isOutputMultipleLines = outputLinesLength > 1; - - if (!isOutputMultipleLines) { - logger.info( - 'We found snapp_installer in "$output" in the remote machine. '); - - final snappInstallerPathConfirmation = interaction.confirm( - 'Do you want to use this path?', - defaultValue: true, - ); - - return snappInstallerPathConfirmation ? output : null; - } - - return null; - } - - /// finds snapp_installer in the remote machine using ssh connection interactively - /// - /// this method is not communicating with the user directly - Future findSnappInstallerPathInteractive( - String username, - InternetAddress ip, { - bool addHostToKnownHosts = true, - }) async { - return processRunner.runCommand( - hostPlatform.sshCommand( - ipv6: ip.isIpv6, - sshTarget: ip.sshTarget(username), - command: - 'find / -type f -name "snapp_installer" -path "*/snapp_installer/bin/*" 2>/dev/null', - addHostToKnownHosts: addHostToKnownHosts, - ), - throwOnError: false, - parseResult: (runResult) { - final output = runResult.stdout.trim(); - - if (runResult.exitCode != 0 && output.isEmpty) { - return null; - } - - final outputLines = output.split('\n').map((e) => e.trim()).toList(); - final outputLinesLength = outputLines.length; - final isOutputMultipleLines = outputLinesLength > 1; + logger.detail('Find Flutter Version output: $output'); - return isOutputMultipleLines ? outputLines.first : output; - }, - parseFail: (e, s) { - logger.detail( - 'Something went wrong while trying to find snapp_installer. \n $e \n $s', - ); + logger.spaces(); - return null; - }, - logger: logger, - label: 'Find Snapp Installer', - ); + return output; } /// install snapp_installer in the remote machine using ssh connection @@ -423,4 +452,51 @@ class RemoteControllerService { return result.exitCode == 0; } + + Future installFlutterPiOnRemote( + String username, + InternetAddress ip, { + bool addHostToKnownHosts = true, + }) async { + final snappInstallerPath = await findSnappInstallerPathInteractive( + username, + ip, + ); + + final RunResult result; + + final installFlutterPiCommand = '$snappInstallerPath install_flutter_pi'; + + logger.detail('Install Flutter Pi Command: \n $installFlutterPiCommand'); + + try { + result = await processRunner.runWithOutput( + hostPlatform.sshCommand( + ipv6: ip.isIpv6, + sshTarget: ip.sshTarget(username), + lastCommand: true, + command: installFlutterPiCommand, + addHostToKnownHosts: addHostToKnownHosts, + ), + processManager: processManager, + logger: logger, + ); + } catch (e, s) { + logger.detail( + 'Something went wrong while trying to install flutter-pi on the remote. \n $e \n $s', + ); + + return false; + } finally { + logger.spaces(); + } + + if (result.exitCode != 0) { + logger.info('Flutter Pi Installer ExitCode: ${result.exitCode}'); + logger.info('Flutter Pi Installer Stdout: ${result.stdout.trim()}'); + logger.info('Flutter Pi Installer Stderr: ${result.stderr}'); + } + + return result.exitCode == 0; + } } diff --git a/lib/service/setup_device/device_config_context.dart b/lib/service/setup_device/device_config_context.dart new file mode 100644 index 0000000..e55008d --- /dev/null +++ b/lib/service/setup_device/device_config_context.dart @@ -0,0 +1,103 @@ +import 'dart:io'; + +import 'package:snapp_cli/configs/embedder.dart'; +import 'package:snapp_cli/utils/common.dart'; + +class DeviceConfigContext { + const DeviceConfigContext({ + this.id, + this.label, + this.targetIp, + this.username, + this.remoteHasSshConnection = false, + this.appExecuterPath, + this.embedder, + }); + + static const DeviceConfigContext empty = DeviceConfigContext(); + + final String? id; + final String? label; + final InternetAddress? targetIp; + final String? username; + final bool remoteHasSshConnection; + final String? appExecuterPath; + final FlutterEmbedder? embedder; + + bool? get ipv6 => targetIp?.isIpv6; + + InternetAddress? get loopbackIp => ipv6 == null + ? null + : ipv6! + ? InternetAddress.loopbackIPv6 + : InternetAddress.loopbackIPv4; + + String? get sshTarget => targetIp == null || username == null + ? null + : targetIp!.sshTarget(username!); + + String? get formattedLoopbackIp => loopbackIp == null + ? null + : ipv6 == true + ? '[${loopbackIp!.address}]' + : loopbackIp!.address; + + String? get sdkName => embedder?.sdkName; + + String get formattedLabel { + if(label ==null || embedder == null) { + return ''; + } + + return '$label-[${embedder!.label}]'; + } + + DeviceConfigContext copyWith({ + String? id, + String? label, + InternetAddress? targetIp, + String? username, + bool? remoteHasSshConnection, + String? appExecuterPath, + FlutterEmbedder? embedder, + }) { + return DeviceConfigContext( + id: id ?? this.id, + label: label ?? this.label, + targetIp: targetIp ?? this.targetIp, + username: username ?? this.username, + remoteHasSshConnection: + remoteHasSshConnection ?? this.remoteHasSshConnection, + appExecuterPath: appExecuterPath ?? this.appExecuterPath, + embedder: embedder ?? this.embedder, + ); + } + + @override + bool operator ==(Object other) => + identical(this, other) || + other is DeviceConfigContext && + runtimeType == other.runtimeType && + id == other.id && + label == other.label && + targetIp == other.targetIp && + username == other.username && + remoteHasSshConnection == other.remoteHasSshConnection && + appExecuterPath == other.appExecuterPath && + embedder == other.embedder; + + @override + int get hashCode => + id.hashCode ^ + label.hashCode ^ + targetIp.hashCode ^ + username.hashCode ^ + remoteHasSshConnection.hashCode ^ + appExecuterPath.hashCode ^ + embedder.hashCode; + + @override + String toString() { + return 'DeviceConfigContext{id: $id, label: $label, targetIp: $targetIp, username: $username, remoteHasSshConnection: $remoteHasSshConnection, appExecuter: $appExecuterPath, embedder: $embedder}'; + } +} diff --git a/lib/service/setup_device/device_setup.dart b/lib/service/setup_device/device_setup.dart new file mode 100644 index 0000000..3e4d0a3 --- /dev/null +++ b/lib/service/setup_device/device_setup.dart @@ -0,0 +1,103 @@ +import 'package:snapp_cli/service/setup_device/device_config_context.dart'; + +export 'package:snapp_cli/service/logger_service.dart'; +export 'package:snapp_cli/commands/base_command.dart'; + +export 'src/device_host_provider.dart'; +export 'src/device_type_provider.dart'; +export 'src/app_executer_provider.dart'; +export 'src/ssh_connection_provider.dart'; + +export 'package:snapp_cli/service/setup_device/device_config_context.dart'; + +/// The `DeviceSetup` class orchestrates a series of setup steps for configuring a [DeviceConfigContext]. +/// It follows the Chain of Responsibility pattern, where each setup step processes the +/// device context and passes it to the next step in the sequence. +/// +/// Example usage: +/// ```dart +/// List steps = [ +/// Step1(), +/// Step2(), +/// Step3(), +/// ]; +/// +/// DeviceSetup deviceSetup = DeviceSetup(steps: steps); +/// DeviceConfigContext context = await deviceSetup.setup(); +/// ``` +/// +/// In this example, `Step1`, `Step2`, and `Step3` are custom implementations of `DeviceSetupStep`. +/// Each step modifies the `DeviceConfigContext` and passes it to the next step in the sequence. +class DeviceSetup { + DeviceSetup({ + required List steps, + }) : assert(steps.isNotEmpty, 'steps list cannot be empty'), + _chain = steps.first { + // Iterate through the list and link each handler to the next one. + for (int i = 0; i < steps.length - 1; i++) { + steps[i].setNext(steps[i + 1]); + } + } + + final DeviceSetupStep _chain; + + /// Starts the setup process by passing the [context] to the first handler in the chain. + /// + /// If no context is provided, the default context `DeviceSetupContext.empty` is used. + /// + /// Returns a `Future` representing the final context after all steps + /// have processed it. + /// + /// The [context] parameter is the initial setup context to be processed. If not provided, + /// the default empty context is used. + /// + /// Example usage with a custom context: + /// ```dart + /// DeviceSetupContext initialContext = DeviceSetupContext(id: '123', label: 'My Device'); + /// DeviceSetupContext result = await deviceSetup.setup(customContext); + /// ``` + Future setup( + [DeviceConfigContext context = DeviceConfigContext.empty]) { + return _chain.handle(context); + } +} + +/// The `DeviceSetupStep` class represents a single step in the device setup process. +/// It is an abstract class that defines the interface for processing a `DeviceConfigContext`. +/// +/// Each concrete subclass must implement the `execute` method to perform specific actions +/// on the provided context. +/// +/// The `DeviceSetupStep` class supports chaining by holding a reference to the next step, +/// allowing a sequence of steps to be linked together. +/// +/// Example subclass implementation: +/// ```dart +/// class Step1 extends DeviceSetupStep { +/// @override +/// Future execute(DeviceConfigContext context) async { +/// // Perform specific actions to modify the context +/// // For example, set some properties or check conditions +/// return context.copyWith(...); +/// } +/// } +/// ``` +abstract class DeviceSetupStep { + DeviceSetupStep? nextHandler; + + void setNext(DeviceSetupStep handler) { + nextHandler = handler; + } + + Future execute(DeviceConfigContext context); + + Future handle(DeviceConfigContext context) async { + final DeviceConfigContext updatedContext = await execute(context); + + if (nextHandler != null) { + return nextHandler!.handle(updatedContext); + } + + return updatedContext; + } +} diff --git a/lib/service/setup_device/src/app_executer_provider.dart b/lib/service/setup_device/src/app_executer_provider.dart new file mode 100644 index 0000000..b41eeb0 --- /dev/null +++ b/lib/service/setup_device/src/app_executer_provider.dart @@ -0,0 +1,29 @@ +import 'package:snapp_cli/service/embedder_provider/embedder_provider.dart'; +import 'package:snapp_cli/service/remote_controller_service.dart'; +import 'package:snapp_cli/service/setup_device/device_setup.dart'; + +class AppExecuterProvider extends DeviceSetupStep { + final RemoteControllerService remoteControllerService; + + AppExecuterProvider({required this.remoteControllerService}); + + @override + Future execute(DeviceConfigContext context) async { + logger.spaces(); + + final selectedEmbedder = context.embedder!; + + final embedderProvider = EmbedderProvider.create( + selectedEmbedder, + context, + remoteControllerService, + ); + + final executablePath = await embedderProvider.provideEmbedderPath(); + + return context.copyWith( + appExecuterPath: executablePath, + embedder: selectedEmbedder, + ); + } +} diff --git a/lib/service/setup_device/src/custom_embedder_provider.dart b/lib/service/setup_device/src/custom_embedder_provider.dart new file mode 100644 index 0000000..e001e83 --- /dev/null +++ b/lib/service/setup_device/src/custom_embedder_provider.dart @@ -0,0 +1,34 @@ +import 'package:recase/recase.dart'; +import 'package:snapp_cli/configs/embedder.dart'; +import 'package:snapp_cli/service/setup_device/device_setup.dart'; + +class CustomEmbedderProvider extends DeviceSetupStep { + + CustomEmbedderProvider(); + + @override + Future execute(DeviceConfigContext context) async { + logger.spaces(); + + logger.info( + ''' +To execute the app on the remote device, we need to know the app executer. +The app executer is a tool that will be used to run the app on the remote device. +The app executer can be the official Flutter Linux embedder or a custom embedder like Flutter-pi or ivi-homescreen. +''', + ); + + logger.spaces(); + + final selectedEmbedderIndex = interaction.selectIndex( + 'Select the app executer', + options: FlutterEmbedder.values.map((e) => e.name.paramCase).toList(), + ); + + final selectedEmbedder = FlutterEmbedder.values[selectedEmbedderIndex]; + + return context.copyWith( + embedder: selectedEmbedder, + ); + } +} diff --git a/lib/service/setup_device/src/device_host_provider.dart b/lib/service/setup_device/src/device_host_provider.dart new file mode 100644 index 0000000..e854747 --- /dev/null +++ b/lib/service/setup_device/src/device_host_provider.dart @@ -0,0 +1,25 @@ +import 'package:snapp_cli/service/setup_device/device_setup.dart'; + +class DeviceHostProvider extends DeviceSetupStep { + @override + Future execute(DeviceConfigContext context) async { + // get remote device ip and username from the user + logger.spaces(); + + logger.info('to setup a new device, we need an IP address and a username.'); + + final targetIp = interaction.readDeviceIp( + description: + 'Please enter the IP-address of the device. (example: 192.168.1.101)'); + + final username = interaction.readDeviceUsername( + description: + 'Please enter the username used for ssh-ing into the remote device. (example: pi)', + ); + + return context.copyWith( + targetIp: targetIp, + username: username, + ); + } +} diff --git a/lib/service/setup_device/src/device_type_provider.dart b/lib/service/setup_device/src/device_type_provider.dart new file mode 100644 index 0000000..a6531de --- /dev/null +++ b/lib/service/setup_device/src/device_type_provider.dart @@ -0,0 +1,81 @@ +import 'package:snapp_cli/configs/predefined_devices.dart'; +import 'package:snapp_cli/service/setup_device/device_setup.dart'; +// ignore: implementation_imports +import 'package:flutter_tools/src/custom_devices/custom_devices_config.dart'; + +class DeviceTypeProvider extends DeviceSetupStep { + DeviceTypeProvider({required this.customDevicesConfig}); + + final CustomDevicesConfig customDevicesConfig; + + @override + Future execute(DeviceConfigContext context) async { + final addCommandOptions = [ + 'Express (recommended)', + 'Custom', + ]; + + final commandIndex = interaction.selectIndex( + 'Please select the type of device you want to setup.', + options: addCommandOptions, + ); + + if (commandIndex == 0) { + return _setupPredefinedDevice(context); + } + + return _setupCustomDevice(context); + } + + Future _setupPredefinedDevice( + DeviceConfigContext context) async { + logger.spaces(); + + final deviceKey = interaction.select( + 'Select your device', + options: predefinedDevices.keys.toList(), + ); + + var predefinedDeviceConfig = predefinedDevices[deviceKey]; + + if (predefinedDeviceConfig == null) { + throwToolExit( + 'Something went wrong while trying to setup predefined $deviceKey device.'); + } + + /// check if the device id already exists in the config file + /// update the id if it + if (_isDuplicatedDeviceId(predefinedDeviceConfig.id)) { + predefinedDeviceConfig = predefinedDeviceConfig.copyWith( + id: _suggestIdForDuplicatedDeviceId(predefinedDeviceConfig.id), + ); + } + + return context.copyWith( + id: predefinedDeviceConfig.id, + label: predefinedDeviceConfig.label, + ); + } + + Future _setupCustomDevice( + DeviceConfigContext context) async { + final id = interaction.readDeviceId(customDevicesConfig); + final label = interaction.readDeviceLabel(); + + return context.copyWith(id: id, label: label); + } + + bool _isDuplicatedDeviceId(String s) { + return customDevicesConfig.devices.any((element) => element.id == s); + } + + String _suggestIdForDuplicatedDeviceId(String s) { + int i = 1; + + while (_isDuplicatedDeviceId('$s-$i')) { + i++; + } + + return '$s-$i'; + } +} diff --git a/lib/service/setup_device/src/install_dependency_provider.dart b/lib/service/setup_device/src/install_dependency_provider.dart new file mode 100644 index 0000000..8b43c51 --- /dev/null +++ b/lib/service/setup_device/src/install_dependency_provider.dart @@ -0,0 +1,46 @@ +import 'package:recase/recase.dart'; +import 'package:snapp_cli/flutter_sdk.dart'; +import 'package:snapp_cli/service/dependency_installer/dependency_installer.dart'; +import 'package:snapp_cli/service/remote_controller_service.dart'; +import 'package:snapp_cli/service/setup_device/device_setup.dart'; + +class InstallDependencyProvider extends DeviceSetupStep { + InstallDependencyProvider({ + required this.remoteControllerService, + required this.flutterSdkManager, + }); + + final RemoteControllerService remoteControllerService; + final FlutterSdkManager flutterSdkManager; + + @override + Future execute(DeviceConfigContext context) async { + logger.spaces(); + + logger.info( + ''' +Installing required dependencies to run the app on the remote device. +Selected custom embedder: ${context.embedder?.name.paramCase} + + +''', + ); + + final dependencyInstaller = DependencyInstaller.create( + context.embedder!, + flutterSdkManager: flutterSdkManager, + remoteControllerService: remoteControllerService, + ); + + final isDependenciesInstalled = await dependencyInstaller.install(); + + if (!isDependenciesInstalled) { + logger.err( + 'Failed to install dependencies! for ${context.embedder?.name.paramCase} embedder.'); + throwToolExit( + 'Failed to install dependencies! for ${context.embedder?.name.paramCase} embedder.'); + } + + return context; + } +} diff --git a/lib/service/setup_device/src/ssh_connection_provider.dart b/lib/service/setup_device/src/ssh_connection_provider.dart new file mode 100644 index 0000000..7564b9c --- /dev/null +++ b/lib/service/setup_device/src/ssh_connection_provider.dart @@ -0,0 +1,60 @@ +import 'package:snapp_cli/service/setup_device/device_setup.dart'; +import 'package:snapp_cli/service/ssh_service.dart'; + +class SshConnectionProvider extends DeviceSetupStep { + final SshService sshService; + + SshConnectionProvider(this.sshService); + + @override + Future execute(DeviceConfigContext context) async { + if (context.targetIp == null || context.username == null) { + throw Exception('Missing target IP or username for SSH connection.'); + } + + logger.spaces(); + + final username = context.username!; + final targetIp = context.targetIp!; + + bool remoteHasSshConnection = + await sshService.testPasswordLessSshConnection(username, targetIp); + + if (!remoteHasSshConnection) { + logger.fail( + 'could not establish a password-less ssh connection to the remote device. \n', + ); + + logger.info( + 'We can create a ssh connection with the remote device, do you want to try it?'); + + final continueWithoutPing = interaction.confirm( + 'Create a ssh connection?', + defaultValue: true, + ); + + if (!continueWithoutPing) { + logger.spaces(); + throwToolExit( + 'Check your ssh connection with the remote device and try again.', + exitCode: 1, + ); + } + + logger.spaces(); + + final sshConnectionCreated = + await sshService.createPasswordLessSshConnection(username, targetIp); + + if (sshConnectionCreated) { + logger.success('SSH connection to the remote device is created!'); + remoteHasSshConnection = true; + } else { + logger.fail('Could not create SSH connection to the remote device!'); + throwToolExit(' SSH connection failed.', exitCode: 1); + } + } + + return context.copyWith(remoteHasSshConnection: remoteHasSshConnection); + } +} diff --git a/pubspec.yaml b/pubspec.yaml index 85dcce0..a34357d 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -1,6 +1,6 @@ name: snapp_cli description: snapp_cli is a Dart command-line tool designed to simplify the process of adding custom devices to the Flutter SDK. -version: 0.6.0 +version: 0.6.1 repository: https://github.com/Snapp-Embedded/snapp_cli topics: [cli, devices, embedded, raspberry, snapp] @@ -19,11 +19,11 @@ dependencies: collection: ^1.18.0 dartssh2: ^2.8.2 yaml: ^3.1.2 - mason_logger: ^0.2.10 - tint: ^2.0.1 + mason_logger: ^0.2.15 process: ^5.0.2 pub_semver: ^2.1.4 http: ^0.13.6 + recase: ^4.1.0 dev_dependencies: lints: ^4.0.0