From a89e6ff6eb7a9409a02e6c776563a8e453b807f4 Mon Sep 17 00:00:00 2001 From: SF97 Date: Mon, 10 Nov 2025 20:23:52 +0100 Subject: [PATCH 1/8] Add custom commands support --- CHANGELOG.md | 31 + README.md | 287 +++++ alex_custom_commands.yaml.example | 341 +++++ lib/commands/custom/add_custom_command.dart | 205 +++ lib/commands/custom/custom_command.dart | 24 + lib/commands/custom/edit_custom_command.dart | 69 + lib/commands/custom/list_custom_commands.dart | 46 + .../custom/remove_custom_command.dart | 42 + lib/commands/custom/show_custom_command.dart | 117 ++ lib/commands/custom/user_custom_command.dart | 77 ++ lib/runner/alex_command_runner.dart | 30 + .../custom_command_action.dart | 1140 +++++++++++++++++ .../custom_command_argument.dart | 87 ++ .../custom_command_config.dart | 311 +++++ .../custom_command_executor.dart | 869 +++++++++++++ lib/src/version.dart | 2 +- pubspec.yaml | 2 +- 17 files changed, 3678 insertions(+), 2 deletions(-) create mode 100644 alex_custom_commands.yaml.example create mode 100644 lib/commands/custom/add_custom_command.dart create mode 100644 lib/commands/custom/custom_command.dart create mode 100644 lib/commands/custom/edit_custom_command.dart create mode 100644 lib/commands/custom/list_custom_commands.dart create mode 100644 lib/commands/custom/remove_custom_command.dart create mode 100644 lib/commands/custom/show_custom_command.dart create mode 100644 lib/commands/custom/user_custom_command.dart create mode 100644 lib/src/custom_commands/custom_command_action.dart create mode 100644 lib/src/custom_commands/custom_command_argument.dart create mode 100644 lib/src/custom_commands/custom_command_config.dart create mode 100644 lib/src/custom_commands/custom_command_executor.dart diff --git a/CHANGELOG.md b/CHANGELOG.md index 4a19e8a..52e7f9f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,3 +1,34 @@ +## 1.10.0 + +* **New feature**: Custom commands support! Define your own commands in `alex_custom_commands.yaml` to automate workflows. + - Execute shell commands, alex commands, or Dart scripts + - Support for command arguments (options and flags) + - Variable substitution in actions using `{{var}}` or `${var}` syntax + - Commands management: `alex custom list`, `add`, `show`, `edit`, `remove` + - **Advanced actions** for conditional logic and file operations: + - `check_git_branch` - Check/switch git branch + - `check_git_clean` - Verify no uncommitted changes + - `check_platform` - Verify current OS (macos/linux/windows) + - `change_dir` - Change working directory + - `check_file_exists` - Verify file existence + - `copy_file` - Copy files + - `rename_file` - Rename files + - `move_file` - Move files + - `create_file` - Create files with content + - `create_dir` - Create directories + - `delete_file` - Delete files + - `delete_dir` - Delete directories + - `rename_dir` - Rename directories + - `replace_in_file` - Replace text in files (with regex support) + - `append_to_file` - Append content to files + - `prepend_to_file` - Prepend content to files + - `print` - Print messages to console + - `wait` - Wait for specified duration (in milliseconds) + - `create_archive` - Create ZIP/TAR.GZ archives + - `extract_archive` - Extract archives + - **Verbose mode** support with `--verbose` flag for detailed execution logs + - See `alex_custom_commands.yaml.example` for examples + ## 1.9.4 * [Code] `code gen` command supports run generation for subproject from root folder. diff --git a/README.md b/README.md index 416934b..3f103b3 100644 --- a/README.md +++ b/README.md @@ -387,6 +387,293 @@ For example: $ alex settings set open_ai_api_key abc123 ``` +### Custom Commands + +Define your own custom commands to automate repetitive workflows, combine multiple operations, or create project-specific shortcuts. + +Custom commands are configured in `alex_custom_commands.yaml` file in your project root. + +```bash +$ alex custom +``` + +#### Manage custom commands + +List all registered custom commands: + +```bash +$ alex custom list +``` + +Show details of a specific command: + +```bash +$ alex custom show --name build-release +``` + +Add a new custom command interactively: + +```bash +$ alex custom add +``` + +Edit the configuration file: + +```bash +$ alex custom edit +``` + +Remove a custom command: + +```bash +$ alex custom remove --name build-release +``` + +#### Configuration + +Custom commands are defined in `alex_custom_commands.yaml`: + +```yaml +custom_commands: + - name: build-release + description: Build release version with all checks + aliases: [br, release] + arguments: + - name: platform + type: option + help: Target platform to build for + abbr: p + allowed: [android, ios, web] + required: true + actions: + - type: alex + command: code gen + - type: exec + command: flutter build {{platform}} --release +``` + +#### Action types + +Custom commands support multiple types of actions: + +**exec** - Execute shell command or program: +```yaml +- type: exec + command: flutter clean + working_dir: /optional/path # optional +``` + +**alex** - Execute existing alex command: +```yaml +- type: alex + command: l10n extract + args: [--locale, en] # optional +``` + +**script** - Execute Dart script: +```yaml +- type: script + path: ./scripts/my_script.dart + args: [arg1, arg2] # optional +``` + +**check_git_branch** - Check current git branch and optionally switch to it: +```yaml +- type: check_git_branch + branch: pipe/app-gallery/prod + auto_switch: true # Switch if not on branch (default: true) + error_message: "Branch does not exist" # Custom error message +``` + +**check_git_clean** - Check that git working directory is clean: +```yaml +- type: check_git_clean + error_message: "There are uncommitted changes" +``` + +**change_dir** - Change working directory: +```yaml +- type: change_dir + path: ios + error_message: "Directory not found" +``` + +**delete_file** - Delete file or directory: +```yaml +- type: delete_file + path: Podfile.lock + recursive: false # For directories (default: false) + ignore_not_found: true # Don't fail if doesn't exist (default: true) +``` + +**check_file_exists** - Check if file or directory exists: +```yaml +- type: check_file_exists + path: ios + should_exist: true # true to check exists, false to check not exists + error_message: "iOS directory not found" +``` + +**copy_file** - Copy a file: +```yaml +- type: copy_file + source: config.txt + destination: config_backup.txt + overwrite: false # Whether to overwrite if destination exists (default: false) +``` + +**rename_file** - Rename a file: +```yaml +- type: rename_file + old_path: old_name.txt + new_path: new_name.txt +``` + +**move_file** - Move a file: +```yaml +- type: move_file + source: file.txt + destination: archive/file.txt +``` + +**create_file** - Create a file with optional content: +```yaml +- type: create_file + path: config.txt + content: "Environment: production" # Optional content with variable substitution + overwrite: false # Whether to overwrite if file exists (default: false) +``` + +**create_dir** - Create a directory: +```yaml +- type: create_dir + path: output + recursive: true # Create parent directories (default: true) +``` + +**delete_dir** - Delete a directory: +```yaml +- type: delete_dir + path: temp + recursive: true # Delete recursively (default: true) + ignore_not_found: true # Don't fail if doesn't exist (default: true) +``` + +**rename_dir** - Rename a directory: +```yaml +- type: rename_dir + old_path: old_directory + new_path: new_directory +``` + +**replace_in_file** - Replace text in file (supports regex): +```yaml +- type: replace_in_file + path: pubspec.yaml + find: 'version: \d+\.\d+\.\d+' + replace: 'version: {{new_version}}' + regex: true # Enable regex matching (default: false) + error_message: 'Failed to update version' +``` + +**append_to_file** - Append content to end of file: +```yaml +- type: append_to_file + path: CHANGELOG.md + content: | + ## [{{version}}] - {{date}} + - New release + create_if_missing: true # Create file if doesn't exist (default: true) +``` + +**prepend_to_file** - Prepend content to beginning of file: +```yaml +- type: prepend_to_file + path: lib/main.dart + content: '// Copyright (c) 2024\n' + create_if_missing: false # Don't create if doesn't exist (default: true) +``` + +**print** - Print message to console: +```yaml +- type: print + message: 'Building for {{platform}}...' + level: info # info, warning, or error (default: info) +``` + +**wait** - Wait for specified duration: +```yaml +- type: wait + milliseconds: 5000 + message: 'Waiting for services to start...' # Optional message +``` + +**check_platform** - Verify current operating system: +```yaml +- type: check_platform + platform: macos # macos, linux, or windows + error_message: 'This command only works on macOS' +``` + +**create_archive** - Create ZIP or TAR.GZ archive: +```yaml +- type: create_archive + source: build/app/outputs/bundle/release/ + destination: releases/app-v{{version}}.zip + format: zip # zip or tar.gz (default: zip) +``` + +**extract_archive** - Extract ZIP or TAR.GZ archive: +```yaml +- type: extract_archive + source: downloads/assets.zip + destination: assets/ +``` + +#### Variable substitution + +Actions support variable substitution using `{{variable_name}}` or `${variable_name}` syntax: + +```yaml +arguments: + - name: platform + type: option + required: true +actions: + - type: exec + command: flutter build {{platform}} +``` + +#### Using custom commands + +Once defined, custom commands work just like built-in alex commands: + +```bash +$ alex build-release --platform android +``` + +Or using an alias: + +```bash +$ alex br -p android +``` + +#### Verbose mode + +Custom commands support the `--verbose` flag to see detailed execution information: + +```bash +$ alex build-release --platform android --verbose +``` + +This will show: +- Each action being executed +- Detailed progress for file operations +- Variable substitution values +- Git operations details + +See `alex_custom_commands.yaml.example` in the repository for more examples. + ## Problem solving ### Command not found diff --git a/alex_custom_commands.yaml.example b/alex_custom_commands.yaml.example new file mode 100644 index 0000000..b178c69 --- /dev/null +++ b/alex_custom_commands.yaml.example @@ -0,0 +1,341 @@ +# Alex Custom Commands Configuration +# This is an example file. Copy it to alex_custom_commands.yaml to use. +# Define your custom commands here + +custom_commands: + # Example 1: Build release for a specific platform + - name: build-release + description: Build release version with all checks and code generation + aliases: [br, release] + arguments: + - name: platform + type: option + help: Target platform to build for + abbr: p + allowed: [android, ios, web, windows, macos, linux] + required: true + actions: + # Run code generation + - type: alex + command: code gen + # Extract localization strings + - type: alex + command: l10n extract + # Build for specified platform + - type: exec + command: flutter build {{platform}} --release + + # Example 2: Quick fix for common issues + - name: quick-fix + description: Quick fix for common Flutter issues + aliases: [qf, fix] + actions: + - type: exec + command: flutter clean + - type: exec + command: flutter pub get + - type: alex + command: code gen + + # Example 3: Run pre-commit checks + - name: pre-commit + description: Run all checks before committing + aliases: [pc, check] + actions: + - type: exec + command: dart analyze + - type: exec + command: dart format --set-exit-if-changed . + - type: exec + command: dart test + + # Example 4: Create a new feature branch + - name: new-feature + description: Create and checkout a new feature branch + arguments: + - name: name + type: option + help: Name of the feature + abbr: n + required: true + actions: + - type: exec + command: git checkout -b feature/{{name}} + + # Example 5: Run a custom Dart script + - name: custom-script + description: Run a custom Dart script with arguments + arguments: + - name: script-arg + type: option + help: Argument to pass to the script + default: default-value + actions: + - type: script + path: ./scripts/my_custom_script.dart + args: [{{script-arg}}] + + # Example 6: Full release workflow + - name: full-release + description: Complete release workflow with version bump + arguments: + - name: version-type + type: option + help: Type of version bump + abbr: v + allowed: [major, minor, patch] + default: patch + actions: + # Run all checks + - type: alex + command: code gen + - type: exec + command: dart analyze + - type: exec + command: dart test + # Start release + - type: alex + command: release start + args: + - '--{{version-type}}' + + # Example 7: Update dependencies and regenerate code + - name: update-deps + description: Update all dependencies and regenerate code + aliases: [ud] + actions: + - type: exec + command: flutter pub upgrade + - type: alex + command: code gen + - type: exec + command: dart analyze + + # Example 8: Simple command with flag argument + - name: build-debug + description: Build debug version with optional verbose output + arguments: + - name: verbose + type: flag + help: Enable verbose output + abbr: v + actions: + - type: exec + command: flutter build apk --debug + + # Example 9: Build for App Gallery with git checks + - name: build-app-gallery + description: Build app for App Gallery (with branch and git checks) + actions: + # Check current branch and switch to pipe/app-gallery/prod + - type: check_git_branch + branch: pipe/app-gallery/prod + auto_switch: true + error_message: "Branch pipe/app-gallery/prod does not exist" + + # Check that there are no uncommitted changes + - type: check_git_clean + error_message: "There are uncommitted changes. Please commit or stash them first" + + # Build the app + - type: exec + command: fvm flutter build apk --target lib/main_app_gallery.dart + + # Example 10: Update iOS pods + - name: update-pods + description: Update iOS CocoaPods dependencies + actions: + # Check platform + - type: check_platform + platform: macos + error_message: 'This command can only run on macOS' + + # Check if ios directory exists + - type: check_file_exists + path: ios + should_exist: true + error_message: "iOS directory not found" + + # Change to ios directory + - type: change_dir + path: ios + + # Delete Podfile.lock if exists + - type: delete_file + path: Podfile.lock + ignore_not_found: true + + # Run pod install + - type: exec + command: pod install --repo-update + + # Example 11: File operations workflow + - name: file-ops-demo + description: Demonstration of all file operations + actions: + # Create a temporary directory + - type: create_dir + path: temp_workspace + recursive: true + + # Create a file with content + - type: create_file + path: temp_workspace/config.txt + content: "Environment: {{env}}\nVersion: 1.0.0" + overwrite: true + + # Copy the file + - type: copy_file + source: temp_workspace/config.txt + destination: temp_workspace/config_backup.txt + overwrite: false + + # Rename the backup + - type: rename_file + old_path: temp_workspace/config_backup.txt + new_path: temp_workspace/config.bak + + # Create another directory + - type: create_dir + path: temp_workspace/archive + + # Move file to archive + - type: move_file + source: temp_workspace/config.bak + destination: temp_workspace/archive/config.bak + + # Rename directory + - type: rename_dir + old_path: temp_workspace/archive + new_path: temp_workspace/backup + + # Clean up - delete directory + - type: delete_dir + path: temp_workspace + recursive: true + ignore_not_found: true + arguments: + - name: env + type: option + help: Environment name + default: production + + # Example 12: Version update with file replacement + - name: update-version + description: Update version in files + arguments: + - name: version + type: option + help: New version number + abbr: v + required: true + actions: + # Print message + - type: print + message: 'Updating version to {{version}}...' + level: info + + # Replace version in pubspec.yaml + - type: replace_in_file + path: pubspec.yaml + find: 'version: \d+\.\d+\.\d+' + replace: 'version: {{version}}' + regex: true + + # Append to changelog + - type: append_to_file + path: CHANGELOG.md + content: | + + ## [{{version}}] - {{date}} + - Updated version + + # Print completion message + - type: print + message: 'Version updated successfully!' + level: info + + # Example 13: Platform-specific build + - name: build-macos + description: Build for macOS only + actions: + # Check platform + - type: check_platform + platform: macos + error_message: 'This command can only run on macOS' + + # Print message + - type: print + message: 'Building for macOS...' + + # Build + - type: exec + command: flutter build macos --release + + # Create archive + - type: create_archive + source: build/macos/Build/Products/Release/ + destination: releases/macos-app.zip + format: zip + + - type: print + message: 'Build completed! Archive: releases/macos-app.zip' + + # Example 14: Deployment with sleep and checks + - name: deploy-staging + description: Deploy to staging server + actions: + - type: print + message: 'Starting deployment to staging...' + level: info + + # Check git is clean + - type: check_git_clean + error_message: 'Cannot deploy with uncommitted changes' + + # Build + - type: exec + command: flutter build web --release + + # Create deployment archive + - type: create_archive + source: build/web + destination: deploy-staging.zip + + - type: print + message: 'Upload deploy-staging.zip to server manually' + level: warning + + # Wait for manual upload + - type: wait + milliseconds: 5000 + message: 'Waiting for upload to complete...' + + # Cleanup + - type: delete_file + path: deploy-staging.zip + ignore_not_found: true + + - type: print + message: 'Deployment complete!' + + # Example 15: Add license header to files + - name: add-license + description: Add license header to Dart files + arguments: + - name: file + type: option + help: File path + required: true + actions: + - type: prepend_to_file + path: '{{file}}' + content: | + // Copyright (c) 2025 Your Company + // Licensed under the MIT License + + create_if_missing: false + error_message: 'File not found: {{file}}' + + - type: print + message: 'License header added to {{file}}' diff --git a/lib/commands/custom/add_custom_command.dart b/lib/commands/custom/add_custom_command.dart new file mode 100644 index 0000000..4347c5c --- /dev/null +++ b/lib/commands/custom/add_custom_command.dart @@ -0,0 +1,205 @@ +import 'dart:io'; + +import 'package:alex/runner/alex_command.dart'; +import 'package:alex/src/custom_commands/custom_command_action.dart'; +import 'package:alex/src/custom_commands/custom_command_argument.dart'; +import 'package:alex/src/custom_commands/custom_command_config.dart'; + +/// Add a new custom command. +class AddCustomCommand extends AlexCommand { + AddCustomCommand() : super('add', 'Add a new custom command.'); + + @override + Future doRun() async { + final config = CustomCommandsConfig.instance; + + printInfo('Add new custom command'); + printInfo(''); + + // Get command name + final name = _prompt('Command name'); + if (name.isEmpty) { + printError('Command name is required'); + return 1; + } + + if (config.hasCommand(name)) { + printError('Command already exists: $name'); + return 1; + } + + // Get description + final description = _prompt('Description (optional)'); + + // Get aliases + final aliasesStr = _prompt('Aliases (comma-separated, optional)'); + final aliases = aliasesStr.isNotEmpty + ? aliasesStr.split(',').map((e) => e.trim()).toList() + : []; + + // Get arguments + final arguments = []; + while (true) { + final addArg = _prompt('Add argument? (y/n)', defaultValue: 'n'); + if (addArg.toLowerCase() != 'y') break; + + final arg = _promptArgument(); + if (arg != null) { + arguments.add(arg); + } + } + + // Get actions + final actions = []; + while (true) { + final addAction = _prompt('Add action? (y/n)', defaultValue: actions.isEmpty ? 'y' : 'n'); + if (addAction.toLowerCase() != 'y') { + if (actions.isEmpty) { + printError('At least one action is required'); + continue; + } + break; + } + + final action = _promptAction(); + if (action != null) { + actions.add(action); + } + } + + if (actions.isEmpty) { + printError('At least one action is required'); + return 1; + } + + // Create command definition + final command = CustomCommandDefinition( + name: name, + description: description, + aliases: aliases, + arguments: arguments, + actions: actions, + ); + + try { + config.addCommand(command); + config.save(); + + printInfo(''); + printInfo('Custom command added: $name'); + printInfo('Config saved to: ${config.getOrCreateConfigPath()}'); + printInfo(''); + printInfo('You can now use it with:'); + printInfo(' alex $name'); + + return 0; + } catch (e) { + printError('Failed to add command: $e'); + return 1; + } + } + + CustomCommandArgument? _promptArgument() { + printInfo(''); + printInfo('Argument configuration:'); + + final name = _prompt(' Argument name'); + if (name.isEmpty) return null; + + final typeStr = _prompt(' Type (option/flag)', defaultValue: 'option'); + final type = typeStr.toLowerCase() == 'flag' + ? CustomCommandArgumentType.flag + : CustomCommandArgumentType.option; + + final help = _prompt(' Help text (optional)'); + final abbr = _prompt(' Abbreviation (optional, single char)'); + final defaultValue = _prompt(' Default value (optional)'); + + String? allowedStr; + List? allowed; + if (type == CustomCommandArgumentType.option) { + allowedStr = _prompt(' Allowed values (comma-separated, optional)'); + if (allowedStr.isNotEmpty) { + allowed = allowedStr.split(',').map((e) => e.trim()).toList(); + } + } + + final requiredStr = _prompt(' Required? (y/n)', defaultValue: 'n'); + final required = requiredStr.toLowerCase() == 'y'; + + return CustomCommandArgument( + name: name, + type: type, + help: help.isNotEmpty ? help : null, + abbr: abbr.isNotEmpty ? abbr : null, + defaultValue: defaultValue.isNotEmpty ? defaultValue : null, + allowed: allowed, + required: required, + ); + } + + CustomCommandAction? _promptAction() { + printInfo(''); + printInfo('Action configuration:'); + + final typeStr = _prompt(' Action type (exec/alex/script)', defaultValue: 'exec'); + final type = typeStr.toLowerCase(); + + switch (type) { + case 'exec': + return _promptExecAction(); + case 'alex': + return _promptAlexAction(); + case 'script': + return _promptScriptAction(); + default: + printError('Unknown action type: $type'); + return null; + } + } + + ExecAction _promptExecAction() { + final command = _prompt(' Command'); + final workingDir = _prompt(' Working directory (optional)'); + + return ExecAction( + command: command, + workingDir: workingDir.isNotEmpty ? workingDir : null, + ); + } + + AlexAction _promptAlexAction() { + final command = _prompt(' Alex command (e.g., "code gen")'); + final argsStr = _prompt(' Arguments (space-separated, optional)'); + + final args = argsStr.isNotEmpty ? argsStr.split(' ') : []; + + return AlexAction( + command: command, + args: args, + ); + } + + ScriptAction _promptScriptAction() { + final path = _prompt(' Script path'); + final argsStr = _prompt(' Arguments (space-separated, optional)'); + + final args = argsStr.isNotEmpty ? argsStr.split(' ') : []; + + return ScriptAction( + path: path, + args: args, + ); + } + + String _prompt(String message, {String defaultValue = ''}) { + if (defaultValue.isNotEmpty) { + stdout.write('$message [$defaultValue]: '); + } else { + stdout.write('$message: '); + } + + final input = stdin.readLineSync() ?? ''; + return input.isEmpty ? defaultValue : input; + } +} diff --git a/lib/commands/custom/custom_command.dart b/lib/commands/custom/custom_command.dart new file mode 100644 index 0000000..8d3062e --- /dev/null +++ b/lib/commands/custom/custom_command.dart @@ -0,0 +1,24 @@ +import 'package:alex/commands/custom/add_custom_command.dart'; +import 'package:alex/commands/custom/edit_custom_command.dart'; +import 'package:alex/commands/custom/list_custom_commands.dart'; +import 'package:alex/commands/custom/remove_custom_command.dart'; +import 'package:alex/commands/custom/show_custom_command.dart'; +import 'package:alex/runner/alex_command.dart'; + +/// Manage custom commands. +class CustomCommand extends AlexCommand { + CustomCommand() + : super('custom', 'Manage custom commands.', ['cmd', 'commands']) { + addSubcommand(ListCustomCommandsCommand()); + addSubcommand(ShowCustomCommand()); + addSubcommand(AddCustomCommand()); + addSubcommand(EditCustomCommand()); + addSubcommand(RemoveCustomCommand()); + } + + @override + Future doRun() async { + printUsage(); + return 0; + } +} diff --git a/lib/commands/custom/edit_custom_command.dart b/lib/commands/custom/edit_custom_command.dart new file mode 100644 index 0000000..dc76097 --- /dev/null +++ b/lib/commands/custom/edit_custom_command.dart @@ -0,0 +1,69 @@ +import 'dart:io'; + +import 'package:alex/runner/alex_command.dart'; +import 'package:alex/src/custom_commands/custom_command_config.dart'; + +/// Edit custom commands configuration. +class EditCustomCommand extends AlexCommand { + EditCustomCommand() + : super('edit', 'Edit custom commands configuration file.') { + argParser.addOption( + 'editor', + abbr: 'e', + help: r'Editor to use (default: $EDITOR or vim)', + ); + } + + @override + Future doRun() async { + final config = CustomCommandsConfig.instance; + final configPath = config.getOrCreateConfigPath(); + + // Create config file if it doesn't exist + final configFile = File(configPath); + if (!configFile.existsSync()) { + printInfo('Creating new config file: $configPath'); + config.save(); + } + + // Determine editor to use + final editorArg = argResults!['editor'] as String?; + final editor = editorArg ?? + Platform.environment['EDITOR'] ?? + Platform.environment['VISUAL'] ?? + (Platform.isMacOS || Platform.isLinux ? 'vim' : 'notepad'); + + printInfo('Opening config file in $editor: $configPath'); + + try { + final result = await Process.run( + editor, + [configPath], + ); + + if (result.exitCode == 0) { + printInfo(''); + printInfo('Reloading configuration...'); + + // Reload configuration + try { + CustomCommandsConfig.reload(); + printInfo('Configuration reloaded successfully'); + return 0; + } catch (e) { + printError('Failed to reload configuration: $e'); + printError('Please check the config file for errors'); + return 1; + } + } else { + printError('Editor exited with code: ${result.exitCode}'); + return result.exitCode; + } + } catch (e) { + printError('Failed to open editor: $e'); + printInfo(''); + printInfo('You can edit the file manually at: $configPath'); + return 1; + } + } +} diff --git a/lib/commands/custom/list_custom_commands.dart b/lib/commands/custom/list_custom_commands.dart new file mode 100644 index 0000000..b7b09e8 --- /dev/null +++ b/lib/commands/custom/list_custom_commands.dart @@ -0,0 +1,46 @@ +import 'package:alex/runner/alex_command.dart'; +import 'package:alex/src/custom_commands/custom_command_config.dart'; + +/// List all custom commands. +class ListCustomCommandsCommand extends AlexCommand { + ListCustomCommandsCommand() + : super('list', 'List all registered custom commands.'); + + @override + Future doRun() async { + final config = CustomCommandsConfig.instance; + + if (config.commands.isEmpty) { + printInfo('No custom commands registered.'); + printInfo(''); + printInfo('To add a custom command, use:'); + printInfo(' alex custom add'); + printInfo(''); + printInfo('Or edit the config file directly:'); + printInfo(' ${config.getOrCreateConfigPath()}'); + return 0; + } + + printInfo('Custom commands (${config.commands.length}):'); + printInfo(''); + + for (final cmd in config.commands) { + printInfo(' ${cmd.name}'); + if (cmd.description.isNotEmpty) { + printInfo(' ${cmd.description}'); + } + if (cmd.aliases.isNotEmpty) { + printInfo(' Aliases: ${cmd.aliases.join(', ')}'); + } + if (cmd.arguments.isNotEmpty) { + printInfo(' Arguments: ${cmd.arguments.length}'); + } + printInfo(' Actions: ${cmd.actions.length}'); + printInfo(''); + } + + printInfo('Config file: ${config.configPath ?? config.getOrCreateConfigPath()}'); + + return 0; + } +} diff --git a/lib/commands/custom/remove_custom_command.dart b/lib/commands/custom/remove_custom_command.dart new file mode 100644 index 0000000..2222df1 --- /dev/null +++ b/lib/commands/custom/remove_custom_command.dart @@ -0,0 +1,42 @@ +import 'package:alex/runner/alex_command.dart'; +import 'package:alex/src/custom_commands/custom_command_config.dart'; + +/// Remove a custom command. +class RemoveCustomCommand extends AlexCommand { + RemoveCustomCommand() + : super('remove', 'Remove a custom command.', ['rm', 'delete']) { + argParser.addOption( + 'name', + abbr: 'n', + help: 'Name of the command to remove', + mandatory: true, + ); + } + + @override + Future doRun() async { + final name = argResults!['name'] as String; + final config = CustomCommandsConfig.instance; + + if (!config.hasCommand(name)) { + printError('Command not found: $name'); + return 1; + } + + try { + final removed = config.removeCommand(name); + if (removed) { + config.save(); + printInfo('Custom command removed: $name'); + printInfo('Config saved to: ${config.getOrCreateConfigPath()}'); + return 0; + } else { + printError('Failed to remove command: $name'); + return 1; + } + } catch (e) { + printError('Failed to remove command: $e'); + return 1; + } + } +} diff --git a/lib/commands/custom/show_custom_command.dart b/lib/commands/custom/show_custom_command.dart new file mode 100644 index 0000000..e50e2bb --- /dev/null +++ b/lib/commands/custom/show_custom_command.dart @@ -0,0 +1,117 @@ +import 'package:alex/runner/alex_command.dart'; +import 'package:alex/src/custom_commands/custom_command_action.dart'; +import 'package:alex/src/custom_commands/custom_command_argument.dart'; +import 'package:alex/src/custom_commands/custom_command_config.dart'; + +/// Show details of a custom command. +class ShowCustomCommand extends AlexCommand { + ShowCustomCommand() + : super('show', 'Show details of a custom command.') { + argParser.addOption( + 'name', + abbr: 'n', + help: 'Name of the command to show', + mandatory: true, + ); + } + + @override + Future doRun() async { + final name = argResults!['name'] as String; + final config = CustomCommandsConfig.instance; + + CustomCommandDefinition? cmd; + try { + cmd = config.findCommand(name); + } catch (e) { + printError('Command not found: $name'); + return 1; + } + + if (cmd == null) { + printError('Command not found: $name'); + return 1; + } + + printInfo('Command: ${cmd.name}'); + if (cmd.description.isNotEmpty) { + printInfo('Description: ${cmd.description}'); + } + if (cmd.aliases.isNotEmpty) { + printInfo('Aliases: ${cmd.aliases.join(', ')}'); + } + printInfo(''); + + if (cmd.arguments.isNotEmpty) { + printInfo('Arguments:'); + for (final arg in cmd.arguments) { + _printArgument(arg); + } + printInfo(''); + } + + printInfo('Actions:'); + for (var i = 0; i < cmd.actions.length; i++) { + _printAction(i + 1, cmd.actions[i]); + } + + return 0; + } + + void _printArgument(CustomCommandArgument arg) { + final buffer = StringBuffer(); + buffer.write(' --${arg.name}'); + + if (arg.abbr != null) { + buffer.write(', -${arg.abbr}'); + } + + final details = []; + if (arg.type == CustomCommandArgumentType.flag) { + details.add('flag'); + } else { + details.add('option'); + } + + if (arg.required) { + details.add('required'); + } + + if (arg.defaultValue != null) { + details.add('default: ${arg.defaultValue}'); + } + + if (arg.allowed != null && arg.allowed!.isNotEmpty) { + details.add('allowed: ${arg.allowed!.join(', ')}'); + } + + buffer.write(' (${details.join(', ')})'); + + printInfo(buffer.toString()); + + if (arg.help != null) { + printInfo(' ${arg.help}'); + } + } + + void _printAction(int index, CustomCommandAction action) { + printInfo(' $index. ${action.type}'); + + if (action is ExecAction) { + printInfo(' command: ${action.command}'); + if (action.workingDir != null) { + printInfo(' working_dir: ${action.workingDir}'); + } + } else if (action is AlexAction) { + printInfo(' command: ${action.command}'); + if (action.args.isNotEmpty) { + printInfo(' args: ${action.args.join(' ')}'); + } + } else if (action is ScriptAction) { + printInfo(' path: ${action.path}'); + if (action.args.isNotEmpty) { + printInfo(' args: ${action.args.join(' ')}'); + } + } + } +} diff --git a/lib/commands/custom/user_custom_command.dart b/lib/commands/custom/user_custom_command.dart new file mode 100644 index 0000000..6d70602 --- /dev/null +++ b/lib/commands/custom/user_custom_command.dart @@ -0,0 +1,77 @@ +import 'package:alex/runner/alex_command.dart'; +import 'package:alex/src/custom_commands/custom_command_argument.dart'; +import 'package:alex/src/custom_commands/custom_command_config.dart'; +import 'package:alex/src/custom_commands/custom_command_executor.dart'; + +/// A dynamically created command based on custom command definition. +class UserCustomCommand extends AlexCommand { + final CustomCommandDefinition _definition; + final CustomCommandExecutor _executor; + + UserCustomCommand(this._definition) + : _executor = CustomCommandExecutor(), + super( + _definition.name, + _definition.description, + _definition.aliases, + ) { + // Add arguments from definition + for (final arg in _definition.arguments) { + _addArgument(arg); + } + } + + void _addArgument(CustomCommandArgument arg) { + switch (arg.type) { + case CustomCommandArgumentType.option: + argParser.addOption( + arg.name, + abbr: arg.abbr, + help: arg.help, + defaultsTo: arg.defaultValue, + allowed: arg.allowed, + mandatory: arg.required, + ); + break; + case CustomCommandArgumentType.flag: + argParser.addFlag( + arg.name, + abbr: arg.abbr, + help: arg.help, + defaultsTo: arg.defaultValue == 'true', + ); + break; + } + } + + @override + Future doRun() async { + // Collect argument values + final arguments = {}; + + for (final arg in _definition.arguments) { + final value = argResults![arg.name]; + if (value != null) { + arguments[arg.name] = value; + } + } + + printVerbose('Executing custom command: ${_definition.name}'); + printVerbose('Arguments: $arguments'); + printVerbose('Verbose mode: enabled'); + + try { + // Create executor with verbose logger if needed + final executor = isVerbose + ? CustomCommandExecutor(logger: out) + : _executor; + + final exitCode = await executor.execute(_definition, arguments); + return exitCode; + } catch (e, st) { + printError('Failed to execute custom command: $e'); + printVerbose('Stack trace: $st'); + return 1; + } + } +} diff --git a/lib/runner/alex_command_runner.dart b/lib/runner/alex_command_runner.dart index 7a76714..dc9b256 100644 --- a/lib/runner/alex_command_runner.dart +++ b/lib/runner/alex_command_runner.dart @@ -1,6 +1,8 @@ import 'dart:async'; import 'package:alex/commands/code/code_command.dart'; +import 'package:alex/commands/custom/custom_command.dart'; +import 'package:alex/commands/custom/user_custom_command.dart'; import 'package:alex/commands/feature/feature_command.dart'; import 'package:alex/commands/l10n/l10n_command.dart'; import 'package:alex/commands/pubspec/pubspec_command.dart'; @@ -8,6 +10,7 @@ import 'package:alex/commands/release/release_command.dart'; import 'package:alex/commands/settings/settings_command.dart'; import 'package:alex/runner/alex_command.dart'; import 'package:alex/commands/update/update_command.dart'; +import 'package:alex/src/custom_commands/custom_command_config.dart'; import 'package:alex/src/local_data.dart'; import 'package:alex/src/system/update_checker.dart'; import 'package:alex/src/version.dart'; @@ -37,8 +40,12 @@ class AlexCommandRunner extends CommandRunner { FeatureCommand(), SettingsCommand(), UpdateCommand(), + CustomCommand(), ].forEach(addCommand); + // Load and register custom commands + _loadCustomCommands(); + argParser ..addFlag( _argVersion, @@ -49,6 +56,29 @@ class AlexCommandRunner extends CommandRunner { ..addVerboseFlag(); } + void _loadCustomCommands() { + try { + CustomCommandsConfig.load(); + final config = CustomCommandsConfig.instance; + + for (final definition in config.commands) { + try { + final command = UserCustomCommand(definition); + addCommand(command); + _out.fine('Registered custom command: ${definition.name}'); + } catch (e) { + _out.warning('Failed to register custom command ${definition.name}: $e'); + } + } + + if (config.commands.isNotEmpty) { + _out.fine('Loaded ${config.commands.length} custom command(s)'); + } + } catch (e) { + _out.fine('No custom commands loaded: $e'); + } + } + @override Future runCommand(ArgResults topLevelResults) async { final version = topLevelResults[_argVersion] as bool; diff --git a/lib/src/custom_commands/custom_command_action.dart b/lib/src/custom_commands/custom_command_action.dart new file mode 100644 index 0000000..16e4868 --- /dev/null +++ b/lib/src/custom_commands/custom_command_action.dart @@ -0,0 +1,1140 @@ +import 'package:yaml/yaml.dart'; + +/// Base class for custom command actions. +abstract class CustomCommandAction { + /// Type of the action. + String get type; + + /// Custom error message to display if action fails. + String? get errorMessage; + + /// Create action from YAML data. + factory CustomCommandAction.fromYaml(YamlMap data) { + final type = data['type'] as String?; + if (type == null) { + throw Exception('Action type is required'); + } + + switch (type) { + case 'exec': + return ExecAction.fromYaml(data); + case 'alex': + return AlexAction.fromYaml(data); + case 'script': + return ScriptAction.fromYaml(data); + case 'check_git_branch': + return CheckGitBranchAction.fromYaml(data); + case 'check_git_clean': + return CheckGitCleanAction.fromYaml(data); + case 'change_dir': + return ChangeDirAction.fromYaml(data); + case 'create_dir': + return CreateDirAction.fromYaml(data); + case 'delete_dir': + return DeleteDirAction.fromYaml(data); + case 'rename_dir': + return RenameDirAction.fromYaml(data); + case 'delete_file': + return DeleteFileAction.fromYaml(data); + case 'check_file_exists': + return CheckFileExistsAction.fromYaml(data); + case 'copy_file': + return CopyFileAction.fromYaml(data); + case 'rename_file': + return RenameFileAction.fromYaml(data); + case 'move_file': + return MoveFileAction.fromYaml(data); + case 'create_file': + return CreateFileAction.fromYaml(data); + case 'replace_in_file': + return ReplaceInFileAction.fromYaml(data); + case 'append_to_file': + return AppendToFileAction.fromYaml(data); + case 'prepend_to_file': + return PrependToFileAction.fromYaml(data); + case 'print': + return PrintAction.fromYaml(data); + case 'wait': + return WaitAction.fromYaml(data); + case 'check_platform': + return CheckPlatformAction.fromYaml(data); + case 'create_archive': + return CreateArchiveAction.fromYaml(data); + case 'extract_archive': + return ExtractArchiveAction.fromYaml(data); + default: + throw Exception('Unknown action type: $type'); + } + } + + /// Convert action to YAML map. + Map toYaml(); +} + +/// Execute shell command or program. +class ExecAction implements CustomCommandAction { + @override + final String type = 'exec'; + + /// Command to execute. + final String command; + + /// Working directory (optional). + final String? workingDir; + + @override + final String? errorMessage; + + ExecAction({ + required this.command, + this.workingDir, + this.errorMessage, + }); + + factory ExecAction.fromYaml(YamlMap data) { + return ExecAction( + command: data['command'] as String? ?? + (throw Exception('exec action requires "command" field')), + workingDir: data['working_dir'] as String?, + errorMessage: data['error_message'] as String?, + ); + } + + @override + Map toYaml() { + final result = { + 'type': type, + 'command': command, + }; + if (workingDir != null) { + result['working_dir'] = workingDir; + } + if (errorMessage != null) { + result['error_message'] = errorMessage; + } + return result; + } +} + +/// Execute existing alex command. +class AlexAction implements CustomCommandAction { + @override + final String type = 'alex'; + + /// Alex command to execute (e.g., 'code gen', 'l10n extract'). + final String command; + + /// Additional arguments for the command. + final List args; + + @override + final String? errorMessage; + + AlexAction({ + required this.command, + this.args = const [], + this.errorMessage, + }); + + factory AlexAction.fromYaml(YamlMap data) { + final args = data['args'] as YamlList?; + return AlexAction( + command: data['command'] as String? ?? + (throw Exception('alex action requires "command" field')), + args: args?.map((e) => e.toString()).toList() ?? [], + errorMessage: data['error_message'] as String?, + ); + } + + @override + Map toYaml() { + final result = { + 'type': type, + 'command': command, + }; + if (args.isNotEmpty) { + result['args'] = args; + } + if (errorMessage != null) { + result['error_message'] = errorMessage; + } + return result; + } +} + +/// Execute Dart script. +class ScriptAction implements CustomCommandAction { + @override + final String type = 'script'; + + /// Path to Dart script file. + final String path; + + /// Arguments to pass to the script. + final List args; + + @override + final String? errorMessage; + + ScriptAction({ + required this.path, + this.args = const [], + this.errorMessage, + }); + + factory ScriptAction.fromYaml(YamlMap data) { + final args = data['args'] as YamlList?; + return ScriptAction( + path: data['path'] as String? ?? + (throw Exception('script action requires "path" field')), + args: args?.map((e) => e.toString()).toList() ?? [], + errorMessage: data['error_message'] as String?, + ); + } + + @override + Map toYaml() { + final result = { + 'type': type, + 'path': path, + }; + if (args.isNotEmpty) { + result['args'] = args; + } + if (errorMessage != null) { + result['error_message'] = errorMessage; + } + return result; + } +} + +/// Check git branch and optionally switch to it. +class CheckGitBranchAction implements CustomCommandAction { + @override + final String type = 'check_git_branch'; + + /// Expected branch name. + final String branch; + + /// Whether to switch to branch if not on it. + final bool autoSwitch; + + @override + final String? errorMessage; + + CheckGitBranchAction({ + required this.branch, + this.autoSwitch = true, + this.errorMessage, + }); + + factory CheckGitBranchAction.fromYaml(YamlMap data) { + return CheckGitBranchAction( + branch: data['branch'] as String? ?? + (throw Exception('check_git_branch action requires "branch" field')), + autoSwitch: data['auto_switch'] as bool? ?? true, + errorMessage: data['error_message'] as String?, + ); + } + + @override + Map toYaml() { + final result = { + 'type': type, + 'branch': branch, + }; + if (!autoSwitch) { + result['auto_switch'] = autoSwitch; + } + if (errorMessage != null) { + result['error_message'] = errorMessage; + } + return result; + } +} + +/// Check that git working directory is clean. +class CheckGitCleanAction implements CustomCommandAction { + @override + final String type = 'check_git_clean'; + + @override + final String? errorMessage; + + CheckGitCleanAction({ + this.errorMessage, + }); + + factory CheckGitCleanAction.fromYaml(YamlMap data) { + return CheckGitCleanAction( + errorMessage: data['error_message'] as String?, + ); + } + + @override + Map toYaml() { + final result = { + 'type': type, + }; + if (errorMessage != null) { + result['error_message'] = errorMessage; + } + return result; + } +} + +/// Change working directory. +class ChangeDirAction implements CustomCommandAction { + @override + final String type = 'change_dir'; + + /// Directory path to change to. + final String path; + + @override + final String? errorMessage; + + ChangeDirAction({ + required this.path, + this.errorMessage, + }); + + factory ChangeDirAction.fromYaml(YamlMap data) { + return ChangeDirAction( + path: data['path'] as String? ?? + (throw Exception('change_dir action requires "path" field')), + errorMessage: data['error_message'] as String?, + ); + } + + @override + Map toYaml() { + final result = { + 'type': type, + 'path': path, + }; + if (errorMessage != null) { + result['error_message'] = errorMessage; + } + return result; + } +} + +/// Delete a file or directory. +class DeleteFileAction implements CustomCommandAction { + @override + final String type = 'delete_file'; + + /// Path to file or directory to delete. + final String path; + + /// Whether to delete recursively (for directories). + final bool recursive; + + /// Whether to ignore if file doesn't exist. + final bool ignoreNotFound; + + @override + final String? errorMessage; + + DeleteFileAction({ + required this.path, + this.recursive = false, + this.ignoreNotFound = true, + this.errorMessage, + }); + + factory DeleteFileAction.fromYaml(YamlMap data) { + return DeleteFileAction( + path: data['path'] as String? ?? + (throw Exception('delete_file action requires "path" field')), + recursive: data['recursive'] as bool? ?? false, + ignoreNotFound: data['ignore_not_found'] as bool? ?? true, + errorMessage: data['error_message'] as String?, + ); + } + + @override + Map toYaml() { + final result = { + 'type': type, + 'path': path, + }; + if (recursive) { + result['recursive'] = recursive; + } + if (!ignoreNotFound) { + result['ignore_not_found'] = ignoreNotFound; + } + if (errorMessage != null) { + result['error_message'] = errorMessage; + } + return result; + } +} + +/// Check if file or directory exists. +class CheckFileExistsAction implements CustomCommandAction { + @override + final String type = 'check_file_exists'; + + /// Path to file or directory to check. + final String path; + + /// Whether file should exist (true) or not exist (false). + final bool shouldExist; + + @override + final String? errorMessage; + + CheckFileExistsAction({ + required this.path, + this.shouldExist = true, + this.errorMessage, + }); + + factory CheckFileExistsAction.fromYaml(YamlMap data) { + return CheckFileExistsAction( + path: data['path'] as String? ?? + (throw Exception('check_file_exists action requires "path" field')), + shouldExist: data['should_exist'] as bool? ?? true, + errorMessage: data['error_message'] as String?, + ); + } + + @override + Map toYaml() { + final result = { + 'type': type, + 'path': path, + }; + if (!shouldExist) { + result['should_exist'] = shouldExist; + } + if (errorMessage != null) { + result['error_message'] = errorMessage; + } + return result; + } +} + +/// Copy a file. +class CopyFileAction implements CustomCommandAction { + @override + final String type = 'copy_file'; + + /// Source file path. + final String source; + + /// Destination file path. + final String destination; + + /// Whether to overwrite if destination exists. + final bool overwrite; + + @override + final String? errorMessage; + + CopyFileAction({ + required this.source, + required this.destination, + this.overwrite = false, + this.errorMessage, + }); + + factory CopyFileAction.fromYaml(YamlMap data) { + return CopyFileAction( + source: data['source'] as String? ?? + (throw Exception('copy_file action requires "source" field')), + destination: data['destination'] as String? ?? + (throw Exception('copy_file action requires "destination" field')), + overwrite: data['overwrite'] as bool? ?? false, + errorMessage: data['error_message'] as String?, + ); + } + + @override + Map toYaml() { + final result = { + 'type': type, + 'source': source, + 'destination': destination, + }; + if (overwrite) { + result['overwrite'] = overwrite; + } + if (errorMessage != null) { + result['error_message'] = errorMessage; + } + return result; + } +} + +/// Rename a file. +class RenameFileAction implements CustomCommandAction { + @override + final String type = 'rename_file'; + + /// Current file path. + final String oldPath; + + /// New file path. + final String newPath; + + @override + final String? errorMessage; + + RenameFileAction({ + required this.oldPath, + required this.newPath, + this.errorMessage, + }); + + factory RenameFileAction.fromYaml(YamlMap data) { + return RenameFileAction( + oldPath: data['old_path'] as String? ?? + (throw Exception('rename_file action requires "old_path" field')), + newPath: data['new_path'] as String? ?? + (throw Exception('rename_file action requires "new_path" field')), + errorMessage: data['error_message'] as String?, + ); + } + + @override + Map toYaml() { + final result = { + 'type': type, + 'old_path': oldPath, + 'new_path': newPath, + }; + if (errorMessage != null) { + result['error_message'] = errorMessage; + } + return result; + } +} + +/// Move a file. +class MoveFileAction implements CustomCommandAction { + @override + final String type = 'move_file'; + + /// Source file path. + final String source; + + /// Destination file path. + final String destination; + + @override + final String? errorMessage; + + MoveFileAction({ + required this.source, + required this.destination, + this.errorMessage, + }); + + factory MoveFileAction.fromYaml(YamlMap data) { + return MoveFileAction( + source: data['source'] as String? ?? + (throw Exception('move_file action requires "source" field')), + destination: data['destination'] as String? ?? + (throw Exception('move_file action requires "destination" field')), + errorMessage: data['error_message'] as String?, + ); + } + + @override + Map toYaml() { + final result = { + 'type': type, + 'source': source, + 'destination': destination, + }; + if (errorMessage != null) { + result['error_message'] = errorMessage; + } + return result; + } +} + +/// Create a file with optional content. +class CreateFileAction implements CustomCommandAction { + @override + final String type = 'create_file'; + + /// File path to create. + final String path; + + /// File content (optional). + final String? content; + + /// Whether to overwrite if file exists. + final bool overwrite; + + @override + final String? errorMessage; + + CreateFileAction({ + required this.path, + this.content, + this.overwrite = false, + this.errorMessage, + }); + + factory CreateFileAction.fromYaml(YamlMap data) { + return CreateFileAction( + path: data['path'] as String? ?? + (throw Exception('create_file action requires "path" field')), + content: data['content'] as String?, + overwrite: data['overwrite'] as bool? ?? false, + errorMessage: data['error_message'] as String?, + ); + } + + @override + Map toYaml() { + final result = { + 'type': type, + 'path': path, + }; + if (content != null) { + result['content'] = content; + } + if (overwrite) { + result['overwrite'] = overwrite; + } + if (errorMessage != null) { + result['error_message'] = errorMessage; + } + return result; + } +} + +/// Create a directory. +class CreateDirAction implements CustomCommandAction { + @override + final String type = 'create_dir'; + + /// Directory path to create. + final String path; + + /// Whether to create parent directories. + final bool recursive; + + @override + final String? errorMessage; + + CreateDirAction({ + required this.path, + this.recursive = true, + this.errorMessage, + }); + + factory CreateDirAction.fromYaml(YamlMap data) { + return CreateDirAction( + path: data['path'] as String? ?? + (throw Exception('create_dir action requires "path" field')), + recursive: data['recursive'] as bool? ?? true, + errorMessage: data['error_message'] as String?, + ); + } + + @override + Map toYaml() { + final result = { + 'type': type, + 'path': path, + }; + if (!recursive) { + result['recursive'] = recursive; + } + if (errorMessage != null) { + result['error_message'] = errorMessage; + } + return result; + } +} + +/// Delete a directory. +class DeleteDirAction implements CustomCommandAction { + @override + final String type = 'delete_dir'; + + /// Directory path to delete. + final String path; + + /// Whether to delete recursively. + final bool recursive; + + /// Whether to ignore if directory doesn't exist. + final bool ignoreNotFound; + + @override + final String? errorMessage; + + DeleteDirAction({ + required this.path, + this.recursive = true, + this.ignoreNotFound = true, + this.errorMessage, + }); + + factory DeleteDirAction.fromYaml(YamlMap data) { + return DeleteDirAction( + path: data['path'] as String? ?? + (throw Exception('delete_dir action requires "path" field')), + recursive: data['recursive'] as bool? ?? true, + ignoreNotFound: data['ignore_not_found'] as bool? ?? true, + errorMessage: data['error_message'] as String?, + ); + } + + @override + Map toYaml() { + final result = { + 'type': type, + 'path': path, + }; + if (!recursive) { + result['recursive'] = recursive; + } + if (!ignoreNotFound) { + result['ignore_not_found'] = ignoreNotFound; + } + if (errorMessage != null) { + result['error_message'] = errorMessage; + } + return result; + } +} + +/// Rename a directory. +class RenameDirAction implements CustomCommandAction { + @override + final String type = 'rename_dir'; + + /// Current directory path. + final String oldPath; + + /// New directory path. + final String newPath; + + @override + final String? errorMessage; + + RenameDirAction({ + required this.oldPath, + required this.newPath, + this.errorMessage, + }); + + factory RenameDirAction.fromYaml(YamlMap data) { + return RenameDirAction( + oldPath: data['old_path'] as String? ?? + (throw Exception('rename_dir action requires "old_path" field')), + newPath: data['new_path'] as String? ?? + (throw Exception('rename_dir action requires "new_path" field')), + errorMessage: data['error_message'] as String?, + ); + } + + @override + Map toYaml() { + final result = { + 'type': type, + 'old_path': oldPath, + 'new_path': newPath, + }; + if (errorMessage != null) { + result['error_message'] = errorMessage; + } + return result; + } +} + +/// Replace text in file. +class ReplaceInFileAction implements CustomCommandAction { + @override + final String type = 'replace_in_file'; + + /// Path to file. + final String path; + + /// Text or pattern to find. + final String find; + + /// Replacement text. + final String replace; + + /// Whether to use regex matching. + final bool regex; + + @override + final String? errorMessage; + + ReplaceInFileAction({ + required this.path, + required this.find, + required this.replace, + this.regex = false, + this.errorMessage, + }); + + factory ReplaceInFileAction.fromYaml(YamlMap data) { + return ReplaceInFileAction( + path: data['path'] as String? ?? + (throw Exception('replace_in_file action requires "path" field')), + find: data['find'] as String? ?? + (throw Exception('replace_in_file action requires "find" field')), + replace: data['replace'] as String? ?? + (throw Exception('replace_in_file action requires "replace" field')), + regex: data['regex'] as bool? ?? false, + errorMessage: data['error_message'] as String?, + ); + } + + @override + Map toYaml() { + final result = { + 'type': type, + 'path': path, + 'find': find, + 'replace': replace, + }; + if (regex) { + result['regex'] = regex; + } + if (errorMessage != null) { + result['error_message'] = errorMessage; + } + return result; + } +} + +/// Append text to file. +class AppendToFileAction implements CustomCommandAction { + @override + final String type = 'append_to_file'; + + /// Path to file. + final String path; + + /// Content to append. + final String content; + + /// Create file if it doesn't exist. + final bool createIfMissing; + + @override + final String? errorMessage; + + AppendToFileAction({ + required this.path, + required this.content, + this.createIfMissing = true, + this.errorMessage, + }); + + factory AppendToFileAction.fromYaml(YamlMap data) { + return AppendToFileAction( + path: data['path'] as String? ?? + (throw Exception('append_to_file action requires "path" field')), + content: data['content'] as String? ?? + (throw Exception('append_to_file action requires "content" field')), + createIfMissing: data['create_if_missing'] as bool? ?? true, + errorMessage: data['error_message'] as String?, + ); + } + + @override + Map toYaml() { + final result = { + 'type': type, + 'path': path, + 'content': content, + }; + if (!createIfMissing) { + result['create_if_missing'] = createIfMissing; + } + if (errorMessage != null) { + result['error_message'] = errorMessage; + } + return result; + } +} + +/// Prepend text to file. +class PrependToFileAction implements CustomCommandAction { + @override + final String type = 'prepend_to_file'; + + /// Path to file. + final String path; + + /// Content to prepend. + final String content; + + /// Create file if it doesn't exist. + final bool createIfMissing; + + @override + final String? errorMessage; + + PrependToFileAction({ + required this.path, + required this.content, + this.createIfMissing = true, + this.errorMessage, + }); + + factory PrependToFileAction.fromYaml(YamlMap data) { + return PrependToFileAction( + path: data['path'] as String? ?? + (throw Exception('prepend_to_file action requires "path" field')), + content: data['content'] as String? ?? + (throw Exception('prepend_to_file action requires "content" field')), + createIfMissing: data['create_if_missing'] as bool? ?? true, + errorMessage: data['error_message'] as String?, + ); + } + + @override + Map toYaml() { + final result = { + 'type': type, + 'path': path, + 'content': content, + }; + if (!createIfMissing) { + result['create_if_missing'] = createIfMissing; + } + if (errorMessage != null) { + result['error_message'] = errorMessage; + } + return result; + } +} + +/// Print message to console. +class PrintAction implements CustomCommandAction { + @override + final String type = 'print'; + + /// Message to print. + final String message; + + /// Message level (info, warning, error). + final String level; + + @override + final String? errorMessage; + + PrintAction({ + required this.message, + this.level = 'info', + this.errorMessage, + }); + + factory PrintAction.fromYaml(YamlMap data) { + return PrintAction( + message: data['message'] as String? ?? + (throw Exception('print action requires "message" field')), + level: data['level'] as String? ?? 'info', + errorMessage: data['error_message'] as String?, + ); + } + + @override + Map toYaml() { + final result = { + 'type': type, + 'message': message, + }; + if (level != 'info') { + result['level'] = level; + } + if (errorMessage != null) { + result['error_message'] = errorMessage; + } + return result; + } +} + +/// Wait for specified duration. +class WaitAction implements CustomCommandAction { + @override + final String type = 'wait'; + + /// Duration in milliseconds. + final int milliseconds; + + /// Optional message to display. + final String? message; + + @override + final String? errorMessage; + + WaitAction({ + required this.milliseconds, + this.message, + this.errorMessage, + }); + + factory WaitAction.fromYaml(YamlMap data) { + return WaitAction( + milliseconds: data['milliseconds'] as int? ?? + (throw Exception('wait action requires "milliseconds" field')), + message: data['message'] as String?, + errorMessage: data['error_message'] as String?, + ); + } + + @override + Map toYaml() { + final result = { + 'type': type, + 'milliseconds': milliseconds, + }; + if (message != null) { + result['message'] = message; + } + if (errorMessage != null) { + result['error_message'] = errorMessage; + } + return result; + } +} + +/// Check current platform. +class CheckPlatformAction implements CustomCommandAction { + @override + final String type = 'check_platform'; + + /// Expected platform (macos, linux, windows). + final String platform; + + @override + final String? errorMessage; + + CheckPlatformAction({ + required this.platform, + this.errorMessage, + }); + + factory CheckPlatformAction.fromYaml(YamlMap data) { + return CheckPlatformAction( + platform: data['platform'] as String? ?? + (throw Exception('check_platform action requires "platform" field')), + errorMessage: data['error_message'] as String?, + ); + } + + @override + Map toYaml() { + final result = { + 'type': type, + 'platform': platform, + }; + if (errorMessage != null) { + result['error_message'] = errorMessage; + } + return result; + } +} + +/// Create archive from files or directory. +class CreateArchiveAction implements CustomCommandAction { + @override + final String type = 'create_archive'; + + /// Source path (file or directory). + final String source; + + /// Destination archive path. + final String destination; + + /// Archive format (zip or tar.gz). + final String format; + + @override + final String? errorMessage; + + CreateArchiveAction({ + required this.source, + required this.destination, + this.format = 'zip', + this.errorMessage, + }); + + factory CreateArchiveAction.fromYaml(YamlMap data) { + return CreateArchiveAction( + source: data['source'] as String? ?? + (throw Exception('create_archive action requires "source" field')), + destination: data['destination'] as String? ?? + (throw Exception('create_archive action requires "destination" field')), + format: data['format'] as String? ?? 'zip', + errorMessage: data['error_message'] as String?, + ); + } + + @override + Map toYaml() { + final result = { + 'type': type, + 'source': source, + 'destination': destination, + }; + if (format != 'zip') { + result['format'] = format; + } + if (errorMessage != null) { + result['error_message'] = errorMessage; + } + return result; + } +} + +/// Extract archive. +class ExtractArchiveAction implements CustomCommandAction { + @override + final String type = 'extract_archive'; + + /// Source archive path. + final String source; + + /// Destination directory. + final String destination; + + @override + final String? errorMessage; + + ExtractArchiveAction({ + required this.source, + required this.destination, + this.errorMessage, + }); + + factory ExtractArchiveAction.fromYaml(YamlMap data) { + return ExtractArchiveAction( + source: data['source'] as String? ?? + (throw Exception('extract_archive action requires "source" field')), + destination: data['destination'] as String? ?? + (throw Exception('extract_archive action requires "destination" field')), + errorMessage: data['error_message'] as String?, + ); + } + + @override + Map toYaml() { + final result = { + 'type': type, + 'source': source, + 'destination': destination, + }; + if (errorMessage != null) { + result['error_message'] = errorMessage; + } + return result; + } +} diff --git a/lib/src/custom_commands/custom_command_argument.dart b/lib/src/custom_commands/custom_command_argument.dart new file mode 100644 index 0000000..4ff11c9 --- /dev/null +++ b/lib/src/custom_commands/custom_command_argument.dart @@ -0,0 +1,87 @@ +import 'package:yaml/yaml.dart'; + +/// Argument type for custom command. +enum CustomCommandArgumentType { + /// Option argument (--name value). + option, + + /// Flag argument (--name). + flag, +} + +/// Argument definition for custom command. +class CustomCommandArgument { + /// Name of the argument. + final String name; + + /// Type of the argument. + final CustomCommandArgumentType type; + + /// Help text for the argument. + final String? help; + + /// Abbreviation for the argument. + final String? abbr; + + /// Default value. + final String? defaultValue; + + /// Allowed values (for option type). + final List? allowed; + + /// Is this argument required? + final bool required; + + CustomCommandArgument({ + required this.name, + required this.type, + this.help, + this.abbr, + this.defaultValue, + this.allowed, + this.required = false, + }); + + factory CustomCommandArgument.fromYaml(YamlMap data) { + final typeStr = data['type'] as String? ?? 'option'; + final CustomCommandArgumentType type; + switch (typeStr) { + case 'option': + type = CustomCommandArgumentType.option; + break; + case 'flag': + type = CustomCommandArgumentType.flag; + break; + default: + throw Exception('Unknown argument type: $typeStr'); + } + + final allowedList = data['allowed'] as YamlList?; + + return CustomCommandArgument( + name: data['name'] as String? ?? + (throw Exception('Argument requires "name" field')), + type: type, + help: data['help'] as String?, + abbr: data['abbr'] as String?, + defaultValue: data['default'] as String?, + allowed: allowedList?.map((e) => e.toString()).toList(), + required: data['required'] as bool? ?? false, + ); + } + + Map toYaml() { + final result = { + 'name': name, + 'type': type == CustomCommandArgumentType.option ? 'option' : 'flag', + }; + + if (help != null) result['help'] = help; + if (abbr != null) result['abbr'] = abbr; + if (defaultValue != null) result['default'] = defaultValue; + if (allowed != null && allowed!.isNotEmpty) result['allowed'] = allowed; + if (required) result['required'] = true; + + return result; + } +} diff --git a/lib/src/custom_commands/custom_command_config.dart b/lib/src/custom_commands/custom_command_config.dart new file mode 100644 index 0000000..37f47c9 --- /dev/null +++ b/lib/src/custom_commands/custom_command_config.dart @@ -0,0 +1,311 @@ +import 'dart:io'; + +import 'package:alex/src/custom_commands/custom_command_action.dart'; +import 'package:alex/src/custom_commands/custom_command_argument.dart'; +import 'package:logging/logging.dart'; +import 'package:yaml/yaml.dart'; +import 'package:path/path.dart' as p; + +/// Custom command definition. +class CustomCommandDefinition { + /// Name of the command. + final String name; + + /// Description of the command. + final String description; + + /// Aliases for the command. + final List aliases; + + /// Arguments for the command. + final List arguments; + + /// Actions to execute. + final List actions; + + CustomCommandDefinition({ + required this.name, + required this.description, + this.aliases = const [], + this.arguments = const [], + required this.actions, + }); + + factory CustomCommandDefinition.fromYaml(YamlMap data) { + final aliasesList = data['aliases'] as YamlList?; + final argumentsList = data['arguments'] as YamlList?; + final actionsList = data['actions'] as YamlList?; + + if (actionsList == null || actionsList.isEmpty) { + throw Exception('Custom command requires at least one action'); + } + + return CustomCommandDefinition( + name: data['name'] as String? ?? + (throw Exception('Custom command requires "name" field')), + description: data['description'] as String? ?? '', + aliases: aliasesList?.map((e) => e.toString()).toList() ?? [], + arguments: argumentsList + ?.map((e) => CustomCommandArgument.fromYaml(e as YamlMap)) + .toList() ?? + [], + actions: actionsList + .map((e) => CustomCommandAction.fromYaml(e as YamlMap)) + .toList(), + ); + } + + Map toYaml() { + final result = { + 'name': name, + 'description': description, + }; + + if (aliases.isNotEmpty) { + result['aliases'] = aliases; + } + if (arguments.isNotEmpty) { + result['arguments'] = arguments.map((e) => e.toYaml()).toList(); + } + result['actions'] = actions.map((e) => e.toYaml()).toList(); + + return result; + } +} + +/// Configuration for custom commands. +class CustomCommandsConfig { + static const _configFileName = 'alex_custom_commands.yaml'; + static final _logger = Logger('custom_commands_config'); + + static CustomCommandsConfig? _instance; + + /// Returns instance of loaded configuration. + static CustomCommandsConfig get instance { + if (_instance == null) { + load(); + } + return _instance!; + } + + static bool get hasInstance => _instance != null; + + /// Load configuration from default location. + static void load({String? path}) { + if (_instance != null) { + _logger.warning('Config already loaded, reloading...'); + _instance = null; + } + + final configPath = path ?? _findConfigFile(); + if (configPath == null) { + _logger.fine('Custom commands config file not found, using empty config'); + _instance = CustomCommandsConfig._([], null); + return; + } + + try { + _instance = _loadFromFile(configPath); + _logger.fine('Loaded custom commands config from: $configPath'); + } catch (e) { + _logger.warning('Failed to load custom commands config: $e'); + _instance = CustomCommandsConfig._([], null); + } + } + + /// Reload configuration. + static void reload() { + _instance = null; + load(); + } + + static String? _findConfigFile() { + // Look for config file in current directory and parent directories + var dir = Directory.current; + for (var i = 0; i < 10; i++) { + final configFile = File(p.join(dir.path, _configFileName)); + if (configFile.existsSync()) { + return configFile.path; + } + + final parent = dir.parent; + if (parent.path == dir.path) { + break; // Reached root + } + dir = parent; + } + + return null; + } + + static CustomCommandsConfig _loadFromFile(String path) { + final file = File(path); + if (!file.existsSync()) { + throw Exception('Config file not found: $path'); + } + + final yamlString = file.readAsStringSync(); + final yamlData = loadYaml(yamlString); + + if (yamlData == null) { + return CustomCommandsConfig._([], path); + } + + if (yamlData is! YamlMap) { + throw Exception('Invalid config file format'); + } + + final commandsList = yamlData['custom_commands'] as YamlList?; + if (commandsList == null || commandsList.isEmpty) { + return CustomCommandsConfig._([], path); + } + + final commands = commandsList + .map((e) => CustomCommandDefinition.fromYaml(e as YamlMap)) + .toList(); + + return CustomCommandsConfig._(commands, path); + } + + final List _commands; + final String? _configPath; + + CustomCommandsConfig._(this._commands, this._configPath); + + /// All custom commands. + List get commands => List.unmodifiable(_commands); + + /// Path to the config file (null if not loaded from file). + String? get configPath => _configPath; + + /// Get config file path or create default path. + String getOrCreateConfigPath() { + if (_configPath != null) { + return _configPath!; + } + + // Default to current directory + return p.join(Directory.current.path, _configFileName); + } + + /// Find command by name or alias. + CustomCommandDefinition? findCommand(String name) { + return _commands.firstWhere( + (cmd) => cmd.name == name || cmd.aliases.contains(name), + orElse: () => throw StateError('Command not found: $name'), + ); + } + + /// Check if command exists. + bool hasCommand(String name) { + try { + findCommand(name); + return true; + } catch (_) { + return false; + } + } + + /// Save configuration to file. + void save() { + final path = getOrCreateConfigPath(); + final file = File(path); + + // Convert to YAML string manually for better formatting + final buffer = StringBuffer(); + buffer.writeln('# Alex Custom Commands Configuration'); + buffer.writeln('# Define your custom commands here'); + buffer.writeln(); + buffer.writeln('custom_commands:'); + + for (final cmd in _commands) { + buffer.writeln(' - name: ${cmd.name}'); + buffer.writeln(' description: ${cmd.description}'); + + if (cmd.aliases.isNotEmpty) { + buffer.writeln(' aliases: ${cmd.aliases}'); + } + + if (cmd.arguments.isNotEmpty) { + buffer.writeln(' arguments:'); + for (final arg in cmd.arguments) { + buffer.writeln(' - name: ${arg.name}'); + buffer.writeln(' type: ${arg.type == CustomCommandArgumentType.option ? 'option' : 'flag'}'); + if (arg.help != null) { + buffer.writeln(' help: ${arg.help}'); + } + if (arg.abbr != null) { + buffer.writeln(' abbr: ${arg.abbr}'); + } + if (arg.defaultValue != null) { + buffer.writeln(' default: ${arg.defaultValue}'); + } + if (arg.allowed != null && arg.allowed!.isNotEmpty) { + buffer.writeln(' allowed: ${arg.allowed}'); + } + if (arg.required) { + buffer.writeln(' required: true'); + } + } + } + + buffer.writeln(' actions:'); + for (final action in cmd.actions) { + if (action is ExecAction) { + buffer.writeln(' - type: exec'); + buffer.writeln(' command: ${action.command}'); + if (action.workingDir != null) { + buffer.writeln(' working_dir: ${action.workingDir}'); + } + } else if (action is AlexAction) { + buffer.writeln(' - type: alex'); + buffer.writeln(' command: ${action.command}'); + if (action.args.isNotEmpty) { + buffer.writeln(' args: ${action.args}'); + } + } else if (action is ScriptAction) { + buffer.writeln(' - type: script'); + buffer.writeln(' path: ${action.path}'); + if (action.args.isNotEmpty) { + buffer.writeln(' args: ${action.args}'); + } + } + } + buffer.writeln(); + } + + file.writeAsStringSync(buffer.toString()); + _logger.info('Saved custom commands config to: $path'); + } + + /// Add a new command. + void addCommand(CustomCommandDefinition command) { + if (hasCommand(command.name)) { + throw Exception('Command already exists: ${command.name}'); + } + _commands.add(command); + } + + /// Remove a command. + bool removeCommand(String name) { + final index = _commands.indexWhere( + (cmd) => cmd.name == name || cmd.aliases.contains(name), + ); + if (index != -1) { + _commands.removeAt(index); + return true; + } + return false; + } + + /// Update a command. + void updateCommand(String name, CustomCommandDefinition newCommand) { + final index = _commands.indexWhere( + (cmd) => cmd.name == name || cmd.aliases.contains(name), + ); + if (index == -1) { + throw Exception('Command not found: $name'); + } + _commands[index] = newCommand; + } +} diff --git a/lib/src/custom_commands/custom_command_executor.dart b/lib/src/custom_commands/custom_command_executor.dart new file mode 100644 index 0000000..96b1d53 --- /dev/null +++ b/lib/src/custom_commands/custom_command_executor.dart @@ -0,0 +1,869 @@ +import 'dart:io'; + +import 'package:alex/src/custom_commands/custom_command_action.dart'; +import 'package:alex/src/custom_commands/custom_command_config.dart'; +import 'package:alex/src/run/cmd.dart'; +import 'package:logging/logging.dart'; + +/// Executor for custom commands. +class CustomCommandExecutor { + final Logger _logger; + final Cmd _cmd; + + CustomCommandExecutor({ + Logger? logger, + Cmd? cmd, + }) : _logger = logger ?? Logger('custom_command_executor'), + _cmd = cmd ?? Cmd(); + + /// Execute a custom command. + Future execute( + CustomCommandDefinition definition, + Map arguments, + ) async { + _logger.info('Executing custom command: ${definition.name}'); + + for (var i = 0; i < definition.actions.length; i++) { + final action = definition.actions[i]; + _logger.fine('Executing action ${i + 1}/${definition.actions.length}: ${action.type}'); + + final exitCode = await _executeAction(action, arguments); + if (exitCode != 0) { + _logger.severe('Action failed with exit code: $exitCode'); + return exitCode; + } + } + + _logger.info('Custom command completed successfully'); + return 0; + } + + Future _executeAction( + CustomCommandAction action, + Map arguments, + ) async { + if (action is ExecAction) { + return _executeExecAction(action, arguments); + } else if (action is AlexAction) { + return _executeAlexAction(action, arguments); + } else if (action is ScriptAction) { + return _executeScriptAction(action, arguments); + } else if (action is CheckGitBranchAction) { + return _executeCheckGitBranchAction(action, arguments); + } else if (action is CheckGitCleanAction) { + return _executeCheckGitCleanAction(action, arguments); + } else if (action is ChangeDirAction) { + return _executeChangeDirAction(action, arguments); + } else if (action is DeleteFileAction) { + return _executeDeleteFileAction(action, arguments); + } else if (action is CheckFileExistsAction) { + return _executeCheckFileExistsAction(action, arguments); + } else if (action is CopyFileAction) { + return _executeCopyFileAction(action, arguments); + } else if (action is RenameFileAction) { + return _executeRenameFileAction(action, arguments); + } else if (action is MoveFileAction) { + return _executeMoveFileAction(action, arguments); + } else if (action is CreateFileAction) { + return _executeCreateFileAction(action, arguments); + } else if (action is CreateDirAction) { + return _executeCreateDirAction(action, arguments); + } else if (action is DeleteDirAction) { + return _executeDeleteDirAction(action, arguments); + } else if (action is RenameDirAction) { + return _executeRenameDirAction(action, arguments); + } else if (action is ReplaceInFileAction) { + return _executeReplaceInFileAction(action, arguments); + } else if (action is AppendToFileAction) { + return _executeAppendToFileAction(action, arguments); + } else if (action is PrependToFileAction) { + return _executePrependToFileAction(action, arguments); + } else if (action is PrintAction) { + return _executePrintAction(action, arguments); + } else if (action is WaitAction) { + return _executeWaitAction(action, arguments); + } else if (action is CheckPlatformAction) { + return _executeCheckPlatformAction(action, arguments); + } else if (action is CreateArchiveAction) { + return _executeCreateArchiveAction(action, arguments); + } else if (action is ExtractArchiveAction) { + return _executeExtractArchiveAction(action, arguments); + } else { + throw Exception('Unknown action type: ${action.type}'); + } + } + + Future _executeExecAction( + ExecAction action, + Map arguments, + ) async { + final command = _substituteVariables(action.command, arguments); + _logger.info('Executing: $command'); + + // Parse command into executable and arguments + final parts = _parseCommand(command); + if (parts.isEmpty) { + _logger.severe('Empty command'); + return 1; + } + + final executable = parts.first; + final args = parts.skip(1).toList(); + + try { + final result = await _cmd.run( + executable, + arguments: args, + workingDir: action.workingDir, + ); + + if (result.exitCode != 0) { + _logger.severe('Command failed: ${result.stderr}'); + } + + return result.exitCode; + } catch (e) { + _logger.severe('Failed to execute command: $e'); + return 1; + } + } + + Future _executeAlexAction( + AlexAction action, + Map arguments, + ) async { + final command = _substituteVariables(action.command, arguments); + final args = action.args + .map((arg) => _substituteVariables(arg, arguments)) + .toList(); + + _logger.info('Executing alex command: $command ${args.join(' ')}'); + + // Parse alex command (e.g., 'code gen' -> ['code', 'gen']) + final commandParts = command.split(' ').where((s) => s.isNotEmpty).toList(); + final allArgs = [...commandParts, ...args]; + + try { + ProcessResult result; + + // Try to find local alex executable first (for development) + final alexPath = _findAlexExecutable(); + if (alexPath != null && File(alexPath).existsSync()) { + _logger.fine('Using local alex: $alexPath'); + result = await _cmd.run('dart', arguments: [alexPath, ...allArgs]); + } else { + // Use globally installed alex + _logger.fine('Using global alex'); + result = await _cmd.run('alex', arguments: allArgs); + } + + if (result.exitCode != 0) { + _logger.severe('Alex command failed: ${result.stderr}'); + } + + return result.exitCode; + } catch (e) { + _logger.severe('Failed to execute alex command: $e'); + return 1; + } + } + + Future _executeScriptAction( + ScriptAction action, + Map arguments, + ) async { + final scriptPath = _substituteVariables(action.path, arguments); + final args = action.args + .map((arg) => _substituteVariables(arg, arguments)) + .toList(); + + _logger.info('Executing script: $scriptPath ${args.join(' ')}'); + + final file = File(scriptPath); + if (!file.existsSync()) { + _logger.severe('Script file not found: $scriptPath'); + return 1; + } + + try { + final result = await _cmd.run('dart', arguments: [scriptPath, ...args]); + + if (result.exitCode != 0) { + _logger.severe('Script failed: ${result.stderr}'); + } + + return result.exitCode; + } catch (e) { + _logger.severe('Failed to execute script: $e'); + return 1; + } + } + + /// Substitute variables in format {{var_name}} with actual values. + String _substituteVariables(String input, Map arguments) { + var result = input; + + // Replace {{arg_name}} with actual argument values + arguments.forEach((key, value) { + final pattern = '{{$key}}'; + result = result.replaceAll(pattern, value.toString()); + }); + + // Also support ${arg_name} format + arguments.forEach((key, value) { + final pattern = '\${$key}'; + result = result.replaceAll(pattern, value.toString()); + }); + + return result; + } + + /// Parse command string into parts, respecting quotes. + List _parseCommand(String command) { + final parts = []; + final buffer = StringBuffer(); + var inQuotes = false; + var quoteChar = ''; + + for (var i = 0; i < command.length; i++) { + final char = command[i]; + + if ((char == '"' || char == "'") && !inQuotes) { + inQuotes = true; + quoteChar = char; + } else if (char == quoteChar && inQuotes) { + inQuotes = false; + quoteChar = ''; + } else if (char == ' ' && !inQuotes) { + if (buffer.isNotEmpty) { + parts.add(buffer.toString()); + buffer.clear(); + } + } else { + buffer.write(char); + } + } + + if (buffer.isNotEmpty) { + parts.add(buffer.toString()); + } + + return parts; + } + + Future _executeCheckGitBranchAction( + CheckGitBranchAction action, + Map arguments, + ) async { + final branch = _substituteVariables(action.branch, arguments); + _logger.info('Checking git branch: $branch'); + + try { + // Get current branch + final currentBranchResult = await _cmd.run( + 'git', + arguments: ['branch', '--show-current'], + immediatePrintStd: false, + immediatePrintErr: false, + ); + + if (currentBranchResult.exitCode != 0) { + _logger.severe('Failed to get current branch'); + return 1; + } + + final currentBranch = currentBranchResult.stdout.toString().trim(); + + if (currentBranch == branch) { + _logger.info('Already on branch: $branch'); + return 0; + } + + if (!action.autoSwitch) { + final message = action.errorMessage ?? 'Not on branch: $branch (current: $currentBranch)'; + _logger.severe(message); + return 1; + } + + // Check if branch exists + final branchExistsResult = await _cmd.run( + 'git', + arguments: ['rev-parse', '--verify', branch], + immediatePrintStd: false, + immediatePrintErr: false, + ); + + if (branchExistsResult.exitCode != 0) { + final message = action.errorMessage ?? 'Branch does not exist: $branch'; + _logger.severe(message); + return 1; + } + + // Switch to branch + _logger.info('Switching to branch: $branch'); + final checkoutResult = await _cmd.run( + 'git', + arguments: ['checkout', branch], + ); + + if (checkoutResult.exitCode != 0) { + _logger.severe('Failed to switch to branch: $branch'); + return 1; + } + + return 0; + } catch (e) { + _logger.severe('Failed to check git branch: $e'); + return 1; + } + } + + Future _executeCheckGitCleanAction( + CheckGitCleanAction action, + Map arguments, + ) async { + _logger.info('Checking git working directory is clean'); + + try { + final result = await _cmd.run( + 'git', + arguments: ['status', '--porcelain'], + immediatePrintStd: false, + immediatePrintErr: false, + ); + + if (result.exitCode != 0) { + _logger.severe('Failed to check git status'); + return 1; + } + + final output = result.stdout.toString().trim(); + if (output.isNotEmpty) { + final message = action.errorMessage ?? 'Git working directory is not clean'; + _logger.severe(message); + _logger.info('Uncommitted changes:\n$output'); + return 1; + } + + _logger.info('Git working directory is clean'); + return 0; + } catch (e) { + _logger.severe('Failed to check git status: $e'); + return 1; + } + } + + Future _executeChangeDirAction( + ChangeDirAction action, + Map arguments, + ) async { + final path = _substituteVariables(action.path, arguments); + _logger.info('Changing directory to: $path'); + + final dir = Directory(path); + if (!dir.existsSync()) { + final message = action.errorMessage ?? 'Directory does not exist: $path'; + _logger.severe(message); + return 1; + } + + try { + Directory.current = dir; + _logger.info('Changed directory to: ${Directory.current.path}'); + return 0; + } catch (e) { + _logger.severe('Failed to change directory: $e'); + return 1; + } + } + + Future _executeDeleteFileAction( + DeleteFileAction action, + Map arguments, + ) async { + final path = _substituteVariables(action.path, arguments); + _logger.info('Deleting: $path'); + + final file = File(path); + final dir = Directory(path); + + final exists = file.existsSync() || dir.existsSync(); + + if (!exists) { + if (action.ignoreNotFound) { + _logger.info('File does not exist (ignored): $path'); + return 0; + } else { + _logger.severe('File does not exist: $path'); + return 1; + } + } + + try { + if (file.existsSync()) { + file.deleteSync(); + _logger.info('Deleted file: $path'); + } else if (dir.existsSync()) { + dir.deleteSync(recursive: action.recursive); + _logger.info('Deleted directory: $path'); + } + return 0; + } catch (e) { + _logger.severe('Failed to delete: $e'); + return 1; + } + } + + Future _executeCheckFileExistsAction( + CheckFileExistsAction action, + Map arguments, + ) async { + final path = _substituteVariables(action.path, arguments); + _logger.info('Checking file exists: $path'); + + final file = File(path); + final dir = Directory(path); + final exists = file.existsSync() || dir.existsSync(); + + if (action.shouldExist && !exists) { + final message = action.errorMessage ?? 'File does not exist: $path'; + _logger.severe(message); + return 1; + } else if (!action.shouldExist && exists) { + final message = action.errorMessage ?? 'File exists but should not: $path'; + _logger.severe(message); + return 1; + } + + _logger.info('File check passed: $path'); + return 0; + } + + Future _executeCopyFileAction( + CopyFileAction action, + Map arguments, + ) async { + final source = _substituteVariables(action.source, arguments); + final destination = _substituteVariables(action.destination, arguments); + _logger.info('Copying file: $source -> $destination'); + + final sourceFile = File(source); + if (!sourceFile.existsSync()) { + _logger.severe('Source file does not exist: $source'); + return 1; + } + + final destFile = File(destination); + if (destFile.existsSync() && !action.overwrite) { + _logger.severe('Destination file already exists: $destination'); + return 1; + } + + try { + await sourceFile.copy(destination); + _logger.info('File copied successfully'); + return 0; + } catch (e) { + _logger.severe('Failed to copy file: $e'); + return 1; + } + } + + Future _executeRenameFileAction( + RenameFileAction action, + Map arguments, + ) async { + final oldPath = _substituteVariables(action.oldPath, arguments); + final newPath = _substituteVariables(action.newPath, arguments); + _logger.info('Renaming file: $oldPath -> $newPath'); + + final file = File(oldPath); + if (!file.existsSync()) { + _logger.severe('File does not exist: $oldPath'); + return 1; + } + + try { + await file.rename(newPath); + _logger.info('File renamed successfully'); + return 0; + } catch (e) { + _logger.severe('Failed to rename file: $e'); + return 1; + } + } + + Future _executeMoveFileAction( + MoveFileAction action, + Map arguments, + ) async { + final source = _substituteVariables(action.source, arguments); + final destination = _substituteVariables(action.destination, arguments); + _logger.info('Moving file: $source -> $destination'); + + final sourceFile = File(source); + if (!sourceFile.existsSync()) { + _logger.severe('Source file does not exist: $source'); + return 1; + } + + try { + await sourceFile.rename(destination); + _logger.info('File moved successfully'); + return 0; + } catch (e) { + _logger.severe('Failed to move file: $e'); + return 1; + } + } + + Future _executeCreateFileAction( + CreateFileAction action, + Map arguments, + ) async { + final path = _substituteVariables(action.path, arguments); + final content = action.content != null + ? _substituteVariables(action.content!, arguments) + : ''; + _logger.info('Creating file: $path'); + + final file = File(path); + if (file.existsSync() && !action.overwrite) { + _logger.severe('File already exists: $path'); + return 1; + } + + try { + // Create parent directories if needed + final parent = file.parent; + if (!parent.existsSync()) { + parent.createSync(recursive: true); + } + + await file.writeAsString(content); + _logger.info('File created successfully'); + return 0; + } catch (e) { + _logger.severe('Failed to create file: $e'); + return 1; + } + } + + Future _executeCreateDirAction( + CreateDirAction action, + Map arguments, + ) async { + final path = _substituteVariables(action.path, arguments); + _logger.info('Creating directory: $path'); + + final dir = Directory(path); + if (dir.existsSync()) { + _logger.info('Directory already exists: $path'); + return 0; + } + + try { + await dir.create(recursive: action.recursive); + _logger.info('Directory created successfully'); + return 0; + } catch (e) { + _logger.severe('Failed to create directory: $e'); + return 1; + } + } + + Future _executeDeleteDirAction( + DeleteDirAction action, + Map arguments, + ) async { + final path = _substituteVariables(action.path, arguments); + _logger.info('Deleting directory: $path'); + + final dir = Directory(path); + if (!dir.existsSync()) { + if (action.ignoreNotFound) { + _logger.info('Directory does not exist (ignored): $path'); + return 0; + } else { + _logger.severe('Directory does not exist: $path'); + return 1; + } + } + + try { + await dir.delete(recursive: action.recursive); + _logger.info('Directory deleted successfully'); + return 0; + } catch (e) { + _logger.severe('Failed to delete directory: $e'); + return 1; + } + } + + Future _executeRenameDirAction( + RenameDirAction action, + Map arguments, + ) async { + final oldPath = _substituteVariables(action.oldPath, arguments); + final newPath = _substituteVariables(action.newPath, arguments); + _logger.info('Renaming directory: $oldPath -> $newPath'); + + final dir = Directory(oldPath); + if (!dir.existsSync()) { + _logger.severe('Directory does not exist: $oldPath'); + return 1; + } + + try { + await dir.rename(newPath); + _logger.info('Directory renamed successfully'); + return 0; + } catch (e) { + _logger.severe('Failed to rename directory: $e'); + return 1; + } + } + + Future _executeReplaceInFileAction( + ReplaceInFileAction action, + Map arguments, + ) async { + final path = _substituteVariables(action.path, arguments); + final find = _substituteVariables(action.find, arguments); + final replace = _substituteVariables(action.replace, arguments); + _logger.info('Replacing in file: $path'); + + final file = File(path); + if (!file.existsSync()) { + _logger.severe('File does not exist: $path'); + return 1; + } + + try { + var content = await file.readAsString(); + + if (action.regex) { + final regex = RegExp(find); + content = content.replaceAll(regex, replace); + } else { + content = content.replaceAll(find, replace); + } + + await file.writeAsString(content); + _logger.info('File updated successfully'); + return 0; + } catch (e) { + _logger.severe('Failed to replace in file: $e'); + return 1; + } + } + + Future _executeAppendToFileAction( + AppendToFileAction action, + Map arguments, + ) async { + final path = _substituteVariables(action.path, arguments); + final content = _substituteVariables(action.content, arguments); + _logger.info('Appending to file: $path'); + + final file = File(path); + if (!file.existsSync() && !action.createIfMissing) { + _logger.severe('File does not exist: $path'); + return 1; + } + + try { + await file.writeAsString(content, mode: FileMode.append); + _logger.info('Content appended successfully'); + return 0; + } catch (e) { + _logger.severe('Failed to append to file: $e'); + return 1; + } + } + + Future _executePrependToFileAction( + PrependToFileAction action, + Map arguments, + ) async { + final path = _substituteVariables(action.path, arguments); + final content = _substituteVariables(action.content, arguments); + _logger.info('Prepending to file: $path'); + + final file = File(path); + + var existingContent = ''; + if (file.existsSync()) { + existingContent = await file.readAsString(); + } else if (!action.createIfMissing) { + _logger.severe('File does not exist: $path'); + return 1; + } + + try { + await file.writeAsString(content + existingContent); + _logger.info('Content prepended successfully'); + return 0; + } catch (e) { + _logger.severe('Failed to prepend to file: $e'); + return 1; + } + } + + Future _executePrintAction( + PrintAction action, + Map arguments, + ) async { + final message = _substituteVariables(action.message, arguments); + + switch (action.level.toLowerCase()) { + case 'error': + _logger.severe(message); + break; + case 'warning': + _logger.warning(message); + break; + default: + _logger.info(message); + } + + return 0; + } + + Future _executeWaitAction( + WaitAction action, + Map arguments, + ) async { + if (action.message != null) { + final message = _substituteVariables(action.message!, arguments); + _logger.info(message); + } + + _logger.info('Waiting for ${action.milliseconds}ms...'); + await Future.delayed(Duration(milliseconds: action.milliseconds)); + + return 0; + } + + Future _executeCheckPlatformAction( + CheckPlatformAction action, + Map arguments, + ) async { + final expectedPlatform = action.platform.toLowerCase(); + final currentPlatform = _getCurrentPlatform(); + + _logger.info('Checking platform: expected=$expectedPlatform, current=$currentPlatform'); + + if (currentPlatform != expectedPlatform) { + final errorMsg = action.errorMessage ?? + 'Platform mismatch: expected $expectedPlatform, but running on $currentPlatform'; + _logger.severe(errorMsg); + return 1; + } + + _logger.info('Platform check passed'); + return 0; + } + + String _getCurrentPlatform() { + if (Platform.isMacOS) return 'macos'; + if (Platform.isLinux) return 'linux'; + if (Platform.isWindows) return 'windows'; + return 'unknown'; + } + + Future _executeCreateArchiveAction( + CreateArchiveAction action, + Map arguments, + ) async { + final source = _substituteVariables(action.source, arguments); + final destination = _substituteVariables(action.destination, arguments); + + _logger.info('Creating archive: $destination from $source'); + + try { + // Use system commands for now (zip/tar) + // TODO: Consider adding archive package for pure Dart implementation + if (action.format == 'zip') { + final result = await _cmd.run( + 'zip', + arguments: ['-r', destination, source], + ); + return result.exitCode; + } else if (action.format == 'tar.gz' || action.format == 'tgz') { + final result = await _cmd.run( + 'tar', + arguments: ['-czf', destination, source], + ); + return result.exitCode; + } else { + _logger.severe('Unsupported archive format: ${action.format}'); + return 1; + } + } catch (e) { + _logger.severe('Failed to create archive: $e'); + return 1; + } + } + + Future _executeExtractArchiveAction( + ExtractArchiveAction action, + Map arguments, + ) async { + final source = _substituteVariables(action.source, arguments); + final destination = _substituteVariables(action.destination, arguments); + + _logger.info('Extracting archive: $source to $destination'); + + // Ensure destination directory exists + final destDir = Directory(destination); + if (!destDir.existsSync()) { + await destDir.create(recursive: true); + } + + try { + // Detect format from file extension + if (source.endsWith('.zip')) { + final result = await _cmd.run( + 'unzip', + arguments: [source, '-d', destination], + ); + return result.exitCode; + } else if (source.endsWith('.tar.gz') || source.endsWith('.tgz')) { + final result = await _cmd.run( + 'tar', + arguments: ['-xzf', source, '-C', destination], + ); + return result.exitCode; + } else { + _logger.severe('Unsupported archive format: $source'); + return 1; + } + } catch (e) { + _logger.severe('Failed to extract archive: $e'); + return 1; + } + } + + /// Find alex executable (bin/alex.dart). + /// Returns null if not found (will use global alex instead). + String? _findAlexExecutable() { + // Try to find bin/alex.dart relative to current directory + var dir = Directory.current; + for (var i = 0; i < 10; i++) { + final alexPath = '${dir.path}/bin/alex.dart'; + if (File(alexPath).existsSync()) { + return alexPath; + } + + final parent = dir.parent; + if (parent.path == dir.path) { + break; + } + dir = parent; + } + + // Not found - will use global alex + return null; + } +} diff --git a/lib/src/version.dart b/lib/src/version.dart index 9389c77..2fa8409 100644 --- a/lib/src/version.dart +++ b/lib/src/version.dart @@ -1,2 +1,2 @@ // Generated code. Do not modify. -const packageVersion = '1.9.4'; +const packageVersion = '1.10.0'; diff --git a/pubspec.yaml b/pubspec.yaml index 353c816..844fb3d 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -4,7 +4,7 @@ homepage: https://github.com/innim/ repository: https://github.com/Innim/alex issue_tracker: https://github.com/Innim/alex/issues -version: 1.9.4 +version: 1.10.0 environment: sdk: ">=3.0.0 <4.0.0" From 733760502cdf82f3d9252f2111c00fa0e05e53bf Mon Sep 17 00:00:00 2001 From: SF97 Date: Mon, 10 Nov 2025 22:48:37 +0100 Subject: [PATCH 2/8] Refactoring and optimization: - Add check custom command - Use enum instead of String - Edit mode improve - Add additional logs --- lib/commands/custom/add_custom_command.dart | 347 +++++++++++++++++- .../custom/check_custom_commands.dart | 215 +++++++++++ lib/commands/custom/custom_command.dart | 2 + lib/runner/alex_command_runner.dart | 19 +- .../custom_command_action.dart | 201 ++++++---- .../custom_command_argument.dart | 6 +- .../custom_command_config.dart | 190 ++++++++-- .../custom_command_executor.dart | 100 ++--- 8 files changed, 902 insertions(+), 178 deletions(-) create mode 100644 lib/commands/custom/check_custom_commands.dart diff --git a/lib/commands/custom/add_custom_command.dart b/lib/commands/custom/add_custom_command.dart index 4347c5c..ec53505 100644 --- a/lib/commands/custom/add_custom_command.dart +++ b/lib/commands/custom/add_custom_command.dart @@ -106,7 +106,8 @@ class AddCustomCommand extends AlexCommand { final name = _prompt(' Argument name'); if (name.isEmpty) return null; - final typeStr = _prompt(' Type (option/flag)', defaultValue: 'option'); + printInfo(' Type:'); + final typeStr = _promptChoice('Select type', ['option', 'flag'], defaultValue: 'option'); final type = typeStr.toLowerCase() == 'flag' ? CustomCommandArgumentType.flag : CustomCommandArgumentType.option; @@ -141,20 +142,82 @@ class AddCustomCommand extends AlexCommand { CustomCommandAction? _promptAction() { printInfo(''); printInfo('Action configuration:'); + printInfo(''); + printInfo('Available action types:'); + + // Get all action types from enum + const allTypes = CustomCommandActionType.values; + for (var i = 0; i < allTypes.length; i++) { + final type = allTypes[i]; + printInfo(' ${i + 1}. ${type.value} - ${type.description}'); + } + printInfo(''); + + // Prompt with validation loop + CustomCommandActionType? actionType; + while (actionType == null) { + final input = _prompt(' Enter action type number (1-${allTypes.length})', defaultValue: '1'); + final number = int.tryParse(input); + + if (number == null || number < 1 || number > allTypes.length) { + printError('Invalid number. Please enter a number between 1 and ${allTypes.length}.'); + continue; + } - final typeStr = _prompt(' Action type (exec/alex/script)', defaultValue: 'exec'); - final type = typeStr.toLowerCase(); + actionType = allTypes[number - 1]; + } + + return _promptActionByType(actionType); + } + CustomCommandAction? _promptActionByType(CustomCommandActionType type) { switch (type) { - case 'exec': + case CustomCommandActionType.exec: return _promptExecAction(); - case 'alex': + case CustomCommandActionType.alex: return _promptAlexAction(); - case 'script': + case CustomCommandActionType.script: return _promptScriptAction(); - default: - printError('Unknown action type: $type'); - return null; + case CustomCommandActionType.checkGitBranch: + return _promptCheckGitBranchAction(); + case CustomCommandActionType.checkGitClean: + return _promptCheckGitCleanAction(); + case CustomCommandActionType.changeDir: + return _promptChangeDirAction(); + case CustomCommandActionType.deleteFile: + return _promptDeleteFileAction(); + case CustomCommandActionType.checkFileExists: + return _promptCheckFileExistsAction(); + case CustomCommandActionType.copyFile: + return _promptCopyFileAction(); + case CustomCommandActionType.renameFile: + return _promptRenameFileAction(); + case CustomCommandActionType.moveFile: + return _promptMoveFileAction(); + case CustomCommandActionType.createFile: + return _promptCreateFileAction(); + case CustomCommandActionType.createDir: + return _promptCreateDirAction(); + case CustomCommandActionType.deleteDir: + return _promptDeleteDirAction(); + case CustomCommandActionType.renameDir: + return _promptRenameDirAction(); + case CustomCommandActionType.replaceInFile: + return _promptReplaceInFileAction(); + case CustomCommandActionType.appendToFile: + return _promptAppendToFileAction(); + case CustomCommandActionType.prependToFile: + return _promptPrependToFileAction(); + case CustomCommandActionType.print: + return _promptPrintAction(); + case CustomCommandActionType.wait: + return _promptWaitAction(); + case CustomCommandActionType.checkPlatform: + return _promptCheckPlatformAction(); + case CustomCommandActionType.createArchive: + return _promptCreateArchiveAction(); + case CustomCommandActionType.extractArchive: + return _promptExtractArchiveAction(); } } @@ -192,6 +255,272 @@ class AddCustomCommand extends AlexCommand { ); } + CheckGitBranchAction _promptCheckGitBranchAction() { + final branch = _prompt(' Branch name'); + final autoSwitchStr = _prompt(' Auto switch to branch if not on it? (y/n)', defaultValue: 'y'); + final autoSwitch = autoSwitchStr.toLowerCase() == 'y'; + + return CheckGitBranchAction( + branch: branch, + autoSwitch: autoSwitch, + ); + } + + CheckGitCleanAction _promptCheckGitCleanAction() { + return CheckGitCleanAction(); + } + + ChangeDirAction _promptChangeDirAction() { + final path = _prompt(' Directory path'); + + return ChangeDirAction(path: path); + } + + DeleteFileAction _promptDeleteFileAction() { + final path = _prompt(' File path to delete'); + final recursiveStr = _prompt(' Delete recursively (for directories)? (y/n)', defaultValue: 'n'); + final recursive = recursiveStr.toLowerCase() == 'y'; + final ignoreNotFoundStr = _prompt(' Ignore if not found? (y/n)', defaultValue: 'y'); + final ignoreNotFound = ignoreNotFoundStr.toLowerCase() == 'y'; + + return DeleteFileAction( + path: path, + recursive: recursive, + ignoreNotFound: ignoreNotFound, + ); + } + + CheckFileExistsAction _promptCheckFileExistsAction() { + final path = _prompt(' File/directory path to check'); + final shouldExistStr = _prompt(' Should exist? (y=must exist, n=must not exist)', defaultValue: 'y'); + final shouldExist = shouldExistStr.toLowerCase() == 'y'; + + return CheckFileExistsAction( + path: path, + shouldExist: shouldExist, + ); + } + + CopyFileAction _promptCopyFileAction() { + final source = _prompt(' Source file path'); + final destination = _prompt(' Destination file path'); + final overwriteStr = _prompt(' Overwrite if exists? (y/n)', defaultValue: 'n'); + final overwrite = overwriteStr.toLowerCase() == 'y'; + + return CopyFileAction( + source: source, + destination: destination, + overwrite: overwrite, + ); + } + + RenameFileAction _promptRenameFileAction() { + final oldPath = _prompt(' Current file path'); + final newPath = _prompt(' New file path'); + + return RenameFileAction( + oldPath: oldPath, + newPath: newPath, + ); + } + + MoveFileAction _promptMoveFileAction() { + final source = _prompt(' Source file path'); + final destination = _prompt(' Destination file path'); + + return MoveFileAction( + source: source, + destination: destination, + ); + } + + CreateFileAction _promptCreateFileAction() { + final path = _prompt(' File path to create'); + final content = _prompt(' File content (optional, supports variables)'); + final overwriteStr = _prompt(' Overwrite if exists? (y/n)', defaultValue: 'n'); + final overwrite = overwriteStr.toLowerCase() == 'y'; + + return CreateFileAction( + path: path, + content: content.isNotEmpty ? content : null, + overwrite: overwrite, + ); + } + + CreateDirAction _promptCreateDirAction() { + final path = _prompt(' Directory path to create'); + final recursiveStr = _prompt(' Create parent directories? (y/n)', defaultValue: 'y'); + final recursive = recursiveStr.toLowerCase() == 'y'; + + return CreateDirAction( + path: path, + recursive: recursive, + ); + } + + DeleteDirAction _promptDeleteDirAction() { + final path = _prompt(' Directory path to delete'); + final recursiveStr = _prompt(' Delete recursively? (y/n)', defaultValue: 'y'); + final recursive = recursiveStr.toLowerCase() == 'y'; + final ignoreNotFoundStr = _prompt(' Ignore if not found? (y/n)', defaultValue: 'y'); + final ignoreNotFound = ignoreNotFoundStr.toLowerCase() == 'y'; + + return DeleteDirAction( + path: path, + recursive: recursive, + ignoreNotFound: ignoreNotFound, + ); + } + + RenameDirAction _promptRenameDirAction() { + final oldPath = _prompt(' Current directory path'); + final newPath = _prompt(' New directory path'); + + return RenameDirAction( + oldPath: oldPath, + newPath: newPath, + ); + } + + ReplaceInFileAction _promptReplaceInFileAction() { + final path = _prompt(' File path'); + final find = _prompt(' Text/pattern to find'); + final replace = _prompt(' Replacement text'); + final regexStr = _prompt(' Use regex matching? (y/n)', defaultValue: 'n'); + final regex = regexStr.toLowerCase() == 'y'; + + return ReplaceInFileAction( + path: path, + find: find, + replace: replace, + regex: regex, + ); + } + + AppendToFileAction _promptAppendToFileAction() { + final path = _prompt(' File path'); + final content = _prompt(' Content to append'); + final createIfMissingStr = _prompt(' Create file if missing? (y/n)', defaultValue: 'y'); + final createIfMissing = createIfMissingStr.toLowerCase() == 'y'; + + return AppendToFileAction( + path: path, + content: content, + createIfMissing: createIfMissing, + ); + } + + PrependToFileAction _promptPrependToFileAction() { + final path = _prompt(' File path'); + final content = _prompt(' Content to prepend'); + final createIfMissingStr = _prompt(' Create file if missing? (y/n)', defaultValue: 'y'); + final createIfMissing = createIfMissingStr.toLowerCase() == 'y'; + + return PrependToFileAction( + path: path, + content: content, + createIfMissing: createIfMissing, + ); + } + + PrintAction _promptPrintAction() { + final message = _prompt(' Message to print'); + + printInfo(' Level:'); + final level = _promptChoice('Select level', ['info', 'warning', 'error'], defaultValue: 'info'); + + return PrintAction( + message: message, + level: level, + ); + } + + WaitAction _promptWaitAction() { + final millisecondsStr = _prompt(' Duration in milliseconds'); + final milliseconds = int.tryParse(millisecondsStr) ?? 1000; + final message = _prompt(' Message to display (optional)'); + + return WaitAction( + milliseconds: milliseconds, + message: message.isNotEmpty ? message : null, + ); + } + + CheckPlatformAction _promptCheckPlatformAction() { + printInfo(' Expected platform:'); + final platform = _promptChoice('Select platform', ['macos', 'linux', 'windows'], defaultValue: 'macos'); + + return CheckPlatformAction(platform: platform); + } + + CreateArchiveAction _promptCreateArchiveAction() { + final source = _prompt(' Source path (file or directory)'); + final destination = _prompt(' Destination archive path'); + + printInfo(' Archive format:'); + final format = _promptChoice('Select format', ['zip', 'tar.gz'], defaultValue: 'zip'); + + return CreateArchiveAction( + source: source, + destination: destination, + format: format, + ); + } + + ExtractArchiveAction _promptExtractArchiveAction() { + final source = _prompt(' Source archive path'); + final destination = _prompt(' Destination directory'); + + return ExtractArchiveAction( + source: source, + destination: destination, + ); + } + + // Prompt user to choose from a list of options. + // User can enter either the option text or its number (1-based). + String _promptChoice( + String message, + List choices, { + String? defaultValue, + }) { + if (choices.isEmpty) { + throw ArgumentError('choices cannot be empty'); + } + + // Print choices + for (var i = 0; i < choices.length; i++) { + printInfo(' ${i + 1}. ${choices[i]}'); + } + + // Determine default index + final defaultIndex = defaultValue != null && choices.contains(defaultValue) + ? choices.indexOf(defaultValue) + 1 + : 1; + + while (true) { + stdout.write(' $message (1-${choices.length}) [$defaultIndex]: '); + final input = stdin.readLineSync() ?? ''; + final value = input.isEmpty ? defaultIndex.toString() : input.trim(); + + // Try to parse as number + final number = int.tryParse(value); + if (number != null && number >= 1 && number <= choices.length) { + return choices[number - 1]; + } + + // Try to find exact match (case-insensitive) + final lowerValue = value.toLowerCase(); + for (final choice in choices) { + if (choice.toLowerCase() == lowerValue) { + return choice; + } + } + + printError('Invalid choice. Please enter a number (1-${choices.length}) or one of: ${choices.join(", ")}'); + } + } + String _prompt(String message, {String defaultValue = ''}) { if (defaultValue.isNotEmpty) { stdout.write('$message [$defaultValue]: '); diff --git a/lib/commands/custom/check_custom_commands.dart b/lib/commands/custom/check_custom_commands.dart new file mode 100644 index 0000000..d48dc79 --- /dev/null +++ b/lib/commands/custom/check_custom_commands.dart @@ -0,0 +1,215 @@ +import 'dart:io'; + +import 'package:alex/runner/alex_command.dart'; +import 'package:alex/src/custom_commands/custom_command_config.dart'; +import 'package:yaml/yaml.dart'; + +/// Check custom commands configuration validity. +class CheckCustomCommandsCommand extends AlexCommand { + CheckCustomCommandsCommand() + : super( + 'check', + 'Check custom commands configuration for errors.', + ); + + @override + Future doRun() async { + printInfo('Checking custom commands configuration...'); + printInfo(''); + + // Find config file + final configPath = _findConfigFile(); + if (configPath == null) { + printInfo('Custom commands config file not found.'); + printInfo(''); + printInfo('Expected location: alex_custom_commands.yaml'); + printInfo('Searched in current directory and parent directories (up to 10 levels)'); + printInfo(''); + printInfo('To create a config file, use:'); + printInfo(' alex custom add'); + return 0; + } + + printInfo('Found config file: $configPath'); + printInfo(''); + + // Try to load and parse directly to catch errors + try { + // Read and parse YAML directly + final file = File(configPath); + if (!file.existsSync()) { + printError('Config file not found: $configPath'); + return 1; + } + + final yamlString = file.readAsStringSync(); + final yamlData = loadYaml(yamlString); + + if (yamlData == null) { + printInfo('Config file is empty.'); + printInfo(''); + printInfo('To add a command, use:'); + printInfo(' alex custom add'); + return 0; + } + + if (yamlData is! YamlMap) { + printError('Invalid config file format: expected YAML map, got ${yamlData.runtimeType}'); + return 1; + } + + final commandsList = yamlData['custom_commands'] as YamlList?; + if (commandsList == null || commandsList.isEmpty) { + printInfo('Config file exists but contains no commands.'); + printInfo(''); + printInfo('To add a command, use:'); + printInfo(' alex custom add'); + return 0; + } + + // Parse each command to validate + final commands = []; + for (var i = 0; i < commandsList.length; i++) { + final cmdData = commandsList[i] as YamlMap; + final cmd = CustomCommandDefinition.fromYaml(cmdData); + commands.add(cmd); + } + + // All commands parsed successfully + + // Show summary + printInfo('✓ Configuration is valid!'); + printInfo(''); + printInfo('Found ${commands.length} custom command(s):'); + printInfo(''); + + for (final cmd in commands) { + printInfo(' ${cmd.name}'); + if (cmd.description.isNotEmpty) { + printInfo(' Description: ${cmd.description}'); + } + if (cmd.aliases.isNotEmpty) { + printInfo(' Aliases: ${cmd.aliases.join(", ")}'); + } + if (cmd.arguments.isNotEmpty) { + printInfo(' Arguments: ${cmd.arguments.length}'); + for (final arg in cmd.arguments) { + final required = arg.required ? ' (required)' : ''; + final defaultVal = arg.defaultValue != null ? ' [default: ${arg.defaultValue}]' : ''; + printInfo(' --${arg.name}$required$defaultVal'); + } + } + printInfo(' Actions: ${cmd.actions.length}'); + for (var i = 0; i < cmd.actions.length; i++) { + final action = cmd.actions[i]; + printInfo(' ${i + 1}. ${action.type.value}'); + } + printInfo(''); + } + + printInfo('You can run these commands with:'); + for (final cmd in commands) { + printInfo(' alex ${cmd.name}'); + } + + return 0; + } catch (e, stackTrace) { + printError('Failed to load custom commands config!'); + printInfo(''); + printError('Error: $e'); + printInfo(''); + + // Try to extract line information from error message + final errorStr = e.toString(); + + // Try multiple patterns to extract line number + var lineMatch = RegExp(r'line (\d+):(\d+)').firstMatch(errorStr); + lineMatch ??= RegExp(r'line (\d+)').firstMatch(errorStr); + + if (lineMatch != null) { + final lineNum = lineMatch.group(1); + final column = lineMatch.groupCount > 1 ? lineMatch.group(2) : null; + + var locationStr = '$configPath:$lineNum'; + if (column != null) { + locationStr += ':$column'; + } + printInfo('Location: $locationStr'); + printInfo(''); + + // Try to show the problematic line + try { + final file = File(configPath); + final lines = file.readAsLinesSync(); + final lineIndex = int.parse(lineNum!) - 1; + + if (lineIndex >= 0 && lineIndex < lines.length) { + printInfo('Problematic line:'); + // Show context: 2 lines before, the error line, 2 lines after + final start = (lineIndex - 2).clamp(0, lines.length); + final end = (lineIndex + 3).clamp(0, lines.length); + + for (var i = start; i < end; i++) { + final prefix = i == lineIndex ? '>>>' : ' '; + printInfo('$prefix ${i + 1}: ${lines[i]}'); + } + printInfo(''); + } + } catch (_) { + // Ignore errors while trying to show context + } + } else { + // If no line number found, try to extract command/action info + final commandMatch = RegExp('command "([^"]+)"').firstMatch(errorStr); + final actionMatch = RegExp(r'action (\d+)').firstMatch(errorStr); + + if (commandMatch != null || actionMatch != null) { + var hint = 'Hint: '; + if (commandMatch != null) { + hint += 'Check command "${commandMatch.group(1)}"'; + } + if (actionMatch != null) { + if (commandMatch != null) hint += ', '; + hint += 'action #${actionMatch.group(1)}'; + } + printInfo(hint); + printInfo(''); + } + } + + printInfo('Stack trace:'); + printInfo(stackTrace.toString()); + printInfo(''); + printInfo('Common issues:'); + printInfo(' • YAML syntax errors (check indentation, quotes)'); + printInfo(' • Missing required fields (name, actions)'); + printInfo(' • Invalid action types'); + printInfo(' • Unescaped special characters in strings'); + printInfo(' • Type mismatches (e.g., number where string expected)'); + printInfo(''); + printInfo('Try editing the config file:'); + printInfo(' alex custom edit'); + return 1; + } + } + + String? _findConfigFile() { + const configFileName = 'alex_custom_commands.yaml'; + var dir = Directory.current; + + for (var i = 0; i < 10; i++) { + final configFile = File('${dir.path}/$configFileName'); + if (configFile.existsSync()) { + return configFile.path; + } + + final parent = dir.parent; + if (parent.path == dir.path) { + break; // Reached root + } + dir = parent; + } + + return null; + } +} diff --git a/lib/commands/custom/custom_command.dart b/lib/commands/custom/custom_command.dart index 8d3062e..8e8ad14 100644 --- a/lib/commands/custom/custom_command.dart +++ b/lib/commands/custom/custom_command.dart @@ -1,4 +1,5 @@ import 'package:alex/commands/custom/add_custom_command.dart'; +import 'package:alex/commands/custom/check_custom_commands.dart'; import 'package:alex/commands/custom/edit_custom_command.dart'; import 'package:alex/commands/custom/list_custom_commands.dart'; import 'package:alex/commands/custom/remove_custom_command.dart'; @@ -14,6 +15,7 @@ class CustomCommand extends AlexCommand { addSubcommand(AddCustomCommand()); addSubcommand(EditCustomCommand()); addSubcommand(RemoveCustomCommand()); + addSubcommand(CheckCustomCommandsCommand()); } @override diff --git a/lib/runner/alex_command_runner.dart b/lib/runner/alex_command_runner.dart index dc9b256..c5a0cf9 100644 --- a/lib/runner/alex_command_runner.dart +++ b/lib/runner/alex_command_runner.dart @@ -58,24 +58,31 @@ class AlexCommandRunner extends CommandRunner { void _loadCustomCommands() { try { + _out.fine('Loading custom commands...'); CustomCommandsConfig.load(); final config = CustomCommandsConfig.instance; + _out.fine('Custom commands config loaded, found ${config.commands.length} command(s)'); for (final definition in config.commands) { try { + _out.fine('Registering custom command: ${definition.name}'); final command = UserCustomCommand(definition); addCommand(command); - _out.fine('Registered custom command: ${definition.name}'); - } catch (e) { - _out.warning('Failed to register custom command ${definition.name}: $e'); + _out.info('Successfully registered custom command: ${definition.name}'); + } catch (e, stackTrace) { + _out.severe('Failed to register custom command ${definition.name}: $e'); + _out.fine('Stack trace: $stackTrace'); } } if (config.commands.isNotEmpty) { - _out.fine('Loaded ${config.commands.length} custom command(s)'); + _out.info('Loaded ${config.commands.length} custom command(s)'); + } else { + _out.fine('No custom commands found in config'); } - } catch (e) { - _out.fine('No custom commands loaded: $e'); + } catch (e, stackTrace) { + _out.warning('Failed to load custom commands: $e'); + _out.fine('Stack trace: $stackTrace'); } } diff --git a/lib/src/custom_commands/custom_command_action.dart b/lib/src/custom_commands/custom_command_action.dart index 16e4868..64cfed6 100644 --- a/lib/src/custom_commands/custom_command_action.dart +++ b/lib/src/custom_commands/custom_command_action.dart @@ -3,67 +3,67 @@ import 'package:yaml/yaml.dart'; /// Base class for custom command actions. abstract class CustomCommandAction { /// Type of the action. - String get type; + CustomCommandActionType get type; /// Custom error message to display if action fails. String? get errorMessage; /// Create action from YAML data. factory CustomCommandAction.fromYaml(YamlMap data) { - final type = data['type'] as String?; - if (type == null) { + final typeStr = data['type'] as String?; + if (typeStr == null) { throw Exception('Action type is required'); } + final type = CustomCommandActionType.fromString(typeStr); + switch (type) { - case 'exec': + case CustomCommandActionType.exec: return ExecAction.fromYaml(data); - case 'alex': + case CustomCommandActionType.alex: return AlexAction.fromYaml(data); - case 'script': + case CustomCommandActionType.script: return ScriptAction.fromYaml(data); - case 'check_git_branch': + case CustomCommandActionType.checkGitBranch: return CheckGitBranchAction.fromYaml(data); - case 'check_git_clean': + case CustomCommandActionType.checkGitClean: return CheckGitCleanAction.fromYaml(data); - case 'change_dir': + case CustomCommandActionType.changeDir: return ChangeDirAction.fromYaml(data); - case 'create_dir': + case CustomCommandActionType.createDir: return CreateDirAction.fromYaml(data); - case 'delete_dir': + case CustomCommandActionType.deleteDir: return DeleteDirAction.fromYaml(data); - case 'rename_dir': + case CustomCommandActionType.renameDir: return RenameDirAction.fromYaml(data); - case 'delete_file': + case CustomCommandActionType.deleteFile: return DeleteFileAction.fromYaml(data); - case 'check_file_exists': + case CustomCommandActionType.checkFileExists: return CheckFileExistsAction.fromYaml(data); - case 'copy_file': + case CustomCommandActionType.copyFile: return CopyFileAction.fromYaml(data); - case 'rename_file': + case CustomCommandActionType.renameFile: return RenameFileAction.fromYaml(data); - case 'move_file': + case CustomCommandActionType.moveFile: return MoveFileAction.fromYaml(data); - case 'create_file': + case CustomCommandActionType.createFile: return CreateFileAction.fromYaml(data); - case 'replace_in_file': + case CustomCommandActionType.replaceInFile: return ReplaceInFileAction.fromYaml(data); - case 'append_to_file': + case CustomCommandActionType.appendToFile: return AppendToFileAction.fromYaml(data); - case 'prepend_to_file': + case CustomCommandActionType.prependToFile: return PrependToFileAction.fromYaml(data); - case 'print': + case CustomCommandActionType.print: return PrintAction.fromYaml(data); - case 'wait': + case CustomCommandActionType.wait: return WaitAction.fromYaml(data); - case 'check_platform': + case CustomCommandActionType.checkPlatform: return CheckPlatformAction.fromYaml(data); - case 'create_archive': + case CustomCommandActionType.createArchive: return CreateArchiveAction.fromYaml(data); - case 'extract_archive': + case CustomCommandActionType.extractArchive: return ExtractArchiveAction.fromYaml(data); - default: - throw Exception('Unknown action type: $type'); } } @@ -74,7 +74,7 @@ abstract class CustomCommandAction { /// Execute shell command or program. class ExecAction implements CustomCommandAction { @override - final String type = 'exec'; + final CustomCommandActionType type = CustomCommandActionType.exec; /// Command to execute. final String command; @@ -103,7 +103,7 @@ class ExecAction implements CustomCommandAction { @override Map toYaml() { final result = { - 'type': type, + 'type': type.value, 'command': command, }; if (workingDir != null) { @@ -119,7 +119,7 @@ class ExecAction implements CustomCommandAction { /// Execute existing alex command. class AlexAction implements CustomCommandAction { @override - final String type = 'alex'; + final CustomCommandActionType type = CustomCommandActionType.alex; /// Alex command to execute (e.g., 'code gen', 'l10n extract'). final String command; @@ -149,7 +149,7 @@ class AlexAction implements CustomCommandAction { @override Map toYaml() { final result = { - 'type': type, + 'type': type.value, 'command': command, }; if (args.isNotEmpty) { @@ -165,7 +165,7 @@ class AlexAction implements CustomCommandAction { /// Execute Dart script. class ScriptAction implements CustomCommandAction { @override - final String type = 'script'; + final CustomCommandActionType type = CustomCommandActionType.script; /// Path to Dart script file. final String path; @@ -195,7 +195,7 @@ class ScriptAction implements CustomCommandAction { @override Map toYaml() { final result = { - 'type': type, + 'type': type.value, 'path': path, }; if (args.isNotEmpty) { @@ -211,7 +211,7 @@ class ScriptAction implements CustomCommandAction { /// Check git branch and optionally switch to it. class CheckGitBranchAction implements CustomCommandAction { @override - final String type = 'check_git_branch'; + final CustomCommandActionType type = CustomCommandActionType.checkGitBranch; /// Expected branch name. final String branch; @@ -240,7 +240,7 @@ class CheckGitBranchAction implements CustomCommandAction { @override Map toYaml() { final result = { - 'type': type, + 'type': type.value, 'branch': branch, }; if (!autoSwitch) { @@ -256,7 +256,7 @@ class CheckGitBranchAction implements CustomCommandAction { /// Check that git working directory is clean. class CheckGitCleanAction implements CustomCommandAction { @override - final String type = 'check_git_clean'; + final CustomCommandActionType type = CustomCommandActionType.checkGitClean; @override final String? errorMessage; @@ -274,7 +274,7 @@ class CheckGitCleanAction implements CustomCommandAction { @override Map toYaml() { final result = { - 'type': type, + 'type': type.value, }; if (errorMessage != null) { result['error_message'] = errorMessage; @@ -286,7 +286,7 @@ class CheckGitCleanAction implements CustomCommandAction { /// Change working directory. class ChangeDirAction implements CustomCommandAction { @override - final String type = 'change_dir'; + final CustomCommandActionType type = CustomCommandActionType.changeDir; /// Directory path to change to. final String path; @@ -310,7 +310,7 @@ class ChangeDirAction implements CustomCommandAction { @override Map toYaml() { final result = { - 'type': type, + 'type': type.value, 'path': path, }; if (errorMessage != null) { @@ -323,7 +323,7 @@ class ChangeDirAction implements CustomCommandAction { /// Delete a file or directory. class DeleteFileAction implements CustomCommandAction { @override - final String type = 'delete_file'; + final CustomCommandActionType type = CustomCommandActionType.deleteFile; /// Path to file or directory to delete. final String path; @@ -357,7 +357,7 @@ class DeleteFileAction implements CustomCommandAction { @override Map toYaml() { final result = { - 'type': type, + 'type': type.value, 'path': path, }; if (recursive) { @@ -376,7 +376,7 @@ class DeleteFileAction implements CustomCommandAction { /// Check if file or directory exists. class CheckFileExistsAction implements CustomCommandAction { @override - final String type = 'check_file_exists'; + final CustomCommandActionType type = CustomCommandActionType.checkFileExists; /// Path to file or directory to check. final String path; @@ -405,7 +405,7 @@ class CheckFileExistsAction implements CustomCommandAction { @override Map toYaml() { final result = { - 'type': type, + 'type': type.value, 'path': path, }; if (!shouldExist) { @@ -421,7 +421,7 @@ class CheckFileExistsAction implements CustomCommandAction { /// Copy a file. class CopyFileAction implements CustomCommandAction { @override - final String type = 'copy_file'; + final CustomCommandActionType type = CustomCommandActionType.copyFile; /// Source file path. final String source; @@ -456,7 +456,7 @@ class CopyFileAction implements CustomCommandAction { @override Map toYaml() { final result = { - 'type': type, + 'type': type.value, 'source': source, 'destination': destination, }; @@ -473,7 +473,7 @@ class CopyFileAction implements CustomCommandAction { /// Rename a file. class RenameFileAction implements CustomCommandAction { @override - final String type = 'rename_file'; + final CustomCommandActionType type = CustomCommandActionType.renameFile; /// Current file path. final String oldPath; @@ -503,7 +503,7 @@ class RenameFileAction implements CustomCommandAction { @override Map toYaml() { final result = { - 'type': type, + 'type': type.value, 'old_path': oldPath, 'new_path': newPath, }; @@ -517,7 +517,7 @@ class RenameFileAction implements CustomCommandAction { /// Move a file. class MoveFileAction implements CustomCommandAction { @override - final String type = 'move_file'; + final CustomCommandActionType type = CustomCommandActionType.moveFile; /// Source file path. final String source; @@ -547,7 +547,7 @@ class MoveFileAction implements CustomCommandAction { @override Map toYaml() { final result = { - 'type': type, + 'type': type.value, 'source': source, 'destination': destination, }; @@ -561,7 +561,7 @@ class MoveFileAction implements CustomCommandAction { /// Create a file with optional content. class CreateFileAction implements CustomCommandAction { @override - final String type = 'create_file'; + final CustomCommandActionType type = CustomCommandActionType.createFile; /// File path to create. final String path; @@ -595,7 +595,7 @@ class CreateFileAction implements CustomCommandAction { @override Map toYaml() { final result = { - 'type': type, + 'type': type.value, 'path': path, }; if (content != null) { @@ -614,7 +614,7 @@ class CreateFileAction implements CustomCommandAction { /// Create a directory. class CreateDirAction implements CustomCommandAction { @override - final String type = 'create_dir'; + final CustomCommandActionType type = CustomCommandActionType.createDir; /// Directory path to create. final String path; @@ -643,7 +643,7 @@ class CreateDirAction implements CustomCommandAction { @override Map toYaml() { final result = { - 'type': type, + 'type': type.value, 'path': path, }; if (!recursive) { @@ -659,7 +659,7 @@ class CreateDirAction implements CustomCommandAction { /// Delete a directory. class DeleteDirAction implements CustomCommandAction { @override - final String type = 'delete_dir'; + final CustomCommandActionType type = CustomCommandActionType.deleteDir; /// Directory path to delete. final String path; @@ -693,7 +693,7 @@ class DeleteDirAction implements CustomCommandAction { @override Map toYaml() { final result = { - 'type': type, + 'type': type.value, 'path': path, }; if (!recursive) { @@ -712,7 +712,7 @@ class DeleteDirAction implements CustomCommandAction { /// Rename a directory. class RenameDirAction implements CustomCommandAction { @override - final String type = 'rename_dir'; + final CustomCommandActionType type = CustomCommandActionType.renameDir; /// Current directory path. final String oldPath; @@ -742,7 +742,7 @@ class RenameDirAction implements CustomCommandAction { @override Map toYaml() { final result = { - 'type': type, + 'type': type.value, 'old_path': oldPath, 'new_path': newPath, }; @@ -756,7 +756,7 @@ class RenameDirAction implements CustomCommandAction { /// Replace text in file. class ReplaceInFileAction implements CustomCommandAction { @override - final String type = 'replace_in_file'; + final CustomCommandActionType type = CustomCommandActionType.replaceInFile; /// Path to file. final String path; @@ -797,7 +797,7 @@ class ReplaceInFileAction implements CustomCommandAction { @override Map toYaml() { final result = { - 'type': type, + 'type': type.value, 'path': path, 'find': find, 'replace': replace, @@ -815,7 +815,7 @@ class ReplaceInFileAction implements CustomCommandAction { /// Append text to file. class AppendToFileAction implements CustomCommandAction { @override - final String type = 'append_to_file'; + final CustomCommandActionType type = CustomCommandActionType.appendToFile; /// Path to file. final String path; @@ -850,7 +850,7 @@ class AppendToFileAction implements CustomCommandAction { @override Map toYaml() { final result = { - 'type': type, + 'type': type.value, 'path': path, 'content': content, }; @@ -867,7 +867,7 @@ class AppendToFileAction implements CustomCommandAction { /// Prepend text to file. class PrependToFileAction implements CustomCommandAction { @override - final String type = 'prepend_to_file'; + final CustomCommandActionType type = CustomCommandActionType.prependToFile; /// Path to file. final String path; @@ -902,7 +902,7 @@ class PrependToFileAction implements CustomCommandAction { @override Map toYaml() { final result = { - 'type': type, + 'type': type.value, 'path': path, 'content': content, }; @@ -919,7 +919,7 @@ class PrependToFileAction implements CustomCommandAction { /// Print message to console. class PrintAction implements CustomCommandAction { @override - final String type = 'print'; + final CustomCommandActionType type = CustomCommandActionType.print; /// Message to print. final String message; @@ -948,7 +948,7 @@ class PrintAction implements CustomCommandAction { @override Map toYaml() { final result = { - 'type': type, + 'type': type.value, 'message': message, }; if (level != 'info') { @@ -964,7 +964,7 @@ class PrintAction implements CustomCommandAction { /// Wait for specified duration. class WaitAction implements CustomCommandAction { @override - final String type = 'wait'; + final CustomCommandActionType type = CustomCommandActionType.wait; /// Duration in milliseconds. final int milliseconds; @@ -993,7 +993,7 @@ class WaitAction implements CustomCommandAction { @override Map toYaml() { final result = { - 'type': type, + 'type': type.value, 'milliseconds': milliseconds, }; if (message != null) { @@ -1009,7 +1009,7 @@ class WaitAction implements CustomCommandAction { /// Check current platform. class CheckPlatformAction implements CustomCommandAction { @override - final String type = 'check_platform'; + final CustomCommandActionType type = CustomCommandActionType.checkPlatform; /// Expected platform (macos, linux, windows). final String platform; @@ -1033,7 +1033,7 @@ class CheckPlatformAction implements CustomCommandAction { @override Map toYaml() { final result = { - 'type': type, + 'type': type.value, 'platform': platform, }; if (errorMessage != null) { @@ -1046,7 +1046,7 @@ class CheckPlatformAction implements CustomCommandAction { /// Create archive from files or directory. class CreateArchiveAction implements CustomCommandAction { @override - final String type = 'create_archive'; + final CustomCommandActionType type = CustomCommandActionType.createArchive; /// Source path (file or directory). final String source; @@ -1072,7 +1072,8 @@ class CreateArchiveAction implements CustomCommandAction { source: data['source'] as String? ?? (throw Exception('create_archive action requires "source" field')), destination: data['destination'] as String? ?? - (throw Exception('create_archive action requires "destination" field')), + (throw Exception( + 'create_archive action requires "destination" field')), format: data['format'] as String? ?? 'zip', errorMessage: data['error_message'] as String?, ); @@ -1081,7 +1082,7 @@ class CreateArchiveAction implements CustomCommandAction { @override Map toYaml() { final result = { - 'type': type, + 'type': type.value, 'source': source, 'destination': destination, }; @@ -1098,7 +1099,7 @@ class CreateArchiveAction implements CustomCommandAction { /// Extract archive. class ExtractArchiveAction implements CustomCommandAction { @override - final String type = 'extract_archive'; + final CustomCommandActionType type = CustomCommandActionType.extractArchive; /// Source archive path. final String source; @@ -1120,7 +1121,8 @@ class ExtractArchiveAction implements CustomCommandAction { source: data['source'] as String? ?? (throw Exception('extract_archive action requires "source" field')), destination: data['destination'] as String? ?? - (throw Exception('extract_archive action requires "destination" field')), + (throw Exception( + 'extract_archive action requires "destination" field')), errorMessage: data['error_message'] as String?, ); } @@ -1128,7 +1130,7 @@ class ExtractArchiveAction implements CustomCommandAction { @override Map toYaml() { final result = { - 'type': type, + 'type': type.value, 'source': source, 'destination': destination, }; @@ -1138,3 +1140,50 @@ class ExtractArchiveAction implements CustomCommandAction { return result; } } + +/// Type of custom command action. +enum CustomCommandActionType { + exec('exec', 'Execute shell command'), + alex('alex', 'Execute alex command'), + script('script', 'Execute Dart script'), + checkGitBranch('check_git_branch', 'Check/switch git branch'), + checkGitClean('check_git_clean', 'Check git is clean'), + changeDir('change_dir', 'Change directory'), + deleteFile('delete_file', 'Delete file'), + checkFileExists('check_file_exists', 'Check file exists'), + copyFile('copy_file', 'Copy file'), + renameFile('rename_file', 'Rename file'), + moveFile('move_file', 'Move file'), + createFile('create_file', 'Create file'), + createDir('create_dir', 'Create directory'), + deleteDir('delete_dir', 'Delete directory'), + renameDir('rename_dir', 'Rename directory'), + replaceInFile('replace_in_file', 'Replace text in file'), + appendToFile('append_to_file', 'Append to file'), + prependToFile('prepend_to_file', 'Prepend to file'), + print('print', 'Print message'), + wait('wait', 'Wait/sleep'), + checkPlatform('check_platform', 'Check platform'), + createArchive('create_archive', 'Create archive'), + extractArchive('extract_archive', 'Extract archive'); + + const CustomCommandActionType(this.value, this.description); + + /// YAML serialization value + final String value; + + /// Command description + final String description; + + static final Map _values = { + for (final e in values) e.value: e + }; + + static CustomCommandActionType fromString(String value) { + final result = _values[value]; + if (result == null) { + throw ArgumentError('Unknown action type: $value'); + } + return result; + } +} diff --git a/lib/src/custom_commands/custom_command_argument.dart b/lib/src/custom_commands/custom_command_argument.dart index 4ff11c9..5498ecf 100644 --- a/lib/src/custom_commands/custom_command_argument.dart +++ b/lib/src/custom_commands/custom_command_argument.dart @@ -62,9 +62,9 @@ class CustomCommandArgument { name: data['name'] as String? ?? (throw Exception('Argument requires "name" field')), type: type, - help: data['help'] as String?, - abbr: data['abbr'] as String?, - defaultValue: data['default'] as String?, + help: data['help']?.toString(), + abbr: data['abbr']?.toString(), + defaultValue: data['default']?.toString(), allowed: allowedList?.map((e) => e.toString()).toList(), required: data['required'] as bool? ?? false, ); diff --git a/lib/src/custom_commands/custom_command_config.dart b/lib/src/custom_commands/custom_command_config.dart index 37f47c9..5e16886 100644 --- a/lib/src/custom_commands/custom_command_config.dart +++ b/lib/src/custom_commands/custom_command_config.dart @@ -40,18 +40,55 @@ class CustomCommandDefinition { throw Exception('Custom command requires at least one action'); } + final cmdName = data['name'] as String? ?? ''; + + // Parse arguments with error context + final arguments = []; + if (argumentsList != null) { + for (var i = 0; i < argumentsList.length; i++) { + try { + final argData = argumentsList[i] as YamlMap; + arguments.add(CustomCommandArgument.fromYaml(argData)); + } catch (e) { + final argData = argumentsList[i] as YamlMap; + var location = 'argument ${i + 1} in command "$cmdName"'; + try { + final span = argData.span; + location = 'line ${span.start.line + 1}:${span.start.column + 1} (argument ${i + 1} in command "$cmdName")'; + } catch (_) { + // Span not available + } + throw Exception('Error parsing $location: $e'); + } + } + } + + // Parse actions with error context + final actions = []; + for (var i = 0; i < actionsList.length; i++) { + try { + final actionData = actionsList[i] as YamlMap; + actions.add(CustomCommandAction.fromYaml(actionData)); + } catch (e) { + final actionData = actionsList[i] as YamlMap; + final actionType = actionData['type'] as String? ?? ''; + var location = 'action ${i + 1} (type: $actionType) in command "$cmdName"'; + try { + final span = actionData.span; + location = 'line ${span.start.line + 1}:${span.start.column + 1} (action ${i + 1}, type: $actionType, command: "$cmdName")'; + } catch (_) { + // Span not available + } + throw Exception('Error parsing $location: $e'); + } + } + return CustomCommandDefinition( - name: data['name'] as String? ?? - (throw Exception('Custom command requires "name" field')), + name: cmdName, description: data['description'] as String? ?? '', aliases: aliasesList?.map((e) => e.toString()).toList() ?? [], - arguments: argumentsList - ?.map((e) => CustomCommandArgument.fromYaml(e as YamlMap)) - .toList() ?? - [], - actions: actionsList - .map((e) => CustomCommandAction.fromYaml(e as YamlMap)) - .toList(), + arguments: arguments, + actions: actions, ); } @@ -97,6 +134,7 @@ class CustomCommandsConfig { _instance = null; } + _logger.fine('Looking for custom commands config file...'); final configPath = path ?? _findConfigFile(); if (configPath == null) { _logger.fine('Custom commands config file not found, using empty config'); @@ -104,11 +142,13 @@ class CustomCommandsConfig { return; } + _logger.info('Found custom commands config at: $configPath'); try { _instance = _loadFromFile(configPath); - _logger.fine('Loaded custom commands config from: $configPath'); - } catch (e) { - _logger.warning('Failed to load custom commands config: $e'); + _logger.info('Successfully loaded custom commands config from: $configPath'); + } catch (e, stackTrace) { + _logger.severe('Failed to load custom commands config: $e'); + _logger.fine('Stack trace: $stackTrace'); _instance = CustomCommandsConfig._([], null); } } @@ -144,25 +184,63 @@ class CustomCommandsConfig { throw Exception('Config file not found: $path'); } + _logger.fine('Reading YAML file: $path'); final yamlString = file.readAsStringSync(); + + _logger.fine('Parsing YAML content (${yamlString.length} characters)'); final yamlData = loadYaml(yamlString); if (yamlData == null) { + _logger.warning('YAML file is empty or null'); return CustomCommandsConfig._([], path); } if (yamlData is! YamlMap) { - throw Exception('Invalid config file format'); + throw Exception('Invalid config file format: expected YamlMap, got ${yamlData.runtimeType}'); } final commandsList = yamlData['custom_commands'] as YamlList?; - if (commandsList == null || commandsList.isEmpty) { + if (commandsList == null) { + _logger.warning('No "custom_commands" key found in YAML'); + return CustomCommandsConfig._([], path); + } + + if (commandsList.isEmpty) { + _logger.fine('custom_commands list is empty'); return CustomCommandsConfig._([], path); } - final commands = commandsList - .map((e) => CustomCommandDefinition.fromYaml(e as YamlMap)) - .toList(); + _logger.fine('Parsing ${commandsList.length} command definition(s)'); + final commands = []; + for (var i = 0; i < commandsList.length; i++) { + try { + final cmdData = commandsList[i] as YamlMap; + final cmdName = cmdData['name'] as String? ?? ''; + _logger.fine('Parsing command ${i + 1}: $cmdName'); + final cmd = CustomCommandDefinition.fromYaml(cmdData); + commands.add(cmd); + _logger.fine('Successfully parsed command: ${cmd.name}'); + } catch (e, stackTrace) { + final cmdData = commandsList[i] as YamlMap; + final cmdName = cmdData['name'] as String? ?? ''; + + // Try to get line number from YamlMap if available + var locationInfo = 'command ${i + 1} ($cmdName)'; + try { + final span = cmdData.span; + locationInfo = '$path:${span.start.line + 1}:${span.start.column + 1}'; + } catch (_) { + // If span is not available, use simple format + } + + final errorMsg = 'Failed to parse $locationInfo: $e'; + _logger.severe(errorMsg); + _logger.fine('Stack trace: $stackTrace'); + + // Rethrow with more context + throw Exception(errorMsg); + } + } return CustomCommandsConfig._(commands, path); } @@ -251,23 +329,33 @@ class CustomCommandsConfig { buffer.writeln(' actions:'); for (final action in cmd.actions) { - if (action is ExecAction) { - buffer.writeln(' - type: exec'); - buffer.writeln(' command: ${action.command}'); - if (action.workingDir != null) { - buffer.writeln(' working_dir: ${action.workingDir}'); - } - } else if (action is AlexAction) { - buffer.writeln(' - type: alex'); - buffer.writeln(' command: ${action.command}'); - if (action.args.isNotEmpty) { - buffer.writeln(' args: ${action.args}'); - } - } else if (action is ScriptAction) { - buffer.writeln(' - type: script'); - buffer.writeln(' path: ${action.path}'); - if (action.args.isNotEmpty) { - buffer.writeln(' args: ${action.args}'); + final actionYaml = action.toYaml(); + buffer.writeln(' - type: ${actionYaml['type']}'); + + // Write all other fields from the YAML map + for (final entry in actionYaml.entries) { + if (entry.key == 'type') continue; // Already written + + final value = entry.value; + if (value is List) { + buffer.writeln(' ${entry.key}: $value'); + } else if (value is Map) { + buffer.writeln(' ${entry.key}:'); + for (final subEntry in (value as Map).entries) { + buffer.writeln(' ${subEntry.key}: ${_escapeYamlString(subEntry.value.toString())}'); + } + } else if (value is String) { + // Escape multiline strings + if (value.contains('\n')) { + buffer.writeln(' ${entry.key}: |'); + for (final line in value.split('\n')) { + buffer.writeln(' $line'); + } + } else { + buffer.writeln(' ${entry.key}: ${_escapeYamlString(value)}'); + } + } else { + buffer.writeln(' ${entry.key}: $value'); } } } @@ -308,4 +396,38 @@ class CustomCommandsConfig { } _commands[index] = newCommand; } + + /// Escape a string for safe YAML output. + /// Wraps strings in quotes if they contain special YAML characters. + String _escapeYamlString(String value) { + // Check if the string needs quoting + final needsQuoting = value.contains('{') || + value.contains('}') || + value.contains('[') || + value.contains(']') || + value.contains(':') || + value.contains('#') || + value.contains('&') || + value.contains('*') || + value.contains('!') || + value.contains('|') || + value.contains('>') || + value.contains("'") || + value.contains('"') || + value.contains('%') || + value.contains('@') || + value.contains('`') || + value.startsWith(' ') || + value.endsWith(' ') || + value.startsWith('-') || + value.startsWith('?'); + + if (!needsQuoting) { + return value; + } + + // Escape double quotes and wrap in double quotes + final escaped = value.replaceAll(r'\', r'\\').replaceAll('"', r'\"'); + return '"$escaped"'; + } } diff --git a/lib/src/custom_commands/custom_command_executor.dart b/lib/src/custom_commands/custom_command_executor.dart index 96b1d53..9915b4d 100644 --- a/lib/src/custom_commands/custom_command_executor.dart +++ b/lib/src/custom_commands/custom_command_executor.dart @@ -25,7 +25,7 @@ class CustomCommandExecutor { for (var i = 0; i < definition.actions.length; i++) { final action = definition.actions[i]; - _logger.fine('Executing action ${i + 1}/${definition.actions.length}: ${action.type}'); + _logger.fine('Executing action ${i + 1}/${definition.actions.length}: ${action.type.value}'); final exitCode = await _executeAction(action, arguments); if (exitCode != 0) { @@ -42,54 +42,53 @@ class CustomCommandExecutor { CustomCommandAction action, Map arguments, ) async { - if (action is ExecAction) { - return _executeExecAction(action, arguments); - } else if (action is AlexAction) { - return _executeAlexAction(action, arguments); - } else if (action is ScriptAction) { - return _executeScriptAction(action, arguments); - } else if (action is CheckGitBranchAction) { - return _executeCheckGitBranchAction(action, arguments); - } else if (action is CheckGitCleanAction) { - return _executeCheckGitCleanAction(action, arguments); - } else if (action is ChangeDirAction) { - return _executeChangeDirAction(action, arguments); - } else if (action is DeleteFileAction) { - return _executeDeleteFileAction(action, arguments); - } else if (action is CheckFileExistsAction) { - return _executeCheckFileExistsAction(action, arguments); - } else if (action is CopyFileAction) { - return _executeCopyFileAction(action, arguments); - } else if (action is RenameFileAction) { - return _executeRenameFileAction(action, arguments); - } else if (action is MoveFileAction) { - return _executeMoveFileAction(action, arguments); - } else if (action is CreateFileAction) { - return _executeCreateFileAction(action, arguments); - } else if (action is CreateDirAction) { - return _executeCreateDirAction(action, arguments); - } else if (action is DeleteDirAction) { - return _executeDeleteDirAction(action, arguments); - } else if (action is RenameDirAction) { - return _executeRenameDirAction(action, arguments); - } else if (action is ReplaceInFileAction) { - return _executeReplaceInFileAction(action, arguments); - } else if (action is AppendToFileAction) { - return _executeAppendToFileAction(action, arguments); - } else if (action is PrependToFileAction) { - return _executePrependToFileAction(action, arguments); - } else if (action is PrintAction) { - return _executePrintAction(action, arguments); - } else if (action is WaitAction) { - return _executeWaitAction(action, arguments); - } else if (action is CheckPlatformAction) { - return _executeCheckPlatformAction(action, arguments); - } else if (action is CreateArchiveAction) { - return _executeCreateArchiveAction(action, arguments); - } else if (action is ExtractArchiveAction) { - return _executeExtractArchiveAction(action, arguments); - } else { - throw Exception('Unknown action type: ${action.type}'); + switch (action.type) { + case CustomCommandActionType.exec: + return _executeExecAction(action as ExecAction, arguments); + case CustomCommandActionType.alex: + return _executeAlexAction(action as AlexAction, arguments); + case CustomCommandActionType.script: + return _executeScriptAction(action as ScriptAction, arguments); + case CustomCommandActionType.checkGitBranch: + return _executeCheckGitBranchAction(action as CheckGitBranchAction, arguments); + case CustomCommandActionType.checkGitClean: + return _executeCheckGitCleanAction(action as CheckGitCleanAction, arguments); + case CustomCommandActionType.changeDir: + return _executeChangeDirAction(action as ChangeDirAction, arguments); + case CustomCommandActionType.deleteFile: + return _executeDeleteFileAction(action as DeleteFileAction, arguments); + case CustomCommandActionType.checkFileExists: + return _executeCheckFileExistsAction(action as CheckFileExistsAction, arguments); + case CustomCommandActionType.copyFile: + return _executeCopyFileAction(action as CopyFileAction, arguments); + case CustomCommandActionType.renameFile: + return _executeRenameFileAction(action as RenameFileAction, arguments); + case CustomCommandActionType.moveFile: + return _executeMoveFileAction(action as MoveFileAction, arguments); + case CustomCommandActionType.createFile: + return _executeCreateFileAction(action as CreateFileAction, arguments); + case CustomCommandActionType.createDir: + return _executeCreateDirAction(action as CreateDirAction, arguments); + case CustomCommandActionType.deleteDir: + return _executeDeleteDirAction(action as DeleteDirAction, arguments); + case CustomCommandActionType.renameDir: + return _executeRenameDirAction(action as RenameDirAction, arguments); + case CustomCommandActionType.replaceInFile: + return _executeReplaceInFileAction(action as ReplaceInFileAction, arguments); + case CustomCommandActionType.appendToFile: + return _executeAppendToFileAction(action as AppendToFileAction, arguments); + case CustomCommandActionType.prependToFile: + return _executePrependToFileAction(action as PrependToFileAction, arguments); + case CustomCommandActionType.print: + return _executePrintAction(action as PrintAction, arguments); + case CustomCommandActionType.wait: + return _executeWaitAction(action as WaitAction, arguments); + case CustomCommandActionType.checkPlatform: + return _executeCheckPlatformAction(action as CheckPlatformAction, arguments); + case CustomCommandActionType.createArchive: + return _executeCreateArchiveAction(action as CreateArchiveAction, arguments); + case CustomCommandActionType.extractArchive: + return _executeExtractArchiveAction(action as ExtractArchiveAction, arguments); } } @@ -737,9 +736,10 @@ class CustomCommandExecutor { if (action.message != null) { final message = _substituteVariables(action.message!, arguments); _logger.info(message); + } else { + _logger.info('Waiting for ${action.milliseconds}ms...'); } - _logger.info('Waiting for ${action.milliseconds}ms...'); await Future.delayed(Duration(milliseconds: action.milliseconds)); return 0; From f87813184e8967bf7d7f8ef7a7d6642c426afb7d Mon Sep 17 00:00:00 2001 From: SF97 Date: Tue, 11 Nov 2025 20:05:14 +0100 Subject: [PATCH 3/8] Refactoring and optimization: - Add additional validation and checks - Reduced the allowed values and ranges --- lib/commands/custom/add_custom_command.dart | 76 ++- lib/commands/custom/show_custom_command.dart | 14 +- .../custom_command_action.dart | 33 +- .../custom_command_config.dart | 31 +- .../custom_command_executor.dart | 489 +++++++++++++----- 5 files changed, 470 insertions(+), 173 deletions(-) diff --git a/lib/commands/custom/add_custom_command.dart b/lib/commands/custom/add_custom_command.dart index ec53505..5a607b2 100644 --- a/lib/commands/custom/add_custom_command.dart +++ b/lib/commands/custom/add_custom_command.dart @@ -7,6 +7,10 @@ import 'package:alex/src/custom_commands/custom_command_config.dart'; /// Add a new custom command. class AddCustomCommand extends AlexCommand { + // Input validation limits + static const int _maxInputLength = 1000; + static const int _maxChoiceInputLength = 100; + AddCustomCommand() : super('add', 'Add a new custom command.'); @override @@ -222,11 +226,21 @@ class AddCustomCommand extends AlexCommand { } ExecAction _promptExecAction() { - final command = _prompt(' Command'); + printInfo(''); + printInfo(' Executable and arguments are separate for security'); + printInfo(''); + + final executable = _prompt(' Executable (e.g., "flutter", "git", "echo")'); + final argsStr = _prompt(' Arguments (space-separated, optional)'); final workingDir = _prompt(' Working directory (optional)'); + final args = argsStr.isNotEmpty + ? argsStr.split(' ').map((e) => e.trim()).where((e) => e.isNotEmpty).toList() + : []; + return ExecAction( - command: command, + executable: executable, + args: args.isNotEmpty ? args : null, workingDir: workingDir.isNotEmpty ? workingDir : null, ); } @@ -501,7 +515,16 @@ class AddCustomCommand extends AlexCommand { while (true) { stdout.write(' $message (1-${choices.length}) [$defaultIndex]: '); final input = stdin.readLineSync() ?? ''; - final value = input.isEmpty ? defaultIndex.toString() : input.trim(); + final rawValue = input.isEmpty ? defaultIndex.toString() : input.trim(); + + // Validate input + final error = _validateInput(rawValue, maxLength: _maxChoiceInputLength); + if (error != null) { + printError(error); + continue; + } + + final value = rawValue; // Try to parse as number final number = int.tryParse(value); @@ -521,14 +544,47 @@ class AddCustomCommand extends AlexCommand { } } - String _prompt(String message, {String defaultValue = ''}) { - if (defaultValue.isNotEmpty) { - stdout.write('$message [$defaultValue]: '); - } else { - stdout.write('$message: '); + // Validate user input to prevent injection and DoS attacks. + String? _validateInput(String input, {int maxLength = _maxInputLength}) { + // Check length + if (input.length > maxLength) { + return 'Input too long (max $maxLength characters)'; } - final input = stdin.readLineSync() ?? ''; - return input.isEmpty ? defaultValue : input; + // Check for null bytes and control characters (except newline/tab/carriage return) + for (var i = 0; i < input.length; i++) { + final code = input.codeUnitAt(i); + // Allow printable ASCII, tab (9), newline (10), carriage return (13), and extended Unicode + if (code < 32 && code != 9 && code != 10 && code != 13) { + return 'Input contains invalid control characters'; + } + if (code == 0) { + return 'Input contains null bytes'; + } + } + + return null; // Valid + } + + String _prompt(String message, {String defaultValue = ''}) { + while (true) { + if (defaultValue.isNotEmpty) { + stdout.write('$message [$defaultValue]: '); + } else { + stdout.write('$message: '); + } + + final input = stdin.readLineSync() ?? ''; + final value = input.isEmpty ? defaultValue : input; + + // Validate input + final error = _validateInput(value); + if (error != null) { + printError(error); + continue; + } + + return value; + } } } diff --git a/lib/commands/custom/show_custom_command.dart b/lib/commands/custom/show_custom_command.dart index e50e2bb..34c2505 100644 --- a/lib/commands/custom/show_custom_command.dart +++ b/lib/commands/custom/show_custom_command.dart @@ -20,14 +20,7 @@ class ShowCustomCommand extends AlexCommand { final name = argResults!['name'] as String; final config = CustomCommandsConfig.instance; - CustomCommandDefinition? cmd; - try { - cmd = config.findCommand(name); - } catch (e) { - printError('Command not found: $name'); - return 1; - } - + final cmd = config.findCommand(name); if (cmd == null) { printError('Command not found: $name'); return 1; @@ -98,7 +91,10 @@ class ShowCustomCommand extends AlexCommand { printInfo(' $index. ${action.type}'); if (action is ExecAction) { - printInfo(' command: ${action.command}'); + printInfo(' executable: ${action.executable}'); + if (action.args != null && action.args!.isNotEmpty) { + printInfo(' args: ${action.args!.join(' ')}'); + } if (action.workingDir != null) { printInfo(' working_dir: ${action.workingDir}'); } diff --git a/lib/src/custom_commands/custom_command_action.dart b/lib/src/custom_commands/custom_command_action.dart index 64cfed6..a0c3620 100644 --- a/lib/src/custom_commands/custom_command_action.dart +++ b/lib/src/custom_commands/custom_command_action.dart @@ -76,27 +76,39 @@ class ExecAction implements CustomCommandAction { @override final CustomCommandActionType type = CustomCommandActionType.exec; - /// Command to execute. - final String command; + /// Executable to run (e.g., "flutter", "git", "echo"). + final String executable; + + /// Arguments for the executable. + final List? args; /// Working directory (optional). final String? workingDir; + /// Error message. @override final String? errorMessage; ExecAction({ - required this.command, + required this.executable, + this.args, this.workingDir, this.errorMessage, }); factory ExecAction.fromYaml(YamlMap data) { + final executable = data['executable'] as String?; + final argsList = data['args'] as YamlList?; + + if (executable == null) { + throw Exception('exec action requires "executable" field'); + } + return ExecAction( - command: data['command'] as String? ?? - (throw Exception('exec action requires "command" field')), - workingDir: data['working_dir'] as String?, - errorMessage: data['error_message'] as String?, + executable: executable, + args: argsList?.map((e) => e.toString()).toList(), + workingDir: data['working_dir']?.toString(), + errorMessage: data['error_message']?.toString(), ); } @@ -104,8 +116,13 @@ class ExecAction implements CustomCommandAction { Map toYaml() { final result = { 'type': type.value, - 'command': command, + 'executable': executable, }; + + if (args != null && args!.isNotEmpty) { + result['args'] = args; + } + if (workingDir != null) { result['working_dir'] = workingDir; } diff --git a/lib/src/custom_commands/custom_command_config.dart b/lib/src/custom_commands/custom_command_config.dart index 5e16886..c6ecca5 100644 --- a/lib/src/custom_commands/custom_command_config.dart +++ b/lib/src/custom_commands/custom_command_config.dart @@ -115,9 +115,12 @@ class CustomCommandsConfig { static const _configFileName = 'alex_custom_commands.yaml'; static final _logger = Logger('custom_commands_config'); + // File search limits + static const int _maxParentDirSearchDepth = 10; + static CustomCommandsConfig? _instance; - /// Returns instance of loaded configuration. + // Returns instance of loaded configuration. static CustomCommandsConfig get instance { if (_instance == null) { load(); @@ -162,7 +165,7 @@ class CustomCommandsConfig { static String? _findConfigFile() { // Look for config file in current directory and parent directories var dir = Directory.current; - for (var i = 0; i < 10; i++) { + for (var i = 0; i < _maxParentDirSearchDepth; i++) { final configFile = File(p.join(dir.path, _configFileName)); if (configFile.existsSync()) { return configFile.path; @@ -266,25 +269,23 @@ class CustomCommandsConfig { return p.join(Directory.current.path, _configFileName); } - /// Find command by name or alias. + // Find command by name or alias. + // Returns null if command is not found. CustomCommandDefinition? findCommand(String name) { - return _commands.firstWhere( - (cmd) => cmd.name == name || cmd.aliases.contains(name), - orElse: () => throw StateError('Command not found: $name'), - ); + for (final cmd in _commands) { + if (cmd.name == name || cmd.aliases.contains(name)) { + return cmd; + } + } + return null; } - /// Check if command exists. + // Check if command exists. bool hasCommand(String name) { - try { - findCommand(name); - return true; - } catch (_) { - return false; - } + return findCommand(name) != null; } - /// Save configuration to file. + // Save configuration to file. void save() { final path = getOrCreateConfigPath(); final file = File(path); diff --git a/lib/src/custom_commands/custom_command_executor.dart b/lib/src/custom_commands/custom_command_executor.dart index 9915b4d..97e2721 100644 --- a/lib/src/custom_commands/custom_command_executor.dart +++ b/lib/src/custom_commands/custom_command_executor.dart @@ -4,9 +4,20 @@ import 'package:alex/src/custom_commands/custom_command_action.dart'; import 'package:alex/src/custom_commands/custom_command_config.dart'; import 'package:alex/src/run/cmd.dart'; import 'package:logging/logging.dart'; +import 'package:path/path.dart' as p; /// Executor for custom commands. class CustomCommandExecutor { + // Security limits + static const int _maxRegexPatternLength = 200; + static const int _maxReplacementLength = 10000; + static const int _regexTimeoutMs = 10; + static const int _maxFileSizeForProcessing = 1073741824; // 1GB + static const int _largeFileWarningSize = 104857600; // 100MB + + // File search limits + static const int _maxParentDirSearchDepth = 10; + final Logger _logger; final Cmd _cmd; @@ -16,6 +27,13 @@ class CustomCommandExecutor { }) : _logger = logger ?? Logger('custom_command_executor'), _cmd = cmd ?? Cmd(); + /// Log error with stack trace and return error code. + int _logError(String message, Object error, StackTrace stackTrace) { + _logger.severe('$message: $error'); + _logger.fine('Stack trace:\n$stackTrace'); + return 1; + } + /// Execute a custom command. Future execute( CustomCommandDefinition definition, @@ -96,34 +114,29 @@ class CustomCommandExecutor { ExecAction action, Map arguments, ) async { - final command = _substituteVariables(action.command, arguments); - _logger.info('Executing: $command'); + // Substitute variables only in arguments, not in executable + final args = action.args?.map((arg) => _substituteVariables(arg, arguments)).toList() ?? []; - // Parse command into executable and arguments - final parts = _parseCommand(command); - if (parts.isEmpty) { - _logger.severe('Empty command'); - return 1; - } - - final executable = parts.first; - final args = parts.skip(1).toList(); + _logger.info('Executing: ${action.executable} ${args.join(" ")}'); try { final result = await _cmd.run( - executable, + action.executable, arguments: args, workingDir: action.workingDir, ); if (result.exitCode != 0) { - _logger.severe('Command failed: ${result.stderr}'); + _logger.severe('Command failed with exit code ${result.exitCode}'); + final stderr = result.stderr.toString(); + if (stderr.isNotEmpty) { + _logger.severe('stderr: $stderr'); + } } return result.exitCode; - } catch (e) { - _logger.severe('Failed to execute command: $e'); - return 1; + } catch (e, stackTrace) { + return _logError('Failed to execute command', e, stackTrace); } } @@ -161,9 +174,8 @@ class CustomCommandExecutor { } return result.exitCode; - } catch (e) { - _logger.severe('Failed to execute alex command: $e'); - return 1; + } catch (e, stackTrace) { + return _logError('Failed to execute alex command', e, stackTrace); } } @@ -192,9 +204,8 @@ class CustomCommandExecutor { } return result.exitCode; - } catch (e) { - _logger.severe('Failed to execute script: $e'); - return 1; + } catch (e, stackTrace) { + return _logError('Failed to execute script', e, stackTrace); } } @@ -217,37 +228,36 @@ class CustomCommandExecutor { return result; } - /// Parse command string into parts, respecting quotes. - List _parseCommand(String command) { - final parts = []; - final buffer = StringBuffer(); - var inQuotes = false; - var quoteChar = ''; - - for (var i = 0; i < command.length; i++) { - final char = command[i]; - - if ((char == '"' || char == "'") && !inQuotes) { - inQuotes = true; - quoteChar = char; - } else if (char == quoteChar && inQuotes) { - inQuotes = false; - quoteChar = ''; - } else if (char == ' ' && !inQuotes) { - if (buffer.isNotEmpty) { - parts.add(buffer.toString()); - buffer.clear(); - } - } else { - buffer.write(char); - } + /// Validate and resolve path to prevent path traversal attacks. + /// + /// This method ensures that the resolved path stays within the allowed base directory. + /// If [baseDir] is null, uses current working directory. + /// + /// Throws [ArgumentError] if path traversal is detected. + String _validateAndResolvePath(String path, [String? baseDir]) { + // Resolve to absolute path + final absolutePath = p.absolute(path); + final normalizedPath = p.normalize(absolutePath); + + // Get base directory (default to current working directory) + final base = baseDir != null ? p.absolute(baseDir) : Directory.current.path; + final normalizedBase = p.normalize(base); + + // Check if path is within allowed directory + if (!p.isWithin(normalizedBase, normalizedPath) && normalizedPath != normalizedBase) { + throw ArgumentError( + 'Path traversal detected: "$path" resolves to "$normalizedPath" ' + 'which is outside allowed directory "$normalizedBase"' + ); } - if (buffer.isNotEmpty) { - parts.add(buffer.toString()); - } + return normalizedPath; + } - return parts; + /// Safely substitute variables in path and validate it. + String _safelyResolvePath(String pathTemplate, Map arguments) { + final pathInput = _substituteVariables(pathTemplate, arguments); + return _validateAndResolvePath(pathInput); } Future _executeCheckGitBranchAction( @@ -311,9 +321,8 @@ class CustomCommandExecutor { } return 0; - } catch (e) { - _logger.severe('Failed to check git branch: $e'); - return 1; + } catch (e, stackTrace) { + return _logError('Failed to check git branch', e, stackTrace); } } @@ -346,9 +355,8 @@ class CustomCommandExecutor { _logger.info('Git working directory is clean'); return 0; - } catch (e) { - _logger.severe('Failed to check git status: $e'); - return 1; + } catch (e, stackTrace) { + return _logError('Failed to check git status', e, stackTrace); } } @@ -356,7 +364,15 @@ class CustomCommandExecutor { ChangeDirAction action, Map arguments, ) async { - final path = _substituteVariables(action.path, arguments); + // Validate path to prevent traversal attacks + String path; + try { + path = _safelyResolvePath(action.path, arguments); + } catch (e) { + _logger.severe('Invalid path: $e'); + return 1; + } + _logger.info('Changing directory to: $path'); final dir = Directory(path); @@ -370,9 +386,8 @@ class CustomCommandExecutor { Directory.current = dir; _logger.info('Changed directory to: ${Directory.current.path}'); return 0; - } catch (e) { - _logger.severe('Failed to change directory: $e'); - return 1; + } catch (e, stackTrace) { + return _logError('Failed to change directory', e, stackTrace); } } @@ -380,7 +395,17 @@ class CustomCommandExecutor { DeleteFileAction action, Map arguments, ) async { - final path = _substituteVariables(action.path, arguments); + final pathInput = _substituteVariables(action.path, arguments); + + // Validate path to prevent traversal attacks + String path; + try { + path = _validateAndResolvePath(pathInput); + } catch (e) { + _logger.severe('Invalid path: $e'); + return 1; + } + _logger.info('Deleting: $path'); final file = File(path); @@ -407,9 +432,8 @@ class CustomCommandExecutor { _logger.info('Deleted directory: $path'); } return 0; - } catch (e) { - _logger.severe('Failed to delete: $e'); - return 1; + } catch (e, stackTrace) { + return _logError('Failed to delete', e, stackTrace); } } @@ -417,7 +441,15 @@ class CustomCommandExecutor { CheckFileExistsAction action, Map arguments, ) async { - final path = _substituteVariables(action.path, arguments); + // Validate path to prevent traversal attacks + String path; + try { + path = _safelyResolvePath(action.path, arguments); + } catch (e) { + _logger.severe('Invalid path: $e'); + return 1; + } + _logger.info('Checking file exists: $path'); final file = File(path); @@ -442,8 +474,16 @@ class CustomCommandExecutor { CopyFileAction action, Map arguments, ) async { - final source = _substituteVariables(action.source, arguments); - final destination = _substituteVariables(action.destination, arguments); + // Validate paths to prevent traversal attacks + String source, destination; + try { + source = _safelyResolvePath(action.source, arguments); + destination = _safelyResolvePath(action.destination, arguments); + } catch (e) { + _logger.severe('Invalid path: $e'); + return 1; + } + _logger.info('Copying file: $source -> $destination'); final sourceFile = File(source); @@ -462,9 +502,8 @@ class CustomCommandExecutor { await sourceFile.copy(destination); _logger.info('File copied successfully'); return 0; - } catch (e) { - _logger.severe('Failed to copy file: $e'); - return 1; + } catch (e, stackTrace) { + return _logError('Failed to copy file', e, stackTrace); } } @@ -472,8 +511,16 @@ class CustomCommandExecutor { RenameFileAction action, Map arguments, ) async { - final oldPath = _substituteVariables(action.oldPath, arguments); - final newPath = _substituteVariables(action.newPath, arguments); + // Validate paths to prevent traversal attacks + String oldPath, newPath; + try { + oldPath = _safelyResolvePath(action.oldPath, arguments); + newPath = _safelyResolvePath(action.newPath, arguments); + } catch (e) { + _logger.severe('Invalid path: $e'); + return 1; + } + _logger.info('Renaming file: $oldPath -> $newPath'); final file = File(oldPath); @@ -486,9 +533,8 @@ class CustomCommandExecutor { await file.rename(newPath); _logger.info('File renamed successfully'); return 0; - } catch (e) { - _logger.severe('Failed to rename file: $e'); - return 1; + } catch (e, stackTrace) { + return _logError('Failed to rename file', e, stackTrace); } } @@ -496,8 +542,16 @@ class CustomCommandExecutor { MoveFileAction action, Map arguments, ) async { - final source = _substituteVariables(action.source, arguments); - final destination = _substituteVariables(action.destination, arguments); + // Validate paths to prevent traversal attacks + String source, destination; + try { + source = _safelyResolvePath(action.source, arguments); + destination = _safelyResolvePath(action.destination, arguments); + } catch (e) { + _logger.severe('Invalid path: $e'); + return 1; + } + _logger.info('Moving file: $source -> $destination'); final sourceFile = File(source); @@ -510,9 +564,8 @@ class CustomCommandExecutor { await sourceFile.rename(destination); _logger.info('File moved successfully'); return 0; - } catch (e) { - _logger.severe('Failed to move file: $e'); - return 1; + } catch (e, stackTrace) { + return _logError('Failed to move file', e, stackTrace); } } @@ -520,7 +573,17 @@ class CustomCommandExecutor { CreateFileAction action, Map arguments, ) async { - final path = _substituteVariables(action.path, arguments); + final pathInput = _substituteVariables(action.path, arguments); + + // Validate path to prevent traversal attacks + String path; + try { + path = _validateAndResolvePath(pathInput); + } catch (e) { + _logger.severe('Invalid path: $e'); + return 1; + } + final content = action.content != null ? _substituteVariables(action.content!, arguments) : ''; @@ -542,9 +605,8 @@ class CustomCommandExecutor { await file.writeAsString(content); _logger.info('File created successfully'); return 0; - } catch (e) { - _logger.severe('Failed to create file: $e'); - return 1; + } catch (e, stackTrace) { + return _logError('Failed to create file', e, stackTrace); } } @@ -552,7 +614,15 @@ class CustomCommandExecutor { CreateDirAction action, Map arguments, ) async { - final path = _substituteVariables(action.path, arguments); + // Validate path to prevent traversal attacks + String path; + try { + path = _safelyResolvePath(action.path, arguments); + } catch (e) { + _logger.severe('Invalid path: $e'); + return 1; + } + _logger.info('Creating directory: $path'); final dir = Directory(path); @@ -565,9 +635,8 @@ class CustomCommandExecutor { await dir.create(recursive: action.recursive); _logger.info('Directory created successfully'); return 0; - } catch (e) { - _logger.severe('Failed to create directory: $e'); - return 1; + } catch (e, stackTrace) { + return _logError('Failed to create directory', e, stackTrace); } } @@ -575,7 +644,15 @@ class CustomCommandExecutor { DeleteDirAction action, Map arguments, ) async { - final path = _substituteVariables(action.path, arguments); + // Validate path to prevent traversal attacks + String path; + try { + path = _safelyResolvePath(action.path, arguments); + } catch (e) { + _logger.severe('Invalid path: $e'); + return 1; + } + _logger.info('Deleting directory: $path'); final dir = Directory(path); @@ -593,9 +670,8 @@ class CustomCommandExecutor { await dir.delete(recursive: action.recursive); _logger.info('Directory deleted successfully'); return 0; - } catch (e) { - _logger.severe('Failed to delete directory: $e'); - return 1; + } catch (e, stackTrace) { + return _logError('Failed to delete directory', e, stackTrace); } } @@ -603,8 +679,16 @@ class CustomCommandExecutor { RenameDirAction action, Map arguments, ) async { - final oldPath = _substituteVariables(action.oldPath, arguments); - final newPath = _substituteVariables(action.newPath, arguments); + // Validate paths to prevent traversal attacks + String oldPath, newPath; + try { + oldPath = _safelyResolvePath(action.oldPath, arguments); + newPath = _safelyResolvePath(action.newPath, arguments); + } catch (e) { + _logger.severe('Invalid path: $e'); + return 1; + } + _logger.info('Renaming directory: $oldPath -> $newPath'); final dir = Directory(oldPath); @@ -617,9 +701,8 @@ class CustomCommandExecutor { await dir.rename(newPath); _logger.info('Directory renamed successfully'); return 0; - } catch (e) { - _logger.severe('Failed to rename directory: $e'); - return 1; + } catch (e, stackTrace) { + return _logError('Failed to rename directory', e, stackTrace); } } @@ -627,7 +710,15 @@ class CustomCommandExecutor { ReplaceInFileAction action, Map arguments, ) async { - final path = _substituteVariables(action.path, arguments); + // Validate path to prevent traversal attacks + String path; + try { + path = _safelyResolvePath(action.path, arguments); + } catch (e) { + _logger.severe('Invalid path: $e'); + return 1; + } + final find = _substituteVariables(action.find, arguments); final replace = _substituteVariables(action.replace, arguments); _logger.info('Replacing in file: $path'); @@ -638,12 +729,56 @@ class CustomCommandExecutor { return 1; } + // Check file size to prevent memory issues + final stat = await file.stat(); + if (stat.size > _largeFileWarningSize) { + _logger.warning('File is very large (${stat.size} bytes), this may take a while'); + } + if (stat.size > _maxFileSizeForProcessing) { + _logger.severe('File too large for in-memory processing: ${stat.size} bytes'); + return 1; + } + try { var content = await file.readAsString(); if (action.regex) { - final regex = RegExp(find); - content = content.replaceAll(regex, replace); + // Validate regex pattern length to prevent injection + if (find.length > _maxRegexPatternLength) { + _logger.severe('Regex pattern too complex (length > $_maxRegexPatternLength)'); + return 1; + } + + // Validate replacement string length + if (replace.length > _maxReplacementLength) { + _logger.severe('Replacement string too long (length > $_maxReplacementLength)'); + return 1; + } + + try { + final regex = RegExp(find); + + // Test regex on multiple sample sizes to detect catastrophic backtracking + final testSizes = [100, 500, 1000]; + for (final size in testSizes) { + final testContent = content.length > size ? content.substring(0, size) : content; + final startTime = DateTime.now(); + regex.allMatches(testContent).toList(); + final elapsed = DateTime.now().difference(startTime); + + if (elapsed.inMilliseconds > _regexTimeoutMs) { + _logger.severe('Regex pattern appears to have catastrophic backtracking ' + '(took ${elapsed.inMilliseconds}ms on $size chars, limit: ${_regexTimeoutMs}ms)'); + return 1; + } + } + + content = content.replaceAll(regex, replace); + } catch (e) { + _logger.severe('Invalid regex pattern: $find'); + _logger.severe('Error: $e'); + return 1; + } } else { content = content.replaceAll(find, replace); } @@ -651,9 +786,8 @@ class CustomCommandExecutor { await file.writeAsString(content); _logger.info('File updated successfully'); return 0; - } catch (e) { - _logger.severe('Failed to replace in file: $e'); - return 1; + } catch (e, stackTrace) { + return _logError('Failed to replace in file', e, stackTrace); } } @@ -661,7 +795,15 @@ class CustomCommandExecutor { AppendToFileAction action, Map arguments, ) async { - final path = _substituteVariables(action.path, arguments); + // Validate path to prevent traversal attacks + String path; + try { + path = _safelyResolvePath(action.path, arguments); + } catch (e) { + _logger.severe('Invalid path: $e'); + return 1; + } + final content = _substituteVariables(action.content, arguments); _logger.info('Appending to file: $path'); @@ -675,9 +817,8 @@ class CustomCommandExecutor { await file.writeAsString(content, mode: FileMode.append); _logger.info('Content appended successfully'); return 0; - } catch (e) { - _logger.severe('Failed to append to file: $e'); - return 1; + } catch (e, stackTrace) { + return _logError('Failed to append to file', e, stackTrace); } } @@ -685,7 +826,15 @@ class CustomCommandExecutor { PrependToFileAction action, Map arguments, ) async { - final path = _substituteVariables(action.path, arguments); + // Validate path to prevent traversal attacks + String path; + try { + path = _safelyResolvePath(action.path, arguments); + } catch (e) { + _logger.severe('Invalid path: $e'); + return 1; + } + final content = _substituteVariables(action.content, arguments); _logger.info('Prepending to file: $path'); @@ -693,6 +842,16 @@ class CustomCommandExecutor { var existingContent = ''; if (file.existsSync()) { + // Check file size before reading to prevent memory exhaustion + final stat = await file.stat(); + if (stat.size > _largeFileWarningSize) { + _logger.warning('File is very large (${stat.size} bytes), this may take a while'); + } + if (stat.size > _maxFileSizeForProcessing) { + _logger.severe('File too large for in-memory processing: ${stat.size} bytes'); + return 1; + } + existingContent = await file.readAsString(); } else if (!action.createIfMissing) { _logger.severe('File does not exist: $path'); @@ -703,9 +862,8 @@ class CustomCommandExecutor { await file.writeAsString(content + existingContent); _logger.info('Content prepended successfully'); return 0; - } catch (e) { - _logger.severe('Failed to prepend to file: $e'); - return 1; + } catch (e, stackTrace) { + return _logError('Failed to prepend to file', e, stackTrace); } } @@ -776,33 +934,63 @@ class CustomCommandExecutor { CreateArchiveAction action, Map arguments, ) async { - final source = _substituteVariables(action.source, arguments); - final destination = _substituteVariables(action.destination, arguments); + // Validate paths to prevent traversal attacks + String source, destination; + try { + source = _safelyResolvePath(action.source, arguments); + destination = _safelyResolvePath(action.destination, arguments); + } catch (e) { + _logger.severe('Invalid path: $e'); + return 1; + } _logger.info('Creating archive: $destination from $source'); try { // Use system commands for now (zip/tar) // TODO: Consider adding archive package for pure Dart implementation + ProcessResult result; if (action.format == 'zip') { - final result = await _cmd.run( + result = await _cmd.run( 'zip', arguments: ['-r', destination, source], ); - return result.exitCode; } else if (action.format == 'tar.gz' || action.format == 'tgz') { - final result = await _cmd.run( + result = await _cmd.run( 'tar', arguments: ['-czf', destination, source], ); - return result.exitCode; } else { _logger.severe('Unsupported archive format: ${action.format}'); return 1; } - } catch (e) { - _logger.severe('Failed to create archive: $e'); - return 1; + + // Cleanup partially created archive on failure + if (result.exitCode != 0) { + final archiveFile = File(destination); + if (archiveFile.existsSync()) { + try { + archiveFile.deleteSync(); + _logger.info('Cleaned up partially created archive: $destination'); + } catch (cleanupError) { + _logger.warning('Failed to cleanup partial archive: $cleanupError'); + } + } + } + + return result.exitCode; + } catch (e, stackTrace) { + // Cleanup on exception + final archiveFile = File(destination); + if (archiveFile.existsSync()) { + try { + archiveFile.deleteSync(); + _logger.info('Cleaned up partially created archive after error: $destination'); + } catch (cleanupError) { + _logger.warning('Failed to cleanup partial archive: $cleanupError'); + } + } + return _logError('Failed to create archive', e, stackTrace); } } @@ -810,38 +998,77 @@ class CustomCommandExecutor { ExtractArchiveAction action, Map arguments, ) async { - final source = _substituteVariables(action.source, arguments); - final destination = _substituteVariables(action.destination, arguments); + // Validate paths to prevent traversal attacks + String source, destination; + try { + source = _safelyResolvePath(action.source, arguments); + destination = _safelyResolvePath(action.destination, arguments); + } catch (e) { + _logger.severe('Invalid path: $e'); + return 1; + } _logger.info('Extracting archive: $source to $destination'); // Ensure destination directory exists final destDir = Directory(destination); - if (!destDir.existsSync()) { + final dirCreatedByUs = !destDir.existsSync(); + if (dirCreatedByUs) { await destDir.create(recursive: true); } try { // Detect format from file extension + ProcessResult result; if (source.endsWith('.zip')) { - final result = await _cmd.run( + result = await _cmd.run( 'unzip', arguments: [source, '-d', destination], ); - return result.exitCode; } else if (source.endsWith('.tar.gz') || source.endsWith('.tgz')) { - final result = await _cmd.run( + result = await _cmd.run( 'tar', arguments: ['-xzf', source, '-C', destination], ); - return result.exitCode; } else { _logger.severe('Unsupported archive format: $source'); return 1; } - } catch (e) { - _logger.severe('Failed to extract archive: $e'); - return 1; + + // Cleanup on failure: only if we created the directory and it's empty + if (result.exitCode != 0 && dirCreatedByUs) { + try { + final isEmpty = destDir.listSync().isEmpty; + if (isEmpty) { + destDir.deleteSync(); + _logger.info('Cleaned up empty extraction directory: $destination'); + } else { + _logger.warning('Extraction failed but directory contains files, not cleaning up: $destination'); + } + } catch (cleanupError) { + _logger.warning('Failed to cleanup extraction directory: $cleanupError'); + } + } + + return result.exitCode; + } catch (e, stackTrace) { + // Cleanup on exception: only if we created the directory and it's empty + if (dirCreatedByUs) { + try { + if (destDir.existsSync()) { + final isEmpty = destDir.listSync().isEmpty; + if (isEmpty) { + destDir.deleteSync(); + _logger.info('Cleaned up empty extraction directory after error: $destination'); + } else { + _logger.warning('Extraction failed but directory contains files, not cleaning up: $destination'); + } + } + } catch (cleanupError) { + _logger.warning('Failed to cleanup extraction directory: $cleanupError'); + } + } + return _logError('Failed to extract archive', e, stackTrace); } } @@ -850,7 +1077,7 @@ class CustomCommandExecutor { String? _findAlexExecutable() { // Try to find bin/alex.dart relative to current directory var dir = Directory.current; - for (var i = 0; i < 10; i++) { + for (var i = 0; i < _maxParentDirSearchDepth; i++) { final alexPath = '${dir.path}/bin/alex.dart'; if (File(alexPath).existsSync()) { return alexPath; From f3c070f91926ac5d898ec1078555f80fc43607d8 Mon Sep 17 00:00:00 2001 From: SF97 Date: Tue, 11 Nov 2025 20:15:21 +0100 Subject: [PATCH 4/8] Update readme and example --- CHANGELOG.md | 2 +- README.md | 9 ++++-- alex_custom_commands.yaml.example | 48 ++++++++++++++++++++----------- 3 files changed, 39 insertions(+), 20 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 52e7f9f..9ffbeab 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,7 +4,7 @@ - Execute shell commands, alex commands, or Dart scripts - Support for command arguments (options and flags) - Variable substitution in actions using `{{var}}` or `${var}` syntax - - Commands management: `alex custom list`, `add`, `show`, `edit`, `remove` + - Commands management: `alex custom list`, `add`, `show`, `edit`, `remove`, `check` - **Advanced actions** for conditional logic and file operations: - `check_git_branch` - Check/switch git branch - `check_git_clean` - Verify no uncommitted changes diff --git a/README.md b/README.md index 3f103b3..2c28f05 100644 --- a/README.md +++ b/README.md @@ -449,7 +449,8 @@ custom_commands: - type: alex command: code gen - type: exec - command: flutter build {{platform}} --release + executable: flutter + args: [build, '{{platform}}', --release] ``` #### Action types @@ -459,7 +460,8 @@ Custom commands support multiple types of actions: **exec** - Execute shell command or program: ```yaml - type: exec - command: flutter clean + executable: flutter + args: [clean] working_dir: /optional/path # optional ``` @@ -641,7 +643,8 @@ arguments: required: true actions: - type: exec - command: flutter build {{platform}} + executable: flutter + args: [build, '{{platform}}'] ``` #### Using custom commands diff --git a/alex_custom_commands.yaml.example b/alex_custom_commands.yaml.example index b178c69..42c0544 100644 --- a/alex_custom_commands.yaml.example +++ b/alex_custom_commands.yaml.example @@ -23,7 +23,8 @@ custom_commands: command: l10n extract # Build for specified platform - type: exec - command: flutter build {{platform}} --release + executable: flutter + args: [build, '{{platform}}', --release] # Example 2: Quick fix for common issues - name: quick-fix @@ -31,9 +32,11 @@ custom_commands: aliases: [qf, fix] actions: - type: exec - command: flutter clean + executable: flutter + args: [clean] - type: exec - command: flutter pub get + executable: flutter + args: [pub, get] - type: alex command: code gen @@ -43,11 +46,14 @@ custom_commands: aliases: [pc, check] actions: - type: exec - command: dart analyze + executable: dart + args: [analyze] - type: exec - command: dart format --set-exit-if-changed . + executable: dart + args: [format, --set-exit-if-changed, .] - type: exec - command: dart test + executable: dart + args: [test] # Example 4: Create a new feature branch - name: new-feature @@ -60,7 +66,8 @@ custom_commands: required: true actions: - type: exec - command: git checkout -b feature/{{name}} + executable: git + args: [checkout, -b, 'feature/{{name}}'] # Example 5: Run a custom Dart script - name: custom-script @@ -90,9 +97,11 @@ custom_commands: - type: alex command: code gen - type: exec - command: dart analyze + executable: dart + args: [analyze] - type: exec - command: dart test + executable: dart + args: [test] # Start release - type: alex command: release start @@ -105,11 +114,13 @@ custom_commands: aliases: [ud] actions: - type: exec - command: flutter pub upgrade + executable: flutter + args: [pub, upgrade] - type: alex command: code gen - type: exec - command: dart analyze + executable: dart + args: [analyze] # Example 8: Simple command with flag argument - name: build-debug @@ -121,7 +132,8 @@ custom_commands: abbr: v actions: - type: exec - command: flutter build apk --debug + executable: flutter + args: [build, apk, --debug] # Example 9: Build for App Gallery with git checks - name: build-app-gallery @@ -139,7 +151,8 @@ custom_commands: # Build the app - type: exec - command: fvm flutter build apk --target lib/main_app_gallery.dart + executable: fvm + args: [flutter, build, apk, --target, lib/main_app_gallery.dart] # Example 10: Update iOS pods - name: update-pods @@ -167,7 +180,8 @@ custom_commands: # Run pod install - type: exec - command: pod install --repo-update + executable: pod + args: [install, --repo-update] # Example 11: File operations workflow - name: file-ops-demo @@ -270,7 +284,8 @@ custom_commands: # Build - type: exec - command: flutter build macos --release + executable: flutter + args: [build, macos, --release] # Create archive - type: create_archive @@ -295,7 +310,8 @@ custom_commands: # Build - type: exec - command: flutter build web --release + executable: flutter + args: [build, web, --release] # Create deployment archive - type: create_archive From 7ba62672dafed9e3035f75a01f7e447cb72e20ac Mon Sep 17 00:00:00 2001 From: Alexander <60105225+Vabiel@users.noreply.github.com> Date: Wed, 12 Nov 2025 13:18:23 +0500 Subject: [PATCH 5/8] Update lib/src/custom_commands/custom_command_executor.dart Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> --- lib/src/custom_commands/custom_command_executor.dart | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/lib/src/custom_commands/custom_command_executor.dart b/lib/src/custom_commands/custom_command_executor.dart index 97e2721..8196958 100644 --- a/lib/src/custom_commands/custom_command_executor.dart +++ b/lib/src/custom_commands/custom_command_executor.dart @@ -11,7 +11,7 @@ class CustomCommandExecutor { // Security limits static const int _maxRegexPatternLength = 200; static const int _maxReplacementLength = 10000; - static const int _regexTimeoutMs = 10; + static const int _regexTimeoutMs = 250; static const int _maxFileSizeForProcessing = 1073741824; // 1GB static const int _largeFileWarningSize = 104857600; // 100MB From eb10322bb7ea7554c317d715c5b0d7f53de3f208 Mon Sep 17 00:00:00 2001 From: Alexander <60105225+Vabiel@users.noreply.github.com> Date: Wed, 12 Nov 2025 13:18:40 +0500 Subject: [PATCH 6/8] Update lib/src/custom_commands/custom_command_executor.dart Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> --- lib/src/custom_commands/custom_command_executor.dart | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/lib/src/custom_commands/custom_command_executor.dart b/lib/src/custom_commands/custom_command_executor.dart index 8196958..e36c57c 100644 --- a/lib/src/custom_commands/custom_command_executor.dart +++ b/lib/src/custom_commands/custom_command_executor.dart @@ -221,7 +221,7 @@ class CustomCommandExecutor { // Also support ${arg_name} format arguments.forEach((key, value) { - final pattern = '\${$key}'; + final pattern = r'\${$key}'; result = result.replaceAll(pattern, value.toString()); }); From 303a00a1a45e1af0d44c7d5abf061f9272fe539f Mon Sep 17 00:00:00 2001 From: SF97 Date: Wed, 12 Nov 2025 10:06:41 +0100 Subject: [PATCH 7/8] revert variable substitution --- lib/src/custom_commands/custom_command_executor.dart | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/lib/src/custom_commands/custom_command_executor.dart b/lib/src/custom_commands/custom_command_executor.dart index e36c57c..8196958 100644 --- a/lib/src/custom_commands/custom_command_executor.dart +++ b/lib/src/custom_commands/custom_command_executor.dart @@ -221,7 +221,7 @@ class CustomCommandExecutor { // Also support ${arg_name} format arguments.forEach((key, value) { - final pattern = r'\${$key}'; + final pattern = '\${$key}'; result = result.replaceAll(pattern, value.toString()); }); From 30b407aede2fd618cee9cebb2a06a0feacf7d3ab Mon Sep 17 00:00:00 2001 From: SF97 Date: Wed, 12 Nov 2025 18:32:46 +0100 Subject: [PATCH 8/8] Fixing vulnerabilities --- README.md | 17 ++ lib/commands/custom/edit_custom_command.dart | 49 +++- .../custom_command_executor.dart | 277 +++++++++++++++++- 3 files changed, 331 insertions(+), 12 deletions(-) diff --git a/README.md b/README.md index 2c28f05..2b84233 100644 --- a/README.md +++ b/README.md @@ -397,6 +397,23 @@ Custom commands are configured in `alex_custom_commands.yaml` file in your proje $ alex custom ``` +> **⚠️ SECURITY WARNING** +> +> Custom commands can execute arbitrary programs, scripts, and shell commands. **NEVER** use custom command YAML files from untrusted sources! +> +> Malicious YAML files can: +> - Delete or modify your files +> - Steal sensitive information (credentials, API keys, etc.) +> - Install malware or backdoors +> - Compromise your entire system +> +> **Only use custom commands that:** +> - You created yourself, OR +> - You have thoroughly reviewed and understand, OR +> - Come from a trusted source you can verify +> +> When in doubt, manually inspect the `alex_custom_commands.yaml` file before running any custom commands. + #### Manage custom commands List all registered custom commands: diff --git a/lib/commands/custom/edit_custom_command.dart b/lib/commands/custom/edit_custom_command.dart index dc76097..fa00934 100644 --- a/lib/commands/custom/edit_custom_command.dart +++ b/lib/commands/custom/edit_custom_command.dart @@ -2,9 +2,29 @@ import 'dart:io'; import 'package:alex/runner/alex_command.dart'; import 'package:alex/src/custom_commands/custom_command_config.dart'; +import 'package:path/path.dart' as p; /// Edit custom commands configuration. class EditCustomCommand extends AlexCommand { + /// Whitelist of allowed editors for security. + static const _allowedEditors = { + 'vim', + 'vi', + 'nvim', + 'nano', + 'emacs', + 'code', // VSCode + 'subl', // Sublime Text + 'atom', + 'notepad', + 'notepad++', + 'gedit', + 'kate', + 'micro', + 'helix', + 'ed', + }; + EditCustomCommand() : super('edit', 'Edit custom commands configuration file.') { argParser.addOption( @@ -14,6 +34,24 @@ class EditCustomCommand extends AlexCommand { ); } + // Validate editor against whitelist for security. + // + // Throws [ArgumentError] if editor is not in allowed list. + String _validateEditor(String editor) { + // Extract base command (ignore path and extensions) + final basename = p.basenameWithoutExtension(editor).toLowerCase(); + + if (_allowedEditors.contains(basename)) { + return editor; + } + + throw ArgumentError( + 'Editor "$editor" is not in the allowed list.\n' + 'Allowed editors: ${_allowedEditors.join(", ")}\n' + 'This restriction is for security reasons.', + ); + } + @override Future doRun() async { final config = CustomCommandsConfig.instance; @@ -28,11 +66,20 @@ class EditCustomCommand extends AlexCommand { // Determine editor to use final editorArg = argResults!['editor'] as String?; - final editor = editorArg ?? + final rawEditor = editorArg ?? Platform.environment['EDITOR'] ?? Platform.environment['VISUAL'] ?? (Platform.isMacOS || Platform.isLinux ? 'vim' : 'notepad'); + // SECURITY: Validate editor against whitelist + String editor; + try { + editor = _validateEditor(rawEditor); + } catch (e) { + printError(e.toString()); + return 1; + } + printInfo('Opening config file in $editor: $configPath'); try { diff --git a/lib/src/custom_commands/custom_command_executor.dart b/lib/src/custom_commands/custom_command_executor.dart index 8196958..a6e7465 100644 --- a/lib/src/custom_commands/custom_command_executor.dart +++ b/lib/src/custom_commands/custom_command_executor.dart @@ -18,9 +18,17 @@ class CustomCommandExecutor { // File search limits static const int _maxParentDirSearchDepth = 10; + // Resource limits per command execution + static const int _maxFilesCreatedPerCommand = 1000; + static const int _maxTotalBytesWrittenPerCommand = 104857600; // 100MB + final Logger _logger; final Cmd _cmd; + // Resource usage tracking (reset per command execution) + int _filesCreatedCount = 0; + int _totalBytesWritten = 0; + CustomCommandExecutor({ Logger? logger, Cmd? cmd, @@ -39,6 +47,10 @@ class CustomCommandExecutor { CustomCommandDefinition definition, Map arguments, ) async { + // Reset resource usage tracking for this command execution + _filesCreatedCount = 0; + _totalBytesWritten = 0; + _logger.info('Executing custom command: ${definition.name}'); for (var i = 0; i < definition.actions.length; i++) { @@ -53,6 +65,7 @@ class CustomCommandExecutor { } _logger.info('Custom command completed successfully'); + _logger.fine('Resource usage: $_filesCreatedCount files created, $_totalBytesWritten bytes written'); return 0; } @@ -117,13 +130,24 @@ class CustomCommandExecutor { // Substitute variables only in arguments, not in executable final args = action.args?.map((arg) => _substituteVariables(arg, arguments)).toList() ?? []; + // SECURITY: Validate working directory if specified + String? validatedWorkingDir; + if (action.workingDir != null) { + try { + validatedWorkingDir = _validateAndResolvePath(action.workingDir!); + } catch (e) { + _logger.severe('Invalid working directory: $e'); + return 1; + } + } + _logger.info('Executing: ${action.executable} ${args.join(" ")}'); try { final result = await _cmd.run( action.executable, arguments: args, - workingDir: action.workingDir, + workingDir: validatedWorkingDir, ); if (result.exitCode != 0) { @@ -183,7 +207,15 @@ class CustomCommandExecutor { ScriptAction action, Map arguments, ) async { - final scriptPath = _substituteVariables(action.path, arguments); + // SECURITY: Validate script path to prevent path traversal attacks + String scriptPath; + try { + scriptPath = _safelyResolvePath(action.path, arguments); + } catch (e) { + _logger.severe('Invalid script path: $e'); + return 1; + } + final args = action.args .map((arg) => _substituteVariables(arg, arguments)) .toList(); @@ -260,6 +292,63 @@ class CustomCommandExecutor { return _validateAndResolvePath(pathInput); } + // Check resource limits before creating/writing files. + // + // Returns true if operation is allowed, false if limits exceeded. + bool _checkResourceLimits(int bytesToWrite) { + // Check file count limit + if (_filesCreatedCount >= _maxFilesCreatedPerCommand) { + _logger.severe( + 'Resource limit exceeded: maximum $_maxFilesCreatedPerCommand files per command', + ); + return false; + } + + // Check total bytes written limit + if (_totalBytesWritten + bytesToWrite > _maxTotalBytesWrittenPerCommand) { + _logger.severe( + 'Resource limit exceeded: maximum $_maxTotalBytesWrittenPerCommand bytes per command ' + '(current: $_totalBytesWritten, attempting: $bytesToWrite)', + ); + return false; + } + + return true; + } + + // Track file creation and bytes written. + void _trackResourceUsage(int bytesWritten) { + _filesCreatedCount++; + _totalBytesWritten += bytesWritten; + } + + // Validate filename for archive operations. + // + // Prevents issues with special characters that could confuse archive utilities. + bool _validateArchiveFilename(String filename) { + // Check for dangerous characters or patterns + if (filename.startsWith('-')) { + _logger.severe('Invalid filename: cannot start with dash: $filename'); + return false; + } + + if (filename.contains('..')) { + _logger.severe('Invalid filename: contains path traversal: $filename'); + return false; + } + + // Check for shell metacharacters that could cause issues + final dangerousChars = [';', '&', '|', '`', r'$', '(', ')', '<', '>', '\n', '\r']; + for (final char in dangerousChars) { + if (filename.contains(char)) { + _logger.severe('Invalid filename: contains dangerous character "$char": $filename'); + return false; + } + } + + return true; + } + Future _executeCheckGitBranchAction( CheckGitBranchAction action, Map arguments, @@ -587,6 +676,12 @@ class CustomCommandExecutor { final content = action.content != null ? _substituteVariables(action.content!, arguments) : ''; + + // SECURITY: Check resource limits before creating file + if (!_checkResourceLimits(content.length)) { + return 1; + } + _logger.info('Creating file: $path'); final file = File(path); @@ -603,6 +698,7 @@ class CustomCommandExecutor { } await file.writeAsString(content); + _trackResourceUsage(content.length); _logger.info('File created successfully'); return 0; } catch (e, stackTrace) { @@ -759,18 +855,44 @@ class CustomCommandExecutor { final regex = RegExp(find); // Test regex on multiple sample sizes to detect catastrophic backtracking - final testSizes = [100, 500, 1000]; - for (final size in testSizes) { - final testContent = content.length > size ? content.substring(0, size) : content; + // Uses progressively larger samples and checks for exponential time growth + final testSizes = [100, 1000, 10000]; + var lastElapsed = 0; + + for (var i = 0; i < testSizes.length; i++) { + final size = testSizes[i]; + // Pad content if needed to ensure consistent testing + final testContent = content.length > size + ? content.substring(0, size) + : content.padRight(size, 'a'); + final startTime = DateTime.now(); regex.allMatches(testContent).toList(); - final elapsed = DateTime.now().difference(startTime); + final elapsed = DateTime.now().difference(startTime).inMilliseconds; - if (elapsed.inMilliseconds > _regexTimeoutMs) { - _logger.severe('Regex pattern appears to have catastrophic backtracking ' - '(took ${elapsed.inMilliseconds}ms on $size chars, limit: ${_regexTimeoutMs}ms)'); + // Check absolute timeout + if (elapsed > _regexTimeoutMs) { + _logger.severe('Regex timeout on $size characters: ${elapsed}ms'); return 1; } + + // Check for exponential growth (skip first iteration) + if (i > 0 && lastElapsed > 0) { + final sizeRatio = size / testSizes[i - 1]; + final timeRatio = elapsed / lastElapsed; + + // If time grows more than 10x relative to size increase, it's suspicious + // Example: 10x size increase should not cause 100x time increase + if (timeRatio > sizeRatio * 10) { + _logger.severe( + 'Regex shows exponential time complexity: ' + '${sizeRatio.toStringAsFixed(1)}x size → ${timeRatio.toStringAsFixed(1)}x time', + ); + return 1; + } + } + + lastElapsed = elapsed; } content = content.replaceAll(regex, replace); @@ -805,6 +927,12 @@ class CustomCommandExecutor { } final content = _substituteVariables(action.content, arguments); + + // SECURITY: Check resource limits before appending + if (!_checkResourceLimits(content.length)) { + return 1; + } + _logger.info('Appending to file: $path'); final file = File(path); @@ -815,6 +943,7 @@ class CustomCommandExecutor { try { await file.writeAsString(content, mode: FileMode.append); + _trackResourceUsage(content.length); _logger.info('Content appended successfully'); return 0; } catch (e, stackTrace) { @@ -836,6 +965,12 @@ class CustomCommandExecutor { } final content = _substituteVariables(action.content, arguments); + + // SECURITY: Check resource limits before prepending + if (!_checkResourceLimits(content.length)) { + return 1; + } + _logger.info('Prepending to file: $path'); final file = File(path); @@ -860,6 +995,7 @@ class CustomCommandExecutor { try { await file.writeAsString(content + existingContent); + _trackResourceUsage(content.length); _logger.info('Content prepended successfully'); return 0; } catch (e, stackTrace) { @@ -944,6 +1080,13 @@ class CustomCommandExecutor { return 1; } + // SECURITY: Validate filenames for archive operations + final sourceBasename = p.basename(source); + final destBasename = p.basename(destination); + if (!_validateArchiveFilename(sourceBasename) || !_validateArchiveFilename(destBasename)) { + return 1; + } + _logger.info('Creating archive: $destination from $source'); try { @@ -1008,8 +1151,20 @@ class CustomCommandExecutor { return 1; } + // SECURITY: Validate filenames for archive operations + final sourceBasename = p.basename(source); + if (!_validateArchiveFilename(sourceBasename)) { + return 1; + } + _logger.info('Extracting archive: $source to $destination'); + // SECURITY: Validate archive contents before extraction to prevent zip slip + if (!await _validateArchiveContents(source, destination)) { + _logger.severe('Archive validation failed: contains path traversal'); + return 1; + } + // Ensure destination directory exists final destDir = Directory(destination); final dirCreatedByUs = !destDir.existsSync(); @@ -1072,8 +1227,108 @@ class CustomCommandExecutor { } } - /// Find alex executable (bin/alex.dart). - /// Returns null if not found (will use global alex instead). + // Validate archive contents to prevent zip slip vulnerability. + // + // This method lists the archive contents before extraction and ensures + // that all files would be extracted within the destination directory. + // + // Returns true if archive is safe to extract, false otherwise. + Future _validateArchiveContents(String archivePath, String destDir) async { + final normalizedDest = p.normalize(p.absolute(destDir)); + + try { + // List archive contents first + ProcessResult listResult; + if (archivePath.endsWith('.zip')) { + listResult = await _cmd.run( + 'unzip', + arguments: ['-l', archivePath], + immediatePrintStd: false, + immediatePrintErr: false, + ); + } else if (archivePath.endsWith('.tar.gz') || archivePath.endsWith('.tgz')) { + listResult = await _cmd.run( + 'tar', + arguments: ['-tzf', archivePath], + immediatePrintStd: false, + immediatePrintErr: false, + ); + } else { + _logger.severe('Unsupported archive format for validation: $archivePath'); + return false; + } + + if (listResult.exitCode != 0) { + _logger.severe('Failed to list archive contents'); + return false; + } + + // Parse and validate each entry + final entries = listResult.stdout.toString().split('\n'); + for (final entry in entries) { + final filename = _extractFilenameFromListing(entry, archivePath); + if (filename.isEmpty) continue; + + // Resolve what the final path would be after extraction + final targetPath = p.normalize(p.absolute(p.join(destDir, filename))); + + // Check if path escapes destination directory + if (!p.isWithin(normalizedDest, targetPath) && targetPath != normalizedDest) { + _logger.severe('Archive contains path traversal: $filename'); + _logger.severe('Would extract to: $targetPath'); + _logger.severe('Outside allowed directory: $normalizedDest'); + return false; + } + } + + _logger.fine('Archive validation passed: ${entries.length} entries checked'); + return true; + } catch (e, stackTrace) { + _logger.severe('Archive validation error: $e'); + _logger.fine('Stack trace:\n$stackTrace'); + return false; + } + } + + // Extract filename from archive listing output. + // + // Different archive tools produce different output formats: + // - unzip -l: columns with size, date, time, and filename + // - tar -tzf: just filenames + String _extractFilenameFromListing(String line, String archivePath) { + final trimmed = line.trim(); + if (trimmed.isEmpty) return ''; + + // For tar archives, output is just the filename + if (archivePath.endsWith('.tar.gz') || archivePath.endsWith('.tgz')) { + return trimmed; + } + + // For zip archives, parse unzip -l output + // Format: " Length Date Time Name" + // Example: " 2000 2024-01-15 10:30 path/to/file.txt" + if (archivePath.endsWith('.zip')) { + // Skip header lines + if (trimmed.startsWith('Archive:') || + trimmed.startsWith('Length') || + trimmed.startsWith('----') || + trimmed.contains('files')) { + return ''; + } + + // Try to extract filename (last part after whitespace) + final parts = trimmed.split(RegExp(r'\s{2,}')); + if (parts.length >= 4) { + // Format: [length] [date] [time] [name...] + return parts.sublist(3).join(' '); + } + } + + return ''; + } + + // Find alex executable (bin/alex.dart). + // Returns null if not found (will use global alex instead). String? _findAlexExecutable() { // Try to find bin/alex.dart relative to current directory var dir = Directory.current;