diff --git a/.github/ISSUE_TEMPLATE/ config.yml b/.github/ISSUE_TEMPLATE/ config.yml new file mode 100644 index 0000000..3ba13e0 --- /dev/null +++ b/.github/ISSUE_TEMPLATE/ config.yml @@ -0,0 +1 @@ +blank_issues_enabled: false diff --git a/.github/ISSUE_TEMPLATE/bug_report.md b/.github/ISSUE_TEMPLATE/bug_report.md new file mode 100644 index 0000000..50a4c7b --- /dev/null +++ b/.github/ISSUE_TEMPLATE/bug_report.md @@ -0,0 +1,29 @@ +--- +name: Bug Report +about: Create a report to help us improve +title: "fix: " +labels: bug +--- + +**Description** + +A clear and concise description of what the bug is. + +**Steps To Reproduce** + +1. Go to '...' +2. Click on '....' +3. Scroll down to '....' +4. See error + +**Expected Behavior** + +A clear and concise description of what you expected to happen. + +**Screenshots** + +If applicable, add screenshots to help explain your problem. + +**Additional Context** + +Add any other context about the problem here. diff --git a/.github/ISSUE_TEMPLATE/feature_request.md b/.github/ISSUE_TEMPLATE/feature_request.md new file mode 100644 index 0000000..ddd2fcc --- /dev/null +++ b/.github/ISSUE_TEMPLATE/feature_request.md @@ -0,0 +1,18 @@ +--- +name: Feature Request +about: A new feature to be added to the project +title: "feat: " +labels: feature +--- + +**Description** + +Clearly describe what you are looking to add. The more context the better. + +**Requirements** + +- [ ] Checklist of requirements to be fulfilled + +**Additional Context** + +Add any other context or screenshots about the feature request go here. diff --git a/.github/PULL_REQUEST_TEMPLATE.md b/.github/PULL_REQUEST_TEMPLATE.md new file mode 100644 index 0000000..6b9372e --- /dev/null +++ b/.github/PULL_REQUEST_TEMPLATE.md @@ -0,0 +1,23 @@ + + +## Description + + + +## Type of Change + + + +- [ ] โœจ New feature (non-breaking change which adds functionality) +- [ ] ๐Ÿ› ๏ธ Bug fix (non-breaking change which fixes an issue) +- [ ] โŒ Breaking change (fix or feature that would cause existing functionality to change) +- [ ] ๐Ÿงน Code refactor +- [ ] โœ… Build configuration change +- [ ] ๐Ÿ“ Documentation +- [ ] ๐Ÿ—‘๏ธ Chore diff --git a/.github/cspell.json b/.github/cspell.json new file mode 100644 index 0000000..1b78898 --- /dev/null +++ b/.github/cspell.json @@ -0,0 +1,20 @@ +{ + "version": "0.2", + "$schema": "https://raw.githubusercontent.com/streetsidesoftware/cspell/main/cspell.schema.json", + "dictionaries": ["vgv_allowed", "vgv_forbidden"], + "dictionaryDefinitions": [ + { + "name": "vgv_allowed", + "path": "https://raw.githubusercontent.com/verygoodopensource/very_good_dictionaries/main/allowed.txt", + "description": "Allowed VGV Spellings" + }, + { + "name": "vgv_forbidden", + "path": "https://raw.githubusercontent.com/verygoodopensource/very_good_dictionaries/main/forbidden.txt", + "description": "Forbidden VGV Spellings" + } + ], + "useGitignore": true, + "words": [ + ] +} diff --git a/.github/dependabot.yaml b/.github/dependabot.yaml new file mode 100644 index 0000000..63b035c --- /dev/null +++ b/.github/dependabot.yaml @@ -0,0 +1,11 @@ +version: 2 +enable-beta-ecosystems: true +updates: + - package-ecosystem: "github-actions" + directory: "/" + schedule: + interval: "daily" + - package-ecosystem: "pub" + directory: "/" + schedule: + interval: "daily" diff --git a/.github/workflows/flutter_compile.yaml b/.github/workflows/flutter_compile.yaml new file mode 100644 index 0000000..0683e30 --- /dev/null +++ b/.github/workflows/flutter_compile.yaml @@ -0,0 +1,55 @@ +name: flutter_compile + +concurrency: + group: ${{ github.workflow }}-${{ github.ref }} + cancel-in-progress: true + +on: + pull_request: + paths: + - ".github/workflows/flutter_compile.yaml" + - "lib/**" + - "test/**" + - "pubspec.yaml" + push: + branches: + - main + paths: + - ".github/workflows/flutter_compile.yaml" + - "lib/**" + - "test/**" + - "pubspec.yaml" + +jobs: + semantic-pull-request: + uses: VeryGoodOpenSource/very_good_workflows/.github/workflows/semantic_pull_request.yml@v1 + + build: + uses: VeryGoodOpenSource/very_good_workflows/.github/workflows/dart_package.yml@v1 + + spell-check: + uses: VeryGoodOpenSource/very_good_workflows/.github/workflows/spell_check.yml@v1 + with: + includes: | + **/*.md + !brick/**/*.md + .*/**/*.md + modified_files_only: false + + verify-version: + runs-on: ubuntu-latest + steps: + - name: ๐Ÿ“š Git Checkout + uses: actions/checkout@v2 + + - name: ๐ŸŽฏ Setup Dart + uses: dart-lang/setup-dart@v1 + with: + sdk: "stable" + + - name: ๐Ÿ“ฆ Install Dependencies + run: | + dart pub get + + - name: ๐Ÿ”Ž Verify version + run: dart run test --run-skipped -t version-verify diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..9f6ee8a --- /dev/null +++ b/.gitignore @@ -0,0 +1,15 @@ +# See https://www.dartlang.org/guides/libraries/private-files + +# Files and directories created by pub +.dart_tool/ +.packages +build/ +pubspec.lock + +# Files generated during tests +.test_coverage.dart +coverage/ +.test_runner.dart + +# Android studio and IntelliJ +.idea \ No newline at end of file diff --git a/README.md b/README.md new file mode 100644 index 0000000..e1b90ec --- /dev/null +++ b/README.md @@ -0,0 +1,71 @@ +## flutter_compile + +![coverage][coverage_badge] +[![style: very good analysis][very_good_analysis_badge]][very_good_analysis_link] +[![License: MIT][license_badge]][license_link] + +Generated by the [Very Good CLI][very_good_cli_link] ๐Ÿค– + +A Dart CLI to simplify the setting up your framework development environment, which describes and automates the steps you need to configure your computer to work on Flutter's Framework. + +--- + +## Getting Started ๐Ÿš€ + +If the CLI application is available on [pub](https://pub.dev), activate globally via: + +```sh +dart pub global activate flutter_compile +``` + +Or locally via: + +```sh +dart pub global activate --source=path +``` + +## Usage + +```sh +# Sample command +$ flutter_compile sample + +# Sample command option +$ flutter_compile sample --cyan + +# Show CLI version +$ flutter_compile --version + +# Show usage help +$ flutter_compile --help +``` + +## Running Tests with coverage ๐Ÿงช + +To run all unit tests use the following command: + +```sh +$ dart pub global activate coverage 1.2.0 +$ dart test --coverage=coverage +$ dart pub global run coverage:format_coverage --lcov --in=coverage --out=coverage/lcov.info +``` + +To view the generated coverage report you can use [lcov](https://github.com/linux-test-project/lcov) +. + +```sh +# Generate Coverage Report +$ genhtml coverage/lcov.info -o coverage/ + +# Open Coverage Report +$ open coverage/index.html +``` + +--- + +[coverage_badge]: coverage_badge.svg +[license_badge]: https://img.shields.io/badge/license-MIT-blue.svg +[license_link]: https://opensource.org/licenses/MIT +[very_good_analysis_badge]: https://img.shields.io/badge/style-very_good_analysis-B22C89.svg +[very_good_analysis_link]: https://pub.dev/packages/very_good_analysis +[very_good_cli_link]: https://github.com/VeryGoodOpenSource/very_good_cli \ No newline at end of file diff --git a/analysis_options.yaml b/analysis_options.yaml new file mode 100644 index 0000000..3a6b5d4 --- /dev/null +++ b/analysis_options.yaml @@ -0,0 +1,4 @@ +include: package:very_good_analysis/analysis_options.6.0.0.yaml +linter: + rules: + public_member_api_docs: false diff --git a/bin/flutter_compile.dart b/bin/flutter_compile.dart new file mode 100644 index 0000000..b0cd36c --- /dev/null +++ b/bin/flutter_compile.dart @@ -0,0 +1,271 @@ +import 'dart:convert'; +import 'dart:io'; + + +void main() async { + print('Flutter Framework Development Environment Setup'); + + // Determine the operating system + final String os = Platform.operatingSystem; + if (os != 'linux' && os != 'macos') { + print('This tool supports only Linux and macOS platforms.'); + exit(1); + } + + // Check prerequisites + await checkPrerequisites(os); + + // Prompt for GitHub username + String githubUsername = await promptUser('Enter your GitHub username: '); + + // Prompt for SSH or HTTPS + String cloneMethod = await promptUser( + 'Choose clone method (1 for SSH, 2 for HTTPS) [Default: 1]: ', + defaultValue: '1'); + + // Clone the flutter/flutter repository + String cloneUrl; + if (cloneMethod == '2') { + cloneUrl = 'https://github.com/flutter/flutter.git'; + } else { + cloneUrl = 'git@github.com:flutter/flutter.git'; + } + + String cloneDir = await promptUser( + 'Enter the directory to clone the Flutter repository [Default: \$HOME/flutter]: ', + defaultValue: '${Platform.environment['HOME']}/flutter'); + + await cloneRepository(cloneUrl, cloneDir); + + // Change directory to cloned repository + Directory flutterDir = Directory(cloneDir); + if (!flutterDir.existsSync()) { + print('Cloned directory does not exist. Exiting.'); + exit(1); + } + Directory.current = flutterDir.path; + + // Rename origin to upstream + await runCommand('git', ['remote', 'rename', 'origin', 'upstream']); + + // Prompt to fork and add origin + print( + '\nPlease ensure you have forked the flutter/flutter repository on GitHub.'); + String forkedCloneMethod = await promptUser( + 'Choose clone method for your fork (1 for SSH, 2 for HTTPS) [Default: 1]: ', + defaultValue: '1'); + + String forkUrl; + if (forkedCloneMethod == '2') { + forkUrl = 'https://github.com/$githubUsername/flutter.git'; + } else { + forkUrl = 'git@github.com:$githubUsername/flutter.git'; + } + + await runCommand('git', ['remote', 'add', 'origin', forkUrl]); + + // Verify remotes + print('\nVerifying remotes:'); + await runCommand('git', ['remote', '-v']); + + // Add Flutter bin to PATH + String flutterBinPath = '$cloneDir/bin'; + await addToPath(flutterBinPath); + + // Update Flutter packages + await runFlutterCommand(['update-packages']); + + // Optional: Configure IDE settings + String configureIDE = await promptUser( + 'Do you want to configure IDE settings for IntelliJ? (y/n) [Default: n]: ', + defaultValue: 'n'); + if (configureIDE.toLowerCase() == 'y') { + await runFlutterCommand(['ide-config', '--overwrite']); + } + + print( + '\nSetup complete! Please restart your terminal or source your shell configuration to apply PATH changes.'); +} + +/// Checks if the required prerequisites are installed. +Future checkPrerequisites(String os) async { + print('\nChecking prerequisites...'); + + // Check Git + if (!await isCommandAvailable('git')) { + print('Error: git is not installed. Please install Git and try again.'); + exit(1); + } + print('โœ” Git is installed.'); + + // Check Python + if (!await isCommandAvailable('python3')) { + print( + 'Error: Python3 is not installed. Please install Python and try again.'); + exit(1); + } + print('โœ” Python3 is installed.'); + + // Check IDE + String ide = await promptUser( + 'Which IDE are you using? (1 for Android Studio, 2 for VS Code) [Default: 2]: ', + defaultValue: '2'); + if (ide == '1') { + if (!await isCommandAvailable('studio')) { + print( + 'Warning: Android Studio does not seem to be installed or not in PATH.'); + } else { + print('โœ” Android Studio is installed.'); + } + } else { + if (!await isCommandAvailable('code')) { + print('Warning: VS Code does not seem to be installed or not in PATH.'); + } else { + print('โœ” VS Code is installed.'); + } + } + + // Install Android platform tools + print('\nInstalling Android platform tools...'); + if (os == 'macos') { + if (!await isCommandAvailable('brew')) { + print( + 'Error: Homebrew is not installed. Please install Homebrew and try again.'); + exit(1); + } + await runCommand('brew', ['install', '--cask', 'android-platform-tools']); + } else if (os == 'linux') { + await runCommand('sudo', ['apt-get', 'update']); + await runCommand('sudo', ['apt-get', 'install', '-y', 'android-tools-adb']); + } + + // Verify adb is in PATH + if (!await isCommandAvailable('adb')) { + print( + 'Error: adb is not in your PATH. Please ensure Android platform tools are correctly installed.'); + exit(1); + } + print('โœ” adb is available in PATH.'); +} + +/// Prompts the user for input with an optional default value. +Future promptUser(String prompt, {String defaultValue = ''}) async { + stdout.write(prompt); + String? input = stdin.readLineSync(); + if (input == null || input.trim().isEmpty) { + return defaultValue; + } + return input.trim(); +} + +/// Checks if a command is available in the system PATH. +Future isCommandAvailable(String command) async { + try { + ProcessResult result = await Process.run('which', [command]); + if (result.exitCode == 0) { + return true; + } + return false; + } catch (e) { + return false; + } +} + +/// Runs a system command and prints its output. +Future runCommand(String command, List args) async { + print('\$ $command ${args.join(' ')}'); + Process process = await Process.start(command, args, runInShell: true); + + // Pipe stdout + process.stdout.transform(utf8.decoder).listen((data) { + stdout.write(data); + }); + + // Pipe stderr + process.stderr.transform(utf8.decoder).listen((data) { + stderr.write(data); + }); + + int exitCode = await process.exitCode; + if (exitCode != 0) { + print( + 'Error: Command "$command ${args.join(' ')}" exited with code $exitCode.'); + exit(exitCode); + } +} + +/// Adds a directory to the user's PATH by modifying shell configuration files. +Future addToPath(String flutterBinPath) async { + String shell = Platform.environment['SHELL'] ?? ''; + String shellConfig; + + if (shell.contains('bash')) { + shellConfig = '.bashrc'; + } else if (shell.contains('zsh')) { + shellConfig = '.zshrc'; + } else { + shellConfig = '.profile'; + } + + String home = Platform.environment['HOME'] ?? ''; + String configPath = '$home/$shellConfig'; + + String exportLine = + '\n# Added by Flutter setup CLI\nexport PATH="\$PATH:$flutterBinPath"\n'; + + File configFile = File(configPath); + if (await configFile.exists()) { + String contents = await configFile.readAsString(); + if (!contents.contains(flutterBinPath)) { + await configFile.writeAsString(exportLine, mode: FileMode.append); + print('โœ” Added Flutter bin directory to PATH in $shellConfig.'); + } else { + print('โœ” Flutter bin directory is already in PATH.'); + } + } else { + print('Warning: Shell configuration file $shellConfig not found.'); + } +} + +/// Runs a Flutter command using the cloned Flutter repository. +Future runFlutterCommand(List args) async { + String flutterExecutable = './bin/flutter'; + if (!await File(flutterExecutable).exists()) { + print('Error: Flutter executable not found at $flutterExecutable.'); + exit(1); + } + + await runCommand(flutterExecutable, args); +} + +/// Clones a Git repository to the specified directory. +Future cloneRepository(String url, String directory) async { + Directory dir = Directory(directory); + if (dir.existsSync()) { + print('Directory $directory already exists. Skipping clone.'); + return; + } + + await runCommand('git', ['clone', url, directory]); +} + + + +// Future main(List args) async { + + + + +// await _flushThenExit(await FlutterCompileCommandRunner().run(args)); +// } + +/// Flushes the stdout and stderr streams, then exits the program with the given +/// status code. +/// +/// This returns a Future that will never complete, since the program will have +/// exited already. This is useful to prevent Future chains from proceeding +/// after you've decided to exit. +Future _flushThenExit(int status) { + return Future.wait([stdout.close(), stderr.close()]) + .then((_) => exit(status)); +} diff --git a/coverage_badge.svg b/coverage_badge.svg new file mode 100644 index 0000000..88bfadf --- /dev/null +++ b/coverage_badge.svg @@ -0,0 +1,20 @@ + + + + + + + + + + + + + + + coverage + coverage + 100% + 100% + + \ No newline at end of file diff --git a/dart_test.yaml b/dart_test.yaml new file mode 100644 index 0000000..2f46c7e --- /dev/null +++ b/dart_test.yaml @@ -0,0 +1,3 @@ +tags: + version-verify: + skip: "Should only be run during pull request. Verifies if version file is updated." \ No newline at end of file diff --git a/lib/flutter_compile.dart b/lib/flutter_compile.dart new file mode 100644 index 0000000..5c37346 --- /dev/null +++ b/lib/flutter_compile.dart @@ -0,0 +1,10 @@ +/// flutter_compile, A Dart CLI to simplify the setting up your framework development environment, which describes and automates the steps you need to configure your computer to work on Flutter's Framework +/// +/// ```sh +/// # activate flutter_compile +/// dart pub global activate flutter_compile +/// +/// # see usage +/// flutter_compile --help +/// ``` +library; diff --git a/lib/src/command_runner.dart b/lib/src/command_runner.dart new file mode 100644 index 0000000..2245fcf --- /dev/null +++ b/lib/src/command_runner.dart @@ -0,0 +1,144 @@ +import 'package:args/args.dart'; +import 'package:args/command_runner.dart'; +import 'package:cli_completion/cli_completion.dart'; +import 'package:flutter_compile/src/commands/commands.dart'; +import 'package:flutter_compile/src/version.dart'; +import 'package:mason_logger/mason_logger.dart'; +import 'package:pub_updater/pub_updater.dart'; + +const executableName = 'flutter_compile'; +const packageName = 'flutter_compile'; +const description = 'A Dart CLI to simplify the setting up your framework development environment, which describes and automates the steps you need to configure your computer to work on Flutter's Framework'; + +/// {@template flutter_compile_command_runner} +/// A [CommandRunner] for the CLI. +/// +/// ```bash +/// $ flutter_compile --version +/// ``` +/// {@endtemplate} +class FlutterCompileCommandRunner extends CompletionCommandRunner { + /// {@macro flutter_compile_command_runner} + FlutterCompileCommandRunner({ + Logger? logger, + PubUpdater? pubUpdater, + }) : _logger = logger ?? Logger(), + _pubUpdater = pubUpdater ?? PubUpdater(), + super(executableName, description) { + // Add root options and flags + argParser + ..addFlag( + 'version', + abbr: 'v', + negatable: false, + help: 'Print the current version.', + ) + ..addFlag( + 'verbose', + help: 'Noisy logging, including all shell commands executed.', + ); + + // Add sub commands + addCommand(SampleCommand(logger: _logger)); + addCommand(UpdateCommand(logger: _logger, pubUpdater: _pubUpdater)); + } + + @override + void printUsage() => _logger.info(usage); + + final Logger _logger; + final PubUpdater _pubUpdater; + + @override + Future run(Iterable args) async { + try { + final topLevelResults = parse(args); + if (topLevelResults['verbose'] == true) { + _logger.level = Level.verbose; + } + return await runCommand(topLevelResults) ?? ExitCode.success.code; + } on FormatException catch (e, stackTrace) { + // On format errors, show the commands error message, root usage and + // exit with an error code + _logger + ..err(e.message) + ..err('$stackTrace') + ..info('') + ..info(usage); + return ExitCode.usage.code; + } on UsageException catch (e) { + // On usage errors, show the commands usage message and + // exit with an error code + _logger + ..err(e.message) + ..info('') + ..info(e.usage); + return ExitCode.usage.code; + } + } + + @override + Future runCommand(ArgResults topLevelResults) async { + // Fast track completion command + if (topLevelResults.command?.name == 'completion') { + await super.runCommand(topLevelResults); + return ExitCode.success.code; + } + + // Verbose logs + _logger + ..detail('Argument information:') + ..detail(' Top level options:'); + for (final option in topLevelResults.options) { + if (topLevelResults.wasParsed(option)) { + _logger.detail(' - $option: ${topLevelResults[option]}'); + } + } + if (topLevelResults.command != null) { + final commandResult = topLevelResults.command!; + _logger + ..detail(' Command: ${commandResult.name}') + ..detail(' Command options:'); + for (final option in commandResult.options) { + if (commandResult.wasParsed(option)) { + _logger.detail(' - $option: ${commandResult[option]}'); + } + } + } + + // Run the command or show version + final int? exitCode; + if (topLevelResults['version'] == true) { + _logger.info(packageVersion); + exitCode = ExitCode.success.code; + } else { + exitCode = await super.runCommand(topLevelResults); + } + + // Check for updates + if (topLevelResults.command?.name != UpdateCommand.commandName) { + await _checkForUpdates(); + } + + return exitCode; + } + + /// Checks if the current version (set by the build runner on the + /// version.dart file) is the most recent one. If not, show a prompt to the + /// user. + Future _checkForUpdates() async { + try { + final latestVersion = await _pubUpdater.getLatestVersion(packageName); + final isUpToDate = packageVersion == latestVersion; + if (!isUpToDate) { + _logger + ..info('') + ..info( + ''' +${lightYellow.wrap('Update available!')} ${lightCyan.wrap(packageVersion)} \u2192 ${lightCyan.wrap(latestVersion)} +Run ${lightCyan.wrap('$executableName update')} to update''', + ); + } + } catch (_) {} + } +} diff --git a/lib/src/commands/commands.dart b/lib/src/commands/commands.dart new file mode 100644 index 0000000..eec317e --- /dev/null +++ b/lib/src/commands/commands.dart @@ -0,0 +1,2 @@ +export 'sample_command.dart'; +export 'update_command.dart'; diff --git a/lib/src/commands/sample_command.dart b/lib/src/commands/sample_command.dart new file mode 100644 index 0000000..4f984a5 --- /dev/null +++ b/lib/src/commands/sample_command.dart @@ -0,0 +1,39 @@ +import 'package:args/command_runner.dart'; +import 'package:mason_logger/mason_logger.dart'; + +/// {@template sample_command} +/// +/// `flutter_compile sample` +/// A [Command] to exemplify a sub command +/// {@endtemplate} +class SampleCommand extends Command { + /// {@macro sample_command} + SampleCommand({ + required Logger logger, + }) : _logger = logger { + argParser.addFlag( + 'cyan', + abbr: 'c', + help: 'Prints the same joke, but in cyan', + negatable: false, + ); + } + + @override + String get description => 'A sample sub command that just prints one joke'; + + @override + String get name => 'sample'; + + final Logger _logger; + + @override + Future run() async { + var output = 'Which unicorn has a cold? The Achoo-nicorn!'; + if (argResults?['cyan'] == true) { + output = lightCyan.wrap(output)!; + } + _logger.info(output); + return ExitCode.success.code; + } +} diff --git a/lib/src/commands/update_command.dart b/lib/src/commands/update_command.dart new file mode 100644 index 0000000..b8687b5 --- /dev/null +++ b/lib/src/commands/update_command.dart @@ -0,0 +1,74 @@ +import 'dart:io'; + +import 'package:args/command_runner.dart'; +import 'package:flutter_compile/src/command_runner.dart'; +import 'package:flutter_compile/src/version.dart'; +import 'package:mason_logger/mason_logger.dart'; +import 'package:pub_updater/pub_updater.dart'; + +/// {@template update_command} +/// A command which updates the CLI. +/// {@endtemplate} +class UpdateCommand extends Command { + /// {@macro update_command} + UpdateCommand({ + required Logger logger, + PubUpdater? pubUpdater, + }) : _logger = logger, + _pubUpdater = pubUpdater ?? PubUpdater(); + + final Logger _logger; + final PubUpdater _pubUpdater; + + @override + String get description => 'Update the CLI.'; + + static const String commandName = 'update'; + + @override + String get name => commandName; + + @override + Future run() async { + final updateCheckProgress = _logger.progress('Checking for updates'); + late final String latestVersion; + try { + latestVersion = await _pubUpdater.getLatestVersion(packageName); + } catch (error) { + updateCheckProgress.fail(); + _logger.err('$error'); + return ExitCode.software.code; + } + updateCheckProgress.complete('Checked for updates'); + + final isUpToDate = packageVersion == latestVersion; + if (isUpToDate) { + _logger.info('CLI is already at the latest version.'); + return ExitCode.success.code; + } + + final updateProgress = _logger.progress('Updating to $latestVersion'); + + late final ProcessResult result; + try { + result = await _pubUpdater.update( + packageName: packageName, + versionConstraint: latestVersion, + ); + } catch (error) { + updateProgress.fail(); + _logger.err('$error'); + return ExitCode.software.code; + } + + if (result.exitCode != ExitCode.success.code) { + updateProgress.fail(); + _logger.err('Error updating CLI: ${result.stderr}'); + return ExitCode.software.code; + } + + updateProgress.complete('Updated to $latestVersion'); + + return ExitCode.success.code; + } +} diff --git a/lib/src/version.dart b/lib/src/version.dart new file mode 100644 index 0000000..67a7647 --- /dev/null +++ b/lib/src/version.dart @@ -0,0 +1,2 @@ +// Generated code. Do not modify. +const packageVersion = '0.0.1'; diff --git a/pubspec.yaml b/pubspec.yaml new file mode 100644 index 0000000..d4115cc --- /dev/null +++ b/pubspec.yaml @@ -0,0 +1,24 @@ +name: flutter_compile +description: A Dart CLI to simplify the setting up your framework development environment, which describes and automates the steps you need to configure your computer to work on Flutter's Framework +version: 0.0.1 +publish_to: none + +environment: + sdk: ^3.4.0 + +dependencies: + args: ^2.5.0 + cli_completion: ^0.5.0 + mason_logger: ^0.2.16 + pub_updater: ^0.4.0 + +dev_dependencies: + build_runner: ^2.4.11 + build_verify: ^3.1.0 + build_version: ^2.1.1 + mocktail: ^1.0.4 + test: ^1.25.8 + very_good_analysis: ^6.0.0 + +executables: + flutter_compile: diff --git a/test/ensure_build_test.dart b/test/ensure_build_test.dart new file mode 100644 index 0000000..3d1173b --- /dev/null +++ b/test/ensure_build_test.dart @@ -0,0 +1,9 @@ +@Tags(['version-verify']) +library; + +import 'package:build_verify/build_verify.dart'; +import 'package:test/test.dart'; + +void main() { + test('ensure_build', expectBuildClean); +} diff --git a/test/src/command_runner_test.dart b/test/src/command_runner_test.dart new file mode 100644 index 0000000..267d4b4 --- /dev/null +++ b/test/src/command_runner_test.dart @@ -0,0 +1,174 @@ +import 'dart:io'; + +import 'package:args/command_runner.dart'; +import 'package:cli_completion/cli_completion.dart'; +import 'package:flutter_compile/src/command_runner.dart'; +import 'package:flutter_compile/src/version.dart'; +import 'package:mason_logger/mason_logger.dart'; +import 'package:mocktail/mocktail.dart'; +import 'package:pub_updater/pub_updater.dart'; +import 'package:test/test.dart'; + +class _MockLogger extends Mock implements Logger {} + +class _MockProgress extends Mock implements Progress {} + +class _MockPubUpdater extends Mock implements PubUpdater {} + +const latestVersion = '0.0.0'; + +final updatePrompt = ''' +${lightYellow.wrap('Update available!')} ${lightCyan.wrap(packageVersion)} \u2192 ${lightCyan.wrap(latestVersion)} +Run ${lightCyan.wrap('$executableName update')} to update'''; + +void main() { + group('FlutterCompileCommandRunner', () { + late PubUpdater pubUpdater; + late Logger logger; + late FlutterCompileCommandRunner commandRunner; + + setUp(() { + pubUpdater = _MockPubUpdater(); + + when( + () => pubUpdater.getLatestVersion(any()), + ).thenAnswer((_) async => packageVersion); + + logger = _MockLogger(); + + commandRunner = FlutterCompileCommandRunner( + logger: logger, + pubUpdater: pubUpdater, + ); + }); + + test('shows update message when newer version exists', () async { + when( + () => pubUpdater.getLatestVersion(any()), + ).thenAnswer((_) async => latestVersion); + + final result = await commandRunner.run(['--version']); + expect(result, equals(ExitCode.success.code)); + verify(() => logger.info(updatePrompt)).called(1); + }); + + test( + 'Does not show update message when the shell calls the ' + 'completion command', + () async { + when( + () => pubUpdater.getLatestVersion(any()), + ).thenAnswer((_) async => latestVersion); + + final result = await commandRunner.run(['completion']); + expect(result, equals(ExitCode.success.code)); + verifyNever(() => logger.info(updatePrompt)); + }, + ); + + test('does not show update message when using update command', () async { + when( + () => pubUpdater.getLatestVersion(any()), + ).thenAnswer((_) async => latestVersion); + when( + () => pubUpdater.update( + packageName: packageName, + versionConstraint: any(named: 'versionConstraint'), + ), + ).thenAnswer( + (_) async => ProcessResult(0, ExitCode.success.code, null, null), + ); + when( + () => pubUpdater.isUpToDate( + packageName: any(named: 'packageName'), + currentVersion: any(named: 'currentVersion'), + ), + ).thenAnswer((_) async => true); + + final progress = _MockProgress(); + final progressLogs = []; + when(() => progress.complete(any())).thenAnswer((answer) { + final message = answer.positionalArguments.elementAt(0) as String?; + if (message != null) progressLogs.add(message); + }); + when(() => logger.progress(any())).thenReturn(progress); + + final result = await commandRunner.run(['update']); + expect(result, equals(ExitCode.success.code)); + verifyNever(() => logger.info(updatePrompt)); + }); + + test('can be instantiated without an explicit analytics/logger instance', + () { + final commandRunner = FlutterCompileCommandRunner(); + expect(commandRunner, isNotNull); + expect(commandRunner, isA>()); + }); + + test('handles FormatException', () async { + const exception = FormatException('oops!'); + var isFirstInvocation = true; + when(() => logger.info(any())).thenAnswer((_) { + if (isFirstInvocation) { + isFirstInvocation = false; + throw exception; + } + }); + final result = await commandRunner.run(['--version']); + expect(result, equals(ExitCode.usage.code)); + verify(() => logger.err(exception.message)).called(1); + verify(() => logger.info(commandRunner.usage)).called(1); + }); + + test('handles UsageException', () async { + final exception = UsageException('oops!', 'exception usage'); + var isFirstInvocation = true; + when(() => logger.info(any())).thenAnswer((_) { + if (isFirstInvocation) { + isFirstInvocation = false; + throw exception; + } + }); + final result = await commandRunner.run(['--version']); + expect(result, equals(ExitCode.usage.code)); + verify(() => logger.err(exception.message)).called(1); + verify(() => logger.info('exception usage')).called(1); + }); + + group('--version', () { + test('outputs current version', () async { + final result = await commandRunner.run(['--version']); + expect(result, equals(ExitCode.success.code)); + verify(() => logger.info(packageVersion)).called(1); + }); + }); + + group('--verbose', () { + test('enables verbose logging', () async { + final result = await commandRunner.run(['--verbose']); + expect(result, equals(ExitCode.success.code)); + + verify(() => logger.detail('Argument information:')).called(1); + verify(() => logger.detail(' Top level options:')).called(1); + verify(() => logger.detail(' - verbose: true')).called(1); + verifyNever(() => logger.detail(' Command options:')); + }); + + test('enables verbose logging for sub commands', () async { + final result = await commandRunner.run([ + '--verbose', + 'sample', + '--cyan', + ]); + expect(result, equals(ExitCode.success.code)); + + verify(() => logger.detail('Argument information:')).called(1); + verify(() => logger.detail(' Top level options:')).called(1); + verify(() => logger.detail(' - verbose: true')).called(1); + verify(() => logger.detail(' Command: sample')).called(1); + verify(() => logger.detail(' Command options:')).called(1); + verify(() => logger.detail(' - cyan: true')).called(1); + }); + }); + }); +} diff --git a/test/src/commands/sample_command_test.dart b/test/src/commands/sample_command_test.dart new file mode 100644 index 0000000..6c32b8b --- /dev/null +++ b/test/src/commands/sample_command_test.dart @@ -0,0 +1,58 @@ +import 'package:flutter_compile/src/command_runner.dart'; +import 'package:mason_logger/mason_logger.dart'; +import 'package:mocktail/mocktail.dart'; +import 'package:test/test.dart'; + +class _MockLogger extends Mock implements Logger {} + +void main() { + group('sample', () { + late Logger logger; + late FlutterCompileCommandRunner commandRunner; + + setUp(() { + logger = _MockLogger(); + commandRunner = FlutterCompileCommandRunner(logger: logger); + }); + + test('tells a joke', () async { + final exitCode = await commandRunner.run(['sample']); + + expect(exitCode, ExitCode.success.code); + + verify( + () => logger.info('Which unicorn has a cold? The Achoo-nicorn!'), + ).called(1); + }); + test('tells a joke in cyan', () async { + final exitCode = await commandRunner.run(['sample', '-c']); + + expect(exitCode, ExitCode.success.code); + + verify( + () => logger.info( + lightCyan.wrap('Which unicorn has a cold? The Achoo-nicorn!'), + ), + ).called(1); + }); + + test('wrong usage', () async { + final exitCode = await commandRunner.run(['sample', '-p']); + + expect(exitCode, ExitCode.usage.code); + + verify(() => logger.err('Could not find an option or flag "-p".')) + .called(1); + verify( + () => logger.info( + ''' +Usage: $executableName sample [arguments] +-h, --help Print this usage information. +-c, --cyan Prints the same joke, but in cyan + +Run "$executableName help" to see global options.''', + ), + ).called(1); + }); + }); +} diff --git a/test/src/commands/update_command_test.dart b/test/src/commands/update_command_test.dart new file mode 100644 index 0000000..d42ce44 --- /dev/null +++ b/test/src/commands/update_command_test.dart @@ -0,0 +1,185 @@ +import 'dart:io'; + +import 'package:flutter_compile/src/command_runner.dart'; +import 'package:flutter_compile/src/commands/commands.dart'; +import 'package:flutter_compile/src/version.dart'; +import 'package:mason_logger/mason_logger.dart'; +import 'package:mocktail/mocktail.dart'; +import 'package:pub_updater/pub_updater.dart'; +import 'package:test/test.dart'; + +class _MockLogger extends Mock implements Logger {} + +class _MockProgress extends Mock implements Progress {} + +class _MockPubUpdater extends Mock implements PubUpdater {} + +void main() { + const latestVersion = '0.0.0'; + + group('update', () { + late PubUpdater pubUpdater; + late Logger logger; + late FlutterCompileCommandRunner commandRunner; + + setUp(() { + final progress = _MockProgress(); + final progressLogs = []; + pubUpdater = _MockPubUpdater(); + logger = _MockLogger(); + commandRunner = FlutterCompileCommandRunner( + logger: logger, + pubUpdater: pubUpdater, + ); + + when( + () => pubUpdater.getLatestVersion(any()), + ).thenAnswer((_) async => packageVersion); + when( + () => pubUpdater.update( + packageName: packageName, + versionConstraint: latestVersion, + ), + ).thenAnswer( + (_) async => ProcessResult(0, ExitCode.success.code, null, null), + ); + when( + () => pubUpdater.isUpToDate( + packageName: any(named: 'packageName'), + currentVersion: any(named: 'currentVersion'), + ), + ).thenAnswer((_) async => true); + when(() => progress.complete(any())).thenAnswer((answer) { + final message = answer.positionalArguments.elementAt(0) as String?; + if (message != null) progressLogs.add(message); + }); + when(() => logger.progress(any())).thenReturn(progress); + }); + + test('can be instantiated without a pub updater', () { + final command = UpdateCommand(logger: logger); + expect(command, isNotNull); + }); + + test( + 'handles pub latest version query errors', + () async { + when( + () => pubUpdater.getLatestVersion(any()), + ).thenThrow(Exception('oops')); + final result = await commandRunner.run(['update']); + expect(result, equals(ExitCode.software.code)); + verify(() => logger.progress('Checking for updates')).called(1); + verify(() => logger.err('Exception: oops')); + verifyNever( + () => pubUpdater.update( + packageName: any(named: 'packageName'), + versionConstraint: any(named: 'versionConstraint'), + ), + ); + }, + ); + + test( + 'handles pub update errors', + () async { + when( + () => pubUpdater.getLatestVersion(any()), + ).thenAnswer((_) async => latestVersion); + when( + () => pubUpdater.update( + packageName: any(named: 'packageName'), + versionConstraint: any(named: 'versionConstraint'), + ), + ).thenThrow(Exception('oops')); + final result = await commandRunner.run(['update']); + expect(result, equals(ExitCode.software.code)); + verify(() => logger.progress('Checking for updates')).called(1); + verify(() => logger.err('Exception: oops')); + verify( + () => pubUpdater.update( + packageName: any(named: 'packageName'), + versionConstraint: any(named: 'versionConstraint'), + ), + ).called(1); + }, + ); + + test('handles pub update process errors', () async { + const error = 'Oh no! Installing this is not possible right now!'; + + when( + () => pubUpdater.getLatestVersion(any()), + ).thenAnswer((_) async => latestVersion); + + when( + () => pubUpdater.update( + packageName: any(named: 'packageName'), + versionConstraint: any(named: 'versionConstraint'), + ), + ).thenAnswer((_) async => ProcessResult(0, 1, null, error)); + + final result = await commandRunner.run(['update']); + + expect(result, equals(ExitCode.software.code)); + verify(() => logger.progress('Checking for updates')).called(1); + verify(() => logger.err('Error updating CLI: $error')); + verify( + () => pubUpdater.update( + packageName: any(named: 'packageName'), + versionConstraint: any(named: 'versionConstraint'), + ), + ).called(1); + }); + + test( + 'updates when newer version exists', + () async { + when( + () => pubUpdater.getLatestVersion(any()), + ).thenAnswer((_) async => latestVersion); + when( + () => pubUpdater.update( + packageName: any(named: 'packageName'), + versionConstraint: any(named: 'versionConstraint'), + ), + ).thenAnswer( + (_) async => ProcessResult(0, ExitCode.success.code, null, null), + ); + when(() => logger.progress(any())).thenReturn(_MockProgress()); + final result = await commandRunner.run(['update']); + expect(result, equals(ExitCode.success.code)); + verify(() => logger.progress('Checking for updates')).called(1); + verify(() => logger.progress('Updating to $latestVersion')).called(1); + verify( + () => pubUpdater.update( + packageName: packageName, + versionConstraint: latestVersion, + ), + ).called(1); + }, + ); + + test( + 'does not update when already on latest version', + () async { + when( + () => pubUpdater.getLatestVersion(any()), + ).thenAnswer((_) async => packageVersion); + when(() => logger.progress(any())).thenReturn(_MockProgress()); + final result = await commandRunner.run(['update']); + expect(result, equals(ExitCode.success.code)); + verify( + () => logger.info('CLI is already at the latest version.'), + ).called(1); + verifyNever(() => logger.progress('Updating to $latestVersion')); + verifyNever( + () => pubUpdater.update( + packageName: any(named: 'packageName'), + versionConstraint: any(named: 'versionConstraint'), + ), + ); + }, + ); + }); +}