diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 0000000..67bbf6b --- /dev/null +++ b/.dockerignore @@ -0,0 +1,3 @@ +vendor/ +test-app/vendor/ +package/ diff --git a/.github/FUNDING.yml b/.github/FUNDING.yml index c68765b..e7e2d3d 100644 --- a/.github/FUNDING.yml +++ b/.github/FUNDING.yml @@ -1 +1 @@ -github: :vendor_name +github: datpmwork diff --git a/.github/ISSUE_TEMPLATE/config.yml b/.github/ISSUE_TEMPLATE/config.yml index 6474295..b8f0126 100644 --- a/.github/ISSUE_TEMPLATE/config.yml +++ b/.github/ISSUE_TEMPLATE/config.yml @@ -1,11 +1,11 @@ blank_issues_enabled: false contact_links: - name: Ask a question - url: https://github.com/:vendor_slug/:package_name/discussions/new?category=q-a + url: https://github.com/datpmwork/sls-tinker/discussions/new?category=q-a about: Ask the community for help - name: Request a feature - url: https://github.com/:vendor_slug/:package_name/discussions/new?category=ideas + url: https://github.com/datpmwork/sls-tinker/discussions/new?category=ideas about: Share ideas for new features - name: Report a security issue - url: https://github.com/:vendor_slug/:package_name/security/policy + url: https://github.com/datpmwork/sls-tinker/security/policy about: Learn how to notify us for sensitive bugs diff --git a/.github/actions/test-action/action.yaml b/.github/actions/test-action/action.yaml new file mode 100644 index 0000000..8052cb3 --- /dev/null +++ b/.github/actions/test-action/action.yaml @@ -0,0 +1,137 @@ +name: 'Test Platform' +description: 'Run tests for a specific PHP/Laravel/Platform combination' + +inputs: + php-version: + description: 'PHP version' + required: true + laravel-version: + description: 'Laravel version' + required: true + platform: + description: 'Platform (bref or vapor)' + required: true + +runs: + using: 'composite' + steps: + - name: Set environment variables + shell: bash + run: | + php_version="${{ inputs.php-version }}" + laravel_version="${{ inputs.laravel-version }}" + platform="${{ inputs.platform }}" + + php_tag="${php_version//./}" + laravel_tag="${laravel_version//[.*]/}" + + # Convert to numbers and calculate port: 9090 + php_tag + laravel_tag + base_port=$php_tag$laravel_tag + # Add offset based on platform + if [[ "$platform" == "vapor" ]]; then + port=$((base_port + 1)) + elif [[ "$platform" == "bref" ]]; then + port=$((base_port + 2)) + else + port=$base_port + fi + + echo "PHP_VERSION=$php_version" >> $GITHUB_ENV + echo "LARAVEL_VERSION=$laravel_version" >> $GITHUB_ENV + echo "PHP_TAG=$php_tag" >> $GITHUB_ENV + echo "LARAVEL_TAG=$laravel_tag" >> $GITHUB_ENV + echo "SLS_TINKER_LAMBDA_ENDPOINT=http://localhost:$port" >> $GITHUB_ENV + echo "PORT=$port" >> $GITHUB_ENV + echo "PLATFORM=$platform" >> $GITHUB_ENV + + - name: Setup PHP + uses: shivammathur/setup-php@v2 + with: + php-version: ${{ inputs.php-version }} + extensions: dom, curl, libxml, mbstring, zip, pcntl, pdo, sqlite, pdo_sqlite + coverage: none + + - name: Install dependencies & Laravel App + shell: bash + run: | + composer create-project "laravel/laravel:${{ inputs.laravel-version }}" --no-interaction app + cd app + php artisan key:generate + + - name: Install Bref + if: inputs.platform == 'bref' + shell: bash + run: | + cd app + composer require bref/bref:^2 --no-interaction + + - name: Install Vapor Core + if: inputs.platform == 'vapor' + shell: bash + run: | + cd app + composer require laravel/vapor-core --no-interaction + composer global require laravel/vapor-cli + mkdir -p ~/.laravel-vapor + cat < ~/.laravel-vapor/config.json + { + "token": "eyJ0eXAiOiJKV1QiLCJhbGciOiJSUzI1NiJ9.eyJhdWQiOiIxIiwianRpIjoiMDdjMmVlODhmYzkyNjQyNjg1YWI5N2FhMjIzNGU2MjYwZWMyM2RjYWQzYjJmOWM0YjVlY2JjY2YwZTU3MzJhNDI3NDRiZDI1NWIwYjg0NDEiLCJpYXQiOjE3NTUzNjQzOTIuMTE3NzExLCJuYmYiOjE3NTUzNjQzOTIuMTE3NzE0LCJleHAiOjE5MTMxMzA3OTIuMTA3ODU3LCJzdWIiOiI2ODk5OSIsInNjb3BlcyI6WyIqIl19.Ma0-GJjBvqs2irvHbW5oOrEJn0I-FCjSXV-zK20LIzwbgSfZNTMvViheazfO1roOow1UyANmzoPGnTyo2EndFM6G0p-UDiB03ce-36Bgj7JKOJ_156omPXsqrPhN7P9nDER1zmas9gM4UHwlHW2k67U9v050FjVT_TBeFvVe6gtWPKLZdTLTT5xmaqZvnAWbNUO_wq4exnmPnzFYVXpDYuuD2fgtzPXAzXauvm1BGR4YBMGvzSx1JCyc-OBnEwdsQIMAomU6lHrybQJzYefHugNx6oHIe2BQtMblsWQOPPSRqD5ciGTnzWdQ1PxBYd7FS3jWRoqv4FWJuTJo7krIPYiBfQtsUXaCnzOyx8pLpFiOCQE5HeldtdmT4KH5VXkYWsZKoA3_gdnTwyeoBaP8TvKt0mE6a6mQhMzasUkPk8-lASsRBHti5dqEu8FEQWWdEGm4--fzH7bN7i2vMsR9nUCXrJx0lLL7e3VL9j6igAKqQssK8gkxhd4q38g1L2esbdkNa6nbv8fGfPaWcOlbEyKog19pRIblCDj3wdtXNJjts1FNmxNSeYuxjKh5Wselx_PRkhLEbFoAIMmWbBEGdVC0p_gByt7yL3-wOFD69PA2SoctyoSKnae9E7w6V_OeDKrcXimbZugW6O0oCLbzhFslcZ0rMbaN0W3r-CRYUDA", + "team": 83505 + } + EOF + + - name: Install package from local path + shell: bash + working-directory: app + run: | + # Add local package to composer.json + composer config repositories.local path ../package + composer require datpmwork/sls-tinker:@dev + rm -rf vendor/datpmwork/sls-tinker + cp -r ../package vendor/datpmwork/sls-tinker + + - name: Vapor Build + shell: bash + working-directory: app + if: inputs.platform == 'vapor' + run: | + cat < vapor.yml + id: 72679 + name: vapor + environments: + production: + memory: 1024 + cli-memory: 512 + runtime: 'php-8.4:al2' + build: + - 'rm -rf bootstrap/cache/*' + - 'composer install --no-dev' + - 'php artisan event:cache' + # - 'npm ci && npm run build && rm -rf node_modules' + EOF + ~/.composer/vendor/bin/vapor build production || exit 0 + + - name: Create Docker Instance + shell: bash + run: | + if [[ "${{ inputs.platform }}" == "bref" ]]; then + docker build -t ${{ inputs.platform }}-$PHP_TAG-$LARAVEL_TAG -f package/tests/Dockerfile --build-arg PHP_VERSION=$PHP_TAG --target ${{ inputs.platform }} . + docker run -d --rm -p $PORT:8080 --name ${{ inputs.platform }}-$PHP_TAG-$LARAVEL_TAG ${{ inputs.platform }}-$PHP_TAG-$LARAVEL_TAG artisan + elif [[ "${{ inputs.platform }}" == "vapor" ]]; then + docker build -t ${{ inputs.platform }}-$PHP_TAG-$LARAVEL_TAG -f package/tests/Dockerfile --build-arg PHP_VERSION=$PHP_TAG --target ${{ inputs.platform }} . + docker run -d --rm -p $PORT:8080 -e VAPOR_SSM_PATH=test -e LAMBDA_TASK_ROOT=/var/task -e APP_RUNNING_IN_CONSOLE=true --name ${{ inputs.platform }}-$PHP_TAG-$LARAVEL_TAG ${{ inputs.platform }}-$PHP_TAG-$LARAVEL_TAG artisan + fi + + - name: Execute tests + shell: bash + working-directory: package + run: | + composer install + php vendor/bin/testbench package:sync-skeleton + SLS_PLATFORM=${{ inputs.platform }} ./vendor/bin/pest + + - name: Stop Lambda Instance + if: always() + shell: bash + run: | + docker stop ${{ inputs.platform }}-$PHP_TAG-$LARAVEL_TAG diff --git a/.github/workflows/run-tests.yml b/.github/workflows/run-tests.yml index adbcbe3..a251a79 100644 --- a/.github/workflows/run-tests.yml +++ b/.github/workflows/run-tests.yml @@ -1,63 +1,66 @@ -name: run-tests +name: Tests -on: - push: - paths: - - '**.php' - - '.github/workflows/run-tests.yml' - - 'phpunit.xml.dist' - - 'composer.json' - - 'composer.lock' - -concurrency: - group: ${{ github.workflow }}-${{ github.ref }} - cancel-in-progress: true +on: [push, pull_request] jobs: - test: - runs-on: ${{ matrix.os }} - timeout-minutes: 5 + # Test with Bref platform + test-bref: + runs-on: ubuntu-latest strategy: - fail-fast: true + fail-fast: false matrix: - os: [ubuntu-latest, windows-latest] - php: [8.4, 8.3] - laravel: [12.*, 11.*, 10.*] - stability: [prefer-lowest, prefer-stable] - include: - - laravel: 12.* - testbench: 10.* + php: [ 8.1, 8.2, 8.3, 8.4 ] + laravel: [ 10.*, 11.*, 12.* ] + stability: [ prefer-stable ] + exclude: + # Laravel 11 requires PHP 8.2+ - laravel: 11.* - testbench: 9.* - - laravel: 10.* - testbench: 8.* - - - name: P${{ matrix.php }} - L${{ matrix.laravel }} - ${{ matrix.stability }} - ${{ matrix.os }} + php: 8.1 + # Laravel 12 requires PHP 8.2+ + - laravel: 12.* + php: 8.1 + name: P${{ matrix.php }} - L${{ matrix.laravel }} - bref steps: - - name: Checkout code + - name: Checkout package code uses: actions/checkout@v4 + with: + path: ./package - - name: Setup PHP - uses: shivammathur/setup-php@v2 + - name: Run tests + uses: ./package/.github/actions/test-action with: php-version: ${{ matrix.php }} - extensions: dom, curl, libxml, mbstring, zip, pcntl, pdo, sqlite, pdo_sqlite, bcmath, soap, intl, gd, exif, iconv, imagick, fileinfo - coverage: none - - - name: Setup problem matchers - run: | - echo "::add-matcher::${{ runner.tool_cache }}/php.json" - echo "::add-matcher::${{ runner.tool_cache }}/phpunit.json" + laravel-version: ${{ matrix.laravel }} + platform: bref - - name: Install dependencies - run: | - composer require "laravel/framework:${{ matrix.laravel }}" "orchestra/testbench:${{ matrix.testbench }}" --no-interaction --no-update - composer update --${{ matrix.stability }} --prefer-dist --no-interaction + # Test with Vapor platform + test-vapor: + runs-on: ubuntu-latest + strategy: + fail-fast: false + matrix: + php: [ 8.1, 8.2, 8.3, 8.4 ] + laravel: [ 10.*, 11.*, 12.* ] + stability: [ prefer-stable ] + exclude: + # Laravel 11 requires PHP 8.2+ + - laravel: 11.* + php: 8.1 + # Laravel 12 requires PHP 8.2+ + - laravel: 12.* + php: 8.1 + name: P${{ matrix.php }} - L${{ matrix.laravel }} - vapor - - name: List Installed Dependencies - run: composer show -D + steps: + - name: Checkout package code + uses: actions/checkout@v4 + with: + path: ./package - - name: Execute tests - run: vendor/bin/pest --ci + - name: Run tests + uses: ./package/.github/actions/test-action + with: + php-version: ${{ matrix.php }} + laravel-version: ${{ matrix.laravel }} + platform: vapor diff --git a/CHANGELOG.md b/CHANGELOG.md index 87b3242..2409488 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,3 +1,3 @@ # Changelog -All notable changes to `:package_name` will be documented in this file. +All notable changes to `sls-tinker` will be documented in this file. diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..5f5090e --- /dev/null +++ b/Dockerfile @@ -0,0 +1,16 @@ +FROM bref/php-82-console:2 + +COPY --from=composer:latest /usr/bin/composer /usr/local/bin/composer +COPY --from=bref/extra-xdebug-php-82:1.8.2 /opt /opt + +COPY composer.json /var/package/ +COPY config/ /var/package/config/ +COPY src/ /var/package/src/ + +COPY composer.json ./ + +RUN composer install --no-autoloader --no-scripts + +COPY . /var/task + +RUN composer dump-autoload diff --git a/LICENSE.md b/LICENSE.md index 58c9ad4..c693f08 100644 --- a/LICENSE.md +++ b/LICENSE.md @@ -1,6 +1,6 @@ The MIT License (MIT) -Copyright (c) :vendor_name +Copyright (c) datpmwork Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal diff --git a/README.md b/README.md index 375da96..91de0cd 100644 --- a/README.md +++ b/README.md @@ -1,74 +1,98 @@ -# :package_description - -[![Latest Version on Packagist](https://img.shields.io/packagist/v/:vendor_slug/:package_slug.svg?style=flat-square)](https://packagist.org/packages/:vendor_slug/:package_slug) -[![GitHub Tests Action Status](https://img.shields.io/github/actions/workflow/status/:vendor_slug/:package_slug/run-tests.yml?branch=main&label=tests&style=flat-square)](https://github.com/:vendor_slug/:package_slug/actions?query=workflow%3Arun-tests+branch%3Amain) -[![GitHub Code Style Action Status](https://img.shields.io/github/actions/workflow/status/:vendor_slug/:package_slug/fix-php-code-style-issues.yml?branch=main&label=code%20style&style=flat-square)](https://github.com/:vendor_slug/:package_slug/actions?query=workflow%3A"Fix+PHP+code+style+issues"+branch%3Amain) -[![Total Downloads](https://img.shields.io/packagist/dt/:vendor_slug/:package_slug.svg?style=flat-square)](https://packagist.org/packages/:vendor_slug/:package_slug) - ---- -This repo can be used to scaffold a Laravel package. Follow these steps to get started: - -1. Press the "Use this template" button at the top of this repo to create a new repo with the contents of this skeleton. -2. Run "php ./configure.php" to run a script that will replace all placeholders throughout all the files. -3. Have fun creating your package. -4. If you need help creating a package, consider picking up our Laravel Package Training video course. ---- - -This is where your description should go. Limit it to a paragraph or two. Consider adding a small example. +# Tinker For Lambda (bref, vapor, etc) -## Support us +[![Latest Version on Packagist](https://img.shields.io/packagist/v/datpmwork/sls-tinker.svg?style=flat-square)](https://packagist.org/packages/datpmwork/sls-tinker) +[![GitHub Tests Action Status](https://img.shields.io/github/actions/workflow/status/datpmwork/sls-tinker/run-tests.yml?branch=main&label=tests&style=flat-square)](https://github.com/datpmwork/sls-tinker/actions?query=workflow%3Arun-tests+branch%3Amain) +[![GitHub Code Style Action Status](https://img.shields.io/github/actions/workflow/status/datpmwork/sls-tinker/fix-php-code-style-issues.yml?branch=main&label=code%20style&style=flat-square)](https://github.com/datpmwork/sls-tinker/actions?query=workflow%3A"Fix+PHP+code+style+issues"+branch%3Amain) +[![Total Downloads](https://img.shields.io/packagist/dt/datpmwork/sls-tinker.svg?style=flat-square)](https://packagist.org/packages/datpmwork/sls-tinker) -[](https://spatie.be/github-ad-click/:package_name) +**Seamless Local-to-Lambda Tinker Bridge with State Persistence** -We invest a lot of resources into creating [best in class open source packages](https://spatie.be/open-source). You can support us by [buying one of our paid products](https://spatie.be/open-source/support-us). +`sls-tinker` revolutionizes debugging and development for serverless Laravel applications by creating a transparent bridge between your local Tinker session and remote Lambda execution. Experience the familiar comfort of your local `php artisan tinker` while executing commands directly against your production Lambda environment with full state preservation across commands. -We highly appreciate you sending us a postcard from your hometown, mentioning which of our package(s) you are using. You'll find our address on [our contact page](https://spatie.be/about-us). We publish all received postcards on [our virtual postcard wall](https://spatie.be/open-source/postcards). +Unlike traditional approaches that require web interfaces or SSH access, this package maintains the native Tinker experience you know and love, while seamlessly forwarding each command to your remote Lambda function and preserving the execution state for subsequent commands. -## Installation +## Support us -You can install the package via composer: +You can support this project via [GitHub Sponsors](https://github.com/sponsors/datpmwork). + +## 🚀 How It Works ```bash -composer require :vendor_slug/:package_slug +# Start local tinker that connects to your Lambda +php artisan sls:tinker your-lambda-function-name ``` -You can publish and run the migrations with: +# All commands execute on Lambda but feel completely local -```bash -php artisan vendor:publish --tag=":package_slug-migrations" -php artisan migrate +```php +>>> $user = User::find(1) // Executes on Lambda +=> App\Models\User {#1234 // Result from Lambda + id: 1, + name: "John Doe", + email: "john@example.com", + } + +>>> $user->posts->count() // State preserved from previous command +=> 5 // $user variable still available + +>>> $posts = $user->posts()->latest()->take(3)->get() // Chaining works perfectly +=> Illuminate\Database\Eloquent\Collection {#5678 + all: [/* 3 posts */] + } ``` -You can publish the config file with: +## ✨ Key Features + +- 🖥️ **Native Local Experience** - Use your familiar local Tinker interface and shortcuts +- ⚡ **Lambda Execution** - Every command runs on your actual Lambda environment +- 💾 **Stateful Sessions** - Variables and state persist across commands seamlessly +- 🔄 **Automatic State Sync** - Previous command context automatically sent with each request +- 🌐 **Multi-Environment** - Switch between different Lambda deployments (staging, production) +- 🔍 **Full Laravel Integration** - Access models, services, facades - everything works as expected +- 📝 **Command History** - Full history support with up/down arrow navigation +- 🏃‍♂️ **Performance Optimized** - Efficient state serialization and minimal overhead + +## Why This Approach? + +**Traditional serverless debugging problems:** +- No SSH access to Lambda functions +- Can't run interactive commands in production +- Web-based tools feel foreign and limited +- State doesn't persist between commands +- Complex setup and authentication + +**`sls-tinker` Solution:** +- Keep using your local terminal and favorite tools +- Execute commands in the actual production environment +- Seamless state management across command invocations +- Zero learning curve - it's just Tinker +- Simple configuration and authentication -```bash -php artisan vendor:publish --tag=":package_slug-config" -``` +## Installation -This is the contents of the published config file: +You can install the package via composer: -```php -return [ -]; +```bash +composer require datpmwork/sls-tinker ``` -Optionally, you can publish the views using +You can publish the config file with: ```bash -php artisan vendor:publish --tag=":package_slug-views" +php artisan vendor:publish --tag="sls-tinker-config" ``` -## Usage +This is the contents of the published config file: ```php -$variable = new VendorName\Skeleton(); -echo $variable->echoPhrase('Hello, VendorName!'); +return [ +]; ``` ## Testing ```bash -composer test +./vendor/bin/pest ``` ## Changelog @@ -85,7 +109,7 @@ Please review [our security policy](../../security/policy) on how to report secu ## Credits -- [:author_name](https://github.com/:author_username) +- [datpmwork](https://github.com/datpmwork) - [All Contributors](../../contributors) ## License diff --git a/composer.json b/composer.json index 8f9b850..e3ee134 100644 --- a/composer.json +++ b/composer.json @@ -1,33 +1,36 @@ { - "name": ":vendor_slug/:package_slug", - "description": ":package_description", + "name": "datpmwork/sls-tinker", + "description": "Interactive Tinker For Lambda (bref, vapor, etc)", "keywords": [ - ":vendor_name", + "datpmwork", "laravel", - ":package_slug" + "sls-tinker" ], - "homepage": "https://github.com/:vendor_slug/:package_slug", + "homepage": "https://github.com/datpmwork/sls-tinker", "license": "MIT", "authors": [ { - "name": ":author_name", - "email": "author@domain.com", + "name": "datpmwork", + "email": "datpm@datpm.work", "role": "Developer" } ], "require": { - "php": "^8.4", + "php": "^7.4|^8.0", + "async-aws/core": "^1.26", + "async-aws/lambda": "^2.11", + "illuminate/contracts": "^10.0||^11.0||^12.0", "spatie/laravel-package-tools": "^1.16", - "illuminate/contracts": "^10.0||^11.0||^12.0" + "ext-json": "*" }, "require-dev": { "laravel/pint": "^1.14", "nunomaduro/collision": "^8.1.1||^7.10.0", "larastan/larastan": "^2.9||^3.0", "orchestra/testbench": "^10.0.0||^9.0.0||^8.22.0", - "pestphp/pest": "^3.0", - "pestphp/pest-plugin-arch": "^3.0", - "pestphp/pest-plugin-laravel": "^3.0", + "pestphp/pest": "^2.0|^3.0", + "pestphp/pest-plugin-arch": "^2.0|^3.0", + "pestphp/pest-plugin-laravel": "^2.0|^3.0", "phpstan/extension-installer": "^1.3||^2.0", "phpstan/phpstan-deprecation-rules": "^1.1||^2.0", "phpstan/phpstan-phpunit": "^1.3||^2.0", @@ -35,13 +38,13 @@ }, "autoload": { "psr-4": { - "VendorName\\Skeleton\\": "src/", - "VendorName\\Skeleton\\Database\\Factories\\": "database/factories/" + "DatPM\\SlsTinker\\": "src/", + "DatPM\\SlsTinker\\Database\\Factories\\": "database/factories/" } }, "autoload-dev": { "psr-4": { - "VendorName\\Skeleton\\Tests\\": "tests/", + "DatPM\\SlsTinker\\Tests\\": "tests/", "Workbench\\App\\": "workbench/app/" } }, @@ -63,11 +66,8 @@ "extra": { "laravel": { "providers": [ - "VendorName\\Skeleton\\SkeletonServiceProvider" - ], - "aliases": { - "Skeleton": "VendorName\\Skeleton\\Facades\\Skeleton" - } + "DatPM\\SlsTinker\\SlsTinkerServiceProvider" + ] } }, "minimum-stability": "dev", diff --git a/config/skeleton.php b/config/skeleton.php deleted file mode 100644 index 7e74186..0000000 --- a/config/skeleton.php +++ /dev/null @@ -1,6 +0,0 @@ - env('SLS_TINKER_LAMBDA_ENDPOINT', ''), + + /** + * Use to set the platform for SLS Tinker. + * The platform can be 'bref' or 'vapor'. + * - 'bref' is used for AWS Lambda with Bref. + * - 'vapor' is used for Laravel Vapor. + * If you are using AWS Lambda with Bref, you can leave this as 'bref'. + * If you are using a different platform or local development, set this to 'vapor' + */ + 'platform' => env('SLS_PLATFORM', 'bref'), +]; diff --git a/configure.php b/configure.php deleted file mode 100644 index f0a21d3..0000000 --- a/configure.php +++ /dev/null @@ -1,370 +0,0 @@ -#!/usr/bin/env php - $version) { - if (in_array($name, $names, true)) { - unset($data['require-dev'][$name]); - } - } - - file_put_contents(__DIR__.'/composer.json', json_encode($data, JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE)); -} - -function remove_composer_script($scriptName) -{ - $data = json_decode(file_get_contents(__DIR__.'/composer.json'), true); - - foreach ($data['scripts'] as $name => $script) { - if ($scriptName === $name) { - unset($data['scripts'][$name]); - break; - } - } - - file_put_contents(__DIR__.'/composer.json', json_encode($data, JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE)); -} - -function remove_readme_paragraphs(string $file): void -{ - $contents = file_get_contents($file); - - file_put_contents( - $file, - preg_replace('/.*/s', '', $contents) ?: $contents - ); -} - -function safeUnlink(string $filename) -{ - if (file_exists($filename) && is_file($filename)) { - unlink($filename); - } -} - -function determineSeparator(string $path): string -{ - return str_replace('/', DIRECTORY_SEPARATOR, $path); -} - -function replaceForWindows(): array -{ - return preg_split('/\\r\\n|\\r|\\n/', run('dir /S /B * | findstr /v /i .git\ | findstr /v /i vendor | findstr /v /i '.basename(__FILE__).' | findstr /r /i /M /F:/ ":author :vendor :package VendorName skeleton migration_table_name vendor_name vendor_slug author@domain.com"')); -} - -function replaceForAllOtherOSes(): array -{ - return explode(PHP_EOL, run('grep -E -r -l -i ":author|:vendor|:package|VendorName|skeleton|migration_table_name|vendor_name|vendor_slug|author@domain.com" --exclude-dir=vendor ./* ./.github/* | grep -v '.basename(__FILE__))); -} - -function getGitHubApiEndpoint(string $endpoint): ?stdClass -{ - try { - $curl = curl_init("https://api.github.com/{$endpoint}"); - curl_setopt_array($curl, [ - CURLOPT_RETURNTRANSFER => true, - CURLOPT_FOLLOWLOCATION => true, - CURLOPT_HTTPGET => true, - CURLOPT_HTTPHEADER => [ - 'User-Agent: spatie-configure-script/1.0', - ], - ]); - - $response = curl_exec($curl); - $statusCode = curl_getinfo($curl, CURLINFO_HTTP_CODE); - - curl_close($curl); - - if ($statusCode === 200) { - return json_decode($response); - } - } catch (Exception $e) { - // ignore - } - - return null; -} - -function searchCommitsForGitHubUsername(): string -{ - $authorName = strtolower(trim(shell_exec('git config user.name'))); - - $committersRaw = shell_exec("git log --author='@users.noreply.github.com' --pretty='%an:%ae' --reverse"); - $committersLines = explode("\n", $committersRaw ?? ''); - $committers = array_filter(array_map(function ($line) use ($authorName) { - $line = trim($line); - [$name, $email] = explode(':', $line) + [null, null]; - - return [ - 'name' => $name, - 'email' => $email, - 'isMatch' => strtolower($name) === $authorName && ! str_contains($name, '[bot]'), - ]; - }, $committersLines), fn ($item) => $item['isMatch']); - - if (empty($committers)) { - return ''; - } - - $firstCommitter = reset($committers); - - return explode('@', $firstCommitter['email'])[0] ?? ''; -} - -function guessGitHubUsernameUsingCli() -{ - try { - if (preg_match('/ogged in to github\.com as ([a-zA-Z-_]+).+/', shell_exec('gh auth status -h github.com 2>&1'), $matches)) { - return $matches[1]; - } - } catch (Exception $e) { - // ignore - } - - return ''; -} - -function guessGitHubUsername(): string -{ - $username = searchCommitsForGitHubUsername(); - if (! empty($username)) { - return $username; - } - - $username = guessGitHubUsernameUsingCli(); - if (! empty($username)) { - return $username; - } - - // fall back to using the username from the git remote - $remoteUrl = shell_exec('git config remote.origin.url') ?? ''; - $remoteUrlParts = explode('/', str_replace(':', '/', trim($remoteUrl))); - - return $remoteUrlParts[1] ?? ''; -} - -function guessGitHubVendorInfo($authorName, $username): array -{ - $remoteUrl = shell_exec('git config remote.origin.url') ?? ''; - $remoteUrlParts = explode('/', str_replace(':', '/', trim($remoteUrl))); - - if (! isset($remoteUrlParts[1])) { - return [$authorName, $username]; - } - - $response = getGitHubApiEndpoint("orgs/{$remoteUrlParts[1]}"); - - if ($response === null) { - return [$authorName, $username]; - } - - return [$response->name ?? $authorName, $response->login ?? $username]; -} - -$gitName = run('git config user.name'); -$authorName = ask('Author name', $gitName); - -$gitEmail = run('git config user.email'); -$authorEmail = ask('Author email', $gitEmail); -$authorUsername = ask('Author username', guessGitHubUsername()); - -$guessGitHubVendorInfo = guessGitHubVendorInfo($authorName, $authorUsername); - -$vendorName = ask('Vendor name', $guessGitHubVendorInfo[0]); -$vendorUsername = ask('Vendor username', $guessGitHubVendorInfo[1] ?? slugify($vendorName)); -$vendorSlug = slugify($vendorUsername); - -$vendorNamespace = str_replace('-', '', ucwords($vendorName)); -$vendorNamespace = ask('Vendor namespace', $vendorNamespace); - -$currentDirectory = getcwd(); -$folderName = basename($currentDirectory); - -$packageName = ask('Package name', $folderName); -$packageSlug = slugify($packageName); -$packageSlugWithoutPrefix = remove_prefix('laravel-', $packageSlug); - -$className = title_case($packageName); -$className = ask('Class name', $className); -$variableName = lcfirst($className); -$description = ask('Package description', "This is my package {$packageSlug}"); - -$usePhpStan = confirm('Enable PhpStan?', true); -$useLaravelPint = confirm('Enable Laravel Pint?', true); -$useDependabot = confirm('Enable Dependabot?', true); -$useLaravelRay = confirm('Use Ray for debugging?', true); -$useUpdateChangelogWorkflow = confirm('Use automatic changelog updater workflow?', true); - -writeln('------'); -writeln("Author : {$authorName} ({$authorUsername}, {$authorEmail})"); -writeln("Vendor : {$vendorName} ({$vendorSlug})"); -writeln("Package : {$packageSlug} <{$description}>"); -writeln("Namespace : {$vendorNamespace}\\{$className}"); -writeln("Class name : {$className}"); -writeln('---'); -writeln('Packages & Utilities'); -writeln('Use Laravel/Pint : '.($useLaravelPint ? 'yes' : 'no')); -writeln('Use Larastan/PhpStan : '.($usePhpStan ? 'yes' : 'no')); -writeln('Use Dependabot : '.($useDependabot ? 'yes' : 'no')); -writeln('Use Ray App : '.($useLaravelRay ? 'yes' : 'no')); -writeln('Use Auto-Changelog : '.($useUpdateChangelogWorkflow ? 'yes' : 'no')); -writeln('------'); - -writeln('This script will replace the above values in all relevant files in the project directory.'); - -if (! confirm('Modify files?', true)) { - exit(1); -} - -$files = (str_starts_with(strtoupper(PHP_OS), 'WIN') ? replaceForWindows() : replaceForAllOtherOSes()); - -foreach ($files as $file) { - replace_in_file($file, [ - ':author_name' => $authorName, - ':author_username' => $authorUsername, - 'author@domain.com' => $authorEmail, - ':vendor_name' => $vendorName, - ':vendor_slug' => $vendorSlug, - 'VendorName' => $vendorNamespace, - ':package_name' => $packageName, - ':package_slug' => $packageSlug, - ':package_slug_without_prefix' => $packageSlugWithoutPrefix, - 'Skeleton' => $className, - 'skeleton' => $packageSlug, - 'migration_table_name' => title_snake($packageSlug), - 'variable' => $variableName, - ':package_description' => $description, - ]); - - match (true) { - str_contains($file, determineSeparator('src/Skeleton.php')) => rename($file, determineSeparator('./src/'.$className.'.php')), - str_contains($file, determineSeparator('src/SkeletonServiceProvider.php')) => rename($file, determineSeparator('./src/'.$className.'ServiceProvider.php')), - str_contains($file, determineSeparator('src/Facades/Skeleton.php')) => rename($file, determineSeparator('./src/Facades/'.$className.'.php')), - str_contains($file, determineSeparator('src/Commands/SkeletonCommand.php')) => rename($file, determineSeparator('./src/Commands/'.$className.'Command.php')), - str_contains($file, determineSeparator('database/migrations/create_skeleton_table.php.stub')) => rename($file, determineSeparator('./database/migrations/create_'.title_snake($packageSlugWithoutPrefix).'_table.php.stub')), - str_contains($file, determineSeparator('config/skeleton.php')) => rename($file, determineSeparator('./config/'.$packageSlugWithoutPrefix.'.php')), - str_contains($file, 'README.md') => remove_readme_paragraphs($file), - default => [], - }; -} - -if (! $useLaravelPint) { - safeUnlink(__DIR__.'/.github/workflows/fix-php-code-style-issues.yml'); - safeUnlink(__DIR__.'/pint.json'); -} - -if (! $usePhpStan) { - safeUnlink(__DIR__.'/phpstan.neon.dist'); - safeUnlink(__DIR__.'/phpstan-baseline.neon'); - safeUnlink(__DIR__.'/.github/workflows/phpstan.yml'); - - remove_composer_deps([ - 'phpstan/extension-installer', - 'phpstan/phpstan-deprecation-rules', - 'phpstan/phpstan-phpunit', - 'larastan/larastan', - ]); - - remove_composer_script('phpstan'); -} - -if (! $useDependabot) { - safeUnlink(__DIR__.'/.github/dependabot.yml'); - safeUnlink(__DIR__.'/.github/workflows/dependabot-auto-merge.yml'); -} - -if (! $useLaravelRay) { - remove_composer_deps(['spatie/laravel-ray']); -} - -if (! $useUpdateChangelogWorkflow) { - safeUnlink(__DIR__.'/.github/workflows/update-changelog.yml'); -} - -confirm('Execute `composer install` and run tests?') && run('composer install && composer test'); - -confirm('Let this script delete itself?', true) && unlink(__FILE__); diff --git a/database/factories/ModelFactory.php b/database/factories/ModelFactory.php deleted file mode 100644 index c51604f..0000000 --- a/database/factories/ModelFactory.php +++ /dev/null @@ -1,19 +0,0 @@ -id(); - - // add fields - - $table->timestamps(); - }); - } -}; diff --git a/docker-compose.yaml b/docker-compose.yaml new file mode 100644 index 0000000..92000a8 --- /dev/null +++ b/docker-compose.yaml @@ -0,0 +1,24 @@ +services: + db: + image: mysql:8 + networks: + - net + + test-app: + image: sls-tinker-test-app + container_name: sls-tinker-test-app + build: + context: . + dockerfile: Dockerfile + networks: + - net + ports: + - 9090:8080 + volumes: + - ./src:/var/task/vendor/datpmwork/sls-tinker/src + - ./test-app/php:/var/task/php/ + command: + - artisan +networks: + net: + driver: "bridge" diff --git a/phpstan.neon.dist b/phpstan.neon.dist index ab1b4c3..ab3bbef 100644 --- a/phpstan.neon.dist +++ b/phpstan.neon.dist @@ -3,6 +3,9 @@ includes: parameters: level: 5 + ignoreErrors: + - + message: "#^Called 'env' outside of the config directory which returns null when the config is cached, use 'config'.$#" paths: - src - config diff --git a/phpunit.xml.dist b/phpunit.xml.dist index bfe434d..7f31e0c 100644 --- a/phpunit.xml.dist +++ b/phpunit.xml.dist @@ -16,7 +16,7 @@ backupStaticProperties="false" > - + tests @@ -28,4 +28,7 @@ ./src + + + diff --git a/public/index.html b/public/index.html new file mode 100644 index 0000000..257ccab --- /dev/null +++ b/public/index.html @@ -0,0 +1,648 @@ + + + + + + Act Log Viewer + + + +
+

Act Log Viewer

+
+ + + + + + + +
+
Disconnected
+
Logdy: Unknown
+
+ +
+ + + +
+
+
Logs: 0
+
Filtered: 0
+
+
+
+
+ + + + diff --git a/resources/views/.gitkeep b/resources/views/.gitkeep deleted file mode 100644 index e69de29..0000000 diff --git a/src/Commands/SkeletonCommand.php b/src/Commands/SkeletonCommand.php deleted file mode 100644 index 3e5f628..0000000 --- a/src/Commands/SkeletonCommand.php +++ /dev/null @@ -1,19 +0,0 @@ -comment('All done'); - - return self::SUCCESS; - } -} diff --git a/src/Commands/SlsTinkerCommand.php b/src/Commands/SlsTinkerCommand.php new file mode 100644 index 0000000..dbf4f30 --- /dev/null +++ b/src/Commands/SlsTinkerCommand.php @@ -0,0 +1,103 @@ +getApplication()->setCatchExceptions(false); + + $config = Configuration::fromInput($this->input); + $config->setUpdateCheck(Checker::NEVER); + + $config->getPresenter()->addCasters( + $this->getCasters() + ); + + if ($this->option('execute')) { + $config->setRawOutput(true); + } + + $lambdaFunctionName = $this->argument('lambda'); + $shell = LambdaShell::newLambdaShell($config, $lambdaFunctionName, $this->detectServerlessProvider()); + $shell->addCommands($this->getCommands()); + $shell->setIncludes($this->argument('include')); + + $path = Env::get('COMPOSER_VENDOR_DIR', $this->getLaravel()->basePath().DIRECTORY_SEPARATOR.'vendor'); + + $path .= '/composer/autoload_classmap.php'; + + $config = $this->getLaravel()->make('config'); + + $loader = ClassAliasAutoloader::register( + $shell, $path, $config->get('tinker.alias', []), $config->get('tinker.dont_alias', []) + ); + + if ($code = $this->option('execute')) { + if ($context = $this->option('context')) { + $shell->restoreContextData($context); + } + + try { + $shell->setOutput($this->output); + $shell->execute($code); + } finally { + $loader->unregister(); + } + + return 0; + } + + try { + return $shell->run(); + } finally { + $loader->unregister(); + } + } + + protected function detectServerlessProvider(): string + { + if (class_exists('\Laravel\Vapor\VaporServiceProvider')) { + return 'vapor'; + } + + if (class_exists('\Bref\Runtime\LambdaRuntime')) { + return 'bref'; + } + + return 'unknown'; + } + + protected function getArguments() + { + return [ + ['lambda', InputArgument::REQUIRED, 'Lambda Function Name'], + ['include', InputArgument::IS_ARRAY, 'Include file(s) before starting tinker'], + ]; + } + + /** + * Get the console command options. + * + * @return array + */ + protected function getOptions() + { + return [ + ['execute', null, InputOption::VALUE_OPTIONAL, 'Execute the given code using Tinker'], + ['context', null, InputOption::VALUE_OPTIONAL, 'The context data contains the defined vars'], + ]; + } +} diff --git a/src/Facades/Skeleton.php b/src/Facades/Skeleton.php deleted file mode 100644 index 571a498..0000000 --- a/src/Facades/Skeleton.php +++ /dev/null @@ -1,16 +0,0 @@ -payload, 'output'); + } +} diff --git a/src/Lambda/InvocationFailed.php b/src/Lambda/InvocationFailed.php new file mode 100644 index 0000000..7d89419 --- /dev/null +++ b/src/Lambda/InvocationFailed.php @@ -0,0 +1,27 @@ +invocationResult = $invocationResult; + $message = $invocationResult->getPayload()['errorMessage'] ?? 'Unknown error'; + + parent::__construct($message); + } + + public function getInvocationResult(): InvocationResult + { + return $this->invocationResult; + } + + public function getInvocationLogs(): string + { + return $this->invocationResult->getLogs(); + } +} diff --git a/src/Lambda/InvocationResult.php b/src/Lambda/InvocationResult.php new file mode 100644 index 0000000..e7bee7d --- /dev/null +++ b/src/Lambda/InvocationResult.php @@ -0,0 +1,47 @@ +result = $result; + $this->payload = $payload; + } + + abstract public function getOutput(); + + public static function new(InvocationResponse $result, $payload) + { + if (config('sls-tinker.platform') === 'vapor') { + return new VaporInvocationResult($result, $payload); + } else { + return new BrefInvocationResult($result, $payload); + } + } + + public function getLogs(): string + { + return base64_decode($this->result->getLogResult()); + } + + /** + * @return mixed + */ + public function getPayload() + { + return $this->payload; + } +} diff --git a/src/Lambda/TinkerLambdaClient.php b/src/Lambda/TinkerLambdaClient.php new file mode 100644 index 0000000..1e4a093 --- /dev/null +++ b/src/Lambda/TinkerLambdaClient.php @@ -0,0 +1,53 @@ +lambda = new LambdaClient( + [ + 'region' => $region, + 'profile' => $profile, + 'endpoint' => config('sls-tinker.lambda_endpoint'), + ], + null, + HttpClient::create([ + 'timeout' => $timeout, + ]) + ); + } + + /** + * Synchronously invoke a function. + * + * @param mixed $event Event data (can be null). + * + * @throws InvocationFailed + */ + public function invoke(string $functionName, $event = null): InvocationResult + { + $rawResult = $this->lambda->invoke([ + 'FunctionName' => $functionName, + 'LogType' => 'Tail', + 'Payload' => $event ?? '', + ]); + + $resultPayload = json_decode($rawResult->getPayload(), true); + $invocationResult = InvocationResult::new($rawResult, $resultPayload); + + $error = $rawResult->getFunctionError(); + if ($error) { + throw new InvocationFailed($invocationResult); + } + + return $invocationResult; + } +} diff --git a/src/Lambda/VaporInvocationResult.php b/src/Lambda/VaporInvocationResult.php new file mode 100644 index 0000000..6236996 --- /dev/null +++ b/src/Lambda/VaporInvocationResult.php @@ -0,0 +1,21 @@ +payload['output'] ?? ''); + if (! $output) { + throw new \RuntimeException('Failed to decode output from base64.'); + } + + $outputJson = json_decode($output, true); + if (json_last_error() !== JSON_ERROR_NONE) { + throw new \RuntimeException('Failed to decode output JSON: '.json_last_error_msg()); + } + + return data_get($outputJson, 'output', ''); + } +} diff --git a/src/ShellListeners/LocalLoopListener.php b/src/ShellListeners/LocalLoopListener.php new file mode 100644 index 0000000..e98fd85 --- /dev/null +++ b/src/ShellListeners/LocalLoopListener.php @@ -0,0 +1,85 @@ +lambdaFunctionName = $lambdaFunctionName; + } + + public static function isSupported(): bool + { + return true; + } + + /** + * @return array|InvocationResult + */ + public function invokeLambdaFunction($arguments) + { + // Because arguments may contain spaces, and are going to be executed remotely + // as a separate process, we need to escape all arguments. + $arguments = array_map(static function (string $arg): string { + return escapeshellarg($arg); + }, $arguments); + + $lambda = new TinkerLambdaClient( + getenv('AWS_DEFAULT_REGION') ?: 'us-east-1', + getenv('AWS_PROFILE') ?: 'default', + 15 * 60 // maximum duration on Lambda + ); + + return $lambda->invoke($this->lambdaFunctionName, json_encode([ + 'cli' => implode(' ', $arguments), + ])); + } + + /** + * @param LambdaShell $shell + */ + public function onExecute(Shell $shell, string $code) + { + if ($code == '\Psy\Exception\BreakException::exitShell();') { + return $code; + } + + $vars = $shell->getScopeVariables(false); + try { + // Evaluate the current code buffer + $result = $this->invokeLambdaFunction([ + 'sls-tinker', + $this->lambdaFunctionName, + '--execute', + $code, + '--context', + base64_encode(serialize($vars)), + ]); + + if ([$output, $context] = $shell->extractContextData($result->getOutput())) { + $shell->writeStdout($output); + + return "extract(unserialize(base64_decode('$context')));"; + } + + return ExecutionClosure::NOOP_INPUT; + } catch (ClientException $_e) { + throw new BreakException($_e->getMessage()); + } catch (\Throwable $throwable) { + throw new ThrowUpException($throwable); + } + } +} diff --git a/src/Shells/LambdaShell.php b/src/Shells/LambdaShell.php new file mode 100644 index 0000000..eb57696 --- /dev/null +++ b/src/Shells/LambdaShell.php @@ -0,0 +1,71 @@ +lambdaFunctionName = $lambdaFunctionName; + $this->platform = $platform; + + parent::__construct($config); + } + + protected static function isRunningInLambda(): bool + { + return ! empty(env('AWS_LAMBDA_RUNTIME_API')); + } + + /** + * @param $context + * @return void + */ + public function writeContextData($vars) + { + $context = base64_encode(serialize($vars)); + + $this->writeStdout("[CONTEXT]{$context}[END_CONTEXT]"); + } + + /** + * @return array + */ + public function extractContextData($output) + { + if ($this->platform == 'vapor') { + $output = base64_decode($output); + } + $pattern = '/(.*(?:\r?\n.*)*)\[CONTEXT\](.*?)\[END_CONTEXT\]/s'; + preg_match($pattern, $output, $matches); + + return empty($matches) ? null : [$matches[1], $matches[2]]; + } + + public function restoreContextData($context) + { + if ($returnVars = unserialize(base64_decode($context))) { + $this->setScopeVariables($returnVars); + } + + $this->contextRestored = true; + } +} diff --git a/src/Shells/LocalLambdaShell.php b/src/Shells/LocalLambdaShell.php new file mode 100644 index 0000000..941493d --- /dev/null +++ b/src/Shells/LocalLambdaShell.php @@ -0,0 +1,17 @@ +lambdaFunctionName); + + return $listeners; + } +} diff --git a/src/Shells/RemoteLambdaShell.php b/src/Shells/RemoteLambdaShell.php new file mode 100644 index 0000000..fec0a78 --- /dev/null +++ b/src/Shells/RemoteLambdaShell.php @@ -0,0 +1,17 @@ +contextRestored) { + $excludedSpecialVars = array_diff($this->getScopeVariables(false), $this->getSpecialScopeVariables(false)); + $this->writeContextData($excludedSpecialVars); + } + } +} diff --git a/src/Skeleton.php b/src/Skeleton.php deleted file mode 100755 index 34c7194..0000000 --- a/src/Skeleton.php +++ /dev/null @@ -1,5 +0,0 @@ -name('skeleton') + ->name('sls-tinker') ->hasConfigFile() - ->hasViews() - ->hasMigration('create_migration_table_name_table') - ->hasCommand(SkeletonCommand::class); + ->hasCommand(SlsTinkerCommand::class); } } diff --git a/tests/ArchTest.php b/tests/ArchTest.php deleted file mode 100644 index 87fb64c..0000000 --- a/tests/ArchTest.php +++ /dev/null @@ -1,5 +0,0 @@ -expect(['dd', 'dump', 'ray']) - ->each->not->toBeUsed(); diff --git a/tests/Dockerfile b/tests/Dockerfile new file mode 100644 index 0000000..69f6d36 --- /dev/null +++ b/tests/Dockerfile @@ -0,0 +1,70 @@ +ARG PHP_VERSION=82 + +FROM bref/php-${PHP_VERSION}-console:2 as bref + +COPY --from=composer:latest /usr/bin/composer /usr/local/bin/composer + +COPY /package /var/package + +COPY /app /var/task + +RUN composer dump-autoload + +FROM laravelphp/vapor:php${PHP_VERSION} as vapor + +COPY --from=composer:latest /usr/bin/composer /usr/local/bin/composer + +# Install dependencies needed for RIE +RUN apk add --no-cache \ + curl \ + ca-certificates \ + bash \ + libc6-compat + +# Download and install AWS Lambda Runtime Interface Emulator +RUN curl -Lo /opt/aws-lambda-rie \ + https://github.com/aws/aws-lambda-runtime-interface-emulator/releases/download/v1.27/aws-lambda-rie && \ + chmod +x /opt/aws-lambda-rie + +COPY <toBeTrue(); -}); diff --git a/tests/LambdaTinkerTest.php b/tests/LambdaTinkerTest.php new file mode 100644 index 0000000..695b0e7 --- /dev/null +++ b/tests/LambdaTinkerTest.php @@ -0,0 +1,40 @@ +expectTinkerOutput('function', [ + '$name = "Laravel";', + '$version = "10";', + 'echo $name . " " . $version;', + '$a = 1;', + '$b = 2;', + '$c = $a + $b;', + 'echo $c;', + ], function ($output) { + expect($output)->toEqual("Laravel 10\n3"); + }); +}); + +it('tests wrong variable usage across commands', function () { + $this->expectTinkerOutput('function', [ + '$a = 1;', + 'echo $c;', + 'echo "a = $a";', + ], function ($output) { + expect($output)->toContain('Undefined variable $c') + ->and($output)->toContain('a = 1'); + }); +}); + +it('should fail when lambda function not found', function () { + $this->expectTinkerOutput('wrong-function', [ + '$name = "Laravel";', + '$version = "10";', + 'echo $name . " " . $version;', + '$a = 1;', + '$b = 2;', + '$c = $a + $b;', + 'echo $c;', + ], function ($output) { + expect($output)->toContain('HTTP 404 returned'); + }); +}); diff --git a/tests/Pest.php b/tests/Pest.php index 7fe1500..c32f3b5 100644 --- a/tests/Pest.php +++ b/tests/Pest.php @@ -1,5 +1,5 @@ in(__DIR__); diff --git a/tests/TestCase.php b/tests/TestCase.php index 220551b..ad0ee4b 100644 --- a/tests/TestCase.php +++ b/tests/TestCase.php @@ -1,37 +1,94 @@ 'VendorName\\Skeleton\\Database\\Factories\\'.class_basename($modelName).'Factory' - ); } protected function getPackageProviders($app) { return [ - SkeletonServiceProvider::class, + SlsTinkerServiceProvider::class, ]; } public function getEnvironmentSetUp($app) { config()->set('database.default', 'testing'); + } + + protected function runTinkerCommands(array $commands, string $lambdaFunction = 'function', int $timeout = 0): string + { + $process = new Process(['php', 'artisan', 'sls-tinker', $lambdaFunction], base_path()); + + $process->setPty(true); + + // Ensure we exit at the end + if (end($commands) !== 'exit;') { + $commands[] = 'exit;'; + } - /* - foreach (\Illuminate\Support\Facades\File::allFiles(__DIR__ . '/database/migrations') as $migration) { - (include $migration->getRealPath())->up(); - } - */ + // Join all commands with newlines + $input = implode("\n", $commands)."\n"; + + $process->setInput($input); + $process->setTimeout($timeout); + + $process->run(); + + // Return both stdout and stderr combined + return $process->getOutput().$process->getErrorOutput(); + } + + protected function extractEchoOutput(string $fullOutput): string + { + // Strip ANSI color codes + $clean = preg_replace('/\e[\[\]()#;?0-9]*[a-zA-Z=]/', '', $fullOutput); + + // Split into lines + $lines = explode("\n", $clean); + + $resultLines = []; + $insidePsyShell = false; + + foreach ($lines as $line) { + $trimmed = trim($line, " \n\r\t\v\0\e"); + + // Wait until Psy Shell starts + if (! $insidePsyShell) { + if (str_contains($trimmed, 'Psy Shell')) { + $insidePsyShell = true; + } + + continue; + } + + // Filter out prompt/response/exit lines + if ($trimmed === '' + || str_starts_with($trimmed, '>') + || str_starts_with($trimmed, '=') + || str_starts_with($trimmed, 'INFO Goodbye.')) { + continue; + } + + // Capture everything else + $resultLines[] = $trimmed; + } + + return implode("\n", $resultLines); + } + + protected function expectTinkerOutput(string $lambdaFunction, array $commands, $expect): void + { + $output = $this->extractEchoOutput($this->runTinkerCommands($commands, $lambdaFunction)); + $expect($output); } } diff --git a/tests/Traits/InteractiveTinkerTesting.php b/tests/Traits/InteractiveTinkerTesting.php new file mode 100644 index 0000000..79414dc --- /dev/null +++ b/tests/Traits/InteractiveTinkerTesting.php @@ -0,0 +1,35 @@ +setInput($input); + $process->setTimeout($timeout); + + $process->run(); + + // Return both stdout and stderr combined + return $process->getOutput().$process->getErrorOutput(); + } + + protected function expectTinkerOutput(array $commands, string $expectedOutput): void + { + $output = $this->runTinkerCommands($commands); + expect($output)->toContain($expectedOutput); + } +}