# 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`.