refactor(*): change to a Symfony Console app

This commit is contained in:
Oliver Davies 2023-04-24 00:24:08 +01:00
parent 4af661bad4
commit 8db64458b1
41 changed files with 5938 additions and 4285 deletions

20
.env Normal file
View file

@ -0,0 +1,20 @@
# In all environments, the following files are loaded if they exist,
# the latter taking precedence over the former:
#
# * .env contains default values for the environment variables needed by the app
# * .env.local uncommitted file with local overrides
# * .env.$APP_ENV committed environment-specific defaults
# * .env.$APP_ENV.local uncommitted environment-specific overrides
#
# Real environment variables win over .env files.
#
# DO NOT DEFINE PRODUCTION SECRETS IN THIS FILE NOR IN ANY OTHER COMMITTED FILES.
#
# Run "composer dump-env prod" to compile .env files for production use (requires symfony/flex >=1.2).
# https://symfony.com/doc/current/best_practices.html#use-environment-variables-for-infrastructure-configuration
###> symfony/framework-bundle ###
APP_ENV=prod
APP_DEBUG=0
APP_SECRET=91b4670ae7b240ca6b97ed20f81c81f0
###< symfony/framework-bundle ###

View file

@ -1,28 +0,0 @@
name: CI
on:
push:
main:
workflow_dispatch:
jobs:
build:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@5a4ac9002d0be2fb38bd78e4b4dbde5606d7042f # v2.3.4
- uses: extractions/setup-just@95b912dc5d3ed106a72907f2f9b91e76d60bdb76 # 1.5.0
- name: Build the Docker image
run: |
docker image build . \
--tag build-configs
- name: Run PHPStan
run: |
docker run \
--rm \
--interactive \
--entrypoint phpstan \
build-configs \
--no-progress

13
.gitignore vendored
View file

@ -1,3 +1,12 @@
/.phpunit.result.cache
/build/
/build-configs
/**/vendor/
###> symfony/framework-bundle ###
/.env.local
/.env.local.php
/.env.*.local
/config/secrets/prod/prod.decrypt.private.php
/public/bundles/
/var/
/vendor/
###< symfony/framework-bundle ###

36
autoload_runtime.template Normal file
View file

@ -0,0 +1,36 @@
<?php
// autoload_runtime.php @generated by Symfony Runtime
if (true === (require_once __DIR__.'/autoload.php') || empty($_SERVER['SCRIPT_FILENAME'])) {
return;
}
$pharPath = Phar::running();
if (strlen($pharPath) == 0) {
$scriptFileName = $_SERVER['SCRIPT_FILENAME'];
} else {
$scriptFileName = $_SERVER['APP_SCRIPT_FILENAME'] ?? $_SERVER['SCRIPT_FILENAME'] ?? null;
}
$app = require $scriptFileName;
if (!is_object($app)) {
throw new TypeError(sprintf('Invalid return value: callable object expected, "%s" returned from "%s".', get_debug_type($app), $_SERVER['SCRIPT_FILENAME']));
}
$runtime = $_SERVER['APP_RUNTIME'] ?? $_ENV['APP_RUNTIME'] ?? %runtime_class%;
$runtime = new $runtime(($_SERVER['APP_RUNTIME_OPTIONS'] ?? $_ENV['APP_RUNTIME_OPTIONS'] ?? []) + %runtime_options%);
[$app, $args] = $runtime
->getResolver($app)
->resolve();
$app = $app(...$args);
exit(
$runtime
->getRunner($app)
->run()
);

View file

@ -1,266 +1,19 @@
#!/usr/bin/env php
<?php
declare(strict_types=1);
use App\Kernel;
use Symfony\Bundle\FrameworkBundle\Console\Application;
require __DIR__.'/../vendor/autoload.php';
$_SERVER['APP_SCRIPT_FILENAME'] = __FILE__;
use Illuminate\Support\{Arr, Collection};
use OliverDaviesLtd\BuildConfigs\ConfigurationData;
use OliverDaviesLtd\BuildConfigs\DataTransferObject\TemplateFile;
use OliverDaviesLtd\BuildConfigs\Enum\{Language, WebServer};
use Silly\Application;
use Symfony\Component\Console\Style\SymfonyStyle;
use Symfony\Component\Filesystem\Filesystem;
use Symfony\Component\Serializer\Encoder\JsonEncoder;
use Symfony\Component\Serializer\NameConverter\CamelCaseToSnakeCaseNameConverter;
use Symfony\Component\Serializer\Normalizer\ObjectNormalizer;
use Symfony\Component\Serializer\Serializer;
use Symfony\Component\Validator\{ConstraintViolationInterface, Validation};
use Symfony\Component\Yaml\Yaml;
use Twig\Environment;
use Twig\Loader\FilesystemLoader;
$app = new Application();
$app->command(
'init [-p|--project-name=] [-l|--language=] [-t|--type=]',
function(
string $projectName,
string $language,
string $type,
): void {
$projectName = str_replace('.', '-', $projectName);
// TODO: validate the project type.
$output = <<<EOF
name: $projectName
language: $language
type: $type
EOF;
file_put_contents('build.yaml', $output);
}
)->descriptions('Initialise a new build.yaml file.', [
'--project-name' => 'The name of the project.',
'--language' => 'The language used in the project.',
'--type' => 'The project type.',
]);;
$app->command(
'generate [-c|--config-file=] [-o|--output-dir=]',
function (
SymfonyStyle $io,
string $configFile = 'build.yaml',
string $outputDir = '.',
): void {
$configurationData = array_merge(
Yaml::parseFile(__DIR__ . '/../resources/build.defaults.yaml'),
Yaml::parseFile($configFile),
);
// Convert the input to a configuration data object.
$normalizer = new ObjectNormalizer(null, new CamelCaseToSnakeCaseNameConverter());
$serializer = new Serializer([$normalizer], [new JsonEncoder()]);
$configurationDataObject = $serializer->deserialize(json_encode($configurationData), ConfigurationData::class, 'json');
$validator = Validation::createValidatorBuilder()->enableAnnotationMapping()->getValidator();
$violations = $validator->validate($configurationDataObject);
if (0 < $violations->count()) {
$io->error('Configuration is invalid.');
$io->listing(
collect($violations)
->map(fn (ConstraintViolationInterface $v) => "{$v->getPropertyPath()} - {$v->getMessage()}")
->toArray()
);
return;
}
if (isset($configurationData['docker-compose'])) {
$configurationData['dockerCompose'] = $configurationData['docker-compose'];
$configurationData['docker-compose'] = null;
}
$configurationData['managedText'] = 'Do not edit this file. It is automatically generated by https://www.oliverdavies.uk/build-configs.';
$filesToGenerate = getFiles(configurationData: $configurationData);
$io->info("Building configuration for {$configurationData['name']}.");
$io->write('Generated files:');
$io->listing(getListOfFiles(filesToGenerate: $filesToGenerate)->toArray());
generateFiles(
configurationData: $configurationData,
filesToGenerate: $filesToGenerate,
outputDir: $outputDir,
);
}
)->descriptions('Generate project-specific configuration files.', [
'--config-file' => 'The path to the project\'s build.yaml file',
'--output-dir' => 'The directory to create files in',
]);
$app->run();
/**
* @param array<string, string> $configurationData
* @param Collection<int, TemplateFile> $filesToGenerate
*/
function generateFiles(
Collection $filesToGenerate,
string $outputDir,
array $configurationData,
): void
{
$filesystem = new Filesystem();
$twig = new Environment(new FilesystemLoader([__DIR__ . '/../templates']));
$filesToGenerate->each(function(TemplateFile $templateFile) use ($configurationData, $filesystem, $outputDir, $twig): void {
if ($templateFile->path !== null) {
if (!$filesystem->exists($templateFile->path)) {
$filesystem->mkdir("{$outputDir}/{$templateFile->path}");
}
}
$sourceFile = "{$templateFile->data}.twig";
$outputFile = collect([
$outputDir,
$templateFile->path,
$templateFile->name,
])->filter()->implode('/');
$filesystem->dumpFile($outputFile, $twig->render($sourceFile, $configurationData));
});
// If the Docker entrypoint file is generated, ensure it is executable.
if ($filesystem->exists("{$outputDir}/tools/docker/images/php/root/usr/local/bin/docker-entrypoint-php")) {
$filesystem->chmod("{$outputDir}/tools/docker/images/php/root/usr/local/bin/docker-entrypoint-php", 0755);
}
if (!is_file(dirname(__DIR__).'/vendor/autoload_runtime.php')) {
throw new LogicException('Symfony Runtime is missing. Try running "composer require symfony/runtime".');
}
function getFiles(array $configurationData): Collection
{
/** @var Collection<int, TemplateFile> */
$filesToGenerate = collect([
new TemplateFile(data: 'common/.dockerignore', name: '.dockerignore'),
new TemplateFile(data: 'common/.hadolint.yaml', name: '.hadolint.yaml'),
new TemplateFile(data: 'env.example', name: '.env.example'),
]);
require_once dirname(__DIR__).'/vendor/autoload_runtime.php';
$extraDatabases = Arr::get($configurationData, 'database.extra_databases', []);
if (count($extraDatabases) > 0) {
$filesToGenerate[] = new TemplateFile(
data: 'extra-databases.sql',
name: 'extra-databases.sql',
path: 'tools/docker/images/database/root/docker-entrypoint-initdb.d',
);
}
return static function (array $context) {
$kernel = new Kernel($context['APP_ENV'], (bool) $context['APP_DEBUG']);
if (false !== Arr::get($configurationData, "justfile", true)) {
$filesToGenerate[] = new TemplateFile(data: 'justfile', name: 'justfile');
}
if (isset($configurationData['dockerCompose']) && $configurationData['dockerCompose'] !== null) {
$filesToGenerate[] = new TemplateFile(data: 'docker-compose.yaml', name: 'docker-compose.yaml');
}
if (isPhp(Arr::get($configurationData, 'language'))) {
$filesToGenerate[] = new TemplateFile(data: 'php/Dockerfile', name: 'Dockerfile');
$filesToGenerate[] = new TemplateFile(data: 'php/phpcs.xml', name: 'phpcs.xml.dist');
$filesToGenerate[] = new TemplateFile(data: 'php/phpunit.xml', name: 'phpunit.xml.dist');
$filesToGenerate[] = new TemplateFile(
data: 'php/docker-entrypoint-php',
name: 'docker-entrypoint-php',
path: 'tools/docker/images/php/root/usr/local/bin',
);
if (Arr::has(array: $configurationData, keys: 'php.phpstan')) {
$filesToGenerate[] = new TemplateFile(data: 'php/phpstan.neon', name: 'phpstan.neon.dist');
}
}
if (isNode(Arr::get($configurationData, 'language'))) {
$filesToGenerate[] = new TemplateFile(data: 'node/.yarnrc', name: '.yarnrc');
$filesToGenerate[] = new TemplateFile(data: 'node/Dockerfile', name: 'Dockerfile');
}
if (isCaddy(Arr::get($configurationData, 'web.type'))) {
$filesToGenerate[] = new TemplateFile(
data: 'web/caddy/Caddyfile',
name: 'Caddyfile',
path: 'tools/docker/images/web/root/etc/caddy',
);
}
if (isNginx(Arr::get($configurationData, 'web.type'))) {
$filesToGenerate[] = new TemplateFile(
data: 'web/nginx/default.conf',
name: 'default.conf',
path: 'tools/docker/images/web/root/etc/nginx/conf.d',
);
}
if ('drupal-project' === Arr::get($configurationData, 'type')) {
// Add a Drupal version of phpunit.xml.dist.
$filesToGenerate[] = new TemplateFile(data: 'drupal-project/phpunit.xml.dist', name: 'phpunit.xml.dist');
}
if (Arr::get($configurationData, 'experimental.createGitHubActionsConfiguration', false) === true) {
$filesToGenerate[] = new TemplateFile(
data: 'ci/github-actions/ci.yml',
name: 'ci.yml',
path: '.github/workflows',
);
}
return $filesToGenerate;
}
function getListOfFiles(Collection $filesToGenerate): Collection
{
return $filesToGenerate
->map(fn (TemplateFile $templateFile): string =>
collect([$templateFile->path, $templateFile->name])->filter()->implode('/'))
->unique()
->sort();
}
function isCaddy(?string $webServer): bool
{
if (is_null($webServer)) {
return false;
}
return strtoupper($webServer) === WebServer::CADDY->name;
}
function isNginx(?string $webServer): bool
{
if (is_null($webServer)) {
return false;
}
return strtoupper($webServer) === WebServer::NGINX->name;
}
function isNode(?string $language): bool
{
if (is_null($language)) {
return false;
}
return strtoupper($language) === Language::NODE->name;
}
function isPhp(?string $language): bool
{
if (is_null($language)) {
return false;
}
return strtoupper($language) === Language::PHP->name;
}
return new Application($kernel);
};

View file

@ -1,10 +1,18 @@
{
"compression": "GZ",
"directories": [
"src",
"templates",
"vendor"
"output": "build-configs",
"files-bin": [
".env.local.php",
"autoload_runtime.template",
"src/Controller/.gitignore"
],
"main": "bin/build-configs",
"output": "build/build-configs"
"directories": [
"config",
"public",
"resources",
"var"
],
"force-autodiscovery": true,
"check-requirements": false,
"exclude-composer-files": false,
"compression": "GZ"
}

View file

@ -1,30 +1,71 @@
{
"type": "project",
"license": "proprietary",
"require": {
"illuminate/support": "^9.50",
"mnapoli/silly": "^1.8",
"symfony/console": "^6.2",
"symfony/filesystem": "^6.2",
"symfony/property-access": "^6.2",
"symfony/serializer": "^6.2",
"symfony/validator": "^6.2",
"symfony/yaml": "^6.2",
"twig/twig": "^3.5"
},
"require-dev": {
"humbug/box": "^4.2",
"pestphp/pest": "^1.22",
"phpstan/phpstan": "^1.9",
"symfony/var-dumper": "^6.2"
},
"autoload": {
"psr-4": {
"OliverDaviesLtd\\BuildConfigs\\": "src/"
}
"php": "^8.1",
"ext-ctype": "*",
"ext-iconv": "*",
"doctrine/annotations": "^2.0",
"illuminate/collections": "*",
"illuminate/support": "^10.8",
"phpdocumentor/reflection-docblock": "^5.3",
"phpstan/phpdoc-parser": "^1.20",
"phpstan/phpstan": "^1.10",
"symfony/console": "6.2.*",
"symfony/dotenv": "6.2.*",
"symfony/flex": "^2.0",
"symfony/framework-bundle": "6.2.*",
"symfony/property-access": "6.2.*",
"symfony/property-info": "6.2.*",
"symfony/runtime": "6.2.*",
"symfony/serializer": "6.2.*",
"symfony/twig-bundle": "6.2.*",
"symfony/validator": "6.2.*",
"symfony/yaml": "6.2.*",
"twig/extra-bundle": "^2.12|^3.0",
"twig/twig": "^2.12|^3.0"
},
"config": {
"sort-packages": true,
"platform": {
"php": "8.2"
},
"allow-plugins": {
"pestphp/pest-plugin": true
"symfony/flex": true,
"symfony/runtime": true,
"bamarni/composer-bin-plugin": true
}
},
"bin": ["bin/build-configs"],
"autoload": {
"psr-4": {
"App\\": "src/"
}
},
"replace": {
"symfony/polyfill-ctype": "*",
"symfony/polyfill-iconv": "*",
"symfony/polyfill-php72": "*",
"symfony/polyfill-php73": "*",
"symfony/polyfill-php74": "*",
"symfony/polyfill-php80": "*",
"symfony/polyfill-php81": "*"
},
"conflict": {
"symfony/symfony": "*"
},
"extra": {
"bin-dir": "new-bin",
"symfony": {
"allow-contrib": false,
"require": "6.2.*"
},
"runtime": {
"autoload_template": "autoload_runtime.template"
}
},
"require-dev": {
"bamarni/composer-bin-plugin": "^1.8",
"symfony/maker-bundle": "^1.48"
}
}

5756
composer.lock generated

File diff suppressed because it is too large Load diff

8
config/bundles.php Normal file
View file

@ -0,0 +1,8 @@
<?php
return [
Symfony\Bundle\FrameworkBundle\FrameworkBundle::class => ['all' => true],
Symfony\Bundle\MakerBundle\MakerBundle::class => ['dev' => true],
Symfony\Bundle\TwigBundle\TwigBundle::class => ['all' => true],
Twig\Extra\TwigExtraBundle\TwigExtraBundle::class => ['all' => true],
];

View file

@ -0,0 +1,19 @@
framework:
cache:
# Unique name of your app: used to compute stable namespaces for cache keys.
#prefix_seed: your_vendor_name/app_name
# The "app" cache stores to the filesystem by default.
# The data in this cache should persist between deploys.
# Other options include:
# Redis
#app: cache.adapter.redis
#default_redis_provider: redis://localhost
# APCu (not recommended with heavy random-write workloads as memory fragmentation can cause perf issues)
#app: cache.adapter.apcu
# Namespaced pools use the above "app" backend by default
#pools:
#my.dedicated.cache: null

View file

@ -0,0 +1,3 @@
framework:
router:
strict_requirements: true

View file

@ -0,0 +1,17 @@
# see https://symfony.com/doc/current/reference/configuration/framework.html
framework:
secret: '%env(APP_SECRET)%'
#csrf_protection: true
#http_method_override: true
# Enables session support. Note that the session will ONLY be started if you read or write from it.
# Remove or comment this section to explicitly disable session support.
session:
handler_id: null
cookie_secure: auto
cookie_samesite: lax
#esi: true
#fragments: true
php_errors:
log: true

View file

@ -0,0 +1,3 @@
framework:
router:
strict_requirements: null

View file

@ -0,0 +1,7 @@
framework:
router:
utf8: true
# Configure how to generate URLs in non-HTTP contexts, such as CLI commands.
# See https://symfony.com/doc/current/routing.html#generating-urls-in-commands
#default_uri: http://localhost

View file

@ -0,0 +1,4 @@
framework:
test: true
session:
storage_id: session.storage.mock_file

View file

@ -0,0 +1,3 @@
framework:
router:
strict_requirements: true

View file

@ -0,0 +1,13 @@
framework:
default_locale: en
translator:
default_path: '%kernel.project_dir%/translations'
fallbacks:
- en
# providers:
# crowdin:
# dsn: '%env(CROWDIN_DSN)%'
# loco:
# dsn: '%env(LOCO_DSN)%'
# lokalise:
# dsn: '%env(LOKALISE_DSN)%'

View file

@ -0,0 +1,6 @@
twig:
default_path: '%kernel.project_dir%/templates'
when@test:
twig:
strict_variables: true

View file

@ -0,0 +1,13 @@
framework:
validation:
email_validation_mode: html5
# Enables validator auto-mapping support.
# For instance, basic validation constraints will be inferred from Doctrine's metadata.
#auto_mapping:
# App\Entity\: []
when@test:
framework:
validation:
not_compromised_password: false

17
config/preload.php Normal file
View file

@ -0,0 +1,17 @@
<?php
declare(strict_types=1);
/*
* This file is part of the box project.
*
* (c) Kevin Herrera <kevin@herrera.io>
* Théo Fidry <theo.fidry@gmail.com>
*
* This source file is subject to the MIT license that is bundled
* with this source code in the file LICENSE.
*/
if (file_exists(dirname(__DIR__).'/var/cache/prod/App_KernelProdContainer.preload.php')) {
require dirname(__DIR__).'/var/cache/prod/App_KernelProdContainer.preload.php';
}

3
config/routes.yaml Normal file
View file

@ -0,0 +1,3 @@
#index:
# path: /
# controller: App\Controller\DefaultController::index

View file

@ -0,0 +1,3 @@
_errors:
resource: '@FrameworkBundle/Resources/config/routing/errors.xml'
prefix: /_error

31
config/services.yaml Normal file
View file

@ -0,0 +1,31 @@
# This file is the entry point to configure your own services.
# Files in the packages/ subdirectory configure your dependencies.
# Put parameters here that don't need to change on each machine where the app is deployed
# https://symfony.com/doc/current/best_practices/configuration.html#application-related-configuration
parameters:
services:
# default configuration for services in *this* file
_defaults:
autowire: true # Automatically injects dependencies in your services.
autoconfigure: true # Automatically registers your services as commands, event subscribers, etc.
# makes classes in src/ available to be used as services
# this creates a service per class whose id is the fully-qualified class name
App\:
resource: '../src/'
exclude:
- '../src/DependencyInjection/'
- '../src/Entity/'
- '../src/Kernel.php'
- '../src/Tests/'
# controllers are imported separately to make sure services can be injected
# as action arguments even if you don't extend any base controller class
App\Controller\:
resource: '../src/Controller/'
tags: ['controller.service_arguments']
# add more service definitions when explicit configuration is needed
# please note that last definitions always *replace* previous ones

View file

@ -1,5 +1,6 @@
_default:
@just --list
build:
compile:
composer dump-env prod
./vendor/bin/box compile

View file

@ -1,5 +1,4 @@
parameters:
level: 8
level: 5
paths:
- bin
- src

View file

@ -1,18 +0,0 @@
<?xml version="1.0" encoding="UTF-8"?>
<phpunit xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xsi:noNamespaceSchemaLocation="./vendor/phpunit/phpunit/phpunit.xsd"
bootstrap="vendor/autoload.php"
colors="true"
>
<testsuites>
<testsuite name="Test Suite">
<directory suffix="Test.php">./tests</directory>
</testsuite>
</testsuites>
<coverage processUncoveredFiles="true">
<include>
<directory suffix=".php">./app</directory>
<directory suffix=".php">./src</directory>
</include>
</coverage>
</phpunit>

34
public/index.php Normal file
View file

@ -0,0 +1,34 @@
<?php
declare(strict_types=1);
/*
* This file is part of the box project.
*
* (c) Kevin Herrera <kevin@herrera.io>
* Théo Fidry <theo.fidry@gmail.com>
*
* This source file is subject to the MIT license that is bundled
* with this source code in the file LICENSE.
*/
use App\Kernel;
use Symfony\Component\Dotenv\Dotenv;
use Symfony\Component\ErrorHandler\Debug;
use Symfony\Component\HttpFoundation\Request;
require dirname(__DIR__).'/vendor/autoload.php';
(new Dotenv())->bootEnv(dirname(__DIR__).'/.env');
if ($_SERVER['APP_DEBUG']) {
umask(0);
Debug::enable();
}
$kernel = new Kernel($_SERVER['APP_ENV'], (bool) $_SERVER['APP_DEBUG']);
$request = Request::createFromGlobals();
$response = $kernel->handle($request);
$response->send();
$kernel->terminate($request, $response);

View file

@ -0,0 +1,269 @@
<?php
declare(strict_types=1);
namespace App\Command;
use App\DataTransferObject\Config;
use App\DataTransferObject\TemplateFile;
use App\Enum\Language;
use App\Enum\WebServer;
use Illuminate\Support\Arr;
use Illuminate\Support\Collection;
use Symfony\Component\Console\Attribute\AsCommand;
use Symfony\Component\Console\Command\Command;
use Symfony\Component\Console\Input\InputInterface;
use Symfony\Component\Console\Input\InputOption;
use Symfony\Component\Console\Output\OutputInterface;
use Symfony\Component\Console\Style\SymfonyStyle;
use Symfony\Component\Filesystem\Filesystem;
use Symfony\Component\Serializer\Encoder\JsonEncoder;
use Symfony\Component\Serializer\NameConverter\CamelCaseToSnakeCaseNameConverter;
use Symfony\Component\Serializer\Normalizer\ObjectNormalizer;
use Symfony\Component\Serializer\Serializer;
use Symfony\Component\Validator\ConstraintViolationInterface;
use Symfony\Component\Validator\Validation;
use Symfony\Component\Yaml\Yaml;
use Twig\Environment;
use Twig\Loader\FilesystemLoader;
#[AsCommand(
name: 'app:generate',
description: 'Generate project-specific configuration files',
)]
class GenerateCommand extends Command
{
protected function configure(): void
{
$this
->addOption(
name: 'config-file',
shortcut: ['c'],
mode: InputOption::VALUE_REQUIRED,
description: 'The path to the project\'s build.yaml file',
default: 'build.yaml',
)
->addOption(
name: 'output-dir',
shortcut: ['o'],
mode: InputOption::VALUE_REQUIRED,
description: 'The directory to create files in',
default: '.',
)
;
}
protected function execute(InputInterface $input, OutputInterface $output): int
{
$io = new SymfonyStyle($input, $output);
$configFile = $input->getOption(name: 'config-file');
$outputDir = $input->getOption(name: 'output-dir');
$configurationData = array_merge(
Yaml::parseFile(filename: __DIR__ . '/../../resources/build.defaults.yaml'),
Yaml::parseFile(filename: $configFile),
);
// Convert the input to a configuration data object.
$normalizer = new ObjectNormalizer(null, new CamelCaseToSnakeCaseNameConverter());
$serializer = new Serializer([$normalizer], [new JsonEncoder()]);
$configurationDataObject = $serializer->deserialize(json_encode($configurationData), Config::class, 'json');
$validator = Validation::createValidatorBuilder()->enableAnnotationMapping()->getValidator();
$violations = $validator->validate($configurationDataObject);
if (0 < $violations->count()) {
$io->error('Configuration is invalid.');
$io->listing(
collect($violations)
->map(fn (ConstraintViolationInterface $v) => "{$v->getPropertyPath()} - {$v->getMessage()}")
->toArray()
);
return Command::FAILURE;
}
if (isset($configurationData['docker-compose'])) {
$configurationData['dockerCompose'] = $configurationData['docker-compose'];
$configurationData['docker-compose'] = null;
}
$configurationData['managedText'] = 'Do not edit this file. It is automatically generated by https://www.oliverdavies.uk/build-configs.';
$filesToGenerate = $this->getFiles(configurationData: $configurationData);
$io->info("Building configuration for {$configurationData['name']}.");
$io->write('Generated files:');
$io->listing($this->getListOfFiles(filesToGenerate: $filesToGenerate)->toArray());
$this->generateFiles(
configurationData: $configurationData,
filesToGenerate: $filesToGenerate,
outputDir: $outputDir,
);
//
return Command::SUCCESS;
}
/**
* @param array<string, string> $configurationData
* @param Collection<int, TemplateFile> $filesToGenerate
*/
function generateFiles(
Collection $filesToGenerate,
string $outputDir,
array $configurationData,
): void
{
$filesystem = new Filesystem();
$twig = new Environment(new FilesystemLoader([__DIR__ . '/../../templates']));
$filesToGenerate->each(function(TemplateFile $templateFile) use ($configurationData, $filesystem, $outputDir, $twig): void {
if ($templateFile->path !== null) {
if (!$filesystem->exists($templateFile->path)) {
$filesystem->mkdir("{$outputDir}/{$templateFile->path}");
}
}
$sourceFile = "{$templateFile->data}.twig";
$outputFile = collect([
$outputDir,
$templateFile->path,
$templateFile->name,
])->filter()->implode('/');
$filesystem->dumpFile($outputFile, $twig->render($sourceFile, $configurationData));
});
// If the Docker entrypoint file is generated, ensure it is executable.
if ($filesystem->exists("{$outputDir}/tools/docker/images/php/root/usr/local/bin/docker-entrypoint-php")) {
$filesystem->chmod("{$outputDir}/tools/docker/images/php/root/usr/local/bin/docker-entrypoint-php", 0755);
}
}
function getFiles(array $configurationData): Collection
{
/** @var Collection<int, TemplateFile> */
$filesToGenerate = collect([
new TemplateFile(data: 'common/.dockerignore', name: '.dockerignore'),
new TemplateFile(data: 'common/.hadolint.yaml', name: '.hadolint.yaml'),
new TemplateFile(data: 'env.example', name: '.env.example'),
]);
$extraDatabases = Arr::get($configurationData, 'database.extra_databases', []);
if (count($extraDatabases) > 0) {
$filesToGenerate[] = new TemplateFile(
data: 'extra-databases.sql',
name: 'extra-databases.sql',
path: 'tools/docker/images/database/root/docker-entrypoint-initdb.d',
);
}
if (false !== Arr::get($configurationData, "justfile", true)) {
$filesToGenerate[] = new TemplateFile(data: 'justfile', name: 'justfile');
}
if (isset($configurationData['dockerCompose']) && $configurationData['dockerCompose'] !== null) {
$filesToGenerate[] = new TemplateFile(data: 'docker-compose.yaml', name: 'docker-compose.yaml');
}
if (static::isPhp(Arr::get($configurationData, 'language'))) {
$filesToGenerate[] = new TemplateFile(data: 'php/Dockerfile', name: 'Dockerfile');
$filesToGenerate[] = new TemplateFile(data: 'php/phpcs.xml', name: 'phpcs.xml.dist');
$filesToGenerate[] = new TemplateFile(data: 'php/phpunit.xml', name: 'phpunit.xml.dist');
$filesToGenerate[] = new TemplateFile(
data: 'php/docker-entrypoint-php',
name: 'docker-entrypoint-php',
path: 'tools/docker/images/php/root/usr/local/bin',
);
if (Arr::has(array: $configurationData, keys: 'php.phpstan')) {
$filesToGenerate[] = new TemplateFile(data: 'php/phpstan.neon', name: 'phpstan.neon.dist');
}
}
if (static::isNode(Arr::get($configurationData, 'language'))) {
$filesToGenerate[] = new TemplateFile(data: 'node/.yarnrc', name: '.yarnrc');
$filesToGenerate[] = new TemplateFile(data: 'node/Dockerfile', name: 'Dockerfile');
}
if (static::isCaddy(Arr::get($configurationData, 'web.type'))) {
$filesToGenerate[] = new TemplateFile(
data: 'web/caddy/Caddyfile',
name: 'Caddyfile',
path: 'tools/docker/images/web/root/etc/caddy',
);
}
if (static::isNginx(Arr::get($configurationData, 'web.type'))) {
$filesToGenerate[] = new TemplateFile(
data: 'web/nginx/default.conf',
name: 'default.conf',
path: 'tools/docker/images/web/root/etc/nginx/conf.d',
);
}
if ('drupal-project' === Arr::get($configurationData, 'type')) {
// Add a Drupal version of phpunit.xml.dist.
$filesToGenerate[] = new TemplateFile(data: 'drupal-project/phpunit.xml.dist', name: 'phpunit.xml.dist');
}
if (Arr::get($configurationData, 'experimental.createGitHubActionsConfiguration', false) === true) {
$filesToGenerate[] = new TemplateFile(
data: 'ci/github-actions/ci.yml',
name: 'ci.yml',
path: '.github/workflows',
);
}
return $filesToGenerate;
}
function getListOfFiles(Collection $filesToGenerate): Collection
{
return $filesToGenerate
->map(fn (TemplateFile $templateFile): string =>
collect([$templateFile->path, $templateFile->name])->filter()->implode('/'))
->unique()
->sort();
}
function isCaddy(?string $webServer): bool
{
if (is_null($webServer)) {
return false;
}
return strtoupper($webServer) === WebServer::CADDY->name;
}
function isNginx(?string $webServer): bool
{
if (is_null($webServer)) {
return false;
}
return strtoupper($webServer) === WebServer::NGINX->name;
}
function isNode(?string $language): bool
{
if (is_null($language)) {
return false;
}
return strtoupper($language) === Language::NODE->name;
}
function isPhp(?string $language): bool
{
if (is_null($language)) {
return false;
}
return strtoupper($language) === Language::PHP->name;
}
}

View file

@ -0,0 +1,49 @@
<?php
declare(strict_types=1);
namespace App\Command;
use Symfony\Component\Console\Attribute\AsCommand;
use Symfony\Component\Console\Command\Command;
use Symfony\Component\Console\Input\InputArgument;
use Symfony\Component\Console\Input\InputInterface;
use Symfony\Component\Console\Output\OutputInterface;
#[AsCommand(
name: 'app:init',
description: 'Add a short description for your command',
)]
class InitCommand extends Command
{
protected function configure(): void
{
$this
->addArgument('projectName', InputArgument::REQUIRED, 'The name of the project')
->addArgument('language', InputArgument::REQUIRED, 'The language the project uses')
->addArgument('type', InputArgument::REQUIRED, 'The type of project')
;
}
protected function execute(InputInterface $input, OutputInterface $output): int
{
[
'language' => $language,
'projectName' => $projectName,
'type' => $type,
] = $input->getArguments();
$projectName = str_replace('.', '-', $projectName);
// TODO: validate the project type.
$output = <<<EOF
name: $projectName
language: $language
type: $type
EOF;
file_put_contents('build.yaml', $output);
return Command::SUCCESS;
}
}

0
src/Controller/.gitignore vendored Normal file
View file

View file

@ -2,11 +2,11 @@
declare(strict_types=1);
namespace OliverDaviesLtd\BuildConfigs;
namespace App\DataTransferObject;
use Symfony\Component\Validator\Constraints as Assert;
final class ConfigurationData
final class Config
{
/**
* @var array<string,string|integer|array<int,string>>

View file

@ -2,7 +2,7 @@
declare(strict_types=1);
namespace OliverDaviesLtd\BuildConfigs\DataTransferObject;
namespace App\DataTransferObject;
readonly final class TemplateFile
{

View file

@ -2,7 +2,7 @@
declare(strict_types=1);
namespace OliverDaviesLtd\BuildConfigs\Enum;
namespace App\Enum;
enum Language
{

View file

@ -2,7 +2,7 @@
declare(strict_types=1);
namespace OliverDaviesLtd\BuildConfigs\Enum;
namespace App\Enum;
enum WebServer
{

56
src/Kernel.php Normal file
View file

@ -0,0 +1,56 @@
<?php
declare(strict_types=1);
/*
* This file is part of the box project.
*
* (c) Kevin Herrera <kevin@herrera.io>
* Théo Fidry <theo.fidry@gmail.com>
*
* This source file is subject to the MIT license that is bundled
* with this source code in the file LICENSE.
*/
namespace App;
use Symfony\Bundle\FrameworkBundle\Kernel\MicroKernelTrait;
use Symfony\Component\DependencyInjection\Loader\Configurator\ContainerConfigurator;
use Symfony\Component\HttpKernel\Kernel as BaseKernel;
use Symfony\Component\Routing\Loader\Configurator\RoutingConfigurator;
use function dirname;
class Kernel extends BaseKernel
{
use MicroKernelTrait;
protected function configureContainer(ContainerConfigurator $container): void
{
$container->import('../config/{packages}/*.yaml');
$container->import('../config/{packages}/'.$this->environment.'/*.yaml');
if (is_file(dirname(__DIR__).'/config/services.yaml')) {
$container->import('../config/services.yaml');
$container->import('../config/{services}_'.$this->environment.'.yaml');
} elseif (is_file($path = dirname(__DIR__).'/config/services.php')) {
(require $path)($container->withPath($path), $this);
}
}
protected function configureRoutes(RoutingConfigurator $routes): void
{
$routes->import('../config/{routes}/'.$this->environment.'/*.yaml');
$routes->import('../config/{routes}/*.yaml');
if (is_file(dirname(__DIR__).'/config/routes.yaml')) {
$routes->import('../config/routes.yaml');
} elseif (is_file($path = dirname(__DIR__).'/config/routes.php')) {
(require $path)($routes->withPath($path), $this);
}
}
public function getProjectDir(): string
{
return __DIR__.'/../';
}
}

218
symfony.lock Normal file
View file

@ -0,0 +1,218 @@
{
"doctrine/annotations": {
"version": "2.0",
"recipe": {
"repo": "github.com/symfony/recipes",
"branch": "main",
"version": "1.10",
"ref": "64d8583af5ea57b7afa4aba4b159907f3a148b05"
}
},
"php": {
"version": "7.2.9"
},
"psr/cache": {
"version": "1.0.1"
},
"psr/container": {
"version": "1.0.0"
},
"psr/event-dispatcher": {
"version": "1.0.0"
},
"psr/log": {
"version": "1.1.3"
},
"psr/simple-cache": {
"version": "1.0.1"
},
"symfony/cache": {
"version": "v5.0.11"
},
"symfony/cache-contracts": {
"version": "v2.2.0"
},
"symfony/config": {
"version": "v5.0.11"
},
"symfony/console": {
"version": "5.1",
"recipe": {
"repo": "github.com/symfony/recipes",
"branch": "master",
"version": "5.1",
"ref": "c6d02bdfba9da13c22157520e32a602dbee8a75c"
},
"files": [
"bin/console"
]
},
"symfony/contracts": {
"version": "v1.0.2"
},
"symfony/debug": {
"version": "v4.2.4"
},
"symfony/dependency-injection": {
"version": "v5.0.11"
},
"symfony/deprecation-contracts": {
"version": "v2.2.0"
},
"symfony/dotenv": {
"version": "v5.0.11"
},
"symfony/error-handler": {
"version": "v5.1.5"
},
"symfony/event-dispatcher": {
"version": "v5.1.5"
},
"symfony/event-dispatcher-contracts": {
"version": "v2.2.0"
},
"symfony/filesystem": {
"version": "v5.0.11"
},
"symfony/finder": {
"version": "v5.0.11"
},
"symfony/flex": {
"version": "1.0",
"recipe": {
"repo": "github.com/symfony/recipes",
"branch": "master",
"version": "1.0",
"ref": "c0eeb50665f0f77226616b6038a9b06c03752d8e"
},
"files": [
".env"
]
},
"symfony/framework-bundle": {
"version": "5.2",
"recipe": {
"repo": "github.com/symfony/recipes",
"branch": "master",
"version": "5.2",
"ref": "6ec87563dcc85cd0c48856dcfbfc29610506d250"
},
"files": [
"config/packages/cache.yaml",
"config/packages/framework.yaml",
"config/packages/test/framework.yaml",
"config/preload.php",
"config/routes/dev/framework.yaml",
"config/services.yaml",
"public/index.php",
"src/Controller/.gitignore",
"src/Kernel.php"
]
},
"symfony/http-client-contracts": {
"version": "v2.3.1"
},
"symfony/http-foundation": {
"version": "v5.1.5"
},
"symfony/http-kernel": {
"version": "v5.1.5"
},
"symfony/maker-bundle": {
"version": "1.48",
"recipe": {
"repo": "github.com/symfony/recipes",
"branch": "main",
"version": "1.0",
"ref": "fadbfe33303a76e25cb63401050439aa9b1a9c7f"
}
},
"symfony/polyfill-intl-grapheme": {
"version": "v1.22.1"
},
"symfony/polyfill-intl-normalizer": {
"version": "v1.22.1"
},
"symfony/polyfill-mbstring": {
"version": "v1.20.0"
},
"symfony/polyfill-php73": {
"version": "v1.20.0"
},
"symfony/polyfill-php80": {
"version": "v1.20.0"
},
"symfony/polyfill-php81": {
"version": "v1.24.0"
},
"symfony/routing": {
"version": "5.1",
"recipe": {
"repo": "github.com/symfony/recipes",
"branch": "master",
"version": "5.1",
"ref": "b4f3e7c95e38b606eef467e8a42a8408fc460c43"
},
"files": [
"config/packages/prod/routing.yaml",
"config/packages/routing.yaml",
"config/routes.yaml"
]
},
"symfony/service-contracts": {
"version": "v2.2.0"
},
"symfony/string": {
"version": "v5.2.6"
},
"symfony/translation": {
"version": "6.2",
"recipe": {
"repo": "github.com/symfony/recipes",
"branch": "main",
"version": "5.3",
"ref": "da64f5a2b6d96f5dc24914517c0350a5f91dee43"
},
"files": [
"config/packages/translation.yaml",
"translations/.gitignore"
]
},
"symfony/twig-bundle": {
"version": "6.2",
"recipe": {
"repo": "github.com/symfony/recipes",
"branch": "main",
"version": "5.4",
"ref": "bb2178c57eee79e6be0b297aa96fc0c0def81387"
},
"files": [
"config/packages/twig.yaml",
"templates/base.html.twig"
]
},
"symfony/validator": {
"version": "6.2",
"recipe": {
"repo": "github.com/symfony/recipes",
"branch": "main",
"version": "5.3",
"ref": "c32cfd98f714894c4f128bb99aa2530c1227603c"
},
"files": [
"config/packages/validator.yaml"
]
},
"symfony/var-dumper": {
"version": "v5.1.5"
},
"symfony/var-exporter": {
"version": "v5.0.11"
},
"symfony/yaml": {
"version": "v5.0.11"
},
"twig/extra-bundle": {
"version": "v3.5.1"
}
}

View file

@ -1,45 +0,0 @@
<?php
/*
|--------------------------------------------------------------------------
| Test Case
|--------------------------------------------------------------------------
|
| The closure you provide to your test functions is always bound to a specific PHPUnit test
| case class. By default, that class is "PHPUnit\Framework\TestCase". Of course, you may
| need to change it using the "uses()" function to bind a different classes or traits.
|
*/
// uses(Tests\TestCase::class)->in('Feature');
/*
|--------------------------------------------------------------------------
| Expectations
|--------------------------------------------------------------------------
|
| When you're writing tests, you often need to check that values meet certain conditions. The
| "expect()" function gives you access to a set of "expectations" methods that you can use
| to assert different things. Of course, you may extend the Expectation API at any time.
|
*/
expect()->extend('toBeOne', function () {
return $this->toBe(1);
});
/*
|--------------------------------------------------------------------------
| Functions
|--------------------------------------------------------------------------
|
| While Pest is very powerful out-of-the-box, you may have some testing code specific to your
| project that you don't want to repeat in every file. Here you can also expose helpers as
| global functions to help you to reduce the number of lines of code in your test files.
|
*/
function something()
{
// ..
}

View file

@ -1,40 +0,0 @@
<?php
use OliverDaviesLtd\BuildConfigs\Validator\ConfigurationValidator;
beforeEach(function (): void {
$this->validator = new ConfigurationValidator();
});
test('The project name should be a string', function (mixed $projectName, int $expectedViolationCount) {
$configuration = [
'name' => $projectName,
];
expect($this->validator->validate($configuration))
->toHaveCount($expectedViolationCount);
})->with(function () {
yield 'Non-empty string' => ['test', 0];
yield 'Empty string' => ['', 1];
yield 'Integer' => [1, 1];
yield 'Null' => [null, 1];
yield 'True' => [true, 1];
yield 'False' => [false, 2];
});
test('The project language should be a supported language', function (mixed $language, int $expectedViolationCount) {
$configuration = [
'language' => $language,
];
expect($this->validator->validate($configuration))
->toHaveCount($expectedViolationCount);
})->with(function () {
yield 'Supported language string' => ['php', 0];
yield 'Non-supported language string' => ['not-supported', 1];
yield 'Empty string' => ['', 1];
yield 'Integer' => [1, 2];
yield 'Null' => [null, 1];
yield 'True' => [true, 2];
yield 'False' => [false, 2];
});

0
translations/.gitignore vendored Normal file
View file

View file

@ -0,0 +1,5 @@
{
"require-dev": {
"humbug/box": "^4.3"
}
}

3104
vendor-bin/box/composer.lock generated Normal file

File diff suppressed because it is too large Load diff