diff --git a/.gitignore b/.gitignore index 6b1cefc3c..1248a5d30 100644 --- a/.gitignore +++ b/.gitignore @@ -43,4 +43,4 @@ mason-lock.json .cursor/ # FVM Version Cache -.fvm/ \ No newline at end of file +.fvm/ diff --git a/docs/concepts/custom_actions.mdx b/docs/concepts/custom_actions.mdx index cab8187b0..21304dd30 100644 --- a/docs/concepts/custom_actions.mdx +++ b/docs/concepts/custom_actions.mdx @@ -23,7 +23,7 @@ Custom actions enable you to: Before creating a custom action, ensure you have: -1. **Dependencies**: `stac_core` and `json_annotation` packages +1. **Dependencies**: `stac` and `json_annotation` packages 2. **Code Generation**: `build_runner` for generating JSON serialization code 3. **Parser**: A corresponding action parser to execute your action. @@ -35,8 +35,7 @@ Create a new file (e.g., `lib/actions/stac_share_action.dart`) and define your a ```dart import 'package:json_annotation/json_annotation.dart'; -import 'package:stac_core/core/stac_action.dart'; -import 'package:stac_core/foundation/specifications/action_type.dart'; +import 'package:stac/stac_core.dart'; part 'stac_share_action.g.dart'; @@ -188,7 +187,7 @@ Similar to widgets, actions can use converters for special types: For `double` fields that may come as integers in JSON: ```dart -import 'package:stac_core/core/converters/double_converter.dart'; +import 'package:stac/stac_core.dart'; @JsonSerializable() class StacCustomAction extends StacAction { @@ -211,7 +210,7 @@ class StacCustomAction extends StacAction { **In Dart (stac/ folder)** ```dart -import 'package:stac_core/stac_core.dart'; +import 'package:stac/stac_core.dart'; import 'package:your_app/actions/stac_share_action.dart'; @StacScreen(screenName: 'article') diff --git a/docs/concepts/custom_widgets.mdx b/docs/concepts/custom_widgets.mdx index 9c0300073..7e4da90d8 100644 --- a/docs/concepts/custom_widgets.mdx +++ b/docs/concepts/custom_widgets.mdx @@ -23,7 +23,7 @@ Custom widgets enable you to: Before creating a custom widget, ensure you have: -1. **Dependencies**: `stac_core` and `json_annotation` packages +1. **Dependencies**: `stac` and `json_annotation` packages 2. **Code Generation**: `build_runner` for generating JSON serialization code 3. **Parser**: A corresponding parser to render your widget. @@ -35,8 +35,7 @@ Create a new file (e.g., `lib/widgets/stac_custom_badge.dart`) and define your w ```dart import 'package:json_annotation/json_annotation.dart'; -import 'package:stac_core/core/stac_widget.dart'; -import 'package:stac_core/foundation/foundation.dart'; +import 'package:stac/stac_core.dart'; part 'stac_custom_badge.g.dart'; @@ -182,7 +181,7 @@ Stac provides converters for special types. Use them when needed: For `double` fields that may come as integers in JSON: ```dart -import 'package:stac_core/core/converters/double_converter.dart'; +import 'package:stac/stac_core.dart'; @JsonSerializable() class StacCustomWidget extends StacWidget { @@ -205,7 +204,7 @@ class StacCustomWidget extends StacWidget { For child widgets in your custom widget: ```dart -import 'package:stac_core/core/converters/stac_widget_converter.dart'; +import 'package:stac/stac_core.dart'; @JsonSerializable(explicitToJson: true) class StacCustomContainer extends StacWidget { @@ -249,7 +248,7 @@ class StacCustomWidget extends StacWidget { **In Dart (stac/ folder)** ```dart -import 'package:stac_core/stac_core.dart'; +import 'package:stac/stac_core.dart'; import 'package:your_app/widgets/stac_custom_badge.dart'; @StacScreen(screenName: 'profile') diff --git a/docs/concepts/rendering_stac_widgets.mdx b/docs/concepts/rendering_stac_widgets.mdx index b405ae4e3..692175c99 100644 --- a/docs/concepts/rendering_stac_widgets.mdx +++ b/docs/concepts/rendering_stac_widgets.mdx @@ -40,7 +40,7 @@ The `home_screen` is defined in your `/stac` folder as a Dart file. Here's an ex **`stac/home_screen.dart`:** ```dart -import 'package:stac_core/stac_core.dart'; +import 'package:stac/stac_core.dart'; @StacScreen(screenName: 'home_screen') StacWidget homeScreen() { @@ -268,7 +268,6 @@ Stac.fromNetwork( ```dart import 'package:flutter/material.dart'; import 'package:stac/stac.dart'; -import 'package:stac_core/actions/network_request/stac_network_request.dart'; class ApiDrivenScreen extends StatelessWidget { const ApiDrivenScreen({super.key}); diff --git a/docs/concepts/theming.mdx b/docs/concepts/theming.mdx index e97589c0d..8110473b5 100644 --- a/docs/concepts/theming.mdx +++ b/docs/concepts/theming.mdx @@ -60,7 +60,7 @@ Here's the complete workflow: **Step 1: Define your theme** in `stac/app_theme.dart`: ```dart -import 'package:stac_core/stac_core.dart'; +import 'package:stac/stac_core.dart'; @StacThemeRef(name: "movie_app_dark") StacTheme get darkTheme => _buildTheme( diff --git a/docs/dsl.mdx b/docs/dsl.mdx index 6616aa40b..0b2eb0653 100644 --- a/docs/dsl.mdx +++ b/docs/dsl.mdx @@ -53,7 +53,7 @@ flutter_project/ Screens are defined in the `stac/screens/` folder (or directly in `stac/`). Each screen is a Dart function annotated with `@StacScreen`: ```dart -import 'package:stac_core/stac_core.dart'; +import 'package:stac/stac_core.dart'; @StacScreen(screenName: 'home_screen') StacWidget homeScreen() { @@ -79,7 +79,7 @@ StacWidget homeScreen() { Themes are defined in the `stac/theme/` folder using the `@StacThemeRef` annotation: ```dart -import 'package:stac_core/stac_core.dart'; +import 'package:stac/stac_core.dart'; @StacThemeRef(name: "app_theme") StacTheme get appTheme => StacTheme( @@ -189,7 +189,7 @@ stac deploy --verbose Here's a complete example showing a screen with navigation, network requests, and theming: ```dart -import 'package:stac_core/stac_core.dart'; +import 'package:stac/stac_core.dart'; @StacScreen(screenName: 'movie_detail') StacWidget movieDetailScreen() { diff --git a/docs/guides/migration.mdx b/docs/guides/migration.mdx index 28eddb5aa..6722d4295 100644 --- a/docs/guides/migration.mdx +++ b/docs/guides/migration.mdx @@ -247,7 +247,7 @@ padding: StacEdgeInsets.symmetric(horizontal: 16.0, vertical: 8.0) **After (Dart file: `stac/home_screen.dart`):** ```dart -import 'package:stac_core/stac_core.dart'; +import 'package:stac/stac_core.dart'; @StacScreen(screenName: 'home_screen') StacWidget homeScreen() { diff --git a/docs/guides/troubleshooting.mdx b/docs/guides/troubleshooting.mdx index 62df62bd2..15ce955f6 100644 --- a/docs/guides/troubleshooting.mdx +++ b/docs/guides/troubleshooting.mdx @@ -214,7 +214,7 @@ results: [ 1. **Missing imports:** ```dart -import 'package:stac_core/stac_core.dart'; // Add this +import 'package:stac/stac_core.dart'; // Add this ``` 2. **Trailing commas:** Dart benefits from trailing commas: diff --git a/docs/project_structure.mdx b/docs/project_structure.mdx index b9313f6ea..12c3bc55b 100644 --- a/docs/project_structure.mdx +++ b/docs/project_structure.mdx @@ -26,7 +26,7 @@ This guide explains the overall structure of a Stac project. It covers where the Contains the `StacOptions` configuration for your project, including project name and ID. ```dart -import 'package:stac_core/stac_core.dart'; +import 'package:stac/stac_core.dart'; StacOptions get defaultStacOptions => StacOptions( name: 'your-project-name', diff --git a/docs/quickstart.mdx b/docs/quickstart.mdx index bfd5a5467..8e5c74967 100644 --- a/docs/quickstart.mdx +++ b/docs/quickstart.mdx @@ -103,7 +103,6 @@ We'll select "Create a new project". Name it `stac demo` and press enter. Stac will now initialize the project and you'll see a message saying ```bash -[SUCCESS] ✓ Added dependency: stac_core [SUCCESS] ✓ Project initialized successfully! [INFO] Next steps: [INFO] 1. Add your Stac widgets definitions to /stac @@ -113,7 +112,7 @@ Stac will now initialize the project and you'll see a message saying `stac init` will add the following to your project: - `stac/hello_world.dart` – A Hello World example widget. All your Stac widgets live in the `stac` folder. -- Adds `stac` and `stac_core` to your `pubspec.yaml`. +- Adds `stac` to your `pubspec.yaml`. - Creates `default_stac_options.dart`, which defines your `StacOptions` (e.g., project name and ID). @@ -122,7 +121,7 @@ Stac will now initialize the project and you'll see a message saying Head over to `stac/hello_world.dart` and build the widget. Use the `@StacScreen` annotation to mark the widget as a screen. ```dart -import 'package:stac_core/stac_core.dart'; +import 'package:stac/stac_core.dart'; @StacScreen(screenName: 'hello_world') StacWidget helloWorld() { diff --git a/examples/movie_app/lib/default_stac_options.dart b/examples/movie_app/lib/default_stac_options.dart index 84f35bb34..6e62e90b4 100644 --- a/examples/movie_app/lib/default_stac_options.dart +++ b/examples/movie_app/lib/default_stac_options.dart @@ -1,6 +1,6 @@ // This file is automatically generated by stac init. -import 'package:stac/stac.dart'; +import 'package:stac/stac_core.dart'; /// Default [StacOptions] for use with your stac project. /// diff --git a/examples/movie_app/lib/widgets/movie_carousel/movie_carousel.dart b/examples/movie_app/lib/widgets/movie_carousel/movie_carousel.dart index b2edb271f..19ddf04ca 100644 --- a/examples/movie_app/lib/widgets/movie_carousel/movie_carousel.dart +++ b/examples/movie_app/lib/widgets/movie_carousel/movie_carousel.dart @@ -1,5 +1,5 @@ import 'package:json_annotation/json_annotation.dart'; -import 'package:stac/stac.dart'; +import 'package:stac/stac_core.dart'; part 'movie_carousel.g.dart'; diff --git a/examples/movie_app/pubspec.yaml b/examples/movie_app/pubspec.yaml index a8e0bacb2..69d4082b7 100644 --- a/examples/movie_app/pubspec.yaml +++ b/examples/movie_app/pubspec.yaml @@ -34,7 +34,7 @@ dependencies: # The following adds the Cupertino Icons font to your application. # Use with the CupertinoIcons class for iOS style icons. cupertino_icons: ^1.0.8 - stac: + stac: ^1.3.1 dio: ^5.8.0+1 smooth_page_indicator: ^1.2.1 diff --git a/packages/stac_cli/.gitignore b/packages/stac_cli/.gitignore new file mode 100644 index 000000000..f4a0ed0a6 --- /dev/null +++ b/packages/stac_cli/.gitignore @@ -0,0 +1,21 @@ +# https://dart.dev/guides/libraries/private-files +# Created by `dart pub` +.dart_tool/ +.packages +build/ + +# Environment / secrets +.env +.env.dev +.env.local +.env.*.local + +# Melos local overrides +pubspec_overrides.yaml + +# IDE +.idea/ +*.iml + +# OS +.DS_Store \ No newline at end of file diff --git a/packages/stac_cli/LICENSE b/packages/stac_cli/LICENSE new file mode 100644 index 000000000..bdc7bbd7c --- /dev/null +++ b/packages/stac_cli/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2025 Stac + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/packages/stac_cli/README.md b/packages/stac_cli/README.md new file mode 100644 index 000000000..02f9ba5fd --- /dev/null +++ b/packages/stac_cli/README.md @@ -0,0 +1,36 @@ +# stac_cli + +Official CLI for managing Stac SDUI projects. + +## Install + +```bash +dart pub global activate --source path . +``` + +## Quick start + +```bash +stac --version +stac login +stac init +stac build +stac deploy +``` + +## Environment + +The CLI reads credentials from: + +- `~/.stac/.env` (prod) +- `~/.stac/.env.dev` (dev) + +Required keys: + +- `STAC_BASE_API_URL` +- `STAC_GOOGLE_CLIENT_ID` +- `STAC_GOOGLE_CLIENT_SECRET` (optional) +- `STAC_FIREBASE_API_KEY` + +Set environment in code via `currentEnvironment` in `lib/src/config/env.dart`. + diff --git a/packages/stac_cli/analysis_options.yaml b/packages/stac_cli/analysis_options.yaml new file mode 100644 index 000000000..dee8927aa --- /dev/null +++ b/packages/stac_cli/analysis_options.yaml @@ -0,0 +1,30 @@ +# This file configures the static analysis results for your project (errors, +# warnings, and lints). +# +# This enables the 'recommended' set of lints from `package:lints`. +# This set helps identify many issues that may lead to problems when running +# or consuming Dart code, and enforces writing Dart using a single, idiomatic +# style and format. +# +# If you want a smaller set of lints you can change this to specify +# 'package:lints/core.yaml'. These are just the most critical lints +# (the recommended set includes the core lints). +# The core lints are also what is used by pub.dev for scoring packages. + +include: package:lints/recommended.yaml + +# Uncomment the following section to specify additional rules. + +# linter: +# rules: +# - camel_case_types + +# analyzer: +# exclude: +# - path/to/excluded/files/** + +# For more information about the core and recommended set of lints, see +# https://dart.dev/go/core-lints + +# For additional information about configuring this file, see +# https://dart.dev/guides/language/analysis-options diff --git a/packages/stac_cli/bin/stac_cli.dart b/packages/stac_cli/bin/stac_cli.dart new file mode 100644 index 000000000..aabbf704c --- /dev/null +++ b/packages/stac_cli/bin/stac_cli.dart @@ -0,0 +1,114 @@ +import 'dart:io'; + +import 'package:args/command_runner.dart'; +import 'package:dotenv/dotenv.dart'; +import 'package:stac_cli/src/commands/auth/login_command.dart'; +import 'package:stac_cli/src/commands/auth/logout_command.dart'; +import 'package:stac_cli/src/commands/auth/status_command.dart'; +import 'package:stac_cli/src/commands/build_command.dart'; +import 'package:stac_cli/src/commands/deploy_command.dart'; +import 'package:stac_cli/src/commands/init_command.dart'; +import 'package:stac_cli/src/commands/project_command.dart'; +import 'package:stac_cli/src/commands/upgrade_command.dart'; +import 'package:stac_cli/src/config/env.dart'; +import 'package:stac_cli/src/exceptions/stac_exception.dart'; +import 'package:stac_cli/src/services/config_service.dart'; +import 'package:stac_cli/src/utils/console_logger.dart'; +import 'package:stac_cli/src/utils/file_utils.dart'; +import 'package:stac_cli/src/version.dart'; + +String get version => currentEnvironment == Environment.dev + ? '$packageVersion-dev' + : packageVersion; + +const _dotenvKeys = [ + 'STAC_BASE_API_URL', + 'STAC_GOOGLE_CLIENT_ID', + 'STAC_GOOGLE_CLIENT_SECRET', + 'STAC_FIREBASE_API_KEY', +]; + +Map _loadDotEnvOverrides() { + final dotEnv = DotEnv(quiet: true); + final configDir = FileUtils.configDirectory; + switch (currentEnvironment) { + case Environment.dev: + dotEnv.load(['$configDir/.env.dev']); + break; + case Environment.prod: + dotEnv.load(['$configDir/.env']); + break; + } + + final overrides = {}; + + for (final key in _dotenvKeys) { + final value = dotEnv[key]; + if (value != null && value.trim().isNotEmpty) { + overrides[key] = value; + } + } + + return overrides; +} + +void main(List arguments) async { + configureEnvironment(_loadDotEnvOverrides()); + + // Initialize configuration service + await ConfigService.instance.initialize(); + + final runner = + CommandRunner('stac', 'Stac CLI - Manage your Stac SDUI projects') + ..addCommand(LoginCommand()) + ..addCommand(LogoutCommand()) + ..addCommand(StatusCommand()) + ..addCommand(InitCommand()) + ..addCommand(ProjectCommand()) + ..addCommand(BuildCommand()) + ..addCommand(DeployCommand()) + ..addCommand(UpgradeCommand()); + + // Add global flags + runner.argParser.addFlag( + 'verbose', + abbr: 'v', + negatable: false, + help: 'Show additional command output.', + ); + + runner.argParser.addFlag( + 'version', + negatable: false, + help: 'Print the tool version.', + ); + + try { + // Parse arguments and check for global flags + final argResults = runner.argParser.parse(arguments); + + if (argResults['version'] as bool) { + print('stac_cli version: $version'); + exit(0); + } + + if (argResults['verbose'] as bool) { + ConsoleLogger.setVerbose(true); + } + + // Run the command + final exitCode = await runner.run(arguments) ?? 0; + exit(exitCode); + } on UsageException catch (e) { + ConsoleLogger.error(e.message); + print(''); + print(e.usage); + exit(1); + } on StacException catch (e) { + ConsoleLogger.error(e.message); + exit(e.exitCode ?? 1); + } catch (e) { + ConsoleLogger.error('Unexpected error: $e'); + exit(1); + } +} diff --git a/packages/stac_cli/lib/src/commands/auth/login_command.dart b/packages/stac_cli/lib/src/commands/auth/login_command.dart new file mode 100644 index 000000000..1528ffc8e --- /dev/null +++ b/packages/stac_cli/lib/src/commands/auth/login_command.dart @@ -0,0 +1,32 @@ +import '../../services/auth_service.dart'; +import '../base_command.dart'; + +/// Command for Google OAuth login +class LoginCommand extends BaseCommand { + final AuthService _authService = AuthService(); + + @override + String get name => 'login'; + + @override + String get description => + 'Authenticate with Google OAuth for Stac cloud services'; + + @override + bool get requiresAuth => false; + + LoginCommand() { + argParser.addFlag( + 'interactive', + abbr: 'i', + help: 'Use interactive login flow (default)', + defaultsTo: true, + ); + } + + @override + Future execute() async { + await _authService.login(); + return 0; + } +} diff --git a/packages/stac_cli/lib/src/commands/auth/logout_command.dart b/packages/stac_cli/lib/src/commands/auth/logout_command.dart new file mode 100644 index 000000000..03cffab56 --- /dev/null +++ b/packages/stac_cli/lib/src/commands/auth/logout_command.dart @@ -0,0 +1,22 @@ +import '../../services/auth_service.dart'; +import '../base_command.dart'; + +/// Command for logging out and clearing stored tokens +class LogoutCommand extends BaseCommand { + final AuthService _authService = AuthService(); + + @override + String get name => 'logout'; + + @override + String get description => 'Clear stored authentication tokens and log out'; + + @override + bool get requiresAuth => false; + + @override + Future execute() async { + await _authService.logout(); + return 0; + } +} diff --git a/packages/stac_cli/lib/src/commands/auth/status_command.dart b/packages/stac_cli/lib/src/commands/auth/status_command.dart new file mode 100644 index 000000000..018ab32ca --- /dev/null +++ b/packages/stac_cli/lib/src/commands/auth/status_command.dart @@ -0,0 +1,22 @@ +import '../../services/auth_service.dart'; +import '../base_command.dart'; + +/// Command for checking authentication status +class StatusCommand extends BaseCommand { + final AuthService _authService = AuthService(); + + @override + String get name => 'status'; + + @override + String get description => 'Show current authentication status'; + + @override + bool get requiresAuth => false; + + @override + Future execute() async { + await _authService.status(); + return 0; + } +} diff --git a/packages/stac_cli/lib/src/commands/base_command.dart b/packages/stac_cli/lib/src/commands/base_command.dart new file mode 100644 index 000000000..5e39cdb57 --- /dev/null +++ b/packages/stac_cli/lib/src/commands/base_command.dart @@ -0,0 +1,64 @@ +import 'package:args/command_runner.dart'; + +import '../exceptions/auth_exception.dart'; +import '../exceptions/stac_exception.dart'; +import '../services/auth_service.dart'; +import '../utils/console_logger.dart'; + +/// Base class for all Stac CLI commands +abstract class BaseCommand extends Command { + final AuthService _authService = AuthService(); + + /// Whether this command requires authentication + bool get requiresAuth => false; + + /// Whether this command requires a Stac project + bool get requiresProject => false; + + @override + Future run() async { + try { + // Check authentication if required + if (requiresAuth) { + await _checkAuthentication(); + } + + // Run the actual command + return await execute(); + } on StacException catch (e) { + ConsoleLogger.error(e.message); + return e.exitCode ?? 1; + } catch (e) { + ConsoleLogger.error('Unexpected error: $e'); + return 1; + } + } + + /// Execute the command logic + Future execute(); + + /// Check if user is authenticated (and refresh token if needed) + Future _checkAuthentication() async { + try { + // Try to refresh the token if needed + final token = await _authService.refreshTokenIfNeeded(); + if (token == null) { + throw const NotAuthenticatedException(); + } + } catch (e) { + if (e is StacException) { + throw const NotAuthenticatedException(); + } + rethrow; + } + } + + /// Helper to get verbose flag from global results + bool get verbose { + final results = globalResults; + if (results != null && results.options.contains('verbose')) { + return results['verbose'] as bool? ?? false; + } + return false; + } +} diff --git a/packages/stac_cli/lib/src/commands/build_command.dart b/packages/stac_cli/lib/src/commands/build_command.dart new file mode 100644 index 000000000..845284614 --- /dev/null +++ b/packages/stac_cli/lib/src/commands/build_command.dart @@ -0,0 +1,49 @@ +import 'base_command.dart'; +import '../services/build_service.dart'; +import '../utils/console_logger.dart'; + +/// Command for building Dart to JSON +class BuildCommand extends BaseCommand { + final BuildService _buildService = BuildService(); + + @override + String get name => 'build'; + + @override + String get description => + 'Convert Dart widget definitions to JSON for Stac SDUI'; + + @override + bool get requiresProject => true; + + BuildCommand() { + argParser.addOption( + 'project', + abbr: 'p', + help: 'Project directory (defaults to current directory)', + ); + + argParser.addFlag( + 'validate', + help: 'Validate generated JSON (enabled by default)', + defaultsTo: true, + ); + } + + @override + Future execute() async { + final projectPath = argResults?['project'] as String?; + + if (verbose) { + ConsoleLogger.debug('Starting build process...'); + } + + try { + await _buildService.build(projectPath: projectPath); + return 0; + } catch (e) { + ConsoleLogger.error('Build failed: $e'); + return 1; + } + } +} diff --git a/packages/stac_cli/lib/src/commands/deploy_command.dart b/packages/stac_cli/lib/src/commands/deploy_command.dart new file mode 100644 index 000000000..5d294f164 --- /dev/null +++ b/packages/stac_cli/lib/src/commands/deploy_command.dart @@ -0,0 +1,68 @@ +import '../services/build_service.dart'; +import '../services/deploy_service.dart'; +import '../utils/console_logger.dart'; +import 'base_command.dart'; + +/// Command for deploying JSON files to the cloud +class DeployCommand extends BaseCommand { + final BuildService _buildService = BuildService(); + final DeployService _deployService = DeployService(); + + @override + String get name => 'deploy'; + + @override + String get description => 'Deploy Stac widgets to the cloud'; + + @override + bool get requiresAuth => true; + + @override + bool get requiresProject => true; + + DeployCommand() { + argParser.addOption( + 'project', + abbr: 'p', + help: 'Project directory (defaults to current directory)', + ); + argParser.addFlag( + 'skip-build', + help: 'Skip building before deployment (deploy existing files)', + negatable: false, + ); + } + + @override + Future execute() async { + final projectPath = argResults?['project'] as String?; + final skipBuild = argResults?['skip-build'] as bool? ?? false; + + try { + // Build before deploying unless --skip-build is specified + if (!skipBuild) { + ConsoleLogger.info('Building project before deployment...'); + + try { + await _buildService.build(projectPath: projectPath); + ConsoleLogger.info('Build completed. Starting deployment...'); + } catch (buildError) { + ConsoleLogger.error('Build failed, aborting deployment.'); + ConsoleLogger.error('Error: $buildError'); + return 1; + } + } else { + if (verbose) { + ConsoleLogger.debug('Skipping build, deploying existing files...'); + } + } + + // Deploy the built files + await _deployService.deploy(projectPath: projectPath); + return 0; + } catch (e) { + ConsoleLogger.error('Deployment failed: $e'); + return 1; + } + } +} diff --git a/packages/stac_cli/lib/src/commands/init_command.dart b/packages/stac_cli/lib/src/commands/init_command.dart new file mode 100644 index 000000000..3b40a19e6 --- /dev/null +++ b/packages/stac_cli/lib/src/commands/init_command.dart @@ -0,0 +1,287 @@ +import 'dart:io'; + +import 'package:interact/interact.dart'; +import 'package:path/path.dart' as path; +import 'package:stac_cli/src/models/project/project.dart'; + +import '../services/project_service.dart'; +import '../utils/console_logger.dart'; +import '../utils/file_utils.dart'; +import 'base_command.dart'; + +/// Command for initializing a Stac project from cloud projects +class InitCommand extends BaseCommand { + final ProjectService _projectService = ProjectService(); + + @override + String get name => 'init'; + + @override + String get description => 'Initialize a Stac project'; + + @override + bool get requiresAuth => true; + + InitCommand() { + argParser.addOption( + 'directory', + abbr: 'd', + help: 'Target directory (defaults to current directory)', + ); + } + + @override + Future execute() async { + final targetDir = + argResults?['directory'] as String? ?? Directory.current.path; + + ConsoleLogger.printStacAscii(); + ConsoleLogger.info( + 'Initializing Stac project in this directory: \n $targetDir', + ); + // Ensure target directory exists + final dir = Directory(targetDir); + if (!await dir.exists()) { + await dir.create(recursive: true); + } + + // If already initialized, ask before overwriting + if (await _isAlreadyInitialized(targetDir)) { + final overwrite = Confirm( + prompt: + 'This project already has stac set up. Do you want to overwrite it?', + defaultValue: false, + ).interact(); + if (!overwrite) { + ConsoleLogger.info('Skipped. No changes made.'); + return 0; + } + } + + // Select or create project + Project? project = await _selectOrCreateProjectInteractively(); + if (project == null) { + return 0; + } + + ConsoleLogger.info('Initializing project: ${project.name}'); + + // Ask before adding dependency + final shouldAdd = Confirm( + prompt: 'Add stac dependency to pubspec.yaml?', + defaultValue: true, + ).interact(); + if (shouldAdd) { + // Add stac to pubspec.yaml + await _addStacToPubspecYaml(targetDir); + } else { + ConsoleLogger.info('Skipped adding stac dependency.'); + } + + // Create stac folder with hello world file + await _createStacFolder(targetDir); + + // Create default_stac_options.dart configuration file + await _createStacConfigFile(targetDir, project); + + ConsoleLogger.success('✓ Project initialized successfully!'); + ConsoleLogger.info('Next steps:'); + ConsoleLogger.info(' 1. Add your Stac widgets definitions to /stac'); + ConsoleLogger.info(' 2. Run "stac build" to convert Dart to JSON'); + ConsoleLogger.info(' 3. Run "stac deploy" to deploy to cloud'); + + return 0; + } + + /// Returns true if the directory already has stac init artifacts. + Future _isAlreadyInitialized(String targetDir) async { + final stacDir = Directory(path.join(targetDir, 'stac')); + final optionsFile = File( + path.join(targetDir, 'lib', 'default_stac_options.dart'), + ); + return await stacDir.exists() || await optionsFile.exists(); + } + + /// Add stac to pubspec.yaml + Future _addStacToPubspecYaml(String targetDir) async { + final pubspecPath = path.join(targetDir, 'pubspec.yaml'); + if (!await File(pubspecPath).exists()) { + ConsoleLogger.error( + 'pubspec.yaml not found in $targetDir. Please run this in a Flutter/Dart project directory.', + ); + throw Exception('pubspec.yaml not found'); + } + + try { + var code = await _run('flutter', ['pub', 'add', 'stac'], targetDir); + if (code != 0) { + ConsoleLogger.warning( + 'flutter pub add failed (exit $code). Trying dart pub add...', + ); + code = await _run('dart', ['pub', 'add', 'stac'], targetDir); + if (code != 0) { + throw Exception('dart pub add stac failed with exit $code'); + } + } + ConsoleLogger.success('✓ Added dependency: stac'); + } on ProcessException catch (e) { + // flutter may not be on PATH; try dart as fallback + ConsoleLogger.warning( + 'Failed to run flutter: ${e.message}. Trying dart.', + ); + try { + final code = await _run('dart', ['pub', 'add', 'stac'], targetDir); + if (code != 0) { + throw Exception('dart pub add stac failed with exit $code'); + } + ConsoleLogger.success('✓ Added dependency: stac'); + } on ProcessException { + // Both flutter and dart commands failed + ConsoleLogger.error('Failed to manually add dependency: $e'); + ConsoleLogger.info( + 'Please manually add stac to your dependencies in pubspec.yaml', + ); + } + } + } + + // Prefer flutter pub add, fallback to dart pub add + Future _run( + String executable, + List args, + String targetDir, + ) async { + ConsoleLogger.info('Running: $executable ${args.join(' ')}'); + + try { + final result = await Process.run( + executable, + args, + workingDirectory: targetDir, + runInShell: Platform + .isWindows, // Use shell on Windows for proper PATH resolution + ); + + if ((result.stdout as Object?).toString().isNotEmpty) { + ConsoleLogger.plain(result.stdout.toString()); + } + if ((result.stderr as Object?).toString().isNotEmpty) { + ConsoleLogger.plain(result.stderr.toString()); + } + return result.exitCode; + } on ProcessException { + rethrow; + } + } + + /// Create stac folder with hello world file + Future _createStacFolder(String targetDir) async { + final stacFolderPath = path.join(targetDir, 'stac'); + await Directory(stacFolderPath).create(recursive: true); + + // Create hello world file + final helloWorldPath = path.join(stacFolderPath, 'hello_world.dart'); + await FileUtils.writeFile(helloWorldPath, ''' +import 'package:stac/stac_core.dart'; + +@StacScreen(screenName: "hello_world") +StacWidget helloWorld() { + return StacScaffold( + body: StacCenter( + child: StacText(data: 'Hello, world!'), + ), + ); +} +'''); + } + + /// Create stac config file + Future _createStacConfigFile(String targetDir, Project project) async { + final stacConfigPath = path.join( + targetDir, + 'lib/default_stac_options.dart', + ); + + final dartConfig = + ''' +// This file is automatically generated by stac init. + +import 'package:stac/stac_core.dart'; + +/// Default [StacOptions] for use with your stac project. +/// +/// Use this to initialize stac **before** calling [runApp]. +/// +/// Example: +/// ```dart +/// import 'package:flutter/material.dart'; +/// import 'package:stac/stac.dart'; +/// import 'default_stac_options.dart'; +/// +/// void main() { +/// Stac.initialize(options: defaultStacOptions); +/// +/// runApp(...); +/// } +/// ``` +StacOptions get defaultStacOptions => StacOptions( + name: '${project.name}', + description: '${project.description}', + projectId: '${project.id}', +); +'''; + + await FileUtils.writeFile(stacConfigPath, dartConfig); + } + + /// Offer a small menu to either use an existing cloud project or create a new one + Future _selectOrCreateProjectInteractively() async { + final selection = Select( + prompt: 'Please select an option:', + options: const [ + 'Use an existing project', + 'Create a new project', + "Don't set up a default project", + ], + ).interact(); + + switch (selection) { + case 0: + return await _projectService.selectProjectInteractively(); + case 1: + final name = Input( + prompt: 'Enter project name:', + validator: (String input) { + if (input.trim().isEmpty) { + throw ValidationError('Project name is required'); + } + return true; + }, + ).interact(); + + final description = Input( + prompt: 'Enter project description (optional):', + defaultValue: '', + ).interact(); + try { + return await _projectService.createProject( + name: name.trim(), + description: description.trim(), + ); + } catch (e) { + // Graceful fallback when creation is not implemented on server side + ConsoleLogger.error("Failed to create project: $e"); + ConsoleLogger.info( + 'You can also run "stac project create -n ${name.trim()} -d ${description.trim()}" and then re-run "stac init".', + ); + return null; + } + case 2: + ConsoleLogger.info("Skipping project setup."); + return null; + default: + ConsoleLogger.error("Invalid selection"); + return null; + } + } +} diff --git a/packages/stac_cli/lib/src/commands/project/create_command.dart b/packages/stac_cli/lib/src/commands/project/create_command.dart new file mode 100644 index 000000000..62724ee91 --- /dev/null +++ b/packages/stac_cli/lib/src/commands/project/create_command.dart @@ -0,0 +1,60 @@ +import '../base_command.dart'; +import '../../services/project_service.dart'; +import '../../utils/console_logger.dart'; + +/// Command for creating a new project on the cloud +class CreateCommand extends BaseCommand { + final ProjectService _projectService = ProjectService(); + + @override + String get name => 'create'; + + @override + String get description => 'Create a new Stac project on the cloud'; + + @override + bool get requiresAuth => true; + + CreateCommand() { + argParser.addOption( + 'name', + abbr: 'n', + mandatory: true, + help: 'Project name', + ); + + argParser.addOption( + 'description', + abbr: 'd', + help: 'Project description', + defaultsTo: '', + ); + } + + @override + Future execute() async { + final name = argResults!['name'] as String; + final description = argResults!['description'] as String; + + ConsoleLogger.info('Creating project: $name'); + + try { + final project = await _projectService.createProject( + name: name, + description: description, + ); + + ConsoleLogger.success('✓ Project created successfully!'); + ConsoleLogger.info('Project ID: ${project.id}'); + ConsoleLogger.info('Name: ${project.name}'); + ConsoleLogger.info('Description: ${project.description}'); + ConsoleLogger.info(''); + ConsoleLogger.info('Run "stac init" to initialize this project locally.'); + + return 0; + } catch (e) { + ConsoleLogger.error('Failed to create project: $e'); + return 1; + } + } +} diff --git a/packages/stac_cli/lib/src/commands/project/list_command.dart b/packages/stac_cli/lib/src/commands/project/list_command.dart new file mode 100644 index 000000000..c2372cdea --- /dev/null +++ b/packages/stac_cli/lib/src/commands/project/list_command.dart @@ -0,0 +1,61 @@ +import 'dart:convert'; + +import '../../services/project_service.dart'; +import '../../utils/console_logger.dart'; +import '../base_command.dart'; + +/// Command for listing all cloud projects +class ListCommand extends BaseCommand { + final ProjectService _projectService = ProjectService(); + + @override + String get name => 'list'; + + @override + String get description => 'List all Stac projects on the cloud'; + + @override + bool get requiresAuth => true; + + ListCommand() { + argParser.addFlag('json', help: 'Output in JSON format', defaultsTo: false); + } + + @override + Future execute() async { + final outputJson = argResults!['json'] as bool; + + try { + final projects = await _projectService.fetchProjects(); + + if (projects.isEmpty) { + ConsoleLogger.info('No projects found.'); + ConsoleLogger.info('Create a new project with "stac project create"'); + return 0; + } + + if (outputJson) { + // Output as JSON + final jsonOutput = projects.map((p) => p.toJson()).toList(); + ConsoleLogger.plain(jsonEncode(jsonOutput)); + } else { + // Human-readable output + ConsoleLogger.info('Found ${projects.length} project(s):'); + ConsoleLogger.plain(''); + + for (final project in projects) { + ConsoleLogger.plain('${project.name} (${project.id})'); + ConsoleLogger.plain(' Description: ${project.description}'); + ConsoleLogger.plain(' Created: ${project.createdAt.toLocal()}'); + ConsoleLogger.plain(' Updated: ${project.updatedAt.toLocal()}'); + ConsoleLogger.plain(''); + } + } + + return 0; + } catch (e) { + ConsoleLogger.error('Failed to fetch projects: $e'); + return 1; + } + } +} diff --git a/packages/stac_cli/lib/src/commands/project_command.dart b/packages/stac_cli/lib/src/commands/project_command.dart new file mode 100644 index 000000000..c14bffc93 --- /dev/null +++ b/packages/stac_cli/lib/src/commands/project_command.dart @@ -0,0 +1,17 @@ +import 'package:args/command_runner.dart'; +import 'project/create_command.dart'; +import 'project/list_command.dart'; + +/// Main project command that groups project-related subcommands +class ProjectCommand extends Command { + @override + String get name => 'project'; + + @override + String get description => 'Manage Stac projects on the cloud'; + + ProjectCommand() { + addSubcommand(CreateCommand()); + addSubcommand(ListCommand()); + } +} diff --git a/packages/stac_cli/lib/src/commands/upgrade_command.dart b/packages/stac_cli/lib/src/commands/upgrade_command.dart new file mode 100644 index 000000000..874dc6841 --- /dev/null +++ b/packages/stac_cli/lib/src/commands/upgrade_command.dart @@ -0,0 +1,92 @@ +import '../services/upgrade_service.dart'; +import '../utils/console_logger.dart'; +import 'base_command.dart'; + +/// Command for upgrading the Stac CLI to the latest version +class UpgradeCommand extends BaseCommand { + final UpgradeService _upgradeService = UpgradeService(); + + @override + String get name => 'upgrade'; + + @override + String get description => 'Upgrade Stac CLI to the latest version'; + + UpgradeCommand() { + argParser.addOption( + 'version', + help: 'Specific version to install (e.g., 1.2.0)', + ); + argParser.addFlag( + 'force', + abbr: 'f', + negatable: false, + help: 'Force upgrade even if already on latest version', + ); + } + + @override + Future execute() async { + final specificVersion = argResults?['version'] as String?; + final force = argResults?['force'] as bool? ?? false; + + ConsoleLogger.info('Checking for updates...'); + + try { + // Check for updates + final checkResult = await _upgradeService.checkForUpdates(); + + final targetVersion = specificVersion ?? checkResult.latestVersion; + + ConsoleLogger.info('Current version: ${checkResult.currentVersion}'); + ConsoleLogger.info('Latest version: ${checkResult.latestVersion}'); + + if (specificVersion != null) { + ConsoleLogger.info('Requested version: $specificVersion'); + } + + // Check if upgrade is needed + if (!force && checkResult.currentVersion == targetVersion) { + ConsoleLogger.success( + '✓ Already on the latest version (${checkResult.currentVersion})', + ); + return 0; + } + + if (!force && !checkResult.updateAvailable && specificVersion == null) { + ConsoleLogger.success( + '✓ Already on the latest version (${checkResult.currentVersion})', + ); + return 0; + } + + ConsoleLogger.info('Upgrading to version $targetVersion...'); + + // Get download URL + String downloadUrl; + if (specificVersion != null) { + downloadUrl = await _upgradeService.getDownloadUrlForVersion( + specificVersion, + ); + } else { + downloadUrl = checkResult.downloadUrl; + } + + // Perform upgrade + await _upgradeService.upgradeTo( + version: targetVersion, + downloadUrl: downloadUrl, + ); + + ConsoleLogger.success( + '✓ Successfully upgraded to version $targetVersion', + ); + ConsoleLogger.info('Run "stac --version" to verify'); + + return 0; + } catch (e) { + ConsoleLogger.error('Failed to upgrade: $e'); + return 1; + } + } +} diff --git a/packages/stac_cli/lib/src/config/env.dart b/packages/stac_cli/lib/src/config/env.dart new file mode 100644 index 000000000..20ebee7f8 --- /dev/null +++ b/packages/stac_cli/lib/src/config/env.dart @@ -0,0 +1,60 @@ +import 'dart:io'; + +// Central environment configuration for the STAC CLI. +// Values are resolved from process environment variables, optionally seeded +// from `.env` / `.env.dev` during CLI startup. + +enum Environment { dev, prod } + +// Flip this to switch environments. +const Environment currentEnvironment = Environment.prod; + +Map _resolvedEnvironment = Map.unmodifiable( + Platform.environment, +); + +void configureEnvironment(Map loadedEnvironment) { + final merged = { + ...loadedEnvironment, + ...Platform.environment, + }; + _resolvedEnvironment = Map.unmodifiable(merged); +} + +class EnvConfig { + final String baseApiUrl; + final String googleOAuthClientId; + final String? googleOAuthClientSecret; + final String firebaseWebApiKey; + + const EnvConfig({ + required this.baseApiUrl, + required this.googleOAuthClientId, + required this.googleOAuthClientSecret, + required this.firebaseWebApiKey, + }); +} + +String? _value(String key) { + final raw = _resolvedEnvironment[key]; + if (raw == null) return null; + final trimmed = raw.trim(); + return trimmed.isEmpty ? null : trimmed; +} + +String _requiredValue(String key) { + final value = _value(key); + if (value == null) { + throw StateError('Missing required environment variable: $key'); + } + return value; +} + +EnvConfig get env { + return EnvConfig( + baseApiUrl: _requiredValue('STAC_BASE_API_URL'), + googleOAuthClientId: _requiredValue('STAC_GOOGLE_CLIENT_ID'), + googleOAuthClientSecret: _value('STAC_GOOGLE_CLIENT_SECRET'), + firebaseWebApiKey: _requiredValue('STAC_FIREBASE_API_KEY'), + ); +} diff --git a/packages/stac_cli/lib/src/exceptions/auth_exception.dart b/packages/stac_cli/lib/src/exceptions/auth_exception.dart new file mode 100644 index 000000000..a106cdf12 --- /dev/null +++ b/packages/stac_cli/lib/src/exceptions/auth_exception.dart @@ -0,0 +1,18 @@ +import 'stac_exception.dart'; + +/// Exception thrown for authentication-related errors +class AuthException extends StacException { + const AuthException(super.message, {super.exitCode = 1, super.cause}); +} + +/// Exception thrown when user is not authenticated +class NotAuthenticatedException extends AuthException { + const NotAuthenticatedException() + : super('Not authenticated. Please run "stac login" first.'); +} + +/// Exception thrown when authentication fails +class AuthenticationFailedException extends AuthException { + const AuthenticationFailedException([String? reason]) + : super('Authentication failed${reason != null ? ': $reason' : ''}'); +} diff --git a/packages/stac_cli/lib/src/exceptions/build_exception.dart b/packages/stac_cli/lib/src/exceptions/build_exception.dart new file mode 100644 index 000000000..a91958900 --- /dev/null +++ b/packages/stac_cli/lib/src/exceptions/build_exception.dart @@ -0,0 +1,18 @@ +import 'stac_exception.dart'; + +/// Exception thrown for build-related errors +class BuildException extends StacException { + const BuildException(super.message, {super.exitCode = 1, super.cause}); +} + +/// Exception thrown when Dart to JSON conversion fails +class ConversionException extends BuildException { + const ConversionException(String message, {dynamic cause}) + : super('Dart to JSON conversion failed: $message', cause: cause); +} + +/// Exception thrown when SDUI validation fails +class ValidationException extends BuildException { + const ValidationException(String message) + : super('SDUI validation failed: $message'); +} diff --git a/packages/stac_cli/lib/src/exceptions/stac_exception.dart b/packages/stac_cli/lib/src/exceptions/stac_exception.dart new file mode 100644 index 000000000..b2db892ae --- /dev/null +++ b/packages/stac_cli/lib/src/exceptions/stac_exception.dart @@ -0,0 +1,11 @@ +/// Base exception class for all STAC CLI errors +class StacException implements Exception { + final String message; + final int? exitCode; + final dynamic cause; + + const StacException(this.message, {this.exitCode, this.cause}); + + @override + String toString() => 'StacException: $message'; +} diff --git a/packages/stac_cli/lib/src/models/auth_token.dart b/packages/stac_cli/lib/src/models/auth_token.dart new file mode 100644 index 000000000..20367d26a --- /dev/null +++ b/packages/stac_cli/lib/src/models/auth_token.dart @@ -0,0 +1,43 @@ +/// OAuth token model for storing authentication data +class AuthToken { + final String accessToken; + final String? refreshToken; + final DateTime expiresAt; + final List scopes; + + const AuthToken({ + required this.accessToken, + this.refreshToken, + required this.expiresAt, + required this.scopes, + }); + + /// Check if the token is expired + bool get isExpired => DateTime.now().isAfter(expiresAt); + + /// Check if the token will expire soon (within 5 minutes) + bool get isExpiringSoon { + final fiveMinutesFromNow = DateTime.now().add(const Duration(minutes: 5)); + return fiveMinutesFromNow.isAfter(expiresAt); + } + + /// Convert to JSON for storage + Map toJson() { + return { + 'access_token': accessToken, + 'refresh_token': refreshToken, + 'expires_at': expiresAt.toIso8601String(), + 'scopes': scopes, + }; + } + + /// Create from JSON + factory AuthToken.fromJson(Map json) { + return AuthToken( + accessToken: json['access_token'] as String, + refreshToken: json['refresh_token'] as String?, + expiresAt: DateTime.parse(json['expires_at'] as String), + scopes: List.from(json['scopes'] as List), + ); + } +} diff --git a/packages/stac_cli/lib/src/models/project/project.dart b/packages/stac_cli/lib/src/models/project/project.dart new file mode 100644 index 000000000..f5df16ca5 --- /dev/null +++ b/packages/stac_cli/lib/src/models/project/project.dart @@ -0,0 +1,83 @@ +import 'package:json_annotation/json_annotation.dart'; +import 'package:stac_cli/src/models/project/subscription.dart'; +import 'package:stac_cli/src/models/project/ui_loads.dart'; +import 'package:stac_cli/src/utils/date_time_utils.dart'; + +part 'project.g.dart'; + +@JsonSerializable() +class Project { + const Project({ + this.id, + required this.name, + this.slug, + this.description, + required this.ownerId, + required this.createdAt, + required this.updatedAt, + this.defaultScreenId, + required this.isPublic, + this.tags = const [], + this.status, + this.deletedAt, + this.subscription, + this.uiLoads, + }); + + final String? id; + final String name; + final String? slug; + final String? description; + final String ownerId; + @FirestoreDateTime() + final DateTime createdAt; + @FirestoreDateTime() + final DateTime updatedAt; + final String? defaultScreenId; + final bool isPublic; + final List tags; + final String? status; + @FirestoreDateTimeNullable() + final DateTime? deletedAt; + final Subscription? subscription; + final UiLoads? uiLoads; + + factory Project.fromJson(Map json) => + _$ProjectFromJson(json); + + Project copyWith({ + String? id, + String? name, + String? slug, + String? description, + String? ownerId, + DateTime? createdAt, + DateTime? updatedAt, + String? defaultScreenId, + bool? isPublic, + List? tags, + String? status, + DateTime? deletedAt, + Subscription? subscription, + UiLoads? uiLoads, + }) { + return Project( + id: id ?? this.id, + name: name ?? this.name, + slug: slug ?? this.slug, + description: description ?? this.description, + ownerId: ownerId ?? this.ownerId, + createdAt: createdAt ?? this.createdAt, + updatedAt: updatedAt ?? this.updatedAt, + defaultScreenId: defaultScreenId ?? this.defaultScreenId, + isPublic: isPublic ?? this.isPublic, + tags: tags ?? this.tags, + status: status ?? this.status, + deletedAt: deletedAt ?? this.deletedAt, + subscription: subscription ?? this.subscription, + uiLoads: uiLoads ?? this.uiLoads, + ); + } + + Map toJson() => _$ProjectToJson(this); +} diff --git a/packages/stac_cli/lib/src/models/project/project.g.dart b/packages/stac_cli/lib/src/models/project/project.g.dart new file mode 100644 index 000000000..91bbad6d6 --- /dev/null +++ b/packages/stac_cli/lib/src/models/project/project.g.dart @@ -0,0 +1,47 @@ +// GENERATED CODE - DO NOT MODIFY BY HAND + +part of 'project.dart'; + +// ************************************************************************** +// JsonSerializableGenerator +// ************************************************************************** + +Project _$ProjectFromJson(Map json) => Project( + id: json['id'] as String?, + name: json['name'] as String, + slug: json['slug'] as String?, + description: json['description'] as String?, + ownerId: json['ownerId'] as String, + createdAt: const FirestoreDateTime().fromJson(json['createdAt']), + updatedAt: const FirestoreDateTime().fromJson(json['updatedAt']), + defaultScreenId: json['defaultScreenId'] as String?, + isPublic: json['isPublic'] as bool, + tags: + (json['tags'] as List?)?.map((e) => e as String).toList() ?? + const [], + status: json['status'] as String?, + deletedAt: const FirestoreDateTimeNullable().fromJson(json['deletedAt']), + subscription: json['subscription'] == null + ? null + : Subscription.fromJson(json['subscription'] as Map), + uiLoads: json['uiLoads'] == null + ? null + : UiLoads.fromJson(json['uiLoads'] as Map), +); + +Map _$ProjectToJson(Project instance) => { + 'id': instance.id, + 'name': instance.name, + 'slug': instance.slug, + 'description': instance.description, + 'ownerId': instance.ownerId, + 'createdAt': const FirestoreDateTime().toJson(instance.createdAt), + 'updatedAt': const FirestoreDateTime().toJson(instance.updatedAt), + 'defaultScreenId': instance.defaultScreenId, + 'isPublic': instance.isPublic, + 'tags': instance.tags, + 'status': instance.status, + 'deletedAt': const FirestoreDateTimeNullable().toJson(instance.deletedAt), + 'subscription': instance.subscription, + 'uiLoads': instance.uiLoads, +}; diff --git a/packages/stac_cli/lib/src/models/project/project_access.dart b/packages/stac_cli/lib/src/models/project/project_access.dart new file mode 100644 index 000000000..e5daecf51 --- /dev/null +++ b/packages/stac_cli/lib/src/models/project/project_access.dart @@ -0,0 +1,46 @@ +enum ProjectAccess { + owner, + admin, + editor, + viewer; + + /// Returns the numeric value for comparison + /// Owner (4) > Admin (3) > Editor (2) > Viewer (1) + int get value { + switch (this) { + case ProjectAccess.owner: + return 4; + case ProjectAccess.admin: + return 3; + case ProjectAccess.editor: + return 2; + case ProjectAccess.viewer: + return 1; + } + } + + /// Compares two ProjectAccess values + /// Returns true if this access level is greater than the other + bool operator >(ProjectAccess other) => value > other.value; + + /// Compares two ProjectAccess values + /// Returns true if this access level is greater than or equal to the other + bool operator >=(ProjectAccess other) => value >= other.value; + + /// Compares two ProjectAccess values + /// Returns true if this access level is less than the other + bool operator <(ProjectAccess other) => value < other.value; + + /// Compares two ProjectAccess values + /// Returns true if this access level is less than or equal to the other + bool operator <=(ProjectAccess other) => value <= other.value; + + /// Compares two ProjectAccess values + /// Returns true if this access level is equal to the other + bool isEqual(ProjectAccess other) => value == other.value; + + /// Returns all access levels that are lower than the current one + List lowerLevels() { + return ProjectAccess.values.where((access) => access < this).toList(); + } +} diff --git a/packages/stac_cli/lib/src/models/project/subscription.dart b/packages/stac_cli/lib/src/models/project/subscription.dart new file mode 100644 index 000000000..4429ee3c0 --- /dev/null +++ b/packages/stac_cli/lib/src/models/project/subscription.dart @@ -0,0 +1,203 @@ +import 'package:json_annotation/json_annotation.dart'; +import 'package:stac_cli/src/utils/date_time_utils.dart'; + +part 'subscription.g.dart'; + +/// Subscription status enumeration +enum SubscriptionStatus { + @JsonValue('active') + active, + @JsonValue('on_hold') + onHold, + @JsonValue('cancelled') + cancelled, + @JsonValue('failed') + failed, + @JsonValue('expired') + expired, +} + +/// Subscription environment enumeration +enum SubscriptionEnvironment { + @JsonValue('test_mode') + testMode, + @JsonValue('live_mode') + liveMode, +} + +/// A model class representing a project subscription. +/// +/// This class encapsulates subscription data stored in Firestore at +/// `projects/{projectId}.subscription`. +@JsonSerializable() +class Subscription { + const Subscription({ + this.subscriptionId, + this.productId, + this.customerId, + this.status, + this.environment, + this.currentPeriodStart, + this.currentPeriodEnd, + this.lastRenewedAt, + this.updatedAt, + this.cancelOnPeriodEnd, + this.additionalUsageBillingEnabled, + this.spendLimitEnabled, + this.alertThreshold, + }); + + /// The Dodo Payments subscription ID + final String? subscriptionId; + + /// The Dodo Payments product ID (plan identifier) + final String? productId; + + /// The Dodo Payments customer ID + final String? customerId; + + /// Current subscription status + final SubscriptionStatus? status; + + /// Environment where the subscription is active + final SubscriptionEnvironment? environment; + + /// Start of the current billing period + @FirestoreDateTimeNullable() + final DateTime? currentPeriodStart; + + /// End of the current billing period + @FirestoreDateTimeNullable() + final DateTime? currentPeriodEnd; + + /// Last time the subscription was renewed + @FirestoreDateTimeNullable() + final DateTime? lastRenewedAt; + + /// Last update timestamp + @FirestoreDateTimeNullable() + final DateTime? updatedAt; + + /// Indicates whether the subscription is set to cancel at period end. + final bool? cancelOnPeriodEnd; + + /// Whether usage-based overage billing is enabled for the project. + final bool? additionalUsageBillingEnabled; + + /// Whether spend limit enforcement is enabled. + final bool? spendLimitEnabled; + + /// Threshold (0-1) for usage alerts. + final double? alertThreshold; + + /// Checks if the subscription is within its current billing period. + /// Returns false if period dates are missing or if current date is outside the period. + /// The check is inclusive: current date must be >= periodStart and <= periodEnd. + bool get isWithinCurrentPeriod { + if (currentPeriodStart == null || currentPeriodEnd == null) { + return false; + } + final now = DateTime.now(); + return (now.isAfter(currentPeriodStart!) || + now.isAtSameMomentAs(currentPeriodStart!)) && + (now.isBefore(currentPeriodEnd!) || + now.isAtSameMomentAs(currentPeriodEnd!)); + } + + /// Creates a Subscription from Firestore document data + factory Subscription.fromFirestore( + String projectId, + Map? data, + ) { + if (data == null) { + return Subscription(); + } + + SubscriptionStatus? parseStatus(String? statusStr) { + if (statusStr == null) return null; + switch (statusStr.toLowerCase()) { + case 'active': + return SubscriptionStatus.active; + case 'on_hold': + return SubscriptionStatus.onHold; + case 'cancelled': + return SubscriptionStatus.cancelled; + case 'failed': + return SubscriptionStatus.failed; + case 'expired': + return SubscriptionStatus.expired; + default: + return null; + } + } + + SubscriptionEnvironment? parseEnvironment(String? envStr) { + if (envStr == null) return null; + switch (envStr.toLowerCase()) { + case 'test_mode': + return SubscriptionEnvironment.testMode; + case 'live_mode': + return SubscriptionEnvironment.liveMode; + default: + return null; + } + } + + return Subscription( + subscriptionId: data['subscriptionId'] as String?, + productId: data['productId'] as String?, + customerId: data['customerId'] as String?, + status: parseStatus(data['status'] as String?), + environment: parseEnvironment(data['environment'] as String?), + currentPeriodStart: DateTimeUtils.parseDateTime( + data['currentPeriodStart'], + ), + currentPeriodEnd: DateTimeUtils.parseDateTime(data['currentPeriodEnd']), + lastRenewedAt: DateTimeUtils.parseDateTime(data['lastRenewedAt']), + updatedAt: DateTimeUtils.parseDateTime(data['updatedAt']), + cancelOnPeriodEnd: data['cancelOnPeriodEnd'] as bool?, + additionalUsageBillingEnabled: + data['additionalUsageBillingEnabled'] as bool?, + spendLimitEnabled: data['spendLimitEnabled'] as bool?, + alertThreshold: (data['alertThreshold'] as num?)?.toDouble(), + ); + } + + factory Subscription.fromJson(Map json) => + _$SubscriptionFromJson(json); + + Map toJson() => _$SubscriptionToJson(this); + + Subscription copyWith({ + String? subscriptionId, + String? productId, + String? customerId, + SubscriptionStatus? status, + SubscriptionEnvironment? environment, + DateTime? currentPeriodStart, + DateTime? currentPeriodEnd, + DateTime? lastRenewedAt, + DateTime? updatedAt, + bool? cancelOnPeriodEnd, + bool? additionalUsageBillingEnabled, + bool? spendLimitEnabled, + double? alertThreshold, + }) { + return Subscription( + subscriptionId: subscriptionId ?? this.subscriptionId, + productId: productId ?? this.productId, + customerId: customerId ?? this.customerId, + status: status ?? this.status, + environment: environment ?? this.environment, + currentPeriodStart: currentPeriodStart ?? this.currentPeriodStart, + currentPeriodEnd: currentPeriodEnd ?? this.currentPeriodEnd, + lastRenewedAt: lastRenewedAt ?? this.lastRenewedAt, + updatedAt: updatedAt ?? this.updatedAt, + cancelOnPeriodEnd: cancelOnPeriodEnd ?? this.cancelOnPeriodEnd, + additionalUsageBillingEnabled: + additionalUsageBillingEnabled ?? this.additionalUsageBillingEnabled, + spendLimitEnabled: spendLimitEnabled ?? this.spendLimitEnabled, + alertThreshold: alertThreshold ?? this.alertThreshold, + ); + } +} diff --git a/packages/stac_cli/lib/src/models/project/subscription.g.dart b/packages/stac_cli/lib/src/models/project/subscription.g.dart new file mode 100644 index 000000000..3ad4993ac --- /dev/null +++ b/packages/stac_cli/lib/src/models/project/subscription.g.dart @@ -0,0 +1,68 @@ +// GENERATED CODE - DO NOT MODIFY BY HAND + +part of 'subscription.dart'; + +// ************************************************************************** +// JsonSerializableGenerator +// ************************************************************************** + +Subscription _$SubscriptionFromJson(Map json) => Subscription( + subscriptionId: json['subscriptionId'] as String?, + productId: json['productId'] as String?, + customerId: json['customerId'] as String?, + status: $enumDecodeNullable(_$SubscriptionStatusEnumMap, json['status']), + environment: $enumDecodeNullable( + _$SubscriptionEnvironmentEnumMap, + json['environment'], + ), + currentPeriodStart: const FirestoreDateTimeNullable().fromJson( + json['currentPeriodStart'], + ), + currentPeriodEnd: const FirestoreDateTimeNullable().fromJson( + json['currentPeriodEnd'], + ), + lastRenewedAt: const FirestoreDateTimeNullable().fromJson( + json['lastRenewedAt'], + ), + updatedAt: const FirestoreDateTimeNullable().fromJson(json['updatedAt']), + cancelOnPeriodEnd: json['cancelOnPeriodEnd'] as bool?, + additionalUsageBillingEnabled: json['additionalUsageBillingEnabled'] as bool?, + spendLimitEnabled: json['spendLimitEnabled'] as bool?, + alertThreshold: (json['alertThreshold'] as num?)?.toDouble(), +); + +Map _$SubscriptionToJson(Subscription instance) => + { + 'subscriptionId': instance.subscriptionId, + 'productId': instance.productId, + 'customerId': instance.customerId, + 'status': _$SubscriptionStatusEnumMap[instance.status], + 'environment': _$SubscriptionEnvironmentEnumMap[instance.environment], + 'currentPeriodStart': const FirestoreDateTimeNullable().toJson( + instance.currentPeriodStart, + ), + 'currentPeriodEnd': const FirestoreDateTimeNullable().toJson( + instance.currentPeriodEnd, + ), + 'lastRenewedAt': const FirestoreDateTimeNullable().toJson( + instance.lastRenewedAt, + ), + 'updatedAt': const FirestoreDateTimeNullable().toJson(instance.updatedAt), + 'cancelOnPeriodEnd': instance.cancelOnPeriodEnd, + 'additionalUsageBillingEnabled': instance.additionalUsageBillingEnabled, + 'spendLimitEnabled': instance.spendLimitEnabled, + 'alertThreshold': instance.alertThreshold, + }; + +const _$SubscriptionStatusEnumMap = { + SubscriptionStatus.active: 'active', + SubscriptionStatus.onHold: 'on_hold', + SubscriptionStatus.cancelled: 'cancelled', + SubscriptionStatus.failed: 'failed', + SubscriptionStatus.expired: 'expired', +}; + +const _$SubscriptionEnvironmentEnumMap = { + SubscriptionEnvironment.testMode: 'test_mode', + SubscriptionEnvironment.liveMode: 'live_mode', +}; diff --git a/packages/stac_cli/lib/src/models/project/ui_loads.dart b/packages/stac_cli/lib/src/models/project/ui_loads.dart new file mode 100644 index 000000000..115510824 --- /dev/null +++ b/packages/stac_cli/lib/src/models/project/ui_loads.dart @@ -0,0 +1,87 @@ +import 'package:json_annotation/json_annotation.dart'; +import 'package:stac_cli/src/utils/date_time_utils.dart'; + +part 'ui_loads.g.dart'; + +/// A model class representing UI load usage data for a project. +/// +/// This class encapsulates UI load usage data stored in Firestore at +/// `projects/{projectId}.uiLoads`. +@JsonSerializable() +class UiLoads { + const UiLoads({ + this.currentPeriodUiLoadCount, + this.lastUiLoadCountFlushed, + this.lastUiLoadsFlushedDelta, + this.lastUiLoadsCountFlushedAt, + this.lastUiLoadsUploadError, + this.lifetimeUiLoadCount, + }); + + /// Current period UI load count (resets at period start) + final int? currentPeriodUiLoadCount; + + /// Last UI load count that was flushed to billing provider + final int? lastUiLoadCountFlushed; + + /// Delta of UI loads that were flushed last time + final int? lastUiLoadsFlushedDelta; + + /// Timestamp when UI loads were last flushed + @FirestoreDateTimeNullable() + final DateTime? lastUiLoadsCountFlushedAt; + + /// Error message from last upload attempt (if any) + final String? lastUiLoadsUploadError; + + /// Lifetime UI load count (never reset) + final int? lifetimeUiLoadCount; + + /// Creates a UiLoads from Firestore document data + factory UiLoads.fromFirestore(String projectId, Map? data) { + if (data == null) { + return UiLoads(); + } + + return UiLoads( + currentPeriodUiLoadCount: (data['currentPeriodUiLoadCount'] as num?) + ?.toInt(), + lastUiLoadCountFlushed: (data['lastUiLoadCountFlushed'] as num?)?.toInt(), + lastUiLoadsFlushedDelta: (data['lastUiLoadsFlushedDelta'] as num?) + ?.toInt(), + lastUiLoadsCountFlushedAt: DateTimeUtils.parseDateTime( + data['lastUiLoadsCountFlushedAt'], + ), + lastUiLoadsUploadError: data['lastUiLoadsUploadError'] as String?, + lifetimeUiLoadCount: (data['lifetimeUiLoadCount'] as num?)?.toInt(), + ); + } + + factory UiLoads.fromJson(Map json) => + _$UiLoadsFromJson(json); + + Map toJson() => _$UiLoadsToJson(this); + + UiLoads copyWith({ + int? currentPeriodUiLoadCount, + int? lastUiLoadCountFlushed, + int? lastUiLoadsFlushedDelta, + DateTime? lastUiLoadsCountFlushedAt, + String? lastUiLoadsUploadError, + int? lifetimeUiLoadCount, + }) { + return UiLoads( + currentPeriodUiLoadCount: + currentPeriodUiLoadCount ?? this.currentPeriodUiLoadCount, + lastUiLoadCountFlushed: + lastUiLoadCountFlushed ?? this.lastUiLoadCountFlushed, + lastUiLoadsFlushedDelta: + lastUiLoadsFlushedDelta ?? this.lastUiLoadsFlushedDelta, + lastUiLoadsCountFlushedAt: + lastUiLoadsCountFlushedAt ?? this.lastUiLoadsCountFlushedAt, + lastUiLoadsUploadError: + lastUiLoadsUploadError ?? this.lastUiLoadsUploadError, + lifetimeUiLoadCount: lifetimeUiLoadCount ?? this.lifetimeUiLoadCount, + ); + } +} diff --git a/packages/stac_cli/lib/src/models/project/ui_loads.g.dart b/packages/stac_cli/lib/src/models/project/ui_loads.g.dart new file mode 100644 index 000000000..8bb5bd02c --- /dev/null +++ b/packages/stac_cli/lib/src/models/project/ui_loads.g.dart @@ -0,0 +1,29 @@ +// GENERATED CODE - DO NOT MODIFY BY HAND + +part of 'ui_loads.dart'; + +// ************************************************************************** +// JsonSerializableGenerator +// ************************************************************************** + +UiLoads _$UiLoadsFromJson(Map json) => UiLoads( + currentPeriodUiLoadCount: (json['currentPeriodUiLoadCount'] as num?)?.toInt(), + lastUiLoadCountFlushed: (json['lastUiLoadCountFlushed'] as num?)?.toInt(), + lastUiLoadsFlushedDelta: (json['lastUiLoadsFlushedDelta'] as num?)?.toInt(), + lastUiLoadsCountFlushedAt: const FirestoreDateTimeNullable().fromJson( + json['lastUiLoadsCountFlushedAt'], + ), + lastUiLoadsUploadError: json['lastUiLoadsUploadError'] as String?, + lifetimeUiLoadCount: (json['lifetimeUiLoadCount'] as num?)?.toInt(), +); + +Map _$UiLoadsToJson(UiLoads instance) => { + 'currentPeriodUiLoadCount': instance.currentPeriodUiLoadCount, + 'lastUiLoadCountFlushed': instance.lastUiLoadCountFlushed, + 'lastUiLoadsFlushedDelta': instance.lastUiLoadsFlushedDelta, + 'lastUiLoadsCountFlushedAt': const FirestoreDateTimeNullable().toJson( + instance.lastUiLoadsCountFlushedAt, + ), + 'lastUiLoadsUploadError': instance.lastUiLoadsUploadError, + 'lifetimeUiLoadCount': instance.lifetimeUiLoadCount, +}; diff --git a/packages/stac_cli/lib/src/models/stac_dsl_artifact.dart b/packages/stac_cli/lib/src/models/stac_dsl_artifact.dart new file mode 100644 index 000000000..f84223efd --- /dev/null +++ b/packages/stac_cli/lib/src/models/stac_dsl_artifact.dart @@ -0,0 +1,54 @@ +/// Represents an annotated callable (screen or theme) discovered in Stac DSL. +class StacDslArtifact { + final StacDslArtifactType type; + + /// Function or getter name to invoke. + final String callableName; + + /// Human-readable identifier (screenName or themeName). + final String artifactName; + + /// Whether this callable should be read as a getter instead of invoked. + final bool isGetter; + + const StacDslArtifact({ + required this.type, + required this.callableName, + required this.artifactName, + this.isGetter = false, + }); + + factory StacDslArtifact.screen({ + required String functionName, + required String screenName, + bool isGetter = false, + }) { + return StacDslArtifact( + type: StacDslArtifactType.screen, + callableName: functionName, + artifactName: screenName, + isGetter: isGetter, + ); + } + + factory StacDslArtifact.theme({ + required String memberName, + required String themeName, + required bool isGetter, + }) { + return StacDslArtifact( + type: StacDslArtifactType.theme, + callableName: memberName, + artifactName: themeName, + isGetter: isGetter, + ); + } + + String get logLabel => + type == StacDslArtifactType.screen ? 'screen' : 'theme'; + + String get resultKeyPrefix => + type == StacDslArtifactType.screen ? 'screens' : 'themes'; +} + +enum StacDslArtifactType { screen, theme } diff --git a/packages/stac_cli/lib/src/services/auth_service.dart b/packages/stac_cli/lib/src/services/auth_service.dart new file mode 100644 index 000000000..5faf3ac6d --- /dev/null +++ b/packages/stac_cli/lib/src/services/auth_service.dart @@ -0,0 +1,557 @@ +import 'dart:async'; +import 'dart:convert'; +import 'dart:io'; + +import 'package:stac_cli/src/exceptions/stac_exception.dart'; +import 'package:stac_cli/src/utils/oauth_pkce.dart'; + +import '../config/env.dart'; +import '../exceptions/auth_exception.dart'; +import '../models/auth_token.dart'; +import '../services/config_service.dart'; +import '../utils/console_logger.dart'; + +/// Service for handling Google OAuth authentication +class AuthService { + // Production OAuth credentials (provided by Stac team) + // Note: In production, these would be the actual Stac OAuth app credentials + static String get _clientId => env.googleOAuthClientId; + static const List _scopes = ['openid', 'email', 'profile']; + + static String get _firebaseApiKey => env.firebaseWebApiKey; + + final ConfigService _configService = ConfigService.instance; + + /// Get the client ID to use + String get clientId => _clientId; + + /// Get the client secret to use (null for PKCE) + String? get clientSecret => env.googleOAuthClientSecret; + + /// Start the OAuth login flow + Future login() async { + try { + // Check if already logged in + final existingToken = await _configService.getAuthToken(); + if (existingToken != null && !existingToken.isExpired) { + // Try to get user email from the token + final email = _extractEmailFromToken(existingToken.accessToken); + if (email != null) { + ConsoleLogger.info('Already logged in as $email'); + } else { + ConsoleLogger.info('Already logged in'); + } + ConsoleLogger.info( + 'Run "stac logout" first if you want to login with a different account.', + ); + return; + } + + ConsoleLogger.info('Starting Google OAuth login...'); + + // Start local server first to get the dynamic port and auth code + final serverResult = await _startCallbackServer(); + final authCode = serverResult['code'] as String; + final redirectUri = serverResult['redirectUri'] as String; + final codeVerifier = serverResult['codeVerifier'] as String; + + // Exchange authorization code for access token + await _exchangeCodeForToken(authCode, redirectUri, codeVerifier); + + // Get the email from the newly created token + final newToken = await _configService.getAuthToken(); + final email = newToken != null + ? _extractEmailFromToken(newToken.accessToken) + : null; + + if (email != null) { + ConsoleLogger.success('Successfully logged in as $email!'); + } else { + ConsoleLogger.success('Successfully logged in!'); + } + } catch (e) { + throw AuthenticationFailedException('Login failed: $e'); + } + } + + /// Logout and clear stored tokens + Future logout() async { + try { + await _configService.clearAuthToken(); + ConsoleLogger.success('Successfully logged out!'); + } catch (e) { + throw AuthException('Logout failed: $e'); + } + } + + /// Refresh the authentication token if it's expired or expiring soon + Future refreshTokenIfNeeded() async { + try { + final token = await _configService.getAuthToken(); + if (token == null) { + return null; + } + + // If token is not expired and not expiring soon, return as is + if (!token.isExpired && !token.isExpiringSoon) { + return token; + } + + // If we have a refresh token, try to refresh + if (token.refreshToken != null) { + try { + return await _refreshToken(token.refreshToken!); + } catch (e) { + ConsoleLogger.debug('Token refresh failed: $e'); + // Clear the invalid token and force re-login + await _configService.clearAuthToken(); + throw StacException( + 'Authentication required. Please run "stac login"', + ); + } + } + + // No refresh token available, user needs to login again + throw StacException('Authentication required. Please run "stac login"'); + } catch (e) { + if (e is StacException) { + rethrow; + } + ConsoleLogger.debug('Authentication check failed: $e'); + throw StacException('Authentication required. Please run "stac login"'); + } + } + + /// Refresh the token using the refresh token + Future _refreshToken(String refreshToken) async { + final client = HttpClient(); + try { + final uri = Uri.parse( + 'https://securetoken.googleapis.com/v1/token?key=$_firebaseApiKey', + ); + + final request = await client.postUrl(uri); + request.headers.contentType = ContentType('application', 'json'); + + final payload = { + 'grant_type': 'refresh_token', + 'refresh_token': refreshToken, + }; + request.write(json.encode(payload)); + + final response = await request.close(); + final responseBody = await response.transform(utf8.decoder).join(); + + if (response.statusCode != 200) { + ConsoleLogger.debug( + 'Token refresh failed with status ${response.statusCode}', + ); + throw AuthenticationFailedException( + 'Token refresh failed (${response.statusCode})', + ); + } + + final data = json.decode(responseBody) as Map; + final newIdToken = data['id_token'] as String?; + final newRefreshToken = data['refresh_token'] as String?; + + if (newIdToken == null) { + ConsoleLogger.debug('No id_token in refresh response: $data'); + throw const AuthenticationFailedException( + 'No id_token in refresh response', + ); + } + + final expiresAt = _parseTokenExpiration(newIdToken); + + final authToken = AuthToken( + accessToken: newIdToken, + refreshToken: + newRefreshToken ?? + refreshToken, // Keep old refresh token if new one not provided + expiresAt: expiresAt, + scopes: _scopes, + ); + + await _configService.storeAuthToken(authToken); + ConsoleLogger.debug('Token refreshed successfully'); + return authToken; + } catch (e) { + ConsoleLogger.debug('Token refresh error: $e'); + rethrow; + } finally { + client.close(); + } + } + + /// Get current authentication status + Future status() async { + try { + final isAuthenticated = await _configService.isAuthenticated(); + + if (isAuthenticated) { + final token = await _configService.getAuthToken(); + ConsoleLogger.success('✓ Authenticated'); + if (token != null) { + // Try to extract and display email + final email = _extractEmailFromToken(token.accessToken); + if (email != null) { + ConsoleLogger.info('Logged in as: $email'); + } + + final now = DateTime.now(); + final timeUntilExpiry = token.expiresAt.difference(now); + + if (token.refreshToken != null) { + ConsoleLogger.info( + 'Session token expires in: ${timeUntilExpiry.inHours}h ${timeUntilExpiry.inMinutes % 60}m', + ); + ConsoleLogger.info( + 'You will remain logged in (tokens refresh automatically)', + ); + } else { + ConsoleLogger.info( + 'Time until expiry: ${timeUntilExpiry.inHours}h ${timeUntilExpiry.inMinutes % 60}m', + ); + ConsoleLogger.warning( + 'No refresh token available - you will need to re-login after expiry', + ); + } + + // Show additional debug info + ConsoleLogger.debug( + 'Token expires at: ${token.expiresAt.toIso8601String()}', + ); + ConsoleLogger.debug( + 'Has refresh token: ${token.refreshToken != null}', + ); + ConsoleLogger.debug('Is expired: ${token.isExpired}'); + ConsoleLogger.debug('Is expiring soon: ${token.isExpiringSoon}'); + } + } else { + ConsoleLogger.info( + 'Not authenticated. Run "stac login" to authenticate.', + ); + } + } catch (e) { + throw AuthException('Failed to check status: $e'); + } + } + + /// Start a local server to receive the OAuth callback + Future> _startCallbackServer() async { + final completer = Completer(); + HttpServer? server; + + try { + // Bind to port 0 to get any available port from the OS + server = await HttpServer.bind('localhost', 0); + final port = server.port; + final redirectUri = 'http://localhost:$port/auth/callback'; + + ConsoleLogger.debug('Started callback server on port $port'); + + // Generate OAuth URL with the dynamic redirect URI + final state = generateSecureState(); + final codeVerifier = generateCodeVerifier(); + final codeChallenge = await createCodeChallenge(codeVerifier); + final authUrl = Uri.https('accounts.google.com', '/o/oauth2/auth', { + 'client_id': clientId, + 'redirect_uri': redirectUri, + 'scope': _scopes.join(' '), + 'response_type': 'code', + 'state': state, + 'code_challenge': codeChallenge, + 'code_challenge_method': 'S256', + }); + + ConsoleLogger.info('Please visit the following URL to authenticate:'); + ConsoleLogger.plain(''); + ConsoleLogger.plain(authUrl.toString()); + ConsoleLogger.plain(''); + + // Try to open browser automatically + try { + if (Platform.isMacOS) { + await Process.run('open', [authUrl.toString()]); + ConsoleLogger.info('Opening browser for authentication...'); + } else if (Platform.isLinux) { + await Process.run('xdg-open', [authUrl.toString()]); + ConsoleLogger.info('Opening browser for authentication...'); + } else if (Platform.isWindows) { + await Process.run('rundll32', [ + 'url.dll,FileProtocolHandler', + authUrl.toString(), + ], runInShell: false); + ConsoleLogger.info('Opening browser for authentication...'); + } + } catch (e) { + ConsoleLogger.warning( + 'Could not open browser automatically. Please copy the URL above.', + ); + } + + ConsoleLogger.info('Waiting for authentication callback...'); + + server.listen((request) async { + final uri = request.uri; + + if (uri.path == '/auth/callback') { + final code = uri.queryParameters['code']; + final error = uri.queryParameters['error']; + final callbackState = uri.queryParameters['state']; + + if (error != null) { + request.response + ..statusCode = 400 + ..write('Authentication failed: $error') + ..close(); + if (!completer.isCompleted) { + completer.completeError(AuthenticationFailedException(error)); + } + return; + } + + if (callbackState == null || callbackState != state) { + request.response + ..statusCode = 400 + ..write('Authentication failed: invalid OAuth state') + ..close(); + if (!completer.isCompleted) { + completer.completeError( + const AuthenticationFailedException( + 'Authentication failed: invalid OAuth state', + ), + ); + } + return; + } + + if (code != null) { + request.response + ..statusCode = 200 + ..write('Authentication successful! You can close this window.') + ..close(); + if (!completer.isCompleted) { + completer.complete(code); + } + return; + } + } + + request.response + ..statusCode = 404 + ..write('Not found') + ..close(); + }); + + final authCode = await completer.future.timeout( + const Duration(minutes: 5), + onTimeout: () { + throw AuthenticationFailedException('Authentication timeout'); + }, + ); + + return { + 'code': authCode, + 'redirectUri': redirectUri, + 'codeVerifier': codeVerifier, + }; + } finally { + await server?.close(); + } + } + + /// Exchange authorization code for access token + Future _exchangeCodeForToken( + String authCode, + String redirectUri, + String codeVerifier, + ) async { + final client = HttpClient(); + try { + final request = await client.postUrl( + Uri.parse('https://oauth2.googleapis.com/token'), + ); + + request.headers.contentType = ContentType( + 'application', + 'x-www-form-urlencoded', + ); + + // Build OAuth token exchange request with dynamic redirect URI + final bodyParams = [ + 'code=${Uri.encodeQueryComponent(authCode)}', + 'client_id=${Uri.encodeQueryComponent(clientId)}', + 'redirect_uri=${Uri.encodeQueryComponent(redirectUri)}', + 'grant_type=authorization_code', + 'code_verifier=${Uri.encodeQueryComponent(codeVerifier)}', + ]; + + // Only add client_secret if available (for development) + if (clientSecret != null) { + bodyParams.add( + 'client_secret=${Uri.encodeQueryComponent(clientSecret!)}', + ); + } + + final body = bodyParams.join('&'); + + request.write(body); + + final response = await request.close(); + final responseBody = await response.transform(utf8.decoder).join(); + + if (response.statusCode != 200) { + throw AuthenticationFailedException( + 'Token exchange failed with status ${response.statusCode}', + ); + } + + final data = json.decode(responseBody) as Map; + + // Prefer Google ID token (OpenID) to sign in to Firebase and get a Firebase ID token + final googleIdToken = data['id_token'] as String?; + if (googleIdToken == null) { + throw const AuthenticationFailedException( + 'Missing id_token from Google OAuth response', + ); + } + + final firebaseAuth = await _signInWithFirebase(googleIdToken); + + // Parse the Firebase ID token to get its actual expiration time + final idToken = firebaseAuth['idToken'] as String; + final expiresAt = _parseTokenExpiration(idToken); + + final authToken = AuthToken( + // Store Firebase ID token as accessToken to be used for Authorization header + accessToken: idToken, + refreshToken: firebaseAuth['refreshToken'] as String?, + expiresAt: expiresAt, + scopes: _scopes, + ); + + await _configService.storeAuthToken(authToken); + } catch (e) { + throw AuthenticationFailedException('Token exchange failed: $e'); + } finally { + client.close(); + } + } + + /// Parse JWT token to extract expiration time + DateTime _parseTokenExpiration(String token) { + try { + // JWT tokens have 3 parts separated by dots: header.payload.signature + final parts = token.split('.'); + if (parts.length != 3) { + throw const AuthenticationFailedException('Invalid JWT token format'); + } + + // Decode the payload (second part) + final payload = parts[1]; + + // Add padding if needed for base64 decoding + final paddedPayload = payload.padRight( + payload.length + (4 - payload.length % 4) % 4, + '=', + ); + + final decodedPayload = utf8.decode(base64.decode(paddedPayload)); + final payloadJson = json.decode(decodedPayload) as Map; + + // Extract expiration time (exp claim is in seconds since epoch) + final exp = payloadJson['exp'] as int?; + if (exp == null) { + throw const AuthenticationFailedException( + 'Token missing expiration claim', + ); + } + + final expirationTime = DateTime.fromMillisecondsSinceEpoch(exp * 1000); + ConsoleLogger.debug( + 'Parsed token expiration: ${expirationTime.toIso8601String()}', + ); + ConsoleLogger.debug( + 'Token expires in: ${expirationTime.difference(DateTime.now()).inMinutes} minutes', + ); + return expirationTime; + } catch (e) { + // If parsing fails, fall back to a short expiration time + ConsoleLogger.debug( + 'Failed to parse token expiration, using 1 hour fallback: $e', + ); + return DateTime.now().add(const Duration(hours: 1)); + } + } + + /// Extract email from JWT token + String? _extractEmailFromToken(String token) { + try { + // JWT tokens have 3 parts separated by dots: header.payload.signature + final parts = token.split('.'); + if (parts.length != 3) { + return null; + } + + // Decode the payload (second part) + final payload = parts[1]; + + // Add padding if needed for base64 decoding + final paddedPayload = payload.padRight( + payload.length + (4 - payload.length % 4) % 4, + '=', + ); + + final decodedPayload = utf8.decode(base64.decode(paddedPayload)); + final payloadJson = json.decode(decodedPayload) as Map; + + // Extract email from the token (could be in 'email' or 'firebase.identities.email' field) + return payloadJson['email'] as String?; + } catch (e) { + ConsoleLogger.debug('Failed to extract email from token: $e'); + return null; + } + } + + /// Sign in to Firebase using Google ID token to obtain a Firebase ID token + Future> _signInWithFirebase(String googleIdToken) async { + final client = HttpClient(); + try { + final uri = Uri.parse( + 'https://identitytoolkit.googleapis.com/v1/accounts:signInWithIdp?key=$_firebaseApiKey', + ); + + final request = await client.postUrl(uri); + request.headers.contentType = ContentType('application', 'json'); + + final payload = { + 'postBody': 'id_token=$googleIdToken&providerId=google.com', + 'requestUri': 'http://localhost', + 'returnIdpCredential': true, + 'returnSecureToken': true, + }; + request.write(json.encode(payload)); + + final response = await request.close(); + final responseBody = await response.transform(utf8.decoder).join(); + + if (response.statusCode != 200) { + throw AuthenticationFailedException( + 'Firebase signInWithIdp failed (${response.statusCode})', + ); + } + + final data = json.decode(responseBody) as Map; + if (data['idToken'] == null) { + throw const AuthenticationFailedException( + 'No Firebase idToken in response', + ); + } + return data; + } finally { + client.close(); + } + } +} diff --git a/packages/stac_cli/lib/src/services/build_service.dart b/packages/stac_cli/lib/src/services/build_service.dart new file mode 100644 index 000000000..8130b2f8d --- /dev/null +++ b/packages/stac_cli/lib/src/services/build_service.dart @@ -0,0 +1,750 @@ +import 'dart:convert'; +import 'dart:io'; + +import 'package:path/path.dart' as path; +import 'package:stac_cli/src/utils/string_utils.dart'; +import 'package:stac_core/core/stac_options.dart'; + +import '../exceptions/build_exception.dart'; +import '../models/stac_dsl_artifact.dart'; +import '../utils/console_logger.dart'; +import '../utils/file_utils.dart'; + +/// Service for building Dart widget definitions to JSON for Stac SDUI +class BuildService { + /// Build the project from Dart to JSON using analyzer + isolate execution + Future build({String? projectPath}) async { + // Determine project root (directory containing pubspec.yaml) + final projectDir = + projectPath ?? _findProjectRoot() ?? Directory.current.path; + + // Load build configuration from lib/default_stac_options.dart (with defaults) + final options = await _loadBuildConfigFromOptions(projectDir); + + ConsoleLogger.info('Building Stac project...'); + ConsoleLogger.debug('Project directory: $projectDir'); + ConsoleLogger.info('Source directory: ${options.sourceDir}'); + ConsoleLogger.info('Output directory: ${options.outputDir}'); + + // Ensure output directory exists + final outputDirPath = path.join(projectDir, options.outputDir); + await Directory(outputDirPath).create(recursive: true); + + // Clear the output directory before generating new files + await _clearOutputDirectory(outputDirPath); + final screensOutputDir = path.join(outputDirPath, 'screens'); + final themesOutputDir = path.join(outputDirPath, 'themes'); + await Directory(screensOutputDir).create(recursive: true); + await Directory(themesOutputDir).create(recursive: true); + + // Find all .dart files in the source directory + final sourceDirPath = path.join(projectDir, options.sourceDir); + final sourceDir = Directory(sourceDirPath); + if (!await sourceDir.exists()) { + throw const BuildException( + 'Source directory does not exist. Run "stac init" first.', + ); + } + + final dartFiles = await _findDartFiles(sourceDirPath); + if (dartFiles.isEmpty) { + throw const BuildException('No .dart files found in source directory.'); + } + + ConsoleLogger.info('Found ${dartFiles.length} .dart file(s) to process'); + + int functionsProcessed = 0; + int functionsFailed = 0; + int themesProcessed = 0; + int themesFailed = 0; + final failedFiles = []; + final generatedResults = >{}; + + for (final filePath in dartFiles) { + final relativePath = path.relative(filePath, from: projectDir); + ConsoleLogger.debug('Processing: $relativePath'); + try { + final sourceFile = File(filePath); + final analysis = await _analyzeStacFile(sourceFile); + final stacScreenArtifacts = analysis.screenArtifacts; + final stacThemeArtifacts = analysis.themeArtifacts; + + if (stacScreenArtifacts.isEmpty && stacThemeArtifacts.isEmpty) { + ConsoleLogger.debug( + 'No @StacScreen or @StacThemeRef annotations found in $relativePath', + ); + continue; + } + + if (stacScreenArtifacts.isNotEmpty) { + ConsoleLogger.info( + 'Found ${stacScreenArtifacts.length} @StacScreen annotated function(s) in $relativePath', + ); + + await _processArtifacts( + projectDir: projectDir, + sourceFile: sourceFile, + relativePath: relativePath, + artifacts: stacScreenArtifacts, + outputDir: screensOutputDir, + generatedResults: generatedResults, + onSuccess: () => functionsProcessed++, + onFailure: () { + functionsFailed++; + failedFiles.add(relativePath); + }, + ); + } + + if (stacThemeArtifacts.isNotEmpty) { + ConsoleLogger.info( + 'Found ${stacThemeArtifacts.length} @StacThemeRef definition(s) in $relativePath', + ); + + await _processArtifacts( + projectDir: projectDir, + sourceFile: sourceFile, + relativePath: relativePath, + artifacts: stacThemeArtifacts, + outputDir: themesOutputDir, + generatedResults: generatedResults, + onSuccess: () => themesProcessed++, + onFailure: () { + themesFailed++; + failedFiles.add(relativePath); + }, + ); + } + } catch (e) { + functionsFailed++; + failedFiles.add(relativePath); + final message = e is BuildException ? e.message : e.toString(); + ConsoleLogger.error('$relativePath: $message'); + } + } + + // Log build results based on success/failure status + final totalProcessed = functionsProcessed + themesProcessed; + final totalFailed = functionsFailed + themesFailed; + + if (totalProcessed > 0) { + if (totalFailed > 0) { + ConsoleLogger.warning('Build completed with errors.'); + } else { + ConsoleLogger.success('✓ Build completed successfully!'); + } + ConsoleLogger.info( + 'Screens → processed: $functionsProcessed, failed: $functionsFailed | Themes → processed: $themesProcessed, failed: $themesFailed', + ); + if (failedFiles.isNotEmpty) { + ConsoleLogger.warning( + 'Failed files:\n${failedFiles.map((f) => ' - $f').join('\n')}', + ); + throw BuildException( + '$totalFailed file(s) failed to build. Fix the errors above and try again.', + ); + } + } else if (totalFailed > 0) { + throw BuildException( + 'Build failed: $totalFailed file(s) failed to process.\n' + ' Failed: ${failedFiles.join(', ')}', + ); + } else { + throw const BuildException( + 'No @StacScreen or @StacThemeRef annotations found. Add annotations to your screen widgets or themes.', + ); + } + } + + /// Find all .dart files in the source directory recursively + Future> _findDartFiles(String sourceDir) async { + final dartFiles = []; + final dir = Directory(sourceDir); + + await for (final entity in dir.list(recursive: true, followLinks: false)) { + if (entity is File && entity.path.endsWith('.dart')) { + // Skip hidden directories and build directories + if (!entity.path.contains('/.') && !entity.path.contains('.build')) { + dartFiles.add(entity.path); + } + } + } + + return dartFiles; + } + + /// Load build configuration from lib/default_stac_options.dart with sensible defaults + Future _loadBuildConfigFromOptions(String projectDir) async { + final optionsPath = path.join( + projectDir, + 'lib', + 'default_stac_options.dart', + ); + if (!await FileUtils.fileExists(optionsPath)) { + throw const BuildException( + 'Could not find default_stac_options.dart. Run "stac init" first.', + ); + } + + try { + final content = await FileUtils.readFile(optionsPath); + + final name = + RegExp(r"name:\s*'([^']*)'").firstMatch(content)?.group(1) ?? 'Stac'; + + final description = + RegExp(r"description:\s*'([^']*)'").firstMatch(content)?.group(1) ?? + 'Stac'; + + final projectId = + RegExp(r"projectId:\s*'([^']*)'").firstMatch(content)?.group(1) ?? + 'stac'; + + final sourceDir = + RegExp(r"sourceDir:\s*'([^']*)'").firstMatch(content)?.group(1) ?? + 'stac'; + + final outputDir = + RegExp(r"outputDir:\s*'([^']*)'").firstMatch(content)?.group(1) ?? + 'stac/.build'; + + return StacOptions( + name: name, + description: description, + projectId: projectId, + sourceDir: sourceDir, + outputDir: outputDir, + ); + } catch (e) { + // If parsing fails, use defaults + ConsoleLogger.error('Failed to parse build options, using defaults: $e'); + return const StacOptions( + name: 'Stac', + description: 'Stac', + projectId: 'stac', + sourceDir: 'stac', + outputDir: 'stac/.build', + ); + } + } + + /// Forbidden imports that pull in Flutter and cannot be compiled with `dart run`. + static const _forbiddenImports = [ + 'package:stac/stac.dart', + 'package:flutter/', + ]; + + /// Validates that a stac source file does not import Flutter-dependent packages. + /// Returns a non-null error message when a forbidden import is found. + String? _validateImports(String content, String filePath) { + for (final line in content.split('\n')) { + final trimmed = line.trim(); + if (!trimmed.startsWith('import ')) continue; + for (final forbidden in _forbiddenImports) { + if (trimmed.contains(forbidden)) { + return 'imports "$forbidden" which requires Flutter and cannot be ' + 'compiled by the CLI. Use "package:stac/stac_core.dart" instead.'; + } + } + } + return null; + } + + /// Analyze a Dart file and return annotated screen + theme information + Future<_StacFileAnalysis> _analyzeStacFile(File file) async { + final screenArtifacts = []; + final themeArtifacts = []; + + try { + // Read and parse the file manually to find @StacScreen annotated functions + final content = await file.readAsString(); + + // Validate imports before doing any expensive work + final importError = _validateImports(content, file.path); + if (importError != null) { + throw BuildException(importError); + } + + // Use dart analyze command to check if the file is valid Dart code + final analyzeResult = Process.runSync( + 'dart', + ['analyze', '--format=json', file.path], + runInShell: Platform + .isWindows, // Use shell on Windows for proper PATH resolution + ); + + if (analyzeResult.exitCode != 0) { + ConsoleLogger.debug('Dart analyze failed for ${file.path}'); + if (ConsoleLogger.isVerbose) { + ConsoleLogger.debug('Analyze output: ${analyzeResult.stdout}'); + ConsoleLogger.debug('Analyze errors: ${analyzeResult.stderr}'); + } + } + final stacScreenFunctions = _extractDslArtifacts( + content, + type: StacDslArtifactType.screen, + ); + screenArtifacts.addAll(stacScreenFunctions); + final stacThemeDefinitions = _extractDslArtifacts( + content, + type: StacDslArtifactType.theme, + ); + themeArtifacts.addAll(stacThemeDefinitions); + + for (final stacScreenFunction in stacScreenFunctions) { + ConsoleLogger.debug( + 'Found @StacScreen annotated function: ${stacScreenFunction.callableName} -> ${stacScreenFunction.artifactName}', + ); + } + + for (final themeDefinition in stacThemeDefinitions) { + ConsoleLogger.debug( + 'Found @StacThemeRef definition: ${themeDefinition.callableName} -> ${themeDefinition.artifactName} (getter: ${themeDefinition.isGetter})', + ); + } + } on BuildException { + rethrow; + } catch (e, stackTrace) { + ConsoleLogger.debug('Error analyzing ${file.path}: $e'); + if (ConsoleLogger.isVerbose) { + ConsoleLogger.debug('Stack trace: $stackTrace'); + } + } + + return _StacFileAnalysis( + screenArtifacts: screenArtifacts, + themeArtifacts: themeArtifacts, + ); + } + + List _extractDslArtifacts( + String content, { + required StacDslArtifactType type, + }) { + final artifacts = []; + final annotationName = type == StacDslArtifactType.screen + ? 'StacScreen' + : 'StacThemeRef'; + final parameterName = type == StacDslArtifactType.screen + ? 'screenName' + : 'name'; + final callablePrefixPattern = type == StacDslArtifactType.screen + ? r'(?:StacWidget\s+)?' + : r'(?:StacTheme\s+)?'; + final annotationPattern = + "@$annotationName\\s*\\(\\s*(?:$parameterName:\\s*)?([\"'])([^\"']+)\\1[^)]*\\)\\s*"; + const spacerPattern = r'(?:/\*[\s\S]*?\*/|//[^\n]*\n|\s)*'; + + final getterPattern = RegExp( + '$annotationPattern$spacerPattern${callablePrefixPattern}get\\s+(\\w+)\\s*(?:=>|\\{)', + multiLine: true, + dotAll: true, + ); + + final functionPattern = RegExp( + '$annotationPattern$spacerPattern$callablePrefixPattern(\\w+)\\s*\\([^)]*\\)\\s*(?:=>|\\{)', + multiLine: true, + dotAll: true, + ); + + StacDslArtifact createArtifact( + String memberName, + String artifactName, + bool isGetter, + ) { + return type == StacDslArtifactType.screen + ? StacDslArtifact.screen( + functionName: memberName, + screenName: artifactName, + isGetter: isGetter, + ) + : StacDslArtifact.theme( + memberName: memberName, + themeName: artifactName, + isGetter: isGetter, + ); + } + + void addDefinition({ + required String? artifactName, + required String? memberName, + required bool isGetter, + }) { + if (artifactName == null || memberName == null) return; + if (artifacts.any( + (definition) => definition.artifactName == artifactName, + )) { + final label = type == StacDslArtifactType.screen ? 'Screen' : 'Theme'; + throw BuildException( + 'Duplicate $label name "$artifactName" found. ${label}s must be unique.', + ); + } + artifacts.add(createArtifact(memberName, artifactName, isGetter)); + } + + for (final match in getterPattern.allMatches(content)) { + addDefinition( + artifactName: match.group(2), + memberName: match.group(3), + isGetter: true, + ); + } + + for (final match in functionPattern.allMatches(content)) { + addDefinition( + artifactName: match.group(2), + memberName: match.group(3), + isGetter: false, + ); + } + + return artifacts; + } + + /// Execute a specific callable (function or getter) and retrieve its toJson() + Future?> _convertCallableToJson( + File file, + String callableName, + String projectDir, { + bool isGetter = false, + }) async { + ConsoleLogger.debug( + 'Converting ${isGetter ? 'getter' : 'function'} $callableName to JSON', + ); + final scriptContent = _createWrapperScript( + file, + callableName, + projectDir, + isGetter: isGetter, + ); + + // Create a temporary file in the project directory for proper dependency resolution + final tempFile = File( + path.join( + projectDir, + '.stac_temp_${DateTime.now().millisecondsSinceEpoch}.dart', + ), + ); + + try { + await tempFile.writeAsString(scriptContent); + final result = await _executeFileInProject(tempFile, projectDir); + if (result == null) return null; + return result; + } finally { + // Ensure cleanup even if execution fails + try { + if (await tempFile.exists()) { + await tempFile.delete(); + } + } catch (_) { + // Ignore cleanup errors + } + } + } + + String _createWrapperScript( + File originalFile, + String callableName, + String projectDir, { + required bool isGetter, + }) { + // Use relative path from project root to the original file + final relativePath = path.relative(originalFile.path, from: projectDir); + // Convert backslashes to forward slashes for Dart imports + final dartImportPath = relativePath.replaceAll('\\', '/'); + final invocation = isGetter ? 'src.$callableName' : 'src.$callableName()'; + + return ''' +import 'dart:convert'; +import '$dartImportPath' as src; + +Future main(List args) async { + try { + final result = await Future.sync(() => $invocation); + final json = (result as dynamic).toJson(); + print(jsonEncode(json)); + } catch (e, st) { + print(jsonEncode({'error': e.toString(), 'stackTrace': st.toString()})); + } +} +'''; + } + + Future _processArtifacts({ + required String projectDir, + required File sourceFile, + required String relativePath, + required List artifacts, + required String outputDir, + required Map> generatedResults, + required void Function() onSuccess, + required void Function() onFailure, + }) async { + for (final artifact in artifacts) { + try { + final json = await _convertCallableToJson( + sourceFile, + artifact.callableName, + projectDir, + isGetter: artifact.isGetter, + ); + + if (json == null) continue; + final cleaned = _cleanJson(json); + if (cleaned == null) continue; + + final fileName = StringUtils.toSnakeCase(artifact.artifactName); + final outputFilePath = path.join(outputDir, '$fileName.json'); + final jsonString = const JsonEncoder.withIndent(' ').convert(cleaned); + await FileUtils.writeFile(outputFilePath, jsonString); + generatedResults['${artifact.resultKeyPrefix}/$fileName.json'] = + cleaned as Map; + ConsoleLogger.info('✓ Generated ${artifact.logLabel}: $fileName.json'); + onSuccess(); + } catch (e) { + onFailure(); + ConsoleLogger.error( + 'Failed to process ${artifact.logLabel} ${artifact.callableName} in $relativePath: $e', + ); + } + } + } + + Future?> _executeFileInProject( + File scriptFile, + String projectDir, + ) async { + try { + // Execute Dart file in project context for proper dependency resolution + final result = + await Process.run( + 'dart', + ['run', path.basename(scriptFile.path)], + workingDirectory: projectDir, + runInShell: Platform + .isWindows, // Use shell on Windows for proper PATH resolution + ).timeout( + const Duration(seconds: 60), + onTimeout: () { + throw Exception('Script execution timed out after 60 seconds'); + }, + ); + + final stdout = result.stdout.toString(); + final stderr = result.stderr.toString(); + + if (stdout.isNotEmpty) { + try { + // Extract JSON from stdout - build hooks may print text before/after the JSON + final jsonString = _extractJson(stdout); + if (jsonString == null) { + ConsoleLogger.debug('No JSON found in output: $stdout'); + throw const FormatException('No valid JSON object found in output'); + } + final decoded = jsonDecode(jsonString); + if (decoded is Map) { + final decodedMap = decoded.cast(); + // Check if it's an error response + if (decodedMap.containsKey('error')) { + final errorMessage = + decodedMap['error']?.toString() ?? 'Unknown error'; + final errorDetails = decodedMap['details']?.toString(); + final errorStack = decodedMap['stackTrace']?.toString(); + + ConsoleLogger.error('Function execution failed: $errorMessage'); + if (errorDetails != null) { + ConsoleLogger.error('Error details: $errorDetails'); + } + if (errorStack != null && ConsoleLogger.isVerbose) { + ConsoleLogger.error('Stack trace: $errorStack'); + } + ConsoleLogger.debug('Full error response: $decodedMap'); + + throw Exception( + 'Function execution failed: $errorMessage${errorDetails != null ? ' - $errorDetails' : ''}', + ); + } + return decodedMap; + } + } catch (e) { + if (e is Exception) rethrow; + ConsoleLogger.debug('Failed to parse JSON output: $e'); + ConsoleLogger.debug('Raw output: $stdout'); + } + } + + if (result.exitCode != 0) { + ConsoleLogger.debug( + 'Dart execution failed with exit code ${result.exitCode}', + ); + if (ConsoleLogger.isVerbose) { + ConsoleLogger.debug('stderr: $stderr'); + } + throw Exception(_summariseCompilationError(stderr)); + } + return null; + } catch (e) { + ConsoleLogger.debug('Error executing script: $e'); + rethrow; + } + } + + /// Recursively removes null values, empty arrays, and empty objects from JSON + dynamic _cleanJson(dynamic json) { + if (json is Map) { + final cleanedMap = {}; + for (final entry in json.entries) { + final cleanedValue = _cleanJson(entry.value); + if (cleanedValue != null && cleanedValue != [] && cleanedValue != {}) { + cleanedMap[entry.key] = cleanedValue; + } + } + return cleanedMap.isEmpty ? null : cleanedMap; + } else if (json is List) { + final cleanedList = json + .map(_cleanJson) + .where((item) => item != null && item != [] && item != {}) + .toList(); + return cleanedList.isEmpty ? null : cleanedList; + } else if (json is String) { + return StringUtils.fixCharacterEncoding(json); + } + return json; + } + + /// Clear the output directory of all existing files + Future _clearOutputDirectory(String outputDirPath) async { + try { + final outputDir = Directory(outputDirPath); + if (await outputDir.exists()) { + ConsoleLogger.debug('Clearing output directory: $outputDirPath'); + + // Get all files in the directory + final files = await outputDir.list().toList(); + int deletedCount = 0; + + for (final entity in files) { + if (entity is File) { + await entity.delete(); + deletedCount++; + } else if (entity is Directory) { + await entity.delete(recursive: true); + deletedCount++; + } + } + + if (deletedCount > 0) { + ConsoleLogger.debug( + 'Cleared $deletedCount file(s) from output directory', + ); + } + } + } catch (e) { + ConsoleLogger.debug('Warning: Could not clear output directory: $e'); + // Don't throw an error - just log a warning and continue + } + } + + String? _findProjectRoot() { + var current = Directory.current; + while (true) { + final pubspecFile = File(path.join(current.path, 'pubspec.yaml')); + if (pubspecFile.existsSync()) { + return current.path; + } + final parent = current.parent; + if (parent.path == current.path) break; + current = parent; + } + return null; + } + + /// Distils a potentially huge compilation stderr into a short, actionable message. + String _summariseCompilationError(String stderr) { + // Detect Flutter-related compilation errors + final flutterIndicators = [ + "isn't a type", + 'package:flutter/', + 'widgets/', + 'material/', + 'cupertino/', + ]; + + final looksLikeFlutterError = flutterIndicators.any( + (indicator) => stderr.contains(indicator), + ); + + if (looksLikeFlutterError) { + return 'Compilation failed: the file (or one of its dependencies) requires Flutter.\n' + ' Stac screen/theme files must use only pure-Dart packages.\n' + ' Replace "import \'package:stac/stac.dart\'" with ' + '"import \'package:stac/stac_core.dart\'" and try again.'; + } + + // For non-Flutter errors, extract the first meaningful error line + final lines = stderr.split('\n').where((l) => l.trim().isNotEmpty); + final firstError = lines.firstWhere( + (l) => l.contains('Error:'), + orElse: () => + lines.isNotEmpty ? lines.first : 'Unknown compilation error', + ); + + return firstError.trim(); + } + + /// Extract JSON object from stdout that may contain build hook output or other text. + /// Finds the first complete JSON object (matching braces) in the string. + String? _extractJson(String output) { + final startIndex = output.indexOf('{'); + if (startIndex == -1) return null; + + // Find matching closing brace by counting brace depth + int depth = 0; + bool inString = false; + bool escape = false; + + for (int i = startIndex; i < output.length; i++) { + final char = output[i]; + + if (escape) { + escape = false; + continue; + } + + if (char == '\\' && inString) { + escape = true; + continue; + } + + if (char == '"') { + inString = !inString; + continue; + } + + if (inString) continue; + + if (char == '{') { + depth++; + } else if (char == '}') { + depth--; + if (depth == 0) { + return output.substring(startIndex, i + 1); + } + } + } + + return null; // No complete JSON object found + } +} + +/// Simple container for annotated artifacts discovered in a file. +class _StacFileAnalysis { + final List screenArtifacts; + final List themeArtifacts; + + const _StacFileAnalysis({ + required this.screenArtifacts, + required this.themeArtifacts, + }); +} diff --git a/packages/stac_cli/lib/src/services/config_service.dart b/packages/stac_cli/lib/src/services/config_service.dart new file mode 100644 index 000000000..efe487f4f --- /dev/null +++ b/packages/stac_cli/lib/src/services/config_service.dart @@ -0,0 +1,80 @@ +import 'dart:convert'; + +import '../exceptions/stac_exception.dart'; +import '../models/auth_token.dart'; +import '../utils/file_utils.dart'; + +/// Service for managing configuration and persistent storage +class ConfigService { + static ConfigService? _instance; + + /// Singleton instance + static ConfigService get instance { + _instance ??= ConfigService._(); + return _instance!; + } + + ConfigService._(); + + /// Initialize the config service + Future initialize() async { + await FileUtils.ensureConfigDirectory(); + } + + /// Store authentication token + Future storeAuthToken(AuthToken token) async { + try { + final tokenJson = json.encode(token.toJson()); + await FileUtils.writeFile(FileUtils.tokenFilePath, tokenJson); + await FileUtils.enforceFileOwnerOnlyAccess(FileUtils.tokenFilePath); + } catch (e) { + throw StacException('Failed to store auth token: $e'); + } + } + + /// Get stored authentication token + Future getAuthToken() async { + try { + if (!await FileUtils.fileExists(FileUtils.tokenFilePath)) { + return null; + } + + final tokenJson = await FileUtils.readFile(FileUtils.tokenFilePath); + final tokenData = json.decode(tokenJson) as Map; + return AuthToken.fromJson(tokenData); + } catch (e) { + throw StacException('Failed to read auth token: $e'); + } + } + + /// Clear stored authentication token + Future clearAuthToken() async { + try { + await FileUtils.deleteFile(FileUtils.tokenFilePath); + } catch (e) { + throw StacException('Failed to clear auth token: $e'); + } + } + + /// Check if user is authenticated (without attempting refresh) + Future isAuthenticated() async { + final token = await getAuthToken(); + return token != null && !token.isExpired; + } + + /// Check if user has a valid token (including refresh capability) + Future hasValidToken() async { + final token = await getAuthToken(); + if (token == null) { + return false; + } + + // If token is not expired, it's valid + if (!token.isExpired) { + return true; + } + + // If token is expired but we have a refresh token, it might be refreshable + return token.refreshToken != null; + } +} diff --git a/packages/stac_cli/lib/src/services/deploy_service.dart b/packages/stac_cli/lib/src/services/deploy_service.dart new file mode 100644 index 000000000..f450e68bc --- /dev/null +++ b/packages/stac_cli/lib/src/services/deploy_service.dart @@ -0,0 +1,188 @@ +import 'dart:io'; + +import 'package:path/path.dart' as path; + +import '../config/env.dart'; +import '../exceptions/stac_exception.dart'; +import '../utils/console_logger.dart'; +import '../utils/file_utils.dart'; +import '../utils/http_client.dart'; + +/// Service for deploying Stac JSON files to the cloud +class DeployService { + final HttpClientService _httpClient = HttpClientService.instance; + + /// Deploy all built JSON files (from stac/.build) to the Screens API + /// - Reads projectId from lib/default_stac_options.dart + /// - For each {name}.json in stac/.build, POST to Cloud Function "screens" + Future deploy({String? projectPath}) async { + final projectDir = projectPath ?? Directory.current.path; + + // Read projectId from default_stac_options.dart + final projectId = await _readProjectIdFromOptions(projectDir); + if (projectId == null || projectId.isEmpty) { + throw StacException( + 'Could not determine projectId from lib/default_stac_options.dart. Run "stac init" first.', + ); + } + + // Build output directory produced by BuildService + final buildDirPath = path.join(projectDir, 'stac', '.build'); + final buildDir = Directory(buildDirPath); + if (!await buildDir.exists()) { + throw StacException( + 'Build directory not found at $buildDirPath. Run "stac build" first.', + ); + } + + ConsoleLogger.info('Deploying screens/themes to cloud...'); + ConsoleLogger.debug('Project ID: $projectId'); + + final screensDir = Directory(path.join(buildDirPath, 'screens')); + final themesDir = Directory(path.join(buildDirPath, 'themes')); + + final screensApiUrl = _resolveScreensApiUrl(); + final themesApiUrl = _resolveThemesApiUrl(); + ConsoleLogger.debug('Screens API: $screensApiUrl'); + ConsoleLogger.debug('Themes API: $themesApiUrl'); + + int screenSuccess = 0; + int screenFail = 0; + int themeSuccess = 0; + int themeFail = 0; + + if (await screensDir.exists()) { + await for (final entity in screensDir.list()) { + if (entity is! File || !entity.path.endsWith('.json')) continue; + + final fileName = path.basename(entity.path); + final screenName = fileName.replaceAll('.json', ''); + ConsoleLogger.info('Uploading screen: $fileName'); + + try { + final jsonString = await entity.readAsString(); + await _uploadScreen( + screensApiUrl: screensApiUrl, + projectId: projectId, + screenName: screenName, + stacJson: jsonString, + ); + ConsoleLogger.success('✓ Uploaded screen: $fileName'); + screenSuccess++; + } catch (e) { + ConsoleLogger.error('✗ Failed screen: $fileName — $e'); + screenFail++; + } + } + } else { + ConsoleLogger.warning( + 'Screens output directory not found at ${screensDir.path}. Skipping screen uploads.', + ); + } + + if (await themesDir.exists()) { + await for (final entity in themesDir.list()) { + if (entity is! File || !entity.path.endsWith('.json')) continue; + + final fileName = path.basename(entity.path); + final themeName = fileName.replaceAll('.json', ''); + ConsoleLogger.info('Uploading theme: $fileName'); + + try { + final jsonString = await entity.readAsString(); + await _uploadTheme( + themesApiUrl: themesApiUrl, + projectId: projectId, + themeName: themeName, + themeJson: jsonString, + ); + ConsoleLogger.success('✓ Uploaded theme: $fileName'); + themeSuccess++; + } catch (e) { + ConsoleLogger.error('✗ Failed theme: $fileName — $e'); + themeFail++; + } + } + } else { + ConsoleLogger.info( + 'No theme output found at ${themesDir.path}. Skipping theme uploads.', + ); + } + + final totalFailures = screenFail + themeFail; + if (totalFailures == 0) { + ConsoleLogger.success('✓ Deployment completed successfully!'); + } else { + ConsoleLogger.warning('⚠️ Deployment completed with issues'); + } + ConsoleLogger.info( + 'Screens → success: $screenSuccess, failed: $screenFail | Themes → success: $themeSuccess, failed: $themeFail', + ); + } + + /// Upload a single screen JSON to Cloud Functions API + Future _uploadScreen({ + required String screensApiUrl, + required String projectId, + required String screenName, + required String stacJson, + }) async { + try { + await _httpClient.post( + screensApiUrl, + data: { + 'projectId': projectId, + 'screenName': screenName, + 'stacJson': stacJson, + }, + ); + } catch (e) { + throw StacException('Failed to upload screen "$screenName": $e'); + } + } + + /// Upload a single theme JSON to Cloud Functions API + Future _uploadTheme({ + required String themesApiUrl, + required String projectId, + required String themeName, + required String themeJson, + }) async { + try { + await _httpClient.post( + themesApiUrl, + data: { + 'projectId': projectId, + 'themeName': themeName, + 'themeJson': themeJson, + }, + ); + } catch (e) { + throw StacException('Failed to upload theme "$themeName": $e'); + } + } + + /// Extract projectId from lib/default_stac_options.dart + Future _readProjectIdFromOptions(String projectDir) async { + final optionsPath = path.join( + projectDir, + 'lib', + 'default_stac_options.dart', + ); + if (!await FileUtils.fileExists(optionsPath)) return null; + final content = await FileUtils.readFile(optionsPath); + final match = RegExp(r"projectId:\s*'([^']*)'").firstMatch(content); + return match?.group(1); + } + + /// Resolve Cloud Function endpoint for screens.save + String _resolveScreensApiUrl() { + // Use current environment's base URL + /screens endpoint + return '${env.baseApiUrl}/screens'; + } + + /// Resolve Cloud Function endpoint for themes.save + String _resolveThemesApiUrl() { + return '${env.baseApiUrl}/themes'; + } +} diff --git a/packages/stac_cli/lib/src/services/project_service.dart b/packages/stac_cli/lib/src/services/project_service.dart new file mode 100644 index 000000000..474ca94fb --- /dev/null +++ b/packages/stac_cli/lib/src/services/project_service.dart @@ -0,0 +1,100 @@ +import 'package:interact/interact.dart'; +import 'package:stac_cli/src/models/project/project.dart'; +import 'package:stac_cli/src/models/project/project_access.dart'; + +import '../exceptions/stac_exception.dart'; +import '../utils/console_logger.dart'; +import '../utils/http_client.dart'; + +/// Service for managing Stac SDUI projects on the cloud +class ProjectService { + final HttpClientService _httpClient = HttpClientService.instance; + + /// Fetch all projects from Firebase Functions (requires Firebase ID token) + Future> fetchProjects({ + ProjectAccess minAccess = ProjectAccess.editor, + }) async { + try { + ConsoleLogger.debug('Fetching projects from cloud functions...'); + + final response = await _httpClient.get( + '/projects', + queryParameters: {'minAccess': minAccess.name}, + ); + final data = response.data as Map; + final projectsJson = (data['projects'] as List?) ?? const []; + + return projectsJson + .map((json) => Project.fromJson(json as Map)) + .toList(); + } catch (e) { + throw StacException('Failed to fetch projects: $e', cause: e); + } + } + + /// Create a new project via Firebase Functions + Future createProject({ + required String name, + required String description, + }) async { + try { + final slug = _generateSlug(name); + ConsoleLogger.debug('Creating project: $name (slug: $slug)'); + + final response = await _httpClient.post( + '/projects', + data: {'name': name, 'slug': slug, 'description': description}, + ); + + return Project.fromJson(response.data as Map); + } catch (e) { + throw StacException('Failed to create project: $e', cause: e); + } + } + + /// Show interactive project selection menu + Future selectProjectInteractively() async { + List projects = []; + + await ConsoleLogger.showLoader( + 'Fetching projects from Stac cloud', + () async { + projects = await fetchProjects(); + }, + ); + + if (projects.isEmpty) { + ConsoleLogger.info('No projects found. Create a new project first.'); + return null; + } + + final options = projects.map((p) => p.name).toList(); + final selection = Select( + prompt: 'Select a project to initialize:', + options: options, + ).interact(); + + return projects[selection]; + } + + /// Generate URL-friendly slug from project name + static String _generateSlug(String name) { + final slug = name + .toLowerCase() + .replaceAll(_nonAlphanumericRegex, '') + .replaceAll(_whitespaceRegex, '-') + .replaceAll(_multipleHyphensRegex, '-') + .trim(); + + if (slug.isEmpty) { + throw StacException( + 'Project name must contain at least one alphanumeric character', + ); + } + return slug; + } + + static final _nonAlphanumericRegex = RegExp(r'[^a-z0-9\s-]'); + static final _whitespaceRegex = RegExp(r'\s+'); + static final _multipleHyphensRegex = RegExp(r'-+'); +} diff --git a/packages/stac_cli/lib/src/services/upgrade_service.dart b/packages/stac_cli/lib/src/services/upgrade_service.dart new file mode 100644 index 000000000..617aff8c5 --- /dev/null +++ b/packages/stac_cli/lib/src/services/upgrade_service.dart @@ -0,0 +1,581 @@ +import 'dart:convert'; +import 'dart:io'; + +import 'package:cryptography/cryptography.dart'; +import 'package:path/path.dart' as path; + +import '../exceptions/stac_exception.dart'; +import '../utils/console_logger.dart'; + +/// Result of checking for updates +class UpgradeCheckResult { + final String currentVersion; + final String latestVersion; + final String downloadUrl; + final bool updateAvailable; + + UpgradeCheckResult({ + required this.currentVersion, + required this.latestVersion, + required this.downloadUrl, + required this.updateAvailable, + }); +} + +/// Service for upgrading the Stac CLI +class UpgradeService { + static const String _repo = 'StacDev/cli-installer'; + static const Set _allowedDownloadHosts = { + 'github.com', + 'objects.githubusercontent.com', + 'github-releases.githubusercontent.com', + 'release-assets.githubusercontent.com', + }; + static const String _publicKeyEnv = 'STAC_UPGRADE_PUBLIC_KEY_B64'; + + /// Check for available updates + Future checkForUpdates() async { + final currentVersion = await getCurrentVersion(); + final latestInfo = await _getLatestRelease(); + final latestVersion = latestInfo['version'] as String; + final downloadUrl = latestInfo['downloadUrl'] as String; + + final updateAvailable = _isNewerVersion(currentVersion, latestVersion); + + return UpgradeCheckResult( + currentVersion: currentVersion, + latestVersion: latestVersion, + downloadUrl: downloadUrl, + updateAvailable: updateAvailable, + ); + } + + /// Get the current installed version + Future getCurrentVersion() async { + try { + final result = await Process.run( + Platform.isWindows ? 'stac.exe' : 'stac', + ['--version'], + runInShell: false, + ); + if (result.exitCode == 0) { + final output = result.stdout.toString().trim(); + // Parse "stac_cli version: X.Y.Z" or "stac_cli version: X.Y.Z-dev" + final match = RegExp(r'version:\s*(\S+)').firstMatch(output); + if (match != null) { + return match.group(1)!.replaceAll('-dev', ''); + } + } + } catch (_) {} + return 'unknown'; + } + + /// Get the download URL for a specific version + Future getDownloadUrlForVersion(String version) async { + final osArch = detectOsArch(); + if (osArch == null) { + throw StacException('Unsupported platform'); + } + + final tag = 'stac-cli-v$version'; + final os = osArch['os']!; + final arch = osArch['arch']!; + + String extension; + if (os == 'windows') { + extension = 'zip'; + } else { + extension = 'tar.gz'; + } + + final assetName = 'stac_cli_${version}_${os}_$arch.$extension'; + return 'https://github.com/$_repo/releases/download/$tag/$assetName'; + } + + /// Upgrade to a specific version + Future upgradeTo({ + required String version, + required String downloadUrl, + }) async { + final osArch = detectOsArch(); + if (osArch == null) { + throw StacException('Unsupported platform'); + } + + _validateDownloadUri(Uri.parse(downloadUrl)); + await _downloadAndInstall(downloadUrl, osArch); + } + + /// Detect OS and architecture + Map? detectOsArch() { + String os; + String arch; + + if (Platform.isMacOS) { + os = 'darwin'; + } else if (Platform.isLinux) { + os = 'linux'; + } else if (Platform.isWindows) { + os = 'windows'; + } else { + return null; + } + + // Detect architecture + final envArch = Platform.environment['PROCESSOR_ARCHITECTURE'] ?? ''; + if (Platform.isMacOS || Platform.isLinux) { + // Use uname to detect arch on Unix systems + try { + final result = Process.runSync('uname', ['-m']); + final machineArch = result.stdout.toString().trim().toLowerCase(); + if (machineArch.contains('arm64') || machineArch.contains('aarch64')) { + arch = 'arm64'; + } else { + arch = 'x64'; + } + } catch (_) { + arch = 'x64'; + } + } else { + // Windows + if (envArch == 'ARM64') { + arch = 'arm64'; + } else { + arch = 'x64'; + } + } + + return {'os': os, 'arch': arch}; + } + + /// Check if latest version is newer than current + bool _isNewerVersion(String current, String latest) { + if (current == 'unknown') return true; + + try { + final currentParts = current.split('.').map(int.parse).toList(); + final latestParts = latest.split('.').map(int.parse).toList(); + + for (var i = 0; i < 3; i++) { + final c = i < currentParts.length ? currentParts[i] : 0; + final l = i < latestParts.length ? latestParts[i] : 0; + if (l > c) return true; + if (l < c) return false; + } + return false; + } catch (_) { + return true; + } + } + + /// Get latest release info from GitHub + Future> _getLatestRelease() async { + final client = HttpClient(); + try { + final request = await client.getUrl( + Uri.parse('https://api.github.com/repos/$_repo/releases/latest'), + ); + request.headers.set('Accept', 'application/vnd.github.v3+json'); + request.headers.set('User-Agent', 'stac-cli'); + + final response = await request.close(); + if (response.statusCode != 200) { + throw StacException( + 'Failed to fetch latest release: HTTP ${response.statusCode}', + ); + } + + final body = await response.transform(utf8.decoder).join(); + final json = jsonDecode(body) as Map; + + final tagName = json['tag_name'] as String; + final version = tagName.replaceFirst('stac-cli-v', ''); + + // Find the appropriate asset for this platform + final osArch = detectOsArch(); + final assets = json['assets'] as List; + String? downloadUrl; + String? binaryAssetName; + + for (final asset in assets) { + final name = asset['name'] as String; + if (_isMatchingAsset(name, osArch!)) { + downloadUrl = asset['browser_download_url'] as String; + binaryAssetName = name; + break; + } + } + + if (downloadUrl == null || binaryAssetName == null) { + throw StacException('No compatible binary found for your platform'); + } + + final checksumUrl = _findChecksumAssetUrl( + assets: assets, + binaryAssetName: binaryAssetName, + ); + + return { + 'version': version, + 'downloadUrl': downloadUrl, + 'checksumUrl': checksumUrl, + }; + } finally { + client.close(); + } + } + + String? _findChecksumAssetUrl({ + required List assets, + required String binaryAssetName, + }) { + final checksumCandidates = { + '$binaryAssetName.sha256', + '$binaryAssetName.sha256sum', + '$binaryAssetName.sha256.txt', + }; + + for (final asset in assets) { + final name = asset['name'] as String; + if (checksumCandidates.contains(name)) { + return asset['browser_download_url'] as String; + } + } + + return null; + } + + bool _isMatchingAsset(String assetName, Map osArch) { + final os = osArch['os']!; + final arch = osArch['arch']!; + return assetName.contains(os) && assetName.contains(arch); + } + + /// Download and install the CLI binary + Future _downloadAndInstall( + String url, + Map osArch, + ) async { + final os = osArch['os']!; + final isWindows = os == 'windows'; + + // Create temp directory + final tempDir = await Directory.systemTemp.createTemp('stac_upgrade_'); + final archivePath = path.join( + tempDir.path, + isWindows ? 'stac.zip' : 'stac.tar.gz', + ); + + try { + // Download the archive + ConsoleLogger.info('Downloading...'); + await _downloadFile(url, archivePath); + + final checksumPath = path.join(tempDir.path, 'stac.sha256'); + final checksumFound = await _downloadChecksumFile( + binaryUrl: url, + checksumDestinationPath: checksumPath, + ); + if (!checksumFound) { + throw StacException( + 'Release checksum file is missing. Refusing to install unverified binary.', + ); + } + + final checksumSigPath = path.join(tempDir.path, 'stac.sha256.sig'); + final signatureFound = await _downloadChecksumSignatureFile( + binaryUrl: url, + signatureDestinationPath: checksumSigPath, + ); + if (!signatureFound) { + throw StacException( + 'Release checksum signature file is missing. Refusing to install unverified binary.', + ); + } + + final publicKeyB64 = Platform.environment[_publicKeyEnv]; + if (publicKeyB64 == null || publicKeyB64.trim().isEmpty) { + throw StacException( + 'Missing $_publicKeyEnv. Refusing to install without signature verification key.', + ); + } + await _verifyChecksumSignature( + checksumFilePath: checksumPath, + signatureFilePath: checksumSigPath, + publicKeyBase64: publicKeyB64, + ); + await _verifySha256(archivePath, checksumPath); + + // Extract the archive + ConsoleLogger.info('Extracting...'); + if (isWindows) { + await _extractZip(archivePath, tempDir.path); + } else { + await _extractTarGz(archivePath, tempDir.path); + } + + // Determine install directory + final installDir = _getInstallDir(); + await Directory(installDir).create(recursive: true); + + // Copy the binary + final binaryName = isWindows ? 'stac.exe' : 'stac'; + final sourcePath = path.join(tempDir.path, binaryName); + final destPath = path.join(installDir, binaryName); + + ConsoleLogger.info('Installing to $destPath...'); + + // On Unix, we might need to handle the case where the binary is in use + if (!isWindows) { + // Try to remove old binary first + try { + final oldFile = File(destPath); + if (await oldFile.exists()) { + await oldFile.delete(); + } + } catch (_) {} + } + + await File(sourcePath).copy(destPath); + + // Make executable on Unix + if (!isWindows) { + await Process.run('chmod', ['+x', destPath]); + } + } finally { + // Cleanup + try { + await tempDir.delete(recursive: true); + } catch (_) {} + } + } + + Future _downloadFile(String url, String destPath) async { + final uri = Uri.parse(url); + _validateDownloadUri(uri); + + final client = HttpClient(); + try { + final request = await client.getUrl(uri); + request.followRedirects = false; + request.headers.set('User-Agent', 'stac-cli'); + + final response = await request.close(); + + // Handle redirects manually to enforce host allowlist. + if (response.statusCode == 302 || + response.statusCode == 301 || + response.statusCode == 307 || + response.statusCode == 308) { + final redirectUrl = response.headers.value('location'); + if (redirectUrl != null) { + await response.drain(); + final nextUri = uri.resolve(redirectUrl); + _validateDownloadUri(nextUri); + return _downloadFile(nextUri.toString(), destPath); + } + } + + if (response.statusCode != 200) { + throw StacException('Failed to download: HTTP ${response.statusCode}'); + } + + final file = File(destPath); + final sink = file.openWrite(); + await response.pipe(sink); + await sink.close(); + } finally { + client.close(); + } + } + + Future _downloadChecksumFile({ + required String binaryUrl, + required String checksumDestinationPath, + }) async { + final candidates = [ + '$binaryUrl.sha256', + '$binaryUrl.sha256sum', + '$binaryUrl.sha256.txt', + ]; + + for (final candidate in candidates) { + try { + await _downloadFile(candidate, checksumDestinationPath); + return true; + } catch (_) { + // Try next naming convention. + } + } + return false; + } + + Future _downloadChecksumSignatureFile({ + required String binaryUrl, + required String signatureDestinationPath, + }) async { + final candidates = [ + '$binaryUrl.sha256.sig', + '$binaryUrl.sha256sum.sig', + '$binaryUrl.sha256.txt.sig', + ]; + + for (final candidate in candidates) { + try { + await _downloadFile(candidate, signatureDestinationPath); + return true; + } catch (_) { + // Try next naming convention. + } + } + return false; + } + + Future _extractTarGz(String archivePath, String destDir) async { + final result = await Process.run('tar', [ + '-xzf', + archivePath, + '-C', + destDir, + ], runInShell: false); + if (result.exitCode != 0) { + throw StacException('Failed to extract archive: ${result.stderr}'); + } + } + + Future _extractZip(String archivePath, String destDir) async { + // Use PowerShell to extract on Windows + final result = await Process.run('powershell', [ + '-Command', + 'Expand-Archive', + '-Path', + archivePath, + '-DestinationPath', + destDir, + '-Force', + ], runInShell: false); + if (result.exitCode != 0) { + throw StacException('Failed to extract archive: ${result.stderr}'); + } + } + + void _validateDownloadUri(Uri uri) { + if (uri.scheme != 'https') { + throw StacException('Refusing non-HTTPS download URL: $uri'); + } + if (!_allowedDownloadHosts.contains(uri.host)) { + throw StacException('Refusing download from untrusted host: ${uri.host}'); + } + } + + Future _verifySha256(String filePath, String checksumFilePath) async { + final checksumContents = await File(checksumFilePath).readAsString(); + final expected = _parseSha256(checksumContents); + final algorithm = Sha256(); + final sink = algorithm.newHashSink(); + await File(filePath).openRead().forEach(sink.add); + sink.close(); + final hash = await sink.hash(); + final actual = hash.bytes + .map((b) => b.toRadixString(16).padLeft(2, '0')) + .join(); + + if (expected.toLowerCase() != actual.toLowerCase()) { + throw StacException( + 'Binary integrity check failed (SHA-256 mismatch). Installation aborted.', + ); + } + } + + Future _verifyChecksumSignature({ + required String checksumFilePath, + required String signatureFilePath, + required String publicKeyBase64, + }) async { + final checksumBytes = await File(checksumFilePath).readAsBytes(); + final signatureContents = await File(signatureFilePath).readAsString(); + + final signatureBytes = _parseSignatureBytes(signatureContents); + final publicKeyBytes = _parseBase64Bytes( + value: publicKeyBase64, + context: _publicKeyEnv, + ); + + final algorithm = Ed25519(); + final isValid = await algorithm.verify( + checksumBytes, + signature: Signature( + signatureBytes, + publicKey: SimplePublicKey(publicKeyBytes, type: KeyPairType.ed25519), + ), + ); + if (!isValid) { + throw StacException( + 'Checksum signature verification failed. Installation aborted.', + ); + } + } + + /// Test hook to validate a downloaded bundle end-to-end. + Future verifyDownloadedBundleForTesting({ + required String archivePath, + required String checksumPath, + required String signaturePath, + required String publicKeyBase64, + }) async { + await _verifyChecksumSignature( + checksumFilePath: checksumPath, + signatureFilePath: signaturePath, + publicKeyBase64: publicKeyBase64, + ); + await _verifySha256(archivePath, checksumPath); + } + + String _parseSha256(String checksumFileContents) { + final firstLine = checksumFileContents + .split('\n') + .map((line) => line.trim()) + .firstWhere((line) => line.isNotEmpty, orElse: () => ''); + final parts = firstLine.split(RegExp(r'\s+')); + if (parts.isEmpty || parts.first.isEmpty) { + throw StacException('Invalid checksum file format'); + } + return parts.first; + } + + List _parseSignatureBytes(String signatureFileContents) { + final firstLine = signatureFileContents + .split('\n') + .map((line) => line.trim()) + .firstWhere((line) => line.isNotEmpty, orElse: () => ''); + return _parseBase64Bytes(value: firstLine, context: 'checksum signature'); + } + + List _parseBase64Bytes({ + required String value, + required String context, + }) { + try { + return base64.decode(value.trim()); + } catch (_) { + throw StacException('Invalid base64 value for $context'); + } + } + + String _getInstallDir() { + // Check if STAC_INSTALL_DIR is set + final envDir = Platform.environment['STAC_INSTALL_DIR']; + if (envDir != null && envDir.isNotEmpty) { + return envDir; + } + + // Default to ~/.stac/bin + if (Platform.isWindows) { + final userProfile = Platform.environment['USERPROFILE'] ?? ''; + return path.join(userProfile, '.stac', 'bin'); + } else { + final home = Platform.environment['HOME'] ?? ''; + return path.join(home, '.stac', 'bin'); + } + } +} diff --git a/packages/stac_cli/lib/src/utils/console_logger.dart b/packages/stac_cli/lib/src/utils/console_logger.dart new file mode 100644 index 000000000..ffd9a184e --- /dev/null +++ b/packages/stac_cli/lib/src/utils/console_logger.dart @@ -0,0 +1,191 @@ +import 'dart:async'; +import 'dart:io'; + +/// Log levels for console output +enum LogLevel { debug, info, warning, error, success } + +/// Simple console logger for the Stac CLI +class ConsoleLogger { + static bool _verbose = false; + + /// Enable or disable verbose logging + static void setVerbose(bool verbose) { + _verbose = verbose; + } + + /// Check if verbose logging is enabled + static bool get isVerbose => _verbose; + + /// Log a debug message (only shown in verbose mode) + static void debug(String message) { + if (_verbose) { + _log(message, LogLevel.debug, '\x1B[90m'); // Gray + } + } + + /// Log an info message + static void info(String message) { + _log(message, LogLevel.info, '\x1B[34m'); // Blue + } + + /// Log a success message + static void success(String message) { + _log(message, LogLevel.success, '\x1B[32m'); // Green + } + + /// Log a warning message + static void warning(String message) { + _log(message, LogLevel.warning, '\x1B[33m'); // Yellow + } + + /// Log an error message + static void error(String message) { + _log(message, LogLevel.error, '\x1B[31m'); // Red + } + + /// Print a message without any prefix or color + static void plain(String message) { + print(message); + } + + /// Print a green ASCII-art banner that spells STAC using '#' + static void printStacAscii() { + // 7-line block letters for S T A C (10 cols each) + const s = [ + ' ###### ', + ' ## ## ', + ' ## ', + ' ##### ', + ' ## ', + ' ## ## ', + ' ###### ', + ]; + const t = [ + ' ######## ', + ' ## ', + ' ## ', + ' ## ', + ' ## ', + ' ## ', + ' ## ', + ]; + const a = [ + ' #### ', + ' ## ## ', + ' ## ## ', + ' ######## ', + ' ## ## ', + ' ## ## ', + ' ## ## ', + ]; + const c = [ + ' ###### ', + ' ## ## ', + ' ## ', + ' ## ', + ' ## ', + ' ## ## ', + ' ###### ', + ]; + + // Two empty lines above + stdout.writeln(''); + stdout.writeln(''); + + final width = _terminalWidth(); + for (var i = 0; i < s.length; i++) { + final raw = '${s[i]}${t[i]}${a[i]} ${c[i]}'; + var pad = _leftPaddingForCenter(raw.length, width); + final line = '${' ' * pad}$raw'; + if (stdout.hasTerminal) { + // Bold + truecolor mint: #7FFFBB -> 127,255,187 + stdout.writeln('\x1B[1;38;2;127;255;187m$line\x1B[0m'); + } else { + stdout.writeln(line); + } + } + + // Two empty lines below + stdout.writeln(''); + stdout.writeln(''); + } + + static int _terminalWidth() { + try { + final colsEnv = Platform.environment['COLUMNS']; + final parsed = int.tryParse(colsEnv ?? ''); + if (parsed != null && parsed > 0) return parsed; + } catch (_) {} + return 80; + } + + static int _leftPaddingForCenter(int contentLength, int totalWidth) { + final remaining = totalWidth - contentLength; + if (remaining <= 0) return 0; + return remaining ~/ 4; + } + + /// Print a spinner with a message (for long-running operations) + static void spinner(String message) { + stdout.write('\x1B[34m⠋\x1B[0m $message'); + } + + /// Clear the current line (useful after spinner) + static void clearLine() { + stdout.write('\r\x1B[K'); + } + + /// Show a loading message with animated dots + static Future showLoader( + String message, + Future Function() task, + ) async { + const dots = ['', '.', '..', '...']; + var dotIndex = 0; + + // Start the loading animation + Timer? timer; + timer = Timer.periodic(const Duration(milliseconds: 500), (timer) { + if (stdout.hasTerminal) { + stdout.write('\r\x1B[K'); // Clear line + stdout.write('\x1B[34m$message${dots[dotIndex]}\x1B[0m'); // Blue color + } + dotIndex = (dotIndex + 1) % dots.length; + }); + + try { + await task(); + } finally { + timer.cancel(); + if (stdout.hasTerminal) { + stdout.write('\r\x1B[K'); // Clear the loader line + } + } + } + + static void _log(String message, LogLevel level, String color) { + final prefix = _getPrefix(level); + final reset = '\x1B[0m'; + + if (stdout.hasTerminal) { + print('$color$prefix$reset $message'); + } else { + print('$prefix $message'); + } + } + + static String _getPrefix(LogLevel level) { + switch (level) { + case LogLevel.debug: + return '[DEBUG]'; + case LogLevel.info: + return '[INFO]'; + case LogLevel.warning: + return '[WARN]'; + case LogLevel.error: + return '[ERROR]'; + case LogLevel.success: + return '[SUCCESS]'; + } + } +} diff --git a/packages/stac_cli/lib/src/utils/date_time_utils.dart b/packages/stac_cli/lib/src/utils/date_time_utils.dart new file mode 100644 index 000000000..13ffe5e06 --- /dev/null +++ b/packages/stac_cli/lib/src/utils/date_time_utils.dart @@ -0,0 +1,70 @@ +import 'package:json_annotation/json_annotation.dart'; + +/// Utility functions for date and time parsing +class DateTimeUtils { + DateTimeUtils._(); + + /// Parses a dynamic date value to DateTime. + /// + /// Handles: + /// - null values (returns null) + /// - DateTime objects (returns as-is) + /// - String values (parses ISO 8601 format) + /// - Firestore Timestamp maps with `_seconds`/`seconds` keys + /// - Other types (returns null) + static DateTime? parseDateTime(dynamic dateValue) { + if (dateValue == null) return null; + if (dateValue is DateTime) return dateValue; + if (dateValue is String) { + try { + return DateTime.parse(dateValue); + } catch (e) { + return null; + } + } + // Handle Firestore Timestamp map format + if (dateValue is Map) { + final seconds = dateValue['_seconds'] ?? dateValue['seconds']; + if (seconds is int) { + final nanoseconds = + (dateValue['_nanoseconds'] ?? dateValue['nanoseconds'] ?? 0) as int; + return DateTime.fromMillisecondsSinceEpoch( + seconds * 1000 + (nanoseconds ~/ 1000000), + ); + } + } + return null; + } +} + +/// JSON converter for DateTime that handles Firestore Timestamps. +/// +/// Use with `@FirestoreDateTime()` annotation on DateTime fields. +class FirestoreDateTime implements JsonConverter { + const FirestoreDateTime(); + + @override + DateTime fromJson(dynamic json) { + final parsed = DateTimeUtils.parseDateTime(json); + if (parsed == null) { + throw FormatException('Cannot parse DateTime from: $json'); + } + return parsed; + } + + @override + String toJson(DateTime object) => object.toIso8601String(); +} + +/// JSON converter for nullable DateTime that handles Firestore Timestamps. +/// +/// Use with `@FirestoreDateTimeNullable()` annotation on DateTime? fields. +class FirestoreDateTimeNullable implements JsonConverter { + const FirestoreDateTimeNullable(); + + @override + DateTime? fromJson(dynamic json) => DateTimeUtils.parseDateTime(json); + + @override + String? toJson(DateTime? object) => object?.toIso8601String(); +} diff --git a/packages/stac_cli/lib/src/utils/file_utils.dart b/packages/stac_cli/lib/src/utils/file_utils.dart new file mode 100644 index 000000000..2e7375a60 --- /dev/null +++ b/packages/stac_cli/lib/src/utils/file_utils.dart @@ -0,0 +1,117 @@ +import 'dart:io'; + +import 'package:path/path.dart' as path; +import 'package:yaml/yaml.dart'; +import 'console_logger.dart'; + +/// Utility functions for file operations +class FileUtils { + /// Get the user's home directory + static String get homeDirectory { + if (Platform.isWindows) { + return Platform.environment['USERPROFILE'] ?? ''; + } + return Platform.environment['HOME'] ?? ''; + } + + /// Get the Stac CLI configuration directory + static String get configDirectory { + final home = homeDirectory; + if (Platform.isWindows) { + return path.join(home, 'AppData', 'Local', 'stac_cli'); + } + return path.join(home, '.stac'); + } + + /// Get the path to the auth token file + static String get tokenFilePath { + return path.join(configDirectory, 'auth.json'); + } + + /// Get the path to the main config file + static String get configFilePath { + return path.join(configDirectory, 'config.yaml'); + } + + /// Ensure the config directory exists + static Future ensureConfigDirectory() async { + final dir = Directory(configDirectory); + if (!await dir.exists()) { + await dir.create(recursive: true); + } + await enforceDirectoryOwnerOnlyAccess(configDirectory); + } + + /// Check if a file exists + static Future fileExists(String filePath) async { + return await File(filePath).exists(); + } + + /// Read a file as string + static Future readFile(String filePath) async { + return await File(filePath).readAsString(); + } + + /// Write a string to a file + static Future writeFile(String filePath, String content) async { + await File(filePath).writeAsString(content); + } + + /// Delete a file + static Future deleteFile(String filePath) async { + final file = File(filePath); + if (await file.exists()) { + await file.delete(); + } + } + + /// Read and parse a YAML file + static Future?> readYamlFile(String filePath) async { + if (!await fileExists(filePath)) { + return null; + } + + final content = await readFile(filePath); + final yaml = loadYaml(content); + + if (yaml is Map) { + return Map.from(yaml); + } + + return null; + } + + /// Best-effort owner-only directory permissions on Unix-like systems. + static Future enforceDirectoryOwnerOnlyAccess( + String directoryPath, + ) async { + if (Platform.isWindows) return; + try { + final result = await Process.run('chmod', ['700', directoryPath]); + if (result.exitCode != 0) { + throw Exception(result.stderr); + } + } catch (e) { + ConsoleLogger.warning( + 'Could not enforce secure directory permissions for $directoryPath', + ); + ConsoleLogger.debug('chmod 700 failed: $e'); + } + } + + /// Best-effort owner-only file permissions on Unix-like systems. + static Future enforceFileOwnerOnlyAccess(String filePath) async { + if (Platform.isWindows) return; + try { + final result = await Process.run('chmod', ['600', filePath]); + if (result.exitCode != 0) { + throw Exception(result.stderr); + } + } catch (e) { + ConsoleLogger.warning( + 'Could not enforce secure file permissions for $filePath', + ); + ConsoleLogger.debug('chmod 600 failed: $e'); + } + } +} diff --git a/packages/stac_cli/lib/src/utils/http_client.dart b/packages/stac_cli/lib/src/utils/http_client.dart new file mode 100644 index 000000000..e449db1a6 --- /dev/null +++ b/packages/stac_cli/lib/src/utils/http_client.dart @@ -0,0 +1,144 @@ +import 'package:dio/dio.dart'; +import '../config/env.dart'; + +import '../exceptions/stac_exception.dart'; +import '../services/auth_service.dart'; +import '../utils/console_logger.dart'; + +/// HTTP client wrapper for making API requests to Stac cloud services +class HttpClientService { + static HttpClientService? _instance; + late final Dio _dio; + final AuthService _authService = AuthService(); + + /// Singleton instance + static HttpClientService get instance { + _instance ??= HttpClientService._(); + return _instance!; + } + + HttpClientService._() { + _dio = Dio( + BaseOptions( + baseUrl: env.baseApiUrl, + connectTimeout: const Duration(seconds: 30), + receiveTimeout: const Duration(seconds: 30), + headers: {'Content-Type': 'application/json'}, + ), + ); + + // Add request interceptor for authentication + _dio.interceptors.add( + InterceptorsWrapper( + onRequest: (options, handler) async { + // Try to refresh token if needed and add auth header + final token = await _authService.refreshTokenIfNeeded(); + if (token != null) { + options.headers['Authorization'] = 'Bearer ${token.accessToken}'; + ConsoleLogger.debug('Auth header attached'); + } + ConsoleLogger.debug('HTTP ${options.method} → ${options.uri}'); + handler.next(options); + }, + onError: (error, handler) { + // Handle common HTTP errors + if (error.response?.statusCode == 401) { + throw StacException( + 'Authentication required. Please run "stac login"', + ); + } else if (error.response?.statusCode == 403) { + throw StacException('Permission denied'); + } else if (error.response?.statusCode == 404) { + throw StacException('Resource not found'); + } + final status = error.response?.statusCode; + final uri = error.requestOptions.uri; + final reason = + error.message ?? error.error?.toString() ?? 'Unknown error'; + handler.next( + DioException( + requestOptions: error.requestOptions, + error: StacException( + 'HTTP request failed (${status ?? 'no-status'}) for $uri: $reason', + ), + ), + ); + }, + ), + ); + } + + /// Make a GET request + Future get( + String path, { + Map? queryParameters, + }) async { + try { + final response = await _dio.get(path, queryParameters: queryParameters); + return response; + } on DioException catch (e) { + final underlying = e.error; + if (underlying is StacException) { + throw underlying; + } + final status = e.response?.statusCode; + final uri = e.requestOptions.uri; + throw StacException( + 'HTTP request failed (${status ?? 'no-status'}) for $uri: ${e.message ?? underlying?.toString() ?? 'Unknown error'}', + ); + } + } + + /// Make a POST request + Future post(String path, {dynamic data}) async { + try { + return await _dio.post(path, data: data); + } on DioException catch (e) { + final underlying = e.error; + if (underlying is StacException) { + throw underlying; + } + final status = e.response?.statusCode; + final uri = e.requestOptions.uri; + throw StacException( + 'HTTP request failed (${status ?? 'no-status'}) for $uri: ${e.message ?? underlying?.toString() ?? 'Unknown error'}', + ); + } catch (e) { + throw StacException('HTTP request failed: $e'); + } + } + + /// Make a PUT request + Future put(String path, {dynamic data}) async { + try { + return await _dio.put(path, data: data); + } on DioException catch (e) { + final underlying = e.error; + if (underlying is StacException) { + throw underlying; + } + final status = e.response?.statusCode; + final uri = e.requestOptions.uri; + throw StacException( + 'HTTP request failed (${status ?? 'no-status'}) for $uri: ${e.message ?? underlying?.toString() ?? 'Unknown error'}', + ); + } + } + + /// Make a DELETE request + Future delete(String path) async { + try { + return await _dio.delete(path); + } on DioException catch (e) { + final underlying = e.error; + if (underlying is StacException) { + throw underlying; + } + final status = e.response?.statusCode; + final uri = e.requestOptions.uri; + throw StacException( + 'HTTP request failed (${status ?? 'no-status'}) for $uri: ${e.message ?? underlying?.toString() ?? 'Unknown error'}', + ); + } + } +} diff --git a/packages/stac_cli/lib/src/utils/oauth_pkce.dart b/packages/stac_cli/lib/src/utils/oauth_pkce.dart new file mode 100644 index 000000000..48e352e62 --- /dev/null +++ b/packages/stac_cli/lib/src/utils/oauth_pkce.dart @@ -0,0 +1,30 @@ +import 'dart:convert'; +import 'dart:math'; + +import 'package:cryptography/cryptography.dart'; + +String _base64UrlEncode(List bytes) { + return base64 + .encode(bytes) + .replaceAll('+', '-') + .replaceAll('/', '_') + .replaceAll('=', ''); +} + +String generateSecureState({int bytesLength = 32}) { + final random = Random.secure(); + final bytes = List.generate(bytesLength, (_) => random.nextInt(256)); + return _base64UrlEncode(bytes); +} + +String generateCodeVerifier({int bytesLength = 64}) { + final random = Random.secure(); + final bytes = List.generate(bytesLength, (_) => random.nextInt(256)); + return _base64UrlEncode(bytes); +} + +Future createCodeChallenge(String codeVerifier) async { + final algorithm = Sha256(); + final hash = await algorithm.hash(utf8.encode(codeVerifier)); + return _base64UrlEncode(hash.bytes); +} diff --git a/packages/stac_cli/lib/src/utils/string_utils.dart b/packages/stac_cli/lib/src/utils/string_utils.dart new file mode 100644 index 000000000..9fbb260ec --- /dev/null +++ b/packages/stac_cli/lib/src/utils/string_utils.dart @@ -0,0 +1,32 @@ +/// Shared string helpers used across Stac packages. +class StringUtils { + const StringUtils._(); + + /// Fixes common UTF-8 encoding artifacts that appear in generated output. + static String fixCharacterEncoding(String text) { + return text + .replaceAll('·', '·') // Fix middle dot encoding issue + .replaceAll('–', '–') // Fix en dash encoding issues + .replaceAll('—', '—') // Fix em dash encoding issues + .replaceAll('Â…', '…') // Fix ellipsis encoding issues + .replaceAll('©', '©') // Fix copyright encoding issues + .replaceAll('®', '®') // Fix registered trademark encoding issues + .replaceAll('°', '°') // Fix degree symbol encoding issues + .replaceAll('±', '±') // Fix plus-minus encoding issues + .replaceAll('²', '²') // Fix superscript 2 encoding issues + .replaceAll('³', '³') // Fix superscript 3 encoding issues + .replaceAll('¼', '¼') // Fix fraction encoding issues + .replaceAll('½', '½') // Fix fraction encoding issues + .replaceAll('¾', '¾'); // Fix fraction encoding issues + } + + /// Converts a camelCase/PascalCase string to snake_case. + static String toSnakeCase(String value) { + return value + .replaceAllMapped( + RegExp(r'[A-Z]'), + (match) => '_${match.group(0)!.toLowerCase()}', + ) + .replaceFirst(RegExp(r'^_'), ''); + } +} diff --git a/packages/stac_cli/lib/src/version.dart b/packages/stac_cli/lib/src/version.dart new file mode 100644 index 000000000..cb5763e0b --- /dev/null +++ b/packages/stac_cli/lib/src/version.dart @@ -0,0 +1,2 @@ +// Generated code. Do not modify. +const packageVersion = '1.5.0'; diff --git a/packages/stac_cli/lib/stac_cli.dart b/packages/stac_cli/lib/stac_cli.dart new file mode 100644 index 000000000..156003439 --- /dev/null +++ b/packages/stac_cli/lib/stac_cli.dart @@ -0,0 +1,9 @@ +export 'src/commands/auth/login_command.dart'; +export 'src/commands/auth/logout_command.dart'; +export 'src/commands/auth/status_command.dart'; +export 'src/exceptions/auth_exception.dart'; +export 'src/exceptions/build_exception.dart'; +export 'src/exceptions/stac_exception.dart'; +export 'src/services/auth_service.dart'; +export 'src/services/config_service.dart'; +export 'src/utils/console_logger.dart'; diff --git a/packages/stac_cli/pubspec.lock b/packages/stac_cli/pubspec.lock new file mode 100644 index 000000000..7dd8bdbc5 --- /dev/null +++ b/packages/stac_cli/pubspec.lock @@ -0,0 +1,677 @@ +# Generated by pub +# See https://dart.dev/tools/pub/glossary#lockfile +packages: + _fe_analyzer_shared: + dependency: transitive + description: + name: _fe_analyzer_shared + sha256: "8d7ff3948166b8ec5da0fbb5962000926b8e02f2ed9b3e51d1738905fbd4c98d" + url: "https://pub.dev" + source: hosted + version: "93.0.0" + analyzer: + dependency: transitive + description: + name: analyzer + sha256: de7148ed2fcec579b19f122c1800933dfa028f6d9fd38a152b04b1516cec120b + url: "https://pub.dev" + source: hosted + version: "10.0.1" + args: + dependency: "direct main" + description: + name: args + sha256: d0481093c50b1da8910eb0bb301626d4d8eb7284aa739614d2b394ee09e3ea04 + url: "https://pub.dev" + source: hosted + version: "2.7.0" + async: + dependency: transitive + description: + name: async + sha256: "758e6d74e971c3e5aceb4110bfd6698efc7f501675bcfe0c775459a8140750eb" + url: "https://pub.dev" + source: hosted + version: "2.13.0" + boolean_selector: + dependency: transitive + description: + name: boolean_selector + sha256: "8aab1771e1243a5063b8b0ff68042d67334e3feab9e95b9490f9a6ebf73b42ea" + url: "https://pub.dev" + source: hosted + version: "2.1.2" + build: + dependency: transitive + description: + name: build + sha256: "275bf6bb2a00a9852c28d4e0b410da1d833a734d57d39d44f94bfc895a484ec3" + url: "https://pub.dev" + source: hosted + version: "4.0.4" + build_config: + dependency: transitive + description: + name: build_config + sha256: "4f64382b97504dc2fcdf487d5aae33418e08b4703fc21249e4db6d804a4d0187" + url: "https://pub.dev" + source: hosted + version: "1.2.0" + build_daemon: + dependency: transitive + description: + name: build_daemon + sha256: bf05f6e12cfea92d3c09308d7bcdab1906cd8a179b023269eed00c071004b957 + url: "https://pub.dev" + source: hosted + version: "4.1.1" + build_runner: + dependency: "direct dev" + description: + name: build_runner + sha256: "39ad4ca8a2876779737c60e4228b4bcd35d4352ef7e14e47514093edc012c734" + url: "https://pub.dev" + source: hosted + version: "2.11.1" + build_version: + dependency: "direct dev" + description: + name: build_version + sha256: "1063066ec338c18f0629d01077c9315f92fae3e7e0e06d0dc10e8aa3145d44f5" + url: "https://pub.dev" + source: hosted + version: "2.1.3" + built_collection: + dependency: transitive + description: + name: built_collection + sha256: "376e3dd27b51ea877c28d525560790aee2e6fbb5f20e2f85d5081027d94e2100" + url: "https://pub.dev" + source: hosted + version: "5.1.1" + built_value: + dependency: transitive + description: + name: built_value + sha256: "6ae8a6435a8c6520c7077b107e77f1fb4ba7009633259a4d49a8afd8e7efc5e9" + url: "https://pub.dev" + source: hosted + version: "8.12.4" + characters: + dependency: transitive + description: + name: characters + sha256: faf38497bda5ead2a8c7615f4f7939df04333478bf32e4173fcb06d428b5716b + url: "https://pub.dev" + source: hosted + version: "1.4.1" + checked_yaml: + dependency: transitive + description: + name: checked_yaml + sha256: "959525d3162f249993882720d52b7e0c833978df229be20702b33d48d91de70f" + url: "https://pub.dev" + source: hosted + version: "2.0.4" + cli_config: + dependency: transitive + description: + name: cli_config + sha256: ac20a183a07002b700f0c25e61b7ee46b23c309d76ab7b7640a028f18e4d99ec + url: "https://pub.dev" + source: hosted + version: "0.2.0" + clock: + dependency: transitive + description: + name: clock + sha256: fddb70d9b5277016c77a80201021d40a2247104d9f4aa7bab7157b7e3f05b84b + url: "https://pub.dev" + source: hosted + version: "1.1.2" + code_builder: + dependency: transitive + description: + name: code_builder + sha256: "6a6cab2ba4680d6423f34a9b972a4c9a94ebe1b62ecec4e1a1f2cba91fd1319d" + url: "https://pub.dev" + source: hosted + version: "4.11.1" + collection: + dependency: transitive + description: + name: collection + sha256: "2f5709ae4d3d59dd8f7cd309b4e023046b57d8a6c82130785d2b0e5868084e76" + url: "https://pub.dev" + source: hosted + version: "1.19.1" + convert: + dependency: transitive + description: + name: convert + sha256: b30acd5944035672bc15c6b7a8b47d773e41e2f17de064350988c5d02adb1c68 + url: "https://pub.dev" + source: hosted + version: "3.1.2" + coverage: + dependency: transitive + description: + name: coverage + sha256: "5da775aa218eaf2151c721b16c01c7676fbfdd99cebba2bf64e8b807a28ff94d" + url: "https://pub.dev" + source: hosted + version: "1.15.0" + crypto: + dependency: transitive + description: + name: crypto + sha256: c8ea0233063ba03258fbcf2ca4d6dadfefe14f02fab57702265467a19f27fadf + url: "https://pub.dev" + source: hosted + version: "3.0.7" + cryptography: + dependency: "direct main" + description: + name: cryptography + sha256: "3eda3029d34ec9095a27a198ac9785630fe525c0eb6a49f3d575272f8e792ef0" + url: "https://pub.dev" + source: hosted + version: "2.9.0" + dart_console: + dependency: transitive + description: + name: dart_console + sha256: dfa4b63eb4382325ff975fdb6b7a0db8303bb5809ee5cb4516b44153844742ed + url: "https://pub.dev" + source: hosted + version: "1.2.0" + dart_style: + dependency: transitive + description: + name: dart_style + sha256: "6f6b30cba0301e7b38f32bdc9a6bdae6f5921a55f0a1eb9450e1e6515645dbb2" + url: "https://pub.dev" + source: hosted + version: "3.1.6" + dio: + dependency: "direct main" + description: + name: dio + sha256: b9d46faecab38fc8cc286f80bc4d61a3bb5d4ac49e51ed877b4d6706efe57b25 + url: "https://pub.dev" + source: hosted + version: "5.9.1" + dio_web_adapter: + dependency: transitive + description: + name: dio_web_adapter + sha256: "7586e476d70caecaf1686d21eee7247ea43ef5c345eab9e0cc3583ff13378d78" + url: "https://pub.dev" + source: hosted + version: "2.1.1" + dotenv: + dependency: "direct main" + description: + name: dotenv + sha256: "379e64b6fc82d3df29461d349a1796ecd2c436c480d4653f3af6872eccbc90e1" + url: "https://pub.dev" + source: hosted + version: "4.2.0" + ffi: + dependency: transitive + description: + name: ffi + sha256: "6d7fd89431262d8f3125e81b50d3847a091d846eafcd4fdb88dd06f36d705a45" + url: "https://pub.dev" + source: hosted + version: "2.2.0" + file: + dependency: transitive + description: + name: file + sha256: a3b4f84adafef897088c160faf7dfffb7696046cb13ae90b508c2cbc95d3b8d4 + url: "https://pub.dev" + source: hosted + version: "7.0.1" + fixnum: + dependency: transitive + description: + name: fixnum + sha256: b6dc7065e46c974bc7c5f143080a6764ec7a4be6da1285ececdc37be96de53be + url: "https://pub.dev" + source: hosted + version: "1.1.1" + flutter: + dependency: transitive + description: flutter + source: sdk + version: "0.0.0" + frontend_server_client: + dependency: transitive + description: + name: frontend_server_client + sha256: f64a0333a82f30b0cca061bc3d143813a486dc086b574bfb233b7c1372427694 + url: "https://pub.dev" + source: hosted + version: "4.0.0" + glob: + dependency: transitive + description: + name: glob + sha256: c3f1ee72c96f8f78935e18aa8cecced9ab132419e8625dc187e1c2408efc20de + url: "https://pub.dev" + source: hosted + version: "2.1.3" + graphs: + dependency: transitive + description: + name: graphs + sha256: "741bbf84165310a68ff28fe9e727332eef1407342fca52759cb21ad8177bb8d0" + url: "https://pub.dev" + source: hosted + version: "2.3.2" + http_multi_server: + dependency: transitive + description: + name: http_multi_server + sha256: aa6199f908078bb1c5efb8d8638d4ae191aac11b311132c3ef48ce352fb52ef8 + url: "https://pub.dev" + source: hosted + version: "3.2.2" + http_parser: + dependency: transitive + description: + name: http_parser + sha256: "178d74305e7866013777bab2c3d8726205dc5a4dd935297175b19a23a2e66571" + url: "https://pub.dev" + source: hosted + version: "4.1.2" + interact: + dependency: "direct main" + description: + name: interact + sha256: b1abf79334bec42e58496a054cb7ee7ca74da6181f6a1fb6b134f1aa22bc4080 + url: "https://pub.dev" + source: hosted + version: "2.2.0" + intl: + dependency: transitive + description: + name: intl + sha256: "3bc132a9dbce73a7e4a21a17d06e1878839ffbf975568bc875c60537824b0c4d" + url: "https://pub.dev" + source: hosted + version: "0.18.1" + io: + dependency: transitive + description: + name: io + sha256: dfd5a80599cf0165756e3181807ed3e77daf6dd4137caaad72d0b7931597650b + url: "https://pub.dev" + source: hosted + version: "1.0.5" + json_annotation: + dependency: "direct main" + description: + name: json_annotation + sha256: cb09e7dac6210041fad964ed7fbee004f14258b4eca4040f72d1234062ace4c8 + url: "https://pub.dev" + source: hosted + version: "4.11.0" + json_serializable: + dependency: "direct dev" + description: + name: json_serializable + sha256: "44729f5c45748e6748f6b9a57ab8f7e4336edc8ae41fc295070e3814e616a6c0" + url: "https://pub.dev" + source: hosted + version: "6.13.0" + lints: + dependency: "direct dev" + description: + name: lints + sha256: "12f842a479589fea194fe5c5a3095abc7be0c1f2ddfa9a0e76aed1dbd26a87df" + url: "https://pub.dev" + source: hosted + version: "6.1.0" + logger: + dependency: transitive + description: + name: logger + sha256: a7967e31b703831a893bbc3c3dd11db08126fe5f369b5c648a36f821979f5be3 + url: "https://pub.dev" + source: hosted + version: "2.6.2" + logging: + dependency: transitive + description: + name: logging + sha256: c8245ada5f1717ed44271ed1c26b8ce85ca3228fd2ffdb75468ab01979309d61 + url: "https://pub.dev" + source: hosted + version: "1.3.0" + matcher: + dependency: transitive + description: + name: matcher + sha256: dc0b7dc7651697ea4ff3e69ef44b0407ea32c487a39fff6a4004fa585e901861 + url: "https://pub.dev" + source: hosted + version: "0.12.19" + material_color_utilities: + dependency: transitive + description: + name: material_color_utilities + sha256: "9c337007e82b1889149c82ed242ed1cb24a66044e30979c44912381e9be4c48b" + url: "https://pub.dev" + source: hosted + version: "0.13.0" + meta: + dependency: transitive + description: + name: meta + sha256: "23f08335362185a5ea2ad3a4e597f1375e78bce8a040df5c600c8d3552ef2394" + url: "https://pub.dev" + source: hosted + version: "1.17.0" + mime: + dependency: transitive + description: + name: mime + sha256: "41a20518f0cb1256669420fdba0cd90d21561e560ac240f26ef8322e45bb7ed6" + url: "https://pub.dev" + source: hosted + version: "2.0.0" + node_preamble: + dependency: transitive + description: + name: node_preamble + sha256: "6e7eac89047ab8a8d26cf16127b5ed26de65209847630400f9aefd7cd5c730db" + url: "https://pub.dev" + source: hosted + version: "2.0.2" + package_config: + dependency: transitive + description: + name: package_config + sha256: f096c55ebb7deb7e384101542bfba8c52696c1b56fca2eb62827989ef2353bbc + url: "https://pub.dev" + source: hosted + version: "2.2.0" + path: + dependency: "direct main" + description: + name: path + sha256: "75cca69d1490965be98c73ceaea117e8a04dd21217b37b292c9ddbec0d955bc5" + url: "https://pub.dev" + source: hosted + version: "1.9.1" + pool: + dependency: transitive + description: + name: pool + sha256: "978783255c543aa3586a1b3c21f6e9d720eb315376a915872c61ef8b5c20177d" + url: "https://pub.dev" + source: hosted + version: "1.5.2" + pub_semver: + dependency: transitive + description: + name: pub_semver + sha256: "5bfcf68ca79ef689f8990d1160781b4bad40a3bd5e5218ad4076ddb7f4081585" + url: "https://pub.dev" + source: hosted + version: "2.2.0" + pubspec_parse: + dependency: transitive + description: + name: pubspec_parse + sha256: "0560ba233314abbed0a48a2956f7f022cce7c3e1e73df540277da7544cad4082" + url: "https://pub.dev" + source: hosted + version: "1.5.0" + shelf: + dependency: transitive + description: + name: shelf + sha256: e7dd780a7ffb623c57850b33f43309312fc863fb6aa3d276a754bb299839ef12 + url: "https://pub.dev" + source: hosted + version: "1.4.2" + shelf_packages_handler: + dependency: transitive + description: + name: shelf_packages_handler + sha256: "89f967eca29607c933ba9571d838be31d67f53f6e4ee15147d5dc2934fee1b1e" + url: "https://pub.dev" + source: hosted + version: "3.0.2" + shelf_static: + dependency: transitive + description: + name: shelf_static + sha256: c87c3875f91262785dade62d135760c2c69cb217ac759485334c5857ad89f6e3 + url: "https://pub.dev" + source: hosted + version: "1.1.3" + shelf_web_socket: + dependency: transitive + description: + name: shelf_web_socket + sha256: "3632775c8e90d6c9712f883e633716432a27758216dfb61bd86a8321c0580925" + url: "https://pub.dev" + source: hosted + version: "3.0.0" + sky_engine: + dependency: transitive + description: flutter + source: sdk + version: "0.0.0" + source_gen: + dependency: transitive + description: + name: source_gen + sha256: "1d562a3c1f713904ebbed50d2760217fd8a51ca170ac4b05b0db490699dbac17" + url: "https://pub.dev" + source: hosted + version: "4.2.0" + source_helper: + dependency: transitive + description: + name: source_helper + sha256: "4a85e90b50694e652075cbe4575665539d253e6ec10e46e76b45368ab5e3caae" + url: "https://pub.dev" + source: hosted + version: "1.3.10" + source_map_stack_trace: + dependency: transitive + description: + name: source_map_stack_trace + sha256: c0713a43e323c3302c2abe2a1cc89aa057a387101ebd280371d6a6c9fa68516b + url: "https://pub.dev" + source: hosted + version: "2.1.2" + source_maps: + dependency: transitive + description: + name: source_maps + sha256: "190222579a448b03896e0ca6eca5998fa810fda630c1d65e2f78b3f638f54812" + url: "https://pub.dev" + source: hosted + version: "0.10.13" + source_span: + dependency: transitive + description: + name: source_span + sha256: "56a02f1f4cd1a2d96303c0144c93bd6d909eea6bee6bf5a0e0b685edbd4c47ab" + url: "https://pub.dev" + source: hosted + version: "1.10.2" + stac_core: + dependency: "direct main" + description: + path: "../stac_core" + relative: true + source: path + version: "1.3.0" + stac_logger: + dependency: "direct overridden" + description: + path: "../stac_logger" + relative: true + source: path + version: "1.1.0" + stack_trace: + dependency: transitive + description: + name: stack_trace + sha256: "8b27215b45d22309b5cddda1aa2b19bdfec9df0e765f2de506401c071d38d1b1" + url: "https://pub.dev" + source: hosted + version: "1.12.1" + stream_channel: + dependency: transitive + description: + name: stream_channel + sha256: "969e04c80b8bcdf826f8f16579c7b14d780458bd97f56d107d3950fdbeef059d" + url: "https://pub.dev" + source: hosted + version: "2.1.4" + stream_transform: + dependency: transitive + description: + name: stream_transform + sha256: ad47125e588cfd37a9a7f86c7d6356dde8dfe89d071d293f80ca9e9273a33871 + url: "https://pub.dev" + source: hosted + version: "2.1.1" + string_scanner: + dependency: transitive + description: + name: string_scanner + sha256: "921cd31725b72fe181906c6a94d987c78e3b98c2e205b397ea399d4054872b43" + url: "https://pub.dev" + source: hosted + version: "1.4.1" + term_glyph: + dependency: transitive + description: + name: term_glyph + sha256: "7f554798625ea768a7518313e58f83891c7f5024f88e46e7182a4558850a4b8e" + url: "https://pub.dev" + source: hosted + version: "1.2.2" + test: + dependency: "direct dev" + description: + name: test + sha256: "280d6d890011ca966ad08df7e8a4ddfab0fb3aa49f96ed6de56e3521347a9ae7" + url: "https://pub.dev" + source: hosted + version: "1.30.0" + test_api: + dependency: transitive + description: + name: test_api + sha256: "8161c84903fd860b26bfdefb7963b3f0b68fee7adea0f59ef805ecca346f0c7a" + url: "https://pub.dev" + source: hosted + version: "0.7.10" + test_core: + dependency: transitive + description: + name: test_core + sha256: "0381bd1585d1a924763c308100f2138205252fb90c9d4eeaf28489ee65ccde51" + url: "https://pub.dev" + source: hosted + version: "0.6.16" + tint: + dependency: transitive + description: + name: tint + sha256: "9652d9a589f4536d5e392cf790263d120474f15da3cf1bee7f1fdb31b4de5f46" + url: "https://pub.dev" + source: hosted + version: "2.0.1" + typed_data: + dependency: transitive + description: + name: typed_data + sha256: f9049c039ebfeb4cf7a7104a675823cd72dba8297f264b6637062516699fa006 + url: "https://pub.dev" + source: hosted + version: "1.4.0" + vector_math: + dependency: transitive + description: + name: vector_math + sha256: d530bd74fea330e6e364cda7a85019c434070188383e1cd8d9777ee586914c5b + url: "https://pub.dev" + source: hosted + version: "2.2.0" + vm_service: + dependency: transitive + description: + name: vm_service + sha256: "45caa6c5917fa127b5dbcfbd1fa60b14e583afdc08bfc96dda38886ca252eb60" + url: "https://pub.dev" + source: hosted + version: "15.0.2" + watcher: + dependency: transitive + description: + name: watcher + sha256: "1398c9f081a753f9226febe8900fce8f7d0a67163334e1c94a2438339d79d635" + url: "https://pub.dev" + source: hosted + version: "1.2.1" + web: + dependency: transitive + description: + name: web + sha256: "868d88a33d8a87b18ffc05f9f030ba328ffefba92d6c127917a2ba740f9cfe4a" + url: "https://pub.dev" + source: hosted + version: "1.1.1" + web_socket: + dependency: transitive + description: + name: web_socket + sha256: "34d64019aa8e36bf9842ac014bb5d2f5586ca73df5e4d9bf5c936975cae6982c" + url: "https://pub.dev" + source: hosted + version: "1.0.1" + web_socket_channel: + dependency: transitive + description: + name: web_socket_channel + sha256: d645757fb0f4773d602444000a8131ff5d48c9e47adfe9772652dd1a4f2d45c8 + url: "https://pub.dev" + source: hosted + version: "3.0.3" + webkit_inspection_protocol: + dependency: transitive + description: + name: webkit_inspection_protocol + sha256: "87d3f2333bb240704cd3f1c6b5b7acd8a10e7f0bc28c28dcf14e782014f4a572" + url: "https://pub.dev" + source: hosted + version: "1.2.1" + win32: + dependency: transitive + description: + name: win32 + sha256: d7cb55e04cd34096cd3a79b3330245f54cb96a370a1c27adb3c84b917de8b08e + url: "https://pub.dev" + source: hosted + version: "5.15.0" + yaml: + dependency: "direct main" + description: + name: yaml + sha256: b9da305ac7c39faa3f030eccd175340f968459dae4af175130b3fc47e40d76ce + url: "https://pub.dev" + source: hosted + version: "3.1.3" +sdks: + dart: ">=3.10.0 <4.0.0" diff --git a/packages/stac_cli/pubspec.yaml b/packages/stac_cli/pubspec.yaml new file mode 100644 index 000000000..b99cc1b09 --- /dev/null +++ b/packages/stac_cli/pubspec.yaml @@ -0,0 +1,33 @@ +name: stac_cli +description: A CLI tool for managing Stac SDUI (Server-Driven UI) projects and converting Dart to JSON. +version: 1.5.0 +repository: https://github.com/StacDev/stac +homepage: https://stac.dev/ + +environment: + sdk: ^3.8.1 + +# Add regular dependencies here. +dependencies: + args: ^2.7.0 + dio: ^5.9.1 + yaml: ^3.1.3 + path: ^1.9.1 + interact: ^2.2.0 + stac_core: ^1.3.0 + json_annotation: ^4.11.0 + dotenv: ^4.2.0 + cryptography: ^2.9.0 + +# Executables that can be run globally +executables: + stac: stac_cli + +dev_dependencies: + lints: ^6.1.0 + test: ^1.30.0 + build_runner: ^2.11.1 + json_serializable: ^6.13.0 + build_version: ^2.0.0 + +# stac_cli/pubspec_overrides.yaml diff --git a/packages/stac_cli/pubspec_overrides.yaml b/packages/stac_cli/pubspec_overrides.yaml new file mode 100644 index 000000000..5418b3e6d --- /dev/null +++ b/packages/stac_cli/pubspec_overrides.yaml @@ -0,0 +1,6 @@ +# melos_managed_dependency_overrides: stac_core,stac_logger +dependency_overrides: + stac_core: + path: ../stac_core + stac_logger: + path: ../stac_logger