35 KiB
Get Installer Bootstrap Implementation Plan
For agentic workers: REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (
- [ ]) syntax for tracking.
Goal: Build a Dockerized Symfony application that serves cached .sh installer scripts from configured Git repositories through stable public paths.
Architecture: Symfony handles routing, admin auth, forms, persistence, and tests. Public script requests resolve against SQLite mappings and read only from a local cache. Git synchronization is a manual admin action that updates cache files atomically and preserves the last good script on failure.
Tech Stack: PHP 8.3, Symfony, Doctrine ORM, SQLite by default through DATABASE_URL, Nginx, PHP-FPM, Docker Compose, Symfony Process, PHPUnit.
File Structure
composer.json: PHP package definition and Symfony dependencies..env,.env.test: default runtime configuration.public/index.php: Symfony front controller.src/Kernel.php: Symfony kernel.src/Entity/User.php: admin user entity.src/Entity/ScriptMapping.php: mapping entity for public path to Git file.src/Repository/UserRepository.php: user lookup for Symfony Security.src/Repository/ScriptMappingRepository.php: mapping lookup by public path.src/Service/PathNormalizer.php: strict public and repository path validation.src/Service/CachePathResolver.php: cache path calculation.src/Service/CachedScriptResolver.php: public request to cached file resolution.src/Service/GitSynchronizer.php: clone, fetch, checkout, copy, and sync status update.src/Security/LoginFormAuthenticator.php: admin login authenticator.src/Form/LoginFormType.php: login form.src/Form/ScriptMappingType.php: admin mapping form.src/Controller/Admin/AuthController.php: login and logout routes.src/Controller/Admin/DashboardController.php: mapping list.src/Controller/Admin/ScriptMappingController.php: CRUD and sync actions.src/Controller/PublicScriptController.php: public catch-all script serving.src/Command/CreateAdminUserCommand.php: first admin user creation.templates/admin/*.html.twig: admin templates.templates/base.html.twig: shared HTML layout.config/packages/*.yaml: framework, Doctrine, Twig, security, validator, test configuration.config/routes.yaml: route import defaults.docker/Dockerfile: production PHP-FPM image.docker/nginx/default.conf: Nginx config.docker/php/php.ini: PHP runtime settings.docker/entrypoint.sh: install-time migrations and cache directory preparation.compose.yaml: production-like Compose stack for Coolify.compose.dev.yaml: development override with source bind mount..dockerignore: Docker build exclusions.tests/*: focused PHPUnit and WebTestCase coverage.README.md: local and Coolify deployment instructions.
Task 1: Bootstrap Symfony Skeleton
Files:
-
Create:
composer.json -
Create:
.env -
Create:
.env.test -
Create:
public/index.php -
Create:
src/Kernel.php -
Create:
config/bundles.php -
Create:
config/packages/framework.yaml -
Create:
config/packages/test/framework.yaml -
Create:
config/routes.yaml -
Create:
phpunit.xml.dist -
Create:
.gitignore -
Step 1: Create the initial Composer project file
Use apply_patch to create composer.json:
{
"type": "project",
"license": "proprietary",
"minimum-stability": "stable",
"prefer-stable": true,
"require": {
"php": "^8.3",
"ext-ctype": "*",
"ext-iconv": "*",
"doctrine/doctrine-bundle": "^2.12",
"doctrine/doctrine-migrations-bundle": "^3.3",
"doctrine/orm": "^3.2",
"symfony/asset": "^7.0",
"symfony/console": "^7.0",
"symfony/dotenv": "^7.0",
"symfony/flex": "^2",
"symfony/form": "^7.0",
"symfony/framework-bundle": "^7.0",
"symfony/password-hasher": "^7.0",
"symfony/process": "^7.0",
"symfony/runtime": "^7.0",
"symfony/security-bundle": "^7.0",
"symfony/twig-bundle": "^7.0",
"symfony/validator": "^7.0",
"symfony/yaml": "^7.0",
"twig/extra-bundle": "^3.10"
},
"require-dev": {
"phpunit/phpunit": "^10.5",
"symfony/browser-kit": "^7.0",
"symfony/css-selector": "^7.0",
"symfony/maker-bundle": "^1.60"
},
"autoload": {
"psr-4": {
"App\\": "src/"
}
},
"autoload-dev": {
"psr-4": {
"App\\Tests\\": "tests/"
}
},
"config": {
"allow-plugins": {
"php-http/discovery": true,
"symfony/flex": true,
"symfony/runtime": true
},
"sort-packages": true
},
"scripts": {
"auto-scripts": {
"cache:clear": "symfony-cmd"
},
"post-install-cmd": [
"@auto-scripts"
],
"post-update-cmd": [
"@auto-scripts"
]
}
}
- Step 2: Install dependencies
Run:
composer install
Expected: vendor/ and composer.lock are created, and Composer exits with status 0.
- Step 3: Add base Symfony files if Flex did not create them
Create missing files only. src/Kernel.php:
<?php
namespace App;
use Symfony\Bundle\FrameworkBundle\Kernel\MicroKernelTrait;
use Symfony\Component\HttpKernel\Kernel as BaseKernel;
final class Kernel extends BaseKernel
{
use MicroKernelTrait;
}
public/index.php:
<?php
use App\Kernel;
require_once dirname(__DIR__).'/vendor/autoload_runtime.php';
return static fn (array $context): Kernel => new Kernel($context['APP_ENV'], (bool) $context['APP_DEBUG']);
- Step 4: Add environment defaults
.env:
APP_ENV=dev
APP_SECRET=change-me-in-production
DATABASE_URL="sqlite:///%kernel.project_dir%/var/data/app.db"
APP_CACHE_DIR="%kernel.project_dir%/var/bootstrap-cache"
.env.test:
APP_ENV=test
APP_SECRET=test-secret
DATABASE_URL="sqlite:///%kernel.project_dir%/var/test/app.db"
APP_CACHE_DIR="%kernel.project_dir%/var/test/bootstrap-cache"
- Step 5: Verify the skeleton boots
Run:
php bin/console about
Expected: Symfony prints project information and exits with status 0.
- Step 6: Commit the bootstrap
Run:
git add composer.json composer.lock .env .env.test public src config phpunit.xml.dist .gitignore
git commit -m "build: bootstrap symfony app"
Task 2: Add Docker And Compose
Files:
-
Create:
docker/Dockerfile -
Create:
docker/nginx/default.conf -
Create:
docker/php/php.ini -
Create:
docker/entrypoint.sh -
Create:
compose.yaml -
Create:
compose.dev.yaml -
Create:
.dockerignore -
Step 1: Add the production PHP-FPM image
Create docker/Dockerfile:
FROM php:8.3-fpm-alpine AS app
RUN apk add --no-cache bash git icu-dev sqlite-dev unzip \
&& docker-php-ext-install intl opcache pdo pdo_sqlite
COPY --from=composer:2 /usr/bin/composer /usr/bin/composer
WORKDIR /app
COPY composer.json composer.lock ./
RUN composer install --no-dev --prefer-dist --no-interaction --no-progress --no-scripts
COPY . .
COPY docker/php/php.ini /usr/local/etc/php/conf.d/app.ini
COPY docker/entrypoint.sh /usr/local/bin/app-entrypoint
RUN chmod +x /usr/local/bin/app-entrypoint \
&& mkdir -p var/data var/bootstrap-cache \
&& composer dump-autoload --classmap-authoritative --no-dev \
&& composer run-script --no-dev post-install-cmd \
&& chown -R www-data:www-data var
ENTRYPOINT ["app-entrypoint"]
CMD ["php-fpm"]
FROM nginx:1.27-alpine AS web
WORKDIR /app
COPY docker/nginx/default.conf /etc/nginx/conf.d/default.conf
COPY public /app/public
- Step 2: Add Nginx configuration
Create docker/nginx/default.conf:
server {
listen 80;
server_name _;
root /app/public;
index index.php;
client_max_body_size 2m;
location / {
try_files $uri /index.php$is_args$args;
}
location ~ ^/index\.php(/|$) {
fastcgi_pass app:9000;
fastcgi_split_path_info ^(.+\.php)(/.*)$;
include fastcgi_params;
fastcgi_param SCRIPT_FILENAME $realpath_root$fastcgi_script_name;
fastcgi_param DOCUMENT_ROOT $realpath_root;
internal;
}
location ~ \.php$ {
return 404;
}
}
- Step 3: Add PHP runtime config and entrypoint
Create docker/php/php.ini:
date.timezone=UTC
memory_limit=256M
upload_max_filesize=2M
post_max_size=2M
opcache.enable=1
opcache.enable_cli=0
opcache.validate_timestamps=0
Create docker/entrypoint.sh:
#!/bin/sh
set -eu
mkdir -p /app/var/data /app/var/bootstrap-cache
chown -R www-data:www-data /app/var
if [ "${APP_ENV:-prod}" != "test" ]; then
php /app/bin/console doctrine:migrations:migrate --no-interaction --allow-no-migration
fi
exec "$@"
- Step 4: Add Compose files
Create compose.yaml:
services:
app:
build:
context: .
dockerfile: docker/Dockerfile
target: app
environment:
APP_ENV: prod
APP_DEBUG: "0"
APP_SECRET: ${APP_SECRET:-change-me}
DATABASE_URL: sqlite:////app/var/data/app.db
APP_CACHE_DIR: /app/var/bootstrap-cache
volumes:
- app-data:/app/var/data
- app-cache:/app/var/bootstrap-cache
nginx:
build:
context: .
dockerfile: docker/Dockerfile
target: app
target: web
depends_on:
- app
ports:
- "${HTTP_PORT:-8080}:80"
volumes:
app-data:
app-cache:
Create compose.dev.yaml:
services:
app:
build:
context: .
dockerfile: docker/Dockerfile
environment:
APP_ENV: dev
APP_DEBUG: "1"
APP_SECRET: dev-secret
DATABASE_URL: sqlite:////app/var/data/app.db
APP_CACHE_DIR: /app/var/bootstrap-cache
volumes:
- .:/app
- app-data:/app/var/data
- app-cache:/app/var/bootstrap-cache
nginx:
volumes:
- ./docker/nginx/default.conf:/etc/nginx/conf.d/default.conf:ro
- ./public:/app/public:ro
- Step 5: Add Docker ignore
Create .dockerignore:
.git
docs/superpowers/plans
var
vendor
.env.local
compose.dev.yaml
- Step 6: Validate config syntax
Run:
docker compose -f compose.yaml config
Expected: Compose prints resolved YAML and exits with status 0.
- Step 7: Commit Docker support
Run:
git add docker compose.yaml compose.dev.yaml .dockerignore
git commit -m "build: add docker deployment files"
Task 3: Add Persistence Model
Files:
-
Create:
src/Entity/User.php -
Create:
src/Entity/ScriptMapping.php -
Create:
src/Repository/UserRepository.php -
Create:
src/Repository/ScriptMappingRepository.php -
Modify:
config/packages/doctrine.yaml -
Create:
migrations/Version*.php -
Create:
tests/Entity/ScriptMappingTest.php -
Step 1: Write entity behavior tests
Create tests/Entity/ScriptMappingTest.php:
<?php
namespace App\Tests\Entity;
use App\Entity\ScriptMapping;
use PHPUnit\Framework\TestCase;
final class ScriptMappingTest extends TestCase
{
public function testDefaultsAreUsableForNewMapping(): void
{
$mapping = new ScriptMapping();
self::assertSame('main', $mapping->getGitRef());
self::assertTrue($mapping->isActive());
self::assertSame('never_synced', $mapping->getLastSyncStatus());
self::assertNull($mapping->getLastSuccessfulSyncAt());
self::assertNull($mapping->getLastSyncError());
}
public function testSyncFailureDoesNotClearPreviousCacheKey(): void
{
$mapping = new ScriptMapping();
$mapping->setCacheKey('scripts/1.sh');
$mapping->markSyncFailed('git fetch failed');
self::assertSame('scripts/1.sh', $mapping->getCacheKey());
self::assertSame('failed', $mapping->getLastSyncStatus());
self::assertSame('git fetch failed', $mapping->getLastSyncError());
}
}
- Step 2: Run the tests and verify failure
Run:
php vendor/bin/phpunit tests/Entity/ScriptMappingTest.php
Expected: FAIL because App\Entity\ScriptMapping does not exist.
- Step 3: Implement the user entity
Create src/Entity/User.php with an auto-increment integer id, unique
username, passwordHash, and JSON roles. Implement Symfony's
UserInterface and PasswordAuthenticatedUserInterface:
<?php
namespace App\Entity;
use App\Repository\UserRepository;
use Doctrine\ORM\Mapping as ORM;
use Symfony\Component\Security\Core\User\PasswordAuthenticatedUserInterface;
use Symfony\Component\Security\Core\User\UserInterface;
#[ORM\Entity(repositoryClass: UserRepository::class)]
#[ORM\Table(name: 'admin_user')]
#[ORM\UniqueConstraint(name: 'uniq_admin_user_username', columns: ['username'])]
class User implements UserInterface, PasswordAuthenticatedUserInterface
{
#[ORM\Id]
#[ORM\GeneratedValue]
#[ORM\Column]
private ?int $id = null;
#[ORM\Column(length: 180)]
private string $username = '';
#[ORM\Column]
private string $passwordHash = '';
/** @var list<string> */
#[ORM\Column(type: 'json')]
private array $roles = ['ROLE_ADMIN'];
public function getId(): ?int
{
return $this->id;
}
public function getUserIdentifier(): string
{
return $this->username;
}
public function getUsername(): string
{
return $this->username;
}
public function setUsername(string $username): self
{
$this->username = trim($username);
return $this;
}
/** @return list<string> */
public function getRoles(): array
{
return array_values(array_unique([...$this->roles, 'ROLE_ADMIN']));
}
/** @param list<string> $roles */
public function setRoles(array $roles): self
{
$this->roles = $roles;
return $this;
}
public function getPassword(): string
{
return $this->passwordHash;
}
public function getPasswordHash(): string
{
return $this->passwordHash;
}
public function setPasswordHash(string $passwordHash): self
{
$this->passwordHash = $passwordHash;
return $this;
}
public function eraseCredentials(): void
{
}
}
- Step 4: Implement the mapping entity
Create src/Entity/ScriptMapping.php with fields listed in the spec and methods
used by tests. Use Doctrine attributes, #[ORM\HasLifecycleCallbacks], unique
public path, nullable token, nullable cache key, and datetime immutable fields.
- Step 5: Add repositories
Create src/Repository/UserRepository.php:
<?php
namespace App\Repository;
use App\Entity\User;
use Doctrine\Bundle\DoctrineBundle\Repository\ServiceEntityRepository;
use Doctrine\Persistence\ManagerRegistry;
use Symfony\Bridge\Doctrine\Security\User\UserLoaderInterface;
final class UserRepository extends ServiceEntityRepository implements UserLoaderInterface
{
public function __construct(ManagerRegistry $registry)
{
parent::__construct($registry, User::class);
}
public function loadUserByIdentifier(string $identifier): ?User
{
return $this->findOneBy(['username' => $identifier]);
}
}
Create src/Repository/ScriptMappingRepository.php with
findActiveByPublicPath(string $publicPath): ?ScriptMapping that filters by
publicPath and active = true.
- Step 6: Generate and inspect migration
Run:
php bin/console make:migration
Expected: a migration creates admin_user and script_mapping tables, including
a unique index on script_mapping.public_path.
- Step 7: Run entity tests
Run:
php vendor/bin/phpunit tests/Entity/ScriptMappingTest.php
Expected: PASS.
- Step 8: Commit persistence
Run:
git add src/Entity src/Repository migrations tests/Entity config/packages/doctrine.yaml
git commit -m "feat: add installer mapping persistence"
Task 4: Add Strict Path Normalization
Files:
-
Create:
src/Service/PathNormalizer.php -
Create:
tests/Service/PathNormalizerTest.php -
Step 1: Write path tests
Create tests/Service/PathNormalizerTest.php:
<?php
namespace App\Tests\Service;
use App\Service\PathNormalizer;
use InvalidArgumentException;
use PHPUnit\Framework\TestCase;
final class PathNormalizerTest extends TestCase
{
private PathNormalizer $normalizer;
protected function setUp(): void
{
$this->normalizer = new PathNormalizer();
}
public function testPublicPathIsNormalized(): void
{
self::assertSame('mcp/graylog/install.sh', $this->normalizer->publicPath('/mcp/graylog/install.sh'));
}
public function testPublicPathMustEndWithShellExtension(): void
{
$this->expectException(InvalidArgumentException::class);
$this->normalizer->publicPath('mcp/graylog/install.txt');
}
public function testPublicPathRejectsTraversal(): void
{
$this->expectException(InvalidArgumentException::class);
$this->normalizer->publicPath('mcp/../install.sh');
}
public function testRepositoryPathAllowsNestedShellFileWithoutShellRequirement(): void
{
self::assertSame('scripts/install', $this->normalizer->repositoryPath('/scripts/install'));
}
public function testRepositoryPathRejectsTraversal(): void
{
$this->expectException(InvalidArgumentException::class);
$this->normalizer->repositoryPath('../install.sh');
}
}
- Step 2: Run tests and verify failure
Run:
php vendor/bin/phpunit tests/Service/PathNormalizerTest.php
Expected: FAIL because PathNormalizer does not exist.
- Step 3: Implement the normalizer
Create src/Service/PathNormalizer.php:
<?php
namespace App\Service;
use InvalidArgumentException;
final class PathNormalizer
{
public function publicPath(string $path): string
{
$normalized = $this->relativePath($path);
if (!str_ends_with($normalized, '.sh')) {
throw new InvalidArgumentException('Public path must end with .sh.');
}
return $normalized;
}
public function repositoryPath(string $path): string
{
return $this->relativePath($path);
}
private function relativePath(string $path): string
{
$path = trim(str_replace('\\', '/', $path));
$path = ltrim($path, '/');
if ($path === '') {
throw new InvalidArgumentException('Path cannot be empty.');
}
$segments = explode('/', $path);
foreach ($segments as $segment) {
if ($segment === '' || $segment === '.' || $segment === '..') {
throw new InvalidArgumentException('Path contains an invalid segment.');
}
}
return implode('/', $segments);
}
}
- Step 4: Run tests
Run:
php vendor/bin/phpunit tests/Service/PathNormalizerTest.php
Expected: PASS.
- Step 5: Commit path validation
Run:
git add src/Service/PathNormalizer.php tests/Service/PathNormalizerTest.php
git commit -m "feat: validate script mapping paths"
Task 5: Serve Cached Public Scripts
Files:
-
Create:
src/Service/CachePathResolver.php -
Create:
src/Service/CachedScriptResolver.php -
Create:
src/Controller/PublicScriptController.php -
Modify:
config/services.yaml -
Create:
tests/Controller/PublicScriptControllerTest.php -
Step 1: Write public endpoint tests
Create tests/Controller/PublicScriptControllerTest.php with WebTestCase tests:
<?php
namespace App\Tests\Controller;
use App\Entity\ScriptMapping;
use Doctrine\ORM\EntityManagerInterface;
use Symfony\Bundle\FrameworkBundle\Test\WebTestCase;
final class PublicScriptControllerTest extends WebTestCase
{
public function testUnknownPathReturnsNotFound(): void
{
$client = self::createClient();
$client->request('GET', '/missing/install.sh');
self::assertResponseStatusCodeSame(404);
}
public function testCachedScriptIsServed(): void
{
$client = self::createClient();
$container = static::getContainer();
$cacheDir = $container->getParameter('app.cache_dir');
@mkdir($cacheDir.'/scripts', 0775, true);
file_put_contents($cacheDir.'/scripts/test.sh', "#!/bin/sh\necho ok\n");
$mapping = (new ScriptMapping())
->setPublicPath('mcp/graylog/install.sh')
->setRepositoryUrl('https://forge.lclr.dev/AI/graylog-mcp.git')
->setGitRef('main')
->setRepositoryFilePath('install.sh')
->setCacheKey('scripts/test.sh');
$mapping->markSyncSucceeded('scripts/test.sh');
$em = $container->get(EntityManagerInterface::class);
$em->persist($mapping);
$em->flush();
$client->request('GET', '/mcp/graylog/install.sh');
self::assertResponseIsSuccessful();
self::assertResponseHeaderSame('content-type', 'text/x-shellscript; charset=UTF-8');
self::assertStringContainsString('echo ok', $client->getResponse()->getContent());
}
}
- Step 2: Run tests and verify failure
Run:
php vendor/bin/phpunit tests/Controller/PublicScriptControllerTest.php
Expected: FAIL because the controller and services do not exist.
- Step 3: Configure cache directory parameter
Modify config/services.yaml:
parameters:
app.cache_dir: '%env(resolve:APP_CACHE_DIR)%'
services:
_defaults:
autowire: true
autoconfigure: true
App\:
resource: '../src/'
- Step 4: Implement cache resolver services
Create src/Service/CachePathResolver.php with methods
servedScriptPath(string $cacheKey): string and
scriptCacheKeyForMapping(int $mappingId): string. Store served scripts under
scripts/{id}.sh.
Create src/Service/CachedScriptResolver.php that normalizes the request path,
loads an active mapping through ScriptMappingRepository, returns null when
no cache key exists, and returns a SplFileInfo only when the resolved file is
readable under app.cache_dir.
- Step 5: Implement public controller
Create src/Controller/PublicScriptController.php:
<?php
namespace App\Controller;
use App\Service\CachedScriptResolver;
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
use Symfony\Component\HttpFoundation\BinaryFileResponse;
use Symfony\Component\HttpFoundation\Response;
use Symfony\Component\HttpFoundation\ResponseHeaderBag;
use Symfony\Component\Routing\Attribute\Route;
final class PublicScriptController extends AbstractController
{
public function __construct(private readonly CachedScriptResolver $resolver)
{
}
#[Route('/{path}', name: 'public_script', requirements: ['path' => '(?!admin(?:/|$)).+'], priority: -255)]
public function __invoke(string $path): Response
{
$file = $this->resolver->resolve($path);
if ($file === null) {
throw $this->createNotFoundException();
}
$response = new BinaryFileResponse($file->getPathname());
$response->headers->set('Content-Type', 'text/x-shellscript; charset=UTF-8');
$response->setContentDisposition(ResponseHeaderBag::DISPOSITION_INLINE, basename($path));
return $response;
}
}
- Step 6: Run public endpoint tests
Run:
php vendor/bin/phpunit tests/Controller/PublicScriptControllerTest.php
Expected: PASS.
- Step 7: Commit public serving
Run:
git add src/Service/CachePathResolver.php src/Service/CachedScriptResolver.php src/Controller/PublicScriptController.php config/services.yaml tests/Controller/PublicScriptControllerTest.php
git commit -m "feat: serve cached installer scripts"
Task 6: Add Admin Authentication
Files:
-
Create:
src/Security/LoginFormAuthenticator.php -
Create:
src/Form/LoginFormType.php -
Create:
src/Controller/Admin/AuthController.php -
Create:
src/Command/CreateAdminUserCommand.php -
Modify:
config/packages/security.yaml -
Create:
templates/base.html.twig -
Create:
templates/admin/login.html.twig -
Create:
tests/Controller/Admin/AuthControllerTest.php -
Step 1: Write admin auth tests
Create tests/Controller/Admin/AuthControllerTest.php:
<?php
namespace App\Tests\Controller\Admin;
use App\Entity\User;
use Doctrine\ORM\EntityManagerInterface;
use Symfony\Bundle\FrameworkBundle\Test\WebTestCase;
use Symfony\Component\PasswordHasher\Hasher\UserPasswordHasherInterface;
final class AuthControllerTest extends WebTestCase
{
public function testAdminRedirectsToLogin(): void
{
$client = self::createClient();
$client->request('GET', '/admin');
self::assertResponseRedirects('/admin/login');
}
public function testAdminCanLogin(): void
{
$client = self::createClient();
$container = static::getContainer();
$user = (new User())->setUsername('admin');
$hash = $container->get(UserPasswordHasherInterface::class)->hashPassword($user, 'secret-password');
$user->setPasswordHash($hash);
$em = $container->get(EntityManagerInterface::class);
$em->persist($user);
$em->flush();
$crawler = $client->request('GET', '/admin/login');
$form = $crawler->selectButton('Sign in')->form([
'login_form[username]' => 'admin',
'login_form[password]' => 'secret-password',
]);
$client->submit($form);
self::assertResponseRedirects('/admin');
}
}
- Step 2: Run tests and verify failure
Run:
php vendor/bin/phpunit tests/Controller/Admin/AuthControllerTest.php
Expected: FAIL because admin routes and security are not configured.
- Step 3: Configure security
Create or modify config/packages/security.yaml with password hashers,
UserRepository provider, form-login authenticator, logout route, and access
control requiring ROLE_ADMIN for ^/admin except ^/admin/login.
- Step 4: Implement login form and authenticator
Create src/Form/LoginFormType.php with username, password, and CSRF.
Create src/Security/LoginFormAuthenticator.php extending
AbstractLoginFormAuthenticator. Authenticate against UserRepository, verify
password credentials, redirect to /admin on success, and use /admin/login
as login URL.
- Step 5: Implement auth controller and templates
Create src/Controller/Admin/AuthController.php with:
GET|POST /admin/loginroute namedadmin_login./admin/logoutroute namedadmin_logout.
Create templates/base.html.twig and templates/admin/login.html.twig with a
simple login form. The submit button text must be Sign in to match tests.
- Step 6: Add admin user command
Create src/Command/CreateAdminUserCommand.php named app:admin:create with
arguments username and password. It hashes the password, creates the user
when absent, updates the password when present, and prints
Admin user "<username>" is ready..
- Step 7: Run admin auth tests
Run:
php vendor/bin/phpunit tests/Controller/Admin/AuthControllerTest.php
Expected: PASS.
- Step 8: Commit admin auth
Run:
git add src/Security src/Form/LoginFormType.php src/Controller/Admin/AuthController.php src/Command/CreateAdminUserCommand.php config/packages/security.yaml templates tests/Controller/Admin/AuthControllerTest.php
git commit -m "feat: add admin authentication"
Task 7: Add Admin Mapping CRUD
Files:
-
Create:
src/Form/ScriptMappingType.php -
Create:
src/Controller/Admin/DashboardController.php -
Create:
src/Controller/Admin/ScriptMappingController.php -
Create:
templates/admin/dashboard.html.twig -
Create:
templates/admin/mapping_form.html.twig -
Create:
tests/Controller/Admin/ScriptMappingControllerTest.php -
Step 1: Write CRUD tests
Create tests/Controller/Admin/ScriptMappingControllerTest.php covering:
-
unauthenticated
GET /admin/mappings/newredirects to login; -
authenticated
POST /admin/mappings/newcreates a mapping with normalizedpublicPath = mcp/graylog/install.sh; -
invalid public path
mcp/graylog/install.txtrenders a validation error. -
Step 2: Run tests and verify failure
Run:
php vendor/bin/phpunit tests/Controller/Admin/ScriptMappingControllerTest.php
Expected: FAIL because mapping admin routes do not exist.
- Step 3: Implement mapping form
Create src/Form/ScriptMappingType.php with fields:
publicPathrepositoryUrlgitRefrepositoryFilePathaccessToken, not required,empty_dataset to an empty stringactive
Use a POST_SUBMIT listener with PathNormalizer to normalize public path and
repository file path. Preserve the existing token when the submitted token field
is empty during edit.
- Step 4: Implement dashboard and CRUD controllers
Create /admin dashboard route listing mappings ordered by public path.
Create routes:
GET|POST /admin/mappings/newGET|POST /admin/mappings/{id}/editPOST /admin/mappings/{id}/delete
Use Doctrine transactions for create, edit, and delete. Add flash messages:
-
Mapping created. -
Mapping updated. -
Mapping deleted. -
Step 5: Add templates
Create Twig templates with a compact table showing public path, repository URL, Git ref, repository file path, active status, last sync status, last success, and last error. Do not render access tokens in table or form initial values.
- Step 6: Run CRUD tests
Run:
php vendor/bin/phpunit tests/Controller/Admin/ScriptMappingControllerTest.php
Expected: PASS.
- Step 7: Commit mapping admin
Run:
git add src/Form/ScriptMappingType.php src/Controller/Admin/DashboardController.php src/Controller/Admin/ScriptMappingController.php templates/admin tests/Controller/Admin/ScriptMappingControllerTest.php
git commit -m "feat: manage script mappings from admin"
Task 8: Add Git Synchronization
Files:
-
Create:
src/Service/GitSynchronizer.php -
Modify:
src/Controller/Admin/ScriptMappingController.php -
Modify:
templates/admin/dashboard.html.twig -
Create:
tests/Service/GitSynchronizerTest.php -
Step 1: Write synchronizer tests
Create tests/Service/GitSynchronizerTest.php with tests using a local fixture
Git repository created under sys_get_temp_dir():
-
successful sync copies
install.shintoAPP_CACHE_DIR/scripts/{id}.sh; -
failed sync for a missing repository file keeps the previous cache key and marks status
failed; -
process error text stored on mapping does not contain the mapping access token.
-
Step 2: Run tests and verify failure
Run:
php vendor/bin/phpunit tests/Service/GitSynchronizerTest.php
Expected: FAIL because GitSynchronizer does not exist.
- Step 3: Implement GitSynchronizer
Create src/Service/GitSynchronizer.php using Symfony Process.
Implementation rules:
-
Working directory:
{APP_CACHE_DIR}/repos/{mappingId}. -
Served cache key:
scripts/{mappingId}.sh. -
Clone command:
git clone --no-checkout <repositoryUrl> <workDir>. -
Update command:
git fetch --all --prune. -
Checkout command:
git checkout --force <gitRef>. -
Use
GIT_TERMINAL_PROMPT=0. -
For token-authenticated HTTPS repositories, create a temporary askpass script outside the repository workdir, set
GIT_ASKPASSto that script, and pass the token through an environment variable consumed by the script. -
Delete the temporary askpass script after every Git operation attempt.
-
Copy the repository file to
{APP_CACHE_DIR}/scripts/{mappingId}.sh.tmp, then rename it to{APP_CACHE_DIR}/scripts/{mappingId}.sh. -
On success, call
markSyncSucceeded($cacheKey). -
On failure, call
markSyncFailed($sanitizedError). -
Sanitize errors by replacing the access token with
[redacted]. -
Step 4: Add admin sync action
Modify ScriptMappingController to add:
POST /admin/mappings/{id}/sync- CSRF token id
sync_mapping_{id} - success flash
Mapping synchronized. - failure flash
Synchronization failed.
Flush mapping status after sync. The action redirects to /admin.
- Step 5: Add sync buttons
Modify templates/admin/dashboard.html.twig so every mapping row has a POST
form with a sync button and CSRF token. The form action targets the sync route.
- Step 6: Run synchronizer tests
Run:
php vendor/bin/phpunit tests/Service/GitSynchronizerTest.php
Expected: PASS.
- Step 7: Run admin CRUD tests
Run:
php vendor/bin/phpunit tests/Controller/Admin/ScriptMappingControllerTest.php
Expected: PASS.
- Step 8: Commit Git synchronization
Run:
git add src/Service/GitSynchronizer.php src/Controller/Admin/ScriptMappingController.php templates/admin/dashboard.html.twig tests/Service/GitSynchronizerTest.php
git commit -m "feat: synchronize scripts from git"
Task 9: Add Database Test Isolation
Files:
-
Create:
tests/DatabaseTestCase.php -
Modify: controller tests that use Doctrine.
-
Modify:
phpunit.xml.dist -
Step 1: Add a reusable database test base
Create tests/DatabaseTestCase.php with helpers to boot the kernel, drop and
create schema from Doctrine metadata, and expose EntityManagerInterface.
- Step 2: Update Doctrine tests
Modify WebTestCase classes that write to the database to reset schema in
setUp() and close the entity manager in tearDown().
- Step 3: Run the full test suite
Run:
php vendor/bin/phpunit
Expected: PASS.
- Step 4: Commit test isolation
Run:
git add tests phpunit.xml.dist
git commit -m "test: isolate database-backed tests"
Task 10: Add Documentation And Final Verification
Files:
-
Create:
README.md -
Modify: any Docker or config file found broken during verification.
-
Step 1: Write README
Create README.md with:
-
project purpose;
-
local dev startup:
docker compose -f compose.yaml -f compose.dev.yaml up --build; -
admin creation:
docker compose -f compose.yaml -f compose.dev.yaml exec app php bin/console app:admin:create admin '<password>'; -
migration command;
-
mapping example for
mcp/graylog/install.sh; -
Coolify deployment notes for
APP_SECRET, volumes, and port; -
private Git token behavior;
-
public usage example:
curl https://example.test/mcp/graylog/install.sh | bash. -
Step 2: Run PHP tests
Run:
php vendor/bin/phpunit
Expected: PASS.
- Step 3: Validate Symfony container
Run:
php bin/console lint:container
Expected: PASS.
- Step 4: Validate Docker Compose config
Run:
docker compose -f compose.yaml -f compose.dev.yaml config
Expected: PASS.
- Step 5: Build and boot the local stack
Run:
docker compose -f compose.yaml -f compose.dev.yaml up --build
Expected: Nginx listens on http://localhost:8080, /admin/login renders the
login page, and the app container does not restart.
- Step 6: Commit docs and final fixes
Run:
git add README.md docker compose.yaml compose.dev.yaml config src templates tests
git commit -m "docs: document installer bootstrap deployment"
Self-Review
- Spec coverage: the plan covers public script serving,
/adminlogin, SQLite persistence, mapping CRUD, optional private Git token support, manual sync, Docker production deployment, dev bind mounts, and focused tests. - Placeholder scan: there are no open placeholders in the plan. Where commands create generated files, each step states the expected observable output.
- Type consistency: mapping terminology is consistent across entity,
repository, services, controllers, templates, and tests. The cache key is a
relative path such as
scripts/{id}.sh, resolved only throughCachePathResolver.