From 83ca2cf43c9b13164e9aa8af442aef943be6a1b7 Mon Sep 17 00:00:00 2001 From: Yisroel Baum Date: Thu, 30 Jul 2026 22:54:20 +0300 Subject: [PATCH 1/2] add nix development environment Provide a direnv-loaded Laravel and future Vue toolchain with isolated worktree ports and a process-compose stack. Use PostgreSQL for local runtime services while retaining in-memory SQLite for PHPUnit. --- .envrc | 11 ++++ .gitignore | 5 ++ Caddyfile | 17 +++++ backend/.env.example | 16 ++--- backend/composer.json | 1 - backend/config/database.php | 2 +- backend/config/queue.php | 4 +- backend/database/.gitignore | 1 - flake.lock | 61 +++++++++++++++++ flake.nix | 54 +++++++++++++++ nix/shell-hook.sh | 127 ++++++++++++++++++++++++++++++++++++ process-compose.yaml | 59 +++++++++++++++++ 12 files changed, 345 insertions(+), 13 deletions(-) create mode 100644 .envrc create mode 100644 .gitignore create mode 100644 Caddyfile delete mode 100644 backend/database/.gitignore create mode 100644 flake.lock create mode 100644 flake.nix create mode 100644 nix/shell-hook.sh create mode 100644 process-compose.yaml diff --git a/.envrc b/.envrc new file mode 100644 index 0000000..53d7982 --- /dev/null +++ b/.envrc @@ -0,0 +1,11 @@ +# Load the flake environment +use flake + +onefetch + +# Use PHP and Node layouts +layout php +layout node + +# Reload when backend dependencies change +watch_file backend/composer.json diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..01f5f11 --- /dev/null +++ b/.gitignore @@ -0,0 +1,5 @@ +/.cert +/.direnv/ +/.mailpit.log +/.postgres/ +/.worktrees/ diff --git a/Caddyfile b/Caddyfile new file mode 100644 index 0000000..8df2d09 --- /dev/null +++ b/Caddyfile @@ -0,0 +1,17 @@ +{ + auto_https off + admin off +} + +localhost:{$CADDY_PORT:8000}, 127.0.0.1:{$CADDY_PORT:8000} { + tls .cert/localhost.pem .cert/localhost-key.pem + + handle /storage/* { + root * backend/public + file_server + } + + handle { + reverse_proxy 127.0.0.1:{$BACKEND_PORT:8001} + } +} diff --git a/backend/.env.example b/backend/.env.example index 40bb13b..c3214a6 100644 --- a/backend/.env.example +++ b/backend/.env.example @@ -2,7 +2,7 @@ APP_NAME=Laravel APP_ENV=local APP_KEY= APP_DEBUG=true -APP_URL=http://localhost:8000 +APP_URL=https://localhost:8000 APP_LOCALE=en APP_FALLBACK_LOCALE=en @@ -20,12 +20,12 @@ LOG_STACK=single LOG_DEPRECATIONS_CHANNEL=null LOG_LEVEL=debug -DB_CONNECTION=sqlite -# DB_HOST=127.0.0.1 -# DB_PORT=3306 -# DB_DATABASE=laravel -# DB_USERNAME=root -# DB_PASSWORD= +DB_CONNECTION=pgsql +DB_HOST="${PGHOST}" +DB_PORT=5432 +DB_DATABASE=postgres +DB_USERNAME=postgres +DB_PASSWORD= SESSION_DRIVER=database SESSION_LIFETIME=120 @@ -47,7 +47,7 @@ REDIS_HOST=127.0.0.1 REDIS_PASSWORD=null REDIS_PORT=6379 -MAIL_MAILER=log +MAIL_MAILER=smtp MAIL_SCHEME=null MAIL_HOST=127.0.0.1 MAIL_PORT=2525 diff --git a/backend/composer.json b/backend/composer.json index 5a8f4f4..611f358 100644 --- a/backend/composer.json +++ b/backend/composer.json @@ -81,7 +81,6 @@ ], "post-create-project-cmd": [ "@php artisan key:generate --ansi", - "@php -r \"file_exists('database/database.sqlite') || touch('database/database.sqlite');\"", "@php artisan migrate --graceful --ansi" ], "pre-package-uninstall": [ diff --git a/backend/config/database.php b/backend/config/database.php index abbb88e..00445da 100644 --- a/backend/config/database.php +++ b/backend/config/database.php @@ -17,7 +17,7 @@ return [ | */ - 'default' => env('DB_CONNECTION', 'sqlite'), + 'default' => env('DB_CONNECTION', 'pgsql'), /* |-------------------------------------------------------------------------- diff --git a/backend/config/queue.php b/backend/config/queue.php index 79c2c0a..e633605 100644 --- a/backend/config/queue.php +++ b/backend/config/queue.php @@ -103,7 +103,7 @@ return [ */ 'batching' => [ - 'database' => env('DB_CONNECTION', 'sqlite'), + 'database' => env('DB_CONNECTION', 'pgsql'), 'table' => 'job_batches', ], @@ -122,7 +122,7 @@ return [ 'failed' => [ 'driver' => env('QUEUE_FAILED_DRIVER', 'database-uuids'), - 'database' => env('DB_CONNECTION', 'sqlite'), + 'database' => env('DB_CONNECTION', 'pgsql'), 'table' => 'failed_jobs', ], diff --git a/backend/database/.gitignore b/backend/database/.gitignore deleted file mode 100644 index 9b19b93..0000000 --- a/backend/database/.gitignore +++ /dev/null @@ -1 +0,0 @@ -*.sqlite* diff --git a/flake.lock b/flake.lock new file mode 100644 index 0000000..143e71c --- /dev/null +++ b/flake.lock @@ -0,0 +1,61 @@ +{ + "nodes": { + "nixpkgs": { + "locked": { + "lastModified": 1785318670, + "narHash": "sha256-dN6Ou5x/+23FZLEpYP3IffO+NyJFzUlGumt1uu3MMaY=", + "owner": "NixOS", + "repo": "nixpkgs", + "rev": "0954f7ee2f6bb3dc7d4e3d0d8bcb8fd4bde4cfc5", + "type": "github" + }, + "original": { + "owner": "NixOS", + "ref": "nixos-unstable", + "repo": "nixpkgs", + "type": "github" + } + }, + "root": { + "inputs": { + "nixpkgs": "nixpkgs", + "utils": "utils" + } + }, + "systems": { + "locked": { + "lastModified": 1681028828, + "narHash": "sha256-Vy1rq5AaRuLzOxct8nz4T6wlgyUR7zLU309k9mBC768=", + "owner": "nix-systems", + "repo": "default", + "rev": "da67096a3b9bf56a91d16901293e51ba5b49a27e", + "type": "github" + }, + "original": { + "owner": "nix-systems", + "repo": "default", + "type": "github" + } + }, + "utils": { + "inputs": { + "systems": "systems" + }, + "locked": { + "lastModified": 1731533236, + "narHash": "sha256-l0KFg5HjrsfsO/JpG+r7fRrqm12kzFHyUHqHCVpMMbI=", + "owner": "numtide", + "repo": "flake-utils", + "rev": "11707dc2f618dd54ca8739b309ec4fc024de578b", + "type": "github" + }, + "original": { + "owner": "numtide", + "repo": "flake-utils", + "type": "github" + } + } + }, + "root": "root", + "version": 7 +} diff --git a/flake.nix b/flake.nix new file mode 100644 index 0000000..85a5fe9 --- /dev/null +++ b/flake.nix @@ -0,0 +1,54 @@ +{ + inputs = { + nixpkgs.url = "github:NixOS/nixpkgs/nixos-unstable"; + utils.url = "github:numtide/flake-utils"; + }; + + outputs = + { + self, + nixpkgs, + utils, + }: + utils.lib.eachDefaultSystem ( + system: + let + pkgs = nixpkgs.legacyPackages.${system}; + php = pkgs.php.buildEnv { + extraConfig = '' + memory_limit = "1G"; + upload_max_filesize = "10M"; + post_max_size = "25M"; + ''; + }; + in + { + formatter = pkgs.nixfmt-tree; + + devShells.default = pkgs.mkShell { + packages = with pkgs; [ + bash + onefetch + just + php + phpPackages.composer + phpPackages.php-codesniffer + vscode-langservers-extracted + nodejs + nixfmt + nixfmt-tree + cypress + yaml-language-server + typescript + postgresql + mailpit + process-compose + mkcert + caddy + ]; + + shellHook = builtins.readFile ./nix/shell-hook.sh; + }; + } + ); +} diff --git a/nix/shell-hook.sh b/nix/shell-hook.sh new file mode 100644 index 0000000..90e0f50 --- /dev/null +++ b/nix/shell-hook.sh @@ -0,0 +1,127 @@ +#!/usr/bin/env bash + +REPO_ROOT="$(git rev-parse --show-toplevel 2>/dev/null || pwd)" +GIT_DIR_PATH="$(git rev-parse --git-dir 2>/dev/null)" +GIT_COMMON_PATH="$(git rev-parse --git-common-dir 2>/dev/null)" + +# Linked worktrees get their own runtime state and port range. +if [ -n "$GIT_COMMON_PATH" ] \ + && [ "$GIT_DIR_PATH" != "$GIT_COMMON_PATH" ]; then + IS_WORKTREE=1 + MAIN_REPO_ROOT="$(cd "$GIT_COMMON_PATH/.." && pwd)" +else + IS_WORKTREE=0 + MAIN_REPO_ROOT="$REPO_ROOT" +fi + +if [ "$IS_WORKTREE" = 1 ]; then + if [ -n "$PORT_OFFSET" ]; then + OFFSET="$PORT_OFFSET" + else + PORT_HASH="$(printf '%s' "$REPO_ROOT" | cksum | cut -d' ' -f1)" + OFFSET=$(( (PORT_HASH % 49 + 1) * 100 )) + fi +else + OFFSET=0 +fi + +export CADDY_PORT=$((8000 + OFFSET)) +export BACKEND_PORT=$((8001 + OFFSET)) +export VITE_PORT=$((5173 + OFFSET)) +export MAILPIT_SMTP_PORT=$((2525 + OFFSET)) +export MAILPIT_UI_PORT=$((8025 + OFFSET)) +export PC_PORT_NUM=$((8080 + OFFSET)) + +export PGDATA="$REPO_ROOT/.postgres" +export PGHOST="$PGDATA" +export PGUSER="postgres" +export PGDATABASE="postgres" + +DEV_APP_URL="https://localhost:$CADDY_PORT" +DEV_DB_CONNECTION="pgsql" +DEV_DB_HOST="$PGHOST" +DEV_DB_PORT="5432" +DEV_DB_DATABASE="$PGDATABASE" +DEV_DB_USERNAME="$PGUSER" +DEV_DB_PASSWORD="" +DEV_MAIL_MAILER="smtp" +DEV_MAIL_HOST="127.0.0.1" +DEV_MAIL_PORT="$MAILPIT_SMTP_PORT" + +if [ ! -d "$PGDATA" ]; then + echo "[pg] initializing cluster at $PGDATA" + initdb --auth=trust --username="$PGUSER" --no-locale --encoding=UTF8 >/dev/null + { + echo "listen_addresses = ''" + echo "unix_socket_directories = '$PGDATA'" + } >> "$PGDATA/postgresql.conf" +fi + +# Worktrees share host-independent localhost certificates with the main checkout. +MAIN_CERT_ROOT="$MAIN_REPO_ROOT/.cert" +if [ ! -d "$MAIN_CERT_ROOT" ]; then + echo "[cert] installing local CA + generating TLS certs in $MAIN_CERT_ROOT" + mkcert -install + mkdir -p "$MAIN_CERT_ROOT" + mkcert \ + -key-file "$MAIN_CERT_ROOT/localhost-key.pem" \ + -cert-file "$MAIN_CERT_ROOT/localhost.pem" \ + localhost 127.0.0.1 ::1 +fi + +if [ "$IS_WORKTREE" = 1 ] \ + && [ ! -e "$REPO_ROOT/.cert" ] \ + && [ ! -L "$REPO_ROOT/.cert" ]; then + ln -s "$MAIN_CERT_ROOT" "$REPO_ROOT/.cert" +fi + +ENV_FILE="$REPO_ROOT/backend/.env" +if [ ! -f "$ENV_FILE" ]; then + if [ "$IS_WORKTREE" = 1 ] && [ -f "$MAIN_REPO_ROOT/backend/.env" ]; then + cp "$MAIN_REPO_ROOT/backend/.env" "$ENV_FILE" + else + cp "$REPO_ROOT/backend/.env.example" "$ENV_FILE" + fi +fi + +set_env_value() { + local env_key="$1" + local env_value="$2" + local escaped_value + + escaped_value="$(printf '%s' "$env_value" | sed 's/[&|\\]/\\&/g')" + + if grep -q "^${env_key}=" "$ENV_FILE"; then + sed -i "s|^${env_key}=.*|${env_key}=${escaped_value}|" "$ENV_FILE" + else + printf '%s=%s\n' "$env_key" "$env_value" >> "$ENV_FILE" + fi +} + +set_env_value APP_URL "$DEV_APP_URL" +set_env_value DB_CONNECTION "$DEV_DB_CONNECTION" +set_env_value DB_HOST "$DEV_DB_HOST" +set_env_value DB_PORT "$DEV_DB_PORT" +set_env_value DB_DATABASE "$DEV_DB_DATABASE" +set_env_value DB_USERNAME "$DEV_DB_USERNAME" +set_env_value DB_PASSWORD "$DEV_DB_PASSWORD" +set_env_value MAIL_MAILER "$DEV_MAIL_MAILER" +set_env_value MAIL_HOST "$DEV_MAIL_HOST" +set_env_value MAIL_PORT "$DEV_MAIL_PORT" + +if [ ! -d "$REPO_ROOT/backend/vendor" ]; then + echo "[composer] installing backend dependencies" + (cd "$REPO_ROOT/backend" && composer install) +fi + +if grep -q '^APP_KEY=$' "$ENV_FILE"; then + echo "[laravel] generating application key" + (cd "$REPO_ROOT/backend" && php artisan key:generate) +fi + +if [ ! -e "$REPO_ROOT/backend/public/storage" ]; then + echo "[laravel] linking public storage" + (cd "$REPO_ROOT/backend" && php artisan storage:link) +fi + +echo "[dev] run 'process-compose up' to start postgres + mailpit + backend + caddy" diff --git a/process-compose.yaml b/process-compose.yaml new file mode 100644 index 0000000..dc1ea2f --- /dev/null +++ b/process-compose.yaml @@ -0,0 +1,59 @@ +version: "0.5" + +processes: + postgres: + command: postgres -D "$PGDATA" -k "$PGDATA" -c listen_addresses='' + shutdown: + signal: 2 + readiness_probe: + exec: + command: pg_isready -h "$PGDATA" -d "$PGDATABASE" -U "$PGUSER" + initial_delay_seconds: 1 + period_seconds: 2 + + migrate: + command: php artisan migrate --force + working_dir: ./backend + depends_on: + postgres: + condition: process_healthy + + mailpit: + command: mailpit --smtp 127.0.0.1:${MAILPIT_SMTP_PORT:-2525} --listen 127.0.0.1:${MAILPIT_UI_PORT:-8025} + readiness_probe: + http_get: + host: 127.0.0.1 + port: ${MAILPIT_UI_PORT:-8025} + path: / + initial_delay_seconds: 1 + period_seconds: 2 + + backend: + command: php artisan serve --host=127.0.0.1 --port=${BACKEND_PORT:-8001} + working_dir: ./backend + depends_on: + migrate: + condition: process_completed_successfully + mailpit: + condition: process_healthy + readiness_probe: + http_get: + host: 127.0.0.1 + port: ${BACKEND_PORT:-8001} + path: /up + initial_delay_seconds: 2 + period_seconds: 10 + + caddy: + command: caddy run --config Caddyfile --adapter caddyfile + depends_on: + backend: + condition: process_healthy + readiness_probe: + http_get: + host: 127.0.0.1 + port: ${CADDY_PORT:-8000} + path: /up + scheme: https + initial_delay_seconds: 1 + period_seconds: 2 From a225433cec6b313b30171032f641c61c8acc4549 Mon Sep 17 00:00:00 2001 From: Yisroel Baum Date: Thu, 30 Jul 2026 22:54:39 +0300 Subject: [PATCH 2/2] add project ai instructions Document Attainly-specific TDD, worktree, Laravel, database, and future Vue conventions adapted from the youngstartup workflow. --- AGENTS.md | 7 ++ ai/backend-context.md | 105 ++++++++++++++++++++++++ ai/frontend-context.md | 90 +++++++++++++++++++++ ai/shared.md | 177 +++++++++++++++++++++++++++++++++++++++++ 4 files changed, 379 insertions(+) create mode 100644 AGENTS.md create mode 100644 ai/backend-context.md create mode 100644 ai/frontend-context.md create mode 100644 ai/shared.md diff --git a/AGENTS.md b/AGENTS.md new file mode 100644 index 0000000..5dea88b --- /dev/null +++ b/AGENTS.md @@ -0,0 +1,7 @@ +# Project context + +Read these on every session. Rules in them override defaults. + +@ai/shared.md +@ai/backend-context.md +@ai/frontend-context.md diff --git a/ai/backend-context.md b/ai/backend-context.md new file mode 100644 index 0000000..740118b --- /dev/null +++ b/ai/backend-context.md @@ -0,0 +1,105 @@ +# Backend context + +Read `ai/shared.md` first. This file covers backend-specific rules. + +## Project context + +**Stack:** PHP 8.4, Laravel 13, Inertia Laravel, PHPUnit, Larastan, Composer. + +**Location:** `backend/`. + +The application is still close to the Laravel starter structure. Do not +introduce a domain architecture, repository layer, service layer, or other +abstraction before the codebase and requested behavior justify it. Match +existing Laravel conventions and inspect similar code before adding a new +pattern. + +## Laravel patterns + +- Keep controllers thin. Put reusable business behavior in an appropriately + named application or domain class once the behavior warrants extraction. +- Use dedicated request validation rather than validating substantial payloads + inline in controllers. +- Let unexpected exceptions reach Laravel's exception handler. Catch only + exceptions that can be handled meaningfully at the current boundary. +- Use Eloquent relationships and query scopes consistently rather than + duplicating query fragments. +- Avoid speculative interfaces and abstractions with only one trivial + implementation. +- Routes currently use Inertia, but the Vue client has not been scaffolded. + Do not add placeholder frontend assets as part of unrelated backend work. + +## Tests + +- Follow the existing PHPUnit organization under `tests/Unit/` and + `tests/Feature/`. +- Prefer `PHPUnit\Framework\TestCase` when a test only exercises plain PHP. +- Extend `Tests\TestCase` only when the test needs Laravel's container, + facades, database, routing, or HTTP kernel. +- HTTP feature tests extend `Tests\TestCase`. +- Use `RefreshDatabase` when a test reads or writes database state. +- Assert behavior at the appropriate seam: + - Unit tests cover isolated business behavior and edge cases. + - Feature tests cover routing, middleware, validation, persistence, and + response shape. +- Do not duplicate every business branch through the HTTP layer when unit + coverage already proves it. Feature tests should focus on wiring and the + public contract. + +## Test database + +- Development and runtime use PostgreSQL through the local Unix socket. +- PHPUnit intentionally uses SQLite `:memory:` as configured in + `phpunit.xml`. +- Feature tests are self-contained and do not require the process-compose + PostgreSQL service. +- Never point `RefreshDatabase` tests at the development PostgreSQL database. +- Keep mail set to the PHPUnit `array` transport unless a test explicitly + exercises a real mail integration. + +## PHP rules + +- Put imports at the top of the file. Do not use inline fully qualified class + names when a normal `use` statement is clearer. +- Do not use arrow functions. Use regular anonymous functions. +- Do not add default values to function or constructor parameters. Pass every + argument explicitly, including nullable arguments. +- Use descriptive names for classes, methods, parameters, and local + variables. +- Document exceptions with `@throws` when a caller is expected to handle + them. + +## Seeders + +- Keep `DatabaseSeeder` as the orchestrator. +- Split substantial seed data into one seeder per domain concept and invoke + them in dependency order. +- Do not add production repository or model APIs solely to make seeding + convenient. +- Use existing lookup methods for cross-seeder relationships. When a group of + records only makes sense together, keep them in one seeder and retain local + references. + +## Migrations + +- Attainly is not in production yet. +- While no production database exists, edit the original `create_*` migration + when changing a table instead of accumulating follow-up alter migrations. +- Keep one migration file per table during this pre-production phase. +- Rebuild the development database with: + + ```sh + php artisan migrate:fresh --seed + ``` + +- Once a production database exists, replace this policy with additive, + forward-only migrations. + +## Before completing backend work + +- Run the focused test during development. +- Run `php artisan test` before completion. +- Run the Composer lint and static-analysis scripts when their dependencies + are available. +- Fix failures caused by the change. Report unrelated baseline failures + precisely rather than hiding them or expanding scope without authorization. diff --git a/ai/frontend-context.md b/ai/frontend-context.md new file mode 100644 index 0000000..7d9dba7 --- /dev/null +++ b/ai/frontend-context.md @@ -0,0 +1,90 @@ +# Frontend context + +Read `ai/shared.md` first. This file covers frontend-specific rules. + +## Current state + +The Vue frontend has not been scaffolded yet. The backend has Inertia Laravel +installed and an Inertia route, but there is no `package.json`, Vue source +tree, Vite configuration, or frontend test setup. + +- Do not create the frontend unless the user explicitly asks. +- Do not invent a frontend directory, package manager, dependency version, or + command before the scaffold establishes it. +- When the frontend is created, update this file with its actual paths, + package versions, scripts, and testing tools. + +## Vue conventions + +Apply these rules once a Vue 3 frontend exists: + +- Use the Composition API and `