diff --git a/.env.slic b/.env.slic index 026e424..dd24814 100644 --- a/.env.slic +++ b/.env.slic @@ -52,7 +52,9 @@ SLIC_PHP_VERSION=7.4 # XDebug configuration parameters, will apply to the `cli`, `wordpress` and `codeception` services. # =============================== -# The IDE key used to identify connection requests coming from the services. +# The IDE key root used to identify connection requests coming from the services. +# Each stack will have a unique and deterministic IDE key set from this root +# in the format "{XDK}_". XDK=slic # Whether to enable XDebug in the containers or not. XDE=0 diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml new file mode 100644 index 0000000..bbd3ed2 --- /dev/null +++ b/.github/workflows/tests.yml @@ -0,0 +1,35 @@ +name: Tests + +on: + push: + branches: + - main + - '[0-9]+.[0-9]+.[0-9]+' + pull_request: + +jobs: + tests: + name: PHPUnit (PHP ${{ matrix.php-version }}) + runs-on: ubuntu-latest + strategy: + fail-fast: true + matrix: + php-version: ['8.2'] + + steps: + - name: Checkout + uses: actions/checkout@v4 + + - name: Setup PHP + uses: shivammathur/setup-php@v2 + with: + php-version: ${{ matrix.php-version }} + coverage: none + env: + fail-fast: true + + - name: Install dependencies + run: composer install --no-interaction --prefer-dist + + - name: Run tests + run: vendor/bin/phpunit diff --git a/.gitignore b/.gitignore index f0f1837..77766ae 100644 --- a/.gitignore +++ b/.gitignore @@ -27,7 +27,9 @@ _plugins .using # Any .env.slic.* file created to override the slic cli tool configuration or to configure the runs. .env.slic.local -.env.slic.run +.env.slic.run* +.env.slic.stacks +.env.slic.stacks.lock # Any .build-version file created by the slic cli tool. .build-version @@ -39,3 +41,6 @@ _plugins # slic own cache directory .cache + +# PHPUnit cache +.phpunit.result.cache diff --git a/README.md b/README.md index 3722bf9..bddd6bc 100644 --- a/README.md +++ b/README.md @@ -13,11 +13,14 @@ The slic (**S**tellarWP **L**ocal **I**nteractive **C**ontainers) CLI command pr * [Using `slic`](#using-slic) * [Tell `slic` how to find your project](#tell-slic-how-to-find-your-project) * [Preparing your project](#preparing-your-project) + * [Working with Multiple Stacks](#working-with-multiple-stacks) + * [Git Worktree Support](#git-worktree-support) * [Adding tests](#adding-tests) * [Running tests](#running-tests) * [Advanced topics](#advanced-topics) * [Defaults for your project with `slic.json`](/docs/slicjson.md) * [Managing PHP Versions](#managing-php-versions) + * [Shell Completion](#shell-completion) * [Making composer installs faster](#making-composer-installs-faster) * [Changing your composer version](#changing-your-composer-version) * [Customizing `slic`'s `.env` file](#customizing-slics-env-file) @@ -26,6 +29,7 @@ The slic (**S**tellarWP **L**ocal **I**nteractive **C**ontainers) CLI command pr * [Releasing a new version of `slic`](/CONTRIBUTING.md) * [Agent Skills (AI-assisted testing)](#agent-skills-ai-assisted-testing) * [Update guide](#update-guide) + * [From 2.0 to 3.0](#from-20-to-30) * [From 1.0 to 2.0](#from-10-to-20) @@ -89,6 +93,8 @@ You can see details and usage on each command by running: slic help ``` +> **Working with Multiple Stacks:** Most `slic` commands support a global `--stack=` flag to target a specific stack. See [Working with Multiple Stacks](#working-with-multiple-stacks) for details. + ## Using `slic` The `slic` command has many subcommands. You can discover what those are by typing `slic` or `slic help`. If you want @@ -96,12 +102,13 @@ more details on any of the subcommands, simply type: `slic [subcommand] help`. ### Tell `slic` how to find your project -The `slic` command needs a place to look for plugins, themes, and WordPress. By default, `slic` creates a `_plugins` and -`_wordpress` directory within the local checkout of `slic`. In most cases, however, developers like to run automated tests -against the paths where they are actively working on code – which likely lives elsewhere. +The `slic` command needs a place to look for plugins, themes, and WordPress. By default, `slic` creates a `_plugins` and `_wordpress` directory within the local checkout of `slic`. In most cases, however, developers like to run automated tests against the paths where they are actively working on code – which likely lives elsewhere. + +Good news! You can use the `slic here` sub-command to create a **stack** that points to the directories you want to work with. + +**What's a stack?** A stack is an isolated slic environment with its own containers, configuration, and ports. You can have multiple stacks running simultaneously, allowing you to work on different projects or WordPress installations without conflicts. -Good news! You can use the `slic here` sub-command to re-point `slic`'s paths so it looks in the places you wish. There -are two locations you can tell `slic` to look. +There are two locations you can tell `slic` to look. #### 1. Plugins Directory @@ -117,6 +124,8 @@ cd /path/to/your/wp-content/plugins slic here ``` +This creates a new stack for this directory. If you already have a stack for this directory, it updates the configuration. + ![slic here](docs/images/slic-here.gif) #### 2. Themes Directory @@ -174,6 +183,24 @@ from which to run tests. This also has the benefit of running tests within the W ![slic here](docs/images/slic-here-wp.gif) +#### Stack Isolation and Ports + +Each stack runs in complete isolation with its own: +- Docker containers (separate from other stacks) +- MySQL database instance +- Redis cache +- WordPress container + +**Important Note:** While each stack has its own WordPress *container*, the WordPress codebase itself may be shared among containers. If you're using the default `_wordpress` directory inside slic (when running `slic here` from a plugins directory), all stacks will share the same WordPress installation files. However, each stack maintains its own database and Redis cache, so data remains isolated. + +**Ports are automatically assigned by Docker** when containers start. You don't need to configure or worry about port conflicts – Docker handles this for you. You can view the assigned ports for any stack using: + +```bash +slic using # Show current stack and its ports +slic stack list # Show all stacks and their ports +slic stack info # Show detailed info for current stack +``` + ### Preparing your project ### Point `slic` at your project @@ -182,18 +209,47 @@ Before you can do anything productive with `slic`, you need to tell it which project you wish to use and then you can initialize the project, run tests, and execute other commands to your heart's content! -Assuming you have a plugin called `the-events-calendar` in the plugins directory -where you ran `slic here`, you can tell `slic` you want to take actions on that -plugin using the following command: +The `slic use` command offers two ways to select your target: + +#### Interactive Mode (No Arguments) + +When you run `slic use` without any arguments, it opens an interactive Terminal User Interface (TUI) that lets you browse and select from available targets: ```bash -slic use the-events-calendar +slic use ``` -> For more information on this command, run `slic help use`. +This launches an interactive menu with these features: +- **Browse targets**: Navigate through all available plugins/themes with arrow keys (↑↓) +- **Fuzzy search**: Type to filter targets instantly (case-insensitive substring matching) +- **Current target indicator**: See which target is currently active (marked with ✓) +- **Scroll indicators**: Visual cues when there are more items above/below the visible window +- **Keyboard controls**: + - `↑↓` Arrow keys to navigate + - Type to filter/search + - `Enter` to select + - `ESC` or `Ctrl+C` to cancel +- **Cross-platform**: Works on Windows, macOS, and Linux + +The interactive mode is especially helpful when: +- You're not sure of the exact target name +- You want to see all available options at once +- You need to quickly switch between targets ![slic use](docs/images/slic-use.gif) +#### Direct Mode (With Target Name) + +If you already know which target you want, you can specify it directly: + +```bash +slic use the-events-calendar +``` + +This immediately switches to the specified target without showing the interactive menu. + +> For more information on this command, run `slic help use`. + ### Initialize your project With your desired plugin containing directory set, you will need to initialize plugins so that they are prepped and ready @@ -214,6 +270,534 @@ What this command does: 3. Generates a `codeception.slic.yml` file in the plugin. 4. Prompts for confirmation on running `composer` and `npm` installs on the plugin. +### Working with Multiple Stacks + +`slic` supports running multiple isolated stacks simultaneously. This is useful when you're working on multiple projects, need different WordPress versions, or want to test against different configurations. + +#### How Stack Resolution Works + +When you run a `slic` command, it determines which stack to use in this priority order: + +1. **`--stack=` flag** - Explicitly specify the stack to use +2. **`SLIC_STACK` environment variable** - Set programmatically for CI or scripts +3. **Current working directory** - Automatically uses the stack that matches your current directory +4. **Single stack fallback** - If only one stack exists, uses it automatically + +#### Managing Multiple Stacks + +##### Viewing All Stacks + +To see all your registered stacks with their configuration and ports: + +```bash +slic stack list +``` + +This shows: +- Stack directory path +- Current target (which plugin/project is active) +- WordPress URL and assigned port +- MySQL and Redis ports +- Container status + +##### Viewing Current Stack Information + +To see detailed information about the stack you're currently using: + +```bash +slic using +``` + +This displays: +- Current target plugin/project +- Stack directory +- WordPress URL and all port assignments +- Whether your current directory matches the active target + +For even more details: + +```bash +slic stack info +``` + +##### Stopping a Specific Stack + +To stop containers for a specific stack without affecting others: + +```bash +slic stack stop /path/to/your/plugins +``` + +If you have multiple stacks and don't specify which one, `slic` will show you a list to choose from. + +##### Stopping All Stacks + +To stop all registered stacks at once: + +```bash +slic stack stop all +``` + +This command: +- Shows a list of all stacks that will be stopped +- Prompts for confirmation before proceeding +- Stops each stack even if one fails +- Shows a comprehensive summary with success/failure counts +- Works from any directory + +**Options:** +- `-y, --yes` - Skip confirmation prompt and proceed immediately + +**Examples:** +```bash +slic stack stop all # Interactive - prompts for confirmation +slic stack stop all -y # Non-interactive - stops all stacks immediately +slic stack stop all --yes # Same as -y +``` + +**Caution:** Use `-y` carefully in automation scripts. It bypasses all confirmations and will immediately stop and remove all registered stacks. + +**Note:** This command requires interactive input to confirm the action unless the `-y` flag is provided. It will exit with an error when run in non-interactive environments (such as CI pipelines or automation scripts) without the `-y` flag. + +This is useful when you want to free up system resources or restart your Docker environment cleanly. + +##### Targeting a Specific Stack + +When working with multiple stacks, you can target a specific one in two ways: + +**Option 1: Change to the stack directory** + +```bash +cd /path/to/project-a/wp-content/plugins +slic run wpunit # Runs tests in project-a's stack +``` + +**Option 2: Use the `--stack` flag** + +```bash +slic --stack=/path/to/project-a/wp-content/plugins run wpunit +``` + +The `--stack` flag works with any `slic` command: + +```bash +# Use a different stack +slic --stack=/path/to/project-b/wp-content/plugins use my-plugin + +# Run composer in a specific stack +slic --stack=/path/to/project-a/wp-content/plugins composer install + +# View logs from a specific stack +slic --stack=/path/to/project-b/wp-content/plugins logs +``` + +#### Port Management + +Unlike earlier versions of `slic`, you **no longer need to configure ports**. Docker automatically assigns available ports when containers start, preventing conflicts when running multiple stacks. + +To find your stack's ports: + +```bash +# Quick view +slic using + +# Detailed view +slic stack info + +# All stacks +slic stack list +``` + +The WordPress site will be accessible at `http://localhost:` where the port is shown by these commands. + +#### Example Multi-Stack Workflow + +Here's a typical workflow when working on multiple projects: + +```bash +# Create stack for project A +cd ~/projects/client-a/wp-content/plugins +slic here +slic use their-plugin + +# Create stack for project B +cd ~/projects/client-b/wp-content/plugins +slic here +slic use another-plugin + +# Work on project A from anywhere +slic --stack=~/projects/client-a/wp-content/plugins run wpunit + +# Or cd to project A and work normally +cd ~/projects/client-a/wp-content/plugins/their-plugin +slic run wpunit # Automatically uses project A's stack + +# Switch to project B +cd ~/projects/client-b/wp-content/plugins/another-plugin +slic shell # Opens shell in project B's stack + +# View all your stacks +slic stack list +``` + +#### XDebug Configuration + +Each stack gets its own unique XDebug configuration to enable debugging multiple stacks simultaneously without conflicts. This is achieved through stack-specific XDebug ports and server names. + +**How Stack-Specific XDebug Works:** + +When you create a stack with `slic here`, slic automatically generates: +- A unique XDebug port in the range 49000-59000 +- A unique server name in the format `slic_{hash}` + +Both are deterministically generated from your stack path using an MD5 hash, so they remain consistent across restarts. + +**Viewing Your Stack's XDebug Configuration:** + +To see your stack's XDebug settings, run: + +```bash +slic xdebug status +``` + +This displays: +- XDebug status (on/off) +- Remote host and port +- IDE key (server name) +- Path mappings for your IDE + +**Why This Matters:** + +With stack-specific XDebug configuration, you can: +- Debug tests running in multiple stacks at the same time +- Keep separate IDE debug configurations for each project +- Avoid port conflicts when multiple stacks are active + +**Example:** + +If you create stacks for two different projects: + +```bash +# Stack A at ~/projects/client-a/wp-content/plugins +cd ~/projects/client-a/wp-content/plugins +slic here +slic xdebug status +# Shows: IDE Key: slic_a7f3c891, Remote port: 52341 + +# Stack B at ~/projects/client-b/wp-content/plugins +cd ~/projects/client-b/wp-content/plugins +slic here +slic xdebug status +# Shows: IDE Key: slic_b8d4e902, Remote port: 54782 +``` + +Each stack has different port numbers and server names based on their paths. This means you can run tests with debugging enabled in both stacks simultaneously, as long as your IDE has separate debug configurations for each (using their respective ports and server names). + +For detailed instructions on configuring your IDE for XDebug, see [Configuring IDEs for Xdebug](/docs/xdebug.md). + +#### Legacy Single-Stack Migration + +If you were using `slic` before multi-stack support was added, your existing configuration is automatically migrated the first time you run a `slic` command. Your original `.env.slic.run` file is backed up as `.env.slic.run.backup` and a new stack is registered for your configuration. + +The migration is seamless – you can continue using `slic` exactly as before, and when you're ready, you can create additional stacks for other projects. + +### Git Worktree Support + +`slic` supports git worktrees for concurrent development workflows. Each worktree automatically gets its own isolated Docker Compose stack with unique XDebug ports, allowing you to work on multiple branches of the same plugin simultaneously without switching context. + +#### Use Cases + +Git worktree support is ideal for: + +- **Parallel feature development** - Work on multiple features in the same plugin at once +- **Pull request reviews** - Test and review PRs without losing your current work +- **Bug fixes while developing** - Quickly switch to a hotfix without disrupting your feature branch +- **Cross-branch testing** - Compare behavior across different branches side-by-side +- **Concurrent client work** - Handle urgent fixes while keeping long-running features in progress + +#### How It Works + +When you create a worktree with `slic`, it: + +1. Creates a new git worktree in a parallel directory (following git's standard naming convention) +2. Automatically registers a new slic stack for that worktree +3. Assigns a unique XDebug port (in the 49000-59000 range) +4. Links the worktree stack to the base stack (shown hierarchically in `slic stack list`) +5. Maintains complete stack isolation while sharing the WordPress installation and database + +**Important:** Each worktree stack shares the same WordPress codebase and database as the base stack, but runs in its own isolated Docker containers with its own ports. + +#### Creating a Worktree + +To create a new worktree for your current plugin: + +```bash +cd /path/to/plugins/the-events-calendar +slic worktree add feature/my-new-feature +``` + +This creates: +- A new git worktree at `../the-events-calendar-feature-my-new-feature/` +- A dedicated slic stack with format `base-stack@worktree-dir` +- A unique XDebug port for isolated debugging + +**Options:** + +```bash +# Create from a specific branch +slic worktree add feature/new-feature origin/main + +# Create and track a new branch +slic worktree add -b feature/new-feature +``` + +The worktree directory name follows git's convention: the plugin name followed by the branch name with slashes replaced by hyphens. + +#### Listing Worktrees + +To see all worktrees for your current plugin: + +```bash +slic worktree list +``` + +This displays a table showing: +- Branch name +- Full path to the worktree +- Associated slic stack name +- XDebug port assignment +- Whether the stack is currently active + +#### Switching Between Worktrees + +Simply navigate to the worktree directory: + +```bash +cd ../the-events-calendar-feature-my-new-feature +slic run wpunit # Automatically uses the worktree's stack +``` + +Or use the `--stack` flag from anywhere: + +```bash +slic --stack=/path/to/worktree run wpunit +``` + +#### Merging a Worktree + +When you're done working on a feature branch, merge it back and clean up: + +```bash +# From the base stack directory (not the worktree directory) +slic worktree merge fix/issue-123 +``` + +**Important:** This command must be run from the base stack directory, not from within the worktree directory being merged. The worktree directory will be removed by git during the merge process. + +The merge command will: +1. Checkout the base branch in the target repository +2. Merge the worktree branch into the base branch +3. Remove the git worktree directory +4. Delete the local worktree branch +5. Stop Docker containers +6. Unregister the slic stack + +Use `-y` or `--yes` to skip confirmation prompts: + +```bash +slic worktree merge fix/issue-123 -y +``` + +If the merge encounters conflicts, the command will stop and provide instructions for manual resolution. + +#### Removing a Worktree + +To remove a worktree and clean up its slic stack: + +```bash +slic worktree remove feature/my-new-feature +``` + +You can specify the worktree by: +- Branch name: `slic worktree remove feature/my-new-feature` +- Directory name: `slic worktree remove ../the-events-calendar-feature-my-new-feature` +- Absolute path: `slic worktree remove /full/path/to/worktree` + +This command: +1. Removes the git worktree +2. Unregisters the associated slic stack +3. Cleans up Docker resources + +**Note:** The command will fail if: +- The worktree has uncommitted changes (use `git worktree remove --force` separately if needed) +- The worktree is currently locked +- The worktree directory is in use + +#### Syncing Worktrees + +Over time, worktrees and stacks can become out of sync (e.g., if a worktree is manually deleted or a stack is removed). To detect and clean up orphaned resources: + +```bash +slic worktree sync +``` + +This command: +- Detects orphaned worktree stacks (slic stack exists but git worktree doesn't) +- Detects unregistered worktrees (git worktree exists but slic stack doesn't) +- Prompts you to clean up orphaned stacks +- Offers to register unregistered worktrees + +Run this periodically or whenever you suspect your worktrees and stacks are out of sync. + +#### Stack Isolation and XDebug + +Each worktree stack: +- Has its own Docker Compose project name (e.g., `slic_plugins_tec_feature_my_feature`) +- Gets a unique XDebug port (automatically allocated from 49000-59000) +- Runs in complete isolation with separate containers +- Shares the same WordPress installation and database with the base stack +- Can run simultaneously with the base stack and other worktree stacks + +**Example XDebug configuration:** + +```bash +# In base stack +cd /path/to/plugins/the-events-calendar +slic xdebug status +# Shows: IDE Key: slic_a7f3c891, Remote port: 52341 + +# In worktree +cd ../the-events-calendar-feature-new-feature +slic xdebug status +# Shows: IDE Key: slic_b8d4e902, Remote port: 54782 +``` + +Each worktree can be debugged independently without port conflicts. + +#### Target Switching in Worktrees + +Worktree stacks are tied to their specific target directory and **cannot switch targets** using `slic use`. This is by design - each worktree represents a specific branch/feature context. + +If you attempt to run `slic use ` in a worktree stack, you'll see an error explaining the restriction and suggesting to switch to the base stack: + +```bash +cd ../the-events-calendar-feature-new-feature +slic use event-tickets +# Error: Cannot switch target in a worktree stack. +# Worktree stacks are tied to a specific target directory. +# To switch targets, use the base stack: slic stack switch /path/to/plugins +``` + +To work with a different target, switch back to your base stack first: + +```bash +slic stack switch /path/to/plugins +slic use event-tickets +``` + +#### Automatic Worktree Detection + +If you navigate to a worktree directory that hasn't been registered with `slic`, the CLI will automatically detect it and offer to register it: + +```bash +cd ../the-events-calendar-feature-some-feature +slic use the-events-calendar +# Detects unregistered worktree and prompts: "Register this worktree? (y/n)" +``` + +This is useful when: +- Cloning a repository with existing worktrees +- Switching to a new machine +- Working with worktrees created by team members + +#### Hierarchical Stack View + +To see your base stack and all associated worktree stacks in a tree structure: + +```bash +slic stack list +``` + +Example output: + +``` +┌─ /path/to/plugins +│ └─ Target: the-events-calendar +│ WordPress: http://localhost:52341 +│ +│ ┌─ Worktrees: +│ │ ├─ /path/to/plugins-feature-new-widget (feature/new-widget) +│ │ │ └─ XDebug: 54782 +│ │ └─ /path/to/plugins-bugfix-123 (bugfix/123) +│ │ └─ XDebug: 55891 +``` + +This view makes it easy to: +- See which worktrees exist for each base stack +- Identify XDebug ports for each worktree +- Understand the relationship between stacks + +#### Example Workflow + +Here's a typical workflow using worktrees: + +```bash +# Start with your main development branch +cd ~/projects/wp-content/plugins/the-events-calendar +slic use the-events-calendar + +# Create a worktree for a new feature +slic worktree add feature/new-widget +cd ../the-events-calendar-feature-new-widget + +# Work on the feature +slic run wpunit +slic shell + +# Meanwhile, an urgent bug comes in +cd ~/projects/wp-content/plugins/the-events-calendar +slic worktree add bugfix/urgent-fix +cd ../the-events-calendar-bugfix-urgent-fix + +# Fix the bug and test +slic run wpunit + +# Both stacks are running simultaneously +slic stack list # See both worktrees + +# When done, clean up +cd ~/projects/wp-content/plugins/the-events-calendar +slic worktree remove feature/new-widget +slic worktree remove bugfix/urgent-fix +``` + +#### Best Practices + +**Do:** +- Use `slic worktree list` to see what worktrees you have before creating new ones +- Run `slic worktree sync` periodically to keep things clean +- Use descriptive branch names that clearly identify the purpose +- Remove worktrees when you're done with them to save disk space + +**Don't:** +- Manually delete worktree directories (use `slic worktree remove` instead) +- Create too many worktrees simultaneously (each consumes system resources) +- Forget to commit or push work before removing a worktree + +#### Troubleshooting + +**Problem:** `slic worktree add` fails with "worktree already exists" +**Solution:** Check `git worktree list` and either use the existing worktree or remove it first with `slic worktree remove` + +**Problem:** Stack appears in `slic stack list` but worktree doesn't exist +**Solution:** Run `slic worktree sync` to clean up orphaned stacks + +**Problem:** Worktree exists but stack isn't registered +**Solution:** Navigate to the worktree directory and run any `slic` command, which will prompt to register it, or run `slic worktree sync` + +**Problem:** XDebug not working in worktree +**Solution:** Check `slic xdebug status` in the worktree directory to see the unique port, then configure your IDE with that port + ### Adding tests As mentioned above, you'll need to use Codeception for your automated testing and it is _highly_ recommended that you make use of [wp-browser](https://wpbrowser.wptestkit.dev/) - which adds a _lot_ of WordPress helper functions and utilities. @@ -294,6 +878,98 @@ To reset slic to the default PHP version (7.4): slic php-version reset ``` +### Shell Completion + +`slic` provides tab completion for bash, zsh, and fish shells. This makes it faster to use `slic` by allowing you to press Tab to autocomplete commands, targets, branches, and options. + +#### Quick Installation + +The easiest way to set up completions is: + +```bash +slic completion install +``` + +This auto-detects your shell and installs the appropriate completion script. You'll be prompted to confirm before any files are modified. + +#### What Gets Completed + +Once installed, you can tab-complete: + +- **Commands**: `slic ` shows all available commands +- **Targets**: `slic use ` shows all valid plugins/themes +- **Subcommands**: `slic stack ` shows `list`, `stop`, `info` +- **Git branches**: `slic worktree add ` shows available branches +- **Stack paths**: `slic --stack=` shows registered stack paths +- **Toggle options**: `slic xdebug ` shows `on`, `off`, `status`, etc. +- **PHP versions**: `slic php-version set ` shows `7.4`, `8.0`, `8.1`, `8.2`, `8.3` + +#### Manual Installation + +If you prefer manual installation or the automatic installation doesn't work: + +**Bash** - Add to `~/.bashrc` or `~/.bash_profile`: + +```bash +# slic completions +if command -v slic &> /dev/null; then + _slic_path=$(command -v slic) + if [[ -L "$_slic_path" ]]; then + _slic_path=$(realpath "$_slic_path" 2>/dev/null || readlink -f "$_slic_path" 2>/dev/null || readlink "$_slic_path") + fi + source "$(dirname "$_slic_path")/completions/bash/slic.bash" + unset _slic_path +fi +``` + +**Zsh** - Add to `~/.zshrc`: + +```zsh +# slic completions +if command -v slic &> /dev/null; then + _slic_path=$(command -v slic) + if [[ -L "$_slic_path" ]]; then + _slic_path=$(realpath "$_slic_path" 2>/dev/null || readlink -f "$_slic_path" 2>/dev/null || readlink "$_slic_path") + fi + fpath=("$(dirname "$_slic_path")/completions/zsh" $fpath) + autoload -Uz compinit && compinit + unset _slic_path +fi +``` + +**Fish** - Create a symlink: + +```fish +mkdir -p ~/.config/fish/completions +ln -sf /path/to/slic/completions/fish/slic.fish ~/.config/fish/completions/slic.fish +``` + +#### Other Completion Commands + +```bash +# Show installation instructions for your shell +slic completion + +# Display the completion script content (for inspection or manual setup) +slic completion show [bash|zsh|fish] + +# Clear the completion cache (useful if completions seem stale) +slic completion cache-clear +``` + +#### How It Works + +The completion system uses a two-layer architecture: + +1. **Shell scripts** (bash/zsh/fish) - Thin wrappers that capture the command line context +2. **PHP backend** - Generates completions dynamically based on your slic configuration + +Completions are cached for performance: +- Commands list: 24 hours +- Targets: 5 minutes +- Git branches: 2 minutes +- Stack paths: 1 minute + ### Making composer installs faster By default, `slic` caches composer dependencies within the container @@ -373,6 +1049,13 @@ List the available Xdebug commands. See if Xdebug is enabled or disabled, the host information, and the path mapping to add to your IDE. +When working with multiple stacks, this command displays stack-specific XDebug configuration: +- The unique XDebug remote port for the current stack (range 49000-59000) +- The stack-specific IDE key/server name (format: `slic_`) +- Path mappings for your IDE + +Each stack gets its own XDebug configuration, allowing you to debug tests running in multiple stacks simultaneously without port conflicts. + Note that this command cannot be ran within `slic shell` because you've SSH'd into the Codeception container which has no knowledge of *slic*. See also: [Configuring Xdebug](/docs/xdebug.md) @@ -432,6 +1115,50 @@ npx skills add stellarwp/slic --list This guide covers the steps needed when upgrading `slic` between major versions. +### From 2.0 to 3.0 + +> **New Feature:** +> - Multi-stack support allows running multiple isolated slic environments simultaneously +> - Automatic port assignment eliminates port configuration and conflicts + +#### Automatic Migration + +If you're upgrading from a single-stack setup, `slic` will automatically migrate your configuration: + +1. Your existing `.env.slic.run` is backed up to `.env.slic.run.backup` +2. A new stack is registered for your current configuration +3. Default ports (8888 for WordPress, 9006 for MySQL, 8379 for Redis) are preserved in the migrated stack +4. You can continue working exactly as before + +**No action required** – the migration happens automatically on your next `slic` command. + +#### Taking Advantage of Multi-Stack Features + +After migration, you can: + +1. **Create additional stacks** for other projects: + ```bash + cd /path/to/another/project/wp-content/plugins + slic here + ``` + +2. **View all your stacks**: + ```bash + slic stack list + ``` + +3. **Check current ports** (now auto-assigned by Docker): + ```bash + slic using + ``` + +4. **Target specific stacks** when needed: + ```bash + slic --stack=/path/to/project run wpunit + ``` + +See [Working with Multiple Stacks](#working-with-multiple-stacks) for complete documentation. + ### From 1.0 to 2.0 > **Breaking Change:** diff --git a/changelog.md b/changelog.md index 652b230..560a91e 100644 --- a/changelog.md +++ b/changelog.md @@ -4,6 +4,22 @@ All notable changes to this project will be documented in this file. The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/), and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). +# [3.0.0] - TBD + +### Changed +- Breaking Change - The `slic here` command will now create a new stack for the current path instead of changing the context of the previously only stack to the path. +- Breaking Change - The `XDK` configuration variable (`slic` by default) is now used as **root** for each stack IDE key. An `XDK` of `slic` will create stack IDE keys like `slic_` and not just `slic` as it was before. +- Extend `-y, --yes` flag support for all commands that might require confirmation + +### Added +- Multiple, isolated Docker Compose projects per path +- Git worktree multi-stack support for concurrent development workflows +- New `slic worktree` command leveraging dedicated stacks for each worktree +- Interactive Terminal User Interface (TUI) for the `slic use` command. Running `slic use` without arguments now opens an interactive selection menu with fuzzy search and current selection indicator. +- New `slic completion` command with subcommands to install bash/zsh/fish completions +- Shell completion support for bash, zsh, and fish shells with dynamic completions for commands, targets, git branches, stack paths, and options +- CLI PHPUnit test suite in the `tests/Cli` directory + # [2.2.0] - 2026-05-29 - Added - First-class theme support for `slic here`, `slic use`, `slic using`, and `slic run`, including auto-detection when running `slic here` from a `themes` directory with a sibling `plugins` directory. - Added - Agent Skills documentation in `skills/slic/` for AI-assisted WordPress integration test creation, debugging, and slic workflow guidance. diff --git a/completions/bash/slic.bash b/completions/bash/slic.bash new file mode 100644 index 0000000..2b56444 --- /dev/null +++ b/completions/bash/slic.bash @@ -0,0 +1,49 @@ +#!/usr/bin/env bash +# Bash completion script for slic +# +# This script provides tab completion for the slic CLI tool. +# It delegates completion generation to a PHP script. +# +# Installation: +# Add this to ~/.bashrc or ~/.bash_profile: +# source /path/to/slic/completions/bash/slic.bash +# +# Or use: slic completion install bash + +_slic_completions() { + local cur prev words cword + _init_completion || return + + # Get the directory where slic is installed + local slic_cmd + slic_cmd=$(command -v slic 2>/dev/null) + + if [[ -z "$slic_cmd" ]]; then + return 0 + fi + + # Resolve symlinks to get the actual slic directory + if [[ -L "$slic_cmd" ]]; then + # Try realpath first (more portable), then readlink -f (GNU), then readlink (basic) + slic_cmd=$(realpath "$slic_cmd" 2>/dev/null || readlink -f "$slic_cmd" 2>/dev/null || readlink "$slic_cmd" 2>/dev/null) + fi + + local slic_dir + slic_dir=$(dirname "$slic_cmd") + + # Call the PHP completion script + local completions + completions=$(php "${slic_dir}/src/completions/complete.php" \ + --line="${COMP_LINE}" \ + --point="${COMP_POINT}" \ + --words="${COMP_WORDS[*]}" \ + --cword="${COMP_CWORD}" 2>/dev/null) + + # Convert space-separated completions to array + if [[ -n "$completions" ]]; then + COMPREPLY=($(compgen -W "$completions" -- "$cur")) + fi +} + +# Register the completion function for slic +complete -F _slic_completions slic diff --git a/completions/fish/slic.fish b/completions/fish/slic.fish new file mode 100644 index 0000000..9078cf6 --- /dev/null +++ b/completions/fish/slic.fish @@ -0,0 +1,41 @@ +# Fish completion script for slic +# +# This script provides tab completion for the slic CLI tool. +# It delegates completion generation to a PHP script. +# +# Installation: +# Copy or symlink this file to ~/.config/fish/completions/slic.fish +# Or use: slic completion install fish + +function __slic_completions + # Find the slic command + set -l slic_cmd (command -v slic 2>/dev/null) + + if test -z "$slic_cmd" + return 0 + end + + # Resolve symlinks to get the actual slic directory + if test -L "$slic_cmd" + set slic_cmd (realpath "$slic_cmd" 2>/dev/null; or readlink -f "$slic_cmd" 2>/dev/null; or readlink "$slic_cmd" 2>/dev/null) + end + + set -l slic_dir (dirname "$slic_cmd") + + # Get the current command line tokens (already completed) + set -l tokens (commandline -opc) + + # Get the current token being typed + set -l current (commandline -ct) + + # Call the PHP completion script + php "$slic_dir/src/completions/complete.php" \ + --shell=fish \ + --words="$tokens" \ + --current="$current" 2>/dev/null +end + +# Register completions for slic command +# -f: don't complete files +# -a: provide completions from the function +complete -c slic -f -a "(__slic_completions)" diff --git a/completions/zsh/_slic b/completions/zsh/_slic new file mode 100644 index 0000000..027a00b --- /dev/null +++ b/completions/zsh/_slic @@ -0,0 +1,65 @@ +#compdef slic +# Zsh completion script for slic +# +# This script provides tab completion for the slic CLI tool. +# It delegates completion generation to a PHP script. +# +# Installation: +# Add this to ~/.zshrc: +# fpath=(/path/to/slic/completions/zsh $fpath) +# autoload -Uz compinit && compinit +# +# Or use: slic completion install zsh + +_slic() { + local slic_cmd slic_dir completions + + # Find the slic command + slic_cmd=$(command -v slic 2>/dev/null) + + if [[ -z "$slic_cmd" ]]; then + return 0 + fi + + # Resolve symlinks to get the actual slic directory + if [[ -L "$slic_cmd" ]]; then + # macOS uses readlink differently than GNU + if [[ "$OSTYPE" == "darwin"* ]]; then + # On macOS, try to use greadlink if available, otherwise use a while loop + if command -v greadlink >/dev/null 2>&1; then + slic_cmd=$(greadlink -f "$slic_cmd") + else + # Follow symlinks manually with protection against circular links + local iterations=0 + local max_iterations=10 + while [[ -L "$slic_cmd" ]] && (( iterations < max_iterations )); do + local link_target + link_target=$(readlink "$slic_cmd") + if [[ "$link_target" == /* ]]; then + slic_cmd="$link_target" + else + slic_cmd="$(dirname "$slic_cmd")/$link_target" + fi + (( iterations++ )) + done + fi + else + slic_cmd=$(readlink -f "$slic_cmd" 2>/dev/null) + fi + fi + + slic_dir=$(dirname "$slic_cmd") + + # Call the PHP completion script + completions=($(php "${slic_dir}/src/completions/complete.php" \ + --line="${BUFFER}" \ + --point="${CURSOR}" \ + --words="${words[*]}" \ + --cword="${CURRENT}" 2>/dev/null)) + + if [[ ${#completions[@]} -gt 0 ]]; then + _describe 'slic commands' completions + fi +} + +_slic "$@" diff --git a/composer.json b/composer.json new file mode 100644 index 0000000..5b53091 --- /dev/null +++ b/composer.json @@ -0,0 +1,21 @@ +{ + "name": "stellarwp/slic", + "description": "StellarWP Local Interactive Containers - containerized testing environment CLI tool for WordPress plugins, themes, and projects.", + "type": "project", + "license": "GPL-2.0-or-later", + "require": { + "php": ">=8.2" + }, + "require-dev": { + "phpunit/phpunit": "^11.0" + }, + "autoload-dev": { + "psr-4": { + "StellarWP\\Slic\\Test\\": "tests/", + "StellarWP\\Slic\\Test\\Support\\": "tests/_support" + } + }, + "config": { + "sort-packages": true + } +} diff --git a/composer.lock b/composer.lock new file mode 100644 index 0000000..8b1057e --- /dev/null +++ b/composer.lock @@ -0,0 +1,1802 @@ +{ + "_readme": [ + "This file locks the dependencies of your project to a known state", + "Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies", + "This file is @generated automatically" + ], + "content-hash": "b388d73aaec46a4546e7ec2780b48839", + "packages": [], + "packages-dev": [ + { + "name": "myclabs/deep-copy", + "version": "1.13.4", + "source": { + "type": "git", + "url": "https://github.com/myclabs/DeepCopy.git", + "reference": "07d290f0c47959fd5eed98c95ee5602db07e0b6a" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/myclabs/DeepCopy/zipball/07d290f0c47959fd5eed98c95ee5602db07e0b6a", + "reference": "07d290f0c47959fd5eed98c95ee5602db07e0b6a", + "shasum": "" + }, + "require": { + "php": "^7.1 || ^8.0" + }, + "conflict": { + "doctrine/collections": "<1.6.8", + "doctrine/common": "<2.13.3 || >=3 <3.2.2" + }, + "require-dev": { + "doctrine/collections": "^1.6.8", + "doctrine/common": "^2.13.3 || ^3.2.2", + "phpspec/prophecy": "^1.10", + "phpunit/phpunit": "^7.5.20 || ^8.5.23 || ^9.5.13" + }, + "type": "library", + "autoload": { + "files": [ + "src/DeepCopy/deep_copy.php" + ], + "psr-4": { + "DeepCopy\\": "src/DeepCopy/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "description": "Create deep copies (clones) of your objects", + "keywords": [ + "clone", + "copy", + "duplicate", + "object", + "object graph" + ], + "support": { + "issues": "https://github.com/myclabs/DeepCopy/issues", + "source": "https://github.com/myclabs/DeepCopy/tree/1.13.4" + }, + "funding": [ + { + "url": "https://tidelift.com/funding/github/packagist/myclabs/deep-copy", + "type": "tidelift" + } + ], + "time": "2025-08-01T08:46:24+00:00" + }, + { + "name": "nikic/php-parser", + "version": "v5.7.0", + "source": { + "type": "git", + "url": "https://github.com/nikic/PHP-Parser.git", + "reference": "dca41cd15c2ac9d055ad70dbfd011130757d1f82" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/nikic/PHP-Parser/zipball/dca41cd15c2ac9d055ad70dbfd011130757d1f82", + "reference": "dca41cd15c2ac9d055ad70dbfd011130757d1f82", + "shasum": "" + }, + "require": { + "ext-ctype": "*", + "ext-json": "*", + "ext-tokenizer": "*", + "php": ">=7.4" + }, + "require-dev": { + "ircmaxell/php-yacc": "^0.0.7", + "phpunit/phpunit": "^9.0" + }, + "bin": [ + "bin/php-parse" + ], + "type": "library", + "extra": { + "branch-alias": { + "dev-master": "5.x-dev" + } + }, + "autoload": { + "psr-4": { + "PhpParser\\": "lib/PhpParser" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "BSD-3-Clause" + ], + "authors": [ + { + "name": "Nikita Popov" + } + ], + "description": "A PHP parser written in PHP", + "keywords": [ + "parser", + "php" + ], + "support": { + "issues": "https://github.com/nikic/PHP-Parser/issues", + "source": "https://github.com/nikic/PHP-Parser/tree/v5.7.0" + }, + "time": "2025-12-06T11:56:16+00:00" + }, + { + "name": "phar-io/manifest", + "version": "2.0.4", + "source": { + "type": "git", + "url": "https://github.com/phar-io/manifest.git", + "reference": "54750ef60c58e43759730615a392c31c80e23176" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/phar-io/manifest/zipball/54750ef60c58e43759730615a392c31c80e23176", + "reference": "54750ef60c58e43759730615a392c31c80e23176", + "shasum": "" + }, + "require": { + "ext-dom": "*", + "ext-libxml": "*", + "ext-phar": "*", + "ext-xmlwriter": "*", + "phar-io/version": "^3.0.1", + "php": "^7.2 || ^8.0" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-master": "2.0.x-dev" + } + }, + "autoload": { + "classmap": [ + "src/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "BSD-3-Clause" + ], + "authors": [ + { + "name": "Arne Blankerts", + "email": "arne@blankerts.de", + "role": "Developer" + }, + { + "name": "Sebastian Heuer", + "email": "sebastian@phpeople.de", + "role": "Developer" + }, + { + "name": "Sebastian Bergmann", + "email": "sebastian@phpunit.de", + "role": "Developer" + } + ], + "description": "Component for reading phar.io manifest information from a PHP Archive (PHAR)", + "support": { + "issues": "https://github.com/phar-io/manifest/issues", + "source": "https://github.com/phar-io/manifest/tree/2.0.4" + }, + "funding": [ + { + "url": "https://github.com/theseer", + "type": "github" + } + ], + "time": "2024-03-03T12:33:53+00:00" + }, + { + "name": "phar-io/version", + "version": "3.2.1", + "source": { + "type": "git", + "url": "https://github.com/phar-io/version.git", + "reference": "4f7fd7836c6f332bb2933569e566a0d6c4cbed74" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/phar-io/version/zipball/4f7fd7836c6f332bb2933569e566a0d6c4cbed74", + "reference": "4f7fd7836c6f332bb2933569e566a0d6c4cbed74", + "shasum": "" + }, + "require": { + "php": "^7.2 || ^8.0" + }, + "type": "library", + "autoload": { + "classmap": [ + "src/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "BSD-3-Clause" + ], + "authors": [ + { + "name": "Arne Blankerts", + "email": "arne@blankerts.de", + "role": "Developer" + }, + { + "name": "Sebastian Heuer", + "email": "sebastian@phpeople.de", + "role": "Developer" + }, + { + "name": "Sebastian Bergmann", + "email": "sebastian@phpunit.de", + "role": "Developer" + } + ], + "description": "Library for handling version information and constraints", + "support": { + "issues": "https://github.com/phar-io/version/issues", + "source": "https://github.com/phar-io/version/tree/3.2.1" + }, + "time": "2022-02-21T01:04:05+00:00" + }, + { + "name": "phpunit/php-code-coverage", + "version": "11.0.12", + "source": { + "type": "git", + "url": "https://github.com/sebastianbergmann/php-code-coverage.git", + "reference": "2c1ed04922802c15e1de5d7447b4856de949cf56" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/sebastianbergmann/php-code-coverage/zipball/2c1ed04922802c15e1de5d7447b4856de949cf56", + "reference": "2c1ed04922802c15e1de5d7447b4856de949cf56", + "shasum": "" + }, + "require": { + "ext-dom": "*", + "ext-libxml": "*", + "ext-xmlwriter": "*", + "nikic/php-parser": "^5.7.0", + "php": ">=8.2", + "phpunit/php-file-iterator": "^5.1.0", + "phpunit/php-text-template": "^4.0.1", + "sebastian/code-unit-reverse-lookup": "^4.0.1", + "sebastian/complexity": "^4.0.1", + "sebastian/environment": "^7.2.1", + "sebastian/lines-of-code": "^3.0.1", + "sebastian/version": "^5.0.2", + "theseer/tokenizer": "^1.3.1" + }, + "require-dev": { + "phpunit/phpunit": "^11.5.46" + }, + "suggest": { + "ext-pcov": "PHP extension that provides line coverage", + "ext-xdebug": "PHP extension that provides line coverage as well as branch and path coverage" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-main": "11.0.x-dev" + } + }, + "autoload": { + "classmap": [ + "src/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "BSD-3-Clause" + ], + "authors": [ + { + "name": "Sebastian Bergmann", + "email": "sebastian@phpunit.de", + "role": "lead" + } + ], + "description": "Library that provides collection, processing, and rendering functionality for PHP code coverage information.", + "homepage": "https://github.com/sebastianbergmann/php-code-coverage", + "keywords": [ + "coverage", + "testing", + "xunit" + ], + "support": { + "issues": "https://github.com/sebastianbergmann/php-code-coverage/issues", + "security": "https://github.com/sebastianbergmann/php-code-coverage/security/policy", + "source": "https://github.com/sebastianbergmann/php-code-coverage/tree/11.0.12" + }, + "funding": [ + { + "url": "https://github.com/sebastianbergmann", + "type": "github" + }, + { + "url": "https://liberapay.com/sebastianbergmann", + "type": "liberapay" + }, + { + "url": "https://thanks.dev/u/gh/sebastianbergmann", + "type": "thanks_dev" + }, + { + "url": "https://tidelift.com/funding/github/packagist/phpunit/php-code-coverage", + "type": "tidelift" + } + ], + "time": "2025-12-24T07:01:01+00:00" + }, + { + "name": "phpunit/php-file-iterator", + "version": "5.1.1", + "source": { + "type": "git", + "url": "https://github.com/sebastianbergmann/php-file-iterator.git", + "reference": "2f3a64888c814fc235386b7387dd5b5ed92ad903" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/sebastianbergmann/php-file-iterator/zipball/2f3a64888c814fc235386b7387dd5b5ed92ad903", + "reference": "2f3a64888c814fc235386b7387dd5b5ed92ad903", + "shasum": "" + }, + "require": { + "php": ">=8.2" + }, + "require-dev": { + "phpunit/phpunit": "^11.3" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-main": "5.1-dev" + } + }, + "autoload": { + "classmap": [ + "src/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "BSD-3-Clause" + ], + "authors": [ + { + "name": "Sebastian Bergmann", + "email": "sebastian@phpunit.de", + "role": "lead" + } + ], + "description": "FilterIterator implementation that filters files based on a list of suffixes.", + "homepage": "https://github.com/sebastianbergmann/php-file-iterator/", + "keywords": [ + "filesystem", + "iterator" + ], + "support": { + "issues": "https://github.com/sebastianbergmann/php-file-iterator/issues", + "security": "https://github.com/sebastianbergmann/php-file-iterator/security/policy", + "source": "https://github.com/sebastianbergmann/php-file-iterator/tree/5.1.1" + }, + "funding": [ + { + "url": "https://github.com/sebastianbergmann", + "type": "github" + }, + { + "url": "https://liberapay.com/sebastianbergmann", + "type": "liberapay" + }, + { + "url": "https://thanks.dev/u/gh/sebastianbergmann", + "type": "thanks_dev" + }, + { + "url": "https://tidelift.com/funding/github/packagist/phpunit/php-file-iterator", + "type": "tidelift" + } + ], + "time": "2026-02-02T13:52:54+00:00" + }, + { + "name": "phpunit/php-invoker", + "version": "5.0.1", + "source": { + "type": "git", + "url": "https://github.com/sebastianbergmann/php-invoker.git", + "reference": "c1ca3814734c07492b3d4c5f794f4b0995333da2" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/sebastianbergmann/php-invoker/zipball/c1ca3814734c07492b3d4c5f794f4b0995333da2", + "reference": "c1ca3814734c07492b3d4c5f794f4b0995333da2", + "shasum": "" + }, + "require": { + "php": ">=8.2" + }, + "require-dev": { + "ext-pcntl": "*", + "phpunit/phpunit": "^11.0" + }, + "suggest": { + "ext-pcntl": "*" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-main": "5.0-dev" + } + }, + "autoload": { + "classmap": [ + "src/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "BSD-3-Clause" + ], + "authors": [ + { + "name": "Sebastian Bergmann", + "email": "sebastian@phpunit.de", + "role": "lead" + } + ], + "description": "Invoke callables with a timeout", + "homepage": "https://github.com/sebastianbergmann/php-invoker/", + "keywords": [ + "process" + ], + "support": { + "issues": "https://github.com/sebastianbergmann/php-invoker/issues", + "security": "https://github.com/sebastianbergmann/php-invoker/security/policy", + "source": "https://github.com/sebastianbergmann/php-invoker/tree/5.0.1" + }, + "funding": [ + { + "url": "https://github.com/sebastianbergmann", + "type": "github" + } + ], + "time": "2024-07-03T05:07:44+00:00" + }, + { + "name": "phpunit/php-text-template", + "version": "4.0.1", + "source": { + "type": "git", + "url": "https://github.com/sebastianbergmann/php-text-template.git", + "reference": "3e0404dc6b300e6bf56415467ebcb3fe4f33e964" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/sebastianbergmann/php-text-template/zipball/3e0404dc6b300e6bf56415467ebcb3fe4f33e964", + "reference": "3e0404dc6b300e6bf56415467ebcb3fe4f33e964", + "shasum": "" + }, + "require": { + "php": ">=8.2" + }, + "require-dev": { + "phpunit/phpunit": "^11.0" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-main": "4.0-dev" + } + }, + "autoload": { + "classmap": [ + "src/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "BSD-3-Clause" + ], + "authors": [ + { + "name": "Sebastian Bergmann", + "email": "sebastian@phpunit.de", + "role": "lead" + } + ], + "description": "Simple template engine.", + "homepage": "https://github.com/sebastianbergmann/php-text-template/", + "keywords": [ + "template" + ], + "support": { + "issues": "https://github.com/sebastianbergmann/php-text-template/issues", + "security": "https://github.com/sebastianbergmann/php-text-template/security/policy", + "source": "https://github.com/sebastianbergmann/php-text-template/tree/4.0.1" + }, + "funding": [ + { + "url": "https://github.com/sebastianbergmann", + "type": "github" + } + ], + "time": "2024-07-03T05:08:43+00:00" + }, + { + "name": "phpunit/php-timer", + "version": "7.0.1", + "source": { + "type": "git", + "url": "https://github.com/sebastianbergmann/php-timer.git", + "reference": "3b415def83fbcb41f991d9ebf16ae4ad8b7837b3" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/sebastianbergmann/php-timer/zipball/3b415def83fbcb41f991d9ebf16ae4ad8b7837b3", + "reference": "3b415def83fbcb41f991d9ebf16ae4ad8b7837b3", + "shasum": "" + }, + "require": { + "php": ">=8.2" + }, + "require-dev": { + "phpunit/phpunit": "^11.0" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-main": "7.0-dev" + } + }, + "autoload": { + "classmap": [ + "src/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "BSD-3-Clause" + ], + "authors": [ + { + "name": "Sebastian Bergmann", + "email": "sebastian@phpunit.de", + "role": "lead" + } + ], + "description": "Utility class for timing", + "homepage": "https://github.com/sebastianbergmann/php-timer/", + "keywords": [ + "timer" + ], + "support": { + "issues": "https://github.com/sebastianbergmann/php-timer/issues", + "security": "https://github.com/sebastianbergmann/php-timer/security/policy", + "source": "https://github.com/sebastianbergmann/php-timer/tree/7.0.1" + }, + "funding": [ + { + "url": "https://github.com/sebastianbergmann", + "type": "github" + } + ], + "time": "2024-07-03T05:09:35+00:00" + }, + { + "name": "phpunit/phpunit", + "version": "11.5.55", + "source": { + "type": "git", + "url": "https://github.com/sebastianbergmann/phpunit.git", + "reference": "adc7262fccc12de2b30f12a8aa0b33775d814f00" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/sebastianbergmann/phpunit/zipball/adc7262fccc12de2b30f12a8aa0b33775d814f00", + "reference": "adc7262fccc12de2b30f12a8aa0b33775d814f00", + "shasum": "" + }, + "require": { + "ext-dom": "*", + "ext-json": "*", + "ext-libxml": "*", + "ext-mbstring": "*", + "ext-xml": "*", + "ext-xmlwriter": "*", + "myclabs/deep-copy": "^1.13.4", + "phar-io/manifest": "^2.0.4", + "phar-io/version": "^3.2.1", + "php": ">=8.2", + "phpunit/php-code-coverage": "^11.0.12", + "phpunit/php-file-iterator": "^5.1.1", + "phpunit/php-invoker": "^5.0.1", + "phpunit/php-text-template": "^4.0.1", + "phpunit/php-timer": "^7.0.1", + "sebastian/cli-parser": "^3.0.2", + "sebastian/code-unit": "^3.0.3", + "sebastian/comparator": "^6.3.3", + "sebastian/diff": "^6.0.2", + "sebastian/environment": "^7.2.1", + "sebastian/exporter": "^6.3.2", + "sebastian/global-state": "^7.0.2", + "sebastian/object-enumerator": "^6.0.1", + "sebastian/recursion-context": "^6.0.3", + "sebastian/type": "^5.1.3", + "sebastian/version": "^5.0.2", + "staabm/side-effects-detector": "^1.0.5" + }, + "suggest": { + "ext-soap": "To be able to generate mocks based on WSDL files" + }, + "bin": [ + "phpunit" + ], + "type": "library", + "extra": { + "branch-alias": { + "dev-main": "11.5-dev" + } + }, + "autoload": { + "files": [ + "src/Framework/Assert/Functions.php" + ], + "classmap": [ + "src/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "BSD-3-Clause" + ], + "authors": [ + { + "name": "Sebastian Bergmann", + "email": "sebastian@phpunit.de", + "role": "lead" + } + ], + "description": "The PHP Unit Testing framework.", + "homepage": "https://phpunit.de/", + "keywords": [ + "phpunit", + "testing", + "xunit" + ], + "support": { + "issues": "https://github.com/sebastianbergmann/phpunit/issues", + "security": "https://github.com/sebastianbergmann/phpunit/security/policy", + "source": "https://github.com/sebastianbergmann/phpunit/tree/11.5.55" + }, + "funding": [ + { + "url": "https://phpunit.de/sponsors.html", + "type": "custom" + }, + { + "url": "https://github.com/sebastianbergmann", + "type": "github" + }, + { + "url": "https://liberapay.com/sebastianbergmann", + "type": "liberapay" + }, + { + "url": "https://thanks.dev/u/gh/sebastianbergmann", + "type": "thanks_dev" + }, + { + "url": "https://tidelift.com/funding/github/packagist/phpunit/phpunit", + "type": "tidelift" + } + ], + "time": "2026-02-18T12:37:06+00:00" + }, + { + "name": "sebastian/cli-parser", + "version": "3.0.2", + "source": { + "type": "git", + "url": "https://github.com/sebastianbergmann/cli-parser.git", + "reference": "15c5dd40dc4f38794d383bb95465193f5e0ae180" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/sebastianbergmann/cli-parser/zipball/15c5dd40dc4f38794d383bb95465193f5e0ae180", + "reference": "15c5dd40dc4f38794d383bb95465193f5e0ae180", + "shasum": "" + }, + "require": { + "php": ">=8.2" + }, + "require-dev": { + "phpunit/phpunit": "^11.0" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-main": "3.0-dev" + } + }, + "autoload": { + "classmap": [ + "src/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "BSD-3-Clause" + ], + "authors": [ + { + "name": "Sebastian Bergmann", + "email": "sebastian@phpunit.de", + "role": "lead" + } + ], + "description": "Library for parsing CLI options", + "homepage": "https://github.com/sebastianbergmann/cli-parser", + "support": { + "issues": "https://github.com/sebastianbergmann/cli-parser/issues", + "security": "https://github.com/sebastianbergmann/cli-parser/security/policy", + "source": "https://github.com/sebastianbergmann/cli-parser/tree/3.0.2" + }, + "funding": [ + { + "url": "https://github.com/sebastianbergmann", + "type": "github" + } + ], + "time": "2024-07-03T04:41:36+00:00" + }, + { + "name": "sebastian/code-unit", + "version": "3.0.3", + "source": { + "type": "git", + "url": "https://github.com/sebastianbergmann/code-unit.git", + "reference": "54391c61e4af8078e5b276ab082b6d3c54c9ad64" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/sebastianbergmann/code-unit/zipball/54391c61e4af8078e5b276ab082b6d3c54c9ad64", + "reference": "54391c61e4af8078e5b276ab082b6d3c54c9ad64", + "shasum": "" + }, + "require": { + "php": ">=8.2" + }, + "require-dev": { + "phpunit/phpunit": "^11.5" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-main": "3.0-dev" + } + }, + "autoload": { + "classmap": [ + "src/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "BSD-3-Clause" + ], + "authors": [ + { + "name": "Sebastian Bergmann", + "email": "sebastian@phpunit.de", + "role": "lead" + } + ], + "description": "Collection of value objects that represent the PHP code units", + "homepage": "https://github.com/sebastianbergmann/code-unit", + "support": { + "issues": "https://github.com/sebastianbergmann/code-unit/issues", + "security": "https://github.com/sebastianbergmann/code-unit/security/policy", + "source": "https://github.com/sebastianbergmann/code-unit/tree/3.0.3" + }, + "funding": [ + { + "url": "https://github.com/sebastianbergmann", + "type": "github" + } + ], + "time": "2025-03-19T07:56:08+00:00" + }, + { + "name": "sebastian/code-unit-reverse-lookup", + "version": "4.0.1", + "source": { + "type": "git", + "url": "https://github.com/sebastianbergmann/code-unit-reverse-lookup.git", + "reference": "183a9b2632194febd219bb9246eee421dad8d45e" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/sebastianbergmann/code-unit-reverse-lookup/zipball/183a9b2632194febd219bb9246eee421dad8d45e", + "reference": "183a9b2632194febd219bb9246eee421dad8d45e", + "shasum": "" + }, + "require": { + "php": ">=8.2" + }, + "require-dev": { + "phpunit/phpunit": "^11.0" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-main": "4.0-dev" + } + }, + "autoload": { + "classmap": [ + "src/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "BSD-3-Clause" + ], + "authors": [ + { + "name": "Sebastian Bergmann", + "email": "sebastian@phpunit.de" + } + ], + "description": "Looks up which function or method a line of code belongs to", + "homepage": "https://github.com/sebastianbergmann/code-unit-reverse-lookup/", + "support": { + "issues": "https://github.com/sebastianbergmann/code-unit-reverse-lookup/issues", + "security": "https://github.com/sebastianbergmann/code-unit-reverse-lookup/security/policy", + "source": "https://github.com/sebastianbergmann/code-unit-reverse-lookup/tree/4.0.1" + }, + "funding": [ + { + "url": "https://github.com/sebastianbergmann", + "type": "github" + } + ], + "time": "2024-07-03T04:45:54+00:00" + }, + { + "name": "sebastian/comparator", + "version": "6.3.3", + "source": { + "type": "git", + "url": "https://github.com/sebastianbergmann/comparator.git", + "reference": "2c95e1e86cb8dd41beb8d502057d1081ccc8eca9" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/sebastianbergmann/comparator/zipball/2c95e1e86cb8dd41beb8d502057d1081ccc8eca9", + "reference": "2c95e1e86cb8dd41beb8d502057d1081ccc8eca9", + "shasum": "" + }, + "require": { + "ext-dom": "*", + "ext-mbstring": "*", + "php": ">=8.2", + "sebastian/diff": "^6.0", + "sebastian/exporter": "^6.0" + }, + "require-dev": { + "phpunit/phpunit": "^11.4" + }, + "suggest": { + "ext-bcmath": "For comparing BcMath\\Number objects" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-main": "6.3-dev" + } + }, + "autoload": { + "classmap": [ + "src/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "BSD-3-Clause" + ], + "authors": [ + { + "name": "Sebastian Bergmann", + "email": "sebastian@phpunit.de" + }, + { + "name": "Jeff Welch", + "email": "whatthejeff@gmail.com" + }, + { + "name": "Volker Dusch", + "email": "github@wallbash.com" + }, + { + "name": "Bernhard Schussek", + "email": "bschussek@2bepublished.at" + } + ], + "description": "Provides the functionality to compare PHP values for equality", + "homepage": "https://github.com/sebastianbergmann/comparator", + "keywords": [ + "comparator", + "compare", + "equality" + ], + "support": { + "issues": "https://github.com/sebastianbergmann/comparator/issues", + "security": "https://github.com/sebastianbergmann/comparator/security/policy", + "source": "https://github.com/sebastianbergmann/comparator/tree/6.3.3" + }, + "funding": [ + { + "url": "https://github.com/sebastianbergmann", + "type": "github" + }, + { + "url": "https://liberapay.com/sebastianbergmann", + "type": "liberapay" + }, + { + "url": "https://thanks.dev/u/gh/sebastianbergmann", + "type": "thanks_dev" + }, + { + "url": "https://tidelift.com/funding/github/packagist/sebastian/comparator", + "type": "tidelift" + } + ], + "time": "2026-01-24T09:26:40+00:00" + }, + { + "name": "sebastian/complexity", + "version": "4.0.1", + "source": { + "type": "git", + "url": "https://github.com/sebastianbergmann/complexity.git", + "reference": "ee41d384ab1906c68852636b6de493846e13e5a0" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/sebastianbergmann/complexity/zipball/ee41d384ab1906c68852636b6de493846e13e5a0", + "reference": "ee41d384ab1906c68852636b6de493846e13e5a0", + "shasum": "" + }, + "require": { + "nikic/php-parser": "^5.0", + "php": ">=8.2" + }, + "require-dev": { + "phpunit/phpunit": "^11.0" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-main": "4.0-dev" + } + }, + "autoload": { + "classmap": [ + "src/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "BSD-3-Clause" + ], + "authors": [ + { + "name": "Sebastian Bergmann", + "email": "sebastian@phpunit.de", + "role": "lead" + } + ], + "description": "Library for calculating the complexity of PHP code units", + "homepage": "https://github.com/sebastianbergmann/complexity", + "support": { + "issues": "https://github.com/sebastianbergmann/complexity/issues", + "security": "https://github.com/sebastianbergmann/complexity/security/policy", + "source": "https://github.com/sebastianbergmann/complexity/tree/4.0.1" + }, + "funding": [ + { + "url": "https://github.com/sebastianbergmann", + "type": "github" + } + ], + "time": "2024-07-03T04:49:50+00:00" + }, + { + "name": "sebastian/diff", + "version": "6.0.2", + "source": { + "type": "git", + "url": "https://github.com/sebastianbergmann/diff.git", + "reference": "b4ccd857127db5d41a5b676f24b51371d76d8544" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/sebastianbergmann/diff/zipball/b4ccd857127db5d41a5b676f24b51371d76d8544", + "reference": "b4ccd857127db5d41a5b676f24b51371d76d8544", + "shasum": "" + }, + "require": { + "php": ">=8.2" + }, + "require-dev": { + "phpunit/phpunit": "^11.0", + "symfony/process": "^4.2 || ^5" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-main": "6.0-dev" + } + }, + "autoload": { + "classmap": [ + "src/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "BSD-3-Clause" + ], + "authors": [ + { + "name": "Sebastian Bergmann", + "email": "sebastian@phpunit.de" + }, + { + "name": "Kore Nordmann", + "email": "mail@kore-nordmann.de" + } + ], + "description": "Diff implementation", + "homepage": "https://github.com/sebastianbergmann/diff", + "keywords": [ + "diff", + "udiff", + "unidiff", + "unified diff" + ], + "support": { + "issues": "https://github.com/sebastianbergmann/diff/issues", + "security": "https://github.com/sebastianbergmann/diff/security/policy", + "source": "https://github.com/sebastianbergmann/diff/tree/6.0.2" + }, + "funding": [ + { + "url": "https://github.com/sebastianbergmann", + "type": "github" + } + ], + "time": "2024-07-03T04:53:05+00:00" + }, + { + "name": "sebastian/environment", + "version": "7.2.1", + "source": { + "type": "git", + "url": "https://github.com/sebastianbergmann/environment.git", + "reference": "a5c75038693ad2e8d4b6c15ba2403532647830c4" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/sebastianbergmann/environment/zipball/a5c75038693ad2e8d4b6c15ba2403532647830c4", + "reference": "a5c75038693ad2e8d4b6c15ba2403532647830c4", + "shasum": "" + }, + "require": { + "php": ">=8.2" + }, + "require-dev": { + "phpunit/phpunit": "^11.3" + }, + "suggest": { + "ext-posix": "*" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-main": "7.2-dev" + } + }, + "autoload": { + "classmap": [ + "src/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "BSD-3-Clause" + ], + "authors": [ + { + "name": "Sebastian Bergmann", + "email": "sebastian@phpunit.de" + } + ], + "description": "Provides functionality to handle HHVM/PHP environments", + "homepage": "https://github.com/sebastianbergmann/environment", + "keywords": [ + "Xdebug", + "environment", + "hhvm" + ], + "support": { + "issues": "https://github.com/sebastianbergmann/environment/issues", + "security": "https://github.com/sebastianbergmann/environment/security/policy", + "source": "https://github.com/sebastianbergmann/environment/tree/7.2.1" + }, + "funding": [ + { + "url": "https://github.com/sebastianbergmann", + "type": "github" + }, + { + "url": "https://liberapay.com/sebastianbergmann", + "type": "liberapay" + }, + { + "url": "https://thanks.dev/u/gh/sebastianbergmann", + "type": "thanks_dev" + }, + { + "url": "https://tidelift.com/funding/github/packagist/sebastian/environment", + "type": "tidelift" + } + ], + "time": "2025-05-21T11:55:47+00:00" + }, + { + "name": "sebastian/exporter", + "version": "6.3.2", + "source": { + "type": "git", + "url": "https://github.com/sebastianbergmann/exporter.git", + "reference": "70a298763b40b213ec087c51c739efcaa90bcd74" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/sebastianbergmann/exporter/zipball/70a298763b40b213ec087c51c739efcaa90bcd74", + "reference": "70a298763b40b213ec087c51c739efcaa90bcd74", + "shasum": "" + }, + "require": { + "ext-mbstring": "*", + "php": ">=8.2", + "sebastian/recursion-context": "^6.0" + }, + "require-dev": { + "phpunit/phpunit": "^11.3" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-main": "6.3-dev" + } + }, + "autoload": { + "classmap": [ + "src/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "BSD-3-Clause" + ], + "authors": [ + { + "name": "Sebastian Bergmann", + "email": "sebastian@phpunit.de" + }, + { + "name": "Jeff Welch", + "email": "whatthejeff@gmail.com" + }, + { + "name": "Volker Dusch", + "email": "github@wallbash.com" + }, + { + "name": "Adam Harvey", + "email": "aharvey@php.net" + }, + { + "name": "Bernhard Schussek", + "email": "bschussek@gmail.com" + } + ], + "description": "Provides the functionality to export PHP variables for visualization", + "homepage": "https://www.github.com/sebastianbergmann/exporter", + "keywords": [ + "export", + "exporter" + ], + "support": { + "issues": "https://github.com/sebastianbergmann/exporter/issues", + "security": "https://github.com/sebastianbergmann/exporter/security/policy", + "source": "https://github.com/sebastianbergmann/exporter/tree/6.3.2" + }, + "funding": [ + { + "url": "https://github.com/sebastianbergmann", + "type": "github" + }, + { + "url": "https://liberapay.com/sebastianbergmann", + "type": "liberapay" + }, + { + "url": "https://thanks.dev/u/gh/sebastianbergmann", + "type": "thanks_dev" + }, + { + "url": "https://tidelift.com/funding/github/packagist/sebastian/exporter", + "type": "tidelift" + } + ], + "time": "2025-09-24T06:12:51+00:00" + }, + { + "name": "sebastian/global-state", + "version": "7.0.2", + "source": { + "type": "git", + "url": "https://github.com/sebastianbergmann/global-state.git", + "reference": "3be331570a721f9a4b5917f4209773de17f747d7" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/sebastianbergmann/global-state/zipball/3be331570a721f9a4b5917f4209773de17f747d7", + "reference": "3be331570a721f9a4b5917f4209773de17f747d7", + "shasum": "" + }, + "require": { + "php": ">=8.2", + "sebastian/object-reflector": "^4.0", + "sebastian/recursion-context": "^6.0" + }, + "require-dev": { + "ext-dom": "*", + "phpunit/phpunit": "^11.0" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-main": "7.0-dev" + } + }, + "autoload": { + "classmap": [ + "src/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "BSD-3-Clause" + ], + "authors": [ + { + "name": "Sebastian Bergmann", + "email": "sebastian@phpunit.de" + } + ], + "description": "Snapshotting of global state", + "homepage": "https://www.github.com/sebastianbergmann/global-state", + "keywords": [ + "global state" + ], + "support": { + "issues": "https://github.com/sebastianbergmann/global-state/issues", + "security": "https://github.com/sebastianbergmann/global-state/security/policy", + "source": "https://github.com/sebastianbergmann/global-state/tree/7.0.2" + }, + "funding": [ + { + "url": "https://github.com/sebastianbergmann", + "type": "github" + } + ], + "time": "2024-07-03T04:57:36+00:00" + }, + { + "name": "sebastian/lines-of-code", + "version": "3.0.1", + "source": { + "type": "git", + "url": "https://github.com/sebastianbergmann/lines-of-code.git", + "reference": "d36ad0d782e5756913e42ad87cb2890f4ffe467a" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/sebastianbergmann/lines-of-code/zipball/d36ad0d782e5756913e42ad87cb2890f4ffe467a", + "reference": "d36ad0d782e5756913e42ad87cb2890f4ffe467a", + "shasum": "" + }, + "require": { + "nikic/php-parser": "^5.0", + "php": ">=8.2" + }, + "require-dev": { + "phpunit/phpunit": "^11.0" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-main": "3.0-dev" + } + }, + "autoload": { + "classmap": [ + "src/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "BSD-3-Clause" + ], + "authors": [ + { + "name": "Sebastian Bergmann", + "email": "sebastian@phpunit.de", + "role": "lead" + } + ], + "description": "Library for counting the lines of code in PHP source code", + "homepage": "https://github.com/sebastianbergmann/lines-of-code", + "support": { + "issues": "https://github.com/sebastianbergmann/lines-of-code/issues", + "security": "https://github.com/sebastianbergmann/lines-of-code/security/policy", + "source": "https://github.com/sebastianbergmann/lines-of-code/tree/3.0.1" + }, + "funding": [ + { + "url": "https://github.com/sebastianbergmann", + "type": "github" + } + ], + "time": "2024-07-03T04:58:38+00:00" + }, + { + "name": "sebastian/object-enumerator", + "version": "6.0.1", + "source": { + "type": "git", + "url": "https://github.com/sebastianbergmann/object-enumerator.git", + "reference": "f5b498e631a74204185071eb41f33f38d64608aa" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/sebastianbergmann/object-enumerator/zipball/f5b498e631a74204185071eb41f33f38d64608aa", + "reference": "f5b498e631a74204185071eb41f33f38d64608aa", + "shasum": "" + }, + "require": { + "php": ">=8.2", + "sebastian/object-reflector": "^4.0", + "sebastian/recursion-context": "^6.0" + }, + "require-dev": { + "phpunit/phpunit": "^11.0" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-main": "6.0-dev" + } + }, + "autoload": { + "classmap": [ + "src/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "BSD-3-Clause" + ], + "authors": [ + { + "name": "Sebastian Bergmann", + "email": "sebastian@phpunit.de" + } + ], + "description": "Traverses array structures and object graphs to enumerate all referenced objects", + "homepage": "https://github.com/sebastianbergmann/object-enumerator/", + "support": { + "issues": "https://github.com/sebastianbergmann/object-enumerator/issues", + "security": "https://github.com/sebastianbergmann/object-enumerator/security/policy", + "source": "https://github.com/sebastianbergmann/object-enumerator/tree/6.0.1" + }, + "funding": [ + { + "url": "https://github.com/sebastianbergmann", + "type": "github" + } + ], + "time": "2024-07-03T05:00:13+00:00" + }, + { + "name": "sebastian/object-reflector", + "version": "4.0.1", + "source": { + "type": "git", + "url": "https://github.com/sebastianbergmann/object-reflector.git", + "reference": "6e1a43b411b2ad34146dee7524cb13a068bb35f9" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/sebastianbergmann/object-reflector/zipball/6e1a43b411b2ad34146dee7524cb13a068bb35f9", + "reference": "6e1a43b411b2ad34146dee7524cb13a068bb35f9", + "shasum": "" + }, + "require": { + "php": ">=8.2" + }, + "require-dev": { + "phpunit/phpunit": "^11.0" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-main": "4.0-dev" + } + }, + "autoload": { + "classmap": [ + "src/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "BSD-3-Clause" + ], + "authors": [ + { + "name": "Sebastian Bergmann", + "email": "sebastian@phpunit.de" + } + ], + "description": "Allows reflection of object attributes, including inherited and non-public ones", + "homepage": "https://github.com/sebastianbergmann/object-reflector/", + "support": { + "issues": "https://github.com/sebastianbergmann/object-reflector/issues", + "security": "https://github.com/sebastianbergmann/object-reflector/security/policy", + "source": "https://github.com/sebastianbergmann/object-reflector/tree/4.0.1" + }, + "funding": [ + { + "url": "https://github.com/sebastianbergmann", + "type": "github" + } + ], + "time": "2024-07-03T05:01:32+00:00" + }, + { + "name": "sebastian/recursion-context", + "version": "6.0.3", + "source": { + "type": "git", + "url": "https://github.com/sebastianbergmann/recursion-context.git", + "reference": "f6458abbf32a6c8174f8f26261475dc133b3d9dc" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/sebastianbergmann/recursion-context/zipball/f6458abbf32a6c8174f8f26261475dc133b3d9dc", + "reference": "f6458abbf32a6c8174f8f26261475dc133b3d9dc", + "shasum": "" + }, + "require": { + "php": ">=8.2" + }, + "require-dev": { + "phpunit/phpunit": "^11.3" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-main": "6.0-dev" + } + }, + "autoload": { + "classmap": [ + "src/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "BSD-3-Clause" + ], + "authors": [ + { + "name": "Sebastian Bergmann", + "email": "sebastian@phpunit.de" + }, + { + "name": "Jeff Welch", + "email": "whatthejeff@gmail.com" + }, + { + "name": "Adam Harvey", + "email": "aharvey@php.net" + } + ], + "description": "Provides functionality to recursively process PHP variables", + "homepage": "https://github.com/sebastianbergmann/recursion-context", + "support": { + "issues": "https://github.com/sebastianbergmann/recursion-context/issues", + "security": "https://github.com/sebastianbergmann/recursion-context/security/policy", + "source": "https://github.com/sebastianbergmann/recursion-context/tree/6.0.3" + }, + "funding": [ + { + "url": "https://github.com/sebastianbergmann", + "type": "github" + }, + { + "url": "https://liberapay.com/sebastianbergmann", + "type": "liberapay" + }, + { + "url": "https://thanks.dev/u/gh/sebastianbergmann", + "type": "thanks_dev" + }, + { + "url": "https://tidelift.com/funding/github/packagist/sebastian/recursion-context", + "type": "tidelift" + } + ], + "time": "2025-08-13T04:42:22+00:00" + }, + { + "name": "sebastian/type", + "version": "5.1.3", + "source": { + "type": "git", + "url": "https://github.com/sebastianbergmann/type.git", + "reference": "f77d2d4e78738c98d9a68d2596fe5e8fa380f449" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/sebastianbergmann/type/zipball/f77d2d4e78738c98d9a68d2596fe5e8fa380f449", + "reference": "f77d2d4e78738c98d9a68d2596fe5e8fa380f449", + "shasum": "" + }, + "require": { + "php": ">=8.2" + }, + "require-dev": { + "phpunit/phpunit": "^11.3" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-main": "5.1-dev" + } + }, + "autoload": { + "classmap": [ + "src/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "BSD-3-Clause" + ], + "authors": [ + { + "name": "Sebastian Bergmann", + "email": "sebastian@phpunit.de", + "role": "lead" + } + ], + "description": "Collection of value objects that represent the types of the PHP type system", + "homepage": "https://github.com/sebastianbergmann/type", + "support": { + "issues": "https://github.com/sebastianbergmann/type/issues", + "security": "https://github.com/sebastianbergmann/type/security/policy", + "source": "https://github.com/sebastianbergmann/type/tree/5.1.3" + }, + "funding": [ + { + "url": "https://github.com/sebastianbergmann", + "type": "github" + }, + { + "url": "https://liberapay.com/sebastianbergmann", + "type": "liberapay" + }, + { + "url": "https://thanks.dev/u/gh/sebastianbergmann", + "type": "thanks_dev" + }, + { + "url": "https://tidelift.com/funding/github/packagist/sebastian/type", + "type": "tidelift" + } + ], + "time": "2025-08-09T06:55:48+00:00" + }, + { + "name": "sebastian/version", + "version": "5.0.2", + "source": { + "type": "git", + "url": "https://github.com/sebastianbergmann/version.git", + "reference": "c687e3387b99f5b03b6caa64c74b63e2936ff874" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/sebastianbergmann/version/zipball/c687e3387b99f5b03b6caa64c74b63e2936ff874", + "reference": "c687e3387b99f5b03b6caa64c74b63e2936ff874", + "shasum": "" + }, + "require": { + "php": ">=8.2" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-main": "5.0-dev" + } + }, + "autoload": { + "classmap": [ + "src/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "BSD-3-Clause" + ], + "authors": [ + { + "name": "Sebastian Bergmann", + "email": "sebastian@phpunit.de", + "role": "lead" + } + ], + "description": "Library that helps with managing the version number of Git-hosted PHP projects", + "homepage": "https://github.com/sebastianbergmann/version", + "support": { + "issues": "https://github.com/sebastianbergmann/version/issues", + "security": "https://github.com/sebastianbergmann/version/security/policy", + "source": "https://github.com/sebastianbergmann/version/tree/5.0.2" + }, + "funding": [ + { + "url": "https://github.com/sebastianbergmann", + "type": "github" + } + ], + "time": "2024-10-09T05:16:32+00:00" + }, + { + "name": "staabm/side-effects-detector", + "version": "1.0.5", + "source": { + "type": "git", + "url": "https://github.com/staabm/side-effects-detector.git", + "reference": "d8334211a140ce329c13726d4a715adbddd0a163" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/staabm/side-effects-detector/zipball/d8334211a140ce329c13726d4a715adbddd0a163", + "reference": "d8334211a140ce329c13726d4a715adbddd0a163", + "shasum": "" + }, + "require": { + "ext-tokenizer": "*", + "php": "^7.4 || ^8.0" + }, + "require-dev": { + "phpstan/extension-installer": "^1.4.3", + "phpstan/phpstan": "^1.12.6", + "phpunit/phpunit": "^9.6.21", + "symfony/var-dumper": "^5.4.43", + "tomasvotruba/type-coverage": "1.0.0", + "tomasvotruba/unused-public": "1.0.0" + }, + "type": "library", + "autoload": { + "classmap": [ + "lib/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "description": "A static analysis tool to detect side effects in PHP code", + "keywords": [ + "static analysis" + ], + "support": { + "issues": "https://github.com/staabm/side-effects-detector/issues", + "source": "https://github.com/staabm/side-effects-detector/tree/1.0.5" + }, + "funding": [ + { + "url": "https://github.com/staabm", + "type": "github" + } + ], + "time": "2024-10-20T05:08:20+00:00" + }, + { + "name": "theseer/tokenizer", + "version": "1.3.1", + "source": { + "type": "git", + "url": "https://github.com/theseer/tokenizer.git", + "reference": "b7489ce515e168639d17feec34b8847c326b0b3c" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/theseer/tokenizer/zipball/b7489ce515e168639d17feec34b8847c326b0b3c", + "reference": "b7489ce515e168639d17feec34b8847c326b0b3c", + "shasum": "" + }, + "require": { + "ext-dom": "*", + "ext-tokenizer": "*", + "ext-xmlwriter": "*", + "php": "^7.2 || ^8.0" + }, + "type": "library", + "autoload": { + "classmap": [ + "src/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "BSD-3-Clause" + ], + "authors": [ + { + "name": "Arne Blankerts", + "email": "arne@blankerts.de", + "role": "Developer" + } + ], + "description": "A small library for converting tokenized PHP source code into XML and potentially other formats", + "support": { + "issues": "https://github.com/theseer/tokenizer/issues", + "source": "https://github.com/theseer/tokenizer/tree/1.3.1" + }, + "funding": [ + { + "url": "https://github.com/theseer", + "type": "github" + } + ], + "time": "2025-11-17T20:03:58+00:00" + } + ], + "aliases": [], + "minimum-stability": "stable", + "stability-flags": {}, + "prefer-stable": false, + "prefer-lowest": false, + "platform": { + "php": ">=8.2" + }, + "platform-dev": {}, + "plugin-api-version": "2.6.0" +} diff --git a/docs/xdebug.md b/docs/xdebug.md index 00449f4..bc5253d 100644 --- a/docs/xdebug.md +++ b/docs/xdebug.md @@ -4,6 +4,8 @@ Use `slic xdebug status` to find the configuration details for the project that * [PHPStorm](#phpstorm) * [VSCode](#vscode) +* [WSL2](#wsl2) +* [Multi-Stack XDebug Configuration](#multi-stack-xdebug-configuration) ## PHPStorm @@ -99,7 +101,7 @@ Or manually set it if you know your WSL2 IP: slic xdebug host 172.24.206.58 ``` -**Note:** WSL2 IP addresses can change after restarting WSL2 or Windows, though they often remain stable. If breakpoints stop working, check if your IP has changed and reconfigure if necessary (see [Troubleshooting](#troubleshooting) below). +**Note:** WSL2 IP addresses can change after restarting WSL2 or Windows, though they often remain stable. If breakpoints stop working, check if your IP has changed and reconfigure if necessary (see [WSL2 Troubleshooting](#wsl2-troubleshooting) below). ### Step 3: Restart slic containers @@ -120,27 +122,272 @@ slic xdebug status You should see your WSL2 IP address in the "Remote host" field. -### Troubleshooting +### WSL2 Troubleshooting -If breakpoints still don't work: +If breakpoints stop working on WSL2: -1. **Check if your WSL2 IP changed**: WSL2 IP addresses can change after restarting WSL2 or Windows (though they often remain stable). If breakpoints stop working, verify your current IP and reconfigure if it has changed: +1. **Check if your WSL2 IP changed**: WSL2 IP addresses can change after restarting WSL2 or Windows (though they often remain stable). Verify your current IP and reconfigure if it has changed: ```bash slic xdebug host $(hostname -I | awk '{print $1}') slic restart slic xdebug on ``` - + You can verify your current WSL2 IP with: ```bash hostname -I | awk '{print $1}' ``` -2. **Verify your editor is listening**: Check your editor's debug console to see if there are any connection attempts from Xdebug. In VS Code, go to View → Output → Debug Console. In PHPStorm, check the Debug tool window. +## Multi-Stack XDebug Configuration + +When working with multiple slic stacks simultaneously, each stack gets its own unique XDebug configuration to prevent conflicts and enable debugging across multiple projects at the same time. + +### How Stack-Specific Configuration Works + +Each slic stack is assigned two unique identifiers: + +1. **XDebug Port**: A unique port in the range of 49000-59000 +2. **Server Name (IDE Key)**: A unique identifier based on the XDK environment variable + +#### Understanding the XDK Environment Variable + +The `XDK` environment variable is used as the **root** to build the IDE key/server name for each stack. The actual IDE key format is: + +``` +${XDK:-slic}_ +``` + +This means: +- If you set `XDK=my_project`, the IDE key becomes `my_project_a1b2c3d4` (where `a1b2c3d4` is the stack hash) +- If `XDK` is not set, it defaults to `slic`, creating IDE keys like `slic_a1b2c3d4` +- This allows you to namespace your debugging sessions by project or organization + +You can set the XDK value using: +```bash +slic xdebug key my_project +``` + +Or by adding it to your `.env.slic.local` file: +``` +XDK=my_project +``` + +Both the port and server name values are deterministically generated from the stack's absolute path using MD5 hashing. This means: +- The same stack will always get the same port and server name +- Different stacks will have different ports and server names +- No manual configuration is needed - it's automatic + +### Benefits of Multi-Stack Configuration + +- **No Port Conflicts**: Each stack has its own XDebug port, so you can debug multiple projects simultaneously +- **Isolated Debugging Sessions**: Switch between debugging different projects without reconfiguring your IDE +- **Consistent Configuration**: The same stack always uses the same port and server name, even after restarts +- **Parallel Development**: Work on multiple plugins or themes across different stacks without interference + +### Viewing Your Stack's Configuration + +Use `slic xdebug status` to see your current stack's XDebug configuration: + +```bash +slic xdebug status +``` + +This will display: +- The XDebug port assigned to your stack +- The server name for your stack +- Path mappings for your IDE configuration + +### IDE Setup for Multiple Stacks + +When configuring your IDE to work with multiple slic stacks, you'll need to create a separate debug configuration for each stack. + +#### PHPStorm Multi-Stack Setup + +For each stack you're working with: + +1. Create a new server configuration (PHP > Servers) +2. Use the stack-specific server name from `slic xdebug status` (e.g., `slic_a1b2c3d4`) +3. Add the stack-specific port from `slic xdebug status` to your debug ports list +4. Configure the same path mappings as described in the [PHPStorm section](#phpstorm) above + +#### VSCode Multi-Stack Setup + +Create multiple debug configurations in your `.vscode/launch.json`: + +```json +{ + "version": "0.2.0", + "configurations": [ + { + "name": "Slic: Stack 1 (Event Tickets)", + "type": "php", + "request": "launch", + "port": 49123, + "pathMappings": { + "/var/www/html/wp-content/plugins": "${workspaceFolder}/event-tickets", + "/var/www/html": "${workspaceFolder}/event-tickets/slic/_wordpress" + }, + "ignore": [ + "**/vendor/**/*.php" + ], + "stopOnEntry": false + }, + { + "name": "Slic: Stack 2 (The Events Calendar)", + "type": "php", + "request": "launch", + "port": 52456, + "pathMappings": { + "/var/www/html/wp-content/plugins": "${workspaceFolder}/the-events-calendar", + "/var/www/html": "${workspaceFolder}/the-events-calendar/slic/_wordpress" + }, + "ignore": [ + "**/vendor/**/*.php" + ], + "stopOnEntry": false + } + ] +} +``` + +### Example: Multiple Stacks Configuration + +Here's an example showing how two different stacks would have different configurations: + +**Stack 1: Event Tickets** (`/Users/developer/projects/event-tickets`) +```bash +$ cd /Users/developer/projects/event-tickets +$ slic xdebug status +XDebug is enabled +Server name: slic_a1b2c3d4 +XDebug port: 49123 +``` -3. **Check Xdebug is enabled**: Make sure Xdebug is enabled in slic: +**Stack 2: The Events Calendar** (`/Users/developer/projects/the-events-calendar`) +```bash +$ cd /Users/developer/projects/the-events-calendar +$ slic xdebug status +XDebug is enabled +Server name: slic_e5f6g7h8 +XDebug port: 52456 +``` + +With this setup: +- You can debug Event Tickets code by starting the "Stack 1" debug configuration (port 49123) +- You can debug The Events Calendar code by starting the "Stack 2" debug configuration (port 52456) +- Both can run simultaneously without any conflicts +- Each stack maintains its own independent debugging session + +### Worktree Path Mappings + +When working with git worktrees in slic, you'll see an additional path mapping in the output of `slic xdebug status`. This is crucial for IDE debugging to work correctly with worktree stacks. + +#### What are Worktree Stacks? + +Git worktrees allow you to have multiple working directories for the same repository, each checked out to different branches. Slic supports this workflow by creating "worktree stacks" that each have their own Docker containers and XDebug configuration. + +#### Worktree Path Mapping Output + +For a worktree stack, `slic xdebug status` will display **three** path mappings instead of the usual two: + +```bash +# Standard mappings (same for all stacks) +Path mapping (host => server): /path/to/wp-content/plugins => /var/www/html/wp-content/plugins +Path mapping (host => server): /path/to/_wordpress => /var/www/html + +# Additional worktree mapping (only for worktree stacks) +Path mapping (host => server): /path/to/my-plugin/feature-branch => /var/www/html/wp-content/plugins/my-plugin +``` + +#### Why the Third Mapping is Important + +The worktree-specific mapping ensures your IDE can correctly map breakpoints and step through code in the worktree directory. Without this mapping: +- Breakpoints set in worktree files won't be hit +- The debugger won't be able to find source files +- Stepping through code will fail + +#### Example: Plugin Worktree + +Let's say you're working on the "event-tickets" plugin with a worktree for a feature branch: + +```bash +$ cd /Users/developer/projects/event-tickets/fix-123 +$ slic xdebug status + +Stack: event-tickets@fix-123 +Project: event-tickets + +XDebug status is: on +Remote host: host.docker.internal +Remote port: 49123 +IDE Key (server name): slic_a1b2c3d4 + +Set up, in your IDE, a server with the following parameters to debug PHP requests: +IDE key, or server name: slic_a1b2c3d4 +Host: http://localhost:8001 +Path mapping (host => server): /Users/developer/projects/event-tickets/_plugins => /var/www/html/wp-content/plugins +Path mapping (host => server): /Users/developer/projects/event-tickets/_wordpress => /var/www/html +Path mapping (host => server): /Users/developer/projects/event-tickets/fix-123 => /var/www/html/wp-content/plugins/event-tickets +``` + +The third mapping (`/Users/developer/projects/event-tickets/fix-123 => /var/www/html/wp-content/plugins/event-tickets`) tells your IDE that files in the worktree directory correspond to the plugin directory inside the container. + +#### Example: Theme Worktree + +For a theme worktree, the mapping points to the themes directory instead: + +```bash +Path mapping (host => server): /Users/developer/projects/my-theme/new-design => /var/www/html/wp-content/themes/my-theme +``` + +#### Configuring Your IDE for Worktrees + +**PHPStorm:** +When adding path mappings for a worktree stack, add all three mappings to your server configuration: + +1. Plugins directory: `/path/to/_plugins` → `/var/www/html/wp-content/plugins` +2. WordPress root: `/path/to/_wordpress` → `/var/www/html` +3. Worktree directory: `/path/to/worktree-dir` → `/var/www/html/wp-content/plugins/your-plugin` (or `/themes/your-theme`) + +**VSCode:** +Add all three mappings to your `pathMappings` in `launch.json`: + +```json +{ + "name": "Slic: Worktree (fix-123)", + "type": "php", + "request": "launch", + "port": 49123, + "pathMappings": { + "/var/www/html/wp-content/plugins": "${workspaceFolder}/event-tickets/_plugins", + "/var/www/html": "${workspaceFolder}/event-tickets/_wordpress", + "/var/www/html/wp-content/plugins/event-tickets": "${workspaceFolder}/event-tickets/fix-123" + } +} +``` + +### Tips for Multi-Stack Debugging + +1. **Always check your current stack**: Run `slic xdebug status` to confirm which stack you're working with +2. **Copy path mappings exactly**: Use the exact paths from `slic xdebug status` output when configuring your IDE +3. **Include all three mappings for worktrees**: Don't forget the worktree-specific mapping when debugging worktree stacks +4. **Name your configurations clearly**: Use descriptive names in your IDE debug configurations to easily identify which stack they're for +5. **Keep configurations in sync**: If you change path mappings or update slic, remember to update all your stack configurations +6. **Use workspace folders**: In IDEs that support it (like VSCode), use workspace folders to manage multiple stacks in a single window + +## General Troubleshooting + +If breakpoints don't work: + +1. **Verify your editor is listening**: Check your editor's debug console to see if there are any connection attempts from Xdebug. In VS Code, go to View → Output → Debug Console. In PHPStorm, check the Debug tool window. + +2. **Check Xdebug is enabled**: Make sure Xdebug is enabled in slic: ```bash slic xdebug on ``` -4. **Verify the port**: Ensure port 9001 is not blocked by a firewall and that your code editor is listening on that port. \ No newline at end of file +3. **Verify the port**: Ensure the XDebug port (check with `slic xdebug status`) is not blocked by a firewall and that your code editor is listening on that port. + +4. **Check path mappings**: Ensure your IDE's path mappings match exactly what `slic xdebug status` shows. Incorrect mappings are a common cause of breakpoints not being hit. + +5. **For WSL2 users**: See the [WSL2 Troubleshooting](#wsl2-troubleshooting) section above for platform-specific issues. diff --git a/phpunit.xml b/phpunit.xml new file mode 100644 index 0000000..e1142f0 --- /dev/null +++ b/phpunit.xml @@ -0,0 +1,11 @@ + + + + tests/Cli + + + diff --git a/slic b/slic index 36b5016..cd73718 100755 --- a/slic +++ b/slic @@ -1,4 +1,4 @@ #!/usr/bin/env php $arg ) { + // Handle --stack= syntax + if ( strpos( $arg, '--stack=' ) === 0 ) { + $stack_flag_count++; + // Extract the path value after '--stack=' (8 characters: length of '--stack=') + $SLIC_STACK_OVERRIDE = substr( $arg, 8 ); + if ( empty( $SLIC_STACK_OVERRIDE ) ) { + echo colorize( "Error: --stack requires a path argument" . PHP_EOL ); + echo colorize( "Usage: slic --stack= or slic --stack " . PHP_EOL ); + exit( 1 ); + } + $indices_to_remove[] = $index; + } + // Handle --stack syntax (two separate arguments) + elseif ( $arg === '--stack' ) { + $stack_flag_count++; + /* + * Check if the next argument exists and is not a flag. + * We check for '--' prefix to identify flags, allowing paths that start with a single hyphen + * (e.g., "-mypath" is valid, but "--help" is a flag). + */ + if ( isset( $argv[ $index + 1 ] ) && strpos( $argv[ $index + 1 ], '--' ) !== 0 ) { + $SLIC_STACK_OVERRIDE = $argv[ $index + 1 ]; + // Mark both --stack and the path for removal + $indices_to_remove[] = $index; + $indices_to_remove[] = $index + 1; + } else { + // --stack flag present but no valid path provided + echo colorize( "Error: --stack requires a path argument" . PHP_EOL ); + echo colorize( "Usage: slic --stack= or slic --stack " . PHP_EOL ); + exit( 1 ); + } + } + // Handle -q (quiet) flag + elseif ( $arg === '-q' ) { + $is_quiet = true; + $indices_to_remove[] = $index; + } +} + +// Warn if duplicate --stack flags were provided +if ( $stack_flag_count > 1 ) { + echo colorize( "Warning: Multiple --stack flags detected. Using the last one: {$SLIC_STACK_OVERRIDE}" . PHP_EOL ); +} + +// Validate the stack path exists if --stack was provided +if ( null !== $SLIC_STACK_OVERRIDE ) { + // Store the original path for error messages + $original_stack_path = $SLIC_STACK_OVERRIDE; + + // Check if this is a stack ID with @ format (worktree) + $is_stack_id = strpos( $SLIC_STACK_OVERRIDE, '@' ) !== false; + + if ( $is_stack_id ) { + // Stack ID format: /base/path@worktree-dir + // Extract the actual directory path for validation + $parts = explode( '@', $SLIC_STACK_OVERRIDE, 2 ); + if ( count( $parts ) === 2 ) { + $base_path = $parts[0]; + $worktree_dir = $parts[1]; + + // Validate that both components are non-empty + if ( empty( $base_path ) || empty( $worktree_dir ) ) { + echo colorize( "Error: Invalid stack ID format - both base path and worktree directory are required" . PHP_EOL ); + echo colorize( "Stack ID provided: {$original_stack_path}" . PHP_EOL ); + echo colorize( "Expected format: /base/path@worktree-directory" . PHP_EOL ); + echo colorize( "Examples:" . PHP_EOL ); + echo colorize( " /Users/Alice/project@feature-branch" . PHP_EOL ); + echo colorize( " ~/work/tec@bugfix-123" . PHP_EOL ); + exit( 1 ); + } + + // The actual worktree directory is: base_path/worktree_dir + $worktree_full_path = $base_path . '/' . $worktree_dir; + + // Validate the worktree directory exists + $worktree_real = realpath( $worktree_full_path ); + if ( false === $worktree_real || ! file_exists( $worktree_real ) ) { + echo colorize( "Error: The worktree directory specified with --stack does not exist" . PHP_EOL ); + echo colorize( "Stack ID: {$original_stack_path}" . PHP_EOL ); + echo colorize( "Expected path: {$worktree_full_path}" . PHP_EOL ); + exit( 1 ); + } + } else { + echo colorize( "Error: Invalid stack ID format" . PHP_EOL ); + echo colorize( "Stack ID provided: {$original_stack_path}" . PHP_EOL ); + echo colorize( "Expected format: /base/path@worktree-dir" . PHP_EOL ); + exit( 1 ); + } + } else { + // Regular filesystem path - validate as before + // Expand relative paths and handle tilde (~) expansion + if ( '~' === $SLIC_STACK_OVERRIDE[0] ) { + $home = getenv( 'HOME' ); + if ( false !== $home ) { + $SLIC_STACK_OVERRIDE = $home . substr( $SLIC_STACK_OVERRIDE, 1 ); + } + } + + // Convert to absolute path if relative + if ( '/' !== $SLIC_STACK_OVERRIDE[0] ) { + $SLIC_STACK_OVERRIDE = getcwd() . '/' . $SLIC_STACK_OVERRIDE; + } + + // Normalize the path (remove .., ., etc.) + $SLIC_STACK_OVERRIDE = realpath( $SLIC_STACK_OVERRIDE ); + + // Check if the path exists + if ( false === $SLIC_STACK_OVERRIDE || ! file_exists( $SLIC_STACK_OVERRIDE ) ) { + echo colorize( "Error: The path specified with --stack does not exist" . PHP_EOL ); + echo colorize( "Path provided: {$original_stack_path}" . PHP_EOL ); + exit( 1 ); + } + } +} + +// Remove the global flags from argv +foreach ( $indices_to_remove as $index ) { + unset( $argv[ $index ] ); +} +if ( ! empty( $indices_to_remove ) ) { + $argv = array_values( $argv ); + $argc = count( $argv ); +} + // Set up the argument parsing function. $args = args( [ 'subcommand', '...', ] ); -$cli_name = 'slic'; -const CLI_VERSION = '2.2.0'; - // If the run-time option `-q`, for "quiet", is specified, then do not print the header. -if ( in_array( '-q', $argv, true ) || ( in_array( 'exec', $argv, true ) && ! in_array( 'help', $argv, true ) ) ) { +if ( $is_quiet || ( in_array( 'exec', $argv, true ) && ! in_array( 'help', $argv, true ) ) ) { // Remove the `-q` flag from the global array of arguments to leave the rest of the commands unchanged. unset( $argv[ array_search( '-q', $argv ) ] ); $argv = array_values( $argv ); @@ -94,6 +235,7 @@ restart Restarts containers in the stack. run Runs a Codeception test in the stack, the equivalent to 'codecept run ...'. shell Opens a shell in the `slic` container. + stack Manages multiple slic stacks (list, stop, info). start Starts containers in the stack. stop Stops containers in the stack. use Sets the plugin to use in the tests. @@ -103,6 +245,7 @@ playwright Runs Playwright commands in the stack. Type {$cli_name} help for info about each command. +Global option: --stack= to target a specific stack. HELP; $help_advanced_message_template = <<< HELP @@ -114,6 +257,7 @@ cache Activates and deactivates object cache support, returns the current object cache status. cc Runs a Codeception command in the stack, the equivalent of 'codecept ...'. cli Runs a wp-cli command or opens a `wp-cli shell` in the stack; alias of `wp`. + completion Manage shell completion (install, show, cache-clear). composer-cache Sets or shows the composer cache directory. config Prints the stack configuration as interpolated from the environment. debug Activates or deactivates {$cli_name} debug output or returns the current debug status. @@ -135,6 +279,7 @@ update Updates the tool and the images used in its services. upgrade Upgrades the {$cli_name} repo. update-dump Updates a SQL dump.sql for acceptance testing by importing, upgrading, and re-exporting it. + worktree Manages git worktrees with dedicated stacks. HELP; $help_message = colorize( $help_message_template ); @@ -142,7 +287,6 @@ $is_help = args( [ 'help' ], $args( '...' ), 0 )( 'help', false ) === 'help'; - $original_subcommand = $args( 'subcommand' ); $subcommand = $args( 'subcommand', 'help' ); diff --git a/src/colors.php b/src/colors.php index 630b9dc..3afe0fa 100644 --- a/src/colors.php +++ b/src/colors.php @@ -11,11 +11,28 @@ * @param string $string The string to colorize. * @param int $color_code The string color, or style code, to apply to the string. * - * @return string The style string. + * @return string The style string. Returns the unmodified string when the NO_COLOR + * environment variable is set. * * @see https://misc.flogisoft.com/bash/tip_colors_and_formatting + * @see https://no-color.org/ */ +/** + * Returns whether color output is disabled via the NO_COLOR environment variable. + * + * @return bool + * + * @see https://no-color.org/ + */ +function no_color() { + return getenv( 'NO_COLOR' ) !== false || isset( $_SERVER['NO_COLOR'] ); +} + function style( $string, $color_code ) { + if ( no_color() ) { + return $string; + } + return "\033[" . $color_code . "m" . $string . "\033[0m"; } diff --git a/src/commands.php b/src/commands.php index d4160d5..e215210 100644 --- a/src/commands.php +++ b/src/commands.php @@ -9,13 +9,65 @@ /** * The slic stop command. * + * @param string|null $stack_id The stack to stop. If null, uses current stack. + * @param bool $unregister Whether to unregister the stack after stopping. Default true. * @return int */ -function command_stop() : int { - $status = slic_realtime()( [ 'down', '--volumes', '--remove-orphans' ] ); +function command_stop( $stack_id = null, $unregister = true ) : int { + // Determine which stack to stop + if ( null === $stack_id ) { + $stack_id = slic_current_stack(); + } + + if ( null === $stack_id ) { + echo magenta( "No active stack. Run 'slic here' to create a stack." . PHP_EOL ); + return 1; + } + + // Get stack state before stopping (for worktree cleanup message) + require_once __DIR__ . '/stacks.php'; + $stack_state = null; + if ( $unregister && slic_stacks_is_worktree( $stack_id ) ) { + $stack_state = slic_stacks_get( $stack_id ); + } + + $status = slic_realtime( $stack_id )( [ 'down', '--volumes', '--remove-orphans' ] ); if ( $status === 0 ) { echo colorize( PHP_EOL . "✅ All services have been stopped." . PHP_EOL ); + + // Unregister the stack if requested + // Note: State file cleanup is handled by slic_stacks_unregister() + if ( $unregister ) { + if ( slic_stacks_unregister( $stack_id ) ) { + echo colorize( "Stack unregistered: {$stack_id}" . PHP_EOL ); + + // If this was a worktree stack, inform user about cleanup + if ( $stack_state && slic_stacks_is_worktree( $stack_id ) ) { + $parsed = slic_stacks_parse_worktree_id( $stack_id ); + if ( $parsed ) { + echo colorize( PHP_EOL . "Note: The git worktree still exists on your filesystem." . PHP_EOL ); + echo colorize( "To remove the worktree and clean up, you can:" . PHP_EOL ); + + // Use the branch from state if available, otherwise try to extract it + $branch_hint = $stack_state['worktree_branch'] ?? null; + if ( ! $branch_hint ) { + $worktree_dir = $parsed['worktree_dir']; + $target = $stack_state['worktree_target'] ?? basename( $parsed['base_path'] ); + + $branch_hint = $worktree_dir; + if ( strpos( $worktree_dir, $target . '-' ) === 0 ) { + $branch_hint = substr( $worktree_dir, strlen( $target ) + 1 ); + $branch_hint = str_replace( '-', '/', $branch_hint ); + } + } + + echo colorize( " • slic worktree remove {$branch_hint}" . PHP_EOL ); + echo colorize( " • git worktree remove {$parsed['base_path']}/{$parsed['worktree_dir']}" . PHP_EOL ); + } + } + } + } } else { echo colorize( PHP_EOL . "❌ Some containers failed to stop. Use slic ps to see what is still running." . PHP_EOL ); } diff --git a/src/commands/cli.php b/src/commands/cli.php index aef69f6..48efeb7 100644 --- a/src/commands/cli.php +++ b/src/commands/cli.php @@ -47,7 +47,7 @@ // If the command is `bash` or `ssh` or is empty, then open a shell in the `cli` service. if ( empty( $cli_command ) || in_array( $cli_command, [ 'bash', 'ssh' ], true ) ) { // @todo replace from ssh command. - $command = sprintf( 'docker exec -it --user "%d:%d" --workdir %s %s bash -c "wp shell"', + $command = sprintf( docker_bin() . ' exec -it --user "%d:%d" --workdir %s %s bash -c "wp shell"', getenv( 'SLIC_UID' ), getenv( 'SLIC_GID' ), escapeshellarg( get_project_container_path( 'site' ) ), diff --git a/src/commands/completion.php b/src/commands/completion.php new file mode 100644 index 0000000..eb42cdb --- /dev/null +++ b/src/commands/completion.php @@ -0,0 +1,538 @@ +{$cli_name} completion [install|show|cache-clear] [bash|zsh|fish] + + SUBCOMMANDS: + + (no arguments) + Auto-detects your shell and displays installation instructions. + + install [bash|zsh|fish] + Automatically installs completions for the specified shell (or auto-detected shell). + - bash: Appends source line to ~/.bashrc or ~/.bash_profile + - zsh: Adds fpath and source to ~/.zshrc + - fish: Creates symlink in ~/.config/fish/completions/ + Checks if already installed to avoid duplicates and asks for confirmation. + + show [bash|zsh|fish] + Outputs the shell completion script content for manual installation. + + cache-clear + Clears the completion cache directory (~/.slic/cache/completions/). + + EXAMPLES: + + {$cli_name} completion + Shows installation instructions for your current shell. + + {$cli_name} completion install + Auto-detects shell and installs completions. + + {$cli_name} completion install bash + Installs bash completions. + + {$cli_name} completion show zsh + Displays the zsh completion script. + + {$cli_name} completion cache-clear + Clears the completion cache. + HELP; + + echo colorize( $help ); + return; +} + +// Parse subcommand and shell arguments +$completion_args = args( [ 'subcommand', 'shell' ], $args( '...' ), 0 ); +$subcommand = $completion_args( 'subcommand', null ); +$shell_arg = $completion_args( 'shell', null ); + +/** + * Detects the current shell from the SHELL environment variable. + * + * @return string|null The detected shell name (bash, zsh, fish) or null if not detected. + */ +function detect_shell() { + $shell_path = getenv( 'SHELL' ); + + if ( empty( $shell_path ) ) { + return null; + } + + $shell_name = basename( $shell_path ); + + // Map shell names to supported types + $supported_shells = [ 'bash', 'zsh', 'fish' ]; + + foreach ( $supported_shells as $supported ) { + if ( strpos( $shell_name, $supported ) !== false ) { + return $supported; + } + } + + return null; +} + +/** + * Gets the path to the slic completions directory. + * + * @return string The absolute path to the completions directory. + */ +function get_completions_dir() { + return SLIC_ROOT_DIR . '/completions'; +} + +/** + * Gets the path to the completion script for a specific shell. + * + * @param string $shell The shell type (bash, zsh, fish). + * + * @return string|null The absolute path to the completion script, or null if not found. + */ +function get_completion_script_path( $shell ) { + $paths = [ + 'bash' => get_completions_dir() . '/bash/slic.bash', + 'zsh' => get_completions_dir() . '/zsh/_slic', + 'fish' => get_completions_dir() . '/fish/slic.fish', + ]; + + if ( ! isset( $paths[ $shell ] ) ) { + return null; + } + + $path = $paths[ $shell ]; + + return file_exists( $path ) ? $path : null; +} + +/** + * Displays installation instructions for a specific shell. + * + * @param string $shell The shell type (bash, zsh, fish). + */ +function show_install_instructions( $shell ) { + $completions_dir = get_completions_dir(); + + echo colorize( PHP_EOL . "Installation instructions for {$shell}:" . PHP_EOL . PHP_EOL ); + + switch ( $shell ) { + case 'bash': + echo colorize( "Add the following to your ~/.bashrc or ~/.bash_profile:" . PHP_EOL . PHP_EOL ); + echo colorize( "# slic completions" . PHP_EOL ); + echo colorize( "if command -v slic &> /dev/null; then" . PHP_EOL ); + echo colorize( " _slic_path=\$(command -v slic 2>/dev/null)" . PHP_EOL ); + echo colorize( " if [[ -L \"\$_slic_path\" ]]; then" . PHP_EOL ); + echo colorize( " _slic_path=\$(realpath \"\$_slic_path\" 2>/dev/null || readlink -f \"\$_slic_path\" 2>/dev/null || readlink \"\$_slic_path\" 2>/dev/null)" . PHP_EOL ); + echo colorize( " fi" . PHP_EOL ); + echo colorize( " source \"\$(dirname \"\$_slic_path\")/completions/bash/slic.bash\"" . PHP_EOL ); + echo colorize( "fi" . PHP_EOL ); + break; + + case 'zsh': + echo colorize( "Add the following to your ~/.zshrc:" . PHP_EOL . PHP_EOL ); + echo colorize( "# slic completions" . PHP_EOL ); + echo colorize( "if command -v slic &> /dev/null; then" . PHP_EOL ); + echo colorize( " _slic_path=\$(command -v slic 2>/dev/null)" . PHP_EOL ); + echo colorize( " if [[ -L \"\$_slic_path\" ]]; then" . PHP_EOL ); + echo colorize( " _slic_path=\$(realpath \"\$_slic_path\" 2>/dev/null || readlink -f \"\$_slic_path\" 2>/dev/null || readlink \"\$_slic_path\" 2>/dev/null)" . PHP_EOL ); + echo colorize( " fi" . PHP_EOL ); + echo colorize( " fpath=(\"\$(dirname \"\$_slic_path\")/completions/zsh\" \$fpath)" . PHP_EOL ); + echo colorize( " autoload -Uz compinit && compinit" . PHP_EOL ); + echo colorize( "fi" . PHP_EOL ); + break; + + case 'fish': + echo colorize( "Option 1: Create a symlink (recommended):" . PHP_EOL . PHP_EOL ); + echo colorize( "mkdir -p ~/.config/fish/completions" . PHP_EOL ); + echo colorize( "ln -s {$completions_dir}/fish/slic.fish ~/.config/fish/completions/slic.fish" . PHP_EOL ); + echo colorize( PHP_EOL . "Option 2: Add to your ~/.config/fish/config.fish:" . PHP_EOL . PHP_EOL ); + echo colorize( "# slic completions" . PHP_EOL ); + echo colorize( "if type -q slic" . PHP_EOL ); + echo colorize( " source (dirname (realpath (which slic)))/completions/fish/slic.fish" . PHP_EOL ); + echo colorize( "end" . PHP_EOL ); + break; + } + + echo PHP_EOL; + echo colorize( "After adding the configuration, reload your shell or run:" . PHP_EOL ); + + switch ( $shell ) { + case 'bash': + echo colorize( "source ~/.bashrc" . PHP_EOL ); + break; + case 'zsh': + echo colorize( "source ~/.zshrc" . PHP_EOL ); + break; + case 'fish': + echo colorize( "source ~/.config/fish/config.fish" . PHP_EOL ); + break; + } +} + +/** + * Checks if completions are already installed for a specific shell. + * + * @param string $shell The shell type (bash, zsh, fish). + * + * @return bool True if completions are already installed. + */ +function is_installed( $shell ) { + $home = getenv( 'HOME' ); + + if ( empty( $home ) ) { + return false; + } + + switch ( $shell ) { + case 'bash': + $files = [ "$home/.bashrc", "$home/.bash_profile" ]; + foreach ( $files as $file ) { + if ( file_exists( $file ) ) { + $content = file_get_contents( $file ); + if ( strpos( $content, 'slic.bash' ) !== false || strpos( $content, 'completions/bash' ) !== false ) { + return true; + } + } + } + return false; + + case 'zsh': + $file = "$home/.zshrc"; + if ( file_exists( $file ) ) { + $content = file_get_contents( $file ); + return strpos( $content, '_slic' ) !== false || strpos( $content, 'completions/zsh' ) !== false; + } + return false; + + case 'fish': + // Check for symlink or file in fish completions directory + // Use is_link() to detect symlinks even if broken + $fish_completion = "$home/.config/fish/completions/slic.fish"; + if ( file_exists( $fish_completion ) || is_link( $fish_completion ) ) { + return true; + } + // Check config.fish + $config_file = "$home/.config/fish/config.fish"; + if ( file_exists( $config_file ) ) { + $content = file_get_contents( $config_file ); + return strpos( $content, 'slic.fish' ) !== false || strpos( $content, 'completions/fish' ) !== false; + } + return false; + } + + return false; +} + +/** + * Installs completions for a specific shell. + * + * @param string $shell The shell type (bash, zsh, fish). + */ +function install_completions( $shell ) { + $home = getenv( 'HOME' ); + + if ( empty( $home ) ) { + echo colorize( "Error: HOME environment variable not set." . PHP_EOL ); + exit( 1 ); + } + + // Check if already installed + if ( is_installed( $shell ) ) { + echo colorize( "Completions for {$shell} appear to be already installed." . PHP_EOL ); + echo colorize( "If they're not working, try running {$GLOBALS['cli_name']} completion cache-clear" . PHP_EOL ); + return; + } + + $completions_dir = get_completions_dir(); + + // Verify completion script exists + $script_path = get_completion_script_path( $shell ); + if ( empty( $script_path ) ) { + echo colorize( "Error: Completion script for {$shell} not found." . PHP_EOL ); + echo colorize( "Expected at: " . get_completions_dir() . "/{$shell}/" . PHP_EOL ); + exit( 1 ); + } + + echo colorize( PHP_EOL . "This will install {$shell} completions for slic." . PHP_EOL ); + + switch ( $shell ) { + case 'bash': + $target_file = file_exists( "$home/.bashrc" ) ? "$home/.bashrc" : "$home/.bash_profile"; + echo colorize( "This will add completion source code to: {$target_file}" . PHP_EOL ); + break; + case 'zsh': + $target_file = "$home/.zshrc"; + echo colorize( "This will add completion configuration to: {$target_file}" . PHP_EOL ); + break; + case 'fish': + $target_dir = "$home/.config/fish/completions"; + $target_file = "$target_dir/slic.fish"; + echo colorize( "This will create a symlink at: {$target_file}" . PHP_EOL ); + break; + } + + echo PHP_EOL; + + $confirm = ask( "Continue with installation?", 'yes' ); + + if ( ! $confirm ) { + echo colorize( "Installation cancelled." . PHP_EOL ); + return; + } + + echo PHP_EOL; + + switch ( $shell ) { + case 'bash': + // Check write permissions before modifying file + if ( file_exists( $target_file ) && ! is_writable( $target_file ) ) { + echo colorize( "Error: {$target_file} is not writable." . PHP_EOL ); + echo colorize( "Please check file permissions and try again." . PHP_EOL ); + exit( 1 ); + } + + $config = "\n# slic completions\n"; + $config .= "if command -v slic &> /dev/null; then\n"; + $config .= " _slic_path=\$(command -v slic 2>/dev/null)\n"; + $config .= " if [[ -L \"\$_slic_path\" ]]; then\n"; + $config .= " _slic_path=\$(realpath \"\$_slic_path\" 2>/dev/null || readlink -f \"\$_slic_path\" 2>/dev/null || readlink \"\$_slic_path\" 2>/dev/null)\n"; + $config .= " fi\n"; + $config .= " source \"\$(dirname \"\$_slic_path\")/completions/bash/slic.bash\"\n"; + $config .= "fi\n"; + + if ( file_put_contents( $target_file, $config, FILE_APPEND ) === false ) { + echo colorize( "Error: Failed to write to {$target_file}" . PHP_EOL ); + exit( 1 ); + } + + echo colorize( "✓ Bash completions installed successfully!" . PHP_EOL ); + echo colorize( "Run source {$target_file} to activate them in this session." . PHP_EOL ); + break; + + case 'zsh': + // Check write permissions before modifying file + if ( file_exists( $target_file ) && ! is_writable( $target_file ) ) { + echo colorize( "Error: {$target_file} is not writable." . PHP_EOL ); + echo colorize( "Please check file permissions and try again." . PHP_EOL ); + exit( 1 ); + } + + $config = "\n# slic completions\n"; + $config .= "if command -v slic &> /dev/null; then\n"; + $config .= " _slic_path=\$(command -v slic 2>/dev/null)\n"; + $config .= " if [[ -L \"\$_slic_path\" ]]; then\n"; + $config .= " _slic_path=\$(realpath \"\$_slic_path\" 2>/dev/null || readlink -f \"\$_slic_path\" 2>/dev/null || readlink \"\$_slic_path\" 2>/dev/null)\n"; + $config .= " fi\n"; + $config .= " fpath=(\"\$(dirname \"\$_slic_path\")/completions/zsh\" \$fpath)\n"; + $config .= " autoload -Uz compinit && compinit\n"; + $config .= "fi\n"; + + if ( file_put_contents( $target_file, $config, FILE_APPEND ) === false ) { + echo colorize( "Error: Failed to write to {$target_file}" . PHP_EOL ); + exit( 1 ); + } + + echo colorize( "✓ Zsh completions installed successfully!" . PHP_EOL ); + echo colorize( "Run source {$target_file} to activate them in this session." . PHP_EOL ); + break; + + case 'fish': + // Create completions directory if it doesn't exist + if ( ! is_dir( $target_dir ) ) { + if ( ! mkdir( $target_dir, 0755, true ) ) { + echo colorize( "Error: Failed to create directory {$target_dir}" . PHP_EOL ); + exit( 1 ); + } + } + + // Check write permissions for the target directory + if ( ! is_writable( $target_dir ) ) { + echo colorize( "Error: Directory {$target_dir} is not writable." . PHP_EOL ); + echo colorize( "Please check directory permissions and try again." . PHP_EOL ); + exit( 1 ); + } + + // Check if symlink or file already exists + if ( file_exists( $target_file ) || is_link( $target_file ) ) { + echo colorize( PHP_EOL . "A file or symlink already exists at {$target_file}" . PHP_EOL ); + $replace = ask( "Do you want to replace it?", 'yes' ); + + if ( ! $replace ) { + echo colorize( "Installation cancelled." . PHP_EOL ); + return; + } + + // Remove existing file/symlink + if ( ! unlink( $target_file ) ) { + echo colorize( "Error: Failed to remove existing file at {$target_file}" . PHP_EOL ); + exit( 1 ); + } + } + + // Create symlink + if ( ! symlink( $script_path, $target_file ) ) { + echo colorize( "Error: Failed to create symlink at {$target_file}" . PHP_EOL ); + exit( 1 ); + } + + echo colorize( "✓ Fish completions installed successfully!" . PHP_EOL ); + echo colorize( "Completions should be available immediately in new fish sessions." . PHP_EOL ); + break; + } +} + +/** + * Shows the completion script for a specific shell. + * + * @param string $shell The shell type (bash, zsh, fish). + */ +function show_completion_script( $shell ) { + $script_path = get_completion_script_path( $shell ); + + if ( empty( $script_path ) ) { + echo colorize( "Error: Completion script for {$shell} not found." . PHP_EOL ); + exit( 1 ); + } + + echo colorize( "Completion script for {$shell}:" . PHP_EOL ); + echo colorize( "File: {$script_path}" . PHP_EOL . PHP_EOL ); + echo file_get_contents( $script_path ); +} + +/** + * Clears the completion cache. + */ +function clear_completion_cache() { + // Use the same cache path logic as CompletionCache.php + $base_dir = getenv( 'SLIC_CACHE_DIR' ); + if ( false === $base_dir || empty( $base_dir ) ) { + $home = getenv( 'HOME' ); + if ( empty( $home ) ) { + echo colorize( "Error: HOME environment variable not set." . PHP_EOL ); + exit( 1 ); + } + $base_dir = $home . '/.slic/cache'; + } + $cache_dir = $base_dir . '/completions'; + + if ( ! is_dir( $cache_dir ) ) { + echo colorize( "Completion cache directory does not exist." . PHP_EOL ); + echo colorize( "Cache dir: {$cache_dir}" . PHP_EOL ); + return; + } + + echo colorize( "Clearing completion cache at: {$cache_dir}" . PHP_EOL ); + + // Remove all files in the cache directory + $files = glob( $cache_dir . '/*' ); + $count = 0; + + if ( ! empty( $files ) ) { + foreach ( $files as $file ) { + if ( is_file( $file ) ) { + unlink( $file ); + $count++; + } + } + } + + echo colorize( "✓ Cleared {$count} cached completion file(s)." . PHP_EOL ); +} + +// Main command logic +if ( $subcommand === 'cache-clear' ) { + clear_completion_cache(); + return; +} + +if ( $subcommand === 'show' ) { + $shell = $shell_arg; + + if ( empty( $shell ) ) { + $shell = detect_shell(); + + if ( empty( $shell ) ) { + echo colorize( "Error: Could not auto-detect shell." . PHP_EOL ); + echo colorize( "Please specify shell explicitly: {$cli_name} completion show [bash|zsh|fish]" . PHP_EOL ); + exit( 1 ); + } + + echo colorize( "Auto-detected shell: {$shell}" . PHP_EOL ); + } + + if ( ! in_array( $shell, [ 'bash', 'zsh', 'fish' ] ) ) { + echo colorize( "Error: Unsupported shell '{$shell}'." . PHP_EOL ); + echo colorize( "Supported shells: bash, zsh, fish" . PHP_EOL ); + exit( 1 ); + } + + show_completion_script( $shell ); + return; +} + +if ( $subcommand === 'install' ) { + $shell = $shell_arg; + + if ( empty( $shell ) ) { + $shell = detect_shell(); + + if ( empty( $shell ) ) { + echo colorize( "Error: Could not auto-detect shell." . PHP_EOL ); + echo colorize( "Please specify shell explicitly: {$cli_name} completion install [bash|zsh|fish]" . PHP_EOL ); + exit( 1 ); + } + + echo colorize( "Auto-detected shell: {$shell}" . PHP_EOL ); + } + + if ( ! in_array( $shell, [ 'bash', 'zsh', 'fish' ] ) ) { + echo colorize( "Error: Unsupported shell '{$shell}'." . PHP_EOL ); + echo colorize( "Supported shells: bash, zsh, fish" . PHP_EOL ); + exit( 1 ); + } + + install_completions( $shell ); + return; +} + +// Default: show installation instructions +$shell = detect_shell(); + +if ( empty( $shell ) ) { + echo colorize( "Could not auto-detect your shell." . PHP_EOL ); + echo colorize( PHP_EOL . "Please use one of the following commands:" . PHP_EOL ); + echo colorize( " {$cli_name} completion install bash" . PHP_EOL ); + echo colorize( " {$cli_name} completion install zsh" . PHP_EOL ); + echo colorize( " {$cli_name} completion install fish" . PHP_EOL ); + exit( 0 ); +} + +echo colorize( "Detected shell: {$shell}" . PHP_EOL ); + +if ( is_installed( $shell ) ) { + echo colorize( PHP_EOL . "✓ Completions for {$shell} are already installed!" . PHP_EOL ); + echo colorize( PHP_EOL . "If completions are not working, try:" . PHP_EOL ); + echo colorize( " 1. {$cli_name} completion cache-clear - Clear the completion cache" . PHP_EOL ); + echo colorize( " 2. Reload your shell configuration" . PHP_EOL ); +} else { + show_install_instructions( $shell ); + echo colorize( PHP_EOL . "Or run {$cli_name} completion install to install automatically." . PHP_EOL ); +} diff --git a/src/commands/config.php b/src/commands/config.php index a9f3028..40ec3c7 100644 --- a/src/commands/config.php +++ b/src/commands/config.php @@ -17,7 +17,6 @@ return; } -$using = slic_target(); setup_id(); $status = slic_realtime()( [ 'config' ] ); diff --git a/src/commands/dc.php b/src/commands/dc.php index 4b11bcb..ee65284 100644 --- a/src/commands/dc.php +++ b/src/commands/dc.php @@ -7,8 +7,7 @@ * @var string $cli_name The current name of the `slic` CLI application. */ -use function StellarWP\Slic\colorize; -use function StellarWP\Slic\slic_realtime; +namespace StellarWP\Slic; if ( $is_help ) { $help = <<< HELP diff --git a/src/commands/here.php b/src/commands/here.php index 803c425..1ffb039 100644 --- a/src/commands/here.php +++ b/src/commands/here.php @@ -49,6 +49,13 @@ $here_dir = $plugins_dir; } +// Normalize to absolute path +$here_dir = realpath( $here_dir ); +if ( false === $here_dir ) { + echo magenta( "Cannot resolve the directory path." ); + exit( 1 ); +} + $has_wp_config = dir_has_wp_config( $here_dir ); $env_values = []; @@ -98,10 +105,81 @@ $env_values['SLIC_CURRENT_PROJECT_RELATIVE_PATH'] = ''; $env_values['SLIC_CURRENT_PROJECT_SUBDIR'] = ''; -write_env_file( $run_settings_file, $env_values, true ); +// Load stacks.php functions +require_once __DIR__ . '/../stacks.php'; + +// Use the plugins directory as the stack identifier +$stack_id = $env_values['SLIC_PLUGINS_DIR']; + +// Check if stack already exists +$existing_stack = slic_stacks_get( $stack_id ); + +if ( null !== $existing_stack ) { + // Stack exists, update its state + echo colorize( PHP_EOL . "Stack already exists for this directory, updating configuration..." . PHP_EOL ); + + // Note: XDebug configuration is allocated atomically during initial registration. + // For older stacks that may not have XDebug config, it will be allocated on next register call. + + // Get the stack-specific state file + $stack_run_file = slic_stacks_get_state_file( $stack_id ); + + // Update the state file + write_env_file( $stack_run_file, $env_values, true ); + + // Reload environment + setup_slic_env( root(), true, $stack_id ); + + echo colorize( PHP_EOL . "Stack configuration updated." . PHP_EOL ); + echo colorize( "Stack ID: {$stack_id}" . PHP_EOL ); -setup_slic_env( root() ); -quietly_tear_down_stack(); + // Ensure ports are up-to-date from Docker if containers are running + if ( slic_stacks_ensure_ports( $stack_id ) ) { + $updated_stack = slic_stacks_get( $stack_id ); + echo colorize( "WordPress URL: http://localhost:{$updated_stack['ports']['wp']}" . PHP_EOL ); + echo colorize( "MySQL Port: {$updated_stack['ports']['mysql']}" . PHP_EOL ); + if ( isset( $updated_stack['ports']['redis'] ) ) { + echo colorize( "Redis Port: {$updated_stack['ports']['redis']}" . PHP_EOL ); + } + } else { + echo colorize( "Ports will be available after containers start." . PHP_EOL ); + } + echo PHP_EOL; +} else { + // New stack, register without ports (Docker will auto-assign when containers start) + echo colorize( PHP_EOL . "Creating new stack..." . PHP_EOL ); + + // Create stack state without ports - they'll be read from Docker after container start + // XDebug configuration will be allocated atomically inside slic_stacks_register() + $stack_state = [ + 'stack_id' => $stack_id, + 'project_name' => slic_stacks_get_project_name( $stack_id ), + 'state_file' => basename( slic_stacks_get_state_file( $stack_id ) ), + 'ports' => null, + 'created_at' => date( 'c' ), + 'status' => 'created', + ]; + + // Register the stack + if ( ! slic_stacks_register( $stack_id, $stack_state ) ) { + echo magenta( "Failed to register stack." ); + exit( 1 ); + } + + // Get the stack-specific state file + $stack_run_file = slic_stacks_get_state_file( $stack_id ); + + // Write the state file + write_env_file( $stack_run_file, $env_values, true ); + + // Reload environment + setup_slic_env( root(), true, $stack_id ); + + echo colorize( PHP_EOL . "Stack created successfully!" . PHP_EOL ); + echo colorize( "Stack ID: {$stack_id}" . PHP_EOL . PHP_EOL ); + echo colorize( "Note: Port assignments will be available after containers start." . PHP_EOL ); + echo colorize( "Run slic using or slic stack info to see port assignments after starting." . PHP_EOL . PHP_EOL ); + echo colorize( "To start using this stack, run: slic use " . PHP_EOL ); +} -echo colorize( PHP_EOL . "Slic plugin path set to {$here_dir}." . PHP_EOL . PHP_EOL ); -echo colorize( "If this is the first time setting this plugin path, be sure to slic init ." ); +echo colorize( PHP_EOL . "If this is the first time setting this plugin path, be sure to slic init ." ); diff --git a/src/commands/host-ip.php b/src/commands/host-ip.php index 971f6eb..0b6c24a 100644 --- a/src/commands/host-ip.php +++ b/src/commands/host-ip.php @@ -18,7 +18,7 @@ // Buffer the output to avoid printing empty blank lines that might mangle the output in quite mode. ob_start(); -$command = sprintf( 'docker exec --user "%d:%d" --workdir %s %s bash -c ". /slic-scripts/host-ip.sh"', +$command = sprintf( docker_bin() . ' exec --user "%d:%d" --workdir %s %s bash -c ". /slic-scripts/host-ip.sh"', getenv( 'SLIC_UID' ), getenv( 'SLIC_GID' ), escapeshellarg( get_project_container_path() ), diff --git a/src/commands/npm.php b/src/commands/npm.php index da35eef..b10ba3b 100644 --- a/src/commands/npm.php +++ b/src/commands/npm.php @@ -34,7 +34,7 @@ $command = 'npm ' . implode( ' ', $command ); $command = get_script_command( build_npm_script( $command ) ); - $docker_command = sprintf( 'docker exec --user "%d:%d" --workdir %s %s ' . $command, + $docker_command = sprintf( docker_bin() . ' exec --user "%d:%d" --workdir %s %s ' . $command, getenv( 'SLIC_UID' ), getenv( 'SLIC_GID' ), escapeshellarg( get_project_container_path() ), @@ -56,7 +56,7 @@ echo light_cyan( "Temporarily using " . slic_target() . PHP_EOL ); - $docker_command = sprintf( 'docker exec --user "%d:%d" --workdir %s %s ' . $command, + $docker_command = sprintf( docker_bin() . ' exec --user "%d:%d" --workdir %s %s ' . $command, getenv( 'SLIC_UID' ), getenv( 'SLIC_GID' ), escapeshellarg( get_project_container_path() ), diff --git a/src/commands/shell.php b/src/commands/shell.php index b2d097b..8d6663b 100644 --- a/src/commands/shell.php +++ b/src/commands/shell.php @@ -37,7 +37,7 @@ setup_id(); -$command = sprintf( 'docker exec -it --user "%d:%d" --workdir %s %s bash', +$command = sprintf( docker_bin() . ' exec -it --user "%d:%d" --workdir %s %s bash', getenv( 'SLIC_UID' ), getenv( 'SLIC_GID' ), escapeshellarg( get_project_container_path() ), diff --git a/src/commands/site-cli.php b/src/commands/site-cli.php index c5c28ea..4ec6963 100644 --- a/src/commands/site-cli.php +++ b/src/commands/site-cli.php @@ -102,7 +102,7 @@ // Do not run the wp-cli container as `root` to avoid a number of file mode issues, run as `www-data` instead. $user = empty( $user ) ? 'www-data' : $user; - $command = sprintf( 'docker exec -it --user "%d:%d" --workdir %s %s bash -c "wp shell"', + $command = sprintf( docker_bin() . ' exec -it --user "%d:%d" --workdir %s %s bash -c "wp shell"', getenv( 'SLIC_UID' ), getenv( 'SLIC_GID' ), escapeshellarg( get_project_container_path( 'site' ) ), diff --git a/src/commands/ssh.php b/src/commands/ssh.php index 78cebc2..c56fe7f 100644 --- a/src/commands/ssh.php +++ b/src/commands/ssh.php @@ -42,7 +42,7 @@ ensure_service_running( $service ); -$command = sprintf( 'docker exec -it --user "%d:%d" --workdir %s %s bash', +$command = sprintf( docker_bin() . ' exec -it --user "%d:%d" --workdir %s %s bash', getenv( 'SLIC_UID' ), getenv( 'SLIC_GID' ), escapeshellarg( get_project_container_path() ), diff --git a/src/commands/stack.php b/src/commands/stack.php new file mode 100644 index 0000000..815bec2 --- /dev/null +++ b/src/commands/stack.php @@ -0,0 +1,480 @@ +{$cli_name} {$subcommand} [] + + SUBCOMMANDS: + + list + Show all registered stacks with their directories, targets, ports, and status. + + stop [] + Stop a specific stack. If no stack is provided, prompts to choose one. + + stop all [-y|--yes] + Stop all registered stacks with confirmation. Base stacks will be removed along + with their associated worktrees (cascade behavior). + + info [] + Show detailed information about a stack. If no stack is provided, uses current stack. + + OPTIONS: + + -y, --yes + Skip confirmation prompt and proceed immediately. Useful for non-interactive + environments like CI pipelines and automation scripts. + + EXAMPLES: + + {$cli_name} stack list + Show all registered stacks. + + {$cli_name} stack stop /Users/Alice/project/wp-content/plugins + Stop the specified stack. + + {$cli_name} stack stop all + Stop all registered stacks (with confirmation). + + {$cli_name} stack stop all -y + Stop all registered stacks without confirmation. + + {$cli_name} stack info + Show information about the current stack. + HELP; + + echo colorize( $help ); + return; +} + +// Parse subcommand and arguments +$sub_args = args( [ 'subcommand', 'stack_path', '...' ], $args( '...' ), 0 ); +$subcommand = $sub_args( 'subcommand', 'list' ); +$stack_path = $sub_args( 'stack_path', null ); + +// Parse all remaining args for flag detection +$all_args = $sub_args( '...', [] ); +$force_yes = in_array( '-y', $all_args ) || in_array( '--yes', $all_args ); + +// Resolve stack path if provided (unless it's 'all' for stop command) +$target_stack_id = null; +if ( null !== $stack_path && ! ( $subcommand === 'stop' && $stack_path === 'all' ) ) { + $target_stack_id = slic_stacks_resolve_from_path( $stack_path ); + if ( null === $target_stack_id ) { + echo magenta( "Stack not found: {$stack_path}" . PHP_EOL ); + exit( 1 ); + } +} + +switch ( $subcommand ) { + case 'list': + command_stack_list(); + break; + + case 'stop': + if ( $stack_path === 'all' ) { + command_stack_stop_all( $force_yes ); + } else { + command_stack_stop( $target_stack_id ); + } + break; + + case 'info': + command_stack_info( $target_stack_id ); + break; + + default: + echo magenta( "Unknown subcommand: {$subcommand}" . PHP_EOL ); + echo "Run '{$cli_name} stack help' for usage information." . PHP_EOL; + exit( 1 ); +} + +/** + * Lists all registered stacks. + */ +function command_stack_list() { + slic_display_stacks_nested(); +} + +/** + * Displays stacks in a nested, hierarchical view showing base stacks and their worktrees. + */ +function slic_display_stacks_nested() { + $current_stack = slic_current_stack(); + $stacks = slic_stacks_list(); + + // Separate base stacks and worktrees + $base_stacks = []; + $worktree_map = []; + + foreach ( $stacks as $stack_id => $state ) { + if ( ! empty( $state['is_worktree'] ) ) { + $base_id = $state['base_stack_id']; + if ( ! isset( $worktree_map[ $base_id ] ) ) { + $worktree_map[ $base_id ] = []; + } + $worktree_map[ $base_id ][ $stack_id ] = $state; + } else { + $base_stacks[ $stack_id ] = $state; + } + } + + // Display + if ( empty( $base_stacks ) ) { + echo "No stacks registered.\n"; + return; + } + + foreach ( $base_stacks as $stack_id => $state ) { + // Check if containers are actually running + $is_running = slic_stacks_is_running( $stack_id ); + $status_icon = $is_running ? green( '●' ) : '○'; + $status_label = $is_running ? green( 'Running' ) : 'Stopped'; + + echo "$status_icon $status_label $stack_id" . ($stack_id === $current_stack ? ' <-current' : '') . "\n"; + echo " Target: " . ( $state['target'] ?? 'none' ) . "\n"; + echo " XDebug: {$state['xdebug_port']}\n"; + + // Show worktrees if any + if ( isset( $worktree_map[ $stack_id ] ) ) { + echo " Worktrees:\n"; + + foreach ( $worktree_map[ $stack_id ] as $wt_id => $wt_state ) { + $wt_is_running = slic_stacks_is_running( $wt_id ); + $wt_status_icon = $wt_is_running ? green( '●' ) : '○'; + $wt_status_label = $wt_is_running ? green( 'Running' ) : 'Stopped'; + echo " $wt_status_icon $wt_status_label {$wt_state['worktree_dir']} ({$wt_state['worktree_branch']})" . ($wt_id === $current_stack ? ' <-current' : '') . "\n"; + echo " XDebug: {$wt_state['xdebug_port']}\n"; + } + } + + echo "\n"; + } +} + +/** + * Stops a specific stack. + * + * @param string|null $stack_id The stack to stop, or null to prompt. + */ +function command_stack_stop( $stack_id = null ) { + $stacks = slic_stacks_list(); + + if ( empty( $stacks ) ) { + echo magenta( "No stacks registered." . PHP_EOL ); + return; + } + + // If no stack specified and multiple exist, prompt user + if ( null === $stack_id && count( $stacks ) > 1 ) { + echo colorize( "Multiple stacks exist. Please specify which stack to stop:" . PHP_EOL . PHP_EOL ); + command_stack_list(); + echo colorize( "Usage: slic stack stop " . PHP_EOL ); + return; + } + + // If no stack specified and only one exists, use it + if ( null === $stack_id ) { + $stack_id = slic_stacks_get_single_id(); + } + + if ( null === $stack_id ) { + echo magenta( "No stack to stop." . PHP_EOL ); + return; + } + + // Verify stack exists + if ( ! isset( $stacks[ $stack_id ] ) ) { + echo magenta( "Stack not found: {$stack_id}" . PHP_EOL ); + return; + } + + echo colorize( "Stopping stack: {$stack_id}" . PHP_EOL ); + + // Call the command_stop function with the specific stack + $status = command_stop( $stack_id ); + + exit( $status ); +} + +/** + * Stops all registered stacks with confirmation. + * + * This command prompts the user for confirmation before stopping all stacks. + * It calls command_stop() for each stack without immediately unregistering them, + * then unregisters only the successfully stopped stacks at the end to avoid + * modifying the registry during iteration. + * + * @param bool $force_yes Whether to skip confirmation prompts. + * @return void Exits with 0 on success, 1 on failure. + */ +function command_stack_stop_all( $force_yes = false ) { + // Validate stdin is interactive + if ( ! $force_yes && ( ! defined( 'STDIN' ) || ! stream_isatty( STDIN ) ) ) { + echo colorize( "Error: This command requires interactive input or -y flag." . PHP_EOL ); + exit( 1 ); + } + + $stacks = slic_stacks_list(); + + if ( empty( $stacks ) ) { + echo colorize( "No stacks registered." . PHP_EOL ); + return; + } + + // Count base stacks and worktrees + $base_count = 0; + $worktree_count = 0; + foreach ( $stacks as $stack_id => $stack ) { + if ( ! empty( $stack['is_worktree'] ) ) { + $worktree_count++; + } else { + $base_count++; + } + } + + $count = count( $stacks ); + $stack_word = $count === 1 ? 'stack' : 'stacks'; + + echo colorize( "The following {$count} {$stack_word} will be stopped and removed:" . PHP_EOL ); + + if ( $worktree_count > 0 ) { + $worktree_word = $worktree_count === 1 ? 'worktree' : 'worktrees'; + echo colorize( "(includes {$base_count} base stack(s) and {$worktree_count} {$worktree_word})" . PHP_EOL ); + } + + echo PHP_EOL; + + foreach ( $stacks as $stack_id => $stack ) { + $type_label = ! empty( $stack['is_worktree'] ) ? ' [worktree]' : ''; + echo colorize( " - {$stack_id} ({$stack['project_name']}){$type_label}" . PHP_EOL ); + } + + echo PHP_EOL; + + if ( ! $force_yes ) { + echo colorize( "Are you sure you want to stop and remove all stacks? (y/N): " ); + + // Read user input + $handle = fopen( "php://stdin", "r" ); + $response = trim( fgets( $handle ) ); + fclose( $handle ); + + if ( ! in_array( strtolower( $response ), [ 'y', 'yes' ], true ) ) { + echo colorize( "Operation cancelled." . PHP_EOL ); + exit( 0 ); + } + + echo PHP_EOL; + } + + $results = [ + 'success' => [], + 'failed' => [], + ]; + + // CRITICAL FIX: Store worktree data BEFORE unregistering stacks + // This also avoids redundant lookups during the stop loop + $worktree_data = []; + foreach ( $stacks as $stack_id => $stack ) { + if ( empty( $stack['is_worktree'] ) ) { + $worktree_data[ $stack_id ] = slic_stacks_get_worktrees( $stack_id ); + } + } + + foreach ( $stacks as $stack_id => $stack ) { + // Skip worktrees as they'll be stopped when we process their base stack + if ( ! empty( $stack['is_worktree'] ) ) { + continue; + } + + echo colorize( "Stopping stack: {$stack_id}" . PHP_EOL ); + + // CRITICAL FIX: For base stacks with worktrees, stop their worktree containers first + if ( isset( $worktree_data[ $stack_id ] ) && count( $worktree_data[ $stack_id ] ) > 0 ) { + $worktrees = $worktree_data[ $stack_id ]; + foreach ( array_keys( $worktrees ) as $wt_id ) { + echo colorize( " Stopping worktree: {$wt_id}" . PHP_EOL ); + $wt_status = command_stop( $wt_id, false ); + if ( $wt_status === 0 ) { + $results['success'][] = $wt_id; + } else { + $results['failed'][] = $wt_id; + echo colorize( " Warning: Failed to stop worktree {$wt_id}" . PHP_EOL ); + } + } + } + + // Stop the base stack without unregistering to avoid modifying registry during iteration + $status = command_stop( $stack_id, false ); + + if ( $status === 0 ) { + $results['success'][] = $stack_id; + } else { + $results['failed'][] = $stack_id; + } + + echo PHP_EOL; + } + + // Unregister all successfully stopped stacks with cascade to remove worktrees + // Note: State file cleanup is handled by slic_stacks_unregister() + foreach ( $results['success'] as $stack_id ) { + if ( slic_stacks_unregister( $stack_id, true ) ) { + echo colorize( "Stack unregistered: {$stack_id}" . PHP_EOL ); + } + } + + // Display summary with breakdown of base stacks and worktrees + $success_count = count( $results['success'] ); + $failed_count = count( $results['failed'] ); + + // Count base stacks and worktrees that were removed + // Note: Worktrees are already in results['success'] as they were explicitly stopped + $base_removed = 0; + $worktree_removed = 0; + foreach ( $results['success'] as $stack_id ) { + $stack_info = $stacks[ $stack_id ] ?? null; + if ( $stack_info ) { + if ( ! empty( $stack_info['is_worktree'] ) ) { + $worktree_removed++; + } else { + $base_removed++; + } + } + } + + echo colorize( PHP_EOL . "Summary:" . PHP_EOL ); + + if ( $success_count > 0 ) { + $success_word = $success_count === 1 ? 'stack' : 'stacks'; + echo colorize( " Successfully stopped and removed {$success_count} {$success_word}" . PHP_EOL ); + + if ( $base_removed > 0 ) { + $base_word = $base_removed === 1 ? 'base stack' : 'base stacks'; + echo colorize( " - {$base_removed} {$base_word}" . PHP_EOL ); + } + + if ( $worktree_removed > 0 ) { + $worktree_word = $worktree_removed === 1 ? 'worktree' : 'worktrees'; + echo colorize( " - {$worktree_removed} {$worktree_word}" . PHP_EOL ); + } + } + + if ( $failed_count > 0 ) { + $failed_word = $failed_count === 1 ? 'stack' : 'stacks'; + echo colorize( " Failed to stop {$failed_count} {$failed_word}:" . PHP_EOL ); + foreach ( $results['failed'] as $failed_stack ) { + echo colorize( " - {$failed_stack}" . PHP_EOL ); + } + exit( 1 ); + } + + exit( 0 ); +} + +/** + * Shows detailed information about a stack. + * + * @param string|null $stack_id The stack to show info for, or null for current stack. + */ +function command_stack_info( $stack_id = null ) { + // If no stack specified, use current + if ( null === $stack_id ) { + $stack_id = slic_current_stack(); + } + + if ( null === $stack_id ) { + echo magenta( "No stack specified and no current stack found." . PHP_EOL ); + echo colorize( "Run slic here to create a stack or specify a stack path." . PHP_EOL ); + return; + } + + $stack = slic_stacks_get( $stack_id ); + + if ( null === $stack ) { + echo magenta( "Stack not found: {$stack_id}" . PHP_EOL ); + return; + } + + echo colorize( "Stack Information:" . PHP_EOL . PHP_EOL ); + echo colorize( "Stack ID: {$stack_id}" . PHP_EOL ); + echo colorize( "Project Name: {$stack['project_name']}" . PHP_EOL ); + echo colorize( "State File: {$stack['state_file']}" . PHP_EOL ); + + // Show current target if set + $stack_env_file = slic_stacks_get_state_file( $stack_id ); + if ( file_exists( $stack_env_file ) ) { + echo colorize( "Environment File: {$stack_env_file}" . PHP_EOL ); + + $env_data = read_env_file( $stack_env_file ); + if ( isset( $env_data['SLIC_CURRENT_PROJECT'] ) && ! empty( $env_data['SLIC_CURRENT_PROJECT'] ) ) { + $target = $env_data['SLIC_CURRENT_PROJECT']; + if ( isset( $env_data['SLIC_CURRENT_PROJECT_SUBDIR'] ) && ! empty( $env_data['SLIC_CURRENT_PROJECT_SUBDIR'] ) ) { + $target .= '/' . $env_data['SLIC_CURRENT_PROJECT_SUBDIR']; + } + echo colorize( "Current Target: {$target}" . PHP_EOL ); + } else { + echo colorize( "Current Target: not set" . PHP_EOL ); + } + + // Show directories + if ( isset( $env_data['SLIC_WP_DIR'] ) ) { + echo colorize( "WordPress Dir: {$env_data['SLIC_WP_DIR']}" . PHP_EOL ); + } + if ( isset( $env_data['SLIC_PLUGINS_DIR'] ) ) { + echo colorize( "Plugins Dir: {$env_data['SLIC_PLUGINS_DIR']}" . PHP_EOL ); + } + if ( isset( $env_data['SLIC_THEMES_DIR'] ) ) { + echo colorize( "Themes Dir: {$env_data['SLIC_THEMES_DIR']}" . PHP_EOL ); + } + } + + slic_stacks_ensure_ports( $stack_id ); + $updated_stack = slic_stacks_get( $stack_id ); + + // Show XDebug configuration since it's always available and it's not allocated by docker. + if ( isset( $updated_stack['xdebug_port'], $updated_stack['xdebug_key'] ) ) { + echo colorize( "XDebug Port: {$updated_stack['xdebug_port']}" . PHP_EOL ); + echo colorize( "XDebug Server: {$updated_stack['xdebug_key']}" . PHP_EOL ); + } + + // Show ports - ensure they're up-to-date from Docker + echo colorize( PHP_EOL . "Ports:" . PHP_EOL ); + if ( isset( $updated_stack['ports']['wp'], $updated_stack['ports']['mysql'] ) ) { + echo colorize( " WordPress: {$updated_stack['ports']['wp']} (http://localhost:{$updated_stack['ports']['wp']})" . PHP_EOL ); + echo colorize( " MySQL: {$updated_stack['ports']['mysql']}" . PHP_EOL ); + if ( isset( $updated_stack['ports']['redis'] ) ) { + echo colorize( " Redis: {$updated_stack['ports']['redis']}" . PHP_EOL ); + } + } else { + echo colorize( " Ports will be available after containers start" . PHP_EOL ); + } + + // Show status + if ( isset( $stack['status'] ) ) { + $status_color = $stack['status'] === 'running' ? 'green' : 'yellow'; + echo colorize( PHP_EOL . "Status: <{$status_color}>{$stack['status']}" . PHP_EOL ); + } + + // Show created time + if ( isset( $stack['created_at'] ) ) { + $created = date( 'Y-m-d H:i:s', strtotime( $stack['created_at'] ) ); + echo colorize( "Created: {$created}" . PHP_EOL ); + } + + echo PHP_EOL; +} diff --git a/src/commands/use.php b/src/commands/use.php index 5542424..22d2f43 100644 --- a/src/commands/use.php +++ b/src/commands/use.php @@ -10,10 +10,15 @@ USAGE: - {$cli_name} {$subcommand} [/] + {$cli_name} {$subcommand} [[/]] + + When called without a target argument, displays an interactive selection menu. EXAMPLES: + {$cli_name} {$subcommand} + Open interactive target selection menu. + {$cli_name} {$subcommand} the-events-calendar Set the use target to the-events-calendar. @@ -28,12 +33,85 @@ $sub_args = args( [ 'target' ], $args( '...' ), 0 ); $target = $sub_args( 'target', false ); +// If no target provided, show TUI +if ( $target === false ) { + // Get all valid targets + $valid_targets = get_valid_targets( true ); // true = return as array + + if ( empty( $valid_targets ) ) { + echo colorize( "No valid targets found. Run 'slic here' first.\n" ); + return 1; + } + + // Get current target (don't require it - may not be set yet) + $current_target = null; + $using = getenv( 'SLIC_CURRENT_PROJECT' ); + $using_subdir = getenv( 'SLIC_CURRENT_PROJECT_SUBDIR' ); + if ( ! empty( $using ) ) { + $current_target = $using . ( $using_subdir ? '/' . $using_subdir : '' ); + } + + // Show TUI + require_once __DIR__ . '/../tui.php'; + $target = tui_select( + $valid_targets, + $current_target, + 'Select target (type to filter, ↑↓ to navigate, Enter to select, ESC to cancel):' + ); + + // If user cancelled, exit gracefully + if ( $target === null ) { + return 0; + } +} + +// Determine which stack to use +$stack_id = slic_current_stack_or_fail( "Cannot switch target without an active stack." ); + +// Check if current stack is a worktree - switching targets is not allowed +if ( slic_stacks_is_worktree( $stack_id ) ) { + $parsed = slic_stacks_parse_worktree_id( $stack_id ); + + if ( null === $parsed ) { + echo magenta( "Cannot switch target: invalid worktree stack format." . PHP_EOL ); + exit( 1 ); + } + + $worktree_dir = $parsed['worktree_dir']; + $stack_state = slic_stacks_get( $stack_id ); + + if ( null === $stack_state ) { + echo magenta( "Cannot switch target: worktree stack state not found." . PHP_EOL ); + exit( 1 ); + } + + $current_target = $stack_state['target'] ?? 'unknown'; + $base_stack_id = $stack_state['base_stack_id'] ?? null; + $requested_target = $target ?: '(current directory)'; + + echo magenta( "Cannot switch target in a worktree stack." . PHP_EOL ); + echo magenta( "Worktree stacks are tied to a specific target directory." . PHP_EOL ); + echo magenta( "Requested target: {$requested_target}" . PHP_EOL ); + echo magenta( "Current worktree: {$worktree_dir} (target: {$current_target})" . PHP_EOL ); + if ( null !== $base_stack_id ) { + echo PHP_EOL; + echo colorize( "To switch targets, use the base stack: slic stack switch {$base_stack_id}" . PHP_EOL ); + } + exit( 1 ); +} + +// Resolve the target (may auto-detect from current directory) $target = (string) ensure_valid_target( $target ); if ( ! empty( $target ) ) { - slic_switch_target( $target ); + slic_switch_target( $target, $stack_id ); } -echo light_cyan( "Using {$target}" . PHP_EOL ); +// Show which stack is being used +$stack = slic_stacks_get( $stack_id ); +echo light_cyan( "Using {$target} in stack: {$stack_id}" . PHP_EOL ); +if ( null !== $stack && isset( $stack['ports']['wp'] ) ) { + echo colorize( "WordPress URL: http://localhost:{$stack['ports']['wp']}" . PHP_EOL ); +} project_apply_config( get_project_local_path( $target ) ); diff --git a/src/commands/using.php b/src/commands/using.php index d4c5faa..b2351f3 100644 --- a/src/commands/using.php +++ b/src/commands/using.php @@ -23,21 +23,71 @@ return; } +// Determine current stack +$stack_id = slic_current_stack(); + +if ( null === $stack_id ) { + echo magenta( "No active stack. Run 'slic here' to create a stack." . PHP_EOL ); + return; +} + $using = slic_target(); -$target_path = get_project_local_path(); +$is_worktree = slic_stacks_is_worktree( $stack_id ); +$worktree_info = null; + +if ( $is_worktree ) { + $stack_state = slic_stacks_get( $stack_id ); + if ( $stack_state ) { + $worktree_info = [ + 'target' => $stack_state['worktree_target'] ?? null, + 'branch' => $stack_state['worktree_branch'] ?? null, + 'full_path' => $stack_state['worktree_full_path'] ?? null, + ]; + } +} + +$target_path = $is_worktree && ! empty( $worktree_info['full_path'] ) + ? $worktree_info['full_path'] + : get_project_local_path(); + if ( empty( $using ) ) { echo magenta( "Currently not using any target, commands requiring a target will fail." . PHP_EOL ); + echo light_cyan( "Stack: {$stack_id}" . PHP_EOL ); return; } -echo light_cyan( "Using {$using}" . PHP_EOL ); +if ( $is_worktree && ! empty( $worktree_info['target'] ) && ! empty( $worktree_info['branch'] ) ) { + echo light_cyan( "Using {$using} ({$worktree_info['target']} worktree for {$worktree_info['branch']})" . PHP_EOL ); +} else { + echo light_cyan( "Using {$using}" . PHP_EOL ); +} +echo light_cyan( "Stack: {$stack_id}" . PHP_EOL ); + +// Show stack ports - ensure they're up-to-date from Docker +require_once __DIR__ . '/../stacks.php'; +if ( slic_stacks_ensure_ports( $stack_id ) ) { + $stack = slic_stacks_get( $stack_id ); + echo colorize( "WordPress URL: http://localhost:{$stack['ports']['wp']}" . PHP_EOL ); + echo colorize( "MySQL Port: {$stack['ports']['mysql']}" . PHP_EOL ); + if ( isset( $stack['ports']['redis'] ) ) { + echo colorize( "Redis Port: {$stack['ports']['redis']}" . PHP_EOL ); + } +} else { + echo colorize( "Ports will be available after containers start." . PHP_EOL ); +} if ( slic_plugins_dir() !== root( '_plugins' ) ) { echo light_cyan( PHP_EOL . "Full target path: " ) . $target_path; } if ( $target_path === getcwd() ) { - echo light_cyan( PHP_EOL . "The directory you are in is the current use target." ); + $message = $is_worktree + ? "The directory you are in is the current use target of the worktree." + : "The directory you are in is the current use target."; + echo light_cyan( PHP_EOL . $message ); } else { - echo yellow( PHP_EOL . "The directory you are in is not the current use target." ); + $message = $is_worktree + ? "The directory you are in is not the current use target of the worktree." + : "The directory you are in is not the current use target."; + echo yellow( PHP_EOL . $message ); } diff --git a/src/commands/worktree.php b/src/commands/worktree.php new file mode 100644 index 0000000..ed3aa1a --- /dev/null +++ b/src/commands/worktree.php @@ -0,0 +1,113 @@ +{$cli_name} worktree [] + + SUBCOMMANDS: + + add [-y|--yes] + Create a new git worktree for the specified branch with a dedicated slic stack. + + list + List all worktrees and their associated slic stacks. + + merge [-y|--yes] + Merge worktree branch into base branch and remove the worktree stack. + Must be run from the base stack directory, not from within the worktree. + + remove [-y|--yes] + Remove a worktree and its associated slic stack. + + sync + Synchronize git worktrees with slic registry, removing stale entries. + + OPTIONS: + + -y, --yes + Skip confirmation prompt and proceed immediately. Useful for non-interactive + environments like CI pipelines and automation scripts. + + EXAMPLES: + + {$cli_name} worktree add fix/issue-123 + Create a worktree for branch 'fix/issue-123' with a dedicated stack. + + {$cli_name} worktree add feature/new-feature -y + Create a worktree without confirmation prompt. + + {$cli_name} worktree list + Show all worktrees and their stacks. + + {$cli_name} worktree remove fix/issue-123 + Remove the worktree for branch 'fix/issue-123' and its stack. + + {$cli_name} worktree merge fix/issue-123 + Merge branch 'fix/issue-123' into base branch and clean up (from base stack directory). + + {$cli_name} worktree merge fix/issue-123 -y + Merge without confirmation prompt. + + {$cli_name} worktree sync + Synchronize worktrees with slic registry. + + NOTE: + + slic provides minimal git worktree integration. Use 'git worktree' directly + for advanced operations. + HELP; + + echo colorize( $help ); + return; +} + +$worktree_subcommands = [ + 'add' => 'Create a new git worktree with dedicated stack', + 'list' => 'List worktrees and their stacks', + 'merge' => 'Merge worktree branch and remove stack', + 'remove' => 'Remove a worktree and its stack', + 'sync' => 'Synchronize git worktrees with slic registry', +]; + +// Parse subcommand from args +$sub_args = args( [ 'subcommand' ], $args( '...' ), 0 ); +$wt_subcommand = $sub_args( 'subcommand', false ); + +if (empty($wt_subcommand)) { + echo "Git Worktree Support for slic\n\n"; + echo "Available commands:\n"; + foreach ($worktree_subcommands as $cmd => $desc) { + echo " slic worktree $cmd - $desc\n"; + } + echo "\nNote: slic provides minimal git worktree integration.\n"; + echo "Use 'git worktree' directly for advanced operations.\n"; + echo "\nRun 'slic worktree help' for more information.\n"; + exit(0); +} + +$subcommand_file = __DIR__ . '/worktree/' . $wt_subcommand . '.php'; + +if (!file_exists($subcommand_file)) { + echo "Unknown worktree command: $wt_subcommand\n"; + echo "Run 'slic worktree' to see available commands.\n"; + exit(1); +} + +// Pass remaining args to subcommand (after 'worktree' and subcommand) +$_args = array_slice($args('...'), 1); + +include $subcommand_file; diff --git a/src/commands/worktree/add.php b/src/commands/worktree/add.php new file mode 100644 index 0000000..a07fe9e --- /dev/null +++ b/src/commands/worktree/add.php @@ -0,0 +1,246 @@ +' first.\n"; + exit(1); +} + +// Parse arguments ($_args is passed from worktree.php) +$branch = $_args[0] ?? null; +$force_yes = in_array('-y', $_args) || in_array('--yes', $_args); + +if (!$branch) { + echo "Usage: slic worktree add [-y|--yes]\n"; + echo "\n"; + echo "Creates a git worktree for the specified branch and registers a slic stack.\n"; + echo "\n"; + echo "Example:\n"; + echo " slic worktree add fix/issue-123\n"; + echo "\n"; + exit(1); +} + +// Validate branch name +if (!slic_worktree_validate_branch($branch)) { + echo "Error: Invalid branch name: $branch\n"; + echo "Branch names cannot contain: .. ~ ^ : ? * [ ]\n"; + exit(1); +} + +// Create worktree directory name +$worktree_dir = slic_worktree_create_dir_name($target, $branch); +if (!$worktree_dir) { + echo "Error: Cannot create valid directory name from branch: $branch\n"; + exit(1); +} + +// Determine base stack ID +$base_stack_id = slic_stacks_get_base_stack_id($stack_id); +$base_state = slic_stacks_get($base_stack_id); + +if (!$base_state) { + echo "Error: Base stack not found: $base_stack_id\n"; + exit(1); +} + +// Build paths +$worktree_stack_id = $base_stack_id . '@' . $worktree_dir; +$worktree_full_path = $base_stack_id . '/' . $worktree_dir; +$target_full_path = $base_stack_id . '/' . $target; + +// Validate target directory exists (fail-fast validation) +if (!is_dir($target_full_path)) { + echo "Error: Target directory not found: $target_full_path\n"; + exit(1); +} + +// Check if trying to register main repository as a worktree +// This prevents confusion where the main repo could be detected as a worktree +// since git worktree list includes the main repo as its first entry +$target_real = realpath($target_full_path); +$worktree_real = realpath($worktree_full_path); +if ($worktree_real !== false && $target_real === $worktree_real) { + echo "Error: Cannot register main repository as a worktree.\n"; + echo "The target and worktree paths are the same: $target_full_path\n"; + echo "Please specify a different branch name.\n"; + exit(1); +} + +// Check if worktree stack already exists +$existing_state = slic_stacks_get($worktree_stack_id); +if ($existing_state) { + echo "Worktree stack already exists!\n"; + echo " Stack ID: $worktree_stack_id\n"; + echo " Directory: $worktree_full_path\n"; + echo " Branch: {$existing_state['worktree_branch']}\n"; + echo " Status: {$existing_state['status']}\n"; + echo "\nTo work on this worktree:\n"; + echo " cd $worktree_full_path\n"; + echo " slic start\n"; + exit(0); +} + +// Check if directory already exists +$should_create_worktree = true; +if (file_exists($worktree_full_path)) { + // Directory exists - check if it's already a git worktree + $existing_worktree = slic_worktree_is_existing($target_full_path, $worktree_full_path, $branch); + + if ($existing_worktree === false) { + // Directory exists but is NOT a git worktree + echo "Error: Directory already exists but is not a git worktree: $worktree_full_path\n"; + echo "Please remove it manually or choose a different branch name.\n"; + echo "\nTo remove the directory:\n"; + echo " rm -rf " . escapeshellarg($worktree_full_path) . "\n"; + exit(1); + } + + // Check for branch mismatch + if (!empty($existing_worktree['error']) && $existing_worktree['error'] === 'branch_mismatch') { + echo "Error: Directory exists as a git worktree but for a different branch.\n"; + echo " Expected branch: $branch\n"; + echo " Actual branch: {$existing_worktree['branch']}\n"; + echo " Directory: $worktree_full_path\n"; + echo "\nOptions:\n"; + echo " 1. Use the existing branch with: slic worktree add {$existing_worktree['branch']}\n"; + echo " 2. Remove the worktree with: cd $target_full_path && git worktree remove " . escapeshellarg(basename($worktree_full_path)) . "\n"; + exit(1); + } + + // Worktree exists with correct branch - skip creation + echo "Git worktree already exists for branch '$branch' at $worktree_full_path\n"; + echo "Skipping git worktree creation, will register slic stack...\n\n"; + $should_create_worktree = false; +} + +// Confirm action +if (!$force_yes) { + echo "This will:\n"; + if ($should_create_worktree) { + echo " 1. Create git worktree at: $worktree_full_path\n"; + echo " 2. Create new branch: $branch\n"; + echo " 3. Register slic stack: $worktree_stack_id\n"; + echo " 4. Allocate unique XDebug port\n"; + } else { + echo " 1. Register existing worktree with slic\n"; + echo " 2. Stack ID: $worktree_stack_id\n"; + echo " 3. Allocate unique XDebug port\n"; + } + echo "\nContinue? [y/N] "; + + $handle = fopen('php://stdin', 'r'); + $confirmation = trim(fgets($handle)); + fclose($handle); + + if (strtolower($confirmation) !== 'y') { + echo "Cancelled.\n"; + exit(0); + } +} + +// Get the base branch before creating the worktree +$base_branch = trim(shell_exec("git -C " . escapeshellarg($target_full_path) . " rev-parse --abbrev-ref HEAD 2>/dev/null")); + +// Execute git worktree add (only if needed) +if ($should_create_worktree) { + echo "\nCreating git worktree...\n"; + + $original_cwd = getcwd(); + chdir($base_stack_id); // Change to parent plugins/themes directory + + $escaped_dir = escapeshellarg($worktree_dir); + $escaped_branch = escapeshellarg($branch); + $cmd = "git -C " . escapeshellarg($target) . " worktree add " . escapeshellarg("../$worktree_dir") . " -b $escaped_branch 2>&1"; + + exec($cmd, $output, $return_code); + chdir($original_cwd); + + if ($return_code !== 0) { + echo "Error: Failed to create git worktree.\n"; + echo "Command: $cmd\n"; + echo "Output:\n"; + echo implode("\n", $output) . "\n"; + echo "\nCommon causes:\n"; + echo " - Branch '$branch' already exists\n"; + echo " - Uncommitted changes in target repository\n"; + echo " - Invalid git repository\n"; + exit(1); + } + + echo "Git worktree created successfully.\n"; +} else { + echo "\nUsing existing git worktree...\n"; +} + +// Register stack with slic +echo "Registering slic stack...\n"; + +// Determine project type +$project_type = get_project_type(); + +// Create worktree stack state +$worktree_state = [ + 'stack_id' => $worktree_stack_id, + 'is_worktree' => true, + 'base_stack_id' => $base_stack_id, + 'base_branch' => $base_branch, + 'worktree_target' => $target, + 'worktree_dir' => $worktree_dir, + 'worktree_branch' => $branch, + 'worktree_full_path' => $worktree_full_path, + 'project_name' => slic_stacks_get_project_name($worktree_stack_id), + 'state_file' => slic_stacks_get_state_file($worktree_stack_id), + 'xdebug_port' => slic_stacks_xdebug_port($worktree_stack_id), + 'xdebug_key' => slic_stacks_xdebug_server_name($worktree_stack_id), + 'target' => $target, // Inherit target from base + 'status' => 'created', +]; + +if (!slic_stacks_register($worktree_stack_id, $worktree_state)) { + echo "Error: Failed to register stack.\n"; + echo "The git worktree was created but slic stack registration failed.\n"; + echo "You may need to manually remove the worktree:\n"; + echo " cd $target_full_path\n"; + echo " git worktree remove ../$worktree_dir\n"; + exit(1); +} + +// Write stack state file +$state_env_vars = [ + 'SLIC_HERE_DIR' => $base_stack_id, + 'SLIC_WP_DIR' => getenv('SLIC_WP_DIR'), + 'SLIC_PLUGINS_DIR' => getenv('SLIC_PLUGINS_DIR'), + 'SLIC_THEMES_DIR' => getenv('SLIC_THEMES_DIR'), + 'SLIC_CURRENT_PROJECT' => $target, +]; + +write_env_file($worktree_state['state_file'], $state_env_vars); + +if ($should_create_worktree) { + echo "Worktree stack registered successfully!\n"; +} else { + echo "Existing worktree registered successfully!\n"; +} +echo "\n"; +echo "Stack Details:\n"; +echo " Stack ID: $worktree_stack_id\n"; +echo " Directory: $worktree_full_path\n"; +echo " Branch: $branch\n"; +echo " XDebug Port: {$worktree_state['xdebug_port']}\n"; +echo " Project Name: {$worktree_state['project_name']}\n"; +echo "\n"; +echo "To start working:\n"; +echo " cd $worktree_full_path\n"; +echo " slic start\n"; diff --git a/src/commands/worktree/list.php b/src/commands/worktree/list.php new file mode 100644 index 0000000..a4a8562 --- /dev/null +++ b/src/commands/worktree/list.php @@ -0,0 +1,67 @@ +' first.\n"; + exit(1); +} + +// Get base stack +$base_stack_id = slic_stacks_get_base_stack_id($stack_id); +$base_state = slic_stacks_get($base_stack_id); + +echo "Worktree Status for Target: $target\n"; +echo str_repeat('=', 60) . "\n\n"; + +echo "Base Stack: $base_stack_id\n"; +echo " Target: $target\n"; +echo " Status: {$base_state['status']}\n"; +echo " XDebug Port: {$base_state['xdebug_port']}\n"; +echo "\n"; + +// Show git worktrees +$target_path = $base_stack_id . '/' . $target; +if (is_dir($target_path . '/.git')) { + echo "Git Worktrees (from git worktree list):\n"; + echo str_repeat('-', 60) . "\n"; + $original_cwd = getcwd(); + chdir($target_path); + passthru('git worktree list'); + chdir($original_cwd); + echo "\n"; +} + +// Show slic worktree stacks +$worktrees = slic_stacks_get_worktrees($base_stack_id); + +if (empty($worktrees)) { + echo "No slic worktree stacks registered.\n"; + echo "\nTo create a worktree:\n"; + echo " slic worktree add \n"; + exit(0); +} + +echo "Slic Worktree Stacks:\n"; +echo str_repeat('-', 60) . "\n"; + +foreach ($worktrees as $wt_stack_id => $wt_state) { + echo " {$wt_state['worktree_dir']}/\n"; + echo " Branch: {$wt_state['worktree_branch']}\n"; + echo " Path: {$wt_state['worktree_full_path']}\n"; + echo " Status: {$wt_state['status']}\n"; + echo " XDebug Port: {$wt_state['xdebug_port']}\n"; + echo " Project: {$wt_state['project_name']}\n"; + echo "\n"; +} + +echo "Total worktrees: " . count($worktrees) . "\n"; diff --git a/src/commands/worktree/merge.php b/src/commands/worktree/merge.php new file mode 100644 index 0000000..c1ec67b --- /dev/null +++ b/src/commands/worktree/merge.php @@ -0,0 +1,336 @@ + [-y|--yes]\n"; + echo "\n"; + echo "Merges a worktree branch into its base branch and removes the worktree stack.\n"; + echo "\n"; + echo "Arguments:\n"; + echo " The branch name of the worktree to merge (required)\n"; + echo "\n"; + echo "Options:\n"; + echo " -y, --yes Skip confirmation prompts\n"; + echo "\n"; + echo "Example:\n"; + echo " slic worktree merge fix/issue-123\n"; + echo "\n"; + echo "Note: This command must be run from the base stack directory,\n"; + echo " not from within the worktree directory being merged.\n"; + exit(1); +} + +// Get current stack state to find the target +$stack_state = slic_stacks_get($stack_id); +$target = $stack_state['target'] ?? null; + +if (!$target) { + echo "Error: No target set. Run 'slic use ' first.\n"; + exit(1); +} + +// Build worktree stack ID from branch name (like remove.php does) +$base_stack_id = slic_stacks_get_base_stack_id($stack_id); +$worktree_dir = slic_worktree_create_dir_name($target, $branch); + +if (!$worktree_dir) { + echo "Error: Invalid branch name: $branch\n"; + exit(1); +} + +$worktree_stack_id = $base_stack_id . '@' . $worktree_dir; +$worktree_full_path = $base_stack_id . '/' . $worktree_dir; +$target_path = $base_stack_id . '/' . $target; + +// Check if running from within the worktree directory being merged +$current_cwd = getcwd(); +$resolved_worktree_path = realpath($worktree_full_path); +$resolved_cwd = realpath($current_cwd); + +if ($resolved_worktree_path && $resolved_cwd) { + // Check if cwd is the worktree directory or a subdirectory of it + if ($resolved_cwd === $resolved_worktree_path || strpos($resolved_cwd, $resolved_worktree_path . '/') === 0) { + echo "Error: Cannot run 'slic worktree merge $branch' from within the worktree directory for '$branch'.\n"; + echo " The worktree directory will be removed by git during the merge process.\n"; + echo "\n"; + echo "Please change to the base stack directory and run:\n"; + echo " cd $base_stack_id && slic worktree merge $branch\n"; + exit(1); + } +} + +// Check if branch exists in git (warn but continue) +if (is_dir($target_path)) { + $branch_check_cmd = "git -C " . escapeshellarg($target_path) . " rev-parse --verify " . escapeshellarg($branch) . " 2>/dev/null"; + exec($branch_check_cmd, $branch_output, $branch_code); + if ($branch_code !== 0) { + echo "Warning: Branch '$branch' does not exist in git.\n"; + } +} + +// Check if stack exists (warn but continue) +$wt_state = slic_stacks_get($worktree_stack_id); +$is_registered = ($wt_state !== null); + +if (!$is_registered) { + echo "Warning: Stack for branch '$branch' is not registered with slic.\n"; +} + +// Extract worktree info from state if available, otherwise use computed values +$worktree_branch = $wt_state['worktree_branch'] ?? $branch; +$worktree_target = $wt_state['worktree_target'] ?? $target; + +// Build paths +$target_path = $base_stack_id . '/' . $worktree_target; + +// Validate target directory exists +if (!is_dir($target_path)) { + echo "Error: Target repository not found: $target_path\n"; + echo "The base repository may have been moved or deleted.\n"; + exit(1); +} + +// Check if git worktree still exists +$git_worktree_exists = false; +$worktree_already_merged = false; + +$original_cwd = getcwd(); +chdir($target_path); + +exec('git worktree list --porcelain 2>&1', $worktree_list_output, $worktree_list_code); +chdir($original_cwd); + +if ($worktree_list_code === 0) { + $current_worktree = null; + foreach ($worktree_list_output as $line) { + if (strpos($line, 'worktree ') === 0) { + $current_worktree = substr($line, 9); + } + if ($current_worktree && $worktree_full_path && realpath($current_worktree) === realpath($worktree_full_path)) { + $git_worktree_exists = true; + break; + } + } +} + +// Get base branch (the branch that was active when worktree was created) +// First check if stored in worktree state (preferred), otherwise fall back to current HEAD +$base_branch = $wt_state['base_branch'] ?? null; + +if (empty($base_branch)) { + // Fallback: use current HEAD in target repo + // Note: This may be incorrect if someone switched branches in the main repo + chdir($target_path); + $base_branch = trim(shell_exec('git rev-parse --abbrev-ref HEAD 2>/dev/null')); + chdir($original_cwd); +} + +if (empty($base_branch)) { + echo "Error: Could not determine base branch in target repository.\n"; + exit(1); +} + +// If worktree doesn't exist, inform user and proceed with stack cleanup only +if (!$git_worktree_exists) { + echo "Notice: Git worktree no longer exists at: $worktree_full_path\n"; + echo "The worktree may have been manually removed or already merged.\n\n"; + + if (!$force_yes) { + echo "This will clean up the slic stack registration:\n"; + echo " 1. Stop Docker containers (if running)\n"; + echo " 2. Unregister slic stack: $worktree_stack_id\n"; + echo "\nContinue? [y/N] "; + + $handle = fopen('php://stdin', 'r'); + $confirmation = trim(fgets($handle)); + fclose($handle); + + if (strtolower($confirmation) !== 'y') { + echo "Cancelled.\n"; + exit(0); + } + } + + // Stop containers (only if registered) + if ($is_registered) { + echo "\nStopping Docker containers...\n"; + $docker_compose = docker_compose(['stop'], $worktree_stack_id); + $docker_compose(); + + // Unregister stack + echo "Unregistering stack...\n"; + if (!slic_stacks_unregister($worktree_stack_id)) { + echo "Warning: Failed to unregister stack from registry.\n"; + } + } + + echo "\nWorktree stack cleaned up successfully.\n"; + echo "Note: The worktree branch '$worktree_branch' was not deleted (worktree not found).\n"; + echo "You may want to delete it manually if no longer needed:\n"; + echo " cd $target_path && git branch -d " . escapeshellarg($worktree_branch) . "\n"; + exit(0); +} + +// Check for uncommitted changes in the worktree +if (is_dir($worktree_full_path)) { + chdir($worktree_full_path); + exec('git status --porcelain 2>&1', $status_output, $status_code); + chdir($original_cwd); + + if ($status_code === 0 && !empty($status_output)) { + echo "Error: Worktree has uncommitted changes:\n"; + foreach ($status_output as $line) { + echo " $line\n"; + } + echo "\nPlease commit or stash your changes before merging.\n"; + exit(1); + } +} + +// Confirmation prompt +if (!$force_yes) { + echo "This will:\n"; + echo " 1. Checkout base branch '$base_branch' in target repository\n"; + echo " 2. Merge worktree branch '$worktree_branch' into '$base_branch'\n"; + echo " 3. Remove git worktree directory: $worktree_full_path\n"; + echo " 4. Delete local worktree branch: $worktree_branch\n"; + echo " 5. Stop Docker containers (if running)\n"; + echo " 6. Unregister slic stack: $worktree_stack_id\n"; + echo "\nContinue? [y/N] "; + + $handle = fopen('php://stdin', 'r'); + $confirmation = trim(fgets($handle)); + fclose($handle); + + if (strtolower($confirmation) !== 'y') { + echo "Cancelled.\n"; + exit(0); + } +} + +// Step 1: Checkout base branch in target repo +echo "\nChecking out base branch '$base_branch'...\n"; + +chdir($target_path); +$cmd = "git checkout " . escapeshellarg($base_branch) . " 2>&1"; +exec($cmd, $checkout_output, $checkout_code); +chdir($original_cwd); + +if ($checkout_code !== 0) { + echo "Error: Failed to checkout base branch '$base_branch'.\n"; + echo "Output: " . implode("\n", $checkout_output) . "\n"; + echo "\nPlease resolve any issues in the target repository and try again.\n"; + exit(1); +} + +// Step 2: Merge worktree branch +echo "Merging branch '$worktree_branch' into '$base_branch'...\n"; + +chdir($target_path); +$cmd = "git merge " . escapeshellarg($worktree_branch) . " 2>&1"; +exec($cmd, $merge_output, $merge_code); +chdir($original_cwd); + +if ($merge_code !== 0) { + echo "Error: Git merge failed.\n"; + echo "Output:\n"; + foreach ($merge_output as $line) { + echo " $line\n"; + } + echo "\nThe merge encountered conflicts or errors.\n"; + echo "Please resolve the merge issue manually:\n"; + echo " cd $target_path\n"; + echo " # Resolve conflicts\n"; + echo " git add .\n"; + echo " git commit\n"; + echo "\nAfter resolving, you can complete the merge with:\n"; + echo " cd $base_stack_id && slic worktree merge $branch\n"; + echo "\nTo stop without merging:\n"; + echo " slic stack stop --stack $worktree_stack_id\n"; + exit(1); +} + +echo "Merge successful.\n"; + +// Step 3: Remove git worktree +echo "Removing git worktree...\n"; + +// Use git -C to avoid chdir issues (original_cwd may be inside the worktree being removed) +$cmd = "git -C " . escapeshellarg($target_path) . " worktree remove " . escapeshellarg('../' . $worktree_dir) . " --force 2>&1"; +exec($cmd, $wt_output, $wt_code); + +if ($wt_code !== 0) { + echo "Warning: Failed to remove git worktree via git command.\n"; + echo "Output: " . implode("\n", $wt_output) . "\n"; + + // Try manual directory removal as fallback + if (is_dir($worktree_full_path)) { + echo "Attempting manual directory removal...\n"; + exec("rm -rf " . escapeshellarg($worktree_full_path), $rm_output, $rm_code); + + if ($rm_code !== 0) { + echo "Warning: Could not remove directory.\n"; + echo "You may need to manually clean up:\n"; + echo " cd $target_path\n"; + echo " git worktree prune\n"; + echo " rm -rf $worktree_full_path\n"; + } + } +} else { + echo "Git worktree removed successfully.\n"; +} + +// Step 4: Delete local worktree branch +echo "Deleting local branch '$worktree_branch'...\n"; + +// Use git -C to avoid chdir issues (original_cwd may no longer exist after worktree removal) +$cmd = "git -C " . escapeshellarg($target_path) . " branch -d " . escapeshellarg($worktree_branch) . " 2>&1"; +exec($cmd, $branch_output, $branch_code); + +if ($branch_code !== 0) { + echo "Warning: Failed to delete branch '$worktree_branch'.\n"; + echo "Output: " . implode("\n", $branch_output) . "\n"; + echo "You may need to delete it manually:\n"; + echo " cd $target_path && git branch -D " . escapeshellarg($worktree_branch) . "\n"; +} else { + echo "Branch deleted successfully.\n"; +} + +// Step 5: Stop Docker containers (only if registered) +if ($is_registered) { + echo "Stopping Docker containers...\n"; + $docker_compose = docker_compose(['stop'], $worktree_stack_id); + $docker_compose(); + + // Step 6: Unregister stack + echo "Unregistering stack...\n"; + if (!slic_stacks_unregister($worktree_stack_id)) { + echo "Warning: Failed to unregister stack from registry.\n"; + } +} + +echo "\nWorktree merged and removed successfully!\n"; +echo " Branch '$worktree_branch' has been merged into '$base_branch'\n"; +if ($is_registered) { + echo " Stack '$worktree_stack_id' has been removed\n"; +} + diff --git a/src/commands/worktree/remove.php b/src/commands/worktree/remove.php new file mode 100644 index 0000000..9fb61e1 --- /dev/null +++ b/src/commands/worktree/remove.php @@ -0,0 +1,202 @@ + [-y|--yes]\n"; + echo "\n"; + echo "Removes a git worktree and its slic stack.\n"; + echo "\n"; + echo "WARNING: This will delete the worktree directory.\n"; + echo " Ensure all changes are committed and pushed.\n"; + echo "\n"; + exit(1); +} + +$stack_state = slic_stacks_get($stack_id); +$target = $stack_state['target'] ?? null; + +if (!$target) { + echo "Error: No target set. Run 'slic use ' first.\n"; + exit(1); +} + +// Build worktree stack ID +$base_stack_id = slic_stacks_get_base_stack_id($stack_id); +$worktree_dir = slic_worktree_create_dir_name($target, $branch); + +if (!$worktree_dir) { + echo "Error: Invalid branch name: $branch\n"; + exit(1); +} + +$worktree_stack_id = $base_stack_id . '@' . $worktree_dir; +$worktree_full_path = $base_stack_id . '/' . $worktree_dir; + +// Check if stack exists +$wt_state = slic_stacks_get($worktree_stack_id); +$is_registered = ($wt_state !== null); + +// Check if git worktree exists +$git_worktree_exists = false; +$target_path = $base_stack_id . '/' . $target; + +if (is_dir($target_path)) { + $original_cwd = getcwd(); + chdir($target_path); + + exec('git worktree list --porcelain 2>&1', $worktree_list_output, $worktree_list_code); + chdir($original_cwd); + + if ($worktree_list_code === 0) { + // Parse worktree list output to find our worktree + $current_worktree = null; + foreach ($worktree_list_output as $line) { + if (strpos($line, 'worktree ') === 0) { + $current_worktree = substr($line, 9); // Remove "worktree " prefix + } + if ($current_worktree && realpath($current_worktree) === realpath($worktree_full_path)) { + $git_worktree_exists = true; + break; + } + } + } +} + +// Handle different scenarios +if (!$is_registered && !$git_worktree_exists) { + echo "Error: Worktree not found.\n"; + echo " - Not registered with slic: $worktree_stack_id\n"; + echo " - Not found as git worktree: $worktree_full_path\n"; + echo "\nRun 'slic worktree list' to see available worktrees.\n"; + exit(1); +} + +if (!$is_registered && $git_worktree_exists) { + echo "WARNING: This worktree is NOT registered with slic.\n"; + echo " - Git worktree exists: $worktree_full_path\n"; + echo " - But no slic stack found: $worktree_stack_id\n"; + echo "\n"; + echo "This can happen if you ran 'slic stop' on the worktree.\n"; + echo "Only the git worktree will be removed.\n"; + echo "\n"; +} + +// Pre-removal validation: Check for uncommitted changes +if (is_dir($worktree_full_path)) { + $original_cwd = getcwd(); + chdir($worktree_full_path); + + exec('git status --porcelain 2>&1', $status_output, $status_code); + chdir($original_cwd); + + if ($status_code === 0 && !empty($status_output)) { + echo "WARNING: Worktree has uncommitted changes:\n"; + foreach ($status_output as $line) { + echo " $line\n"; + } + echo "\n"; + + if (!$force_yes) { + echo "These changes will be LOST. Continue? [y/N] "; + $handle = fopen('php://stdin', 'r'); + $confirmation = trim(fgets($handle)); + fclose($handle); + + if (strtolower($confirmation) !== 'y') { + echo "Cancelled. Commit or stash your changes first.\n"; + exit(0); + } + } + } +} + +// Final confirmation +if (!$force_yes) { + echo "This will:\n"; + if ($is_registered) { + echo " 1. Stop Docker containers (if running)\n"; + echo " 2. Remove git worktree directory: $worktree_full_path\n"; + echo " 3. Unregister slic stack: $worktree_stack_id\n"; + echo " 4. Delete stack state file\n"; + } else { + echo " 1. Remove git worktree directory: $worktree_full_path\n"; + } + echo "\nThis operation cannot be undone. Continue? [y/N] "; + + $handle = fopen('php://stdin', 'r'); + $confirmation = trim(fgets($handle)); + fclose($handle); + + if (strtolower($confirmation) !== 'y') { + echo "Cancelled.\n"; + exit(0); + } +} + +// Step 1: Stop containers (only if registered) +if ($is_registered) { + echo "\nStopping Docker containers...\n"; + $docker_compose = docker_compose(['stop'], $worktree_stack_id); + $docker_compose(); +} + +// Step 2: Remove git worktree +echo "Removing git worktree...\n"; + +$original_cwd = getcwd(); +$target_path = $base_stack_id . '/' . $target; + +if (is_dir($target_path)) { + chdir($target_path); + + $escaped_dir = escapeshellarg('../' . $worktree_dir); + $cmd = "git worktree remove $escaped_dir --force 2>&1"; + exec($cmd, $output, $return_code); + + chdir($original_cwd); + + if ($return_code !== 0) { + echo "Warning: Failed to remove git worktree via git command.\n"; + echo "Output: " . implode("\n", $output) . "\n"; + + // Try manual directory removal as fallback + if (is_dir($worktree_full_path)) { + echo "Attempting manual directory removal...\n"; + exec("rm -rf " . escapeshellarg($worktree_full_path), $rm_output, $rm_code); + + if ($rm_code !== 0) { + echo "Error: Could not remove directory.\n"; + echo "You may need to manually clean up:\n"; + echo " cd $target_path\n"; + echo " git worktree prune\n"; + echo " rm -rf $worktree_full_path\n"; + } + } + } else { + echo "Git worktree removed successfully.\n"; + } +} else { + echo "Warning: Target directory not found, skipping git worktree removal.\n"; +} + +// Step 3: Unregister stack (only if registered) +// Note: State file cleanup is handled by slic_stacks_unregister() +if ($is_registered) { + echo "Unregistering stack...\n"; + if (!slic_stacks_unregister($worktree_stack_id)) { + echo "Warning: Failed to unregister stack from registry.\n"; + } +} + +echo "\nWorktree removed successfully.\n"; diff --git a/src/commands/worktree/sync.php b/src/commands/worktree/sync.php new file mode 100644 index 0000000..721fc97 --- /dev/null +++ b/src/commands/worktree/sync.php @@ -0,0 +1,401 @@ + $state) { + // Only check worktree stacks + if (empty($state['is_worktree'])) { + continue; + } + + $worktree_path = $state['worktree_full_path'] ?? null; + + if (!$worktree_path) { + // Missing worktree_full_path - corrupted entry + $orphaned_slic_stacks[] = [ + 'stack_id' => $check_stack_id, + 'reason' => 'Missing worktree_full_path field', + 'state' => $state, + ]; + $issues_found = true; + continue; + } + + // Check if directory exists + if (!is_dir($worktree_path)) { + $orphaned_slic_stacks[] = [ + 'stack_id' => $check_stack_id, + 'path' => $worktree_path, + 'reason' => 'Directory not found', + 'state' => $state, + ]; + $issues_found = true; + continue; + } + + // Check if it's actually a git worktree (has .git file, not directory) + $git_file = $worktree_path . '/.git'; + if (!is_file($git_file)) { + $orphaned_slic_stacks[] = [ + 'stack_id' => $check_stack_id, + 'path' => $worktree_path, + 'reason' => 'Directory exists but not a git worktree (.git is not a file)', + 'state' => $state, + ]; + $issues_found = true; + continue; + } + + // Verify .git file contains gitdir reference + $git_content = @file_get_contents($git_file); + if ($git_content === false || strpos($git_content, 'gitdir:') !== 0) { + $orphaned_slic_stacks[] = [ + 'stack_id' => $check_stack_id, + 'path' => $worktree_path, + 'reason' => 'Invalid .git file (not a worktree reference)', + 'state' => $state, + ]; + $issues_found = true; + continue; + } +} + +if (!empty($orphaned_slic_stacks)) { + echo "Found " . count($orphaned_slic_stacks) . " orphaned slic stack(s):\n\n"; + foreach ($orphaned_slic_stacks as $idx => $orphan) { + echo " [" . ($idx + 1) . "] Stack ID: {$orphan['stack_id']}\n"; + if (isset($orphan['path'])) { + echo " Expected path: {$orphan['path']}\n"; + } + echo " Reason: {$orphan['reason']}\n"; + if (!empty($orphan['state']['worktree_branch'])) { + echo " Branch: {$orphan['state']['worktree_branch']}\n"; + } + echo "\n"; + } +} else { + echo "No orphaned slic stacks found.\n\n"; +} + +// ============================================================================ +// Phase 2: Find orphaned git worktrees (exist but not registered in slic) +// ============================================================================ + +echo "Phase 2: Checking for unregistered git worktrees...\n"; +echo str_repeat('-', 60) . "\n"; + +// Use `git worktree list --porcelain` to get accurate worktree information +if ($target) { + $target_path = $base_stack_id . '/' . $target; + + if (is_dir($target_path . '/.git')) { + // Execute git worktree list --porcelain + $original_cwd = getcwd(); + chdir($target_path); + + exec('git worktree list --porcelain 2>&1', $worktree_output, $worktree_return); + chdir($original_cwd); + + if ($worktree_return === 0) { + // Parse porcelain output + $git_worktrees = []; + $current_worktree = []; + + foreach ($worktree_output as $line) { + if (empty($line)) { + // Empty line marks end of entry + if (!empty($current_worktree)) { + $git_worktrees[] = $current_worktree; + $current_worktree = []; + } + continue; + } + + // Parse key-value pairs + if (strpos($line, 'worktree ') === 0) { + $current_worktree['path'] = trim(substr($line, 9)); + } elseif (strpos($line, 'HEAD ') === 0) { + $current_worktree['head'] = trim(substr($line, 5)); + } elseif (strpos($line, 'branch ') === 0) { + $current_worktree['branch'] = trim(substr($line, 7)); + } elseif (strpos($line, 'bare') === 0) { + $current_worktree['bare'] = true; + } elseif (strpos($line, 'detached') === 0) { + $current_worktree['detached'] = true; + } elseif (strpos($line, 'locked ') === 0) { + $current_worktree['locked'] = trim(substr($line, 7)); + } elseif (strpos($line, 'prunable ') === 0) { + $current_worktree['prunable'] = trim(substr($line, 9)); + } + } + + // Add last entry if exists + if (!empty($current_worktree)) { + $git_worktrees[] = $current_worktree; + } + + // Check each git worktree against registered slic stacks + foreach ($git_worktrees as $git_wt) { + $wt_path = $git_wt['path'] ?? null; + + if (!$wt_path) { + continue; + } + + // Skip the main repository (not a worktree) + if (realpath($wt_path) === realpath($target_path)) { + continue; + } + + // Check if this worktree is in the base stack directory + $parent_dir = dirname($wt_path); + if (realpath($parent_dir) !== realpath($base_stack_id)) { + // Worktree is not in the expected location, skip it + continue; + } + + $dir_name = basename($wt_path); + + // Build expected stack ID + $expected_stack_id = $base_stack_id . '@' . $dir_name; + + // Check if registered in slic + if (!isset($stacks[$expected_stack_id])) { + // Check if directory name matches target prefix pattern + if ($target && strpos($dir_name, $target . '-') === 0) { + $orphaned_git_worktrees[] = [ + 'path' => $wt_path, + 'dir_name' => $dir_name, + 'branch' => $git_wt['branch'] ?? 'unknown', + 'expected_stack_id' => $expected_stack_id, + 'head' => $git_wt['head'] ?? null, + 'detached' => !empty($git_wt['detached']), + 'locked' => $git_wt['locked'] ?? null, + 'prunable' => $git_wt['prunable'] ?? null, + ]; + $issues_found = true; + } + } + } + + if (!empty($orphaned_git_worktrees)) { + echo "Found " . count($orphaned_git_worktrees) . " unregistered git worktree(s):\n\n"; + foreach ($orphaned_git_worktrees as $idx => $orphan) { + echo " [" . ($idx + 1) . "] Directory: {$orphan['path']}\n"; + echo " Branch: {$orphan['branch']}\n"; + if ($orphan['detached']) { + echo " Status: Detached HEAD\n"; + } + if ($orphan['locked']) { + echo " Locked: {$orphan['locked']}\n"; + } + if ($orphan['prunable']) { + echo " Prunable: {$orphan['prunable']}\n"; + } + echo " Suggested stack ID: {$orphan['expected_stack_id']}\n"; + echo "\n"; + } + } else { + echo "No unregistered git worktrees found.\n\n"; + } + } else { + echo "Warning: Could not list git worktrees.\n"; + echo "Command output: " . implode("\n", $worktree_output) . "\n\n"; + } + } else { + echo "Target directory is not a git repository: $target_path\n\n"; + } +} + +// ============================================================================ +// Phase 3: Summary and cleanup options +// ============================================================================ + +echo str_repeat('=', 60) . "\n"; +echo "Summary:\n"; +echo str_repeat('=', 60) . "\n"; +echo "Orphaned slic stacks: " . count($orphaned_slic_stacks) . "\n"; +echo "Unregistered git worktrees: " . count($orphaned_git_worktrees) . "\n"; + +if (!$issues_found) { + echo "\nAll worktrees are in sync. No issues found.\n"; + exit(0); +} + +// ============================================================================ +// Phase 4: Cleanup (if requested) +// ============================================================================ + +if (!$auto_clean && !$dry_run) { + echo "\nTo clean up orphaned entries, run:\n"; + echo " slic worktree sync --clean\n"; + echo "\nTo see what would be cleaned without making changes, run:\n"; + echo " slic worktree sync --dry-run\n"; + exit(0); +} + +echo "\n"; + +if ($dry_run) { + echo "DRY RUN MODE - No changes will be made\n"; + echo str_repeat('=', 60) . "\n\n"; +} + +// Handle orphaned slic stacks cleanup +if (!empty($orphaned_slic_stacks)) { + echo "Cleanup plan for orphaned slic stacks:\n"; + echo str_repeat('-', 60) . "\n"; + + foreach ($orphaned_slic_stacks as $idx => $orphan) { + echo "[" . ($idx + 1) . "] {$orphan['stack_id']}\n"; + echo " Action: Unregister from slic registry\n"; + if (!empty($orphan['state']['state_file'])) { + echo " Also remove state file: {$orphan['state']['state_file']}\n"; + } + echo "\n"; + } + + if ($dry_run) { + echo "Dry-run: Would unregister " . count($orphaned_slic_stacks) . " stack(s)\n\n"; + } else { + // Confirm cleanup + if (!$force_yes) { + echo "Proceed with cleaning up " . count($orphaned_slic_stacks) . " orphaned stack(s)? [y/N] "; + $handle = fopen('php://stdin', 'r'); + $confirmation = trim(fgets($handle)); + fclose($handle); + + if (strtolower($confirmation) !== 'y') { + echo "Skipped cleaning orphaned slic stacks.\n\n"; + goto handle_git_orphans; + } + } + + // Perform cleanup + $cleaned_count = 0; + $failed_count = 0; + + foreach ($orphaned_slic_stacks as $orphan) { + echo "Cleaning {$orphan['stack_id']}... "; + + // Unregister from registry (state file cleanup is handled by slic_stacks_unregister) + if (slic_stacks_unregister($orphan['stack_id'])) { + echo "OK\n"; + $cleaned_count++; + } else { + echo "FAILED\n"; + $failed_count++; + } + } + + echo "\nCleaned $cleaned_count stack(s)"; + if ($failed_count > 0) { + echo ", $failed_count failed"; + } + echo ".\n\n"; + } +} + +// Handle unregistered git worktrees +handle_git_orphans: + +if (!empty($orphaned_git_worktrees)) { + echo "Suggestions for unregistered git worktrees:\n"; + echo str_repeat('-', 60) . "\n"; + + foreach ($orphaned_git_worktrees as $idx => $orphan) { + echo "[" . ($idx + 1) . "] {$orphan['path']}\n"; + + if ($orphan['prunable']) { + echo " Status: Prunable (worktree directory is missing)\n"; + echo " Suggested action: Run 'git worktree prune' to clean up\n"; + } else { + echo " Suggested action: Register manually by running commands in this directory:\n"; + echo " cd {$orphan['path']}\n"; + echo " slic here # This will detect and offer to register the worktree\n"; + } + echo "\n"; + } + + echo "Note: Unregistered git worktrees are not automatically cleaned up.\n"; + echo "Use 'git worktree remove ' or 'git worktree prune' as needed.\n\n"; +} + +if ($dry_run) { + echo "\nDRY RUN COMPLETE - No changes were made.\n"; + echo "Run without --dry-run to apply changes.\n"; +} + +exit(0); diff --git a/src/completions/CompletionCache.php b/src/completions/CompletionCache.php new file mode 100644 index 0000000..55671a1 --- /dev/null +++ b/src/completions/CompletionCache.php @@ -0,0 +1,205 @@ +cache_dir = $base_dir . '/completions'; + + // Ensure cache directory exists + // If mkdir fails, operations will gracefully degrade (cache reads/writes will fail silently) + if ( ! file_exists( $this->cache_dir ) ) { + $created = @mkdir( $this->cache_dir, 0755, true ); + // Verify directory is actually usable after creation attempt + if ( ! $created || ! is_dir( $this->cache_dir ) || ! is_writable( $this->cache_dir ) ) { + // Cache will be non-functional, but completions will still work without caching + $this->cache_dir = ''; + } + } + } + + /** + * Gets a value from the cache if it exists and is not expired. + * + * @param string $key The cache key. + * + * @return mixed|null The cached data or null if not found or expired. + */ + public function get( string $key ) { + $cache_file = $this->get_cache_file( $key ); + + if ( empty( $cache_file ) || ! file_exists( $cache_file ) ) { + return null; + } + + // Use file locking to prevent reading partially written data + $fp = @fopen( $cache_file, 'r' ); + if ( false === $fp ) { + return null; + } + + $locked = flock( $fp, LOCK_SH ); + if ( ! $locked ) { + fclose( $fp ); + return null; + } + + $contents = fread( $fp, filesize( $cache_file ) ); + flock( $fp, LOCK_UN ); + fclose( $fp ); + + if ( false === $contents ) { + return null; + } + + $data = json_decode( $contents, true ); + if ( ! is_array( $data ) || ! isset( $data['created_at'], $data['ttl'], $data['data'] ) ) { + // Invalid cache format, delete it + @unlink( $cache_file ); + return null; + } + + // Check if expired + $created_at = (int) $data['created_at']; + $ttl = (int) $data['ttl']; + $now = time(); + + if ( ( $created_at + $ttl ) < $now ) { + // Expired, delete it + @unlink( $cache_file ); + return null; + } + + return $data['data']; + } + + /** + * Sets a value in the cache with the specified TTL. + * + * @param string $key The cache key. + * @param mixed $data The data to cache. + * @param int $ttl Time-to-live in seconds. + * + * @return bool True on success, false on failure. + */ + public function set( string $key, $data, int $ttl ): bool { + $cache_file = $this->get_cache_file( $key ); + + $cache_data = [ + 'created_at' => time(), + 'ttl' => $ttl, + 'data' => $data, + ]; + + $json = json_encode( $cache_data, JSON_PRETTY_PRINT ); + if ( false === $json ) { + return false; + } + + // Use file locking to prevent race conditions + $fp = @fopen( $cache_file, 'w' ); + if ( false === $fp ) { + return false; + } + + $locked = flock( $fp, LOCK_EX ); + if ( $locked ) { + fwrite( $fp, $json ); + flock( $fp, LOCK_UN ); + } + fclose( $fp ); + + return $locked; + } + + /** + * Clears a specific cache entry. + * + * @param string $key The cache key to clear. + * + * @return bool True if the file was deleted, false otherwise. + */ + public function clear( string $key ): bool { + $cache_file = $this->get_cache_file( $key ); + + if ( file_exists( $cache_file ) ) { + return @unlink( $cache_file ); + } + + return false; + } + + /** + * Clears all cache entries. + * + * @return bool True on success, false on failure. + */ + public function clear_all(): bool { + if ( ! is_dir( $this->cache_dir ) ) { + return true; + } + + $files = glob( $this->cache_dir . '/*.json' ); + if ( false === $files ) { + return false; + } + + $success = true; + foreach ( $files as $file ) { + if ( is_file( $file ) ) { + if ( ! @unlink( $file ) ) { + $success = false; + } + } + } + + return $success; + } + + /** + * Gets the cache file path for a given key. + * + * @param string $key The cache key. + * + * @return string The full path to the cache file, or empty string if cache is disabled. + */ + private function get_cache_file( string $key ): string { + if ( empty( $this->cache_dir ) ) { + return ''; + } + // Sanitize the key to prevent directory traversal + $safe_key = preg_replace( '/[^a-zA-Z0-9_-]/', '_', $key ); + return $this->cache_dir . '/' . $safe_key . '.json'; + } +} diff --git a/src/completions/complete.php b/src/completions/complete.php new file mode 100755 index 0000000..dc2270d --- /dev/null +++ b/src/completions/complete.php @@ -0,0 +1,189 @@ +#!/usr/bin/env php +" --point= --words="" --cword= + * php complete.php --shell=fish --words="" --current="" + * + * @package StellarWP\Slic\Completions + */ + +namespace StellarWP\Slic\Completions; + +// Suppress all output except completions +error_reporting( 0 ); +ini_set( 'display_errors', '0' ); + +// Parse command line arguments +$options = getopt( '', [ 'line:', 'point:', 'words:', 'cword:', 'shell:', 'current:' ] ); + +if ( empty( $options ) ) { + exit( 0 ); +} + +// Load required files +require_once __DIR__ . '/CompletionCache.php'; +require_once __DIR__ . '/completers/CommandCompleter.php'; +require_once __DIR__ . '/completers/UseCompleter.php'; +require_once __DIR__ . '/completers/StackCompleter.php'; +require_once __DIR__ . '/completers/ToggleCompleter.php'; +require_once __DIR__ . '/completers/XdebugCompleter.php'; +require_once __DIR__ . '/completers/WorktreeCompleter.php'; +require_once __DIR__ . '/completers/PhpVersionCompleter.php'; + +// Parse the command line +$shell = $options['shell'] ?? 'bash'; +$words = []; +$current_word_index = 0; +$current_word = ''; + +if ( $shell === 'fish' ) { + // Fish provides words as a string (already-completed tokens) and current token being typed + if ( isset( $options['words'] ) ) { + $words = explode( ' ', $options['words'] ); + } + $current_word = $options['current'] ?? ''; + // If current word is empty (trailing space), we're completing the next word + // Otherwise, we're completing the last word in the list + $current_word_index = empty( $current_word ) ? count( $words ) : count( $words ) - 1; +} else { + // Bash/Zsh format + if ( isset( $options['words'] ) ) { + $words = explode( ' ', $options['words'] ); + } + $current_word_index = isset( $options['cword'] ) ? (int) $options['cword'] : 0; + $current_word = $words[ $current_word_index ] ?? ''; +} + +// Ensure we have at least the command name +if ( empty( $words ) || count( $words ) < 1 ) { + exit( 0 ); +} + +// Remove 'slic' from the beginning if present +if ( isset( $words[0] ) && ( $words[0] === 'slic' || basename( $words[0] ) === 'slic' ) ) { + array_shift( $words ); + $current_word_index--; +} + +// Adjust for negative index (shouldn't happen, but be safe) +if ( $current_word_index < 0 ) { + $current_word_index = 0; +} + +// Update current_word after index adjustment +$current_word = $words[ $current_word_index ] ?? ''; + +// Initialize cache +$cache = new CompletionCache(); + +// Determine what to complete +$completions = []; + +// Check if we're completing a --stack option value +// Handle both --stack= and --stack formats +$completing_stack_option = false; + +// Check if current word starts with --stack= (for "--stack=" format) +if ( strpos( $current_word, '--stack=' ) === 0 ) { + $completing_stack_option = true; +} + +// Check if previous word is --stack (for "--stack " format) +if ( ! $completing_stack_option && $current_word_index > 0 ) { + $prev_word = $words[ $current_word_index - 1 ] ?? ''; + if ( $prev_word === '--stack' ) { + $completing_stack_option = true; + } +} + +if ( $completing_stack_option ) { + // Complete with stack paths + $completer = new StackCompleter( $cache ); + // Get stack IDs from the StackCompleter + require_once dirname( dirname( __DIR__ ) ) . '/src/stacks.php'; + $stacks = \StellarWP\Slic\slic_stacks_list(); + if ( is_array( $stacks ) ) { + $stack_paths = array_keys( $stacks ); + // If current word has --stack= prefix, we need to preserve it + if ( strpos( $current_word, '--stack=' ) === 0 ) { + $prefix = substr( $current_word, strlen( '--stack=' ) ); + $stack_paths = array_filter( $stack_paths, function( $path ) use ( $prefix ) { + return empty( $prefix ) || strpos( $path, $prefix ) === 0; + } ); + // Prepend --stack= to each path + $completions = array_map( function( $path ) { + return '--stack=' . $path; + }, $stack_paths ); + } else { + // For "--stack " format, just return the paths + $prefix = $current_word; + $completions = array_filter( $stack_paths, function( $path ) use ( $prefix ) { + return empty( $prefix ) || strpos( $path, $prefix ) === 0; + } ); + } + } +} elseif ( $current_word_index === 0 || empty( $words ) ) { + // Complete top-level commands + // CommandCompleter already filters by prefix, so no need for additional filtering + $completer = new CommandCompleter( $cache ); + $completions = $completer->get_completions( $current_word ); +} else { + // Delegate to specific completers based on the command + $command = $words[0] ?? ''; + + // Use command-specific completers + switch ( $command ) { + case 'use': + $completer = new UseCompleter( $cache ); + $completions = $completer->get_completions( $current_word ); + break; + + case 'stack': + $completer = new StackCompleter( $cache ); + $completions = $completer->get_completions( $words, $current_word ); + break; + + case 'airplane-mode': + case 'build-prompt': + case 'build-subdir': + case 'cache': + case 'debug': + case 'interactive': + $completer = new ToggleCompleter( $cache ); + $completions = $completer->get_completions( $current_word ); + break; + + case 'xdebug': + $completer = new XdebugCompleter( $cache ); + $completions = $completer->get_completions( $words, $current_word ); + break; + + case 'worktree': + $completer = new WorktreeCompleter( $cache ); + $completions = $completer->get_completions( $words, $current_word ); + break; + + case 'php-version': + $completer = new PhpVersionCompleter( $cache ); + $completions = $completer->get_completions( $words, $current_word ); + break; + + default: + // No specific completer for this command + $completions = []; + break; + } +} + +// Output completions +if ( ! empty( $completions ) ) { + echo implode( ' ', $completions ); +} + +exit( 0 ); diff --git a/src/completions/completers/CommandCompleter.php b/src/completions/completers/CommandCompleter.php new file mode 100644 index 0000000..1c22cf6 --- /dev/null +++ b/src/completions/completers/CommandCompleter.php @@ -0,0 +1,129 @@ +cache = $cache; + } + + /** + * Gets command completions, optionally filtered by a prefix. + * + * @param string $prefix Optional prefix to filter commands. + * + * @return array List of matching commands. + */ + public function get_completions( string $prefix = '' ): array { + $commands = $this->get_commands(); + + if ( empty( $prefix ) ) { + return $commands; + } + + return array_filter( $commands, function( $command ) use ( $prefix ) { + return strpos( $command, $prefix ) === 0; + } ); + } + + /** + * Gets the list of all available commands. + * + * Checks cache first, then scans the commands directory if needed. + * + * @return array List of command names. + */ + private function get_commands(): array { + // Try cache first + $cached = $this->cache->get( self::CACHE_KEY ); + if ( null !== $cached && is_array( $cached ) ) { + return $cached; + } + + // Scan commands directory + $commands = $this->scan_commands(); + + // Cache the results + $this->cache->set( self::CACHE_KEY, $commands, self::CACHE_TTL ); + + return $commands; + } + + /** + * Scans the commands directory to find all available commands. + * + * @return array List of command names. + */ + private function scan_commands(): array { + // Determine slic root directory + // This script is in src/completions/completers/, so go up 3 levels + $slic_root = dirname( dirname( dirname( __DIR__ ) ) ); + $commands_dir = $slic_root . '/src/commands'; + + if ( ! is_dir( $commands_dir ) ) { + return []; + } + + $files = glob( $commands_dir . '/*.php' ); + if ( false === $files ) { + return []; + } + + $commands = []; + foreach ( $files as $file ) { + $command = basename( $file, '.php' ); + // Exclude any files that shouldn't be commands (none currently, but good practice) + if ( ! empty( $command ) && $command[0] !== '.' ) { + $commands[] = $command; + } + } + + // Add command aliases (from slic.php) + // The 'wp' command is an alias for 'cli', but both are valid for completion + $aliases = [ 'wp' ]; + $commands = array_merge( $commands, $aliases ); + + sort( $commands, SORT_NATURAL ); + + return $commands; + } +} diff --git a/src/completions/completers/PhpVersionCompleter.php b/src/completions/completers/PhpVersionCompleter.php new file mode 100644 index 0000000..cbe210e --- /dev/null +++ b/src/completions/completers/PhpVersionCompleter.php @@ -0,0 +1,161 @@ +cache = $cache; + } + + /** + * Gets completions for the 'php-version' command. + * + * @param array $words The command words so far. + * @param string $current_word The current word being completed. + * + * @return array List of matching completions. + */ + public function get_completions( array $words, string $current_word ): array { + // Remove 'php-version' from the beginning if present + if ( isset( $words[0] ) && $words[0] === 'php-version' ) { + array_shift( $words ); + } + + // If we're completing the first argument after 'php-version' + // This happens when: no words left, or the first word is empty (trailing space), or we're still typing the first word + if ( count( $words ) === 0 || empty( $words[0] ) || ( count( $words ) === 1 && $words[0] === $current_word ) ) { + // Return subcommands + return $this->filter_by_prefix( $this->get_subcommands(), $current_word ); + } + + // If we have a subcommand, check if it's 'set' + $subcommand = $words[0] ?? ''; + + if ( $subcommand === 'set' ) { + // For 'set', suggest PHP versions if we're completing the second argument + if ( count( $words ) === 1 || ( count( $words ) === 2 && $words[1] === $current_word ) ) { + return $this->filter_by_prefix( $this->get_versions(), $current_word ); + } + } + + // No further completions needed + return []; + } + + /** + * Gets the list of subcommands (cached). + * + * @return array List of subcommands. + */ + private function get_subcommands(): array { + // Try cache first + $cached = $this->cache->get( self::SUBCOMMANDS_CACHE_KEY ); + if ( null !== $cached && is_array( $cached ) ) { + return $cached; + } + + // Cache the subcommands + $this->cache->set( self::SUBCOMMANDS_CACHE_KEY, self::SUBCOMMANDS, self::CACHE_TTL ); + + return self::SUBCOMMANDS; + } + + /** + * Gets the list of PHP versions (cached). + * + * @return array List of PHP versions. + */ + private function get_versions(): array { + // Try cache first + $cached = $this->cache->get( self::VERSIONS_CACHE_KEY ); + if ( null !== $cached && is_array( $cached ) ) { + return $cached; + } + + // Cache the versions + $this->cache->set( self::VERSIONS_CACHE_KEY, self::PHP_VERSIONS, self::CACHE_TTL ); + + return self::PHP_VERSIONS; + } + + /** + * Filters an array of items by a prefix. + * + * @param array $items The items to filter. + * @param string $prefix The prefix to match. + * + * @return array Filtered items. + */ + private function filter_by_prefix( array $items, string $prefix ): array { + if ( empty( $prefix ) ) { + return $items; + } + + return array_filter( $items, function( $item ) use ( $prefix ) { + return strpos( $item, $prefix ) === 0; + } ); + } +} diff --git a/src/completions/completers/StackCompleter.php b/src/completions/completers/StackCompleter.php new file mode 100644 index 0000000..da96364 --- /dev/null +++ b/src/completions/completers/StackCompleter.php @@ -0,0 +1,162 @@ +cache = $cache; + } + + /** + * Gets completions for the 'stack' command. + * + * @param array $words The command words so far. + * @param string $current_word The current word being completed. + * + * @return array List of matching completions. + */ + public function get_completions( array $words, string $current_word ): array { + // Remove 'stack' from the beginning if present + if ( isset( $words[0] ) && $words[0] === 'stack' ) { + array_shift( $words ); + } + + // If we're completing the first argument after 'stack' + // This happens when: no words left, or the first word is empty (trailing space), or we're still typing the first word + if ( count( $words ) === 0 || empty( $words[0] ) || ( count( $words ) === 1 && $words[0] === $current_word ) ) { + // Return subcommands + return $this->filter_by_prefix( self::SUBCOMMANDS, $current_word ); + } + + // If we have a subcommand, check if it needs stack IDs + $subcommand = $words[0] ?? ''; + + if ( in_array( $subcommand, [ 'stop', 'info' ], true ) ) { + // Return stack IDs (plus 'all' for stop command) + $completions = $this->get_stack_ids(); + + // Add 'all' option for 'stop' command + if ( $subcommand === 'stop' ) { + $completions[] = 'all'; + } + + return $this->filter_by_prefix( $completions, $current_word ); + } + + // No further completions needed + return []; + } + + /** + * Gets the list of registered stack IDs. + * + * Checks cache first, then queries the stack registry if needed. + * + * @return array List of stack IDs. + */ + private function get_stack_ids(): array { + // Try cache first + $cached = $this->cache->get( self::CACHE_KEY ); + if ( null !== $cached && is_array( $cached ) ) { + return $cached; + } + + // Query the stack registry + $stack_ids = $this->scan_stacks(); + + // Cache the results + $this->cache->set( self::CACHE_KEY, $stack_ids, self::CACHE_TTL ); + + return $stack_ids; + } + + /** + * Scans the stack registry for all registered stacks. + * + * @return array List of stack IDs (directory paths). + */ + private function scan_stacks(): array { + // Load slic stack functions + $slic_root = dirname( dirname( dirname( __DIR__ ) ) ); + require_once $slic_root . '/src/stacks.php'; + + $stacks = \StellarWP\Slic\slic_stacks_list(); + if ( ! is_array( $stacks ) ) { + return []; + } + + // Stack IDs are the keys of the registry + $stack_ids = array_keys( $stacks ); + + sort( $stack_ids, SORT_NATURAL ); + + return $stack_ids; + } + + /** + * Filters an array of items by a prefix. + * + * @param array $items The items to filter. + * @param string $prefix The prefix to match. + * + * @return array Filtered items. + */ + private function filter_by_prefix( array $items, string $prefix ): array { + if ( empty( $prefix ) ) { + return $items; + } + + return array_filter( $items, function( $item ) use ( $prefix ) { + return strpos( $item, $prefix ) === 0; + } ); + } +} diff --git a/src/completions/completers/ToggleCompleter.php b/src/completions/completers/ToggleCompleter.php new file mode 100644 index 0000000..8d65893 --- /dev/null +++ b/src/completions/completers/ToggleCompleter.php @@ -0,0 +1,72 @@ +cache = $cache; + } + + /** + * Gets toggle option completions, optionally filtered by a prefix. + * + * @param string $prefix Optional prefix to filter options. + * + * @return array List of matching options. + */ + public function get_completions( string $prefix = '' ): array { + if ( empty( $prefix ) ) { + return self::OPTIONS; + } + + return array_filter( self::OPTIONS, function( $option ) use ( $prefix ) { + return strpos( $option, $prefix ) === 0; + } ); + } +} diff --git a/src/completions/completers/UseCompleter.php b/src/completions/completers/UseCompleter.php new file mode 100644 index 0000000..37a4382 --- /dev/null +++ b/src/completions/completers/UseCompleter.php @@ -0,0 +1,210 @@ +cache = $cache; + } + + /** + * Gets target completions, optionally filtered by a prefix. + * + * @param string $prefix Optional prefix to filter targets. + * + * @return array List of matching targets and subdirectories. + */ + public function get_completions( string $prefix = '' ): array { + $targets = $this->get_targets(); + + if ( empty( $prefix ) ) { + return $targets; + } + + return array_filter( $targets, function( $target ) use ( $prefix ) { + return strpos( $target, $prefix ) === 0; + } ); + } + + /** + * Gets the list of all available targets and their subdirectories. + * + * Checks cache first, then scans the filesystem if needed. + * + * @return array List of target names and subdirectories. + */ + private function get_targets(): array { + // Try cache first + $cached = $this->cache->get( self::CACHE_KEY ); + if ( null !== $cached && is_array( $cached ) ) { + return $cached; + } + + // Scan filesystem for targets + $targets = $this->scan_targets(); + + // Cache the results + $this->cache->set( self::CACHE_KEY, $targets, self::CACHE_TTL ); + + return $targets; + } + + /** + * Scans the filesystem to find all available targets and subdirectories. + * + * This includes: + * - All valid targets from get_valid_targets() (plugins, themes, site) + * - Subdirectories within plugins that contain composer.json or standard test directories + * + * @return array List of target names and subdirectories in "plugin/subdir" format. + */ + private function scan_targets(): array { + // Load slic functions + $slic_root = dirname( dirname( dirname( __DIR__ ) ) ); + require_once $slic_root . '/src/slic.php'; + + // Get base targets (plugins, themes, site) + $base_targets = \StellarWP\Slic\get_valid_targets( true ); + if ( ! is_array( $base_targets ) ) { + $base_targets = []; + } + + $all_targets = $base_targets; + + // For each plugin target, scan for valid subdirectories + foreach ( $base_targets as $target ) { + // Skip 'site' and themes + if ( $target === 'site' || strpos( $target, 'theme' ) !== false ) { + continue; + } + + $subdirs = $this->scan_plugin_subdirectories( $target ); + foreach ( $subdirs as $subdir ) { + $all_targets[] = $target . '/' . $subdir; + } + } + + sort( $all_targets, SORT_NATURAL ); + + return $all_targets; + } + + /** + * Scans a plugin directory for valid subdirectories. + * + * A subdirectory is considered valid if it contains: + * - A composer.json file, OR + * - Standard test directories (tests/, tests/wpunit/, etc.) + * + * @param string $plugin_name The plugin name/directory. + * + * @return array List of valid subdirectory names. + */ + private function scan_plugin_subdirectories( string $plugin_name ): array { + // Get the plugins directory using slic's plugin directory resolution + $plugin_dir = \StellarWP\Slic\slic_plugins_dir( $plugin_name ); + + if ( ! is_dir( $plugin_dir ) ) { + return []; + } + + $subdirs = []; + $entries = @scandir( $plugin_dir ); + + if ( false === $entries ) { + return []; + } + + foreach ( $entries as $entry ) { + // Skip hidden files, . and .. + if ( $entry[0] === '.' ) { + continue; + } + + $full_path = $plugin_dir . '/' . $entry; + + // Must be a directory + if ( ! is_dir( $full_path ) ) { + continue; + } + + // Check if it's a valid subdirectory + if ( $this->is_valid_subdirectory( $full_path ) ) { + $subdirs[] = $entry; + } + } + + sort( $subdirs, SORT_NATURAL ); + + return $subdirs; + } + + /** + * Checks if a subdirectory is valid for the 'use' command. + * + * A subdirectory is valid if it contains: + * - A composer.json file, OR + * - A tests/ directory with standard test structure + * + * @param string $dir_path Full path to the subdirectory. + * + * @return bool True if valid, false otherwise. + */ + private function is_valid_subdirectory( string $dir_path ): bool { + // Check for composer.json + if ( file_exists( $dir_path . '/composer.json' ) ) { + return true; + } + + // Check for tests directory + if ( is_dir( $dir_path . '/tests' ) ) { + return true; + } + + // Check for codeception.yml (alternative test setup) + if ( file_exists( $dir_path . '/codeception.yml' ) ) { + return true; + } + + return false; + } +} diff --git a/src/completions/completers/WorktreeCompleter.php b/src/completions/completers/WorktreeCompleter.php new file mode 100644 index 0000000..e231dbb --- /dev/null +++ b/src/completions/completers/WorktreeCompleter.php @@ -0,0 +1,189 @@ +cache = $cache; + } + + /** + * Gets completions for the 'worktree' command. + * + * @param array $words The command words so far. + * @param string $current_word The current word being completed. + * + * @return array List of matching completions. + */ + public function get_completions( array $words, string $current_word ): array { + // Remove 'worktree' from the beginning if present + if ( isset( $words[0] ) && $words[0] === 'worktree' ) { + array_shift( $words ); + } + + // If we're completing the first argument after 'worktree' + // This happens when: no words left, or the first word is empty (trailing space), or we're still typing the first word + if ( count( $words ) === 0 || empty( $words[0] ) || ( count( $words ) === 1 && $words[0] === $current_word ) ) { + // Return subcommands + return $this->filter_by_prefix( self::SUBCOMMANDS, $current_word ); + } + + // If we have a subcommand, check if it needs branch names + $subcommand = $words[0] ?? ''; + + if ( in_array( $subcommand, self::BRANCH_SUBCOMMANDS, true ) ) { + // Return branch names + $branches = $this->get_branches(); + return $this->filter_by_prefix( $branches, $current_word ); + } + + // No further completions needed + return []; + } + + /** + * Gets the list of git branches. + * + * Checks cache first, then queries git if needed. + * + * @return array List of branch names. + */ + private function get_branches(): array { + // Try cache first + $cached = $this->cache->get( self::CACHE_KEY ); + if ( null !== $cached && is_array( $cached ) ) { + return $cached; + } + + // Query git for branches + $branches = $this->scan_branches(); + + // Cache the results + $this->cache->set( self::CACHE_KEY, $branches, self::CACHE_TTL ); + + return $branches; + } + + /** + * Scans git for all available branches (local and remote). + * + * @return array List of branch names. + */ + private function scan_branches(): array { + // Get all branches including remote branches + $output = []; + $return_var = 0; + exec( 'git branch -a 2>/dev/null', $output, $return_var ); + + if ( $return_var !== 0 || empty( $output ) ) { + // Not in a git repository or git command failed + return []; + } + + $branches = []; + foreach ( $output as $line ) { + $line = trim( $line ); + + // Skip empty lines + if ( empty( $line ) ) { + continue; + } + + // Remove the current branch indicator (*) + $line = ltrim( $line, '* ' ); + + // Skip HEAD references + if ( strpos( $line, 'HEAD ->' ) !== false ) { + continue; + } + + // Clean up remote branch names (remotes/origin/branch-name -> branch-name) + if ( strpos( $line, 'remotes/' ) === 0 ) { + $line = preg_replace( '#^remotes/[^/]+/(.+)$#', '$1', $line ); + } + + // Add if not already in the list + if ( ! in_array( $line, $branches, true ) ) { + $branches[] = $line; + } + } + + sort( $branches, SORT_NATURAL ); + + return $branches; + } + + /** + * Filters an array of items by a prefix. + * + * @param array $items The items to filter. + * @param string $prefix The prefix to match. + * + * @return array Filtered items. + */ + private function filter_by_prefix( array $items, string $prefix ): array { + if ( empty( $prefix ) ) { + return $items; + } + + return array_filter( $items, function( $item ) use ( $prefix ) { + return strpos( $item, $prefix ) === 0; + } ); + } +} diff --git a/src/completions/completers/XdebugCompleter.php b/src/completions/completers/XdebugCompleter.php new file mode 100644 index 0000000..8bb01a2 --- /dev/null +++ b/src/completions/completers/XdebugCompleter.php @@ -0,0 +1,107 @@ +cache = $cache; + } + + /** + * Gets xdebug option completions. + * + * @param array $words The command words so far. + * @param string $current_word The current word being completed. + * + * @return array List of matching options. + */ + public function get_completions( array $words, string $current_word ): array { + // Remove 'xdebug' from the beginning if present + if ( isset( $words[0] ) && $words[0] === 'xdebug' ) { + array_shift( $words ); + } + + // If we're completing the first argument after 'xdebug' + // This happens when: no words left, or the first word is empty (trailing space), or we're still typing the first word + if ( count( $words ) === 0 || empty( $words[0] ) || ( count( $words ) === 1 && $words[0] === $current_word ) ) { + // Return all options + return $this->filter_by_prefix( self::OPTIONS, $current_word ); + } + + // If we have an option that requires a value, no further completion + $option = $words[0] ?? ''; + if ( in_array( $option, self::VALUE_OPTIONS, true ) ) { + return []; + } + + // No further completions needed + return []; + } + + /** + * Filters an array of items by a prefix. + * + * @param array $items The items to filter. + * @param string $prefix The prefix to match. + * + * @return array Filtered items. + */ + private function filter_by_prefix( array $items, string $prefix ): array { + if ( empty( $prefix ) ) { + return $items; + } + + return array_filter( $items, function( $item ) use ( $prefix ) { + return strpos( $item, $prefix ) === 0; + } ); + } +} diff --git a/src/docker.php b/src/docker.php index 579c590..98ce589 100644 --- a/src/docker.php +++ b/src/docker.php @@ -31,14 +31,21 @@ function os() { * Curried docker compose wrapper. * * @param array $options A list of options to initialize the wrapper. + * @param string|null $stack_id The stack to run docker compose for. If null, uses current stack. * * @return \Closure A closure to actually call docker compose with more arguments. */ -function docker_compose( array $options = [] ) { +function docker_compose( array $options = [], $stack_id = null ) { setup_id(); $is_ci = is_ci(); + // Add project name for stack isolation + $project_name = get_stack_project_name( $stack_id ); + if ( null !== $project_name ) { + $options = array_merge( [ '-p', $project_name ], $options ); + } + $host_ip = false; if ( ! $is_ci && 'Linux' === os() ) { $linux_overrides = stack( '-linux-override' ); @@ -51,7 +58,9 @@ function docker_compose( array $options = [] ) { $dc_bin = docker_compose_bin(); - return static function ( array $command = [] ) use ( $dc_bin, $options, $host_ip, $is_ci ) { + $stack_id = slic_current_stack(); + + return static function ( array $command = [] ) use ( $dc_bin, $options, $host_ip, $is_ci, $stack_id ) { $command = $dc_bin . ' ' . implode( ' ', $options ) . ' ' . implode( ' ', $command ); if ( ! empty( $host_ip ) ) { @@ -60,6 +69,12 @@ function docker_compose( array $options = [] ) { $command = 'XDH=' . $xdebug_remote_host . ' ' . $command; } + if ( ! empty( $stack_id ) ) { + foreach ( xdebug_get_env_vars( $stack_id ) as $key => $value ) { + $command = "{$key}={$value} " . $command; + } + } + if ( ! empty( $is_ci ) ) { // Disable XDebug in CI context to speed up the builds. $command = 'XDE=0 XDEBUG_DISABLE=1 ' . $command; @@ -150,10 +165,11 @@ function stack( $postfix = '' ) { * Typically, this would be slic-stack.yml for plugin-only setups, but if running in site mode, it adds slic-stack.site.yml. * * @param bool $filenames_only Return only the files part of the stack, without including option flags. + * @param string|null $stack_id The stack identifier to use. If null, uses current stack. * * @return string[] Array of docker compose arguments indicating the files that should be used to initialize the stack. */ -function slic_stack_array( $filenames_only = false ) { +function slic_stack_array( $filenames_only = false, $stack_id = null ) { $file_prefix = $filenames_only ? '' : '-f'; $quote = $filenames_only ? '' : '"'; $base_stack = stack(); @@ -164,6 +180,26 @@ function slic_stack_array( $filenames_only = false ) { $stack_array[] = $quote . stack( '.site' ) . $quote; } + // Load stacks.php if needed for worktree detection + if ( ! function_exists( 'slic_stacks_is_worktree' ) ) { + require_once __DIR__ . '/stacks.php'; + } + + // Add worktree override if current stack is a worktree + if ( ! function_exists( 'slic_current_stack' ) ) { + require_once __DIR__ . '/slic.php'; + } + + // Use provided stack_id or fall back to current stack + if ( null === $stack_id ) { + $stack_id = slic_current_stack(); + } + + if ( $stack_id && slic_stacks_is_worktree( $stack_id ) ) { + $stack_array[] = $file_prefix; + $stack_array[] = $quote . stack( '.worktree' ) . $quote; + } + return array_values( array_filter( $stack_array ) ); } @@ -172,14 +208,21 @@ function slic_stack_array( $filenames_only = false ) { * * @param array $options A list of options to initialize the wrapper. * @param bool $is_realtime Whether the command should be run in real time (true) or passively (false). + * @param string|null $stack_id The stack to run docker compose for. If null, uses current stack. * * @return \Closure A closure that will run the process in real time and return the process exit status. */ -function docker_compose_process( array $options = [], $is_realtime = true ) { +function docker_compose_process( array $options = [], $is_realtime = true, $stack_id = null ) { setup_id(); $is_ci = is_ci(); + // Add project name for stack isolation + $project_name = get_stack_project_name( $stack_id ); + if ( null !== $project_name ) { + $options = array_merge( [ '-p', $project_name ], $options ); + } + $host_ip = false; if ( ! $is_ci && 'Linux' === os() ) { $linux_override = stack( '-linux-override' ); @@ -190,7 +233,7 @@ function docker_compose_process( array $options = [], $is_realtime = true ) { $host_ip = host_ip( 'Linux' ); } - return static function ( array $command = [], $prefix = null ) use ( $options, $host_ip, $is_ci, $is_realtime ) { + return static function ( array $command = [], $prefix = null ) use ( $options, $host_ip, $is_ci, $is_realtime, $stack_id ) { if ( $is_ci || ! is_interactive() ) { $no_tty_map = [ 'exec' => [ '-T' ], @@ -214,6 +257,12 @@ function docker_compose_process( array $options = [], $is_realtime = true ) { $command = 'XDH=' . $xdebug_remote_host . ' ' . $command; } + if ( ! empty( $stack_id ) ) { + foreach ( xdebug_get_env_vars( $stack_id ) as $key => $value ) { + $command = "{$key}={$value} " . $command; + } + } + if ( ! empty( $is_ci ) ) { // Disable XDebug in CI context to speed up the builds. $command = 'XDE=0 ' . $command; @@ -229,22 +278,35 @@ function docker_compose_process( array $options = [], $is_realtime = true ) { * This approach is used for commands that can be run in a parallel or forked process without interactivity. * * @param array $options A list of options to initialize the wrapper. + * @param string|null $stack_id The stack to run docker compose for. If null, uses current stack. * * @return \Closure A closure that will run the process in real time and return the process exit status. */ -function docker_compose_passive( array $options = [] ) { - return docker_compose_process( $options, false ); +function docker_compose_passive( array $options = [], $stack_id = null ) { + return docker_compose_process( $options, false, $stack_id ); } /** * Executes a docker compose command in real time, printing the output as produced by the command. * * @param array $options A list of options to initialize the wrapper. + * @param string|null $stack_id The stack to run docker compose for. If null, uses current stack. * * @return \Closure A closure that will run the process in real time and return the process exit status. */ -function docker_compose_realtime( array $options = [] ) { - return docker_compose_process( $options, true ); +function docker_compose_realtime( array $options = [], $stack_id = null ) { + return docker_compose_process( $options, true, $stack_id ); +} + +/** + * Returns the path to the docker binary. + * + * Unless the `SLIC_DOCKER_BIN` environment variable is set, we'll use the default `docker` command. + * + * @return string + */ +function docker_bin(): string { + return (string) getenv( 'SLIC_DOCKER_BIN' ) ?: 'docker'; } /** @@ -258,6 +320,34 @@ function docker_compose_realtime( array $options = [] ) { * @return string */ function docker_compose_bin(): string { - return (string) getenv( 'SLIC_DOCKER_COMPOSE_BIN' ) ?: 'docker compose'; + return (string) getenv( 'SLIC_DOCKER_COMPOSE_BIN' ) ?: docker_bin() . ' compose'; } +/** + * Gets the Docker Compose project name for a stack. + * + * @param string|null $stack_id The stack identifier. If null, uses current stack. + * @return string|null The project name or null if no stack. + */ +function get_stack_project_name( $stack_id = null ) { + // Load stacks.php functions if not already loaded + if ( ! function_exists( 'slic_stacks_get_project_name' ) ) { + require_once __DIR__ . '/stacks.php'; + } + + // If no stack_id provided, try to determine current stack + if ( null === $stack_id ) { + if ( ! function_exists( 'slic_current_stack' ) ) { + require_once __DIR__ . '/slic.php'; + } + $stack_id = slic_current_stack(); + } + + if ( null === $stack_id ) { + return null; + } + + return slic_stacks_get_project_name( $stack_id ); +} + + diff --git a/src/notify.php b/src/notify.php index 732e675..562a62b 100644 --- a/src/notify.php +++ b/src/notify.php @@ -5,6 +5,13 @@ namespace StellarWP\Slic; +/** + * Outputs a notification about the WordPress site URL. + * + * Displays a colored message showing the local WordPress site URL with the configured HTTP port. + * + * @return void + */ function service_wordpress_notify() { echo colorize( PHP_EOL . "Your WordPress site is reachable at: http://localhost:" . getenv( 'WORDPRESS_HTTP_PORT' ) . "" . PHP_EOL ); } \ No newline at end of file diff --git a/src/project.php b/src/project.php index 54278b0..99481b4 100644 --- a/src/project.php +++ b/src/project.php @@ -350,4 +350,21 @@ function project_apply_php_version( $slic_env_local, $slic_json, $composer_json project_show_php_version_message( $project_version, 'auto-detected from project', 'success' ); slic_set_php_version( $project_version ); } + + // Switch to the PHP version detected from the stack's env file, if any. + $stack_id = slic_current_stack(); + if ( $stack_id !== null ) { + $stack_env_file = slic_stacks_get_state_file( $stack_id ); + $stack_env_file_lines = read_env_file( $stack_env_file ); + + if ( isset( $stack_env_file_lines['SLIC_PHP_VERSION'] ) ) { + $local = normalize_php_version( $stack_env_file_lines['SLIC_PHP_VERSION'] ); + + if ( $local && $local !== $effective ) { + project_show_php_version_message( $local, 'from stack\'s file', 'switch' ); + project_maybe_show_version_mismatch_message( $local, $project_version ); + slic_set_php_version( $local ); + } + } + } } diff --git a/src/services.php b/src/services.php index d91dc4e..22043ff 100644 --- a/src/services.php +++ b/src/services.php @@ -110,12 +110,13 @@ function service_running( string $service ) { /** * Quietly tears down the stack, silencing any error messages. * + * @param string|null $stack_id The stack to tear down. If null, uses current stack. * @return int The process exit status. */ -function quietly_tear_down_stack() { +function quietly_tear_down_stack( $stack_id = null ) { ob_start(); - setup_slic_env( root() ); - $status = teardown_stack( true ); + setup_slic_env( root(), false, $stack_id ); + $status = teardown_stack( true, $stack_id ); ob_end_clean(); return $status; @@ -125,13 +126,36 @@ function quietly_tear_down_stack() { * Returns the service container ID, if any. * * @param string $service The name of the service to return the container ID for, e.g. `wordpress`. + * @param string|null $stack_id The stack to get the service from. If null, uses current stack. * * @return string|null The service container ID if found, `null` otherwise. */ -function get_service_id( string $service ) { - $root = root(); - $command = "docker ps -f label=com.docker.compose.project.working_dir='$root' " . - "-f label=com.docker.compose.service=$service --format '{{.ID}}'"; +function get_service_id( string $service, $stack_id = null ) { + // Load stacks.php functions if not already loaded + if ( ! function_exists( 'slic_stacks_get_project_name' ) ) { + require_once __DIR__ . '/stacks.php'; + } + + // Get the project name for filtering + if ( null === $stack_id ) { + if ( ! function_exists( 'slic_current_stack' ) ) { + require_once __DIR__ . '/slic.php'; + } + $stack_id = slic_current_stack(); + } + + if ( null === $stack_id ) { + // No stack found, fall back to old behavior (working_dir) + $root = root(); + $command = docker_bin() . " ps -f label=com.docker.compose.project.working_dir='$root' " . + "-f label=com.docker.compose.service=$service --format '{{.ID}}'"; + } else { + // Use project name for filtering + $project_name = slic_stacks_get_project_name( $stack_id ); + $command = docker_bin() . " ps -f label=com.docker.compose.project='$project_name' " . + "-f label=com.docker.compose.service=$service --format '{{.ID}}'"; + } + debug( "Executing command: $command" . PHP_EOL ); exec( $command, $output, $status ); diff --git a/src/slic.php b/src/slic.php index 366fb94..4ee2604 100644 --- a/src/slic.php +++ b/src/slic.php @@ -8,23 +8,28 @@ use function StellarWP\Slic\Env\backup_env_var; use function StellarWP\Slic\Env\env_var_backup; +require_once __DIR__ . '/xdebug.php'; + /** * Get the CLI header. * - * @param string $cli_name CLI command name. - * @param boolean $full Should the full heading be returned? - * @param string|null $extra Extra message to add to the header. + * @param string $cli_name CLI command name. + * @param boolean $full Should the full heading be returned? + * @param string|null $extra Extra message to add to the header. + * * @return string */ function cli_header( $cli_name, $full = false, $extra = null ) { + $compact = ! $full || no_color(); + $header_parts = [ light_cyan( $cli_name ) . ' version ' . light_cyan( CLI_VERSION ), - $full ? PHP_EOL : ' - ', + $compact ? ' - ' : PHP_EOL, 'StellarWP local testing and development tool', PHP_EOL, ]; - if ( ! $full ) { + if ( $compact ) { return implode( '', $header_parts ) . PHP_EOL; } @@ -149,20 +154,24 @@ function get_valid_targets( $as_array = true ) { $targets_str .= PHP_EOL . " Plugins:" . PHP_EOL; $targets_str .= implode( - PHP_EOL, array_map( + PHP_EOL, + array_map( static function ( $target ) { return " - {$target}"; - }, $plugins + }, + $plugins ) ); if ( $include_themes && $themes ) { $targets_str .= PHP_EOL . " Themes:" . PHP_EOL; $targets_str .= implode( - PHP_EOL, array_map( + PHP_EOL, + array_map( static function ( $target ) { return " - {$target}"; - }, $themes + }, + $themes ) ); } @@ -182,7 +191,7 @@ static function ( $target ) { * - If slic here was done on the site level, "site" is also a valid target. * * @param string $target The target to check in the valid list of targets. - * @param bool $exit Whether to exit if the target is invalid, or to return `false`. + * @param bool $exit Whether to exit if the target is invalid, or to return `false`. * * @return string|false $target The validated target or `false` to indicate the target is not valid if the `$exit` * parameter is set to `false`. @@ -249,10 +258,11 @@ function get_target_relative_path( $target ) { /** * Sets up the environment from the cli tool. * - * @param string $root_dir The cli tool root directory. - * @param bool $reset Whether to force a reset of the env vars or not, if already set up. + * @param string $root_dir The cli tool root directory. + * @param bool $reset Whether to force a reset of the env vars or not, if already set up. + * @param string|null $stack_id The stack to load environment for. If null, uses current stack or legacy file. */ -function setup_slic_env( $root_dir, $reset = false ) { +function setup_slic_env( $root_dir, $reset = false, $stack_id = null ) { static $set; if ( ! $reset && $set === true ) { @@ -261,6 +271,15 @@ function setup_slic_env( $root_dir, $reset = false ) { $set = true; + // Attempt legacy migration on first run + if ( ! $reset && is_file( __DIR__ . '/stacks.php' ) ) { + require_once __DIR__ . '/stacks.php'; + if ( slic_stacks_migrate_legacy() ) { + echo colorize( PHP_EOL . "✓ Migrated existing slic configuration to multi-stack format." . PHP_EOL ); + echo colorize( "Your previous configuration has been backed up to .env.slic.run.backup" . PHP_EOL . PHP_EOL ); + } + } + // Let's declare we're performing slics. putenv( 'STELLAR_SLIC=1' ); // Backwards compat @@ -285,19 +304,47 @@ function setup_slic_env( $root_dir, $reset = false ) { load_env_file( $root_dir . '/.env.slic.local' ); } - // Load the current session configuration file. - $run_env_file = $root_dir . '/.env.slic.run'; + // Start by loading the run file for all stacks, if any. + $run_file = $root_dir . '/.env.slic.run'; $staged_php_version = null; - if ( file_exists( $run_env_file ) ) { - load_env_file( $run_env_file ); + if ( is_file( $run_file ) ) { + load_env_file( $run_file ); + } - // Read directly from file to check for staged version before a project's .env.slic.local loads. - $run_env = read_env_file($run_env_file); - $is_php_version_staged = ($run_env['SLIC_PHP_VERSION_STAGED'] ?? '') === '1'; + // Load the current session configuration file. + // If a stack_id is provided, load that stack's state file. + // Otherwise, try to determine the current stack or fall back to .env.slic.run. + if ( ! $stack_id ) { + if ( ! function_exists( 'slic_current_stack' ) ) { + require_once __DIR__ . '/stacks.php'; + } - if ( $is_php_version_staged && isset( $run_env['SLIC_PHP_VERSION'] ) ) { - $staged_php_version = $run_env['SLIC_PHP_VERSION']; + $stack_id = slic_current_stack(); + + if ( $stack_id ) { + /* + * Set stack-specific configuration. + * This needs to happen after loading the run file but before loading target overrides + * so that .env.slic.local in the target can still override if needed + */ + $stack_run_file = get_stack_env_file( $stack_id ); + load_env_file( $stack_run_file ); + // Load XDebug configuration for the stack. + xdebug_setup_env_vars( $stack_id ); + } + } + + // Read the staged PHP version before a project's .env.slic.local can overwrite it. + // The staged version may come from the global run file or a stack-specific state file. + $staged_env_file = ! empty( $stack_run_file ) && is_file( $stack_run_file ) ? $stack_run_file : $run_file; + + if ( is_file( $staged_env_file ) ) { + $staged_env = read_env_file( $staged_env_file ); + $is_php_version_staged = ( $staged_env['SLIC_PHP_VERSION_STAGED'] ?? '' ) === '1'; + + if ( $is_php_version_staged && isset( $staged_env['SLIC_PHP_VERSION'] ) ) { + $staged_php_version = $staged_env['SLIC_PHP_VERSION']; } } @@ -316,7 +363,7 @@ function setup_slic_env( $root_dir, $reset = false ) { } $target_path = get_project_local_path(); - if( ! empty( $target_path ) ) { + if ( ! empty( $target_path ) ) { // Load the local overrides from the target. if ( file_exists( $target_path . '/.env.slic.local' ) ) { load_env_file( $target_path . '/.env.slic.local' ); @@ -330,21 +377,15 @@ function setup_slic_env( $root_dir, $reset = false ) { putenv( "SLIC_PHP_VERSION=$target_version" ); putenv( "SLIC_PHP_CLI_VERSION=$target_version" ); } elseif ( $staged_php_version !== null ) { - // A PHP version was staged, restore it (project's .env.slic.local may have overwritten it to the wrong version). + // A PHP version was staged; restore it since the project's .env.slic.local may have overwritten it. putenv( "SLIC_PHP_VERSION=$staged_php_version" ); } // All the possible env files have been loaded, time to set the db image depending on the PHP version. setup_db_env(); - /* - * Set the host env var to make xdebug work on Linux with host.docker.internal. - * This will already be set on Mac/Windows, and overriding it would break things. - * See extra_hosts: in slick-stack.yml. - */ - if ( PHP_OS === 'Linux' ) { - putenv( sprintf( 'host=%s', getenv( 'XDH' ) ?: 'host.docker.internal' ) ); - } + // Set up XDebug host for Linux + xdebug_setup_linux_host(); $default_wp_dir = root( '/_wordpress' ); $wp_dir = getenv( 'SLIC_WP_DIR' ); @@ -375,6 +416,66 @@ function setup_slic_env( $root_dir, $reset = false ) { putenv( 'COMPOSER_CACHE_DIR=' . cache( '/composer' ) ); } + // WORKTREE SUPPORT: Set worktree-specific environment variables + // These variables are used by slic-stack.worktree.yml to mount the worktree directory + // into the correct container path, shadowing the base stack's version. + if ( $stack_id && slic_stacks_is_worktree( $stack_id ) ) { + $parsed = slic_stacks_parse_worktree_id( $stack_id ); + + if ( null !== $parsed ) { + // Calculate full worktree path + $worktree_full_path = $parsed['base_path'] . '/' . $parsed['worktree_dir']; + + // Set the full path to the worktree directory + putenv( "SLIC_WORKTREE_FULL_PATH={$worktree_full_path}" ); + $_ENV['SLIC_WORKTREE_FULL_PATH'] = $worktree_full_path; + + // Determine container path based on target type (plugin or theme) + $stack = slic_stacks_get( $stack_id ); + if ( null !== $stack && isset( $stack['target'] ) ) { + $target = $stack['target']; + + // Check if target is a plugin or theme + // Plugins are in SLIC_PLUGINS_DIR, themes are in SLIC_THEMES_DIR + $plugins_dir = getenv( 'SLIC_PLUGINS_DIR' ); + $themes_dir = getenv( 'SLIC_THEMES_DIR' ); + + $container_path = null; + + // Check if target exists in plugins directory + if ( ! empty( $plugins_dir ) && file_exists( "{$plugins_dir}/{$target}" ) ) { + $container_path = "plugins/{$target}"; + } elseif ( ! empty( $themes_dir ) && file_exists( "{$themes_dir}/{$target}" ) ) { + $container_path = "themes/{$target}"; + } + + // Log error if container path could not be determined + if ( null === $container_path ) { + error_log( "slic: Could not determine container path for worktree target '{$target}'" ); + } + + // Set container path if determined + if ( null !== $container_path ) { + putenv( "SLIC_WORKTREE_CONTAINER_PATH={$container_path}" ); + $_ENV['SLIC_WORKTREE_CONTAINER_PATH'] = $container_path; + } + } + + // Set flag to indicate this is a worktree context + putenv( "SLIC_IS_WORKTREE=1" ); + $_ENV['SLIC_IS_WORKTREE'] = '1'; + } + } else { + // Not a worktree stack - clear any stale worktree variables + putenv( 'SLIC_IS_WORKTREE=' ); + putenv( 'SLIC_WORKTREE_FULL_PATH=' ); + putenv( 'SLIC_WORKTREE_CONTAINER_PATH=' ); + + unset( $_ENV['SLIC_IS_WORKTREE'] ); + unset( $_ENV['SLIC_WORKTREE_FULL_PATH'] ); + unset( $_ENV['SLIC_WORKTREE_CONTAINER_PATH'] ); + } + // Most commands are nested shells that should not run with a time limit. remove_time_limit(); } @@ -382,9 +483,9 @@ function setup_slic_env( $root_dir, $reset = false ) { /** * Sets the PHP version for the current environment. * - * @param string $version The PHP version to set. - * @param bool $require_confirm Whether to require confirmation before restarting the stack. - * @param bool $skip_rebuild Whether to skip rebuilding the stack. + * @param string $version The PHP version to set. + * @param bool $require_confirm Whether to require confirmation before restarting the stack. + * @param bool $skip_rebuild Whether to skip rebuilding the stack. */ function slic_set_php_version( $version, $require_confirm = false, $skip_rebuild = false ) { $message = " PHP version set: $version"; @@ -404,7 +505,18 @@ function slic_set_php_version( $version, $require_confirm = false, $skip_rebuild $message = $staged_message; } - $run_settings_file = root( '/.env.slic.run' ); + $stack = slic_current_stack(); + + if ( $stack !== null ) { + // We should update the stack settings, not all the stacks. + $run_settings_file = slic_stacks_get_state_file( $stack ); + $message .= PHP_EOL . "The PHP version will be set for the current stack."; + } else { + // Apply this version to all the stacks. + $run_settings_file = root( '/.env.slic.run' ); + $message .= PHP_EOL . "The PHP version will be set for all stacks."; + } + write_env_file( $run_settings_file, $data, true ); echo colorize( $message . PHP_EOL ); @@ -412,7 +524,7 @@ function slic_set_php_version( $version, $require_confirm = false, $skip_rebuild $confirm = true; if ( ! $skip_rebuild && $require_confirm ) { - $confirm = ask("Do you want to restart the stack now? ", 'yes'); + $confirm = ask( "Do you want to restart the stack now? ", 'yes' ); } if ( ! $confirm ) { @@ -428,9 +540,10 @@ function slic_set_php_version( $version, $require_confirm = false, $skip_rebuild return; } - // Set the environment variable so docker-compose build/rebuild operations use the correct PHP version. - putenv( "SLIC_PHP_VERSION=$version" ); + // Set the PHP version now to make sure it will take effect immediately during rebuild and restart. + putenv( "SLIC_PHP_VERSION={$version}" ); + // Rebuild the stack making sure the PHP version used is the one just set. rebuild_stack(); update_stack_images(); restart_php_services( true ); @@ -473,17 +586,153 @@ function slic_target( $require = true ) { return trim( $using_full ); } +/** + * Determines the current stack to use. + * + * Priority order: + * 1. Global $SLIC_STACK_OVERRIDE (set by --stack flag) + * 2. SLIC_STACK environment variable + * 3. Stack matching current working directory + * 4. Single stack if only one exists + * 5. null if no stack can be determined + * + * @return string|null The stack ID or null if not found. + */ +function slic_current_stack() { + // Load stacks.php functions if not already loaded + if ( ! function_exists( 'slic_stacks_list' ) ) { + require_once __DIR__ . '/stacks.php'; + } + + // 1. Check global override (set by --stack flag) + global $SLIC_STACK_OVERRIDE; + if ( ! empty( $SLIC_STACK_OVERRIDE ) ) { + return slic_stacks_resolve_from_path( $SLIC_STACK_OVERRIDE ); + } + + // 2. Check environment variable + $env_stack = getenv( 'SLIC_STACK' ); + if ( ! empty( $env_stack ) ) { + return slic_stacks_resolve_from_path( $env_stack ); + } + + // 3. Try to resolve from current working directory + $cwd_stack = slic_stacks_resolve_from_cwd(); + if ( null !== $cwd_stack ) { + return $cwd_stack; + } + + // 4. If only one stack exists, use it (backward compatibility) + $single_stack_id = slic_stacks_get_single_id(); + if ( null !== $single_stack_id ) { + return $single_stack_id; + } + + // 5. Auto-detect and offer to register unregistered worktrees + $cwd = getcwd(); + $detected = slic_stacks_detect_worktree( $cwd ); + + if ( $detected ) { + echo "Detected unregistered git worktree!\n"; + echo " Target: {$detected['target']}\n"; + echo " Directory: {$detected['dir_name']}\n"; + echo " Branch: {$detected['branch']}\n"; + echo "\nRegister this as a slic worktree stack? [y/N] "; + + $handle = fopen( 'php://stdin', 'r' ); + $confirmation = trim( fgets( $handle ) ); + fclose( $handle ); + + if ( strtolower( $confirmation ) === 'y' ) { + // Auto-register + $worktree_stack_id = $detected['base_stack_id'] . '@' . $detected['dir_name']; + + $worktree_state = [ + 'stack_id' => $worktree_stack_id, + 'is_worktree' => true, + 'base_stack_id' => $detected['base_stack_id'], + 'worktree_target' => $detected['target'], + 'worktree_dir' => $detected['dir_name'], + 'worktree_branch' => $detected['branch'], + 'worktree_full_path' => $detected['full_path'], + 'project_name' => slic_stacks_get_project_name( $worktree_stack_id ), + 'state_file' => slic_stacks_get_state_file( $worktree_stack_id ), + 'xdebug_port' => slic_stacks_xdebug_port( $worktree_stack_id ), + 'xdebug_key' => slic_stacks_xdebug_server_name( $worktree_stack_id ), + 'target' => $detected['target'], + 'status' => 'created', + ]; + + if ( slic_stacks_register( $worktree_stack_id, $worktree_state ) ) { + echo "Registered successfully!\n"; + + return $worktree_stack_id; + } else { + echo "Failed to register stack.\n"; + } + } + } + + return null; +} + +/** + * Gets the current stack or exits with an error if not found. + * + * @param string|null $reason Optional reason to display if stack not found. + * + * @return string The stack ID. + */ +function slic_current_stack_or_fail( $reason = null ) { + $stack_id = slic_current_stack(); + + if ( null === $stack_id ) { + $message = "No slic stack found."; + + if ( ! empty( $reason ) ) { + $message .= " " . $reason; + } + + // Load stacks.php functions if not already loaded + if ( ! function_exists( 'slic_stacks_count' ) ) { + require_once __DIR__ . '/stacks.php'; + } + + $stack_count = slic_stacks_count(); + + if ( $stack_count === 0 ) { + $message .= PHP_EOL . "Run 'slic here' in a plugin directory to create a stack."; + } elseif ( $stack_count > 1 ) { + $message .= PHP_EOL . "Multiple stacks exist. Use --stack= or cd to the stack directory."; + $message .= PHP_EOL . "Run 'slic stack list' to see all available stacks."; + } + + echo magenta( $message . PHP_EOL ); + exit( 1 ); + } + + return $stack_id; +} + /** * Switches the current `use` target. * - * @param string $target Target to switch to. + * @param string $target Target to switch to. + * @param string|null $stack_id The stack to switch target for. If null, uses current stack. */ -function slic_switch_target( $target ) { +function slic_switch_target( $target, $stack_id = null ) { $root = root(); - $run_settings_file = "{$root}/.env.slic.run"; $target_relative_path = ''; $subdir = ''; + // Determine which stack to use + if ( null === $stack_id ) { + $stack_id = slic_current_stack_or_fail( "Cannot switch target without an active stack." ); + } + + // Get the stack-specific state file + $run_settings_file = get_stack_env_file( $stack_id ); + if ( slic_here_is_site() ) { $target_relative_path = get_target_relative_path( $target ); } @@ -501,7 +750,14 @@ function slic_switch_target( $target ) { write_env_file( $run_settings_file, $env_values, true ); - setup_slic_env( $root ); + // Update the stack registry with the target so worktree commands can access it + require_once __DIR__ . '/stacks.php'; + if ( ! slic_stacks_update( $stack_id, [ 'target' => $target ] ) ) { + echo magenta( "Warning: Could not update stack registry with target." . PHP_EOL ); + echo magenta( "Worktree commands may not work correctly." . PHP_EOL ); + } + + setup_slic_env( $root, false, $stack_id ); } /** @@ -511,8 +767,8 @@ function slic_switch_target( $target ) { */ function php_services() { return [ - 'slic' => 'slic', - 'wordpress' => 'WordPress', + 'slic' => 'slic', + 'wordpress' => 'WordPress', ]; } @@ -529,9 +785,10 @@ function restart_php_services( bool $hard = false ): void { /** * Concurrently restart multiple services at once. * - * @param array|string[] $services The list of services to restart, e.g. [ 'wordpress', 'slic' ], - * or keyed by service => pretty_name, e.g. [ 'wordpress' => 'WordPress' ] - * @param bool $hard Whether to restart the service using the `docker compose restart` + * @param array|string[] $services The list of services to restart, e.g. [ 'wordpress', 'slic' ], + * or keyed by service => pretty_name, e.g. [ 'wordpress' => + * 'WordPress' ] + * @param bool $hard Whether to restart the service using the `docker compose restart` * command or to use full tear-down and up again cycle. */ function restart_services( array $services, bool $hard = false ): void { @@ -552,19 +809,19 @@ function restart_services( array $services, bool $hard = false ): void { echo colorize( PHP_EOL . sprintf( - "✅ %s service%s restarted.", - implode( ', ', $services ), - count( $services ) > 1 ? 's' : '' + "✅ %s service%s restarted.", + implode( ', ', $services ), + count( $services ) > 1 ? 's' : '' ) . PHP_EOL ); } /** * Restarts a stack services if it's running. * - * @param string $service The name of the service to restart, e.g. `wordpress`. + * @param string $service The name of the service to restart, e.g. `wordpress`. * @param string|null $pretty_name The pretty name to use for the service, or `null` to use the service name. - * @param bool $hard Whether to restart the service using the `docker compose restart` command or to use full tear-down - * and up again cycle. + * @param bool $hard Whether to restart the service using the `docker compose restart` command or to use + * full tear-down and up again cycle. */ function restart_service( $service, $pretty_name = null, $hard = false ) { $pretty_name = $pretty_name ?: $service; @@ -795,43 +1052,50 @@ function git_handle() { * * @return \Closure The process closure to start a real-time process using slic stack. */ -function slic_passive() { - return docker_compose_passive( slic_stack_array() ); +function slic_passive( $stack_id = null ) { + return docker_compose_passive( slic_stack_array( false, $stack_id ), $stack_id ); } /** * Runs a process in slic stack and returns the exit status. * + * @param string|null $stack_id The stack to run the command for. If null, uses current stack. + * * @return \Closure The process closure to start a real-time process using slic stack. */ -function slic_realtime() { - return docker_compose_realtime( slic_stack_array() ); +function slic_realtime( $stack_id = null ) { + return docker_compose_realtime( slic_stack_array( false, $stack_id ), $stack_id ); } /** * Returns the process Closure to start a real-time process using slic stack. * + * @param string|null $stack_id The stack to run the command for. If null, uses current stack. + * * @return \Closure The process closure to start a real-time process using slic stack. */ -function slic_process() { - return docker_compose( slic_stack_array() ); +function slic_process( $stack_id = null ) { + return docker_compose( slic_stack_array( false, $stack_id ), $stack_id ); } /** * Tears down slic stack. + * + * @param bool $passive Whether to run the command passively or in realtime. + * @param string|null $stack_id The stack to tear down. If null, uses current stack. */ -function teardown_stack( $passive = false ) { +function teardown_stack( $passive = false, $stack_id = null ) { if ( $passive ) { - return slic_passive()( [ 'down', '--volumes', '--remove-orphans' ] ); + return slic_passive( $stack_id )( [ 'down', '--volumes', '--remove-orphans' ] ); } - return slic_realtime()( [ 'down', '--volumes', '--remove-orphans' ] ); + return slic_realtime( $stack_id )( [ 'down', '--volumes', '--remove-orphans' ] ); } /** * Rebuilds the slic stack. */ -function rebuild_stack() { +function rebuild_stack(): void { echo "Building the stack images..." . PHP_EOL . PHP_EOL; if ( is_ci() ) { @@ -855,46 +1119,46 @@ function write_build_version() { * Prints information about slic tool. */ function slic_info() { - $config_vars = [ - 'SLIC_TEST_SUBNET', - 'CLI_VERBOSITY', - 'CI', - 'TRAVIS_CI', - 'COMPOSER_CACHE_DIR', - 'CONTINUOUS_INTEGRATION', - 'GITHUB_ACTION', - 'SLIC_PHP_VERSION', - 'SLIC_PHP_VERSION_STAGED', - 'SLIC_COMPOSER_VERSION', - 'SLIC_CURRENT_PROJECT', - 'SLIC_CURRENT_PROJECT_CONTAINER_PATH', - 'SLIC_CURRENT_PROJECT_RELATIVE_PATH', - 'SLIC_CURRENT_PROJECT_SUBDIR', - 'SLIC_HOST', - 'SLIC_PLUGINS', - 'SLIC_THEMES', - 'SLIC_GIT_DOMAIN', - 'SLIC_GIT_HANDLE', - 'SLIC_HERE_DIR', - 'SLIC_PLUGINS_DIR', - 'SLIC_THEMES_DIR', - 'SLIC_WP_DIR', - 'SLIC_INTERACTIVE', - 'SLIC_BUILD_PROMPT', - 'SLIC_BUILD_SUBDIR', - 'TERM', - 'XDK', - 'XDE', - 'XDH', - 'XDP', - 'UID', - 'SLIC_UID', - 'GID', - 'SLIC_GID', - 'MYSQL_ROOT_PASSWORD', - 'WORDPRESS_HTTP_PORT', - 'SSH_AUTH_SOCK', - ]; + $config_vars = array_merge( + [ + 'SLIC_TEST_SUBNET', + 'CLI_VERBOSITY', + 'CI', + 'TRAVIS_CI', + 'COMPOSER_CACHE_DIR', + 'CONTINUOUS_INTEGRATION', + 'GITHUB_ACTION', + 'SLIC_PHP_VERSION', + 'SLIC_PHP_VERSION_STAGED', + 'SLIC_COMPOSER_VERSION', + 'SLIC_CURRENT_PROJECT', + 'SLIC_CURRENT_PROJECT_RELATIVE_PATH', + 'SLIC_CURRENT_PROJECT_SUBDIR', + 'SLIC_HOST', + 'SLIC_PLUGINS', + 'SLIC_THEMES', + 'SLIC_GIT_DOMAIN', + 'SLIC_GIT_HANDLE', + 'SLIC_HERE_DIR', + 'SLIC_PLUGINS_DIR', + 'SLIC_THEMES_DIR', + 'SLIC_WP_DIR', + 'SLIC_INTERACTIVE', + 'SLIC_BUILD_PROMPT', + 'SLIC_BUILD_SUBDIR', + 'TERM', + ], + xdebug_get_info_vars(), + [ + 'UID', + 'SLIC_UID', + 'GID', + 'SLIC_GID', + 'MYSQL_ROOT_PASSWORD', + 'WORDPRESS_HTTP_PORT', + 'SSH_AUTH_SOCK', + ] + ); // Read .env.slic.run directly to show runtime state. $run_env_file = root( '/.env.slic.run' ); @@ -907,11 +1171,11 @@ function slic_info() { $slic_root = root(); $target_path = get_project_local_path(); echo implode( PHP_EOL, array_filter( [ - file_exists( $slic_root . '/.env.slic' ) ? " - " . $slic_root . '/.env.slic' : null, - file_exists( $slic_root . '/.env.slic.local' ) ? " - " . $slic_root . '/.env.slic.local' : null, - file_exists( $target_path . '/.env.slic.local' ) ? " - " . $target_path . '/.env.slic.local' : null, - file_exists( $slic_root . '/.env.slic.run' ) ? " - " . $slic_root . '/.env.slic.run' : null, - ] ) ) . PHP_EOL . PHP_EOL; + file_exists( $slic_root . '/.env.slic' ) ? " - " . $slic_root . '/.env.slic' : null, + file_exists( $slic_root . '/.env.slic.local' ) ? " - " . $slic_root . '/.env.slic.local' : null, + file_exists( $target_path . '/.env.slic.local' ) ? " - " . $target_path . '/.env.slic.local' : null, + file_exists( $slic_root . '/.env.slic.run' ) ? " - " . $slic_root . '/.env.slic.run' : null, + ] ) ) . PHP_EOL . PHP_EOL; echo colorize( "Current configuration:" . PHP_EOL ); foreach ( $config_vars as $key ) { @@ -1092,115 +1356,6 @@ function slic_handle_interactive( callable $args ) { write_env_file( $run_settings_file, [ 'SLIC_INTERACTIVE' => $value ], true ); } -/** - * Prints the current XDebug status to screen. - */ -function xdebug_status() { - $enabled = getenv( 'XDE' ); - $ide_key = getenv( 'XDK' ); - if ( empty( $ide_key ) ) { - $ide_key = 'slic'; - } - $localhost_port = getenv( 'WORDPRESS_HTTP_PORT' ); - if ( empty( $localhost_port ) ) { - $localhost_port = '8888'; - } - - echo 'XDebug status is: ' . ( $enabled ? light_cyan( 'on' ) : magenta( 'off' ) ) . PHP_EOL; - echo 'Remote host: ' . light_cyan( getenv( 'XDH' ) ) . PHP_EOL; - echo 'Remote port: ' . light_cyan( getenv( 'XDP' ) ) . PHP_EOL; - - echo 'IDE Key: ' . light_cyan( $ide_key ) . PHP_EOL; - echo colorize( PHP_EOL . "You can override these values in the .env.slic.local" . - " file or by using the " . - "'xdebug (host|key|port) ' command." ) . PHP_EOL; - - - echo PHP_EOL . 'Set up, in your IDE, a server with the following parameters to debug PHP requests:' . PHP_EOL; - echo 'IDE key, or server name: ' . light_cyan( $ide_key ) . PHP_EOL; - echo 'Host: ' . light_cyan( 'http://localhost' . ( $localhost_port === '80' ? '' : ':' . $localhost_port ) ) . PHP_EOL; - echo colorize( 'Path mapping (host => server): ' - . slic_plugins_dir() - . ' => /var/www/html/wp-content/plugins' ) . PHP_EOL; - echo colorize( 'Path mapping (host => server): ' - . slic_wp_dir() - . ' => /var/www/html' ); - - $default_mask = ( slic_wp_dir() === root( '/_wordpress' ) ) + 2 * ( slic_plugins_dir() === root( '/_plugins' ) ); - - switch ( $default_mask ) { - case 1: - echo PHP_EOL . PHP_EOL; - echo yellow( 'Note: slic is using the default WordPress directory and a different plugins directory: ' . - 'set path mappings correctly and keep that in mind.' ); - break; - case 2: - echo PHP_EOL . PHP_EOL; - echo yellow( 'Note: slic is using the default plugins directory and a different WordPress directory: ' . - 'set path mappings correctly and keep that in mind.' ); - break; - case 3; - default: - break; - } -} - -/** - * Handles the XDebug command request. - * - * @param callable $args The closure that will produce the current XDebug request arguments. - */ -function slic_handle_xdebug( callable $args ) { - $run_settings_file = root( '/.env.slic.run' ); - $toggle = $args( 'toggle', 'on' ); - - if ( 'status' === $toggle ) { - xdebug_status(); - - return; - } - - $map = [ - 'host' => 'XDH', - 'key' => 'XDK', - 'port' => 'XDP', - ]; - if ( array_key_exists( $toggle, $map ) ) { - $var = $args( 'value' ); - echo colorize( "Setting {$map[$toggle]}={$var}" ) . PHP_EOL . PHP_EOL; - write_env_file( $run_settings_file, [ $map[ $toggle ] => $var ], true ); - echo PHP_EOL . PHP_EOL . colorize( "Tear down the stack with down and restart it to apply the new settings!" . PHP_EOL ); - - return; - } - - $value = 'on' === $toggle ? 1 : 0; - echo 'XDebug status: ' . ( $value ? light_cyan( 'on' ) : magenta( 'off' ) ) . PHP_EOL; - - if ( $value !== (int) getenv( 'XDE' ) ) { - $xdebug_env_vars = [ 'XDE' => $value, 'XDEBUG_DISABLE' => 1 === $value ? 0 : 1 ]; - write_env_file( $run_settings_file, $xdebug_env_vars, true ); - } - - foreach ( [ 'slic', 'wordpress' ] as $service ) { - if ( ! service_running( $service ) ) { - continue; - } - - echo PHP_EOL; - - if ( $value === 1 ) { - // Enable XDebug in the service. - echo colorize( "Enabling XDebug in {$service}..." ); - slic_realtime()( [ 'exec', $service, 'xdebug-on' ] ); - } else { - echo colorize( "Disabling XDebug in {$service}..." ); - // Disable XDebug in the service. - slic_realtime()( [ 'exec', $service, 'xdebug-off' ] ); - } - } -} - /** * Updates the stack images by pulling the latest version of each. */ @@ -1214,7 +1369,7 @@ function update_stack_images() { * Check if a recognized command's required file exists in the specified directory. * * @param string $base_command Command name, such as 'composer' or 'npm'. - * @param string $path The directory path in which to look for relevantly-required files (e.g. 'package.json'). + * @param string $path The directory path in which to look for relevantly-required files (e.g. 'package.json'). * * @return bool True if the path is a directory and the command doesn't have a known file requirement or the expected * file does exist. False if the path is not a directory or a recognized command didn't find the @@ -1243,9 +1398,9 @@ function dir_has_req_build_file( $base_command, $path ) { /** * Maybe run the install process (e.g. Composer, NPM) on a given target. * - * @param string $base_command Base command to run. - * @param string $target Target to potentially run composer install against. - * @param array $sub_directories Sub directories to prompt for additional execution. + * @param string $base_command Base command to run. + * @param string $target Target to potentially run composer install against. + * @param array $sub_directories Sub directories to prompt for additional execution. * * @return array Result of command execution. */ @@ -1285,10 +1440,10 @@ function maybe_build_install_command_pool( $base_command, $target, array $sub_di * If any subdirectories are provided and are available in the target, then the user will be prompted to run the same * command on those subdirectories. * - * @param string $base_command The base service command to run, e.g. `npm`, `composer`, etc. - * @param array $command The command to run, e.g. `['install','--save-dev']` in array format. + * @param string $base_command The base service command to run, e.g. `npm`, `composer`, etc. + * @param array $command The command to run, e.g. `['install','--save-dev']` in array format. * @param array $sub_directories Sub directories to prompt for additional execution. - * @param string $using An optional target to use in place of the specified one. + * @param string $using An optional target to use in place of the specified one. * * @return array The built command pool. */ @@ -1333,11 +1488,12 @@ function build_command_pool( $base_command, array $command, array $sub_directori // If the command is executing a dynamic script in the scripts directory, grab the command name. if ( preg_match( '!\. /slic-scripts/(\..*.sh)!', $friendly_base_command, $results ) ) { - $file = escapeshellarg( SLIC_ROOT_DIR . '/' . trim( getenv( 'SLIC_SCRIPTS' ), '.' ) . '/' . $results[1] ); + $file = escapeshellarg( SLIC_ROOT_DIR . '/' . trim( getenv( 'SLIC_SCRIPTS' ), + '.' ) . '/' . $results[1] ); $friendly_base_command = `tail -n 1 $file`; } - $prefix = "{$friendly_base_command}:" . light_cyan( $target_name ); + $prefix = "{$friendly_base_command}:" . light_cyan( $target_name ); // Execute command as the parent. if ( 'target' !== $target ) { @@ -1370,7 +1526,7 @@ function build_command_pool( $base_command, array $command, array $sub_directori * When this happens, the return status of the command will be a `1`. * We iterate until the status is a `0`. */ - $network_rm_status = (int) process( "docker network rm {$network_name}_slic {$network_name}_default" )( 'status' ); + $network_rm_status = (int) process( docker_bin() . " network rm {$network_name}_slic {$network_name}_default" )( 'status' ); } while ( $network_rm_status !== 0 ); } @@ -1402,12 +1558,12 @@ function build_command_pool( $base_command, array $command, array $sub_directori * Executes a pool of commands in parallel. * * @param array $pool Pool of processes to execute in parallel. - * $pool[] = [ - * 'target' => (string) Slic target. - * 'container' => (string) Container on which to execute the command. - * 'command' => (array) The command to run, e.g. `['install', '--save-dev']` in array format. - * 'process' => (closure) The function to execute for each Slic target. - * ] + * $pool[] = [ + * 'target' => (string) Slic target. + * 'container' => (string) Container on which to execute the command. + * 'command' => (array) The command to run, e.g. `['install', '--save-dev']` in array format. + * 'process' => (closure) The function to execute for each Slic target. + * ] * * @return int Result of combined command execution. */ @@ -1433,8 +1589,8 @@ function execute_command_pool( $pool ) { /** * Returns an array of arguments to correctly run a wp-cli command in the slic stack. * - * @param array $command The wp-cli command to run, anything after the `wp`; e.g. `['plugin', 'list']`. - * @param bool $requirements Whether to ensure the requirements to run a cli command are met or not. + * @param array $command The wp-cli command to run, anything after the `wp`; e.g. `['plugin', 'list']`. + * @param bool $requirements Whether to ensure the requirements to run a cli command are met or not. * * @return array The complete command arguments, ready to be used in the `slic` or `slic_realtime` functions. */ @@ -1455,7 +1611,7 @@ function cli_command( array $command = [], $requirements = false ) { * be up-to-date with the remote: this is done by design as the sync of local and remote branches should be a developer * concern. * - * @param string $branch The name of the branch to switch to, e.g. `release/B20.03`. + * @param string $branch The name of the branch to switch to, e.g. `release/B20.03`. * @param string|null $plugin The slug of the plugin to switch branch for; if not specified, then the current slic * target will be used. */ @@ -1522,7 +1678,7 @@ function switch_plugin_branch( $branch, $plugin = null ) { } // Restore the current working directory to the previous value. - echo light_cyan( 'Using ' . slic_target() . " once again". PHP_EOL ); + echo light_cyan( 'Using ' . slic_target() . " once again" . PHP_EOL ); $restored = chdir( $cwd ); if ( false === $restored ) { @@ -1540,7 +1696,7 @@ function maybe_prompt_for_repo_update() { $cli_version = CLI_VERSION; $today = date( 'Y-m-d' ); - if ( file_exists( SLIC_ROOT_DIR . '/.remote-version' ) ) { + if ( is_file( SLIC_ROOT_DIR . '/.remote-version' ) ) { list( $check_date, $remote_version ) = explode( ':', file_get_contents( SLIC_ROOT_DIR . '/.remote-version' ) ); } @@ -1583,7 +1739,7 @@ function maybe_prompt_for_stack_update() { $build_version = '0.0.1'; $cli_version = CLI_VERSION; - if ( file_exists( SLIC_ROOT_DIR . '/.build-version' ) ) { + if ( is_file( SLIC_ROOT_DIR . '/.build-version' ) ) { $build_version = file_get_contents( SLIC_ROOT_DIR . '/.build-version' ); } @@ -1650,10 +1806,10 @@ function build_subdir_status() { * If any subdirectories are provided and are available in the target, then the user will be prompted to run the same * command on those subdirectories. * - * @param array $targets An array of targets for the command pool; note the targets are NOT validated by + * @param array $targets An array of targets for the command pool; note the targets are NOT validated by * this function and the validation should be done by the calling code. - * @param string $base_command The base service command to run, e.g. `npm`, `composer`, etc. - * @param array $command The command to run, e.g. `['install','--save-dev']` in array format. + * @param string $base_command The base service command to run, e.g. `npm`, `composer`, etc. + * @param array $command The command to run, e.g. `['install','--save-dev']` in array format. * @param array $sub_directories Sub directories to prompt for additional execution. * * @return array The built command pool for all the targets. @@ -1761,33 +1917,34 @@ function collect_target_suites() { * @return bool Whether the current system is ARM-based, or not. */ function is_arm64() { - $arm64_architecture_file = __DIR__ . '/../.architecture_arm64'; - $x86_architecture_file = __DIR__ . '/../.architecture_x86'; + $arm64_architecture_file = __DIR__ . '/../.architecture_arm64'; + $x86_architecture_file = __DIR__ . '/../.architecture_x86'; - if ( is_file($arm64_architecture_file) ) { - return true; + if ( is_file( $arm64_architecture_file ) ) { + return true; } - if ( is_file($x86_architecture_file) ) { - return false; + if ( is_file( $x86_architecture_file ) ) { + return false; } - $is_64bit = PHP_INT_SIZE === 8; + $is_64bit = PHP_INT_SIZE === 8; - $machine_type = strtolower(php_uname('m')); + $machine_type = strtolower( php_uname( 'm' ) ); // Non 64bit machines are not supported. - $is_arm64 = $is_64bit - && (strpos($machine_type, 'aarch64') !== false || strpos($machine_type, 'arm64') !== false); + $is_arm64 = $is_64bit + && ( strpos( $machine_type, 'aarch64' ) !== false || strpos( $machine_type, 'arm64' ) !== false ); - if ($is_arm64) { - touch($arm64_architecture_file); - return true; - } + if ( $is_arm64 ) { + touch( $arm64_architecture_file ); + + return true; + } - touch($x86_architecture_file); + touch( $x86_architecture_file ); - return false; + return false; } /** @@ -1815,8 +1972,9 @@ function setup_architecture_env() { * * Directories part of the path will be recursively created. * - * @param string $path The path, relative to the cache directory root directory, to return the cache absolute path for. - * @param bool $create Whether the directory required should be created if not present or not. + * @param string $path The path, relative to the cache directory root directory, to return the cache absolute path + * for. + * @param bool $create Whether the directory required should be created if not present or not. * * @return string The absolute path to the created directory or file. */ diff --git a/src/stacks.php b/src/stacks.php new file mode 100644 index 0000000..5f0ee9d --- /dev/null +++ b/src/stacks.php @@ -0,0 +1,971 @@ + Array of stacks indexed by stack_id. + */ +function slic_stacks_list() { + $registry_file = slic_stacks_registry_file(); + + if (!file_exists($registry_file)) { + return []; + } + + $content = file_get_contents($registry_file); + if ($content === false) { + return []; + } + + $stacks = json_decode($content, true); + if (!is_array($stacks)) { + return []; + } + + return $stacks; +} + +/** + * Registers a new stack in the registry (thread-safe). + * Uses file locking to prevent race conditions during concurrent stack registration. + * + * @param string $stack_id The stack identifier (full directory path). + * @param array $state The stack state data. + * @return bool True on success, false on failure. + */ +function slic_stacks_register($stack_id, array $state) { + return slic_stacks_with_lock(function() use ($stack_id, $state) { + $stacks = slic_stacks_list(); + + // Validate base stack exists for worktrees + if (!empty($state['is_worktree']) && !empty($state['base_stack_id'])) { + if (!isset($stacks[$state['base_stack_id']])) { + echo "Error: Base stack not found: {$state['base_stack_id']}\n"; + return false; + } + } + + // Allocate XDebug port if not provided (CRITICAL: inside lock) + if (!isset($state['xdebug_port'])) { + $state['xdebug_port'] = slic_stacks_xdebug_port($stack_id); + } + + $state['xdebug_key'] = slic_stacks_xdebug_server_name($stack_id); + + $stacks[$stack_id] = array_merge([ + 'created_at' => date('c'), + 'status' => 'created', + ], $state); + + return slic_stacks_write_registry($stacks); + }); +} + +/** + * Unregisters a stack from the registry (thread-safe). + * Uses file locking to prevent race conditions during concurrent stack operations. + * + * This function automatically cleans up associated state files (.env.slic.run.) + * when unregistering stacks. This ensures consistent cleanup regardless of which + * code path triggers unregistration. + * + * @param string $stack_id The stack identifier to remove. + * @param bool $cascade If true, also removes child worktree stacks. + * @return bool True on success, false on failure. + */ +function slic_stacks_unregister($stack_id, $cascade = false) { + return slic_stacks_with_lock(function() use ($stack_id, $cascade) { + $stacks = slic_stacks_list(); + + if (!isset($stacks[$stack_id])) { + return true; // Already not registered + } + + // Check if this is a base stack with worktrees + if (!slic_stacks_is_worktree($stack_id)) { + $worktrees = slic_stacks_get_worktrees($stack_id); + if (!empty($worktrees)) { + if (!$cascade) { + echo "Warning: Stack has " . count($worktrees) . " worktree(s). "; + echo "Use --cascade to remove them.\n"; + + foreach ($worktrees as $wt_id => $wt_state) { + echo " - {$wt_state['worktree_dir']}\n"; + } + + return false; + } + + // Remove all worktrees and their state files + foreach (array_keys($worktrees) as $wt_id) { + // Clean up worktree state file + $wt_state_file = slic_stacks_get_state_file($wt_id); + if (file_exists($wt_state_file)) { + @unlink($wt_state_file); + } + unset($stacks[$wt_id]); + } + } + } + + // Clean up the stack's state file before unregistering + $state_file = slic_stacks_get_state_file($stack_id); + if (file_exists($state_file)) { + @unlink($state_file); + } + + unset($stacks[$stack_id]); + return slic_stacks_write_registry($stacks); + }); +} + +/** + * Gets the state of a specific stack. + * + * @param string $stack_id The stack identifier. + * @return array|null The stack state or null if not found. + */ +function slic_stacks_get($stack_id) { + $stacks = slic_stacks_list(); + return $stacks[$stack_id] ?? null; +} + +/** + * Updates the state of an existing stack. + * + * @param string $stack_id The stack identifier. + * @param array $state The new state data (will be merged with existing). + * @return bool True on success, false on failure. + */ +function slic_stacks_update($stack_id, array $state) { + return slic_stacks_with_lock(function() use ($stack_id, $state) { + $stacks = slic_stacks_list(); + + if (!isset($stacks[$stack_id])) { + return false; + } + + $stacks[$stack_id] = array_merge($stacks[$stack_id], $state); + + return slic_stacks_write_registry($stacks); + }); +} + +/** + * Writes the stacks registry to disk with atomic file operations. + * Prevents race conditions when multiple slic processes run concurrently. + * + * @param array $stacks The stacks data to write. + * @return bool True on success, false on failure. + */ +function slic_stacks_write_registry(array $stacks) { + $registry_file = slic_stacks_registry_file(); + $temp_file = $registry_file . '.tmp.' . getmypid(); + + // Encode JSON + $json = json_encode($stacks, JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES); + if ($json === false) { + echo "Error encoding registry: " . json_last_error_msg() . "\n"; + return false; + } + + // Write to temporary file + $result = file_put_contents($temp_file, $json); + if ($result === false) { + return false; + } + + // Atomic rename (overwrites registry file) + if (!rename($temp_file, $registry_file)) { + @unlink($temp_file); + return false; + } + + return true; +} + +/** + * Acquires an exclusive lock on the registry file for atomic read-modify-write operations. + * Executes the provided callback while holding the lock to prevent race conditions. + * + * @param callable $callback Function to execute with locked registry. + * @return mixed Result of callback. + * @throws \RuntimeException If lock cannot be acquired. + */ +function slic_stacks_with_lock(callable $callback) { + $registry_file = slic_stacks_registry_file(); + $lock_file = $registry_file . '.lock'; + + // Create lock file if it doesn't exist + if (!file_exists($lock_file)) { + if (@touch($lock_file) === false) { + throw new \RuntimeException("Cannot create lock file: $lock_file (check permissions)"); + } + } + + $lock_handle = fopen($lock_file, 'c'); + if ($lock_handle === false) { + throw new \RuntimeException("Cannot open lock file: $lock_file"); + } + + try { + // Acquire exclusive lock with timeout + $attempts = 0; + $max_attempts = 50; // 5 seconds total + + while (!flock($lock_handle, LOCK_EX | LOCK_NB)) { + if ($attempts++ >= $max_attempts) { + throw new \RuntimeException( + "Cannot acquire lock on registry after 5 seconds. " . + "Another slic process may be running. " . + "If stuck, remove: $lock_file" + ); + } + usleep(100000); // 100ms + } + + // Execute callback with lock held + $result = $callback(); + + // Release lock + flock($lock_handle, LOCK_UN); + + return $result; + } finally { + fclose($lock_handle); + } +} + +/** + * Resolves the stack ID from the current working directory. + * Uses prioritized resolution to prefer worktree stacks over base stacks. + * + * Resolution priority: + * 1. Exact match with worktree stacks (checked FIRST) + * 2. Exact match with base stacks + * 3. CWD is within a base stack directory + * 4. Parent directory walk + * + * @return string|null The stack ID or null if not found. + */ +function slic_stacks_resolve_from_cwd() { + $cwd = getcwd(); + + if ($cwd === false || $cwd === '') { + return null; + } + + $stacks = slic_stacks_list(); + + // Normalize CWD for comparison + $cwd_real = realpath($cwd); + if ($cwd_real === false) { + // Path doesn't exist or realpath failed, use original + $cwd_real = $cwd; + } + + // PRIORITY 1: Exact match with worktree stacks (check FIRST) + // This ensures worktrees take precedence over base stacks + foreach ($stacks as $stack_id => $state) { + if (slic_stacks_is_worktree($stack_id)) { + $worktree_path = $state['worktree_full_path'] ?? null; + + if ($worktree_path) { + $worktree_real = realpath($worktree_path); + if ($worktree_real === false) { + // realpath failed, use original path + $worktree_real = $worktree_path; + } + + // Check exact match + if ($cwd_real === $worktree_real) { + return $stack_id; + } + + // Check if CWD is within worktree directory tree + // Add trailing slashes to prevent partial matches (e.g., /foo vs /foobar) + if (strpos($cwd_real . '/', $worktree_real . '/') === 0) { + return $stack_id; + } + } + } + } + + // PRIORITY 2: Exact match with base stacks + foreach ($stacks as $stack_id => $state) { + if (!slic_stacks_is_worktree($stack_id)) { + $stack_real = realpath($stack_id); + if ($stack_real === false) { + // realpath failed, use original stack_id + $stack_real = $stack_id; + } + + // Check exact match + if ($cwd_real === $stack_real) { + return $stack_id; + } + } + } + + // PRIORITY 3: CWD is within a base stack directory + foreach ($stacks as $stack_id => $state) { + if (!slic_stacks_is_worktree($stack_id)) { + $stack_real = realpath($stack_id); + if ($stack_real === false) { + // realpath failed, use original stack_id + $stack_real = $stack_id; + } + + // Check if CWD is within base stack directory tree + // Add trailing slashes to prevent partial matches + if (strpos($cwd_real . '/', $stack_real . '/') === 0) { + return $stack_id; + } + } + } + + // PRIORITY 4: Parent directory walk + // Walk up the directory tree to find any matching stack + $path = $cwd_real; // Use normalized path for consistency + while ($path !== '/' && $path !== '.') { + $resolved = slic_stacks_resolve_from_path($path); + if ($resolved) { + return $resolved; + } + $path = dirname($path); + } + + return null; +} + +/** + * Resolves a stack ID from a given path. + * Supports ~ expansion, stack IDs with @ format, and finds matching stack by exact or parent directory match. + * Prioritizes worktree stacks over base stacks when the path points to a worktree directory. + * + * @param string $path The path to resolve (can be a filesystem path or a stack ID with @). + * @return string|null The stack ID or null if not found. + */ +function slic_stacks_resolve_from_path($path) { + // Check if this is already a stack ID with @ format + if (strpos($path, '@') !== false) { + $stacks = slic_stacks_list(); + // If it's a registered stack ID, return it as-is + if (isset($stacks[$path])) { + return $path; + } + // Not a registered stack ID, treat as invalid + return null; + } + + // Expand ~ to home directory + if (strpos($path, '~') === 0) { + $home = getenv('HOME'); + if ($home === false) { + $home = getenv('USERPROFILE'); // Windows fallback + } + if ($home !== false) { + $path = $home . substr($path, 1); + } + } + + // Resolve to absolute path + $path = realpath($path); + if ($path === false) { + // Path doesn't exist, try without realpath + $path = func_get_arg(0); + if (strpos($path, '~') === 0) { + $home = getenv('HOME') ?: getenv('USERPROFILE'); + if ($home !== false) { + $path = $home . substr($path, 1); + } + } + } + + $stacks = slic_stacks_list(); + + // PRIORITY 1: Check if this path is a worktree directory (exact match) + // This ensures worktree stacks take precedence over base stacks + foreach ($stacks as $stack_id => $state) { + if (slic_stacks_is_worktree($stack_id)) { + $worktree_path = $state['worktree_full_path'] ?? null; + if ($worktree_path) { + $worktree_real = realpath($worktree_path); + if ($worktree_real !== false && $worktree_real === $path) { + return $stack_id; + } + } + } + } + + // PRIORITY 2: Try exact match with base stacks + if (isset($stacks[$path])) { + return $path; + } + + // PRIORITY 3: Check if path is within a worktree directory + foreach ($stacks as $stack_id => $state) { + if (slic_stacks_is_worktree($stack_id)) { + $worktree_path = $state['worktree_full_path'] ?? null; + if ($worktree_path) { + $worktree_real = realpath($worktree_path); + if ($worktree_real !== false) { + // Check if path is within this worktree directory + // strpos($haystack, $needle) checks if $needle appears at position 0 in $haystack + // So this checks if $path starts with $worktree_real + if (strpos($path . '/', $worktree_real . '/') === 0) { + return $stack_id; + } + } + } + } + } + + // PRIORITY 4: Try to find a base stack where the path is within its directory + // For example, if stack is /Users/Alice/project/wp-content/plugins + // and path is /Users/Alice/project/wp-content/plugins/my-plugin + // we should match it + foreach ($stacks as $stack_id => $stack) { + // Skip worktrees, we already checked them + if (slic_stacks_is_worktree($stack_id)) { + continue; + } + // Add trailing slashes to prevent false positives (e.g., /foo vs /foobar) + if (strpos($path . '/', $stack_id . '/') === 0) { + // Path starts with stack_id, so it's within that stack's directory + return $stack_id; + } + } + + // PRIORITY 5: Try to find a stack by checking parent directories + $current_path = $path; + while ($current_path !== dirname($current_path)) { + // Check worktrees first + foreach ($stacks as $stack_id => $state) { + if (slic_stacks_is_worktree($stack_id)) { + $worktree_path = $state['worktree_full_path'] ?? null; + if ($worktree_path) { + $worktree_real = realpath($worktree_path); + if ($worktree_real !== false && $worktree_real === $current_path) { + return $stack_id; + } + } + } + } + // Then check base stacks + if (isset($stacks[$current_path])) { + return $current_path; + } + $current_path = dirname($current_path); + } + + return null; +} + +/** + * Generates a Docker Compose project name for a stack. + * Uses a hash of the stack ID to ensure uniqueness and avoid special characters. + * + * @param string $stack_id The stack identifier. + * @return string The project name. + */ +function slic_stacks_get_project_name($stack_id) { + // Use first 8 characters of MD5 hash for a short, unique identifier + $hash = substr(md5($stack_id), 0, 8); + return 'slic_' . $hash; +} + +/** + * Gets the state file path for a specific stack. + * + * @param string $stack_id The stack identifier. + * @return string The absolute path to the stack's state file. + */ +function slic_stacks_get_state_file($stack_id) { + $hash = substr(md5($stack_id), 0, 8); + return dirname(__DIR__) . '/.env.slic.run.' . $hash; +} + +/** + * Allocates an XDebug port for a stack, ensuring no conflicts with other stacks. + * + * This function uses a two-phase allocation strategy: + * 1. Attempts to use a deterministic hash-based port (preferred for consistency) + * 2. If collision detected, finds the next available port in the range + * + * Port range: 49000-59000 (10,000 possible unique ports) + * - Avoids well-known ports (< 1024) + * - Avoids common service ports (3306, 6379, 8080, etc.) + * - Avoids Docker's ephemeral port range (32768-65535) + * - Provides 10,000 possible unique ports + * + * Conflict Detection: + * - Checks all registered stacks for existing port allocations + * - Excludes current stack from conflict check (allows re-initialization) + * - Linear search for next available port if preferred port is taken + * + * Edge Cases Handled: + * - Stack re-initialization: Excluded from its own conflict check + * - Port exhaustion: Falls back to preferred port (should never happen with 10k ports) + * - Missing xdebug_port in state: Safely ignored during conflict check + * + * @param string $stack_id The stack identifier (absolute path). + * @return int The allocated XDebug port number (49000-59000). + */ +function slic_stacks_xdebug_port($stack_id) { + $min_port = 49000; + $max_port = 59000; + + // Try deterministic port first (hash-based for consistency) + $hash = substr(md5($stack_id), 0, 8); + $hash_decimal = hexdec($hash); + $port_range = $max_port - $min_port + 1; + $preferred_port = $min_port + ($hash_decimal % $port_range); + + // Check if port is available across all registered stacks + $stacks = slic_stacks_list(); + $used_ports = []; + foreach ($stacks as $sid => $state) { + // Exclude current stack from conflict check (allows re-initialization) + if ($sid !== $stack_id && !empty($state['xdebug_port'])) { + $used_ports[] = $state['xdebug_port']; + } + } + + // Convert to hash set for O(1) lookups + $used_ports_set = array_flip($used_ports); + + // Return preferred port if available + if (!isset($used_ports_set[$preferred_port])) { + return $preferred_port; + } + + // Collision detected - find next available port + for ($port = $min_port; $port <= $max_port; $port++) { + if (!isset($used_ports_set[$port])) { + return $port; + } + } + + // Fallback (should never happen with 10,000 ports available) + return $preferred_port; +} + +/** + * Generates a deterministic XDebug server name for a stack based on its path. + * + * The server name is used by IDEs to identify debugging sessions and match + * path mappings. Each stack needs a unique server name. + * + * The key is built using the XDK configuration env var as root. + * + * Format: "{key_root:-slic}_{hash}" where hash is the first 8 characters of MD5 + * Example: "slic_a7f3c891" + * + * @param string $stack_id The stack identifier (absolute path). + * @return string The XDebug server name. + */ +function slic_stacks_xdebug_server_name($stack_id) { + // Use the same hash approach as project names for consistency + $hash = substr(md5($stack_id), 0, 8); + + // Read XDK root from config files, not from getenv() which may be + // overwritten by xdebug_setup_env_vars() with a full stack-specific key + // (e.g. "slic_a1dc6067") instead of just the root "slic". + $root = dirname(__DIR__); + $config = read_env_file($root . '/.env.slic'); + if (file_exists($root . '/.env.slic.local')) { + $config = array_merge($config, read_env_file($root . '/.env.slic.local')); + } + $key_root = $config['XDK'] ?? 'slic'; + + return $key_root . '_' . $hash; +} + +/** + * Counts the number of registered stacks. + * + * @return int The number of stacks. + */ +function slic_stacks_count() { + return count(slic_stacks_list()); +} + +/** + * Gets the single stack if only one exists, null otherwise. + * + * @return array|null The single stack's data or null. + */ +function slic_stacks_get_single() { + $stacks = slic_stacks_list(); + + if (count($stacks) !== 1) { + return null; + } + + return reset($stacks); +} + +/** + * Gets the stack ID of the single stack if only one exists, null otherwise. + * + * @return string|null The single stack's ID or null. + */ +function slic_stacks_get_single_id() { + $stacks = slic_stacks_list(); + + if (count($stacks) !== 1) { + return null; + } + + reset($stacks); + return key($stacks); +} + +/** + * Reads actual port assignments from Docker for a running stack. + * + * @param string $stack_id The stack identifier. + * @return array|null Array with keys 'wp', 'mysql', 'redis' or null if containers aren't running. + */ +function slic_stacks_read_ports_from_docker($stack_id) { + $project_name = slic_stacks_get_project_name($stack_id); + + // Check if containers are actually running + $check_command = docker_bin() . " ps -q -f label=com.docker.compose.project='$project_name' 2>/dev/null"; + $check_command_output = shell_exec( $check_command ); + $container_ids = $check_command_output ? trim( $check_command_output ) : ''; + + if (empty($container_ids)) { + // Containers not running + return null; + } + + $ports = []; + + // Map service names to port numbers we need to extract + $service_map = [ + 'wordpress' => ['wp', 80], + 'db' => ['mysql', 3306], + 'redis' => ['redis', 6379], + ]; + + foreach ($service_map as $service_name => $info) { + list($port_key, $container_port) = $info; + + // Get the container ID for this service + $get_id_command = docker_bin() . " ps -q -f label=com.docker.compose.project='$project_name' " . + "-f label=com.docker.compose.service=$service_name 2>/dev/null"; + $service_id = trim(shell_exec($get_id_command)); + + if (empty($service_id)) { + continue; + } + + // Get the port mapping for this container + $port_command = docker_bin() . " port $service_id $container_port 2>/dev/null | grep '0.0.0.0' | cut -d: -f2"; + $host_port = trim(shell_exec($port_command)); + + if (!empty($host_port) && is_numeric($host_port)) { + $ports[$port_key] = (int)$host_port; + } + } + + // Only return ports if we got all three + if (count($ports) === 3) { + return $ports; + } + + return null; +} + +/** + * Checks if a stack's containers are currently running. + * Uses caching to avoid repeated Docker calls within a single execution. + * + * @param string $stack_id The stack identifier. + * @return bool True if containers are running, false otherwise. + */ +function slic_stacks_is_running($stack_id) { + // Cache to avoid repeated Docker calls in a single execution + static $cache = []; + + if (isset($cache[$stack_id])) { + return $cache[$stack_id]; + } + + $project_name = slic_stacks_get_project_name($stack_id); + + // Add timeout protection to prevent hanging on slow Docker + // Use timeout command if available (2 seconds) + $timeout_prefix = ''; + if (shell_exec('command -v timeout 2>/dev/null')) { + $timeout_prefix = 'timeout 2 '; + } + + // Check if containers are actually running + $check_command = "{$timeout_prefix}" . docker_bin() . " ps -q -f label=com.docker.compose.project='$project_name' 2>/dev/null"; + $check_command_output = shell_exec($check_command); + $container_ids = $check_command_output ? trim($check_command_output) : null; + + $is_running = !empty($container_ids); + $cache[$stack_id] = $is_running; + + return $is_running; +} + +/** + * Ensures a stack has port assignments, reading from Docker if needed. + * Updates the registry with current port assignments if containers are running. + * + * @param string $stack_id The stack identifier. + * @return bool True if ports are available, false otherwise. + */ +function slic_stacks_ensure_ports($stack_id) { + $stack = slic_stacks_get($stack_id); + + if (null === $stack) { + return false; + } + + // If ports are already set and valid, we're done + if (isset($stack['ports']) && is_array($stack['ports']) && count($stack['ports']) === 3) { + return true; + } + + // Try to read ports from Docker + $ports = slic_stacks_read_ports_from_docker($stack_id); + + if (null !== $ports) { + // Update the registry with the actual ports + slic_stacks_update($stack_id, ['ports' => $ports]); + return true; + } + + // Containers not running yet + return false; +} + +/** + * Checks if a stack ID represents a worktree. + * + * @param string $stack_id The stack identifier. + * @return bool True if worktree. + */ +function slic_stacks_is_worktree($stack_id) { + return strpos($stack_id, '@') !== false; +} + +/** + * Gets all worktree stacks for a base stack. + * + * @param string $base_stack_id The base stack identifier. + * @return array Array of worktree stacks. + */ +function slic_stacks_get_worktrees($base_stack_id) { + $stacks = slic_stacks_list(); + $worktrees = []; + + foreach ($stacks as $stack_id => $state) { + if (!empty($state['is_worktree']) && + !empty($state['base_stack_id']) && + $state['base_stack_id'] === $base_stack_id) { + $worktrees[$stack_id] = $state; + } + } + + return $worktrees; +} + +/** + * Parses a worktree stack ID into components. + * + * @param string $stack_id The stack identifier. + * @return array|null Array with 'base_path' and 'worktree_dir', or null if not a worktree. + */ +function slic_stacks_parse_worktree_id($stack_id) { + if (!slic_stacks_is_worktree($stack_id)) { + return null; + } + + $parts = explode('@', $stack_id, 2); + if (count($parts) !== 2) { + return null; + } + + // Validate that both components are non-empty + if (empty($parts[0]) || empty($parts[1])) { + return null; // Both base path and worktree directory are required + } + + // Validate that worktree_dir does not contain @ + if (strpos($parts[1], '@') !== false) { + return null; // worktree_dir must not contain @ + } + + return [ + 'base_path' => $parts[0], + 'worktree_dir' => $parts[1], + ]; +} + +/** + * Gets the base stack ID for a given stack (base or worktree). + * + * @param string $stack_id The stack identifier. + * @return string The base stack ID. + */ +function slic_stacks_get_base_stack_id($stack_id) { + // Validate input + if (!is_string($stack_id) || $stack_id === '') { + return ''; + } + + $parsed = slic_stacks_parse_worktree_id($stack_id); + return $parsed ? $parsed['base_path'] : $stack_id; +} + +/** + * Detects if a path is an unregistered git worktree. + * Checks if the .git file (not directory) contains gitdir: reference, + * which indicates a git worktree. Attempts to match against registered + * base stacks to provide worktree metadata. + * + * @param string $path The path to check. + * @return array|null Worktree metadata or null if not a worktree. + */ +function slic_stacks_detect_worktree($path) { + $git_file = $path . '/.git'; + + // Check if .git is a FILE (not directory) - indicates worktree + if (!is_file($git_file)) { + return null; + } + + // Read .git file content + $git_content = @file_get_contents($git_file); + if ($git_content === false) { + return null; + } + + // Check for gitdir reference (worktree marker) + if (strpos($git_content, 'gitdir:') !== 0) { + return null; // Could be a submodule or other git construct + } + + // This is a git worktree - try to find the base stack + $parent_dir = dirname($path); + $dir_name = basename($path); + + // Look for registered base stacks in parent directory + $stacks = slic_stacks_list(); + foreach ($stacks as $stack_id => $state) { + // Skip existing worktrees + if (slic_stacks_is_worktree($stack_id)) { + continue; + } + + // Check if parent directory matches + $stack_real = realpath($stack_id); + $parent_real = realpath($parent_dir); + + if ($stack_real === false || $parent_real === false) { + continue; + } + + if ($stack_real !== $parent_real) { + continue; + } + + // Check if directory name matches pattern: {target}-{branch-slug} + $target = $state['target'] ?? null; + if ($target && strpos($dir_name, $target . '-') === 0) { + // Extract branch from git + $current_branch = trim(shell_exec("git -C " . escapeshellarg($path) . " rev-parse --abbrev-ref HEAD 2>/dev/null")); + + return [ + 'base_stack_id' => $stack_id, + 'target' => $target, + 'dir_name' => $dir_name, + 'parent_dir' => $parent_dir, + 'full_path' => $path, + 'branch' => $current_branch, + ]; + } + } + + return null; +} + +/** + * Migrates legacy .env.slic.run file to new stack system. + * This ensures backward compatibility for existing slic installations. + * + * @return bool True if migration was performed, false otherwise. + */ +function slic_stacks_migrate_legacy() { + $root_dir = dirname( __DIR__ ); + $legacy_run_file = $root_dir . '/.env.slic.run'; + $registry_file = slic_stacks_registry_file(); + + // If registry already exists or legacy file doesn't exist, no migration needed + if ( file_exists( $registry_file ) || ! file_exists( $legacy_run_file ) ) { + return false; + } + + // Load legacy environment file + require_once __DIR__ . '/utils.php'; + $legacy_env = read_env_file( $legacy_run_file ); + + // Extract stack information from legacy file + $plugins_dir = $legacy_env['SLIC_PLUGINS_DIR'] ?? $root_dir . '/_plugins'; + + // Use plugins directory as stack ID + $stack_id = $plugins_dir; + + // Create stack state + $stack_state = [ + 'stack_id' => $stack_id, + 'project_name' => slic_stacks_get_project_name( $stack_id ), + 'state_file' => basename( slic_stacks_get_state_file( $stack_id ) ), + 'xdebug_port' => slic_stacks_xdebug_port( $stack_id ), + 'xdebug_key' => slic_stacks_xdebug_server_name( $stack_id ), + 'ports' => [ + 'wp' => 8888, // Default legacy port + 'mysql' => 9006, // Default legacy port + 'redis' => 8379, // Default legacy port + ], + 'created_at' => date( 'c' ), + 'status' => 'migrated', + ]; + + // Register the stack + if ( ! slic_stacks_register( $stack_id, $stack_state ) ) { + return false; + } + + // Copy legacy file to new stack-specific file + $new_run_file = slic_stacks_get_state_file( $stack_id ); + if ( ! copy( $legacy_run_file, $new_run_file ) ) { + return false; + } + + // Rename legacy file to .env.slic.run.backup + rename( $legacy_run_file, $legacy_run_file . '.backup' ); + + return true; +} diff --git a/src/tui.php b/src/tui.php new file mode 100644 index 0000000..28d45f0 --- /dev/null +++ b/src/tui.php @@ -0,0 +1,629 @@ +/dev/null' ); + + if ( $stty_settings === null ) { + return false; + } + + // Set raw mode: disable echo, canonical mode, and signals + shell_exec( 'stty -icanon -echo -isig 2>/dev/null' ); + + return trim( $stty_settings ); +} + +/** + * Restore terminal to normal mode on Unix systems + * + * @param string $settings Original stty settings + */ +function tui_unix_restore( $settings ) { + if ( $settings ) { + shell_exec( "stty " . escapeshellarg( $settings ) . " 2>/dev/null" ); + } +} + +/** + * Check if running in a TTY on Unix + * + * @return bool + */ +function tui_unix_is_tty() { + return function_exists( 'posix_isatty' ) + && posix_isatty( STDIN ) + && posix_isatty( STDOUT ); +} + +/** + * Set terminal to raw mode on Windows + * + * @return bool Success + */ +function tui_windows_raw_mode() { + // Enable VT100 support for ANSI escape codes (Windows 10+) + if ( function_exists( 'sapi_windows_vt100_support' ) ) { + sapi_windows_vt100_support( STDOUT, true ); + sapi_windows_vt100_support( STDERR, true ); + } + + // Set stream to non-blocking mode for character-by-character reading + stream_set_blocking( STDIN, false ); + + return true; +} + +/** + * Restore terminal on Windows + */ +function tui_windows_restore() { + stream_set_blocking( STDIN, true ); +} + +/** + * Check if running in a TTY on Windows + * + * @return bool + */ +function tui_windows_is_tty() { + // Check if STDIN/STDOUT are connected to console + // On Windows, we can check if stream_isatty exists (PHP 7.2+) + if ( function_exists( 'stream_isatty' ) ) { + return stream_isatty( STDIN ) && stream_isatty( STDOUT ); + } + + // Fallback: check if we can get console mode + // If not redirected, this should work + return ! getenv( 'CI' ) && PHP_SAPI !== 'cgi-fcgi'; +} + +/** + * Set terminal to raw mode (cross-platform) + * + * @return mixed Platform-specific settings for restoration + */ +function tui_terminal_raw_mode() { + $platform = tui_platform(); + + switch ( $platform ) { + case 'unix': + return [ 'platform' => 'unix', 'settings' => tui_unix_raw_mode() ]; + + case 'windows': + tui_windows_raw_mode(); + return [ 'platform' => 'windows', 'settings' => true ]; + + default: + return false; + } +} + +/** + * Restore terminal to normal mode (cross-platform) + * + * @param mixed $state State returned from tui_terminal_raw_mode() + */ +function tui_terminal_restore( $state ) { + if ( ! $state || ! is_array( $state ) || ! isset( $state['platform'] ) ) { + return; + } + + switch ( $state['platform'] ) { + case 'unix': + tui_unix_restore( $state['settings'] ); + break; + + case 'windows': + tui_windows_restore(); + break; + } +} + +/** + * Check if running in a TTY (cross-platform) + * + * @return bool + */ +function tui_is_tty() { + $platform = tui_platform(); + + switch ( $platform ) { + case 'unix': + return tui_unix_is_tty(); + + case 'windows': + return tui_windows_is_tty(); + + default: + return false; + } +} + +/** + * Read a single character from STDIN on Unix + * + * @return string|false + */ +function tui_unix_read_char() { + return fread( STDIN, 1 ); +} + +/** + * Read a single character from STDIN on Windows (non-blocking) + * + * @return string|false + */ +function tui_windows_read_char() { + // Non-blocking read + $char = fread( STDIN, 1 ); + + // If nothing available, sleep briefly and try again + if ( $char === false || $char === '' ) { + usleep( 10000 ); // 10ms + return false; + } + + return $char; +} + +/** + * Read a single key press (cross-platform) + * Handles multi-byte sequences for arrow keys, ESC, etc. + * + * @return array ['type' => 'char|arrow|enter|escape|ctrl_c|backspace|none', 'value' => ...] + */ +function tui_read_key() { + $platform = tui_platform(); + + // Read first character + if ( $platform === 'windows' ) { + // Windows: use non-blocking read with retry loop + $c = false; + $attempts = 0; + while ( $c === false && $attempts < 100 ) { + $c = tui_windows_read_char(); + $attempts++; + if ( $c === false ) { + usleep( 10000 ); // 10ms between attempts + } + } + if ( $c === false ) { + return [ 'type' => 'none', 'value' => null ]; + } + } else { + // Unix: blocking read + $c = tui_unix_read_char(); + if ( $c === false ) { + return [ 'type' => 'none', 'value' => null ]; + } + } + + // Check for escape sequences (arrow keys, etc.) + if ( $c === "\033" || $c === "\x1b" ) { + // Try to read next characters for escape sequence + // Set a short timeout for reading the rest of the sequence + $next = fread( STDIN, 1 ); + + if ( $next === '[' || $next === 'O' ) { + // ANSI escape sequence + $arrow = fread( STDIN, 1 ); + if ( $arrow === false || $arrow === '' ) { + // Failed to read arrow key, treat as ESC + return [ 'type' => 'escape', 'value' => null ]; + } + switch ( $arrow ) { + case 'A': + return [ 'type' => 'arrow', 'value' => 'up' ]; + case 'B': + return [ 'type' => 'arrow', 'value' => 'down' ]; + case 'C': + return [ 'type' => 'arrow', 'value' => 'right' ]; + case 'D': + return [ 'type' => 'arrow', 'value' => 'left' ]; + } + } elseif ( $next === false || $next === '' ) { + // Just ESC key pressed, no sequence following + return [ 'type' => 'escape', 'value' => null ]; + } + + // Unknown escape sequence, treat as ESC + return [ 'type' => 'escape', 'value' => null ]; + } + + // Check for Ctrl+C + if ( $c === "\003" || $c === "\x03" ) { + return [ 'type' => 'ctrl_c', 'value' => null ]; + } + + // Check for Enter (handle both Unix \n and Windows \r\n) + if ( $c === "\n" || $c === "\r" ) { + // On Windows, might need to consume following \n + if ( $c === "\r" && $platform === 'windows' ) { + // Consume the following \n character if present + fread( STDIN, 1 ); + } + return [ 'type' => 'enter', 'value' => null ]; + } + + // Check for Backspace (multiple possible codes) + if ( $c === "\177" || $c === "\x7f" || $c === "\010" || $c === "\x08" ) { + return [ 'type' => 'backspace', 'value' => null ]; + } + + // Handle Windows arrow keys (if not using ANSI codes) + // Windows getch() returns 0x00 or 0xE0 followed by scan code + if ( ( $c === "\x00" || $c === "\xe0" ) && $platform === 'windows' ) { + $scan = fread( STDIN, 1 ); + if ( $scan === false || $scan === '' ) { + // Failed to read scan code, return as regular char + return [ 'type' => 'char', 'value' => $c ]; + } + switch ( $scan ) { + case 'H': + return [ 'type' => 'arrow', 'value' => 'up' ]; + case 'P': + return [ 'type' => 'arrow', 'value' => 'down' ]; + case 'M': + return [ 'type' => 'arrow', 'value' => 'right' ]; + case 'K': + return [ 'type' => 'arrow', 'value' => 'left' ]; + } + } + + // Regular character + return [ 'type' => 'char', 'value' => $c ]; +} + +/** + * Clear the screen + */ +function tui_clear_screen() { + echo "\033[2J"; +} + +/** + * Move cursor to specific position (1-indexed) + * + * @param int $row Row position + * @param int $col Column position + */ +function tui_move_cursor( $row, $col ) { + echo "\033[{$row};{$col}H"; +} + +/** + * Clear from cursor to end of screen + */ +function tui_clear_to_end() { + echo "\033[J"; +} + +/** + * Hide cursor + */ +function tui_hide_cursor() { + echo "\033[?25l"; +} + +/** + * Show cursor + */ +function tui_show_cursor() { + echo "\033[?25h"; +} + +/** + * Move cursor to beginning of line + */ +function tui_cursor_home() { + echo "\r"; +} + +/** + * Move cursor up by N lines + * + * @param int $n Number of lines to move up + */ +function tui_move_cursor_up( $n ) { + if ( $n > 0 ) { + echo "\033[{$n}A"; + } +} + +/** + * Filter items by search term (substring match, case-insensitive) + * + * @param array $items All items + * @param string $search Search term + * @return array Filtered items + */ +function tui_filter_items( array $items, $search ) { + if ( empty( $search ) ) { + return $items; + } + + $search_lower = strtolower( $search ); + + return array_values( + array_filter( + $items, + function ( $item ) use ( $search_lower ) { + return strpos( strtolower( $item ), $search_lower ) !== false; + } + ) + ); +} + +/** + * Render the TUI display + * + * @param array $visible_items Items to display (after filtering) + * @param int $selected_index Currently highlighted index + * @param string $search Current search term + * @param string|null $current Current/active item (marked with ✓) + * @param string $prompt Prompt text + * @param int $scroll_offset Scroll position + * @param int $max_visible Maximum items to show + * @return int Number of lines rendered + */ +function tui_render( $visible_items, $selected_index, $search, $current, $prompt, $scroll_offset, $max_visible ) { + // Clear from cursor to end (preserves terminal history) + tui_cursor_home(); + tui_clear_to_end(); + + // Track lines rendered so we can move cursor back during re-renders and cleanup. + // We count each echo statement that produces a visible line, including the search + // line which now has a trailing newline so cursor positioning is correct. + $lines_rendered = 0; + + // Display prompt + echo colorize( "$prompt\n" ); + $lines_rendered++; + + // Calculate visible window + $total_items = count( $visible_items ); + $window_items = array_slice( $visible_items, $scroll_offset, $max_visible ); + + // Display items + foreach ( $window_items as $idx => $item ) { + $absolute_idx = $scroll_offset + $idx; + $is_selected = ( $absolute_idx === $selected_index ); + $is_current = ( $item === $current ); + + // Prefix + $prefix = $is_selected ? '> ' : ' '; + + // Suffix for current item + $suffix = $is_current ? ' ✓' : ''; + + // Color + if ( $is_selected ) { + echo colorize( "{$prefix}{$item}{$suffix}\n" ); + } else { + echo "{$prefix}{$item}{$suffix}\n"; + } + $lines_rendered++; + } + + // Scroll indicators + if ( $scroll_offset > 0 ) { + echo colorize( " ↑ " . $scroll_offset . " more above...\n" ); + $lines_rendered++; + } + if ( $scroll_offset + $max_visible < $total_items ) { + $remaining = $total_items - ( $scroll_offset + $max_visible ); + echo colorize( " ↓ $remaining more below...\n" ); + $lines_rendered++; + } + + // Empty line if no results + if ( empty( $visible_items ) ) { + echo colorize( " No matches found\n" ); + $lines_rendered++; + } + + // Blank line before search + echo "\n"; + $lines_rendered++; + + // Search box + echo colorize( "Search: " ) . $search . "\n"; + $lines_rendered++; + + // Show cursor + tui_show_cursor(); + + return $lines_rendered; +} + +/** + * Display an interactive selection list with fuzzy search + * + * @param array $items Array of items to select from + * @param string|null $current Currently selected item (will be marked) + * @param string $prompt Prompt text to display + * @return string|null Selected item or null if cancelled + */ +function tui_select( array $items, $current = null, $prompt = 'Select:' ) { + if ( empty( $items ) ) { + return null; + } + + // Check if we're in a TTY + if ( ! tui_is_tty() ) { + echo colorize( "Interactive mode not available in this environment.\n" ); + echo colorize( "Available targets:\n" ); + foreach ( $items as $item ) { + $marker = ( $item === $current ) ? ' ✓' : ''; + echo " - {$item}{$marker}\n"; + } + echo "\nUsage: slic use \n"; + return null; + } + + // Save terminal state + $terminal_state = tui_terminal_raw_mode(); + + if ( $terminal_state === false ) { + echo colorize( "Failed to initialize terminal.\n" ); + return null; + } + + // Setup cleanup handlers + $cleanup_done = false; + $lines_rendered = 0; + $cleanup = function ( $selected_item = null ) use ( $terminal_state, &$cleanup_done, &$lines_rendered ) { + if ( $cleanup_done ) { + return; + } + $cleanup_done = true; + + // Move cursor back to TUI start position + if ( $lines_rendered > 0 ) { + tui_move_cursor_up( $lines_rendered ); + tui_cursor_home(); + } + + // Clear only TUI content (from cursor to end) + tui_clear_to_end(); + + // Restore terminal + tui_show_cursor(); + tui_terminal_restore( $terminal_state ); + + // Show confirmation message if item was selected + if ( $selected_item !== null ) { + echo colorize( "Selected: $selected_item\n" ); + } + }; + + // Register shutdown handler + register_shutdown_function( $cleanup ); + + // Hide cursor initially + tui_hide_cursor(); + + // State + $search = ''; + $selected_index = 0; + $scroll_offset = 0; + $max_visible = 12; + + // Filter items + $filtered = $items; + + // Initial render - capture lines rendered + $lines_rendered = tui_render( $filtered, $selected_index, $search, $current, $prompt, $scroll_offset, $max_visible ); + + // Main loop + try { + while ( true ) { + $key = tui_read_key(); + + if ( $key['type'] === 'none' ) { + continue; + } + + switch ( $key['type'] ) { + case 'escape': + case 'ctrl_c': + // Cancel - restore and return null + $cleanup( null ); + return null; + + case 'enter': + // Select current item + $selected_item = null; + if ( ! empty( $filtered ) && isset( $filtered[ $selected_index ] ) ) { + $selected_item = $filtered[ $selected_index ]; + } + $cleanup( $selected_item ); + return $selected_item; + + case 'arrow': + if ( $key['value'] === 'up' && $selected_index > 0 ) { + $selected_index--; + + // Adjust scroll if needed + if ( $selected_index < $scroll_offset ) { + $scroll_offset = $selected_index; + } + } elseif ( $key['value'] === 'down' && $selected_index < count( $filtered ) - 1 ) { + $selected_index++; + + // Adjust scroll if needed + if ( $selected_index >= $scroll_offset + $max_visible ) { + $scroll_offset = $selected_index - $max_visible + 1; + } + } + break; + + case 'backspace': + if ( strlen( $search ) > 0 ) { + $search = substr( $search, 0, -1 ); + $filtered = tui_filter_items( $items, $search ); + $selected_index = 0; + $scroll_offset = 0; + } + break; + + case 'char': + // Add to search (only printable characters) + if ( ord( $key['value'] ) >= 32 && ord( $key['value'] ) < 127 ) { + $search .= $key['value']; + $filtered = tui_filter_items( $items, $search ); + $selected_index = 0; + $scroll_offset = 0; + } + break; + } + + // Move cursor back to start of TUI before re-rendering + if ( $lines_rendered > 0 ) { + tui_move_cursor_up( $lines_rendered ); + tui_cursor_home(); + } + + // Re-render and capture new line count + $lines_rendered = tui_render( $filtered, $selected_index, $search, $current, $prompt, $scroll_offset, $max_visible ); + } + } finally { + // Always restore terminal + $cleanup(); + } +} diff --git a/src/utils.php b/src/utils.php index be82294..35c0920 100644 --- a/src/utils.php +++ b/src/utils.php @@ -45,7 +45,7 @@ function args( array $map = [], ?array $source = null, $offset = 1 ) { } return static function ( $key, $default = null ) use ( $full_map ) { - return null !== $full_map[ $key ] ? $full_map[ $key ] : $default; + return $full_map[ $key ] ?? $default; }; } @@ -294,7 +294,13 @@ function is_ci() { return false; } -// Whether the current run context is a `slic` binary one or not. +/** + * Returns whether the current run context is a slic binary one or not. + * + * Checks environment variables to determine if the code is running in a slic context. + * + * @return bool Whether the current run context is a slic binary one or not. + */ function is_slic() { $env_vars = [ 'STELLAR_SLIC', @@ -717,3 +723,23 @@ function normalize_php_version( $version ) { return $version; } + +/** + * Gets the state file path for a specific stack. + * + * @param string|null $stack_id The stack identifier. If null, returns the default .env.slic.run path. + * + * @return string The absolute path to the stack's state file. + */ +function get_stack_env_file( $stack_id = null ) { + if ( null === $stack_id ) { + return root( '.env.slic.run' ); + } + + // Load stacks.php functions if not already loaded + if ( ! function_exists( 'slic_stacks_get_state_file' ) ) { + require_once __DIR__ . '/stacks.php'; + } + + return slic_stacks_get_state_file( $stack_id ); +} diff --git a/src/worktree-utils.php b/src/worktree-utils.php new file mode 100644 index 0000000..1217fa0 --- /dev/null +++ b/src/worktree-utils.php @@ -0,0 +1,255 @@ + 200 ) { + return false; + } + + // Whitelist validation: only alphanumeric, dash, underscore + if ( ! preg_match( '/^[a-zA-Z0-9_-]+$/', $name ) ) { + return false; + } + + // No further sanitization needed + return $name; +} + +/** + * Validates a branch name for git worktree creation. + * + * Enforces git branch naming rules to prevent command injection + * and ensure compatibility with git worktree commands. + * + * Git branch naming rules: + * - No ".." (parent directory reference) + * - No "~", "^", ":", "?", "*", "[" (special git characters) + * - Cannot start or end with "/" + * - Cannot contain consecutive slashes + * - Length: 1-250 characters + * + * @param string $branch The branch name to validate. + * + * @return bool True if valid, false otherwise. + */ +function slic_worktree_validate_branch( $branch ) { + // Check for empty or null + if ( ! is_string( $branch ) || empty( $branch ) ) { + return false; + } + + // Length validation (1-250 characters) + if ( strlen( $branch ) < 1 || strlen( $branch ) > 250 ) { + return false; + } + + // Invalid patterns that violate git branch naming rules + $invalid_patterns = [ + '/\.\./', // No parent directory references + '/[\~\^\:\?\*\[\\\]/', // No special git chars (added backslash) + '/^\//', // No leading slash + '/\/$/', // No trailing slash + '/\/\//', // No consecutive slashes + '/\.lock$/', // No .lock suffix + '/[\x00-\x1F\x7F]/', // No control characters + '/\s/', // No spaces + ]; + + foreach ( $invalid_patterns as $pattern ) { + if ( preg_match( $pattern, $branch ) ) { + return false; + } + } + + // Check for reserved git ref name + if ( $branch === '@' ) { + return false; + } + + return true; +} + +/** + * Creates a filesystem-safe worktree directory name from target and branch. + * + * Combines the target plugin/theme name with the branch name to create + * a unique, filesystem-safe directory name for the worktree. + * + * Example: + * - Target: "the-events-calendar" + * - Branch: "fix/issue-123" + * - Result: "the-events-calendar-fix-issue-123" + * + * @param string $target The target plugin/theme name. + * @param string $branch The git branch name. + * + * @return string|false The sanitized directory name or false if invalid. + */ +function slic_worktree_create_dir_name( $target, $branch ) { + // Validate branch name first + if ( ! slic_worktree_validate_branch( $branch ) ) { + return false; + } + + // Validate target is not empty + if ( empty( $target ) || ! is_string( $target ) ) { + return false; + } + + // Sanitize target first to prevent @ or other special chars + $sanitized_target = slic_worktree_sanitize_dir_name( $target ); + if ( $sanitized_target === false ) { + return false; + } + + // Convert slashes in branch name to dashes for filesystem safety + // e.g., "feature/new-thing" -> "feature-new-thing" + $branch_slug = str_replace( '/', '-', $branch ); + + // Combine target and branch slug + $dir_name = $sanitized_target . '-' . $branch_slug; + + // Apply final sanitization to ensure the result is filesystem-safe + return slic_worktree_sanitize_dir_name( $dir_name ); +} + +/** + * Checks if a directory is already a git worktree for a specific target repository. + * + * Uses 'git worktree list --porcelain' to verify if the directory is already + * registered as a worktree in git. This prevents attempting to create a worktree + * that already exists. + * + * @param string $target_path The path to the main repository (e.g., plugin directory). + * @param string $worktree_path The full path to the potential worktree directory. + * @param string $expected_branch Optional. The expected branch name to verify. + * + * @return array|false Array with worktree info if exists, false otherwise. + * Array keys: 'path', 'branch', 'head' + */ +function slic_worktree_is_existing( $target_path, $worktree_path, $expected_branch = null ) { + // Validate inputs + if ( ! is_dir( $target_path ) || ! file_exists( $worktree_path ) ) { + return false; + } + + // Get worktree list in porcelain format for parsing + $escaped_target = escapeshellarg( $target_path ); + $cmd = "git -C " . $escaped_target . " worktree list --porcelain 2>/dev/null"; + $output = shell_exec( $cmd ); + + if ( empty( $output ) ) { + return false; + } + + // Parse porcelain output + // Format: + // worktree /path/to/worktree + // HEAD + // branch refs/heads/ + // + // + // Note: The main repository appears as the first entry in git worktree list. + // This is normal git behavior and not an error. + $worktrees = []; + $current_worktree = []; + + $lines = explode( "\n", trim( $output ) ); + foreach ( $lines as $line ) { + $line = trim( $line ); + + // Empty line indicates end of a worktree entry + if ( empty( $line ) ) { + if ( ! empty( $current_worktree ) ) { + $worktrees[] = $current_worktree; + $current_worktree = []; + } + continue; + } + + // Parse line + if ( strpos( $line, 'worktree ' ) === 0 ) { + $current_worktree['path'] = trim( substr( $line, 9 ) ); + } elseif ( strpos( $line, 'HEAD ' ) === 0 ) { + $current_worktree['head'] = trim( substr( $line, 5 ) ); + } elseif ( strpos( $line, 'branch ' ) === 0 ) { + $branch_ref = trim( substr( $line, 7 ) ); + // Extract branch name from refs/heads/branch-name + if ( strpos( $branch_ref, 'refs/heads/' ) === 0 ) { + $current_worktree['branch'] = substr( $branch_ref, 11 ); + } + } + } + + // Don't forget the last worktree if file doesn't end with blank line + if ( ! empty( $current_worktree ) ) { + $worktrees[] = $current_worktree; + } + + // Normalize paths for comparison (resolve symlinks, etc.) + // realpath() can return false for paths that don't exist yet or are not accessible. + // Fall back to the original path in such cases for comparison. + $worktree_real = realpath( $worktree_path ); + if ( $worktree_real === false ) { + $worktree_real = $worktree_path; + } + + // Search for matching worktree + foreach ( $worktrees as $wt ) { + if ( empty( $wt['path'] ) ) { + continue; + } + + $wt_real = realpath( $wt['path'] ); + if ( $wt_real === false ) { + $wt_real = $wt['path']; + } + + // Check if paths match + if ( $wt_real === $worktree_real ) { + // If expected branch is specified, verify it matches + if ( $expected_branch !== null && ! empty( $wt['branch'] ) ) { + if ( $wt['branch'] !== $expected_branch ) { + // Worktree exists but for wrong branch + return [ + 'path' => $wt['path'], + 'branch' => $wt['branch'], + 'head' => $wt['head'] ?? null, + 'error' => 'branch_mismatch', + ]; + } + } + + // Worktree exists and branch matches (or not checked) + return [ + 'path' => $wt['path'], + 'branch' => $wt['branch'] ?? null, + 'head' => $wt['head'] ?? null, + ]; + } + } + + return false; +} diff --git a/src/xdebug.php b/src/xdebug.php new file mode 100644 index 0000000..496cb34 --- /dev/null +++ b/src/xdebug.php @@ -0,0 +1,358 @@ +`. + $ide_key = slic_stacks_xdebug_server_name( $stack_id ); + } else { + // Final fallback when no stack is detected + $ide_key = 'slic'; + } + + // Get the WordPress port from the stack registry if available + $localhost_port = null; + $stack_has_localhost_port = false; + if ( null !== $stack_id ) { + slic_stacks_ensure_ports( $stack_id ); // Refresh ports from Docker + $stack = slic_stacks_get( $stack_id ); + if ( null !== $stack && isset( $stack['ports']['wp'] ) ) { + $localhost_port = $stack['ports']['wp']; + $stack_has_localhost_port = true; + } + } + + // Fall back to WORDPRESS_HTTP_PORT env var or default + if ( empty( $localhost_port ) ) { + $localhost_port = getenv( 'WORDPRESS_HTTP_PORT' ); + } + if ( empty( $localhost_port ) ) { + $localhost_port = '8888'; + } + + // Show current stack information if multi-stack is active + if ( function_exists( 'slic_stacks_list' ) ) { + $all_stacks = slic_stacks_list(); + $stack_count = count( $all_stacks ); + + // Helper function to display stack information + $display_stack_info = function( $stack_id, $label_prefix = 'Stack' ) { + if ( ! function_exists( 'slic_stacks_get' ) ) { + echo colorize( 'Warning: Stack functions not available.' ) . PHP_EOL; + return; + } + $stack = slic_stacks_get( $stack_id ); + if ( null !== $stack ) { + echo colorize( $label_prefix . ': ' . $stack_id . '' ) . PHP_EOL; + echo colorize( 'Project: ' . $stack['project_name'] . '' ) . PHP_EOL . PHP_EOL; + } else { + echo colorize( 'Warning: Stack ID "' . $stack_id . '" not found.' ) . PHP_EOL; + echo colorize( 'Showing global XDebug settings from .env.slic files.' ) . PHP_EOL . PHP_EOL; + } + }; + + if ( $stack_count > 1 ) { + // Multiple stacks exist + if ( null !== $stack_id ) { + // Show which stack we're displaying config for + $display_stack_info( $stack_id, 'XDebug configuration for stack' ); + } else { + // No active stack found + echo colorize( 'No active stack found. Showing global XDebug settings from .env.slic files.' ) . PHP_EOL . PHP_EOL; + } + } elseif ( $stack_count === 1 ) { + // Single stack - backward compatible display + if ( null !== $stack_id ) { + $display_stack_info( $stack_id ); + } + } + // If no stacks exist, don't show stack info at all + } + + echo 'XDebug status is: ' . ( $enabled ? light_cyan( 'on' ) : magenta( 'off' ) ) . PHP_EOL; + echo 'Remote host: ' . light_cyan( getenv( 'XDH' ) ) . PHP_EOL; + $remote_port = $stack_id ? slic_stacks_xdebug_port($stack_id) : getenv('XDP'); + echo 'Remote port: ' . light_cyan( $remote_port ) . PHP_EOL; + + echo 'IDE Key (server name): ' . light_cyan( $ide_key ) . PHP_EOL; + echo colorize( PHP_EOL . "You can override these values in the .env.slic.local" . + " file or by using the " . + "'xdebug (host|key|port) ' command." ) . PHP_EOL; + + + echo PHP_EOL . 'Set up, in your IDE, a server with the following parameters to debug PHP requests:' . PHP_EOL; + echo 'IDE key, or server name: ' . light_cyan( $ide_key ) . PHP_EOL; + if ($stack_id !== null) { + if($stack_has_localhost_port){ + echo 'Host: ' . light_cyan( 'http://localhost' . ( $localhost_port === '80' ? '' : ':' . $localhost_port ) ) . PHP_EOL; + } else { + echo 'Host: ' . yellow( 'not available until containers start' ) . PHP_EOL; + } + } else { + echo 'Host: ' . light_cyan( 'http://localhost' . ( $localhost_port === '80' ? '' : ':' . $localhost_port ) ) . PHP_EOL; + } + echo colorize( 'Path mapping (host => server): ' + . slic_plugins_dir() + . ' => /var/www/html/wp-content/plugins' ) . PHP_EOL; + echo colorize( 'Path mapping (host => server): ' + . slic_wp_dir() + . ' => /var/www/html' ); + + // Add worktree-specific path mapping if this is a worktree stack + if ( null !== $stack_id && slic_stacks_is_worktree( $stack_id ) ) { + // Get stack data (reuse if already fetched) + if ( ! isset( $stack ) ) { + $stack = slic_stacks_get( $stack_id ); + } + + if ( ! empty( $stack['is_worktree'] ) ) { + // Validate required fields - corrupted stack entries may be missing these + $worktree_path = $stack['worktree_full_path'] ?? null; + $base_stack_id = $stack['base_stack_id'] ?? null; + + // Only proceed if we have all required data + if ( ! empty( $worktree_path ) && ! empty( $base_stack_id ) ) { + // Determine type by checking if base_stack_id is under plugins or themes directory + // base_stack_id is the absolute path to the base stack directory + $plugins_dir = realpath( slic_plugins_dir() ); + $themes_dir = realpath( slic_themes_dir() ); + $base_path = realpath( $base_stack_id ); + + $is_plugin = null; + if ( $base_path && $plugins_dir && strpos( $base_path, $plugins_dir ) === 0 ) { + $is_plugin = true; + } elseif ( $base_path && $themes_dir && strpos( $base_path, $themes_dir ) === 0 ) { + $is_plugin = false; // It's a theme + } + + // Only display mapping if we successfully determined the type + if ( $is_plugin !== null ) { + // Get the worktree target (plugin or theme name) + $target = ! empty( $stack['worktree_target'] ) ? $stack['worktree_target'] : $stack['target']; + + // Validate target is not empty + if ( ! empty( $target ) ) { + // Build the container path + if ( $is_plugin ) { + $container_path = '/var/www/html/wp-content/plugins/' . $target; + } else { + $container_path = '/var/www/html/wp-content/themes/' . $target; + } + + // Display the worktree path mapping + echo PHP_EOL . colorize( 'Path mapping (host => server): ' + . $worktree_path + . ' => ' . $container_path . '' ); + } + } + } + } + } + + $default_mask = ( slic_wp_dir() === root( '/_wordpress' ) ) + 2 * ( slic_plugins_dir() === root( '/_plugins' ) ); + + switch ( $default_mask ) { + case 1: + echo PHP_EOL . PHP_EOL; + echo yellow( 'Note: slic is using the default WordPress directory and a different plugins directory: ' . + 'set path mappings correctly and keep that in mind.' ); + break; + case 2: + echo PHP_EOL . PHP_EOL; + echo yellow( 'Note: slic is using the default plugins directory and a different WordPress directory: ' . + 'set path mappings correctly and keep that in mind.' ); + break; + case 3: + default: + break; + } +} + +/** + * Handles the XDebug command request. + * + * @since 3.0.0 + * + * @param callable $args The closure that will produce the current XDebug request arguments. + */ +function slic_handle_xdebug( callable $args ) { + // Get the current stack's run file + $stack_id = slic_current_stack(); + if ( null !== $stack_id ) { + $run_settings_file = get_stack_env_file( $stack_id ); + } else { + // Fall back to legacy file if no stack + $run_settings_file = root( '/.env.slic.run' ); + } + $toggle = $args( 'toggle', 'on' ); + + if ( 'status' === $toggle ) { + xdebug_status( $stack_id ); + + return; + } + + $map = [ + 'host' => 'XDH', + 'key' => 'XDK', + 'port' => 'XDP', + ]; + if ( array_key_exists( $toggle, $map ) ) { + $var = $args( 'value' ); + echo colorize( "Setting {$map[$toggle]}={$var}" ) . PHP_EOL . PHP_EOL; + write_env_file( $run_settings_file, [ $map[ $toggle ] => $var ], true ); + echo PHP_EOL . PHP_EOL . colorize( "Tear down the stack with down and restart it to apply the new settings!" . PHP_EOL ); + + return; + } + + $value = 'on' === $toggle ? 1 : 0; + echo 'XDebug status: ' . ( $value ? light_cyan( 'on' ) : magenta( 'off' ) ) . PHP_EOL; + + if ( $value !== (int) getenv( 'XDE' ) ) { + $xdebug_env_vars = [ 'XDE' => $value, 'XDEBUG_DISABLE' => 1 === $value ? 0 : 1 ]; + write_env_file( $run_settings_file, $xdebug_env_vars, true ); + } + + foreach ( [ 'slic', 'wordpress' ] as $service ) { + if ( ! service_running( $service ) ) { + continue; + } + + echo PHP_EOL; + + if ( $value === 1 ) { + // Enable XDebug in the service. + echo colorize( "Enabling XDebug in {$service}..." ); + slic_realtime()( [ 'exec', $service, 'xdebug-on' ] ); + } else { + echo colorize( "Disabling XDebug in {$service}..." ); + // Disable XDebug in the service. + slic_realtime()( [ 'exec', $service, 'xdebug-off' ] ); + } + } +} + +/** + * Returns an array of environment variables to set up XDebug for the current stack, if any. + * + * @since TBD + * + * @param string|null $stack_id The stack ID to get the env vars for. + * + * @return array{XDP?: string, XDE?: string} A map of the XDebug env vars to set, if any, + */ +function xdebug_get_env_vars($stack_id = null):array{ + if ( null === $stack_id ) { + return []; + } + + $stack = slic_stacks_get( $stack_id ); + + // Always set XDebug values from stack registry to ensure stack-specific values are used + // These will override default values from .env.slic, but can still be overridden by + // .env.slic.local files loaded later + if ( null == $stack ) { + return []; + } + + $env_vars = []; + + if ( isset( $stack['xdebug_port'] ) ) { + $env_vars['XDP'] = $stack['xdebug_port']; + } + + if ( isset( $stack['xdebug_key'] ) ) { + $env_vars['XDK'] = $stack['xdebug_key']; + } + + return $env_vars; +} + +/** + * Sets up XDebug environment variables from the stack registry. + * + * This function loads XDebug configuration from the stack registry and sets + * the appropriate environment variables (XDP, XDK, XDH). It should be called + * during environment setup to ensure stack-specific XDebug values are used. + * + * Early return when $stack_id is null is correct behavior - without a stack ID, + * there's no stack-specific XDebug configuration to load from the registry. + * In this case, default XDebug values from .env.slic files will be used. + * + * @since 3.0.0 + * + * @param string|null $stack_id Optional stack ID. If not provided, no stack-specific values are loaded. + */ +function xdebug_setup_env_vars( $stack_id = null ) { + $env_vars = xdebug_get_env_vars( $stack_id ); + + foreach($env_vars as $key => $value){ + putenv( $key . '=' . $value ); + } +} + +/** + * Sets up the XDebug host environment variable for Linux. + * + * On Linux, the host.docker.internal hostname needs to be explicitly set + * for XDebug to work properly. This function sets the 'host' environment + * variable to the XDH value or falls back to 'host.docker.internal'. + * + * This function is called during Docker Compose setup (in docker_compose() function) + * to ensure the Linux host environment is properly configured before starting containers. + * + * This should only be called on Linux systems. + * + * @since 3.0.0 + */ +function xdebug_setup_linux_host() { + if ( PHP_OS === 'Linux' ) { + putenv( sprintf( 'host=%s', getenv( 'XDH' ) ?: 'host.docker.internal' ) ); + } +} + +/** + * Returns the list of XDebug-related environment variables for display. + * + * This function is used by slic_info() to determine which XDebug environment + * variables should be displayed to the user when they run the 'info' command. + * + * @since 3.0.0 + * + * @return array Array of XDebug environment variable names. + */ +function xdebug_get_info_vars() { + return [ + 'XDK', + 'XDE', + 'XDH', + 'XDP', + ]; +} diff --git a/tests/Cli/BaseTestCase.php b/tests/Cli/BaseTestCase.php new file mode 100644 index 0000000..1d0b145 --- /dev/null +++ b/tests/Cli/BaseTestCase.php @@ -0,0 +1,100 @@ +initialDir = getcwd(); + } + + public function tearDown(): void { + // Restore working directory first so stack stop can resolve paths. + chdir( $this->initialDir ); + + // Unregister any stacks created during the test. + foreach ( $this->createdStackIds as $stackId ) { + $this->slicExec( + 'stack stop ' . escapeshellarg( $stackId ), + $this->dockerMockEnv() + ); + } + + parent::tearDown(); + } + + /** + * Execute a slic command and return the output. + * + * @param string $command The command to execute, escaped if required. + * @param array $env Optional environment variables to set for the command. + * + * @return string The command output. + */ + protected function slicExec( string $command, array $env = [] ): string { + $env['NO_COLOR'] = '1'; + + $envString = ''; + foreach ( $env as $key => $value ) { + $envString .= $key . '=' . escapeshellarg( $value ) . ' '; + } + + $commandString = $envString . 'php ' . escapeshellarg( dirname( __DIR__, 2 ) . '/slic.php' ) . ' ' . $command; + + // Redirect stderr to stdout to capture all output. + return (string) shell_exec( $commandString . ' 2>&1' ); + } + + /** + * Returns env vars that mock docker binaries so that no real docker commands are executed. + * + * @return array + */ + protected function dockerMockEnv(): array { + return [ + 'SLIC_DOCKER_BIN' => self::$dockerMockBin, + 'SLIC_DOCKER_COMPOSE_BIN' => self::$dockerMockBin, + ]; + } + + /** + * Creates a temporary plugins directory with a plugin, chdirs into it, and runs `slic here`. + * + * The created stack is automatically unregistered during tearDown. + * + * @param string $pluginName The name of the plugin directory to create. + * + * @return string The absolute path to the plugins directory. + */ + protected function setUpPluginsDir( string $pluginName = 'test-plugin' ): string { + $pluginsDir = Directory::createTemp() + ->createPlugin( $pluginName ) + ->getAbsolutePath(); + chdir( $pluginsDir ); + $this->slicExec( 'here' ); + + // Track the stack ID (resolved path) for cleanup. + $this->createdStackIds[] = realpath( $pluginsDir ); + + return $pluginsDir; + } +} diff --git a/tests/Cli/CliVersionTest.php b/tests/Cli/CliVersionTest.php new file mode 100644 index 0000000..8ef6286 --- /dev/null +++ b/tests/Cli/CliVersionTest.php @@ -0,0 +1,13 @@ +slicExec('help'); + + $this->assertStringContainsString('slic version 3.0.0', $output); + } +} diff --git a/tests/Cli/PHPVersionTest.php b/tests/Cli/PHPVersionTest.php new file mode 100644 index 0000000..277a975 --- /dev/null +++ b/tests/Cli/PHPVersionTest.php @@ -0,0 +1,342 @@ +setUpPluginsDir(); + + $this->assertStringContainsString( + 'PHP version currently set to 7.4', + $this->slicExec( 'php-version' ), + 'The general PHP version should be the default one.' + ); + + $this->slicExec( 'use test-plugin' ); + + $this->assertStringContainsString( + 'PHP version currently set to 7.4', + $this->slicExec( 'php-version' ), + 'The plugin PHP version should be the default one.' + ); + } + + public function test_php_version_set_with_skip_rebuild_stages_version(): void { + $this->setUpPluginsDir(); + + $output = $this->slicExec( 'php-version set 8.1 --skip-rebuild' ); + + $this->assertStringContainsString( + 'PHP version staged for one time use: 8.1', + $output, + 'The version should be staged.' + ); + + $output = $this->slicExec( 'php-version' ); + + $this->assertStringContainsString( + 'PHP version is staged to switch to 8.1', + $output, + 'The php-version command should report the staged version.' + ); + } + + public function test_php_version_reset_with_skip_rebuild_resets_to_default(): void { + $this->setUpPluginsDir(); + + // Set to 8.1 first. + $this->slicExec( 'php-version set 8.1 --skip-rebuild' ); + + // Reset to default. + $output = $this->slicExec( 'php-version reset --skip-rebuild' ); + + $this->assertStringContainsString( + 'Resetting PHP version to: 7.4', + $output, + 'The reset output should mention 7.4.' + ); + } + + public function test_staged_version_is_applied_on_slic_use(): void { + $this->setUpPluginsDir(); + + // Stage version 8.2. + $this->slicExec( 'php-version set 8.2 --skip-rebuild' ); + + // Run slic use to apply the staged version; this triggers docker, so mock it. + $output = $this->slicExec( 'use test-plugin', $this->dockerMockEnv() ); + + $this->assertStringContainsString( + 'PHP 8.2 (using staged version)', + $output, + 'The staged version should be applied during slic use.' + ); + + $this->assertStringContainsString( + 'PHP version set: 8.2', + $output, + 'The version should be set during slic use.' + ); + } + + public function test_auto_detection_from_composer_json(): void { + $pluginsDir = $this->setUpPluginsDir(); + + // Create a composer.json with config.platform.php in the plugin directory. + file_put_contents( + $pluginsDir . '/test-plugin/composer.json', + json_encode( [ + 'config' => [ + 'platform' => [ + 'php' => '8.2.10', + ], + ], + ] ) + ); + + // Run slic use, which triggers auto-detection and docker; mock docker. + $output = $this->slicExec( 'use test-plugin', $this->dockerMockEnv() ); + + $this->assertStringContainsString( + 'PHP 8.2 (auto-detected from project)', + $output, + 'The PHP version should be auto-detected from composer.json.' + ); + + $output = $this->slicExec( 'php-version' ); + + $this->assertStringContainsString( + '8.2', + $output, + 'The php-version command should show the auto-detected version.' + ); + } + + public function test_auto_detection_from_slic_json(): void { + $pluginsDir = $this->setUpPluginsDir(); + + // Create a slic.json with phpVersion in the plugin directory. + file_put_contents( + $pluginsDir . '/test-plugin/slic.json', + json_encode( [ 'phpVersion' => '8.3' ] ) + ); + + $output = $this->slicExec( 'use test-plugin', $this->dockerMockEnv() ); + + $this->assertStringContainsString( + 'PHP 8.3 (auto-detected from project)', + $output, + 'The PHP version should be auto-detected from slic.json.' + ); + } + + public function test_slic_json_takes_priority_over_composer_json(): void { + $pluginsDir = $this->setUpPluginsDir(); + + // Create both files with different versions. + file_put_contents( + $pluginsDir . '/test-plugin/composer.json', + json_encode( [ + 'config' => [ + 'platform' => [ + 'php' => '8.1.0', + ], + ], + ] ) + ); + + file_put_contents( + $pluginsDir . '/test-plugin/slic.json', + json_encode( [ 'phpVersion' => '8.3' ] ) + ); + + $output = $this->slicExec( 'use test-plugin', $this->dockerMockEnv() ); + + $this->assertStringContainsString( + 'PHP 8.3 (auto-detected from project)', + $output, + 'slic.json should take priority over composer.json.' + ); + } + + public function test_cli_override_via_env_var(): void { + $pluginsDir = $this->setUpPluginsDir(); + + // Create a composer.json to verify CLI overrides project detection. + file_put_contents( + $pluginsDir . '/test-plugin/composer.json', + json_encode( [ + 'config' => [ + 'platform' => [ + 'php' => '8.1.0', + ], + ], + ] ) + ); + + $env = array_merge( $this->dockerMockEnv(), [ + 'SLIC_PHP_VERSION' => '8.4', + ] ); + + $output = $this->slicExec( 'use test-plugin', $env ); + + $this->assertStringContainsString( + 'PHP 8.4 (CLI override - temporary)', + $output, + 'The CLI override should take priority.' + ); + } + + public function test_project_env_slic_local_override(): void { + $pluginsDir = $this->setUpPluginsDir(); + + // Create a .env.slic.local in the plugin directory with a PHP version override. + file_put_contents( + $pluginsDir . '/test-plugin/.env.slic.local', + "SLIC_PHP_VERSION=8.0\n" + ); + + $output = $this->slicExec( 'use test-plugin', $this->dockerMockEnv() ); + + $this->assertStringContainsString( + "PHP 8.0 (from project's .env.slic.local)", + $output, + 'The .env.slic.local override should be applied.' + ); + } + + public function test_php_version_set_rejects_invalid_format(): void { + $this->setUpPluginsDir(); + + $output = $this->slicExec( 'php-version set 8' ); + + $this->assertStringContainsString( + 'Error: set-version requires a PHP version number with a single dot', + $output, + 'A version without a dot should be rejected.' + ); + + $output = $this->slicExec( 'php-version set 8.1.10' ); + + $this->assertStringContainsString( + 'Error: set-version requires a PHP version number with a single dot', + $output, + 'A version with two dots should be rejected.' + ); + + $output = $this->slicExec( 'php-version set abc' ); + + $this->assertStringContainsString( + 'Error: set-version requires a PHP version number with a single dot', + $output, + 'A non-numeric version should be rejected.' + ); + } + + public function test_cli_override_takes_priority_over_staged_version(): void { + $this->setUpPluginsDir(); + + // Stage version 8.1. + $this->slicExec( 'php-version set 8.1 --skip-rebuild' ); + + // Override with CLI env var. + $env = array_merge( $this->dockerMockEnv(), [ + 'SLIC_PHP_VERSION' => '8.4', + ] ); + + $output = $this->slicExec( 'use test-plugin', $env ); + + $this->assertStringContainsString( + 'PHP 8.4 (CLI override - temporary)', + $output, + 'The CLI override should take priority over the staged version.' + ); + } + + public function test_project_env_slic_local_takes_priority_over_auto_detection(): void { + $pluginsDir = $this->setUpPluginsDir(); + + // Set up both .env.slic.local and composer.json with different versions. + file_put_contents( + $pluginsDir . '/test-plugin/.env.slic.local', + "SLIC_PHP_VERSION=8.0\n" + ); + + file_put_contents( + $pluginsDir . '/test-plugin/composer.json', + json_encode( [ + 'config' => [ + 'platform' => [ + 'php' => '8.2.0', + ], + ], + ] ) + ); + + $output = $this->slicExec( 'use test-plugin', $this->dockerMockEnv() ); + + $this->assertStringContainsString( + "PHP 8.0 (from project's .env.slic.local)", + $output, + '.env.slic.local should take priority over composer.json auto-detection.' + ); + } + + public function test_composer_json_version_below_7_4_is_ignored(): void { + $pluginsDir = $this->setUpPluginsDir(); + + // Create a composer.json with PHP version below 7.4. + file_put_contents( + $pluginsDir . '/test-plugin/composer.json', + json_encode( [ + 'config' => [ + 'platform' => [ + 'php' => '7.2.0', + ], + ], + ] ) + ); + + $output = $this->slicExec( 'use test-plugin', $this->dockerMockEnv() ); + + // Should not detect any version, falling back to default. + $this->assertStringNotContainsString( + 'auto-detected from project', + $output, + 'A PHP version below 7.4 should not be auto-detected.' + ); + + $output = $this->slicExec( 'php-version' ); + + $this->assertStringContainsString( + '7.4', + $output, + 'The default version should remain 7.4 when composer.json specifies a version below 7.4.' + ); + } + + public function test_composer_json_long_version_is_normalized(): void { + $pluginsDir = $this->setUpPluginsDir(); + + // Create a composer.json with a three-part version. + file_put_contents( + $pluginsDir . '/test-plugin/composer.json', + json_encode( [ + 'config' => [ + 'platform' => [ + 'php' => '8.1.25', + ], + ], + ] ) + ); + + $output = $this->slicExec( 'use test-plugin', $this->dockerMockEnv() ); + + $this->assertStringContainsString( + 'PHP 8.1 (auto-detected from project)', + $output, + 'A three-part version should be normalized to major.minor.' + ); + } +} diff --git a/tests/Cli/README.md b/tests/Cli/README.md new file mode 100644 index 0000000..d84d576 --- /dev/null +++ b/tests/Cli/README.md @@ -0,0 +1,18 @@ +# CLI Tests + +These tests treat the `slic` CLI application as a black box. They control only: + +- Working directory +- Files on disk +- Environment variables +- Command-line options and arguments + +Tests must assert on either the CLI output (stdout/stderr, exit code) or its side-effects (created files, modified state). + +## Purpose + +These tests provide a **refactoring umbrella**: they validate the CLI's external behavior without any knowledge of specific files, classes, or functions in the implementation. Internal restructuring should not require changes to these tests as long as the CLI's behavior remains the same. + +## Framework + +The tests use PHPUnit for familiarity, but they are **CLI end-to-end tests**, not unit tests. Each test exercises the `slic` command as a user would. diff --git a/tests/_support/Factories/Directory.php b/tests/_support/Factories/Directory.php new file mode 100644 index 0000000..e212263 --- /dev/null +++ b/tests/_support/Factories/Directory.php @@ -0,0 +1,89 @@ +absolutePath = $this->createDirectory( $absolutePath ); + } + + /** + * Returns the directory absolute path. + * + * @return string The directory absolute path. + */ + public function getAbsolutePath(): string { + return $this->absolutePath; + } + + /** + * Factory method to create a new directory in the system temp directory. + * + * @param string|null $path The relative path of the directory to create, or `null` to generate a random path. + * + * @return self + * + * @throws RuntimeException If the directory already exists or cannot be created. + */ + public static function createTemp( ?string $path = null ): self { + $path ??= '/slic-test-plugins-dir-' . uniqid( '', true ); + $absolutePath = sys_get_temp_dir() . '/' . ltrim( $path, '/' ); + + return new self( $absolutePath ); + } + + /** + * Creates a plugin directory that contains an empty plugin main file. + * + * @param string $pluginDirectoryName The name of the plugin directory to create, e.g. `test-plugin`. + * + * @return $this + * + * @throws RuntimeException On failure to create the plugin directory or the main plugin file. + */ + public function createPlugin( string $pluginDirectoryName ): self { + /** @noinspection UnusedFunctionResultInspection */ + $this->createDirectory( $this->absolutePath . '/' . ltrim( $pluginDirectoryName, '/' ) ); + + $pluginFilePath = $this->absolutePath . "/$pluginDirectoryName/plugin.php"; + if ( ! file_put_contents( $pluginFilePath, "/**\n* Plugin Name: Test Plugin\n*/" ) ) { + throw new RuntimeException( "Failed to create $pluginFilePath" ); + } + + return $this; + } + + /** + * Creates a directory. + * + * @param string $absolutePath The absolute path to the directory to create. + * + * @return string The absolute path to the created directory. + * + * @throws RuntimeException On failure to create the directory or if the directory already exists. + */ + private function createDirectory( string $absolutePath ): string { + if ( is_dir( $absolutePath ) ) { + throw new RuntimeException( "Directory $absolutePath already exists." ); + } + + if ( ! mkdir( $absolutePath, 0777, true ) || ! is_dir( $absolutePath ) ) { + throw new RuntimeException( "Failed to create plugins directory $absolutePath" ); + } + + return $absolutePath; + } +} diff --git a/tests/_support/bin/docker-mock b/tests/_support/bin/docker-mock new file mode 100755 index 0000000..a286882 --- /dev/null +++ b/tests/_support/bin/docker-mock @@ -0,0 +1,3 @@ +#!/bin/bash +# A mock docker binary that does nothing and exits successfully. +exit 0 diff --git a/version.php b/version.php new file mode 100644 index 0000000..ced38ce --- /dev/null +++ b/version.php @@ -0,0 +1,3 @@ +