From 437829c542230ced12923d0fa699b5e452c2fe50 Mon Sep 17 00:00:00 2001 From: thibaud-leclere Date: Tue, 5 May 2026 09:17:53 +0200 Subject: [PATCH] docs: add installer bootstrap implementation plan --- .../2026-05-05-get-installer-bootstrap.md | 1357 +++++++++++++++++ 1 file changed, 1357 insertions(+) create mode 100644 docs/superpowers/plans/2026-05-05-get-installer-bootstrap.md diff --git a/docs/superpowers/plans/2026-05-05-get-installer-bootstrap.md b/docs/superpowers/plans/2026-05-05-get-installer-bootstrap.md new file mode 100644 index 0000000..694a5a5 --- /dev/null +++ b/docs/superpowers/plans/2026-05-05-get-installer-bootstrap.md @@ -0,0 +1,1357 @@ +# 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`: + +```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: + +```bash +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 + new Kernel($context['APP_ENV'], (bool) $context['APP_DEBUG']); +``` + +- [ ] **Step 4: Add environment defaults** + +`.env`: + +```dotenv +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`: + +```dotenv +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: + +```bash +php bin/console about +``` + +Expected: Symfony prints project information and exits with status `0`. + +- [ ] **Step 6: Commit the bootstrap** + +Run: + +```bash +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`: + +```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`: + +```nginx +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`: + +```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`: + +```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`: + +```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`: + +```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`: + +```gitignore +.git +docs/superpowers/plans +var +vendor +.env.local +compose.dev.yaml +``` + +- [ ] **Step 6: Validate config syntax** + +Run: + +```bash +docker compose -f compose.yaml config +``` + +Expected: Compose prints resolved YAML and exits with status `0`. + +- [ ] **Step 7: Commit Docker support** + +Run: + +```bash +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 +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: + +```bash +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 + */ + #[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 */ + public function getRoles(): array + { + return array_values(array_unique([...$this->roles, 'ROLE_ADMIN'])); + } + + /** @param list $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 +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: + +```bash +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: + +```bash +php vendor/bin/phpunit tests/Entity/ScriptMappingTest.php +``` + +Expected: PASS. + +- [ ] **Step 8: Commit persistence** + +Run: + +```bash +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 +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: + +```bash +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 +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: + +```bash +php vendor/bin/phpunit tests/Service/PathNormalizerTest.php +``` + +Expected: PASS. + +- [ ] **Step 5: Commit path validation** + +Run: + +```bash +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 +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: + +```bash +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`: + +```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 + '(?!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: + +```bash +php vendor/bin/phpunit tests/Controller/PublicScriptControllerTest.php +``` + +Expected: PASS. + +- [ ] **Step 7: Commit public serving** + +Run: + +```bash +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 +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: + +```bash +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 "" is ready.`. + +- [ ] **Step 7: Run admin auth tests** + +Run: + +```bash +php vendor/bin/phpunit tests/Controller/Admin/AuthControllerTest.php +``` + +Expected: PASS. + +- [ ] **Step 8: Commit admin auth** + +Run: + +```bash +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: + +```bash +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: + +```bash +php vendor/bin/phpunit tests/Controller/Admin/ScriptMappingControllerTest.php +``` + +Expected: PASS. + +- [ ] **Step 7: Commit mapping admin** + +Run: + +```bash +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: + +```bash +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 `. +- Update command: `git fetch --all --prune`. +- Checkout command: `git checkout --force `. +- 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: + +```bash +php vendor/bin/phpunit tests/Service/GitSynchronizerTest.php +``` + +Expected: PASS. + +- [ ] **Step 7: Run admin CRUD tests** + +Run: + +```bash +php vendor/bin/phpunit tests/Controller/Admin/ScriptMappingControllerTest.php +``` + +Expected: PASS. + +- [ ] **Step 8: Commit Git synchronization** + +Run: + +```bash +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: + +```bash +php vendor/bin/phpunit +``` + +Expected: PASS. + +- [ ] **Step 4: Commit test isolation** + +Run: + +```bash +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 ''`; +- 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: + +```bash +php vendor/bin/phpunit +``` + +Expected: PASS. + +- [ ] **Step 3: Validate Symfony container** + +Run: + +```bash +php bin/console lint:container +``` + +Expected: PASS. + +- [ ] **Step 4: Validate Docker Compose config** + +Run: + +```bash +docker compose -f compose.yaml -f compose.dev.yaml config +``` + +Expected: PASS. + +- [ ] **Step 5: Build and boot the local stack** + +Run: + +```bash +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: + +```bash +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`.