diff --git a/CONTEXT.md b/CONTEXT.md index dd37607..2daa609 100644 --- a/CONTEXT.md +++ b/CONTEXT.md @@ -1,23 +1,54 @@ -# GoFast CLI (`gof`) - Context Document +# GoFast CLI (`gof`) Context -## Overview +## Metadata +- Domain: `gof` CLI - Go application code generator +- Primary audience: LLM agents working on CLI development +- Last updated: 2026-03-10 +- Status: Active +- Stability note: Sections marked `[STABLE]` should change rarely. Sections marked `[VOLATILE]` are expected to change often. -The `gof` CLI is a code generation tool that builds Go applications like Lego bricks. It generates a full-stack application with: -- Go backend with ConnectRPC transport -- PostgreSQL database with SQLC -- OAuth authentication -- Optional Svelte frontend client +--- -The CLI uses a **skeleton-based code generation** approach - it copies template files and performs smart token replacements and dynamic content generation. +## 0. Context Maintenance Protocol (LLM-First) [STABLE] -## Source of Truth: `../gofast-app` +This file is the primary working context for the `gof` CLI tool. -**IMPORTANT:** The `gofast-app` repository (located at `../gofast-app` relative to this CLI) is the **single source of truth** for all templates and integrations. When investigating issues or understanding how generated code should look: +- LLM agents should treat this as a living document and update it whenever meaningful behavior changes. +- If code and this file diverge, prefer updating this file quickly so future work stays reliable. +- Temporary or branch-specific behavior should be documented here with clear cleanup notes. -1. **Check `../gofast-app` first** - it contains the complete application with all integrations enabled -2. **Template files live there** - domain services, transport handlers, configs, migrations, etc. -3. **Integration markers** (`GF_STRIPE_START/END`, `GF_EMAIL_START/END`, `GF_FILE_START/END`) wrap optional code -4. **Use `TEST=true`** when running CLI commands locally - this copies from `../gofast-app` instead of downloading +### Quick update checklist +- Refresh `Last updated` date +- Review `Current Work` and `Future Work` +- Validate `Critical Invariants` +- Update marker references if any markers renamed or added +- Remove obsolete notes + +### Freshness target +- Re-review this file regularly (every 2 weeks) to prevent context drift. + +--- + +## 1. Summary [STABLE] + +The `gof` CLI is a code generation tool that builds full-stack Go applications like Lego bricks. It generates a production-ready application with Go backend (ConnectRPC), PostgreSQL (SQLC), OAuth auth, optional Svelte or TanStack frontend, and optional integrations (Stripe, S3, Postmark). + +The CLI uses **skeleton-based code generation**: it copies template files from a reference repository and performs token replacements plus marker-based dynamic content injection. + +- **Primary entry points:** `cmd/gof/main.go` -> `cmd/gof/cmd/root.go` (Cobra commands) +- **Main responsibilities:** Project scaffolding, CRUD model generation, integration wiring, client scaffolding, infra/monitoring setup +- **Highest-risk areas:** Test generation (`model_test_gen.go`), marker-based code injection, multi-client scaffolding/order-dependent generation, permission bitmask computation + +--- + +## Source of Truth: `../gofast-app` [STABLE] + +**CRITICAL:** The `gofast-app` repository (at `../gofast-app` relative to this CLI) is the **single source of truth** for all templates and integrations. + +1. **Check `../gofast-app` first** when investigating issues or understanding generated code +2. **Skeleton templates live there** - `domain/skeleton/`, `transport/skeleton/`, `e2e/skeletons.test.ts` +3. **Integration markers** (`GF_STRIPE_START/END`, `GF_FILE_START/END`, `GF_EMAIL_START/END`) wrap optional code +4. **Use `TEST=true`** when running CLI commands locally - copies from `../gofast-app` instead of downloading ```bash # Local development - uses ../gofast-app as source @@ -25,262 +56,388 @@ TEST=true go run ./cmd/gof/... init demo TEST=true go run ../cmd/gof/... add stripe # from inside demo/ ``` -## Project Structure +### Key template locations in `../gofast-app` -``` -cmd/gof/ -├── main.go # Entry point, calls cmd.Execute() -├── cmd/ # Cobra commands -│ ├── root.go # Root command -│ ├── init.go # Project initialization -│ ├── add.go # Add integrations (stripe, r2, postmark) -│ ├── model.go # Model generation orchestration -│ ├── model_db.go # Proto, schema, SQL query generation -│ ├── model_service.go # Domain service layer generation -│ ├── model_test_gen.go # Test fixture generation -│ ├── model_transport.go # ConnectRPC transport generation -│ ├── client.go # Client service setup -│ ├── auth.go # Auth command -│ ├── infra.go # Infrastructure files -│ └── version.go # Version display -├── integrations/ # Optional integration handlers -│ ├── integrations.go # Shared helpers (strip, copy, migrate, nav) -│ ├── stripe.go # Stripe payment integration -│ ├── r2.go # Cloudflare R2 file storage -│ └── postmark.go # Postmark email integration -├── config/config.go # gofast.json configuration management -├── repo/repo.go # Template repository download -├── svelte/svelte.go # Svelte client page generation -├── auth/ # Authentication TUI (Bubble Tea) -│ ├── auth.go -│ ├── bubble.go -│ └── config.go -└── build.sh # Build script +| Template | Path | +|----------|------| +| Service skeleton | `app/service-core/domain/skeleton/` (service.go, service_test.go, validation.go, validation_test.go) | +| Transport skeleton | `app/service-core/transport/skeleton/` (route.go, route_test.go) | +| E2E skeleton | `e2e/skeletons.test.ts` | +| Proto skeleton | `proto/v1/skeleton.proto` | +| Svelte skeleton | `app/service-svelte/src/routes/(app)/models/skeletons/` | +| TanStack skeleton | `app/service-tanstack/src/routes/_layout/models/skeletons/` | +| Main wiring | `app/service-core/main.go` (marker injection points) | +| Auth permissions | `app/pkg/auth/auth.go` (permission flags) | + +--- + +## Test Strategy [STABLE] + +### ALWAYS TEST EVERYTHING + +Testing is the most critical part of this CLI. Every change must be verified by generating a demo project and running its full test suite. Always include a client for full coverage. + +### Scope and intent +- The CLI itself has no unit tests - it is tested by **generating projects and running their tests** +- Generated projects include Go unit tests, integration tests, and Playwright e2e tests +- CI runs `golangci-lint` on the CLI code itself (`.github/workflows/lint.yml`) + +### LLM default policy +- On every code change, regenerate a demo project and verify Go builds + tests pass +- For marker/injection changes, test multiple model type combinations +- For integration changes, test with client enabled and disabled +- If tests are deferred, document the gap explicitly + +### How to test + +```bash +# 1. Generate scenario (from gofast-cli root) +cd /home/mat/projects/gofast-cli +rm -rf demo +TEST=true go run ./cmd/gof/... init demo +cd demo + +# 1.5. For local testing, switch buf.gen.yaml to local plugins +# Replace remote plugins with local ones before running `make gen` +# Use this shape: +# version: v2 +# plugins: +# - local: protoc-gen-go +# out: app/gen +# opt: paths=source_relative +# - local: protoc-gen-connect-go +# out: app/gen +# opt: paths=source_relative +# - local: protoc-gen-es +# out: app/service-svelte/src/lib/gen +# opt: target=ts +# - local: protoc-gen-es +# out: app/service-tanstack/src/lib/gen +# opt: target=ts + +# ... add models/integrations/client as needed ... + +# 2. Quick verification (no secrets needed) +docker compose up postgres -d +goose -dir app/service-core/storage/migrations postgres \ + "postgres://postgres:postgres@localhost:5432/postgres?sslmode=disable" up +cd app/service-core && go test -race ./... + +# 3. Full test suite (requires secrets - ask user for them) +# Run from demo directory +CONTEXT=gofast-rc \ +GITHUB_CLIENT_ID= \ +GITHUB_CLIENT_SECRET= \ +GOOGLE_CLIENT_ID= \ +GOOGLE_CLIENT_SECRET= \ +TWILIO_ACCOUNT_SID= \ +TWILIO_AUTH_TOKEN= \ +TWILIO_SERVICE_SID= \ +PAYMENT_PROVIDER=stripe \ +STRIPE_API_KEY= \ +STRIPE_PRICE_ID_BASIC= \ +STRIPE_PRICE_ID_PRO= \ +STRIPE_WEBHOOK_SECRET= \ +BUCKET_NAME=gofast \ +R2_ACCESS_KEY= \ +R2_SECRET_KEY= \ +R2_ENDPOINT= \ +EMAIL_FROM=admin@gofast.live \ +POSTMARK_API_KEY= \ +bash scripts/run_tests.sh + +# 4. Check e2e results +cat e2e/test-results/.last-run.json +# Should show: {"status": "passed", "failedTests": []} ``` -## CLI Commands +### E2E validation (after client-side generation) -### `gof init [project_name]` -Sets up a new Go project with: -- Docker Compose (PostgreSQL, services) -- OAuth authentication -- Base project structure -- Git repository with initial commit +After every client-side generation (`gof client svelte|tanstack`, `gof model` with client enabled, `gof add` with client enabled), validate with the full e2e suite. This is the most important validation indicator — Go unit tests alone don't catch client-side wiring issues. -**Prerequisites:** buf, goose, sqlc, docker, docker-compose, gh (for repo creation) +```bash +# From demo/ directory, after all gof commands and codegen: +make gen && make sql -**Creates:** `gofast.json` config file with project metadata +# Start services (use starts for svelte, startt for tanstack) +docker compose -f docker-compose.yml -f docker-compose.tanstack.yml up --build -d +# or: docker compose -f docker-compose.yml -f docker-compose.svelte.yml up --build -d -**Output:** Prints `gh repo create` command for easy GitHub repo setup +# Wait for all containers to be healthy, then migrate +make migrate -### `gof model [name] [columns...]` -Generates a complete CRUD model with all layers. +# Run e2e tests +cd e2e +PLAYWRIGHT_BASE_URL=http://localhost:3000 PUBLIC_CORE_URL=http://localhost:4000 npx playwright test +``` -**Syntax:** `gof model note title:string views:number published_at:date is_active:bool` +**Expected results (no integrations):** ~33 passed, ~28 skipped (integration tests for emails/files/payments). +**Expected results (with integrations):** skipped count decreases as integration tests become runnable. -**Model name rules:** -- Must be lowercase letters and underscores only (e.g., `user_profile`, `event_log`) -- Must be singular - plural names are rejected with a suggestion (e.g., `trucks` → use `truck`) -- Underscores are converted to CamelCase for Go types (e.g., `event_log` → `EventLog`) +**Common e2e pitfalls:** +- `docker compose down -v` wipes the DB volume — must re-run `make migrate` before tests +- `routeTree.gen.ts` (TanStack) must match actual route files — the CLI now regenerates it via `@tanstack/router-generator` during TanStack client formatting +- Proto codegen (`make gen`) must run before `make startt` — client imports services from `main_pb.ts` +- `e2e/users.test.ts` in the root template is currently flaky because it clicks the first `Edit` link instead of the row for the generated `adminEmail` -**Column name rules:** -- Must be lowercase letters, numbers, and underscores (e.g., `view_count`, `is_active2`) -- Reserved names rejected: `id`, `user_id`, `created`, `updated` (auto-generated) -- Go keywords rejected: `type`, `func`, `var`, `package`, `map`, `chan`, etc. +### Resetting the database -**Column types:** -| Type | SQL | Proto | Go | Validation | -|------|-----|-------|-----|------------| -| string | text | string | string | required, minlength(3) | -| number | numeric | string | string | must parse, >= 1 | -| date | timestamptz | string | time.Time | valid date format | -| bool | boolean | bool | bool | none | +```bash +cd demo +docker compose down -v # Remove container AND volume +docker compose up postgres -d +sleep 2 +goose -dir app/service-core/storage/migrations postgres \ + "postgres://postgres:postgres@localhost:5432/postgres?sslmode=disable" up +``` -**Generates:** -1. **Database layer:** Proto definitions, SQL migrations, SQLC queries -2. **Service layer:** Domain service with validation logic -3. **Transport layer:** ConnectRPC handlers -4. **Tests:** Service tests, transport tests, validation tests -5. **Client (if Svelte):** List page, detail/create page, navigation entry - -### `gof client svelte` -Adds a Svelte frontend client service: -- Copies template from downloaded repo -- Updates docker-compose -- Generates CRUD pages for all existing models - -### `gof infra` -Adds infrastructure/deployment files: -- `docker-compose.monitoring.yml` - Local monitoring stack -- `infra/` folder - Terraform configs, setup scripts, PR environment manifests -- `monitoring/` folder - Grafana/Alloy configs -- Marks project as `infraPopulated: true` in config - -**Setup scripts:** `setup_rke2.sh`, `setup_gh.sh`, `setup_cloudflare.sh` - -### `gof add ` -Adds optional integrations to the project. Available integrations: - -**`gof add stripe`** - Stripe payment integration: -- Payment domain service (checkout, portal, webhook handling) -- Subscriptions database migration -- Full subscription-based access control - -**`gof add r2`** - Cloudflare R2 file storage: -- File domain service (upload, download, delete via S3 API) -- Files database migration -- File management UI (if client enabled) - -**`gof add postmark`** - Postmark email integration: -- Email domain service (send emails with attachments) -- Emails database migration -- Email management UI (if client enabled) - -**See:** [Integration Marker System](#integration-marker-system) for how this works. - -## Integration Marker System - -The CLI uses a **marker-based integration system** for optional features like Stripe payments. This allows clean addition/removal of integrations without hardcoded string replacements. - -### How It Works - -1. **Reference repo (`gofast-app`) contains ALL integrations** with code wrapped in markers: - ```go - // GF_STRIPE_START - // ... stripe-specific code ... - // GF_STRIPE_END - ``` - -2. **`gofast.json` tracks enabled integrations:** - ```json - { - "projectName": "myapp", - "integrations": ["stripe"], - ... - } - ``` - -3. **On `gof init`:** - - Copy full template from `gofast-app` - - Read `integrations` from config (empty by default) - - Strip ALL marker blocks for integrations NOT in the list - - Result: clean project without optional features - -4. **On `gof add `:** - - Add integration name to `gofast.json` - - Copy relevant files from template (domain/, transport/, migrations) - - Copy files that have markers, strip only OTHER integrations' markers - - Result: integration code is present with its markers intact - -### Marker Naming Convention - -Each integration has its own marker prefix (singular form): -- Stripe: `GF_STRIPE_START` / `GF_STRIPE_END` -- Files: `GF_FILE_START` / `GF_FILE_END` -- Email: `GF_EMAIL_START` / `GF_EMAIL_END` - -### Files with Integration Markers - -Markers exist in these locations: -- `app/service-core/main.go` - imports, deps, route mounting -- `app/service-core/config/config.go` - integration-specific config fields -- `app/service-core/storage/query.sql` - integration queries -- `app/service-core/storage/migrations/` - integration tables -- `proto/v1/main.proto` - service definitions +### Test scenarios -For Stripe specifically: -- `app/service-core/domain/login/service.go` - `CheckUserAccess()` function +Each scenario should be tested fresh (`rm -rf demo` first). Always add client for full coverage. -### Benefits +**Model type variations:** +```bash +# All strings +TEST=true go run ../cmd/gof/... model article title:string body:string author:string +# All numbers +TEST=true go run ../cmd/gof/... model metric count:number value:number score:number +# All dates +TEST=true go run ../cmd/gof/... model event start:date end:date reminder:date +# All bools +TEST=true go run ../cmd/gof/... model settings dark_mode:bool notifications:bool auto_save:bool +# Mixed (classic) +TEST=true go run ../cmd/gof/... model post title:string views:number published_at:date is_active:bool +# Single column each type +TEST=true go run ../cmd/gof/... model tag name:string +TEST=true go run ../cmd/gof/... model counter value:number +TEST=true go run ../cmd/gof/... model deadline due:date +TEST=true go run ../cmd/gof/... model toggle enabled:bool +# Snake_case names +TEST=true go run ../cmd/gof/... model user_profile display_name:string bio:string +TEST=true go run ../cmd/gof/... model event_log event_type:string occurred_at:date +``` -- **Single source of truth:** All code lives in `gofast-app` with markers -- **Config-driven:** `gofast.json` determines what's included -- **Composable:** Multiple integrations can coexist (stripe + analytics) -- **Maintainable:** No hardcoded string replacements, just marker stripping -- **Future-proof:** Adding new integrations = adding new markers +**Integration combinations:** +```bash +# Individual +TEST=true go run ../cmd/gof/... add stripe +TEST=true go run ../cmd/gof/... add s3 +TEST=true go run ../cmd/gof/... add postmark +# All together (order matters - test different orders) +``` -### Implementation +**Client timing variations (critical - order bugs are common):** +```bash +# CLIENT AT START +gof client svelte|tanstack -> add integrations -> add models -```go -// Strip integrations not in the enabled list -func StripIntegrations(projectPath string, enabledIntegrations []string) error { - // Walk all files - // For each GF_*_START marker, extract integration name - // If integration not in enabledIntegrations, remove the block -} +# CLIENT IN MIDDLE +add some integrations/models -> gof client svelte|tanstack -> add more + +# CLIENT AT END +add all integrations/models -> gof client svelte|tanstack ``` -## How Code Generation Works +### Known bug patterns +- formatDate function included when model has no date columns +- Missing trailing comma in nav array when adding integrations +- Icon imported but nav entry not added +- Proto field names (snake_case vs camelCase in TypeScript) +- Permission flags not updated for new models + +--- + +## 2. Architecture [STABLE] + +### 2.1 Component map + +```mermaid +flowchart LR + subgraph CLI["gof CLI"] + CMD[Cobra Commands] + CFG[Config Manager] + INT[Integrations] + SVL[Svelte Generator] + E2E[E2E Generator] + AUTH[Auth TUI] + REPO[Repo Downloader] + end + + subgraph EXT["External"] + APP["../gofast-app
(template repo)"] + ADMIN["admin.gofast.live
(auth + download)"] + end + + CMD --> CFG + CMD --> INT + CMD --> SVL + CMD --> E2E + CMD --> AUTH + CMD --> REPO + REPO --> APP + REPO --> ADMIN + AUTH --> ADMIN +``` -### Skeleton-Based Generation +### 2.2 Code generation flow (`gof model`) + +```mermaid +sequenceDiagram + participant U as User + participant M as model.go + participant DB as model_db.go + participant SVC as model_service.go + participant TST as model_test_gen.go + participant E as e2e.go + participant S as client generators + + U->>M: gof model note title:string + M->>DB: generateProto() + generateSchema() + generateQueries() + M->>M: generateAuthAccessFlags() + wireCoreMain() + M->>SVC: generateServiceLayer() + generateTransportLayer() + M->>TST: generateServiceTestContent() + generateValidationTestContent() + M->>TST: generateTransportTestContent() + M->>E: GenerateClientE2ETest() + UpdateSeedDevUser() + M->>S: Generate client scaffolding for each enabled frontend +``` -1. **Source templates** live in skeleton directories: - - `app/service-core/domain/skeleton/` - Service layer - - `app/service-core/transport/skeleton/` - Transport layer - - `app/service-client/src/routes/(app)/models/skeletons/` - Svelte pages +### 2.3 Integration addition flow (`gof add`) + +```mermaid +flowchart TD + A[gof add stripe] --> B[Authenticate] + B --> C[Download template to tmpDir] + C --> D[Copy integration files
domain, transport, migrations] + D --> E[Merge markers into main.go, config.go] + E --> F[Strip OTHER integrations' markers] + F --> G[Add client pages for each enabled frontend] + G --> H[Update gofast.json] +``` -2. **Token replacement:** - - `skeleton` → model name (lowercase) - - `Skeleton` → Model name (capitalized) - - `skeletons` → pluralized - - `Skeletons` → pluralized + capitalized +--- -3. **Dynamic content generation** for tests and validation using markers like: - ```go - // GF_FIXTURES_START - // generated code - // GF_FIXTURES_END - ``` +## 3. File Tree (Curated) [STABLE] -### Wiring Injection +```text +cmd/gof/ +├── main.go # Entry point -> cmd.Execute() +├── build.sh # Cross-platform build (linux, darwin, windows) +├── cmd/ +│ ├── root.go # Root Cobra command +│ ├── init.go # gof init - project scaffolding +│ ├── model.go # gof model - CRUD generation orchestrator (560 lines) +│ ├── model_db.go # Proto, SQL migration, SQLC query generation +│ ├── model_service.go # Service + transport + validation generation +│ ├── model_test_gen.go # Test generation for service/transport/validation (542 lines) +│ ├── add.go # gof add - integration dispatcher +│ ├── client.go # gof client - frontend scaffolding +│ ├── infra.go # gof infra - Terraform/deployment files +│ ├── mon.go # gof mon - monitoring stack +│ ├── auth.go # gof auth - authentication +│ └── version.go # gof version +├── config/ +│ └── config.go # gofast.json management (v2.17.0) +├── repo/ +│ └── repo.go # Template repo download (admin.gofast.live) +├── integrations/ +│ ├── integrations.go # Core helpers: strip, copy, merge markers (548 lines) +│ ├── stripe.go # Stripe: strip, add, client +│ ├── s3.go # S3: strip, add, client +│ └── postmark.go # Postmark: strip, add, client +├── svelte/ +│ └── svelte.go # Svelte page generation per model +├── tanstack/ +│ └── tanstack.go # TanStack page generation per model +├── e2e/ +│ └── e2e.go # Playwright e2e test generation (294 lines) +└── auth/ + ├── auth.go # Auth flow runner + ├── bubble.go # Bubble Tea TUI components + └── config.go # Auth config (token storage) +``` -The CLI uses **marker-based injection** to wire new models into existing code: +Related files outside `cmd/gof/`: +- `go.mod` - Module: `github.com/gofast-live/gofast-cli/v2`, Go 1.25 +- `.github/workflows/lint.yml` - golangci-lint CI +- `web/` - Marketing website (SvelteKit on Cloudflare Workers) - not part of CLI +- `cmd/gofast/` - Legacy v1 CLI - ignore -**In `app/service-core/transport/server.go`:** -- `GF_TP_IMPORT_SERVICES_START/END` - Service imports -- `GF_TP_HANDLER_FIELDS_START/END` - Handler struct fields -- `GF_TP_ROUTES_START/END` - Route registration +--- -**In `app/service-core/main.go`:** -- `GF_MAIN_INIT_SERVICES_START/END` - Service instantiation -- `GF_MAIN_HANDLER_ARGS_START/END` - Handler constructor args +## 4. Core Contracts [STABLE] -**In `app/pkg/auth/auth.go`:** -- `GF_ACCESS_FLAGS_START/END` - Permission flags -- `GF_USER_ACCESS_START/END` - User access bitmask +### 4.1 CLI commands + +| Command | Purpose | +|---------|---------| +| `gof init ` | Scaffold new project | +| `gof model ` | Generate CRUD model with all layers | +| `gof client svelte` | Add Svelte frontend | +| `gof client tanstack` | Add TanStack frontend | +| `gof add stripe` | Add Stripe payments | +| `gof add s3` | Add S3 file storage | +| `gof add postmark` | Add Postmark email | +| `gof infra` | Add Terraform/deployment files | +| `gof mon` | Add monitoring stack (Grafana, Loki, Tempo, Prometheus) | +| `gof auth` | Authenticate with GoFast | +| `gof version` | Print version (v2.17.0) | -## Test Generation +**Prerequisites for `gof init`:** buf, sqlc, goose, docker, docker-compose -Tests are generated for Go only (client-side test generation dropped due to complexity). +### 4.2 Model generation contract -### Service Tests (`model_test_gen.go`) -Generates in `app/service-core/domain/{model}/{model}_test.go`: -- `makeQuery{Model}(i, userID)` - Factory with dynamic fields -- `makeInsert{Model}Params(userID)` - Insert params builder -- `makeCreate{Model}Req()` - Proto request fixtures -- Zero/invalid variants for validation testing +**Syntax:** `gof model ...` -### Transport Tests -Generates in `app/service-core/transport/{model}/route_test.go`: -- Request fixtures based on column types -- Validation test cases +**Model name rules:** +- Lowercase letters and underscores only (e.g., `user_profile`, `event_log`) +- Must be singular - plural names rejected with suggestion (e.g., `trucks` -> use `truck`) +- Minimum 2 columns required -### Validation Tests -Generates comprehensive table-driven tests: -- String: required, minlength -- Number: parse validation, >= 1 -- Date: format validation -- UUID: for edit operations +**Column name rules:** +- Lowercase letters, numbers, underscores (e.g., `view_count`, `is_active2`) +- Reserved names rejected: `id`, `user_id`, `created`, `updated` (auto-generated) +- Go keywords rejected: `type`, `func`, `var`, `package`, `map`, `chan`, etc. +- SQL keywords rejected: e.g., `start`, `end`, `select`, `order`, `group` -## Configuration +**Column types:** + +| Type | SQL | Proto | Go | Validation | +|------|-----|-------|-----|------------| +| string | text | string | string | required, minlength(3) | +| number | numeric | string | string | must parse float, >= 1 | +| date | timestamptz | string | time.Time | valid date format (RFC3339 or YYYY-MM-DD) | +| bool | boolean | bool | bool | none | + +**Generated artifacts per model:** +1. `proto/v1/{name}.proto` + appends to `main.proto` +2. `app/service-core/storage/migrations/{num}_create_{plural}.sql` +3. Appends queries to `app/service-core/storage/query.sql` +4. `app/service-core/domain/{pkgname}/` - service.go, validation.go, service_test.go, validation_test.go +5. `app/service-core/transport/{pkgname}/` - route.go, route_test.go +6. Wires into `app/service-core/main.go` (imports, deps, routes) +7. Updates `app/pkg/auth/auth.go` (permission flags) +8. Updates `scripts/seed_dev_user.sh` (permission bitmask) +9. `e2e/{plural}.test.ts` (if at least one client exists, since `gof client` owns the `e2e/` folder) +10. Client pages for each configured frontend (Svelte and/or TanStack) + +### 4.3 Naming conversions + +| Input (snake_case) | Output | Used for | +|---------------------|--------|----------| +| `user_profile` | `UserProfile` (PascalCase) | Go types, proto messages | +| `user_profile` | `userProfile` (camelCase) | Go variables, TS proto field access | +| `user_profile` | `userprofile` | Go package/directory names | +| `user_profile` | `user_profiles` (pluralized) | Table names, route paths, proto service | + +### 4.4 Configuration (`gofast.json`) -**`gofast.json`** - Project configuration file: ```json { - "projectName": "myapp", + "project_name": "myapp", "services": [ - {"name": "service-core", "port": 8080}, - {"name": "service-client", "port": 3000} + {"name": "core", "port": "4000"}, + {"name": "svelte", "port": "3000"}, + {"name": "tanstack", "port": "3000"} ], "models": [ { @@ -291,296 +448,334 @@ Generates comprehensive table-driven tests: ] } ], - "integrations": ["stripe"], - "infraPopulated": false + "integrations": ["stripe", "s3"], + "infra_populated": true, + "monitoring_populated": false } ``` -**Note:** `integrations` is empty by default. Each `gof add ` command adds to this list. +**Always use config checks, not file existence:** +- `config.HasService("svelte")` / `config.HasService("tanstack")`, not `os.Stat(...)` +- `config.HasIntegration("stripe")` to check integrations -## Demo Project +--- -The `demo` project contains **output generated by the CLI**. It gets recreated each time the CLI changes to verify what we're producing. +## 5. Marker System [STABLE] -Use it to: -1. See what the CLI currently generates -2. Inspect generated code for debugging -3. Regenerate after CLI changes to verify output +### Integration markers -### Regenerating Demo +Code in `../gofast-app` is wrapped with markers for optional features. On `gof init`, all integration markers are stripped. On `gof add`, the target integration's markers are kept and others stripped. -```bash -rm -rf demo -TEST=true go run ./cmd/gof/... init demo -``` +**Go files:** `// GF__START` / `// GF__END` +**SQL files:** `-- GF__START` / `-- GF__END` -The `TEST=true` env var makes the CLI copy from local `../gofast-app` instead of downloading from the network. +| Integration | Marker prefix | +|-------------|---------------| +| Stripe | `GF_STRIPE_` | +| S3/Files | `GF_FILE_` | +| Postmark/Email | `GF_EMAIL_` | -### Adding a Model +**Files with integration markers:** +- `app/service-core/main.go` - imports, deps, route mounting +- `app/service-core/config/config.go` - integration-specific config fields +- `app/service-core/storage/query.sql` - integration queries +- `app/service-core/storage/migrations/` - integration tables +- `proto/v1/main.proto` - service definitions +- `app/service-core/domain/login/service.go` - `CheckUserAccess()` (Stripe-specific) -From inside the demo directory: -```bash -cd demo -TEST=true go run ../cmd/gof/... model note title:string content:string views:number published:date active:bool -``` +### Model wiring markers (in generated project) -### Testing Generated Code +**In `app/service-core/main.go`:** +- `GF_MAIN_IMPORT_SERVICES_START/END` - Service imports +- `GF_MAIN_IMPORT_ROUTES_START/END` - Route imports +- `GF_MAIN_INIT_SERVICES_START/END` - Deps initialization +- `GF_MAIN_MOUNT_ROUTES_START/END` - Route mounting -**Important:** Tests require PostgreSQL to be running via Docker Compose. +**In `app/service-core/config/config.go`:** +- `GF_CONFIG_STRUCT_INSERT` - Struct fields +- `GF_CONFIG_INIT_INSERT` - Initialization code -```bash -# Start PostgreSQL first -cd demo && docker compose up postgres -d +**In `app/pkg/auth/auth.go`:** +- `GF_ACCESS_FLAGS_END` - Permission flag constants (insert before) +- `GF_USER_ACCESS_END` - UserAccess bitmask entries (insert before) -# Apply migrations for new models (init applies base migrations automatically) -goose -dir app/service-core/storage/migrations postgres "postgres://postgres:postgres@localhost:5432/postgres?sslmode=disable" up +### Test generation markers (in skeleton templates) -# Run tests -cd demo/app/service-core && go test -race ./... +| Marker | Location | Purpose | +|--------|----------|---------| +| `GF_TP_TEST_ENTITY_FIELDS_START/END` | service_test.go, route_test.go | Database entity field assignments | +| `GF_TP_TEST_CREATE_FIELDS_START/END` | service_test.go, route_test.go | Proto creation request fields | +| `GF_TP_TEST_EDIT_FIELDS_START/END` | service_test.go, route_test.go | Proto edit request fields | +| `GF_TP_TEST_INVALID_FIELDS_START/END` | service_test.go | Invalid proto fields for validation tests | +| `GF_TP_TEST_EDIT_ASSERT_START/END` | route_test.go | Assertions after edit operations | +| `GF_FIXTURES_START/END` | validation_test.go | Proto builder helper functions | +| `GF_MODEL_CONFIG_START/END` | skeletons.test.ts | E2E test model config object | -# Stop PostgreSQL when done -cd demo && docker compose stop -``` +### How marker replacement works -### Resetting the Database +1. Read skeleton template file +2. Check conditions (has date columns? has non-bool columns?) +3. Build field-specific content per column type +4. Replace each marker region using `replaceMarkerRegion()` - preserves indentation +5. Strip marker lines themselves (clean output) +6. Apply token replacement (`skeleton` -> model name, etc.) +7. Write generated file -If you have stale schema from previous test runs, reset the database: +--- -```bash -cd demo -docker compose down -v # Remove container AND volume (clears all data) -docker compose up postgres -d -sleep 2 # Wait for postgres to start -goose -dir app/service-core/storage/migrations postgres "postgres://postgres:postgres@localhost:5432/postgres?sslmode=disable" up -``` +## 6. Test Generation Details [STABLE] -## Makefile Commands +### Permission bitmask system -Generated projects use Makefile commands instead of shell scripts: +Uses bit-shifting (iota pattern) for per-model CRUD permissions: +- Bits 0-1: Plan flags (Free, Pro) +- Bits 2+: Model flags (4 per model: Get, Create, Edit, Remove) +- Final 8 bits: Integration flags (Stripe, S3/Files, Postmark/Email) -| Command | Description | -|---------|-------------| -| `make start` | Start services with Docker Compose | -| `make startc` | Start with client service | -| `make keys` | Generate public/private keys | -| `make sql` | Regenerate SQLC queries | -| `make gen` | Regenerate proto/buf code | -| `make migrate` | Apply database migrations | +`e2e/e2e.go:ComputeUserAccess()` recalculates the dev user permission value dynamically. -## Design Considerations +Client-side permission marker updates were removed. Svelte and TanStack no longer have frontend permission-marker injection; user access is edited through a simple input in the generated app template. -**Scalability of generation:** -- `gof model` can have ANY combination of columns -- Examples: 5 dates + 6 bools, all strings, mixed types, etc. -- Generation logic must handle all combinations cleanly +### Generated Go tests -**Test generation complexity:** -- Go tests are being generated (service, transport, validation) -- If test generation becomes too complicated to maintain, **drop it** -- Client-side tests already dropped for this reason -- Keep it simple - if it's too hard to generate reliably, it's not worth it +**Service tests** (`domain/{model}/service_test.go`): +- Test environment with real PostgreSQL (testutil) +- Tests per function: Unauthorized, Forbidden, Validation Error, Success +- Factory helpers: `createTestSkeleton`, `contextWithUser` -## Key Files for Development +**Validation tests** (`domain/{model}/validation_test.go`): +- Table-driven tests for both Create and Edit validation +- Per-column: string (required, minlength), number (parse, gte), date (format) +- Edit adds UUID validation cases +- If all columns are bool, validation error test is removed -| Purpose | File | -|---------|------| -| Model orchestration | `cmd/model.go` | -| Test generation | `cmd/model_test_gen.go` | -| Service generation | `cmd/model_service.go` | -| Transport generation | `cmd/model_transport.go` | -| Database/Proto | `cmd/model_db.go` | -| Svelte pages | `svelte/svelte.go` | -| Config management | `config/config.go` | -| Integration helpers | `integrations/integrations.go` | -| Stripe integration | `integrations/stripe.go` | -| R2 integration | `integrations/r2.go` | -| Postmark integration | `integrations/postmark.go` | +**Transport tests** (`transport/{model}/route_test.go`): +- HTTP server with mock auth interceptor +- Tests: Create, GetByID, Edit, Remove, GetAll (streaming) +- Uses ConnectRPC client for type-safe requests -## Gotchas +### Generated E2E tests (Playwright) -**Proto → TypeScript field naming:** -- Proto uses snake_case: `published_at` -- Generated TypeScript uses camelCase: `publishedAt` -- Svelte generation uses `toCamelCase()` for proto field access +`e2e/{plural}.test.ts`: +- Create with field validation +- List and verify created item +- Edit and assert updated values +- Delete and verify removal +- Stream get-all endpoint -**Use config checks, not file existence:** -- Use `config.IsSvelte()` not `os.Stat("app/service-client")` -- Use `config.HasIntegration("stripe")` to check integrations -- Config is the source of truth for what's enabled +### Smart conditional behavior -**Migration numbering:** -- Always calculate next number dynamically from existing migrations -- Never copy migrations with hardcoded numbers +- Removes `"time"` import if no date columns +- Removes Create validation error test if only bool columns +- Generates appropriate test values per column type +- Handles `formatDate` inclusion only when date columns present (Svelte) +- `gof client` copies the full `e2e/` folder as-is, including integration tests +- `gof add stripe|s3|postmark` does not modify `e2e/` +- TanStack formatting runs `npm ci`, regenerates `src/routeTree.gen.ts` with `@tanstack/router-generator`, then formats +- Svelte and TanStack are intentionally aligned at CLI time: no frontend build/typecheck is run by the CLI -## Dependencies +--- -- **Cobra:** CLI framework -- **Bubble Tea:** TUI for authentication -- **go-pluralize:** Pluralization of model names +## 7. Telemetry and Observability [STABLE] -## TEST FUCKING EVERYTHING :D +- No telemetry in the CLI itself +- Generated projects include OpenTelemetry tracing in service layer (via `ot.StartSpan`) +- `gof mon` adds Grafana/Loki/Tempo/Prometheus monitoring stack +- CI: golangci-lint on push/PR to main -Comprehensive testing of the CLI. Always include client - `run_tests.sh` covers Go build/lint/test + client lint/build + e2e tests. +--- -### How to Test +## 8. Current Work [VOLATILE] -```bash -# 1. Generate scenario (from gofast-cli root) -cd /home/mat/projects/gofast-cli -rm -rf demo -TEST=true go run ./cmd/gof/... init demo -cd demo -# ... add models/integrations/client ... +- Active initiatives: None documented +- Integration naming: `r2` was renamed to `s3` (more generic, works with any S3-compatible storage) -# 2. Run full test suite (requires secrets - ask user for them) -# Script is at scripts/run_tests.sh, run from demo directory -CONTEXT=gofast-rc \ -GITHUB_CLIENT_ID= \ -GITHUB_CLIENT_SECRET= \ -GOOGLE_CLIENT_ID= \ -GOOGLE_CLIENT_SECRET= \ -TWILIO_ACCOUNT_SID= \ -TWILIO_AUTH_TOKEN= \ -TWILIO_SERVICE_SID= \ -PAYMENT_PROVIDER=stripe \ -STRIPE_API_KEY= \ -STRIPE_PRICE_ID_BASIC= \ -STRIPE_PRICE_ID_PRO= \ -STRIPE_WEBHOOK_SECRET= \ -BUCKET_NAME=gofast \ -R2_ACCESS_KEY= \ -R2_SECRET_KEY= \ -R2_ENDPOINT= \ -EMAIL_FROM=admin@gofast.live \ -POSTMARK_API_KEY= \ -bash scripts/run_tests.sh +--- -# 3. Check e2e results -cat e2e/test-results/.last-run.json -# Should show: {"status": "passed", "failedTests": []} -``` +## 9. Future Work [VOLATILE] -### Test Scenarios +1. Additional client frameworks (Next.js, Vue - stubbed in client.go but not implemented) +2. Additional integrations (new marker prefixes) +3. Unit tests for the CLI itself (currently tested only via generated project verification) -Each scenario should be tested fresh (rm -rf demo first). Always add client for full coverage. +Known gaps: +- No automated CI pipeline that generates a demo project and runs its tests +- `scripts/run_tests.sh` referenced in CONTEXT but doesn't exist in CLI repo (lives in generated project) -**Model type variations:** -```bash -# All strings -TEST=true go run ../cmd/gof/... model article title:string body:string author:string +--- -# All numbers -TEST=true go run ../cmd/gof/... model metric count:number value:number score:number +## 10. Critical Invariants and Tricky Flows [STABLE] -# All dates -TEST=true go run ../cmd/gof/... model event start:date end:date reminder:date +### 10.1 Security/scoping invariants +- Authentication required for all commands that download templates (init, add, client, infra, mon) +- `TEST=true` bypasses auth and uses local `../gofast-app` (development only) +- Generated projects scope all queries by `user_id` - never expose other users' data -# All bools -TEST=true go run ../cmd/gof/... model settings dark_mode:bool notifications:bool auto_save:bool +### 10.2 Data integrity invariants +- Migration numbering must be calculated dynamically from existing files - never hardcoded +- `gofast.json` is the source of truth for enabled features, not file existence +- Permission bitmask must be recalculated whenever models are added -# Mixed (the classic) -TEST=true go run ../cmd/gof/... model post title:string views:number published_at:date is_active:bool +### 10.3 High-risk flows -# Single column each type -TEST=true go run ../cmd/gof/... model tag name:string -TEST=true go run ../cmd/gof/... model counter value:number -TEST=true go run ../cmd/gof/... model deadline due:date -TEST=true go run ../cmd/gof/... model toggle enabled:bool +**Integration addition order:** Adding integrations before/after client affects different code paths. The `gof add` command checks configured frontend services to decide whether to also add client-side pages. If client is added after integration, `gof client` must handle already-enabled integrations. -# Snake_case names -TEST=true go run ../cmd/gof/... model user_profile display_name:string bio:string -TEST=true go run ../cmd/gof/... model event_log event_type:string occurred_at:date -``` +**E2E ownership:** `gof client` is the single owner of the `e2e/` folder. `gof add` no longer adds/removes e2e files. -**Integration combinations:** -```bash -# Individual -TEST=true go run ../cmd/gof/... add stripe -TEST=true go run ../cmd/gof/... add r2 -TEST=true go run ../cmd/gof/... add postmark +**Marker merging:** When adding an integration, the CLI copies files with markers from the template and merges marker blocks into existing project files (main.go, config.go). It must strip markers for OTHER integrations that aren't enabled, while keeping the target integration's markers. -# All together -TEST=true go run ../cmd/gof/... add stripe -TEST=true go run ../cmd/gof/... add r2 -TEST=true go run ../cmd/gof/... add postmark +### 10.4 Easy-to-break gotchas +- Proto uses snake_case (`published_at`), TypeScript uses camelCase (`publishedAt`) - Svelte generation must use `toCamelCase()` +- Proto response fields stay camelCase for snake_case models (`userProfile`), so client generators must rewrite `.skeleton`-style proto field access before blanket `skeleton -> model_name` token replacement +- Go package names strip underscores: `user_profile` -> package `userprofile` +- Plural detection uses `go-pluralize` - some edge cases may not pluralize correctly +- Adding client generates pages for ALL existing models in config, not just new ones +- Adding TanStack client to a project with existing models requires route-tree regeneration after route scaffolding; the CLI now does this directly via TanStack's router generator instead of `vite build` +- `model_test_gen.go` has multiple instances of the same marker (e.g., `GF_TP_TEST_CREATE_FIELDS` appears 3+ times in service_test.go) - `replaceMarkerRegion` must handle all occurrences -# Order variations (client before/after integrations) -TEST=true go run ../cmd/gof/... client svelte -TEST=true go run ../cmd/gof/... add postmark # This was a bug! +--- + +## 11. Quick Reference APIs [STABLE] + +```go +// Config +config.ParseConfig() (*Config, error) +config.Initialize(projectName string) error +config.AddModel(name string, columns []Column) error +config.AddIntegration(name string) error +config.HasService(name string) bool +config.AddService(name, port string) error +config.HasIntegration(name string) bool +config.MarkInfraPopulated() error +config.MarkMonitoringPopulated() error + +// Integrations +integrations.StripIntegration(projectPath, integration string) error +integrations.RemoveMarkerBlocks(content, startMarker, endMarker string) string +integrations.CopyDir(src, dst string) error +integrations.CopyFile(src, dst string) error +integrations.GetNextMigrationNumber(migrationsDir string) (int, error) +integrations.MergeMainGoMarkers(srcMain, dstMain, integration string) error +integrations.MergeConfigMarkers(srcConfig, dstConfig, integration string) error +integrations.StripOtherIntegrations(projectPath string, keep string) error + +// Repo +repo.DownloadRepo(email, apiKey, projectName string) error + +// E2E +e2e.GenerateClientE2ETest(modelName string, columns []config.Column) error +e2e.ComputeUserAccess(numModels int) int +e2e.UpdateSeedDevUser() error + +// Svelte +svelte.GenerateSvelteScaffolding(modelName string, columns []config.Column) error + +// TanStack +tanstack.GenerateTanstackScaffolding(modelName string, columns []config.Column) error + +// Naming helpers (in model.go) +toCamelCase(s string) string // snake_case -> PascalCase +toGoPackageName(s string) string // snake_case -> lowercase (no underscores) +toGoVarName(s string) string // snake_case -> camelCase ``` -**Client timing variations:** +--- + +## 12. Runbook [VOLATILE] + +### 12.1 Local development + ```bash -# CLIENT AT START - client first, then models and integrations -rm -rf demo -TEST=true go run ./cmd/gof/... init demo -cd demo -TEST=true go run ../cmd/gof/... client svelte -TEST=true go run ../cmd/gof/... add r2 -TEST=true go run ../cmd/gof/... add postmark -TEST=true go run ../cmd/gof/... model note title:string content:string -TEST=true go run ../cmd/gof/... model event start:date end:date -TEST=true go run ../cmd/gof/... add stripe -./run_tests.sh +# Run any gof command locally +TEST=true go run ./cmd/gof/... -# CLIENT IN MIDDLE - some stuff, then client, then more stuff +# Full regeneration test rm -rf demo TEST=true go run ./cmd/gof/... init demo cd demo -TEST=true go run ../cmd/gof/... add r2 -TEST=true go run ../cmd/gof/... model note title:string content:string + +# Before `make gen`, edit buf.gen.yaml to use local plugins: +# version: v2 +# plugins: +# - local: protoc-gen-go +# out: app/gen +# opt: paths=source_relative +# - local: protoc-gen-connect-go +# out: app/gen +# opt: paths=source_relative +# - local: protoc-gen-es +# out: app/service-svelte/src/lib/gen +# opt: target=ts +# - local: protoc-gen-es +# out: app/service-tanstack/src/lib/gen +# opt: target=ts + TEST=true go run ../cmd/gof/... client svelte -TEST=true go run ../cmd/gof/... add postmark +# or: TEST=true go run ../cmd/gof/... client tanstack TEST=true go run ../cmd/gof/... add stripe -TEST=true go run ../cmd/gof/... model task description:string due:date priority:number done:bool -./run_tests.sh - -# CLIENT AT END - all models and integrations, then client last -rm -rf demo -TEST=true go run ./cmd/gof/... init demo -cd demo -TEST=true go run ../cmd/gof/... add r2 +TEST=true go run ../cmd/gof/... add s3 TEST=true go run ../cmd/gof/... add postmark -TEST=true go run ../cmd/gof/... add stripe -TEST=true go run ../cmd/gof/... model article title:string body:string -TEST=true go run ../cmd/gof/... model counter value:number -TEST=true go run ../cmd/gof/... model deadline due:date -TEST=true go run ../cmd/gof/... model toggle enabled:bool -TEST=true go run ../cmd/gof/... client svelte -./run_tests.sh +TEST=true go run ../cmd/gof/... model note title:string content:string views:number published:date active:bool + +# Verify generated code compiles and tests pass +docker compose up postgres -d +goose -dir app/service-core/storage/migrations postgres \ + "postgres://postgres:postgres@localhost:5432/postgres?sslmode=disable" up +cd app/service-core && go test -race ./... +``` + +### 12.2 Building release binaries + +```bash +cd cmd/gof +bash build.sh +# Produces: gof-linux-amd64, gof-darwin-amd64, gof-windows-amd64.exe ``` -### Known Bug Patterns +### 12.3 Debugging checklist +1. Check `gofast.json` - is the config state correct? +2. Check `../gofast-app` - does the template have the expected markers/content? +3. Check generated files in `demo/` - did token replacement work correctly? +4. For test failures: check migration numbering, permission bitmask, import statements +5. For integration issues: verify marker stripping left the right code in place + +### 12.4 Generated project Makefile commands -Things that have broken before: -- [ ] formatDate function included when model has no date columns -- [ ] Missing trailing comma in nav array when adding integrations -- [ ] Icon imported but nav entry not added -- [ ] Proto field names (snake_case vs camelCase in TypeScript) -- [ ] Permission flags not updated for new models +| Command | Description | +|---------|-------------| +| `make start` | Start services with Docker Compose | +| `make starts` | Start with the Svelte client | +| `make startt` | Start with the TanStack client | +| `make startm` | Start with monitoring stack | +| `make keys` | Generate public/private keys | +| `make sql` | Regenerate SQLC queries | +| `make gen` | Regenerate proto/buf code | +| `make migrate` | Apply database migrations | -## Infrastructure & Integrations +--- -**Approach: Static Configuration with Empty Defaults** +## 13. Infrastructure [STABLE] -Infrastructure files in `gofast-app` come with **all integrations pre-configured**. Integration-specific variables default to empty strings, so users don't need to configure GitHub secrets/vars for integrations they don't use. +**Approach: Static configuration with empty defaults.** -**Key Files:** +Infrastructure files in `gofast-app` come with all integrations pre-configured. Integration-specific variables default to empty strings, so unused integrations don't break anything. -- `infra/integrations.tf` - All integration-specific variables (with `default = ""`) and Kubernetes secrets in one place -- `infra/variables.tf` - Base infrastructure variables only -- `infra/secrets.tf` - Base secrets only (regcred, cron, e2e, r2-credentials for DB backups, google-oauth) +**Key infra files:** +- `infra/integrations.tf` - All integration variables (default = "") +- `infra/variables.tf` - Base infrastructure variables +- `infra/secrets.tf` - Base secrets (regcred, cron, e2e, google-oauth) - `infra/service-core.tf` - Deployment with all integration env vars pre-wired -**Why No Dynamic Injection:** +No dynamic injection for infra files - they're copied as-is from the template. -1. Empty env vars don't break the app (integration code is stripped if not enabled) -2. Simpler CLI - no marker-based injection for infra files -3. Users can manually delete unused env blocks from `service-core.tf` if desired -4. Single source of truth in `gofast-app` +--- -**User Workflow:** +## Dependencies [STABLE] -1. Run `gof infra` - copies all infra files as-is -2. Configure GitHub secrets/vars only for integrations they actually use -3. Unused integration variables remain empty - no harm done +| Package | Purpose | +|---------|---------| +| `github.com/spf13/cobra` v1.9.1 | CLI framework | +| `github.com/charmbracelet/bubbletea` v0.26.6 | Terminal UI for auth | +| `github.com/charmbracelet/bubbles` v0.18.0 | TUI components | +| `github.com/charmbracelet/lipgloss` v0.12.1 | Terminal styling | +| `github.com/gertd/go-pluralize` v0.2.1 | Model name pluralization | diff --git a/README.md b/README.md index 9bd35e2..6df5022 100644 --- a/README.md +++ b/README.md @@ -1,7 +1,7 @@ # GoFast CLI Building blocks for Go. -Generate production-ready Go apps with ConnectRPC, SvelteKit, and PostgreSQL. +Generate production-ready Go apps with ConnectRPC, SvelteKit or TanStack Start, and PostgreSQL. This repository contains two CLI tools: - **`gof`** - v2 CLI (current) @@ -19,7 +19,7 @@ The v2 CLI generates full-stack Go applications with: - Go backend with ConnectRPC transport - PostgreSQL database with SQLC - OAuth authentication (GitHub, Google, Phone) -- Optional Svelte frontend +- Optional Svelte or TanStack frontend - Optional integrations (Stripe, S3, Postmark) Visit [gofast.live](https://gofast.live) for more details and features. @@ -73,6 +73,7 @@ make start | `gof init ` | Create new project | | `gof model [cols...]` | Generate CRUD model | | `gof client svelte` | Add Svelte frontend | +| `gof client tanstack` | Add TanStack frontend | | `gof add stripe` | Add Stripe payments | | `gof add s3` | Add S3 file storage | | `gof add postmark` | Add Postmark email | @@ -105,6 +106,7 @@ gof model comment content:string # Add frontend gof client svelte +# or: gof client tanstack # Add payments gof add stripe @@ -115,11 +117,11 @@ gof infra # Generate code make sql && make gen && make format && make migrate -# Run with client -make startc +# Run with Svelte +make starts -# Run with client + monitoring (Grafana, Alloy, Loki, Tempo, Prometheus) -make startcm +# Run with TanStack +make startt ``` ### Generated Project Commands @@ -127,9 +129,11 @@ make startcm | Command | Description | |---------|-------------| | `make start` | Start backend services | -| `make startc` | Start with Svelte client | +| `make starts` | Start with Svelte client | +| `make startt` | Start with TanStack client | | `make startm` | Start with monitoring (Grafana, Alloy, Loki, Tempo, Prometheus) | -| `make startcm` | Start with client + monitoring | +| `make startsm` | Start with Svelte client + monitoring | +| `make starttm` | Start with TanStack client + monitoring | | `make sql` | Regenerate SQLC queries | | `make gen` | Regenerate proto code | | `make migrate` | Apply database migrations | diff --git a/cmd/gof/auth/config.go b/cmd/gof/auth/config.go index 962d0b9..3e7405f 100644 --- a/cmd/gof/auth/config.go +++ b/cmd/gof/auth/config.go @@ -42,6 +42,10 @@ func checkConfig(email string, apiKey string) tea.Cmd { } func CheckAuthentication() (string, string, error) { + if os.Getenv("TEST") == "true" { + return "test@gofast.local", "test-api-key", nil + } + path, err := os.UserConfigDir() if err != nil { return "", "", err diff --git a/cmd/gof/clients/clients.go b/cmd/gof/clients/clients.go new file mode 100644 index 0000000..5da0cfa --- /dev/null +++ b/cmd/gof/clients/clients.go @@ -0,0 +1,73 @@ +package clients + +import "github.com/gofast-live/gofast-cli/v2/cmd/gof/config" + +const ( + Svelte = "svelte" + Tanstack = "tanstack" +) + +type Spec struct { + Name string + DisplayName string + ServiceDir string + ComposeFile string + Port string + PaymentsRouteSubpath string + FilesRouteSubpath string + EmailsRouteSubpath string +} + +var specs = map[string]Spec{ + Svelte: { + Name: Svelte, + DisplayName: "Svelte", + ServiceDir: "service-svelte", + ComposeFile: "docker-compose.svelte.yml", + Port: "3000", + PaymentsRouteSubpath: "src/routes/(app)/payments", + FilesRouteSubpath: "src/routes/(app)/files", + EmailsRouteSubpath: "src/routes/(app)/emails", + }, + Tanstack: { + Name: Tanstack, + DisplayName: "TanStack", + ServiceDir: "service-tanstack", + ComposeFile: "docker-compose.tanstack.yml", + Port: "3000", + PaymentsRouteSubpath: "src/routes/_layout/payments", + FilesRouteSubpath: "src/routes/_layout/files.tsx", + EmailsRouteSubpath: "src/routes/_layout/emails.tsx", + }, +} + +func SpecFor(name string) (Spec, bool) { + spec, ok := specs[name] + return spec, ok +} + +func All() []Spec { + return []Spec{ + specs[Svelte], + specs[Tanstack], + } +} + +func Enabled(cfg *config.Config) []Spec { + if cfg == nil { + return nil + } + + var enabled []Spec + for _, service := range cfg.Services { + spec, ok := SpecFor(service.Name) + if ok { + enabled = append(enabled, spec) + } + } + return enabled +} + +func HasAny(cfg *config.Config) bool { + return len(Enabled(cfg)) > 0 +} diff --git a/cmd/gof/cmd/add.go b/cmd/gof/cmd/add.go index de2a545..d499aee 100644 --- a/cmd/gof/cmd/add.go +++ b/cmd/gof/cmd/add.go @@ -4,6 +4,7 @@ import ( "os/exec" "github.com/gofast-live/gofast-cli/v2/cmd/gof/auth" + "github.com/gofast-live/gofast-cli/v2/cmd/gof/clients" "github.com/gofast-live/gofast-cli/v2/cmd/gof/config" "github.com/gofast-live/gofast-cli/v2/cmd/gof/integrations" "github.com/spf13/cobra" @@ -16,6 +17,21 @@ func init() { addCmd.AddCommand(addPostmarkCmd) } +func formatEnabledClients() error { + cfg, err := config.ParseConfig() + if err != nil { + return err + } + + for _, client := range clients.Enabled(cfg) { + if err := formatClientProject(client.Name); err != nil { + return err + } + } + + return nil +} + var addCmd = &cobra.Command{ Use: "add", Short: "Add optional features to the project", @@ -72,12 +88,16 @@ After running this command: cmd.Printf("Error updating config: %v\n", err) return } + if err := formatEnabledClients(); err != nil { + cmd.Printf("Error formatting client after Stripe add: %v\n", err) + return + } cmd.Println("") cmd.Println(config.SuccessStyle.Render("Stripe integration added successfully!")) cmd.Println("") - if config.IsSvelte() { - cmd.Println("Add this route to your navigation:") + if cfg, err := config.ParseConfig(); err == nil && clients.HasAny(cfg) { + cmd.Println("Add this route to your client navigation:") cmd.Printf(" %s\n", config.SuccessStyle.Render("/payments")) cmd.Println("") } @@ -148,12 +168,16 @@ After running this command: cmd.Printf("Error updating config: %v\n", err) return } + if err := formatEnabledClients(); err != nil { + cmd.Printf("Error formatting client after S3 add: %v\n", err) + return + } cmd.Println("") cmd.Println(config.SuccessStyle.Render("S3 integration added successfully!")) cmd.Println("") - if config.IsSvelte() { - cmd.Println("Add this route to your navigation:") + if cfg, err := config.ParseConfig(); err == nil && clients.HasAny(cfg) { + cmd.Println("Add this route to your client navigation:") cmd.Printf(" %s\n", config.SuccessStyle.Render("/files")) cmd.Println("") } @@ -223,12 +247,16 @@ After running this command: cmd.Printf("Error updating config: %v\n", err) return } + if err := formatEnabledClients(); err != nil { + cmd.Printf("Error formatting client after Postmark add: %v\n", err) + return + } cmd.Println("") cmd.Println(config.SuccessStyle.Render("Postmark integration added successfully!")) cmd.Println("") - if config.IsSvelte() { - cmd.Println("Add this route to your navigation:") + if cfg, err := config.ParseConfig(); err == nil && clients.HasAny(cfg) { + cmd.Println("Add this route to your client navigation:") cmd.Printf(" %s\n", config.SuccessStyle.Render("/emails")) cmd.Println("") } diff --git a/cmd/gof/cmd/client.go b/cmd/gof/cmd/client.go index 12269e8..0b57e3d 100644 --- a/cmd/gof/cmd/client.go +++ b/cmd/gof/cmd/client.go @@ -1,7 +1,6 @@ package cmd import ( - "encoding/json" "fmt" "io" "os" @@ -9,10 +8,13 @@ import ( "strings" "github.com/gofast-live/gofast-cli/v2/cmd/gof/auth" + "github.com/gofast-live/gofast-cli/v2/cmd/gof/clients" "github.com/gofast-live/gofast-cli/v2/cmd/gof/config" + "github.com/gofast-live/gofast-cli/v2/cmd/gof/e2e" "github.com/gofast-live/gofast-cli/v2/cmd/gof/integrations" "github.com/gofast-live/gofast-cli/v2/cmd/gof/repo" "github.com/gofast-live/gofast-cli/v2/cmd/gof/svelte" + "github.com/gofast-live/gofast-cli/v2/cmd/gof/tanstack" "github.com/spf13/cobra" ) @@ -23,7 +25,7 @@ func init() { var clientCmd = &cobra.Command{ Use: "client [client_type]", Short: "Create a new client service", - Long: "Create a new client service (e.g., Svelte/Next/Vue) connected to your Go service", + Long: "Create a new client service connected to your Go service", Args: cobra.ExactArgs(1), Run: func(cmd *cobra.Command, args []string) { email, apiKey, err := auth.CheckAuthentication() @@ -31,7 +33,7 @@ var clientCmd = &cobra.Command{ cmd.Printf("Authentication failed: %v.\n", err) return } - // Ensure we are inside a valid gofast project (has gofast.json) + con, err := config.ParseConfig() if err != nil { cmd.Printf("%v\n", err) @@ -39,44 +41,17 @@ var clientCmd = &cobra.Command{ } serviceType := args[0] - validServiceTypes := map[string]bool{ - "svelte": true, - "next": true, - "vue": true, - } - if !validServiceTypes[serviceType] { - cmd.Println("Invalid service type. Valid types are: svelte, next, vue") - return - } - if serviceType == "vue" || serviceType == "next" { - cmd.Println("Vue and Next clients are not implemented yet. Please use 'svelte' for now.") + spec, ok := clients.SpecFor(serviceType) + if !ok { + cmd.Println("Invalid service type. Valid types are: svelte, tanstack") return } - // Ensure gofast.json includes the svelte service on port 3000 - if serviceType == "svelte" { - hasSvelte := false - for _, svc := range con.Services { - if svc.Name == "svelte" { - hasSvelte = true - break - } - } - if !hasSvelte { - con.Services = append(con.Services, config.Service{Name: "svelte", Port: "3000"}) - data, jerr := json.MarshalIndent(con, "", " ") - if jerr != nil { - cmd.Printf("Error serializing config with svelte service: %v\n", jerr) - } else if werr := os.WriteFile(config.ConfigFileName, data, 0644); werr != nil { - cmd.Printf("Error writing %s: %v\n", config.ConfigFileName, werr) - } - } else { - cmd.Println("Svelte service already exists.") - return - } + if config.HasService(spec.Name) { + cmd.Printf("%s service already exists.\n", spec.DisplayName) + return } - // Prepare a temp workspace and download the template repo into it tmpDir, err := os.MkdirTemp("", "gofast-app-*") if err != nil { cmd.Printf("Error creating temp directory: %v\n", err) @@ -84,7 +59,6 @@ var clientCmd = &cobra.Command{ } defer func() { _ = os.RemoveAll(tmpDir) }() - // Work within the temp directory cwd, err := os.Getwd() if err != nil { cmd.Printf("Error getting working directory: %v\n", err) @@ -96,44 +70,19 @@ var clientCmd = &cobra.Command{ } defer func() { _ = os.Chdir(cwd) }() - // Download the full repo into the temp dir (no removal of client here) srcRepoName := "gofast-app-src" if err := repo.DownloadRepo(email, apiKey, srcRepoName); err != nil { cmd.Printf("Error downloading repository to temp directory: %v\n", err) return } - // Ensure the client compose file is present and matches the project name. - projClientCompose := filepath.Join(cwd, "docker-compose.client.yml") - srcClientCompose := filepath.Join(tmpDir, srcRepoName, "docker-compose.client.yml") - if err := copyFile(srcClientCompose, projClientCompose); err != nil { - cmd.Printf("Error copying %s: %v\n", projClientCompose, err) - return - } - clientComposeContent, err := os.ReadFile(projClientCompose) - if err != nil { - cmd.Printf("Error reading %s: %v\n", projClientCompose, err) + if err := copyComposeFile(tmpDir, srcRepoName, cwd, con.ProjectName, spec.ComposeFile); err != nil { + cmd.Printf("Error copying %s: %v\n", spec.ComposeFile, err) return } - newClientComposeContent := strings.ReplaceAll(string(clientComposeContent), "gofast", con.ProjectName) - if err := os.WriteFile(projClientCompose, []byte(newClientComposeContent), 0644); err != nil { - cmd.Printf("Error writing to %s: %v\n", projClientCompose, err) - return - } - - // Determine source client folder based on requested type - var srcClientPath string - switch serviceType { - case "svelte": - srcClientPath = filepath.Join(tmpDir, srcRepoName, "app", "service-client") - case "next": - srcClientPath = filepath.Join(tmpDir, srcRepoName, "app", "service-next") - case "vue": - srcClientPath = filepath.Join(tmpDir, srcRepoName, "app", "service-vue") - } - // Destination is always app/service-client inside the project - dstClientPath := filepath.Join(cwd, "app", "service-client") + srcClientPath := filepath.Join(tmpDir, srcRepoName, "app", spec.ServiceDir) + dstClientPath := filepath.Join(cwd, "app", spec.ServiceDir) if _, err := os.Stat(srcClientPath); err != nil { cmd.Printf("Source client folder not found in template: %v\n", err) @@ -145,13 +94,11 @@ var clientCmd = &cobra.Command{ return } } - // Ensure destination parent exists if err := os.MkdirAll(filepath.Dir(dstClientPath), 0o755); err != nil { cmd.Printf("Error creating destination directory: %v\n", err) return } - // Try moving first. If cross-device rename fails, fall back to copy. if err := os.Rename(srcClientPath, dstClientPath); err != nil { if copyErr := copyDir(srcClientPath, dstClientPath); copyErr != nil { cmd.Printf("Error copying client folder: %v (original move error: %v)\n", copyErr, err) @@ -159,33 +106,30 @@ var clientCmd = &cobra.Command{ } } - // Strip integration-related content from client if not enabled - // Note: Use con.Integrations directly since we're in tmpDir and can't read gofast.json enabledIntegrations := make(map[string]bool) for _, integration := range con.Integrations { enabledIntegrations[integration] = true } if !enabledIntegrations["stripe"] { - if err := integrations.StripeStripClient(dstClientPath); err != nil { + if err := integrations.StripeStripClient(spec.Name, dstClientPath); err != nil { cmd.Printf("Error stripping stripe from client: %v\n", err) return } } if !enabledIntegrations["s3"] { - if err := integrations.S3StripClient(dstClientPath); err != nil { + if err := integrations.S3StripClient(spec.Name, dstClientPath); err != nil { cmd.Printf("Error stripping s3 from client: %v\n", err) return } } if !enabledIntegrations["postmark"] { - if err := integrations.PostmarkStripClient(dstClientPath); err != nil { + if err := integrations.PostmarkStripClient(spec.Name, dstClientPath); err != nil { cmd.Printf("Error stripping postmark from client: %v\n", err) return } } - // Copy e2e folder and strip integration-specific tests srcE2E := filepath.Join(tmpDir, srcRepoName, "e2e") dstE2E := filepath.Join(cwd, "e2e") if _, err := os.Stat(srcE2E); err == nil { @@ -193,35 +137,15 @@ var clientCmd = &cobra.Command{ cmd.Printf("Error copying e2e folder: %v\n", err) return } - // Strip integration-specific e2e tests - if !enabledIntegrations["stripe"] { - if err := integrations.StripeStripE2E(dstE2E); err != nil { - cmd.Printf("Error stripping stripe from e2e: %v\n", err) - return - } - } - if !enabledIntegrations["s3"] { - if err := integrations.S3StripE2E(dstE2E); err != nil { - cmd.Printf("Error stripping s3 from e2e: %v\n", err) - return - } - } - if !enabledIntegrations["postmark"] { - if err := integrations.PostmarkStripE2E(dstE2E); err != nil { - cmd.Printf("Error stripping postmark from e2e: %v\n", err) - return - } - } } - // Change back to the original directory before generating svelte files. if err := os.Chdir(cwd); err != nil { cmd.Printf("Error changing back to original directory: %v\n", err) return } cmd.Println("") - cmd.Println("Adding Svelte client service...") + cmd.Printf("Adding %s client service...\n", spec.DisplayName) for _, m := range con.Models { if m.Name == "skeleton" { @@ -230,36 +154,42 @@ var clientCmd = &cobra.Command{ cmd.Printf("Generating pages for '%s'...\n", m.Name) - svelteColumns := make([]svelte.Column, len(m.Columns)) + e2eColumns := make([]e2e.Column, len(m.Columns)) for i, col := range m.Columns { - svelteColumns[i] = svelte.Column{ - Name: col.Name, - Type: col.Type, - } + e2eColumns[i] = e2e.Column{Name: col.Name, Type: col.Type} + } + if err := e2e.GenerateClientE2ETest(m.Name, e2eColumns); err != nil { + cmd.Printf("Error generating e2e test for '%s': %v\n", m.Name, err) + return } - if err := svelte.GenerateSvelteScaffolding(m.Name, svelteColumns); err != nil { + if err := generateClientScaffolding(spec.Name, m.Name, m.Columns); err != nil { cmd.Printf("Error generating '%s' client pages: %v\n", m.Name, err) + return } + } - // Add permissions to user management page - if err := svelte.UpdateUserPermissions(m.Name); err != nil { - cmd.Printf("Error updating user permissions for '%s': %v\n", m.Name, err) - } + if err := formatClientProject(spec.Name); err != nil { + cmd.Printf("Error formatting %s client: %v\n", spec.DisplayName, err) + return + } + + if err := config.AddService(spec.Name, spec.Port); err != nil { + cmd.Printf("Error updating %s: %v\n", config.ConfigFileName, err) + return } cmd.Println("") - cmd.Println(config.SuccessStyle.Render("Svelte client added successfully!")) + cmd.Println(config.SuccessStyle.Render(spec.DisplayName + " client added successfully!")) cmd.Println("") - // Print routes to add to navigation var routes []string for _, m := range con.Models { - if m.Name != "skeleton" { - routes = append(routes, svelte.GetModelPath(m.Name)) + if m.Name == "skeleton" { + continue } + routes = append(routes, clientModelPath(spec.Name, m.Name)) } - // Add integration routes if enabled if enabledIntegrations["stripe"] { routes = append(routes, "/payments") } @@ -279,11 +209,69 @@ var clientCmd = &cobra.Command{ cmd.Println("Next steps:") cmd.Printf(" 1. Run %s to regenerate proto code\n", config.SuccessStyle.Render("'make gen'")) - cmd.Printf(" 2. Run %s to launch your app with your new client service\n", config.SuccessStyle.Render("'make startc'")) + switch spec.Name { + case clients.Svelte: + cmd.Printf(" 2. Run %s to launch your app with the Svelte client\n", config.SuccessStyle.Render("'make starts'")) + case clients.Tanstack: + cmd.Printf(" 2. Run %s to launch your app with the TanStack client\n", config.SuccessStyle.Render("'make startt'")) + } cmd.Println("") }, } +func generateClientScaffolding(clientType, modelName string, columns []config.Column) error { + switch clientType { + case clients.Svelte: + svelteColumns := make([]svelte.Column, len(columns)) + for i, col := range columns { + svelteColumns[i] = svelte.Column{Name: col.Name, Type: col.Type} + } + return svelte.GenerateSvelteScaffolding(modelName, svelteColumns) + case clients.Tanstack: + tanstackColumns := make([]tanstack.Column, len(columns)) + for i, col := range columns { + tanstackColumns[i] = tanstack.Column{Name: col.Name, Type: col.Type} + } + return tanstack.GenerateTanstackScaffolding(modelName, tanstackColumns) + default: + return fmt.Errorf("unsupported client type %q", clientType) + } +} + +func formatClientProject(clientType string) error { + switch clientType { + case clients.Svelte: + return svelte.FormatProject() + case clients.Tanstack: + return tanstack.FormatProject() + default: + return fmt.Errorf("unsupported client type %q", clientType) + } +} + +func clientModelPath(clientType, modelName string) string { + switch clientType { + case clients.Tanstack: + return tanstack.GetModelPath(modelName) + default: + return svelte.GetModelPath(modelName) + } +} + +func copyComposeFile(tmpDir, srcRepoName, cwd, projectName, composeFile string) error { + projCompose := filepath.Join(cwd, composeFile) + srcCompose := filepath.Join(tmpDir, srcRepoName, composeFile) + if err := copyFile(srcCompose, projCompose); err != nil { + return err + } + content, err := os.ReadFile(projCompose) + if err != nil { + return err + } + updated := strings.ReplaceAll(string(content), "gofast", projectName) + return os.WriteFile(projCompose, []byte(updated), 0o644) +} + // copyDir copies a directory recursively from src to dst. func copyDir(src string, dst string) error { fi, err := os.Stat(src) diff --git a/cmd/gof/cmd/init.go b/cmd/gof/cmd/init.go index e39f439..e3c7e95 100644 --- a/cmd/gof/cmd/init.go +++ b/cmd/gof/cmd/init.go @@ -7,6 +7,7 @@ import ( "strings" "github.com/gofast-live/gofast-cli/v2/cmd/gof/auth" + "github.com/gofast-live/gofast-cli/v2/cmd/gof/clients" "github.com/gofast-live/gofast-cli/v2/cmd/gof/config" "github.com/gofast-live/gofast-cli/v2/cmd/gof/integrations" "github.com/gofast-live/gofast-cli/v2/cmd/gof/repo" @@ -80,8 +81,10 @@ var initCmd = &cobra.Command{ cmd.Printf("Warning: could not remove template git metadata: %v\n", err) } // remove template-only folders and files - if err := os.RemoveAll(filepath.Join(projectName, "app", "service-client")); err != nil { - cmd.Printf("Warning: could not remove initial client folder: %v\n", err) + for _, client := range clients.All() { + if err := os.RemoveAll(filepath.Join(projectName, "app", client.ServiceDir)); err != nil { + cmd.Printf("Warning: could not remove initial %s client folder: %v\n", client.DisplayName, err) + } } if err := os.RemoveAll(filepath.Join(projectName, "monitoring")); err != nil { cmd.Printf("Warning: could not remove monitoring folder: %v\n", err) @@ -92,8 +95,10 @@ var initCmd = &cobra.Command{ if err := os.Remove(filepath.Join(projectName, "docker-compose.monitoring.yml")); err != nil && !os.IsNotExist(err) { cmd.Printf("Warning: could not remove monitoring docker compose file: %v\n", err) } - if err := os.Remove(filepath.Join(projectName, "docker-compose.client.yml")); err != nil && !os.IsNotExist(err) { - cmd.Printf("Warning: could not remove client docker compose file: %v\n", err) + for _, client := range clients.All() { + if err := os.Remove(filepath.Join(projectName, client.ComposeFile)); err != nil && !os.IsNotExist(err) { + cmd.Printf("Warning: could not remove %s docker compose file: %v\n", client.DisplayName, err) + } } if err := os.RemoveAll(filepath.Join(projectName, "e2e")); err != nil { cmd.Printf("Warning: could not remove e2e folder: %v\n", err) diff --git a/cmd/gof/cmd/model.go b/cmd/gof/cmd/model.go index 3a2f29f..b5f851c 100644 --- a/cmd/gof/cmd/model.go +++ b/cmd/gof/cmd/model.go @@ -8,9 +8,9 @@ import ( "github.com/gertd/go-pluralize" "github.com/gofast-live/gofast-cli/v2/cmd/gof/auth" + "github.com/gofast-live/gofast-cli/v2/cmd/gof/clients" "github.com/gofast-live/gofast-cli/v2/cmd/gof/config" "github.com/gofast-live/gofast-cli/v2/cmd/gof/e2e" - "github.com/gofast-live/gofast-cli/v2/cmd/gof/svelte" "github.com/spf13/cobra" ) @@ -30,6 +30,22 @@ var typeMap = map[string]string{ "bool": "boolean", } +var sqlKeywords = map[string]bool{ + "all": true, "and": true, "any": true, "as": true, "asc": true, + "between": true, "by": true, "case": true, "check": true, "column": true, + "create": true, "default": true, "delete": true, "desc": true, "distinct": true, + "drop": true, "else": true, "end": true, "exists": true, "false": true, + "from": true, "full": true, "group": true, "having": true, "in": true, + "index": true, "inner": true, "insert": true, "into": true, "is": true, + "join": true, "key": true, "left": true, "like": true, "limit": true, + "not": true, "null": true, "offset": true, "on": true, "or": true, + "order": true, "outer": true, "primary": true, "references": true, "returning": true, + "right": true, "select": true, "set": true, "start": true, "table": true, + "then": true, "to": true, "true": true, "union": true, "unique": true, + "update": true, "user": true, "using": true, "values": true, "view": true, + "when": true, "where": true, "with": true, +} + var modelCmd = &cobra.Command{ Use: "model [model_name] [columns...]", Short: "Create a new model", @@ -55,7 +71,8 @@ Example: } // Ensure we are inside a valid gofast project (has gofast.json) - if _, err := config.ParseConfig(); err != nil { + con, err := config.ParseConfig() + if err != nil { cmd.Printf("%v\n", err) return } @@ -134,6 +151,12 @@ Example: return } + // Check for SQL keywords that would break generated migrations/queries + if sqlKeywords[colName] { + cmd.Printf("Error: Column name '%s' is a reserved SQL keyword. Choose a different name.\n", colName) + return + } + colType := strings.ToLower(parts[1]) if !validTypes[colType] { cmd.Printf("Error: Invalid type '%s' for column '%s'.\n", parts[1], colName) @@ -230,24 +253,30 @@ Example: return } - if config.IsSvelte() { - svelteColumns := make([]svelte.Column, len(columns)) + enabledClients := clients.Enabled(con) + if len(enabledClients) > 0 { + e2eColumns := make([]e2e.Column, len(columns)) for i, col := range columns { - svelteColumns[i] = svelte.Column{ - Name: col.Name, - Type: col.Type, - } + e2eColumns[i] = e2e.Column{Name: col.Name, Type: col.Type} } - err = svelte.GenerateSvelteScaffolding(modelName, svelteColumns) + err = e2e.GenerateClientE2ETest(modelName, e2eColumns) if err != nil { - cmd.Printf("Error generating Svelte client pages: %v.\n", err) + cmd.Printf("Error generating client e2e test: %v.\n", err) return } - // Update user management page with new model permissions - err = svelte.UpdateUserPermissions(modelName) - if err != nil { - cmd.Printf("Error updating user permissions page: %v.\n", err) - return + for _, client := range enabledClients { + err = generateClientScaffolding(client.Name, modelName, configColumns) + if err != nil { + cmd.Printf("Error generating %s client pages: %v.\n", client.DisplayName, err) + return + } + } + for _, client := range enabledClients { + err = formatClientProject(client.Name) + if err != nil { + cmd.Printf("Error formatting %s client: %v.\n", client.DisplayName, err) + return + } } } @@ -266,8 +295,15 @@ Example: cmd.Printf(" - Queries: %s\n", config.SuccessStyle.Render("app/service-core/storage/query.sql")) cmd.Printf(" - Service: %s\n", config.SuccessStyle.Render("app/service-core/domain/"+goPackageName)) cmd.Printf(" - Transport: %s\n", config.SuccessStyle.Render("app/service-core/transport/"+goPackageName)) - if config.IsSvelte() { - cmd.Printf(" - Client: %s\n", config.SuccessStyle.Render("app/service-client/src/routes/(app)/models/"+pluralizeClient.Plural(modelName))) + for _, client := range enabledClients { + clientPath := "app/" + client.ServiceDir + "/src/routes" + switch client.Name { + case clients.Svelte: + clientPath += "/(app)/models/" + pluralizeClient.Plural(modelName) + case clients.Tanstack: + clientPath += "/_layout/models/" + pluralizeClient.Plural(modelName) + } + cmd.Printf(" - %s: %s\n", client.DisplayName+" client", config.SuccessStyle.Render(clientPath)) } cmd.Println("") cmd.Println("Next steps:") @@ -276,13 +312,9 @@ Example: cmd.Printf(" 3. Run %s to format generated code\n", config.SuccessStyle.Render("'make format'")) cmd.Printf(" 4. Run %s to apply migrations\n", config.SuccessStyle.Render("'make migrate'")) cmd.Println("") - if config.IsSvelte() { - cmd.Printf("If you have existing users, update their permissions at %s\n", config.SuccessStyle.Render("/users")) - cmd.Println("") - } - if config.IsSvelte() { + if len(enabledClients) > 0 { cmd.Println("Add this route to your navigation:") - cmd.Printf(" %s\n", config.SuccessStyle.Render(svelte.GetModelPath(modelName))) + cmd.Printf(" %s\n", config.SuccessStyle.Render(clientModelPath(enabledClients[0].Name, modelName))) cmd.Println("") } }, diff --git a/cmd/gof/cmd/model_db.go b/cmd/gof/cmd/model_db.go index 97975d7..6e3c872 100644 --- a/cmd/gof/cmd/model_db.go +++ b/cmd/gof/cmd/model_db.go @@ -44,7 +44,7 @@ func generateProto(modelName string, columns []Column) error { if !ok { ptype = "string" } - b.WriteString(fmt.Sprintf(" %s %s = %d;\n", ptype, col.Name, fieldNo)) + fmt.Fprintf(&b, " %s %s = %d;\n", ptype, col.Name, fieldNo) fieldNo++ } b.WriteString("}\n") @@ -86,53 +86,53 @@ func generateProto(modelName string, columns []Column) error { // Messages pluralCap := capitalize(pluralModelName) // GetAll - sb.WriteString(fmt.Sprintf("// GetAll%s\n", pluralCap)) - sb.WriteString(fmt.Sprintf("message GetAll%sRequest {}\n", pluralCap)) - sb.WriteString(fmt.Sprintf("message GetAll%sResponse {\n", pluralCap)) - sb.WriteString(fmt.Sprintf(" %s %s = 1;\n", capitalizedModelName, modelName)) + fmt.Fprintf(&sb, "// GetAll%s\n", pluralCap) + fmt.Fprintf(&sb, "message GetAll%sRequest {}\n", pluralCap) + fmt.Fprintf(&sb, "message GetAll%sResponse {\n", pluralCap) + fmt.Fprintf(&sb, " %s %s = 1;\n", capitalizedModelName, modelName) sb.WriteString("}\n\n") // GetByID - sb.WriteString(fmt.Sprintf("// Get%sByID\n", capitalizedModelName)) - sb.WriteString(fmt.Sprintf("message Get%sByIDRequest {\n", capitalizedModelName)) + fmt.Fprintf(&sb, "// Get%sByID\n", capitalizedModelName) + fmt.Fprintf(&sb, "message Get%sByIDRequest {\n", capitalizedModelName) sb.WriteString(" string id = 1;\n") sb.WriteString("}\n") - sb.WriteString(fmt.Sprintf("message Get%sByIDResponse {\n", capitalizedModelName)) - sb.WriteString(fmt.Sprintf(" %s %s = 1;\n", capitalizedModelName, modelName)) + fmt.Fprintf(&sb, "message Get%sByIDResponse {\n", capitalizedModelName) + fmt.Fprintf(&sb, " %s %s = 1;\n", capitalizedModelName, modelName) sb.WriteString("}\n\n") // Create - sb.WriteString(fmt.Sprintf("// Create%s\n", capitalizedModelName)) - sb.WriteString(fmt.Sprintf("message Create%sRequest {\n", capitalizedModelName)) - sb.WriteString(fmt.Sprintf(" %s %s = 1;\n", capitalizedModelName, modelName)) + fmt.Fprintf(&sb, "// Create%s\n", capitalizedModelName) + fmt.Fprintf(&sb, "message Create%sRequest {\n", capitalizedModelName) + fmt.Fprintf(&sb, " %s %s = 1;\n", capitalizedModelName, modelName) sb.WriteString("}\n") - sb.WriteString(fmt.Sprintf("message Create%sResponse {\n", capitalizedModelName)) - sb.WriteString(fmt.Sprintf(" %s %s = 1;\n", capitalizedModelName, modelName)) + fmt.Fprintf(&sb, "message Create%sResponse {\n", capitalizedModelName) + fmt.Fprintf(&sb, " %s %s = 1;\n", capitalizedModelName, modelName) sb.WriteString("}\n\n") // Edit - sb.WriteString(fmt.Sprintf("// Edit%s\n", capitalizedModelName)) - sb.WriteString(fmt.Sprintf("message Edit%sRequest {\n", capitalizedModelName)) - sb.WriteString(fmt.Sprintf(" %s %s = 1;\n", capitalizedModelName, modelName)) + fmt.Fprintf(&sb, "// Edit%s\n", capitalizedModelName) + fmt.Fprintf(&sb, "message Edit%sRequest {\n", capitalizedModelName) + fmt.Fprintf(&sb, " %s %s = 1;\n", capitalizedModelName, modelName) sb.WriteString("}\n") - sb.WriteString(fmt.Sprintf("message Edit%sResponse {\n", capitalizedModelName)) - sb.WriteString(fmt.Sprintf(" %s %s = 1;\n", capitalizedModelName, modelName)) + fmt.Fprintf(&sb, "message Edit%sResponse {\n", capitalizedModelName) + fmt.Fprintf(&sb, " %s %s = 1;\n", capitalizedModelName, modelName) sb.WriteString("}\n\n") // Remove - sb.WriteString(fmt.Sprintf("// Remove%s\n", capitalizedModelName)) - sb.WriteString(fmt.Sprintf("message Remove%sRequest {\n", capitalizedModelName)) + fmt.Fprintf(&sb, "// Remove%s\n", capitalizedModelName) + fmt.Fprintf(&sb, "message Remove%sRequest {\n", capitalizedModelName) sb.WriteString(" string id = 1;\n") sb.WriteString("}\n") - sb.WriteString(fmt.Sprintf("message Remove%sResponse {}\n\n", capitalizedModelName)) + fmt.Fprintf(&sb, "message Remove%sResponse {}\n\n", capitalizedModelName) // Service - sb.WriteString(fmt.Sprintf("service %sService {\n", capitalizedModelName)) - sb.WriteString(fmt.Sprintf(" rpc GetAll%s(GetAll%sRequest) returns (stream GetAll%sResponse) {}\n", pluralCap, pluralCap, pluralCap)) - sb.WriteString(fmt.Sprintf(" rpc Get%sByID(Get%sByIDRequest) returns (Get%sByIDResponse) {}\n", capitalizedModelName, capitalizedModelName, capitalizedModelName)) - sb.WriteString(fmt.Sprintf(" rpc Create%s(Create%sRequest) returns (Create%sResponse) {}\n", capitalizedModelName, capitalizedModelName, capitalizedModelName)) - sb.WriteString(fmt.Sprintf(" rpc Edit%s(Edit%sRequest) returns (Edit%sResponse) {}\n", capitalizedModelName, capitalizedModelName, capitalizedModelName)) - sb.WriteString(fmt.Sprintf(" rpc Remove%s(Remove%sRequest) returns (Remove%sResponse) {}\n", capitalizedModelName, capitalizedModelName, capitalizedModelName)) + fmt.Fprintf(&sb, "service %sService {\n", capitalizedModelName) + fmt.Fprintf(&sb, " rpc GetAll%s(GetAll%sRequest) returns (stream GetAll%sResponse) {}\n", pluralCap, pluralCap, pluralCap) + fmt.Fprintf(&sb, " rpc Get%sByID(Get%sByIDRequest) returns (Get%sByIDResponse) {}\n", capitalizedModelName, capitalizedModelName, capitalizedModelName) + fmt.Fprintf(&sb, " rpc Create%s(Create%sRequest) returns (Create%sResponse) {}\n", capitalizedModelName, capitalizedModelName, capitalizedModelName) + fmt.Fprintf(&sb, " rpc Edit%s(Edit%sRequest) returns (Edit%sResponse) {}\n", capitalizedModelName, capitalizedModelName, capitalizedModelName) + fmt.Fprintf(&sb, " rpc Remove%s(Remove%sRequest) returns (Remove%sResponse) {}\n", capitalizedModelName, capitalizedModelName, capitalizedModelName) sb.WriteString("}\n") mainContent = mainContent + sb.String() diff --git a/cmd/gof/config/config.go b/cmd/gof/config/config.go index fa5ae24..55c7522 100644 --- a/cmd/gof/config/config.go +++ b/cmd/gof/config/config.go @@ -36,12 +36,12 @@ type Model struct { } type Config struct { - ProjectName string `json:"project_name"` - Services []Service `json:"services"` - Models []Model `json:"models"` - Integrations []string `json:"integrations"` - InfraPopulated bool `json:"infra_populated"` - MonitoringPopulated bool `json:"monitoring_populated"` + ProjectName string `json:"project_name"` + Services []Service `json:"services"` + Models []Model `json:"models"` + Integrations []string `json:"integrations"` + InfraPopulated bool `json:"infra_populated"` + MonitoringPopulated bool `json:"monitoring_populated"` } type Service struct { @@ -49,6 +49,15 @@ type Service struct { Port string `json:"port"` } +func writeConfig(cfg *Config) error { + data, err := json.MarshalIndent(cfg, "", " ") + if err != nil { + return err + } + + return os.WriteFile(ConfigFileName, data, 0644) +} + func ParseConfig() (*Config, error) { data, err := os.ReadFile(ConfigFileName) if err != nil { @@ -82,12 +91,7 @@ func AddModel(modelName string, columns []Column) error { } config.Models = append(config.Models, newModel) - data, err := json.MarshalIndent(config, "", " ") - if err != nil { - return err - } - - return os.WriteFile(ConfigFileName, data, 0644) + return writeConfig(config) } func Initialize(projectName string) error { @@ -121,36 +125,54 @@ func Initialize(projectName string) error { } func IsSvelte() bool { + return HasService("svelte") +} + +func IsTanstack() bool { + return HasService("tanstack") +} + +func HasService(name string) bool { config, err := ParseConfig() if err != nil { return false } for _, service := range config.Services { - if service.Name == "svelte" { + if service.Name == name { return true } } return false } -func MarkInfraPopulated() error { +func AddService(name, port string) error { cfg, err := ParseConfig() if err != nil { return err } - if cfg.InfraPopulated { - return nil + for _, service := range cfg.Services { + if service.Name == name { + return nil + } } - cfg.InfraPopulated = true + cfg.Services = append(cfg.Services, Service{Name: name, Port: port}) + return writeConfig(cfg) +} - data, err := json.MarshalIndent(cfg, "", " ") +func MarkInfraPopulated() error { + cfg, err := ParseConfig() if err != nil { return err } - return os.WriteFile(ConfigFileName, data, 0644) + if cfg.InfraPopulated { + return nil + } + + cfg.InfraPopulated = true + return writeConfig(cfg) } func MarkMonitoringPopulated() error { @@ -164,13 +186,7 @@ func MarkMonitoringPopulated() error { } cfg.MonitoringPopulated = true - - data, err := json.MarshalIndent(cfg, "", " ") - if err != nil { - return err - } - - return os.WriteFile(ConfigFileName, data, 0644) + return writeConfig(cfg) } func HasIntegration(name string) bool { @@ -200,11 +216,5 @@ func AddIntegration(name string) error { } cfg.Integrations = append(cfg.Integrations, name) - - data, err := json.MarshalIndent(cfg, "", " ") - if err != nil { - return err - } - - return os.WriteFile(ConfigFileName, data, 0644) + return writeConfig(cfg) } diff --git a/cmd/gof/e2e/e2e.go b/cmd/gof/e2e/e2e.go index 4a49b00..e6ddba4 100644 --- a/cmd/gof/e2e/e2e.go +++ b/cmd/gof/e2e/e2e.go @@ -156,30 +156,30 @@ func GenerateClientE2ETest(modelName string, columns []Column) error { } var configB strings.Builder - configB.WriteString(fmt.Sprintf("\tname: '%s',\n", capitalizedModelName)) - configB.WriteString(fmt.Sprintf("\tplural: '%s',\n", pluralCap)) - configB.WriteString(fmt.Sprintf("\troute: '/models/%s',\n", pluralLower)) - configB.WriteString(fmt.Sprintf("\tcreateRoute: '/models/%s/new',\n", pluralLower)) - configB.WriteString(fmt.Sprintf("\tcreateLinkLabel: 'Create New %s',\n", capitalizedModelName)) + fmt.Fprintf(&configB, "\tname: '%s',\n", capitalizedModelName) + fmt.Fprintf(&configB, "\tplural: '%s',\n", pluralCap) + fmt.Fprintf(&configB, "\troute: '/models/%s',\n", pluralLower) + fmt.Fprintf(&configB, "\tcreateRoute: '/models/%s/new',\n", pluralLower) + fmt.Fprintf(&configB, "\tcreateLinkLabel: 'Create New %s',\n", capitalizedModelName) configB.WriteString("\tsaveButtonLabel: 'Save',\n") configB.WriteString("\tdeleteButtonLabel: 'Delete',\n") configB.WriteString("\tlistHeaders: [\n") for _, header := range headers { - configB.WriteString(fmt.Sprintf("\t\t'%s',\n", header)) + fmt.Fprintf(&configB, "\t\t'%s',\n", header) } configB.WriteString("\t],\n") configB.WriteString("\ttoastMessages: {\n") - configB.WriteString(fmt.Sprintf("\t\tupdateSuccess: '%s updated successfully.',\n", capitalizedModelName)) + fmt.Fprintf(&configB, "\t\tupdateSuccess: '%s updated successfully.',\n", capitalizedModelName) configB.WriteString("\t},\n") configB.WriteString("\tfields: [\n") for _, meta := range fieldMetas { configB.WriteString("\t\t{\n") - configB.WriteString(fmt.Sprintf("\t\t\tname: '%s',\n", meta.name)) - configB.WriteString(fmt.Sprintf("\t\t\tlabel: '%s',\n", meta.label)) - configB.WriteString(fmt.Sprintf("\t\t\ttype: %s,\n", meta.typeLiteral)) - configB.WriteString(fmt.Sprintf("\t\t\tcreateValue: %s,\n", meta.createLiteral)) + fmt.Fprintf(&configB, "\t\t\tname: '%s',\n", meta.name) + fmt.Fprintf(&configB, "\t\t\tlabel: '%s',\n", meta.label) + fmt.Fprintf(&configB, "\t\t\ttype: %s,\n", meta.typeLiteral) + fmt.Fprintf(&configB, "\t\t\tcreateValue: %s,\n", meta.createLiteral) if meta.validation != "" { - configB.WriteString(fmt.Sprintf("\t\t\tvalidationMessage: %s,\n", meta.validation)) + fmt.Fprintf(&configB, "\t\t\tvalidationMessage: %s,\n", meta.validation) } if meta.useTimestamp { configB.WriteString("\t\t\tuseTimestamp: true,\n") @@ -187,10 +187,10 @@ func GenerateClientE2ETest(modelName string, columns []Column) error { configB.WriteString("\t\t},\n") } configB.WriteString("\t],\n") - configB.WriteString(fmt.Sprintf("\tcreateAssertField: '%s',\n", createAssertField)) + fmt.Fprintf(&configB, "\tcreateAssertField: '%s',\n", createAssertField) configB.WriteString("\teditScenario: {\n") - configB.WriteString(fmt.Sprintf("\t\tfieldName: '%s',\n", editMeta.name)) - configB.WriteString(fmt.Sprintf("\t\tnewValue: %s,\n", editValueLiteral)) + fmt.Fprintf(&configB, "\t\tfieldName: '%s',\n", editMeta.name) + fmt.Fprintf(&configB, "\t\tnewValue: %s,\n", editValueLiteral) configB.WriteString("\t},\n") replaceRegion := func(content, startMarker, endMarker, replacement string) (string, error) { diff --git a/cmd/gof/integrations/integrations.go b/cmd/gof/integrations/integrations.go index d362115..e907c85 100644 --- a/cmd/gof/integrations/integrations.go +++ b/cmd/gof/integrations/integrations.go @@ -8,6 +8,8 @@ import ( "sort" "strconv" "strings" + + "github.com/gofast-live/gofast-cli/v2/cmd/gof/clients" ) // StripIntegration removes all GF__START/END blocks from all files in the project @@ -292,6 +294,67 @@ func AppendMarkerBlock(srcPath, dstPath, integration string) error { return os.WriteFile(dstPath, []byte(result), 0644) } +func StripClientIntegration(clientType, clientPath, integration string) error { + spec, ok := clients.SpecFor(clientType) + if !ok { + return fmt.Errorf("unknown client type %q", clientType) + } + + routeSubpath, err := integrationRouteSubpath(spec, integration) + if err != nil { + return err + } + + targetPath := filepath.Join(clientPath, filepath.FromSlash(routeSubpath)) + if err := os.RemoveAll(targetPath); err != nil && !os.IsNotExist(err) { + return fmt.Errorf("removing %s route %s: %w", integration, targetPath, err) + } + return nil +} + +func AddClientIntegration(tmpProject, clientType, clientPath, integration string) error { + spec, ok := clients.SpecFor(clientType) + if !ok { + return fmt.Errorf("unknown client type %q", clientType) + } + + routeSubpath, err := integrationRouteSubpath(spec, integration) + if err != nil { + return err + } + + srcPath := filepath.Join(tmpProject, "app", spec.ServiceDir, filepath.FromSlash(routeSubpath)) + dstPath := filepath.Join(clientPath, filepath.FromSlash(routeSubpath)) + + info, err := os.Stat(srcPath) + if err != nil { + return fmt.Errorf("stat client integration source %s: %w", srcPath, err) + } + if info.IsDir() { + if err := CopyDir(srcPath, dstPath); err != nil { + return fmt.Errorf("copying client integration directory %s: %w", srcPath, err) + } + return nil + } + if err := CopyFile(srcPath, dstPath); err != nil { + return fmt.Errorf("copying client integration file %s: %w", srcPath, err) + } + return nil +} + +func integrationRouteSubpath(spec clients.Spec, integration string) (string, error) { + switch integration { + case "stripe": + return spec.PaymentsRouteSubpath, nil + case "s3": + return spec.FilesRouteSubpath, nil + case "postmark": + return spec.EmailsRouteSubpath, nil + default: + return "", fmt.Errorf("unknown integration %q", integration) + } +} + // MergeMainGoMarkers extracts marker blocks from src main.go and injects them into dst main.go // Import blocks are injected before GF_MAIN_IMPORT_SERVICES_START // Init blocks are injected before GF_MAIN_INIT_SERVICES_START diff --git a/cmd/gof/integrations/postmark.go b/cmd/gof/integrations/postmark.go index 933c7c7..52281ea 100644 --- a/cmd/gof/integrations/postmark.go +++ b/cmd/gof/integrations/postmark.go @@ -5,6 +5,7 @@ import ( "os" "path/filepath" + "github.com/gofast-live/gofast-cli/v2/cmd/gof/clients" "github.com/gofast-live/gofast-cli/v2/cmd/gof/config" "github.com/gofast-live/gofast-cli/v2/cmd/gof/repo" ) @@ -35,47 +36,14 @@ func PostmarkStrip(projectPath string) error { return nil } -// PostmarkStripClient removes Postmark-related content from the Svelte client. -// Called by 'gof client svelte' command after copying the client folder. -func PostmarkStripClient(clientPath string) error { - // Remove emails route folder - emailsPath := filepath.Join(clientPath, "src", "routes", "(app)", "emails") - if err := os.RemoveAll(emailsPath); err != nil && !os.IsNotExist(err) { - return fmt.Errorf("removing emails folder: %w", err) - } - return nil -} - -// PostmarkAddClient adds Postmark-related content to an existing Svelte client. -// Called by 'gof add postmark' when client already exists. -func PostmarkAddClient(tmpProject, clientPath string) error { - // Copy emails route folder - srcEmails := filepath.Join(tmpProject, "app", "service-client", "src", "routes", "(app)", "emails") - dstEmails := filepath.Join(clientPath, "src", "routes", "(app)", "emails") - if err := CopyDir(srcEmails, dstEmails); err != nil { - return fmt.Errorf("copying emails folder: %w", err) - } - return nil -} - -// PostmarkStripE2E removes Postmark-related e2e tests. -// Called by 'gof client svelte' when postmark is not enabled. -func PostmarkStripE2E(e2ePath string) error { - if err := os.Remove(filepath.Join(e2ePath, "emails.test.ts")); err != nil && !os.IsNotExist(err) { - return fmt.Errorf("removing emails.test.ts: %w", err) - } - return nil +// PostmarkStripClient removes Postmark-related content from a generated client. +func PostmarkStripClient(clientType, clientPath string) error { + return StripClientIntegration(clientType, clientPath, "postmark") } -// PostmarkAddE2E adds Postmark-related e2e tests. -// Called by 'gof add postmark' when client exists. -func PostmarkAddE2E(tmpProject, e2ePath string) error { - src := filepath.Join(tmpProject, "e2e", "emails.test.ts") - dst := filepath.Join(e2ePath, "emails.test.ts") - if err := CopyFile(src, dst); err != nil { - return fmt.Errorf("copying emails.test.ts: %w", err) - } - return nil +// PostmarkAddClient adds Postmark-related content to an existing client. +func PostmarkAddClient(tmpProject, clientType, clientPath string) error { + return AddClientIntegration(tmpProject, clientType, clientPath, "postmark") } // PostmarkAdd adds Postmark email integration to an existing project. @@ -133,18 +101,16 @@ func PostmarkAdd(email, apiKey string) error { return fmt.Errorf("copying files with EMAIL markers: %w", err) } - // 6. Add client-side Email content if Svelte client is configured - if config.IsSvelte() { - clientPath := filepath.Join("app", "service-client") - if err := PostmarkAddClient(tmpProject, clientPath); err != nil { - return fmt.Errorf("adding email to client: %w", err) - } - // Add e2e tests if e2e folder exists - e2ePath := "e2e" - if _, err := os.Stat(e2ePath); err == nil { - if err := PostmarkAddE2E(tmpProject, e2ePath); err != nil { - return fmt.Errorf("adding postmark e2e tests: %w", err) - } + cfg, err := config.ParseConfig() + if err != nil { + return fmt.Errorf("parsing config: %w", err) + } + + enabledClients := clients.Enabled(cfg) + for _, client := range enabledClients { + clientPath := filepath.Join("app", client.ServiceDir) + if err := PostmarkAddClient(tmpProject, client.Name, clientPath); err != nil { + return fmt.Errorf("adding email to %s client: %w", client.DisplayName, err) } } diff --git a/cmd/gof/integrations/s3.go b/cmd/gof/integrations/s3.go index ffd5522..a9cbc58 100644 --- a/cmd/gof/integrations/s3.go +++ b/cmd/gof/integrations/s3.go @@ -5,6 +5,7 @@ import ( "os" "path/filepath" + "github.com/gofast-live/gofast-cli/v2/cmd/gof/clients" "github.com/gofast-live/gofast-cli/v2/cmd/gof/config" "github.com/gofast-live/gofast-cli/v2/cmd/gof/repo" ) @@ -35,47 +36,14 @@ func S3Strip(projectPath string) error { return nil } -// S3StripClient removes S3-related content from the Svelte client. -// Called by 'gof client svelte' command after copying the client folder. -func S3StripClient(clientPath string) error { - // Remove files route folder - filesPath := filepath.Join(clientPath, "src", "routes", "(app)", "files") - if err := os.RemoveAll(filesPath); err != nil && !os.IsNotExist(err) { - return fmt.Errorf("removing files folder: %w", err) - } - return nil -} - -// S3AddClient adds S3-related content to an existing Svelte client. -// Called by 'gof add s3' when client already exists. -func S3AddClient(tmpProject, clientPath string) error { - // Copy files route folder - srcFiles := filepath.Join(tmpProject, "app", "service-client", "src", "routes", "(app)", "files") - dstFiles := filepath.Join(clientPath, "src", "routes", "(app)", "files") - if err := CopyDir(srcFiles, dstFiles); err != nil { - return fmt.Errorf("copying files folder: %w", err) - } - return nil -} - -// S3StripE2E removes S3-related e2e tests. -// Called by 'gof client svelte' when s3 is not enabled. -func S3StripE2E(e2ePath string) error { - if err := os.Remove(filepath.Join(e2ePath, "files.test.ts")); err != nil && !os.IsNotExist(err) { - return fmt.Errorf("removing files.test.ts: %w", err) - } - return nil +// S3StripClient removes S3-related content from a generated client. +func S3StripClient(clientType, clientPath string) error { + return StripClientIntegration(clientType, clientPath, "s3") } -// S3AddE2E adds S3-related e2e tests. -// Called by 'gof add s3' when client exists. -func S3AddE2E(tmpProject, e2ePath string) error { - src := filepath.Join(tmpProject, "e2e", "files.test.ts") - dst := filepath.Join(e2ePath, "files.test.ts") - if err := CopyFile(src, dst); err != nil { - return fmt.Errorf("copying files.test.ts: %w", err) - } - return nil +// S3AddClient adds S3-related content to an existing client. +func S3AddClient(tmpProject, clientType, clientPath string) error { + return AddClientIntegration(tmpProject, clientType, clientPath, "s3") } // S3Add adds S3 file storage integration to an existing project. @@ -133,18 +101,16 @@ func S3Add(email, apiKey string) error { return fmt.Errorf("copying files with FILE markers: %w", err) } - // 6. Add client-side Files content if Svelte client is configured - if config.IsSvelte() { - clientPath := filepath.Join("app", "service-client") - if err := S3AddClient(tmpProject, clientPath); err != nil { - return fmt.Errorf("adding files to client: %w", err) - } - // Add e2e tests if e2e folder exists - e2ePath := "e2e" - if _, err := os.Stat(e2ePath); err == nil { - if err := S3AddE2E(tmpProject, e2ePath); err != nil { - return fmt.Errorf("adding s3 e2e tests: %w", err) - } + cfg, err := config.ParseConfig() + if err != nil { + return fmt.Errorf("parsing config: %w", err) + } + + enabledClients := clients.Enabled(cfg) + for _, client := range enabledClients { + clientPath := filepath.Join("app", client.ServiceDir) + if err := S3AddClient(tmpProject, client.Name, clientPath); err != nil { + return fmt.Errorf("adding files to %s client: %w", client.DisplayName, err) } } diff --git a/cmd/gof/integrations/stripe.go b/cmd/gof/integrations/stripe.go index 42d1367..323a9fa 100644 --- a/cmd/gof/integrations/stripe.go +++ b/cmd/gof/integrations/stripe.go @@ -6,6 +6,7 @@ import ( "path/filepath" "strings" + "github.com/gofast-live/gofast-cli/v2/cmd/gof/clients" "github.com/gofast-live/gofast-cli/v2/cmd/gof/config" "github.com/gofast-live/gofast-cli/v2/cmd/gof/repo" ) @@ -70,47 +71,14 @@ func stripeReplaceCheckUserAccess(projectPath string) error { return os.WriteFile(loginServicePath, []byte(s), 0644) } -// StripeStripClient removes Stripe-related content from the Svelte client. -// Called by 'gof client svelte' command after copying the client folder. -func StripeStripClient(clientPath string) error { - // Remove payments route folder - paymentsPath := filepath.Join(clientPath, "src", "routes", "(app)", "payments") - if err := os.RemoveAll(paymentsPath); err != nil && !os.IsNotExist(err) { - return fmt.Errorf("removing payments folder: %w", err) - } - return nil -} - -// StripeAddClient adds Stripe-related content to an existing Svelte client. -// Called by 'gof add stripe' when client already exists. -func StripeAddClient(tmpProject, clientPath string) error { - // Copy payments route folder - srcPayments := filepath.Join(tmpProject, "app", "service-client", "src", "routes", "(app)", "payments") - dstPayments := filepath.Join(clientPath, "src", "routes", "(app)", "payments") - if err := CopyDir(srcPayments, dstPayments); err != nil { - return fmt.Errorf("copying payments folder: %w", err) - } - return nil -} - -// StripeStripE2E removes Stripe-related e2e tests. -// Called by 'gof client svelte' when stripe is not enabled. -func StripeStripE2E(e2ePath string) error { - if err := os.Remove(filepath.Join(e2ePath, "payments.test.ts")); err != nil && !os.IsNotExist(err) { - return fmt.Errorf("removing payments.test.ts: %w", err) - } - return nil +// StripeStripClient removes Stripe-related content from a generated client. +func StripeStripClient(clientType, clientPath string) error { + return StripClientIntegration(clientType, clientPath, "stripe") } -// StripeAddE2E adds Stripe-related e2e tests. -// Called by 'gof add stripe' when client exists. -func StripeAddE2E(tmpProject, e2ePath string) error { - src := filepath.Join(tmpProject, "e2e", "payments.test.ts") - dst := filepath.Join(e2ePath, "payments.test.ts") - if err := CopyFile(src, dst); err != nil { - return fmt.Errorf("copying payments.test.ts: %w", err) - } - return nil +// StripeAddClient adds Stripe-related content to an existing client. +func StripeAddClient(tmpProject, clientType, clientPath string) error { + return AddClientIntegration(tmpProject, clientType, clientPath, "stripe") } // StripeAdd adds Stripe payment integration to an existing project. @@ -168,18 +136,16 @@ func StripeAdd(email, apiKey string) error { return fmt.Errorf("copying files with stripe markers: %w", err) } - // 6. Add client-side Stripe content if Svelte client is configured - if config.IsSvelte() { - clientPath := filepath.Join("app", "service-client") - if err := StripeAddClient(tmpProject, clientPath); err != nil { - return fmt.Errorf("adding stripe to client: %w", err) - } - // Add e2e tests if e2e folder exists - e2ePath := "e2e" - if _, err := os.Stat(e2ePath); err == nil { - if err := StripeAddE2E(tmpProject, e2ePath); err != nil { - return fmt.Errorf("adding stripe e2e tests: %w", err) - } + cfg, err := config.ParseConfig() + if err != nil { + return fmt.Errorf("parsing config: %w", err) + } + + enabledClients := clients.Enabled(cfg) + for _, client := range enabledClients { + clientPath := filepath.Join("app", client.ServiceDir) + if err := StripeAddClient(tmpProject, client.Name, clientPath); err != nil { + return fmt.Errorf("adding stripe to %s client: %w", client.DisplayName, err) } } diff --git a/cmd/gof/svelte/svelte.go b/cmd/gof/svelte/svelte.go index 8260b85..c93b51a 100644 --- a/cmd/gof/svelte/svelte.go +++ b/cmd/gof/svelte/svelte.go @@ -5,11 +5,10 @@ import ( "os" "os/exec" "path/filepath" - "strconv" + "regexp" "strings" "github.com/gertd/go-pluralize" - "github.com/gofast-live/gofast-cli/v2/cmd/gof/e2e" ) type Column struct { @@ -56,97 +55,9 @@ func toPascalCase(s string) string { return b.String() } -// UpdateUserPermissions adds new model permissions to the user management page -// (users/[user_id]/+page.svelte) by inserting entries before the GF_PERMISSIONS_END marker. -func UpdateUserPermissions(modelName string) error { - path := "./app/service-client/src/routes/(app)/users/[user_id]/+page.svelte" - contentBytes, err := os.ReadFile(path) - if err != nil { - // User management page doesn't exist, skip silently - if os.IsNotExist(err) { - return nil - } - return fmt.Errorf("reading user details page: %w", err) - } - content := string(contentBytes) - - // Build permission names - modelCap := toPascalCase(modelName) - modelPlural := pluralizeClient.Plural(modelName) - modelPluralCap := toPascalCase(modelPlural) - - // Check if already present - if strings.Contains(content, "Get"+modelPluralCap) { - return nil - } - - const marker = "// GF_PERMISSIONS_END" - e := strings.Index(content, marker) - if e == -1 { - return fmt.Errorf("permissions marker %s not found in user details page", marker) - } - - // Find the last bit number by scanning backwards from the marker - // Look for the last "bit: N" pattern before the marker - beforeMarker := content[:e] - lastBit := -1 - for i := len(beforeMarker) - 1; i >= 0; i-- { - if i >= 4 && beforeMarker[i-4:i+1] == "bit: " { - // Found "bit: ", now extract the number - numStart := i + 1 - numEnd := numStart - for numEnd < len(beforeMarker) && beforeMarker[numEnd] >= '0' && beforeMarker[numEnd] <= '9' { - numEnd++ - } - if numEnd > numStart { - if n, err := strconv.Atoi(beforeMarker[numStart:numEnd]); err == nil && n > lastBit { - lastBit = n - } - } - break - } - } - - if lastBit == -1 { - return fmt.Errorf("could not find last bit number in permissions array") - } - - // Build the new permissions entries - nextBit := lastBit + 1 - newPerms := fmt.Sprintf(` { name: "Get%[1]s", bit: %[3]d }, - { name: "Create%[2]s", bit: %[4]d }, - { name: "Edit%[2]s", bit: %[5]d }, - { name: "Remove%[2]s", bit: %[6]d } -`, modelPluralCap, modelCap, nextBit, nextBit+1, nextBit+2, nextBit+3) - - // Find the last permission entry line and ensure it has a trailing comma - markerLineStart := strings.LastIndex(content[:e], "\n") + 1 - - // Find the previous non-empty line (which should be the last permission entry) - prevLineEnd := markerLineStart - 1 - for prevLineEnd > 0 && content[prevLineEnd-1] == '\n' { - prevLineEnd-- - } - prevLineStart := strings.LastIndex(content[:prevLineEnd], "\n") + 1 - prevLine := content[prevLineStart:prevLineEnd] - trimmedPrev := strings.TrimSpace(prevLine) - - // If the previous line ends with "}" but no comma, add the comma - if strings.HasSuffix(trimmedPrev, "}") && !strings.HasSuffix(trimmedPrev, "},") { - // Find the position of the closing brace and add comma after it - bracePos := prevLineStart + strings.LastIndex(prevLine, "}") - content = content[:bracePos+1] + "," + content[bracePos+1:] - // Update marker position since we added a character - markerLineStart++ - } - - // Insert the new permissions before the marker - content = content[:markerLineStart] + newPerms + content[markerLineStart:] - - if err := os.WriteFile(path, []byte(content), 0o644); err != nil { - return fmt.Errorf("writing user details page: %w", err) - } - return nil +func replaceProtoFieldAccess(content, fieldName, replacement string) string { + pattern := regexp.MustCompile(`\.` + regexp.QuoteMeta(fieldName) + `\b`) + return pattern.ReplaceAllString(content, "."+replacement) } func GenerateSvelteScaffolding(modelName string, columns []Column) error { @@ -159,19 +70,11 @@ func GenerateSvelteScaffolding(modelName string, columns []Column) error { if err := generateClientDetailPage(modelName, columns); err != nil { return fmt.Errorf("generating client detail page: %w", err) } - e2e_columns := make([]e2e.Column, len(columns)) - for i, c := range columns { - e2e_columns[i] = e2e.Column{ - Name: c.Name, - Type: c.Type, - } - } - if err := e2e.GenerateClientE2ETest(modelName, e2e_columns); err != nil { - return fmt.Errorf("generating e2e test: %w", err) - } + return nil +} - // run npm i && npm run format in the service-client directory - cmd := "cd ./app/service-client && npm ci && npm run format" +func FormatProject() error { + cmd := "cd ./app/service-svelte && npm ci && npm run format" execCmd := exec.Command("bash", "-c", cmd) out, err := execCmd.CombinedOutput() if err != nil { @@ -183,7 +86,7 @@ func GenerateSvelteScaffolding(modelName string, columns []Column) error { // generateClientConnect updates the client-side ConnectRPC wiring by adding the // Service import and exporting a typed client instance in connect.ts. func generateClientConnect(modelName string) error { - path := "./app/service-client/src/lib/connect.ts" + path := "./app/service-svelte/src/lib/connect.ts" b, err := os.ReadFile(path) if err != nil { return fmt.Errorf("reading connect.ts: %w", err) @@ -254,13 +157,13 @@ func generateClientConnect(modelName string) error { // singular/plural model variants. Columns are not yet expanded; this // is a straight token-based clone of the skeleton UI. func generateClientListPage(modelName string, columns []Column) error { - sourcePath := "./app/service-client/src/routes/(app)/models/skeletons/+page.svelte" + sourcePath := "./app/service-svelte/src/routes/(app)/models/skeletons/+page.svelte" pluralLower := pluralizeClient.Plural(modelName) pluralCap := toPascalCase(pluralLower) capitalizedModelName := toPascalCase(modelName) // Ensure destination directory exists - destDir := filepath.Join("app/service-client/src/routes/(app)/models", pluralLower) + destDir := filepath.Join("app/service-svelte/src/routes/(app)/models", pluralLower) if err := os.MkdirAll(destDir, 0o755); err != nil { return fmt.Errorf("creating destination directory %s: %w", destDir, err) } @@ -277,9 +180,9 @@ func generateClientListPage(modelName string, columns []Column) error { s = strings.ReplaceAll(s, "Skeletons", pluralCap) s = strings.ReplaceAll(s, "skeletons", pluralLower) s = strings.ReplaceAll(s, "Skeleton", capitalizedModelName) - // Replace protobuf field access patterns with camelCase BEFORE the blanket replacement + // Replace protobuf field access patterns with camelCase BEFORE the blanket replacement. camelName := toCamelCase(modelName) - s = strings.ReplaceAll(s, "res.skeleton", "res."+camelName) + s = replaceProtoFieldAccess(s, "skeleton", camelName) s = strings.ReplaceAll(s, "skeleton", modelName) // Helper: Title-case a label from snake_case @@ -378,14 +281,14 @@ func generateClientListPage(modelName string, columns []Column) error { // singular/plural model variants. It also expands the column-aware regions for // empty model defaults, form-data extraction, request payload fields, and form inputs. func generateClientDetailPage(modelName string, columns []Column) error { - sourcePath := "./app/service-client/src/routes/(app)/models/skeletons/[skeleton_id]/+page.svelte" + sourcePath := "./app/service-svelte/src/routes/(app)/models/skeletons/[skeleton_id]/+page.svelte" pluralLower := pluralizeClient.Plural(modelName) pluralCap := toPascalCase(pluralLower) capitalizedModelName := toPascalCase(modelName) // Ensure destination directory exists: /(app)/models//[_id] destDir := filepath.Join( - "app/service-client/src/routes/(app)/models", + "app/service-svelte/src/routes/(app)/models", pluralLower, "["+modelName+"_id]", ) @@ -405,11 +308,9 @@ func generateClientDetailPage(modelName string, columns []Column) error { s = strings.ReplaceAll(s, "Skeletons", pluralCap) s = strings.ReplaceAll(s, "skeletons", pluralLower) s = strings.ReplaceAll(s, "Skeleton", capitalizedModelName) - // Replace protobuf field access patterns with camelCase BEFORE the blanket replacement - // Be specific to avoid matching partial strings like params.skeleton_id camelName := toCamelCase(modelName) - s = strings.ReplaceAll(s, "!s.skeleton)", "!s."+camelName+")") // null check: if (!s.skeleton) - s = strings.ReplaceAll(s, " s.skeleton;", " s."+camelName+";") // return s.skeleton; + // Replace protobuf field access patterns with camelCase BEFORE the blanket replacement. + s = replaceProtoFieldAccess(s, "skeleton", camelName) s = strings.ReplaceAll(s, "skeleton: {", camelName+": {") s = strings.ReplaceAll(s, "skeleton", modelName) diff --git a/cmd/gof/tanstack/tanstack.go b/cmd/gof/tanstack/tanstack.go new file mode 100644 index 0000000..935daf6 --- /dev/null +++ b/cmd/gof/tanstack/tanstack.go @@ -0,0 +1,469 @@ +package tanstack + +import ( + "fmt" + "os" + "os/exec" + "path/filepath" + "regexp" + "strings" + + "github.com/gertd/go-pluralize" +) + +type Column struct { + Name string + Type string +} + +var pluralizeClient = pluralize.NewClient() + +func GetModelPath(modelName string) string { + return "/models/" + pluralizeClient.Plural(modelName) +} + +func FormatProject() error { + cmd := `cd ./app/service-tanstack && npm ci && node --input-type=module -e "import { Generator } from '@tanstack/router-generator'; import { getConfig } from '@tanstack/router-plugin'; const root = process.cwd(); const generator = new Generator({ config: getConfig({}, root), root }); await generator.run();" && npm run format` + execCmd := exec.Command("bash", "-c", cmd) + out, err := execCmd.CombinedOutput() + if err != nil { + return fmt.Errorf("running npm commands: %w\nOutput: %s", err, string(out)) + } + return nil +} + +func GenerateTanstackScaffolding(modelName string, columns []Column) error { + if err := generateClientConnect(modelName); err != nil { + return fmt.Errorf("generating client connect.ts: %w", err) + } + if err := generateClientListPage(modelName, columns); err != nil { + return fmt.Errorf("generating client list page: %w", err) + } + if err := generateClientDetailPage(modelName, columns); err != nil { + return fmt.Errorf("generating client detail page: %w", err) + } + return nil +} + +func toCamelCase(s string) string { + parts := strings.Split(s, "_") + if len(parts) == 1 { + return s + } + var b strings.Builder + b.WriteString(parts[0]) + for _, p := range parts[1:] { + if p == "" { + continue + } + b.WriteString(strings.ToUpper(p[:1]) + p[1:]) + } + return b.String() +} + +func toPascalCase(s string) string { + parts := strings.Split(s, "_") + var b strings.Builder + for _, p := range parts { + if p == "" { + continue + } + b.WriteString(strings.ToUpper(p[:1]) + p[1:]) + } + return b.String() +} + +func replaceProtoFieldAccess(content, fieldName, replacement string) string { + pattern := regexp.MustCompile(`\.` + regexp.QuoteMeta(fieldName) + `\b`) + return pattern.ReplaceAllString(content, "."+replacement) +} + +func generateClientConnect(modelName string) error { + path := "./app/service-tanstack/src/lib/connect.ts" + b, err := os.ReadFile(path) + if err != nil { + return fmt.Errorf("reading connect.ts: %w", err) + } + s := string(b) + + pascalName := toPascalCase(modelName) + serviceToken := pascalName + "Service" + clientExport := "export const " + modelName + "_client = createClient(" + serviceToken + ", transport)" + + if !strings.Contains(s, serviceToken) { + marker := "from './gen/proto/v1/main_pb'" + idx := strings.Index(s, marker) + if idx == -1 { + return fmt.Errorf("main_pb import not found in connect.ts") + } + pre := s[:idx] + braceOpen := strings.LastIndex(pre, "{") + braceClose := strings.LastIndex(pre, "}") + if braceOpen == -1 || braceClose == -1 || braceClose < braceOpen { + return fmt.Errorf("malformed main_pb import in connect.ts") + } + importList := strings.TrimSpace(pre[braceOpen+1 : braceClose]) + if importList == "" { + importList = serviceToken + } else { + if !strings.HasSuffix(importList, ",") { + importList += "," + } + importList += "\n " + serviceToken + } + s = s[:braceOpen+1] + "\n " + importList + "\n" + s[braceClose:] + } + + if !strings.Contains(s, clientExport) { + insertAfter := "export const skeleton_client = createClient(SkeletonService, transport)" + pos := strings.Index(s, insertAfter) + if pos == -1 { + if !strings.HasSuffix(s, "\n") { + s += "\n" + } + s += clientExport + "\n" + } else { + lineEnd := pos + len(insertAfter) + s = s[:lineEnd] + "\n" + clientExport + s[lineEnd:] + } + } + + if err := os.WriteFile(path, []byte(s), 0o644); err != nil { + return fmt.Errorf("writing connect.ts: %w", err) + } + return nil +} + +func generateClientListPage(modelName string, columns []Column) error { + sourcePath := "./app/service-tanstack/src/routes/_layout/models/skeletons/index.tsx" + pluralLower := pluralizeClient.Plural(modelName) + pluralCap := toPascalCase(pluralLower) + capitalizedModelName := toPascalCase(modelName) + + destDir := filepath.Join("app/service-tanstack/src/routes/_layout/models", pluralLower) + if err := os.MkdirAll(destDir, 0o755); err != nil { + return fmt.Errorf("creating destination directory %s: %w", destDir, err) + } + destPath := filepath.Join(destDir, "index.tsx") + + contentBytes, err := os.ReadFile(sourcePath) + if err != nil { + return fmt.Errorf("reading template file %s: %w", sourcePath, err) + } + + s := string(contentBytes) + s = strings.ReplaceAll(s, "Skeletons", pluralCap) + s = strings.ReplaceAll(s, "skeletons", pluralLower) + s = strings.ReplaceAll(s, "Skeleton", capitalizedModelName) + camelName := toCamelCase(modelName) + s = replaceProtoFieldAccess(s, "skeleton", camelName) + s = strings.ReplaceAll(s, "skeleton", modelName) + + toTitle := func(name string) string { + parts := strings.Split(name, "_") + for i := range parts { + if parts[i] == "" { + continue + } + parts[i] = strings.ToUpper(parts[i][:1]) + parts[i][1:] + } + return strings.Join(parts, " ") + } + + var headersBuilder strings.Builder + for _, c := range columns { + headersBuilder.WriteString(" ") + headersBuilder.WriteString(toTitle(c.Name)) + headersBuilder.WriteString("\n") + } + headersBuilder.WriteString(" Created\n") + headersBuilder.WriteString(" Updated\n") + + var cellsBuilder strings.Builder + for _, c := range columns { + field := toCamelCase(c.Name) + switch c.Type { + case "date": + cellsBuilder.WriteString(" {new Date(" + modelName + "." + field + ").toLocaleDateString()}\n") + case "bool": + cellsBuilder.WriteString(" {" + modelName + "." + field + " ? 'Yes' : 'No'}\n") + default: + cellsBuilder.WriteString(" {" + modelName + "." + field + "}\n") + } + } + cellsBuilder.WriteString(" {new Date(" + modelName + ".created).toLocaleDateString()}\n") + cellsBuilder.WriteString(" {new Date(" + modelName + ".updated).toLocaleDateString()}\n") + + replaceRegion := func(content, startMarker, endMarker, replacement string) (string, error) { + start := strings.Index(content, startMarker) + end := strings.Index(content, endMarker) + if start == -1 || end == -1 || end < start { + return content, fmt.Errorf("markers %q .. %q not found", startMarker, endMarker) + } + start += len(startMarker) + return content[:start] + "\n" + replacement + content[end:], nil + } + + var replaceErr error + s, replaceErr = replaceRegion(s, "{/* GF_LIST_HEADERS_START */}", "{/* GF_LIST_HEADERS_END */}", headersBuilder.String()) + if replaceErr != nil { + return fmt.Errorf("replacing headers: %w", replaceErr) + } + s, replaceErr = replaceRegion(s, "{/* GF_LIST_CELLS_START */}", "{/* GF_LIST_CELLS_END */}", cellsBuilder.String()) + if replaceErr != nil { + return fmt.Errorf("replacing cells: %w", replaceErr) + } + + markers := []string{ + "{/* GF_LIST_HEADERS_START */}", + "{/* GF_LIST_HEADERS_END */}", + "{/* GF_LIST_CELLS_START */}", + "{/* GF_LIST_CELLS_END */}", + } + var outLines []string + for line := range strings.SplitSeq(s, "\n") { + skip := false + for _, marker := range markers { + if strings.Contains(line, marker) { + skip = true + break + } + } + if !skip { + outLines = append(outLines, line) + } + } + s = strings.Join(outLines, "\n") + + if err := os.WriteFile(destPath, []byte(s), 0o644); err != nil { + return fmt.Errorf("writing client list page %s: %w", destPath, err) + } + return nil +} + +func generateClientDetailPage(modelName string, columns []Column) error { + sourcePath := "./app/service-tanstack/src/routes/_layout/models/skeletons/$skeleton_id.tsx" + pluralLower := pluralizeClient.Plural(modelName) + pluralCap := toPascalCase(pluralLower) + capitalizedModelName := toPascalCase(modelName) + + destDir := filepath.Join("app/service-tanstack/src/routes/_layout/models", pluralLower) + if err := os.MkdirAll(destDir, 0o755); err != nil { + return fmt.Errorf("creating destination directory %s: %w", destDir, err) + } + destPath := filepath.Join(destDir, "$"+modelName+"_id.tsx") + + contentBytes, err := os.ReadFile(sourcePath) + if err != nil { + return fmt.Errorf("reading template file %s: %w", sourcePath, err) + } + + s := string(contentBytes) + s = strings.ReplaceAll(s, "Skeletons", pluralCap) + s = strings.ReplaceAll(s, "skeletons", pluralLower) + s = strings.ReplaceAll(s, "Skeleton", capitalizedModelName) + camelName := toCamelCase(modelName) + s = replaceProtoFieldAccess(s, "skeleton", camelName) + s = strings.ReplaceAll(s, "skeleton: {", camelName+": {") + s = strings.ReplaceAll(s, "skeleton", modelName) + + var emptyBuilder strings.Builder + emptyIndent := " " + emptyBuilder.WriteString(emptyIndent + "created: '',\n") + emptyBuilder.WriteString(emptyIndent + "updated: '',\n") + emptyBuilder.WriteString(emptyIndent + "id: '',\n") + for _, c := range columns { + field := toCamelCase(c.Name) + if c.Type == "bool" { + emptyBuilder.WriteString(emptyIndent + field + ": false,\n") + continue + } + emptyBuilder.WriteString(emptyIndent + field + ": '',\n") + } + emptySnippet := strings.TrimRight(emptyBuilder.String(), "\n") + + var formDataBuilder strings.Builder + formDataIndent := " " + for _, c := range columns { + field := toCamelCase(c.Name) + if c.Type == "bool" { + formDataBuilder.WriteString(formDataIndent + "const " + field + " = formData.get('" + c.Name + "') === 'on'\n") + continue + } + formDataBuilder.WriteString(formDataIndent + "const " + field + " = formData.get('" + c.Name + "')?.toString() ?? ''\n") + } + formDataSnippet := strings.TrimRight(formDataBuilder.String(), "\n") + + var payloadBuilder strings.Builder + for _, c := range columns { + field := toCamelCase(c.Name) + payloadBuilder.WriteString(" " + field + ",\n") + } + payloadSnippet := strings.TrimRight(payloadBuilder.String(), "\n") + + toTitle := func(name string) string { + parts := strings.Split(name, "_") + for i := range parts { + if parts[i] == "" { + continue + } + parts[i] = strings.ToUpper(parts[i][:1]) + parts[i][1:] + } + return strings.Join(parts, " ") + } + + var fieldsBuilder strings.Builder + for _, c := range columns { + label := toTitle(c.Name) + field := toCamelCase(c.Name) + switch c.Type { + case "string": + fieldsBuilder.WriteString(" \n") + fieldsBuilder.WriteString("
\n") + fieldsBuilder.WriteString(" \n") + fieldsBuilder.WriteString("
Enter at least 3 characters
\n") + fieldsBuilder.WriteString("
\n") + case "number": + fieldsBuilder.WriteString(" \n") + fieldsBuilder.WriteString("
\n") + fieldsBuilder.WriteString(" \n") + fieldsBuilder.WriteString("
Enter a positive number
\n") + fieldsBuilder.WriteString("
\n") + case "date": + fieldsBuilder.WriteString(" \n") + fieldsBuilder.WriteString("
\n") + fieldsBuilder.WriteString(" \n") + fieldsBuilder.WriteString("
Select a valid date
\n") + fieldsBuilder.WriteString("
\n") + case "bool": + fieldsBuilder.WriteString(" \n") + } + } + fieldsSnippet := strings.TrimRight(fieldsBuilder.String(), "\n") + + replaceRegion := func(content, startMarker, endMarker, replacement string) (string, error) { + start := strings.Index(content, startMarker) + end := strings.Index(content, endMarker) + if start == -1 || end == -1 || end < start { + return content, fmt.Errorf("markers %q .. %q not found", startMarker, endMarker) + } + return content[:start] + replacement + "\n" + content[end+len(endMarker):], nil + } + + var replaceErr error + s, replaceErr = replaceRegion(s, "// GF_DETAIL_EMPTY_START", "// GF_DETAIL_EMPTY_END", emptySnippet) + if replaceErr != nil { + return fmt.Errorf("replacing empty defaults: %w", replaceErr) + } + s, replaceErr = replaceRegion(s, "// GF_DETAIL_FORMDATA_START", "// GF_DETAIL_FORMDATA_END", formDataSnippet) + if replaceErr != nil { + return fmt.Errorf("replacing form data: %w", replaceErr) + } + s, replaceErr = replaceRegion(s, "// GF_DETAIL_CREATE_FIELDS_START", "// GF_DETAIL_CREATE_FIELDS_END", payloadSnippet) + if replaceErr != nil { + return fmt.Errorf("replacing create fields: %w", replaceErr) + } + s, replaceErr = replaceRegion(s, "// GF_DETAIL_EDIT_FIELDS_START", "// GF_DETAIL_EDIT_FIELDS_END", payloadSnippet) + if replaceErr != nil { + return fmt.Errorf("replacing edit fields: %w", replaceErr) + } + s, replaceErr = replaceRegion(s, "{/* GF_DETAIL_FIELDS_START */}", "{/* GF_DETAIL_FIELDS_END */}", fieldsSnippet) + if replaceErr != nil { + return fmt.Errorf("replacing UI fields: %w", replaceErr) + } + + hasDateColumn := false + for _, c := range columns { + if c.Type == "date" { + hasDateColumn = true + break + } + } + + markers := []string{ + "// GF_DETAIL_EMPTY_START", "// GF_DETAIL_EMPTY_END", + "// GF_DETAIL_FORMDATA_START", "// GF_DETAIL_FORMDATA_END", + "// GF_DETAIL_CREATE_FIELDS_START", "// GF_DETAIL_CREATE_FIELDS_END", + "// GF_DETAIL_EDIT_FIELDS_START", "// GF_DETAIL_EDIT_FIELDS_END", + "{/* GF_DETAIL_FIELDS_START */}", "{/* GF_DETAIL_FIELDS_END */}", + } + var outLines []string + inFormatDateFunc := false + braceDepth := 0 + for line := range strings.SplitSeq(s, "\n") { + skip := false + for _, marker := range markers { + if strings.Contains(line, marker) { + skip = true + break + } + } + if !hasDateColumn { + trimmed := strings.TrimSpace(line) + if strings.HasPrefix(trimmed, "function formatDate(") { + inFormatDateFunc = true + braceDepth = 0 + skip = true + } + if inFormatDateFunc { + skip = true + for _, ch := range line { + if ch == '{' { + braceDepth++ + } else if ch == '}' { + braceDepth-- + if braceDepth == 0 { + inFormatDateFunc = false + break + } + } + } + } + } + if !skip { + outLines = append(outLines, line) + } + } + s = strings.Join(outLines, "\n") + + if err := os.WriteFile(destPath, []byte(s), 0o644); err != nil { + return fmt.Errorf("writing client detail page %s: %w", destPath, err) + } + return nil +} diff --git a/workflows/DESC.md b/workflows/DESC.md new file mode 100644 index 0000000..a3b68dc --- /dev/null +++ b/workflows/DESC.md @@ -0,0 +1,93 @@ +# DESC: Generate Domain `CONTEXT.md` + +Generate a structured `CONTEXT.md` for a specific folder from one inline prompt. + +Trigger example: +- `@workflows/DESC.md - lib/meetings, capture context for meetings domain` + +Expected output for example: +- `lib/meetings/CONTEXT.md` + +--- + +## 1. Parse Input + +Input format: +- `, ` + +Rules: +- First comma-separated segment is `target_folder`. +- Remaining text is domain context/hints. +- If no comma is provided, treat whole input as `target_folder` and proceed. + +--- + +## 2. Scope of Investigation + +Primary scope: +- All files under `target_folder/**` + +Secondary scope (only when referenced by code/tests): +- Direct dependencies in neighboring modules +- Related jobs/workers, GraphQL/LiveView entry points, telemetry, and auth/scoping paths + +Use fast discovery first (`rg`, file tree, key entry points), then deep-read only important files. + +--- + +## 3. Output Location + +Always write to: +- `/CONTEXT.md` + +If file exists: +- Refresh/restructure in place. +- Remove stale duplicates. +- Keep still-accurate content. + +--- + +## 4. Required `CONTEXT.md` Structure + +Start from: +- `workflows/TEMPLATE.md` + +Use this section order: +1. Metadata +2. Context Maintenance Protocol (LLM-First) +3. Summary +4. Test Strategy +5. Architecture (with Graphs) +6. File Tree (Curated) +7. Core Contracts +8. Telemetry and Observability +9. Current Work +10. Future Work +11. Critical Invariants and Tricky Flows +12. Quick Reference APIs +13. Runbook + +Formatting rules: +- Mark sections `[STABLE]` or `[VOLATILE]`. +- Use concise, factual language. +- Prefer Mermaid for architecture/flow diagrams. +- Include file paths for non-obvious claims. +- Keep auth, tenant scoping, and failure/retry behavior explicit. + +--- + +## 5. Quality Gate + +Before finishing, verify: +- `CONTEXT.md` reflects current code, not assumptions. +- Test strategy is explicit, including unit vs integration guidance. +- LLM default testing policy is present (update/add unit tests for changes; add integration tests for boundary/contract changes). +- Security/scoping invariants are explicit. +- Telemetry update points are listed. +- Current vs Future work are separated. +- `Last updated` is today. + +Final response should report: +- written file path +- short summary of what was captured +- unknowns (if any) diff --git a/workflows/PR.md b/workflows/PR.md new file mode 100644 index 0000000..5d5f0fb --- /dev/null +++ b/workflows/PR.md @@ -0,0 +1,83 @@ +# PR Review + +When the user triggers this flow (e.g. "@workflows/PR.md - fix/quiet-calendar-sync-log"), review the pull request thoroughly and report findings. + +## 1. Gather Context +* **Checkout the branch** into a worktree so you can read full files, not just diffs: + ```bash + git fetch origin + git worktree add worktree/review- origin/ + ``` +* **Fetch PR details:** + ```bash + gh pr view --json title,body,files + gh pr diff + ``` +* **Verify (MANDATORY):** Confirm you are reading from the correct worktree before proceeding. + ```bash + cd worktree/review- + git branch --show-current + pwd + ``` +* **Read changed files in full** from the worktree — not just the diff. Understand the surrounding code, callers, and related modules. + +> **⚠️ WORKTREE DISCIPLINE — READ THIS CAREFULLY** +> +> You MUST use the worktree path (`worktree/review-/...`) for ALL file reads during the review. You have a tendency to drift back to the repo root and read files from there — this means you are reading the **wrong branch** and your review will be incorrect. +> +> Rules: +> 1. **Every** `Read` call must use an absolute path starting with the worktree directory. +> 2. **Every** `Grep`/`Glob` call must have `path` set to the worktree directory. +> 3. **Before each file read**, mentally verify the path contains `worktree/review-`. If it doesn't, you are reading from the wrong place. +> 4. **Never** read from the repo root to "check something quickly" — the root is a different branch. + +> **⚠️ DO NOT run tests, builds, or any other commands in the worktree.** This is a static review only — read the code, don't execute it. + +## 2. Review Priorities + +Focus on what matters. **3 critical findings beat 20 nitpicks.** + +### Critical (always check) +1. **Security** — SQL injection, auth bypasses, missing permission checks, exposed secrets, XSS. Double-check every raw SQL query and every auth/authorization path. +2. **Correctness** — Logic bugs, race conditions, missing error handling at system boundaries, data loss risks. +3. **Data integrity** — Missing multi-tenant scoping (account_id), unsafe migrations, broken constraints. + +### Important (check if relevant) +4. **Performance** — N+1 queries, missing indexes for new queries, unbounded lists. +5. **Breaking changes** — API contract changes, removed fields, changed return types. + +### Skip +* Style nitpicks already covered by formatters/linters +* Defensive programming suggestions (don't add guards for impossible states) +* Missing docs/comments on clear code +* Hypothetical future concerns + +## 3. Cleanup (BEFORE reporting) + +> **⚠️ MANDATORY CLEANUP — DO NOT SKIP** +> +> You MUST remove the worktree **before** writing your review output. +> LLMs routinely skip this step. You are not special — you will skip it too unless you do it RIGHT NOW. +> +> Run this command immediately after finishing your analysis, **before** composing your response: +> ```bash +> git worktree remove worktree/review- +> ``` +> **Do NOT proceed to step 4 until the worktree is removed.** If removal fails, run `git worktree remove --force worktree/review-`. + +## 4. Output + +Report findings grouped by severity: + +``` +## 🔴 Critical + + +## 🟡 Important + + +## ✅ Looks Good + +``` + +If there are no critical or important findings, say so clearly — don't invent issues to fill the report. diff --git a/workflows/SHIP.md b/workflows/SHIP.md new file mode 100644 index 0000000..3d4f937 --- /dev/null +++ b/workflows/SHIP.md @@ -0,0 +1,57 @@ +# Ship: Fire-and-Forget PR + +When the user triggers this flow (e.g. "@workflows/SHIP.md - change drawer to modal"), follow these steps strictly to ensure an isolated, high-speed delivery. + +## 1. Isolation +* **Generate Branch Name:** Create a descriptive kebab-case branch name based on the request (e.g., `feat/drawer-to-modal`, `fix/login-bug`). +* **Create Worktree:** Create a new worktree in the `worktree/` directory to keep the main working directory clean. + ```bash + git fetch origin main + git worktree add worktree/ship- -b origin/main + ``` + +## 2. Implementation +* **Context:** All file operations (read, write, search) **MUST** be performed within the `worktree/ship-` directory. +* **Reading Files:** Some tools may fail on gitignored directories (like `worktree/`). If a read tool fails, fall back to shell commands (e.g., `cat `). +* **Execute:** Apply the requested code changes, refactors, or bug fixes. +* **Tests:** Add or update any relevant tests for your changes. Do NOT run tests — the worktree lacks full app setup. Let CI/CD handle test execution. + +> **⚠️ WORKTREE DISCIPLINE — READ THIS CAREFULLY** +> +> You MUST use the worktree path (`worktree/ship-/...`) for ALL file operations during implementation. You have a tendency to drift back to the repo root and read/write files there — this means you are working on the **wrong branch** and your changes will be lost or applied to the wrong place. +> +> Rules: +> 1. **Every** `Read`/`Edit`/`Write` call must use an absolute path starting with the worktree directory. +> 2. **Every** `Grep`/`Glob` call must have `path` set to the worktree directory. +> 3. **Before each file operation**, mentally verify the path contains `worktree/ship-`. If it doesn't, you are working in the wrong place. +> 4. **Never** read from the repo root to "check something quickly" — the root is a different branch. + +## 3. Delivery +* **Review (MANDATORY):** Before committing, verify your changes are correct and complete. Do NOT skip this step. + ```bash + cd worktree/ship- + git status + git diff + ``` +* **Commit:** + ```bash + git add . + git commit -m ": " + ``` +* **Push:** + ```bash + git push -u origin + ``` +* **Pull Request:** + ```bash + gh pr create --title "" --body "<Description>" --head <branch-name> + ``` + +## 4. Cleanup +* **Remove worktree:** + ```bash + git worktree remove worktree/ship-<branch-name> + ``` + +## 5. Final Output +* **Report:** Output *only* the link to the created Pull Request. diff --git a/workflows/SPIKE.md b/workflows/SPIKE.md new file mode 100644 index 0000000..4f7cc1a --- /dev/null +++ b/workflows/SPIKE.md @@ -0,0 +1,73 @@ +# Spike: Codebase Investigation + +Research a topic in the codebase and produce a concise, reusable summary. **No code changes.** + +Trigger: `@workflows/SPIKE.md — how does calendar sync work?` + +## 1. Scope + +* Clarify the question. If ambiguous, ask before diving in. +* Pick a short kebab-case name for the spike (e.g. `calendar-sync`). +* Identify likely starting points (modules, contexts, entry points). + +## 2. Investigate + +Run these steps in parallel where possible (e.g. using subagents or concurrent tool calls): + +### 2a. Broad search +* Grep for keywords, find relevant modules and entry points. +* Map the **file structure** — which directories and files are involved? List them as a tree. + +### 2b. Deep read +* Follow the call chain through key files. Understand how data flows. +* **Map dependencies** — for each key module, identify: + * What calls it (callers / entry points) + * What it calls (dependencies / downstream) + * This is the blast radius — document it. + +### 2c. Context +* **Tests** — tests document edge cases and expected behavior better than comments. +* **Config** — feature flags, environment variables, application config that affect behavior. + +## 3. Output + +Write the findings to `workflows/spikes/SPIKE-<spike-name>.md` using this template: + +```markdown +# Spike: <Title> + +> <1-2 sentence summary of how it works> + +## File Structure + +<ASCII tree of the relevant directories/files> + +## Key Files + +| File | Purpose | +|------|---------| +| `path/to/file.ex:fn_name` | what it does | +| `path/to/other.ex:fn_name` | what it does | + +## Dependency Graph + +<ASCII diagram showing callers → module → dependencies> + +Example: + LiveView.mount ──→ Calendar.Context.sync/2 + ├──→ Google.API.list_events/1 + ├──→ Repo.insert_all/2 + └──→ PubSub.broadcast/2 + +## Flow + +1. Step one +2. Step two +3. ... + +## Gotchas + +- <anything surprising, non-obvious, or easy to break> +``` + +Keep it concise. Link to files, not paragraphs of explanation. If the answer is simple, the output should be simple. diff --git a/workflows/TEMPLATE.md b/workflows/TEMPLATE.md new file mode 100644 index 0000000..9cddf75 --- /dev/null +++ b/workflows/TEMPLATE.md @@ -0,0 +1,207 @@ +# CONTEXT Template (LLM-First) + +Use this file as the baseline structure when generating `<target_folder>/CONTEXT.md`. + +# <Domain Name> Context + +## Metadata +- Domain: +- Primary audience: +- Last updated: YYYY-MM-DD +- Status: Active +- Stability note: Sections marked `[STABLE]` should change rarely. Sections marked `[VOLATILE]` are expected to change often. + +--- + +## 0. Context Maintenance Protocol (LLM-First) [STABLE] + +This file is the primary working context for this domain. + +- LLM agents should treat this as a living document and update it whenever meaningful behavior changes. +- If code and this file diverge, prefer updating this file quickly so future work stays reliable. +- Temporary or branch-specific behavior should be documented here with clear cleanup notes. + +### Quick update checklist +- Refresh `Last updated` date +- Review `Current Work` and `Future Work` +- Validate `Critical Invariants` +- Update telemetry references if operation/event names changed +- Remove obsolete notes + +### Freshness target +- Re-review this file regularly (for example, every 2 weeks) to prevent context drift. + +--- + +## 1. Summary [STABLE] + +Short, high-signal summary of what this domain does and why it exists. + +- Primary entry points: +- Main responsibilities: +- Highest-risk areas: + +--- + +## Test Strategy [STABLE] + +### Scope and intent +- Cover the full runtime surface for this domain. +- Keep most tests small, deterministic, and close to changed code. +- Use integration tests for boundary and contract behavior. + +### LLM default policy (required) +- On every code update/change, add or update unit tests in touched modules/files. +- Add or update integration tests when the change crosses boundaries (module, service, API, queue, DB, external contract). +- If tests are intentionally deferred (WIP or blocked dependency), document the gap and cleanup plan. + +### Preferred test pyramid +1. Unit tests for local logic, state transitions, and edge cases. +2. Integration tests for realistic flows across components. +3. Repo quality gate before merge (formatter, linter/static checks, test suite). + +### Practical guidelines +- Keep unit tests focused and fast; avoid turning them into mini end-to-end suites. +- Add at least one regression test for each bug fix. +- Prefer confidence in critical invariants and data integrity paths over vanity coverage numbers. + +--- + +## 2. Architecture (with Graphs) [STABLE] + +### 2.1 Component map +```mermaid +flowchart LR + A[Entry Point] --> B[Core Module] + B --> C[Dependency] + B --> D[(DB)] +``` + +### 2.2 Main request/processing flow +```mermaid +sequenceDiagram + participant U as Upstream + participant M as Module + participant D as DB/Service + U->>M: request/event + M->>D: read/write + D-->>M: result + M-->>U: response +``` + +### 2.3 Background jobs / async flow (if applicable) +```mermaid +flowchart TD + E[Event] --> Q[Queue] + Q --> J[Job] + J --> S[Side Effect] +``` + +--- + +## 3. File Tree (Curated) [STABLE] + +```text +<target_folder>/ +├── context.ex +├── schema.ex +├── graphql.ex +├── context_test.exs +└── schema_test.exs +``` + +Related files outside folder: +- `path/to/file.ex` - why it matters + +--- + +## 4. Core Contracts [STABLE] + +### 4.1 Public/API contract +- Endpoints, GraphQL fields, callbacks, events, or behavior contracts. + +### 4.2 Auth and scope model +- Identity types: +- Scope/permission requirements: +- Multi-tenant isolation rules: + +### 4.3 Data model and key enums +- Core entities: +- IDs/prefixes: +- Status/enum semantics: + +### 4.4 Error model +- Error types/codes: +- Mapping to HTTP/GraphQL/domain errors: + +--- + +## 5. Telemetry and Observability [STABLE] + +- Metrics/events emitted: +- Where to update known operation lists: +- Logs/traces to inspect first: +- Alerting signals (if any): + +--- + +## 6. Current Work [VOLATILE] + +- Active initiatives: +- Temporary code paths: +- Branch-specific behavior (if present): + +Cleanup checklist: +1. <cleanup item> +2. <cleanup item> + +--- + +## 7. Future Work [VOLATILE] + +1. Priority item +2. Priority item +3. Priority item + +Known gaps/risks: +- <gap> + +--- + +## 8. Critical Invariants and Tricky Flows [STABLE] + +### 8.1 Security/scoping invariants +- <must never break> + +### 8.2 Data integrity invariants +- <must never break> + +### 8.3 High-risk end-to-end flows +1. Trigger: +2. Processing path: +3. Side effects: +4. Failure/retry behavior: + +### 8.4 Easy-to-break gotchas +- <gotcha> + +--- + +## 9. Quick Reference APIs [STABLE] + +```elixir +# key function calls used frequently in this domain +``` + +--- + +## 10. Runbook [VOLATILE] + +### 10.1 Local verification +```bash +# narrow tests first +``` + +### 10.2 Debugging checklist +1. <step> +2. <step>