diff --git a/CHANGELOG.md b/CHANGELOG.md index 4a19e8a..9ffbeab 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`, `check` + - **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..2b84233 100644 --- a/README.md +++ b/README.md @@ -387,6 +387,313 @@ 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 +``` + +> **⚠️ 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: + +```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 + executable: flutter + args: [build, '{{platform}}', --release] +``` + +#### Action types + +Custom commands support multiple types of actions: + +**exec** - Execute shell command or program: +```yaml +- type: exec + executable: flutter + args: [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 + executable: flutter + args: [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..42c0544 --- /dev/null +++ b/alex_custom_commands.yaml.example @@ -0,0 +1,357 @@ +# 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 + executable: flutter + args: [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 + executable: flutter + args: [clean] + - type: exec + executable: flutter + args: [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 + executable: dart + args: [analyze] + - type: exec + executable: dart + args: [format, --set-exit-if-changed, .] + - type: exec + executable: dart + args: [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 + executable: git + args: [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 + executable: dart + args: [analyze] + - type: exec + executable: dart + args: [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 + executable: flutter + args: [pub, upgrade] + - type: alex + command: code gen + - type: exec + executable: dart + args: [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 + executable: flutter + args: [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 + executable: fvm + args: [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 + executable: pod + args: [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 + executable: flutter + args: [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 + executable: flutter + args: [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..5a607b2 --- /dev/null +++ b/lib/commands/custom/add_custom_command.dart @@ -0,0 +1,590 @@ +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 { + // Input validation limits + static const int _maxInputLength = 1000; + static const int _maxChoiceInputLength = 100; + + 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; + + printInfo(' Type:'); + final typeStr = _promptChoice('Select 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:'); + 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; + } + + actionType = allTypes[number - 1]; + } + + return _promptActionByType(actionType); + } + + CustomCommandAction? _promptActionByType(CustomCommandActionType type) { + switch (type) { + case CustomCommandActionType.exec: + return _promptExecAction(); + case CustomCommandActionType.alex: + return _promptAlexAction(); + case CustomCommandActionType.script: + return _promptScriptAction(); + 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(); + } + } + + ExecAction _promptExecAction() { + 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( + executable: executable, + args: args.isNotEmpty ? args : null, + 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, + ); + } + + 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 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); + 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(", ")}'); + } + } + + // 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)'; + } + + // 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/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 new file mode 100644 index 0000000..8e8ad14 --- /dev/null +++ b/lib/commands/custom/custom_command.dart @@ -0,0 +1,26 @@ +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'; +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()); + addSubcommand(CheckCustomCommandsCommand()); + } + + @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..fa00934 --- /dev/null +++ b/lib/commands/custom/edit_custom_command.dart @@ -0,0 +1,116 @@ +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( + 'editor', + abbr: 'e', + help: r'Editor to use (default: $EDITOR or vim)', + ); + } + + // 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; + 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 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 { + 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..34c2505 --- /dev/null +++ b/lib/commands/custom/show_custom_command.dart @@ -0,0 +1,113 @@ +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; + + final cmd = config.findCommand(name); + 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(' executable: ${action.executable}'); + if (action.args != null && action.args!.isNotEmpty) { + printInfo(' args: ${action.args!.join(' ')}'); + } + 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..c5a0cf9 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,36 @@ class AlexCommandRunner extends CommandRunner { ..addVerboseFlag(); } + 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.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.info('Loaded ${config.commands.length} custom command(s)'); + } else { + _out.fine('No custom commands found in config'); + } + } catch (e, stackTrace) { + _out.warning('Failed to load custom commands: $e'); + _out.fine('Stack trace: $stackTrace'); + } + } + @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..a0c3620 --- /dev/null +++ b/lib/src/custom_commands/custom_command_action.dart @@ -0,0 +1,1206 @@ +import 'package:yaml/yaml.dart'; + +/// Base class for custom command actions. +abstract class CustomCommandAction { + /// Type of the action. + 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 typeStr = data['type'] as String?; + if (typeStr == null) { + throw Exception('Action type is required'); + } + + final type = CustomCommandActionType.fromString(typeStr); + + switch (type) { + case CustomCommandActionType.exec: + return ExecAction.fromYaml(data); + case CustomCommandActionType.alex: + return AlexAction.fromYaml(data); + case CustomCommandActionType.script: + return ScriptAction.fromYaml(data); + case CustomCommandActionType.checkGitBranch: + return CheckGitBranchAction.fromYaml(data); + case CustomCommandActionType.checkGitClean: + return CheckGitCleanAction.fromYaml(data); + case CustomCommandActionType.changeDir: + return ChangeDirAction.fromYaml(data); + case CustomCommandActionType.createDir: + return CreateDirAction.fromYaml(data); + case CustomCommandActionType.deleteDir: + return DeleteDirAction.fromYaml(data); + case CustomCommandActionType.renameDir: + return RenameDirAction.fromYaml(data); + case CustomCommandActionType.deleteFile: + return DeleteFileAction.fromYaml(data); + case CustomCommandActionType.checkFileExists: + return CheckFileExistsAction.fromYaml(data); + case CustomCommandActionType.copyFile: + return CopyFileAction.fromYaml(data); + case CustomCommandActionType.renameFile: + return RenameFileAction.fromYaml(data); + case CustomCommandActionType.moveFile: + return MoveFileAction.fromYaml(data); + case CustomCommandActionType.createFile: + return CreateFileAction.fromYaml(data); + case CustomCommandActionType.replaceInFile: + return ReplaceInFileAction.fromYaml(data); + case CustomCommandActionType.appendToFile: + return AppendToFileAction.fromYaml(data); + case CustomCommandActionType.prependToFile: + return PrependToFileAction.fromYaml(data); + case CustomCommandActionType.print: + return PrintAction.fromYaml(data); + case CustomCommandActionType.wait: + return WaitAction.fromYaml(data); + case CustomCommandActionType.checkPlatform: + return CheckPlatformAction.fromYaml(data); + case CustomCommandActionType.createArchive: + return CreateArchiveAction.fromYaml(data); + case CustomCommandActionType.extractArchive: + return ExtractArchiveAction.fromYaml(data); + } + } + + /// Convert action to YAML map. + Map toYaml(); +} + +/// Execute shell command or program. +class ExecAction implements CustomCommandAction { + @override + final CustomCommandActionType type = CustomCommandActionType.exec; + + /// 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.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( + executable: executable, + args: argsList?.map((e) => e.toString()).toList(), + workingDir: data['working_dir']?.toString(), + errorMessage: data['error_message']?.toString(), + ); + } + + @override + Map toYaml() { + final result = { + 'type': type.value, + 'executable': executable, + }; + + if (args != null && args!.isNotEmpty) { + result['args'] = args; + } + + 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 CustomCommandActionType type = CustomCommandActionType.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.value, + '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 CustomCommandActionType type = CustomCommandActionType.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.value, + '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 CustomCommandActionType type = CustomCommandActionType.checkGitBranch; + + /// 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.value, + '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 CustomCommandActionType type = CustomCommandActionType.checkGitClean; + + @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.value, + }; + if (errorMessage != null) { + result['error_message'] = errorMessage; + } + return result; + } +} + +/// Change working directory. +class ChangeDirAction implements CustomCommandAction { + @override + final CustomCommandActionType type = CustomCommandActionType.changeDir; + + /// 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.value, + 'path': path, + }; + if (errorMessage != null) { + result['error_message'] = errorMessage; + } + return result; + } +} + +/// Delete a file or directory. +class DeleteFileAction implements CustomCommandAction { + @override + final CustomCommandActionType type = CustomCommandActionType.deleteFile; + + /// 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.value, + '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 CustomCommandActionType type = CustomCommandActionType.checkFileExists; + + /// 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.value, + '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 CustomCommandActionType type = CustomCommandActionType.copyFile; + + /// 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.value, + '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 CustomCommandActionType type = CustomCommandActionType.renameFile; + + /// 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.value, + 'old_path': oldPath, + 'new_path': newPath, + }; + if (errorMessage != null) { + result['error_message'] = errorMessage; + } + return result; + } +} + +/// Move a file. +class MoveFileAction implements CustomCommandAction { + @override + final CustomCommandActionType type = CustomCommandActionType.moveFile; + + /// 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.value, + '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 CustomCommandActionType type = CustomCommandActionType.createFile; + + /// 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.value, + '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 CustomCommandActionType type = CustomCommandActionType.createDir; + + /// 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.value, + '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 CustomCommandActionType type = CustomCommandActionType.deleteDir; + + /// 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.value, + '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 CustomCommandActionType type = CustomCommandActionType.renameDir; + + /// 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.value, + '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 CustomCommandActionType type = CustomCommandActionType.replaceInFile; + + /// 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.value, + '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 CustomCommandActionType type = CustomCommandActionType.appendToFile; + + /// 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.value, + '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 CustomCommandActionType type = CustomCommandActionType.prependToFile; + + /// 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.value, + '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 CustomCommandActionType type = CustomCommandActionType.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.value, + '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 CustomCommandActionType type = CustomCommandActionType.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.value, + '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 CustomCommandActionType type = CustomCommandActionType.checkPlatform; + + /// 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.value, + 'platform': platform, + }; + if (errorMessage != null) { + result['error_message'] = errorMessage; + } + return result; + } +} + +/// Create archive from files or directory. +class CreateArchiveAction implements CustomCommandAction { + @override + final CustomCommandActionType type = CustomCommandActionType.createArchive; + + /// 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.value, + '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 CustomCommandActionType type = CustomCommandActionType.extractArchive; + + /// 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.value, + 'source': source, + 'destination': destination, + }; + if (errorMessage != null) { + result['error_message'] = errorMessage; + } + 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 new file mode 100644 index 0000000..5498ecf --- /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']?.toString(), + abbr: data['abbr']?.toString(), + defaultValue: data['default']?.toString(), + 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..c6ecca5 --- /dev/null +++ b/lib/src/custom_commands/custom_command_config.dart @@ -0,0 +1,434 @@ +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'); + } + + 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: cmdName, + description: data['description'] as String? ?? '', + aliases: aliasesList?.map((e) => e.toString()).toList() ?? [], + arguments: arguments, + actions: actions, + ); + } + + 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'); + + // File search limits + static const int _maxParentDirSearchDepth = 10; + + 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; + } + + _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'); + _instance = CustomCommandsConfig._([], null); + return; + } + + _logger.info('Found custom commands config at: $configPath'); + try { + _instance = _loadFromFile(configPath); + _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); + } + } + + /// 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 < _maxParentDirSearchDepth; 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'); + } + + _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: expected YamlMap, got ${yamlData.runtimeType}'); + } + + final commandsList = yamlData['custom_commands'] as YamlList?; + 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); + } + + _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); + } + + 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. + // Returns null if command is not found. + CustomCommandDefinition? findCommand(String name) { + for (final cmd in _commands) { + if (cmd.name == name || cmd.aliases.contains(name)) { + return cmd; + } + } + return null; + } + + // Check if command exists. + bool hasCommand(String name) { + return findCommand(name) != null; + } + + // 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) { + 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'); + } + } + } + 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; + } + + /// 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 new file mode 100644 index 0000000..a6e7465 --- /dev/null +++ b/lib/src/custom_commands/custom_command_executor.dart @@ -0,0 +1,1351 @@ +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'; +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 = 250; + static const int _maxFileSizeForProcessing = 1073741824; // 1GB + static const int _largeFileWarningSize = 104857600; // 100MB + + // 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, + }) : _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, + 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++) { + final action = definition.actions[i]; + _logger.fine('Executing action ${i + 1}/${definition.actions.length}: ${action.type.value}'); + + 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'); + _logger.fine('Resource usage: $_filesCreatedCount files created, $_totalBytesWritten bytes written'); + return 0; + } + + Future _executeAction( + CustomCommandAction action, + Map arguments, + ) async { + 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); + } + } + + Future _executeExecAction( + ExecAction action, + Map arguments, + ) async { + // 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: validatedWorkingDir, + ); + + if (result.exitCode != 0) { + _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, stackTrace) { + return _logError('Failed to execute command', e, stackTrace); + } + } + + 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, stackTrace) { + return _logError('Failed to execute alex command', e, stackTrace); + } + } + + Future _executeScriptAction( + ScriptAction action, + Map arguments, + ) async { + // 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(); + + _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, stackTrace) { + return _logError('Failed to execute script', e, stackTrace); + } + } + + /// 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; + } + + /// 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"' + ); + } + + return normalizedPath; + } + + /// Safely substitute variables in path and validate it. + String _safelyResolvePath(String pathTemplate, Map arguments) { + final pathInput = _substituteVariables(pathTemplate, arguments); + 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, + ) 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, stackTrace) { + return _logError('Failed to check git branch', e, stackTrace); + } + } + + 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, stackTrace) { + return _logError('Failed to check git status', e, stackTrace); + } + } + + Future _executeChangeDirAction( + ChangeDirAction action, + Map arguments, + ) async { + // 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); + 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, stackTrace) { + return _logError('Failed to change directory', e, stackTrace); + } + } + + Future _executeDeleteFileAction( + DeleteFileAction action, + Map arguments, + ) async { + 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); + 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, stackTrace) { + return _logError('Failed to delete', e, stackTrace); + } + } + + Future _executeCheckFileExistsAction( + CheckFileExistsAction action, + Map arguments, + ) async { + // 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); + 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 { + // 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); + 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, stackTrace) { + return _logError('Failed to copy file', e, stackTrace); + } + } + + Future _executeRenameFileAction( + RenameFileAction action, + Map arguments, + ) async { + // 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); + 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, stackTrace) { + return _logError('Failed to rename file', e, stackTrace); + } + } + + Future _executeMoveFileAction( + MoveFileAction action, + Map arguments, + ) async { + // 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); + 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, stackTrace) { + return _logError('Failed to move file', e, stackTrace); + } + } + + Future _executeCreateFileAction( + CreateFileAction action, + Map arguments, + ) async { + 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) + : ''; + + // SECURITY: Check resource limits before creating file + if (!_checkResourceLimits(content.length)) { + return 1; + } + + _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); + _trackResourceUsage(content.length); + _logger.info('File created successfully'); + return 0; + } catch (e, stackTrace) { + return _logError('Failed to create file', e, stackTrace); + } + } + + Future _executeCreateDirAction( + CreateDirAction action, + Map arguments, + ) async { + // 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); + 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, stackTrace) { + return _logError('Failed to create directory', e, stackTrace); + } + } + + Future _executeDeleteDirAction( + DeleteDirAction action, + Map arguments, + ) async { + // 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); + 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, stackTrace) { + return _logError('Failed to delete directory', e, stackTrace); + } + } + + Future _executeRenameDirAction( + RenameDirAction action, + Map arguments, + ) async { + // 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); + 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, stackTrace) { + return _logError('Failed to rename directory', e, stackTrace); + } + } + + Future _executeReplaceInFileAction( + ReplaceInFileAction action, + Map arguments, + ) async { + // 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'); + + final file = File(path); + if (!file.existsSync()) { + _logger.severe('File does not exist: $path'); + 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) { + // 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 + // 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).inMilliseconds; + + // 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); + } catch (e) { + _logger.severe('Invalid regex pattern: $find'); + _logger.severe('Error: $e'); + return 1; + } + } else { + content = content.replaceAll(find, replace); + } + + await file.writeAsString(content); + _logger.info('File updated successfully'); + return 0; + } catch (e, stackTrace) { + return _logError('Failed to replace in file', e, stackTrace); + } + } + + Future _executeAppendToFileAction( + AppendToFileAction action, + Map arguments, + ) async { + // 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); + + // SECURITY: Check resource limits before appending + if (!_checkResourceLimits(content.length)) { + return 1; + } + + _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); + _trackResourceUsage(content.length); + _logger.info('Content appended successfully'); + return 0; + } catch (e, stackTrace) { + return _logError('Failed to append to file', e, stackTrace); + } + } + + Future _executePrependToFileAction( + PrependToFileAction action, + Map arguments, + ) async { + // 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); + + // SECURITY: Check resource limits before prepending + if (!_checkResourceLimits(content.length)) { + return 1; + } + + _logger.info('Prepending to file: $path'); + + final file = File(path); + + 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'); + return 1; + } + + try { + await file.writeAsString(content + existingContent); + _trackResourceUsage(content.length); + _logger.info('Content prepended successfully'); + return 0; + } catch (e, stackTrace) { + return _logError('Failed to prepend to file', e, stackTrace); + } + } + + 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); + } else { + _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 { + // 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; + } + + // 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 { + // Use system commands for now (zip/tar) + // TODO: Consider adding archive package for pure Dart implementation + ProcessResult result; + if (action.format == 'zip') { + result = await _cmd.run( + 'zip', + arguments: ['-r', destination, source], + ); + } else if (action.format == 'tar.gz' || action.format == 'tgz') { + result = await _cmd.run( + 'tar', + arguments: ['-czf', destination, source], + ); + } else { + _logger.severe('Unsupported archive format: ${action.format}'); + 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); + } + } + + Future _executeExtractArchiveAction( + ExtractArchiveAction action, + Map arguments, + ) async { + // 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; + } + + // 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(); + if (dirCreatedByUs) { + await destDir.create(recursive: true); + } + + try { + // Detect format from file extension + ProcessResult result; + if (source.endsWith('.zip')) { + result = await _cmd.run( + 'unzip', + arguments: [source, '-d', destination], + ); + } else if (source.endsWith('.tar.gz') || source.endsWith('.tgz')) { + result = await _cmd.run( + 'tar', + arguments: ['-xzf', source, '-C', destination], + ); + } else { + _logger.severe('Unsupported archive format: $source'); + 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); + } + } + + // 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; + for (var i = 0; i < _maxParentDirSearchDepth; 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"