From 4cdf21ccce4944f740f59581cefc1f3d8d80b196 Mon Sep 17 00:00:00 2001 From: Giorgio Balduzzi Date: Tue, 23 May 2023 11:05:16 +0200 Subject: [PATCH] First beta --- .editorconfig | 24 + .gitignore | 2 + LICENSE.md | 16 + Readme.md | 137 +++ bin/skipper | 6 + composer.json | 38 + composer.lock | 941 +++++++++++++++++++++ pint.json | 6 + proxy/Readme.md | 6 + proxy/config/.gitignore | 2 + proxy/data/.gitignore | 2 + proxy/docker-compose.yml | 28 + src/CliApplication.php | 72 ++ src/Commands/BaseCommand.php | 60 ++ src/Commands/HostCmd.php | 56 ++ src/Commands/InitCmd.php | 262 ++++++ src/Commands/ListCmd.php | 23 + src/Commands/ManCmd.php | 86 ++ src/Commands/ProjectCmds/ArtisanCmd.php | 45 + src/Commands/ProjectCmds/BackupCmd.php | 150 ++++ src/Commands/ProjectCmds/BashCmd.php | 29 + src/Commands/ProjectCmds/CheckCmd.php | 24 + src/Commands/ProjectCmds/ComposeCmd.php | 35 + src/Commands/ProjectCmds/ComposerCmd.php | 44 + src/Commands/ProjectCmds/DockCmd.php | 32 + src/Commands/ProjectCmds/DockerBaseCmd.php | 23 + src/Commands/ProjectCmds/EditCmd.php | 175 ++++ src/Commands/ProjectCmds/InfoCmd.php | 24 + src/Commands/ProjectCmds/RestoreCmd.php | 165 ++++ src/Commands/ProjectCmds/RmCmd.php | 51 ++ src/Commands/ProjectCmds/SailCmd.php | 84 ++ src/Commands/ProjectCmds/SyncCmd.php | 41 + src/Commands/ProjectCmds/TinkerCmd.php | 31 + src/Commands/ProxyCmd/CertsCmd.php | 63 ++ src/Commands/ProxyCmd/ConfigCmd.php | 25 + src/Commands/ProxyCmd/DownCmd.php | 19 + src/Commands/ProxyCmd/RestartCmd.php | 17 + src/Commands/ProxyCmd/ShowCmd.php | 18 + src/Commands/ProxyCmd/UpCmd.php | 17 + src/Commands/ProxyCmd/UpdateCmd.php | 23 + src/Commands/ShutdownCmd.php | 31 + src/Commands/WithProject.php | 135 +++ src/Config/Config.php | 60 ++ src/Config/Project.php | 139 +++ src/Config/Proxy.php | 115 +++ src/Config/Repository.php | 116 +++ src/Helpers.php | 15 + src/Utils/DockerBase.php | 54 ++ src/Utils/Execute.php | 61 ++ src/Utils/Globals.php | 18 + src/Utils/HostFile.php | 180 ++++ src/Utils/TiknilStyle.php | 24 + src/index.php | 13 + 53 files changed, 3863 insertions(+) create mode 100644 .editorconfig create mode 100644 .gitignore create mode 100644 LICENSE.md create mode 100644 Readme.md create mode 100755 bin/skipper create mode 100644 composer.json create mode 100644 composer.lock create mode 100644 pint.json create mode 100644 proxy/Readme.md create mode 100644 proxy/config/.gitignore create mode 100644 proxy/data/.gitignore create mode 100644 proxy/docker-compose.yml create mode 100644 src/CliApplication.php create mode 100644 src/Commands/BaseCommand.php create mode 100644 src/Commands/HostCmd.php create mode 100644 src/Commands/InitCmd.php create mode 100644 src/Commands/ListCmd.php create mode 100644 src/Commands/ManCmd.php create mode 100644 src/Commands/ProjectCmds/ArtisanCmd.php create mode 100644 src/Commands/ProjectCmds/BackupCmd.php create mode 100644 src/Commands/ProjectCmds/BashCmd.php create mode 100644 src/Commands/ProjectCmds/CheckCmd.php create mode 100644 src/Commands/ProjectCmds/ComposeCmd.php create mode 100644 src/Commands/ProjectCmds/ComposerCmd.php create mode 100644 src/Commands/ProjectCmds/DockCmd.php create mode 100644 src/Commands/ProjectCmds/DockerBaseCmd.php create mode 100644 src/Commands/ProjectCmds/EditCmd.php create mode 100644 src/Commands/ProjectCmds/InfoCmd.php create mode 100644 src/Commands/ProjectCmds/RestoreCmd.php create mode 100644 src/Commands/ProjectCmds/RmCmd.php create mode 100644 src/Commands/ProjectCmds/SailCmd.php create mode 100644 src/Commands/ProjectCmds/SyncCmd.php create mode 100644 src/Commands/ProjectCmds/TinkerCmd.php create mode 100644 src/Commands/ProxyCmd/CertsCmd.php create mode 100644 src/Commands/ProxyCmd/ConfigCmd.php create mode 100644 src/Commands/ProxyCmd/DownCmd.php create mode 100644 src/Commands/ProxyCmd/RestartCmd.php create mode 100644 src/Commands/ProxyCmd/ShowCmd.php create mode 100644 src/Commands/ProxyCmd/UpCmd.php create mode 100644 src/Commands/ProxyCmd/UpdateCmd.php create mode 100644 src/Commands/ShutdownCmd.php create mode 100644 src/Commands/WithProject.php create mode 100644 src/Config/Config.php create mode 100644 src/Config/Project.php create mode 100644 src/Config/Proxy.php create mode 100644 src/Config/Repository.php create mode 100644 src/Helpers.php create mode 100644 src/Utils/DockerBase.php create mode 100644 src/Utils/Execute.php create mode 100644 src/Utils/Globals.php create mode 100644 src/Utils/HostFile.php create mode 100644 src/Utils/TiknilStyle.php create mode 100644 src/index.php diff --git a/.editorconfig b/.editorconfig new file mode 100644 index 0000000..6333947 --- /dev/null +++ b/.editorconfig @@ -0,0 +1,24 @@ +root = true + +[*] +charset = utf-8 +end_of_line = lf +indent_size = 4 +indent_style = space +insert_final_newline = true +trim_trailing_whitespace = true + +[*.md] +trim_trailing_whitespace = false + +[*.{yml,yaml}] +indent_size = 2 + +[docker-compose.yml] +indent_size = 4 + +[*.{js,ts,css,scss}] +indent_size = 2 + +[*.blade.php] +indent_size = 2 diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..ecdf2d7 --- /dev/null +++ b/.gitignore @@ -0,0 +1,2 @@ +vendor +.idea diff --git a/LICENSE.md b/LICENSE.md new file mode 100644 index 0000000..40e9a82 --- /dev/null +++ b/LICENSE.md @@ -0,0 +1,16 @@ +MIT License + +Copyright (c) Tiknil srl + +Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated +documentation files (the "Software"), to deal in the Software without restriction, including without limitation the +rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit +persons to whom the Software is furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all copies or substantial portions of the +Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE +WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR +COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR +OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. diff --git a/Readme.md b/Readme.md new file mode 100644 index 0000000..7c50676 --- /dev/null +++ b/Readme.md @@ -0,0 +1,137 @@ +[Skipper](https://github.com/tiknil/skipper) is a tool for managing multiple local Laravel docker-compose deployment. + +It is strongly opinionated towards the patterns used by [Tiknil](https://www.tiknil.com) while building a laravel +application. + +- [Install & Update](#installation) +- [Usage](#usage) +- [Architecture](#architecture) +- [Mailpit for local emails](#mailpit) +- [Docker compose commands](#docker-compose-commands) +- [Project fields](#project) + +### Installation + +``` +composer global require tiknil/skipper +``` + +Make sure that the composer binaries directory is in your `$PATH` + +You can update Skipper to the latest version by running + +``` +composer global update tiknil/skipper +``` + +### Usage + +Register the current path as a skipper project: + +```bash +# No options, sane defaults will be used and you will be asked to confirm the main fields +skipper init + +# All fields can also be set via command line options +skipper init --host=[host] --name=[name] \ + --compose-file=docker/docker-compose.yml \ + --env-file=docker/.env \ + --http-container=nginx + --php-container=php-fpm +``` + +You will be asked to automatically install the default +[laravel docker compose](https://github.com/tiknil/laravel-docker-compose) files. + +See [Project](#project) for details about each field. + +---- +Start the project containers + +```bash +skipper sail +``` + +---- + +Stops the project containers + +```bash +skipper dock +``` + +---- + +Install caddy root certificate + +```bash +skipper proxy:certs +``` + +---- +Once a project is running, skipper provides some useful commands to directly interact with it. + +```bash +skipper bash # Start a new bash shell inside the PHP container +skipper composer [command...] # Run a composer command (use instead of composer [command] +skipper artisan [command...] # Run an artisan command (use instead of php artisan [command] +skipper tinker # Start a new Laravel Tinker shell +skipper backup # Create a new MySQL backup +skipper restore --file [file] # Restore a MySQL backup +``` + +Run `skipper` without arguments for a complete list of available commands + +### Architecture + +Skipper runs a [Caddy](https://caddyserver.com/) container, running as reverse proxy and forwarding +requests to the corresponding project instance. Caddy is also able to generate HTTPS certificates for local domains, +enabling a local environment very similar to a production deployment. + +Skipper install its files inside the `~/.skipper` directory. + +### Mailpit + +Skipper also runs a [Mailpit](https://github.com/axllent/mailpit) container by default. You can see the web +dashboard at [localhost:8025](http://localhost:8025) + +Laravel should use the host `host.docker.internal` and port `1025` with driver SMTP: + +``` +MAIL_MAILER=smtp +MAIL_HOST=host.docker.internal +MAIL_PORT=1025 +``` + +This avoids running a separate mailpit/mailhog instance for each project. + +### Docker compose commands + +You may need to run custom docker compose command for a project, e.g. `ps` to see running containers. + +You can use the `compose` command: + +``` +skipper compose [command] +``` + +Basically, replace each `docker-compose [command]` with `skipper compose [command]`. +This is *required* because skipper attaches some options to the docker-compose command that are required, such as the +name or the env file path. + +### Project + +For the reverse proxy to work, skipper need to know about a project and update the caddy configuration file. +A new project is registered using the `init` command. + +> All available projects are registered inside the `~/.skipper/config.yaml` configuration file. + +- **name**: The project name is used as the docker compose prefix for each container, volume or network related to the + project. As a consequence, it must be unique and an update can result in data loss (new docker volumes will be used). +- **host**: The domain to register in the reverse proxy +- **path**: The path to the project root +- **composeFile**: relative path to the docker-compose file. +- **envFile**: relative path to the .env file for the docker-compose file +- **httpContainer**: name of the http container, where caddy should forward the requests +- **phpContainer**: name of the php container, where the utility commands should be run. + diff --git a/bin/skipper b/bin/skipper new file mode 100755 index 0000000..1e9039c --- /dev/null +++ b/bin/skipper @@ -0,0 +1,6 @@ +#!/usr/bin/env php +=7.4.0" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-master": "2.0.x-dev" + } + }, + "autoload": { + "psr-4": { + "Psr\\Container\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "PHP-FIG", + "homepage": "https://www.php-fig.org/" + } + ], + "description": "Common Container Interface (PHP FIG PSR-11)", + "homepage": "https://github.com/php-fig/container", + "keywords": [ + "PSR-11", + "container", + "container-interface", + "container-interop", + "psr" + ], + "support": { + "issues": "https://github.com/php-fig/container/issues", + "source": "https://github.com/php-fig/container/tree/2.0.2" + }, + "time": "2021-11-05T16:47:00+00:00" + }, + { + "name": "symfony/console", + "version": "v6.2.10", + "source": { + "type": "git", + "url": "https://github.com/symfony/console.git", + "reference": "12288d9f4500f84a4d02254d4aa968b15488476f" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/symfony/console/zipball/12288d9f4500f84a4d02254d4aa968b15488476f", + "reference": "12288d9f4500f84a4d02254d4aa968b15488476f", + "shasum": "" + }, + "require": { + "php": ">=8.1", + "symfony/deprecation-contracts": "^2.1|^3", + "symfony/polyfill-mbstring": "~1.0", + "symfony/service-contracts": "^1.1|^2|^3", + "symfony/string": "^5.4|^6.0" + }, + "conflict": { + "symfony/dependency-injection": "<5.4", + "symfony/dotenv": "<5.4", + "symfony/event-dispatcher": "<5.4", + "symfony/lock": "<5.4", + "symfony/process": "<5.4" + }, + "provide": { + "psr/log-implementation": "1.0|2.0|3.0" + }, + "require-dev": { + "psr/log": "^1|^2|^3", + "symfony/config": "^5.4|^6.0", + "symfony/dependency-injection": "^5.4|^6.0", + "symfony/event-dispatcher": "^5.4|^6.0", + "symfony/lock": "^5.4|^6.0", + "symfony/process": "^5.4|^6.0", + "symfony/var-dumper": "^5.4|^6.0" + }, + "suggest": { + "psr/log": "For using the console logger", + "symfony/event-dispatcher": "", + "symfony/lock": "", + "symfony/process": "" + }, + "type": "library", + "autoload": { + "psr-4": { + "Symfony\\Component\\Console\\": "" + }, + "exclude-from-classmap": [ + "/Tests/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Fabien Potencier", + "email": "fabien@symfony.com" + }, + { + "name": "Symfony Community", + "homepage": "https://symfony.com/contributors" + } + ], + "description": "Eases the creation of beautiful and testable command line interfaces", + "homepage": "https://symfony.com", + "keywords": [ + "cli", + "command-line", + "console", + "terminal" + ], + "support": { + "source": "https://github.com/symfony/console/tree/v6.2.10" + }, + "funding": [ + { + "url": "https://symfony.com/sponsor", + "type": "custom" + }, + { + "url": "https://github.com/fabpot", + "type": "github" + }, + { + "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", + "type": "tidelift" + } + ], + "time": "2023-04-28T13:37:43+00:00" + }, + { + "name": "symfony/deprecation-contracts", + "version": "v3.2.1", + "source": { + "type": "git", + "url": "https://github.com/symfony/deprecation-contracts.git", + "reference": "e2d1534420bd723d0ef5aec58a22c5fe60ce6f5e" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/symfony/deprecation-contracts/zipball/e2d1534420bd723d0ef5aec58a22c5fe60ce6f5e", + "reference": "e2d1534420bd723d0ef5aec58a22c5fe60ce6f5e", + "shasum": "" + }, + "require": { + "php": ">=8.1" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-main": "3.3-dev" + }, + "thanks": { + "name": "symfony/contracts", + "url": "https://github.com/symfony/contracts" + } + }, + "autoload": { + "files": [ + "function.php" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Nicolas Grekas", + "email": "p@tchwork.com" + }, + { + "name": "Symfony Community", + "homepage": "https://symfony.com/contributors" + } + ], + "description": "A generic function and convention to trigger deprecation notices", + "homepage": "https://symfony.com", + "support": { + "source": "https://github.com/symfony/deprecation-contracts/tree/v3.2.1" + }, + "funding": [ + { + "url": "https://symfony.com/sponsor", + "type": "custom" + }, + { + "url": "https://github.com/fabpot", + "type": "github" + }, + { + "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", + "type": "tidelift" + } + ], + "time": "2023-03-01T10:25:55+00:00" + }, + { + "name": "symfony/polyfill-ctype", + "version": "v1.27.0", + "source": { + "type": "git", + "url": "https://github.com/symfony/polyfill-ctype.git", + "reference": "5bbc823adecdae860bb64756d639ecfec17b050a" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/symfony/polyfill-ctype/zipball/5bbc823adecdae860bb64756d639ecfec17b050a", + "reference": "5bbc823adecdae860bb64756d639ecfec17b050a", + "shasum": "" + }, + "require": { + "php": ">=7.1" + }, + "provide": { + "ext-ctype": "*" + }, + "suggest": { + "ext-ctype": "For best performance" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-main": "1.27-dev" + }, + "thanks": { + "name": "symfony/polyfill", + "url": "https://github.com/symfony/polyfill" + } + }, + "autoload": { + "files": [ + "bootstrap.php" + ], + "psr-4": { + "Symfony\\Polyfill\\Ctype\\": "" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Gert de Pagter", + "email": "BackEndTea@gmail.com" + }, + { + "name": "Symfony Community", + "homepage": "https://symfony.com/contributors" + } + ], + "description": "Symfony polyfill for ctype functions", + "homepage": "https://symfony.com", + "keywords": [ + "compatibility", + "ctype", + "polyfill", + "portable" + ], + "support": { + "source": "https://github.com/symfony/polyfill-ctype/tree/v1.27.0" + }, + "funding": [ + { + "url": "https://symfony.com/sponsor", + "type": "custom" + }, + { + "url": "https://github.com/fabpot", + "type": "github" + }, + { + "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", + "type": "tidelift" + } + ], + "time": "2022-11-03T14:55:06+00:00" + }, + { + "name": "symfony/polyfill-intl-grapheme", + "version": "v1.27.0", + "source": { + "type": "git", + "url": "https://github.com/symfony/polyfill-intl-grapheme.git", + "reference": "511a08c03c1960e08a883f4cffcacd219b758354" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/symfony/polyfill-intl-grapheme/zipball/511a08c03c1960e08a883f4cffcacd219b758354", + "reference": "511a08c03c1960e08a883f4cffcacd219b758354", + "shasum": "" + }, + "require": { + "php": ">=7.1" + }, + "suggest": { + "ext-intl": "For best performance" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-main": "1.27-dev" + }, + "thanks": { + "name": "symfony/polyfill", + "url": "https://github.com/symfony/polyfill" + } + }, + "autoload": { + "files": [ + "bootstrap.php" + ], + "psr-4": { + "Symfony\\Polyfill\\Intl\\Grapheme\\": "" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Nicolas Grekas", + "email": "p@tchwork.com" + }, + { + "name": "Symfony Community", + "homepage": "https://symfony.com/contributors" + } + ], + "description": "Symfony polyfill for intl's grapheme_* functions", + "homepage": "https://symfony.com", + "keywords": [ + "compatibility", + "grapheme", + "intl", + "polyfill", + "portable", + "shim" + ], + "support": { + "source": "https://github.com/symfony/polyfill-intl-grapheme/tree/v1.27.0" + }, + "funding": [ + { + "url": "https://symfony.com/sponsor", + "type": "custom" + }, + { + "url": "https://github.com/fabpot", + "type": "github" + }, + { + "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", + "type": "tidelift" + } + ], + "time": "2022-11-03T14:55:06+00:00" + }, + { + "name": "symfony/polyfill-intl-normalizer", + "version": "v1.27.0", + "source": { + "type": "git", + "url": "https://github.com/symfony/polyfill-intl-normalizer.git", + "reference": "19bd1e4fcd5b91116f14d8533c57831ed00571b6" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/symfony/polyfill-intl-normalizer/zipball/19bd1e4fcd5b91116f14d8533c57831ed00571b6", + "reference": "19bd1e4fcd5b91116f14d8533c57831ed00571b6", + "shasum": "" + }, + "require": { + "php": ">=7.1" + }, + "suggest": { + "ext-intl": "For best performance" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-main": "1.27-dev" + }, + "thanks": { + "name": "symfony/polyfill", + "url": "https://github.com/symfony/polyfill" + } + }, + "autoload": { + "files": [ + "bootstrap.php" + ], + "psr-4": { + "Symfony\\Polyfill\\Intl\\Normalizer\\": "" + }, + "classmap": [ + "Resources/stubs" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Nicolas Grekas", + "email": "p@tchwork.com" + }, + { + "name": "Symfony Community", + "homepage": "https://symfony.com/contributors" + } + ], + "description": "Symfony polyfill for intl's Normalizer class and related functions", + "homepage": "https://symfony.com", + "keywords": [ + "compatibility", + "intl", + "normalizer", + "polyfill", + "portable", + "shim" + ], + "support": { + "source": "https://github.com/symfony/polyfill-intl-normalizer/tree/v1.27.0" + }, + "funding": [ + { + "url": "https://symfony.com/sponsor", + "type": "custom" + }, + { + "url": "https://github.com/fabpot", + "type": "github" + }, + { + "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", + "type": "tidelift" + } + ], + "time": "2022-11-03T14:55:06+00:00" + }, + { + "name": "symfony/polyfill-mbstring", + "version": "v1.27.0", + "source": { + "type": "git", + "url": "https://github.com/symfony/polyfill-mbstring.git", + "reference": "8ad114f6b39e2c98a8b0e3bd907732c207c2b534" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/symfony/polyfill-mbstring/zipball/8ad114f6b39e2c98a8b0e3bd907732c207c2b534", + "reference": "8ad114f6b39e2c98a8b0e3bd907732c207c2b534", + "shasum": "" + }, + "require": { + "php": ">=7.1" + }, + "provide": { + "ext-mbstring": "*" + }, + "suggest": { + "ext-mbstring": "For best performance" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-main": "1.27-dev" + }, + "thanks": { + "name": "symfony/polyfill", + "url": "https://github.com/symfony/polyfill" + } + }, + "autoload": { + "files": [ + "bootstrap.php" + ], + "psr-4": { + "Symfony\\Polyfill\\Mbstring\\": "" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Nicolas Grekas", + "email": "p@tchwork.com" + }, + { + "name": "Symfony Community", + "homepage": "https://symfony.com/contributors" + } + ], + "description": "Symfony polyfill for the Mbstring extension", + "homepage": "https://symfony.com", + "keywords": [ + "compatibility", + "mbstring", + "polyfill", + "portable", + "shim" + ], + "support": { + "source": "https://github.com/symfony/polyfill-mbstring/tree/v1.27.0" + }, + "funding": [ + { + "url": "https://symfony.com/sponsor", + "type": "custom" + }, + { + "url": "https://github.com/fabpot", + "type": "github" + }, + { + "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", + "type": "tidelift" + } + ], + "time": "2022-11-03T14:55:06+00:00" + }, + { + "name": "symfony/process", + "version": "v6.2.10", + "source": { + "type": "git", + "url": "https://github.com/symfony/process.git", + "reference": "b34cdbc9c5e75d45a3703e63a48ad07aafa8bf2e" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/symfony/process/zipball/b34cdbc9c5e75d45a3703e63a48ad07aafa8bf2e", + "reference": "b34cdbc9c5e75d45a3703e63a48ad07aafa8bf2e", + "shasum": "" + }, + "require": { + "php": ">=8.1" + }, + "type": "library", + "autoload": { + "psr-4": { + "Symfony\\Component\\Process\\": "" + }, + "exclude-from-classmap": [ + "/Tests/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Fabien Potencier", + "email": "fabien@symfony.com" + }, + { + "name": "Symfony Community", + "homepage": "https://symfony.com/contributors" + } + ], + "description": "Executes commands in sub-processes", + "homepage": "https://symfony.com", + "support": { + "source": "https://github.com/symfony/process/tree/v6.2.10" + }, + "funding": [ + { + "url": "https://symfony.com/sponsor", + "type": "custom" + }, + { + "url": "https://github.com/fabpot", + "type": "github" + }, + { + "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", + "type": "tidelift" + } + ], + "time": "2023-04-18T13:56:57+00:00" + }, + { + "name": "symfony/service-contracts", + "version": "v3.2.1", + "source": { + "type": "git", + "url": "https://github.com/symfony/service-contracts.git", + "reference": "a8c9cedf55f314f3a186041d19537303766df09a" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/symfony/service-contracts/zipball/a8c9cedf55f314f3a186041d19537303766df09a", + "reference": "a8c9cedf55f314f3a186041d19537303766df09a", + "shasum": "" + }, + "require": { + "php": ">=8.1", + "psr/container": "^2.0" + }, + "conflict": { + "ext-psr": "<1.1|>=2" + }, + "suggest": { + "symfony/service-implementation": "" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-main": "3.3-dev" + }, + "thanks": { + "name": "symfony/contracts", + "url": "https://github.com/symfony/contracts" + } + }, + "autoload": { + "psr-4": { + "Symfony\\Contracts\\Service\\": "" + }, + "exclude-from-classmap": [ + "/Test/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Nicolas Grekas", + "email": "p@tchwork.com" + }, + { + "name": "Symfony Community", + "homepage": "https://symfony.com/contributors" + } + ], + "description": "Generic abstractions related to writing services", + "homepage": "https://symfony.com", + "keywords": [ + "abstractions", + "contracts", + "decoupling", + "interfaces", + "interoperability", + "standards" + ], + "support": { + "source": "https://github.com/symfony/service-contracts/tree/v3.2.1" + }, + "funding": [ + { + "url": "https://symfony.com/sponsor", + "type": "custom" + }, + { + "url": "https://github.com/fabpot", + "type": "github" + }, + { + "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", + "type": "tidelift" + } + ], + "time": "2023-03-01T10:32:47+00:00" + }, + { + "name": "symfony/string", + "version": "v6.2.8", + "source": { + "type": "git", + "url": "https://github.com/symfony/string.git", + "reference": "193e83bbd6617d6b2151c37fff10fa7168ebddef" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/symfony/string/zipball/193e83bbd6617d6b2151c37fff10fa7168ebddef", + "reference": "193e83bbd6617d6b2151c37fff10fa7168ebddef", + "shasum": "" + }, + "require": { + "php": ">=8.1", + "symfony/polyfill-ctype": "~1.8", + "symfony/polyfill-intl-grapheme": "~1.0", + "symfony/polyfill-intl-normalizer": "~1.0", + "symfony/polyfill-mbstring": "~1.0" + }, + "conflict": { + "symfony/translation-contracts": "<2.0" + }, + "require-dev": { + "symfony/error-handler": "^5.4|^6.0", + "symfony/http-client": "^5.4|^6.0", + "symfony/intl": "^6.2", + "symfony/translation-contracts": "^2.0|^3.0", + "symfony/var-exporter": "^5.4|^6.0" + }, + "type": "library", + "autoload": { + "files": [ + "Resources/functions.php" + ], + "psr-4": { + "Symfony\\Component\\String\\": "" + }, + "exclude-from-classmap": [ + "/Tests/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Nicolas Grekas", + "email": "p@tchwork.com" + }, + { + "name": "Symfony Community", + "homepage": "https://symfony.com/contributors" + } + ], + "description": "Provides an object-oriented API to strings and deals with bytes, UTF-8 code points and grapheme clusters in a unified way", + "homepage": "https://symfony.com", + "keywords": [ + "grapheme", + "i18n", + "string", + "unicode", + "utf-8", + "utf8" + ], + "support": { + "source": "https://github.com/symfony/string/tree/v6.2.8" + }, + "funding": [ + { + "url": "https://symfony.com/sponsor", + "type": "custom" + }, + { + "url": "https://github.com/fabpot", + "type": "github" + }, + { + "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", + "type": "tidelift" + } + ], + "time": "2023-03-20T16:06:02+00:00" + }, + { + "name": "symfony/yaml", + "version": "v6.2.10", + "source": { + "type": "git", + "url": "https://github.com/symfony/yaml.git", + "reference": "61916f3861b1e9705b18cfde723921a71dd1559d" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/symfony/yaml/zipball/61916f3861b1e9705b18cfde723921a71dd1559d", + "reference": "61916f3861b1e9705b18cfde723921a71dd1559d", + "shasum": "" + }, + "require": { + "php": ">=8.1", + "symfony/polyfill-ctype": "^1.8" + }, + "conflict": { + "symfony/console": "<5.4" + }, + "require-dev": { + "symfony/console": "^5.4|^6.0" + }, + "suggest": { + "symfony/console": "For validating YAML files using the lint command" + }, + "bin": [ + "Resources/bin/yaml-lint" + ], + "type": "library", + "autoload": { + "psr-4": { + "Symfony\\Component\\Yaml\\": "" + }, + "exclude-from-classmap": [ + "/Tests/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Fabien Potencier", + "email": "fabien@symfony.com" + }, + { + "name": "Symfony Community", + "homepage": "https://symfony.com/contributors" + } + ], + "description": "Loads and dumps YAML files", + "homepage": "https://symfony.com", + "support": { + "source": "https://github.com/symfony/yaml/tree/v6.2.10" + }, + "funding": [ + { + "url": "https://symfony.com/sponsor", + "type": "custom" + }, + { + "url": "https://github.com/fabpot", + "type": "github" + }, + { + "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", + "type": "tidelift" + } + ], + "time": "2023-04-28T13:25:36+00:00" + } + ], + "packages-dev": [ + { + "name": "laravel/pint", + "version": "v1.10.0", + "source": { + "type": "git", + "url": "https://github.com/laravel/pint.git", + "reference": "c7a01fa9bdd79819e7a2f1ba63ac1b02e6692dbc" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/laravel/pint/zipball/c7a01fa9bdd79819e7a2f1ba63ac1b02e6692dbc", + "reference": "c7a01fa9bdd79819e7a2f1ba63ac1b02e6692dbc", + "shasum": "" + }, + "require": { + "ext-json": "*", + "ext-mbstring": "*", + "ext-tokenizer": "*", + "ext-xml": "*", + "php": "^8.1.0" + }, + "require-dev": { + "friendsofphp/php-cs-fixer": "^3.16.0", + "illuminate/view": "^10.5.1", + "laravel-zero/framework": "^10.0.2", + "mockery/mockery": "^1.5.1", + "nunomaduro/larastan": "^2.5.1", + "nunomaduro/termwind": "^1.15.1", + "pestphp/pest": "^2.4.0" + }, + "bin": [ + "builds/pint" + ], + "type": "project", + "autoload": { + "psr-4": { + "App\\": "app/", + "Database\\Seeders\\": "database/seeders/", + "Database\\Factories\\": "database/factories/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Nuno Maduro", + "email": "enunomaduro@gmail.com" + } + ], + "description": "An opinionated code formatter for PHP.", + "homepage": "https://laravel.com", + "keywords": [ + "format", + "formatter", + "lint", + "linter", + "php" + ], + "support": { + "issues": "https://github.com/laravel/pint/issues", + "source": "https://github.com/laravel/pint" + }, + "time": "2023-04-25T14:52:30+00:00" + } + ], + "aliases": [], + "minimum-stability": "dev", + "stability-flags": [], + "prefer-stable": true, + "prefer-lowest": false, + "platform": { + "ext-posix": "*", + "php": "^8.0" + }, + "platform-dev": [], + "plugin-api-version": "2.3.0" +} diff --git a/pint.json b/pint.json new file mode 100644 index 0000000..9cef0a3 --- /dev/null +++ b/pint.json @@ -0,0 +1,6 @@ +{ + "preset": "laravel", + "rules": { + "not_operator_with_successor_space": false + } +} diff --git a/proxy/Readme.md b/proxy/Readme.md new file mode 100644 index 0000000..6ba6531 --- /dev/null +++ b/proxy/Readme.md @@ -0,0 +1,6 @@ +## Caddy docker compose + +This directory is copied as-is to `~/.skipper/caddy` and is used to run +caddy as reverse proxy to the projects running with skipper. + +Skipper will create at runtime the proper Caddyfile diff --git a/proxy/config/.gitignore b/proxy/config/.gitignore new file mode 100644 index 0000000..d6b7ef3 --- /dev/null +++ b/proxy/config/.gitignore @@ -0,0 +1,2 @@ +* +!.gitignore diff --git a/proxy/data/.gitignore b/proxy/data/.gitignore new file mode 100644 index 0000000..d6b7ef3 --- /dev/null +++ b/proxy/data/.gitignore @@ -0,0 +1,2 @@ +* +!.gitignore diff --git a/proxy/docker-compose.yml b/proxy/docker-compose.yml new file mode 100644 index 0000000..25b90a9 --- /dev/null +++ b/proxy/docker-compose.yml @@ -0,0 +1,28 @@ +version: "3.9" + +name: "skipper" + +services: + caddy: + image: caddy:latest + restart: unless-stopped + healthcheck: + disable: true + ports: + - 80:80 # Needed for the ACME HTTP-01 challenge. + - 443:443 + volumes: + - ./Caddyfile:/etc/caddy/Caddyfile:ro + - ./config:/config + - ./data:/data + networks: + - network + + mailpit: + image: 'axllent/mailpit:latest' + ports: + - 1025:1025 + - 8025:8025 +networks: + network: + driver: bridge diff --git a/src/CliApplication.php b/src/CliApplication.php new file mode 100644 index 0000000..7ec79c3 --- /dev/null +++ b/src/CliApplication.php @@ -0,0 +1,72 @@ +add(new ListCmd()); + $this->add(new ShutdownCmd()); + $this->add(new HostCmd()); + $this->add(new ManCmd()); + + // Project management + $this->add(new InitCmd()); + $this->add(new SailCmd()); + $this->add(new DockCmd()); + $this->add(new EditCmd()); + $this->add(new RmCmd()); + $this->add(new InfoCmd()); + $this->add(new CheckCmd()); + $this->add(new ComposeCmd()); + $this->add(new DockerBaseCmd()); + + // Project utils + $this->add(new BashCmd()); + $this->add(new ComposerCmd()); + $this->add(new ArtisanCmd()); + $this->add(new TinkerCmd()); + $this->add(new BackupCmd()); + $this->add(new RestoreCmd()); + $this->add(new SyncCmd()); + + // Caddy + $this->add(new CertsCmd()); + $this->add(new ShowCmd()); + $this->add(new UpCmd()); + $this->add(new DownCmd()); + $this->add(new RestartCmd()); + $this->add(new ConfigCmd()); + $this->add(new UpdateCmd()); + } +} diff --git a/src/Commands/BaseCommand.php b/src/Commands/BaseCommand.php new file mode 100644 index 0000000..e0432be --- /dev/null +++ b/src/Commands/BaseCommand.php @@ -0,0 +1,60 @@ +input = $input; + Globals::$input = $this->input; + + $this->output = $output; + Globals::$output = $this->output; + + $this->io = $io; + Globals::$io = $this->io; + + if ($this->withConfig) { + $this->configRepo = new Repository(); + Globals::$configRepo = $this->configRepo; + } + + $this->prepare(); + + if (method_exists($this, 'validateProjectDir')) { + $this->validateProjectDir(); + } + + return $this->handle(); + + } +} diff --git a/src/Commands/HostCmd.php b/src/Commands/HostCmd.php new file mode 100644 index 0000000..4c31fa9 --- /dev/null +++ b/src/Commands/HostCmd.php @@ -0,0 +1,56 @@ +setDefinition( + new InputDefinition([ + new InputArgument( + 'host', + InputArgument::REQUIRED, + 'The host to add or remove' + ), + new InputOption( + 'remove', + 'r', + InputOption::VALUE_NONE, + 'If set, the host will be removed from the file', + ), + new InputOption( + 'ip', + '', + InputOption::VALUE_REQUIRED, + 'The IP that the host should map to', + '127.0.0.1' + ), + ]) + ); + } + + protected function handle(): int + { + $host = $this->input->getArgument('host'); + $remove = $this->input->getOption('remove'); + + $ip = $this->input->getOption('ip'); + + $hostFile = HostFile::for($host, $ip); + + $remove + ? $hostFile->requestRemove() + : $hostFile->requestAdd(); + + return Command::SUCCESS; + } +} diff --git a/src/Commands/InitCmd.php b/src/Commands/InitCmd.php new file mode 100644 index 0000000..a5a5ee0 --- /dev/null +++ b/src/Commands/InitCmd.php @@ -0,0 +1,262 @@ +setDefinition( + new InputDefinition([ + new InputOption( + 'host', + '', + InputOption::VALUE_REQUIRED, + 'The project host' + ), + new InputOption( + 'name', + '', + InputOption::VALUE_REQUIRED, + 'The project name. Must be unique' + ), + new InputOption( + 'compose-file', + '', + InputOption::VALUE_REQUIRED, + 'Path to the docker compose file', + 'docker/docker-compose.yml' + ), + new InputOption( + 'env-file', + '', + InputOption::VALUE_REQUIRED, + 'Path to an optional .env file for docker compose', + 'docker/.env' + ), + new InputOption( + 'http-container', + '', + InputOption::VALUE_REQUIRED, + 'The container handling http requests', + 'nginx' + ), + new InputOption( + 'php-container', + '', + InputOption::VALUE_REQUIRED, + 'The php container', + 'php-fpm' + ), + new InputOption( + 'docker-base', + '', + InputOption::VALUE_NONE, + 'Force a new install of the base docker compose directory at this path' + ), + ]) + ); + } + + protected function handle(): int + { + $project = $this->configRepo->config->projectByPath(getcwd(), false); + + if (!empty($project)) { + $this->io->warning([ + 'Invalid path', + 'The current path is already in use for project '.$project->name, + ]); + + return Command::FAILURE; + + } + + $path = rtrim(getcwd(), '/'); + + $this->preparePath($path); + + $name = $this->validateName(); + if (empty($name)) { + return Command::FAILURE; + } + + $host = $this->validateHost($name); + if (empty($host)) { + return Command::FAILURE; + } + + $composeFile = $this->validateComposeFile(); + if (empty($composeFile)) { + return Command::SUCCESS; + } + + $envFile = $this->input->getOption('env-file'); + $httpContainer = $this->input->getOption('http-container'); + $phpContainer = $this->input->getOption('php-container'); + + $project = new Project( + path: $path, + name: $name, + host: $host, + composeFile: $composeFile, + envFile: $envFile, + httpContainer: $httpContainer, + phpContainer: $phpContainer + ); + + $this->io->definitionList( + ...$project->definitionList() + ); + + $this->io->writeln('All values can be configured through command line options. See skipper init help for more details'); + + $confirm = $this->io->confirm('Proceed with the project creation?', true); + + if (!$confirm) { + return Command::SUCCESS; + } + + $this->configRepo->config->projects[$project->name] = $project; + $this->configRepo->updateConfig(); + + $this->configRepo->caddy->writeCaddyfile($this->configRepo->config); + $this->configRepo->caddy->reload(); + + HostFile::for($project->host)->requestAdd(); + + $this->io->success("$name has been created successfully."); + + $this->io->writeln('Use command sail to start the project containers'); + $this->io->writeln('Use command check to validate your project compose file'); + + return Command::SUCCESS; + } + + private function preparePath(): void + { + // Check if this is a Laravel project + + if (!file_exists('composer.json')) { + $this->io->warning([ + 'This is not a PHP project', + 'Skipper is developed for Laravel projects and will probably not work with different stacks', + ]); + + if (!$this->io->confirm('Proceed anyway?', false)) { + exit(0); + } + } else { + $compJson = json_decode(file_get_contents('composer.json'), true); + + if (!isset($compJson['require']['laravel/framework'])) { + $this->io->warning([ + 'This is not a Laravel project', + 'Skipper is developed for Laravel projects and will probably not work with different stacks', + ]); + + if (!$this->io->confirm('Proceed anyway?', false)) { + exit(0); + } + } + } + + $dockerBase = $this->input->getOption('docker-base'); + $composeFile = $this->input->getOption('compose-file'); + + if (!$dockerBase && !file_exists($composeFile)) { + $this->io->warning([ + 'Docker compose not found', + "Skipper can prepare your project with a default configuration from\n{$this->configRepo->config->dockerBaseUrl}", + ]); + + $dockerBase = $this->io->confirm('Add default configuration to this project?'); + } + + if ($dockerBase) { + DockerBase::install(getcwd(), 'docker'); + } + } + + private function validateName(): string|null + { + $name = $this->input->getOption('name'); + if (empty($name)) { + $dirName = slug(basename(getcwd())); + $name = $this->io->ask('Project name', $dirName); + } + + $projectByName = $this->configRepo->config->projectByName($name); + + if (!empty($projectByName)) { + $this->io->error([ + 'Invalid name', + $name.' is already in use for project at path '.$projectByName->path, + ]); + + return null; + } + + return $name; + } + + private function validateHost(string $name): string|null + { + $host = $this->input->getOption('host'); + if (empty($host)) { + $host = $this->io->ask('Project Host (do not include http/https)', "$name.localhost"); + } + + $host = rtrim($host, '/'); + + if (str_starts_with($host, 'http')) { + $this->io->error([ + 'Host should not contain protocol http', + ]); + + return null; + } + + $projectByHost = $this->configRepo->config->projectByHost($host); + + if (!empty($projectByHost)) { + $this->io->error([ + 'Invalid host', + 'The current host is already in use for project '.$projectByHost->name, + ]); + + return null; + } + + return $host; + } + + private function validateComposeFile(): string|null + { + $composeFile = $this->input->getOption('compose-file'); + + if (!file_exists($composeFile)) { + $composeFile = $this->io->ask('Relative path to the docker-compose file', 'docker/docker-compose.yml'); + } + + if (!file_exists($composeFile)) { + $confirm = $this->io->confirm("File '$composeFile' not found. Proceed anyway?"); + + if (!$confirm) { + return null; + } + } + + return $composeFile; + } +} diff --git a/src/Commands/ListCmd.php b/src/Commands/ListCmd.php new file mode 100644 index 0000000..8c69416 --- /dev/null +++ b/src/Commands/ListCmd.php @@ -0,0 +1,23 @@ + [$p->isRunning() ? 'Yes' : 'No', $p->name, $p->host, $p->path], $this->configRepo->config->projects); + + $this->io->table(['Running', 'Name', 'Host', 'Path'], $data); + + if (empty($data)) { + $this->io->text('There are no projects yet'); + } + + return Command::SUCCESS; + } +} diff --git a/src/Commands/ManCmd.php b/src/Commands/ManCmd.php new file mode 100644 index 0000000..edf3ba7 --- /dev/null +++ b/src/Commands/ManCmd.php @@ -0,0 +1,86 @@ +getApplication(); + + $all = $app->all(); + + $this->io->writeln("{$app->getName()} {$app->getVersion()}"); + $this->io->newLine(); + + $this->io->writeln('Usage'); + + $this->io->text('skipper [command] [options] [arguments]'); + + $this->io->newLine(); + + $this->io->writeln('Default options'); + $this->io->writeln('Available for all commands'); + $this->io->definitionList( + ['-h, --help' => 'Display help for the given command'], + ['-q, --quiet' => 'Do not output any message'], + ['-V, --version' => 'Display the current skipper version'], + ['-n, --no-interaction' => 'Do not ask any interactive question'], + ); + + $this->io->writeln('COMMANDS'); + + $this->io->writeln(' Proxy'); + $this->io->definitionList( + ['proxy:certs' => ' Install proxy local root certificate'], + ['proxy:up' => 'Launch the proxy docker compose instance'], + ['proxy:down' => 'Stop the proxy docker compose instance'], + ['proxy:restart' => 'Restart the proxy container instance'], + ['proxy:show' => 'Print the current Caddyfile configuration'], + ['proxy:config' => 'Regenerate the proxy configuration file'], + ['proxy:update' => ' Update the proxy deployment after a skipper upgrade'], + ); + + $this->io->writeln(' Helpers'); + $this->io->definitionList( + ['list' => 'List all skipper registered projects'], + ['shutdown' => 'Stop all skipper projects and the reverse proxy'], + ['host' => 'Register a new host in your /etc/hosts file'], + ); + + $this->io->writeln(' Project management'); + $this->io->definitionList( + ['init' => 'Create a new Skipper project'], + ['sail' => 'Start a skipper project'], + ['dock' => 'Stop a skipper project'], + ['edit' => 'Edit a skipper project fields'], + ['rm' => 'Removes a skipper project reference'], + ['info' => 'Display the skipper project summary'], + ['check' => 'Check if the docker-compose file has some issues'], + ['compose' => 'Run a docker-compose command for the project'], + ['docker-base' => 'Reinstall the default docker base setup'], + ); + + $this->io->writeln(' Project utils'); + $this->io->definitionList( + ['bash' => 'Start a bash session in the php container'], + ['composer' => 'Run a composer command'], + ['artisan' => 'Run an artisan command (alias: art)'], + ['tinker' => 'Start a new Tinker shell (alias: tk)'], + ['sync' => 'Update dependencies, do migrations and sync your project'], + ['backup' => 'Create a new MySQL backup'], + ['restore' => 'Restore a MySQL backup'], + ); + + $this->io->writeln('Refer to https://github.com/tiknil/skipper for additional documentation'); + $this->io->writeln('Use skipper help [command] or skipper [command] --help for details about a specific command'); + + return Command::SUCCESS; + } +} diff --git a/src/Commands/ProjectCmds/ArtisanCmd.php b/src/Commands/ProjectCmds/ArtisanCmd.php new file mode 100644 index 0000000..8ad6c34 --- /dev/null +++ b/src/Commands/ProjectCmds/ArtisanCmd.php @@ -0,0 +1,45 @@ +ignoreValidationErrors(); + + $this->addArgument('cmd', InputArgument::IS_ARRAY); + + $this->addUsage('migrate'); + $this->addUsage('ide-helper:models --write'); + $this->addUsage('make:migration alter_users_table'); + } + + protected function handle(): int + { + $this->checkRunning(); + + $argv = $_SERVER['argv']; + array_shift($argv); + array_shift($argv); + + return Execute::onShell([ + ...$this->project->baseCommand(), + 'exec', + $this->project->phpContainer, + 'php', + 'artisan', + ...$argv, + ]); + + } +} diff --git a/src/Commands/ProjectCmds/BackupCmd.php b/src/Commands/ProjectCmds/BackupCmd.php new file mode 100644 index 0000000..77f4365 --- /dev/null +++ b/src/Commands/ProjectCmds/BackupCmd.php @@ -0,0 +1,150 @@ +setDefinition( + new InputDefinition([ + new InputOption( + 'user', + '', + InputOption::VALUE_REQUIRED, + 'MySQL user', + 'dbuser' + ), + new InputOption( + 'psw', + '', + InputOption::VALUE_REQUIRED, + 'MySQL user password', + 'dbpsw' + ), + new InputOption( + 'db', + '', + InputOption::VALUE_REQUIRED, + 'MySQL database name', + 'dbname' + ), + new InputOption( + 'container', + '', + InputOption::VALUE_REQUIRED, + 'MySQL container name', + 'mysql' + ), + new InputOption( + 'output', + 'o', + InputOption::VALUE_REQUIRED, + 'Path to the output file', + ), + ]) + ); + } + + protected function handle(): int + { + $this->checkRunning(); + + $dbUser = $this->input->getOption('user'); + $dbPsw = $this->input->getOption('psw'); + $dbName = $this->input->getOption('db'); + + $container = $this->input->getOption('container'); + + $outputFile = $this->input->getOption('output'); + if (empty($outputFile)) { + $outputFile = $this->defaultFile(); + } + + $dumpCmd = [ + ...$this->project->baseCommand(), + 'exec', + '-e', + "MYSQL_PWD=$dbPsw", + $container, + 'mysqldump', + '-u', + $dbUser, + $dbName, + '--no-tablespaces', + ]; + + Execute::logCmd([...$dumpCmd, '|', 'gzip', '>', $outputFile]); + + if (!file_exists(dirname($outputFile))) { + mkdir(dirname($outputFile), 0777, true); + } + + $fileStream = fopen($outputFile, 'w'); + + // Se il file va compressato, serve una fase intermedi + $sqlOutput = new InputStream(); + + $gzipProcess = new Process(['gzip']); + $gzipProcess->setInput($sqlOutput); + + $dumpProcess = new Process($dumpCmd); + $dumpProcess->start(function ($type, $buffer) use ($sqlOutput) { + if (Process::ERR === $type) { + $this->output->write($buffer); + } else { + $sqlOutput->write($buffer); + } + }); + + $gzipProcess->start(function ($type, $buffer) use ($fileStream) { + if (Process::ERR === $type) { + $this->output->write($buffer); + } else { + fwrite($fileStream, $buffer); + } + }); + + $dumpResult = $dumpProcess->wait(); + + $sqlOutput->close(); + $gzipResult = $gzipProcess->wait(); + + $dumpResult = $gzipResult === Command::SUCCESS ? $dumpResult : Command::FAILURE; + + fclose($fileStream); + + if ($dumpResult === Command::SUCCESS) { + $this->io->success("Backup $outputFile created successfully"); + } + + $uncompressedFile = rtrim($outputFile, '.gz'); + + $this->io->writeln(['You can uncompress it using']); + $this->io->writeln("gunzip < $outputFile > $uncompressedFile"); + + return $dumpResult; + + } + + private function defaultFile(): string + { + $date = date('ymd_His'); + + return "docker/backups/{$this->project->name}_$date.sql.gz"; + } +} diff --git a/src/Commands/ProjectCmds/BashCmd.php b/src/Commands/ProjectCmds/BashCmd.php new file mode 100644 index 0000000..4b36441 --- /dev/null +++ b/src/Commands/ProjectCmds/BashCmd.php @@ -0,0 +1,29 @@ +checkRunning(); + + $cmd = [ + ...$this->project->baseCommand(), + 'exec', + $this->project->phpContainer, + 'bash', + ]; + + return Execute::onShell($cmd); + + } +} diff --git a/src/Commands/ProjectCmds/CheckCmd.php b/src/Commands/ProjectCmds/CheckCmd.php new file mode 100644 index 0000000..f7ef88b --- /dev/null +++ b/src/Commands/ProjectCmds/CheckCmd.php @@ -0,0 +1,24 @@ +checkComposeFile()) { + $this->io->success('No issues found'); + } + + return Command::SUCCESS; + + } +} diff --git a/src/Commands/ProjectCmds/ComposeCmd.php b/src/Commands/ProjectCmds/ComposeCmd.php new file mode 100644 index 0000000..fedc8d2 --- /dev/null +++ b/src/Commands/ProjectCmds/ComposeCmd.php @@ -0,0 +1,35 @@ +ignoreValidationErrors(); + + $this->addArgument('cmd', InputArgument::IS_ARRAY, 'The command to run as docker compose'); + + $this->addUsage('exec mysql mysql -u root -p'); + $this->addUsage('logs nginx'); + } + + protected function handle(): int + { + $argv = $_SERVER['argv']; + array_shift($argv); + array_shift($argv); + + return Execute::onShell([...$this->project->baseCommand(), ...$argv]); + + } +} diff --git a/src/Commands/ProjectCmds/ComposerCmd.php b/src/Commands/ProjectCmds/ComposerCmd.php new file mode 100644 index 0000000..b94a825 --- /dev/null +++ b/src/Commands/ProjectCmds/ComposerCmd.php @@ -0,0 +1,44 @@ +ignoreValidationErrors(); + + $this->addArgument('cmd', InputArgument::IS_ARRAY); + + $this->addUsage('install'); + $this->addUsage('dump-autoload'); + $this->addUsage('require tiknil/wire-table'); + } + + protected function handle(): int + { + $this->checkRunning(); + + $argv = $_SERVER['argv']; + array_shift($argv); + array_shift($argv); + + return Execute::onShell([ + ...$this->project->baseCommand(), + 'exec', + $this->project->phpContainer, + 'composer', + ...$argv, + ]); + + } +} diff --git a/src/Commands/ProjectCmds/DockCmd.php b/src/Commands/ProjectCmds/DockCmd.php new file mode 100644 index 0000000..0643b44 --- /dev/null +++ b/src/Commands/ProjectCmds/DockCmd.php @@ -0,0 +1,32 @@ +project->baseCommand(), + 'down', + ]; + + $result = Execute::onShell($cmd); + + if ($result === Command::SUCCESS) { + $this->io->writeln('Use command caddy stop to stop the reverse proxy'); + $this->io->writeln('Use command shutdown to stop all running projects and the reverse proxy'); + } + + return $result; + } +} diff --git a/src/Commands/ProjectCmds/DockerBaseCmd.php b/src/Commands/ProjectCmds/DockerBaseCmd.php new file mode 100644 index 0000000..1cc8086 --- /dev/null +++ b/src/Commands/ProjectCmds/DockerBaseCmd.php @@ -0,0 +1,23 @@ +project->path); + + return Command::SUCCESS; + + } +} diff --git a/src/Commands/ProjectCmds/EditCmd.php b/src/Commands/ProjectCmds/EditCmd.php new file mode 100644 index 0000000..cc0ae08 --- /dev/null +++ b/src/Commands/ProjectCmds/EditCmd.php @@ -0,0 +1,175 @@ +recursive = false; + parent::configure(); + } + + protected function handle(): int + { + $name = $this->validateName(); + if (empty($name)) { + return Command::SUCCESS; + } + + $host = $this->validateHost(); + if (empty($host)) { + return Command::SUCCESS; + } + + $composeFile = $this->validateComposeFile(); + if (empty($composeFile)) { + return Command::SUCCESS; + } + + $envFile = $this->validateEnvFile(); + $httpContainer = $this->validateHttpContainer(); + $phpContainer = $this->validatePhpContainer(); + + $updatedProj = new Project( + path: $this->project->path, + name: $name, + host: $host, + composeFile: $composeFile, + envFile: $envFile, + httpContainer: $httpContainer, + phpContainer: $phpContainer + ); + + $this->io->definitionList( + ...$updatedProj->definitionList() + ); + + $confirm = $this->io->confirm('Save this updated data?', true); + + if (!$confirm) { + return Command::SUCCESS; + } + + if ($name !== $this->project->name) { + unset($this->configRepo->config->projects[$this->project->name]); + } + + $this->configRepo->config->projects[$updatedProj->name] = $updatedProj; + $this->configRepo->updateConfig(); + + $this->configRepo->caddy->writeCaddyfile($this->configRepo->config); + $this->configRepo->caddy->reload(); + + $this->io->success('Project updated successfully'); + + if ($name !== $this->project->name) { + $this->io->writeln('You need to run the sail command to apply your new name'); + } + + if ($updatedProj->host !== $this->project->host) { + if ($this->io->confirm('Allow skipper to update your /etc/hosts file?')) { + HostFile::for($this->project->host)->requestRemove(); + HostFile::for($updatedProj->host)->requestAdd(); + } + } + + return Command::SUCCESS; + } + + private function validateName(): string|null + { + $name = $this->io->ask('Project name', $this->project->name); + + if ($name !== $this->project->name) { + if ($this->configRepo->config->projectByName($name) !== null) { + + $this->io->error([ + 'Invalid name', + "Project $name already exists", + ]); + + return null; + } + + $this->io->warning([ + 'Potential data loss', + "Changing the project name to $name will replace existing docker volumes.\n". + 'The data in your local volumes (e.g. MySQL) will be lost', + ]); + + $confirm = $this->io->confirm('Proceed anyway?'); + + if (!$confirm) { + return null; + } + } + + return $name; + } + + private function validateHost(): string|null + { + $host = $this->io->ask('Project Host (do not include http/https)', $this->project->host); + + if (str_starts_with($host, 'http')) { + $this->io->error([ + 'Host should not contain protocol http', + ]); + + return null; + } + + if ($host !== $this->project->host && $this->configRepo->config->projectByHost($host) !== null) { + + $this->io->error([ + 'Invalid host', + "$host is already in use", + ]); + + return null; + } + + return $host; + } + + private function validateComposeFile(): string|null + { + $composeFile = $this->io->ask('Relative path to the docker-compose file', $this->project->composeFile); + + if (!file_exists($composeFile)) { + $confirm = $this->io->confirm("File '$composeFile' not found. Proceed anyway?"); + + if (!$confirm) { + return null; + } + } + + return $composeFile; + } + + private function validateEnvFile(): string + { + return $this->io->ask('Docker compose env file (optional)', $this->project->envFile); + } + + private function validateHttpContainer(): string + { + return $this->io->ask('HTTP container name', $this->project->httpContainer); + } + + private function validatePhpContainer(): string + { + return $this->io->ask('PHP container name', $this->project->phpContainer); + } +} diff --git a/src/Commands/ProjectCmds/InfoCmd.php b/src/Commands/ProjectCmds/InfoCmd.php new file mode 100644 index 0000000..c031352 --- /dev/null +++ b/src/Commands/ProjectCmds/InfoCmd.php @@ -0,0 +1,24 @@ +io->definitionList( + ...$this->project->definitionList() + ); + + return Command::SUCCESS; + + } +} diff --git a/src/Commands/ProjectCmds/RestoreCmd.php b/src/Commands/ProjectCmds/RestoreCmd.php new file mode 100644 index 0000000..8569910 --- /dev/null +++ b/src/Commands/ProjectCmds/RestoreCmd.php @@ -0,0 +1,165 @@ +setDefinition( + new InputDefinition([ + new InputOption( + 'user', + '', + InputOption::VALUE_REQUIRED, + 'MySQL user', + 'dbuser' + ), + new InputOption( + 'psw', + '', + InputOption::VALUE_REQUIRED, + 'MySQL user password', + 'dbpsw' + ), + new InputOption( + 'db', + '', + InputOption::VALUE_REQUIRED, + 'MySQL database name', + 'dbname' + ), + new InputOption( + 'container', + '', + InputOption::VALUE_REQUIRED, + 'MySQL container name', + 'mysql' + ), + new InputOption( + 'file', + 'f', + InputOption::VALUE_REQUIRED, + 'Path to the input file (REQUIRED)', + ), + ]) + ); + } + + protected function handle(): int + { + $this->checkRunning(); + + $dbUser = $this->input->getOption('user'); + $dbPsw = $this->input->getOption('psw'); + $dbName = $this->input->getOption('db'); + + $container = $this->input->getOption('container'); + + $file = $this->input->getOption('file'); + if (empty($file)) { + $this->io->warning('Input file is missing, use --file [filepath]'); + + return Command::SUCCESS; + } + + if (!file_exists($file)) { + $this->io->warning("Input file $file does not exists"); + + return Command::SUCCESS; + } + + $mysqlCmd = [ + ...$this->project->baseCommand(), + 'exec', + '-e', + "MYSQL_PWD=$dbPsw", + '-T', + $container, + 'mysql', + '-u', + $dbUser, + $dbName, + ]; + + if (!file_exists(dirname($file))) { + mkdir(dirname($file), 0777, true); + } + + $this->io->warning('Restoring the DB is a risky operation. You may lose your data'); + $this->io->text('Input file: '.$file); + + $confirm = $this->io->confirm('Proceed anyway?'); + + if (!$confirm) { + return Command::SUCCESS; + } + + Execute::logCmd(['gunzip', '<', $file, '|', ...$mysqlCmd]); + + try { + $fileStream = fopen($file, 'r'); + + $sqlStream = new InputStream(); + + $gunzipProc = new Process(['gunzip']); + $gunzipProc->setInput($fileStream); + + $process = new Process($mysqlCmd); + $process->setInput($sqlStream); + + $gunzipProc->start(function ($type, $buffer) use ($sqlStream) { + if (Process::ERR === $type) { + $this->output->write($buffer); + } else { + $sqlStream->write($buffer); + } + }); + $process->start(function ($type, $buffer) { + if (Process::ERR === $type) { + $this->output->write($buffer); + } else { + $this->output->write($buffer); + } + }); + + $gunResult = $gunzipProc->wait(); + + // Close streams or the process remains pending + fclose($fileStream); + $sqlStream->close(); + + $result = $process->wait(); + } catch (Exception $e) { + $this->io->error($e->getMessage()); + + $gunResult = Command::FAILURE; + $result = Command::FAILURE; + } + + if ($gunResult === Command::SUCCESS && $result === Command::SUCCESS) { + $this->io->success("Backup $file restored successfully"); + } else { + $this->io->error('An error occurred'); + + $this->io->text('Try running directly the full command you find above, it may work in case of an internal skipper php problem'); + } + + return $gunResult === Command::SUCCESS && $result === Command::SUCCESS; + + } +} diff --git a/src/Commands/ProjectCmds/RmCmd.php b/src/Commands/ProjectCmds/RmCmd.php new file mode 100644 index 0000000..07602e1 --- /dev/null +++ b/src/Commands/ProjectCmds/RmCmd.php @@ -0,0 +1,51 @@ +io->infoText('Removing the skipper project'); + } + + protected function handle(): int + { + Execute::onShell($this->project->composeCommand('down')); + + $this->io->definitionList(...$this->project->definitionList()); + + $this->io->writeln('Skipper will remove all its references to this project, but no file at this path will be edited.'); + $confirm = $this->io->confirm("Proceed with {$this->project->name} deletion?", false); + + if (!$confirm) { + return Command::SUCCESS; + } + + Execute::onShell($this->project->composeCommand('rm')); + + unset($this->configRepo->config->projects[$this->project->name]); + $this->configRepo->updateConfig(); + + $this->configRepo->caddy->writeCaddyfile($this->configRepo->config); + $this->configRepo->caddy->reload(); + + if ($this->io->confirm("Remove {$this->project->host} from your /etc/hosts file?")) { + + HostFile::for($this->project->host)->requestRemove(); + } + + return Command::SUCCESS; + + } +} diff --git a/src/Commands/ProjectCmds/SailCmd.php b/src/Commands/ProjectCmds/SailCmd.php new file mode 100644 index 0000000..2158513 --- /dev/null +++ b/src/Commands/ProjectCmds/SailCmd.php @@ -0,0 +1,84 @@ +setDefinition( + new InputDefinition([ + new InputOption( + 'build', + 'b', + InputOption::VALUE_NONE, + 'Force a new containers build' + ), + ]) + ); + } + + protected function handle(): int + { + $this->io->definitionList( + ...$this->project->definitionList() + ); + + $result = $this->checkComposeFile(); + + if (!$result) { + $confirm = $this->io->confirm('We found some problems on your compose file. Proceed anyway?'); + + if (!$confirm) { + return Command::SUCCESS; + } + } + + $shouldBuild = $this->input->getOption('build'); + + // Start caddy + $this->configRepo->caddy->start(); + + // Start project + $cmd = [...$this->project->baseCommand(), 'up', '-d', '--remove-orphans']; + + if ($shouldBuild) { + $cmd[] = '--build'; + } + + $result = Execute::onShell($cmd); + + if ($result === Command::FAILURE) { + return Command::FAILURE; + } + + if (!HostFile::for($this->project->host)->check()) { + $this->io->writeln([ + "Host {$this->project->host} is not registered inside your /etc/hosts file.", + "Use command skipper host {$this->project->host}", + ]); + + $this->io->newLine(); + } + + $this->io->writeln([ + "{$this->project->name} is up and running at https://{$this->project->host}", + ]); + + return Command::SUCCESS; + + } +} diff --git a/src/Commands/ProjectCmds/SyncCmd.php b/src/Commands/ProjectCmds/SyncCmd.php new file mode 100644 index 0000000..0bbdfc8 --- /dev/null +++ b/src/Commands/ProjectCmds/SyncCmd.php @@ -0,0 +1,41 @@ +project->composeCommand([ + 'exec', + $this->project->phpContainer, + 'composer', + 'install', + ])); + + Execute::onShell($this->project->composeCommand([ + 'exec', + $this->project->phpContainer, + 'php', + 'artisan', + 'migrate', + ])); + + Execute::hideOutput(['cd', $this->project->path], false); + + Execute::onShell(['yarn', 'install']); + + Execute::onShell(['yarn', 'build']); + + return Command::SUCCESS; + } +} diff --git a/src/Commands/ProjectCmds/TinkerCmd.php b/src/Commands/ProjectCmds/TinkerCmd.php new file mode 100644 index 0000000..f754190 --- /dev/null +++ b/src/Commands/ProjectCmds/TinkerCmd.php @@ -0,0 +1,31 @@ +checkRunning(); + + $cmd = [ + ...$this->project->baseCommand(), + 'exec', + $this->project->phpContainer, + 'php', + 'artisan', + 'tinker', + ]; + + return Execute::onShell($cmd); + + } +} diff --git a/src/Commands/ProxyCmd/CertsCmd.php b/src/Commands/ProxyCmd/CertsCmd.php new file mode 100644 index 0000000..4ccb8c1 --- /dev/null +++ b/src/Commands/ProxyCmd/CertsCmd.php @@ -0,0 +1,63 @@ +configRepo->caddy; + + $this->io->writeln('The proxy (Caddy) creates a local root certificate to sign its https certificates'); + $this->io->writeln("The OS normally doesn't trust this root certificate, but we can install it on our system"); + + $uid = posix_getuid(); + + $this->io->writeln('Looking for root certificate in '.$caddy->certPath().''); + + if (!file_exists($caddy->certPath())) { + $this->io->note([ + 'Root cert not found.', + 'Caddy generates the certs the first time it starts with a project enabled', + 'Try sailing a project before running this command', + ]); + + return Command::SUCCESS; + } + + $this->io->newLine(); + + $cmd = []; + + $this->io->writeln('Mac OS X will prompt for your Touch ID or password'); + + if ($uid !== 0) { + $cmd[] = 'sudo'; + } + + $cmd[] = 'security'; + $cmd[] = 'add-trusted-cert'; + $cmd[] = '-d'; + $cmd[] = '-k'; + $cmd[] = '/Library/Keychains/System.keychain'; + $cmd[] = $caddy->certPath(); + + $result = Execute::onShell($cmd); + + if ($result === Command::SUCCESS) { + $this->io->success('Cert installed correctly'); + + $this->io->writeln([ + 'The browser may need some time to refresh its cache and recognize the certificate as valid', + ]); + } + + return $result; + } +} diff --git a/src/Commands/ProxyCmd/ConfigCmd.php b/src/Commands/ProxyCmd/ConfigCmd.php new file mode 100644 index 0000000..08c80c7 --- /dev/null +++ b/src/Commands/ProxyCmd/ConfigCmd.php @@ -0,0 +1,25 @@ +configRepo->caddy; + + $caddy->writeCaddyfile($this->configRepo->config); + + $this->io->success('Configuration updated'); + + $this->io->writeln('Use skipper proxy:show to see the updated file'); + $this->io->writeln('Use skipper proxy:restart to see the new configuration in action'); + + return Command::SUCCESS; + } +} diff --git a/src/Commands/ProxyCmd/DownCmd.php b/src/Commands/ProxyCmd/DownCmd.php new file mode 100644 index 0000000..21735e9 --- /dev/null +++ b/src/Commands/ProxyCmd/DownCmd.php @@ -0,0 +1,19 @@ +io->info(['If you have at least one project running, docker will not be able to dismiss the skipper network']); + $caddy = $this->configRepo->caddy; + + return $caddy->stop(); + } +} diff --git a/src/Commands/ProxyCmd/RestartCmd.php b/src/Commands/ProxyCmd/RestartCmd.php new file mode 100644 index 0000000..9135453 --- /dev/null +++ b/src/Commands/ProxyCmd/RestartCmd.php @@ -0,0 +1,17 @@ +configRepo->caddy; + + return $caddy->reload(); + } +} diff --git a/src/Commands/ProxyCmd/ShowCmd.php b/src/Commands/ProxyCmd/ShowCmd.php new file mode 100644 index 0000000..670e37c --- /dev/null +++ b/src/Commands/ProxyCmd/ShowCmd.php @@ -0,0 +1,18 @@ +configRepo->caddy; + + return Execute::onOutput(['cat', $caddy->caddyfilePath()]); + } +} diff --git a/src/Commands/ProxyCmd/UpCmd.php b/src/Commands/ProxyCmd/UpCmd.php new file mode 100644 index 0000000..0ba3d73 --- /dev/null +++ b/src/Commands/ProxyCmd/UpCmd.php @@ -0,0 +1,17 @@ +configRepo->caddy; + + return $caddy->start(); + } +} diff --git a/src/Commands/ProxyCmd/UpdateCmd.php b/src/Commands/ProxyCmd/UpdateCmd.php new file mode 100644 index 0000000..bb64f02 --- /dev/null +++ b/src/Commands/ProxyCmd/UpdateCmd.php @@ -0,0 +1,23 @@ +configRepo->caddy; + + $caddy->setup(); + + $caddy->start(); + $caddy->reload(); + + return Command::SUCCESS; + } +} diff --git a/src/Commands/ShutdownCmd.php b/src/Commands/ShutdownCmd.php new file mode 100644 index 0000000..2afcb76 --- /dev/null +++ b/src/Commands/ShutdownCmd.php @@ -0,0 +1,31 @@ +configRepo->config->projects as $project) { + if (!$project->isRunning()) { + $this->io->writeln("Skipping {$project->name}, not currently running"); + + continue; + } + + $this->io->writeln("Stopping {$project->name}"); + + Execute::onShell($project->composeCommand('down')); + } + + $this->configRepo->caddy->stop(); + + return Command::SUCCESS; + } +} diff --git a/src/Commands/WithProject.php b/src/Commands/WithProject.php new file mode 100644 index 0000000..5e02eb0 --- /dev/null +++ b/src/Commands/WithProject.php @@ -0,0 +1,135 @@ +configRepo->config->projectByPath(getcwd(), $this->recursive); + + if (empty($project)) { + $this->io->warning([ + 'Invalid path', + 'The current path does not belong to a valid skipper project', + ]); + + $this->io->writeln('Use command init to create a new skipper project for this path'); + + exit(); + } + + $this->project = $project; + } + + protected function checkComposeFile(): bool + { + if (!file_exists($this->project->composeFilePath())) { + $this->io->warning('Compose file not found at path '.$this->project->composeFilePath()); + + return false; + } + + $warnings = 0; + + // Load file + try { + $data = Yaml::parseFile($this->project->composeFilePath()); + } catch (\Exception $e) { + + $this->io->error(['Error parsing the compose file', $e->getMessage()]); + + return false; + } + + $network = $data['networks'][$this->configRepo->config->network] ?? []; + if ( + empty($network) + || ($network['external'] ?? '') !== true) { + + $this->io->warning([ + 'Invalid networks configuration in your compose file', + "Caddy can't contact your nginx container unless they use a default external container named {$this->configRepo->config->network}", + ]); + + $this->io->infoText('Add this to your compose file:'); + $this->io->text(<<configRepo->config->network}: + external: true +EOD + ); + + $warnings++; + } + + $services = $data['services'] ?? []; + + if (!isset($services[$this->project->httpContainer])) { + $this->io->warning([ + "{$this->project->httpContainer} container is missing from your compose file", + "Caddy is forwarding HTTP requests to {$this->project->httpContainer}, it should exist with port 80 exposed", + ]); + + $warnings++; + } + + $nginxNetworks = $services[$this->project->httpContainer]['networks'] ?? []; + + if (!empty(array_diff(['default', $this->configRepo->config->network], $nginxNetworks))) { + $this->io->warning([ + "{$this->project->httpContainer} container should be attached to two networks", + "Add both 'default' and '{$this->configRepo->config->network}' as {$this->project->httpContainer} networks", + ]); + + $warnings++; + } + + if (!isset($services[$this->project->phpContainer])) { + $this->io->warning([ + "{$this->project->phpContainer} container is missing from your compose file", + "Skipper runs PHP and bash commands on {$this->project->phpContainer}, it should exist", + ]); + + $warnings++; + } + + $override = rtrim(dirname($this->project->composeFilePath()), '/').'/docker-compose.override.yml'; + + if (file_exists($override)) { + $this->io->warning([ + "Found override file at $override", + 'It will not be picked up unless you use skipper from the directory the file is located at '. + '('.dirname($override).")\n". + 'We suggest not using an override file, to avoid different behaviour depending on the directory you are running scripts from', + ]); + + $warnings++; + } + + return $warnings === 0; + } + + protected function checkRunning(): void + { + if (!$this->project->isRunning()) { + $this->io->warning('Project is not running. Use the sail command'); + + exit(); + } + } +} diff --git a/src/Config/Config.php b/src/Config/Config.php new file mode 100644 index 0000000..d01504d --- /dev/null +++ b/src/Config/Config.php @@ -0,0 +1,60 @@ + $projects + */ + public function __construct( + public array $projects = [], + public string $network = 'skipper_network', + public string $dockerBaseUrl = 'https://github.com/tiknil/laravel-docker-compose' + ) { + } + + public function projectByPath(string $path, bool $recursive = false): Project|null + { + while ($path !== '' && $path !== '/') { + foreach ($this->projects as $project) { + if ($project->path === $path) { + return $project; + } + } + + if ($recursive) { + $path = dirname($path); + } else { + $path = ''; + } + } + + return null; + } + + public function projectByName(string $name): Project|null + { + return $this->projects[$name] ?? null; + } + + public function projectByHost(string $host): Project|null + { + foreach ($this->projects as $project) { + if ($project->host === $host) { + return $project; + } + } + + return null; + } + + public function toArray(): array + { + return [ + 'projects' => array_map(fn ($p) => $p->toArray(), $this->projects), + 'network' => $this->network, + 'dockerBaseUrl' => $this->dockerBaseUrl, + ]; + } +} diff --git a/src/Config/Project.php b/src/Config/Project.php new file mode 100644 index 0000000..b542415 --- /dev/null +++ b/src/Config/Project.php @@ -0,0 +1,139 @@ +name, + '-f', + $this->composeFilePath(), + ]; + + if (!empty($this->envFile) && file_exists($this->envFilePath())) { + $cmd[] = '--env-file'; + $cmd[] = $this->envFilePath(); + } + + return $cmd; + } + + public function composeCommand(array|string $cmd): array + { + $cmd = is_string($cmd) ? explode(' ', $cmd) : $cmd; + + return [ + ...$this->baseCommand(), + ...$cmd, + ]; + } + + public function composeFilePath(): string + { + if (str_starts_with($this->composeFile, '/')) { + return $this->composeFile; + } + + if (rtrim(getcwd(), '/') === rtrim($this->path, '/')) { + return $this->composeFile; + } + + return rtrim($this->path, '/').'/'.$this->composeFile; + + } + + public function envFilePath(): string + { + if (empty($this->envFile)) { + return ''; + } + + if (str_starts_with($this->envFile, '/')) { + return $this->envFile; + } + + if (rtrim(getcwd(), '/') === rtrim($this->envFile, '/')) { + return $this->envFile; + } + + return rtrim($this->path, '/').'/'.$this->envFile; + + } + + public function definitionList(): array + { + return [ + ['name' => $this->name], + ['host' => $this->host], + ['path' => $this->path], + ['composeFile' => $this->composeFile], + ['envFile' => $this->envFile ?: ''], + ['httpContainer' => $this->httpContainer], + ['phpContainer' => $this->phpContainer], + ]; + } + + public function isRunning(): bool + { + $process = new Process([ + ...$this->baseCommand(), + 'ps', + $this->phpContainer, + '--format=json', + ]); + + $result = $process->run(); + + return $result === Command::SUCCESS && trim($process->getOutput()) !== '[]'; + } + + public function toArray(): array + { + return [ + 'path' => $this->path, + 'name' => $this->name, + 'host' => $this->host, + 'composeFile' => $this->composeFile, + 'envFile' => $this->envFile, + 'httpContainer' => $this->httpContainer, + 'phpContainer' => $this->phpContainer, + ]; + } + + public function __serialize(): array + { + return $this->toArray(); + } +} diff --git a/src/Config/Proxy.php b/src/Config/Proxy.php new file mode 100644 index 0000000..eb6e9f7 --- /dev/null +++ b/src/Config/Proxy.php @@ -0,0 +1,115 @@ +proxyDir()) && file_exists($this->proxyDir().'/docker-compose.yml'); + } + + public function setup(): void + { + if (!file_exists($this->proxyDir())) { + mkdir($this->proxyDir(), 0777, true); + } + + Execute::hideOutput([ + 'cp', + '-r', + path('proxy/'), // Keep the trailing / + $this->proxyDir(), + ]); + } + + public function writeCaddyfile(Config $config): void + { + $fileContent = <<<'EOD' +{ + email local-certs@tiknil.com + local_certs +} + +EOD; + + foreach ($config->projects as $project) { + $fileContent .= <<host}:443 { + encode gzip + reverse_proxy {$project->name}-{$project->httpContainer}-1:80 { + header_up X-Real-IP {remote_host} + } +} + +EOD; + } + + file_put_contents($this->caddyfilePath(), $fileContent); + } + + public function start(): int + { + $cmd = [ + ...$this->baseCommand(), + 'up', + '-d', + '--remove-orphans', + ]; + + return Execute::onShell($cmd); + } + + public function reload(): int + { + $cmd = [ + ...$this->baseCommand(), + 'restart', + 'caddy', + ]; + + return Execute::onShell($cmd); + } + + public function stop(): int + { + $cmd = [ + ...$this->baseCommand(), + 'down', + ]; + + return Execute::onShell($cmd); + } + + public function certPath(): string + { + return $this->proxyDir().'/data/caddy/pki/authorities/local/root.crt'; + } + + public function caddyfilePath(): string + { + return $this->proxyDir().'/Caddyfile'; + } + + private function baseCommand(): array + { + return [ + 'docker', + 'compose', + '-f', + $this->proxyDir().'/docker-compose.yml', + ]; + } + + private function proxyDir(): string + { + return rtrim(getenv('HOME'), '/').'/.skipper/proxy'; + } +} diff --git a/src/Config/Repository.php b/src/Config/Repository.php new file mode 100644 index 0000000..73c9390 --- /dev/null +++ b/src/Config/Repository.php @@ -0,0 +1,116 @@ +config = $this->loadConfig(); + $this->caddy = $this->loadCaddy(); + } + + private function loadConfig(): Config + { + // Checks if the file exists + if (!file_exists(self::filePath())) { + + Globals::$io->infoText('Skipper configuration file not found. Creating now'); + + $this->config = new Config(); + $this->updateConfig(); + + Globals::$io->infoText('Configuration file created at '.$this->filePath()); + + return $this->config; + + } else { + try { + $data = Yaml::parseFile($this->filePath()); + + $config = new Config(); + + if (is_array($data['projects'] ?? false)) { + + $config->projects = array_map( + fn ($proj) => Project::from($proj), + $data['projects'] + ); + + } else { + Globals::$io->warning('Projects key missing from config files'); + } + + if (is_string($data['network'] ?? false)) { + $config->network = $data['network']; + } else { + Globals::$io->warning('Network key missing from config files. Using default'); + } + + if (is_string($data['dockerBaseUrl'] ?? false)) { + $config->dockerBaseUrl = $data['dockerBaseUrl']; + } + + return $config; + + } catch (\Exception $e) { + Globals::$io->error('Unable to read config file located in '.$this->filePath()); + Globals::$io->error($e->getMessage()); + exit(); + } + } + } + + private function loadCaddy(): Proxy + { + $caddy = new Proxy(); + + if (!$caddy->check()) { + Globals::$io->info('Caddy files are missing. Setting them up now'); + + $caddy->setup(); + + $caddy->writeCaddyfile($this->config); + + Globals::$io->info('Done. Caddy is ready to run'); + + } + + return $caddy; + } + + public function updateConfig(): void + { + if (!file_exists($this->fileDir())) { + mkdir($this->fileDir(), 0777, true); + } + + $result = file_put_contents($this->filePath(), Yaml::dump($this->config->toArray(), 4)); + + if (!$result) { + exit(); + } + } + + private function fileDir(): string + { + return rtrim(getenv('HOME'), '/').'/.skipper'; + } + + private function fileName(): string + { + return 'config.yaml'; + } + + private function filePath(): string + { + return $this->fileDir().'/'.$this->fileName(); + } +} diff --git a/src/Helpers.php b/src/Helpers.php new file mode 100644 index 0000000..2e2fd3e --- /dev/null +++ b/src/Helpers.php @@ -0,0 +1,15 @@ +path = rtrim($this->path, '/'); + $this->folder = trim($this->folder, '/'); + $this->io = Globals::$io; + $this->config = Globals::$configRepo->config; + } + + public static function install(string $path, string $folder = 'docker'): void + { + (new self($path, $folder))->performInstall(); + } + + public function performInstall() + { + $filePath = "$this->path/$this->folder"; + $this->io->writeln(["Installing base docker configuration into {$this->folder}/"]); + + if (file_exists($filePath.'/')) { + + $this->io->note(["Folder {$this->folder} already exists.", 'All its content will be deleted and replaced']); + + if ($this->io->confirm('Proceed anyway?')) { + + Execute::onOutput(['rm', '-r', $filePath]); + } else { + $this->io->writeln('Operation canceled. The base docker configuration will not be installed'); + } + } + + $result = Execute::onShell(['git', 'clone', $this->config->dockerBaseUrl, $filePath]); + + if ($result !== Command::SUCCESS) { + return; + } + + Execute::onOutput(['cp', $filePath.'/.env.base', $filePath.'/.env']); + + Execute::onOutput(['rm', '-rf', $filePath.'/.git', $filePath.'/.env.base']); + } +} diff --git a/src/Utils/Execute.php b/src/Utils/Execute.php new file mode 100644 index 0000000..88d4d84 --- /dev/null +++ b/src/Utils/Execute.php @@ -0,0 +1,61 @@ +setTimeout(0); + $process->setTty(true); + + $result = $process->run(); + + Globals::$output->writeln(''); + + return $result; + } + + public static function onOutput(array $cmd, bool $log = true): int + { + if ($log) { + self::logCmd($cmd); + } + + $process = new Process($cmd); + + $process->enableOutput(); + + $result = $process->run(function ($type, $buffer) { + echo $buffer; + }); + + echo "\n"; + + return $result; + } + + public static function hideOutput(array $cmd, bool $log = true): int + { + if ($log) { + self::logCmd($cmd); + } + + $process = new Process($cmd); + $process->disableOutput(); + + return $process->run(); + } + + public static function logCmd(array $cmd): void + { + Globals::$output->writeln(' >>> '.implode(' ', $cmd)."\n"); + } +} diff --git a/src/Utils/Globals.php b/src/Utils/Globals.php new file mode 100644 index 0000000..82553fa --- /dev/null +++ b/src/Utils/Globals.php @@ -0,0 +1,18 @@ +io = Globals::$io; + } + + public static function for(string $host, string $ip = '127.0.0.1'): self + { + return new self($host, $ip); + } + + public function check(): bool + { + return $this->findHostLine($this->readFile()) !== null; + } + + public function requestAdd(): void + { + if (!$this->add()) { + $this->io->writeln('You can add the record manually to your /etc/hosts'. + "file or try again with command skipper host $this->host --ip $this->ip" + ); + } + } + + public function add(): bool + { + $this->io->writeln("Mapping $this->host to $this->ip"); + + $lines = $this->readFile(); + + $hostLine = $this->findHostLine($lines); + + if ($hostLine !== null) { + $this->io->writeln("$this->host already found on your hosts file at line $hostLine:"); + $this->io->newLine(); + $this->io->writeln($lines[$hostLine]); + + if (!$this->io->confirm('Do you want to overwrite it?')) { + return false; + } + + array_splice($lines, $hostLine, 1); + } + + $lines[] = "$this->ip\t$this->host"; + + if ($this->writeFile($lines) !== Command::SUCCESS) { + $this->io->error([ + 'Error updating your host file', + 'Check your file content and, if required, restore the file from the backup', + ]); + + $this->io->definitionList( + ['cat /etc/hosts' => 'Check your file content'], + ['sudo cp /etc/hosts.bkp /etc/hosts' => 'Restore the backup'] + ); + + return false; + } else { + + $this->io->writeln("$this->host mapped successfully to $this->ip"); + + } + + $this->io->newLine(); + + return true; + + } + + public function requestRemove(): void + { + + if (!$this->remove()) { + $this->io->writeln('You can remove the record manually to your /etc/hosts file'. + " or try again with command skipper host $this->host --remove" + ); + } + } + + public function remove(): bool + { + $this->io->writeln("Removing $this->host from /etc/hosts"); + + $lines = $this->readFile(); + + $hostLine = $this->findHostLine($lines); + + if ($hostLine === null) { + $this->io->writeln("$this->host not found on your hosts file"); + + return true; + } + + array_splice($lines, $hostLine, 1); + + if ($this->writeFile($lines) !== Command::SUCCESS) { + $this->io->error([ + 'Error updating your host file', + 'Check your file content and, if required, restore the file from the backup', + ]); + + $this->io->definitionList( + ['cat /etc/hosts' => 'Check your file content'], + ['sudo cp /etc/hosts.bkp /etc/hosts' => 'Restore the backup'] + ); + + return false; + } else { + + $this->io->writeln("$this->host removed successfully from your /etc/hosts file"); + } + + $this->io->newLine(); + + return true; + + } + + private function readFile(): array + { + $lines = preg_split('/\r\n|\n|\r/', trim(file_get_contents('/etc/hosts'))); + + return $lines; + } + + private function findHostLine(array $lines): int|null + { + foreach ($lines as $i => $line) { + if (str_starts_with($line, '#')) { + continue; + } + + if (str_ends_with($line, $this->host)) { + return $i; + } + } + + return null; + } + + private function writeFile(array $lines): int + { + $this->io->writeln('We need sudo permissions to create a backup copy of your hosts file and to remove the host'); + $this->io->writeln('You may be prompted for your password'); + + if (Execute::onShell(['sudo', 'cp', '/etc/hosts', '/etc/hosts.bkp'], false) !== Command::SUCCESS) { + $this->io->error('Error creating hosts backup file.'); + + return false; + } + + $this->io->writeln('Backup file /etc/hosts.bkp created successfully'); + + $inputStream = new InputStream(); + + $process = new Process(['sudo', 'tee', '/etc/hosts']); + $process->setInput($inputStream); + + $process->start(); + foreach ($lines as $line) { + $inputStream->write($line."\n"); + } + $inputStream->close(); + + return $process->wait(); + } +} diff --git a/src/Utils/TiknilStyle.php b/src/Utils/TiknilStyle.php new file mode 100644 index 0000000..6a152bf --- /dev/null +++ b/src/Utils/TiknilStyle.php @@ -0,0 +1,24 @@ +block($message, 'INFO', 'fg=green', ' ', false); + } + + public function infoText(string|array $message) + { + $messages = is_string($message) ? [$message] : $message; + + foreach ($messages as $msg) { + + $this->writeln(" [INFO] $msg"); + + } + } +} diff --git a/src/index.php b/src/index.php new file mode 100644 index 0000000..4183651 --- /dev/null +++ b/src/index.php @@ -0,0 +1,13 @@ +registerCommands(); + +$app->setDefaultCommand('man'); + +$app->run();