diff --git a/packages/flutterfire_cli/bin/flutterfire.dart b/packages/flutterfire_cli/bin/flutterfire.dart index 40248161..642e5ec6 100755 --- a/packages/flutterfire_cli/bin/flutterfire.dart +++ b/packages/flutterfire_cli/bin/flutterfire.dart @@ -18,6 +18,7 @@ import 'dart:io'; +import 'package:flutterfire_cli/flutterfire_cli.dart'; import 'package:flutterfire_cli/src/command_runner.dart'; import 'package:flutterfire_cli/src/common/strings.dart'; import 'package:flutterfire_cli/src/common/utils.dart' as utils; @@ -54,13 +55,52 @@ Future main(List arguments) async { try { FlutterApp? flutterApp; + // Parse custom flags that we handle ourselves before passing to the command runner. + // These flags are not registered options in the command runner, so we extract them here. + // + // Supported custom flags: + // --cwd= Target Flutter project directory + // --firebase-executable= Custom Firebase CLI executable (e.g., 'node') + // --firebase-base-args= Comma-separated base args for Firebase CLI + // --firebase-workdir= Working directory for Firebase CLI commands + String? cwdArg; + String? firebaseExecutable; + String? firebaseBaseArgs; + String? firebaseWorkdir; + final filteredArgs = []; + + for (final arg in arguments) { + if (arg.startsWith('--cwd=')) { + cwdArg = arg.substring(6); + } else if (arg.startsWith('--firebase-executable=')) { + firebaseExecutable = arg.substring(22); + } else if (arg.startsWith('--firebase-base-args=')) { + firebaseBaseArgs = arg.substring(21); + } else if (arg.startsWith('--firebase-workdir=')) { + firebaseWorkdir = arg.substring(19); + } else { + filteredArgs.add(arg); + } + } + + // Configure custom Firebase CLI if specified. + // This allows using a forked/custom Firebase CLI instead of the system's 'firebase' command. + if (firebaseExecutable != null || firebaseBaseArgs != null || firebaseWorkdir != null) { + globalFirebaseCliConfig = FirebaseCliConfig( + executable: firebaseExecutable ?? 'firebase', + baseArgs: firebaseBaseArgs?.split(',') ?? const [], + workingDirectory: firebaseWorkdir, + ); + } + // upload-crashlytics-symbols & bundle-service-file scripts are ran from Xcode environment - if (!arguments.contains('upload-crashlytics-symbols') && - !arguments.contains('bundle-service-file')) { - flutterApp = await FlutterApp.load(Directory.current); + if (!filteredArgs.contains('upload-crashlytics-symbols') && + !filteredArgs.contains('bundle-service-file')) { + final projectDir = cwdArg != null ? Directory(cwdArg) : Directory.current; + flutterApp = await FlutterApp.load(projectDir); } - await FlutterFireCommandRunner(flutterApp).run(arguments); + await FlutterFireCommandRunner(flutterApp).run(filteredArgs); } on FlutterFireException catch (err) { if (utils.activeSpinnerState != null) { try { diff --git a/packages/flutterfire_cli/lib/flutterfire_cli.dart b/packages/flutterfire_cli/lib/flutterfire_cli.dart index 1eeb0db1..da8b2209 100644 --- a/packages/flutterfire_cli/lib/flutterfire_cli.dart +++ b/packages/flutterfire_cli/lib/flutterfire_cli.dart @@ -16,3 +16,11 @@ */ export 'src/common/strings.dart' show FlutterFireException; +export 'src/common/utils.dart' show writeToFirebaseJson; +export 'src/firebase.dart' + show FirebaseCliConfig, globalFirebaseCliConfig; +export 'src/firebase/firebase_dart_configuration_write.dart' + show FirebaseDartConfigurationWrite; +export 'src/firebase/firebase_platform_options.dart' + show FirebasePlatformOptions, fetchAllFirebaseOptions; +export 'src/flutter_app.dart' show FlutterApp; diff --git a/packages/flutterfire_cli/lib/src/commands/config.dart b/packages/flutterfire_cli/lib/src/commands/config.dart index 5b2b5d19..ff125a94 100644 --- a/packages/flutterfire_cli/lib/src/commands/config.dart +++ b/packages/flutterfire_cli/lib/src/commands/config.dart @@ -565,6 +565,9 @@ class ConfigCommand extends FlutterFireCommand { Future run() async { // Has to set during `run()` otherwise `argResults` will be null updateDebugMode(argResults!['debug'] as bool); + // Set non-interactive mode if --yes flag was passed. + // This causes the CLI to fail with errors instead of prompting. + setNonInteractiveMode(yes); try { commandRequiresFlutterApp(); final reconfigured = await checkIfUserRequiresReconfigure(); diff --git a/packages/flutterfire_cli/lib/src/common/prompts/android_prompts.dart b/packages/flutterfire_cli/lib/src/common/prompts/android_prompts.dart index 70a70370..7a0cff91 100644 --- a/packages/flutterfire_cli/lib/src/common/prompts/android_prompts.dart +++ b/packages/flutterfire_cli/lib/src/common/prompts/android_prompts.dart @@ -29,7 +29,7 @@ String getAndroidServiceFile({ ); } } else { - if (isCI) { + if (shouldFailInsteadOfPrompt) { throw ValidationException( kAndroid, 'The path: $serviceFilePath is not a valid path for the Android `google-services.json` file. Please re-run the command with a valid path.', diff --git a/packages/flutterfire_cli/lib/src/common/prompts/apple_prompts.dart b/packages/flutterfire_cli/lib/src/common/prompts/apple_prompts.dart index 538f8c88..78704dd3 100644 --- a/packages/flutterfire_cli/lib/src/common/prompts/apple_prompts.dart +++ b/packages/flutterfire_cli/lib/src/common/prompts/apple_prompts.dart @@ -50,7 +50,7 @@ Future promptCheckBuildConfiguration( ); if (!buildConfigurations.contains(buildConfiguration)) { - if (isCI) { + if (shouldFailInsteadOfPrompt) { throw ValidationException( platform, 'The build configuration: $buildConfiguration does not exist. Please re-run the command with a valid build configuration.', @@ -97,7 +97,7 @@ Future promptCheckTarget(String target, String platform) async { await findTargetsAvailable(platform, getXcodeProjectPath(platform)); if (!targets.contains(target)) { - if (isCI) { + if (shouldFailInsteadOfPrompt) { throw ValidationException( platform, 'The target $target does not exist. Please re-run the command with a valid target.', diff --git a/packages/flutterfire_cli/lib/src/common/prompts/dart_file_prompts.dart b/packages/flutterfire_cli/lib/src/common/prompts/dart_file_prompts.dart index 382a6b7b..92acf0c4 100644 --- a/packages/flutterfire_cli/lib/src/common/prompts/dart_file_prompts.dart +++ b/packages/flutterfire_cli/lib/src/common/prompts/dart_file_prompts.dart @@ -43,7 +43,7 @@ bool promptWriteConfigurationFile({ final outputFile = File(configurationFilePath); final fileExists = outputFile.existsSync(); - if (!fileExists || isCI) { + if (!fileExists || shouldFailInsteadOfPrompt) { return true; } else { final shouldOverwrite = promptBool( diff --git a/packages/flutterfire_cli/lib/src/common/utils.dart b/packages/flutterfire_cli/lib/src/common/utils.dart index b99caf47..2f0e85c9 100644 --- a/packages/flutterfire_cli/lib/src/common/utils.dart +++ b/packages/flutterfire_cli/lib/src/common/utils.dart @@ -100,6 +100,23 @@ bool get isCI { return ci.isCI; } +/// Flag to indicate non-interactive mode (e.g., when --yes is passed). +/// When true, the CLI should fail with an error instead of prompting for input. +/// This is set by the config command when --yes flag is used. +bool _isNonInteractive = false; + +/// Sets the non-interactive mode flag. +/// Call this when --yes flag is passed to prevent interactive prompts. +void setNonInteractiveMode(bool value) { + _isNonInteractive = value; +} + +/// Returns true if the CLI should fail instead of prompting for input. +/// This is true when running in CI environment OR when --yes flag was passed. +bool get shouldFailInsteadOfPrompt { + return isCI || _isNonInteractive; +} + int get terminalWidth { if (stdout.hasTerminal) { return stdout.terminalColumns; diff --git a/packages/flutterfire_cli/lib/src/common/validation.dart b/packages/flutterfire_cli/lib/src/common/validation.dart index ce5ab4f7..a7234a6f 100644 --- a/packages/flutterfire_cli/lib/src/common/validation.dart +++ b/packages/flutterfire_cli/lib/src/common/validation.dart @@ -35,10 +35,10 @@ Future appleValidation({ } if (buildConfiguration != null && target != null) { - if (isCI) { + if (shouldFailInsteadOfPrompt) { throw ValidationException( platform, - 'Cannot set both target and build configuration in CI.', + 'Cannot set both target and build configuration in non-interactive mode (CI or --yes flag).', ); } // If user has set both, we need to find out which one they want to use @@ -56,10 +56,10 @@ Future appleValidation({ } if (serviceFilePath != null && target == null && buildConfiguration == null) { - if (isCI) { + if (shouldFailInsteadOfPrompt) { throw ValidationException( platform, - 'Cannot set service file path without a target or build configuration in CI.', + 'Cannot set service file path without a target or build configuration in non-interactive mode (CI or --yes flag).', ); } // If user has set serviceFilePath, but not a config type, we need to find out which one they want to use @@ -143,10 +143,10 @@ DartConfigurationFileInputs dartConfigurationFileValidation({ configurationFilePath: configurationFilePath, flutterAppPath: flutterAppPath, ); - if (isCI && !overwrite) { + if (shouldFailInsteadOfPrompt && !overwrite) { throw ValidationException( kDart, - 'Required to overwrite Dart Firebase options configuration file in CI. Use `--overwrite-firebase-options` or `--yes` flag.', + 'Required to overwrite Dart Firebase options configuration file in non-interactive mode (CI or --yes flag). Use `--overwrite-firebase-options` or `--yes` flag.', ); } final writeConfigurationFile = overwrite || diff --git a/packages/flutterfire_cli/lib/src/firebase.dart b/packages/flutterfire_cli/lib/src/firebase.dart index f282ce69..608db7cc 100644 --- a/packages/flutterfire_cli/lib/src/firebase.dart +++ b/packages/flutterfire_cli/lib/src/firebase.dart @@ -28,16 +28,62 @@ import 'common/utils.dart'; import 'firebase/firebase_app.dart'; import 'firebase/firebase_project.dart'; +/// Configuration for customizing the Firebase CLI executable. +/// +/// This allows using a custom Firebase CLI installation (e.g., a forked version +/// or running via node directly) instead of the default 'firebase' command. +/// +/// Example usage with node: +/// ```dart +/// globalFirebaseCliConfig = FirebaseCliConfig( +/// executable: 'node', +/// baseArgs: ['/path/to/firebase.js'], +/// ); +/// ``` +class FirebaseCliConfig { + const FirebaseCliConfig({ + this.executable = 'firebase', + this.baseArgs = const [], + this.environment = const {}, + this.workingDirectory, + }); + + /// The executable to run (e.g., 'firebase', 'node', 'npx'). + final String executable; + + /// Base arguments to prepend to all commands (e.g., ['/path/to/firebase.js']). + final List baseArgs; + + /// Additional environment variables to set when running commands. + final Map environment; + + /// The working directory for commands. If null, uses Directory.current. + final String? workingDirectory; + + /// Default configuration using the standard 'firebase' CLI. + static const FirebaseCliConfig defaultConfig = FirebaseCliConfig(); +} + +/// Global configuration for the Firebase CLI. +/// Set this before calling any Firebase functions to use a custom CLI. +FirebaseCliConfig? globalFirebaseCliConfig; + /// Simple check to verify Firebase Tools CLI is installed. bool? _existsCache; Future exists() async { if (_existsCache != null) { return _existsCache!; } + final config = globalFirebaseCliConfig; + final executable = config?.executable ?? 'firebase'; + final args = [...?config?.baseArgs, '--version']; + final process = await Process.run( - 'firebase', - ['--version'], + executable, + args, runInShell: true, + environment: config?.environment, + workingDirectory: config?.workingDirectory, ); return _existsCache = process.exitCode == 0; } @@ -79,8 +125,12 @@ Future> runFirebaseCommand( logMissingFirebaseCli, ); } - final workingDirectoryPath = Directory.current.path; + final config = globalFirebaseCliConfig; + final executable = config?.executable ?? 'firebase'; + final workingDirectoryPath = + config?.workingDirectory ?? Directory.current.path; final execArgs = [ + ...?config?.baseArgs, ...commandAndArgs, '--json', if (project != null) '--project=$project', @@ -90,10 +140,11 @@ Future> runFirebaseCommand( ProcessResult process; try { process = await Process.run( - 'firebase', + executable, execArgs, workingDirectory: workingDirectoryPath, environment: { + ...?config?.environment, if (serviceAccount != null) 'GOOGLE_APPLICATION_CREDENTIALS': serviceAccount, }, diff --git a/packages/flutterfire_cli/lib/src/firebase/firebase_android_options.dart b/packages/flutterfire_cli/lib/src/firebase/firebase_android_options.dart index a66e7c99..1ceea3f9 100644 --- a/packages/flutterfire_cli/lib/src/firebase/firebase_android_options.dart +++ b/packages/flutterfire_cli/lib/src/firebase/firebase_android_options.dart @@ -44,10 +44,11 @@ extension FirebaseAndroidOptions on FirebaseOptions { var selectedAndroidApplicationId = androidApplicationId ?? flutterApp.androidApplicationId; - if (isCI && selectedAndroidApplicationId == null) { + if (shouldFailInsteadOfPrompt && selectedAndroidApplicationId == null) { throw ValidationException( kAndroid, - 'A valid application package name is required when configuring on CI. Please use the `--android-package-name` flag', + 'A valid application package name is required in non-interactive mode (CI or --yes flag). ' + 'Please use the `--android-package-name` flag to specify the Android package name.', ); } selectedAndroidApplicationId ??= promptInput( diff --git a/packages/flutterfire_cli/lib/src/firebase/firebase_apple_options.dart b/packages/flutterfire_cli/lib/src/firebase/firebase_apple_options.dart index 36debd88..523e1a02 100644 --- a/packages/flutterfire_cli/lib/src/firebase/firebase_apple_options.dart +++ b/packages/flutterfire_cli/lib/src/firebase/firebase_apple_options.dart @@ -38,13 +38,13 @@ extension FirebaseAppleOptions on FirebaseOptions { var selectedAppleBundleId = appleBundleIdentifier ?? (macos ? flutterApp.macosBundleId : flutterApp.iosBundleId); - if (isCI && selectedAppleBundleId == null) { + if (shouldFailInsteadOfPrompt && selectedAppleBundleId == null) { final flag = platformIdentifier == kMacos ? '--macos-bundle-id' : '--ios-bundle-id'; throw ValidationException( platformIdentifier, - 'A valid bundle Id is required when configuring on CI. Please use the $flag flag.', + 'A valid bundle Id is required in non-interactive mode (CI or --yes flag). Please use the $flag flag.', ); } selectedAppleBundleId ??= promptInput(