diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml new file mode 100644 index 0000000..9ec432b --- /dev/null +++ b/.github/workflows/build.yml @@ -0,0 +1,27 @@ +name: Build and Test + +on: + push: + branches: [ "main" ] + pull_request: + branches: [ "main" ] + + # Allows you to run this workflow manually from the Actions tab + workflow_dispatch: + +jobs: + build: + runs-on: ${{ matrix.os }} + strategy: + matrix: + os: [ubuntu-latest, macos-latest, windows-latest] + steps: + - uses: actions/checkout@v4 + - name: Set up Go + uses: actions/setup-go@v5 + with: + go-version: '1.24.4' + - name: Build + run: go build -v ./... + - name: Test + run: go test -v ./... diff --git a/.github/workflows/codecov.yml b/.github/workflows/codecov.yml index fbf91c0..9e861d2 100644 --- a/.github/workflows/codecov.yml +++ b/.github/workflows/codecov.yml @@ -15,8 +15,7 @@ jobs: steps: - uses: actions/checkout@v4 - name: Run Tests - run: | - go test -coverprofile=coverage.txt + run: go test -coverprofile=coverage.txt - uses: codecov/codecov-action@v5 with: token: ${{ secrets.CODECOV_TOKEN }} diff --git a/.gitignore b/.gitignore index 09b89d3..95706d6 100644 --- a/.gitignore +++ b/.gitignore @@ -8,7 +8,6 @@ *.so *.dylib raid -!raid/ # Test binary, built with `go test -c` *.test diff --git a/README.md b/README.md index 80d8681..07b93c3 100644 --- a/README.md +++ b/README.md @@ -1,4 +1,5 @@ # Raid - Distributed Development Orchestration +[![codecov](https://codecov.io/github/8bitAlex/raid/graph/badge.svg?token=Z75V7I2TLW)](https://codecov.io/github/8bitAlex/raid) `Raid` is a configurable command-line application that orchestrates common development tasks, environments, and dependencies across distributed code repositories. @@ -12,19 +13,441 @@ never miss running that one test ever again. πŸ“– For a deeper look at the goals and design of raid, see the [design proposal blog post](https://alexsalerno.dev/blog/raid-design-proposal?utm_source=chatgpt.com). +[Getting Started](#getting-started) β€’ [Best Practices](#⚠-best-practices) β€’ [Documentation](#usage--documentation) + ## Key Features -- **Portable YAML Configurations**: Define your development environment using simple, version-controlled YAML files -- **Multiple Raid Profiles**: Manage different project configurations and environments with separate profiles -- **Distributed Repository Management**: Automatically clone, update, and manage multiple repositories across your development environment -- **Development Environment Automation**: Streamline setup, dependency installation, and environment configuration -- **Self-Healing Test Runner**: Robust testing framework with automatic error recovery and retry mechanisms -- **Custom Global Commands**: Extend functionality and automate common tasks with user-defined commands that work across all managed repositories +- **Portable YAML Configurations**: Define your development environments, tasks, and dependencies using simple, version-controlled YAML files. +- **Multiple Profiles**: Easily switch between different project setups or team configurations with isolated profiles. +- **Automated Task Execution**: Orchestrate shell commands, scripts, and custom tasks across multiple repositories with a single command. +- **Environment Management**: Define, share, and execute complex development environments to ensure consistency for all contributors. + +| Platform | Supported | +|----------|:---------:| +| Linux | βœ… | +| Mac | βœ… | +| Windows | βœ… | -## Project Status +## Development `Raid` is currently in the **prototype stage**. Core functionality is still being explored and iterated on, so expect frequent changes and incomplete features. -If you’d like to follow the most up-to-date work, check out the ['alpha'](https://github.com/8bitAlex/raid/tree/alpha) branch. This is where active development of the prototype is happening. +Feedback, issues, and contributions are welcome as the project takes shape. + +## Getting Started + +### Installation + +```bash +# Installation instructions will be added here +``` + +### Configuration + +1. Create a profile configuration file (e.g., `my-project.raid.yaml`) +2. Define your repositories and dependencies +3. Configure environment settings + +### Execution + +```bash +raid profile add my-project.raid.yaml # Add and activate a profile +raid install # Clone repos and setup environment +raid env dev # Execute development environment (if configured) +``` + +## ⚠ Best Practices + +- **Store profiles securely:** If your raid profile contains sensitive configuration or secrets, keep it in a secure, private location outside of your public codebase. +- **Never commit secrets:** Always keep secrets and credentials in private raid profiles. Do not store them in public repositories. + +## Usage & Documentation + +**Note:** Raid is currently in the prototype stage. Some features may be incomplete or in development. + +[Commands](#commands) β€’ [Profile Configuration](#profile-configuration) β€’ [Repository Configuration](#repository-configuration) β€’ [JSON Schema Specifications](#json-schema-specifications) + +## Commands + +[profile](#raid-profile) β€’ [install](#raid-install) β€’ [env](#raid-env) + +### `raid profile` + +Manage raid profiles. If there are no non-option arguments, the currently active profile is displayed. + +#### Subcommands + +##### `raid profile add ` + +Add profile(s) from a YAML (.yaml, .yml) or JSON (.json) file. The file will be validated against the raid profile schema. + +**Features:** +- **Multiple Profiles Support**: Add multiple profiles from a single file using YAML document separators (`---`) or JSON arrays +- **Validation**: Each profile is validated against the JSON schema + +**Examples:** +```bash +# Add a single profile +raid profile add my-project.raid.yaml + +# Add multiple profiles from YAML with document separators +raid profile add multiple-profiles.yaml + +# Add multiple profiles from JSON array +raid profile add multiple-profiles.json +``` + +**Example Output:** +```bash +# Single profile (auto-activated) +Profile 'my-project' has been successfully added from my-project.raid.yaml + +# Multiple profiles (first auto-activated) +Profiles: + development + personal + open-source +have been successfully added from multiple-profiles.yaml +Profile 'development' set as active + +# Some profiles already exist +Profiles already exist with names: + development + +Profiles: + personal + open-source +have been successfully added from multiple-profiles.yaml +``` + +##### `raid profile list` + +List all available profiles and show the currently active profile. + +**Example Output:** +```bash +Available profiles: + my-project (active) ~/.raid/profiles/my-project.raid.yaml + development ~/.raid/profiles/development.raid.yaml + personal ~/.raid/profiles/personal.raid.yaml +``` + +##### `raid profile use ` + +Set a specific profile as the active profile. + +**Example:** +```bash +raid profile use my-project +# Output: Profile 'my-project' is now active. +``` + +##### `raid profile remove [profile-name...]` + +Remove one or more profiles. You can specify multiple profile names to remove them all at once. + +**Examples:** +```bash +# Remove a single profile +raid profile remove old-project + +# Remove multiple profiles +raid profile remove project1 project2 project3 +``` + +**Example Output:** +```bash +Profile 'old-project' has been removed. +Profile 'project1' has been removed. +Profile 'project2' has been removed. +``` + +### `raid install` + +Clones all repositories defined in the active profile to their specified paths. If a repository already exists, it will be skipped. Repositories are cloned concurrently for better performance. + +**Prerequisites:** +- An active profile must be set using `raid profile use ` +- The active profile must contain valid repository definitions + +**Features:** +- **Concurrent**: All repositories are cloned simultaneously for faster installation + +**Options:** +- `--threads, -t`: Maximum number of concurrent repository clones (default: 0 = unlimited) + +**Examples:** +```bash +# Set an active profile first +raid profile use my-project + +# Install all repositories with unlimited concurrency (default) +raid install + +# Install with limited concurrency (max 3 concurrent clones) +raid install --threads 3 + +# Install with limited concurrency using short flag +raid install -t 5 +``` + +**Concurrency Guidelines:** +- **Unlimited (default)**: Best for fast networks and when you want maximum speed +- **Limited (3-5)**: Good for slower networks or when you want to avoid overwhelming the system +- **Very Limited (1-2)**: Useful for very slow connections or when you need to minimize resource usage + +## Profile Configuration + +A profile configuration file follows the naming pattern `*.raid.yaml` and defines the properties of a raid profileβ€”a group of repositories and their environments. + +### Single Profile Configuration + +```yaml +# yaml-language-server: $schema=schemas/raid-profile.schema.json + +name: my-project + +repositories: + - name: frontend + path: ~/Developer/frontend + url: https://github.com/myorg/frontend + + - name: backend + path: ~/Developer/backend + url: https://github.com/myorg/backend + +environments: + - name: dev + variables: + - name: NODE_ENV + value: development + - name: DATABASE_URL + value: postgresql://localhost:5432/myproject + tasks: + - type: Shell + cmd: echo "Setting up development environment..." + - type: Script + path: ./scripts/setup-dev.sh +``` + +### Multiple Profiles in a Single File + +You can define multiple profiles in a single file using YAML document separators (`---`) or JSON arrays. Each profile in the file is individually validated against the schema. + +#### YAML with Document Separators + +```yaml +# yaml-language-server: $schema=schemas/raid-profile.schema.json + +name: development +repositories: + - name: frontend + path: ~/Developer/company/frontend + url: https://github.com/company/frontend + - name: backend + path: ~/Developer/company/backend + url: https://github.com/company/backend +--- +name: personal +repositories: + - name: blog + path: ~/Developer/blog + url: https://github.com/username/blog + - name: dotfiles + path: ~/Developer/dotfiles + url: https://github.com/username/dotfiles +--- +name: open-source +repositories: + - name: raid + path: ~/Developer/raid + url: https://github.com/8bitAlex/raid +``` + +#### JSON with Arrays + +```json +[ + { + "$schema": "schemas/raid-profile.schema.json", + "name": "development", + "repositories": [ + { + "name": "frontend", + "path": "~/Developer/company/frontend", + "url": "https://github.com/company/frontend" + }, + { + "name": "backend", + "path": "~/Developer/company/backend", + "url": "https://github.com/company/backend" + } + ] + }, + { + "name": "personal", + "repositories": [ + { + "name": "blog", + "path": "~/Developer/blog", + "url": "https://github.com/username/blog" + } + ] + } +] +``` + + + +### Profile Management Features + +- **Schema Validation**: Each profile is validated against the JSON schema +- **Multiple Format Support**: YAML and JSON files are both supported +- **IDE Integration**: Use `$schema` references for autocomplete and validation + +**Note:** For detailed schema information, see the [JSON Schema Specifications](#json-schema-specifications) section. + +## Repository Configuration + +A repository configuration file named `raid.yaml` defines the properties of an individual repository. This file should be located in the root directory of a git repository. + +**Note:** Repository configurations follow the `raid-repo.schema.json` schema. See the [JSON Schema Specifications](#json-schema-specifications) section for detailed schema information. + +### Example Repository Configuration + +```yaml +# yaml-language-server: $schema=schemas/raid-repo.schema.json + +name: my-service +branch: main + +environments: + - name: dev + variables: + - name: NODE_ENV + value: development + tasks: + - type: Shell + cmd: npm install + - type: Shell + cmd: npm run build + - type: Shell + cmd: npm test +``` + +## JSON Schema Specifications + +Raid uses **JSON Schema Draft 2020-12** for configuration validation. The schema system consists of three main files: + +- **`raid-profile.schema.json`** - Main profile configuration schema +- **`raid-defs.schema.json`** - Shared definitions for environments and tasks +- **`raid-repo.schema.json`** - Individual repository configuration schema + +### Schema Validation + +All profile and repo configurations are validated against the **JSON Schema Draft 2020-12** specification. This ensures your configuration files have the correct structure and required fields. + +### IDE Integration + +For the best development experience, include schema references in your configuration files: + +```yaml +# yaml-language-server: $schema=schemas/raid-profile.schema.json +``` + +This provides: +- βœ… **Autocomplete** for field names and values +- βœ… **Real-time validation** of your configuration +- βœ… **Error highlighting** for invalid configurations +- βœ… **Documentation tooltips** for each field + +### Schema Structure Details + +#### Profile Schema (`raid-profile.schema.json`) +A raid profile configuration must contain: + +- **`name`** (string, required) - The name of the raid profile +- **`repositories`** (array, required) - Array of repository configurations + - Each repository must have: + - `name` (string, required) - The name of the repository + - `path` (string, required) - The local path to the repository + - `url` (string, required) - The URL of the repository +- **`environments`** (array, optional) - Array of environment configurations + +#### Repository Schema (`raid-repo.schema.json`) +A repository configuration must contain: + +- **`name`** (string, required) - The name of the repository +- **`branch`** (string, required) - The branch to checkout +- **`environments`** (array, optional) - Array of environment configurations (follows `raid-defs.schema.json`) + +#### Definitions Schema (`raid-defs.schema.json`) +Environments and tasks follow this shared schema: + +**Environment Schema:** +- **`name`** (string, required) - The name of the environment +- **`variables`** (array, optional) - Environment variables to set + - Each variable must have: + - `name` (string, required) - The name of the variable + - `value` (string, required) - The value of the variable +- **`tasks`** (array, optional) - Tasks to be executed + +**Task Schema:** +Tasks support two types: + +**Shell Tasks:** +```yaml +- type: Shell + cmd: echo "Hello World" + concurrent: true # Optional: execute concurrently with other tasks +``` + +**Script Tasks:** +```yaml +- type: Script + path: ./scripts/setup.sh + concurrent: false # Optional: execute sequentially +``` + +### Technical Details + +- **Schema Compatibility**: Fully compatible with JSON Schema Draft 2020-12 +- **Validation Engine**: Uses `github.com/santhosh-tekuri/jsonschema/v6` library for validation +- **File Format Support**: Both YAML and JSON files are supported +- **Multiple Profiles**: Each profile in a multi-profile file is individually validated + +## Contributing + +We welcome contributions! Please see our [Contributing Guidelines](docs/CONTRIBUTING.md) for details. + +## License + +This project is licensed under the **GNU General Public License v3.0** (GPL-3.0). + +### Key License Highlights + +**What you can do:** +- βœ… **Use** the software for any purpose +- βœ… **Study** how the software works +- βœ… **Modify** the software to suit your needs +- βœ… **Distribute** copies of the software +- βœ… **Distribute** modified versions + +**What you must do:** +- πŸ“‹ **License your modifications** under the same GPL-3.0 license +- πŸ“‹ **Include source code** when distributing the software +- πŸ“‹ **State changes** you made to the software +- πŸ“‹ **Include the license** and copyright notices + +**What you cannot do:** +- ❌ **Make the software proprietary** - modifications must remain open source +- ❌ **Remove the license** or copyright notices +- ❌ **Sublicense** under different terms + +### Full License Text + +The complete license text is available in the [LICENSE](LICENSE) file. For more information about the GNU GPL, visit [https://www.gnu.org/licenses/](https://www.gnu.org/licenses/). + +### Contributing -Feedback, issues, and contributions are welcome as the project takes shape. \ No newline at end of file +By contributing to this project, you agree that your contributions will be licensed under the same GPL-3.0 license diff --git a/docs/CONTRIBUTING.md b/docs/CONTRIBUTING.md new file mode 100644 index 0000000..ff19afc --- /dev/null +++ b/docs/CONTRIBUTING.md @@ -0,0 +1,339 @@ +# Contributing to Raid + +Thank you for your interest in contributing to Raid! This document provides guidelines and information for contributors. + +## Table of Contents + +- [Getting Started](#getting-started) +- [Development Setup](#development-setup) +- [Code Style](#code-style) +- [Testing](#testing) +- [Submitting Changes](#submitting-changes) +- [Code Review Process](#code-review-process) +- [Reporting Issues](#reporting-issues) +- [Feature Requests](#feature-requests) +- [Community Guidelines](#community-guidelines) + +## Getting Started + +### Prerequisites + +- Go 1.24 or later +- Git +- Basic understanding of Go development + +### Fork and Clone + +1. Fork the repository on GitHub +2. Clone your fork locally: + ```bash + git clone https://github.com/YOUR_USERNAME/raid.git + cd raid + ``` +3. Add the upstream remote: + ```bash + git remote add upstream https://github.com/8bitAlex/raid.git + ``` + +## Development Setup + +### Install Dependencies + +```bash +go mod download +``` + +### Build the Project + +```bash +go build -o raid ./main.go +``` + +### Run Tests + +```bash +go test ./... +``` + +### Run with Coverage + +```bash +go test -coverprofile=coverage.out ./... +go tool cover -html=coverage.out +``` + +## Code Style + +### Go Code Style + +- Follow the [Go Code Review Comments](https://github.com/golang/go/wiki/CodeReviewComments) +- Use `gofmt` to format your code +- Run `go vet` before submitting +- Use meaningful variable and function names +- Add comments for exported functions and types + +### Project Structure + +``` +src/ +β”œβ”€β”€ cmd/ # Command-line interface commands +β”œβ”€β”€ internal/ # Internal packages (not importable) +β”‚ β”œβ”€β”€ lib/ # Core library functionality +β”‚ β”œβ”€β”€ sys/ # System-specific code +β”‚ └── utils/ # Utility functions +└── raid/ # Public packages + +schemas/ # JSON Schema definitions (root level) +β”œβ”€β”€ raid-profile.schema.json +β”œβ”€β”€ raid-defs.schema.json +β”œβ”€β”€ raid-repo.schema.json +└── README.md +``` + +### Commit Messages + +Use conventional commit format: + +``` +type(scope): description +``` + +Types: +- `feat`: New feature +- `fix`: Bug fix +- `docs`: Documentation changes +- `style`: Code style changes +- `refactor`: Code refactoring +- `test`: Test changes +- `chore`: Maintenance tasks + +Examples: +``` +feat(profile): add support for multiple profile formats + +fix(install): resolve concurrent repository cloning issue + +docs(readme): update JSON schema specifications section +``` + +## Testing + +### Writing Tests + +- Write tests for new functionality +- Aim for good test coverage +- Use descriptive test names +- Test both success and error cases + +### Test Structure + +```go +func TestFunctionName(t *testing.T) { + // Arrange + input := "test input" + + // Act + result, err := FunctionName(input) + + // Assert + if err != nil { + t.Errorf("Expected no error, got %v", err) + } + if result != "expected output" { + t.Errorf("Expected 'expected output', got '%s'", result) + } +} +``` + +### Running Tests + +```bash +# Run all tests +go test ./... + +# Run tests in a specific package +go test ./src/internal/lib + +# Run tests with verbose output +go test -v ./... + +# Run tests with race detection +go test -race ./... +``` + +## Submitting Changes + +### Workflow + +1. **Create a feature branch:** + ```bash + git checkout -b feature/your-feature-name + ``` + +2. **Make your changes** and commit them with clear messages + +3. **Push to your fork:** + ```bash + git push origin feature/your-feature-name + ``` + +4. **Create a Pull Request** on GitHub + +### Pull Request Guidelines + +- **Title**: Clear, descriptive title +- **Description**: Explain what the PR does and why +- **Related Issues**: Link to any related issues +- **Testing**: Describe how you tested the changes +- **Breaking Changes**: Note any breaking changes + +### PR Template + +```markdown +## Description +Brief description of what this PR accomplishes. + +## Type of Change +- [ ] Bug fix +- [ ] New feature +- [ ] Documentation update +- [ ] Code refactoring +- [ ] Test addition/update + +## Testing +- [ ] Tests pass locally +- [ ] Added new tests for new functionality +- [ ] Updated existing tests if needed + +## Checklist +- [ ] Code follows project style guidelines +- [ ] Self-review completed +- [ ] Documentation updated if needed +- [ ] No breaking changes introduced + +## Related Issues +Closes #(issue number) +``` + +## Code Review Process + +### Review Guidelines + +- Be respectful and constructive +- Focus on the code, not the person +- Ask questions if something is unclear +- Suggest improvements when possible +- Approve only when you're satisfied + +### Review Checklist + +- [ ] Code follows project conventions +- [ ] Tests are included and pass +- [ ] Documentation is updated if needed +- [ ] No obvious bugs or issues +- [ ] Performance considerations addressed +- [ ] Security implications considered + +## Reporting Issues + +### Bug Reports + +When reporting bugs, please include: + +- **Description**: Clear description of the problem +- **Steps to Reproduce**: Detailed steps to reproduce the issue +- **Expected Behavior**: What you expected to happen +- **Actual Behavior**: What actually happened +- **Environment**: OS, Go version, Raid version +- **Additional Context**: Any other relevant information + +### Issue Template + +```markdown +## Bug Description +[Clear description of the bug] + +## Steps to Reproduce +1. [Step 1] +2. [Step 2] +3. [Step 3] + +## Expected Behavior +[What you expected to happen] + +## Actual Behavior +[What actually happened] + +## Environment +- OS: [e.g., macOS 14.0, Ubuntu 22.04] +- Go Version: [e.g., go version go1.21.0 darwin/amd64] +- Raid Version: [e.g., 1.0.0-Alpha] + +## Additional Context +[Any other context about the problem] +``` + +## Feature Requests + +### Feature Request Guidelines + +- **Clear Description**: Explain what you want to achieve +- **Use Case**: Describe the problem this feature would solve +- **Proposed Solution**: Suggest how it might be implemented +- **Alternatives**: Consider if there are existing ways to achieve this + +### Feature Request Template + +```markdown +## Feature Description +[Clear description of the feature you're requesting] + +## Problem Statement +[Describe the problem this feature would solve] + +## Proposed Solution +[Describe your proposed solution] + +## Alternatives Considered +[Describe any alternatives you've considered] + +## Additional Context +[Any other context about the feature request] +``` + +## Community Guidelines + +### Code of Conduct + +- Be respectful and inclusive +- Welcome newcomers +- Focus on constructive feedback +- Help others learn and grow + +### Communication + +- **GitHub Issues**: For bugs and feature requests +- **GitHub Discussions**: For questions and general discussion +- **Pull Requests**: For code contributions + +### Getting Help + +- Check existing documentation first +- Search existing issues and discussions +- Ask questions in GitHub Discussions +- Be patient and respectful + +## License + +By contributing to Raid, you agree that your contributions will be licensed under the same [GNU General Public License v3.0](LICENSE) that covers the project. + +## Recognition + +Contributors will be recognized in: +- Project README +- Release notes +- Contributor statistics + +--- + +Thank you for contributing to Raid! Your contributions help make this project better for everyone. diff --git a/docs/examples/env-demo.raid.yaml b/docs/examples/env-demo.raid.yaml new file mode 100644 index 0000000..e8bd282 --- /dev/null +++ b/docs/examples/env-demo.raid.yaml @@ -0,0 +1,71 @@ +# Example profile demonstrating environment execution +name: env-demo + +repositories: + - name: frontend + path: ~/Developer/env-demo-frontend + url: https://github.com/8bitAlex/raid.git + - name: backend + path: ~/Developer/env-demo-backend + url: https://github.com/8bitAlex/raid.git + +environments: + - name: dev + variables: + - name: NODE_ENV + value: development + - name: DATABASE_URL + value: postgresql://localhost:5432/dev_db + - name: API_URL + value: http://localhost:3000 + tasks: + - type: Shell + cmd: echo "Setting up development environment..." + - type: Shell + cmd: echo "NODE_ENV=$NODE_ENV" + - type: Shell + cmd: echo "DATABASE_URL=$DATABASE_URL" + - type: Shell + cmd: echo "API_URL=$API_URL" + - type: Script + path: ./scripts/setup-dev.sh + + - name: prod + variables: + - name: NODE_ENV + value: production + - name: DATABASE_URL + value: postgresql://prod-server:5432/prod_db + - name: API_URL + value: https://api.example.com + tasks: + - type: Shell + cmd: echo "Setting up production environment..." + - type: Shell + cmd: echo "NODE_ENV=$NODE_ENV" + - type: Shell + cmd: echo "DATABASE_URL=$DATABASE_URL" + - type: Shell + cmd: echo "API_URL=$API_URL" + - type: Script + path: ./scripts/setup-prod.sh + + - name: test + variables: + - name: NODE_ENV + value: test + - name: DATABASE_URL + value: postgresql://localhost:5432/test_db + - name: API_URL + value: http://localhost:3001 + tasks: + - type: Shell + cmd: echo "Setting up test environment..." + - type: Shell + cmd: echo "NODE_ENV=$NODE_ENV" + - type: Shell + cmd: echo "DATABASE_URL=$DATABASE_URL" + - type: Shell + cmd: echo "API_URL=$API_URL" + - type: Script + path: ./scripts/setup-test.sh diff --git a/docs/examples/example.raid.yaml b/docs/examples/example.raid.yaml new file mode 100644 index 0000000..a2295e0 --- /dev/null +++ b/docs/examples/example.raid.yaml @@ -0,0 +1,49 @@ +# yaml-language-server: $schema=../../schemas/raid-profile.schema.json + +name: raid +repositories: + - name: raid + path: ~/Developer/raid-test + url: https://github.com/8bitAlex/raid.git + - name: raid-2 + path: ~/Developer/raid-test-2 + url: https://github.com/8bitAlex/raid.git + +environments: + - name: dev + tasks: + - type: Shell + cmd: echo "Hello, world! - From profile" + - type: Script + path: ./hello.sh + variables: + - name: NODE_ENV + value: development + - name: DATABASE_URL + value: postgresql://localhost:5432/dev_db + - name: API_URL + value: https://api.example.com + +--- + +name: example2 +repositories: + - name: raid + path: ~/Developer/raid + url: https://github.com/8bitAlex/raid.git + +--- + +name: example3 +repositories: + - name: raid + path: ~/Developer/raid + url: https://github.com/8bitAlex/raid.git + +--- + +name: example4 +repositories: + - name: raid + path: ~/Developer/raid + url: https://github.com/8bitAlex/raid.git \ No newline at end of file diff --git a/docs/examples/hello.sh b/docs/examples/hello.sh new file mode 100755 index 0000000..8e8fe0a --- /dev/null +++ b/docs/examples/hello.sh @@ -0,0 +1 @@ +echo "Hello, world!" \ No newline at end of file diff --git a/docs/examples/install-demo.raid.yaml b/docs/examples/install-demo.raid.yaml new file mode 100644 index 0000000..e73e53e --- /dev/null +++ b/docs/examples/install-demo.raid.yaml @@ -0,0 +1,14 @@ +# Example profile for demonstrating the install command +# This profile defines repositories that can be cloned + +name: install-demo +repositories: + - name: frontend + path: ~/Developer/demo-frontend + url: https://github.com/8bitAlex/raid.git + - name: backend + path: ~/Developer/demo-backend + url: https://github.com/8bitAlex/raid.git + - name: shared-libs + path: ~/Developer/demo-shared + url: https://github.com/8bitAlex/raid.git diff --git a/docs/examples/multiple-profiles.json b/docs/examples/multiple-profiles.json new file mode 100644 index 0000000..fc6a403 --- /dev/null +++ b/docs/examples/multiple-profiles.json @@ -0,0 +1,52 @@ +[ + { + "name": "development", + "repositories": [ + { + "name": "frontend", + "path": "~/Developer/frontend", + "url": "https://github.com/company/frontend.git" + }, + { + "name": "backend", + "path": "~/Developer/backend", + "url": "https://github.com/company/backend.git" + }, + { + "name": "shared-libs", + "path": "~/Developer/shared-libs", + "url": "https://github.com/company/shared-libs.git" + } + ] + }, + { + "name": "personal", + "repositories": [ + { + "name": "blog", + "path": "~/Developer/blog", + "url": "https://github.com/username/blog.git" + }, + { + "name": "dotfiles", + "path": "~/Developer/dotfiles", + "url": "https://github.com/username/dotfiles.git" + } + ] + }, + { + "name": "open-source", + "repositories": [ + { + "name": "raid", + "path": "~/Developer/raid", + "url": "https://github.com/8bitAlex/raid.git" + }, + { + "name": "other-project", + "path": "~/Developer/other-project", + "url": "https://github.com/username/other-project.git" + } + ] + } +] diff --git a/docs/examples/multiple-profiles.yaml b/docs/examples/multiple-profiles.yaml new file mode 100644 index 0000000..e6552d2 --- /dev/null +++ b/docs/examples/multiple-profiles.yaml @@ -0,0 +1,32 @@ +# Example YAML file with multiple profiles using document separators +# Each profile is separated by "---" + +name: development +repositories: + - name: frontend + path: ~/Developer/frontend + url: https://github.com/company/frontend.git + - name: backend + path: ~/Developer/backend + url: https://github.com/company/backend.git + - name: shared-libs + path: ~/Developer/shared-libs + url: https://github.com/company/shared-libs.git +--- +name: personal +repositories: + - name: blog + path: ~/Developer/blog + url: https://github.com/username/blog.git + - name: dotfiles + path: ~/Developer/dotfiles + url: https://github.com/username/dotfiles.git +--- +name: open-source +repositories: + - name: raid + path: ~/Developer/raid + url: https://github.com/8bitAlex/raid.git + - name: other-project + path: ~/Developer/other-project + url: https://github.com/username/other-project.git diff --git a/docs/examples/raid.yaml b/docs/examples/raid.yaml new file mode 100644 index 0000000..b192f89 --- /dev/null +++ b/docs/examples/raid.yaml @@ -0,0 +1,12 @@ +# yaml-language-server: $schema=../../schemas/raid-repo.schema.json + +name: raid +branch: main + +environments: + - name: dev + tasks: + - type: Shell + cmd: echo "Hello, world!" + - type: Script + path: ./scripts/hello.sh \ No newline at end of file diff --git a/go.mod b/go.mod new file mode 100644 index 0000000..4731129 --- /dev/null +++ b/go.mod @@ -0,0 +1,26 @@ +module github.com/8bitalex/raid + +go 1.24.4 + +require ( + github.com/mitchellh/go-homedir v1.1.0 + github.com/santhosh-tekuri/jsonschema/v6 v6.0.2 + github.com/spf13/cobra v1.9.1 + github.com/spf13/viper v1.20.1 + gopkg.in/yaml.v3 v3.0.1 +) + +require ( + github.com/fsnotify/fsnotify v1.9.0 // indirect + github.com/go-viper/mapstructure/v2 v2.4.0 // indirect + github.com/inconshreveable/mousetrap v1.1.0 // indirect + github.com/pelletier/go-toml/v2 v2.2.4 // indirect + github.com/sagikazarmark/locafero v0.10.0 // indirect + github.com/sourcegraph/conc v0.3.1-0.20240121214520-5f936abd7ae8 // indirect + github.com/spf13/afero v1.14.0 // indirect + github.com/spf13/cast v1.9.2 // indirect + github.com/spf13/pflag v1.0.7 // indirect + github.com/subosito/gotenv v1.6.0 // indirect + golang.org/x/sys v0.35.0 // indirect + golang.org/x/text v0.28.0 // indirect +) diff --git a/go.sum b/go.sum new file mode 100644 index 0000000..9dffe49 --- /dev/null +++ b/go.sum @@ -0,0 +1,58 @@ +github.com/cpuguy83/go-md2man/v2 v2.0.6/go.mod h1:oOW0eioCTA6cOiMLiUPZOpcVxMig6NIQQ7OS05n1F4g= +github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= +github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/dlclark/regexp2 v1.11.0 h1:G/nrcoOa7ZXlpoa/91N3X7mM3r8eIlMBBJZvsz/mxKI= +github.com/dlclark/regexp2 v1.11.0/go.mod h1:DHkYz0B9wPfa6wondMfaivmHpzrQ3v9q8cnmRbL6yW8= +github.com/frankban/quicktest v1.14.6 h1:7Xjx+VpznH+oBnejlPUj8oUpdxnVs4f8XU8WnHkI4W8= +github.com/frankban/quicktest v1.14.6/go.mod h1:4ptaffx2x8+WTWXmUCuVU6aPUX1/Mz7zb5vbUoiM6w0= +github.com/fsnotify/fsnotify v1.9.0 h1:2Ml+OJNzbYCTzsxtv8vKSFD9PbJjmhYF14k/jKC7S9k= +github.com/fsnotify/fsnotify v1.9.0/go.mod h1:8jBTzvmWwFyi3Pb8djgCCO5IBqzKJ/Jwo8TRcHyHii0= +github.com/go-viper/mapstructure/v2 v2.4.0 h1:EBsztssimR/CONLSZZ04E8qAkxNYq4Qp9LvH92wZUgs= +github.com/go-viper/mapstructure/v2 v2.4.0/go.mod h1:oJDH3BJKyqBA2TXFhDsKDGDTlndYOZ6rGS0BRZIxGhM= +github.com/google/go-cmp v0.6.0 h1:ofyhxvXcZhMsU5ulbFiLKl/XBFqE1GSq7atu8tAmTRI= +github.com/google/go-cmp v0.6.0/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY= +github.com/inconshreveable/mousetrap v1.1.0 h1:wN+x4NVGpMsO7ErUn/mUI3vEoE6Jt13X2s0bqwp9tc8= +github.com/inconshreveable/mousetrap v1.1.0/go.mod h1:vpF70FUmC8bwa3OWnCshd2FqLfsEA9PFc4w1p2J65bw= +github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE= +github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk= +github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY= +github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE= +github.com/mitchellh/go-homedir v1.1.0 h1:lukF9ziXFxDFPkA1vsr5zpc1XuPDn/wFntq5mG+4E0Y= +github.com/mitchellh/go-homedir v1.1.0/go.mod h1:SfyaCUpYCn1Vlf4IUYiD9fPX4A5wJrkLzIz1N1q0pr0= +github.com/pelletier/go-toml/v2 v2.2.4 h1:mye9XuhQ6gvn5h28+VilKrrPoQVanw5PMw/TB0t5Ec4= +github.com/pelletier/go-toml/v2 v2.2.4/go.mod h1:2gIqNv+qfxSVS7cM2xJQKtLSTLUE9V8t9Stt+h56mCY= +github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= +github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= +github.com/rogpeppe/go-internal v1.9.0 h1:73kH8U+JUqXU8lRuOHeVHaa/SZPifC7BkcraZVejAe8= +github.com/rogpeppe/go-internal v1.9.0/go.mod h1:WtVeX8xhTBvf0smdhujwtBcq4Qrzq/fJaraNFVN+nFs= +github.com/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM= +github.com/sagikazarmark/locafero v0.10.0 h1:FM8Cv6j2KqIhM2ZK7HZjm4mpj9NBktLgowT1aN9q5Cc= +github.com/sagikazarmark/locafero v0.10.0/go.mod h1:Ieo3EUsjifvQu4NZwV5sPd4dwvu0OCgEQV7vjc9yDjw= +github.com/santhosh-tekuri/jsonschema/v6 v6.0.2 h1:KRzFb2m7YtdldCEkzs6KqmJw4nqEVZGK7IN2kJkjTuQ= +github.com/santhosh-tekuri/jsonschema/v6 v6.0.2/go.mod h1:JXeL+ps8p7/KNMjDQk3TCwPpBy0wYklyWTfbkIzdIFU= +github.com/sourcegraph/conc v0.3.1-0.20240121214520-5f936abd7ae8 h1:+jumHNA0Wrelhe64i8F6HNlS8pkoyMv5sreGx2Ry5Rw= +github.com/sourcegraph/conc v0.3.1-0.20240121214520-5f936abd7ae8/go.mod h1:3n1Cwaq1E1/1lhQhtRK2ts/ZwZEhjcQeJQ1RuC6Q/8U= +github.com/spf13/afero v1.14.0 h1:9tH6MapGnn/j0eb0yIXiLjERO8RB6xIVZRDCX7PtqWA= +github.com/spf13/afero v1.14.0/go.mod h1:acJQ8t0ohCGuMN3O+Pv0V0hgMxNYDlvdk+VTfyZmbYo= +github.com/spf13/cast v1.9.2 h1:SsGfm7M8QOFtEzumm7UZrZdLLquNdzFYfIbEXntcFbE= +github.com/spf13/cast v1.9.2/go.mod h1:jNfB8QC9IA6ZuY2ZjDp0KtFO2LZZlg4S/7bzP6qqeHo= +github.com/spf13/cobra v1.9.1 h1:CXSaggrXdbHK9CF+8ywj8Amf7PBRmPCOJugH954Nnlo= +github.com/spf13/cobra v1.9.1/go.mod h1:nDyEzZ8ogv936Cinf6g1RU9MRY64Ir93oCnqb9wxYW0= +github.com/spf13/pflag v1.0.6/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg= +github.com/spf13/pflag v1.0.7 h1:vN6T9TfwStFPFM5XzjsvmzZkLuaLX+HS+0SeFLRgU6M= +github.com/spf13/pflag v1.0.7/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg= +github.com/spf13/viper v1.20.1 h1:ZMi+z/lvLyPSCoNtFCpqjy0S4kPbirhpTMwl8BkW9X4= +github.com/spf13/viper v1.20.1/go.mod h1:P9Mdzt1zoHIG8m2eZQinpiBjo6kCmZSKBClNNqjJvu4= +github.com/stretchr/testify v1.10.0 h1:Xv5erBjTwe/5IxqUQTdXv5kgmIvbHo3QQyRwhJsOfJA= +github.com/stretchr/testify v1.10.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY= +github.com/subosito/gotenv v1.6.0 h1:9NlTDc1FTs4qu0DDq7AEtTPNw6SVm7uBMsUCUjABIf8= +github.com/subosito/gotenv v1.6.0/go.mod h1:Dk4QP5c2W3ibzajGcXpNraDfq2IrhjMIvMSWPKKo0FU= +golang.org/x/sys v0.35.0 h1:vz1N37gP5bs89s7He8XuIYXpyY0+QlsKmzipCbUtyxI= +golang.org/x/sys v0.35.0/go.mod h1:BJP2sWEmIv4KK5OTEluFJCKSidICx8ciO85XgH3Ak8k= +golang.org/x/text v0.28.0 h1:rhazDwis8INMIwQ4tpjLDzUhx6RlXqZNPEM0huQojng= +golang.org/x/text v0.28.0/go.mod h1:U8nCwOR8jO/marOQ0QbDiOngZVEBB7MAiitBuMjXiNU= +gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= +gopkg.in/check.v1 v1.0.0-20190902080502-41f04d3bba15 h1:YR8cESwS4TdDjEe65xsg0ogRM/Nc3DYOhEAlW+xobZo= +gopkg.in/check.v1 v1.0.0-20190902080502-41f04d3bba15/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= +gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= +gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= diff --git a/main.go b/main.go new file mode 100644 index 0000000..c09031a --- /dev/null +++ b/main.go @@ -0,0 +1,8 @@ +package main + +import "github.com/8bitalex/raid/src/cmd" + +// this is just a prototype so don't judge me too harshly +func main() { + cmd.Execute() +} diff --git a/raid.yaml b/raid.yaml new file mode 100644 index 0000000..c34d3b3 --- /dev/null +++ b/raid.yaml @@ -0,0 +1,12 @@ +# yaml-language-server: $schema=./schemas/raid-repo.schema.json + +name: raid +branch: main + +environments: + - name: dev + tasks: + - type: Shell + cmd: echo "Hello, world! - From raid" + - type: Script + path: ./docs/examples/hello.sh \ No newline at end of file diff --git a/schemas/README.md b/schemas/README.md new file mode 100644 index 0000000..4996686 --- /dev/null +++ b/schemas/README.md @@ -0,0 +1,51 @@ +# Raid Schema Specifications + +This directory contains JSON Schema definitions for Raid configuration files. + +## Schema Files + +- `raid-profile.schema.json` - Schema for raid profile configuration files +- `raid-repo.schema.json` - Schema for individual repository configuration files + +## Schema Version + +These schemas follow the **JSON Schema Draft 2020-12** specification and are compatible with the `github.com/santhosh-tekuri/jsonschema/v6` library (v6.0.2). + +The `$schema` field is properly supported and the library fully validates against the JSON Schema Draft 2020-12 specification. + +## Usage + +The schemas are used to validate YAML and JSON configuration files in the Raid CLI tool. The validation ensures that configuration files have the correct structure and required fields. + +### Supported File Formats + +- YAML files (`.yaml`, `.yml`) +- JSON files (`.json`) + +### Validation + +Profile files are validated against `raid-profile.schema.json` when using the `raid profile add` command. The validation checks: + +- Required fields are present +- Data types are correct +- Structure matches the schema definition + +## Schema Structure + +### Raid Profile Schema + +A raid profile configuration must contain: + +- `name` (string, required) - The name of the raid profile +- `repositories` (array, optional) - Array of repository configurations + - Each repository must have: + - `name` (string, required) - The name of the repository + - `path` (string, required) - The local path to the repository + - `url` (string, required) - The URL of the repository + +### Raid Repository Schema + +A repository configuration must contain: + +- `name` (string, required) - The name of the repository +- `branch` (string, required) - The branch to checkout diff --git a/schemas/raid-defs.schema.json b/schemas/raid-defs.schema.json new file mode 100644 index 0000000..a33f504 --- /dev/null +++ b/schemas/raid-defs.schema.json @@ -0,0 +1,101 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "$id": "raid-defs.schema.json", + "title": "Raid Schema Definitions", + "description": "Shared schema definitions for raid", + "type": "object", + "additionalProperties": false, + "properties": { + "tasks": { + "type": "array", + "description": "Tasks to be executed", + "minItems": 1, + "items": { + "type": "object", + "oneOf": [ + { + "type": "object", + "properties": { + "type": { + "type": "string", + "enum": ["Shell"] + }, + "cmd": { + "type": "string", + "description": "Shell command to execute" + }, + "concurrent": { + "ref": "#/concurrent" + } + }, + "required": ["type", "cmd"], + "additionalProperties": false + }, + { + "type": "object", + "properties": { + "type": { + "type": "string", + "enum": ["Script"] + }, + "path": { + "type": "string", + "description": "Path to the script file" + }, + "concurrent": { + "ref": "#/concurrent" + } + }, + "required": ["type", "path"], + "additionalProperties": false + } + ] + } + }, + "environments": { + "type": "array", + "description": "The environments to include in the raid profile", + "minItems": 1, + "items": { + "type": "object", + "properties": { + "name": { + "type": "string", + "description": "The name of the environment" + }, + "tasks": { + "$ref": "#/properties/tasks" + }, + "variables": { + "type": "array", + "description": "Environment variables to set", + "minItems": 1, + "items": { + "type": "object", + "description": "Environment variables to set", + "properties": { + "name": { + "type": "string", + "description": "The name of the variable" + }, + "value": { + "type": "string", + "description": "The value of the variable" + } + }, + "required": ["name", "value"], + "additionalProperties": false + } + } + }, + "required": ["name"] + } + } + }, + "$defs": { + "concurrent": { + "type": "boolean", + "description": "Whether to execute the task concurrently with other tasks" + } + } +} \ No newline at end of file diff --git a/schemas/raid-profile.schema.json b/schemas/raid-profile.schema.json new file mode 100644 index 0000000..14fcbae --- /dev/null +++ b/schemas/raid-profile.schema.json @@ -0,0 +1,42 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "$id": "raid-profile.schema.json", + "title": "Raid Profile Configuration", + "description": "Configuration for one or more raid profiles.", + "type": "object", + "additionalProperties": false, + "properties": { + "name": { + "type": "string", + "description": "The name of the raid profile" + }, + "repositories": { + "type": "array", + "description": "The repositories to include in the raid profile", + "items": { + "type": "object", + "properties": { + "name": { + "type": "string", + "description": "The name of the repository" + }, + "path": { + "type": "string", + "description": "The local path to the repository" + }, + "url": { + "type": "string", + "description": "The URL of the repository" + } + }, + "required": ["name", "path", "url"] + }, + "minItems": 1 + }, + "environments": { + "$ref": "raid-defs.schema.json#/properties/environments" + } + }, + "required": ["name"] +} + diff --git a/schemas/raid-repo.schema.json b/schemas/raid-repo.schema.json new file mode 100644 index 0000000..f1e2ec5 --- /dev/null +++ b/schemas/raid-repo.schema.json @@ -0,0 +1,22 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "$id": "raid-repo.schema.json", + "title": "Raid Repository Configuration", + "description": "Configuration for a single repository.", + "type": "object", + "additionalProperties": false, + "properties": { + "name": { + "type": "string", + "description": "The name of the repository" + }, + "branch": { + "type": "string", + "description": "The branch to checkout" + }, + "environments": { + "$ref": "raid-defs.schema.json#/properties/environments" + } + }, + "required": ["name","branch"] +} \ No newline at end of file diff --git a/src/cmd/env/env.go b/src/cmd/env/env.go new file mode 100644 index 0000000..172fda4 --- /dev/null +++ b/src/cmd/env/env.go @@ -0,0 +1,23 @@ +package env + +import ( + "github.com/spf13/cobra" +) + +var ( + concurrency int +) + +func init() { + Command.Flags().IntVarP(&concurrency, "threads", "t", 0, "Maximum number of concurrent task executions (0 = unlimited)") +} + +var Command = &cobra.Command{ + Use: "env [environment-name]", + Short: "Execute an environment", + Long: "Execute an environment by name. The environment will be searched for in the active profile and all repository configurations. Tasks are executed concurrently and environment variables are set globally.", + Args: cobra.ExactArgs(1), + Run: func(cmd *cobra.Command, args []string) { + + }, +} diff --git a/src/cmd/install/install.go b/src/cmd/install/install.go new file mode 100644 index 0000000..cce625d --- /dev/null +++ b/src/cmd/install/install.go @@ -0,0 +1,28 @@ +package install + +import ( + "log" + + "github.com/8bitalex/raid/src/raid" + "github.com/spf13/cobra" +) + +var ( + maxThreads int = 0 +) + +func init() { + Command.Flags().IntVarP(&maxThreads, "threads", "t", 0, "Maximum number of concurrent threads (0 = unlimited)") +} + +var Command = &cobra.Command{ + Use: "install", + Short: "Install the active profile", + Long: "Clones all repositories defined in the active profile to their specified paths. If a repository already exists, it will be skipped. Repositories are cloned concurrently for better performance.", + Args: cobra.NoArgs, + Run: func(cmd *cobra.Command, args []string) { + if err := raid.Install(maxThreads); err != nil { + log.Fatalf("Installation failed: %v\n", err) + } + }, +} diff --git a/src/cmd/profile/add.go b/src/cmd/profile/add.go new file mode 100644 index 0000000..e38c63b --- /dev/null +++ b/src/cmd/profile/add.go @@ -0,0 +1,73 @@ +package profile + +import ( + "fmt" + "os" + "strings" + + "github.com/8bitalex/raid/src/internal/sys" + pro "github.com/8bitalex/raid/src/raid/profile" + "github.com/spf13/cobra" +) + +var AddProfileCmd = &cobra.Command{ + Use: "add filepath", + Short: "Add profile(s) from YAML or JSON file", + Args: cobra.ExactArgs(1), + Run: func(cmd *cobra.Command, args []string) { + path := args[0] + + if !sys.FileExists(path) { + fmt.Printf("File '%s' does not exist\n", path) + os.Exit(1) + } + + if err := pro.Validate(path); err != nil { + fmt.Printf("Invalid Profile: %v\n", err) + os.Exit(1) + } + + profiles, err := pro.Unmarshal(path) + if err != nil { + fmt.Printf("Failed to extract profiles: %v\n", err) + os.Exit(1) + } + + var newProfiles []pro.Profile + var existingNames []string + for _, profile := range profiles { + if exists := pro.Contains(profile.Name); exists { + existingNames = append(existingNames, profile.Name) + } else { + newProfiles = append(newProfiles, profile) + } + } + + if len(existingNames) > 0 { + fmt.Printf("Profiles already exist with names:\n\t%s\n\n", strings.Join(existingNames, ",\n\t")) + } + + if len(newProfiles) == 0 { + fmt.Printf("No new profiles found in %s\n", path) + os.Exit(0) + } + + pro.AddAll(newProfiles) + + if pro.Get().IsZero() { + pro.Set(newProfiles[0].Name) + fmt.Printf("Profile '%s' set as active\n", newProfiles[0].Name) + } + + if len(newProfiles) == 1 { + fmt.Printf("Profile '%s' has been successfully added from %s\n", newProfiles[0].Name, path) + } else { + names := make([]string, 0, len(newProfiles)) + for _, profile := range newProfiles { + names = append(names, profile.Name) + } + fmt.Printf("Profiles:\n\t%s\nhave been successfully added from %s\n", strings.Join(names, ",\n\t"), path) + } + fmt.Print() + }, +} diff --git a/src/cmd/profile/list.go b/src/cmd/profile/list.go new file mode 100644 index 0000000..ad44a84 --- /dev/null +++ b/src/cmd/profile/list.go @@ -0,0 +1,32 @@ +package profile + +import ( + "fmt" + + pro "github.com/8bitalex/raid/src/raid/profile" + "github.com/spf13/cobra" +) + +var ListProfileCmd = &cobra.Command{ + Use: "list", + Short: "List profiles", + Run: func(cmd *cobra.Command, args []string) { + profiles := pro.GetAll() + activeProfile := pro.Get() + + if len(profiles) == 0 { + fmt.Println("No profiles found.") + return + } + + fmt.Println("Available profiles:") + for _, profile := range profiles { + activeIndicator := "" + if profile.Name == activeProfile.Name { + activeIndicator = " (active)" + } + fmt.Printf("\t%s%s\t%s\n", profile.Name, activeIndicator, profile.Path) + } + fmt.Print() + }, +} diff --git a/src/cmd/profile/profile.go b/src/cmd/profile/profile.go new file mode 100644 index 0000000..ed72eb1 --- /dev/null +++ b/src/cmd/profile/profile.go @@ -0,0 +1,30 @@ +package profile + +import ( + "fmt" + + pro "github.com/8bitalex/raid/src/raid/profile" + "github.com/spf13/cobra" +) + +func init() { + Command.AddCommand(AddProfileCmd) + Command.AddCommand(ListProfileCmd) + Command.AddCommand(UseProfileCmd) + Command.AddCommand(RemoveProfileCmd) +} + +var Command = &cobra.Command{ + Use: "profile", + Aliases: []string{"p"}, + Short: "Manage raid profiles", + Args: cobra.NoArgs, + Run: func(cmd *cobra.Command, args []string) { + profile := pro.Get() + if !profile.IsZero() { + fmt.Println(profile.Name) + } else { + fmt.Println("No active profile found. Use 'raid profile use ' to set one.") + } + }, +} diff --git a/src/cmd/profile/remove.go b/src/cmd/profile/remove.go new file mode 100644 index 0000000..61be14f --- /dev/null +++ b/src/cmd/profile/remove.go @@ -0,0 +1,27 @@ +package profile + +import ( + "fmt" + + pro "github.com/8bitalex/raid/src/raid/profile" + "github.com/spf13/cobra" +) + +var RemoveProfileCmd = &cobra.Command{ + Use: "remove profile", + Short: "Remove profile(s)", + SuggestFor: []string{"delete"}, + Args: cobra.MinimumNArgs(1), + Run: func(cmd *cobra.Command, args []string) { + + for _, name := range args { + if err := pro.Remove(name); err != nil { + fmt.Printf("Profile '%s' not found. Use 'raid profile list' to see available profiles.\n", name) + } else { + fmt.Printf("Profile '%s' has been removed.\n", name) + } + } + + fmt.Print() + }, +} diff --git a/src/cmd/profile/use.go b/src/cmd/profile/use.go new file mode 100644 index 0000000..380a87c --- /dev/null +++ b/src/cmd/profile/use.go @@ -0,0 +1,26 @@ +package profile + +import ( + "fmt" + "os" + + pro "github.com/8bitalex/raid/src/raid/profile" + "github.com/spf13/cobra" +) + +var UseProfileCmd = &cobra.Command{ + Use: "use profile", + Short: "Use a specific profile", + SuggestFor: []string{"set"}, + Args: cobra.ExactArgs(1), + Run: func(cmd *cobra.Command, args []string) { + name := args[0] + + if err := pro.Set(name); err != nil { + fmt.Printf("Profile '%s' not found. Use 'raid profile list' to see available profiles.\n", name) + os.Exit(1) + } + fmt.Printf("Profile '%s' is now active.\n", name) + fmt.Print() + }, +} diff --git a/src/cmd/raid.go b/src/cmd/raid.go new file mode 100644 index 0000000..95bbe9e --- /dev/null +++ b/src/cmd/raid.go @@ -0,0 +1,35 @@ +package cmd + +import ( + "log" + + "github.com/8bitalex/raid/src/cmd/env" + "github.com/8bitalex/raid/src/cmd/install" + "github.com/8bitalex/raid/src/cmd/profile" + "github.com/8bitalex/raid/src/raid" + "github.com/spf13/cobra" +) + +var rootCmd = &cobra.Command{ + Use: "raid", + Version: "1.0.0-Alpha", + Short: "Raid is a tool for orchestrating common tasks across your development environment(s).", + Long: `Raid is a configurable command-line application that orchestrates common development tasks, environments, and dependencies across distributed code repositories.`, + Args: cobra.NoArgs, +} + +func init() { + cobra.OnInitialize(raid.Initialize) + // Global Flags + rootCmd.PersistentFlags().StringVarP(raid.ConfigPath, raid.ConfigPathFlag, raid.ConfigPathFlagShort, "", raid.ConfigPathFlagDesc) + // Subcommands + rootCmd.AddCommand(profile.Command) + rootCmd.AddCommand(install.Command) + rootCmd.AddCommand(env.Command) +} + +func Execute() { + if err := rootCmd.Execute(); err != nil { + log.Fatalf("Failed to execute root command: %v", err) + } +} diff --git a/src/internal/lib/config.go b/src/internal/lib/config.go new file mode 100644 index 0000000..5136c1a --- /dev/null +++ b/src/internal/lib/config.go @@ -0,0 +1,44 @@ +package lib + +import ( + sys "github.com/8bitalex/raid/src/internal/sys" + "github.com/spf13/viper" +) + +var CfgPath string + +var defaultConfigPath = sys.GetHomeDir() + sys.Sep + ConfigDirName + sys.Sep + +func InitConfig() error { + viper.SetConfigFile(getOrCreateConfigFile()) + if err := viper.ReadInConfig(); err != nil { + return err + } + return nil +} + +func getOrCreateConfigFile() string { + path := getPath() + if !sys.FileExists(path) { + sys.CreateFile(path) + } + return path +} + +func getPath() string { + if CfgPath == "" { + CfgPath = defaultConfigPath + ConfigFileName + } + return CfgPath +} + +func Set(key string, value any) { + viper.Set(key, value) + Write() +} + +func Write() { + if err := viper.WriteConfig(); err != nil { + panic(err) + } +} diff --git a/src/internal/lib/env.go b/src/internal/lib/env.go new file mode 100644 index 0000000..43f5bce --- /dev/null +++ b/src/internal/lib/env.go @@ -0,0 +1,126 @@ +package lib + +// import ( +// "fmt" +// "os" +// "path/filepath" +// "strings" +// ) + +// // EnvironmentManager handles environment execution +// type EnvironmentManager struct { +// taskRunner *TaskRunner +// } + +// // NewEnvironmentManager creates a new environment manager +// func NewEnvironmentManager(concurrency int) *EnvironmentManager { +// return &EnvironmentManager{ +// taskRunner: NewTaskRunner(concurrency), +// } +// } + +// // ExecuteEnvironment executes an environment by name +// func (em *EnvironmentManager) ExecuteEnvironment(envName string) error { +// // Get the active profile +// profile, err := GetActiveProfileContent() +// if err != nil { +// return fmt.Errorf("failed to get active profile: %w", err) +// } + +// fmt.Printf("Executing environment '%s' for profile '%s'\n", envName, profile.Name) + +// // Find and execute environment from profile first +// profileEnv, found := em.findEnvironmentInProfile(profile, envName) +// if found { +// fmt.Println("Found environment in profile, executing...") +// if err := em.executeEnvironment(profileEnv); err != nil { +// return fmt.Errorf("failed to execute profile environment: %w", err) +// } +// } + +// // Find and execute environments from repositories +// for _, repo := range profile.Repositories { +// fmt.Printf("Checking repository '%s' for environment '%s'\n", repo.Name, envName) + +// repoEnv, found := em.findEnvironmentInRepository(repo, envName) +// if found { +// fmt.Printf("Found environment in repository '%s', executing...\n", repo.Name) +// if err := em.executeEnvironment(repoEnv); err != nil { +// return fmt.Errorf("failed to execute repository environment '%s': %w", repo.Name, err) +// } +// } +// } + +// return nil +// } + +// // findEnvironmentInProfile finds an environment in the profile +// func (em *EnvironmentManager) findEnvironmentInProfile(profile *ProfileContent, envName string) (*Environment, bool) { +// for _, env := range profile.Environments { +// if strings.EqualFold(env.Name, envName) { +// return &env, true +// } +// } +// return nil, false +// } + +// // findEnvironmentInRepository finds an environment in a repository +// func (em *EnvironmentManager) findEnvironmentInRepository(repo Repository, envName string) (*Environment, bool) { +// // Read the repository configuration file +// repoConfigPath := filepath.Join(repo.Path, "raid.yaml") +// if _, err := os.Stat(repoConfigPath); os.IsNotExist(err) { +// // Try raid.yml +// repoConfigPath = filepath.Join(repo.Path, "raid.yml") +// if _, err := os.Stat(repoConfigPath); os.IsNotExist(err) { +// // Try raid.json +// repoConfigPath = filepath.Join(repo.Path, "raid.json") +// if _, err := os.Stat(repoConfigPath); os.IsNotExist(err) { +// return nil, false +// } +// } +// } + +// // Read and parse the repository configuration +// repoProfile, err := ReadProfileFile(repoConfigPath) +// if err != nil { +// fmt.Printf("Warning: failed to read repository config %s: %v\n", repoConfigPath, err) +// return nil, false +// } + +// // Look for the environment in the repository +// for _, env := range repoProfile.Environments { +// if strings.EqualFold(env.Name, envName) { +// return &env, true +// } +// } + +// return nil, false +// } + +// // executeEnvironment executes an environment +// func (em *EnvironmentManager) executeEnvironment(env *Environment) error { +// fmt.Printf("Executing environment '%s'\n", env.Name) + +// // Set environment variables first +// if err := em.setEnvironmentVariables(env.Variables); err != nil { +// return fmt.Errorf("failed to set environment variables: %w", err) +// } + +// // Execute tasks +// if err := em.taskRunner.ExecuteTasks(env.Tasks); err != nil { +// return fmt.Errorf("failed to execute tasks: %w", err) +// } + +// return nil +// } + +// // setEnvironmentVariables sets the environment variables globally +// func (em *EnvironmentManager) setEnvironmentVariables(vars []EnvironmentVariable) error { +// for _, v := range vars { +// fmt.Printf("Setting environment variable: %s=%s\n", v.Name, v.Value) +// if err := os.Setenv(v.Name, v.Value); err != nil { +// return fmt.Errorf("failed to set environment variable %s: %w", v.Name, err) +// } +// } +// return nil +// } diff --git a/src/internal/lib/lib.go b/src/internal/lib/lib.go new file mode 100644 index 0000000..87bb37c --- /dev/null +++ b/src/internal/lib/lib.go @@ -0,0 +1,80 @@ +// The lib package is the implementation of the core functionality of the raid CLI tool. +package lib + +import ( + "fmt" + "sync" +) + +const ( + YAML_SEP = "---" +) + +type Context struct { + Profile Profile +} + +var context *Context + +func Compile() error { + if context == nil { + return ForceCompile() + } + return nil +} + +func ForceCompile() error { + profile, err := BuildProfile(GetProfile()) + if err != nil { + return err + } + context = &Context{ + Profile: profile, + } + return nil +} + +func Install(maxThreads int) error { + profile := context.Profile + if profile.IsZero() { + return fmt.Errorf("profile not found") + } + + var semaphore chan struct{} + if maxThreads > 0 { + semaphore = make(chan struct{}, maxThreads) + } + + var wg sync.WaitGroup + errorChan := make(chan error, len(profile.Repositories)) + + for _, repo := range profile.Repositories { + wg.Add(1) + go func(repo Repo) { + defer wg.Done() + + if semaphore != nil { + semaphore <- struct{}{} + defer func() { <-semaphore }() + } + + if err := CloneRepository(repo); err != nil { + errorChan <- fmt.Errorf("failed to install repository '%s': %w", repo.Name, err) + } + }(repo) + } + + wg.Wait() + close(errorChan) + + var errors []error + for err := range errorChan { + errors = append(errors, err) + } + + if len(errors) > 0 { + return fmt.Errorf("some repositories failed to install: %v", errors) + } + + return nil +} diff --git a/src/internal/lib/profile.go b/src/internal/lib/profile.go new file mode 100644 index 0000000..f9bc602 --- /dev/null +++ b/src/internal/lib/profile.go @@ -0,0 +1,339 @@ +package lib + +import ( + "bytes" + "encoding/json" + "fmt" + "io" + "os" + "path/filepath" + "strings" + + sys "github.com/8bitalex/raid/src/internal/sys" + "github.com/santhosh-tekuri/jsonschema/v6" + "github.com/spf13/viper" + "gopkg.in/yaml.v3" +) + +const ACTIVE_PROFILE_KEY = "profile" +const ALL_PROFILES_KEY = "profiles" +const SCHEMA_PATH = "schemas/raid-profile.schema.json" + +type Profile struct { + Name string `json:"name"` + Path string `json:"path"` + Repositories []Repo `json:"repositories"` +} + +func (p Profile) IsZero() bool { + return p.Name == "" || p.Path == "" +} + +func SetProfile(name string) error { + if !ContainsProfile(name) { + return fmt.Errorf("profile '%s' not found", name) + } + Set(ACTIVE_PROFILE_KEY, name) + return nil +} + +func GetProfile() Profile { + name := viper.GetString(ACTIVE_PROFILE_KEY) + paths := getProfilePaths() + return Profile{ + Name: name, + Path: paths[name], + } +} + +func AddProfile(profile Profile) { + profiles := viper.GetStringMapString(ALL_PROFILES_KEY) + if profiles == nil { + profiles = make(map[string]string) + } + + profiles[profile.Name] = profile.Path + Set(ALL_PROFILES_KEY, profiles) +} + +func AddProfiles(profiles []Profile) { + for _, profile := range profiles { + AddProfile(profile) + } +} + +func GetProfiles() []Profile { + profilesMap := getProfilePaths() + results := make([]Profile, 0, len(profilesMap)) + for name, path := range profilesMap { + results = append(results, Profile{Name: name, Path: path}) + } + return results +} + +func getProfilePaths() map[string]string { + profiles := viper.GetStringMapString(ALL_PROFILES_KEY) + if profiles == nil { + return make(map[string]string) + } + return profiles +} + +func RemoveProfile(name string) error { + profiles := viper.GetStringMapString(ALL_PROFILES_KEY) + if profiles == nil { + return fmt.Errorf("no profiles found") + } + if _, exists := profiles[name]; !exists { + return fmt.Errorf("profile '%s' not found", name) + } + delete(profiles, name) + Set(ALL_PROFILES_KEY, profiles) + return nil +} + +func ExtractProfile(name, path string) (Profile, error) { + profiles, err := ExtractProfiles(path) + if err != nil { + return Profile{}, err + } + for _, profile := range profiles { + if strings.EqualFold(profile.Name, name) { + return profile, nil + } + } + return Profile{}, fmt.Errorf("profile '%s' not found in %s", name, path) +} + +func ExtractProfiles(path string) ([]Profile, error) { + profileData, err := os.ReadFile(path) + if err != nil { + return nil, fmt.Errorf("failed to read profile from file %s: %w", path, err) + } + + ext := strings.ToLower(filepath.Ext(path)) + var profiles []Profile + + switch ext { + case ".yaml", ".yml": + profiles, err = extractProfilesFromYAML(profileData, path) + case ".json": + profiles, err = extractProfilesFromJSON(profileData, path) + default: + return nil, fmt.Errorf("unsupported file format: %s. Supported formats are .yaml, .yml, and .json", ext) + } + + if err != nil { + return nil, err + } + + if len(profiles) == 0 { + return nil, fmt.Errorf("no profiles found in file %s", path) + } + + return profiles, nil +} + +func extractProfilesFromYAML(data []byte, path string) ([]Profile, error) { + var profiles []Profile + + documents := strings.Split(string(data), YAML_SEP) + + for _, doc := range documents { + doc = strings.TrimSpace(doc) + if doc == "" { + continue + } + + var profile Profile + if err := yaml.Unmarshal([]byte(doc), &profile); err != nil { + return nil, fmt.Errorf("invalid YAML document in %s: %w", path, err) + } + profile.Path = path + + profiles = append(profiles, profile) + } + + return profiles, nil +} + +func extractProfilesFromJSON(data []byte, path string) ([]Profile, error) { + var profiles []Profile + + var profile Profile + if err := json.Unmarshal(data, &profile); err == nil { + profile.Path = path + return []Profile{profile}, nil + } + + if err := json.Unmarshal(data, &profiles); err != nil { + return nil, fmt.Errorf("invalid JSON format in %s: %w", path, err) + } + + results := make([]Profile, 0, len(profiles)) + for _, p := range profiles { + p.Path = path + results = append(results, p) + } + + return results, nil +} + +func ContainsProfile(name string) bool { + profiles := viper.GetStringMapString(ALL_PROFILES_KEY) + if profiles == nil { + return false + } + + _, exists := profiles[name] + return exists +} + +func ValidateProfile(path string) error { + if !sys.FileExists(path) { + return fmt.Errorf("file not found at %s", path) + } + + c := jsonschema.NewCompiler() + sch, err := c.Compile(SCHEMA_PATH) + if err != nil { + return err + } + + f, err := os.Open(path) + if err != nil { + return err + } + defer f.Close() + + profile := io.Reader(f) + + ext := strings.ToLower(filepath.Ext(path)) + if ext == ".yaml" || ext == ".yml" { + data, err := yamlToJSON(f) + if err != nil { + return err + } + profile = bytes.NewReader(data) + } + + json, err := jsonschema.UnmarshalJSON(profile) + if err != nil { + return err + } + + err = sch.Validate(json) + if err != nil { + return fmt.Errorf("invalid profile format: %w", err) + } + return nil +} + +func yamlToJSON(file io.Reader) ([]byte, error) { + var data interface{} + if err := yaml.NewDecoder(file).Decode(&data); err != nil { + return nil, err + } + return json.Marshal(data) +} + +func BuildProfile(profile Profile) (Profile, error) { + if profile.IsZero() { + return Profile{}, fmt.Errorf("invalid profile: %v", profile) + } + if !sys.FileExists(profile.Path) { + return Profile{}, fmt.Errorf("profile file not found at %s", profile.Path) + } + if err := ValidateProfile(profile.Path); err != nil { + return Profile{}, fmt.Errorf("invalid profile: %w", err) + } + profile, err := ExtractProfile(profile.Name, profile.Path) + if err != nil { + return Profile{}, fmt.Errorf("invalid profile: %w", err) + } + return profile, nil +} + +// // ProfileContent represents the content of a profile file +// type ProfileContent struct { +// Name string `json:"name" yaml:"name"` +// Repositories []Repository `json:"repositories" yaml:"repositories"` +// Environments []Environment `json:"environments" yaml:"environments"` +// } + +// // Repository represents a repository in a profile +// type Repository struct { +// Name string `json:"name" yaml:"name"` +// Path string `json:"path" yaml:"path"` +// URL string `json:"url" yaml:"url"` +// } + +// // Environment represents an environment configuration +// type Environment struct { +// Name string `json:"name" yaml:"name"` +// Tasks []Task `json:"tasks" yaml:"tasks"` +// Variables []EnvironmentVariable `json:"variables" yaml:"variables"` +// } + +// // Task represents a task to be executed +// type Task struct { +// Type string `json:"type" yaml:"type"` +// Cmd string `json:"cmd,omitempty" yaml:"cmd,omitempty"` +// Path string `json:"path,omitempty" yaml:"path,omitempty"` +// } + +// // EnvironmentVariable represents an environment variable +// type EnvironmentVariable struct { +// Name string `json:"name" yaml:"name"` +// Value string `json:"value" yaml:"value"` +// } + +// // GetActiveProfileContent reads and parses the active profile file +// func GetActiveProfileContent() (*ProfileContent, error) { +// activeProfile := GetProfile() +// if activeProfile == "" { +// return nil, fmt.Errorf("no active profile set. Use 'raid profile use ' to set an active profile") +// } + +// profilePath, err := GetProfilePath(activeProfile) +// if err != nil { +// return nil, fmt.Errorf("failed to get profile path for '%s': %w", activeProfile, err) +// } + +// return ReadProfileFile(profilePath) +// } + +// // ReadProfileFile reads and parses a profile file +// func ReadProfileFile(filePath string) (*ProfileContent, error) { +// // Read the profile file +// profileData, err := os.ReadFile(filePath) +// if err != nil { +// return nil, fmt.Errorf("failed to read profile file: %w", err) +// } + +// // Check file extension to determine format +// ext := strings.ToLower(filepath.Ext(filePath)) +// var profile ProfileContent + +// switch ext { +// case ".yaml", ".yml": +// // Parse YAML +// if err := yaml.Unmarshal(profileData, &profile); err != nil { +// return nil, fmt.Errorf("invalid YAML format: %w", err) +// } +// case ".json": +// // Parse JSON +// if err := json.Unmarshal(profileData, &profile); err != nil { +// return nil, fmt.Errorf("invalid JSON format: %w", err) +// } +// default: +// return nil, fmt.Errorf("unsupported file format: %s. Supported formats are .yaml, .yml, and .json", ext) +// } + +// // Validate required fields +// if profile.Name == "" { +// return nil, fmt.Errorf("profile file is missing required 'name' field") +// } + +// return &profile, nil +// } diff --git a/src/internal/lib/repo.go b/src/internal/lib/repo.go new file mode 100644 index 0000000..4170371 --- /dev/null +++ b/src/internal/lib/repo.go @@ -0,0 +1,64 @@ +package lib + +import ( + "fmt" + "os" + "os/exec" + "path/filepath" + + sys "github.com/8bitalex/raid/src/internal/sys" +) + +type Repo struct { + Name string + Path string + URL string +} + +func (r Repo) IsZero() bool { + return r.Name == "" || r.Path == "" || r.URL == "" +} + +func BuildRepo(repo Repo) (Repo, error) { + return repo, nil +} + +func CloneRepository(repo Repo) error { + path := sys.ExpandPath(repo.Path) + + if sys.FileExists(path) && isGitRepository(path) { + fmt.Printf("Repository '%s' already exists at %s, skipping\n", repo.Name, path) + return nil + } + + if !isGitInstalled() { + return fmt.Errorf("git is not installed or not in the PATH") + } + + if err := os.MkdirAll(path, 0755); err != nil { + return fmt.Errorf("failed to create directory '%s': %w", path, err) + } + + if err := clone(path, repo.URL); err != nil { + return fmt.Errorf("failed to clone repository '%s': %w", repo.Name, err) + } + + return nil +} + +func isGitRepository(path string) bool { + gitDir := filepath.Join(path, ".git") + return sys.FileExists(gitDir) +} + +func isGitInstalled() bool { + cmd := exec.Command("git", "--version") + return cmd.Run() == nil +} + +func clone(path string, url string) error { + cmd := exec.Command("git", "clone", url, path) + cmd.Stdout = os.Stdout + cmd.Stderr = os.Stderr + return cmd.Run() +} diff --git a/src/internal/lib/strings.go b/src/internal/lib/strings.go new file mode 100644 index 0000000..b8b65d1 --- /dev/null +++ b/src/internal/lib/strings.go @@ -0,0 +1,12 @@ +package lib + +import "github.com/8bitalex/raid/src/internal/sys" + +const ( + ConfigDirName = ".raid" + ConfigFileName = "config.toml" + ConfigPathDefault = "~" + sys.Sep + ConfigDirName + sys.Sep + ConfigFileName + ConfigPathFlag = "config" + ConfigPathFlagShort = "c" + ConfigPathFlagDesc = "configuration file path (default is " + ConfigPathDefault + ")" +) diff --git a/src/internal/lib/task_runner.go b/src/internal/lib/task_runner.go new file mode 100644 index 0000000..81a3a6e --- /dev/null +++ b/src/internal/lib/task_runner.go @@ -0,0 +1,136 @@ +package lib + +// import ( +// "fmt" +// "os" +// "os/exec" +// "path/filepath" +// "sync" +// ) + +// // TaskRunner handles the execution of tasks +// type TaskRunner struct { +// concurrency int +// } + +// // NewTaskRunner creates a new task runner with the specified concurrency limit +// func NewTaskRunner(concurrency int) *TaskRunner { +// return &TaskRunner{ +// concurrency: concurrency, +// } +// } + +// // ExecuteTasks executes a list of tasks concurrently +// func (tr *TaskRunner) ExecuteTasks(tasks []Task) error { +// if len(tasks) == 0 { +// return nil +// } + +// // Use a semaphore to limit concurrency +// var semaphore chan struct{} +// if tr.concurrency > 0 { +// semaphore = make(chan struct{}, tr.concurrency) +// } + +// var wg sync.WaitGroup +// errorChan := make(chan error, len(tasks)) +// var outputMutex sync.Mutex + +// for i, task := range tasks { +// wg.Add(1) +// go func(taskIndex int, task Task) { +// defer wg.Done() + +// // Acquire semaphore if concurrency is limited +// if semaphore != nil { +// semaphore <- struct{}{} +// defer func() { <-semaphore }() +// } + +// // Lock output to prevent interleaved messages +// outputMutex.Lock() +// fmt.Printf("Executing task %d: %s\n", taskIndex+1, task.Type) +// outputMutex.Unlock() + +// if err := tr.executeTask(task); err != nil { +// errorChan <- fmt.Errorf("task %d failed: %w", taskIndex+1, err) +// } else { +// outputMutex.Lock() +// fmt.Printf("Task %d completed successfully\n", taskIndex+1) +// outputMutex.Unlock() +// } +// }(i, task) +// } + +// wg.Wait() +// close(errorChan) + +// // Check for any errors +// var errors []error +// for err := range errorChan { +// errors = append(errors, err) +// } + +// if len(errors) > 0 { +// return fmt.Errorf("some tasks failed: %v", errors) +// } + +// return nil +// } + +// // executeTask executes a single task +// func (tr *TaskRunner) executeTask(task Task) error { +// switch task.Type { +// case "Shell": +// return tr.executeShellTask(task) +// case "Script": +// return tr.executeScriptTask(task) +// default: +// return fmt.Errorf("unknown task type: %s", task.Type) +// } +// } + +// // executeShellTask executes a shell command +// func (tr *TaskRunner) executeShellTask(task Task) error { +// if task.Cmd == "" { +// return fmt.Errorf("shell task requires 'cmd' field") +// } + +// cmd := exec.Command("sh", "-c", task.Cmd) +// cmd.Stdout = os.Stdout +// cmd.Stderr = os.Stderr +// cmd.Stdin = os.Stdin + +// return cmd.Run() +// } + +// // executeScriptTask executes a script file +// func (tr *TaskRunner) executeScriptTask(task Task) error { +// if task.Path == "" { +// return fmt.Errorf("script task requires 'path' field") +// } + +// // Resolve the script path +// scriptPath, err := filepath.Abs(task.Path) +// if err != nil { +// return fmt.Errorf("failed to resolve script path: %w", err) +// } + +// // Check if the script file exists +// if _, err := os.Stat(scriptPath); os.IsNotExist(err) { +// return fmt.Errorf("script file not found: %s", scriptPath) +// } + +// // Make the script executable +// if err := os.Chmod(scriptPath, 0755); err != nil { +// return fmt.Errorf("failed to make script executable: %w", err) +// } + +// // Execute the script +// cmd := exec.Command(scriptPath) +// cmd.Stdout = os.Stdout +// cmd.Stderr = os.Stderr +// cmd.Stdin = os.Stdin + +// return cmd.Run() +// } diff --git a/src/internal/sys/system.go b/src/internal/sys/system.go new file mode 100644 index 0000000..6cf99c9 --- /dev/null +++ b/src/internal/sys/system.go @@ -0,0 +1,47 @@ +package sys + +import ( + "log" + "os" + "path" + "strings" + + "github.com/mitchellh/go-homedir" +) + +// Path Separator as a string +const Sep = string(os.PathSeparator) + +func GetHomeDir() string { + home, err := homedir.Dir() + if err != nil { + log.Fatalf("Failed to get home directory: %v", err) + } + return home +} + +func CreateFile(filePath string) *os.File { + pathEx := ExpandPath(filePath) + os.MkdirAll(path.Dir(pathEx), os.ModeDir|0755) + + file, err := os.Create(pathEx) + if err != nil { + log.Fatalf("Failed to create file '%s': %v", pathEx, err) + } + return file +} + +func FileExists(path string) bool { + path = ExpandPath(path) + if _, err := os.Open(path); err != nil { + return false + } + return true +} + +func ExpandPath(path string) string { + if strings.HasPrefix(path, "~") { + return strings.Replace(path, "~", GetHomeDir(), 1) + } + return os.ExpandEnv(path) +} diff --git a/src/internal/utils/cobra-ext.go b/src/internal/utils/cobra-ext.go new file mode 100644 index 0000000..b61c138 --- /dev/null +++ b/src/internal/utils/cobra-ext.go @@ -0,0 +1,34 @@ +package utils + +import ( + "fmt" + + "github.com/spf13/cobra" +) + +// MatchOne checks for at least one valid PositionalArgs. Acts like an OR operator. +func MatchOne(pargs ...cobra.PositionalArgs) cobra.PositionalArgs { + return func(cmd *cobra.Command, args []string) error { + var errors []error + for _, arg := range pargs { + if err := arg(cmd, args); err == nil { + return nil + } else { + errors = append(errors, err) + } + } + return mergeErr(errors) + } +} + +func mergeErr(errs []error) error { + var result string + for _, err := range errs { + if len(result) == 0 { + result = err.Error() + } else { + result = result + ", " + err.Error() + } + } + return fmt.Errorf("%s", result) +} diff --git a/src/raid/profile/profile.go b/src/raid/profile/profile.go new file mode 100644 index 0000000..f342239 --- /dev/null +++ b/src/raid/profile/profile.go @@ -0,0 +1,53 @@ +/* +Manage raid profiles. +*/ +package profile + +import "github.com/8bitalex/raid/src/internal/lib" + +type Profile = lib.Profile + +// Returns the active profile +func Get() Profile { + return lib.GetProfile() +} + +// Returns a slice of all added profiles +func GetAll() []Profile { + return lib.GetProfiles() +} + +// Adds a profile to the available profile list +func Add(profile Profile) { + lib.AddProfile(profile) +} + +// Adds multiple profiles to the profile list +func AddAll(profiles []Profile) { + lib.AddProfiles(profiles) +} + +// Sets the active profile +func Set(name string) error { + return lib.SetProfile(name) +} + +// Removes a profile from the profile list +func Remove(name string) error { + return lib.RemoveProfile(name) +} + +// Extracts profiles from a file +func Unmarshal(path string) ([]Profile, error) { + return lib.ExtractProfiles(path) +} + +// Validates a profile file against the JSON schema +func Validate(path string) error { + return lib.ValidateProfile(path) +} + +// Checks if a profile exists +func Contains(name string) bool { + return lib.ContainsProfile(name) +} diff --git a/src/raid/raid.go b/src/raid/raid.go new file mode 100644 index 0000000..1447ab0 --- /dev/null +++ b/src/raid/raid.go @@ -0,0 +1,58 @@ +/* +The primary interface for the raid CLI tool functionality. + +Lifecycle: + 1. Initialize: set up the raid environment, including loading configurations and initializing data storage. + 2. Compile: compile the raid configurations and prepare them for execution. + 3. Execute: run the raid commands based on the compiled configurations. + 4. Shutdown: gracefully shut down the raid environment, ensuring all resources are released and saved. + +Related packages: + - `raid/profile`: provides the core functionality for loading and managing profiles + - `raid/repo`: provides the core functionality for loading and managing repositories + - `raid/env`: provides the core functionality for loading and managing environments +*/ +package raid + +import ( + "log" + + "github.com/8bitalex/raid/src/internal/lib" +) + +const ( + ConfigPathFlag = lib.ConfigPathFlag + ConfigPathFlagDesc = lib.ConfigPathFlagDesc + ConfigPathFlagShort = lib.ConfigPathFlagShort + ConfigPathDefault = lib.ConfigPathDefault + ConfigDirName = lib.ConfigDirName + ConfigFileName = lib.ConfigFileName +) + +// Pointer to the configuration path +var ConfigPath = &lib.CfgPath + +// Initialize the raid environment, including loading configurations and initializing data storage. +func Initialize() { + if err := lib.InitConfig(); err != nil { + log.Fatalf("Failed to initialize configuration: %v", err) + } + if err := Compile(); err != nil { + log.Fatalf("Failed to compile configurations: %v", err) + } +} + +// Compile the raid configurations for execution. Uses cached results if available. +func Compile() error { + return lib.Compile() +} + +// Force compile the raid configurations for execution. Ignores cache. +func ForceCompile() error { + return lib.ForceCompile() +} + +// Install the active profile +func Install(maxThreads int) error { + return lib.Install(maxThreads) +}