├── CLAUDE.md ├── tool ├── release_tool │ ├── analysis_options.yaml │ └── pubspec.yaml ├── fvm.template.rb └── grind.dart ├── test ├── support_assets │ ├── empty_folder │ │ └── empty.md │ ├── dart_package │ │ └── pubspec.yaml │ ├── pubspecs │ │ ├── pubspec_dart.yaml │ │ └── pubspec_flutter.yaml │ └── flutter_app │ │ └── pubspec.yaml ├── ensure_build_test.dart ├── utils │ ├── releases_test.dart │ ├── is_git_commit_test.dart │ ├── which_test.dart │ ├── releases_api_test.dart │ ├── compare_semver_test.dart │ └── http_test.dart ├── fixtures │ └── flutter.version.example.json ├── mocks.dart ├── src │ ├── utils │ │ └── convert_posix_path_test.dart │ ├── commands │ │ └── flutter_upgrade_check_test.dart │ ├── workflows │ │ ├── validate_flutter_version.workflow_test.dart │ │ └── test_logger.dart │ ├── services │ │ ├── process_service_test.dart │ │ └── cache_service_version_match_test.dart │ └── models │ │ └── flutter_root_version_file_test.dart ├── commands │ ├── config_command_test.dart │ └── alias_command_test.dart ├── testing_helpers │ └── prepare_test_environment.dart ├── services │ ├── git_service_test.dart │ ├── app_config_service_test.dart │ └── get_all_versions_test.dart └── install_script_validation_test.dart ├── lib ├── src │ ├── version.dart │ ├── workflows │ │ ├── workflow.dart │ │ ├── setup_flutter.workflow.dart │ │ ├── verify_project.workflow.dart │ │ ├── validate_flutter_version.workflow.dart │ │ ├── run_configured_flutter.workflow.dart │ │ ├── use_version.workflow.dart │ │ ├── check_project_constraints.workflow.dart │ │ └── resolve_project_deps.workflow.dart │ ├── utils │ │ ├── convert_posix_path.dart │ │ ├── console_utils.dart │ │ ├── git_utils.dart │ │ ├── compare_semver.dart │ │ ├── http.dart │ │ ├── which.dart │ │ ├── pretty_json.dart │ │ ├── exceptions.dart │ │ ├── git_clone_progress_tracker.dart │ │ ├── extensions.dart │ │ └── change_case.dart │ ├── models │ │ ├── log_level_model.dart │ │ ├── git_reference_model.dart │ │ └── log_level_model.mapper.dart │ ├── services │ │ ├── base_service.dart │ │ ├── process_service.dart │ │ └── releases_service │ │ │ └── models │ │ │ └── version_model.dart │ ├── commands │ │ ├── dart_command.dart │ │ ├── exec_command.dart │ │ ├── destroy_command.dart │ │ ├── spawn_command.dart │ │ ├── base_command.dart │ │ ├── flutter_command.dart │ │ ├── flavor_command.dart │ │ ├── remove_command.dart │ │ ├── install_command.dart │ │ ├── config_command.dart │ │ └── releases_command.dart │ └── api │ │ ├── models │ │ └── json_response.dart │ │ └── api_service.dart └── fvm.dart ├── .markdownlint.json ├── assets └── android-studio-config.png ├── docs ├── postcss.config.js ├── styles.css ├── pages │ ├── _app.mdx │ ├── documentation │ │ ├── _meta.json │ │ ├── troubleshooting │ │ │ ├── _meta.json │ │ │ ├── overview.md │ │ │ └── git-safe-directory-windows.md │ │ ├── advanced │ │ │ ├── _meta.json │ │ │ └── release-multiple-channels.md │ │ ├── getting-started │ │ │ ├── _meta.json │ │ │ └── overview.md │ │ └── guides │ │ │ ├── _meta.json │ │ │ ├── project-flavors.md │ │ │ ├── global-configuration.mdx │ │ │ ├── monorepo.md │ │ │ ├── vscode.mdx │ │ │ ├── quick-reference.md │ │ │ └── running-flutter.mdx │ ├── _meta.json │ └── index.mdx ├── components │ ├── Logo.tsx │ ├── Spacer.tsx │ ├── Search.tsx │ ├── GithubStarButton.tsx │ ├── TwitterButton.tsx │ └── ui │ │ └── button.tsx ├── lib │ └── utils.ts ├── next-env.d.ts ├── public │ ├── assets │ │ ├── logo.svg │ │ └── powered-by-vercel.svg │ └── uninstall.sh ├── components.json ├── tsconfig.json ├── next.config.js ├── package.json ├── tailwind.config.ts ├── globals.css └── tailwind.config.js ├── dart_test.yaml ├── .dockerignore ├── .husky ├── pre-commit └── pre-push ├── example └── README.md ├── .github ├── actions │ ├── prepare │ │ └── action.yml │ └── test │ │ └── action.yml ├── ISSUE_TEMPLATE │ ├── feature_request.md │ └── bug_report.md └── workflows │ ├── deploy_macos.yml │ ├── deploy_windows.yml │ ├── deploy_homebrew.yml │ ├── deploy_docker.yml │ └── data.yaml ├── .actrc ├── .env.act ├── .docker ├── Dockerfile └── alpine │ └── Dockerfile ├── bin ├── main.dart └── compile.dart ├── .context └── docs │ ├── README.md │ └── act-testing.md ├── LICENSE ├── pubspec.yaml ├── .gitignore ├── scripts ├── install.md ├── README.md ├── test-install.sh ├── install.ps1 └── uninstall.sh ├── .pubignore ├── .metadata ├── analysis_options.yaml ├── setup.sh └── AGENTS.md /CLAUDE.md: -------------------------------------------------------------------------------- 1 | AGENTS.md -------------------------------------------------------------------------------- /tool/release_tool/analysis_options.yaml: -------------------------------------------------------------------------------- 1 | include: ../../analysis_options.yaml 2 | -------------------------------------------------------------------------------- /test/support_assets/empty_folder/empty.md: -------------------------------------------------------------------------------- 1 | # Empty markdown so folder gets versioned 2 | -------------------------------------------------------------------------------- /lib/src/version.dart: -------------------------------------------------------------------------------- 1 | // Generated code. Do not modify. 2 | const packageVersion = '4.0.5'; 3 | -------------------------------------------------------------------------------- /.markdownlint.json: -------------------------------------------------------------------------------- 1 | { 2 | "MD003": { "style": "atx" }, 3 | "MD013":false, 4 | "MD041": {"level":2} 5 | } -------------------------------------------------------------------------------- /assets/android-studio-config.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/leoafarias/fvm/HEAD/assets/android-studio-config.png -------------------------------------------------------------------------------- /docs/postcss.config.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | plugins: { 3 | tailwindcss: {}, 4 | autoprefixer: {}, 5 | }, 6 | } 7 | -------------------------------------------------------------------------------- /docs/styles.css: -------------------------------------------------------------------------------- 1 | .buttons-row { 2 | display: flex; 3 | flex-direction: row; 4 | 5 | justify-content: center; 6 | margin: 0 auto; 7 | } -------------------------------------------------------------------------------- /lib/src/workflows/workflow.dart: -------------------------------------------------------------------------------- 1 | import '../services/base_service.dart'; 2 | 3 | abstract class Workflow extends ContextualService { 4 | const Workflow(super.context); 5 | } 6 | -------------------------------------------------------------------------------- /docs/pages/_app.mdx: -------------------------------------------------------------------------------- 1 | import "../globals.css"; 2 | import "../styles.css"; 3 | 4 | export default function App({ Component, pageProps }) { 5 | return ; 6 | } 7 | -------------------------------------------------------------------------------- /docs/pages/documentation/_meta.json: -------------------------------------------------------------------------------- 1 | { 2 | "getting-started": "Getting Started", 3 | "guides": "Guides", 4 | "advanced": "Advanced", 5 | "troubleshooting": "Troubleshooting" 6 | } 7 | -------------------------------------------------------------------------------- /test/ensure_build_test.dart: -------------------------------------------------------------------------------- 1 | import 'package:build_verify/build_verify.dart'; 2 | import 'package:test/test.dart'; 3 | 4 | void main() { 5 | test('ensure_build', expectBuildClean); 6 | } 7 | -------------------------------------------------------------------------------- /docs/components/Logo.tsx: -------------------------------------------------------------------------------- 1 | import Image from "next/image"; 2 | 3 | export const Logo = ({ size = 25 }) => ( 4 | FVM logo 5 | ); 6 | -------------------------------------------------------------------------------- /docs/lib/utils.ts: -------------------------------------------------------------------------------- 1 | import { type ClassValue, clsx } from "clsx" 2 | import { twMerge } from "tailwind-merge" 3 | 4 | export function cn(...inputs: ClassValue[]) { 5 | return twMerge(clsx(inputs)) 6 | } 7 | -------------------------------------------------------------------------------- /docs/components/Spacer.tsx: -------------------------------------------------------------------------------- 1 | type Props = { 2 | size: number; 3 | }; 4 | 5 | function Spacer({ size = 10 }: Props) { 6 | return ; 7 | } 8 | 9 | export default Spacer; 10 | -------------------------------------------------------------------------------- /docs/pages/documentation/troubleshooting/_meta.json: -------------------------------------------------------------------------------- 1 | { 2 | "overview": { 3 | "title": "Troubleshooting Overview" 4 | }, 5 | "git-safe-directory-windows": { 6 | "title": "Git Safe Directory (Windows)" 7 | } 8 | } 9 | -------------------------------------------------------------------------------- /dart_test.yaml: -------------------------------------------------------------------------------- 1 | concurrency: 2 2 | timeout: 10m 3 | paths: 4 | - test/utils 5 | - test/src 6 | - test/version_format_workflow_test.dart 7 | - test/complete_workflow_test.dart 8 | # Do not include widget tests or sample/example app tests -------------------------------------------------------------------------------- /docs/next-env.d.ts: -------------------------------------------------------------------------------- 1 | /// 2 | /// 3 | 4 | // NOTE: This file should not be edited 5 | // see https://nextjs.org/docs/basic-features/typescript for more information. 6 | -------------------------------------------------------------------------------- /lib/src/utils/convert_posix_path.dart: -------------------------------------------------------------------------------- 1 | /// Replaces all backslashes with forward slashes. 2 | /// 3 | /// Useful to make Windows paths compatible with Posix systems. 4 | String convertToPosixPath(String path) => path.replaceAll(r'\', '/'); 5 | -------------------------------------------------------------------------------- /test/support_assets/dart_package/pubspec.yaml: -------------------------------------------------------------------------------- 1 | name: dart_package 2 | description: A starting point for Dart libraries or applications. 3 | 4 | environment: 5 | sdk: ">=2.19.0 <3.0.0" 6 | 7 | dev_dependencies: 8 | pedantic: ^1.11.1 9 | test: ^1.24.6 10 | -------------------------------------------------------------------------------- /test/support_assets/pubspecs/pubspec_dart.yaml: -------------------------------------------------------------------------------- 1 | name: dart_package 2 | description: A starting point for Dart libraries or applications. 3 | 4 | environment: 5 | sdk: ">=2.19.0 <3.0.0" 6 | 7 | dev_dependencies: 8 | pedantic: ^1.11.1 9 | test: ^1.24.6 10 | -------------------------------------------------------------------------------- /docs/pages/documentation/advanced/_meta.json: -------------------------------------------------------------------------------- 1 | { 2 | "json-api": { 3 | "title": "JSON API" 4 | }, 5 | "custom-version": { 6 | "title": "Custom Flutter SDK" 7 | }, 8 | "release-multiple-channels": { 9 | "title": "Release Multiple Channels" 10 | } 11 | } 12 | -------------------------------------------------------------------------------- /docs/pages/documentation/getting-started/_meta.json: -------------------------------------------------------------------------------- 1 | { 2 | "overview": { 3 | "title": "Overview" 4 | }, 5 | "installation": { 6 | "title": "Installation" 7 | }, 8 | "configuration": { 9 | "title": "Configuration" 10 | }, 11 | "faq": { 12 | "title": "FAQ" 13 | } 14 | } 15 | -------------------------------------------------------------------------------- /docs/public/assets/logo.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | -------------------------------------------------------------------------------- /docs/components/Search.tsx: -------------------------------------------------------------------------------- 1 | import "@docsearch/css"; 2 | import { DocSearch } from "@docsearch/react"; 3 | 4 | function Search() { 5 | return ( 6 | 11 | ); 12 | } 13 | 14 | export default Search; 15 | -------------------------------------------------------------------------------- /docs/pages/_meta.json: -------------------------------------------------------------------------------- 1 | { 2 | "index": { 3 | "title": "Overview", 4 | "type": "page" 5 | }, 6 | "documentation": { 7 | "title": "Documentation", 8 | "type": "page" 9 | }, 10 | "contact": { 11 | "title": "Contact ↗", 12 | "type": "page", 13 | "href": "https://twitter.com/leoafarias", 14 | "newWindow": true 15 | } 16 | } 17 | -------------------------------------------------------------------------------- /test/support_assets/flutter_app/pubspec.yaml: -------------------------------------------------------------------------------- 1 | name: flutter_app 2 | description: A new Flutter project. 3 | publish_to: "none" 4 | version: 1.0.0+1 5 | environment: 6 | sdk: ">=2.12.0 <3.0.0" 7 | dependencies: 8 | flutter: 9 | sdk: flutter 10 | dev_dependencies: 11 | flutter_test: 12 | sdk: flutter 13 | flutter: 14 | uses-material-design: true 15 | -------------------------------------------------------------------------------- /test/support_assets/pubspecs/pubspec_flutter.yaml: -------------------------------------------------------------------------------- 1 | name: flutter_app 2 | description: A new Flutter project. 3 | publish_to: "none" 4 | version: 1.0.0+1 5 | environment: 6 | sdk: ">=2.12.0 <3.0.0" 7 | dependencies: 8 | flutter: 9 | sdk: flutter 10 | dev_dependencies: 11 | flutter_test: 12 | sdk: flutter 13 | flutter: 14 | uses-material-design: true 15 | -------------------------------------------------------------------------------- /docs/components.json: -------------------------------------------------------------------------------- 1 | { 2 | "$schema": "https://ui.shadcn.com/schema.json", 3 | "style": "default", 4 | "rsc": false, 5 | "tsx": true, 6 | "tailwind": { 7 | "config": "tailwind.config.js", 8 | "css": "globals.css", 9 | "baseColor": "slate", 10 | "cssVariables": true 11 | }, 12 | "aliases": { 13 | "components": "@/components", 14 | "utils": "@/lib/utils" 15 | } 16 | } -------------------------------------------------------------------------------- /.dockerignore: -------------------------------------------------------------------------------- 1 | # Development files 2 | .git 3 | .dart_tool 4 | build 5 | coverage 6 | test 7 | 8 | # Documentation 9 | docs 10 | *.md 11 | README* 12 | CHANGELOG* 13 | LICENSE 14 | 15 | # IDE and editor files 16 | .vscode 17 | .idea 18 | *.swp 19 | *.swo 20 | 21 | # OS files 22 | .DS_Store 23 | Thumbs.db 24 | 25 | # CI/CD files 26 | .github 27 | .dockerignore 28 | Dockerfile* 29 | 30 | # Temporary files 31 | *.log 32 | *.tmp 33 | temp -------------------------------------------------------------------------------- /docs/components/GithubStarButton.tsx: -------------------------------------------------------------------------------- 1 | import Link from "next/link"; 2 | 3 | export default function GithubStartButton() { 4 | return ( 5 | 6 | FVM on GitHub 10 | 11 | ); 12 | } 13 | -------------------------------------------------------------------------------- /tool/release_tool/pubspec.yaml: -------------------------------------------------------------------------------- 1 | name: fvm_release_tool 2 | description: Release automation package for the FVM project. 3 | version: 0.0.1 4 | 5 | environment: 6 | # Release tooling relies on Dart >=3.8 for cli_pkg and related features. 7 | sdk: ">=3.8.0 <4.0.0" 8 | 9 | dependencies: 10 | cli_pkg: 2.14.0 11 | grinder: ^0.9.5 12 | path: ^1.9.0 13 | meta: ^1.10.0 14 | 15 | dev_dependencies: 16 | lints: ^2.1.1 17 | test: ^1.24.6 18 | -------------------------------------------------------------------------------- /lib/src/utils/console_utils.dart: -------------------------------------------------------------------------------- 1 | import 'package:dart_console/dart_console.dart'; 2 | 3 | Table createTable([List columns = const []]) { 4 | final table = Table() 5 | ..borderColor = ConsoleColor.white 6 | ..borderType = BorderType.grid 7 | ..borderStyle = BorderStyle.square 8 | ..headerStyle = FontStyle.bold; 9 | 10 | for (final column in columns) { 11 | table.insertColumn(header: column, alignment: TextAlignment.left); 12 | } 13 | 14 | return table; 15 | } 16 | -------------------------------------------------------------------------------- /.husky/pre-commit: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env sh 2 | . "$(dirname -- "$0")/_/husky.sh" 3 | 4 | # Run lint_staged for formatting and fixing staged files 5 | dart run lint_staged 6 | 7 | # Run analysis on the entire project (faster than per-file) 8 | echo "Running dart analyze..." 9 | dart analyze --fatal-infos 10 | 11 | # Run DCM analysis if available 12 | if command -v dcm >/dev/null 2>&1; then 13 | echo "Running DCM analysis..." 14 | dcm analyze lib 15 | else 16 | echo "DCM not found, skipping DCM analysis" 17 | fi 18 | -------------------------------------------------------------------------------- /example/README.md: -------------------------------------------------------------------------------- 1 | # Example 2 | 3 | The following is a step by step if you want to run a specific version of Flutter within a project. 4 | 5 | First choose the version you would like to install and cache on your machine. 6 | 7 | This will install version 1.17.4 and cache locally. 8 | 9 | ```bash 10 | > fvm install 1.17.4 11 | ``` 12 | 13 | Go into the project directory 14 | 15 | ```bash 16 | > cd path/to/project 17 | ``` 18 | 19 | Set the project to use the version that you have installed. 20 | 21 | ```bash 22 | > fvm use 1.17.4 23 | ``` 24 | -------------------------------------------------------------------------------- /test/utils/releases_test.dart: -------------------------------------------------------------------------------- 1 | import 'package:mason_logger/mason_logger.dart'; 2 | import 'package:test/test.dart'; 3 | 4 | import '../testing_utils.dart'; 5 | 6 | void main() { 7 | group('Flutter Releases', () { 8 | late TestCommandRunner runner; 9 | 10 | setUp(() { 11 | runner = TestFactory.commandRunner(); 12 | }); 13 | 14 | test('Can check releases', () async { 15 | final exitCode = await runner.run(['fvm', 'releases']); 16 | 17 | expect(exitCode, ExitCode.success.code); 18 | }); 19 | }); 20 | } 21 | -------------------------------------------------------------------------------- /lib/src/utils/git_utils.dart: -------------------------------------------------------------------------------- 1 | bool isPossibleGitCommit(String hash) { 2 | // Trim whitespace and normalize to lowercase 3 | final normalized = hash.trim().toLowerCase(); 4 | 5 | return _isValidShortCommitSha(normalized) || 6 | _isValidFullCommitSha(normalized); 7 | } 8 | 9 | bool _isValidShortCommitSha(String str) { 10 | // Keep the practical 7-10 range but handle uppercase 11 | return RegExp(r'^[a-f0-9]{7,10}$').hasMatch(str); 12 | } 13 | 14 | bool _isValidFullCommitSha(String str) { 15 | return RegExp(r'^[a-f0-9]{40}$').hasMatch(str); 16 | } 17 | -------------------------------------------------------------------------------- /.github/actions/prepare/action.yml: -------------------------------------------------------------------------------- 1 | name: "Prepare" 2 | description: "Prepare and tests the project" 3 | 4 | inputs: 5 | sdk-version: 6 | description: "Dart SDK version" 7 | required: false 8 | # 3.6.0 Version required for pubspec_parse ^1.5.0 compatibility 9 | default: "3.6.0" 10 | 11 | runs: 12 | using: "composite" 13 | steps: 14 | - name: Setup Dart 15 | uses: dart-lang/setup-dart@v1 16 | with: 17 | sdk: ${{ inputs.sdk-version }} 18 | 19 | - name: Get dependencies 20 | run: dart pub get 21 | shell: bash 22 | -------------------------------------------------------------------------------- /tool/fvm.template.rb: -------------------------------------------------------------------------------- 1 | class Fvm < Formula 2 | desc "Flutter Version Management: A CLI to manage Flutter SDK versions" 3 | homepage "https://github.com/leoafarias/fvm" 4 | version "{{VERSION}}" 5 | 6 | on_macos do 7 | if Hardware::CPU.arm? 8 | url "{{MACOS_ARM64_URL}}" 9 | sha256 "{{MACOS_ARM64_SHA256}}" 10 | else 11 | url "{{MACOS_X64_URL}}" 12 | sha256 "{{MACOS_X64_SHA256}}" 13 | end 14 | end 15 | 16 | def install 17 | bin.install "fvm" 18 | end 19 | 20 | test do 21 | assert_match "FVM #{version}", shell_output("#{bin}/fvm --version").strip 22 | end 23 | end -------------------------------------------------------------------------------- /docs/components/TwitterButton.tsx: -------------------------------------------------------------------------------- 1 | import Link from "next/link"; 2 | import { useEffect, useState } from "react"; 3 | 4 | export default function TwitterButton() { 5 | const [mounted, setMounted] = useState(false); 6 | useEffect(() => { 7 | setMounted(true); 8 | }, []); 9 | 10 | if (!mounted) { 11 | return null; 12 | } 13 | 14 | return ( 15 | 16 | X (formerly Twitter) Follow 20 | 21 | ); 22 | } 23 | -------------------------------------------------------------------------------- /docs/pages/documentation/guides/_meta.json: -------------------------------------------------------------------------------- 1 | { 2 | "quick-reference": { 3 | "title": "Quick Reference" 4 | }, 5 | "workflows": { 6 | "title": "Common Workflows" 7 | }, 8 | "basic-commands": { 9 | "title": "Commands Reference" 10 | }, 11 | "running-flutter": { 12 | "title": "Running Flutter" 13 | }, 14 | "project-flavors": { 15 | "title": "Project Flavors" 16 | }, 17 | "monorepo": { 18 | "title": "Monorepo Support" 19 | }, 20 | "global-configuration": { 21 | "title": "Global Configuration" 22 | }, 23 | 24 | "vscode": { 25 | "title": "VSCode Configuration" 26 | } 27 | } 28 | -------------------------------------------------------------------------------- /.github/actions/test/action.yml: -------------------------------------------------------------------------------- 1 | name: "Run tests" 2 | description: "Grind tasks for testing" 3 | 4 | inputs: 5 | with-coverage: 6 | description: "Generate coverage reports" 7 | required: false 8 | default: "false" 9 | 10 | runs: 11 | using: "composite" 12 | steps: 13 | - name: Prepare tests 14 | run: dart run grinder test-setup 15 | shell: bash 16 | 17 | - name: Run tests 18 | run: dart test --coverage=coverage 19 | shell: bash 20 | 21 | - name: Generate coverage report 22 | run: dart run grinder coverage 23 | shell: bash 24 | if: ${{ inputs.with-coverage == 'true' }} -------------------------------------------------------------------------------- /lib/src/models/log_level_model.dart: -------------------------------------------------------------------------------- 1 | import 'package:dart_mappable/dart_mappable.dart'; 2 | 3 | part 'log_level_model.mapper.dart'; 4 | 5 | @MappableEnum() 6 | enum Level { 7 | /// The most verbose log level -- everything is logged. 8 | verbose, 9 | 10 | /// Used for debug info. 11 | debug, 12 | 13 | /// Default log level used for standard logs. 14 | info, 15 | 16 | /// Used to indicate a potential problem. 17 | warning, 18 | 19 | /// Used to indicate a problem. 20 | error, 21 | 22 | /// Used to indicate an urgent/severe problem. 23 | critical, 24 | 25 | /// The least verbose level -- nothing is logged. 26 | quiet, 27 | } 28 | -------------------------------------------------------------------------------- /lib/src/services/base_service.dart: -------------------------------------------------------------------------------- 1 | import 'package:meta/meta.dart'; 2 | 3 | import '../utils/context.dart'; 4 | import 'logger_service.dart'; 5 | 6 | abstract class Contextual { 7 | final FvmContext _context; 8 | 9 | const Contextual(this._context); 10 | 11 | /// Gets context, if no context is passed will get from scope 12 | @protected 13 | FvmContext get context => _context; 14 | 15 | @protected 16 | Logger get logger => _context.get(); 17 | 18 | @protected 19 | T get() => _context.get(); 20 | } 21 | 22 | abstract class ContextualService extends Contextual { 23 | const ContextualService(super.context); 24 | } 25 | -------------------------------------------------------------------------------- /.actrc: -------------------------------------------------------------------------------- 1 | # Act configuration for FVM project 2 | # This file configures the Docker images used by act when running GitHub Actions locally 3 | 4 | # Ubuntu - most frequently used runner 5 | -P ubuntu-latest=catthehacker/ubuntu:act-latest 6 | 7 | # Windows/macOS jobs will run in Linux containers (act limitation) 8 | # These are mapped for compatibility with workflows that use them 9 | -P windows-latest=catthehacker/ubuntu:act-latest 10 | -P macos-latest=catthehacker/ubuntu:act-latest 11 | 12 | # Use node 18 as default (matching GitHub Actions) 13 | --container-architecture linux/amd64 14 | 15 | # Enable offline mode for faster action loading 16 | --action-offline-mode -------------------------------------------------------------------------------- /lib/src/utils/compare_semver.dart: -------------------------------------------------------------------------------- 1 | import 'package:pub_semver/pub_semver.dart'; 2 | 3 | /// Compares two semantic version strings. 4 | /// Returns -1, 0, or 1 for less than, equal, or greater than respectively. 5 | /// Throws [FormatException] if either version is invalid. 6 | int compareSemver(String version, String otherVersion) { 7 | // Parse the versions - throws FormatException if invalid 8 | final ver1 = Version.parse(version); 9 | final ver2 = Version.parse(otherVersion); 10 | 11 | // Use the built-in semantic version comparison 12 | if (ver1 < ver2) return -1; 13 | if (ver1 > ver2) return 1; 14 | 15 | return 0; // versions are equal 16 | } 17 | -------------------------------------------------------------------------------- /test/fixtures/flutter.version.example.json: -------------------------------------------------------------------------------- 1 | { 2 | "frameworkVersion": "3.33.0-1.0.pre-1070", 3 | "channel": "master", 4 | "repositoryUrl": "unknown source", 5 | "frameworkRevision": "be9526fbaaaab9474e95d196b70c41297eeda2d0", 6 | "frameworkCommitDate": "2025-07-22 11:34:11 -0700", 7 | "engineRevision": "be9526fbaaaab9474e95d196b70c41297eeda2d0", 8 | "engineCommitDate": "2025-07-22 18:34:11.000Z", 9 | "engineContentHash": "70fb28dde094789120421d4e807a9c37a0131296", 10 | "engineBuildDate": "2025-07-22 11:47:42.829", 11 | "dartSdkVersion": "3.10.0 (build 3.10.0-15.0.dev)", 12 | "devToolsVersion": "2.48.0", 13 | "flutterVersion": "3.33.0-1.0.pre-1070" 14 | } 15 | -------------------------------------------------------------------------------- /docs/pages/documentation/advanced/release-multiple-channels.md: -------------------------------------------------------------------------------- 1 | --- 2 | id: release_multiple_channels 3 | title: Release In Multiple Channels 4 | --- 5 | 6 | # Release In Multiple Channels 7 | 8 | Sometimes a Flutter version exists in multiple channels. FVM prioritizes the most stable channel: **stable > beta > dev**. 9 | 10 | ## Example 11 | 12 | Version `3.16.0` exists in both stable and beta channels: 13 | 14 | ```bash 15 | # Installs from stable channel (default) 16 | fvm use 3.16.0 17 | ``` 18 | 19 | ## Force Specific Channel 20 | 21 | To install from a specific channel, use `@channel`: 22 | 23 | ```bash 24 | # Install from beta channel 25 | fvm use 3.16.0@beta 26 | ``` 27 | -------------------------------------------------------------------------------- /.husky/pre-push: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env sh 2 | . "$(dirname -- "$0")/_/husky.sh" 3 | 4 | # Add Flutter/Dart to PATH for GUI apps 5 | if command -v flutter >/dev/null 2>&1; then 6 | FLUTTER_BIN="$(command -v flutter)" 7 | FLUTTER_DIR="$(dirname "$FLUTTER_BIN")" 8 | export PATH="$FLUTTER_DIR/cache/dart-sdk/bin:$FLUTTER_DIR:$PATH" 9 | elif command -v dart >/dev/null 2>&1; then 10 | DART_BIN="$(command -v dart)" 11 | DART_DIR="$(dirname "$DART_BIN")" 12 | export PATH="$DART_DIR:$PATH" 13 | fi 14 | 15 | # Run dart analyze before pushing 16 | echo "Running dart analyze..." 17 | dart analyze --fatal-infos 18 | 19 | # Run DCM analyze if available 20 | if command -v dcm >/dev/null 2>&1; then 21 | echo "Running DCM analysis..." 22 | dcm analyze lib 23 | fi 24 | -------------------------------------------------------------------------------- /docs/pages/documentation/troubleshooting/overview.md: -------------------------------------------------------------------------------- 1 | --- 2 | id: troubleshooting 3 | title: Troubleshooting Overview 4 | description: Quick links to the most common issues developers hit while using FVM. 5 | --- 6 | 7 | # Troubleshooting 8 | 9 | Use these guides to quickly diagnose and fix common environment issues that block FVM or Flutter workflows. Each article focuses on a single symptom so you can jump directly to the solution you need. 10 | 11 | ## Common Issues 12 | 13 | - [Git Safe Directory Error on Windows](/documentation/troubleshooting/git-safe-directory-windows) 14 | 15 | ## Need More Help? 16 | 17 | - Check the [FAQ](/documentation/getting-started/faq) for additional questions. 18 | - Join the community on Discord or GitHub for edge cases that are not yet covered. 19 | -------------------------------------------------------------------------------- /lib/src/commands/dart_command.dart: -------------------------------------------------------------------------------- 1 | import 'package:args/args.dart'; 2 | 3 | import '../workflows/run_configured_flutter.workflow.dart'; 4 | import 'base_command.dart'; 5 | 6 | /// Proxies Dart Commands 7 | class DartCommand extends BaseFvmCommand { 8 | @override 9 | final name = 'dart'; 10 | @override 11 | final description = 12 | 'Runs Dart commands using the project\'s configured Flutter SDK'; 13 | @override 14 | final argParser = ArgParser.allowAnything(); 15 | 16 | DartCommand(super.context); 17 | 18 | @override 19 | Future run() async { 20 | final args = argResults!.arguments; 21 | 22 | final runConfiguredFlutterWorkflow = RunConfiguredFlutterWorkflow(context); 23 | 24 | final result = await runConfiguredFlutterWorkflow('dart', args: args); 25 | 26 | return result.exitCode; 27 | } 28 | } 29 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/feature_request.md: -------------------------------------------------------------------------------- 1 | --- 2 | name: Feature request 3 | about: Suggest an idea for this project 4 | title: "[Feature Request]" 5 | labels: enhancement 6 | assignees: "" 7 | --- 8 | 9 | ## Before creating a feature request make sure the suggestion fit within our [principles](https://fvm.app/#principles) 10 | 11 | **Is your feature request related to a problem? Please describe.** 12 | A clear and concise description of what the problem is. Ex. I'm always frustrated when [...] 13 | 14 | **Describe the solution you'd like** 15 | A clear and concise description of what you want to happen. 16 | 17 | **Describe alternatives you've considered** 18 | A clear and concise description of any alternative solutions or features you've considered. 19 | 20 | **Additional context** 21 | Add any other context or screenshots about the feature request here. 22 | -------------------------------------------------------------------------------- /lib/src/workflows/setup_flutter.workflow.dart: -------------------------------------------------------------------------------- 1 | import '../models/cache_flutter_version_model.dart'; 2 | import '../services/flutter_service.dart'; 3 | import 'workflow.dart'; 4 | 5 | class SetupFlutterWorkflow extends Workflow { 6 | const SetupFlutterWorkflow(super.context); 7 | 8 | Future call(CacheFlutterVersion version) async { 9 | // Skip setup if version has already been setup. 10 | if (version.isSetup) return; 11 | 12 | logger 13 | ..info('Setting up Flutter SDK: ${version.name}') 14 | ..info(); 15 | 16 | try { 17 | await get().setup(version); 18 | logger 19 | ..info() 20 | ..success('Flutter SDK: ${version.printFriendlyName} is setup'); 21 | } on Exception catch (_) { 22 | logger.err('Failed to setup Flutter SDK'); 23 | 24 | rethrow; 25 | } 26 | } 27 | } 28 | -------------------------------------------------------------------------------- /.github/workflows/deploy_macos.yml: -------------------------------------------------------------------------------- 1 | name: Deploy Macos 2 | 3 | on: 4 | workflow_dispatch: 5 | 6 | jobs: 7 | deploy-macos: 8 | name: Deploy (Macos) 9 | runs-on: macos-latest 10 | env: 11 | PUB_CREDENTIALS: ${{ secrets.PUB_CREDENTIALS }} 12 | GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} 13 | RELEASE_DART_SDK: "3.9.0" 14 | 15 | steps: 16 | - name: Checkout 17 | uses: actions/checkout@v4 18 | 19 | - name: Prepare environment 20 | uses: ./.github/actions/prepare 21 | with: 22 | sdk-version: ${{ env.RELEASE_DART_SDK }} 23 | 24 | - name: Get release tool dependencies 25 | working-directory: tool/release_tool 26 | run: dart pub get 27 | 28 | - name: Deploy Github Mac 29 | working-directory: tool/release_tool 30 | run: dart run grinder pkg-github-macos 31 | -------------------------------------------------------------------------------- /.env.act: -------------------------------------------------------------------------------- 1 | # Environment variables for act (local GitHub Actions testing) 2 | # This file is used when running workflows locally with act 3 | 4 | # Dart/Flutter SDK paths 5 | DART_SDK=/usr/local/flutter/bin/cache/dart-sdk 6 | FLUTTER_ROOT=/usr/local/flutter 7 | 8 | # GitHub context simulation 9 | GITHUB_WORKSPACE=/github/workspace 10 | GITHUB_REPOSITORY=leoafarias/fvm 11 | GITHUB_REF=refs/heads/main 12 | GITHUB_SHA=local-test 13 | GITHUB_ACTOR=act-user 14 | GITHUB_RUN_ID=local-run 15 | GITHUB_RUN_NUMBER=1 16 | 17 | # Disable analytics for local testing 18 | PUB_CACHE=/tmp/.pub-cache 19 | FLUTTER_ANALYTICS=false 20 | DART_PUB_ANALYZER_EXPERIMENT=false 21 | 22 | # Coverage settings 23 | COVERAGE_SERVICE_NAME=local-act 24 | COVERAGE_SERVICE_JOB_ID=local-job 25 | 26 | # Act-specific settings 27 | ACT=true 28 | CI=true 29 | 30 | # Package manager settings 31 | DEBIAN_FRONTEND=noninteractive -------------------------------------------------------------------------------- /lib/src/utils/http.dart: -------------------------------------------------------------------------------- 1 | import 'dart:convert'; 2 | import 'dart:io'; 3 | 4 | /// Does a simple get request on [url] 5 | Future httpRequest(String url, {Map? headers}) async { 6 | final client = HttpClient(); 7 | 8 | try { 9 | final request = await client.getUrl(Uri.parse(url)); 10 | 11 | headers?.forEach(request.headers.set); 12 | 13 | final response = await request.close(); 14 | 15 | if (response.statusCode >= 400) { 16 | throw HttpException( 17 | 'HTTP ${response.statusCode}: ${response.reasonPhrase} - URL: $url', 18 | ); 19 | } 20 | 21 | final stream = response.transform(const Utf8Decoder()); 22 | final buffer = StringBuffer(); 23 | await for (final data in stream) { 24 | buffer.write(data); 25 | } 26 | 27 | return buffer.toString(); 28 | } finally { 29 | client.close(); 30 | } 31 | } 32 | -------------------------------------------------------------------------------- /.docker/Dockerfile: -------------------------------------------------------------------------------- 1 | FROM dart:stable 2 | 3 | # Set fvm version 4 | ARG FVM_VERSION 5 | 6 | # Canonical install script used by tooling validation 7 | ENV FVM_INSTALL_SCRIPT_URL=https://raw.githubusercontent.com/leoafarias/fvm/main/scripts/install.sh 8 | 9 | # Set the working directory in the container to /app 10 | WORKDIR /app 11 | 12 | # Copy pubspec files first for better layer caching 13 | COPY pubspec.* ./ 14 | 15 | # Get dependencies first 16 | RUN dart pub get --no-precompile 17 | 18 | # Copy the rest of the application 19 | COPY . . 20 | 21 | # Update and install system dependencies, then clean up 22 | RUN apt-get update && apt-get install -y curl git unzip xz-utils zip \ 23 | && rm -rf /var/lib/apt/lists/* 24 | 25 | # Build FVM from source instead of installing from remote 26 | RUN dart compile exe bin/main.dart -o /usr/local/bin/fvm 27 | 28 | # Verify installation 29 | RUN fvm --version 30 | -------------------------------------------------------------------------------- /test/utils/is_git_commit_test.dart: -------------------------------------------------------------------------------- 1 | import 'package:fvm/src/utils/git_utils.dart'; 2 | import 'package:test/test.dart'; 3 | 4 | void main() { 5 | group('Is git commit', () { 6 | test('Long valid git hash', () { 7 | const String testHash = '476ad8a917e64e345f05e4147e573e2a42b379f9'; 8 | expect(isPossibleGitCommit(testHash), isTrue); 9 | }); 10 | 11 | test('Short valid git hash', () { 12 | const String testHash = 'fa345b1'; 13 | expect(isPossibleGitCommit(testHash), isTrue); 14 | }); 15 | 16 | test('Too Short invalid git hash', () { 17 | const String testHash = 'fa3'; 18 | expect(isPossibleGitCommit(testHash), isFalse); 19 | }); 20 | 21 | test('Invalid character in git hash', () { 22 | const String testHash = '476ad8g917e64e345f05e4147e573e2a42b379f9'; 23 | expect(isPossibleGitCommit(testHash), isFalse); 24 | }); 25 | }); 26 | } 27 | -------------------------------------------------------------------------------- /bin/main.dart: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env dart 2 | 3 | import 'dart:io'; 4 | 5 | import 'package:fvm/src/runner.dart'; 6 | import 'package:fvm/src/utils/context.dart'; 7 | 8 | Future main(List args) async { 9 | final updatableArgs = [...args]; 10 | final skipInput = updatableArgs.remove('--fvm-skip-input'); 11 | final controller = FvmContext.create(skipInput: skipInput); 12 | 13 | await _flushThenExit(await FvmCommandRunner(controller).run(updatableArgs)); 14 | } 15 | 16 | /// Flushes the stdout and stderr streams, then exits the program with the given 17 | /// status code. 18 | /// 19 | /// This returns a Future that will never complete, since the program will have 20 | /// exited already. This is useful to prevent Future chains from proceeding 21 | /// after you've decided to exit. 22 | Future _flushThenExit(int status) { 23 | return Future.wait([stdout.close(), stderr.close()]) 24 | .then((_) => exit(status)); 25 | } 26 | -------------------------------------------------------------------------------- /.context/docs/README.md: -------------------------------------------------------------------------------- 1 | # Developer Documentation 2 | 3 | Technical documentation for FVM developers. 4 | 5 | ## Testing 6 | 7 | | File | Description | 8 | |------|-------------| 9 | | `testing-methodology.md` | Test patterns, TestFactory, mocking, best practices | 10 | | `integration-tests.md` | Integration test phases, 38 tests, running tests | 11 | 12 | ## Architecture 13 | 14 | | File | Description | 15 | |------|-------------| 16 | | `version-parsing.md` | Version string parsing regex and implementation | 17 | | `v4-release-notes.md` | v4.0 architecture changes, migration guide | 18 | 19 | ## Workflows 20 | 21 | | File | Description | 22 | |------|-------------| 23 | | `act-testing.md` | Testing GitHub workflows locally with `act` | 24 | 25 | ## Related Documentation 26 | 27 | - @README.md - Project overview, release process 28 | - @CHANGELOG.md - Version history, breaking changes 29 | - @.github/workflows/README.md - CI/CD pipelines, deployment 30 | -------------------------------------------------------------------------------- /docs/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "target": "es5", 4 | "lib": ["dom", "dom.iterable", "esnext"], 5 | "allowJs": true, 6 | "skipLibCheck": true, 7 | "strict": false, 8 | "forceConsistentCasingInFileNames": true, 9 | "noEmit": true, 10 | "esModuleInterop": true, 11 | "module": "esnext", 12 | "moduleResolution": "node", 13 | "resolveJsonModule": true, 14 | "isolatedModules": true, 15 | "jsx": "preserve", 16 | "incremental": true, 17 | "plugins": [ 18 | { 19 | "name": "next" 20 | } 21 | ], 22 | "paths": { 23 | "@/*": ["./*"] 24 | }, 25 | "strictNullChecks": true 26 | }, 27 | "include": [ 28 | "next-env.d.ts", 29 | "**/*.ts", 30 | "**/*.tsx", 31 | "pages/_app.ts", 32 | "next.config.js", 33 | ".next/types/**/*.ts" 34 | ], 35 | "exclude": ["node_modules"] 36 | } 37 | -------------------------------------------------------------------------------- /test/mocks.dart: -------------------------------------------------------------------------------- 1 | // Mock classes 2 | import 'dart:io'; 3 | 4 | import 'package:fvm/fvm.dart'; 5 | import 'package:mocktail/mocktail.dart'; 6 | 7 | class MockProjectService extends Mock implements ProjectService {} 8 | 9 | class MockCacheService extends Mock implements CacheService {} 10 | 11 | class MockFlutterReleasesService extends Mock implements FlutterReleaseClient {} 12 | 13 | class MockFvmContext extends Mock implements FvmContext {} 14 | 15 | class MockProject extends Mock implements Project {} 16 | 17 | class MockCacheVersion extends Mock implements CacheFlutterVersion {} 18 | 19 | class MockFvmDirectory extends Mock implements Directory {} 20 | 21 | class MockDirectory extends Mock implements Directory {} 22 | 23 | class MockFlutterSdkRelease extends Mock implements FlutterSdkRelease {} 24 | 25 | class MockChannels extends Mock implements Channels {} 26 | 27 | class MockFlutterReleasesResponse extends Mock 28 | implements FlutterReleasesResponse {} 29 | -------------------------------------------------------------------------------- /test/src/utils/convert_posix_path_test.dart: -------------------------------------------------------------------------------- 1 | import 'package:fvm/src/utils/convert_posix_path.dart'; 2 | import 'package:test/test.dart'; 3 | 4 | void main() { 5 | group('convertToPosixPath', () { 6 | final cases = { 7 | 'C\\Users\\Name\\Documents': 'C/Users/Name/Documents', 8 | 'C:/Users/Name/Documents': 'C:/Users/Name/Documents', 9 | '': '', 10 | 'C:/Users\\Name/Documents': 'C:/Users/Name/Documents', 11 | 'C\\Users\\New Folder\\Documents': 'C/Users/New Folder/Documents', 12 | '/': '/', 13 | '/home/username': '/home/username', 14 | '/Applications/Utilities': '/Applications/Utilities', 15 | '/usr/bin': '/usr/bin', 16 | '/var/log/apache2/access.log': '/var/log/apache2/access.log', 17 | }; 18 | 19 | cases.forEach((input, expected) { 20 | test('converts "$input"', () { 21 | expect(convertToPosixPath(input), equals(expected)); 22 | }); 23 | }); 24 | }); 25 | } 26 | -------------------------------------------------------------------------------- /.github/workflows/deploy_windows.yml: -------------------------------------------------------------------------------- 1 | name: Deploy Windows 2 | 3 | on: 4 | workflow_dispatch: 5 | 6 | jobs: 7 | deploy-windows: 8 | name: Deploy (Windows) 9 | runs-on: windows-latest 10 | env: 11 | GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} 12 | CHOCOLATEY_TOKEN: ${{ secrets.CHOCOLATEY_TOKEN }} 13 | RELEASE_DART_SDK: "3.9.0" 14 | steps: 15 | - name: Checkout 16 | uses: actions/checkout@v4 17 | 18 | - name: Prepare environment 19 | uses: ./.github/actions/prepare 20 | with: 21 | sdk-version: ${{ env.RELEASE_DART_SDK }} 22 | 23 | - name: Get release tool dependencies 24 | working-directory: tool/release_tool 25 | run: dart pub get 26 | 27 | - name: Deploy Github Windows 28 | working-directory: tool/release_tool 29 | run: dart run grinder pkg-github-windows 30 | 31 | - name: Deploy Chocolatey (Windows) 32 | working-directory: tool/release_tool 33 | run: dart run grinder pkg-chocolatey-deploy 34 | -------------------------------------------------------------------------------- /.github/workflows/deploy_homebrew.yml: -------------------------------------------------------------------------------- 1 | name: Deploy Homebrew 2 | 3 | on: 4 | workflow_dispatch: 5 | 6 | jobs: 7 | deploy-homebrew: 8 | name: Update Homebrew 9 | runs-on: macos-latest 10 | env: 11 | PUB_CREDENTIALS: ${{ secrets.PUB_CREDENTIALS }} 12 | GITHUB_TOKEN: ${{ secrets.HOMEBREW_FVM_GH_TOKEN }} 13 | RELEASE_DART_SDK: "3.9.0" 14 | steps: 15 | - name: Checkout 16 | uses: actions/checkout@v4 17 | 18 | - name: Prepare environment 19 | uses: ./.github/actions/prepare 20 | with: 21 | sdk-version: ${{ env.RELEASE_DART_SDK }} 22 | 23 | - name: Get release tool dependencies 24 | working-directory: tool/release_tool 25 | run: dart pub get 26 | 27 | - name: Deploy versioned formula 28 | working-directory: tool/release_tool 29 | run: dart run grinder pkg-homebrew-update --versioned-formula 30 | 31 | - name: Deploy homebrew 32 | working-directory: tool/release_tool 33 | run: dart run grinder pkg-homebrew-update 34 | -------------------------------------------------------------------------------- /lib/src/workflows/verify_project.workflow.dart: -------------------------------------------------------------------------------- 1 | import '../models/project_model.dart'; 2 | import '../utils/constants.dart'; 3 | import '../utils/exceptions.dart'; 4 | import 'workflow.dart'; 5 | 6 | class VerifyProjectWorkflow extends Workflow { 7 | const VerifyProjectWorkflow(super.context); 8 | 9 | void call(Project project, {required bool force}) { 10 | if (project.hasPubspec || force) return; 11 | 12 | if (project.hasConfig && project.path != context.workingDirectory) { 13 | logger 14 | ..info() 15 | ..info('Using $kFvmConfigFileName in ${project.path}') 16 | ..info() 17 | ..info( 18 | 'If this is incorrect either use --force flag or remove $kFvmConfigFileName and $kFvmDirName directory.', 19 | ) 20 | ..info(); 21 | 22 | return; 23 | } 24 | 25 | logger.info('No pubspec.yaml detected in this directory'); 26 | 27 | if (!logger.confirm('Would you like to continue?', defaultValue: true)) { 28 | throw ForceExit.success('Project verification failed'); 29 | } 30 | } 31 | } 32 | -------------------------------------------------------------------------------- /.context/docs/act-testing.md: -------------------------------------------------------------------------------- 1 | # Act Testing Notes 2 | 3 | ## Container Differences 4 | 5 | When testing workflows locally with act, be aware of these differences: 6 | 7 | 1. **Default Containers**: 8 | - Act uses: `catthehacker/ubuntu:act-latest` (feature-rich) 9 | - GitHub Actions uses: `ubuntu:latest` (minimal) 10 | 11 | 2. **Missing Commands in Minimal Containers**: 12 | - `file` - not available in ubuntu:latest 13 | - `shellcheck` - needs to be installed 14 | - Other common tools may be missing 15 | 16 | 3. **Best Practices**: 17 | - Test with actual container images when specified 18 | - Don't rely on commands that aren't universally available 19 | - Follow KISS principle - use basic tools like `tar`, `grep`, etc. 20 | - Always test the `test-container` job which uses ubuntu:latest 21 | 22 | 4. **Running Specific Container Tests**: 23 | ```bash 24 | # Test the container job specifically 25 | ./scripts/test-workflows.sh -w test-install.yml -j test-container 26 | ``` 27 | 28 | This ensures compatibility with the actual GitHub Actions environment. 29 | -------------------------------------------------------------------------------- /.github/workflows/deploy_docker.yml: -------------------------------------------------------------------------------- 1 | name: Deploy Docker 2 | 3 | on: 4 | workflow_dispatch: 5 | 6 | jobs: 7 | deploy-docker: 8 | name: Docker Deploy (latest) 9 | runs-on: ubuntu-latest 10 | steps: 11 | - name: Checkout 12 | uses: actions/checkout@v4 13 | 14 | - name: Prepare environment 15 | uses: ./.github/actions/prepare 16 | 17 | - name: Set up QEMU 18 | uses: docker/setup-qemu-action@v3 19 | 20 | - name: Set up Docker Buildx 21 | uses: docker/setup-buildx-action@v3 22 | 23 | - name: Login to DockerHub 24 | uses: docker/login-action@v3 25 | with: 26 | username: ${{ secrets.DOCKERHUB_USERNAME }} 27 | password: ${{ secrets.DOCKERHUB_TOKEN }} 28 | 29 | - name: Build and push (latest) 30 | id: docker_build_latest 31 | uses: docker/build-push-action@v5 32 | with: 33 | file: ./.docker/Dockerfile 34 | push: true 35 | tags: leoafarias/fvm:latest 36 | -------------------------------------------------------------------------------- /.github/workflows/data.yaml: -------------------------------------------------------------------------------- 1 | name: Fetch Data 2 | on: 3 | schedule: 4 | - cron: 0 * * * * 5 | workflow_dispatch: {} 6 | 7 | jobs: 8 | scheduled: 9 | runs-on: ubuntu-latest 10 | steps: 11 | - name: Setup deno 12 | uses: denoland/setup-deno@main 13 | with: 14 | deno-version: v1.x 15 | 16 | - name: Check out repo 17 | uses: actions/checkout@v4 18 | 19 | - name: Fetch data 20 | uses: githubocto/flat@v3 21 | with: 22 | http_url: https://storage.googleapis.com/flutter_infra_release/releases/releases_macos.json 23 | downloaded_filename: releases_macos.json 24 | - name: Fetch data 25 | uses: githubocto/flat@v3 26 | with: 27 | http_url: https://storage.googleapis.com/flutter_infra_release/releases/releases_windows.json 28 | downloaded_filename: releases_windows.json 29 | - name: Fetch data 30 | uses: githubocto/flat@v3 31 | with: 32 | http_url: https://storage.googleapis.com/flutter_infra_release/releases/releases_linux.json 33 | downloaded_filename: releases_linux.json 34 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2019 Leo Farias 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /lib/fvm.dart: -------------------------------------------------------------------------------- 1 | export 'package:fvm/src/models/cache_flutter_version_model.dart'; 2 | export 'package:fvm/src/models/config_model.dart'; 3 | export 'package:fvm/src/models/flutter_root_version_file.dart'; 4 | export 'package:fvm/src/models/flutter_version_model.dart'; 5 | export 'package:fvm/src/models/log_level_model.dart'; 6 | export 'package:fvm/src/models/project_model.dart'; 7 | export 'package:fvm/src/services/cache_service.dart'; 8 | export 'package:fvm/src/services/project_service.dart'; 9 | export 'package:fvm/src/services/releases_service/models/flutter_releases_model.dart'; 10 | export 'package:fvm/src/services/releases_service/models/version_model.dart'; 11 | export 'package:fvm/src/services/releases_service/releases_client.dart'; 12 | export 'package:fvm/src/utils/change_case.dart'; 13 | export 'package:fvm/src/utils/constants.dart'; 14 | export 'package:fvm/src/utils/context.dart'; 15 | export 'package:fvm/src/utils/exceptions.dart'; 16 | export 'package:fvm/src/utils/extensions.dart'; 17 | export 'package:fvm/src/utils/git_utils.dart'; 18 | export 'package:fvm/src/utils/helpers.dart'; 19 | export 'package:fvm/src/utils/pretty_json.dart'; 20 | -------------------------------------------------------------------------------- /lib/src/commands/exec_command.dart: -------------------------------------------------------------------------------- 1 | import 'package:args/args.dart'; 2 | import 'package:args/command_runner.dart'; 3 | 4 | import '../workflows/run_configured_flutter.workflow.dart'; 5 | import 'base_command.dart'; 6 | 7 | /// Executes scripts with the configured Flutter SDK 8 | class ExecCommand extends BaseFvmCommand { 9 | @override 10 | final name = 'exec'; 11 | @override 12 | final description = 13 | 'Executes commands with the project\'s configured Flutter SDK in the environment'; 14 | @override 15 | final argParser = ArgParser.allowAnything(); 16 | 17 | /// Constructor 18 | ExecCommand(super.context); 19 | 20 | @override 21 | Future run() async { 22 | if (argResults!.rest.isEmpty) { 23 | throw UsageException('No command was provided to be executed', usage); 24 | } 25 | 26 | final cmd = argResults!.rest[0]; 27 | 28 | // Removes version from first arg 29 | final execArgs = [...?argResults?.rest]..removeAt(0); 30 | 31 | final runConfiguredFlutterWorkflow = RunConfiguredFlutterWorkflow(context); 32 | 33 | final result = await runConfiguredFlutterWorkflow(cmd, args: execArgs); 34 | 35 | return result.exitCode; 36 | } 37 | } 38 | -------------------------------------------------------------------------------- /test/utils/which_test.dart: -------------------------------------------------------------------------------- 1 | import 'dart:io'; 2 | 3 | import 'package:fvm/src/utils/which.dart'; 4 | import 'package:test/test.dart'; 5 | 6 | void main() { 7 | // Benchmark test for `which` function 8 | // Using a simplistic loop to measure performance. 9 | // For a more accurate benchmark, consider using a benchmarking package. 10 | test('Benchmark: which function', () { 11 | const totalIterations = 1000; 12 | // Setup specific environment variables, as above. 13 | var startTime = DateTime.now(); 14 | for (int i = 0; i < totalIterations; i++) { 15 | which('command', binDir: false); 16 | } 17 | var endTime = DateTime.now(); 18 | var elapsedTime = endTime.difference(startTime); 19 | 20 | final perInvocationSpeed = elapsedTime.inMilliseconds / totalIterations; 21 | 22 | // Platform-specific performance expectations 23 | // Windows PATH lookup is significantly slower than Unix systems 24 | final expectedMaxTime = Platform.isWindows ? 50.0 : 1.0; 25 | 26 | expect( 27 | perInvocationSpeed, 28 | lessThan(expectedMaxTime), 29 | reason: 30 | 'should be faster than ${expectedMaxTime}ms per call on ${Platform.operatingSystem}', 31 | ); 32 | }); 33 | } 34 | -------------------------------------------------------------------------------- /lib/src/utils/which.dart: -------------------------------------------------------------------------------- 1 | import 'dart:io'; 2 | 3 | import 'package:path/path.dart'; 4 | 5 | String? which(String command, {bool binDir = false}) { 6 | String? pathEnv = Platform.environment['PATH']; 7 | String? pathExtEnv = 8 | Platform.isWindows ? Platform.environment['PATHEXT'] : null; 9 | 10 | if (pathEnv == null) { 11 | return null; 12 | } 13 | 14 | List paths = pathEnv.split(Platform.isWindows ? ';' : ':'); 15 | List possibleExtensions = 16 | pathExtEnv != null ? pathExtEnv.split(';') : ['']; 17 | 18 | for (String dir in paths) { 19 | String fullPath = join(dir, command); 20 | File exec = File(fullPath); 21 | 22 | if (exec.existsSync()) { 23 | final execPath = exec.absolute.path; 24 | 25 | return binDir ? dirname(execPath) : execPath; 26 | } 27 | 28 | if (Platform.isWindows && pathExtEnv != null) { 29 | for (var ext in possibleExtensions) { 30 | String winPath = '$fullPath$ext'; 31 | exec = File(winPath); 32 | if (exec.existsSync()) { 33 | final execPath = exec.absolute.path; 34 | 35 | return binDir ? dirname(execPath) : execPath; 36 | } 37 | } 38 | } 39 | } 40 | 41 | return null; 42 | } 43 | -------------------------------------------------------------------------------- /docs/pages/documentation/getting-started/overview.md: -------------------------------------------------------------------------------- 1 | --- 2 | id: overview 3 | title: Overview 4 | --- 5 | 6 | # Overview 7 | 8 | FVM ensures consistent app builds by managing Flutter SDK versions per project. Install multiple Flutter versions and switch between them instantly to test new releases without reinstalling Flutter. 9 | 10 | ## Quick Start 11 | 12 | ```bash 13 | # Install FVM 14 | brew tap leoafarias/fvm 15 | brew install fvm 16 | 17 | # Set Flutter version for a project 18 | cd my_project 19 | fvm use 3.19.0 20 | 21 | # Run Flutter commands 22 | fvm flutter doctor 23 | ``` 24 | 25 | ## Key Features 26 | 27 | - **Per-project Flutter versions** - Each project can use a different Flutter SDK 28 | - **Fast switching** - Change versions instantly without re-downloading 29 | - **Team consistency** - Everyone uses the same Flutter version via `.fvmrc` 30 | - **CI/CD friendly** - Simple commands for automation 31 | - **Fork support** - Use custom Flutter repositories 32 | 33 | ## Next Steps 34 | 35 | 1. [Install FVM](/documentation/getting-started/installation) on your system 36 | 2. [Configure](/documentation/getting-started/configuration) your first project 37 | 3. Check the [FAQ](/documentation/getting-started/faq) for common questions 38 | -------------------------------------------------------------------------------- /docs/pages/documentation/guides/project-flavors.md: -------------------------------------------------------------------------------- 1 | --- 2 | id: project_flavors 3 | title: Project Flavors 4 | --- 5 | 6 | # Project Flavors 7 | 8 | You can have multiple Flutter SDK versions configured per project environment or release type. FVM follows the same convention as Flutter and calls this `flavors`. 9 | 10 | It allows you to create the following configuration for your project. 11 | 12 | ```json 13 | { 14 | "flutter": "stable", 15 | "flavors": { 16 | "development": "stable", 17 | "staging": "3.19.0", 18 | "production": "3.16.0" 19 | } 20 | } 21 | ``` 22 | 23 | ## Pin flavor version 24 | 25 | To choose a Flutter SDK version for a specific flavor, you just use the `use` command. 26 | 27 | ```bash 28 | fvm use {version} --flavor {flavor_name} 29 | ``` 30 | 31 | This will pin `version` to `flavor_name`. 32 | 33 | ## Switch flavors 34 | 35 | This will get the version configured for the flavor and set it as the project version. 36 | 37 | ```bash 38 | fvm use {flavor_name} 39 | ``` 40 | 41 | ## Spawn a command with a flavor version 42 | 43 | This will get the version configured for the flavor and use it to run a Flutter command. 44 | 45 | ```bash 46 | fvm flavor {flavor_name} {flutter_command} {flutter_command_args} 47 | ``` 48 | -------------------------------------------------------------------------------- /lib/src/utils/pretty_json.dart: -------------------------------------------------------------------------------- 1 | import 'dart:convert'; 2 | 3 | /// Formats [json] 4 | String prettyJson(Map json) { 5 | var encoder = JsonEncoder.withIndent(' '); 6 | 7 | return encoder.convert(json); 8 | } 9 | 10 | String mapToYaml(Map map, [int indentLevel = 0]) { 11 | final buffer = StringBuffer(); 12 | 13 | map.forEach((key, value) { 14 | final indent = ' ' * indentLevel * 2; 15 | buffer.write('$indent$key:'); 16 | 17 | if (value is Map) { 18 | buffer.write('\n'); 19 | buffer.write(mapToYaml(value, indentLevel + 1)); 20 | } else if (value is List) { 21 | buffer.write('\n'); 22 | for (var item in value) { 23 | buffer.write('$indent - '); 24 | if (item is Map) { 25 | buffer.write('\n'); 26 | buffer.write(mapToYaml(item, indentLevel + 2)); 27 | } else { 28 | buffer.write('$item\n'); 29 | } 30 | } 31 | } else if (value == null) { 32 | buffer.write(' null\n'); 33 | } else if (value is bool) { 34 | buffer.write(value ? ' true\n' : ' false\n'); 35 | } else { 36 | buffer.write(' $value\n'); 37 | } 38 | }); 39 | 40 | return buffer.toString(); 41 | } 42 | -------------------------------------------------------------------------------- /docs/next.config.js: -------------------------------------------------------------------------------- 1 | const withNextra = require("nextra")({ 2 | theme: "nextra-theme-docs", 3 | themeConfig: "./theme.config.tsx", 4 | }); 5 | 6 | module.exports = withNextra({ 7 | reactStrictMode: true, 8 | 9 | async redirects() { 10 | return [ 11 | { 12 | source: "/docs", 13 | destination: "/documentation/getting-started/overview", 14 | permanent: true, 15 | }, 16 | { 17 | source: "/documentation", 18 | destination: "/documentation/getting-started/overview", 19 | permanent: true, 20 | }, 21 | { 22 | source: "/documentation/getting-started", 23 | destination: "/documentation/getting-started/overview", 24 | permanent: true, 25 | }, 26 | { 27 | source: "/documentation/troubleshooting", 28 | destination: "/documentation/troubleshooting/overview", 29 | permanent: true, 30 | }, 31 | { 32 | source: "/docs/guides/faq", 33 | destination: "/documentation/getting-started/faq", 34 | permanent: true, 35 | }, 36 | { 37 | source: "/docs/guides/global_version", 38 | destination: "/documentation/guides/global-configuration", 39 | permanent: true, 40 | }, 41 | ]; 42 | }, 43 | }); 44 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/bug_report.md: -------------------------------------------------------------------------------- 1 | --- 2 | name: Bug report 3 | about: Create a report to help us improve 4 | title: "[BUG]" 5 | labels: bug 6 | assignees: "" 7 | --- 8 | 9 | **Before creating a bug report please make check the following** 10 | 11 | - [ ] You have read our [FAQ](https://fvm.app/documentation/getting-started/faq) 12 | - [ ] If you have used flutter. Please install correctly, run `pub cache repair`. Close the terminal and try again. 13 | - [ ] If you are on Windows. Make sure you are running the terminal as `administrator` or with `developer` permissions. 14 | - [ ] Run `fvm doctor` if possible and add the output to the issue. 15 | 16 | **Describe the bug** 17 | A clear and concise description of what the bug is. 18 | 19 | **To Reproduce** 20 | Steps to reproduce the behavior: 21 | 22 | 1. Go to terminal.. 23 | 2. Run `fvm use stable`... 24 | 3. Check... 25 | 4. See error 26 | 27 | **Expected behavior** 28 | A clear and concise description of what you expected to happen. 29 | 30 | **Logs** 31 | Please provide the verbose logs by running `--verbose` after the command. 32 | 33 | **Desktop (please complete the following information):** 34 | 35 | - OS: [e.g. iOS] 36 | - FVM Version [e.g. 22] 37 | - If Windows: Which Powershell are you using? 38 | 39 | **Additional context** 40 | Add any other context about the problem here. 41 | -------------------------------------------------------------------------------- /docs/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "nextra-docs-template", 3 | "version": "0.0.1", 4 | "description": "Nextra docs template", 5 | "scripts": { 6 | "dev": "next dev", 7 | "build": "next build", 8 | "start": "next start" 9 | }, 10 | "repository": { 11 | "type": "git", 12 | "url": "git+https://github.com/shuding/nextra-docs-template.git" 13 | }, 14 | "author": "Shu Ding ", 15 | "license": "MIT", 16 | "bugs": { 17 | "url": "https://github.com/shuding/nextra-docs-template/issues" 18 | }, 19 | "homepage": "https://github.com/shuding/nextra-docs-template#readme", 20 | "dependencies": { 21 | "@docsearch/react": "^3.5.2", 22 | "@radix-ui/react-icons": "^1.3.0", 23 | "@radix-ui/react-slot": "^1.0.2", 24 | "autoprefixer": "^10.4.15", 25 | "class-variance-authority": "^0.7.0", 26 | "clsx": "^2.1.0", 27 | "lucide-react": "^0.323.0", 28 | "next": "^13.5.6", 29 | "nextra": "^2.13.2", 30 | "nextra-theme-docs": "^2.13.2", 31 | "postcss": "^8.4.29", 32 | "react": "^18.2.0", 33 | "react-dom": "^18.2.0", 34 | "tailwind-merge": "^2.2.1", 35 | "tailwindcss": "^3.4.1", 36 | "tailwindcss-animate": "^1.0.7" 37 | }, 38 | "devDependencies": { 39 | "@types/node": "20.11.10", 40 | "eslint": "^8.56.0", 41 | "typescript": "^4.9.5" 42 | } 43 | } 44 | -------------------------------------------------------------------------------- /pubspec.yaml: -------------------------------------------------------------------------------- 1 | name: fvm 2 | description: A simple cli to manage Flutter SDK versions per project. Support 3 | channels, releases, and local cache for fast switching between versions. 4 | version: 4.0.5 5 | homepage: https://github.com/leoafarias/fvm 6 | 7 | environment: 8 | sdk: ">=3.6.0 <4.0.0" 9 | 10 | executables: 11 | fvm: main 12 | 13 | dependencies: 14 | args: ^2.4.2 15 | date_format: ^2.0.7 16 | git: ^2.2.1 17 | interact: ^2.2.0 18 | io: ^1.0.4 19 | mason_logger: ^0.2.9 20 | meta: ^1.10.0 21 | path: ^1.9.0 22 | pub_semver: ^2.1.4 23 | pub_updater: ^0.4.0 24 | scope: ^5.1.0 25 | yaml: ^3.1.2 26 | yaml_edit: ^2.2.0 27 | dart_console: ^1.2.0 28 | tint: ^2.0.1 29 | stack_trace: ^1.11.1 30 | pubspec_parse: ^1.5.0 31 | jsonc: ^0.0.3 32 | dart_mappable: ^4.2.2 33 | cli_completion: ^0.5.0 34 | yaml_writer: ^2.1.0 35 | win32: ^5.0.0 36 | 37 | dev_dependencies: 38 | grinder: ^0.9.5 39 | test: ^1.24.6 40 | lints: ^2.1.1 41 | crypto: ^3.0.3 42 | http: ^1.1.0 43 | dart_code_metrics_presets: ^2.9.0 44 | build_runner: ^2.4.8 45 | dart_mappable_builder: ^4.2.3 46 | build_verify: ^3.1.0 47 | build_version: ^2.1.1 48 | mocktail: ^1.0.4 49 | husky: ^0.1.7 50 | lint_staged: ^0.5.1 51 | 52 | # Git hooks configuration 53 | lint_staged: 54 | '**.dart': dart format && dart fix --apply 55 | -------------------------------------------------------------------------------- /.docker/alpine/Dockerfile: -------------------------------------------------------------------------------- 1 | FROM alpine:3.13 2 | 3 | USER root 4 | 5 | ARG FVM_VERSION 6 | ARG GLIBC_VERSION="2.28-r0" 7 | 8 | # Install Required Tools 9 | RUN apk -U update && apk -U add \ 10 | bash \ 11 | ca-certificates \ 12 | curl \ 13 | git \ 14 | make \ 15 | libstdc++ \ 16 | libgcc \ 17 | mesa-dev \ 18 | unzip \ 19 | wget \ 20 | zlib \ 21 | && wget https://alpine-pkgs.sgerrand.com/sgerrand.rsa.pub -O /etc/apk/keys/sgerrand.rsa.pub \ 22 | && wget https://github.com/sgerrand/alpine-pkg-glibc/releases/download/${GLIBC_VERSION}/glibc-${GLIBC_VERSION}.apk -O /tmp/glibc.apk \ 23 | && wget https://github.com/sgerrand/alpine-pkg-glibc/releases/download/${GLIBC_VERSION}/glibc-bin-${GLIBC_VERSION}.apk -O /tmp/glibc-bin.apk \ 24 | && apk add /tmp/glibc.apk /tmp/glibc-bin.apk \ 25 | && rm -rf /tmp/* \ 26 | && rm -rf /var/cache/apk/* \ 27 | && addgroup -g 1000 flutter \ 28 | && adduser -u 1000 -G flutter -s /bin/bash -D flutter 29 | 30 | USER flutter 31 | 32 | ARG HOME=/home/flutter 33 | 34 | ENV PATH=$HOME/fvm:$HOME/.pub-cache/bin:$HOME/fvm/default/bin:${PATH} 35 | 36 | RUN cd $HOME \ 37 | && wget https://github.com/leoafarias/fvm/releases/download/${FVM_VERSION}/fvm-${FVM_VERSION}-linux-x64.tar.gz \ 38 | && tar -xf fvm-${FVM_VERSION}-linux-x64.tar.gz \ 39 | && rm fvm-${FVM_VERSION}-linux-x64.tar.gz \ 40 | && fvm --version -------------------------------------------------------------------------------- /lib/src/utils/exceptions.dart: -------------------------------------------------------------------------------- 1 | import 'dart:io'; 2 | 3 | import 'package:io/io.dart'; 4 | 5 | /// Represents an FVM-specific exception that carries a user-facing message. 6 | /// 7 | /// Exceptions of this type are intended to be self-explanatory, no debugging info 8 | class AppException implements Exception { 9 | /// User-readable error message. 10 | final String message; 11 | 12 | /// Initializes an instance with a user-readable message. 13 | const AppException(this.message); 14 | 15 | @override 16 | String toString() => message; 17 | } 18 | 19 | class AppDetailedException extends AppException { 20 | final String info; 21 | 22 | /// Constructor 23 | const AppDetailedException(super.message, this.info); 24 | 25 | @override 26 | String toString() => message; 27 | } 28 | 29 | bool checkIfNeedsPrivilegePermission(FileSystemException err) { 30 | return err.osError?.errorCode == 1314 && Platform.isWindows; 31 | } 32 | 33 | class ForceExit extends AppException { 34 | final int exitCode; 35 | 36 | const ForceExit(super.message, this.exitCode); 37 | 38 | static ForceExit success([String? message]) => 39 | ForceExit(message ?? '', ExitCode.success.code); 40 | 41 | static ForceExit unavailable([String? message]) => 42 | ForceExit(message ?? '', ExitCode.unavailable.code); 43 | 44 | @override 45 | String toString() => message; 46 | } 47 | -------------------------------------------------------------------------------- /lib/src/workflows/validate_flutter_version.workflow.dart: -------------------------------------------------------------------------------- 1 | import '../models/flutter_version_model.dart'; 2 | import '../utils/exceptions.dart'; 3 | import '../utils/extensions.dart'; 4 | import 'workflow.dart'; 5 | 6 | class ValidateFlutterVersionWorkflow extends Workflow { 7 | const ValidateFlutterVersionWorkflow(super.context); 8 | 9 | FlutterVersion call(String version) { 10 | final flutterVersion = FlutterVersion.parse(version); 11 | 12 | if (flutterVersion.fromFork) { 13 | logger.debug('Forked version: $version'); 14 | 15 | // Check if fork exists on config 16 | final fork = context.config.forks.firstWhereOrNull( 17 | (f) => f.name == flutterVersion.fork, 18 | ); 19 | 20 | if (fork == null) { 21 | throw AppDetailedException( 22 | 'Fork "${flutterVersion.fork}" has not been configured', 23 | 'Add the fork to your configuration first: fvm config', 24 | ); 25 | } 26 | 27 | return flutterVersion; 28 | } 29 | 30 | // If its channel or local version no need for further validation 31 | if (flutterVersion.isChannel || flutterVersion.isCustom) { 32 | return flutterVersion; 33 | } 34 | 35 | // Skip git reference validation - let the installation process handle it 36 | logger.debug('Skipping git reference validation for version: $version'); 37 | 38 | return flutterVersion; 39 | } 40 | } 41 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Files and directories created by pub 2 | .dart_tool/ 3 | .packages 4 | .claude 5 | 6 | # Do not version the sdk versions 7 | versions/ 8 | .DS_Store 9 | 10 | # Conventional directory for build outputs 11 | build/ 12 | 13 | # Directory created by dartdoc 14 | doc/api/ 15 | 16 | # Test Coverage 17 | test/.test_coverage.dart 18 | 19 | # IntelliJ related 20 | *.iml 21 | *.ipr 22 | *.iws 23 | .idea/ 24 | 25 | # Visual Studio Code related 26 | .classpath 27 | .project 28 | .settings/ 29 | .vscode/ 30 | coverage/lcov.info 31 | 32 | .vscode/settings.json 33 | packages/cli/test/.test_coverage.dart 34 | packages/cli/.env 35 | .env 36 | node_modules 37 | test/.test_cov.dart 38 | test/support_assets/dart_package/lib/src/version.dart 39 | 40 | coverage/test 41 | .failed_tracker 42 | coverage/html 43 | test/support_assets/*/pubspec.lock 44 | test/support_assets/*/pubspec.lock 45 | 46 | docs/.next 47 | 48 | # Ignore fvm binary 49 | fvm 50 | .env.local 51 | 52 | # Act local testing 53 | .secrets.act 54 | *.act.log 55 | 56 | # FVM Version Cache 57 | .fvm/ 58 | 59 | test/fixtures/sample_app 60 | 61 | # Example app directory 62 | example_app/ 63 | # Context folder (allow docs subfolder) 64 | .context/* 65 | !.context/docs/ 66 | .augment-guidelines 67 | 68 | # Platform-specific directories (generated during testing) 69 | windows/ 70 | web/ 71 | linux/ 72 | macos/ 73 | ios/ 74 | android/ 75 | coverage/html_report 76 | .augment/env/setup.sh 77 | -------------------------------------------------------------------------------- /test/utils/releases_api_test.dart: -------------------------------------------------------------------------------- 1 | import 'package:fvm/src/services/releases_service/releases_client.dart'; 2 | import 'package:fvm/src/utils/context.dart'; 3 | import 'package:test/test.dart'; 4 | 5 | import '../testing_utils.dart'; 6 | 7 | void main() { 8 | late FvmContext context; 9 | 10 | setUp(() { 11 | context = TestFactory.context(); 12 | }); 13 | 14 | group('Flutter Releases API', () { 15 | test('Has Flutter Releases', () async { 16 | final flutterReleases = FlutterReleaseClient(context); 17 | final releases = await flutterReleases.fetchReleases(); 18 | final versionsExists = releases.containsVersion('v1.8.1') && 19 | releases.containsVersion('v1.9.6') && 20 | releases.containsVersion('v1.10.5') && 21 | releases.containsVersion('v1.9.1+hotfix.4'); 22 | final channels = releases.channels.toMap().keys; 23 | 24 | expect(versionsExists, isTrue); 25 | expect(channels.length, 3); 26 | }); 27 | 28 | // test('Can fetch releases for all platforms', () async { 29 | // try { 30 | // await Future.wait([ 31 | // fetch(getReleasesUrl('macos')), 32 | // fetch(getReleasesUrl('linux')), 33 | // fetch(getReleasesUrl('windows')), 34 | // ]); 35 | 36 | // expect(true, true); 37 | // } on Exception catch (err) { 38 | // fail('Could not resolve all platform releases \n $err'); 39 | // } 40 | // }); 41 | }); 42 | } 43 | -------------------------------------------------------------------------------- /scripts/install.md: -------------------------------------------------------------------------------- 1 | # FVM Installation Guide 2 | 3 | ## Install on macOS and Linux 4 | 5 | ### Quick Install (Latest Version) 6 | 7 | ```bash 8 | curl -fsSL https://fvm.app/install.sh | bash 9 | ``` 10 | 11 | Note: The installer cannot modify your current shell PATH when run as a separate 12 | process (e.g., `curl | bash`). For CI or same-step usage, add: 13 | 14 | ```bash 15 | export PATH="$HOME/fvm/bin:$PATH" 16 | ``` 17 | 18 | ### Install Specific Version 19 | 20 | ```bash 21 | curl -fsSL https://fvm.app/install.sh | bash -s 3.2.1 22 | ``` 23 | 24 | ### Container/CI Installation 25 | 26 | For Docker, Podman, or CI environments: 27 | 28 | ```bash 29 | export FVM_ALLOW_ROOT=true 30 | curl -fsSL https://fvm.app/install.sh | bash 31 | ``` 32 | 33 | For same-step usage, add: 34 | 35 | ```bash 36 | export PATH="$HOME/fvm/bin:$PATH" 37 | ``` 38 | 39 | For later steps, persist PATH using your CI's env file mechanism 40 | (e.g., `$GITHUB_PATH` on GitHub Actions, `$BASH_ENV` on CircleCI). 41 | 42 | ### Uninstall 43 | 44 | ```bash 45 | curl -fsSL https://fvm.app/install.sh | bash -s -- --uninstall 46 | ``` 47 | 48 | ## Install on Windows 49 | 50 | ### Chocolatey 51 | 52 | ```bash 53 | choco install fvm 54 | ``` 55 | 56 | ## Features 57 | 58 | - **PATH instructions** for bash, zsh, and fish shells 59 | - **Container support** with security safeguards 60 | - **Version validation** and error handling 61 | - **Unified install/uninstall** in a single script 62 | - **Cross-platform** support (macOS, Linux, Windows) 63 | -------------------------------------------------------------------------------- /.pubignore: -------------------------------------------------------------------------------- 1 | # Documentation website (Next.js) - not needed in Dart package 2 | docs/ 3 | 4 | # Shell scripts and setup files - not needed in Dart package 5 | scripts/ 6 | setup.sh 7 | 8 | # Docker configuration - not needed in Dart package 9 | .docker/ 10 | 11 | # GitHub workflows and templates - not needed in Dart package 12 | .github/ 13 | 14 | # Large Flutter release data files - not needed in Dart package 15 | releases_linux.json 16 | releases_macos.json 17 | releases_windows.json 18 | 19 | # Development and testing files - not needed in Dart package 20 | ACT_TESTING_NOTES.md 21 | FVM_v4.0_GITHUB_RELEASE.md 22 | TEST_RELEASE_WORKFLOW.md 23 | .actrc 24 | .env.act 25 | .husky/ 26 | coverage/ 27 | 28 | # Chocolatey package spec - generated during build 29 | fvm.nuspec 30 | 31 | # Build directory - contains compiled binaries for GitHub releases 32 | build/ 33 | 34 | # Release automation package (not published to pub.dev) 35 | tool/release_tool/ 36 | 37 | # Keep these files (they're useful for pub.dev users): 38 | # README.md - main project documentation 39 | # CHANGELOG.md - required for pub.dev 40 | # LICENSE - required for pub.dev 41 | # lib/ - the actual Dart code 42 | # bin/ - CLI executables 43 | # test/ - useful for pub.dev users 44 | # example/ - useful for pub.dev users 45 | # tool/ - build tools that might be referenced 46 | # assets/ - package assets 47 | # pubspec.yaml - package configuration 48 | # analysis_options.yaml - Dart analysis configuration 49 | # build.yaml - build configuration 50 | # dart_test.yaml - test configuration 51 | -------------------------------------------------------------------------------- /test/commands/config_command_test.dart: -------------------------------------------------------------------------------- 1 | import 'package:fvm/fvm.dart'; 2 | import 'package:io/io.dart'; 3 | import 'package:test/test.dart'; 4 | 5 | import '../testing_utils.dart'; 6 | 7 | void main() { 8 | group('Config command:', () { 9 | late TestCommandRunner runner; 10 | late LocalAppConfig originalConfig; 11 | 12 | setUp(() { 13 | runner = TestFactory.commandRunner(); 14 | originalConfig = LocalAppConfig.read(); 15 | }); 16 | 17 | tearDown(() { 18 | originalConfig.save(); 19 | }); 20 | 21 | test('fvm config --no-update-check persists disableUpdateCheck', () async { 22 | final exitCode = await runner.runOrThrow([ 23 | 'fvm', 24 | 'config', 25 | '--no-update-check', 26 | ]); 27 | 28 | expect(exitCode, ExitCode.success.code); 29 | 30 | final updatedConfig = LocalAppConfig.read(); 31 | expect(updatedConfig.disableUpdateCheck, isTrue); 32 | }); 33 | 34 | test('fvm config --update-check sets disableUpdateCheck to false', 35 | () async { 36 | // First disable update check 37 | await runner.runOrThrow(['fvm', 'config', '--no-update-check']); 38 | 39 | // Then re-enable it 40 | final exitCode = await runner.runOrThrow([ 41 | 'fvm', 42 | 'config', 43 | '--update-check', 44 | ]); 45 | 46 | expect(exitCode, ExitCode.success.code); 47 | 48 | final updatedConfig = LocalAppConfig.read(); 49 | expect(updatedConfig.disableUpdateCheck, isFalse); 50 | }); 51 | }); 52 | } 53 | -------------------------------------------------------------------------------- /lib/src/commands/destroy_command.dart: -------------------------------------------------------------------------------- 1 | import 'package:io/io.dart'; 2 | 3 | import '../utils/extensions.dart'; 4 | import 'base_command.dart'; 5 | 6 | /// Destroy FVM cache by deleting all Flutter SDK versions 7 | class DestroyCommand extends BaseFvmCommand { 8 | @override 9 | final name = 'destroy'; 10 | 11 | @override 12 | final description = 13 | 'Completely removes the FVM cache and all cached Flutter SDK versions'; 14 | 15 | /// Constructor 16 | DestroyCommand(super.context) { 17 | argParser.addFlag( 18 | 'force', 19 | abbr: 'f', 20 | help: 'Bypass confirmation prompt (use with caution)', 21 | negatable: false, 22 | ); 23 | } 24 | 25 | @override 26 | Future run() async { 27 | final force = boolArg('force'); 28 | 29 | // Proceed if force flag is used OR user confirms 30 | // When skipInput is true, default to false (safe default for destructive operation) 31 | final shouldProceed = force || 32 | logger.confirm( 33 | 'Are you sure you want to destroy the FVM cache directory and references?\n' 34 | 'This action cannot be undone. Do you want to proceed?', 35 | defaultValue: false, 36 | ); 37 | 38 | if (shouldProceed) { 39 | if (context.versionsCachePath.dir.existsSync()) { 40 | context.versionsCachePath.dir.deleteSync(recursive: true); 41 | logger.success( 42 | 'FVM Directory ${context.versionsCachePath} has been deleted', 43 | ); 44 | } 45 | } 46 | 47 | return ExitCode.success.code; 48 | } 49 | } 50 | -------------------------------------------------------------------------------- /docs/pages/documentation/guides/global-configuration.mdx: -------------------------------------------------------------------------------- 1 | --- 2 | id: global_version 3 | title: Configure Global Version 4 | --- 5 | 6 | import { Callout } from "nextra/components"; 7 | 8 | # Overview 9 | 10 | You can have the default Flutter version on your machine and still preserve dynamic switching. This allows you to make no changes to how you currently use Flutter, but benefit from faster switching and version caching. 11 | 12 | 13 | It is recommended that you install FVM as a standalone executable instead of 14 | through pub.dev to avoid dependency conflicts. 15 | 16 | 17 | If you are configuring a global version, FVM will check if the global version is set in your environment path. If it is not, it will provide you with the path that needs to be configured. 18 | 19 | ### Link global version 20 | 21 | To accomplish this, FVM provides you with a helper command to configure a global version. 22 | 23 | ```bash 24 | fvm global {version} 25 | ``` 26 | 27 | Now you will be able to do the following. 28 | 29 | ```bash title="Example" 30 | # Set beta channel as global 31 | fvm global beta 32 | 33 | # Check version 34 | flutter --version # Will be beta release 35 | 36 | # Set stable channel as global 37 | fvm global stable 38 | 39 | # Check version 40 | flutter --version # Will be stable release 41 | ``` 42 | 43 | 44 | After you run the command, FVM will check if the global version is configured 45 | in your environment path. If it is not, it will provide you with the path that 46 | needs to be configured. 47 | 48 | -------------------------------------------------------------------------------- /scripts/README.md: -------------------------------------------------------------------------------- 1 | # FVM Scripts 2 | 3 | This directory contains installation and testing scripts for FVM. 4 | 5 | ## Scripts 6 | 7 | ### install.sh 8 | The main FVM installation script for Linux/macOS that: 9 | - Detects OS and architecture 10 | - Downloads the appropriate FVM binary 11 | - Creates a system-wide symlink 12 | - Configures shell PATH 13 | - Supports container environments (Docker, Podman, CI) 14 | - **Now includes uninstall functionality via `--uninstall` flag** 15 | 16 | Usage: 17 | ```bash 18 | # Install latest version 19 | curl -fsSL https://fvm.app/install.sh | bash 20 | 21 | # Install specific version 22 | curl -fsSL https://fvm.app/install.sh | bash -s 3.2.1 23 | 24 | # Uninstall FVM 25 | ./install.sh --uninstall 26 | 27 | # Container/CI support 28 | export FVM_ALLOW_ROOT=true 29 | ./install.sh 30 | ``` 31 | 32 | ### test-install.sh 33 | Test script for the installation logic: 34 | - Tests container detection (Docker, Podman) 35 | - Tests CI environment detection 36 | - Tests manual override (FVM_ALLOW_ROOT) 37 | - Validates security (blocks root in regular environments) 38 | 39 | Usage: 40 | ```bash 41 | # Test as regular user 42 | ./scripts/test-install.sh 43 | 44 | # Test all scenarios (requires root) 45 | sudo ./scripts/test-install.sh 46 | ``` 47 | 48 | ### install.md 49 | Documentation for the installation process. 50 | 51 | ## Design Principles 52 | 53 | All scripts follow: 54 | - **KISS**: Simple, straightforward logic 55 | - **DRY**: No code duplication 56 | - **YAGNI**: Only essential features 57 | - **Security**: Safe defaults with escape hatches for containers 58 | -------------------------------------------------------------------------------- /docs/tailwind.config.ts: -------------------------------------------------------------------------------- 1 | /** @type {import('tailwindcss').Config} */ 2 | export default { 3 | content: [ 4 | "./pages/**/*.{js,ts,jsx,tsx,mdx}", 5 | "./components/**/*.{js,ts,jsx,tsx}", 6 | "./theme.config.tsx", 7 | ], 8 | theme: { 9 | extend: { 10 | fontFamily: { 11 | sans: ["var(--font-inter)"], 12 | mono: ["var(--font-jetbrains-mono)"], 13 | }, 14 | fontSize: { 15 | "2xs": ["0.75rem", { lineHeight: "1.25rem" }], 16 | xs: ["0.8125rem", { lineHeight: "1.5rem" }], 17 | sm: ["0.875rem", { lineHeight: "1.5rem" }], 18 | base: ["1rem", { lineHeight: "1.75rem" }], 19 | lg: ["1.125rem", { lineHeight: "1.75rem" }], 20 | xl: ["1.25rem", { lineHeight: "1.75rem" }], 21 | "2xl": ["1.5rem", { lineHeight: "2rem" }], 22 | "3xl": ["1.875rem", { lineHeight: "2.25rem" }], 23 | "4xl": ["2.25rem", { lineHeight: "2.5rem" }], 24 | "5xl": ["3rem", { lineHeight: "1" }], 25 | "6xl": ["3.75rem", { lineHeight: "1" }], 26 | "7xl": ["4.5rem", { lineHeight: "1" }], 27 | "8xl": ["6rem", { lineHeight: "1" }], 28 | "9xl": ["8rem", { lineHeight: "1" }], 29 | }, 30 | boxShadow: { 31 | glow: "0 0 4px rgb(0 0 0 / 0.1)", 32 | }, 33 | maxWidth: { 34 | lg: "33rem", 35 | "2xl": "40rem", 36 | "3xl": "50rem", 37 | "5xl": "66rem", 38 | }, 39 | opacity: { 40 | 1: "0.01", 41 | 2.5: "0.025", 42 | 7.5: "0.075", 43 | 15: "0.15", 44 | }, 45 | }, 46 | }, 47 | plugins: [], 48 | darkMode: "class", 49 | }; 50 | -------------------------------------------------------------------------------- /lib/src/commands/spawn_command.dart: -------------------------------------------------------------------------------- 1 | import 'package:args/args.dart'; 2 | import 'package:args/command_runner.dart'; 3 | 4 | import '../services/flutter_service.dart'; 5 | import '../workflows/ensure_cache.workflow.dart'; 6 | import '../workflows/validate_flutter_version.workflow.dart'; 7 | import 'base_command.dart'; 8 | 9 | /// Spawn Flutter Commands in other versions 10 | class SpawnCommand extends BaseFvmCommand { 11 | @override 12 | final name = 'spawn'; 13 | @override 14 | final description = 'Executes Flutter commands using a specific SDK version'; 15 | @override 16 | final argParser = ArgParser.allowAnything(); 17 | 18 | SpawnCommand(super.context); 19 | 20 | @override 21 | Future run() async { 22 | if (argResults!.rest.isEmpty) { 23 | throw UsageException( 24 | 'Need to provide a version to spawn a Flutter command', 25 | usage, 26 | ); 27 | } 28 | 29 | final validateFlutterVersion = ValidateFlutterVersionWorkflow(context); 30 | final ensureCache = EnsureCacheWorkflow(context); 31 | final version = argResults!.rest[0]; 32 | // Removes version from first arg 33 | final flutterArgs = [...?argResults?.rest]..removeAt(0); 34 | 35 | final flutterVersion = validateFlutterVersion(version); 36 | // Will install version if not already installed 37 | final cacheVersion = await ensureCache(flutterVersion); 38 | // Runs flutter command with pinned version 39 | logger.info('Spawning version "$version"...'); 40 | 41 | final results = await get().runFlutter( 42 | flutterArgs, 43 | cacheVersion, 44 | ); 45 | 46 | return results.exitCode; 47 | } 48 | } 49 | -------------------------------------------------------------------------------- /test/src/commands/flutter_upgrade_check_test.dart: -------------------------------------------------------------------------------- 1 | import 'package:fvm/src/commands/flutter_command.dart'; 2 | import 'package:fvm/src/models/cache_flutter_version_model.dart'; 3 | import 'package:fvm/src/models/flutter_version_model.dart'; 4 | import 'package:fvm/src/services/cache_service.dart'; 5 | import 'package:fvm/src/utils/exceptions.dart'; 6 | import 'package:test/test.dart'; 7 | 8 | import '../../testing_utils.dart'; 9 | 10 | void main() { 11 | group('checkIfUpgradeCommand', () { 12 | test('throws for release versions', () { 13 | final context = TestFactory.context(); 14 | final cacheService = context.get(); 15 | final release = FlutterVersion.parse('2.0.0'); 16 | final dir = cacheService.getVersionCacheDir(release); 17 | dir.createSync(recursive: true); 18 | final cacheVersion = CacheFlutterVersion.fromVersion( 19 | release, 20 | directory: dir.path, 21 | ); 22 | cacheService.setGlobal(cacheVersion); 23 | 24 | expect( 25 | () => checkIfUpgradeCommand(context, ['upgrade']), 26 | throwsA(isA()), 27 | ); 28 | }); 29 | 30 | test('allows channel versions', () { 31 | final context = TestFactory.context(); 32 | final cacheService = context.get(); 33 | final channel = FlutterVersion.parse('stable'); 34 | final dir = cacheService.getVersionCacheDir(channel); 35 | dir.createSync(recursive: true); 36 | final cacheVersion = CacheFlutterVersion.fromVersion( 37 | channel, 38 | directory: dir.path, 39 | ); 40 | cacheService.setGlobal(cacheVersion); 41 | 42 | expect( 43 | () => checkIfUpgradeCommand(context, ['upgrade']), 44 | returnsNormally, 45 | ); 46 | }); 47 | }); 48 | } 49 | -------------------------------------------------------------------------------- /lib/src/commands/base_command.dart: -------------------------------------------------------------------------------- 1 | import 'package:args/command_runner.dart'; 2 | import 'package:meta/meta.dart'; 3 | 4 | import '../services/base_service.dart'; 5 | import '../services/logger_service.dart'; 6 | import '../utils/context.dart'; 7 | 8 | /// Base Command 9 | abstract class BaseFvmCommand extends Command { 10 | @protected 11 | final FvmContext context; 12 | 13 | BaseFvmCommand(this.context); 14 | 15 | Logger get logger => context.get(); 16 | 17 | T get() => context.get(); 18 | 19 | @override 20 | String get invocation => 'fvm $name'; 21 | 22 | // Override to make sure commands are visible by default 23 | @override 24 | bool get hidden => false; 25 | } 26 | 27 | extension CommandExtension on Command { 28 | /// Checks if the command-line option named [name] was parsed. 29 | bool wasParsed(String name) => argResults!.wasParsed(name); 30 | 31 | /// Gets the parsed command-line option named [name] as `bool`. 32 | bool boolArg(String name) => argResults![name] == true; 33 | 34 | /// Gets the parsed command-line option named [name] as `String`. 35 | String? stringArg(String name) { 36 | final arg = argResults![name] as String?; 37 | if (arg == 'null' || (arg == null || arg.isEmpty)) { 38 | return null; 39 | } 40 | 41 | return arg; 42 | } 43 | 44 | int? intArg(String name) { 45 | final arg = stringArg(name); 46 | 47 | return arg == null ? null : int.tryParse(arg); 48 | } 49 | 50 | /// Gets the parsed command-line option named [name] as `List`. 51 | List stringsArg(String name) => (argResults![name] as List).cast(); 52 | 53 | /// Gets the first rest argument if available, otherwise returns null 54 | String? get firstRestArg => 55 | argResults!.rest.isEmpty ? null : argResults!.rest[0]; 56 | } 57 | -------------------------------------------------------------------------------- /tool/grind.dart: -------------------------------------------------------------------------------- 1 | import 'dart:io'; 2 | 3 | import 'package:grinder/grinder.dart'; 4 | 5 | import '../test/testing_helpers/prepare_test_environment.dart'; 6 | 7 | void main(List args) => grind(args); 8 | 9 | @Task('Compile') 10 | void compile() { 11 | run('dart', arguments: ['compile', 'exe', 'bin/main.dart', '-o', 'fvm']); 12 | } 13 | 14 | @Task('Prepare test environment') 15 | void testSetup() { 16 | final testDir = Directory(getTempTestDir()); 17 | if (testDir.existsSync()) { 18 | testDir.deleteSync(recursive: true); 19 | } 20 | 21 | runDartScript('bin/main.dart', arguments: ['install', 'stable']); 22 | } 23 | 24 | @Task('Run tests') 25 | @Depends(testSetup) 26 | Future test() async { 27 | await runAsync('dart', arguments: ['test', '--coverage=coverage']); 28 | } 29 | 30 | @Task('Get coverage') 31 | Future coverage() async { 32 | await runAsync('dart', arguments: ['pub', 'global', 'activate', 'coverage']); 33 | 34 | // Format coverage 35 | await runAsync( 36 | 'dart', 37 | arguments: [ 38 | 'pub', 39 | 'global', 40 | 'run', 41 | 'coverage:format_coverage', 42 | '--lcov', 43 | '--packages=.dart_tool/package_config.json', 44 | '--report-on=lib/', 45 | '--in=coverage', 46 | '--out=coverage/lcov.info', 47 | ], 48 | ); 49 | } 50 | 51 | @Task('Run integration tests') 52 | Future integrationTest() async { 53 | print('Running integration tests...'); 54 | 55 | // Run integration tests using the new Dart command 56 | await runAsync( 57 | 'dart', 58 | arguments: ['run', 'bin/main.dart', 'integration-test'], 59 | ); 60 | 61 | print('Integration tests completed successfully'); 62 | } 63 | 64 | @Task('Run all tests (unit + integration)') 65 | @Depends(test, integrationTest) 66 | void testAll() { 67 | print('All tests completed successfully'); 68 | } 69 | -------------------------------------------------------------------------------- /.metadata: -------------------------------------------------------------------------------- 1 | # This file tracks properties of this Flutter project. 2 | # Used by Flutter tool to assess capabilities and perform upgrades etc. 3 | # 4 | # This file should be version controlled and should not be manually edited. 5 | 6 | version: 7 | revision: "35c388afb57ef061d06a39b537336c87e0e3d1b1" 8 | channel: "stable" 9 | 10 | project_type: app 11 | 12 | # Tracks metadata for the flutter migrate command 13 | migration: 14 | platforms: 15 | - platform: root 16 | create_revision: 35c388afb57ef061d06a39b537336c87e0e3d1b1 17 | base_revision: 35c388afb57ef061d06a39b537336c87e0e3d1b1 18 | - platform: android 19 | create_revision: 35c388afb57ef061d06a39b537336c87e0e3d1b1 20 | base_revision: 35c388afb57ef061d06a39b537336c87e0e3d1b1 21 | - platform: ios 22 | create_revision: 35c388afb57ef061d06a39b537336c87e0e3d1b1 23 | base_revision: 35c388afb57ef061d06a39b537336c87e0e3d1b1 24 | - platform: linux 25 | create_revision: 35c388afb57ef061d06a39b537336c87e0e3d1b1 26 | base_revision: 35c388afb57ef061d06a39b537336c87e0e3d1b1 27 | - platform: macos 28 | create_revision: 35c388afb57ef061d06a39b537336c87e0e3d1b1 29 | base_revision: 35c388afb57ef061d06a39b537336c87e0e3d1b1 30 | - platform: web 31 | create_revision: 35c388afb57ef061d06a39b537336c87e0e3d1b1 32 | base_revision: 35c388afb57ef061d06a39b537336c87e0e3d1b1 33 | - platform: windows 34 | create_revision: 35c388afb57ef061d06a39b537336c87e0e3d1b1 35 | base_revision: 35c388afb57ef061d06a39b537336c87e0e3d1b1 36 | 37 | # User provided section 38 | 39 | # List of Local paths (relative to this file) that should be 40 | # ignored by the migrate tool. 41 | # 42 | # Files that are not part of the templates will be ignored by default. 43 | unmanaged_files: 44 | - 'lib/main.dart' 45 | - 'ios/Runner.xcodeproj/project.pbxproj' 46 | -------------------------------------------------------------------------------- /docs/globals.css: -------------------------------------------------------------------------------- 1 | @tailwind base; 2 | @tailwind components; 3 | @tailwind utilities; 4 | 5 | @layer base { 6 | :root { 7 | --background: 0 0% 100%; 8 | --foreground: 222.2 84% 4.9%; 9 | 10 | --card: 0 0% 100%; 11 | --card-foreground: 222.2 84% 4.9%; 12 | 13 | --popover: 0 0% 100%; 14 | --popover-foreground: 222.2 84% 4.9%; 15 | 16 | --primary: 222.2 47.4% 11.2%; 17 | --primary-foreground: 210 40% 98%; 18 | 19 | --secondary: 210 40% 96.1%; 20 | --secondary-foreground: 222.2 47.4% 11.2%; 21 | 22 | --muted: 210 40% 96.1%; 23 | --muted-foreground: 215.4 16.3% 46.9%; 24 | 25 | --accent: 210 40% 96.1%; 26 | --accent-foreground: 222.2 47.4% 11.2%; 27 | 28 | --destructive: 0 84.2% 60.2%; 29 | --destructive-foreground: 210 40% 98%; 30 | 31 | --border: 214.3 31.8% 91.4%; 32 | --input: 214.3 31.8% 91.4%; 33 | --ring: 222.2 84% 4.9%; 34 | 35 | --radius: 0.5rem; 36 | } 37 | 38 | .dark { 39 | --background: 222.2 84% 4.9%; 40 | --foreground: 210 40% 98%; 41 | 42 | --card: 222.2 84% 4.9%; 43 | --card-foreground: 210 40% 98%; 44 | 45 | --popover: 222.2 84% 4.9%; 46 | --popover-foreground: 210 40% 98%; 47 | 48 | --primary: 210 40% 98%; 49 | --primary-foreground: 222.2 47.4% 11.2%; 50 | 51 | --secondary: 217.2 32.6% 17.5%; 52 | --secondary-foreground: 210 40% 98%; 53 | 54 | --muted: 217.2 32.6% 17.5%; 55 | --muted-foreground: 215 20.2% 65.1%; 56 | 57 | --accent: 217.2 32.6% 17.5%; 58 | --accent-foreground: 210 40% 98%; 59 | 60 | --destructive: 0 62.8% 30.6%; 61 | --destructive-foreground: 210 40% 98%; 62 | 63 | --border: 217.2 32.6% 17.5%; 64 | --input: 217.2 32.6% 17.5%; 65 | --ring: 212.7 26.8% 83.9%; 66 | } 67 | } 68 | 69 | @layer base { 70 | * { 71 | @apply border-border; 72 | } 73 | body { 74 | @apply bg-background text-foreground; 75 | } 76 | } 77 | 78 | -------------------------------------------------------------------------------- /lib/src/models/git_reference_model.dart: -------------------------------------------------------------------------------- 1 | import 'dart:convert'; 2 | 3 | import 'package:dart_mappable/dart_mappable.dart'; 4 | 5 | part 'git_reference_model.mapper.dart'; 6 | 7 | // Renamed to GitReference to be more descriptive 8 | @MappableClass() 9 | abstract class GitReference with GitReferenceMappable { 10 | final String sha; 11 | final String name; 12 | 13 | const GitReference({required this.sha, required this.name}); 14 | 15 | static List parseGitReferences(String output) { 16 | return _parseGitReferences(output); 17 | } 18 | } 19 | 20 | const _localBranchPrefix = 'refs/heads/'; 21 | const _localTagPrefix = 'refs/tags/'; 22 | 23 | // Renamed function to better describe what it does 24 | List _parseGitReferences(String input) { 25 | final lines = const LineSplitter().convert(input); 26 | final references = []; 27 | 28 | for (final line in lines) { 29 | final reference = _parseFromLine(line); 30 | if (reference != null) { 31 | references.add(reference); 32 | } 33 | } 34 | 35 | return references; 36 | } 37 | 38 | GitReference? _parseFromLine(String line) { 39 | final parts = line.split('\t'); 40 | if (parts.length != 2) return null; 41 | 42 | final sha = parts[0]; 43 | final ref = parts[1]; 44 | 45 | if (ref.startsWith(_localBranchPrefix)) { 46 | return GitBranch(sha: sha, name: ref.substring(_localBranchPrefix.length)); 47 | } 48 | 49 | if (ref.startsWith(_localTagPrefix) && !ref.endsWith('^{}')) { 50 | return GitTag(sha: sha, name: ref.substring(_localTagPrefix.length)); 51 | } 52 | 53 | return null; 54 | } 55 | 56 | @MappableClass() 57 | class GitBranch extends GitReference with GitBranchMappable { 58 | const GitBranch({required super.sha, required super.name}); 59 | } 60 | 61 | @MappableClass() 62 | class GitTag extends GitReference with GitTagMappable { 63 | const GitTag({required super.sha, required super.name}); 64 | } 65 | -------------------------------------------------------------------------------- /analysis_options.yaml: -------------------------------------------------------------------------------- 1 | include: package:lints/recommended.yaml 2 | 3 | analyzer: 4 | exclude: 5 | - tool/release_tool/** 6 | 7 | # For lint rules and documentation, see http://dart-lang.github.io/linter/lints. 8 | # Uncomment to specify additional rules. 9 | linter: 10 | rules: 11 | public_member_api_docs: false 12 | prefer_relative_imports: true 13 | 14 | dart_code_metrics: 15 | extends: 16 | - package:dart_code_metrics_presets/recommended.yaml 17 | - package:dart_code_metrics_presets/metrics_recommended.yaml 18 | metrics-exclude: 19 | - test/** 20 | rules-exclude: 21 | - test/** 22 | rules: 23 | newline-before-return: true 24 | avoid-importing-entrypoint-exports: 25 | only-in-src: true 26 | prefer-match-file-name: false 27 | prefer-correct-callback-field-name: false 28 | match-getter-setter-field-names: false 29 | avoid-duplicate-cascades: false 30 | prefer-dedicated-media-query-methods: false 31 | avoid-shadowing: false 32 | avoid-duplicate-initializers: false 33 | enum-constants-ordering: false 34 | avoid-accessing-collections-by-constant-index: false 35 | avoid-unsafe-collection-methods: false 36 | move-variable-closer-to-its-usage: false 37 | prefer-prefixed-global-constants: false 38 | avoid-nullable-interpolation: false 39 | avoid-returning-widgets: false 40 | avoid-nested-conditional-expressions: 41 | acceptable-level: 3 42 | member-ordering: 43 | order: 44 | - public-fields 45 | - private-fields 46 | - constructors 47 | - static-methods 48 | - private-methods 49 | - private-getters 50 | - private-setters 51 | - public-getters 52 | - public-setters 53 | - public-methods 54 | - overridden-public-methods 55 | - overridden-public-getters 56 | - build-method 57 | prefer-named-boolean-parameters: 58 | ignore-single: true 59 | -------------------------------------------------------------------------------- /lib/src/workflows/run_configured_flutter.workflow.dart: -------------------------------------------------------------------------------- 1 | import 'dart:io'; 2 | 3 | import '../models/cache_flutter_version_model.dart'; 4 | import '../services/cache_service.dart'; 5 | import '../services/flutter_service.dart'; 6 | import '../services/process_service.dart'; 7 | import '../services/project_service.dart'; 8 | import '../utils/constants.dart'; 9 | import 'ensure_cache.workflow.dart'; 10 | import 'validate_flutter_version.workflow.dart'; 11 | import 'workflow.dart'; 12 | 13 | class RunConfiguredFlutterWorkflow extends Workflow { 14 | const RunConfiguredFlutterWorkflow(super.context); 15 | 16 | Future call(String cmd, {required List args}) async { 17 | // Try to select a version: project version has priority, then global. 18 | 19 | CacheFlutterVersion? selectedVersion; 20 | final projectVersion = get().findVersion(); 21 | 22 | if (projectVersion != null) { 23 | final version = get().call( 24 | projectVersion, 25 | ); 26 | selectedVersion = await get().call(version); 27 | logger.debug( 28 | '$kPackageName: Running Flutter from version "$projectVersion"', 29 | ); 30 | } else { 31 | final globalVersion = get().getGlobal(); 32 | if (globalVersion != null) { 33 | selectedVersion = globalVersion; 34 | logger.debug( 35 | '$kPackageName: Running Flutter from global version "${globalVersion.flutterSdkVersion}"', 36 | ); 37 | } 38 | } 39 | 40 | // Execute using the selected version if available. 41 | if (selectedVersion != null) { 42 | return get().run(cmd, args, selectedVersion); 43 | } 44 | 45 | // Fallback: run using the system's PATH. 46 | logger.debug('$kPackageName: Running Flutter version configured in PATH.'); 47 | logger.debug(''); 48 | 49 | return get().run(cmd, args: args); 50 | } 51 | } 52 | -------------------------------------------------------------------------------- /lib/src/commands/flutter_command.dart: -------------------------------------------------------------------------------- 1 | import 'package:args/args.dart'; 2 | import 'package:meta/meta.dart'; 3 | 4 | import '../services/cache_service.dart'; 5 | import '../services/project_service.dart'; 6 | import '../utils/context.dart'; 7 | import '../utils/exceptions.dart'; 8 | import '../workflows/run_configured_flutter.workflow.dart'; 9 | import '../workflows/validate_flutter_version.workflow.dart'; 10 | import 'base_command.dart'; 11 | 12 | /// Proxies Flutter Commands 13 | class FlutterCommand extends BaseFvmCommand { 14 | @override 15 | final name = 'flutter'; 16 | @override 17 | final description = 18 | 'Runs Flutter commands using the project\'s configured SDK version'; 19 | @override 20 | final argParser = ArgParser.allowAnything(); 21 | 22 | FlutterCommand(super.context); 23 | 24 | @override 25 | Future run() async { 26 | final args = argResults!.arguments; 27 | checkIfUpgradeCommand(context, args); 28 | final runConfiguredFlutterWorkflow = RunConfiguredFlutterWorkflow(context); 29 | 30 | final result = await runConfiguredFlutterWorkflow('flutter', args: args); 31 | 32 | return result.exitCode; 33 | } 34 | } 35 | 36 | @visibleForTesting 37 | void checkIfUpgradeCommand(FvmContext context, List args) { 38 | if (args.isEmpty || args.first != 'upgrade') return; 39 | 40 | // Get current version - project version has priority, then global 41 | final projectVersionName = context.get().findVersion(); 42 | final versionToCheck = 43 | projectVersionName ?? context.get().getGlobal()?.name; 44 | 45 | if (versionToCheck != null) { 46 | final version = context.get().call( 47 | versionToCheck, 48 | ); 49 | 50 | // Only block upgrade for release versions, not channels 51 | if (!version.isChannel) { 52 | throw AppException( 53 | 'You should not upgrade a release version. ' 54 | 'Please install a channel instead to upgrade it. ', 55 | ); 56 | } 57 | } 58 | } 59 | -------------------------------------------------------------------------------- /bin/compile.dart: -------------------------------------------------------------------------------- 1 | import 'dart:io'; 2 | 3 | Future main() async { 4 | const package = 'fvm'; // Your package name 5 | const destination = 6 | '/usr/local/bin'; // system location for user installed binaries 7 | 8 | // Identify the operating system 9 | var os = Platform.operatingSystem; 10 | 11 | if (os != 'macos' && os != 'linux') { 12 | print('Unsupported OS. Only MacOS and Linux are supported.'); 13 | 14 | return; 15 | } 16 | 17 | // Get temporary directory 18 | var tempDir = await Directory.systemTemp.createTemp('fvm-compile'); 19 | 20 | var tempFile = File('${tempDir.path}/$package-$os'); 21 | 22 | // Compile the package to native executable 23 | print('Compiling package...'); 24 | final compileResult = await Process.run('dart', [ 25 | 'compile', 26 | 'exe', 27 | 'bin/main.dart', 28 | '-o', 29 | tempFile.path, 30 | ]); 31 | 32 | // Error checking for compile process 33 | if (compileResult.exitCode != 0) { 34 | print('Error occurred in compilation:\n ${compileResult.stderr}'); 35 | 36 | return; 37 | } 38 | 39 | print('Compilation successful.'); 40 | print('Moving compiled package to destination...'); 41 | 42 | // Move the compiled executable to desired directory 43 | 44 | // Make sure your Dart application has the necessary permissions for this operation 45 | if (await tempFile.exists()) { 46 | await tempFile.rename('$destination/$package'); 47 | print('Executable moved successfully'); 48 | } else { 49 | print('Failed moving the binary. File does not exist.'); 50 | } 51 | 52 | // Clean up the temp directory 53 | await tempDir.delete(); 54 | 55 | // Deactivate current globally activated version of FVM 56 | final deactivateResult = await Process.run('dart', [ 57 | 'pub', 58 | 'global', 59 | 'deactivate', 60 | 'fvm', 61 | ]); 62 | if (deactivateResult.exitCode == 0) { 63 | print('Deactivated current global version of FVM successfully'); 64 | } else { 65 | print('Error during the deactivation of the global FVM version'); 66 | } 67 | } 68 | -------------------------------------------------------------------------------- /docs/components/ui/button.tsx: -------------------------------------------------------------------------------- 1 | import { Slot } from "@radix-ui/react-slot"; 2 | import { cva, type VariantProps } from "class-variance-authority"; 3 | import * as React from "react"; 4 | 5 | import { cn } from "@/lib/utils"; 6 | 7 | const buttonVariants = cva( 8 | "inline-flex items-center justify-center rounded-md text-sm font-medium transition-colors focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring disabled:pointer-events-none disabled:opacity-50", 9 | { 10 | variants: { 11 | variant: { 12 | default: 13 | "bg-primary text-primary-foreground shadow hover:bg-primary/90", 14 | destructive: 15 | "bg-destructive text-destructive-foreground shadow-sm hover:bg-destructive/90", 16 | outline: 17 | "border border-input bg-transparent shadow-sm hover:bg-accent hover:text-accent-foreground", 18 | secondary: 19 | "bg-secondary text-secondary-foreground shadow-sm hover:bg-secondary/80", 20 | ghost: "hover:bg-accent hover:text-accent-foreground", 21 | link: "text-primary underline-offset-4 hover:underline", 22 | }, 23 | size: { 24 | default: "h-9 px-4 py-2", 25 | sm: "h-8 rounded-md px-3 text-xs", 26 | lg: "h-10 rounded-md px-8", 27 | icon: "h-9 w-9", 28 | }, 29 | }, 30 | defaultVariants: { 31 | variant: "default", 32 | size: "default", 33 | }, 34 | } 35 | ); 36 | 37 | export interface ButtonProps 38 | extends React.ButtonHTMLAttributes, 39 | VariantProps { 40 | asChild?: boolean; 41 | } 42 | 43 | const Button = React.forwardRef( 44 | ({ className, variant, size, asChild = false, ...props }, ref) => { 45 | const Comp = asChild ? Slot : "button"; 46 | return ( 47 | 52 | ); 53 | } 54 | ); 55 | Button.displayName = "Button"; 56 | 57 | export { Button, buttonVariants }; 58 | -------------------------------------------------------------------------------- /test/src/workflows/validate_flutter_version.workflow_test.dart: -------------------------------------------------------------------------------- 1 | import 'package:fvm/src/workflows/validate_flutter_version.workflow.dart'; 2 | import 'package:test/test.dart'; 3 | 4 | import '../../testing_utils.dart'; 5 | 6 | void main() { 7 | group('ValidateFlutterVersionWorkflow', () { 8 | /// Valid release version 9 | test('should return valid release version', () async { 10 | const version = '3.10.0'; 11 | 12 | final context = TestFactory.context(); 13 | 14 | final workflow = ValidateFlutterVersionWorkflow(context); 15 | 16 | final result = workflow.call(version); 17 | 18 | expect(result.isRelease, isTrue); 19 | expect(result.name, equals(version)); 20 | }); 21 | 22 | /// Invalid version 23 | test('should return invalid version as unknownRef', () async { 24 | const version = 'invalid-version'; 25 | 26 | final context = TestFactory.context(); 27 | 28 | final workflow = ValidateFlutterVersionWorkflow(context); 29 | 30 | final result = workflow.call(version); 31 | 32 | expect(result.isUnknownRef, isTrue); 33 | expect(result.name, equals(version)); 34 | }); 35 | 36 | /// Channel 37 | test('should return channel version', () async { 38 | const version = 'stable'; 39 | 40 | final context = TestFactory.context(); 41 | 42 | final workflow = ValidateFlutterVersionWorkflow(context); 43 | 44 | final result = workflow.call(version); 45 | 46 | expect(result.isChannel, isTrue); 47 | expect(result.name, equals(version)); 48 | }); 49 | 50 | /// Commit 51 | 52 | test('should skip validation when force flag is true', () async { 53 | // Arrange 54 | const version = 'invalid-version'; 55 | 56 | final context = TestFactory.context(); 57 | 58 | final workflow = ValidateFlutterVersionWorkflow(context); 59 | 60 | // Act 61 | final result = workflow.call(version); 62 | 63 | // Assert 64 | expect(result.isUnknownRef, isTrue); 65 | expect(result.name, equals(version)); 66 | }); 67 | }); 68 | } 69 | -------------------------------------------------------------------------------- /lib/src/utils/git_clone_progress_tracker.dart: -------------------------------------------------------------------------------- 1 | import 'package:io/ansi.dart'; 2 | import '../services/logger_service.dart'; 3 | 4 | /// Tracks and displays progress for git clone operations 5 | class GitCloneProgressTracker { 6 | static const _progressBarWidth = 50; 7 | static const _progressRegex = 8 | r'(Enumerating objects:|Counting objects:|Compressing objects:|Receiving objects:|Resolving deltas:).*?(\d+)%'; 9 | 10 | final Logger _logger; 11 | final RegExp _regex = RegExp(_progressRegex); 12 | 13 | int _lastPercentage = -1; 14 | String _currentPhase = ''; 15 | 16 | GitCloneProgressTracker(this._logger); 17 | 18 | void _displayProgress(String phase, int percentage) { 19 | final label = phase.padRight(20); 20 | final filled = (percentage / 100 * _progressBarWidth).round(); 21 | final empty = _progressBarWidth - filled; 22 | final progressBar = green.wrap('[${'█' * filled}${'░' * empty}]'); 23 | _logger.write('\r $label $progressBar $percentage%'); 24 | } 25 | 26 | /// Processes a line of git clone output and updates progress if applicable 27 | void processLine(String line) { 28 | try { 29 | final match = _regex.firstMatch(line); 30 | if (match == null) return; 31 | 32 | final phase = match.group(1)!; 33 | final percentage = int.tryParse(match.group(2) ?? '') ?? 0; 34 | 35 | // Complete previous phase when switching 36 | if (_currentPhase.isNotEmpty && _currentPhase != phase) { 37 | _displayProgress(_currentPhase, 100); 38 | _logger.write('\n'); 39 | } 40 | 41 | // Update only if percentage changed 42 | if (percentage != _lastPercentage) { 43 | _displayProgress(phase, percentage); 44 | _lastPercentage = percentage; 45 | _currentPhase = phase; 46 | } 47 | } catch (_) { 48 | // Ignore parsing errors - git clone continues 49 | } 50 | } 51 | 52 | /// Completes the progress tracking and ensures proper formatting 53 | void complete() { 54 | if (_currentPhase.isNotEmpty) { 55 | _logger.write('\n'); 56 | } 57 | } 58 | } 59 | -------------------------------------------------------------------------------- /lib/src/models/log_level_model.mapper.dart: -------------------------------------------------------------------------------- 1 | // coverage:ignore-file 2 | // GENERATED CODE - DO NOT MODIFY BY HAND 3 | // ignore_for_file: type=lint 4 | // ignore_for_file: unused_element, unnecessary_cast, override_on_non_overriding_member 5 | // ignore_for_file: strict_raw_type, inference_failure_on_untyped_parameter 6 | 7 | part of 'log_level_model.dart'; 8 | 9 | class LevelMapper extends EnumMapper { 10 | LevelMapper._(); 11 | 12 | static LevelMapper? _instance; 13 | static LevelMapper ensureInitialized() { 14 | if (_instance == null) { 15 | MapperContainer.globals.use(_instance = LevelMapper._()); 16 | } 17 | return _instance!; 18 | } 19 | 20 | static Level fromValue(dynamic value) { 21 | ensureInitialized(); 22 | return MapperContainer.globals.fromValue(value); 23 | } 24 | 25 | @override 26 | Level decode(dynamic value) { 27 | switch (value) { 28 | case r'verbose': 29 | return Level.verbose; 30 | case r'debug': 31 | return Level.debug; 32 | case r'info': 33 | return Level.info; 34 | case r'warning': 35 | return Level.warning; 36 | case r'error': 37 | return Level.error; 38 | case r'critical': 39 | return Level.critical; 40 | case r'quiet': 41 | return Level.quiet; 42 | default: 43 | throw MapperException.unknownEnumValue(value); 44 | } 45 | } 46 | 47 | @override 48 | dynamic encode(Level self) { 49 | switch (self) { 50 | case Level.verbose: 51 | return r'verbose'; 52 | case Level.debug: 53 | return r'debug'; 54 | case Level.info: 55 | return r'info'; 56 | case Level.warning: 57 | return r'warning'; 58 | case Level.error: 59 | return r'error'; 60 | case Level.critical: 61 | return r'critical'; 62 | case Level.quiet: 63 | return r'quiet'; 64 | } 65 | } 66 | } 67 | 68 | extension LevelMapperExtension on Level { 69 | String toValue() { 70 | LevelMapper.ensureInitialized(); 71 | return MapperContainer.globals.toValue(this) as String; 72 | } 73 | } 74 | -------------------------------------------------------------------------------- /test/src/services/process_service_test.dart: -------------------------------------------------------------------------------- 1 | import 'dart:io'; 2 | 3 | import 'package:fvm/src/services/process_service.dart'; 4 | import 'package:test/test.dart'; 5 | 6 | import '../../testing_utils.dart'; 7 | 8 | void main() { 9 | late TestCommandRunner runner; 10 | 11 | setUp(() { 12 | runner = TestFactory.commandRunner(); 13 | }); 14 | 15 | group('ProcessService', () { 16 | test('successful command returns ProcessResult', () async { 17 | final processService = runner.context.get(); 18 | final result = await processService.run('echo', args: ['hello']); 19 | 20 | expect(result.exitCode, equals(0)); 21 | expect(result.stdout.toString().trim(), contains('hello')); 22 | }); 23 | 24 | test('throwOnError=true throws on failure', () async { 25 | final processService = runner.context.get(); 26 | 27 | expect( 28 | () => processService.run('false', throwOnError: true), 29 | throwsA(isA()), 30 | ); 31 | }); 32 | 33 | test('throwOnError=false returns failed result', () async { 34 | final processService = runner.context.get(); 35 | final result = await processService.run('false', throwOnError: false); 36 | 37 | expect(result.exitCode, isNot(0)); 38 | }); 39 | 40 | test('environment variables are passed through', () async { 41 | final processService = runner.context.get(); 42 | final result = await processService.run( 43 | Platform.isWindows ? 'cmd' : 'sh', 44 | args: Platform.isWindows 45 | ? ['/c', 'echo %TEST_VAR%'] 46 | : ['-c', 'echo \$TEST_VAR'], 47 | environment: {'TEST_VAR': 'test_value'}, 48 | ); 49 | 50 | expect(result.stdout.toString(), contains('test_value')); 51 | }); 52 | 53 | test('echoOutput is disabled in test mode', () async { 54 | final processService = runner.context.get(); 55 | final result = await processService.run( 56 | 'echo', 57 | args: ['test'], 58 | echoOutput: true, 59 | ); 60 | 61 | expect(result.exitCode, equals(0)); 62 | }); 63 | }); 64 | } 65 | -------------------------------------------------------------------------------- /lib/src/utils/extensions.dart: -------------------------------------------------------------------------------- 1 | import 'dart:io'; 2 | 3 | extension ListExtension on Iterable { 4 | /// Returns firstWhereOrNull 5 | T? firstWhereOrNull(bool Function(T) test) { 6 | for (var element in this) { 7 | if (test(element)) { 8 | return element; 9 | } 10 | } 11 | 12 | return null; 13 | } 14 | } 15 | 16 | extension IOExtensions on String { 17 | Directory get dir => Directory(this); 18 | File get file => File(this); 19 | Link get link => Link(this); 20 | 21 | bool exists() => type() != FileSystemEntityType.notFound; 22 | 23 | FileSystemEntityType type() => FileSystemEntity.typeSync(this); 24 | 25 | bool isDir() => type() == FileSystemEntityType.directory; 26 | bool isFile() => type() == FileSystemEntityType.file; 27 | } 28 | 29 | extension FileExtensions on File { 30 | String? read() => existsSync() ? readAsStringSync() : null; 31 | void write(String contents) { 32 | if (existsSync()) { 33 | writeAsStringSync(contents); 34 | } else { 35 | createSync(recursive: true); 36 | writeAsStringSync(contents); 37 | } 38 | } 39 | } 40 | 41 | extension DirectoryExtensions on Directory { 42 | void deleteIfExists() { 43 | if (existsSync()) { 44 | deleteSync(recursive: true); 45 | } 46 | } 47 | 48 | void ensureExists() { 49 | if (!existsSync()) { 50 | createSync(recursive: true); 51 | } 52 | } 53 | } 54 | 55 | extension LinkExtensions on Link { 56 | /// Creates a symlink from [source] to the [target] 57 | void createLink(String targetPath) { 58 | // Check if needs to do anything 59 | 60 | final target = Directory(targetPath); 61 | 62 | final sourceExists = existsSync(); 63 | if (sourceExists && targetSync() == target.path) { 64 | return; 65 | } 66 | 67 | if (sourceExists) { 68 | deleteSync(); 69 | } 70 | 71 | createSync(target.path, recursive: true); 72 | } 73 | } 74 | 75 | extension StringExtensions on String { 76 | String get capitalize { 77 | if (isEmpty) return this; 78 | final firstChar = substring(0, 1).toUpperCase(); 79 | final remainingChars = substring(1); 80 | 81 | return '$firstChar$remainingChars'; 82 | } 83 | } 84 | -------------------------------------------------------------------------------- /setup.sh: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | set -e 3 | 4 | # Update system packages 5 | sudo apt-get update 6 | 7 | # Install required dependencies 8 | sudo apt-get install -y apt-transport-https wget gnupg git curl 9 | 10 | # Add Dart repository key and repository 11 | wget -qO- https://dl-ssl.google.com/linux/linux_signing_key.pub | sudo gpg --dearmor -o /usr/share/keyrings/dart.gpg 12 | echo "deb [signed-by=/usr/share/keyrings/dart.gpg arch=amd64] https://storage.googleapis.com/download.dartlang.org/linux/debian stable main" | sudo tee /etc/apt/sources.list.d/dart_stable.list 13 | 14 | # Update package list and install Dart SDK 15 | sudo apt-get update 16 | sudo apt-get install -y dart 17 | 18 | # Add Dart to system PATH 19 | echo 'PATH="$PATH:/usr/lib/dart/bin"' | sudo tee -a /etc/environment 20 | 21 | # Set PATH for current session 22 | export PATH="$PATH:/usr/lib/dart/bin" 23 | export PATH="$PATH:$HOME/.pub-cache/bin" 24 | 25 | # Add to user profile for future sessions 26 | echo 'export PATH="$PATH:/usr/lib/dart/bin"' >> $HOME/.profile 27 | echo 'export PATH="$PATH:$HOME/.pub-cache/bin"' >> $HOME/.profile 28 | 29 | # Verify Dart installation 30 | dart --version 31 | 32 | # Navigate to project directory 33 | cd /mnt/persist/workspace 34 | 35 | # Install project dependencies 36 | dart pub get 37 | 38 | # Install grinder globally for build tasks 39 | dart pub global activate grinder 40 | 41 | # Set up Git configuration (required for FVM tests) 42 | git config --global user.name "Test User" 43 | git config --global user.email "test@example.com" 44 | git config --global init.defaultBranch main 45 | 46 | # Create necessary test directories 47 | mkdir -p $HOME/fvm-test 48 | mkdir -p $HOME/.fvm 49 | mkdir -p $HOME/fvm_test_cache 50 | 51 | # Create the Git cache directory that the tests expect 52 | mkdir -p $HOME/fvm_test_cache/gitcache 53 | cd $HOME/fvm_test_cache/gitcache 54 | 55 | # Initialize a bare Git repository for the cache 56 | git init --bare 57 | 58 | # Add Flutter repository as remote (this creates a proper Git cache) 59 | git remote add origin https://github.com/flutter/flutter.git 60 | 61 | # Go back to project directory 62 | cd /mnt/persist/workspace 63 | 64 | # Verify grinder installation 65 | dart pub global run grinder --version -------------------------------------------------------------------------------- /lib/src/api/models/json_response.dart: -------------------------------------------------------------------------------- 1 | import 'package:dart_mappable/dart_mappable.dart'; 2 | 3 | import '../../models/cache_flutter_version_model.dart'; 4 | import '../../models/project_model.dart'; 5 | import '../../services/releases_service/models/version_model.dart'; 6 | import '../../utils/context.dart'; 7 | import '../../utils/helpers.dart'; 8 | import '../../utils/pretty_json.dart'; 9 | 10 | part 'json_response.mapper.dart'; 11 | 12 | typedef JSONMap = Map; 13 | 14 | @MappableClass(generateMethods: skipCopyWith) 15 | abstract class APIResponse with APIResponseMappable { 16 | const APIResponse(); 17 | 18 | String toPrettyJson() => prettyJson(toMap()); 19 | } 20 | 21 | @MappableClass() 22 | class GetCacheVersionsResponse extends APIResponse 23 | with GetCacheVersionsResponseMappable { 24 | final String size; 25 | final List versions; 26 | 27 | static final fromMap = GetCacheVersionsResponseMapper.fromMap; 28 | static final fromJson = GetCacheVersionsResponseMapper.fromJson; 29 | 30 | const GetCacheVersionsResponse({required this.size, required this.versions}); 31 | } 32 | 33 | @MappableClass() 34 | class GetReleasesResponse extends APIResponse with GetReleasesResponseMappable { 35 | /// Channels in Flutter releases 36 | final Channels channels; 37 | 38 | /// List of all releases 39 | final List versions; 40 | 41 | static final fromMap = GetReleasesResponseMapper.fromMap; 42 | static final fromJson = GetReleasesResponseMapper.fromJson; 43 | 44 | const GetReleasesResponse({required this.versions, required this.channels}); 45 | } 46 | 47 | @MappableClass() 48 | class GetProjectResponse extends APIResponse with GetProjectResponseMappable { 49 | final Project project; 50 | 51 | static final fromMap = GetProjectResponseMapper.fromMap; 52 | static final fromJson = GetProjectResponseMapper.fromJson; 53 | 54 | const GetProjectResponse({required this.project}); 55 | } 56 | 57 | @MappableClass() 58 | class GetContextResponse extends APIResponse with GetContextResponseMappable { 59 | final FvmContext context; 60 | static final fromMap = GetContextResponseMapper.fromMap; 61 | static final fromJson = GetContextResponseMapper.fromJson; 62 | 63 | const GetContextResponse({required this.context}); 64 | } 65 | -------------------------------------------------------------------------------- /AGENTS.md: -------------------------------------------------------------------------------- 1 | # FVM - Flutter Version Manager 2 | 3 | CLI tool for managing Flutter SDK versions per project. 4 | 5 | ## Structure 6 | 7 | - `bin/main.dart`: CLI entry point 8 | - `lib/src/commands/`: CLI commands 9 | - `lib/src/services/`: Business logic (cache, flutter, git, project) 10 | - `lib/src/workflows/`: Multi-step orchestration 11 | - `lib/src/models/`: Data models (dart_mappable) 12 | - `test/`: Unit + integration tests 13 | 14 | ## Commands 15 | 16 | ```bash 17 | dart pub get # Install dependencies 18 | dart test # Unit tests 19 | dart run grinder integration-test # Integration suite (needs Flutter) 20 | dart analyze --fatal-infos # Analysis (must pass) 21 | dcm analyze lib # Code metrics 22 | dart run build_runner build --delete-conflicting-outputs # Regenerate mappers 23 | ``` 24 | 25 | ## Verification 26 | 27 | Pre-commit hooks run automatically. Before pushing: 28 | 1. `dart analyze --fatal-infos` passes 29 | 2. `dcm analyze lib` passes 30 | 3. `dart test` passes 31 | 32 | ## Architecture 33 | 34 | Commands → Workflows → Services → Models 35 | 36 | - Services accessed via `FvmContext.get()` 37 | - Workflows extend `Workflow` for multi-step operations 38 | - Models use `@MappableClass()` with `part 'name.mapper.dart';` 39 | 40 | ## Critical Rules 41 | 42 | - NEVER bypass git hooks with `--no-verify` 43 | - YOU MUST run `build_runner build` after modifying `@MappableClass()` models 44 | - Integration tests require Flutter: run `dart run grinder test-setup` first 45 | 46 | ## Gotchas 47 | 48 | - Release tool (`tool/release_tool/`) requires Dart >=3.8.0 49 | - Version format: `[fork/]version[@channel]` (e.g., `stable`, `3.24.0`, `custom-fork/3.24.0@beta`) 50 | 51 | ## Documentation 52 | 53 | Developer docs in `.context/docs/`. Reference for specific tasks: 54 | 55 | - @README.md - Project overview, release process 56 | - @CHANGELOG.md - Version history, breaking changes 57 | - @.github/workflows/README.md - CI/CD pipelines, deployment automation 58 | - @.context/docs/testing-methodology.md - Test patterns, TestFactory, mocking 59 | - @.context/docs/integration-tests.md - Integration test phases (38 tests) 60 | - @.context/docs/version-parsing.md - Version parsing regex and logic 61 | - @.context/docs/v4-release-notes.md - v4.0 architecture changes, migration 62 | -------------------------------------------------------------------------------- /scripts/test-install.sh: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env bash 2 | # Test install.sh root warning behavior 3 | # 4 | # The current install.sh behavior is: 5 | # - Warns when running as root 6 | # - Still proceeds (it does not block root) 7 | # 8 | # To avoid downloading/installing binaries, this test intentionally forces an 9 | # early failure (unsafe install base) after the root warning is printed. 10 | # 11 | # Usage: 12 | # ./test-install.sh (run as regular user) 13 | # sudo ./test-install.sh (run as root) 14 | # 15 | set -euo pipefail 16 | 17 | RED=$'\033[0;31m' 18 | GREEN=$'\033[0;32m' 19 | NC=$'\033[0m' 20 | 21 | pass() { echo -e "${GREEN}✅ $1${NC}"; } 22 | fail() { echo -e "${RED}❌ $1${NC}"; exit 1; } 23 | 24 | echo "🧪 Testing scripts/install.sh root warning behavior" 25 | echo "==================================================" 26 | echo "" 27 | echo "This test forces an early failure to avoid downloads." 28 | echo "" 29 | 30 | run_install_sh_with_early_failure() { 31 | # Use an unsafe install base (/) so install.sh exits before requiring curl/tar. 32 | # This should still print the root warning when executed as root. 33 | FVM_INSTALL_DIR="/" ./scripts/install.sh 2>&1 || true 34 | } 35 | 36 | assert_contains() { 37 | local haystack="$1" 38 | local needle="$2" 39 | if echo "$haystack" | grep -Fq "$needle"; then 40 | pass "Contains: $needle" 41 | else 42 | echo "$haystack" >&2 43 | fail "Expected output to contain: $needle" 44 | fi 45 | } 46 | 47 | assert_not_contains() { 48 | local haystack="$1" 49 | local needle="$2" 50 | if echo "$haystack" | grep -Fq "$needle"; then 51 | echo "$haystack" >&2 52 | fail "Expected output to NOT contain: $needle" 53 | else 54 | pass "Does not contain: $needle" 55 | fi 56 | } 57 | 58 | if [[ $(id -u) -eq 0 ]]; then 59 | echo "Running as root" 60 | echo "" 61 | 62 | output="$(run_install_sh_with_early_failure)" 63 | assert_contains "$output" "⚠ Warning: Running as root" 64 | assert_contains "$output" "refusing to use unsafe install base" 65 | else 66 | echo "Running as non-root" 67 | echo "" 68 | 69 | output="$(run_install_sh_with_early_failure)" 70 | assert_not_contains "$output" "⚠ Warning: Running as root" 71 | assert_contains "$output" "refusing to use unsafe install base" 72 | 73 | echo "" 74 | echo "ℹ️ To test root behavior, run: sudo $0" 75 | fi 76 | 77 | echo "" 78 | echo "✅ All tests passed!" 79 | -------------------------------------------------------------------------------- /docs/pages/documentation/guides/monorepo.md: -------------------------------------------------------------------------------- 1 | --- 2 | id: monorepo 3 | title: Monorepo support 4 | --- 5 | 6 | # Monorepo support 7 | 8 | FVM ensures all projects in a monorepo use the same Flutter SDK version, preventing compatibility issues and simplifying development. Here's how to set up FVM in common monorepo configurations: 9 | 10 | ## Melos: Monorepo with a Shared `pubspec.yaml` 11 | 12 | A shared `pubspec.yaml` at the monorepo's root is beneficial for projects with common dependencies, ensuring they all adhere to a unified Flutter SDK version. 13 | 14 | Melos requires a `pubspec.yaml` at the root of the monorepo. Running `fvm use` at the root of the monorepo will generate a `.fvmrc` file at the root, allowing all packages to use the same Flutter SDK version. 15 | 16 | ### Automatic Melos Configuration 17 | 18 | **New in FVM 3.3.0**: FVM now automatically manages the `sdkPath` configuration in `melos.yaml` when you run `fvm use`. This ensures that all scripts and Melos commands utilize the FVM-managed Flutter SDK version, maintaining consistency across the monorepo. 19 | 20 | When you run `fvm use`, FVM will: 21 | - Detect `melos.yaml` in the current directory or parent directories (up to the git root) 22 | - Add or update the `sdkPath` field to point to `.fvm/flutter_sdk` 23 | - Calculate the correct relative path for nested project structures 24 | - Preserve existing non-FVM paths with a warning 25 | 26 | ### Manual Configuration 27 | 28 | If you prefer to manage the configuration manually or want to disable automatic updates: 29 | 30 | 1. To disable Melos updates for a project: 31 | ```json 32 | // .fvmrc 33 | { 34 | "flutter": "3.19.0", 35 | "updateMelosSettings": false 36 | } 37 | ``` 38 | 39 | 2. To manually set the SDK path in `melos.yaml`: 40 | ```yaml 41 | name: my_workspace 42 | packages: 43 | - packages/** 44 | sdkPath: .fvm/flutter_sdk # Points to FVM-managed Flutter SDK 45 | ``` 46 | 47 | For detailed configuration instructions, refer to the [sdkPath](https://melos.invertase.dev/~melos-latest/configuration/overview#sdkpath) in Melos documentation. 48 | 49 | ## Project with Subfolders 50 | 51 | This setup involves repositories segmented into subfolders, with each housing a distinct Flutter project, and lacking a unified monorepo management tool. 52 | 53 | Run `fvm use` at the root folder of the Flutter projects to generate a `.fvmrc` file. Now, each project can use the same Flutter SDK version. -------------------------------------------------------------------------------- /docs/pages/documentation/guides/vscode.mdx: -------------------------------------------------------------------------------- 1 | --- 2 | id: vscode 3 | title: VSCode Configuration 4 | --- 5 | 6 | import { Callout } from "nextra/components"; 7 | 8 | # VSCode Configuration 9 | 10 | ## Overview 11 | 12 | The integration of FVM with Visual Studio Code simplifies the process of managing and using multiple Flutter SDK versions within your projects. 13 | 14 | FVM automatically configures Visual Studio Code to use the appropriate Flutter SDK version for each project, enhancing workflow efficiency and ensuring that all team members use the correct SDK version. 15 | 16 | ## Automatic Detection and Configuration 17 | 18 | FVM detects the use of Visual Studio Code through two methods: 19 | 20 | 1. The presence of a .vscode directory at the root of your project. 21 | 2. The `TERM_PROGRAM` environment variable set to vscode, indicating that the Visual Studio Code terminal is in use. 22 | 23 | Upon detection, FVM automatically updates the .vscode/settings.json file within your project to specify the Flutter SDK path. This configuration directs Visual Studio Code to use the Flutter SDK version managed by FVM for your project. 24 | 25 | ```json 26 | { 27 | "dart.flutterSdkPath": ".fvm/versions/stable" 28 | } 29 | ``` 30 | 31 | ## Integration with Dart Code Extension 32 | 33 | The [Dart Code extension](https://marketplace.visualstudio.com/items?itemName=Dart-Code.dart-code) for VSCode, essential for Flutter development, responds to the SDK path changes made by FVM. It notifies you about the environment change through the terminal and automatically switches to the new Flutter SDK version for both terminal commands and IDE tools. 34 | 35 | ## Benefits of FVM Integration 36 | 37 | - **Simplified SDK Management**: Automatically switches between Flutter SDK versions based on project requirements without manual configuration changes. 38 | - **Consistent Development Environment**: Ensures all developers in a team are using the same Flutter SDK version, as specified by FVM configuration. 39 | - **Enhanced Workflow**: Removes the need to prefix commands with fvm in the Visual Studio Code terminal, allowing you to use flutter commands directly. 40 | 41 | 42 | With the Dart Code extension's automatic SDK path update, the `flutter` 43 | command within the VSCode terminal will reference the Flutter SDK version managed 44 | by FVM, not the version installed globally on your system or specified in your 45 | OS environment PATH. 46 | -------------------------------------------------------------------------------- /docs/tailwind.config.js: -------------------------------------------------------------------------------- 1 | /** @type {import('tailwindcss').Config} */ 2 | module.exports = { 3 | darkMode: ["class"], 4 | content: [ 5 | './pages/**/*.{ts,tsx}', 6 | './components/**/*.{ts,tsx}', 7 | './app/**/*.{ts,tsx}', 8 | './src/**/*.{ts,tsx}', 9 | ], 10 | prefix: "", 11 | theme: { 12 | container: { 13 | center: true, 14 | padding: "2rem", 15 | screens: { 16 | "2xl": "1400px", 17 | }, 18 | }, 19 | extend: { 20 | colors: { 21 | border: "hsl(var(--border))", 22 | input: "hsl(var(--input))", 23 | ring: "hsl(var(--ring))", 24 | background: "hsl(var(--background))", 25 | foreground: "hsl(var(--foreground))", 26 | primary: { 27 | DEFAULT: "hsl(var(--primary))", 28 | foreground: "hsl(var(--primary-foreground))", 29 | }, 30 | secondary: { 31 | DEFAULT: "hsl(var(--secondary))", 32 | foreground: "hsl(var(--secondary-foreground))", 33 | }, 34 | destructive: { 35 | DEFAULT: "hsl(var(--destructive))", 36 | foreground: "hsl(var(--destructive-foreground))", 37 | }, 38 | muted: { 39 | DEFAULT: "hsl(var(--muted))", 40 | foreground: "hsl(var(--muted-foreground))", 41 | }, 42 | accent: { 43 | DEFAULT: "hsl(var(--accent))", 44 | foreground: "hsl(var(--accent-foreground))", 45 | }, 46 | popover: { 47 | DEFAULT: "hsl(var(--popover))", 48 | foreground: "hsl(var(--popover-foreground))", 49 | }, 50 | card: { 51 | DEFAULT: "hsl(var(--card))", 52 | foreground: "hsl(var(--card-foreground))", 53 | }, 54 | }, 55 | borderRadius: { 56 | lg: "var(--radius)", 57 | md: "calc(var(--radius) - 2px)", 58 | sm: "calc(var(--radius) - 4px)", 59 | }, 60 | keyframes: { 61 | "accordion-down": { 62 | from: { height: "0" }, 63 | to: { height: "var(--radix-accordion-content-height)" }, 64 | }, 65 | "accordion-up": { 66 | from: { height: "var(--radix-accordion-content-height)" }, 67 | to: { height: "0" }, 68 | }, 69 | }, 70 | animation: { 71 | "accordion-down": "accordion-down 0.2s ease-out", 72 | "accordion-up": "accordion-up 0.2s ease-out", 73 | }, 74 | }, 75 | }, 76 | plugins: [require("tailwindcss-animate")], 77 | } -------------------------------------------------------------------------------- /test/src/services/cache_service_version_match_test.dart: -------------------------------------------------------------------------------- 1 | import 'package:fvm/src/services/cache_service.dart'; 2 | import 'package:test/test.dart'; 3 | 4 | import '../../testing_utils.dart'; 5 | 6 | void main() { 7 | group('versionsMatch', () { 8 | late CacheService cacheService; 9 | 10 | setUp(() { 11 | cacheService = CacheService(TestFactory.context()); 12 | }); 13 | test('returns true for matching versions', () { 14 | expect(cacheService.versionsMatch('1.2.3', '1.2.3'), isTrue); 15 | }); 16 | 17 | test('handles leading v prefix differences', () { 18 | expect(cacheService.versionsMatch('v1.2.3', '1.2.3'), isTrue); 19 | expect(cacheService.versionsMatch('V3.4.5', '3.4.5'), isTrue); 20 | }); 21 | 22 | test('tolerates stripped pre-release from cached SDK', () { 23 | expect(cacheService.versionsMatch('1.17.0-dev.3.1', '1.17.0'), isTrue); 24 | }); 25 | 26 | test('requires exact match when both sides include pre-release', () { 27 | expect( 28 | cacheService.versionsMatch('1.17.0-dev.3.1', '1.17.0-dev.3.1'), 29 | isTrue, 30 | ); 31 | expect( 32 | cacheService.versionsMatch('1.17.0-dev.3.1', '1.17.0-dev.4.0'), 33 | isFalse, 34 | ); 35 | expect( 36 | cacheService.versionsMatch('3.19.0-1.0.pre.1', '3.19.0-1.0.pre.2'), 37 | isFalse, 38 | ); 39 | }); 40 | 41 | test('rejects when cached retains pre-release suffix', () { 42 | expect(cacheService.versionsMatch('1.17.0', '1.17.0-dev.3.1'), isFalse); 43 | }); 44 | 45 | test('rejects differing build metadata', () { 46 | expect( 47 | cacheService.versionsMatch('1.12.13+hotfix.9', '1.12.13+hotfix.9'), 48 | isTrue, 49 | ); 50 | expect( 51 | cacheService.versionsMatch('1.12.13+hotfix.9', '1.12.13+hotfix.8'), 52 | isFalse, 53 | ); 54 | }); 55 | 56 | test('falls back to normalized string equality for non-semver refs', () { 57 | expect(cacheService.versionsMatch('abc123', 'abc123'), isTrue); 58 | expect(cacheService.versionsMatch('abc123', 'def456'), isFalse); 59 | }); 60 | 61 | test('preserves case sensitivity beyond leading prefix', () { 62 | expect(cacheService.versionsMatch('FeatureFix', 'featurefix'), isFalse); 63 | }); 64 | 65 | test('flags real version mismatches', () { 66 | expect(cacheService.versionsMatch('3.16.0', '3.19.0'), isFalse); 67 | }); 68 | }); 69 | } 70 | -------------------------------------------------------------------------------- /docs/pages/documentation/guides/quick-reference.md: -------------------------------------------------------------------------------- 1 | --- 2 | id: quick-reference 3 | title: Quick Reference 4 | --- 5 | 6 | # FVM Quick Reference 7 | 8 | ## Essential Commands 9 | 10 | | Command | Description | Example | 11 | |---------|-------------|---------| 12 | | `fvm use [version]` | Set project SDK version | `fvm use 3.19.0` | 13 | | `fvm install [version]` | Download SDK version | `fvm install stable` | 14 | | `fvm list` | Show installed versions | `fvm list` | 15 | | `fvm global [version]` | Set system default | `fvm global 3.19.0` | 16 | | `fvm flutter [cmd]` | Run Flutter commands | `fvm flutter doctor` | 17 | | `fvm dart [cmd]` | Run Dart commands | `fvm dart pub get` | 18 | 19 | ## Version Formats 20 | 21 | | Format | Example | Description | 22 | |--------|---------|-------------| 23 | | Release | `3.19.0` | Specific version number | 24 | | Channel | `stable` | Latest from channel | 25 | | Commit | `fa345b1` | Git commit hash | 26 | | Fork | `myco/stable` | Custom repository | 27 | 28 | ## Common Options 29 | 30 | | Option | Commands | Purpose | 31 | |--------|----------|---------| 32 | | `--force` | use, global | Skip validation | 33 | | `--pin` | use | Pin channel version | 34 | | `--flavor` | use | Set flavor version | 35 | | `--setup` | install | Run Flutter setup (default: ON) | 36 | | `--no-setup` | install, use | Skip setup for faster caching | 37 | | `--skip-pub-get` | use, install | Skip dependencies | 38 | 39 | ## Workflows 40 | 41 | ### New Project Setup 42 | ```bash 43 | cd myproject 44 | fvm use 3.19.0 45 | ``` 46 | 47 | ### Switch Versions 48 | ```bash 49 | fvm use 3.16.0 --force 50 | ``` 51 | 52 | ### Test Multiple Versions 53 | ```bash 54 | fvm spawn 3.19.0 test 55 | fvm spawn 3.16.0 test 56 | ``` 57 | 58 | ### Custom Fork 59 | ```bash 60 | fvm fork add myco https://github.com/myco/flutter.git 61 | fvm use myco/stable 62 | ``` 63 | 64 | ## File Structure 65 | 66 | ``` 67 | myproject/ 68 | ├── .fvm/ 69 | │ ├── flutter_sdk → ../../../.fvm/versions/3.19.0 70 | │ └── fvm_config.json 71 | ├── .fvmrc 72 | └── .gitignore (updated) 73 | ``` 74 | 75 | ## Environment Variables 76 | 77 | - `FVM_CACHE_PATH` - Custom cache directory 78 | - `FVM_GIT_CACHE_PATH` - Git cache location 79 | - `FVM_FLUTTER_URL` - Custom Flutter repo 80 | 81 | ## Tips 82 | 83 | - Use `fvm doctor` to troubleshoot issues 84 | - Add `.fvm/flutter_sdk` to `.gitignore` 85 | - Commit `.fvmrc` for team consistency 86 | - Use `--no-setup` for faster caching 87 | - Enable git cache for faster installs -------------------------------------------------------------------------------- /test/utils/compare_semver_test.dart: -------------------------------------------------------------------------------- 1 | import 'package:fvm/src/utils/compare_semver.dart'; 2 | import 'package:test/test.dart'; 3 | 4 | void main() { 5 | group('Semver compare test', () { 6 | // Test case for major version differences 7 | test('Major versions comparison', () { 8 | expect(compareSemver("2.0.0", "1.0.0"), 1); 9 | expect(compareSemver("1.0.0", "2.0.0"), -1); 10 | expect(compareSemver("1.0.0", "1.0.0"), 0); 11 | }); 12 | 13 | // Test case for minor version differences 14 | test('Minor versions comparison', () { 15 | expect(compareSemver("1.2.0", "1.1.0"), 1); 16 | expect(compareSemver("1.1.0", "1.2.0"), -1); 17 | expect(compareSemver("1.1.0", "1.1.0"), 0); 18 | }); 19 | 20 | // Test case for patch version differences 21 | test('Patch versions comparison', () { 22 | expect(compareSemver("1.1.2", "1.1.1"), 1); 23 | expect(compareSemver("1.1.1", "1.1.2"), -1); 24 | expect(compareSemver("1.1.1", "1.1.1"), 0); 25 | }); 26 | 27 | // Test case for mixed differences (major/minor/patch) 28 | test('Mixed versions comparison', () { 29 | expect(compareSemver("3.3.2", "2.2.1"), 1); 30 | expect(compareSemver("2.2.1", "3.3.2"), -1); 31 | expect(compareSemver("2.2.1", "2.2.1"), 0); 32 | }); 33 | 34 | // Test case for prerelease versions 35 | test('Prerelease versions comparison', () { 36 | expect(compareSemver("1.0.0-alpha", "1.0.0-alpha.1"), -1); 37 | expect(compareSemver("1.0.0-alpha.1", "1.0.0-alpha.beta"), -1); 38 | expect(compareSemver("1.0.0-alpha.beta", "1.0.0-beta"), -1); 39 | expect(compareSemver("1.0.0-beta", "1.0.0-beta.2"), -1); 40 | expect(compareSemver("1.0.0-beta.2", "1.0.0-beta.11"), -1); 41 | expect(compareSemver("1.0.0-beta.11", "1.0.0-rc.1"), -1); 42 | expect(compareSemver("1.0.0-rc.1", "1.0.0"), -1); 43 | }); 44 | 45 | // Test case for build metadata (should be ignored when comparing) 46 | test('Metadata versions comparison', () { 47 | expect(compareSemver("1.0.0+20130313144700", "1.0.0+20130313144700"), 0); 48 | }); 49 | 50 | // Test case for invalid version formats 51 | test('Invalid versions comparison', () { 52 | expect(() => compareSemver("1.0.0", "invalid"), throwsFormatException); 53 | expect(() => compareSemver("invalid", "1.0.0"), throwsFormatException); 54 | expect(() => compareSemver("invalid", "invalid"), throwsFormatException); 55 | }); 56 | }); 57 | } 58 | -------------------------------------------------------------------------------- /lib/src/utils/change_case.dart: -------------------------------------------------------------------------------- 1 | const _snakeCaseSeparator = '_'; 2 | const _paramCaseSeparator = '-'; 3 | const _spaceSeparator = ' '; 4 | const _noSpaceSeparator = ''; 5 | final RegExp _upperAlphaRegex = RegExp(r'[A-Z]'); 6 | 7 | final _symbolSet = {_snakeCaseSeparator, _paramCaseSeparator, _spaceSeparator}; 8 | 9 | class ChangeCase { 10 | final String text; 11 | 12 | const ChangeCase(this.text); 13 | 14 | List _groupWords(String text) { 15 | final sb = StringBuffer(); 16 | final words = []; 17 | final isAllCaps = text.toUpperCase() == text; 18 | 19 | for (var i = 0; i < text.length; i++) { 20 | final char = text[i]; 21 | final nextChar = i + 1 == text.length ? null : text[i + 1]; 22 | 23 | if (_symbolSet.contains(char)) { 24 | continue; 25 | } 26 | 27 | sb.write(char); 28 | 29 | final isEndOfWord = nextChar == null || 30 | (_upperAlphaRegex.hasMatch(nextChar) && !isAllCaps) || 31 | _symbolSet.contains(nextChar); 32 | 33 | if (isEndOfWord) { 34 | words.add(sb.toString()); 35 | sb.clear(); 36 | } 37 | } 38 | 39 | return words; 40 | } 41 | 42 | String _getCamelCase() { 43 | final words = _words.map(_upperCaseFirstLetter).toList(); 44 | if (_words.isNotEmpty) { 45 | words[0] = words[0].toLowerCase(); 46 | } 47 | 48 | return words.join(_noSpaceSeparator); 49 | } 50 | 51 | String _uppercase(String separator) => _words.uppercase.join(separator); 52 | 53 | String _lowerCase(String separator) => _words.lowercase.join(separator); 54 | 55 | String _upperCaseFirstLetter(String word) { 56 | if (word.isEmpty) return ''; 57 | 58 | return word.capitalize; 59 | } 60 | 61 | List get _words => _groupWords(text); 62 | 63 | /// camelCase 64 | String get camelCase => _getCamelCase(); 65 | 66 | /// CONSTANT_CASE 67 | String get constantCase => _uppercase(_snakeCaseSeparator); 68 | 69 | /// snake_case 70 | String get snakeCase => _lowerCase(_snakeCaseSeparator); 71 | 72 | /// param-case 73 | String get paramCase => _lowerCase(_paramCaseSeparator); 74 | } 75 | 76 | extension on String { 77 | String get capitalize { 78 | if (isEmpty) return this; 79 | 80 | return this[0].toUpperCase() + substring(1).toLowerCase(); 81 | } 82 | } 83 | 84 | extension on List { 85 | List get lowercase => map((e) => e.toLowerCase()).toList(); 86 | List get uppercase => map((e) => e.toUpperCase()).toList(); 87 | } 88 | -------------------------------------------------------------------------------- /lib/src/commands/flavor_command.dart: -------------------------------------------------------------------------------- 1 | import 'package:args/args.dart'; 2 | import 'package:args/command_runner.dart'; 3 | 4 | import '../services/flutter_service.dart'; 5 | import '../services/project_service.dart'; 6 | import '../workflows/ensure_cache.workflow.dart'; 7 | import '../workflows/validate_flutter_version.workflow.dart'; 8 | import 'base_command.dart'; 9 | 10 | /// Executes a Flutter command using a specified version defined by the project flavor 11 | class FlavorCommand extends BaseFvmCommand { 12 | @override 13 | final name = 'flavor'; 14 | @override 15 | final description = 16 | 'Executes Flutter commands using the SDK version configured for a specific project flavor'; 17 | @override 18 | final argParser = ArgParser.allowAnything(); 19 | 20 | FlavorCommand(super.context); 21 | 22 | @override 23 | Future run() async { 24 | final ensureCache = EnsureCacheWorkflow(context); 25 | final validateFlutterVersion = ValidateFlutterVersionWorkflow(context); 26 | 27 | if (argResults!.rest.isEmpty) { 28 | throw UsageException( 29 | 'A flavor must be specified to execute the Flutter command', 30 | usage, 31 | ); 32 | } 33 | 34 | final project = get().findAncestor(); 35 | 36 | final flavor = argResults!.rest[0]; 37 | 38 | if (!project.flavors.containsKey(flavor)) { 39 | throw UsageException( 40 | 'The specified flavor is not defined in the project configuration file', 41 | usage, 42 | ); 43 | } 44 | 45 | final version = project.flavors[flavor]; 46 | if (version != null) { 47 | // Removes flavor from first arg 48 | final flutterArgs = [...?argResults?.rest]..removeAt(0); 49 | 50 | // Will install version if not already installed 51 | final flutterVersion = validateFlutterVersion(version); 52 | final cacheVersion = await ensureCache(flutterVersion); 53 | // Runs flutter command with pinned version 54 | logger.info( 55 | 'Using Flutter version "$version" for the "$flavor" flavor...', 56 | ); 57 | 58 | final results = await get().runFlutter( 59 | flutterArgs, 60 | cacheVersion, 61 | ); 62 | 63 | return results.exitCode; 64 | } 65 | throw UsageException( 66 | 'A version must be specified for the flavor "$flavor" in the project configuration file to execute the Flutter command', 67 | usage, 68 | ); 69 | } 70 | 71 | @override 72 | String get invocation => 'fvm flavor {flavor}'; 73 | } 74 | -------------------------------------------------------------------------------- /lib/src/workflows/use_version.workflow.dart: -------------------------------------------------------------------------------- 1 | import 'dart:async'; 2 | 3 | import 'package:io/ansi.dart'; 4 | 5 | import '../models/cache_flutter_version_model.dart'; 6 | import '../models/project_model.dart'; 7 | import '../utils/helpers.dart'; 8 | import '../utils/which.dart'; 9 | import 'resolve_project_deps.workflow.dart'; 10 | import 'setup_flutter.workflow.dart'; 11 | import 'setup_gitignore.workflow.dart'; 12 | import 'update_melos_settings.workflow.dart'; 13 | import 'update_project_references.workflow.dart'; 14 | import 'update_vscode_settings.workflow.dart'; 15 | import 'verify_project.workflow.dart'; 16 | import 'workflow.dart'; 17 | 18 | class UseVersionWorkflow extends Workflow { 19 | const UseVersionWorkflow(super.context); 20 | 21 | Future call({ 22 | required CacheFlutterVersion version, 23 | required Project project, 24 | bool force = false, 25 | bool skipSetup = false, 26 | bool skipPubGet = false, 27 | String? flavor, 28 | }) async { 29 | if (!skipSetup) { 30 | await get()(version); 31 | } 32 | 33 | get()(project, force: force); 34 | 35 | final updatedProject = await get()( 36 | project, 37 | version, 38 | flavor: flavor, 39 | force: force, 40 | ); 41 | 42 | get()(project); 43 | 44 | if (!skipPubGet) { 45 | await get()( 46 | updatedProject, 47 | version, 48 | force: force, 49 | ); 50 | } 51 | 52 | await get()(updatedProject); 53 | 54 | await get()(updatedProject); 55 | 56 | final versionLabel = cyan.wrap(version.printFriendlyName); 57 | // Different message if configured environment 58 | if (flavor != null) { 59 | logger.success( 60 | 'Project now uses Flutter SDK: $versionLabel on [$flavor] flavor.', 61 | ); 62 | } else { 63 | logger.success('Project now uses Flutter SDK : $versionLabel'); 64 | } 65 | 66 | if (version.flutterExec == which('flutter')) { 67 | logger.debug('Flutter SDK is already in your PATH'); 68 | 69 | return; 70 | } 71 | 72 | if (isVsCode()) { 73 | logger 74 | ..important( 75 | 'Running on VsCode, please restart the terminal to apply changes.', 76 | ) 77 | ..info( 78 | 'You can then use "flutter" command within the VsCode terminal.', 79 | ); 80 | } 81 | } 82 | } 83 | -------------------------------------------------------------------------------- /test/src/models/flutter_root_version_file_test.dart: -------------------------------------------------------------------------------- 1 | import 'dart:io'; 2 | 3 | import 'package:fvm/src/models/flutter_root_version_file.dart'; 4 | import 'package:path/path.dart' as path; 5 | import 'package:test/test.dart'; 6 | 7 | void main() { 8 | group('FlutterRootVersionFile', () { 9 | test('parses sample file', () { 10 | final fixturePath = 11 | path.join('test', 'fixtures', 'flutter.version.example.json'); 12 | final file = File(fixturePath); 13 | expect(file.existsSync(), isTrue); 14 | 15 | final metadata = FlutterRootVersionFile.tryLoadFromFile(file); 16 | expect(metadata, isNotNull); 17 | expect(metadata!.primaryVersion, '3.33.0-1.0.pre-1070'); 18 | expect(metadata.channel, 'master'); 19 | expect(metadata.dartSdkVersion, startsWith('3.10.0')); 20 | }); 21 | 22 | test('falls back to frameworkVersion when flutterVersion missing', () { 23 | final metadata = 24 | FlutterRootVersionFile.fromMap({'frameworkVersion': '3.2.0'}); 25 | 26 | expect(metadata.primaryVersion, '3.2.0'); 27 | }); 28 | 29 | test('returns null when file missing', () { 30 | final missing = FlutterRootVersionFile.tryLoadFromFile( 31 | File(path.join('test', 'fixtures', 'no_such_file.json')), 32 | ); 33 | 34 | expect(missing, isNull); 35 | }); 36 | 37 | test('returns null for malformed JSON', () { 38 | final tempDir = Directory.systemTemp.createTempSync('malformed_'); 39 | final file = File(path.join(tempDir.path, 'flutter.version.json')); 40 | file.writeAsStringSync('{ invalid json }'); 41 | 42 | expect(FlutterRootVersionFile.tryLoadFromFile(file), isNull); 43 | 44 | tempDir.deleteSync(recursive: true); 45 | }); 46 | 47 | test('returns null for JSON array instead of object', () { 48 | final tempDir = Directory.systemTemp.createTempSync('array_'); 49 | final file = File(path.join(tempDir.path, 'flutter.version.json')); 50 | file.writeAsStringSync('["3.0.0"]'); 51 | 52 | expect(FlutterRootVersionFile.tryLoadFromFile(file), isNull); 53 | 54 | tempDir.deleteSync(recursive: true); 55 | }); 56 | 57 | test('returns null for empty file', () { 58 | final tempDir = Directory.systemTemp.createTempSync('empty_'); 59 | final file = File(path.join(tempDir.path, 'flutter.version.json')); 60 | file.writeAsStringSync(''); 61 | 62 | expect(FlutterRootVersionFile.tryLoadFromFile(file), isNull); 63 | 64 | tempDir.deleteSync(recursive: true); 65 | }); 66 | }); 67 | } 68 | -------------------------------------------------------------------------------- /lib/src/commands/remove_command.dart: -------------------------------------------------------------------------------- 1 | import 'dart:io'; 2 | 3 | import 'package:io/io.dart'; 4 | 5 | import '../models/flutter_version_model.dart'; 6 | import '../services/cache_service.dart'; 7 | import '../utils/constants.dart'; 8 | import 'base_command.dart'; 9 | 10 | /// Removes Flutter SDK 11 | class RemoveCommand extends BaseFvmCommand { 12 | @override 13 | final name = 'remove'; 14 | 15 | @override 16 | final description = 'Removes Flutter SDK versions from the cache'; 17 | 18 | RemoveCommand(super.context) { 19 | argParser.addFlag( 20 | 'all', 21 | abbr: 'a', 22 | help: 'Removes all cached Flutter SDK versions', 23 | negatable: false, 24 | ); 25 | } 26 | 27 | /// Constructor 28 | 29 | @override 30 | Future run() async { 31 | final all = boolArg('all'); 32 | 33 | if (all) { 34 | final confirmRemoval = logger.confirm( 35 | 'Are you sure you want to remove all versions in your $kPackageName cache ?', 36 | defaultValue: false, 37 | ); 38 | if (confirmRemoval) { 39 | final versionsCache = Directory(context.versionsCachePath); 40 | if (versionsCache.existsSync()) { 41 | versionsCache.deleteSync(recursive: true); 42 | 43 | logger.success( 44 | '$kPackageName Directory ${versionsCache.path} has been deleted', 45 | ); 46 | } 47 | } 48 | 49 | return ExitCode.success.code; 50 | } 51 | 52 | String? version; 53 | 54 | if (argResults!.rest.isEmpty) { 55 | final versions = await get().getAllVersions(); 56 | version = logger.cacheVersionSelector(versions); 57 | } else { 58 | version = argResults!.rest[0]; 59 | } 60 | final validVersion = FlutterVersion.parse(version); 61 | final cacheVersion = get().getVersion(validVersion); 62 | 63 | // Check if version is installed 64 | if (cacheVersion == null) { 65 | logger.info('Flutter SDK: ${validVersion.name} is not installed'); 66 | 67 | return ExitCode.success.code; 68 | } 69 | 70 | final progress = logger.progress('Removing ${validVersion.name}...'); 71 | try { 72 | /// Remove if version is cached 73 | 74 | get().remove(cacheVersion); 75 | 76 | progress.complete('${validVersion.name} removed.'); 77 | } on Exception { 78 | progress.fail('Could not remove $validVersion'); 79 | rethrow; 80 | } 81 | 82 | return ExitCode.success.code; 83 | } 84 | 85 | @override 86 | String get invocation => 'fvm remove {version}'; 87 | } 88 | -------------------------------------------------------------------------------- /test/testing_helpers/prepare_test_environment.dart: -------------------------------------------------------------------------------- 1 | import 'dart:io'; 2 | 3 | import 'package:fvm/src/utils/constants.dart'; 4 | import 'package:fvm/src/utils/context.dart'; 5 | import 'package:io/io.dart'; 6 | import 'package:path/path.dart'; 7 | 8 | String getTempTestDir([String? contextId = '', String path = '']) { 9 | return join(kUserHome, 'fvm-test', contextId, path); 10 | } 11 | 12 | String getTempTestProjectDir([String? contextId = '', String name = '']) { 13 | return join(getTempTestDir(contextId, 'projects'), name); 14 | } 15 | 16 | String getSupportAssetDir(String name) { 17 | return join(Directory.current.path, 'test', 'support_assets', name); 18 | } 19 | 20 | final List directories = [ 21 | getSupportAssetDir('flutter_app'), 22 | getSupportAssetDir('dart_package'), 23 | getSupportAssetDir('empty_folder'), 24 | ]; 25 | 26 | Future prepareLocalProjects(String toPath) async { 27 | final promises = >[]; 28 | for (var directory in directories) { 29 | final assetDir = Directory(directory); 30 | final assetDirName = basename(assetDir.path); 31 | final tmpDir = Directory(join(toPath, assetDirName)); 32 | 33 | if (await tmpDir.exists()) { 34 | await tmpDir.delete(recursive: true); 35 | } 36 | 37 | await tmpDir.create(recursive: true); 38 | 39 | // Copy assetDir to tmpDir 40 | promises.add(copyPath(assetDir.path, tmpDir.path)); 41 | } 42 | 43 | await Future.wait(promises); 44 | } 45 | 46 | Future setUpContext( 47 | FvmContext context, [ 48 | bool removeTempDir = true, 49 | ]) async { 50 | final tempPath = getTempTestDir(context.debugLabel); 51 | 52 | final tempDir = Directory(tempPath); 53 | final projects = Directory(getTempTestProjectDir(context.debugLabel)); 54 | 55 | if (projects.existsSync()) { 56 | projects.deleteSync(recursive: true); 57 | } 58 | 59 | if (!tempDir.existsSync()) { 60 | tempDir.createSync(recursive: true); 61 | } else if (removeTempDir) { 62 | tempDir.deleteSync(recursive: true); 63 | tempDir.createSync(recursive: true); 64 | } 65 | 66 | await prepareLocalProjects(projects.path); 67 | } 68 | 69 | void tearDownContext(FvmContext context) { 70 | // final tempPath = getTempTestDir(context.id); 71 | 72 | // final tempDir = Directory(tempPath); 73 | 74 | // if (tempDir.existsSync()) { 75 | // try { 76 | // tempDir.deleteSync(recursive: true); 77 | // } on FileSystemException catch (e) { 78 | // // just log the error, as it can fail due to open files 79 | // logger.err(e.message); 80 | // } 81 | // } 82 | } 83 | -------------------------------------------------------------------------------- /lib/src/workflows/check_project_constraints.workflow.dart: -------------------------------------------------------------------------------- 1 | import 'dart:async'; 2 | 3 | import 'package:pub_semver/pub_semver.dart'; 4 | 5 | import '../models/cache_flutter_version_model.dart'; 6 | import '../models/project_model.dart'; 7 | import '../utils/exceptions.dart'; 8 | import 'workflow.dart'; 9 | 10 | class CheckProjectConstraintsWorkflow extends Workflow { 11 | const CheckProjectConstraintsWorkflow(super.context); 12 | 13 | /// Checks if the Flutter SDK version used in the project meets the specified constraints. 14 | FutureOr call( 15 | Project project, 16 | CacheFlutterVersion cachedVersion, { 17 | required bool force, 18 | }) { 19 | final sdkVersion = cachedVersion.dartSdkVersion; 20 | final constraints = project.sdkConstraint; 21 | 22 | if (sdkVersion == null || 23 | constraints == null || 24 | constraints.isEmpty || 25 | sdkVersion.isEmpty) { 26 | logger.debug( 27 | 'No SDK constraints to check or missing SDK version information', 28 | ); 29 | 30 | return false; 31 | } 32 | 33 | Version dartSdkVersion; 34 | try { 35 | dartSdkVersion = Version.parse(sdkVersion); 36 | } on FormatException catch (e) { 37 | logger.warn('Could not parse Flutter SDK version $sdkVersion: $e'); 38 | if (force) { 39 | logger.warn('Continuing anyway due to force flag'); 40 | 41 | return false; 42 | } 43 | logger 44 | ..warn('Could not parse Flutter SDK version $sdkVersion: $e') 45 | ..info() 46 | ..info('Continuing without checking version constraints'); 47 | 48 | return false; 49 | } 50 | 51 | final allowedInConstraint = constraints.allows(dartSdkVersion); 52 | final message = 53 | '${cachedVersion.printFriendlyName} has Dart SDK $sdkVersion'; 54 | 55 | if (!allowedInConstraint) { 56 | logger 57 | ..info( 58 | '$message does not meet the project constraints of $constraints.', 59 | ) 60 | ..info('This could cause unexpected behavior or issues.') 61 | ..info(''); 62 | 63 | if (force) { 64 | logger.warn( 65 | 'Skipping version constraint confirmation because of --force flag detected', 66 | ); 67 | 68 | return false; 69 | } 70 | 71 | if (!logger.confirm('Would you like to proceed?', defaultValue: false)) { 72 | throw AppException( 73 | 'The Flutter SDK version $sdkVersion is not compatible with the project constraints. You may need to adjust the version to avoid potential issues.', 74 | ); 75 | } 76 | } 77 | 78 | return true; 79 | } 80 | } 81 | -------------------------------------------------------------------------------- /lib/src/api/api_service.dart: -------------------------------------------------------------------------------- 1 | import 'dart:io'; 2 | 3 | import '../services/base_service.dart'; 4 | import '../services/cache_service.dart'; 5 | import '../services/project_service.dart'; 6 | import '../services/releases_service/releases_client.dart'; 7 | import '../utils/helpers.dart'; 8 | import 'models/json_response.dart'; 9 | 10 | /// Service providing JSON API access to FVM data for integrations and tooling. 11 | class ApiService extends ContextualService { 12 | const ApiService(super.context); 13 | 14 | /// Returns the current FVM context and configuration. 15 | GetContextResponse getContext() => GetContextResponse(context: context); 16 | 17 | /// Returns project information for the specified directory. 18 | /// If [projectDir] is null, searches from current directory upward. 19 | GetProjectResponse getProject([Directory? projectDir]) { 20 | final project = get().findAncestor(directory: projectDir); 21 | 22 | return GetProjectResponse(project: project); 23 | } 24 | 25 | /// Returns all cached Flutter SDK versions with optional size calculation. 26 | /// Set [skipCacheSizeCalculation] to true for faster response on large caches. 27 | Future getCachedVersions({ 28 | bool skipCacheSizeCalculation = false, 29 | }) async { 30 | final versions = await get().getAllVersions(); 31 | 32 | if (skipCacheSizeCalculation) { 33 | return GetCacheVersionsResponse( 34 | size: formatFriendlyBytes(0), 35 | versions: versions, 36 | ); 37 | } 38 | 39 | final versionSizes = await Future.wait( 40 | versions.map((version) { 41 | return getDirectorySize(Directory(version.directory)); 42 | }), 43 | ); 44 | 45 | return GetCacheVersionsResponse( 46 | size: formatFriendlyBytes(versionSizes.fold(0, (a, b) => a + b)), 47 | versions: versions, 48 | ); 49 | } 50 | 51 | /// Returns available Flutter SDK releases with optional filtering. 52 | /// Use [limit] to restrict count and [channelName] to filter by channel. 53 | Future getReleases({ 54 | int? limit, 55 | String? channelName, 56 | }) async { 57 | final payload = await get().fetchReleases(); 58 | 59 | var filteredVersions = payload.versions.where((version) { 60 | if (channelName == null) return true; 61 | 62 | return version.channel.name == channelName; 63 | }); 64 | 65 | if (limit != null) { 66 | filteredVersions = filteredVersions.take(limit); 67 | } 68 | 69 | return GetReleasesResponse( 70 | versions: filteredVersions.toList(), 71 | channels: payload.channels, 72 | ); 73 | } 74 | } 75 | -------------------------------------------------------------------------------- /test/services/git_service_test.dart: -------------------------------------------------------------------------------- 1 | import 'dart:io'; 2 | 3 | import 'package:fvm/fvm.dart'; 4 | import 'package:fvm/src/services/git_service.dart'; 5 | import 'package:fvm/src/services/process_service.dart'; 6 | import 'package:test/test.dart'; 7 | 8 | import '../testing_utils.dart'; 9 | 10 | class _FakeProcessService extends ProcessService { 11 | _FakeProcessService(super.context); 12 | 13 | ProcessException? exception; 14 | 15 | @override 16 | Future run( 17 | String command, { 18 | List args = const [], 19 | String? workingDirectory, 20 | Map? environment, 21 | bool throwOnError = true, 22 | bool echoOutput = false, 23 | }) async { 24 | if (exception != null) { 25 | throw exception!; 26 | } 27 | 28 | return ProcessResult(0, 0, '', ''); 29 | } 30 | } 31 | 32 | void main() { 33 | group('GitService', () { 34 | late FvmContext context; 35 | late GitService gitService; 36 | late _FakeProcessService processService; 37 | 38 | setUp(() { 39 | context = TestFactory.context( 40 | generators: { 41 | ProcessService: (ctx) { 42 | processService = _FakeProcessService(ctx); 43 | return processService; 44 | }, 45 | }, 46 | ); 47 | 48 | gitService = GitService(context); 49 | }); 50 | 51 | test('throws AppException when git command fails', () async { 52 | context.get(); 53 | processService.exception = ProcessException( 54 | 'git', 55 | ['ls-remote', '--tags', '--branches', context.flutterUrl], 56 | 'fatal: git not found', 57 | 127, 58 | ); 59 | 60 | await expectLater( 61 | gitService.isGitReference('stable'), 62 | throwsA( 63 | isA().having( 64 | (e) => e.message, 65 | 'message', 66 | allOf( 67 | contains('Failed to fetch git references from'), 68 | contains('Ensure git is installed'), 69 | ), 70 | ), 71 | ), 72 | ); 73 | }); 74 | 75 | test('AppException message includes flutter URL', () async { 76 | context.get(); 77 | processService.exception = ProcessException( 78 | 'git', 79 | const ['ls-remote'], 80 | 'fatal: unable to access', 81 | 128, 82 | ); 83 | 84 | await expectLater( 85 | gitService.isGitReference('beta'), 86 | throwsA( 87 | isA().having( 88 | (e) => e.message, 89 | 'message', 90 | contains(context.flutterUrl), 91 | ), 92 | ), 93 | ); 94 | }); 95 | }); 96 | } 97 | -------------------------------------------------------------------------------- /test/commands/alias_command_test.dart: -------------------------------------------------------------------------------- 1 | import 'package:fvm/fvm.dart'; 2 | import 'package:io/io.dart'; 3 | import 'package:test/test.dart'; 4 | 5 | import '../testing_utils.dart'; 6 | 7 | void main() { 8 | group('Command Aliases Test:', () { 9 | late TestCommandRunner runner; 10 | 11 | setUp(() { 12 | runner = TestFactory.commandRunner(); 13 | }); 14 | 15 | group('Install command aliases:', () { 16 | test('fvm i works same as fvm install', () async { 17 | const version = 'stable'; 18 | 19 | // Test that 'fvm i' works 20 | final exitCode = await runner.runOrThrow(['fvm', 'i', version]); 21 | expect(exitCode, ExitCode.success.code); 22 | 23 | // Verify installation 24 | final cacheVersion = runner.context.get().getVersion( 25 | FlutterVersion.parse(version), 26 | ); 27 | expect(cacheVersion, isNotNull, reason: 'Install via alias failed'); 28 | }); 29 | 30 | test('fvm i shows same help as fvm install', () async { 31 | // Both should show help and succeed 32 | final iResult = await runner.run(['fvm', 'i', '--help']); 33 | final installResult = await runner.run(['fvm', 'install', '--help']); 34 | 35 | expect(iResult, equals(installResult)); 36 | }); 37 | }); 38 | 39 | group('List command aliases:', () { 40 | test('fvm ls works same as fvm list', () async { 41 | // Both commands should succeed 42 | final exitCodeAlias = await runner.runOrThrow(['fvm', 'ls']); 43 | final exitCodeFull = await runner.runOrThrow(['fvm', 'list']); 44 | 45 | expect(exitCodeAlias, ExitCode.success.code); 46 | expect(exitCodeFull, ExitCode.success.code); 47 | }); 48 | 49 | test('fvm ls shows same help as fvm list', () async { 50 | // Both should show help and succeed 51 | final lsResult = await runner.run(['fvm', 'ls', '--help']); 52 | final listResult = await runner.run(['fvm', 'list', '--help']); 53 | 54 | expect(lsResult, equals(listResult)); 55 | }); 56 | }); 57 | 58 | group('All command aliases verification:', () { 59 | test('All defined aliases are accessible', () async { 60 | final runner = TestFactory.commandRunner(); 61 | 62 | // Test install alias 63 | expect(runner.commands.containsKey('i'), isTrue); 64 | expect(runner.commands.containsKey('install'), isTrue); 65 | expect(runner.commands['i']?.name, equals('install')); 66 | 67 | // Test list alias 68 | expect(runner.commands.containsKey('ls'), isTrue); 69 | expect(runner.commands.containsKey('list'), isTrue); 70 | expect(runner.commands['ls']?.name, equals('list')); 71 | }); 72 | }); 73 | }); 74 | } 75 | -------------------------------------------------------------------------------- /docs/pages/documentation/guides/running-flutter.mdx: -------------------------------------------------------------------------------- 1 | --- 2 | id: running_flutter 3 | title: Running Flutter 4 | --- 5 | 6 | import { Callout } from "nextra/components"; 7 | 8 | # Running Flutter 9 | 10 | FVM provides proxy commands to run Flutter and Dart with the correct SDK version for your project. 11 | 12 | ## Proxy Commands 13 | 14 | ### Flutter 15 | 16 | ```bash 17 | fvm flutter 18 | ``` 19 | 20 | ### Dart 21 | 22 | ```bash 23 | fvm dart 24 | ``` 25 | 26 | 27 | 28 | **Tip:** Create aliases for convenience: 29 | 30 | ```bash 31 | alias f="fvm flutter" 32 | alias d="fvm dart" 33 | ``` 34 | 35 | 36 | 37 | ## SDK Resolution Order 38 | 39 | When you run `fvm flutter` or `fvm dart`, FVM looks for the SDK in this order: 40 | 41 | 1. Project `.fvmrc` file 42 | 2. Ancestor directory `.fvmrc` 43 | 3. Global version (`fvm global`) 44 | 4. System PATH Flutter 45 | 46 | ## Direct SDK Access 47 | 48 | You can also call the SDK directly using the symlink: 49 | 50 | ```bash 51 | .fvm/flutter_sdk/bin/flutter run 52 | ``` 53 | 54 | ## Spawn Command 55 | 56 | Run commands with any installed Flutter version: 57 | 58 | ```bash 59 | fvm spawn 3.19.0 doctor 60 | ``` 61 | 62 | 63 | 64 | If you wish to reroute `flutter` and `dart` calls to FVM, i.e., ensure that running `flutter` on the terminal internally runs `fvm flutter`, then you could run the below commands. 65 | 66 | **On Mac** 67 | 68 | ```bash 69 | sudo echo 'fvm flutter ${@:1}' > "/usr/local/bin/flutter" && sudo chmod +x /usr/local/bin/flutter 70 | sudo echo 'fvm dart ${@:1}' > "/usr/local/bin/dart" && sudo chmod +x /usr/local/bin/dart 71 | ``` 72 | 73 | **On Linux** 74 | 75 | ```bash 76 | echo 'fvm flutter ${@:1}' > "$HOME/.local/bin/flutter" && chmod +x "$HOME/.local/bin/flutter" 77 | echo 'fvm dart ${@:1}' > "$HOME/.local/bin/dart" && chmod +x "$HOME/.local/bin/dart" 78 | ``` 79 | 80 | If you've installed flutter/dart using native package managers, the binaries might conflict with these new shortcuts, so consider deleting the existing ones and taking a backup for easier restoration. 81 | 82 | If you wish to remove these reroutes, just delete the corresponding files as shown below: 83 | 84 | **On Mac** 85 | 86 | ```bash 87 | sudo rm /usr/local/bin/flutter 88 | sudo rm /usr/local/bin/dart 89 | ``` 90 | 91 | **On Linux** 92 | 93 | ```bash 94 | rm "$HOME/.local/bin/flutter" 95 | rm "$HOME/.local/bin/dart" 96 | ``` 97 | 98 | 99 | 100 | ## Spawn Command 101 | 102 | Spawns a command on any installed Flutter SDK. 103 | 104 | ```bash 105 | fvm spawn {version} 106 | ``` 107 | 108 | ## Examples 109 | 110 | The following will run `flutter analyze` on the `master` channel: 111 | 112 | ```bash 113 | fvm spawn master analyze 114 | ``` -------------------------------------------------------------------------------- /lib/src/workflows/resolve_project_deps.workflow.dart: -------------------------------------------------------------------------------- 1 | import '../models/cache_flutter_version_model.dart'; 2 | import '../models/project_model.dart'; 3 | import '../services/flutter_service.dart'; 4 | import '../services/process_service.dart'; 5 | import '../utils/exceptions.dart'; 6 | import 'workflow.dart'; 7 | 8 | class ResolveProjectDependenciesWorkflow extends Workflow { 9 | const ResolveProjectDependenciesWorkflow(super.context); 10 | 11 | Future call( 12 | Project project, 13 | CacheFlutterVersion version, { 14 | required bool force, 15 | }) async { 16 | final flutterService = get(); 17 | 18 | if (version.isNotSetup) { 19 | logger.warn('Flutter SDK is not setup, skipping resolve dependencies.'); 20 | 21 | return false; 22 | } 23 | 24 | if (project.dartToolVersion == version.dartSdkVersion) { 25 | logger 26 | ..info('Dart tool version matches SDK version, skipping resolve.') 27 | ..info(); 28 | 29 | return true; 30 | } 31 | 32 | if (!context.runPubGetOnSdkChanges) { 33 | logger 34 | ..warn('Skipping "pub get" because of config setting') 35 | ..info(); 36 | 37 | return false; 38 | } 39 | 40 | if (!project.hasPubspec) { 41 | logger 42 | ..warn('Skipping "pub get" because no pubspec.yaml found.') 43 | ..info(); 44 | 45 | return true; 46 | } 47 | 48 | // Try to resolve offline 49 | final pubGetOfflineResults = await flutterService.pubGet( 50 | version, 51 | offline: true, 52 | ); 53 | 54 | if (pubGetOfflineResults.isSuccess) { 55 | logger.info('Dependencies resolved offline.'); 56 | 57 | return true; 58 | } 59 | 60 | logger.info('Trying to resolve dependencies online...'); 61 | final pubGetResults = await flutterService.pubGet(version); 62 | 63 | if (pubGetResults.isSuccess) { 64 | logger.info('Dependencies resolved.'); 65 | 66 | return true; 67 | } 68 | 69 | logger.err('Could not resolve dependencies.'); 70 | logger 71 | ..info() 72 | ..err(pubGetResults.stderr.toString()); 73 | 74 | logger.info( 75 | 'The error could indicate incompatible dependencies to the SDK.', 76 | ); 77 | 78 | if (force) { 79 | logger.warn('Force pinning due to --force flag.'); 80 | 81 | return false; 82 | } 83 | 84 | final confirmation = logger.confirm( 85 | 'Would you like to continue pinning this version anyway?', 86 | defaultValue: false, 87 | ); 88 | 89 | if (!confirmation) { 90 | throw AppException('Dependencies not resolved.'); 91 | } 92 | 93 | if (pubGetResults.stdout != null) { 94 | logger.debug(pubGetResults.stdout); 95 | } 96 | 97 | return true; 98 | } 99 | } 100 | -------------------------------------------------------------------------------- /lib/src/services/process_service.dart: -------------------------------------------------------------------------------- 1 | import 'dart:io'; 2 | 3 | import 'package:io/io.dart'; 4 | 5 | import 'base_service.dart'; 6 | 7 | class ProcessService extends ContextualService { 8 | const ProcessService(super.context); 9 | 10 | void _throwIfProcessFailed( 11 | ProcessResult pr, 12 | String process, 13 | List args, 14 | ) { 15 | if (pr.exitCode != 0) { 16 | final values = { 17 | if (pr.stdout != null) 'stdout': pr.stdout.toString().trim(), 18 | if (pr.stderr != null) 'stderr': pr.stderr.toString().trim(), 19 | }..removeWhere((k, v) => v.isEmpty); 20 | 21 | String message; 22 | if (values.isEmpty) { 23 | message = 'Unknown error'; 24 | } else if (values.length == 1) { 25 | message = values.values.single; 26 | } else { 27 | if (values['stderr'] != null) { 28 | message = values['stderr']!; 29 | } else { 30 | message = values['stdout']!; 31 | } 32 | } 33 | 34 | throw ProcessException(process, args, message, pr.exitCode); 35 | } 36 | } 37 | 38 | Future run( 39 | String command, { 40 | List args = const [], 41 | 42 | /// Listen for stdout and stderr 43 | String? workingDirectory, 44 | Map? environment, 45 | bool throwOnError = true, 46 | bool echoOutput = false, 47 | }) async { 48 | logger 49 | ..debug('') 50 | ..debug('Running: $command') 51 | ..debug(''); 52 | ProcessResult processResult; 53 | if (!echoOutput || context.isTest) { 54 | processResult = await Process.run( 55 | command, 56 | args, 57 | workingDirectory: workingDirectory, 58 | environment: environment, 59 | runInShell: true, 60 | ); 61 | 62 | if (throwOnError) { 63 | _throwIfProcessFailed(processResult, command, args); 64 | } 65 | 66 | return processResult; 67 | } 68 | final process = await Process.start( 69 | command, 70 | args, 71 | workingDirectory: workingDirectory, 72 | environment: environment, 73 | runInShell: true, 74 | mode: ProcessStartMode.inheritStdio, 75 | ); 76 | 77 | processResult = ProcessResult( 78 | process.pid, 79 | await process.exitCode, 80 | null, 81 | null, 82 | ); 83 | if (throwOnError) { 84 | _throwIfProcessFailed(processResult, command, args); 85 | } 86 | 87 | return processResult; 88 | } 89 | } 90 | 91 | extension ProcessResultX on ProcessResult { 92 | // Note: Using `this.exitCode` explicitly to avoid shadowing by 93 | // dart:io's top-level `exitCode` getter (which returns the current 94 | // process's exit code, not the ProcessResult's). 95 | bool get isSuccess => this.exitCode == ExitCode.success.code; 96 | 97 | bool get isFailure => this.exitCode != ExitCode.success.code; 98 | } 99 | -------------------------------------------------------------------------------- /test/src/workflows/test_logger.dart: -------------------------------------------------------------------------------- 1 | import 'package:fvm/src/models/cache_flutter_version_model.dart'; 2 | import 'package:fvm/src/services/logger_service.dart'; 3 | import 'package:fvm/src/utils/context.dart'; 4 | 5 | /// Test logger that allows simulating user input 6 | class TestLogger extends Logger { 7 | final Map _confirmResponses = {}; 8 | final Map _selectResponses = {}; 9 | final Map _versionResponses = {}; 10 | 11 | TestLogger(FvmContext context) : super(context); 12 | 13 | /// Set a response for a specific confirmation prompt 14 | void setConfirmResponse(String promptPattern, bool response) { 15 | _confirmResponses[promptPattern] = response; 16 | } 17 | 18 | /// Set a response for a specific selection prompt 19 | void setSelectResponse(String promptPattern, int optionIndex) { 20 | _selectResponses[promptPattern] = optionIndex; 21 | } 22 | 23 | /// Set a response for a specific version selection prompt 24 | void setVersionResponse(String promptPattern, String version) { 25 | _versionResponses[promptPattern] = version; 26 | } 27 | 28 | @override 29 | bool confirm(String? message, {required bool defaultValue}) { 30 | // Store the message in outputs like the parent 31 | if (message != null) { 32 | outputs.add(message); 33 | } 34 | 35 | // Check if we have a predefined response for this prompt 36 | if (message != null) { 37 | for (final entry in _confirmResponses.entries) { 38 | if (message.contains(entry.key)) { 39 | info('User response: ${entry.value ? "Yes" : "No"}'); 40 | return entry.value; 41 | } 42 | } 43 | } 44 | 45 | // Fall back to parent behavior 46 | return super.confirm(message, defaultValue: defaultValue); 47 | } 48 | 49 | @override 50 | String select( 51 | String? message, { 52 | required List options, 53 | int? defaultSelection, 54 | }) { 55 | if (message != null) { 56 | outputs.add(message); 57 | for (final entry in _selectResponses.entries) { 58 | if (message.contains(entry.key)) { 59 | final index = entry.value; 60 | if (index >= 0 && index < options.length) { 61 | info('User selected: ${options[index]}'); 62 | return options[index]; 63 | } 64 | } 65 | } 66 | } 67 | return super.select( 68 | message, 69 | options: options, 70 | defaultSelection: defaultSelection, 71 | ); 72 | } 73 | 74 | @override 75 | String cacheVersionSelector(List versions) { 76 | final prompt = 'Select a version: '; 77 | outputs.add(prompt); 78 | for (final entry in _versionResponses.entries) { 79 | if (prompt.contains(entry.key)) { 80 | info('User selected version: ${entry.value}'); 81 | return entry.value; 82 | } 83 | } 84 | return super.cacheVersionSelector(versions); 85 | } 86 | } 87 | -------------------------------------------------------------------------------- /docs/pages/index.mdx: -------------------------------------------------------------------------------- 1 | import Spacer from "../components/Spacer"; 2 | import GithubStarButton from "../components/GithubStarButton"; 3 | import TwitterButton from "../components/TwitterButton"; 4 | import Link from "next/link"; 5 | import MainHeading from "../components/MainHeading"; 6 | 7 | 8 | 9 | --- 10 | 11 |
12 | 13 | 14 | 15 | Pub Likes 16 | 17 | 18 | 19 | 20 | 21 | 22 | 23 |
24 | --- 25 | 26 | FVM streamlines Flutter version management. It allows per-project SDK versions, ensuring consistent app builds and easier testing of new releases, thereby boosting the efficiency of your Flutter project tasks. 27 | 28 | ## Why FVM? 29 | 30 | - Need for simultaneous use of multiple Flutter SDKs. 31 | - SDK testing requires constant [channel](https://github.com/flutter/flutter/wiki/Flutter-build-release-channels) switching. 32 | - Channel switches are slow and need repeated reinstalls. 33 | - Difficulty managing the latest successful SDK version used in an app. 34 | - Flutter's major updates demand total app migration. 35 | - Inconsistencies occur in development environments within teams. 36 | 37 | ## Contributors 38 | 39 | The Flutter community sits at the core of FVM. We're incredibly grateful to our open-source contributors whose dedication and hard work fuel this project. 40 | 41 | Their invaluable participation helps us develop and manage Flutter versions more efficiently, making FVM a crucial tool for every Flutter developer. Without them, FVM wouldn't exist. 42 | 43 | --- 44 | 45 | 46 | 47 | 48 | 49 | ## Principles 50 | 51 | - Interact with the SDK only through Flutter tools. 52 | - Avoid overriding any Flutter CLI commands. 53 | - Adhere to Flutter's recommended installation procedures for effective caching. 54 | - Aim to enhance Flutter's behavior, not to alter it. 55 | - Prioritize a simple and intuitive API. 56 | 57 | ## Video Guides & Walkthroughs 58 | 59 | You can view a playlist of many YouTube guides & walkthroughs done by the incredible Flutter community in many different languages. 60 | 61 | -------------------------------------------------------------------------------- /lib/src/commands/install_command.dart: -------------------------------------------------------------------------------- 1 | import 'dart:async'; 2 | 3 | import 'package:io/io.dart'; 4 | 5 | import '../services/project_service.dart'; 6 | import '../utils/exceptions.dart'; 7 | import '../workflows/ensure_cache.workflow.dart'; 8 | import '../workflows/setup_flutter.workflow.dart'; 9 | import '../workflows/use_version.workflow.dart'; 10 | import '../workflows/validate_flutter_version.workflow.dart'; 11 | import 'base_command.dart'; 12 | 13 | /// Installs Flutter SDK 14 | class InstallCommand extends BaseFvmCommand { 15 | @override 16 | final name = 'install'; 17 | 18 | @override 19 | final description = 20 | 'Installs a Flutter SDK version and caches it for future use'; 21 | 22 | InstallCommand(super.context) { 23 | argParser 24 | ..addFlag( 25 | 'setup', 26 | abbr: 's', 27 | help: 'Downloads SDK dependencies after install (default: true)', 28 | defaultsTo: true, 29 | negatable: true, 30 | ) 31 | ..addFlag( 32 | 'skip-pub-get', 33 | help: 'Skip resolving dependencies after switching Flutter SDK', 34 | defaultsTo: false, 35 | negatable: false, 36 | ); 37 | } 38 | 39 | @override 40 | Future run() async { 41 | final setup = boolArg('setup'); 42 | final skipPubGet = boolArg('skip-pub-get'); 43 | 44 | final ensureCache = EnsureCacheWorkflow(context); 45 | final useVersion = UseVersionWorkflow(context); 46 | final setupFlutter = SetupFlutterWorkflow(context); 47 | final validateFlutterVersion = ValidateFlutterVersionWorkflow(context); 48 | 49 | // If no version was passed as argument check project config. 50 | if (argResults!.rest.isEmpty) { 51 | final project = get().findAncestor(); 52 | 53 | final version = project.pinnedVersion; 54 | 55 | // If no config found is version throw error 56 | if (version == null) { 57 | throw const AppException( 58 | 'Please provide a channel or a version, or run' 59 | ' this command in a Flutter project that has FVM configured.', 60 | ); 61 | } 62 | 63 | final cacheVersion = await ensureCache(version, shouldInstall: true); 64 | 65 | await useVersion( 66 | version: cacheVersion, 67 | project: project, 68 | force: true, 69 | skipSetup: !setup, 70 | skipPubGet: skipPubGet, 71 | ); 72 | 73 | return ExitCode.success.code; 74 | } 75 | final version = firstRestArg!; 76 | 77 | final flutterVersion = validateFlutterVersion(version); 78 | 79 | final cacheVersion = await ensureCache(flutterVersion, shouldInstall: true); 80 | 81 | if (setup) { 82 | await setupFlutter(cacheVersion); 83 | } 84 | 85 | return ExitCode.success.code; 86 | } 87 | 88 | @override 89 | String get invocation => 'fvm install {version}, if no {version}' 90 | ' is provided will install version configured in project.'; 91 | 92 | @override 93 | List get aliases => ['i']; 94 | } 95 | -------------------------------------------------------------------------------- /lib/src/commands/config_command.dart: -------------------------------------------------------------------------------- 1 | import 'package:io/ansi.dart'; 2 | import 'package:io/io.dart'; 3 | import 'package:yaml_writer/yaml_writer.dart'; 4 | 5 | import '../models/config_model.dart'; 6 | import 'base_command.dart'; 7 | 8 | /// Fvm Config 9 | class ConfigCommand extends BaseFvmCommand { 10 | @override 11 | final name = 'config'; 12 | 13 | @override 14 | final description = 'Configure global FVM settings and preferences'; 15 | 16 | ConfigCommand(super.context) { 17 | ConfigOptions.injectArgParser(argParser); 18 | argParser.addFlag( 19 | 'update-check', 20 | help: 'Enables or disables automatic update checking for FVM', 21 | defaultsTo: true, 22 | negatable: true, 23 | ); 24 | } 25 | 26 | @override 27 | Future run() async { 28 | // Flag if settings should be saved 29 | final globalConfig = LocalAppConfig.read().toMap(); 30 | bool hasChanges = false; 31 | 32 | void updateConfigKey(ConfigOptions key, T value) { 33 | if (wasParsed(key.paramKey)) { 34 | logger.info( 35 | 'Setting ${key.paramKey} to: ${yellow.wrap(value.toString())}', 36 | ); 37 | 38 | if (globalConfig[key.name] != value) { 39 | globalConfig[key.name] = value; 40 | hasChanges = true; 41 | } 42 | } 43 | } 44 | 45 | for (var key in ConfigOptions.values) { 46 | updateConfigKey(key, argResults![key.paramKey]); 47 | } 48 | 49 | if (wasParsed('update-check')) { 50 | final updateCheckEnabled = argResults!['update-check'] as bool; 51 | final disableUpdateCheck = !updateCheckEnabled; 52 | 53 | logger.info( 54 | 'Setting update-check to: ${yellow.wrap(updateCheckEnabled.toString())}', 55 | ); 56 | 57 | if (globalConfig['disableUpdateCheck'] != disableUpdateCheck) { 58 | globalConfig['disableUpdateCheck'] = disableUpdateCheck; 59 | hasChanges = true; 60 | } 61 | } 62 | 63 | // Save 64 | if (hasChanges) { 65 | logger.info(''); 66 | final updateProgress = logger.progress('Saving settings'); 67 | // Update settings 68 | try { 69 | LocalAppConfig.fromMap(globalConfig).save(); 70 | } catch (error) { 71 | updateProgress.fail('Failed to save settings'); 72 | rethrow; 73 | } 74 | updateProgress.complete('Settings saved.'); 75 | 76 | return ExitCode.success.code; 77 | } 78 | 79 | final config = LocalAppConfig.read(); 80 | 81 | logger 82 | ..info('FVM Configuration:') 83 | ..info('Located at ${config.location}') 84 | ..info(''); 85 | 86 | if (config.isEmpty) { 87 | logger.info('No settings have been configured.'); 88 | 89 | return ExitCode.success.code; 90 | } 91 | 92 | final yamlWriter = YamlWriter(); 93 | final yamlString = yamlWriter.write(config.toMap()); 94 | 95 | logger.info(yamlString); 96 | 97 | return ExitCode.success.code; 98 | } 99 | } 100 | -------------------------------------------------------------------------------- /scripts/install.ps1: -------------------------------------------------------------------------------- 1 | # Requires admin rights 2 | if (-NOT ([Security.Principal.WindowsPrincipal][Security.Principal.WindowsIdentity]::GetCurrent()).IsInRole([Security.Principal.WindowsBuiltInRole] "Administrator")) { 3 | Write-Host "Run the script as an admin" 4 | exit 1 5 | } 6 | 7 | Function CleanUp { 8 | Remove-Item -Path "fvm.tar.gz" -Force -ErrorAction SilentlyContinue 9 | } 10 | 11 | Function CatchErrors { 12 | param ($exitCode) 13 | if ($exitCode -ne 0) { 14 | Write-Host "An error occurred." 15 | CleanUp 16 | exit 1 17 | } 18 | } 19 | 20 | # Terminal colors 21 | $Color_Off = '' 22 | $Red = [System.ConsoleColor]::Red 23 | $Green = [System.ConsoleColor]::Green 24 | $Dim = [System.ConsoleColor]::Gray 25 | $White = [System.ConsoleColor]::White 26 | 27 | Function Write-ErrorLine { 28 | param ($msg) 29 | Write-Host -ForegroundColor $Red "error: $msg" 30 | exit 1 31 | } 32 | 33 | Function Write-Info { 34 | param ($msg) 35 | Write-Host -ForegroundColor $Dim $msg 36 | } 37 | 38 | Function Write-Success { 39 | param ($msg) 40 | Write-Host -ForegroundColor $Green $msg 41 | } 42 | 43 | # Detect OS and architecture 44 | $OS = if ($env:OS -eq 'Windows_NT') { 'windows' } else { 'unknown' } 45 | $ARCH = if ([Environment]::Is64BitOperatingSystem) { 'x64' } else { 'x86' } 46 | 47 | Write-Info "Detected OS: $OS" 48 | Write-Info "Detected Architecture: $ARCH" 49 | 50 | # Check for curl 51 | try { 52 | $curl = Get-Command curl -ErrorAction Stop 53 | } catch { 54 | Write-ErrorLine "curl is required but not installed." 55 | } 56 | 57 | $github_repo = "fluttertools/fvm" 58 | 59 | # Get FVM version 60 | if ($args.Count -eq 0) { 61 | try { 62 | $FVM_VERSION = Invoke-RestMethod -Uri "https://api.github.com/repos/$github_repo/releases/latest" | Select-Object -ExpandProperty tag_name 63 | } catch { 64 | Write-ErrorLine "Failed to fetch the latest FVM version from GitHub." 65 | } 66 | } else { 67 | $FVM_VERSION = $args[0] 68 | } 69 | 70 | Write-Info "Installing FVM Version: $FVM_VERSION" 71 | 72 | # Download FVM 73 | $URL = "https://github.com/fluttertools/fvm/releases/download/$FVM_VERSION/fvm-$FVM_VERSION-$OS-x64.zip" 74 | Write-Host "Downloading from $URL" 75 | try { 76 | Invoke-WebRequest -Uri $URL -OutFile "fvm.tar.gz" 77 | } catch { 78 | Write-ErrorLine "Failed to download FVM from $URL." 79 | } 80 | 81 | $FVM_DIR = "C:\Program Files\fvm" 82 | 83 | # Extract binary 84 | try { 85 | tar -xzf fvm.tar.gz -C $FVM_DIR 86 | } catch { 87 | Write-ErrorLine "Extraction failed." 88 | } 89 | 90 | # Cleanup 91 | CleanUp 92 | 93 | # Verify Installation 94 | try { 95 | $INSTALLED_FVM_VERSION = & fvm --version 96 | if ($INSTALLED_FVM_VERSION -eq $FVM_VERSION) { 97 | Write-Success "FVM $INSTALLED_FVM_VERSION installed successfully." 98 | } else { 99 | Write-ErrorLine "FVM version verification failed." 100 | } 101 | } catch { 102 | Write-ErrorLine "Installation failed. Exiting." 103 | } 104 | -------------------------------------------------------------------------------- /lib/src/services/releases_service/models/version_model.dart: -------------------------------------------------------------------------------- 1 | import 'package:dart_mappable/dart_mappable.dart'; 2 | 3 | import '../../../models/flutter_version_model.dart'; 4 | import '../releases_client.dart'; 5 | 6 | part 'version_model.mapper.dart'; 7 | 8 | /// Release Model 9 | @MappableClass() 10 | class FlutterSdkRelease with FlutterSdkReleaseMappable { 11 | /// Release hash 12 | final String hash; 13 | 14 | /// Release channel 15 | final FlutterChannel channel; 16 | 17 | /// Release version 18 | final String version; 19 | 20 | /// Release date 21 | @MappableField(key: 'release_date') 22 | final DateTime releaseDate; 23 | 24 | /// Release archive name 25 | final String archive; 26 | 27 | /// Release sha256 hash 28 | final String sha256; 29 | 30 | /// Is release active in a channel 31 | @MappableField(key: 'active_channel') 32 | final bool activeChannel; 33 | 34 | /// Version of the Dart SDK 35 | @MappableField(key: 'dart_sdk_version') 36 | final String? dartSdkVersion; 37 | 38 | /// Dart SDK architecture 39 | @MappableField(key: 'dart_sdk_arch') 40 | final String? dartSdkArch; 41 | 42 | static final fromMap = FlutterSdkReleaseMapper.fromMap; 43 | static final fromJson = FlutterSdkReleaseMapper.fromJson; 44 | 45 | const FlutterSdkRelease({ 46 | required this.hash, 47 | required this.channel, 48 | required this.version, 49 | required this.releaseDate, 50 | required this.archive, 51 | required this.sha256, 52 | required this.dartSdkArch, 53 | required this.dartSdkVersion, 54 | this.activeChannel = false, 55 | }); 56 | 57 | /// Returns channel name of the release 58 | @MappableField() 59 | String get channelName => channel.name; 60 | 61 | /// Returns archive url of the release 62 | @MappableField() 63 | String get archiveUrl { 64 | return '${FlutterReleaseClient.storageUrl}/flutter_infra_release/releases/$archive'; 65 | } 66 | } 67 | 68 | /// Release channels model 69 | @MappableClass() 70 | class Channels with ChannelsMappable { 71 | /// Beta channel release 72 | final FlutterSdkRelease beta; 73 | 74 | /// Dev channel release 75 | final FlutterSdkRelease dev; 76 | 77 | /// Stable channel release 78 | final FlutterSdkRelease stable; 79 | 80 | static final fromMap = ChannelsMapper.fromMap; 81 | static final fromJson = ChannelsMapper.fromJson; 82 | 83 | /// Channel model constructor 84 | const Channels({required this.beta, required this.dev, required this.stable}); 85 | 86 | /// Returns a list of all releases 87 | List get toList => [dev, beta, stable]; 88 | 89 | /// Returns channel by name 90 | FlutterSdkRelease operator [](String channelName) { 91 | if (channelName == 'beta') return beta; 92 | if (channelName == 'dev') return dev; 93 | if (channelName == 'stable') return stable; 94 | throw Exception('Not a valid channel $channelName'); 95 | } 96 | 97 | /// Returns a hash map of the channels model 98 | Map toHashMap() => { 99 | beta.hash: 'beta', 100 | dev.hash: 'dev', 101 | stable.hash: 'stable', 102 | }; 103 | } 104 | -------------------------------------------------------------------------------- /test/utils/http_test.dart: -------------------------------------------------------------------------------- 1 | import 'dart:async'; 2 | import 'dart:io'; 3 | 4 | import 'package:fvm/src/utils/http.dart'; 5 | import 'package:mocktail/mocktail.dart'; 6 | import 'package:test/test.dart'; 7 | 8 | class _MockHttpClient extends Mock implements HttpClient {} 9 | 10 | class _MockHttpClientRequest extends Mock implements HttpClientRequest {} 11 | 12 | void main() { 13 | setUpAll(() { 14 | registerFallbackValue(Uri.parse('http://localhost')); 15 | }); 16 | 17 | group('httpRequest', () { 18 | test('throws HttpException with URL on 404', () async { 19 | final server = await HttpServer.bind(InternetAddress.loopbackIPv4, 0); 20 | addTearDown(() => server.close(force: true)); 21 | 22 | server.listen((request) async { 23 | request.response 24 | ..statusCode = HttpStatus.notFound 25 | ..reasonPhrase = 'Not Found'; 26 | await request.response.close(); 27 | }); 28 | 29 | final url = 30 | 'http://${server.address.host}:${server.port}/resource-does-not-exist'; 31 | 32 | await expectLater( 33 | httpRequest(url), 34 | throwsA( 35 | isA().having( 36 | (e) => e.message, 37 | 'message', 38 | allOf(contains('404'), contains(url)), 39 | ), 40 | ), 41 | ); 42 | }); 43 | 44 | test('closes client on error', () async { 45 | final client = _MockHttpClient(); 46 | final request = _MockHttpClientRequest(); 47 | 48 | when(() => client.getUrl(any())).thenAnswer((_) async => request); 49 | when(() => request.close()).thenThrow(const SocketException('failure')); 50 | when(() => client.close(force: any(named: 'force'))).thenAnswer((_) {}); 51 | 52 | await expectLater( 53 | HttpOverrides.runZoned( 54 | () => httpRequest('https://example.com'), 55 | createHttpClient: (_) => client, 56 | ), 57 | throwsA(isA()), 58 | ); 59 | 60 | verify(() => client.close(force: false)).called(1); 61 | }); 62 | 63 | test('passes custom headers to request', () async { 64 | final server = await HttpServer.bind(InternetAddress.loopbackIPv4, 0); 65 | addTearDown(() => server.close(force: true)); 66 | 67 | final capturedHeaders = Completer(); 68 | 69 | server.listen((request) async { 70 | capturedHeaders.complete(request.headers); 71 | request.response 72 | ..statusCode = HttpStatus.ok 73 | ..write('ok'); 74 | await request.response.close(); 75 | }); 76 | 77 | final url = 'http://${server.address.host}:${server.port}/'; 78 | final response = await httpRequest(url, headers: { 79 | 'Authorization': 'Bearer token', 80 | 'Accept': 'application/json', 81 | }); 82 | 83 | expect(response, equals('ok')); 84 | 85 | final headers = await capturedHeaders.future; 86 | expect(headers.value('authorization'), 'Bearer token'); 87 | expect(headers.value('accept'), 'application/json'); 88 | }); 89 | 90 | test('throws FormatException for malformed URL', () { 91 | expect( 92 | () => httpRequest('://malformed-url'), 93 | throwsA(isA()), 94 | ); 95 | }); 96 | }); 97 | } 98 | -------------------------------------------------------------------------------- /test/services/app_config_service_test.dart: -------------------------------------------------------------------------------- 1 | import 'package:fvm/src/models/config_model.dart'; 2 | import 'package:fvm/src/services/app_config_service.dart'; 3 | import 'package:test/test.dart'; 4 | 5 | void main() { 6 | group('AppConfigService', () { 7 | group('buildConfig', () { 8 | test('returns valid AppConfig', () { 9 | // Test that buildConfig returns a valid config 10 | final config = AppConfigService.buildConfig(); 11 | expect(config, isA()); 12 | }); 13 | 14 | test('applies overrides correctly', () { 15 | // Test that overrides are applied 16 | final overrides = AppConfig( 17 | privilegedAccess: true, 18 | cachePath: '/custom/cache', 19 | ); 20 | 21 | final config = AppConfigService.buildConfig(overrides: overrides); 22 | 23 | expect(config.privilegedAccess, isTrue); 24 | expect(config.cachePath, equals('/custom/cache')); 25 | }); 26 | }); 27 | 28 | group('createAppConfig', () { 29 | test('handles null configs gracefully', () { 30 | // Test with all null configs except global (which is required) 31 | final globalConfig = LocalAppConfig(); 32 | 33 | final result = AppConfigService.createAppConfig( 34 | globalConfig: globalConfig, 35 | envConfig: null, 36 | projectConfig: null, 37 | overrides: null, 38 | ); 39 | 40 | // Should return a valid AppConfig 41 | expect(result, isA()); 42 | }); 43 | 44 | test('merges multiple configs correctly', () { 45 | // Create configs with different settings 46 | final globalConfig = LocalAppConfig() 47 | ..cachePath = '/global/cache' 48 | ..privilegedAccess = false; 49 | 50 | final overrides = AppConfig(privilegedAccess: true); 51 | 52 | // Test the merge behavior 53 | final result = AppConfigService.createAppConfig( 54 | globalConfig: globalConfig, 55 | envConfig: null, 56 | projectConfig: null, 57 | overrides: overrides, 58 | ); 59 | 60 | // Overrides should win for privilegedAccess 61 | expect(result.privilegedAccess, isTrue); 62 | // Global config should provide cachePath 63 | expect(result.cachePath, equals('/global/cache')); 64 | }); 65 | }); 66 | 67 | group('environment variable support', () { 68 | test('_loadEnvironment configuration exists', () { 69 | // Test by creating config with environment that should be processed 70 | final config = AppConfigService.buildConfig(); 71 | 72 | // This test verifies the config structure exists and can handle environment variables 73 | expect(config, isA()); 74 | expect(config.cachePath, isA()); 75 | }); 76 | 77 | test('FVM_HOME fallback logic exists in implementation', () { 78 | // Since we can't easily mock Platform.environment in tests, 79 | // this test verifies the structure supports environment variables. 80 | // The actual FVM_HOME fallback logic is tested through manual verification. 81 | 82 | // Create a config and verify the structure 83 | final config = AppConfigService.buildConfig(); 84 | expect(config, isA()); 85 | }); 86 | }); 87 | }); 88 | } 89 | -------------------------------------------------------------------------------- /lib/src/commands/releases_command.dart: -------------------------------------------------------------------------------- 1 | import 'package:args/command_runner.dart'; 2 | import 'package:dart_console/dart_console.dart'; 3 | import 'package:mason_logger/mason_logger.dart'; 4 | 5 | import '../services/releases_service/models/version_model.dart'; 6 | import '../services/releases_service/releases_client.dart'; 7 | import '../utils/console_utils.dart'; 8 | import '../utils/helpers.dart'; 9 | import 'base_command.dart'; 10 | 11 | /// List installed SDK Versions 12 | class ReleasesCommand extends BaseFvmCommand { 13 | @override 14 | final name = 'releases'; 15 | 16 | @override 17 | final description = 18 | 'Lists all Flutter SDK releases available for installation'; 19 | 20 | // Add option to pass channel name 21 | ReleasesCommand(super.context) { 22 | argParser.addOption( 23 | 'channel', 24 | abbr: 'c', 25 | help: 'Filter releases by channel (stable, beta, dev, all)', 26 | allowed: ['stable', 'beta', 'dev', 'all'], 27 | defaultsTo: 'stable', 28 | ); 29 | } 30 | 31 | @override 32 | Future run() async { 33 | // Get channel name 34 | final channelName = stringArg('channel'); 35 | final allChannel = 'all'; 36 | 37 | if (channelName != null) { 38 | if (!isFlutterChannel(channelName) && channelName != allChannel) { 39 | throw UsageException('Invalid Channel name: $channelName', usage); 40 | } 41 | } 42 | 43 | bool shouldFilterRelease(FlutterSdkRelease release) { 44 | if (channelName == allChannel) { 45 | return false; 46 | } 47 | 48 | return release.channel.name != channelName; 49 | } 50 | 51 | logger.debug('Filtering by channel: $channelName'); 52 | 53 | final releases = await get().fetchReleases(); 54 | 55 | final versions = releases.versions.reversed; 56 | 57 | final table = createTable() 58 | ..insertColumn(header: 'Version', alignment: TextAlignment.left) 59 | ..insertColumn(header: 'Release Date', alignment: TextAlignment.left) 60 | ..insertColumn(header: 'Channel', alignment: TextAlignment.left); 61 | 62 | for (var release in versions) { 63 | var channelLabel = release.channel.toString().split('.').last; 64 | if (release.activeChannel) { 65 | // Add checkmark icon 66 | // as ascii code 67 | // Add backgroundColor 68 | final checkmark = String.fromCharCode(0x2713); 69 | 70 | channelLabel = '$channelLabel ${green.wrap(checkmark)}'; 71 | } 72 | 73 | if (shouldFilterRelease(release)) { 74 | continue; 75 | } 76 | 77 | table.insertRow([ 78 | release.version, 79 | friendlyDate(release.releaseDate), 80 | channelLabel, 81 | ]); 82 | } 83 | 84 | logger.info(table.toString()); 85 | 86 | logger.info('Channel:'); 87 | 88 | final channelsTable = createTable() 89 | ..insertColumn(header: 'Channel', alignment: TextAlignment.left) 90 | ..insertColumn(header: 'Version', alignment: TextAlignment.left) 91 | ..insertColumn(header: 'Release Date', alignment: TextAlignment.left); 92 | 93 | for (var release in releases.channels.toList) { 94 | if (shouldFilterRelease(release)) { 95 | continue; 96 | } 97 | channelsTable.insertRow([ 98 | release.channel.name, 99 | release.version, 100 | friendlyDate(release.releaseDate), 101 | ]); 102 | } 103 | 104 | logger.info(channelsTable.toString()); 105 | 106 | return ExitCode.success.code; 107 | } 108 | } 109 | -------------------------------------------------------------------------------- /test/services/get_all_versions_test.dart: -------------------------------------------------------------------------------- 1 | import 'dart:io'; 2 | 3 | import 'package:fvm/fvm.dart'; 4 | import 'package:path/path.dart' as path; 5 | import 'package:test/test.dart'; 6 | 7 | import '../testing_utils.dart'; 8 | 9 | void main() { 10 | late CacheService cacheService; 11 | late FvmContext context; 12 | late Directory tempDir; 13 | 14 | setUp(() { 15 | // Create test context using TestFactory 16 | context = TestFactory.context( 17 | debugLabel: 'get-all-versions-test', 18 | privilegedAccess: true, 19 | ); 20 | 21 | // Use the cache directory that TestFactory provides 22 | tempDir = Directory(context.versionsCachePath); 23 | 24 | // Create the cache service with test context 25 | cacheService = CacheService(context); 26 | }); 27 | 28 | tearDown(() { 29 | // Clean up is handled by TestFactory, but we can ensure it's clean 30 | if (tempDir.existsSync()) { 31 | tempDir.deleteSync(recursive: true); 32 | } 33 | }); 34 | 35 | group('getAllVersions', () { 36 | test('detects versions in both root and fork directories', () async { 37 | // Setup test directories 38 | // 1. Standard version: stable 39 | final stableVersion = FlutterVersion.parse('stable'); 40 | final stableDir = cacheService.getVersionCacheDir(stableVersion); 41 | stableDir.createSync(recursive: true); 42 | File(path.join(stableDir.path, 'version')) 43 | ..createSync() 44 | ..writeAsStringSync('stable'); 45 | 46 | // 2. Fork directory with a version inside 47 | final forkedVersion = FlutterVersion.parse('testfork/master'); 48 | final forkedVersionDir = cacheService.getVersionCacheDir(forkedVersion); 49 | forkedVersionDir.createSync(recursive: true); 50 | File(path.join(forkedVersionDir.path, 'version')) 51 | ..createSync() 52 | ..writeAsStringSync('master'); 53 | 54 | print('Directory structure:'); 55 | print('- ${tempDir.path}/'); 56 | print(' - stable/'); 57 | print(' - version (content: "stable")'); 58 | print(' - testfork/'); 59 | print(' - master/'); 60 | print(' - version (content: "master")'); 61 | 62 | // When: Getting all versions 63 | final versions = await cacheService.getAllVersions(); 64 | 65 | // Debug output 66 | print('Found ${versions.length} versions:'); 67 | for (final version in versions) { 68 | print( 69 | '- ${version.name} (fromFork: ${version.fromFork}, fork: ${version.fork}, ' 70 | 'version: ${version.version}, directory: ${version.directory})', 71 | ); 72 | } 73 | 74 | // Verification 75 | expect( 76 | versions.length, 77 | equals(2), 78 | reason: 'Should find both the regular and forked versions', 79 | ); 80 | 81 | // Find regular version 82 | final foundStableVersion = versions.firstWhere( 83 | (v) => v.version == 'stable', 84 | orElse: () => throw TestFailure('Standard version "stable" not found'), 85 | ); 86 | expect(foundStableVersion.fromFork, isFalse); 87 | expect(foundStableVersion.directory, equals(stableDir.path)); 88 | 89 | // Find forked version 90 | final foundMasterVersion = versions.firstWhere( 91 | (v) => v.version == 'master' && v.fromFork, 92 | orElse: () => throw TestFailure('Forked version "master" not found'), 93 | ); 94 | expect(foundMasterVersion.fromFork, isTrue); 95 | expect(foundMasterVersion.fork, equals('testfork')); 96 | expect(foundMasterVersion.directory, equals(forkedVersionDir.path)); 97 | }); 98 | }); 99 | } 100 | -------------------------------------------------------------------------------- /docs/public/assets/powered-by-vercel.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /scripts/uninstall.sh: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env bash 2 | # Uninstall FVM - removes the install bin directory, preserves cached SDKs 3 | set -euo pipefail 4 | 5 | # Paths 6 | resolve_install_base() { 7 | local base="${FVM_INSTALL_DIR:-}" 8 | if [ -z "$base" ]; then 9 | base="${HOME}/fvm" 10 | fi 11 | 12 | case "$base" in 13 | \~) base="$HOME" ;; 14 | \~/*) base="$HOME/${base#\~/}" ;; 15 | esac 16 | 17 | printf '%s\n' "$base" 18 | } 19 | 20 | INSTALL_BASE="$(resolve_install_base)" 21 | BIN_DIR="${INSTALL_BASE}/bin" 22 | 23 | OLD_USER_PATH="${HOME}/.fvm_flutter" 24 | OLD_SYSTEM_PATH="/usr/local/bin/fvm" 25 | 26 | validate_install_base() { 27 | local base="$1" 28 | local bin_dir="${base}/bin" 29 | 30 | if [ -z "$base" ] || [ "$base" = "/" ]; then 31 | echo "error: refusing to use unsafe install base: '${base:-}'" >&2 32 | echo " Set FVM_INSTALL_DIR to a directory under your HOME (default: \$HOME/fvm)" >&2 33 | exit 1 34 | fi 35 | 36 | case "$base" in 37 | /*) ;; 38 | *) 39 | echo "error: FVM_INSTALL_DIR must be an absolute path (got: $base)" >&2 40 | exit 1 41 | ;; 42 | esac 43 | 44 | if [ "$base" = "$HOME" ]; then 45 | echo "error: refusing to use HOME as install base ($HOME). Use a subdirectory like \$HOME/fvm." >&2 46 | exit 1 47 | fi 48 | 49 | case "$base" in 50 | "$HOME"/*) ;; 51 | *) 52 | echo "error: refusing to uninstall outside HOME: $base" >&2 53 | echo " Use a directory under $HOME, or unset FVM_INSTALL_DIR to use the default." >&2 54 | exit 1 55 | ;; 56 | esac 57 | 58 | case "$bin_dir" in 59 | /bin|/usr/bin|/usr/local/bin|/sbin|/usr/sbin) 60 | echo "error: refusing to use unsafe bin directory: $bin_dir" >&2 61 | exit 1 62 | ;; 63 | esac 64 | } 65 | 66 | echo "Uninstalling FVM..." 67 | echo "" 68 | 69 | removed_any=0 70 | 71 | validate_install_base "$INSTALL_BASE" 72 | 73 | # 1. Remove install bin directory (NOT entire ~/fvm/) 74 | if [ -d "$BIN_DIR" ]; then 75 | rm -rf "$BIN_DIR" 2>/dev/null || true 76 | if [ ! -d "$BIN_DIR" ]; then 77 | echo "✓ Removed $BIN_DIR" 78 | removed_any=1 79 | else 80 | echo "⚠ Could not remove $BIN_DIR" >&2 81 | fi 82 | fi 83 | 84 | # 2. Remove old user directory (safe to nuke - installer-controlled) 85 | if [ -d "$OLD_USER_PATH" ]; then 86 | rm -rf "$OLD_USER_PATH" 2>/dev/null || true 87 | if [ ! -d "$OLD_USER_PATH" ]; then 88 | echo "✓ Removed $OLD_USER_PATH" 89 | removed_any=1 90 | else 91 | echo "⚠ Could not remove $OLD_USER_PATH" >&2 92 | fi 93 | fi 94 | 95 | # 3. Remove old system symlink 96 | if [ -L "$OLD_SYSTEM_PATH" ]; then 97 | rm -f "$OLD_SYSTEM_PATH" 2>/dev/null || true 98 | if [ -L "$OLD_SYSTEM_PATH" ] && command -v sudo >/dev/null 2>&1; then 99 | sudo rm -f "$OLD_SYSTEM_PATH" 2>/dev/null || true 100 | fi 101 | if [ ! -e "$OLD_SYSTEM_PATH" ] && [ ! -L "$OLD_SYSTEM_PATH" ]; then 102 | echo "✓ Removed $OLD_SYSTEM_PATH" 103 | removed_any=1 104 | else 105 | echo "⚠ Could not remove $OLD_SYSTEM_PATH (may need sudo)" >&2 106 | fi 107 | elif [ -e "$OLD_SYSTEM_PATH" ]; then 108 | echo "⚠ Found existing non-symlink file at $OLD_SYSTEM_PATH; not removing automatically." >&2 109 | fi 110 | 111 | [ "$removed_any" -eq 0 ] && echo "No FVM installation found." 112 | 113 | echo "" 114 | echo "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━" 115 | echo "Note: Cached Flutter SDKs remain in $INSTALL_BASE/versions/" 116 | echo " To remove them: rm -rf $INSTALL_BASE/" 117 | echo "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━" 118 | echo "" 119 | echo "Remove PATH entries from your shell config:" 120 | echo " - ~/.bashrc" 121 | echo " - ~/.zshrc" 122 | echo " - ~/.config/fish/config.fish" 123 | echo "" 124 | echo "Look for: $BIN_DIR" 125 | echo "" 126 | echo "FVM uninstalled successfully." 127 | -------------------------------------------------------------------------------- /docs/public/uninstall.sh: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env bash 2 | # Uninstall FVM - removes the install bin directory, preserves cached SDKs 3 | set -euo pipefail 4 | 5 | # Paths 6 | resolve_install_base() { 7 | local base="${FVM_INSTALL_DIR:-}" 8 | if [ -z "$base" ]; then 9 | base="${HOME}/fvm" 10 | fi 11 | 12 | case "$base" in 13 | \~) base="$HOME" ;; 14 | \~/*) base="$HOME/${base#\~/}" ;; 15 | esac 16 | 17 | printf '%s\n' "$base" 18 | } 19 | 20 | INSTALL_BASE="$(resolve_install_base)" 21 | BIN_DIR="${INSTALL_BASE}/bin" 22 | 23 | OLD_USER_PATH="${HOME}/.fvm_flutter" 24 | OLD_SYSTEM_PATH="/usr/local/bin/fvm" 25 | 26 | validate_install_base() { 27 | local base="$1" 28 | local bin_dir="${base}/bin" 29 | 30 | if [ -z "$base" ] || [ "$base" = "/" ]; then 31 | echo "error: refusing to use unsafe install base: '${base:-}'" >&2 32 | echo " Set FVM_INSTALL_DIR to a directory under your HOME (default: \$HOME/fvm)" >&2 33 | exit 1 34 | fi 35 | 36 | case "$base" in 37 | /*) ;; 38 | *) 39 | echo "error: FVM_INSTALL_DIR must be an absolute path (got: $base)" >&2 40 | exit 1 41 | ;; 42 | esac 43 | 44 | if [ "$base" = "$HOME" ]; then 45 | echo "error: refusing to use HOME as install base ($HOME). Use a subdirectory like \$HOME/fvm." >&2 46 | exit 1 47 | fi 48 | 49 | case "$base" in 50 | "$HOME"/*) ;; 51 | *) 52 | echo "error: refusing to uninstall outside HOME: $base" >&2 53 | echo " Use a directory under $HOME, or unset FVM_INSTALL_DIR to use the default." >&2 54 | exit 1 55 | ;; 56 | esac 57 | 58 | case "$bin_dir" in 59 | /bin|/usr/bin|/usr/local/bin|/sbin|/usr/sbin) 60 | echo "error: refusing to use unsafe bin directory: $bin_dir" >&2 61 | exit 1 62 | ;; 63 | esac 64 | } 65 | 66 | echo "Uninstalling FVM..." 67 | echo "" 68 | 69 | removed_any=0 70 | 71 | validate_install_base "$INSTALL_BASE" 72 | 73 | # 1. Remove install bin directory (NOT entire ~/fvm/) 74 | if [ -d "$BIN_DIR" ]; then 75 | rm -rf "$BIN_DIR" 2>/dev/null || true 76 | if [ ! -d "$BIN_DIR" ]; then 77 | echo "✓ Removed $BIN_DIR" 78 | removed_any=1 79 | else 80 | echo "⚠ Could not remove $BIN_DIR" >&2 81 | fi 82 | fi 83 | 84 | # 2. Remove old user directory (safe to nuke - installer-controlled) 85 | if [ -d "$OLD_USER_PATH" ]; then 86 | rm -rf "$OLD_USER_PATH" 2>/dev/null || true 87 | if [ ! -d "$OLD_USER_PATH" ]; then 88 | echo "✓ Removed $OLD_USER_PATH" 89 | removed_any=1 90 | else 91 | echo "⚠ Could not remove $OLD_USER_PATH" >&2 92 | fi 93 | fi 94 | 95 | # 3. Remove old system symlink 96 | if [ -L "$OLD_SYSTEM_PATH" ]; then 97 | rm -f "$OLD_SYSTEM_PATH" 2>/dev/null || true 98 | if [ -L "$OLD_SYSTEM_PATH" ] && command -v sudo >/dev/null 2>&1; then 99 | sudo rm -f "$OLD_SYSTEM_PATH" 2>/dev/null || true 100 | fi 101 | if [ ! -e "$OLD_SYSTEM_PATH" ] && [ ! -L "$OLD_SYSTEM_PATH" ]; then 102 | echo "✓ Removed $OLD_SYSTEM_PATH" 103 | removed_any=1 104 | else 105 | echo "⚠ Could not remove $OLD_SYSTEM_PATH (may need sudo)" >&2 106 | fi 107 | elif [ -e "$OLD_SYSTEM_PATH" ]; then 108 | echo "⚠ Found existing non-symlink file at $OLD_SYSTEM_PATH; not removing automatically." >&2 109 | fi 110 | 111 | [ "$removed_any" -eq 0 ] && echo "No FVM installation found." 112 | 113 | echo "" 114 | echo "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━" 115 | echo "Note: Cached Flutter SDKs remain in $INSTALL_BASE/versions/" 116 | echo " To remove them: rm -rf $INSTALL_BASE/" 117 | echo "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━" 118 | echo "" 119 | echo "Remove PATH entries from your shell config:" 120 | echo " - ~/.bashrc" 121 | echo " - ~/.zshrc" 122 | echo " - ~/.config/fish/config.fish" 123 | echo "" 124 | echo "Look for: $BIN_DIR" 125 | echo "" 126 | echo "FVM uninstalled successfully." 127 | -------------------------------------------------------------------------------- /test/install_script_validation_test.dart: -------------------------------------------------------------------------------- 1 | import 'dart:io'; 2 | 3 | import 'package:test/test.dart'; 4 | 5 | void main() { 6 | group('Install Script Validation:', () { 7 | test('Local install.sh matches docs/public version', () async { 8 | // Read the local script 9 | final localScript = File('scripts/install.sh'); 10 | expect( 11 | localScript.existsSync(), 12 | true, 13 | reason: 'Local install.sh script should exist', 14 | ); 15 | 16 | final localContent = await localScript.readAsString(); 17 | 18 | // Read the docs/public version 19 | final publicScript = File('docs/public/install.sh'); 20 | expect( 21 | publicScript.existsSync(), 22 | true, 23 | reason: 'Public install.sh script should exist in docs/public', 24 | ); 25 | 26 | final publicContent = await publicScript.readAsString(); 27 | 28 | // Compare the contents 29 | expect( 30 | localContent.trim(), 31 | equals(publicContent.trim()), 32 | reason: 'Local install.sh should match the docs/public version', 33 | ); 34 | }); 35 | 36 | test('Local uninstall.sh matches docs/public version', () async { 37 | // Read the local script 38 | final localScript = File('scripts/uninstall.sh'); 39 | expect( 40 | localScript.existsSync(), 41 | true, 42 | reason: 'Local uninstall.sh script should exist', 43 | ); 44 | 45 | final localContent = await localScript.readAsString(); 46 | 47 | // Read the docs/public version 48 | final publicScript = File('docs/public/uninstall.sh'); 49 | expect( 50 | publicScript.existsSync(), 51 | isTrue, 52 | reason: 'Public uninstall.sh script should exist in docs/public', 53 | ); 54 | 55 | final publicContent = await publicScript.readAsString(); 56 | 57 | // Compare the contents 58 | expect( 59 | localContent.trim(), 60 | equals(publicContent.trim()), 61 | reason: 'Local uninstall.sh should match the docs/public version', 62 | ); 63 | }); 64 | 65 | test('Dockerfile uses correct install script URL', () async { 66 | // Read the Dockerfile 67 | final dockerfile = File('.docker/Dockerfile'); 68 | expect(dockerfile.existsSync(), isTrue, reason: 'Dockerfile should exist'); 69 | 70 | final dockerfileContent = await dockerfile.readAsString(); 71 | 72 | // Check that it uses the correct URL 73 | const expectedUrl = 74 | 'https://raw.githubusercontent.com/leoafarias/fvm/main/scripts/install.sh'; 75 | expect( 76 | dockerfileContent.contains(expectedUrl), 77 | isTrue, 78 | reason: 79 | 'Dockerfile should reference the correct public install script URL', 80 | ); 81 | }); 82 | 83 | test('Release grinder task moves scripts to correct location', () async { 84 | final grinderFile = File('tool/release_tool/tool/grind.dart'); 85 | expect( 86 | grinderFile.existsSync(), 87 | isTrue, 88 | reason: 'Release grinder file should exist', 89 | ); 90 | 91 | final grinderContent = await grinderFile.readAsString(); 92 | 93 | expect( 94 | grinderContent.contains( 95 | "@Task('Move install scripts to public directory')", 96 | ), 97 | true, 98 | reason: 99 | 'Release grinder should define a task for moving install scripts', 100 | ); 101 | 102 | expect( 103 | grinderContent.contains("install.sh"), 104 | true, 105 | reason: 'Release grinder should reference install.sh', 106 | ); 107 | 108 | expect( 109 | grinderContent.contains("docs/public"), 110 | true, 111 | reason: 'Release grinder should target the docs/public directory', 112 | ); 113 | }); 114 | }); 115 | } 116 | -------------------------------------------------------------------------------- /docs/pages/documentation/troubleshooting/git-safe-directory-windows.md: -------------------------------------------------------------------------------- 1 | --- 2 | id: git-safe-directory-windows 3 | title: "Git Safe Directory Error on Windows" 4 | description: "Fix 'Unable to find git in your PATH' errors on Windows when Git 2.35.2+ blocks unsafe repositories" 5 | --- 6 | 7 | # Git Safe Directory Error on Windows 8 | 9 | ## Symptoms 10 | 11 | You run FVM commands on Windows and see: 12 | 13 | ``` 14 | Error: Unable to find git in your PATH. 15 | ``` 16 | 17 | `git --version` still works, so PATH is fine—but Flutter commands that rely on Git fail. 18 | 19 | ## Root Cause 20 | 21 | Git 2.35.2 (April 2022) introduced a security fix for [CVE-2022-24765](https://nvd.nist.gov/vuln/detail/CVE-2022-24765). It refuses to run inside repositories owned by a different Windows user. Because FVM stores Flutter SDKs under `%LOCALAPPDATA%\fvm\versions`, Windows access control lists sometimes make Git think the directory belongs to another user. Git then halts with the misleading "unable to find git" error. 22 | 23 | ## Quick Fix (Recommended) 24 | 25 | Tell Git to trust every repository on your development machine: 26 | 27 | ```bash 28 | git config --global --add safe.directory "*" 29 | ``` 30 | 31 | Restart your terminal and IDE (VS Code, Android Studio, etc.) after running the command. 32 | 33 | ### What This Does 34 | 35 | Git's `safe.directory` list defines which paths bypass the ownership check. Using `*` is a pragmatic fix for single-user development machines because it restores pre-2.35.2 behavior. 36 | 37 | ## Alternative: Trust Only FVM Directories 38 | 39 | If you want more control, add the directories that FVM manages: 40 | 41 | ```bash 42 | # Trust the entire FVM cache 43 | git config --global --add safe.directory "C:/Users/YourUsername/AppData/Local/fvm/versions" 44 | 45 | # Or trust a single Flutter version 46 | git config --global --add safe.directory "C:/Users/YourUsername/AppData/Local/fvm/versions/3.24.0" 47 | ``` 48 | 49 | Replace `YourUsername` with your Windows account name. Keep forward slashes (`/`) in the path. 50 | 51 | ## Verify the Fix 52 | 53 | ```bash 54 | # List all trusted directories 55 | git config --global --get-all safe.directory 56 | 57 | # Double-check your setup 58 | fvm doctor 59 | 60 | # Retry the original command 61 | fvm flutter doctor 62 | ``` 63 | 64 | ## Why This Happens 65 | 66 | ### The Security Update 67 | - Git 2.35.2+ blocks repositories whose file owner does not match the current user. 68 | - The change prevents malicious repositories from hijacking shared directories. 69 | 70 | ### Why FVM Is Affected 71 | 1. FVM installs Flutter SDKs under `%LOCALAPPDATA%\fvm\versions`. 72 | 2. Windows assigns ownership metadata that may not match your current SID (especially after renames, domain joins, or running shells as Administrator). 73 | 3. Flutter tools call Git internally; when Git refuses to run, Flutter reports "Unable to find git in your PATH". 74 | 75 | ### When It Happens 76 | - After installing/upgrading to Git 2.35.2 or newer 77 | - On fresh Windows setups where no safe directories are configured 78 | - After changing Windows usernames or using multiple user profiles 79 | - When running PowerShell 7+ or terminals elevated as Administrator 80 | - In CI environments that download artifacts created by another user account 81 | 82 | ## Other Solutions 83 | 84 | ### Enable Windows Developer Mode 85 | Some users report that enabling Developer Mode (`Settings → System → For developers`) alleviates ownership mismatches. You still need to restart your terminal. 86 | 87 | ### Run the Terminal as Administrator (Temporary) 88 | Launching PowerShell or CMD as Administrator can bypass the ownership check, but it is inconvenient and not recommended long term. 89 | 90 | ## Related Resources 91 | 92 | - [Git CVE-2022-24765 announcement](https://github.blog/2022-04-18-git-security-vulnerability-announced/) 93 | - [Git safe.directory documentation](https://git-scm.com/docs/git-config#Documentation/git-config.txt-safedirectory) 94 | - [FVM FAQ](/documentation/getting-started/faq) 95 | --------------------------------------------------------------------------------