diff --git a/.github/workflows/codecov.yml b/.github/workflows/codecov.yml index 9e861d2..c4b0c1a 100644 --- a/.github/workflows/codecov.yml +++ b/.github/workflows/codecov.yml @@ -14,8 +14,12 @@ jobs: runs-on: ubuntu-latest steps: - uses: actions/checkout@v4 + - name: Set up Go + uses: actions/setup-go@v5 + with: + go-version: '1.24.4' - name: Run Tests - run: go test -coverprofile=coverage.txt + run: go test -coverprofile=coverage.txt -covermode=atomic ./... - uses: codecov/codecov-action@v5 with: token: ${{ secrets.CODECOV_TOKEN }} diff --git a/.gitignore b/.gitignore index e5103f4..f3fef72 100644 --- a/.gitignore +++ b/.gitignore @@ -24,3 +24,7 @@ go.work.sum # env file .env + +# AI assistant files +CLAUDE.md +.claude/ diff --git a/README.md b/README.md index 4d55728..8617f7e 100644 --- a/README.md +++ b/README.md @@ -1,230 +1,95 @@ [![Build and Test](https://github.com/8bitAlex/raid/actions/workflows/build.yml/badge.svg)](https://github.com/8bitAlex/raid/actions/workflows/build.yml) [![codecov](https://codecov.io/github/8bitAlex/raid/graph/badge.svg?token=Z75V7I2TLW)](https://codecov.io/github/8bitAlex/raid) -[![Go Report Card](https://goreportcard.com/badge/github.com/8bitAlex/raid)](https://goreportcard.com/report/github.com/8bitAlex/raidο£Ώβ—Š) +[![Go Report Card](https://goreportcard.com/badge/github.com/8bitAlex/raid)](https://goreportcard.com/report/github.com/8bitAlex/raid) -# Raid - Distributed Development Orchestration +# Raid β€” Distributed Development Orchestration ![Windows](https://img.shields.io/badge/Windows-Yes-blue?logo=windows) ![macOS](https://img.shields.io/badge/macOS-Yes-lightgrey?logo=apple) ![Linux](https://img.shields.io/badge/Linux-Yes-yellow?logo=linux) - `Raid` is a configurable command-line application that orchestrates common development tasks, environments, and dependencies across distributed code repositories. -If you have ever pulled a repo (or repos) that require days of configuration just to get a passing build, -or have onboarded to a new team that has no documentation, or have a folder of scripts to automate your tasks but haven't -shared them yet, then you are probably in need of this. - -`Raid` handles the pain of error-prone knowledge-dependent tasks and management of your development environment. You no longer need -to worry about wasted time onboarding new contributors. Tribal knowledge can be codified into the repo itself. And you will -never miss running that one test ever again. +Tribal knowledge codified into the repo itself β€” onboarding becomes a single command. -πŸ“– 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). +πŸ“– For a deeper look at the goals and design, see the [design proposal blog post](https://alexsalerno.dev/blog/raid-design-proposal?utm_source=chatgpt.com). ## Key Features -- **Portable YAML Configurations**: Define your development environments, tasks, and dependencies using simple, version-controlled YAML files. Your configurations live alongside your code, making them easy to share and maintain. -- **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. +- **Portable YAML Configurations** β€” define environments, tasks, and dependencies in version-controlled YAML files that live alongside your code. +- **Multiple Profiles** β€” switch between project setups or team configurations with isolated profiles. +- **Rich Task Runner** β€” 12 built-in task types covering shell commands, scripts, HTTP downloads, service health checks, git operations, template rendering, user prompts, and more. +- **Environment Management** β€” define and apply consistent development environments for all contributors. +- **Custom Commands** β€” codify repeated operational tasks (patch, proxy, verify, deploy) as first-class `raid ` subcommands that live alongside your configuration. -## Development +## Development Status -`Raid` is currently in the **prototype stage**. Core functionality is still being explored and iterated on, so expect frequent changes and incomplete features. +`Raid` is currently in the **prototype stage**. Core functionality is still being explored and iterated on β€” expect frequent changes and incomplete features. Feedback, issues, and contributions are welcome as the project takes shape. --- -[Getting Started](#getting-started) β€’ [Best Practices](#best-practices) β€’ [Documentation](#usage--documentation) - ---- - ## Getting Started ### Installation -#### MacOS - ```bash -brew install raid # coming soon +brew install raid # macOS β€” Linux and Windows coming soon ``` -#### Linux +### Quickstart -```bash -# coming soon -``` - -#### Windows +First, create a profile file (see [Configuration](#configuration) below), then: ```bash -# coming soon +raid profile add my-project.raid.yaml # register and activate a profile +raid install # clone repos and run install tasks +raid env dev # apply the dev environment ``` -### 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 sensitive 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. +Manage profiles. A profile is a named collection of repositories and environments. -**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 ` +- `raid profile add ` β€” register profiles from a YAML or JSON file; the first added profile is set as active automatically +- `raid profile list` β€” list all registered profiles +- `raid profile ` β€” switch the active profile +- `raid profile remove ` β€” remove a profile -Set a specific profile as the active profile. +### `raid install` -**Example:** -```bash -raid profile use my-project -# Output: Profile 'my-project' is now active. -``` +Clone all repositories in the active profile and run any configured install tasks. Already-cloned repos are skipped. Use `-t` to limit concurrent clone threads. -##### `raid profile remove [profile-name...]` +### `raid env` -Remove one or more profiles. You can specify multiple profile names to remove them all at once. +- `raid env ` β€” apply a named environment: writes `.env` files into each repo and runs environment tasks +- `raid env` β€” show the currently active environment +- `raid env list` β€” list available environments -**Examples:** -```bash -# Remove a single profile -raid profile remove old-project +### `raid ` -# Remove multiple profiles -raid profile remove project1 project2 project3 -``` +Run a custom command defined in the active profile or any of its repositories. -**Example Output:** ```bash -Profile 'old-project' has been removed. -Profile 'project1' has been removed. -Profile 'project2' has been removed. +raid build # run the "build" command +raid deploy # run the "deploy" command ``` -### `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. +Custom commands appear alongside built-in commands in `raid --help`. Commands defined in a profile take priority over same-named commands from repositories. -**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 +## Configuration -## Profile Configuration +Tasks can be defined under `install`, within any environment, or in named `groups` β€” in both profile and repo configs. -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. +### Profile (`*.raid.yaml`) -### Single Profile Configuration +A profile defines the repositories, environments, and reusable task groups for a project. The `$schema` annotation enables autocomplete and validation in editors like VS Code. ```yaml # yaml-language-server: $schema=schemas/raid-profile.schema.json @@ -235,7 +100,6 @@ repositories: - name: frontend path: ~/Developer/frontend url: https://github.com/myorg/frontend - - name: backend path: ~/Developer/backend url: https://github.com/myorg/backend @@ -248,230 +112,264 @@ environments: - name: DATABASE_URL value: postgresql://localhost:5432/myproject tasks: + - type: Print + message: "Applying dev environment..." + color: green - type: Shell - cmd: echo "Setting up development environment..." - - type: Script - path: ./scripts/setup-dev.sh + cmd: docker compose up -d + - type: Wait + url: localhost:5432 + timeout: 30s + +install: + tasks: + - type: Shell + cmd: brew install node + +groups: + verify-services: + - type: Wait + url: http://localhost:3000 + timeout: 10s + - type: Wait + url: localhost:5432 + timeout: 10s + +commands: + - name: sync + usage: "Pull latest on all repos and restart services" + tasks: + - type: Git + op: pull + path: ~/Developer/frontend + - type: Git + op: pull + path: ~/Developer/backend + - type: Shell + cmd: docker compose restart ``` -### Multiple Profiles in a Single File +Multiple profiles can be defined in a single file using YAML document separators (`---`) or a JSON array. -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. +### Repository (`raid.yaml`) -#### YAML with Document Separators +Individual repositories can carry their own `raid.yaml` at their root to define repo-specific environments and install tasks. These are merged with the profile configuration at load time. Committing this file to each repo is the recommended way to share setup knowledge with your team. ```yaml -# yaml-language-server: $schema=schemas/raid-profile.schema.json +# yaml-language-server: $schema=schemas/raid-repo.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 +name: my-service +branch: main + +environments: + - name: dev + tasks: + - type: Shell + cmd: npm install + - type: Shell + cmd: npm run build + +commands: + - name: test + usage: "Run the test suite" + tasks: + - type: Shell + cmd: npm test ``` -#### 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" - } - ] - } -] +--- + +## Tasks + +All task types support two optional modifiers: + +```yaml +concurrent: true # run in parallel with other concurrent tasks +condition: # skip this task unless all conditions are met + platform: darwin # only on this OS (darwin, linux, windows) + exists: ~/.config/myapp # only if this path exists + cmd: which docker # only if this command exits 0 ``` +### Shell +Run a command string in a configurable shell. -### Profile Management Features +```yaml +- type: Shell + cmd: echo "hello $USER" + shell: bash # optional: bash (default), sh, zsh, powershell, cmd + literal: false # optional: skip env var expansion before passing to shell + path: ~/project # optional: working directory. Defaults to ~ for profile tasks, repo dir for repo tasks +``` -- **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 +### Script -**Note:** For detailed schema information, see the [JSON Schema Specifications](#json-schema-specifications) section. +Execute a script file directly. -## Repository Configuration +```yaml +- type: Script + path: ./scripts/setup.sh + runner: bash # optional: bash, sh, zsh, python, python3, node, powershell +``` -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. +### HTTP -**Note:** Repository configurations follow the `raid-repo.schema.json` schema. See the [JSON Schema Specifications](#json-schema-specifications) section for detailed schema information. +Download a file from a URL. -### Example Repository Configuration +```yaml +- type: HTTP + url: https://example.com/config.json + dest: ~/.config/myapp/config.json +``` + +### Wait + +Poll an HTTP(S) URL or TCP address until it responds, then continue. ```yaml -# yaml-language-server: $schema=schemas/raid-repo.schema.json +- type: Wait + url: http://localhost:8080/health # or TCP: localhost:5432 + timeout: 60s # optional, default: 30s +``` -name: my-service -branch: main +### Template -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 +Render a file by substituting `$VAR` and `${VAR}` references with environment variable values. + +```yaml +- type: Template + src: ./config/app.env.template + dest: ~/.config/myapp/app.env ``` -## JSON Schema Specifications +### Group + +Execute a named group of tasks defined in the profile's top-level `groups` map. -Raid uses **JSON Schema Draft 2020-12** for configuration validation. The schema system consists of three main files: +```yaml +- type: Group + ref: verify-services +``` -- **`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 +### Parallel -### Schema Validation +Like `Group`, but forces all tasks in the group to run concurrently, then waits for all to finish before continuing. -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. +```yaml +- type: Parallel + ref: start-services +``` -### IDE Integration +### Git -For the best development experience, include schema references in your configuration files: +Perform a git operation in a repository directory. ```yaml -# yaml-language-server: $schema=schemas/raid-profile.schema.json +- type: Git + op: pull # pull, checkout, fetch, reset + branch: main # required for checkout; optional for pull, fetch, reset + path: ~/Developer/myrepo # optional, defaults to current directory ``` -This provides: -- βœ… **Autocomplete** for field names and values -- βœ… **Real-time validation** of your configuration -- βœ… **Error highlighting** for invalid configurations -- βœ… **Documentation tooltips** for each field +### Print -### Schema Structure Details +Print a formatted message to stdout. Useful for labelling steps in long task sequences. -#### Profile Schema (`raid-profile.schema.json`) -A raid profile configuration must contain: +```yaml +- type: Print + message: "Deploying $APP_VERSION to production..." + color: yellow # optional: red, green, yellow, blue, cyan, white + literal: false # optional: skip env var expansion +``` -- **`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 +### Prompt -#### Repository Schema (`raid-repo.schema.json`) -A repository configuration must contain: +Ask the user for input and store the result in an environment variable for use by downstream tasks. -- **`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`) +```yaml +- type: Prompt + var: TARGET_ENV + message: "Which environment? (dev/staging/prod)" + default: dev # optional: used when user presses enter with no input +``` -#### Definitions Schema (`raid-defs.schema.json`) -Environments and tasks follow this shared schema: +### Confirm -**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 +Pause and require explicit confirmation (`y` or `yes`) before continuing. Useful before destructive operations. -**Task Schema:** -Tasks support two types: +```yaml +- type: Confirm + message: "This will reset the production database. Continue?" +``` + +### Retry + +Re-run a group of tasks on failure, up to a configurable number of attempts. -**Shell Tasks:** ```yaml -- type: Shell - cmd: echo "Hello World" - concurrent: true # Optional: execute concurrently with other tasks +- type: Retry + ref: run-migrations + attempts: 3 # optional, default: 3 + delay: 5s # optional, default: 1s ``` -**Script Tasks:** +--- + +## Commands Configuration + +Custom commands are defined in the `commands` array of a profile or repository `raid.yaml`. They become first-class `raid ` subcommands at runtime. + ```yaml -- type: Script - path: ./scripts/setup.sh - concurrent: false # Optional: execute sequentially +commands: + - name: deploy + usage: "Build and deploy all services" # shown in raid --help + tasks: + - type: Confirm + message: "Deploy to production?" + - type: Shell + cmd: make deploy + out: # optional β€” defaults to full stdout+stderr when omitted + stdout: true + stderr: false + file: $DEPLOY_LOG # also write all output here; supports $VAR expansion ``` -### Technical Details +**`name`** (required) β€” the subcommand name; e.g. `name: deploy` is invoked as `raid deploy`. Cannot shadow built-in names (`profile`, `install`, `env`). -- **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 +**`usage`** (optional) β€” short description shown next to the command in `raid --help`. -## Contributing +**`tasks`** (required) β€” the task sequence to run. All standard task types are supported. -We welcome contributions! Please see our [Contributing Guidelines](docs/CONTRIBUTING.md) for details. +**`out`** (optional) β€” controls output handling. When omitted, stdout and stderr behave normally. When present: +- `stdout` β€” show task stdout (default: `true` when `out` is omitted; set explicitly when using `out`) +- `stderr` β€” show task stderr (default: `true` when `out` is omitted; set explicitly when using `out`) +- `file` β€” additionally write all output to this path; supports `$VAR` expansion -## License +**Priority** β€” when a profile and one of its repositories define a command with the same name, the profile's definition wins. + +--- -This project is licensed under the **GNU General Public License v3.0** (GPL-3.0). +## Best Practices -### Key License Highlights +**Commit `raid.yaml` to each repo.** This is how setup knowledge gets shared β€” anyone with raid can run `raid install` and get a working environment without reading a wiki. -**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 +**Use `commands` to codify team workflows.** Repeated operational tasks β€” patching, proxying, deploying, verifying β€” belong in `commands`, not in Slack messages or shared scripts. Anyone on the team can run `raid deploy` without knowing the steps. Use `groups` for reusable internal sequences that commands and other tasks compose from. -**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 +**Gate destructive steps with `Confirm`.** Any task sequence that resets data, force-pushes, or modifies production should begin with a `Confirm` task to prevent accidental runs. -**What you cannot do:** -- ❌ **Make the software proprietary** - modifications must remain open source -- ❌ **Remove the license** or copyright notices -- ❌ **Sublicense** under different terms +**Use `Print` to structure long sequences.** Clear section headers make install and deploy output readable at a glance, especially for new team members. -### Full License Text +**Keep profiles in a dotfiles repo.** Profile files reference your repos and environments. Storing them in a private dotfiles repo keeps them version-controlled and accessible across machines. -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/). +**Never commit secrets.** Use environment variable references or keep sensitive values in private profiles β€” never hardcode credentials in a committed raid file. + +--- -### Contributing +## Contributing + +Contributions are welcome. See [docs/CONTRIBUTING.md](docs/CONTRIBUTING.md) for details. + +## License -By contributing to this project, you agree that your contributions will be licensed under the same GPL-3.0 license +Licensed under the **GNU General Public License v3.0**. See [LICENSE](LICENSE) for the full text. diff --git a/docs/examples/example.raid.yaml b/docs/examples/example.raid.yaml index a2295e0..5f73196 100644 --- a/docs/examples/example.raid.yaml +++ b/docs/examples/example.raid.yaml @@ -14,8 +14,17 @@ environments: tasks: - type: Shell cmd: echo "Hello, world! - From profile" + shell: bash + - type: Shell + literal: false + cmd: echo $NODE_ENV - type: Script - path: ./hello.sh + path: ./docs/examples/hello.sh + runner: bash + concurrent: false + - type: Shell + cmd: echo "Hello from concurrent task" + concurrent: true variables: - name: NODE_ENV value: development @@ -24,26 +33,7 @@ environments: - 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 +install: + tasks: + - type: Shell + cmd: echo "Installing dependencies..." \ No newline at end of file diff --git a/docs/examples/hello.sh b/docs/examples/hello.sh index 8e8fe0a..d714f32 100755 --- a/docs/examples/hello.sh +++ b/docs/examples/hello.sh @@ -1 +1,2 @@ -echo "Hello, world!" \ No newline at end of file +#!/bin/sh +echo "Hello, world! - From script" \ No newline at end of file diff --git a/go.mod b/go.mod index eef56cd..0a194e7 100644 --- a/go.mod +++ b/go.mod @@ -3,12 +3,12 @@ module github.com/8bitalex/raid go 1.24.4 require ( + github.com/joho/godotenv v1.5.1 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 - github.com/joho/godotenv v1.5.1 ) require ( diff --git a/schemas/raid-defs.schema.json b/schemas/raid-defs.schema.json index a33f504..584fdbd 100644 --- a/schemas/raid-defs.schema.json +++ b/schemas/raid-defs.schema.json @@ -16,16 +16,23 @@ { "type": "object", "properties": { - "type": { + "type": { "type": "string", "const": "Shell" }, + "concurrent": { "$ref": "#/$defs/concurrent" }, + "condition": { "$ref": "#/$defs/condition" }, + "cmd": { "type": "string", "description": "Command to execute" }, + "shell": { "type": "string", - "enum": ["Shell"] + "enum": ["bash", "sh", "zsh", "powershell", "pwsh", "ps", "cmd"], + "description": "Shell to use (default: bash)" }, - "cmd": { - "type": "string", - "description": "Shell command to execute" + "literal": { + "type": "boolean", + "description": "Pass the command to the shell without prior env var expansion", + "default": false }, - "concurrent": { - "ref": "#/concurrent" + "path": { + "type": "string", + "description": "Working directory for the command. Defaults to ~ for profile tasks, the repo directory for repo tasks." } }, "required": ["type", "cmd"], @@ -34,19 +41,154 @@ { "type": "object", "properties": { - "type": { + "type": { "type": "string", "const": "Script" }, + "concurrent": { "$ref": "#/$defs/concurrent" }, + "condition": { "$ref": "#/$defs/condition" }, + "path": { "type": "string", "description": "Path to the script file" }, + "runner": { "type": "string", - "enum": ["Script"] + "enum": ["bash", "sh", "zsh", "python", "python2", "python3", "node", "powershell"], + "description": "Interpreter to use (optional)" + } + }, + "required": ["type", "path"], + "additionalProperties": false + }, + { + "type": "object", + "properties": { + "type": { "type": "string", "const": "HTTP" }, + "concurrent": { "$ref": "#/$defs/concurrent" }, + "condition": { "$ref": "#/$defs/condition" }, + "url": { "type": "string", "description": "URL to download from" }, + "dest": { "type": "string", "description": "Local path to write the file to" } + }, + "required": ["type", "url", "dest"], + "additionalProperties": false + }, + { + "type": "object", + "properties": { + "type": { "type": "string", "const": "Wait" }, + "concurrent": { "$ref": "#/$defs/concurrent" }, + "condition": { "$ref": "#/$defs/condition" }, + "url": { "type": "string", "description": "HTTP(S) URL or TCP host:port to poll" }, + "timeout": { "type": "string", "description": "Max wait duration (e.g. 30s, 1m). Defaults to 30s." } + }, + "required": ["type", "url"], + "additionalProperties": false + }, + { + "type": "object", + "properties": { + "type": { "type": "string", "const": "Template" }, + "concurrent": { "$ref": "#/$defs/concurrent" }, + "condition": { "$ref": "#/$defs/condition" }, + "src": { "type": "string", "description": "Path to the template file. Supports $VAR and ${VAR} substitution." }, + "dest": { "type": "string", "description": "Path to write the rendered file to" } + }, + "required": ["type", "src", "dest"], + "additionalProperties": false + }, + { + "type": "object", + "properties": { + "type": { "type": "string", "const": "Group" }, + "concurrent": { "$ref": "#/$defs/concurrent" }, + "condition": { "$ref": "#/$defs/condition" }, + "ref": { "type": "string", "description": "Name of the group to execute, as defined in the profile's top-level groups map" } + }, + "required": ["type", "ref"], + "additionalProperties": false + }, + { + "type": "object", + "properties": { + "type": { "type": "string", "const": "Git" }, + "concurrent": { "$ref": "#/$defs/concurrent" }, + "condition": { "$ref": "#/$defs/condition" }, + "op": { + "type": "string", + "enum": ["pull", "checkout", "fetch", "reset"], + "description": "Git operation to perform" }, - "path": { + "branch": { "type": "string", "description": "Target branch (required for checkout, optional for others)" }, + "path": { "type": "string", "description": "Path to the git repository. Defaults to the current working directory." } + }, + "required": ["type", "op"], + "additionalProperties": false + }, + { + "type": "object", + "properties": { + "type": { "type": "string", "const": "Prompt" }, + "concurrent": { "$ref": "#/$defs/concurrent" }, + "condition": { "$ref": "#/$defs/condition" }, + "var": { "type": "string", "description": "Environment variable name to set with the user's input" }, + "message": { "type": "string", "description": "Message to display to the user" }, + "default": { "type": "string", "description": "Default value if the user provides no input" } + }, + "required": ["type", "var"], + "additionalProperties": false + }, + { + "type": "object", + "properties": { + "type": { "type": "string", "const": "Confirm" }, + "concurrent": { "$ref": "#/$defs/concurrent" }, + "condition": { "$ref": "#/$defs/condition" }, + "message": { "type": "string", "description": "Confirmation prompt to display to the user" } + }, + "required": ["type"], + "additionalProperties": false + }, + { + "type": "object", + "properties": { + "type": { "type": "string", "const": "Parallel" }, + "concurrent": { "$ref": "#/$defs/concurrent" }, + "condition": { "$ref": "#/$defs/condition" }, + "ref": { "type": "string", "description": "Name of the group whose tasks will all be executed concurrently" } + }, + "required": ["type", "ref"], + "additionalProperties": false + }, + { + "type": "object", + "properties": { + "type": { "type": "string", "const": "Print" }, + "concurrent": { "$ref": "#/$defs/concurrent" }, + "condition": { "$ref": "#/$defs/condition" }, + "message": { "type": "string", "description": "Message to print. Supports $VAR substitution unless literal is true." }, + "color": { "type": "string", - "description": "Path to the script file" + "enum": ["red", "green", "yellow", "blue", "cyan", "white"], + "description": "Optional terminal color for the output" }, - "concurrent": { - "ref": "#/concurrent" + "literal": { + "type": "boolean", + "description": "Skip environment variable expansion in the message", + "default": false } }, - "required": ["type", "path"], + "required": ["type", "message"], + "additionalProperties": false + }, + { + "type": "object", + "properties": { + "type": { "type": "string", "const": "Retry" }, + "concurrent": { "$ref": "#/$defs/concurrent" }, + "condition": { "$ref": "#/$defs/condition" }, + "ref": { "type": "string", "description": "Name of the group to retry on failure" }, + "attempts": { + "type": "integer", + "minimum": 1, + "description": "Maximum number of attempts (default: 3)" + }, + "delay": { "type": "string", "description": "Duration to wait between attempts (e.g. 1s, 500ms). Default: 1s." } + }, + "required": ["type", "ref"], "additionalProperties": false } ] @@ -88,7 +230,60 @@ } } }, - "required": ["name"] + "required": ["name"], + "additionalProperties": false + } + }, + "install": { + "type": "object", + "description": "Options for installing the raid profile", + "properties": { + "tasks": { + "$ref": "#/properties/tasks" + } + }, + "additionalProperties": false + }, + "commands": { + "type": "array", + "description": "Custom commands exposed as top-level raid subcommands", + "minItems": 1, + "items": { + "type": "object", + "properties": { + "name": { + "type": "string", + "description": "Command name used to invoke it via 'raid '" + }, + "usage": { + "type": "string", + "description": "Short description shown in 'raid --help'" + }, + "tasks": { + "$ref": "#/properties/tasks" + }, + "out": { + "type": "object", + "description": "Output configuration for the command", + "properties": { + "stdout": { + "type": "boolean", + "description": "Show stdout from tasks (default: true when out is omitted)" + }, + "stderr": { + "type": "boolean", + "description": "Show stderr from tasks (default: true when out is omitted)" + }, + "file": { + "type": "string", + "description": "Path to additionally write all output to. Supports $VAR expansion." + } + }, + "additionalProperties": false + } + }, + "required": ["name", "tasks"], + "additionalProperties": false } } }, @@ -96,6 +291,26 @@ "concurrent": { "type": "boolean", "description": "Whether to execute the task concurrently with other tasks" + }, + "condition": { + "type": "object", + "description": "All specified fields must be satisfied for the task to run", + "properties": { + "platform": { + "type": "string", + "enum": ["darwin", "linux", "windows"], + "description": "Only run on this platform" + }, + "exists": { + "type": "string", + "description": "Only run if this file or directory exists" + }, + "cmd": { + "type": "string", + "description": "Only run if this command exits with code 0" + } + }, + "additionalProperties": false } } -} \ No newline at end of file +} diff --git a/schemas/raid-profile.schema.json b/schemas/raid-profile.schema.json index 14fcbae..5629847 100644 --- a/schemas/raid-profile.schema.json +++ b/schemas/raid-profile.schema.json @@ -35,6 +35,19 @@ }, "environments": { "$ref": "raid-defs.schema.json#/properties/environments" + }, + "install": { + "$ref": "raid-defs.schema.json#/properties/install" + }, + "groups": { + "type": "object", + "description": "Named reusable task sequences. Reference them in any task list with type: Group and ref: .", + "additionalProperties": { + "$ref": "raid-defs.schema.json#/properties/tasks" + } + }, + "commands": { + "$ref": "raid-defs.schema.json#/properties/commands" } }, "required": ["name"] diff --git a/schemas/raid-repo.schema.json b/schemas/raid-repo.schema.json index f1e2ec5..1b93c94 100644 --- a/schemas/raid-repo.schema.json +++ b/schemas/raid-repo.schema.json @@ -16,6 +16,12 @@ }, "environments": { "$ref": "raid-defs.schema.json#/properties/environments" + }, + "install": { + "$ref": "raid-defs.schema.json#/properties/install" + }, + "commands": { + "$ref": "raid-defs.schema.json#/properties/commands" } }, "required": ["name","branch"] diff --git a/src/cmd/env/env.go b/src/cmd/env/env.go index 14b0e93..1c16de8 100644 --- a/src/cmd/env/env.go +++ b/src/cmd/env/env.go @@ -17,30 +17,34 @@ var Command = &cobra.Command{ Args: cobra.RangeArgs(0, 1), Run: func(cmd *cobra.Command, args []string) { if len(args) == 0 { - env := env.Get() - if env == "" { + active := env.Get() + if active == "" { cmd.PrintErrln("No active environment set.") } else { - cmd.Println("Active environment:", env) + cmd.Println("Active environment:", active) } - } else if len(args) == 1 { - name := args[0] - if !env.Contains(name) { - cmd.PrintErrln("Environment not found:", name) - } else { - cmd.Println("Setting up environment:", name) - if err := env.Set(name); err != nil { - cmd.PrintErrln("Failed to switch environment:", err) - } - raid.ForceLoad() - if err := env.Execute(env.Get()); err != nil { - cmd.PrintErrln("Failed to execute environment:", err) - } else { - cmd.Println("Environment executed successfully.") - } - } - } else { - cmd.PrintErrln("Invalid number of arguments.") + return + } + + name := args[0] + if !env.Contains(name) { + cmd.PrintErrln("Environment not found:", name) + return + } + + cmd.Println("Setting up environment:", name) + if err := env.Set(name); err != nil { + cmd.PrintErrln("Failed to switch environment:", err) + return + } + if err := raid.ForceLoad(); err != nil { + cmd.PrintErrln("Failed to reload profile:", err) + return + } + if err := env.Execute(env.Get()); err != nil { + cmd.PrintErrln("Failed to execute environment:", err) + return } + cmd.Println("Environment executed successfully.") }, } diff --git a/src/cmd/env/list.go b/src/cmd/env/list.go index 3d85b74..a37fdc8 100644 --- a/src/cmd/env/list.go +++ b/src/cmd/env/list.go @@ -20,6 +20,5 @@ var ListEnvCmd = &cobra.Command{ for _, env := range envs { fmt.Printf("\t%s\n", env) } - fmt.Print() }, } diff --git a/src/cmd/install/install.go b/src/cmd/install/install.go index cce625d..8208a3a 100644 --- a/src/cmd/install/install.go +++ b/src/cmd/install/install.go @@ -7,9 +7,7 @@ import ( "github.com/spf13/cobra" ) -var ( - maxThreads int = 0 -) +var maxThreads int func init() { Command.Flags().IntVarP(&maxThreads, "threads", "t", 0, "Maximum number of concurrent threads (0 = unlimited)") diff --git a/src/cmd/profile/add.go b/src/cmd/profile/add.go index e38c63b..0c15280 100644 --- a/src/cmd/profile/add.go +++ b/src/cmd/profile/add.go @@ -52,10 +52,16 @@ var AddProfileCmd = &cobra.Command{ os.Exit(0) } - pro.AddAll(newProfiles) + if err := pro.AddAll(newProfiles); err != nil { + fmt.Printf("Failed to save profiles: %v\n", err) + os.Exit(1) + } if pro.Get().IsZero() { - pro.Set(newProfiles[0].Name) + if err := pro.Set(newProfiles[0].Name); err != nil { + fmt.Printf("Failed to set active profile: %v\n", err) + os.Exit(1) + } fmt.Printf("Profile '%s' set as active\n", newProfiles[0].Name) } @@ -68,6 +74,5 @@ var AddProfileCmd = &cobra.Command{ } 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 index fad7a1e..b13028b 100644 --- a/src/cmd/profile/list.go +++ b/src/cmd/profile/list.go @@ -27,6 +27,5 @@ var ListProfileCmd = &cobra.Command{ } fmt.Printf("\t%s%s\t%s\n", profile.Name, activeIndicator, profile.Path) } - fmt.Print() }, } diff --git a/src/cmd/profile/remove.go b/src/cmd/profile/remove.go index 61be14f..a594d24 100644 --- a/src/cmd/profile/remove.go +++ b/src/cmd/profile/remove.go @@ -21,7 +21,5 @@ var RemoveProfileCmd = &cobra.Command{ fmt.Printf("Profile '%s' has been removed.\n", name) } } - - fmt.Print() }, } diff --git a/src/cmd/raid.go b/src/cmd/raid.go index 95bbe9e..8347b34 100644 --- a/src/cmd/raid.go +++ b/src/cmd/raid.go @@ -1,7 +1,10 @@ package cmd import ( + "fmt" "log" + "os" + "strings" "github.com/8bitalex/raid/src/cmd/env" "github.com/8bitalex/raid/src/cmd/install" @@ -10,6 +13,16 @@ import ( "github.com/spf13/cobra" ) +// reservedNames are built-in cobra subcommands that custom commands cannot shadow. +var reservedNames = map[string]bool{ + "profile": true, + "install": true, + "env": true, + "help": true, + "version": true, + "completion": true, +} + var rootCmd = &cobra.Command{ Use: "raid", Version: "1.0.0-Alpha", @@ -19,7 +32,6 @@ var rootCmd = &cobra.Command{ } func init() { - cobra.OnInitialize(raid.Initialize) // Global Flags rootCmd.PersistentFlags().StringVarP(raid.ConfigPath, raid.ConfigPathFlag, raid.ConfigPathFlagShort, "", raid.ConfigPathFlagDesc) // Subcommands @@ -29,7 +41,51 @@ func init() { } func Execute() { + // Pre-initialize before cobra parses args so that profile commands can be + // registered as subcommands. cobra.OnInitialize runs after arg parsing, + // which is too late for dynamic subcommand registration. + applyConfigFlag(os.Args) + raid.Initialize() + + for _, cmd := range raid.GetCommands() { + if reservedNames[cmd.Name] { + fmt.Fprintf(os.Stderr, "warning: command '%s' conflicts with a built-in subcommand and will be ignored\n", cmd.Name) + continue + } + name := cmd.Name + rootCmd.AddCommand(&cobra.Command{ + Use: name, + Short: cmd.Usage, + RunE: func(c *cobra.Command, args []string) error { + return raid.ExecuteCommand(name) + }, + }) + } + if err := rootCmd.Execute(); err != nil { log.Fatalf("Failed to execute root command: %v", err) } } + +// applyConfigFlag scans args for --config / -c so the config path is set +// before the pre-initialization call, matching cobra's later flag parsing. +// Scanning stops at the first -- end-of-flags marker. +func applyConfigFlag(args []string) { + for i, arg := range args { + if arg == "--" { + return + } + if strings.HasPrefix(arg, "--config=") { + *raid.ConfigPath = strings.TrimPrefix(arg, "--config=") + return + } + if strings.HasPrefix(arg, "-c=") { + *raid.ConfigPath = strings.TrimPrefix(arg, "-c=") + return + } + if (arg == "--config" || arg == "-c") && i+1 < len(args) && !strings.HasPrefix(args[i+1], "-") { + *raid.ConfigPath = args[i+1] + return + } + } +} diff --git a/src/cmd/raid_test.go b/src/cmd/raid_test.go new file mode 100644 index 0000000..3efd12a --- /dev/null +++ b/src/cmd/raid_test.go @@ -0,0 +1,60 @@ +package cmd + +import ( + "testing" + + "github.com/8bitalex/raid/src/raid" +) + +func TestApplyConfigFlag(t *testing.T) { + tests := []struct { + name string + args []string + wantPath string + }{ + { + name: "no config flag", + args: []string{"raid", "install"}, + wantPath: "", + }, + { + name: "long flag with separate value", + args: []string{"raid", "--config", "/custom/config.toml", "install"}, + wantPath: "/custom/config.toml", + }, + { + name: "long flag with equals", + args: []string{"raid", "--config=/custom/config.toml"}, + wantPath: "/custom/config.toml", + }, + { + name: "short flag", + args: []string{"raid", "-c", "/custom/config.toml"}, + wantPath: "/custom/config.toml", + }, + { + name: "config flag at end with no value", + args: []string{"raid", "--config"}, + wantPath: "", + }, + { + name: "config flag before subcommand", + args: []string{"raid", "--config", "/path.toml", "env", "list"}, + wantPath: "/path.toml", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + old := *raid.ConfigPath + *raid.ConfigPath = "" + t.Cleanup(func() { *raid.ConfigPath = old }) + + applyConfigFlag(tt.args) + + if got := *raid.ConfigPath; got != tt.wantPath { + t.Errorf("applyConfigFlag() ConfigPath = %q, want %q", got, tt.wantPath) + } + }) + } +} diff --git a/src/internal/lib/command.go b/src/internal/lib/command.go new file mode 100644 index 0000000..1f67f50 --- /dev/null +++ b/src/internal/lib/command.go @@ -0,0 +1,100 @@ +package lib + +import ( + "fmt" + "io" + "os" + "path/filepath" + + "github.com/8bitalex/raid/src/internal/sys" +) + +// Command is a named, user-defined CLI command that can be invoked via 'raid '. +type Command struct { + Name string `json:"name"` + Usage string `json:"usage"` + Tasks []Task `json:"tasks"` + Out *Output `json:"out,omitempty"` +} + +// Output configures how a command's task output is handled. +// Stdout and Stderr default to true when Out is nil. +// When Out is set, only streams explicitly set to true are shown. +type Output struct { + Stdout bool `json:"stdout"` + Stderr bool `json:"stderr"` + File string `json:"file,omitempty"` +} + +// IsZero reports whether the command is uninitialized. +func (c Command) IsZero() bool { + return c.Name == "" +} + +// GetCommands returns all commands available in the active profile. +func GetCommands() []Command { + if context == nil { + return nil + } + return context.Profile.Commands +} + +// ExecuteCommand runs the tasks for the named command, applying any output configuration. +func ExecuteCommand(name string) error { + for _, cmd := range GetCommands() { + if cmd.Name == name { + return runCommand(cmd) + } + } + return fmt.Errorf("command '%s' not found", name) +} + +func runCommand(cmd Command) error { + if cmd.Out == nil { + return ExecuteTasks(cmd.Tasks) + } + + origOut, origErr := commandStdout, commandStderr + defer func() { + commandStdout = origOut + commandStderr = origErr + }() + + if !cmd.Out.Stdout { + commandStdout = io.Discard + } + if !cmd.Out.Stderr { + commandStderr = io.Discard + } + + if cmd.Out.File != "" { + expanded := sys.ExpandPath(cmd.Out.File) + if err := os.MkdirAll(filepath.Dir(expanded), 0755); err != nil { + return fmt.Errorf("failed to create output directory for '%s': %w", cmd.Out.File, err) + } + f, err := os.Create(expanded) + if err != nil { + return fmt.Errorf("failed to open output file '%s': %w", cmd.Out.File, err) + } + defer f.Close() + commandStdout = io.MultiWriter(commandStdout, f) + commandStderr = io.MultiWriter(commandStderr, f) + } + + return ExecuteTasks(cmd.Tasks) +} + +// mergeCommands merges additional into base. On name conflicts, base takes priority. +func mergeCommands(base, additional []Command) []Command { + existing := make(map[string]bool, len(base)) + for _, c := range base { + existing[c.Name] = true + } + result := append([]Command(nil), base...) + for _, c := range additional { + if !existing[c.Name] { + result = append(result, c) + } + } + return result +} diff --git a/src/internal/lib/command_test.go b/src/internal/lib/command_test.go new file mode 100644 index 0000000..a063e3c --- /dev/null +++ b/src/internal/lib/command_test.go @@ -0,0 +1,268 @@ +package lib + +import ( + "bytes" + "io" + "os" + "path/filepath" + "runtime" + "strings" + "testing" +) + +// --- Command.IsZero --- + +func TestCommandIsZero(t *testing.T) { + tests := []struct { + name string + cmd Command + want bool + }{ + {"empty", Command{}, true}, + {"name only", Command{Name: "build"}, false}, + {"name and usage", Command{Name: "build", Usage: "Build services"}, false}, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + if got := tt.cmd.IsZero(); got != tt.want { + t.Errorf("IsZero() = %v, want %v", got, tt.want) + } + }) + } +} + +// --- GetCommands --- + +func TestGetCommands_nilContext(t *testing.T) { + setupTestConfig(t) + if got := GetCommands(); got != nil { + t.Errorf("GetCommands() with nil context = %v, want nil", got) + } +} + +func TestGetCommands_withCommands(t *testing.T) { + setupTestConfig(t) + context = &Context{ + Profile: Profile{ + Commands: []Command{ + {Name: "build", Usage: "Build services"}, + {Name: "test", Usage: "Run tests"}, + }, + }, + } + + got := GetCommands() + if len(got) != 2 { + t.Fatalf("GetCommands() = %d commands, want 2", len(got)) + } + if got[0].Name != "build" || got[1].Name != "test" { + t.Errorf("GetCommands() names = %v/%v, want build/test", got[0].Name, got[1].Name) + } +} + +// --- ExecuteCommand --- + +func TestExecuteCommand_notFound(t *testing.T) { + setupTestConfig(t) + context = &Context{ + Profile: Profile{ + Commands: []Command{{Name: "other", Tasks: []Task{{Type: Shell, Cmd: "exit 0"}}}}, + }, + } + + if err := ExecuteCommand("nonexistent"); err == nil { + t.Fatal("ExecuteCommand() expected error for unknown command, got nil") + } +} + +func TestExecuteCommand_success(t *testing.T) { + setupTestConfig(t) + origOut := commandStdout + commandStdout = io.Discard + t.Cleanup(func() { commandStdout = origOut }) + + context = &Context{ + Profile: Profile{ + Commands: []Command{{Name: "noop", Tasks: []Task{{Type: Shell, Cmd: "exit 0"}}}}, + }, + } + + if err := ExecuteCommand("noop"); err != nil { + t.Errorf("ExecuteCommand() error: %v", err) + } +} + +func TestExecuteCommand_taskFailure(t *testing.T) { + setupTestConfig(t) + context = &Context{ + Profile: Profile{ + Commands: []Command{{Name: "fail", Tasks: []Task{{Type: Shell, Cmd: "exit 1"}}}}, + }, + } + + if err := ExecuteCommand("fail"); err == nil { + t.Fatal("ExecuteCommand() expected error from failing task, got nil") + } +} + +// --- runCommand --- + +func TestRunCommand_nilOut(t *testing.T) { + origOut := commandStdout + commandStdout = io.Discard + t.Cleanup(func() { commandStdout = origOut }) + + cmd := Command{Name: "noop", Tasks: []Task{{Type: Shell, Cmd: "exit 0"}}} + if err := runCommand(cmd); err != nil { + t.Errorf("runCommand() with nil Out error: %v", err) + } +} + +func TestRunCommand_suppressOutput(t *testing.T) { + // Replace writers with buffers so we can verify nothing was written. + bufOut := &bytes.Buffer{} + bufErr := &bytes.Buffer{} + origOut, origErr := commandStdout, commandStderr + commandStdout, commandStderr = bufOut, bufErr + t.Cleanup(func() { + commandStdout = origOut + commandStderr = origErr + }) + + cmd := Command{ + Name: "silent", + Tasks: []Task{{Type: Shell, Cmd: "echo suppressed"}}, + Out: &Output{Stdout: false, Stderr: false}, + } + if err := runCommand(cmd); err != nil { + t.Fatalf("runCommand() error: %v", err) + } + + if bufOut.Len() > 0 { + t.Errorf("commandStdout received %q, want nothing (stdout suppressed)", bufOut.String()) + } +} + +func TestRunCommand_restoresWriters(t *testing.T) { + // Set sentinel writers and confirm they are restored after runCommand. + bufOut := &bytes.Buffer{} + bufErr := &bytes.Buffer{} + origOut, origErr := commandStdout, commandStderr + commandStdout, commandStderr = bufOut, bufErr + t.Cleanup(func() { + commandStdout = origOut + commandStderr = origErr + }) + + cmd := Command{ + Name: "restore-check", + Tasks: []Task{{Type: Shell, Cmd: "exit 0"}}, + Out: &Output{Stdout: false, Stderr: false}, + } + _ = runCommand(cmd) + + if commandStdout != bufOut { + t.Error("commandStdout not restored to original writer after runCommand") + } + if commandStderr != bufErr { + t.Error("commandStderr not restored to original writer after runCommand") + } +} + +func TestRunCommand_fileOutput(t *testing.T) { + // Silence normal output so the test stays clean. + origOut, origErr := commandStdout, commandStderr + commandStdout, commandStderr = io.Discard, io.Discard + t.Cleanup(func() { + commandStdout = origOut + commandStderr = origErr + }) + + outFile := filepath.Join(t.TempDir(), "output.txt") + cmd := Command{ + Name: "file-out", + Tasks: []Task{{Type: Shell, Cmd: "echo filetest"}}, + Out: &Output{Stdout: true, Stderr: false, File: outFile}, + } + if err := runCommand(cmd); err != nil { + t.Fatalf("runCommand() error: %v", err) + } + + data, err := os.ReadFile(outFile) + if err != nil { + t.Fatalf("ReadFile(%q) error: %v", outFile, err) + } + if !strings.Contains(string(data), "filetest") { + t.Errorf("output file = %q, want to contain 'filetest'", string(data)) + } +} + +func TestRunCommand_fileCreateError(t *testing.T) { + if runtime.GOOS == "windows" { + t.Skip("permission-based path tricks don't apply on Windows") + } + // Use a path whose parent is a regular file, so MkdirAll fails. + dir := t.TempDir() + blockingFile := filepath.Join(dir, "not-a-dir") + if err := os.WriteFile(blockingFile, []byte{}, 0644); err != nil { + t.Fatal(err) + } + outFile := filepath.Join(blockingFile, "output.txt") + cmd := Command{ + Name: "bad-file", + Tasks: []Task{{Type: Shell, Cmd: "exit 0"}}, + Out: &Output{Stdout: true, File: outFile}, + } + if err := runCommand(cmd); err == nil { + t.Fatal("runCommand() expected error for bad output file path, got nil") + } +} + +// --- mergeCommands --- + +func TestMergeCommands(t *testing.T) { + a := Command{Name: "a", Usage: "from-a"} + b := Command{Name: "b"} + c := Command{Name: "c"} + aAlt := Command{Name: "a", Usage: "from-alt"} + + tests := []struct { + name string + base []Command + additional []Command + wantNames []string + wantUsage map[string]string // optional spot-check + }{ + {"both nil", nil, nil, nil, nil}, + {"empty additional", []Command{a, b}, nil, []string{"a", "b"}, nil}, + {"empty base", nil, []Command{c}, []string{"c"}, nil}, + {"no conflicts", []Command{a}, []Command{b}, []string{"a", "b"}, nil}, + { + "conflict: base wins", + []Command{a}, + []Command{aAlt, c}, + []string{"a", "c"}, + map[string]string{"a": "from-a"}, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got := mergeCommands(tt.base, tt.additional) + + if len(got) != len(tt.wantNames) { + t.Fatalf("mergeCommands() len = %d, want %d", len(got), len(tt.wantNames)) + } + for i, cmd := range got { + if cmd.Name != tt.wantNames[i] { + t.Errorf("result[%d].Name = %q, want %q", i, cmd.Name, tt.wantNames[i]) + } + if tt.wantUsage != nil { + if expected, ok := tt.wantUsage[cmd.Name]; ok && cmd.Usage != expected { + t.Errorf("result[%d].Usage = %q, want %q", i, cmd.Usage, expected) + } + } + } + }) + } +} diff --git a/src/internal/lib/config.go b/src/internal/lib/config.go index 0ee715f..27cb52e 100644 --- a/src/internal/lib/config.go +++ b/src/internal/lib/config.go @@ -1,6 +1,9 @@ package lib import ( + "fmt" + "path/filepath" + sys "github.com/8bitalex/raid/src/internal/sys" "github.com/spf13/viper" ) @@ -16,38 +19,46 @@ const ( var CfgPath string -var defaultConfigPath = sys.GetHomeDir() + sys.Sep + ConfigDirName + sys.Sep +var defaultConfigPath = filepath.Join(sys.GetHomeDir(), ConfigDirName) func InitConfig() error { - viper.SetConfigFile(getOrCreateConfigFile()) + path, err := getOrCreateConfigFile() + if err != nil { + return err + } + viper.SetConfigFile(path) if err := viper.ReadInConfig(); err != nil { return err } return nil } -func getOrCreateConfigFile() string { - path := getPath() +func getOrCreateConfigFile() (string, error) { + path := sys.ExpandPath(getPath()) if !sys.FileExists(path) { - sys.CreateFile(path) + f, err := sys.CreateFile(path) + if err != nil { + return "", fmt.Errorf("failed to create config file at %s: %w", path, err) + } + f.Close() } - return path + return path, nil } func getPath() string { if CfgPath == "" { - CfgPath = defaultConfigPath + ConfigFileName + CfgPath = filepath.Join(defaultConfigPath, ConfigFileName) } return CfgPath } -func Set(key string, value any) { +// Set stores key in the viper config and persists it to disk. +func Set(key string, value any) error { viper.Set(key, value) - Write() + return Write() } -func Write() { - if err := viper.WriteConfig(); err != nil { - panic(err) - } +// Write persists the current viper config to disk. +func Write() error { + return viper.WriteConfig() } diff --git a/src/internal/lib/config_test.go b/src/internal/lib/config_test.go new file mode 100644 index 0000000..785beca --- /dev/null +++ b/src/internal/lib/config_test.go @@ -0,0 +1,103 @@ +package lib + +import ( + "os" + "path/filepath" + "testing" + + "github.com/spf13/viper" +) + +// setupTestConfig initializes a fresh viper config backed by a temp file and +// registers cleanup to restore global state after the test. +func setupTestConfig(t *testing.T) { + t.Helper() + dir := t.TempDir() + configPath := filepath.Join(dir, "config.toml") + + oldCfgPath := CfgPath + oldContext := context + t.Cleanup(func() { + CfgPath = oldCfgPath + context = oldContext + viper.Reset() + }) + + CfgPath = configPath + context = nil + + if err := InitConfig(); err != nil { + t.Fatalf("setupTestConfig: InitConfig() error: %v", err) + } +} + +func TestInitConfig_createsAndReadsConfig(t *testing.T) { + dir := t.TempDir() + configPath := filepath.Join(dir, "config.toml") + + oldCfgPath := CfgPath + t.Cleanup(func() { + CfgPath = oldCfgPath + viper.Reset() + }) + CfgPath = configPath + + if err := InitConfig(); err != nil { + t.Fatalf("InitConfig() error: %v", err) + } + + if _, err := os.Stat(configPath); err != nil { + t.Errorf("InitConfig() did not create config file: %v", err) + } +} + +func TestInitConfig_existingConfig(t *testing.T) { + dir := t.TempDir() + configPath := filepath.Join(dir, "config.toml") + + if err := os.WriteFile(configPath, []byte(""), 0644); err != nil { + t.Fatal(err) + } + + oldCfgPath := CfgPath + t.Cleanup(func() { + CfgPath = oldCfgPath + viper.Reset() + }) + CfgPath = configPath + + if err := InitConfig(); err != nil { + t.Fatalf("InitConfig() on existing file error: %v", err) + } +} + +func TestInitConfig_invalidTOML(t *testing.T) { + dir := t.TempDir() + configPath := filepath.Join(dir, "config.toml") + if err := os.WriteFile(configPath, []byte("invalid = [toml"), 0644); err != nil { + t.Fatal(err) + } + + oldCfgPath := CfgPath + t.Cleanup(func() { + CfgPath = oldCfgPath + viper.Reset() + }) + CfgPath = configPath + + if err := InitConfig(); err == nil { + t.Fatal("InitConfig() expected error for invalid TOML, got nil") + } +} + +func TestSet_persistsKeyInViper(t *testing.T) { + setupTestConfig(t) + + if err := Set("testkey", "testvalue"); err != nil { + t.Fatalf("Set() error: %v", err) + } + + if got := viper.GetString("testkey"); got != "testvalue" { + t.Errorf("Set() did not persist key: got %q, want %q", got, "testvalue") + } +} diff --git a/src/internal/lib/env.go b/src/internal/lib/env.go index 84bf6ea..ef4f264 100644 --- a/src/internal/lib/env.go +++ b/src/internal/lib/env.go @@ -2,43 +2,48 @@ package lib import ( "fmt" + "path/filepath" sys "github.com/8bitalex/raid/src/internal/sys" "github.com/joho/godotenv" "github.com/spf13/viper" ) -const ( - ACTIVE_ENV_KEY = "env" -) +const activeEnvKey = "env" +// Env represents a named environment with variables and tasks. type Env struct { - Name string `json:"name"` - Variables []EnvVar `json:"variables"` + Name string `json:"name"` + Variables []EnvVar `json:"variables"` + Tasks []Task `json:"tasks"` } +// IsZero reports whether the environment is uninitialized. func (e Env) IsZero() bool { return e.Name == "" } +// EnvVar is a key/value pair written into a repository's .env file. type EnvVar struct { Name string `json:"name"` Value string `json:"value"` } +// SetEnv sets the named environment as the active environment. func SetEnv(name string) error { if name == "" || !ContainsEnv(name) { return fmt.Errorf("environment '%s' not found", name) } - Set(ACTIVE_ENV_KEY, name) - return nil + return Set(activeEnvKey, name) } +// GetEnv returns the name of the currently active environment. func GetEnv() string { - return viper.GetString(ACTIVE_ENV_KEY) + return viper.GetString(activeEnvKey) } +// ListEnvs returns the names of all environments in the active profile. func ListEnvs() []string { if context == nil || len(context.Profile.Environments) == 0 { return []string{} @@ -51,6 +56,7 @@ func ListEnvs() []string { return names } +// ContainsEnv reports whether an environment with the given name exists in the active profile. func ContainsEnv(name string) bool { for _, envName := range ListEnvs() { if envName == name { @@ -60,7 +66,21 @@ func ContainsEnv(name string) bool { return false } +// ExecuteEnv writes environment variables to each repo's .env file and runs the environment's tasks. func ExecuteEnv(name string) error { + if context == nil { + return fmt.Errorf("raid context is not initialized") + } + if err := setEnvVariablesForRepos(name); err != nil { + return fmt.Errorf("failed to set env variables: %w", err) + } + if err := runTasksForEnv(name); err != nil { + return fmt.Errorf("failed to run env tasks: %w", err) + } + return nil +} + +func setEnvVariablesForRepos(name string) error { for _, repo := range context.Profile.Repositories { fmt.Printf("Setting up environment for repo: %s\n", repo.Name) @@ -69,11 +89,7 @@ func ExecuteEnv(name string) error { return fmt.Errorf("invalid path for repo '%s': %w", repo.Name, err) } - pEnv := context.Profile.getEnv(name) - rEnv := repo.getEnv(name) - - err = setEnvVariables(pEnv.Variables, rEnv.Variables, path) - if err != nil { + if err := setEnvVariables(context.Profile.getEnv(name).Variables, repo.getEnv(name).Variables, path); err != nil { return fmt.Errorf("failed to set env variables for repo '%s': %w", repo.Name, err) } } @@ -81,14 +97,13 @@ func ExecuteEnv(name string) error { } func buildEnvPath(path string) (string, error) { - filepath := sys.ExpandPath(path) + sys.Sep + ".env" - // create file if it does not exist - file, err := sys.CreateFile(filepath) + filePath := filepath.Join(sys.ExpandPath(path), ".env") + file, err := sys.CreateFile(filePath) if err != nil { return "", err } file.Close() - return filepath, nil + return filePath, nil } func setEnvVariables(profVars []EnvVar, repoVars []EnvVar, path string) error { @@ -100,16 +115,41 @@ func setEnvVariables(profVars []EnvVar, repoVars []EnvVar, path string) error { for _, v := range profVars { envMap[v.Name] = v.Value } - for _, v := range repoVars { - fmt.Printf("Setting variable %s=%s\n", v.Name, v.Value) envMap[v.Name] = v.Value } - err = godotenv.Write(envMap, path) - if err != nil { - return err + return godotenv.Write(envMap, path) +} + +func runTasksForEnv(name string) error { + env := context.Profile.getEnv(name) + if env.IsZero() || len(env.Tasks) == 0 { + return nil } - return nil + return ExecuteTasks(withDefaultDir(env.Tasks, sys.GetHomeDir())) } +// LoadEnv loads .env files from all repositories in the active profile into the process environment. +func LoadEnv() error { + if context == nil { + return fmt.Errorf("context not initialized") + } + + var paths []string + for _, r := range context.Profile.Repositories { + p := filepath.Join(sys.ExpandPath(r.Path), ".env") + if sys.FileExists(p) { + paths = append(paths, p) + } + } + + if len(paths) == 0 { + return nil + } + + if err := godotenv.Load(paths...); err != nil { + return fmt.Errorf("failed to load env files: %w", err) + } + return nil +} diff --git a/src/internal/lib/env_test.go b/src/internal/lib/env_test.go new file mode 100644 index 0000000..2e88d28 --- /dev/null +++ b/src/internal/lib/env_test.go @@ -0,0 +1,378 @@ +package lib + +import ( + "os" + "path/filepath" + "testing" +) + +func TestEnvIsZero(t *testing.T) { + tests := []struct { + name string + env Env + want bool + }{ + {"empty env", Env{}, true}, + {"named env", Env{Name: "dev"}, false}, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + if got := tt.env.IsZero(); got != tt.want { + t.Errorf("IsZero() = %v, want %v", got, tt.want) + } + }) + } +} + +func TestListEnvs_nilContext(t *testing.T) { + setupTestConfig(t) + + envs := ListEnvs() + if len(envs) != 0 { + t.Errorf("ListEnvs() with nil context = %v, want empty slice", envs) + } +} + +func TestListEnvs_emptyEnvironments(t *testing.T) { + setupTestConfig(t) + + context = &Context{ + Profile: Profile{Name: "test", Path: "/path"}, + } + + envs := ListEnvs() + if len(envs) != 0 { + t.Errorf("ListEnvs() with no environments = %v, want empty slice", envs) + } +} + +func TestListEnvs_withEnvironments(t *testing.T) { + setupTestConfig(t) + + context = &Context{ + Profile: Profile{ + Environments: []Env{ + {Name: "dev"}, + {Name: "prod"}, + }, + }, + } + + envs := ListEnvs() + if len(envs) != 2 { + t.Fatalf("ListEnvs() = %v, want 2 environments", envs) + } +} + +func TestContainsEnv(t *testing.T) { + setupTestConfig(t) + + context = &Context{ + Profile: Profile{ + Environments: []Env{ + {Name: "dev"}, + }, + }, + } + + if !ContainsEnv("dev") { + t.Error("ContainsEnv(\"dev\") = false, want true") + } + if ContainsEnv("prod") { + t.Error("ContainsEnv(\"prod\") = true, want false") + } +} + +func TestSetEnv_emptyName(t *testing.T) { + setupTestConfig(t) + + err := SetEnv("") + if err == nil { + t.Fatal("SetEnv(\"\") expected error, got nil") + } +} + +func TestSetEnv_notFound(t *testing.T) { + setupTestConfig(t) + + context = &Context{ + Profile: Profile{ + Environments: []Env{{Name: "dev"}}, + }, + } + + err := SetEnv("nonexistent") + if err == nil { + t.Fatal("SetEnv() expected error for nonexistent env") + } +} + +func TestSetAndGetEnv(t *testing.T) { + setupTestConfig(t) + + context = &Context{ + Profile: Profile{ + Environments: []Env{{Name: "dev"}}, + }, + } + + if err := SetEnv("dev"); err != nil { + t.Fatalf("SetEnv() error: %v", err) + } + + got := GetEnv() + if got != "dev" { + t.Errorf("GetEnv() = %q, want %q", got, "dev") + } +} + +func TestExecuteEnv_buildEnvPathError(t *testing.T) { + setupTestConfig(t) + + // Use a regular file as the repo path; .env cannot be created inside a file. + tmpFile, err := os.CreateTemp("", "raid-test-*") + if err != nil { + t.Fatal(err) + } + tmpFile.Close() + defer os.Remove(tmpFile.Name()) + + context = &Context{ + Profile: Profile{ + Name: "test", + Path: "/test", + Repositories: []Repo{ + {Name: "repo1", Path: tmpFile.Name(), URL: "http://x.com"}, + }, + }, + } + + err = ExecuteEnv("dev") + if err == nil { + t.Fatal("ExecuteEnv() expected error when buildEnvPath fails") + } +} + +func TestExecuteEnv_taskFailure(t *testing.T) { + setupTestConfig(t) + + dir := t.TempDir() + + context = &Context{ + Profile: Profile{ + Name: "test", + Path: "/test", + Repositories: []Repo{ + {Name: "repo1", Path: dir, URL: "http://x.com"}, + }, + Environments: []Env{ + { + Name: "dev", + Tasks: []Task{{Type: Shell, Cmd: "exit 1"}}, + }, + }, + }, + } + + err := ExecuteEnv("dev") + if err == nil { + t.Fatal("ExecuteEnv() expected error from failing task") + } +} + +func TestExecuteEnv_success(t *testing.T) { + setupTestConfig(t) + + dir := t.TempDir() + + context = &Context{ + Profile: Profile{ + Name: "test", + Path: "/test", + Repositories: []Repo{ + {Name: "repo1", Path: dir, URL: "http://x.com"}, + }, + Environments: []Env{ + { + Name: "dev", + Variables: []EnvVar{{Name: "APP_ENV", Value: "development"}}, + }, + }, + }, + } + + if err := ExecuteEnv("dev"); err != nil { + t.Errorf("ExecuteEnv() error: %v", err) + } +} + +func TestExecuteEnv_noMatchingEnvTasks(t *testing.T) { + setupTestConfig(t) + + dir := t.TempDir() + + context = &Context{ + Profile: Profile{ + Name: "test", + Path: "/test", + Repositories: []Repo{ + {Name: "repo1", Path: dir, URL: "http://x.com"}, + }, + // No environments defined β€” env name won't match anything. + }, + } + + // Runs setEnvVariablesForRepos (empty vars) then runTasksForEnv (zero env, returns nil). + if err := ExecuteEnv("nonexistent"); err != nil { + t.Errorf("ExecuteEnv() with no matching env error: %v", err) + } +} + +func TestLoadEnv_nilContext(t *testing.T) { + context = nil + + err := LoadEnv() + if err == nil { + t.Fatal("LoadEnv() expected error for nil context") + } +} + +func TestLoadEnv_noEnvFiles(t *testing.T) { + setupTestConfig(t) + + context = &Context{ + Profile: Profile{ + Repositories: []Repo{ + {Name: "repo1", Path: "/nonexistent/path"}, + }, + }, + } + + if err := LoadEnv(); err != nil { + t.Errorf("LoadEnv() with no .env files error: %v", err) + } +} + +func TestLoadEnv_withEnvFiles(t *testing.T) { + setupTestConfig(t) + + dir := t.TempDir() + envFile := filepath.Join(dir, ".env") + if err := os.WriteFile(envFile, []byte("RAID_LOAD_TEST=hello\n"), 0644); err != nil { + t.Fatal(err) + } + defer os.Unsetenv("RAID_LOAD_TEST") + + context = &Context{ + Profile: Profile{ + Repositories: []Repo{ + {Name: "repo1", Path: dir}, + }, + }, + } + + if err := LoadEnv(); err != nil { + t.Errorf("LoadEnv() with .env files error: %v", err) + } +} + +func TestExecuteEnv_repoEnvVars(t *testing.T) { + setupTestConfig(t) + + dir := t.TempDir() + + context = &Context{ + Profile: Profile{ + Name: "test", + Path: "/test", + Repositories: []Repo{ + { + Name: "repo1", + Path: dir, + URL: "http://x.com", + // Repo has its own env vars for "dev" β€” exercises the repoVars loop. + Environments: []Env{ + { + Name: "dev", + Variables: []EnvVar{{Name: "REPO_SPECIFIC", Value: "repo_val"}}, + }, + }, + }, + }, + Environments: []Env{ + { + Name: "dev", + Variables: []EnvVar{{Name: "PROFILE_VAR", Value: "prof_val"}}, + }, + }, + }, + } + + if err := ExecuteEnv("dev"); err != nil { + t.Errorf("ExecuteEnv() with repo env vars error: %v", err) + } +} + +func TestExecuteEnv_setEnvWriteError(t *testing.T) { + setupTestConfig(t) + + dir := t.TempDir() + // Pre-create .env as read-only so godotenv.Write fails. + envPath := filepath.Join(dir, ".env") + if err := os.WriteFile(envPath, []byte(""), 0444); err != nil { + t.Fatal(err) + } + defer os.Chmod(envPath, 0644) + + context = &Context{ + Profile: Profile{ + Name: "test", + Path: "/test", + Repositories: []Repo{ + {Name: "repo1", Path: dir, URL: "http://x.com"}, + }, + Environments: []Env{ + {Name: "dev", Variables: []EnvVar{{Name: "KEY", Value: "val"}}}, + }, + }, + } + + if err := ExecuteEnv("dev"); err == nil { + t.Fatal("ExecuteEnv() expected error when .env is read-only") + } +} + +func TestLoadEnv_loadFailure(t *testing.T) { + setupTestConfig(t) + + dir := t.TempDir() + // Create a directory named .env β€” godotenv.Load will fail reading it as a file. + fakeEnvPath := filepath.Join(dir, ".env") + if err := os.MkdirAll(fakeEnvPath, 0755); err != nil { + t.Fatal(err) + } + + context = &Context{ + Profile: Profile{ + Repositories: []Repo{ + {Name: "repo1", Path: dir}, + }, + }, + } + + if err := LoadEnv(); err == nil { + t.Fatal("LoadEnv() expected error when .env is a directory") + } +} + +func TestLoadEnv_emptyRepositories(t *testing.T) { + setupTestConfig(t) + + context = &Context{ + Profile: Profile{}, + } + + if err := LoadEnv(); err != nil { + t.Errorf("LoadEnv() with empty repositories error: %v", err) + } +} diff --git a/src/internal/lib/lib.go b/src/internal/lib/lib.go index e249d9b..0efad9e 100644 --- a/src/internal/lib/lib.go +++ b/src/internal/lib/lib.go @@ -2,21 +2,43 @@ package lib import ( + "bytes" + "embed" + "encoding/json" "fmt" + "io" + "os" + "path/filepath" + "strings" "sync" + + sys "github.com/8bitalex/raid/src/internal/sys" + "github.com/santhosh-tekuri/jsonschema/v6" + "gopkg.in/yaml.v3" ) +//go:embed schemas/*.json +var schemaFS embed.FS + const ( - YAML_SEP = "---" + yamlSep = "---" + RaidConfigFileName = "raid.yaml" ) +// Context holds the active profile and environment for the current raid session. type Context struct { Profile Profile Env string } +// OnInstall holds the tasks to run during profile installation. +type OnInstall struct { + Tasks []Task `json:"tasks"` +} + var context *Context +// Load initializes the context from the active profile, using cached results if available. func Load() error { if context == nil { return ForceLoad() @@ -24,12 +46,39 @@ func Load() error { return nil } +// ForceLoad rebuilds the context from the active profile, ignoring any cached state. func ForceLoad() error { - profile, err := buildProfile(GetProfile()) + p := GetProfile() + if p.IsZero() { + context = &Context{Env: GetEnv()} + return nil + } + + profile, err := buildProfile(p) if err != nil { return err } + homeDir := sys.GetHomeDir() + for i := range profile.Commands { + profile.Commands[i].Tasks = withDefaultDir(profile.Commands[i].Tasks, homeDir) + } + for name, tasks := range profile.Groups { + profile.Groups[name] = withDefaultDir(tasks, homeDir) + } + + for i := range profile.Repositories { + if err := buildRepo(&profile.Repositories[i]); err != nil { + return err + } + repo := &profile.Repositories[i] + repoDir := sys.ExpandPath(repo.Path) + for j := range repo.Commands { + repo.Commands[j].Tasks = withDefaultDir(repo.Commands[j].Tasks, repoDir) + } + profile.Commands = mergeCommands(profile.Commands, repo.Commands) + } + context = &Context{ Profile: profile, Env: GetEnv(), @@ -37,7 +86,11 @@ func ForceLoad() error { return nil } +// Install clones all repositories in the active profile and runs install tasks. func Install(maxThreads int) error { + if context == nil { + return fmt.Errorf("raid context is not initialized") + } profile := context.Profile if profile.IsZero() { return fmt.Errorf("profile not found") @@ -79,5 +132,152 @@ func Install(maxThreads int) error { return fmt.Errorf("some repositories failed to install: %v", errors) } + if err := ExecuteTasks(withDefaultDir(profile.Install.Tasks, sys.GetHomeDir())); err != nil { + return fmt.Errorf("failed to execute install tasks: %w", err) + } + + var repoTasks []Task + for _, r := range profile.Repositories { + repoTasks = append(repoTasks, withDefaultDir(r.Install.Tasks, sys.ExpandPath(r.Path))...) + } + if err := ExecuteTasks(repoTasks); err != nil { + return fmt.Errorf("failed to execute repository install tasks: %w", err) + } + + return nil +} + +// ValidateSchema validates the file at path against the JSON schema at schemaPath. +// schemaPath must be an absolute or CWD-relative path to a schema file on disk. +func ValidateSchema(path string, schemaPath string) error { + path = sys.ExpandPath(path) + schemaPath = sys.ExpandPath(schemaPath) + + if path == "" || !sys.FileExists(path) { + return fmt.Errorf("file not found at %s", path) + } + if schemaPath == "" || !sys.FileExists(schemaPath) { + return fmt.Errorf("file not found at %s", schemaPath) + } + + c := jsonschema.NewCompiler() + sch, err := c.Compile(schemaPath) + if err != nil { + return err + } + + return validateFile(path, sch) +} + +// validateWithEmbeddedSchema validates path against a schema embedded in the binary. +// schemaName must be the bare filename of a schema in the embedded schemas directory +// (e.g. "raid-profile.schema.json"). All embedded schemas are registered so that +// cross-schema $ref values resolve correctly. +func validateWithEmbeddedSchema(path, schemaName string) error { + path = sys.ExpandPath(path) + if path == "" || !sys.FileExists(path) { + return fmt.Errorf("file not found at %s", path) + } + + c := jsonschema.NewCompiler() + entries, err := schemaFS.ReadDir("schemas") + if err != nil { + return fmt.Errorf("failed to read embedded schemas: %w", err) + } + for _, entry := range entries { + data, err := schemaFS.ReadFile("schemas/" + entry.Name()) + if err != nil { + return fmt.Errorf("failed to read embedded schema %s: %w", entry.Name(), err) + } + var doc any + if err := json.Unmarshal(data, &doc); err != nil { + return fmt.Errorf("failed to parse embedded schema %s: %w", entry.Name(), err) + } + if err := c.AddResource(entry.Name(), doc); err != nil { + return fmt.Errorf("failed to register embedded schema %s: %w", entry.Name(), err) + } + } + + sch, err := c.Compile(schemaName) + if err != nil { + return err + } + + return validateFile(path, sch) +} + +func validateFile(path string, sch *jsonschema.Schema) error { + f, err := os.Open(path) + if err != nil { + return err + } + defer f.Close() + + ext := strings.ToLower(filepath.Ext(path)) + if ext == ".yaml" || ext == ".yml" { + // Validate every document in a multi-doc YAML stream individually so + // that profile files using --- separators are fully validated. + dec := yaml.NewDecoder(f) + count := 0 + for { + var raw any + if err := dec.Decode(&raw); err != nil { + if err == io.EOF { + break + } + return err + } + count++ + jsonBytes, err := json.Marshal(raw) + if err != nil { + return err + } + doc, err := jsonschema.UnmarshalJSON(bytes.NewReader(jsonBytes)) + if err != nil { + return err + } + if err := sch.Validate(doc); err != nil { + return fmt.Errorf("invalid format: %w", err) + } + } + if count == 0 { + return fmt.Errorf("invalid format: file contains no YAML documents") + } + return nil + } + + data, err := io.ReadAll(f) + if err != nil { + return err + } + + // Detect a top-level JSON array and validate each element individually, + // mirroring how extractProfilesFromJSON supports both single-object and + // array-of-objects JSON profile files. + var arr []any + if json.Unmarshal(data, &arr) == nil { + for _, elem := range arr { + jsonBytes, err := json.Marshal(elem) + if err != nil { + return err + } + doc, err := jsonschema.UnmarshalJSON(bytes.NewReader(jsonBytes)) + if err != nil { + return err + } + if err := sch.Validate(doc); err != nil { + return fmt.Errorf("invalid format: %w", err) + } + } + return nil + } + + doc, err := jsonschema.UnmarshalJSON(bytes.NewReader(data)) + if err != nil { + return err + } + if err := sch.Validate(doc); err != nil { + return fmt.Errorf("invalid format: %w", err) + } return nil } diff --git a/src/internal/lib/lib_test.go b/src/internal/lib/lib_test.go new file mode 100644 index 0000000..e36310e --- /dev/null +++ b/src/internal/lib/lib_test.go @@ -0,0 +1,439 @@ +package lib + +import ( + "os" + "path/filepath" + "testing" +) + +// repoRoot walks up from the package directory to find the repository root +// (identified by a schemas/ subdirectory). +func repoRoot(t *testing.T) string { + t.Helper() + wd, err := os.Getwd() + if err != nil { + t.Fatal(err) + } + dir := wd + for { + if fi, err := os.Stat(filepath.Join(dir, "schemas")); err == nil && fi.IsDir() { + return dir + } + parent := filepath.Dir(dir) + if parent == dir { + t.Fatal("could not find repo root (no schemas/ dir found)") + } + dir = parent + } +} + +func TestConditionIsZero(t *testing.T) { + tests := []struct { + name string + c Condition + want bool + }{ + {"all empty", Condition{}, true}, + {"platform set", Condition{Platform: "linux"}, false}, + {"exists set", Condition{Exists: "/tmp"}, false}, + {"cmd set", Condition{Cmd: "exit 0"}, false}, + {"all set", Condition{Platform: "linux", Exists: "/tmp", Cmd: "exit 0"}, false}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + if got := tt.c.IsZero(); got != tt.want { + t.Errorf("IsZero() = %v, want %v", got, tt.want) + } + }) + } +} + +func TestLoad_noContext(t *testing.T) { + setupTestConfig(t) + + if err := Load(); err != nil { + t.Errorf("Load() error: %v", err) + } +} + +func TestLoad_withExistingContext(t *testing.T) { + setupTestConfig(t) + + context = &Context{Profile: Profile{Name: "test", Path: "/path"}} + + if err := Load(); err != nil { + t.Errorf("Load() with existing context error: %v", err) + } + // Should not reload β€” cached context must be preserved. + if context.Profile.Name != "test" { + t.Errorf("Load() modified existing context unexpectedly") + } +} + +func TestForceLoad_buildProfileError(t *testing.T) { + setupTestConfig(t) + + dir := t.TempDir() + profilePath := filepath.Join(dir, "profile.yaml") + // badfield violates additionalProperties:false in the profile schema. + os.WriteFile(profilePath, []byte("name: test\nbadfield: invalid"), 0644) + + if err := AddProfile(Profile{Name: "test", Path: profilePath}); err != nil { + t.Fatalf("AddProfile() error: %v", err) + } + if err := SetProfile("test"); err != nil { + t.Fatalf("SetProfile() error: %v", err) + } + + if err := ForceLoad(); err == nil { + t.Fatal("ForceLoad() expected error when buildProfile fails") + } +} + +func TestForceLoad_buildRepoError(t *testing.T) { + root := repoRoot(t) + setupTestConfig(t) + + dir := t.TempDir() + repoDir := filepath.Join(dir, "repo") + os.MkdirAll(repoDir, 0755) + + // Invalid raid.yaml: missing "branch" (required) and has extra field (additionalProperties:false). + os.WriteFile(filepath.Join(repoDir, RaidConfigFileName), []byte("name: myrepo\nextrafield: bad"), 0644) + + profilePath := filepath.Join(dir, "profile.yaml") + content := "name: buildrepoerr\nrepositories:\n - name: myrepo\n path: " + repoDir + "\n url: http://example.com/repo.git\n" + os.WriteFile(profilePath, []byte(content), 0644) + + wd, _ := os.Getwd() + os.Chdir(root) + defer os.Chdir(wd) + + if err := AddProfile(Profile{Name: "buildrepoerr", Path: profilePath}); err != nil { + t.Fatalf("AddProfile() error: %v", err) + } + if err := SetProfile("buildrepoerr"); err != nil { + t.Fatalf("SetProfile() error: %v", err) + } + + if err := ForceLoad(); err == nil { + t.Fatal("ForceLoad() expected error when buildRepo fails") + } +} + +func TestForceLoad_noActiveProfile(t *testing.T) { + setupTestConfig(t) + + if err := ForceLoad(); err != nil { + t.Errorf("ForceLoad() with no active profile error: %v", err) + } + if context == nil { + t.Fatal("ForceLoad() did not set context") + } +} + +func TestForceLoad_withValidProfile(t *testing.T) { + root := repoRoot(t) + setupTestConfig(t) + + // Build a valid profile file in a temp directory with a fake git repo inside. + dir := t.TempDir() + repoDir := filepath.Join(dir, "myrepo") + os.MkdirAll(filepath.Join(repoDir, ".git"), 0755) + + profilePath := filepath.Join(dir, "profile.yaml") + content := "name: testprofile\nrepositories:\n - name: myrepo\n path: " + repoDir + "\n url: http://example.com/repo.git\n" + if err := os.WriteFile(profilePath, []byte(content), 0644); err != nil { + t.Fatal(err) + } + + // Schema resolution requires the CWD to be the repo root. + wd, _ := os.Getwd() + if err := os.Chdir(root); err != nil { + t.Fatal(err) + } + defer os.Chdir(wd) + + if err := AddProfile(Profile{Name: "testprofile", Path: profilePath}); err != nil { + t.Fatalf("AddProfile() error: %v", err) + } + if err := SetProfile("testprofile"); err != nil { + t.Fatalf("SetProfile() error: %v", err) + } + + if err := ForceLoad(); err != nil { + t.Errorf("ForceLoad() with valid profile error: %v", err) + } + if context == nil || context.Profile.Name != "testprofile" { + t.Errorf("ForceLoad() context = %v, want profile named testprofile", context) + } +} + +func TestValidateSchema_missingFile(t *testing.T) { + err := ValidateSchema("/nonexistent/file.yaml", "/nonexistent/schema.json") + if err == nil { + t.Fatal("ValidateSchema() expected error for missing file") + } +} + +func TestValidateSchema_missingSchema(t *testing.T) { + dir := t.TempDir() + path := filepath.Join(dir, "file.yaml") + os.WriteFile(path, []byte("name: test"), 0644) + + err := ValidateSchema(path, "/nonexistent/schema.json") + if err == nil { + t.Fatal("ValidateSchema() expected error for missing schema") + } +} + +func TestValidateSchema_emptyPaths(t *testing.T) { + err := ValidateSchema("", "") + if err == nil { + t.Fatal("ValidateSchema() expected error for empty paths") + } +} + +func TestValidateSchema_badSchemaFile(t *testing.T) { + dir := t.TempDir() + dataPath := filepath.Join(dir, "file.json") + os.WriteFile(dataPath, []byte(`{"name": "test"}`), 0644) + + schemaPath := filepath.Join(dir, "bad-schema.json") + os.WriteFile(schemaPath, []byte(`not valid json`), 0644) + + err := ValidateSchema(dataPath, schemaPath) + if err == nil { + t.Fatal("ValidateSchema() expected error for malformed schema file") + } +} + +func TestValidateSchema_invalidJSONContent(t *testing.T) { + dir := t.TempDir() + schemaPath := filepath.Join(dir, "schema.json") + os.WriteFile(schemaPath, []byte(`{"$schema":"https://json-schema.org/draft/2020-12/schema","type":"object"}`), 0644) + + dataPath := filepath.Join(dir, "bad.json") + os.WriteFile(dataPath, []byte(`not valid json`), 0644) + + err := ValidateSchema(dataPath, schemaPath) + if err == nil { + t.Fatal("ValidateSchema() expected error for invalid JSON content") + } +} + +func TestValidateSchema_invalidYAMLContent(t *testing.T) { + dir := t.TempDir() + schemaPath := filepath.Join(dir, "schema.json") + os.WriteFile(schemaPath, []byte(`{"$schema":"https://json-schema.org/draft/2020-12/schema","type":"object"}`), 0644) + + dataPath := filepath.Join(dir, "invalid.yaml") + os.WriteFile(dataPath, []byte("key: [unclosed"), 0644) + + err := ValidateSchema(dataPath, schemaPath) + if err == nil { + t.Fatal("ValidateSchema() expected error for invalid YAML content") + } +} + +func TestValidateSchema_validYAML(t *testing.T) { + dir := t.TempDir() + schemaPath := filepath.Join(dir, "schema.json") + os.WriteFile(schemaPath, []byte(`{"$schema":"https://json-schema.org/draft/2020-12/schema","type":"object","required":["name"],"properties":{"name":{"type":"string"}}}`), 0644) + + dataPath := filepath.Join(dir, "profile.yaml") + os.WriteFile(dataPath, []byte("name: myprofile"), 0644) + + if err := ValidateSchema(dataPath, schemaPath); err != nil { + t.Errorf("ValidateSchema() on valid YAML error: %v", err) + } +} + +func TestValidateSchema_validJSON(t *testing.T) { + dir := t.TempDir() + schemaPath := filepath.Join(dir, "schema.json") + os.WriteFile(schemaPath, []byte(`{"$schema":"https://json-schema.org/draft/2020-12/schema","type":"object","required":["name"],"properties":{"name":{"type":"string"}}}`), 0644) + + dataPath := filepath.Join(dir, "profile.json") + os.WriteFile(dataPath, []byte(`{"name":"myprofile"}`), 0644) + + if err := ValidateSchema(dataPath, schemaPath); err != nil { + t.Errorf("ValidateSchema() on valid JSON error: %v", err) + } +} + +func TestValidateSchema_schemaViolation(t *testing.T) { + dir := t.TempDir() + schemaPath := filepath.Join(dir, "schema.json") + // Require "name" field and disallow additional properties. + os.WriteFile(schemaPath, []byte(`{"$schema":"https://json-schema.org/draft/2020-12/schema","type":"object","required":["name"],"additionalProperties":false,"properties":{"name":{"type":"string"}}}`), 0644) + + dataPath := filepath.Join(dir, "bad.yaml") + os.WriteFile(dataPath, []byte("unknown: field"), 0644) + + err := ValidateSchema(dataPath, schemaPath) + if err == nil { + t.Fatal("ValidateSchema() expected error for schema violation") + } +} + +func TestInstall_noProfile(t *testing.T) { + setupTestConfig(t) + + context = &Context{Profile: Profile{}} + + err := Install(1) + if err == nil { + t.Fatal("Install() expected error when profile is zero") + } +} + +func TestInstall_noRepos(t *testing.T) { + setupTestConfig(t) + + context = &Context{ + Profile: Profile{Name: "test", Path: "/path"}, + } + + if err := Install(0); err != nil { + t.Errorf("Install() with no repos error: %v", err) + } +} + +func TestInstall_withSemaphoreAndExistingRepo(t *testing.T) { + setupTestConfig(t) + + dir := t.TempDir() + // Fake git repo β€” CloneRepository will skip cloning. + os.MkdirAll(filepath.Join(dir, ".git"), 0755) + + context = &Context{ + Profile: Profile{ + Name: "test", + Path: "/path", + Repositories: []Repo{ + {Name: "repo1", Path: dir, URL: "http://example.com"}, + }, + }, + } + + // maxThreads=1 exercises the semaphore acquisition/release paths. + if err := Install(1); err != nil { + t.Errorf("Install() with semaphore and existing repo error: %v", err) + } +} + +func TestInstall_cloneFailure(t *testing.T) { + setupTestConfig(t) + + dir := t.TempDir() + + context = &Context{ + Profile: Profile{ + Name: "test", + Path: "/path", + Repositories: []Repo{ + // Non-existent local path causes git to fail immediately. + {Name: "repo1", Path: filepath.Join(dir, "newrepo"), URL: "file:///nonexistent/repo.git"}, + }, + }, + } + + err := Install(0) + if err == nil { + t.Fatal("Install() expected error for failed clone") + } +} + +func TestInstall_installTaskFailure(t *testing.T) { + setupTestConfig(t) + + context = &Context{ + Profile: Profile{ + Name: "test", + Path: "/path", + Install: OnInstall{ + Tasks: []Task{{Type: Shell, Cmd: "exit 1"}}, + }, + }, + } + + err := Install(0) + if err == nil { + t.Fatal("Install() expected error from failing install task") + } +} + +func TestInstall_repoInstallTaskFailure(t *testing.T) { + setupTestConfig(t) + + dir := t.TempDir() + os.MkdirAll(filepath.Join(dir, ".git"), 0755) + + context = &Context{ + Profile: Profile{ + Name: "test", + Path: "/path", + Repositories: []Repo{ + { + Name: "repo1", + Path: dir, + URL: "http://example.com", + Install: OnInstall{ + Tasks: []Task{{Type: Shell, Cmd: "exit 1"}}, + }, + }, + }, + }, + } + + err := Install(0) + if err == nil { + t.Fatal("Install() expected error from failing repo install task") + } +} + +func TestForceLoad_mergesRepoCommands(t *testing.T) { + root := repoRoot(t) + setupTestConfig(t) + + dir := t.TempDir() + repoDir := filepath.Join(dir, "myrepo") + os.MkdirAll(filepath.Join(repoDir, ".git"), 0755) + + // repo raid.yaml defines a command "repo-cmd" alongside the required fields. + repoYAML := "name: myrepo\nbranch: main\ncommands:\n - name: repo-cmd\n tasks:\n - type: Shell\n cmd: exit 0\n" + os.WriteFile(filepath.Join(repoDir, RaidConfigFileName), []byte(repoYAML), 0644) + + profileContent := "name: mergetest\nrepositories:\n - name: myrepo\n path: " + repoDir + "\n url: http://example.com/repo.git\ncommands:\n - name: profile-cmd\n tasks:\n - type: Shell\n cmd: exit 0\n" + profilePath := filepath.Join(dir, "profile.yaml") + os.WriteFile(profilePath, []byte(profileContent), 0644) + + wd, _ := os.Getwd() + os.Chdir(root) + defer os.Chdir(wd) + + if err := AddProfile(Profile{Name: "mergetest", Path: profilePath}); err != nil { + t.Fatalf("AddProfile() error: %v", err) + } + if err := SetProfile("mergetest"); err != nil { + t.Fatalf("SetProfile() error: %v", err) + } + + if err := ForceLoad(); err != nil { + t.Fatalf("ForceLoad() error: %v", err) + } + + cmds := GetCommands() + names := make(map[string]bool, len(cmds)) + for _, c := range cmds { + names[c.Name] = true + } + if !names["profile-cmd"] { + t.Error("GetCommands() missing 'profile-cmd' from profile") + } + if !names["repo-cmd"] { + t.Error("GetCommands() missing 'repo-cmd' merged from repo") + } +} diff --git a/src/internal/lib/profile.go b/src/internal/lib/profile.go index ea46510..3c364e4 100644 --- a/src/internal/lib/profile.go +++ b/src/internal/lib/profile.go @@ -1,33 +1,35 @@ 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" - ALL_PROFILES_KEY = "profiles" - PROFILE_SCHEMA_PATH = "schemas/raid-profile.schema.json" + activeProfileKey = "profile" + allProfilesKey = "profiles" + profileSchemaPath = "raid-profile.schema.json" ) +// Profile represents a named collection of repositories, environments, and task groups. type Profile struct { - Name string `json:"name"` - Path string `json:"path"` - Repositories []Repo `json:"repositories"` - Environments []Env `json:"environments"` + Name string `json:"name"` + Path string `json:"path"` + Repositories []Repo `json:"repositories"` + Environments []Env `json:"environments"` + Install OnInstall `json:"install"` + Groups map[string][]Task `json:"groups"` + Commands []Command `json:"commands"` } +// IsZero reports whether the profile is uninitialized. func (p Profile) IsZero() bool { return p.Name == "" || p.Path == "" } @@ -41,20 +43,21 @@ func (p Profile) getEnv(name string) Env { return Env{} } +// SetProfile sets the named profile as the active profile. func SetProfile(name string) error { if !ContainsProfile(name) { return fmt.Errorf("profile '%s' not found", name) } - Set(ACTIVE_PROFILE_KEY, name) - return nil + return Set(activeProfileKey, name) } +// GetProfile returns the currently active profile. func GetProfile() Profile { if context != nil && !context.Profile.IsZero() { return context.Profile } - - name := viper.GetString(ACTIVE_PROFILE_KEY) + + name := viper.GetString(activeProfileKey) paths := getProfilePaths() return Profile{ Name: name, @@ -62,22 +65,27 @@ func GetProfile() Profile { } } -func AddProfile(profile Profile) { - profiles := viper.GetStringMapString(ALL_PROFILES_KEY) +// AddProfile registers a profile in the config store. +func AddProfile(profile Profile) error { + profiles := viper.GetStringMapString(allProfilesKey) if profiles == nil { profiles = make(map[string]string) } - profiles[profile.Name] = profile.Path - Set(ALL_PROFILES_KEY, profiles) + return Set(allProfilesKey, profiles) } -func AddProfiles(profiles []Profile) { +// AddProfiles registers multiple profiles in the config store. +func AddProfiles(profiles []Profile) error { for _, profile := range profiles { - AddProfile(profile) + if err := AddProfile(profile); err != nil { + return err + } } + return nil } +// ListProfiles returns all registered profiles. func ListProfiles() []Profile { profilesMap := getProfilePaths() results := make([]Profile, 0, len(profilesMap)) @@ -88,15 +96,16 @@ func ListProfiles() []Profile { } func getProfilePaths() map[string]string { - profiles := viper.GetStringMapString(ALL_PROFILES_KEY) + profiles := viper.GetStringMapString(allProfilesKey) if profiles == nil { return make(map[string]string) } return profiles } +// RemoveProfile removes a registered profile by name. func RemoveProfile(name string) error { - profiles := viper.GetStringMapString(ALL_PROFILES_KEY) + profiles := viper.GetStringMapString(allProfilesKey) if profiles == nil { return fmt.Errorf("no profiles found") } @@ -104,10 +113,10 @@ func RemoveProfile(name string) error { return fmt.Errorf("profile '%s' not found", name) } delete(profiles, name) - Set(ALL_PROFILES_KEY, profiles) - return nil + return Set(allProfilesKey, profiles) } +// ExtractProfile reads and returns a single named profile from the given file. func ExtractProfile(name, path string) (Profile, error) { profiles, err := ExtractProfiles(path) if err != nil { @@ -121,6 +130,7 @@ func ExtractProfile(name, path string) (Profile, error) { return Profile{}, fmt.Errorf("profile '%s' not found in %s", name, path) } +// ExtractProfiles reads all profiles from a YAML or JSON file. func ExtractProfiles(path string) ([]Profile, error) { profileData, err := os.ReadFile(path) if err != nil { @@ -153,7 +163,7 @@ func ExtractProfiles(path string) ([]Profile, error) { func extractProfilesFromYAML(data []byte, path string) ([]Profile, error) { var profiles []Profile - documents := strings.Split(string(data), YAML_SEP) + documents := strings.Split(string(data), yamlSep) for _, doc := range documents { doc = strings.TrimSpace(doc) @@ -174,14 +184,13 @@ func extractProfilesFromYAML(data []byte, path string) ([]Profile, error) { } 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 } + var profiles []Profile if err := json.Unmarshal(data, &profiles); err != nil { return nil, fmt.Errorf("invalid JSON format in %s: %w", path, err) } @@ -195,8 +204,9 @@ func extractProfilesFromJSON(data []byte, path string) ([]Profile, error) { return results, nil } +// ContainsProfile reports whether a profile with the given name is registered. func ContainsProfile(name string) bool { - profiles := viper.GetStringMapString(ALL_PROFILES_KEY) + profiles := viper.GetStringMapString(allProfilesKey) if profiles == nil { return false } @@ -205,52 +215,9 @@ func ContainsProfile(name string) bool { return exists } +// ValidateProfile validates the profile file at path against the profile JSON schema. 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(PROFILE_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) + return validateWithEmbeddedSchema(path, profileSchemaPath) } func buildProfile(profile Profile) (Profile, error) { diff --git a/src/internal/lib/profile_test.go b/src/internal/lib/profile_test.go new file mode 100644 index 0000000..bf52711 --- /dev/null +++ b/src/internal/lib/profile_test.go @@ -0,0 +1,393 @@ +package lib + +import ( + "encoding/json" + "os" + "path/filepath" + "testing" + + "gopkg.in/yaml.v3" +) + +func TestProfileIsZero(t *testing.T) { + tests := []struct { + name string + profile Profile + want bool + }{ + {"empty profile", Profile{}, true}, + {"name only", Profile{Name: "test"}, true}, + {"path only", Profile{Path: "/some/path"}, true}, + {"name and path", Profile{Name: "test", Path: "/some/path"}, false}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + if got := tt.profile.IsZero(); got != tt.want { + t.Errorf("IsZero() = %v, want %v", got, tt.want) + } + }) + } +} + +func TestProfileGetEnv(t *testing.T) { + profile := Profile{ + Environments: []Env{ + {Name: "dev", Variables: []EnvVar{{Name: "KEY", Value: "val"}}}, + {Name: "prod"}, + }, + } + + t.Run("found", func(t *testing.T) { + env := profile.getEnv("dev") + if env.Name != "dev" { + t.Errorf("getEnv(\"dev\") = %q, want \"dev\"", env.Name) + } + }) + + t.Run("not found returns zero", func(t *testing.T) { + env := profile.getEnv("staging") + if !env.IsZero() { + t.Errorf("getEnv(\"staging\") should return zero Env, got %v", env) + } + }) +} + +func TestAddAndContainsProfile(t *testing.T) { + setupTestConfig(t) + + if err := AddProfile(Profile{Name: "myprofile", Path: "/some/path"}); err != nil { + t.Fatalf("AddProfile() error: %v", err) + } + + if !ContainsProfile("myprofile") { + t.Error("ContainsProfile() = false after AddProfile(), want true") + } +} + +func TestContainsProfile_notFound(t *testing.T) { + setupTestConfig(t) + + if ContainsProfile("nonexistent") { + t.Error("ContainsProfile() = true for nonexistent profile, want false") + } +} + +func TestListProfiles(t *testing.T) { + setupTestConfig(t) + + if err := AddProfile(Profile{Name: "list-a", Path: "/a"}); err != nil { + t.Fatalf("AddProfile() error: %v", err) + } + if err := AddProfile(Profile{Name: "list-b", Path: "/b"}); err != nil { + t.Fatalf("AddProfile() error: %v", err) + } + + profiles := ListProfiles() + names := make(map[string]bool) + for _, p := range profiles { + names[p.Name] = true + } + if !names["list-a"] || !names["list-b"] { + t.Errorf("ListProfiles() = %v, missing added profiles", profiles) + } +} + +func TestAddProfiles(t *testing.T) { + setupTestConfig(t) + + if err := AddProfiles([]Profile{ + {Name: "bulk-a", Path: "/a"}, + {Name: "bulk-b", Path: "/b"}, + }); err != nil { + t.Fatalf("AddProfiles() error: %v", err) + } + + if !ContainsProfile("bulk-a") || !ContainsProfile("bulk-b") { + t.Error("AddProfiles() did not add all profiles") + } +} + +func TestRemoveProfile(t *testing.T) { + setupTestConfig(t) + + if err := AddProfile(Profile{Name: "toremove", Path: "/path"}); err != nil { + t.Fatalf("AddProfile() error: %v", err) + } + if err := RemoveProfile("toremove"); err != nil { + t.Fatalf("RemoveProfile() error: %v", err) + } + if ContainsProfile("toremove") { + t.Error("ContainsProfile() = true after RemoveProfile(), want false") + } +} + +func TestRemoveProfile_notFound(t *testing.T) { + setupTestConfig(t) + + if err := AddProfile(Profile{Name: "existing", Path: "/path"}); err != nil { + t.Fatalf("AddProfile() error: %v", err) + } + err := RemoveProfile("nonexistent") + if err == nil { + t.Fatal("RemoveProfile() expected error for nonexistent profile") + } +} + +func TestRemoveProfile_noProfiles(t *testing.T) { + setupTestConfig(t) + + err := RemoveProfile("anything") + if err == nil { + t.Fatal("RemoveProfile() on empty config should error") + } +} + +func TestSetProfile_notFound(t *testing.T) { + setupTestConfig(t) + + err := SetProfile("nonexistent") + if err == nil { + t.Fatal("SetProfile() expected error for nonexistent profile") + } +} + +func TestSetAndGetProfile(t *testing.T) { + setupTestConfig(t) + + if err := AddProfile(Profile{Name: "active", Path: "/active/path"}); err != nil { + t.Fatalf("AddProfile() error: %v", err) + } + if err := SetProfile("active"); err != nil { + t.Fatalf("SetProfile() error: %v", err) + } + + got := GetProfile() + if got.Name != "active" { + t.Errorf("GetProfile() name = %q, want %q", got.Name, "active") + } +} + +func TestGetProfile_fromContext(t *testing.T) { + setupTestConfig(t) + + context = &Context{ + Profile: Profile{Name: "ctx-profile", Path: "/ctx/path"}, + } + + got := GetProfile() + if got.Name != "ctx-profile" { + t.Errorf("GetProfile() from context = %q, want %q", got.Name, "ctx-profile") + } +} + +func TestExtractProfiles_singleYAML(t *testing.T) { + dir := t.TempDir() + path := filepath.Join(dir, "profile.yaml") + + data := Profile{Name: "yamltest", Path: path} + b, _ := yaml.Marshal(data) + if err := os.WriteFile(path, b, 0644); err != nil { + t.Fatal(err) + } + + profiles, err := ExtractProfiles(path) + if err != nil { + t.Fatalf("ExtractProfiles() error: %v", err) + } + if len(profiles) != 1 || profiles[0].Name != "yamltest" { + t.Errorf("ExtractProfiles() = %v, want single profile named yamltest", profiles) + } +} + +func TestExtractProfiles_multiDocYAML(t *testing.T) { + dir := t.TempDir() + path := filepath.Join(dir, "profiles.yaml") + + content := "name: first\n---\nname: second\n" + if err := os.WriteFile(path, []byte(content), 0644); err != nil { + t.Fatal(err) + } + + profiles, err := ExtractProfiles(path) + if err != nil { + t.Fatalf("ExtractProfiles() error: %v", err) + } + if len(profiles) != 2 { + t.Fatalf("ExtractProfiles() returned %d profiles, want 2", len(profiles)) + } +} + +func TestExtractProfiles_singleJSON(t *testing.T) { + dir := t.TempDir() + path := filepath.Join(dir, "profile.json") + + data, _ := json.Marshal(Profile{Name: "jsontest"}) + if err := os.WriteFile(path, data, 0644); err != nil { + t.Fatal(err) + } + + profiles, err := ExtractProfiles(path) + if err != nil { + t.Fatalf("ExtractProfiles() error: %v", err) + } + if len(profiles) != 1 || profiles[0].Name != "jsontest" { + t.Errorf("ExtractProfiles() = %v, want profile named jsontest", profiles) + } +} + +func TestExtractProfiles_arrayJSON(t *testing.T) { + dir := t.TempDir() + path := filepath.Join(dir, "profiles.json") + + data, _ := json.Marshal([]Profile{{Name: "a"}, {Name: "b"}}) + if err := os.WriteFile(path, data, 0644); err != nil { + t.Fatal(err) + } + + profiles, err := ExtractProfiles(path) + if err != nil { + t.Fatalf("ExtractProfiles() error: %v", err) + } + if len(profiles) != 2 { + t.Fatalf("ExtractProfiles() returned %d profiles, want 2", len(profiles)) + } +} + +func TestExtractProfiles_invalidJSON(t *testing.T) { + dir := t.TempDir() + path := filepath.Join(dir, "bad.json") + os.WriteFile(path, []byte("{invalid json}"), 0644) + + _, err := ExtractProfiles(path) + if err == nil { + t.Fatal("ExtractProfiles() expected error for invalid JSON") + } +} + +func TestExtractProfiles_invalidYAML(t *testing.T) { + dir := t.TempDir() + path := filepath.Join(dir, "bad.yaml") + os.WriteFile(path, []byte("key: [unclosed"), 0644) + + _, err := ExtractProfiles(path) + if err == nil { + t.Fatal("ExtractProfiles() expected error for invalid YAML") + } +} + +func TestExtractProfiles_unsupportedExtension(t *testing.T) { + dir := t.TempDir() + path := filepath.Join(dir, "profile.xml") + os.WriteFile(path, []byte(""), 0644) + + _, err := ExtractProfiles(path) + if err == nil { + t.Fatal("ExtractProfiles() expected error for unsupported extension") + } +} + +func TestExtractProfiles_fileNotFound(t *testing.T) { + _, err := ExtractProfiles("/nonexistent/path/profile.yaml") + if err == nil { + t.Fatal("ExtractProfiles() expected error for missing file") + } +} + +func TestExtractProfiles_emptyYAML(t *testing.T) { + dir := t.TempDir() + path := filepath.Join(dir, "empty.yaml") + os.WriteFile(path, []byte(""), 0644) + + _, err := ExtractProfiles(path) + if err == nil { + t.Fatal("ExtractProfiles() expected error for empty YAML (no profiles)") + } +} + +func TestExtractProfile_found(t *testing.T) { + dir := t.TempDir() + path := filepath.Join(dir, "profiles.yaml") + os.WriteFile(path, []byte("name: first\n---\nname: second\n"), 0644) + + p, err := ExtractProfile("first", path) + if err != nil { + t.Fatalf("ExtractProfile() error: %v", err) + } + if p.Name != "first" { + t.Errorf("ExtractProfile() name = %q, want %q", p.Name, "first") + } +} + +func TestExtractProfile_notFound(t *testing.T) { + dir := t.TempDir() + path := filepath.Join(dir, "profiles.yaml") + os.WriteFile(path, []byte("name: only\n"), 0644) + + _, err := ExtractProfile("nonexistent", path) + if err == nil { + t.Fatal("ExtractProfile() expected error for missing profile name") + } +} + +func TestExtractProfile_extractionError(t *testing.T) { + _, err := ExtractProfile("anyname", "/nonexistent/path/profile.yaml") + if err == nil { + t.Fatal("ExtractProfile() expected error for missing file") + } +} + +func TestBuildProfile_zero(t *testing.T) { + _, err := buildProfile(Profile{}) + if err == nil { + t.Fatal("buildProfile() expected error for zero profile") + } +} + +func TestBuildProfile_fileNotFound(t *testing.T) { + _, err := buildProfile(Profile{Name: "test", Path: "/nonexistent/path/profile.yaml"}) + if err == nil { + t.Fatal("buildProfile() expected error when profile file not found") + } +} + +func TestBuildProfile_validationError(t *testing.T) { + dir := t.TempDir() + profilePath := filepath.Join(dir, "profile.yaml") + // badfield violates additionalProperties:false in the profile schema. + os.WriteFile(profilePath, []byte("name: test\nbadfield: invalid"), 0644) + + _, err := buildProfile(Profile{Name: "test", Path: profilePath}) + if err == nil { + t.Fatal("buildProfile() expected error when schema validation fails") + } +} + +func TestBuildProfile_extractionError(t *testing.T) { + root := repoRoot(t) + dir := t.TempDir() + + profilePath := filepath.Join(dir, "profile.yaml") + // Valid per profile schema (only requires "name"), but we'll look for a different name. + os.WriteFile(profilePath, []byte("name: actualname"), 0644) + + wd, _ := os.Getwd() + os.Chdir(root) + defer os.Chdir(wd) + + _, err := buildProfile(Profile{Name: "wrongname", Path: profilePath}) + if err == nil { + t.Fatal("buildProfile() expected error when profile name not found in file") + } +} + +func TestValidateProfile_schemaViolation(t *testing.T) { + dir := t.TempDir() + path := filepath.Join(dir, "profile.yaml") + // badfield violates additionalProperties:false in the profile schema. + os.WriteFile(path, []byte("name: test\nbadfield: invalid"), 0644) + + if err := ValidateProfile(path); err == nil { + t.Fatal("ValidateProfile() expected error for schema violation") + } +} diff --git a/src/internal/lib/repo.go b/src/internal/lib/repo.go index ecb9d75..cdea140 100644 --- a/src/internal/lib/repo.go +++ b/src/internal/lib/repo.go @@ -7,15 +7,23 @@ import ( "path/filepath" sys "github.com/8bitalex/raid/src/internal/sys" + "gopkg.in/yaml.v3" ) +const repoSchemaPath = "raid-repo.schema.json" + +// Repo represents a single repository entry in a profile. type Repo struct { - Name string `json:"name"` - Path string `json:"path"` - URL string `json:"url"` - Environments []Env `json:"environments"` + Name string `json:"name"` + Path string `json:"path"` + URL string `json:"url"` + Branch string `json:"branch"` + Environments []Env `json:"environments"` + Install OnInstall `json:"install"` + Commands []Command `json:"commands"` } +// IsZero reports whether the repo is uninitialized. func (r Repo) IsZero() bool { return r.Name == "" || r.Path == "" || r.URL == "" } @@ -29,10 +37,33 @@ func (r Repo) getEnv(name string) Env { return Env{} } -func buildRepo(repo Repo) (Repo, error) { - return repo, nil +func buildRepo(repo *Repo) error { + if repo.IsZero() { + return fmt.Errorf("invalid repository: %v", repo) + } + + raidFile := filepath.Join(sys.ExpandPath(repo.Path), RaidConfigFileName) + if !sys.FileExists(raidFile) { + return nil + } + + if err := ValidateRepo(raidFile); err != nil { + return fmt.Errorf("invalid raid configuration for '%s': %w", repo.Name, err) + } + + repoConfig, err := ExtractRepo(repo.Path) + if err != nil { + return fmt.Errorf("failed to read config for '%s': %w", repo.Name, err) + } + + repo.Environments = append(repo.Environments, repoConfig.Environments...) + repo.Install.Tasks = append(repo.Install.Tasks, repoConfig.Install.Tasks...) + repo.Commands = append(repo.Commands, repoConfig.Commands...) + + return nil } +// CloneRepository clones a repository to its configured path. Skips if it already exists. func CloneRepository(repo Repo) error { path := sys.ExpandPath(repo.Path) @@ -49,7 +80,7 @@ func CloneRepository(repo Repo) error { return fmt.Errorf("failed to create directory '%s': %w", path, err) } - if err := clone(path, repo.URL); err != nil { + if err := clone(path, repo.URL, repo.Branch); err != nil { return fmt.Errorf("failed to clone repository '%s': %w", repo.Name, err) } @@ -66,9 +97,35 @@ func isGitInstalled() bool { return cmd.Run() == nil } -func clone(path string, url string) error { - cmd := exec.Command("git", "clone", url, path) +func clone(path string, url string, branch string) error { + args := []string{"clone"} + if branch != "" { + args = append(args, "--branch", branch) + } + args = append(args, url, path) + cmd := exec.Command("git", args...) cmd.Stdout = os.Stdout cmd.Stderr = os.Stderr return cmd.Run() } + +// ValidateRepo validates the repo config file at path against the repo JSON schema. +func ValidateRepo(path string) error { + return validateWithEmbeddedSchema(path, repoSchemaPath) +} + +// ExtractRepo reads and parses the raid.yaml from the given repository directory. +func ExtractRepo(path string) (Repo, error) { + filePath := filepath.Join(sys.ExpandPath(path), RaidConfigFileName) + data, err := os.ReadFile(filePath) + if err != nil { + return Repo{}, fmt.Errorf("failed to read %s: %w", filePath, err) + } + + var repo Repo + if err := yaml.Unmarshal(data, &repo); err != nil { + return Repo{}, fmt.Errorf("failed to parse %s: %w", filePath, err) + } + + return repo, nil +} diff --git a/src/internal/lib/repo_test.go b/src/internal/lib/repo_test.go new file mode 100644 index 0000000..34630bc --- /dev/null +++ b/src/internal/lib/repo_test.go @@ -0,0 +1,200 @@ +package lib + +import ( + "os" + "os/exec" + "path/filepath" + "testing" +) + +func TestRepoIsZero(t *testing.T) { + tests := []struct { + name string + repo Repo + want bool + }{ + {"empty", Repo{}, true}, + {"name only", Repo{Name: "test"}, true}, + {"name and path", Repo{Name: "test", Path: "/path"}, true}, + {"name and url", Repo{Name: "test", URL: "http://example.com"}, true}, + {"path and url", Repo{Path: "/path", URL: "http://example.com"}, true}, + {"all three fields", Repo{Name: "test", Path: "/path", URL: "http://example.com"}, false}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + if got := tt.repo.IsZero(); got != tt.want { + t.Errorf("IsZero() = %v, want %v", got, tt.want) + } + }) + } +} + +func TestRepoGetEnv(t *testing.T) { + repo := Repo{ + Environments: []Env{ + {Name: "dev"}, + {Name: "prod"}, + }, + } + + t.Run("found", func(t *testing.T) { + env := repo.getEnv("dev") + if env.Name != "dev" { + t.Errorf("getEnv(\"dev\") = %q, want \"dev\"", env.Name) + } + }) + + t.Run("not found returns zero", func(t *testing.T) { + env := repo.getEnv("staging") + if !env.IsZero() { + t.Errorf("getEnv(\"staging\") should return zero Env, got %v", env) + } + }) +} + +func TestExtractRepo_validYAML(t *testing.T) { + dir := t.TempDir() + content := "environments:\n - name: dev\n" + if err := os.WriteFile(filepath.Join(dir, RaidConfigFileName), []byte(content), 0644); err != nil { + t.Fatal(err) + } + + repo, err := ExtractRepo(dir) + if err != nil { + t.Fatalf("ExtractRepo() error: %v", err) + } + if len(repo.Environments) != 1 || repo.Environments[0].Name != "dev" { + t.Errorf("ExtractRepo() environments = %v, want [{dev}]", repo.Environments) + } +} + +func TestExtractRepo_invalidYAML(t *testing.T) { + dir := t.TempDir() + os.WriteFile(filepath.Join(dir, RaidConfigFileName), []byte("key: [unclosed"), 0644) + + _, err := ExtractRepo(dir) + if err == nil { + t.Fatal("ExtractRepo() expected error for invalid YAML") + } +} + +func TestExtractRepo_missingFile(t *testing.T) { + _, err := ExtractRepo("/nonexistent/path") + if err == nil { + t.Fatal("ExtractRepo() expected error for missing directory") + } +} + +func TestCloneRepository_alreadyExists(t *testing.T) { + dir := t.TempDir() + // Simulate an existing git repository. + if err := os.MkdirAll(filepath.Join(dir, ".git"), 0755); err != nil { + t.Fatal(err) + } + + repo := Repo{Name: "test", Path: dir, URL: "http://example.com"} + if err := CloneRepository(repo); err != nil { + t.Errorf("CloneRepository() on existing repo should skip, got error: %v", err) + } +} + +func TestCloneRepository_pathExistsNotGitRepo(t *testing.T) { + dir := t.TempDir() + // Directory exists but is not a git repo, and no network URL is reachable. + // Use a local file:// URL pointing to a nonexistent path so git fails fast. + repo := Repo{Name: "test", Path: filepath.Join(dir, "newrepo"), URL: "file:///nonexistent/repo.git"} + + err := CloneRepository(repo) + if err == nil { + t.Fatal("CloneRepository() expected error for failed clone") + } +} + +func TestBuildRepo_zero(t *testing.T) { + repo := Repo{} + err := buildRepo(&repo) + if err == nil { + t.Fatal("buildRepo() expected error for zero repo") + } +} + +func TestBuildRepo_noRaidYAML(t *testing.T) { + dir := t.TempDir() + repo := Repo{Name: "test", Path: dir, URL: "http://x.com"} + if err := buildRepo(&repo); err != nil { + t.Errorf("buildRepo() with no raid.yaml should return nil, got: %v", err) + } +} + +func TestBuildRepo_validationError(t *testing.T) { + dir := t.TempDir() + // badfield violates additionalProperties:false in the repo schema. + os.WriteFile(filepath.Join(dir, RaidConfigFileName), []byte("name: test\nbranch: main\nbadfield: invalid"), 0644) + + repo := Repo{Name: "test", Path: dir, URL: "http://x.com"} + if err := buildRepo(&repo); err == nil { + t.Fatal("buildRepo() expected error when schema validation fails") + } +} + +func TestBuildRepo_validRaidYAML(t *testing.T) { + root := repoRoot(t) + dir := t.TempDir() + + // Valid raid.yaml: repo schema requires "name" and "branch". + os.WriteFile(filepath.Join(dir, RaidConfigFileName), []byte("name: myrepo\nbranch: main"), 0644) + + wd, _ := os.Getwd() + os.Chdir(root) + defer os.Chdir(wd) + + repo := Repo{Name: "myrepo", Path: dir, URL: "http://x.com"} + if err := buildRepo(&repo); err != nil { + t.Errorf("buildRepo() with valid raid.yaml error: %v", err) + } +} + +func TestCloneRepository_mkdirAllError(t *testing.T) { + // Use a regular file as a parent directory β€” os.MkdirAll will fail with ENOTDIR. + tmpFile, err := os.CreateTemp("", "raid-test-*") + if err != nil { + t.Fatal(err) + } + tmpFile.Close() + defer os.Remove(tmpFile.Name()) + + repo := Repo{Name: "test", Path: filepath.Join(tmpFile.Name(), "subdir"), URL: "file:///nonexistent.git"} + if err := CloneRepository(repo); err == nil { + t.Fatal("CloneRepository() expected error when MkdirAll fails") + } +} + +func TestCloneRepository_successLocalRepo(t *testing.T) { + if !isGitInstalled() { + t.Skip("git not installed") + } + + srcDir := t.TempDir() + if err := exec.Command("git", "init", "--bare", srcDir).Run(); err != nil { + t.Skipf("git init --bare failed: %v", err) + } + + destDir := filepath.Join(t.TempDir(), "clone") + repo := Repo{Name: "test", Path: destDir, URL: "file://" + srcDir} + + if err := CloneRepository(repo); err != nil { + t.Errorf("CloneRepository() to local bare repo error: %v", err) + } +} + +func TestValidateRepo_schemaViolation(t *testing.T) { + dir := t.TempDir() + path := filepath.Join(dir, "raid.yaml") + // badfield violates additionalProperties:false in the repo schema. + os.WriteFile(path, []byte("name: test\nbranch: main\nbadfield: invalid"), 0644) + + if err := ValidateRepo(path); err == nil { + t.Fatal("ValidateRepo() expected error for schema violation") + } +} diff --git a/src/internal/lib/schemas/raid-defs.schema.json b/src/internal/lib/schemas/raid-defs.schema.json new file mode 100644 index 0000000..584fdbd --- /dev/null +++ b/src/internal/lib/schemas/raid-defs.schema.json @@ -0,0 +1,316 @@ +{ + "$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", "const": "Shell" }, + "concurrent": { "$ref": "#/$defs/concurrent" }, + "condition": { "$ref": "#/$defs/condition" }, + "cmd": { "type": "string", "description": "Command to execute" }, + "shell": { + "type": "string", + "enum": ["bash", "sh", "zsh", "powershell", "pwsh", "ps", "cmd"], + "description": "Shell to use (default: bash)" + }, + "literal": { + "type": "boolean", + "description": "Pass the command to the shell without prior env var expansion", + "default": false + }, + "path": { + "type": "string", + "description": "Working directory for the command. Defaults to ~ for profile tasks, the repo directory for repo tasks." + } + }, + "required": ["type", "cmd"], + "additionalProperties": false + }, + { + "type": "object", + "properties": { + "type": { "type": "string", "const": "Script" }, + "concurrent": { "$ref": "#/$defs/concurrent" }, + "condition": { "$ref": "#/$defs/condition" }, + "path": { "type": "string", "description": "Path to the script file" }, + "runner": { + "type": "string", + "enum": ["bash", "sh", "zsh", "python", "python2", "python3", "node", "powershell"], + "description": "Interpreter to use (optional)" + } + }, + "required": ["type", "path"], + "additionalProperties": false + }, + { + "type": "object", + "properties": { + "type": { "type": "string", "const": "HTTP" }, + "concurrent": { "$ref": "#/$defs/concurrent" }, + "condition": { "$ref": "#/$defs/condition" }, + "url": { "type": "string", "description": "URL to download from" }, + "dest": { "type": "string", "description": "Local path to write the file to" } + }, + "required": ["type", "url", "dest"], + "additionalProperties": false + }, + { + "type": "object", + "properties": { + "type": { "type": "string", "const": "Wait" }, + "concurrent": { "$ref": "#/$defs/concurrent" }, + "condition": { "$ref": "#/$defs/condition" }, + "url": { "type": "string", "description": "HTTP(S) URL or TCP host:port to poll" }, + "timeout": { "type": "string", "description": "Max wait duration (e.g. 30s, 1m). Defaults to 30s." } + }, + "required": ["type", "url"], + "additionalProperties": false + }, + { + "type": "object", + "properties": { + "type": { "type": "string", "const": "Template" }, + "concurrent": { "$ref": "#/$defs/concurrent" }, + "condition": { "$ref": "#/$defs/condition" }, + "src": { "type": "string", "description": "Path to the template file. Supports $VAR and ${VAR} substitution." }, + "dest": { "type": "string", "description": "Path to write the rendered file to" } + }, + "required": ["type", "src", "dest"], + "additionalProperties": false + }, + { + "type": "object", + "properties": { + "type": { "type": "string", "const": "Group" }, + "concurrent": { "$ref": "#/$defs/concurrent" }, + "condition": { "$ref": "#/$defs/condition" }, + "ref": { "type": "string", "description": "Name of the group to execute, as defined in the profile's top-level groups map" } + }, + "required": ["type", "ref"], + "additionalProperties": false + }, + { + "type": "object", + "properties": { + "type": { "type": "string", "const": "Git" }, + "concurrent": { "$ref": "#/$defs/concurrent" }, + "condition": { "$ref": "#/$defs/condition" }, + "op": { + "type": "string", + "enum": ["pull", "checkout", "fetch", "reset"], + "description": "Git operation to perform" + }, + "branch": { "type": "string", "description": "Target branch (required for checkout, optional for others)" }, + "path": { "type": "string", "description": "Path to the git repository. Defaults to the current working directory." } + }, + "required": ["type", "op"], + "additionalProperties": false + }, + { + "type": "object", + "properties": { + "type": { "type": "string", "const": "Prompt" }, + "concurrent": { "$ref": "#/$defs/concurrent" }, + "condition": { "$ref": "#/$defs/condition" }, + "var": { "type": "string", "description": "Environment variable name to set with the user's input" }, + "message": { "type": "string", "description": "Message to display to the user" }, + "default": { "type": "string", "description": "Default value if the user provides no input" } + }, + "required": ["type", "var"], + "additionalProperties": false + }, + { + "type": "object", + "properties": { + "type": { "type": "string", "const": "Confirm" }, + "concurrent": { "$ref": "#/$defs/concurrent" }, + "condition": { "$ref": "#/$defs/condition" }, + "message": { "type": "string", "description": "Confirmation prompt to display to the user" } + }, + "required": ["type"], + "additionalProperties": false + }, + { + "type": "object", + "properties": { + "type": { "type": "string", "const": "Parallel" }, + "concurrent": { "$ref": "#/$defs/concurrent" }, + "condition": { "$ref": "#/$defs/condition" }, + "ref": { "type": "string", "description": "Name of the group whose tasks will all be executed concurrently" } + }, + "required": ["type", "ref"], + "additionalProperties": false + }, + { + "type": "object", + "properties": { + "type": { "type": "string", "const": "Print" }, + "concurrent": { "$ref": "#/$defs/concurrent" }, + "condition": { "$ref": "#/$defs/condition" }, + "message": { "type": "string", "description": "Message to print. Supports $VAR substitution unless literal is true." }, + "color": { + "type": "string", + "enum": ["red", "green", "yellow", "blue", "cyan", "white"], + "description": "Optional terminal color for the output" + }, + "literal": { + "type": "boolean", + "description": "Skip environment variable expansion in the message", + "default": false + } + }, + "required": ["type", "message"], + "additionalProperties": false + }, + { + "type": "object", + "properties": { + "type": { "type": "string", "const": "Retry" }, + "concurrent": { "$ref": "#/$defs/concurrent" }, + "condition": { "$ref": "#/$defs/condition" }, + "ref": { "type": "string", "description": "Name of the group to retry on failure" }, + "attempts": { + "type": "integer", + "minimum": 1, + "description": "Maximum number of attempts (default: 3)" + }, + "delay": { "type": "string", "description": "Duration to wait between attempts (e.g. 1s, 500ms). Default: 1s." } + }, + "required": ["type", "ref"], + "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"], + "additionalProperties": false + } + }, + "install": { + "type": "object", + "description": "Options for installing the raid profile", + "properties": { + "tasks": { + "$ref": "#/properties/tasks" + } + }, + "additionalProperties": false + }, + "commands": { + "type": "array", + "description": "Custom commands exposed as top-level raid subcommands", + "minItems": 1, + "items": { + "type": "object", + "properties": { + "name": { + "type": "string", + "description": "Command name used to invoke it via 'raid '" + }, + "usage": { + "type": "string", + "description": "Short description shown in 'raid --help'" + }, + "tasks": { + "$ref": "#/properties/tasks" + }, + "out": { + "type": "object", + "description": "Output configuration for the command", + "properties": { + "stdout": { + "type": "boolean", + "description": "Show stdout from tasks (default: true when out is omitted)" + }, + "stderr": { + "type": "boolean", + "description": "Show stderr from tasks (default: true when out is omitted)" + }, + "file": { + "type": "string", + "description": "Path to additionally write all output to. Supports $VAR expansion." + } + }, + "additionalProperties": false + } + }, + "required": ["name", "tasks"], + "additionalProperties": false + } + } + }, + "$defs": { + "concurrent": { + "type": "boolean", + "description": "Whether to execute the task concurrently with other tasks" + }, + "condition": { + "type": "object", + "description": "All specified fields must be satisfied for the task to run", + "properties": { + "platform": { + "type": "string", + "enum": ["darwin", "linux", "windows"], + "description": "Only run on this platform" + }, + "exists": { + "type": "string", + "description": "Only run if this file or directory exists" + }, + "cmd": { + "type": "string", + "description": "Only run if this command exits with code 0" + } + }, + "additionalProperties": false + } + } +} diff --git a/src/internal/lib/schemas/raid-profile.schema.json b/src/internal/lib/schemas/raid-profile.schema.json new file mode 100644 index 0000000..5629847 --- /dev/null +++ b/src/internal/lib/schemas/raid-profile.schema.json @@ -0,0 +1,55 @@ +{ + "$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" + }, + "install": { + "$ref": "raid-defs.schema.json#/properties/install" + }, + "groups": { + "type": "object", + "description": "Named reusable task sequences. Reference them in any task list with type: Group and ref: .", + "additionalProperties": { + "$ref": "raid-defs.schema.json#/properties/tasks" + } + }, + "commands": { + "$ref": "raid-defs.schema.json#/properties/commands" + } + }, + "required": ["name"] +} + diff --git a/src/internal/lib/schemas/raid-repo.schema.json b/src/internal/lib/schemas/raid-repo.schema.json new file mode 100644 index 0000000..1b93c94 --- /dev/null +++ b/src/internal/lib/schemas/raid-repo.schema.json @@ -0,0 +1,28 @@ +{ + "$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" + }, + "install": { + "$ref": "raid-defs.schema.json#/properties/install" + }, + "commands": { + "$ref": "raid-defs.schema.json#/properties/commands" + } + }, + "required": ["name","branch"] +} \ No newline at end of file diff --git a/src/internal/lib/task.go b/src/internal/lib/task.go new file mode 100644 index 0000000..1df70f6 --- /dev/null +++ b/src/internal/lib/task.go @@ -0,0 +1,110 @@ +package lib + +import ( + "strings" + + "github.com/8bitalex/raid/src/internal/sys" +) + +// Condition guards a task β€” all specified fields must be satisfied for the task to run. +type Condition struct { + Platform string `json:"platform,omitempty"` + Exists string `json:"exists,omitempty"` + Cmd string `json:"cmd,omitempty"` +} + +// IsZero reports whether no condition fields are set. +func (c Condition) IsZero() bool { + return c.Platform == "" && c.Exists == "" && c.Cmd == "" +} + +// Task represents a single unit of work in a task sequence. +type Task struct { + Type TaskType `json:"type"` + Concurrent bool `json:"concurrent,omitempty"` + Condition *Condition `json:"condition,omitempty"` + // Shell + Cmd string `json:"cmd,omitempty"` + Literal bool `json:"literal,omitempty"` + Shell string `json:"shell,omitempty"` + // Script + Path string `json:"path,omitempty"` + Runner string `json:"runner,omitempty"` + // HTTP + URL string `json:"url,omitempty"` + Dest string `json:"dest,omitempty"` + // Wait + Timeout string `json:"timeout,omitempty"` + // Template + Src string `json:"src,omitempty"` + // Group / Parallel / Retry + Ref string `json:"ref,omitempty"` + // Git + Op string `json:"op,omitempty"` + Branch string `json:"branch,omitempty"` + // Prompt / Confirm / Print + Message string `json:"message,omitempty"` + // Prompt + Var string `json:"var,omitempty"` + Default string `json:"default,omitempty"` + // Print + Color string `json:"color,omitempty"` + // Retry + Attempts int `json:"attempts,omitempty"` + Delay string `json:"delay,omitempty"` +} + +// IsZero reports whether the task has no type set. +func (t Task) IsZero() bool { + return t.Type == "" +} + +// Expand returns a copy of the task with all string fields passed through environment variable expansion. +func (t Task) Expand() Task { + return Task{ + Type: t.Type, + Concurrent: t.Concurrent, + Condition: t.Condition, + Cmd: sys.Expand(t.Cmd), + Literal: t.Literal, + Shell: t.Shell, + Path: sys.ExpandPath(t.Path), + Runner: sys.ExpandPath(t.Runner), + URL: sys.Expand(t.URL), + Dest: sys.ExpandPath(t.Dest), + Timeout: t.Timeout, + Src: sys.ExpandPath(t.Src), + Ref: t.Ref, + Op: t.Op, + Branch: sys.Expand(t.Branch), + Message: sys.Expand(t.Message), + Var: t.Var, + Default: sys.Expand(t.Default), + Color: t.Color, + Attempts: t.Attempts, + Delay: t.Delay, + } +} + +// TaskType identifies which task executor to dispatch to. +type TaskType string + +const ( + Shell TaskType = "shell" + Script TaskType = "script" + HTTP TaskType = "http" + Wait TaskType = "wait" + Template TaskType = "template" + Group TaskType = "group" + Git TaskType = "git" + Prompt TaskType = "prompt" + Confirm TaskType = "confirm" + Parallel TaskType = "parallel" + Print TaskType = "print" + Retry TaskType = "retry" +) + +// ToLower returns the task type normalized to lowercase for case-insensitive comparisons. +func (t TaskType) ToLower() TaskType { + return TaskType(strings.ToLower(string(t))) +} diff --git a/src/internal/lib/task_runner.go b/src/internal/lib/task_runner.go index 81a3a6e..cb572a0 100644 --- a/src/internal/lib/task_runner.go +++ b/src/internal/lib/task_runner.go @@ -1,136 +1,580 @@ 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() -// } +import ( + "bufio" + "fmt" + "io" + "net" + "net/http" + "os" + "os/exec" + "path/filepath" + "strings" + "sync" + "time" + + "github.com/8bitalex/raid/src/internal/sys" +) + +// commandStdout and commandStderr are the output writers used by task execution. +// ExecuteCommand replaces these temporarily when a command's Out field is set. +var ( + commandStdout io.Writer = os.Stdout + commandStderr io.Writer = os.Stderr +) + +// stdinMu serializes all stdin reads so that concurrent Prompt and Confirm +// tasks do not interleave reads or compete for input. +var stdinMu sync.Mutex + +var colorCodes = map[string]string{ + "red": "\033[31m", + "green": "\033[32m", + "yellow": "\033[33m", + "blue": "\033[34m", + "cyan": "\033[36m", + "white": "\033[37m", + "reset": "\033[0m", +} + +func evaluateCondition(c *Condition) bool { + if c.Platform != "" { + if string(sys.GetPlatform()) != strings.ToLower(c.Platform) { + return false + } + } + if c.Exists != "" { + if !sys.FileExists(sys.ExpandPath(c.Exists)) { + return false + } + } + if c.Cmd != "" { + shell := getShell("") + cmd := exec.Command(shell[0], append(shell[1:], c.Cmd)...) + cmd.Stdout = io.Discard + cmd.Stderr = io.Discard + if err := cmd.Run(); err != nil { + return false + } + } + return true +} + +func ExecuteTasks(tasks []Task) error { + var wg sync.WaitGroup + errorChan := make(chan error, len(tasks)) + + for _, task := range tasks { + if task.Concurrent { + wg.Add(1) + go func(task Task) { + defer wg.Done() + if err := ExecuteTask(task); err != nil { + errorChan <- fmt.Errorf("failed to execute task '%s': %w", task.Type, err) + } + }(task) + } else { + if err := ExecuteTask(task); err != nil { + // Wait for any already-started concurrent tasks before returning. + wg.Wait() + close(errorChan) + errs := []error{fmt.Errorf("failed to execute task '%s': %w", task.Type, err)} + for e := range errorChan { + errs = append(errs, e) + } + return fmt.Errorf("some tasks failed to execute: %v", errs) + } + } + } + + wg.Wait() + close(errorChan) + + var errors []error + for err := range errorChan { + errors = append(errors, err) + } + + if len(errors) > 0 { + return fmt.Errorf("some tasks failed to execute: %v", errors) + } + + return nil +} + +func ExecuteTask(task Task) error { + if task.IsZero() { + return nil + } + + if task.Condition != nil && !evaluateCondition(task.Condition) { + return nil + } + + switch task.Type.ToLower() { + case Shell: + return execShell(task) + case Script: + return execScript(task) + case HTTP: + return execHTTP(task) + case Wait: + return execWait(task) + case Template: + return execTemplate(task) + case Group: + return execGroup(task) + case Git: + return execGit(task) + case Prompt: + return execPrompt(task) + case Confirm: + return execConfirm(task) + case Parallel: + return execParallel(task) + case Print: + return execPrint(task) + case Retry: + return execRetry(task) + default: + return fmt.Errorf("invalid task type: %s", task.Type) + } +} + +func execShell(task Task) error { + if !task.Literal { + task = task.Expand() + } + if task.Cmd == "" { + return fmt.Errorf("cmd is required for Shell task") + } + + shell := getShell(task.Shell) + cmd := exec.Command(shell[0], append(shell[1:], task.Cmd)...) + if task.Path != "" { + cmd.Dir = sys.ExpandPath(task.Path) + } + setCmdOutput(cmd) + + err := cmd.Run() + if err != nil { + return fmt.Errorf("failed to execute shell command '%s': %w", task.Cmd, err) + } + + return nil +} + +func getShell(shell string) []string { + if shell == "" { + if sys.GetPlatform() == sys.Windows { + return []string{"cmd", "/c"} + } + return []string{"bash", "-c"} + } + + switch strings.ToLower(shell) { + case "/bin/bash", "bash": + return []string{"bash", "-c"} + case "/bin/sh", "sh": + return []string{"sh", "-c"} + case "/bin/zsh", "zsh": + return []string{"zsh", "-c"} + case "powershell", "pwsh", "ps": + if _, err := exec.LookPath("pwsh"); err == nil { + return []string{"pwsh", "-Command"} + } + return []string{"powershell", "-Command"} + case "cmd": + return []string{"cmd", "/c"} + default: + if sys.GetPlatform() == sys.Windows { + return []string{"cmd", "/c"} + } + return []string{"bash", "-c"} + } +} + +func execScript(task Task) error { + task = task.Expand() + + if !sys.FileExists(task.Path) { + return fmt.Errorf("file does not exist: %s", task.Path) + } + + var cmd *exec.Cmd + if task.Runner != "" { + cmd = exec.Command(task.Runner, task.Path) + } else { + cmd = exec.Command(task.Path) + } + + setCmdOutput(cmd) + + err := cmd.Run() + if err != nil { + return fmt.Errorf("failed to execute script '%s': %w", task.Path, err) + } + + return nil +} + +func setCmdOutput(cmd *exec.Cmd) { + cmd.Stdout = commandStdout + cmd.Stderr = commandStderr + cmd.Stdin = os.Stdin +} + +func execHTTP(task Task) error { + task = task.Expand() + + if task.URL == "" { + return fmt.Errorf("url is required for HTTP task") + } + if task.Dest == "" { + return fmt.Errorf("dest is required for HTTP task") + } + + client := &http.Client{Timeout: 30 * time.Second} + resp, err := client.Get(task.URL) + if err != nil { + return fmt.Errorf("failed to fetch '%s': %w", task.URL, err) + } + defer resp.Body.Close() + + if resp.StatusCode < 200 || resp.StatusCode >= 300 { + return fmt.Errorf("HTTP request to '%s' returned status %d", task.URL, resp.StatusCode) + } + + if err := os.MkdirAll(filepath.Dir(task.Dest), 0755); err != nil { + return fmt.Errorf("failed to create directory for '%s': %w", task.Dest, err) + } + + f, err := os.Create(task.Dest) + if err != nil { + return fmt.Errorf("failed to create file '%s': %w", task.Dest, err) + } + defer f.Close() + + if _, err := io.Copy(f, resp.Body); err != nil { + f.Close() + os.Remove(task.Dest) + return fmt.Errorf("failed to write to '%s': %w", task.Dest, err) + } + + return nil +} + +func execWait(task Task) error { + task = task.Expand() + + if task.URL == "" { + return fmt.Errorf("url is required for Wait task") + } + + timeout := 30 * time.Second + if task.Timeout != "" { + d, err := time.ParseDuration(task.Timeout) + if err != nil { + return fmt.Errorf("invalid timeout '%s': %w", task.Timeout, err) + } + timeout = d + } + + fmt.Fprintf(commandStdout, "Waiting for %s (timeout: %s)...\n", task.URL, timeout) + + check := checkHTTP + if !strings.HasPrefix(task.URL, "http://") && !strings.HasPrefix(task.URL, "https://") { + check = checkTCP + } + + deadline := time.Now().Add(timeout) + for time.Now().Before(deadline) { + if check(task.URL) == nil { + return nil + } + time.Sleep(1 * time.Second) + } + + return fmt.Errorf("timed out waiting for '%s' after %s", task.URL, timeout) +} + +func checkHTTP(url string) error { + client := &http.Client{Timeout: 2 * time.Second} + resp, err := client.Get(url) + if err != nil { + return err + } + resp.Body.Close() + if resp.StatusCode < 200 || resp.StatusCode >= 300 { + return fmt.Errorf("HTTP %d", resp.StatusCode) + } + return nil +} + +func checkTCP(address string) error { + conn, err := net.DialTimeout("tcp", address, 2*time.Second) + if err != nil { + return err + } + conn.Close() + return nil +} + +func execTemplate(task Task) error { + task = task.Expand() + + if task.Src == "" { + return fmt.Errorf("src is required for Template task") + } + if task.Dest == "" { + return fmt.Errorf("dest is required for Template task") + } + + if !sys.FileExists(task.Src) { + return fmt.Errorf("template file does not exist: %s", task.Src) + } + + data, err := os.ReadFile(task.Src) + if err != nil { + return fmt.Errorf("failed to read template '%s': %w", task.Src, err) + } + + rendered := os.ExpandEnv(string(data)) + + if err := os.MkdirAll(filepath.Dir(task.Dest), 0755); err != nil { + return fmt.Errorf("failed to create directory for '%s': %w", task.Dest, err) + } + + if err := os.WriteFile(task.Dest, []byte(rendered), 0644); err != nil { + return fmt.Errorf("failed to write output file '%s': %w", task.Dest, err) + } + + return nil +} + +func execGroup(task Task) error { + if task.Ref == "" { + return fmt.Errorf("ref is required for Group task") + } + if context == nil || context.Profile.Groups == nil { + return fmt.Errorf("no groups defined in the active profile") + } + + tasks, ok := context.Profile.Groups[task.Ref] + if !ok { + return fmt.Errorf("group '%s' not found in profile", task.Ref) + } + + return ExecuteTasks(tasks) +} + +func execGit(task Task) error { + task = task.Expand() + + if task.Op == "" { + return fmt.Errorf("op is required for Git task") + } + + dir := task.Path + if dir == "" { + var err error + dir, err = os.Getwd() + if err != nil { + return fmt.Errorf("failed to get working directory: %w", err) + } + } + + info, statErr := os.Stat(dir) + if statErr != nil || !info.IsDir() { + return fmt.Errorf("path is not a directory: %s", dir) + } + + var args []string + switch strings.ToLower(task.Op) { + case "pull": + args = []string{"pull"} + if task.Branch != "" { + args = append(args, "origin", task.Branch) + } + case "checkout": + if task.Branch == "" { + return fmt.Errorf("branch is required for git checkout") + } + args = []string{"checkout", task.Branch} + case "fetch": + args = []string{"fetch"} + if task.Branch != "" { + args = append(args, "origin", task.Branch) + } + case "reset": + args = []string{"reset", "--hard"} + if task.Branch != "" { + args = append(args, task.Branch) + } + default: + return fmt.Errorf("invalid git operation '%s' (supported: pull, checkout, fetch, reset)", task.Op) + } + + cmd := exec.Command("git", args...) + cmd.Dir = dir + setCmdOutput(cmd) + + if err := cmd.Run(); err != nil { + return fmt.Errorf("git %s failed in '%s': %w", task.Op, dir, err) + } + + return nil +} + +func execPrompt(task Task) error { + if task.Var == "" { + return fmt.Errorf("var is required for Prompt task") + } + + message := task.Message + if message == "" { + message = fmt.Sprintf("Enter value for %s:", task.Var) + } + + stdinMu.Lock() + defer stdinMu.Unlock() + + fmt.Fprint(commandStdout, message+" ") + + reader := bufio.NewReader(os.Stdin) + value, err := reader.ReadString('\n') + if err != nil { + return fmt.Errorf("failed to read input: %w", err) + } + value = strings.TrimRight(value, "\r\n") + + if value == "" && task.Default != "" { + value = task.Default + } + + os.Setenv(task.Var, value) + return nil +} + +func execConfirm(task Task) error { + message := task.Message + if message == "" { + message = "Continue?" + } + + stdinMu.Lock() + defer stdinMu.Unlock() + + fmt.Fprint(commandStdout, message+" [y/N] ") + + reader := bufio.NewReader(os.Stdin) + answer, err := reader.ReadString('\n') + if err != nil { + return fmt.Errorf("failed to read input: %w", err) + } + answer = strings.TrimSpace(strings.ToLower(answer)) + + if answer != "y" && answer != "yes" { + return fmt.Errorf("aborted by user") + } + return nil +} + +func execParallel(task Task) error { + if task.Ref == "" { + return fmt.Errorf("ref is required for Parallel task") + } + if context == nil || context.Profile.Groups == nil { + return fmt.Errorf("no groups defined in the active profile") + } + + tasks, ok := context.Profile.Groups[task.Ref] + if !ok { + return fmt.Errorf("group '%s' not found in profile", task.Ref) + } + + concurrent := make([]Task, len(tasks)) + for i, t := range tasks { + t.Concurrent = true + concurrent[i] = t + } + + return ExecuteTasks(concurrent) +} + +func execPrint(task Task) error { + msg := task.Message + if !task.Literal { + msg = os.ExpandEnv(msg) + } + + if task.Color != "" { + if code, ok := colorCodes[strings.ToLower(task.Color)]; ok { + fmt.Fprintf(commandStdout, "%s%s%s\n", code, msg, colorCodes["reset"]) + return nil + } + } + + fmt.Fprintln(commandStdout, msg) + return nil +} + +func execRetry(task Task) error { + if task.Ref == "" { + return fmt.Errorf("ref is required for Retry task") + } + if context == nil || context.Profile.Groups == nil { + return fmt.Errorf("no groups defined in the active profile") + } + + tasks, ok := context.Profile.Groups[task.Ref] + if !ok { + return fmt.Errorf("group '%s' not found in profile", task.Ref) + } + + attempts := task.Attempts + if attempts <= 0 { + attempts = 3 + } + + delay := time.Second + if task.Delay != "" { + d, err := time.ParseDuration(task.Delay) + if err != nil { + return fmt.Errorf("invalid delay '%s': %w", task.Delay, err) + } + delay = d + } + + var lastErr error + for i := 0; i < attempts; i++ { + if i > 0 { + fmt.Fprintf(commandStdout, "Retrying... (attempt %d/%d)\n", i+1, attempts) + time.Sleep(delay) + } + if err := ExecuteTasks(tasks); err != nil { + lastErr = err + continue + } + return nil + } + + return fmt.Errorf("all %d attempts failed: %w", attempts, lastErr) +} + +// withDefaultDir returns a copy of tasks with path set to dir on any Shell task +// that does not already have an explicit path. Used to apply profile-level (home) +// and repository-level (repo path) defaults without modifying the original slice. +func withDefaultDir(tasks []Task, dir string) []Task { + if dir == "" || len(tasks) == 0 { + return tasks + } + result := make([]Task, len(tasks)) + for i, t := range tasks { + if t.Type.ToLower() == Shell && t.Path == "" { + t.Path = dir + } + result[i] = t + } + return result +} diff --git a/src/internal/lib/task_runner_extra_test.go b/src/internal/lib/task_runner_extra_test.go new file mode 100644 index 0000000..4b173de --- /dev/null +++ b/src/internal/lib/task_runner_extra_test.go @@ -0,0 +1,264 @@ +package lib + +import ( + "net/http" + "net/http/httptest" + "os" + "path/filepath" + "runtime" + "strings" + "testing" +) + +// --- execConfirm --- + +func TestExecuteTask_confirm_noMessage(t *testing.T) { + r, w, err := os.Pipe() + if err != nil { + t.Fatal(err) + } + w.WriteString("y\n") + w.Close() + + origStdin := os.Stdin + os.Stdin = r + defer func() { os.Stdin = origStdin }() + + // Empty Message exercises the `message = "Continue?"` default branch. + task := Task{Type: Confirm} + if err := ExecuteTask(task); err != nil { + t.Errorf("confirm with no message and answer 'y': unexpected error: %v", err) + } +} + +func TestExecuteTask_confirm_readError(t *testing.T) { + r, _, err := os.Pipe() + if err != nil { + t.Fatal(err) + } + r.Close() // Close the read end so ReadString returns an error. + + origStdin := os.Stdin + os.Stdin = r + defer func() { os.Stdin = origStdin }() + + task := Task{Type: Confirm, Message: "Proceed?"} + if err := ExecuteTask(task); err == nil { + t.Fatal("expected error from closed stdin, got nil") + } +} + +// --- execPrompt --- + +func TestExecuteTask_prompt_readError(t *testing.T) { + r, _, err := os.Pipe() + if err != nil { + t.Fatal(err) + } + r.Close() + + origStdin := os.Stdin + os.Stdin = r + defer func() { os.Stdin = origStdin }() + + task := Task{Type: Prompt, Var: "RAID_PROMPT_ERR_TEST"} + if err := ExecuteTask(task); err == nil { + t.Fatal("expected error from closed stdin, got nil") + } +} + +// --- execParallel / execRetry group not found --- + +func TestExecuteTask_parallel_groupNotFound(t *testing.T) { + context = &Context{ + Profile: Profile{ + Groups: map[string][]Task{ + "other": {{Type: Shell, Cmd: "exit 0"}}, + }, + }, + } + defer func() { context = nil }() + + task := Task{Type: Parallel, Ref: "nonexistent"} + if err := ExecuteTask(task); err == nil { + t.Fatal("expected error for nonexistent parallel group ref, got nil") + } +} + +func TestExecuteTask_retry_groupNotFound(t *testing.T) { + context = &Context{ + Profile: Profile{ + Groups: map[string][]Task{ + "other": {{Type: Shell, Cmd: "exit 0"}}, + }, + }, + } + defer func() { context = nil }() + + task := Task{Type: Retry, Ref: "nonexistent"} + if err := ExecuteTask(task); err == nil { + t.Fatal("expected error for nonexistent retry group ref, got nil") + } +} + +// --- execGit branch coverage --- + +func TestExecuteTask_git_checkoutNoBranch(t *testing.T) { + dir := t.TempDir() + task := Task{Type: Git, Op: "checkout", Path: dir} + err := ExecuteTask(task) + if err == nil { + t.Fatal("expected error for checkout without branch") + } + if !strings.Contains(err.Error(), "branch is required") { + t.Errorf("error %q should mention 'branch is required'", err.Error()) + } +} + +func TestExecuteTask_git_pullWithBranch(t *testing.T) { + // Exercises the `args = append(args, "origin", task.Branch)` branch. + // Git will fail (not a real repo), but the code path is covered. + dir := t.TempDir() + _ = ExecuteTask(Task{Type: Git, Op: "pull", Branch: "main", Path: dir}) +} + +func TestExecuteTask_git_fetchWithBranch(t *testing.T) { + dir := t.TempDir() + _ = ExecuteTask(Task{Type: Git, Op: "fetch", Branch: "main", Path: dir}) +} + +// --- execHTTP error paths --- + +func TestExecuteTask_http_mkdirAllError(t *testing.T) { + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + w.Write([]byte("content")) + })) + defer srv.Close() + + f, err := os.CreateTemp("", "raid-http-test-*") + if err != nil { + t.Fatal(err) + } + f.Close() + defer os.Remove(f.Name()) + + // Parent path contains a file component β€” MkdirAll fails. + task := Task{ + Type: HTTP, + URL: srv.URL, + Dest: filepath.Join(f.Name(), "subdir", "output.txt"), + } + if err := ExecuteTask(task); err == nil { + t.Fatal("execHTTP: expected error when MkdirAll fails") + } +} + +func TestExecuteTask_http_createError(t *testing.T) { + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + w.Write([]byte("content")) + })) + defer srv.Close() + + dir := t.TempDir() + // Create a directory where the output file should be β€” os.Create on a dir fails. + dest := filepath.Join(dir, "output") + os.MkdirAll(dest, 0755) + + task := Task{ + Type: HTTP, + URL: srv.URL, + Dest: dest, + } + if err := ExecuteTask(task); err == nil { + t.Fatal("execHTTP: expected error when dest is a directory") + } +} + +// --- execTemplate error paths --- + +func TestExecuteTask_template_readError(t *testing.T) { + if runtime.GOOS == "windows" { + t.Skip("file permission 0000 not enforced on Windows") + } + dir := t.TempDir() + srcPath := filepath.Join(dir, "template.txt") + if err := os.WriteFile(srcPath, []byte("content"), 0000); err != nil { + t.Fatal(err) + } + defer os.Chmod(srcPath, 0644) + + task := Task{ + Type: Template, + Src: srcPath, + Dest: filepath.Join(dir, "output.txt"), + } + if err := ExecuteTask(task); err == nil { + t.Fatal("execTemplate: expected error when src is unreadable") + } +} + +func TestExecuteTask_template_mkdirAllError(t *testing.T) { + dir := t.TempDir() + srcPath := filepath.Join(dir, "template.txt") + os.WriteFile(srcPath, []byte("hello"), 0644) + + f, err := os.CreateTemp("", "raid-template-test-*") + if err != nil { + t.Fatal(err) + } + f.Close() + defer os.Remove(f.Name()) + + task := Task{ + Type: Template, + Src: srcPath, + Dest: filepath.Join(f.Name(), "subdir", "output.txt"), + } + if err := ExecuteTask(task); err == nil { + t.Fatal("execTemplate: expected error when dest parent MkdirAll fails") + } +} + +func TestExecuteTask_template_writeError(t *testing.T) { + dir := t.TempDir() + srcPath := filepath.Join(dir, "template.txt") + os.WriteFile(srcPath, []byte("hello"), 0644) + + destPath := filepath.Join(dir, "output.txt") + os.WriteFile(destPath, []byte(""), 0444) // read-only + defer os.Chmod(destPath, 0644) + + task := Task{ + Type: Template, + Src: srcPath, + Dest: destPath, + } + if err := ExecuteTask(task); err == nil { + t.Fatal("execTemplate: expected error when dest is read-only") + } +} + +// --- checkHTTP error path --- + +func TestCheckHTTP_unreachableURL(t *testing.T) { + // Port 0 is reserved; connection should be refused immediately. + err := checkHTTP("http://127.0.0.1:1/") + if err == nil { + t.Fatal("checkHTTP() expected error for unreachable URL, got nil") + } +} + +// --- ExecuteTasks concurrent error collection --- + +func TestExecuteTasks_concurrentErrorCollected(t *testing.T) { + // A concurrent task fails AND the following sequential task also fails. + // When the sequential task fails, wg.Wait() completes then errorChan is drained, + // exercising the `errs = append(errs, e)` line. + tasks := []Task{ + {Type: Shell, Cmd: "exit 1", Concurrent: true}, + {Type: Shell, Cmd: "exit 1"}, // sequential failure triggers the drain + } + if err := ExecuteTasks(tasks); err == nil { + t.Fatal("expected error when concurrent and sequential tasks both fail") + } +} diff --git a/src/internal/lib/task_runner_test.go b/src/internal/lib/task_runner_test.go new file mode 100644 index 0000000..36e89fe --- /dev/null +++ b/src/internal/lib/task_runner_test.go @@ -0,0 +1,1236 @@ +package lib + +import ( + "net" + "net/http" + "net/http/httptest" + "os" + "os/exec" + "path/filepath" + "runtime" + "strings" + "testing" +) + +// --- getShell --- + +func TestGetShell(t *testing.T) { + tests := []struct { + input string + want []string + }{ + {"bash", []string{"bash", "-c"}}, + {"/bin/bash", []string{"bash", "-c"}}, + {"sh", []string{"sh", "-c"}}, + {"/bin/sh", []string{"sh", "-c"}}, + {"zsh", []string{"zsh", "-c"}}, + {"/bin/zsh", []string{"zsh", "-c"}}, + {"BASH", []string{"bash", "-c"}}, // case-insensitive + } + + for _, tt := range tests { + t.Run(tt.input, func(t *testing.T) { + got := getShell(tt.input) + if len(got) != len(tt.want) { + t.Fatalf("getShell(%q) = %v, want %v", tt.input, got, tt.want) + } + for i := range got { + if got[i] != tt.want[i] { + t.Errorf("getShell(%q)[%d] = %q, want %q", tt.input, i, got[i], tt.want[i]) + } + } + }) + } +} + +func TestGetShell_defaults(t *testing.T) { + var wantEmpty, wantUnknown []string + if runtime.GOOS == "windows" { + wantEmpty = []string{"cmd", "/c"} + wantUnknown = []string{"cmd", "/c"} + } else { + wantEmpty = []string{"bash", "-c"} + wantUnknown = []string{"bash", "-c"} + } + + for _, tt := range []struct { + input string + want []string + }{ + {"", wantEmpty}, + {"unknown", wantUnknown}, + } { + t.Run(tt.input, func(t *testing.T) { + got := getShell(tt.input) + if len(got) != len(tt.want) { + t.Fatalf("getShell(%q) = %v, want %v", tt.input, got, tt.want) + } + for i := range got { + if got[i] != tt.want[i] { + t.Errorf("getShell(%q)[%d] = %q, want %q", tt.input, i, got[i], tt.want[i]) + } + } + }) + } +} + +func TestGetShell_powershell(t *testing.T) { + // The resolved binary is "pwsh" when available, "powershell" otherwise. + wantBin := "powershell" + if _, err := exec.LookPath("pwsh"); err == nil { + wantBin = "pwsh" + } + + for _, input := range []string{"powershell", "pwsh", "ps"} { + t.Run(input, func(t *testing.T) { + got := getShell(input) + if len(got) != 2 { + t.Fatalf("getShell(%q) = %v, want 2-element slice", input, got) + } + if got[0] != wantBin { + t.Errorf("getShell(%q)[0] = %q, want %q", input, got[0], wantBin) + } + if got[1] != "-Command" { + t.Errorf("getShell(%q)[1] = %q, want \"-Command\"", input, got[1]) + } + }) + } +} + +// --- ExecuteTask --- + +func TestExecuteTask_zeroTaskIsNoop(t *testing.T) { + if err := ExecuteTask(Task{}); err != nil { + t.Errorf("ExecuteTask(zero) returned unexpected error: %v", err) + } +} + +func TestExecuteTask_unknownType(t *testing.T) { + err := ExecuteTask(Task{Type: "bogus", Cmd: "exit 0"}) + if err == nil { + t.Fatal("expected error for unknown task type, got nil") + } + if !strings.Contains(err.Error(), "bogus") { + t.Errorf("error %q should mention the invalid type", err.Error()) + } +} + +func TestExecuteTask_shell(t *testing.T) { + tests := []struct { + name string + task Task + wantErr bool + }{ + { + name: "successful command", + task: Task{Type: Shell, Cmd: "exit 0"}, + wantErr: false, + }, + { + name: "failing command", + task: Task{Type: Shell, Cmd: "exit 1"}, + wantErr: true, + }, + { + name: "type is case-insensitive", + task: Task{Type: "SHELL", Cmd: "exit 0"}, + wantErr: false, + }, + { + name: "explicit bash shell", + task: Task{Type: Shell, Cmd: "exit 0", Shell: "bash"}, + wantErr: false, + }, + { + name: "explicit sh shell", + task: Task{Type: Shell, Cmd: "exit 0", Shell: "sh"}, + wantErr: false, + }, + { + name: "literal=true still executes", + task: Task{Type: Shell, Cmd: "exit 0", Literal: true}, + wantErr: false, + }, + { + name: "concurrent shell task succeeds", + task: Task{Type: Shell, Cmd: "exit 0", Concurrent: true}, + wantErr: false, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + err := ExecuteTask(tt.task) + if (err != nil) != tt.wantErr { + t.Errorf("ExecuteTask() error = %v, wantErr = %v", err, tt.wantErr) + } + }) + } +} + +func TestExecuteTask_shell_zsh(t *testing.T) { + if _, err := exec.LookPath("zsh"); err != nil { + t.Skip("zsh not available on this system") + } + task := Task{Type: Shell, Cmd: "exit 0", Shell: "zsh"} + if err := ExecuteTask(task); err != nil { + t.Errorf("unexpected error with zsh shell: %v", err) + } +} + +func TestExecuteTask_shell_errorMentionsCommand(t *testing.T) { + task := Task{Type: Shell, Cmd: "exit 42"} + err := ExecuteTask(task) + if err == nil { + t.Fatal("expected error, got nil") + } + if !strings.Contains(err.Error(), "exit 42") { + t.Errorf("error %q should mention the failed command", err.Error()) + } +} + +func TestExecuteTask_shell_literal_skipsGoExpansion(t *testing.T) { + // With Literal=false, sys.Expand runs before the shell sees the command. + // With Literal=true, the raw string is passed to the shell unchanged. + // We verify by using a variable that exists in Go's env but NOT in the shell. + // sys.Expand would replace it; with literal=true it is left for the shell. + // The shell will expand it to empty, so the command still succeeds either way β€” + // what we care about is that Literal=true does not crash or error. + os.Setenv("RAID_LITERAL_TEST", "value") + defer os.Unsetenv("RAID_LITERAL_TEST") + + task := Task{Type: Shell, Cmd: "exit 0", Literal: true} + if err := ExecuteTask(task); err != nil { + t.Errorf("literal task returned unexpected error: %v", err) + } +} + +// --- Script tasks --- + +func TestExecuteTask_script(t *testing.T) { + successScript := writeTempScript(t, "#!/bin/sh\nexit 0\n") + failScript := writeTempScript(t, "#!/bin/sh\nexit 1\n") + + tests := []struct { + name string + task Task + wantErr bool + }{ + { + name: "missing file", + task: Task{Type: Script, Path: "/nonexistent/path/script.sh"}, + wantErr: true, + }, + { + name: "success with runner", + task: Task{Type: Script, Path: successScript, Runner: "bash"}, + wantErr: false, + }, + { + name: "failure with runner", + task: Task{Type: Script, Path: failScript, Runner: "bash"}, + wantErr: true, + }, + { + name: "type is case-insensitive", + task: Task{Type: "SCRIPT", Path: successScript, Runner: "bash"}, + wantErr: false, + }, + { + name: "concurrent script task succeeds", + task: Task{Type: Script, Path: successScript, Runner: "bash", Concurrent: true}, + wantErr: false, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + err := ExecuteTask(tt.task) + if (err != nil) != tt.wantErr { + t.Errorf("ExecuteTask() error = %v, wantErr = %v", err, tt.wantErr) + } + }) + } +} + +func TestExecuteTask_script_directExecution(t *testing.T) { + if runtime.GOOS == "windows" { + t.Skip("direct .sh execution not supported on Windows") + } + script := writeTempScript(t, "#!/bin/sh\nexit 0\n") + task := Task{Type: Script, Path: script} + if err := ExecuteTask(task); err != nil { + t.Errorf("unexpected error with direct script execution: %v", err) + } +} + +func TestExecuteTask_script_missingFile_errorMentionsPath(t *testing.T) { + task := Task{Type: Script, Path: "/no/such/file.sh"} + err := ExecuteTask(task) + if err == nil { + t.Fatal("expected error, got nil") + } + if !strings.Contains(err.Error(), "does not exist") { + t.Errorf("error %q should mention file not existing", err.Error()) + } +} + +// --- ExecuteTasks --- + +func TestExecuteTasks(t *testing.T) { + successScript := writeTempScript(t, "#!/bin/sh\nexit 0\n") + failScript := writeTempScript(t, "#!/bin/sh\nexit 1\n") + + tests := []struct { + name string + tasks []Task + wantErr bool + }{ + { + name: "nil slice is noop", + tasks: nil, + wantErr: false, + }, + { + name: "empty slice is noop", + tasks: []Task{}, + wantErr: false, + }, + { + name: "all sequential succeed", + tasks: []Task{ + {Type: Shell, Cmd: "exit 0"}, + {Type: Shell, Cmd: "exit 0"}, + }, + wantErr: false, + }, + { + name: "sequential failure reports error", + tasks: []Task{ + {Type: Shell, Cmd: "exit 1"}, + }, + wantErr: true, + }, + { + name: "all concurrent succeed", + tasks: []Task{ + {Type: Shell, Cmd: "exit 0", Concurrent: true}, + {Type: Shell, Cmd: "exit 0", Concurrent: true}, + {Type: Shell, Cmd: "exit 0", Concurrent: true}, + }, + wantErr: false, + }, + { + name: "concurrent failure reports error", + tasks: []Task{ + {Type: Shell, Cmd: "exit 1", Concurrent: true}, + {Type: Shell, Cmd: "exit 0", Concurrent: true}, + }, + wantErr: true, + }, + { + name: "mixed sequential and concurrent", + tasks: []Task{ + {Type: Shell, Cmd: "exit 0"}, + {Type: Shell, Cmd: "exit 0", Concurrent: true}, + {Type: Shell, Cmd: "exit 0"}, + }, + wantErr: false, + }, + { + name: "script tasks included", + tasks: []Task{ + {Type: Shell, Cmd: "exit 0"}, + {Type: Script, Path: successScript, Runner: "bash"}, + }, + wantErr: false, + }, + { + name: "script failure reported", + tasks: []Task{ + {Type: Script, Path: failScript, Runner: "bash"}, + }, + wantErr: true, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + err := ExecuteTasks(tt.tasks) + if (err != nil) != tt.wantErr { + t.Errorf("ExecuteTasks() error = %v, wantErr = %v", err, tt.wantErr) + } + }) + } +} + +func TestExecuteTasks_sequentialFailsFast(t *testing.T) { + // A failing sequential task must halt further sequential execution. + // We verify this by placing a task after the failure that would write a + // marker file if it ran β€” the marker must not exist after the run. + marker := filepath.Join(t.TempDir(), "should-not-exist") + tasks := []Task{ + {Type: Shell, Cmd: "exit 1"}, + {Type: Shell, Cmd: "echo done > " + marker}, + } + + err := ExecuteTasks(tasks) + if err == nil { + t.Fatal("expected error, got nil") + } + if _, statErr := os.Stat(marker); statErr == nil { + t.Error("second task ran after sequential failure; expected fail-fast") + } +} + +func TestExecuteTasks_errorMentionsTaskType(t *testing.T) { + tasks := []Task{ + {Type: Shell, Cmd: "exit 1"}, + } + + err := ExecuteTasks(tasks) + if err == nil { + t.Fatal("expected error, got nil") + } + if !strings.Contains(err.Error(), "shell") { + t.Errorf("error %q should mention the task type", err.Error()) + } +} + +// --- HTTP tasks --- + +func TestExecuteTask_http(t *testing.T) { + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.Write([]byte("downloaded content")) + })) + defer srv.Close() + + dest := filepath.Join(t.TempDir(), "output.txt") + + tests := []struct { + name string + task Task + wantErr bool + }{ + { + name: "successful download writes file", + task: Task{Type: HTTP, URL: srv.URL, Dest: dest}, + wantErr: false, + }, + { + name: "missing url", + task: Task{Type: HTTP, Dest: dest}, + wantErr: true, + }, + { + name: "missing dest", + task: Task{Type: HTTP, URL: srv.URL}, + wantErr: true, + }, + { + name: "unreachable url", + task: Task{Type: HTTP, URL: "http://localhost:0/no", Dest: dest}, + wantErr: true, + }, + { + name: "type is case-insensitive", + task: Task{Type: "HTTP", URL: srv.URL, Dest: dest}, + wantErr: false, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + err := ExecuteTask(tt.task) + if (err != nil) != tt.wantErr { + t.Errorf("ExecuteTask() error = %v, wantErr = %v", err, tt.wantErr) + } + }) + } +} + +func TestExecuteTask_http_writesCorrectContent(t *testing.T) { + const body = "hello from server" + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.Write([]byte(body)) + })) + defer srv.Close() + + dest := filepath.Join(t.TempDir(), "out.txt") + if err := ExecuteTask(Task{Type: HTTP, URL: srv.URL, Dest: dest}); err != nil { + t.Fatalf("unexpected error: %v", err) + } + + got, err := os.ReadFile(dest) + if err != nil { + t.Fatalf("failed to read output file: %v", err) + } + if string(got) != body { + t.Errorf("file content = %q, want %q", got, body) + } +} + +func TestExecuteTask_http_createsDestDirectory(t *testing.T) { + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.Write([]byte("ok")) + })) + defer srv.Close() + + dest := filepath.Join(t.TempDir(), "a", "b", "c", "out.txt") + if err := ExecuteTask(Task{Type: HTTP, URL: srv.URL, Dest: dest}); err != nil { + t.Fatalf("unexpected error: %v", err) + } + if _, err := os.Stat(dest); err != nil { + t.Errorf("expected dest file to exist: %v", err) + } +} + +func TestExecuteTask_http_nonSuccessStatus(t *testing.T) { + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(http.StatusNotFound) + })) + defer srv.Close() + + dest := filepath.Join(t.TempDir(), "out.txt") + err := ExecuteTask(Task{Type: HTTP, URL: srv.URL, Dest: dest}) + if err == nil { + t.Fatal("expected error for non-2xx status, got nil") + } + if !strings.Contains(err.Error(), "404") { + t.Errorf("error %q should mention the status code", err.Error()) + } +} + +// --- Wait tasks --- + +func TestExecuteTask_wait_http(t *testing.T) { + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(http.StatusOK) + })) + defer srv.Close() + + tests := []struct { + name string + task Task + wantErr bool + }{ + { + name: "http url responds immediately", + task: Task{Type: Wait, URL: srv.URL, Timeout: "5s"}, + wantErr: false, + }, + { + name: "default timeout used when not specified", + task: Task{Type: Wait, URL: srv.URL}, + wantErr: false, + }, + { + name: "missing url", + task: Task{Type: Wait}, + wantErr: true, + }, + { + name: "invalid timeout", + task: Task{Type: Wait, URL: srv.URL, Timeout: "not-a-duration"}, + wantErr: true, + }, + { + name: "type is case-insensitive", + task: Task{Type: "WAIT", URL: srv.URL, Timeout: "5s"}, + wantErr: false, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + err := ExecuteTask(tt.task) + if (err != nil) != tt.wantErr { + t.Errorf("ExecuteTask() error = %v, wantErr = %v", err, tt.wantErr) + } + }) + } +} + +func TestExecuteTask_wait_tcp(t *testing.T) { + ln, err := net.Listen("tcp", "localhost:0") + if err != nil { + t.Fatal(err) + } + defer ln.Close() + + task := Task{Type: Wait, URL: ln.Addr().String(), Timeout: "5s"} + if err := ExecuteTask(task); err != nil { + t.Errorf("unexpected error waiting for TCP: %v", err) + } +} + +func TestExecuteTask_wait_timeout(t *testing.T) { + // Nothing is listening β€” must time out. + task := Task{Type: Wait, URL: "localhost:19234", Timeout: "1s"} + err := ExecuteTask(task) + if err == nil { + t.Fatal("expected timeout error, got nil") + } + if !strings.Contains(err.Error(), "timed out") { + t.Errorf("error %q should mention timeout", err.Error()) + } +} + +// --- Template tasks --- + +func TestExecuteTask_template(t *testing.T) { + dir := t.TempDir() + src := filepath.Join(dir, "tmpl.txt") + dest := filepath.Join(dir, "out.txt") + os.WriteFile(src, []byte("hello $TMPL_TEST_VAR"), 0644) + + tests := []struct { + name string + task Task + wantErr bool + }{ + { + name: "successful render", + task: Task{Type: Template, Src: src, Dest: dest}, + wantErr: false, + }, + { + name: "missing src", + task: Task{Type: Template, Dest: dest}, + wantErr: true, + }, + { + name: "missing dest", + task: Task{Type: Template, Src: src}, + wantErr: true, + }, + { + name: "src file does not exist", + task: Task{Type: Template, Src: "/nonexistent/tmpl.txt", Dest: dest}, + wantErr: true, + }, + { + name: "type is case-insensitive", + task: Task{Type: "TEMPLATE", Src: src, Dest: dest}, + wantErr: false, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + err := ExecuteTask(tt.task) + if (err != nil) != tt.wantErr { + t.Errorf("ExecuteTask() error = %v, wantErr = %v", err, tt.wantErr) + } + }) + } +} + +func TestExecuteTask_template_expandsEnvVars(t *testing.T) { + os.Setenv("TMPL_TEST_VAR", "world") + defer os.Unsetenv("TMPL_TEST_VAR") + + dir := t.TempDir() + src := filepath.Join(dir, "tmpl.txt") + dest := filepath.Join(dir, "out.txt") + os.WriteFile(src, []byte("hello $TMPL_TEST_VAR and ${TMPL_TEST_VAR}"), 0644) + + if err := ExecuteTask(Task{Type: Template, Src: src, Dest: dest}); err != nil { + t.Fatalf("unexpected error: %v", err) + } + + got, err := os.ReadFile(dest) + if err != nil { + t.Fatalf("failed to read output file: %v", err) + } + const want = "hello world and world" + if string(got) != want { + t.Errorf("rendered content = %q, want %q", got, want) + } +} + +func TestExecuteTask_template_createsDestDirectory(t *testing.T) { + dir := t.TempDir() + src := filepath.Join(dir, "tmpl.txt") + dest := filepath.Join(dir, "a", "b", "out.txt") + os.WriteFile(src, []byte("content"), 0644) + + if err := ExecuteTask(Task{Type: Template, Src: src, Dest: dest}); err != nil { + t.Fatalf("unexpected error: %v", err) + } + if _, err := os.Stat(dest); err != nil { + t.Errorf("expected dest file to exist: %v", err) + } +} + +func TestExecuteTask_template_unsetVarExpandsToEmpty(t *testing.T) { + os.Unsetenv("TMPL_UNSET_VAR") + + dir := t.TempDir() + src := filepath.Join(dir, "tmpl.txt") + dest := filepath.Join(dir, "out.txt") + os.WriteFile(src, []byte("value=$TMPL_UNSET_VAR"), 0644) + + if err := ExecuteTask(Task{Type: Template, Src: src, Dest: dest}); err != nil { + t.Fatalf("unexpected error: %v", err) + } + + got, _ := os.ReadFile(dest) + if string(got) != "value=" { + t.Errorf("rendered content = %q, want %q", got, "value=") + } +} + +// --- Condition --- + +func TestExecuteTask_condition_platform(t *testing.T) { + // Task with a condition that will never match (impossible platform). + task := Task{ + Type: Shell, + Cmd: "exit 0", + Condition: &Condition{Platform: "nonexistent-platform"}, + } + if err := ExecuteTask(task); err != nil { + t.Errorf("task with unmet condition should be skipped, got error: %v", err) + } +} + +func TestExecuteTask_condition_exists_filePresent(t *testing.T) { + f, err := os.CreateTemp(t.TempDir(), "exists-*") + if err != nil { + t.Fatal(err) + } + f.Close() + + // Condition is met β€” task runs. + task := Task{ + Type: Shell, + Cmd: "exit 0", + Condition: &Condition{Exists: f.Name()}, + } + if err := ExecuteTask(task); err != nil { + t.Errorf("unexpected error with met exists condition: %v", err) + } +} + +func TestExecuteTask_condition_exists_fileMissing(t *testing.T) { + // Condition is NOT met β€” task is skipped (no error). + task := Task{ + Type: Shell, + Cmd: "exit 1", + Condition: &Condition{Exists: "/nonexistent/file/path"}, + } + if err := ExecuteTask(task); err != nil { + t.Errorf("task with unmet exists condition should be skipped, got error: %v", err) + } +} + +func TestExecuteTask_condition_cmd_passes(t *testing.T) { + // Condition command exits 0 β€” task runs. + task := Task{ + Type: Shell, + Cmd: "exit 0", + Condition: &Condition{Cmd: "exit 0"}, + } + if err := ExecuteTask(task); err != nil { + t.Errorf("unexpected error when condition cmd passes: %v", err) + } +} + +func TestExecuteTask_condition_cmd_fails(t *testing.T) { + // Condition command exits non-0 β€” task is skipped (no error). + marker := filepath.Join(t.TempDir(), "should-not-exist") + task := Task{ + Type: Shell, + Cmd: "echo done > " + marker, + Condition: &Condition{Cmd: "exit 1"}, + } + if err := ExecuteTask(task); err != nil { + t.Errorf("task with failing condition cmd should be skipped, got error: %v", err) + } + if _, err := os.Stat(marker); err == nil { + t.Error("task body ran despite failing condition cmd") + } +} + +// --- Group tasks --- + +func TestExecuteTask_group_noContext(t *testing.T) { + context = nil + task := Task{Type: Group, Ref: "mygroup"} + err := ExecuteTask(task) + if err == nil { + t.Fatal("expected error when context is nil, got nil") + } +} + +func TestExecuteTask_group_noGroups(t *testing.T) { + context = &Context{Profile: Profile{}} + defer func() { context = nil }() + + task := Task{Type: Group, Ref: "mygroup"} + err := ExecuteTask(task) + if err == nil { + t.Fatal("expected error when profile has no groups, got nil") + } +} + +func TestExecuteTask_group_missingRef(t *testing.T) { + context = &Context{ + Profile: Profile{ + Groups: map[string][]Task{ + "other": {{Type: Shell, Cmd: "exit 0"}}, + }, + }, + } + defer func() { context = nil }() + + task := Task{Type: Group, Ref: "missing"} + err := ExecuteTask(task) + if err == nil { + t.Fatal("expected error for missing group ref, got nil") + } + if !strings.Contains(err.Error(), "missing") { + t.Errorf("error %q should mention the group name", err.Error()) + } +} + +func TestExecuteTask_group_success(t *testing.T) { + marker := filepath.Join(t.TempDir(), "group-ran") + context = &Context{ + Profile: Profile{ + Groups: map[string][]Task{ + "mygroup": { + {Type: Shell, Cmd: "echo done > " + marker}, + }, + }, + }, + } + defer func() { context = nil }() + + task := Task{Type: Group, Ref: "mygroup"} + if err := ExecuteTask(task); err != nil { + t.Fatalf("unexpected error: %v", err) + } + if _, err := os.Stat(marker); err != nil { + t.Errorf("group tasks did not run: marker file missing") + } +} + +func TestExecuteTask_group_propagatesFailure(t *testing.T) { + context = &Context{ + Profile: Profile{ + Groups: map[string][]Task{ + "failgroup": { + {Type: Shell, Cmd: "exit 1"}, + }, + }, + }, + } + defer func() { context = nil }() + + task := Task{Type: Group, Ref: "failgroup"} + if err := ExecuteTask(task); err == nil { + t.Fatal("expected error from failing group task, got nil") + } +} + +func TestExecuteTask_group_emptyRefError(t *testing.T) { + task := Task{Type: Group, Ref: ""} + if err := ExecuteTask(task); err == nil { + t.Fatal("expected error for empty ref, got nil") + } +} + +// --- Git tasks --- + +func initTempGitRepo(t *testing.T) string { + t.Helper() + dir := t.TempDir() + run(t, dir, "git", "init") + run(t, dir, "git", "config", "user.email", "test@example.com") + run(t, dir, "git", "config", "user.name", "Test") + // Create an initial commit so HEAD exists. + run(t, dir, "git", "commit", "--allow-empty", "-m", "init") + return dir +} + +func run(t *testing.T, dir string, name string, args ...string) { + t.Helper() + cmd := exec.Command(name, args...) + cmd.Dir = dir + if out, err := cmd.CombinedOutput(); err != nil { + t.Fatalf("command %q failed: %v\n%s", name+" "+strings.Join(args, " "), err, out) + } +} + +func TestExecuteTask_git(t *testing.T) { + dir := initTempGitRepo(t) + + tests := []struct { + name string + task Task + wantErr bool + }{ + { + name: "missing op", + task: Task{Type: Git, Path: dir}, + wantErr: true, + }, + { + name: "invalid op", + task: Task{Type: Git, Op: "push", Path: dir}, + wantErr: true, + }, + { + name: "path does not exist", + task: Task{Type: Git, Op: "pull", Path: "/nonexistent/path"}, + wantErr: true, + }, + { + name: "fetch with no remote succeeds", + task: Task{Type: Git, Op: "fetch", Path: dir}, + wantErr: false, + }, + { + name: "checkout nonexistent branch fails", + task: Task{Type: Git, Op: "checkout", Branch: "nonexistent-branch-xyz", Path: dir}, + wantErr: true, + }, + { + name: "reset hard HEAD succeeds", + task: Task{Type: Git, Op: "reset", Branch: "HEAD", Path: dir}, + wantErr: false, + }, + { + name: "type is case-insensitive", + task: Task{Type: "GIT", Op: "fetch", Path: dir}, + wantErr: false, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + err := ExecuteTask(tt.task) + if (err != nil) != tt.wantErr { + t.Errorf("ExecuteTask() error = %v, wantErr = %v", err, tt.wantErr) + } + }) + } +} + +func TestExecuteTask_git_defaultsToWorkingDir(t *testing.T) { + // When Dir is empty, git runs in the current working directory. + // We just verify it doesn't crash β€” the actual git op (fetch) exits 0 with no remote. + task := Task{Type: Git, Op: "fetch"} + // The test runner's working dir is the package directory (a valid git repo). + if err := ExecuteTask(task); err != nil { + t.Logf("note: git fetch returned error (possibly no remote): %v", err) + } +} + +// --- Print tasks --- + +func TestExecuteTask_print(t *testing.T) { + os.Setenv("PRINT_TEST_VAR", "world") + defer os.Unsetenv("PRINT_TEST_VAR") + + tests := []struct { + name string + task Task + wantErr bool + }{ + { + name: "basic message", + task: Task{Type: Print, Message: "hello"}, + wantErr: false, + }, + { + name: "expands env vars", + task: Task{Type: Print, Message: "hello $PRINT_TEST_VAR"}, + wantErr: false, + }, + { + name: "literal skips expansion", + task: Task{Type: Print, Message: "hello $PRINT_TEST_VAR", Literal: true}, + wantErr: false, + }, + { + name: "with valid color", + task: Task{Type: Print, Message: "colored", Color: "green"}, + wantErr: false, + }, + { + name: "with unknown color falls back gracefully", + task: Task{Type: Print, Message: "msg", Color: "magenta"}, + wantErr: false, + }, + { + name: "type is case-insensitive", + task: Task{Type: "PRINT", Message: "hello"}, + wantErr: false, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + err := ExecuteTask(tt.task) + if (err != nil) != tt.wantErr { + t.Errorf("ExecuteTask() error = %v, wantErr = %v", err, tt.wantErr) + } + }) + } +} + +// --- Prompt tasks --- + +func TestExecuteTask_prompt_missingVar(t *testing.T) { + task := Task{Type: Prompt} + if err := ExecuteTask(task); err == nil { + t.Fatal("expected error for missing var, got nil") + } +} + +func TestExecuteTask_prompt_setsEnvVar(t *testing.T) { + os.Unsetenv("RAID_PROMPT_TEST") + + r, w, err := os.Pipe() + if err != nil { + t.Fatal(err) + } + w.WriteString("myvalue\n") + w.Close() + + origStdin := os.Stdin + os.Stdin = r + defer func() { os.Stdin = origStdin }() + + task := Task{Type: Prompt, Var: "RAID_PROMPT_TEST", Message: "Enter:"} + if err := ExecuteTask(task); err != nil { + t.Fatalf("unexpected error: %v", err) + } + + if got := os.Getenv("RAID_PROMPT_TEST"); got != "myvalue" { + t.Errorf("env var RAID_PROMPT_TEST = %q, want %q", got, "myvalue") + } + os.Unsetenv("RAID_PROMPT_TEST") +} + +func TestExecuteTask_prompt_usesDefault(t *testing.T) { + os.Unsetenv("RAID_PROMPT_DEFAULT_TEST") + + r, w, err := os.Pipe() + if err != nil { + t.Fatal(err) + } + w.WriteString("\n") // empty input + w.Close() + + origStdin := os.Stdin + os.Stdin = r + defer func() { os.Stdin = origStdin }() + + task := Task{Type: Prompt, Var: "RAID_PROMPT_DEFAULT_TEST", Default: "fallback"} + if err := ExecuteTask(task); err != nil { + t.Fatalf("unexpected error: %v", err) + } + + if got := os.Getenv("RAID_PROMPT_DEFAULT_TEST"); got != "fallback" { + t.Errorf("env var = %q, want %q", got, "fallback") + } + os.Unsetenv("RAID_PROMPT_DEFAULT_TEST") +} + +// --- Confirm tasks --- + +func TestExecuteTask_confirm_yes(t *testing.T) { + for _, answer := range []string{"y\n", "yes\n", "Y\n", "YES\n"} { + r, w, err := os.Pipe() + if err != nil { + t.Fatal(err) + } + w.WriteString(answer) + w.Close() + + origStdin := os.Stdin + os.Stdin = r + + task := Task{Type: Confirm, Message: "Proceed?"} + err = ExecuteTask(task) + os.Stdin = origStdin + + if err != nil { + t.Errorf("answer %q: unexpected error: %v", answer, err) + } + } +} + +func TestExecuteTask_confirm_no(t *testing.T) { + for _, answer := range []string{"n\n", "no\n", "\n", "maybe\n"} { + r, w, err := os.Pipe() + if err != nil { + t.Fatal(err) + } + w.WriteString(answer) + w.Close() + + origStdin := os.Stdin + os.Stdin = r + + task := Task{Type: Confirm, Message: "Proceed?"} + err = ExecuteTask(task) + os.Stdin = origStdin + + if err == nil { + t.Errorf("answer %q: expected error (aborted), got nil", answer) + } + if err != nil && !strings.Contains(err.Error(), "aborted") { + t.Errorf("answer %q: error %q should mention 'aborted'", answer, err.Error()) + } + } +} + +// --- Parallel tasks --- + +func TestExecuteTask_parallel_noContext(t *testing.T) { + context = nil + task := Task{Type: Parallel, Ref: "mygroup"} + if err := ExecuteTask(task); err == nil { + t.Fatal("expected error when context is nil, got nil") + } +} + +func TestExecuteTask_parallel_missingRef(t *testing.T) { + task := Task{Type: Parallel, Ref: ""} + if err := ExecuteTask(task); err == nil { + t.Fatal("expected error for empty ref, got nil") + } +} + +func TestExecuteTask_parallel_success(t *testing.T) { + markerA := filepath.Join(t.TempDir(), "a") + markerB := filepath.Join(t.TempDir(), "b") + context = &Context{ + Profile: Profile{ + Groups: map[string][]Task{ + "workers": { + {Type: Shell, Cmd: "echo done > " + markerA}, + {Type: Shell, Cmd: "echo done > " + markerB}, + }, + }, + }, + } + defer func() { context = nil }() + + task := Task{Type: Parallel, Ref: "workers"} + if err := ExecuteTask(task); err != nil { + t.Fatalf("unexpected error: %v", err) + } + for _, m := range []string{markerA, markerB} { + if _, err := os.Stat(m); err != nil { + t.Errorf("expected marker %s to exist", m) + } + } +} + +func TestExecuteTask_parallel_propagatesFailure(t *testing.T) { + context = &Context{ + Profile: Profile{ + Groups: map[string][]Task{ + "broken": { + {Type: Shell, Cmd: "exit 1"}, + }, + }, + }, + } + defer func() { context = nil }() + + task := Task{Type: Parallel, Ref: "broken"} + if err := ExecuteTask(task); err == nil { + t.Fatal("expected error from failing parallel task, got nil") + } +} + +// --- Retry tasks --- + +func TestExecuteTask_retry_missingRef(t *testing.T) { + task := Task{Type: Retry, Ref: ""} + if err := ExecuteTask(task); err == nil { + t.Fatal("expected error for empty ref, got nil") + } +} + +func TestExecuteTask_retry_noContext(t *testing.T) { + context = nil + task := Task{Type: Retry, Ref: "mygroup"} + if err := ExecuteTask(task); err == nil { + t.Fatal("expected error when context is nil, got nil") + } +} + +func TestExecuteTask_retry_succeedsOnFirstAttempt(t *testing.T) { + marker := filepath.Join(t.TempDir(), "ran") + context = &Context{ + Profile: Profile{ + Groups: map[string][]Task{ + "work": {{Type: Shell, Cmd: "echo done > " + marker}}, + }, + }, + } + defer func() { context = nil }() + + task := Task{Type: Retry, Ref: "work", Attempts: 3} + if err := ExecuteTask(task); err != nil { + t.Fatalf("unexpected error: %v", err) + } + if _, err := os.Stat(marker); err != nil { + t.Error("expected marker to exist after successful retry task") + } +} + +func TestExecuteTask_retry_exhaustsAllAttempts(t *testing.T) { + context = &Context{ + Profile: Profile{ + Groups: map[string][]Task{ + "always-fail": {{Type: Shell, Cmd: "exit 1"}}, + }, + }, + } + defer func() { context = nil }() + + task := Task{Type: Retry, Ref: "always-fail", Attempts: 2, Delay: "1ms"} + err := ExecuteTask(task) + if err == nil { + t.Fatal("expected error after all retries exhausted, got nil") + } + if !strings.Contains(err.Error(), "2 attempts") { + t.Errorf("error %q should mention attempt count", err.Error()) + } +} + +func TestExecuteTask_retry_invalidDelay(t *testing.T) { + context = &Context{ + Profile: Profile{ + Groups: map[string][]Task{ + "work": {{Type: Shell, Cmd: "exit 0"}}, + }, + }, + } + defer func() { context = nil }() + + task := Task{Type: Retry, Ref: "work", Delay: "not-a-duration"} + if err := ExecuteTask(task); err == nil { + t.Fatal("expected error for invalid delay, got nil") + } +} + +// --- helpers --- + +func writeTempScript(t *testing.T, content string) string { + t.Helper() + path := filepath.Join(t.TempDir(), "script.sh") + if err := os.WriteFile(path, []byte(content), 0755); err != nil { + t.Fatalf("failed to write temp script: %v", err) + } + return path +} diff --git a/src/internal/lib/task_test.go b/src/internal/lib/task_test.go new file mode 100644 index 0000000..db8a2e7 --- /dev/null +++ b/src/internal/lib/task_test.go @@ -0,0 +1,114 @@ +package lib + +import ( + "os" + "testing" +) + +func TestTaskIsZero(t *testing.T) { + tests := []struct { + name string + task Task + want bool + }{ + {"empty task", Task{}, true}, + {"task with type only", Task{Type: Shell}, false}, + {"task with cmd but no type", Task{Cmd: "echo hi"}, true}, + {"shell task with cmd", Task{Type: Shell, Cmd: "echo hi"}, false}, + {"script task with path", Task{Type: Script, Path: "/tmp/script.sh"}, false}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + if got := tt.task.IsZero(); got != tt.want { + t.Errorf("IsZero() = %v, want %v", got, tt.want) + } + }) + } +} + +func TestTaskTypeToLower(t *testing.T) { + tests := []struct { + input TaskType + want TaskType + }{ + {"shell", "shell"}, + {"Shell", "shell"}, + {"SHELL", "shell"}, + {"script", "script"}, + {"Script", "script"}, + {"SCRIPT", "script"}, + {"", ""}, + } + + for _, tt := range tests { + t.Run(string(tt.input), func(t *testing.T) { + if got := tt.input.ToLower(); got != tt.want { + t.Errorf("ToLower() = %q, want %q", got, tt.want) + } + }) + } +} + +func TestTaskExpand_expandsEnvVarsInFields(t *testing.T) { + os.Setenv("RAID_TEST_EXPAND", "hello") + defer os.Unsetenv("RAID_TEST_EXPAND") + + task := Task{ + Type: Shell, + Concurrent: true, + Literal: true, + Cmd: "echo $RAID_TEST_EXPAND", + Shell: "bash", + Path: "/tmp/$RAID_TEST_EXPAND/script.sh", + Runner: "$RAID_TEST_EXPAND", + } + + got := task.Expand() + + if got.Cmd != "echo hello" { + t.Errorf("Expand().Cmd = %q, want %q", got.Cmd, "echo hello") + } + if got.Path != "/tmp/hello/script.sh" { + t.Errorf("Expand().Path = %q, want %q", got.Path, "/tmp/hello/script.sh") + } + if got.Runner != "hello" { + t.Errorf("Expand().Runner = %q, want %q", got.Runner, "hello") + } +} + +func TestTaskExpand_preservesNonStringFields(t *testing.T) { + task := Task{ + Type: Script, + Concurrent: true, + Literal: true, + Shell: "zsh", + } + + got := task.Expand() + + if got.Type != task.Type { + t.Errorf("Expand().Type = %q, want %q", got.Type, task.Type) + } + if got.Concurrent != task.Concurrent { + t.Errorf("Expand().Concurrent = %v, want %v", got.Concurrent, task.Concurrent) + } + if got.Literal != task.Literal { + t.Errorf("Expand().Literal = %v, want %v", got.Literal, task.Literal) + } + if got.Shell != task.Shell { + t.Errorf("Expand().Shell = %q, want %q", got.Shell, task.Shell) + } +} + +func TestTaskExpand_doesNotMutateOriginal(t *testing.T) { + os.Setenv("RAID_TEST_ORIG", "changed") + defer os.Unsetenv("RAID_TEST_ORIG") + + original := Task{Type: Shell, Cmd: "echo $RAID_TEST_ORIG"} + _ = original.Expand() + + if original.Cmd != "echo $RAID_TEST_ORIG" { + t.Errorf("Expand() mutated original: Cmd = %q", original.Cmd) + } +} diff --git a/src/internal/sys/system.go b/src/internal/sys/system.go index f002d24..32a8dd9 100644 --- a/src/internal/sys/system.go +++ b/src/internal/sys/system.go @@ -1,17 +1,30 @@ package sys import ( + "fmt" "log" "os" - "path" + "path/filepath" + "runtime" "strings" "github.com/mitchellh/go-homedir" ) -// Path Separator as a string +// Sep is the OS path separator as a string. const Sep = string(os.PathSeparator) +// Platform identifies the host operating system. +type Platform string + +const ( + Windows Platform = "windows" + Linux Platform = "linux" + Darwin Platform = "darwin" + Other Platform = "other" +) + +// GetHomeDir returns the current user's home directory, fatally logging on failure. func GetHomeDir() string { home, err := homedir.Dir() if err != nil { @@ -20,27 +33,89 @@ func GetHomeDir() string { return home } +// CreateFile opens or creates the file at filePath for reading and writing, creating parent directories as needed. func CreateFile(filePath string) (*os.File, error) { pathEx := ExpandPath(filePath) - if FileExists(pathEx) { - return os.Open(pathEx) + if err := os.MkdirAll(filepath.Dir(pathEx), 0755); err != nil { + return nil, fmt.Errorf("failed to create directories for '%s': %w", filePath, err) } - - os.MkdirAll(path.Dir(pathEx), os.ModeDir|0755) - return os.Create(pathEx) + return os.OpenFile(pathEx, os.O_RDWR|os.O_CREATE, 0644) } +// FileExists reports whether the file or directory at path exists. +// Permission errors are treated as the path existing to avoid silently +// overwriting or recreating inaccessible files. func FileExists(path string) bool { path = ExpandPath(path) - if _, err := os.Open(path); err != nil { - return false + _, err := os.Stat(path) + if err == nil { + return true + } + return os.IsPermission(err) +} + +// Expand expands environment variables in input without tokenizing, preserving +// quoting and spacing. Use ExpandPath for file system paths that also need ~ expansion. +func Expand(input string) string { + return os.ExpandEnv(input) +} + +// ExpandPath expands environment variables and a leading ~ in the given path. +func ExpandPath(input string) string { + if input == "" { + return input + } + + input = os.ExpandEnv(input) + input = strings.TrimSpace(input) + input, _ = homedir.Expand(input) + return input +} + +// SplitInput splits a command string into tokens, respecting double-quoted segments. +// todo: replace with a proper shell-quoting parser +func SplitInput(input string) []string { + var out []string + var b strings.Builder + inQuote := false + skipSpace := false + + for _, ch := range input { + switch { + case ch == '"': + inQuote = !inQuote + if !inQuote && b.Len() > 0 { + out = append(out, b.String()) + b.Reset() + } + skipSpace = false + case ch == ' ' && !inQuote: + if !skipSpace && b.Len() > 0 { + out = append(out, b.String()) + b.Reset() + } + skipSpace = true + default: + skipSpace = false + b.WriteRune(ch) + } + } + if b.Len() > 0 { + out = append(out, b.String()) } - return true + return out } -func ExpandPath(path string) string { - if strings.HasPrefix(path, "~") { - return strings.Replace(path, "~", GetHomeDir(), 1) +// GetPlatform returns the current operating system as a Platform value. +func GetPlatform() Platform { + switch runtime.GOOS { + case "windows": + return Windows + case "darwin": + return Darwin + case "linux": + return Linux + default: + return Other } - return os.ExpandEnv(path) } diff --git a/src/internal/sys/system_test.go b/src/internal/sys/system_test.go new file mode 100644 index 0000000..3a7ef09 --- /dev/null +++ b/src/internal/sys/system_test.go @@ -0,0 +1,213 @@ +package sys + +import ( + "os" + "path/filepath" + "testing" +) + +func TestGetPlatform(t *testing.T) { + p := GetPlatform() + switch p { + case Windows, Linux, Darwin, Other: + // valid Platform value + default: + t.Errorf("GetPlatform() = %q, not a valid Platform value", p) + } +} + +func TestGetHomeDir(t *testing.T) { + home := GetHomeDir() + if home == "" { + t.Error("GetHomeDir() returned empty string") + } +} + +func TestFileExists(t *testing.T) { + tests := []struct { + name string + path func(t *testing.T) string + want bool + }{ + { + name: "existing file", + path: func(t *testing.T) string { + f, err := os.CreateTemp(t.TempDir(), "raid-test-*") + if err != nil { + t.Fatal(err) + } + f.Close() + return f.Name() + }, + want: true, + }, + { + name: "non-existing file", + path: func(t *testing.T) string { + return filepath.Join(t.TempDir(), "does-not-exist") + }, + want: false, + }, + { + name: "existing directory", + path: func(t *testing.T) string { + return t.TempDir() + }, + want: true, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + path := tt.path(t) + if got := FileExists(path); got != tt.want { + t.Errorf("FileExists(%q) = %v, want %v", path, got, tt.want) + } + }) + } +} + +func TestExpandPath(t *testing.T) { + t.Run("empty string", func(t *testing.T) { + if got := ExpandPath(""); got != "" { + t.Errorf("ExpandPath(\"\") = %q, want \"\"", got) + } + }) + + t.Run("expands env var", func(t *testing.T) { + os.Setenv("RAID_SYS_TEST", "testvalue") + defer os.Unsetenv("RAID_SYS_TEST") + + got := ExpandPath("/tmp/$RAID_SYS_TEST/path") + if got != "/tmp/testvalue/path" { + t.Errorf("ExpandPath() = %q, want %q", got, "/tmp/testvalue/path") + } + }) + + t.Run("expands tilde", func(t *testing.T) { + got := ExpandPath("~/something") + if got == "~/something" { + t.Error("ExpandPath() did not expand tilde") + } + if got == "" { + t.Error("ExpandPath() returned empty string for ~/something") + } + }) + + t.Run("absolute path unchanged", func(t *testing.T) { + got := ExpandPath("/usr/local/bin") + if got != "/usr/local/bin" { + t.Errorf("ExpandPath(%q) = %q, want unchanged", "/usr/local/bin", got) + } + }) +} + +func TestExpand(t *testing.T) { + t.Run("empty string", func(t *testing.T) { + if got := Expand(""); got != "" { + t.Errorf("Expand(\"\") = %q, want \"\"", got) + } + }) + + t.Run("single token with env var", func(t *testing.T) { + os.Setenv("RAID_EXPAND_A", "hello") + defer os.Unsetenv("RAID_EXPAND_A") + + got := Expand("$RAID_EXPAND_A") + if got != "hello" { + t.Errorf("Expand() = %q, want %q", got, "hello") + } + }) + + t.Run("multiple tokens with env vars", func(t *testing.T) { + os.Setenv("RAID_EXPAND_X", "foo") + os.Setenv("RAID_EXPAND_Y", "bar") + defer os.Unsetenv("RAID_EXPAND_X") + defer os.Unsetenv("RAID_EXPAND_Y") + + got := Expand("$RAID_EXPAND_X $RAID_EXPAND_Y") + if got != "foo bar" { + t.Errorf("Expand() = %q, want %q", got, "foo bar") + } + }) +} + +func TestSplitInput(t *testing.T) { + tests := []struct { + name string + input string + want []string + }{ + {"empty string", "", nil}, + {"single word", "hello", []string{"hello"}}, + {"two words", "hello world", []string{"hello", "world"}}, + {"quoted string with space", `"hello world"`, []string{"hello world"}}, + {"word then quoted", `echo "hello world"`, []string{"echo", "hello world"}}, + {"multiple spaces", "a b", []string{"a", "b"}}, + {"trailing space", "a b ", []string{"a", "b"}}, + {"leading space", " a b", []string{"a", "b"}}, + {"empty quotes", `""`, nil}, + {"quoted then unquoted", `"foo" bar`, []string{"foo", "bar"}}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got := SplitInput(tt.input) + if len(got) != len(tt.want) { + t.Fatalf("SplitInput(%q) = %v, want %v", tt.input, got, tt.want) + } + for i := range got { + if got[i] != tt.want[i] { + t.Errorf("SplitInput(%q)[%d] = %q, want %q", tt.input, i, got[i], tt.want[i]) + } + } + }) + } +} + +func TestCreateFile_newFile(t *testing.T) { + dir := t.TempDir() + path := filepath.Join(dir, "sub", "nested", "file.txt") + + f, err := CreateFile(path) + if err != nil { + t.Fatalf("CreateFile() error: %v", err) + } + f.Close() + + if !FileExists(path) { + t.Errorf("CreateFile() did not create file at %q", path) + } +} + +func TestCreateFile_mkdirAllError(t *testing.T) { + // Use a regular file as a parent directory component β€” os.MkdirAll will fail with ENOTDIR. + f, err := os.CreateTemp("", "raid-test-*") + if err != nil { + t.Fatal(err) + } + f.Close() + defer os.Remove(f.Name()) + + path := filepath.Join(f.Name(), "subdir", "file.txt") + if _, err := CreateFile(path); err == nil { + t.Fatal("CreateFile() expected error when parent path contains a file component") + } +} + +func TestCreateFile_existingFile(t *testing.T) { + dir := t.TempDir() + path := filepath.Join(dir, "existing.txt") + + existing, err := os.Create(path) + if err != nil { + t.Fatal(err) + } + existing.Close() + + f, err := CreateFile(path) + if err != nil { + t.Fatalf("CreateFile() on existing file error: %v", err) + } + f.Close() +} diff --git a/src/internal/utils/Common.go b/src/internal/utils/Common.go deleted file mode 100644 index 273762e..0000000 --- a/src/internal/utils/Common.go +++ /dev/null @@ -1,15 +0,0 @@ -package utils - -import "fmt" - -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/internal/utils/cobra_ext_test.go b/src/internal/utils/cobra_ext_test.go new file mode 100644 index 0000000..b2d4f37 --- /dev/null +++ b/src/internal/utils/cobra_ext_test.go @@ -0,0 +1,34 @@ +package utils + +import ( + "testing" + + "github.com/spf13/cobra" +) + +func TestMatchOne_allFail(t *testing.T) { + validator := MatchOne(cobra.ExactArgs(1), cobra.MinimumNArgs(2)) + cmd := &cobra.Command{} + err := validator(cmd, []string{}) + if err == nil { + t.Fatal("MatchOne() expected error when all validators fail, got nil") + } +} + +func TestMatchOne_firstSucceeds(t *testing.T) { + validator := MatchOne(cobra.ExactArgs(1), cobra.ExactArgs(2)) + cmd := &cobra.Command{} + err := validator(cmd, []string{"one"}) + if err != nil { + t.Errorf("MatchOne() unexpected error when first validator passes: %v", err) + } +} + +func TestMatchOne_secondSucceeds(t *testing.T) { + validator := MatchOne(cobra.ExactArgs(2), cobra.ExactArgs(1)) + cmd := &cobra.Command{} + err := validator(cmd, []string{"one"}) + if err != nil { + t.Errorf("MatchOne() unexpected error when second validator passes: %v", err) + } +} diff --git a/src/internal/utils/common.go b/src/internal/utils/common.go new file mode 100644 index 0000000..725fce3 --- /dev/null +++ b/src/internal/utils/common.go @@ -0,0 +1,42 @@ +package utils + +import ( + "encoding/json" + "fmt" + "io" + "strings" + + "gopkg.in/yaml.v3" +) + +// MergeErr combines multiple errors into a single error, skipping nil entries. +// Returns nil if all errors are nil or the slice is empty. +func MergeErr(errs []error) error { + var msgs []string + for _, err := range errs { + if err != nil { + msgs = append(msgs, err.Error()) + } + } + if len(msgs) == 0 { + return nil + } + return fmt.Errorf("%s", strings.Join(msgs, ", ")) +} + +// YAMLToJSON converts the first YAML document in file to JSON. +// Returns an error if the reader contains more than one YAML document, as +// only the first would be validated and silently ignoring later documents +// can mask configuration mistakes. +func YAMLToJSON(file io.Reader) ([]byte, error) { + dec := yaml.NewDecoder(file) + var data interface{} + if err := dec.Decode(&data); err != nil { + return nil, err + } + var extra interface{} + if err := dec.Decode(&extra); err == nil { + return nil, fmt.Errorf("multi-document YAML is not supported for schema validation") + } + return json.Marshal(data) +} diff --git a/src/internal/utils/common_test.go b/src/internal/utils/common_test.go new file mode 100644 index 0000000..1244c39 --- /dev/null +++ b/src/internal/utils/common_test.go @@ -0,0 +1,50 @@ +package utils + +import ( + "errors" + "strings" + "testing" +) + +func TestMergeErr_singleError(t *testing.T) { + err := MergeErr([]error{errors.New("only error")}) + if err == nil { + t.Fatal("MergeErr() returned nil, want error") + } + if err.Error() != "only error" { + t.Errorf("MergeErr() = %q, want %q", err.Error(), "only error") + } +} + +func TestMergeErr_multipleErrors(t *testing.T) { + err := MergeErr([]error{errors.New("first"), errors.New("second"), errors.New("third")}) + if err == nil { + t.Fatal("MergeErr() returned nil, want error") + } + msg := err.Error() + for _, sub := range []string{"first", "second", "third"} { + if !strings.Contains(msg, sub) { + t.Errorf("MergeErr() = %q, missing %q", msg, sub) + } + } +} + +func TestYAMLToJSON_validYAML(t *testing.T) { + yaml := strings.NewReader("name: test\nvalue: 42") + result, err := YAMLToJSON(yaml) + if err != nil { + t.Fatalf("YAMLToJSON() error: %v", err) + } + got := string(result) + if !strings.Contains(got, `"name"`) || !strings.Contains(got, "test") { + t.Errorf("YAMLToJSON() = %q, expected JSON with name and test", got) + } +} + +func TestYAMLToJSON_invalidYAML(t *testing.T) { + invalid := strings.NewReader("key: [unclosed") + _, err := YAMLToJSON(invalid) + if err == nil { + t.Fatal("YAMLToJSON() expected error for invalid YAML, got nil") + } +} diff --git a/src/raid/profile/profile.go b/src/raid/profile/profile.go index 85717d1..976b6d1 100644 --- a/src/raid/profile/profile.go +++ b/src/raid/profile/profile.go @@ -16,13 +16,13 @@ func ListAll() []Profile { } // Adds a profile to the available profile list -func Add(profile Profile) { - lib.AddProfile(profile) +func Add(profile Profile) error { + return lib.AddProfile(profile) } // Adds multiple profiles to the profile list -func AddAll(profiles []Profile) { - lib.AddProfiles(profiles) +func AddAll(profiles []Profile) error { + return lib.AddProfiles(profiles) } // Sets the active profile diff --git a/src/raid/raid.go b/src/raid/raid.go index 5c03d04..fcd7c74 100644 --- a/src/raid/raid.go +++ b/src/raid/raid.go @@ -15,12 +15,15 @@ Related packages: package raid import ( + "fmt" "log" + "os" "github.com/8bitalex/raid/src/internal/lib" ) const ( + RaidConfigFileName = lib.RaidConfigFileName ConfigPathFlag = lib.ConfigPathFlag ConfigPathFlagDesc = lib.ConfigPathFlagDesc ConfigPathFlagShort = lib.ConfigPathFlagShort @@ -35,10 +38,13 @@ 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) + log.Fatalf("init config: %v", err) } if err := Load(); err != nil { - log.Fatalf("Failed to compile configurations: %v", err) + log.Fatalf("load profile: %v", err) + } + if err := lib.LoadEnv(); err != nil { + fmt.Fprintf(os.Stderr, "%v\n", err) } } @@ -56,3 +62,13 @@ func ForceLoad() error { func Install(maxThreads int) error { return lib.Install(maxThreads) } + +// GetCommands returns all commands available in the active profile. +func GetCommands() []lib.Command { + return lib.GetCommands() +} + +// ExecuteCommand runs the named command from the active profile. +func ExecuteCommand(name string) error { + return lib.ExecuteCommand(name) +}