Cleaner config and path usage, added dev notes

This commit is contained in:
Daniel Winning 2025-04-13 12:59:44 +01:00
parent be41aeb81d
commit 7d8fcac15e
12 changed files with 224 additions and 171 deletions

View file

@ -1,3 +1,27 @@
# Loom | Spinner
An environment management application for PHP developers.
## Dev Notes
**Argument priority:**
- Those passed explicitly in the CLI commands
- Any set within `{projectDirectory}/spinner.yaml`
- Fall back to `/config/spinner.yaml`
## Commands
### `spin:up`
#### Arguments
- `name` - **Required**: The name for your Docker containers.
- `path` - **Required**: The **absolute path** to your project root directory.
- `php` - **Optional**: If passed, sets the PHP version used by your container. Can be overridden
by creating a `spinner.yaml` file in your project root directory and defining the key `options.environment.php.version`
#### Options
- `node-disabled` - **Optional**: Disables Node. Can also define the key `options.environment.node.enabled` in your
`spinner.yaml` file.

View file

@ -16,12 +16,6 @@ RUN apt-get -qq update && apt-get -qq install -y \
RUN docker-php-ext-configure intl > /dev/null
RUN docker-php-ext-install mysqli pdo pdo_mysql sockets intl exif bcmath > /dev/null
#RUN pecl install xdebug redis \
# && docker-php-ext-enable redis
#COPY ./xdebug.ini.tmp "${PHP_INI_DIR}/conf.d/xdebug.ini"
#RUN docker-php-ext-install opcache > /dev/null
#COPY ./opcache.ini "${PHP_INI_DIR}/conf.d"
COPY --from=composer:latest /usr/bin/composer /usr/local/bin/composer
WORKDIR /var/www/html

View file

@ -1,6 +1,6 @@
ENV NVM_DIR /root/.nvm
RUN curl -o- https://raw.githubusercontent.com/nvm-sh/nvm/v0.39.7/install.sh | bash \
RUN curl -o- https://raw.githubusercontent.com/nvm-sh/nvm/v0.40.2/install.sh | bash \
&& . "$NVM_DIR/nvm.sh" \
&& nvm install ${NODE_VERSION} \
&& nvm use ${NODE_VERSION}

View file

@ -0,0 +1,6 @@
RUN pecl install xdebug redis \
&& docker-php-ext-enable redis
COPY ./xdebug.ini.tmp "${PHP_INI_DIR}/conf.d/xdebug.ini"
RUN docker-php-ext-install opcache > /dev/null
COPY ./opcache.ini "${PHP_INI_DIR}/conf.d"

View file

@ -5,28 +5,86 @@ declare(strict_types=1);
namespace Loom\Spinner\Classes\Config;
use Loom\Spinner\Classes\Collection\FilePathCollection;
use Loom\Spinner\Classes\File\Interface\DataPathInterface;
use Loom\Spinner\Classes\File\SpinnerFilePath;
use Loom\Utility\FilePath\FilePath;
use Symfony\Component\Console\Input\InputInterface;
use Symfony\Component\Yaml\Yaml;
class Config
{
public function __construct(private readonly FilePathCollection $filePaths)
private FilePathCollection $filePaths;
public function __construct()
{
$this->setFilePaths();
}
public function getFilePaths(): FilePathCollection
{
return $this->filePaths;
}
public function getFilePath(string $key): ?FilePath
{
return $this->filePaths->get($key);
}
public function addFilePath(FilePath $filePath, string $key): void
{
$this->filePaths->add($filePath, $key);
}
/**
* @throws \Exception
*/
protected function getEnvironmentOption(string $service, string $option): mixed
public function getPhpVersion(InputInterface $input): ?float
{
if ($this->filePaths->get('projectCustomConfig')?->exists()) {
$config = Yaml::parseFile(
$this->filePaths->get('projectCustomConfig')->getAbsolutePath()
)['options']['environment'] ?? null;
if ($input->getOption('php')) {
return (float) $input->getOption('php');
}
if ($config) {
if (isset($config[$service][$option])) {
return $config[$service][$option];
return (float) $this->getEnvironmentOption('php', 'version');
}
/**
* @throws \Exception
*/
public function isNodeEnabled(InputInterface $input): bool
{
if ($input->getOption('node-disabled')) {
return false;
}
return $this->getEnvironmentOption('node', 'enabled');
}
/**
* @throws \Exception
*/
public function getNodeVersion(InputInterface $input): ?int
{
if ($input->getOption('node')) {
return (int) $input->getOption('node');
}
return (int) $this->getEnvironmentOption('node','version');
}
/**
* @throws \Exception
*/
public function getEnvironmentOption(string $service, string $option): mixed
{
$projectCustomConfig = $this->filePaths->get('projectCustomConfig');
if ($projectCustomConfig->exists()) {
$customConfig = Yaml::parseFile($projectCustomConfig->getAbsolutePath())['options']['environment']
?? null;
if ($customConfig) {
if (isset($customConfig[$service][$option])) {
return $customConfig[$service][$option];
}
}
}
@ -37,29 +95,23 @@ class Config
/**
* @throws \Exception
*/
protected function getDefaultConfig()
protected function getDefaultConfig(): ?array
{
return Yaml::parseFile($this->filePaths->get('defaultSpinnerConfig')?->getAbsolutePath())['options']
?? null;
}
/**
* @throws \Exception
*/
public function getDefaultPhpVersionArgument(): ?float
private function setFilePaths(): void
{
return $this->getEnvironmentOption('php', 'version');
}
/**
* @throws \Exception
*/
protected function isNodeEnabled(InputInterface $input): bool
{
if ($input->getOption('node-disabled')) {
return true;
}
return $this->getEnvironmentOption('node', 'enabled');
$this->filePaths = new FilePathCollection([
'config' => new SpinnerFilePath('config'),
'defaultSpinnerConfig' => new SpinnerFilePath('config/spinner.yaml'),
'envTemplate' => new SpinnerFilePath('config/.template.env'),
'data' => new SpinnerFilePath('data'),
'phpYamlTemplate' => new SpinnerFilePath('config/php.yaml'),
'phpFpmDataDirectory' => new SpinnerFilePath('config/php-fpm'),
DataPathInterface::CONFIG_PHP_FPM_DIRECTORY => new SpinnerFilePath(DataPathInterface::CONFIG_PHP_FPM_DIRECTORY),
'nodeDockerfileTemplate' => new SpinnerFilePath('config/php-fpm/Node.Dockerfile'),
]);
}
}

View file

@ -4,7 +4,7 @@ declare(strict_types=1);
namespace Loom\Spinner\Classes\File;
use Loom\Spinner\Classes\Collection\FilePathCollection;
use Loom\Spinner\Classes\Config\Config;
use Loom\Spinner\Classes\File\Interface\FileBuilderInterface;
use Symfony\Component\Console\Input\InputInterface;
@ -12,15 +12,16 @@ abstract class AbstractFileBuilder implements FileBuilderInterface
{
protected string $content;
public function __construct(private readonly SpinnerFilePath $path, protected readonly FilePathCollection $filePaths)
public function __construct(protected SpinnerFilePath $path, protected Config $config)
{
return $this;
}
abstract public function build(InputInterface $input): void;
abstract public function build(InputInterface $input): AbstractFileBuilder;
public function save(): void
{
file_put_contents($this->path->getAbsolutePath(), $this->content);
file_put_contents($this->path->getProvidedPath(), $this->content);
}

View file

@ -1,28 +0,0 @@
<?php
declare(strict_types=1);
namespace Loom\Spinner\Classes\File;
use Loom\Spinner\Classes\Collection\FilePathCollection;
use Symfony\Component\Console\Input\InputInterface;
class DockerFileBuilder extends AbstractFileBuilder
{
public function __construct(FilePathCollection $filePaths)
{
$projectDataDir = $filePaths->get('projectData');
if (!$projectDataDir instanceof SpinnerFilePath) {
return;
}
parent::__construct($projectDataDir, $filePaths);
}
public function build(InputInterface $input): void
{
$this->content = file_get_contents($this->filePaths->get('phpFpmDockerfileTemplate')->getAbsolutePath());
$this->content = str_replace('${PHP_VERSION}', $input->getOption('php'), $this->content);
}
}

View file

@ -0,0 +1,10 @@
<?php
declare(strict_types=1);
namespace Loom\Spinner\Classes\File\Interface;
interface DataPathInterface
{
public const string CONFIG_PHP_FPM_DIRECTORY = 'config/php-fpm/Dockerfile';
}

View file

@ -8,6 +8,6 @@ use Symfony\Component\Console\Input\InputInterface;
interface FileBuilderInterface
{
public function build(InputInterface $input): void;
public function build(InputInterface $input): FileBuilderInterface;
public function save(): void;
}

View file

@ -0,0 +1,50 @@
<?php
declare(strict_types=1);
namespace Loom\Spinner\Classes\File;
use Loom\Spinner\Classes\Config\Config;
use Loom\Spinner\Classes\File\Interface\DataPathInterface;
use Symfony\Component\Console\Input\InputInterface;
class PHPDockerFileBuilder extends AbstractFileBuilder
{
/**
* @throws \Exception
*/
public function __construct(Config $config)
{
$projectPhpFpmDockerfile = $config->getFilePath('projectPhpFpmDockerfile');
if (!$projectPhpFpmDockerfile instanceof SpinnerFilePath) {
throw new \Exception('Project PHP-FPM Dockerfile not found');
}
return parent::__construct($projectPhpFpmDockerfile, $config);
}
/**
* @throws \Exception
*/
public function build(InputInterface $input): PHPDockerFileBuilder
{
$this->setInitialContent();
$this->content = str_replace('${PHP_VERSION}', (string) $this->config->getPhpVersion($input), $this->content);
if ($this->config->isNodeEnabled($input)) {
$this->content .= "\r\n\r\n" . file_get_contents($this->config->getFilePaths()->get('nodeDockerfileTemplate')->getAbsolutePath());
$this->content = str_replace('${NODE_VERSION}', (string) $this->config->getNodeVersion($input), $this->content);
}
return $this;
}
private function setInitialContent(): void
{
$this->content = file_get_contents(
$this->config->getFilePaths()->get(DataPathInterface::CONFIG_PHP_FPM_DIRECTORY)->getAbsolutePath()
);
}
}

View file

@ -19,17 +19,13 @@ use Symfony\Component\Yaml\Yaml;
class AbstractSpinnerCommand extends Command implements ConsoleCommandInterface
{
protected Config $config;
protected FilePathCollection $filePaths;
protected string $rootDirectory;
protected SymfonyStyle $style;
protected System $system;
public function __construct()
{
$this->rootDirectory = dirname(__DIR__, 2);
$this->system = new System();
$this->setFilePaths();
$this->config = new Config($this->filePaths);
$this->config = new Config();
parent::__construct();
}
@ -55,61 +51,17 @@ class AbstractSpinnerCommand extends Command implements ConsoleCommandInterface
return Command::SUCCESS;
}
/**
* @throws \Exception
*/
protected function getDefaultConfig()
{
if (!$this->filePaths->get('defaultSpinnerConfig')?->exists()) {
throw new \Exception('Default spinner configuration file not found.');
}
return Yaml::parseFile($this->filePaths->get('defaultSpinnerConfig')->getAbsolutePath())['options'] ?? null;
}
protected function buildDockerComposeCommand(string $command, bool $daemon = true): string
{
return sprintf(
'cd %s && docker-compose --env-file=%s %s%s',
$this->filePaths->get('projectData')->getAbsolutePath(),
$this->filePaths->get('projectEnv')->getAbsolutePath(),
$this->config->getFilePaths()->get('projectData')->getAbsolutePath(),
$this->config->getFilePaths()->get('projectEnv')->getAbsolutePath(),
$command,
$daemon ? ' -d' : ''
);
}
/**
* @throws \Exception
*/
protected function getEnvironmentOption(string $service, string $option): mixed
{
if ($this->filePaths->get('projectCustomConfig')?->exists()) {
$config = Yaml::parseFile(
$this->filePaths->get('projectCustomConfig')->getAbsolutePath()
)['options']['environment'] ?? null;
if ($config) {
if (isset($config[$service][$option])) {
return $config[$service][$option];
}
}
}
return $this->getDefaultConfig()['environment'][$service][$option] ?? null;
}
/**
* @throws \Exception
*/
protected function isNodeEnabled(InputInterface $input): bool
{
if ($input->getOption('node-disabled')) {
return true;
}
return $this->getEnvironmentOption('node', 'enabled');
}
private function validatePathArgument(InputInterface $input): bool
{
if ($input->hasArgument('path')) {
@ -121,7 +73,7 @@ class AbstractSpinnerCommand extends Command implements ConsoleCommandInterface
return false;
}
$this->filePaths->add($projectDirectory, 'project');
$this->config->addFilePath($projectDirectory, 'project');
}
return true;
@ -130,20 +82,32 @@ class AbstractSpinnerCommand extends Command implements ConsoleCommandInterface
private function validateNameArgument(InputInterface $input): void
{
if ($input->hasArgument('name')) {
$this->filePaths->add(
$this->config->addFilePath(
new SpinnerFilePath(sprintf('data/environments/%s', $input->getArgument('name'))),
'projectData'
);
$this->filePaths->add(
$this->config->addFilePath(
new SpinnerFilePath(sprintf('data/environments/%s/.env', $input->getArgument('name'))),
'projectEnv'
);
$this->filePaths->add(
$this->config->addFilePath(
new SpinnerFilePath(sprintf('data/environments/%s/docker-compose.yml', $input->getArgument('name'))),
'projectDockerCompose'
);
$this->filePaths->add(
new FilePath($this->filePaths->get('project')->getAbsolutePath() . DIRECTORY_SEPARATOR . 'spinner.yaml'),
$this->config->addFilePath(
new SpinnerFilePath(sprintf('data/environments/%s/php-fpm', $input->getArgument('name'))),
'projectPhpFpmDirectory'
);
$this->config->addFilePath(
new SpinnerFilePath(sprintf('data/environments/%s/php-fpm/Dockerfile', $input->getArgument('name'))),
'projectPhpFpmDockerfile'
);
$this->config->addFilePath(
new SpinnerFilePath(sprintf('data/environments/%s/php-fpm/Node.Dockerfile', $input->getArgument('name'))),
'projectPhpFpmNodeDockerfile'
);
$this->config->addFilePath(
new FilePath($this->config->getFilePaths()->get('project')->getAbsolutePath() . DIRECTORY_SEPARATOR . 'spinner.yaml'),
'projectCustomConfig'
);
}
@ -153,18 +117,4 @@ class AbstractSpinnerCommand extends Command implements ConsoleCommandInterface
{
$this->style = new SymfonyStyle($input, $output);
}
private function setFilePaths(): void
{
$this->filePaths = new FilePathCollection([
'config' => new SpinnerFilePath('config'),
'defaultSpinnerConfig' => new SpinnerFilePath('config/spinner.yaml'),
'envTemplate' => new SpinnerFilePath('config/.template.env'),
'data' => new SpinnerFilePath('data'),
'phpYamlTemplate' => new SpinnerFilePath('config/php.yaml'),
'phpFpmDataDirectory' => new SpinnerFilePath('config/php-fpm'),
'phpFpmDockerfileTemplate' => new SpinnerFilePath('config/php-fpm/Dockerfile'),
'nodeDockerfileTemplate' => new SpinnerFilePath('config/php-fpm/Node.Dockerfile'),
]);
}
}

View file

@ -4,6 +4,7 @@ declare(strict_types=1);
namespace Loom\Spinner\Command;
use Loom\Spinner\Classes\File\PHPDockerFileBuilder;
use Loom\Spinner\Classes\File\SpinnerFilePath;
use Loom\Spinner\Classes\OS\PortGenerator;
use Symfony\Component\Console\Attribute\AsCommand;
@ -37,8 +38,19 @@ class SpinCommand extends AbstractSpinnerCommand
$this
->addArgument('name', InputArgument::REQUIRED, 'The name for your Docker container.')
->addArgument('path', InputArgument::REQUIRED, 'The absolute path to your projects root directory.')
->addOption('php', null, InputOption::VALUE_REQUIRED, 'The PHP version to use (e.g., 8.0).', $this->config->getDefaultPhpVersionArgument())
->addOption('node-disabled', null, InputOption::VALUE_NONE, 'Set this flag to disable Node.js for your environment.');
->addOption(
'php',
null,
InputOption::VALUE_OPTIONAL,
'The PHP version to use (e.g., 8.0).'
)
->addOption(
'node-disabled',
null,
InputOption::VALUE_NONE,
'Set this flag to disable Node.js for your environment.'
)
->addOption('node', null, InputOption::VALUE_OPTIONAL, 'The Node.js version to use (e.g. 20).');
}
/**
@ -55,7 +67,7 @@ class SpinCommand extends AbstractSpinnerCommand
}
$this->style->success("Spinning up a new development environment...");
$this->style->text('Creating project data...');
$this->createProjectData($input);
$command = $this->buildDockerComposeCommand(sprintf('-p %s up', $input->getArgument('name')));
@ -67,13 +79,13 @@ class SpinCommand extends AbstractSpinnerCommand
protected function projectDataExists(): bool
{
if (!$this->filePaths->get('projectData')->exists()) {
if ($this->config->getFilePaths()->get('projectData')->exists()) {
$this->style->warning('Project data already exists. Skipping new build.');
return false;
return true;
}
return true;
return false;
}
/**
@ -92,7 +104,7 @@ class SpinCommand extends AbstractSpinnerCommand
*/
private function createProjectDataDirectory(): void
{
$projectData = $this->filePaths->get('projectData');
$projectData = $this->config->getFilePaths()->get('projectData');
if (!$projectData instanceof SpinnerFilePath) {
throw new \Exception('Invalid project data directory provided.');
@ -106,7 +118,7 @@ class SpinCommand extends AbstractSpinnerCommand
*/
private function createEnvironmentFile(InputInterface $input): void
{
$projectEnv = $this->filePaths->get('projectEnv');
$projectEnv = $this->config->getFilePaths()->get('projectEnv');
if (!$projectEnv instanceof SpinnerFilePath) {
throw new \Exception('Invalid project environment file provided.');
@ -115,8 +127,8 @@ class SpinCommand extends AbstractSpinnerCommand
file_put_contents(
$projectEnv->getProvidedPath(),
sprintf(
file_get_contents($this->filePaths->get('envTemplate')->getAbsolutePath()),
$this->filePaths->get('project')->getAbsolutePath(),
file_get_contents($this->config->getFilePaths()->get('envTemplate')->getAbsolutePath()),
$this->config->getFilePaths()->get('project')->getAbsolutePath(),
$input->getArgument('name'),
$input->getOption('php'),
$this->getPort('php'),
@ -129,8 +141,8 @@ class SpinCommand extends AbstractSpinnerCommand
*/
private function buildDockerComposeFile(): void
{
$projectData = $this->filePaths->get('projectData');
$projectDockerCompose = $this->filePaths->get('projectDockerCompose');
$projectData = $this->config->getFilePaths()->get('projectData');
$projectDockerCompose = $this->config->getFilePaths()->get('projectDockerCompose');
if (!$projectData instanceof SpinnerFilePath || !$projectDockerCompose instanceof SpinnerFilePath) {
throw new \Exception('Invalid project data directory provided.');
@ -141,7 +153,7 @@ class SpinCommand extends AbstractSpinnerCommand
}
file_put_contents(
$projectDockerCompose->getProvidedPath(),
file_get_contents($this->filePaths->get('phpYamlTemplate')->getAbsolutePath())
file_get_contents($this->config->getFilePaths()->get('phpYamlTemplate')->getAbsolutePath())
);
}
@ -150,25 +162,7 @@ class SpinCommand extends AbstractSpinnerCommand
*/
private function buildDockerfile(InputInterface $input): void
{
$phpFpmDockerfileTemplate = file_get_contents($this->filePaths->get('phpFpmDockerfileTemplate')->getAbsolutePath());
$phpFpmDockerfileTemplate = str_replace('${PHP_VERSION}', $input->getOption('php'), $phpFpmDockerfileTemplate);
if ($this->isNodeEnabled($input)) {
// Add contents of Node.Dockerfile from /config/php-fpm/Node.Dockerfile
$phpFpmDockerfileTemplate .= "\r\n\r\n" . file_get_contents($this->filePaths->get('nodeDockerfileTemplate')->getAbsolutePath());
$phpFpmDockerfileTemplate = str_replace('${NODE_VERSION}', (string) $this->getEnvironmentOption('node', 'version'), $phpFpmDockerfileTemplate);
}
$projectDataPath = $this->filePaths->get('projectData');
if (!$projectDataPath instanceof SpinnerFilePath) {
return;
}
file_put_contents(
$projectDataPath->getProvidedPath() . '/php-fpm/Dockerfile',
$phpFpmDockerfileTemplate
);
(new PHPDockerFileBuilder($this->config))->build($input)->save();
}
private function getPort(string $service): int