1
0
Fork 0
get-installer-bootstrap/docs/superpowers/plans/2026-05-05-get-installer-bootstrap.md
2026-05-05 09:17:53 +02:00

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/login route named admin_login.
  • /admin/logout route named admin_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/new redirects to login;

  • authenticated POST /admin/mappings/new creates a mapping with normalized publicPath = mcp/graylog/install.sh;

  • invalid public path mcp/graylog/install.txt renders 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:

  • publicPath
  • repositoryUrl
  • gitRef
  • repositoryFilePath
  • accessToken, not required, empty_data set to an empty string
  • active

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/new
  • GET|POST /admin/mappings/{id}/edit
  • POST /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.sh into APP_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_ASKPASS to 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, /admin login, 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 through CachePathResolver.