├── 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 |
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 |
--------------------------------------------------------------------------------
/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 |
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 |
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 |

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 |
--------------------------------------------------------------------------------