diff --git a/CHANGELOG.md b/CHANGELOG.md index 5435667..64e0b0a 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,56 @@ All notable changes to this project will be documented in this file. The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/). +## [1.3.0] - 2026-06-16 + +### Added +- **Go plugin** — third language, generates production-ready Go backend: + - Router: **chi v5** (thin net/http wrapper, no framework lock-in) + - ORM: **GORM v2** + PostgreSQL via pgx driver + - Migration: **golang-migrate** (up/down SQL files, consistent with Alembic/Prisma) + - Config: **godotenv** + `internal/config` struct + - Dev: **air** hot reload + **Makefile** with `dev`, `build`, `migrate-up/down`, `test`, `lint` + - Logger: **slog** (stdlib, no dependency) +- **`--jwt` addon (Go)** — JWT auth overlay: `AuthService`, `Claims`, bcrypt password hashing, `middleware.Auth()` chi middleware, user CRUD handlers, GORM soft-delete model, migration `000002_create_users` +- **`--module-path` flag** — Go module path (e.g. `github.com/username/my-app`); prompted interactively for Go projects, defaults to `github.com/example/` in non-TTY +- **Docker addon (Go)** — multi-stage `Dockerfile` (golang:1.22-alpine → alpine:3.20), `docker-compose.yml` with PostgreSQL healthcheck +- **CI addon (Go)** — GitHub Actions workflow with PostgreSQL service, `go vet`, `go test -race` +- **Testing addon (Go)** — `tests/health_test.go` using `net/http/httptest`, no external deps +- **`archgen add jwt`** — inject JWT addon into existing Go projects; reads module path from `go.mod` for correct placeholder replacement +- Go language choice added to interactive prompts; `database` prompt skipped for Go (always PostgreSQL) +- Go project detection in `detectProjectLanguage()` via `go.mod` presence + +### Fixed +- `--module-path` flag correctly maps to `options.modulePath` (previously `--module` did not bind due to Commander camelCase convention) + +## [1.2.0] - 2026-06-16 + +### Added +- **`--observability` addon (Node.js + Python)** — production-grade observability stack: + - Node.js: `src/telemetry.ts` (OpenTelemetry SDK auto-init), `src/plugins/metrics.plugin.ts` (Prometheus `/metrics` endpoint via `prom-client`), `src/config/sentry.ts` (Sentry stub, gated by `SENTRY_DSN`) + - Python: `app/core/observability.py` (`instrument_app(app)` — OTel + FastAPI instrumentation + Prometheus via `prometheus-fastapi-instrumentator`), `app/core/sentry.py` (Sentry stub) + - Both: `observability/prometheus.yml`, `observability/docker-compose.observability.yml` (Prometheus + Grafana + OTel Collector), `observability/otel-collector.yml`, `observability/grafana-dashboard.json` (request rate, p95 latency, error rate, active handles panels) + - Does **not** overwrite `app.ts`/`main.py` — overlay is additive, safe to combine with oauth/api-docs + - Deps injected: `@opentelemetry/sdk-node`, `@opentelemetry/auto-instrumentations-node`, `@opentelemetry/exporter-trace-otlp-http`, `prom-client` (Node); `opentelemetry-sdk`, `opentelemetry-instrumentation-fastapi`, `opentelemetry-exporter-otlp-proto-http`, `prometheus-fastapi-instrumentator`, `structlog` (Python) +- **`--sentry` sub-flag** — pair with `--observability` to inject `@sentry/node` / `sentry-sdk[fastapi]` into the project manifest; also supported via `archgen add observability --sentry` +- **`--pre-commit` addon (Python only)** — git hook suite matching Node.js `--husky`: + - `.pre-commit-config.yaml` with `ruff` (lint + format), `ruff-format`, `black`, `mypy`, trailing-whitespace, end-of-file-fixer + - Injects `pre-commit>=3.7.0` into `[project.optional-dependencies] dev` + - Next steps printed: `pre-commit install` +- **`archgen add observability --sentry`** — `AddAddonOptions` now accepts `sentry?: boolean` so Sentry dep injection works for post-scaffold addon injection too +- **Improved test quality in generated projects**: + - Node.js testing addon: fixed import path bug (`../../../base/src/app` → `../src/app`); added `tests/users.test.ts` (401/200/404 flow with auth); `jest.config.js` overlay with `coverageThreshold: 80%` + - Python testing addon: `conftest.py` now overrides `get_db` dependency → SQLite in-memory isolation; added `tests/test_auth.py` (register/login/401 flows) and `tests/test_users.py` (CRUD + auth guard); `pytest.ini` overlay with `--cov-fail-under=80`; `aiosqlite>=0.20.0` injected into dev deps automatically + +### Fixed +- **Python testing addon** — `aiosqlite` (required by `sqlite+aiosqlite:///:memory:` in conftest) now injected into `[project.optional-dependencies] dev` when `--testing` is enabled +- **Python dep injection** — new `_injectPyprojectDeps(deps[])` batch helper replaces single-dep injection; new `_injectPyprojectDevDeps(deps[])` targets `[project.optional-dependencies] dev` array for dev-only packages +- **`archgen add` argument description** — removed stale `(docker|testing|ci|husky)` hint; now points to `archgen list` + +### Tests +- **New unit test file** `observability-addon.test.ts` — 18 tests covering Node and Python observability wiring, pre-commit overlay, sentry flag, aiosqlite injection, dry-run guards, and no-app.ts-overwrite assertion +- Total: **160 unit tests** (up from 142) + ## [1.1.0] - 2026-06-02 ### Added diff --git a/README.md b/README.md index 49b09c6..c15340c 100644 --- a/README.md +++ b/README.md @@ -1,6 +1,6 @@ # archgen -> A CLI tool that generates production-ready backend projects in seconds — so you can focus on building, not configuring. +> A CLI tool that generates production-ready backend projects in seconds — Node.js, Python, or Go — so you can focus on building, not configuring. --- @@ -32,6 +32,9 @@ Answer a few prompts. Your project is ready in under a second. - Optional S3 storage (AWS S3 / R2 / MinIO) — upload, presigned URLs, delete, exists - Optional Claude Code setup — `CLAUDE.md` + pre-configured skills for Claude Code agent - Optional Cursor setup — `.cursor/skills/` with pre-configured skills for Cursor agent +- Optional observability stack — OpenTelemetry traces + Prometheus `/metrics` + Grafana dashboard + OTel Collector (both Node.js and Python) +- Optional Sentry error tracking — injected alongside `--observability` +- Optional pre-commit hooks (Python) — ruff + black + mypy via `.pre-commit-config.yaml` - Auto update notifier — hints when a new version is available - Interactive CLI prompts — no flags required - Post-scaffold addon injection with `archgen add` @@ -45,6 +48,7 @@ Answer a few prompts. Your project is ready in under a second. |----------|-------| | Node.js | TypeScript · Fastify · Prisma · MariaDB/MySQL · Redis · JWT · Zod · Pino · Swagger | | Python | FastAPI · SQLAlchemy 2.0 · Alembic · PostgreSQL · Redis · Pydantic v2 · APScheduler | +| Go | chi v5 · GORM v2 · PostgreSQL · golang-migrate · godotenv · slog · air | --- @@ -68,6 +72,11 @@ archgen create my-app --cursor # add Cursor agent setup ( archgen create my-app --email # add nodemailer SMTP support archgen create my-app --s3 # add AWS S3 / R2 / MinIO support archgen create my-app --claude-code --cursor # add both AI agent setups +archgen create my-app --observability # add OTel + Prometheus + Grafana +archgen create my-app --observability --sentry # add observability + Sentry +archgen create my-app --pre-commit # add pre-commit hooks (Python only) +archgen create my-api --language go --module-path github.com/user/my-api # Go project +archgen create my-api --language go --jwt --docker # Go with JWT auth + Docker archgen create my-app --force # overwrite existing directory archgen create my-app --dry-run # preview files without writing archgen create my-app --skip-git # skip automatic git init @@ -87,6 +96,10 @@ archgen add claude-code # Claude Code setup (CLAUDE.md + .claude/skills/) archgen add cursor # Cursor agent setup (.cursor/skills/) archgen add email # nodemailer SMTP email support archgen add s3 # AWS S3 / R2 / MinIO storage support +archgen add observability # OTel + Prometheus + Grafana +archgen add observability --sentry # with Sentry error tracking +archgen add pre-commit # pre-commit hooks (Python only: ruff + black + mypy) +archgen add jwt # JWT auth addon (Go only) archgen add ci --dry-run # preview changes without writing ``` @@ -123,6 +136,11 @@ archgen doctor # check that required tools are installed | `--api-docs` | Include Scalar + Swagger API docs | `false` | | `--email` | Include nodemailer SMTP email support | `false` | | `--s3` | Include AWS S3 / R2 / MinIO storage | `false` | +| `--observability` | Include OTel + Prometheus + Grafana stack | `false` | +| `--sentry` | Include Sentry (pair with `--observability`) | `false` | +| `--pre-commit` | Include pre-commit hooks (Python only) | `false` | +| `--jwt` | Include JWT auth routes + middleware (Go only) | `false` | +| `--module-path` | Go module path (e.g. `github.com/user/app`) | prompt | | `-a, --author` | Author name | `Your Name` | | `-d, --description` | Project description | — | | `--force` | Overwrite existing directory | `false` | diff --git a/cli/command/add.ts b/cli/command/add.ts index 8b4c67d..a6baadb 100644 --- a/cli/command/add.ts +++ b/cli/command/add.ts @@ -6,10 +6,11 @@ import { logger } from "../../core/logger"; import { registry } from "../../core/registry"; export const addCommand = new Command("add") - .argument("", "Addon to inject (docker|testing|ci|husky)") + .argument("", "Addon to inject (run 'archgen list' to see available addons)") .option("--dry-run", "Preview files without writing", false) + .option("--sentry", "Include Sentry dep when adding observability addon", false) .description("Add an addon to an existing project in the current directory") - .action(async (addon: string, options: { dryRun: boolean }) => { + .action(async (addon: string, options: { dryRun: boolean; sentry: boolean }) => { const cwd = process.cwd(); const language = detectProjectLanguage(cwd); @@ -37,7 +38,7 @@ export const addCommand = new Command("add") const archgen = new ArchGen(); try { - await archgen.addAddon(cwd, language, addon, { dryRun: options.dryRun }); + await archgen.addAddon(cwd, language, addon, { dryRun: options.dryRun, sentry: options.sentry }); } catch (error) { if (error instanceof ArchGenError) { logger.error(error.message); diff --git a/cli/command/index.ts b/cli/command/index.ts index 33df4fc..c4d8569 100644 --- a/cli/command/index.ts +++ b/cli/command/index.ts @@ -7,6 +7,7 @@ import { findPresetFile, loadPreset, mergePreset } from "../../core/config-prese const VALID_NODE_DATABASES = ["mysql", "postgresql"]; const VALID_PYTHON_DATABASES = ["postgresql", "sqlite"]; +const VALID_GO_DATABASES = ["postgresql"]; export const createCommand = new Command("create") .argument("", "Name of the project") @@ -23,6 +24,11 @@ export const createCommand = new Command("create") .option("--email", "Include email service (SMTP)") .option("--s3", "Include AWS S3 / Cloudflare R2 / MinIO storage service") .option("--queue", "Include background job queue (BullMQ for Node, arq for Python)") + .option("--pre-commit", "Include pre-commit hooks (Python only: ruff + black + mypy)") + .option("--observability", "Include observability stack (OpenTelemetry + Prometheus + Sentry stub)") + .option("--sentry", "Include Sentry error tracking dependency (requires --observability)") + .option("--jwt", "Include JWT authentication (Go only)") + .option("--module-path ", "Go module path (e.g. github.com/username/my-app)") .option("--all", "Include all addons (docker + testing + ci)", false) .option("-a, --author ", "Author name") .option("-d, --description ", "Project description") @@ -52,7 +58,10 @@ export const createCommand = new Command("create") if (finalOptions.database) { const lang = finalOptions.language; - const valid = lang === "python" ? VALID_PYTHON_DATABASES : VALID_NODE_DATABASES; + const valid = + lang === "python" ? VALID_PYTHON_DATABASES : + lang === "go" ? VALID_GO_DATABASES : + VALID_NODE_DATABASES; if (!valid.includes(finalOptions.database)) { logger.error( `Invalid database "${finalOptions.database}" for ${lang}. Must be one of: ${valid.join(", ")}`, diff --git a/cli/prompts.ts b/cli/prompts.ts index 62992d6..43b92cd 100644 --- a/cli/prompts.ts +++ b/cli/prompts.ts @@ -15,6 +15,10 @@ export async function promptMissingOptions( husky: false, claudeCode: false, cursor: false, + preCommit: false, + observability: false, + sentry: false, + jwt: false, ...options, language: options.language ?? "node", }; @@ -30,6 +34,7 @@ export async function promptMissingOptions( choices: [ { title: "Node.js (TypeScript + Fastify)", value: "node" }, { title: "Python (FastAPI)", value: "python" }, + { title: "Go (chi + GORM + PostgreSQL)", value: "go" }, ], }); } @@ -59,6 +64,19 @@ export async function promptMissingOptions( }); } + if (!options.modulePath) { + const fixedLang = options.language; + questions.push({ + type: (_prev, values) => { + const lang = fixedLang ?? (values as GenerateOptions).language; + return lang === "go" ? "text" : null; + }, + name: "modulePath", + message: "Go module path:", + initial: `github.com/example/${_projectName}`, + }); + } + if (!options.docker) { questions.push({ type: "confirm", @@ -112,20 +130,26 @@ export async function promptMissingOptions( }); } - const extraAddons = ["websocket", "oauth", "apiDocs", "email", "s3", "queue"] as const; - const anyExtraSet = extraAddons.some((k) => options[k] !== undefined); + const extraAddons = ["websocket", "oauth", "apiDocs", "email", "s3", "queue", "observability", "preCommit", "jwt"] as const; + const anyExtraSet = extraAddons.some((k) => options[k as keyof GenerateOptions] !== undefined); if (!anyExtraSet) { const fixedLang = options.language; questions.push({ type: (_prev, values) => { const lang = fixedLang ?? (values as GenerateOptions).language; - return lang === "node" || lang === "python" ? "multiselect" : null; + return lang === "node" || lang === "python" || lang === "go" ? "multiselect" : null; }, name: "extraAddons", message: "Include extra addons? (Space to select, Enter to confirm)", choices: (_prev, values) => { const lang = fixedLang ?? (values as GenerateOptions).language; + if (lang === "go") { + return [ + { title: "JWT authentication", value: "jwt", selected: false }, + ]; + } const nodeOnly = lang === "node"; + const pythonOnly = lang === "python"; const all = [ { title: "WebSocket", value: "websocket", selected: false }, { title: "OAuth2 (Google + GitHub)", value: "oauth", selected: false }, @@ -133,9 +157,12 @@ export async function promptMissingOptions( { title: "Email (SMTP)", value: "email", selected: false }, { title: "Storage (S3 / R2 / MinIO)", value: "s3", selected: false }, { title: "Queue (BullMQ / arq)", value: "queue", selected: false }, + { title: "Observability (OTel + Prometheus)", value: "observability", selected: false }, { title: "Husky + lint-staged", value: "husky", selected: false, disabled: !nodeOnly }, + { title: "pre-commit hooks (ruff + black + mypy)", value: "preCommit", selected: false, disabled: !pythonOnly }, ]; - return nodeOnly ? all : all.filter((c) => c.value !== "husky"); + if (nodeOnly) return all.filter((c) => c.value !== "preCommit"); + return all.filter((c) => c.value !== "husky"); }, hint: "none to skip", }); @@ -174,6 +201,9 @@ export async function promptMissingOptions( raw.s3 = selected.includes("s3"); raw.queue = selected.includes("queue"); if (selected.includes("husky")) raw.husky = true; + if (selected.includes("observability")) raw.observability = true; + if (selected.includes("preCommit")) raw.preCommit = true; + if (selected.includes("jwt")) raw.jwt = true; delete raw.extraAddons; } return raw as unknown as GenerateOptions; diff --git a/core/detector.ts b/core/detector.ts index 3e45e1a..26977f1 100644 --- a/core/detector.ts +++ b/core/detector.ts @@ -1,12 +1,13 @@ import fs from "fs-extra"; import path from "path"; -export type DetectedLanguage = "node" | "python" | null; +export type DetectedLanguage = "node" | "python" | "go" | null; export function detectProjectLanguage(cwd: string): DetectedLanguage { const pkgPath = path.join(cwd, "package.json"); const pyprojectPath = path.join(cwd, "pyproject.toml"); const mainPyPath = path.join(cwd, "main.py"); + const goModPath = path.join(cwd, "go.mod"); if (fs.existsSync(pkgPath)) { try { @@ -25,5 +26,9 @@ export function detectProjectLanguage(cwd: string): DetectedLanguage { return "python"; } + if (fs.existsSync(goModPath)) { + return "go"; + } + return null; } diff --git a/core/registry.ts b/core/registry.ts index 19ea8e9..15cc733 100644 --- a/core/registry.ts +++ b/core/registry.ts @@ -1,5 +1,6 @@ import { NodePlugin } from "../plugins/node"; import { PythonPlugin } from "../plugins/python"; +import { GoPlugin } from "../plugins/go"; import { Plugin } from "../types"; class PluginRegistry { @@ -12,7 +13,8 @@ class PluginRegistry { private registerDefaults() { this.register("node", new NodePlugin()); - this.register("python", new PythonPlugin()) + this.register("python", new PythonPlugin()); + this.register("go", new GoPlugin()); } register(name: string, plugin: Plugin) { diff --git a/package.json b/package.json index 9296c3c..ce6532f 100644 --- a/package.json +++ b/package.json @@ -1,7 +1,7 @@ { "name": "@kidkender/archgen", - "version": "1.1.0", - "description": "Generate production-ready Node.js and Python project structures in seconds", + "version": "1.3.0", + "description": "Generate production-ready Node.js, Python, and Go project structures in seconds", "main": "dist/index.js", "module": "dist/index.mjs", "exports": { diff --git a/plugins/go/config.ts b/plugins/go/config.ts new file mode 100644 index 0000000..1e78629 --- /dev/null +++ b/plugins/go/config.ts @@ -0,0 +1,7 @@ +import { PluginConfig } from "../../types"; + +export const goConfig: PluginConfig = { + name: "Go", + description: "Go · chi · GORM · PostgreSQL · golang-migrate · slog", + addons: ["docker", "testing", "ci", "jwt"], +}; diff --git a/plugins/go/index.ts b/plugins/go/index.ts new file mode 100644 index 0000000..067bb6e --- /dev/null +++ b/plugins/go/index.ts @@ -0,0 +1,117 @@ +import path from "path"; +import { BasePlugin, AddonEntry } from "../../core/base-plugin"; +import { TemplateVariables } from "../../core/template-engine"; +import { AddAddonOptions, GenerateOptions, StackInfo } from "../../types"; +import { goConfig } from "./config"; + +export class GoPlugin extends BasePlugin { + readonly name = goConfig.name; + readonly description = goConfig.description; + readonly addons = goConfig.addons; + readonly stack: StackInfo = { + runtime: "Go 1.22+", + framework: "chi v5", + orm: "GORM v2", + database: "PostgreSQL (pgx)", + cache: "—", + auth: "JWT addon (golang-jwt/jwt)", + validation: "stdlib", + testing: "Go testing + httptest", + extras: ["golang-migrate", "godotenv", "slog", "air"], + }; + + private _cachedModulePath?: string; + + protected get relativeTemplateDir(): string { + return "plugins/go/template"; + } + + protected getVariables(projectName: string, options: GenerateOptions): TemplateVariables { + return { + PROJECT_NAME: projectName, + MODULE_PATH: options.modulePath ?? `github.com/example/${projectName}`, + AUTHOR: options.author ?? "Your Name", + DESCRIPTION: options.description ?? "A Go backend project", + }; + } + + protected getAddonEntries(options: GenerateOptions, addonsPath: string): AddonEntry[] { + return [ + { + condition: !!options.docker, + path: path.join(addonsPath, "docker"), + label: "Docker support", + }, + { + condition: !!options.testing, + path: path.join(addonsPath, "testing"), + label: "testing setup", + }, + { + condition: !!options.ci, + path: path.join(addonsPath, "ci"), + label: "GitHub Actions CI", + }, + { + condition: !!options.jwt, + path: path.join(addonsPath, "jwt"), + label: "JWT authentication", + }, + ]; + } + + async applyAddon(projectPath: string, addon: string, options: AddAddonOptions): Promise { + try { + const goModContent = await this.fs.readFile(path.join(projectPath, "go.mod")); + const match = goModContent.match(/^module\s+(\S+)/m); + this._cachedModulePath = match ? match[1] : undefined; + } catch { + this._cachedModulePath = undefined; + } + await super.applyAddon(projectPath, addon, options); + this._cachedModulePath = undefined; + } + + protected buildApplyAddonVariables(projectName: string): TemplateVariables { + return { + PROJECT_NAME: projectName, + MODULE_PATH: this._cachedModulePath ?? `github.com/example/${projectName}`, + AUTHOR: "Your Name", + DESCRIPTION: "", + }; + } + + protected async readProjectName(projectPath: string): Promise { + const content = await this.fs.readFile(path.join(projectPath, "go.mod")); + const match = content.match(/^module\s+(\S+)/m); + if (match) { + const parts = match[1].split("/"); + return parts[parts.length - 1]; + } + return path.basename(projectPath); + } + + showNextSteps(projectName: string, options: GenerateOptions): void { + console.log(""); + console.log(" Next steps:"); + console.log(` cd ${projectName}`); + console.log(` cp .env.example .env`); + console.log(` go mod tidy`); + if (options.docker) { + console.log(` docker compose up`); + } else { + console.log(` make migrate-up`); + console.log(` air # hot reload — or: go run ./cmd/server`); + } + if (options.jwt) { + console.log(""); + console.log(" Auth routes:"); + console.log(" POST /api/v1/auth/register"); + console.log(" POST /api/v1/auth/login"); + console.log(" GET /api/v1/users Bearer token required"); + console.log(" GET /api/v1/users/me Bearer token required"); + console.log(" GET /api/v1/users/{id} Bearer token required"); + } + console.log(""); + } +} diff --git a/plugins/go/template/addons/ci/.github/workflows/ci.yml b/plugins/go/template/addons/ci/.github/workflows/ci.yml new file mode 100644 index 0000000..e7fcc8d --- /dev/null +++ b/plugins/go/template/addons/ci/.github/workflows/ci.yml @@ -0,0 +1,50 @@ +name: CI + +on: + push: + branches: [main] + pull_request: + branches: [main] + +jobs: + test: + runs-on: ubuntu-latest + + services: + postgres: + image: postgres:16 + env: + POSTGRES_USER: postgres + POSTGRES_PASSWORD: postgres + POSTGRES_DB: testdb + ports: + - 5432:5432 + options: >- + --health-cmd pg_isready + --health-interval 10s + --health-timeout 5s + --health-retries 5 + + env: + DATABASE_URL: postgres://postgres:postgres@localhost:5432/testdb?sslmode=disable + JWT_SECRET: test-secret + + steps: + - uses: actions/checkout@v4 + + - uses: actions/setup-go@v5 + with: + go-version: '1.22' + cache: true + + - name: Download dependencies + run: go mod download + + - name: Vet + run: go vet ./... + + - name: Test + run: go test -race -coverprofile=coverage.out ./... + + - name: Coverage report + run: go tool cover -func=coverage.out diff --git a/plugins/go/template/addons/docker/.dockerignore b/plugins/go/template/addons/docker/.dockerignore new file mode 100644 index 0000000..9395bb0 --- /dev/null +++ b/plugins/go/template/addons/docker/.dockerignore @@ -0,0 +1,6 @@ +bin/ +tmp/ +.env +.git/ +vendor/ +*.test diff --git a/plugins/go/template/addons/docker/Dockerfile b/plugins/go/template/addons/docker/Dockerfile new file mode 100644 index 0000000..2aaceb9 --- /dev/null +++ b/plugins/go/template/addons/docker/Dockerfile @@ -0,0 +1,13 @@ +FROM golang:1.22-alpine AS builder +WORKDIR /app +COPY go.mod go.sum ./ +RUN go mod download +COPY . . +RUN CGO_ENABLED=0 GOOS=linux go build -ldflags="-s -w" -o bin/{{PROJECT_NAME}} ./cmd/server + +FROM alpine:3.20 +RUN apk --no-cache add ca-certificates tzdata +WORKDIR /app +COPY --from=builder /app/bin/{{PROJECT_NAME}} . +EXPOSE 8080 +CMD ["./{{PROJECT_NAME}}"] diff --git a/plugins/go/template/addons/docker/docker-compose.yml b/plugins/go/template/addons/docker/docker-compose.yml new file mode 100644 index 0000000..6943079 --- /dev/null +++ b/plugins/go/template/addons/docker/docker-compose.yml @@ -0,0 +1,32 @@ +services: + app: + build: . + ports: + - "8080:8080" + environment: + PORT: "8080" + DATABASE_URL: postgres://postgres:postgres@db:5432/{{PROJECT_NAME}}?sslmode=disable + JWT_SECRET: changeme + depends_on: + db: + condition: service_healthy + restart: unless-stopped + + db: + image: postgres:16-alpine + environment: + POSTGRES_USER: postgres + POSTGRES_PASSWORD: postgres + POSTGRES_DB: {{PROJECT_NAME}} + ports: + - "5432:5432" + volumes: + - pgdata:/var/lib/postgresql/data + healthcheck: + test: ["CMD-SHELL", "pg_isready -U postgres"] + interval: 5s + timeout: 5s + retries: 5 + +volumes: + pgdata: diff --git a/plugins/go/template/addons/jwt/go.mod b/plugins/go/template/addons/jwt/go.mod new file mode 100644 index 0000000..b067cba --- /dev/null +++ b/plugins/go/template/addons/jwt/go.mod @@ -0,0 +1,13 @@ +module {{MODULE_PATH}} + +go 1.22 + +require ( + github.com/go-chi/chi/v5 v5.1.0 + github.com/golang-jwt/jwt/v5 v5.2.1 + github.com/golang-migrate/migrate/v4 v4.18.1 + github.com/joho/godotenv v1.5.1 + golang.org/x/crypto v0.27.0 + gorm.io/driver/postgres v1.5.9 + gorm.io/gorm v1.25.12 +) diff --git a/plugins/go/template/addons/jwt/internal/handlers/auth.go b/plugins/go/template/addons/jwt/internal/handlers/auth.go new file mode 100644 index 0000000..767705c --- /dev/null +++ b/plugins/go/template/addons/jwt/internal/handlers/auth.go @@ -0,0 +1,52 @@ +package handlers + +import ( + "encoding/json" + "net/http" +) + +type registerRequest struct { + Username string `json:"username"` + Email string `json:"email"` + Password string `json:"password"` +} + +type loginRequest struct { + Email string `json:"email"` + Password string `json:"password"` +} + +func (h *Handler) Register(w http.ResponseWriter, r *http.Request) { + var req registerRequest + if err := json.NewDecoder(r.Body).Decode(&req); err != nil { + w.Header().Set("Content-Type", "application/json") + http.Error(w, `{"error":"invalid request body"}`, http.StatusBadRequest) + return + } + user, err := h.authSvc.Register(req.Username, req.Email, req.Password) + if err != nil { + w.Header().Set("Content-Type", "application/json") + http.Error(w, `{"error":"registration failed","detail":"`+err.Error()+`"}`, http.StatusConflict) + return + } + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(http.StatusCreated) + _ = json.NewEncoder(w).Encode(map[string]any{"data": user}) +} + +func (h *Handler) Login(w http.ResponseWriter, r *http.Request) { + var req loginRequest + if err := json.NewDecoder(r.Body).Decode(&req); err != nil { + w.Header().Set("Content-Type", "application/json") + http.Error(w, `{"error":"invalid request body"}`, http.StatusBadRequest) + return + } + token, err := h.authSvc.Login(req.Email, req.Password) + if err != nil { + w.Header().Set("Content-Type", "application/json") + http.Error(w, `{"error":"invalid credentials"}`, http.StatusUnauthorized) + return + } + w.Header().Set("Content-Type", "application/json") + _ = json.NewEncoder(w).Encode(map[string]any{"data": map[string]string{"token": token}}) +} diff --git a/plugins/go/template/addons/jwt/internal/handlers/handlers.go b/plugins/go/template/addons/jwt/internal/handlers/handlers.go new file mode 100644 index 0000000..2a8b98c --- /dev/null +++ b/plugins/go/template/addons/jwt/internal/handlers/handlers.go @@ -0,0 +1,15 @@ +package handlers + +import ( + "{{MODULE_PATH}}/internal/services" + "gorm.io/gorm" +) + +type Handler struct { + db *gorm.DB + authSvc *services.AuthService +} + +func New(db *gorm.DB, authSvc *services.AuthService) *Handler { + return &Handler{db: db, authSvc: authSvc} +} diff --git a/plugins/go/template/addons/jwt/internal/handlers/users.go b/plugins/go/template/addons/jwt/internal/handlers/users.go new file mode 100644 index 0000000..8ff12e5 --- /dev/null +++ b/plugins/go/template/addons/jwt/internal/handlers/users.go @@ -0,0 +1,47 @@ +package handlers + +import ( + "encoding/json" + "net/http" + "strconv" + + authmw "{{MODULE_PATH}}/internal/middleware" + "{{MODULE_PATH}}/internal/models" + "github.com/go-chi/chi/v5" +) + +func (h *Handler) ListUsers(w http.ResponseWriter, r *http.Request) { + var users []models.User + h.db.Select("id, username, email, created_at, updated_at").Find(&users) + w.Header().Set("Content-Type", "application/json") + _ = json.NewEncoder(w).Encode(map[string]any{"data": users}) +} + +func (h *Handler) GetUser(w http.ResponseWriter, r *http.Request) { + id, err := strconv.Atoi(chi.URLParam(r, "id")) + if err != nil { + w.Header().Set("Content-Type", "application/json") + http.Error(w, `{"error":"invalid id"}`, http.StatusBadRequest) + return + } + var user models.User + if err := h.db.First(&user, id).Error; err != nil { + w.Header().Set("Content-Type", "application/json") + http.Error(w, `{"error":"user not found"}`, http.StatusNotFound) + return + } + w.Header().Set("Content-Type", "application/json") + _ = json.NewEncoder(w).Encode(map[string]any{"data": user}) +} + +func (h *Handler) Me(w http.ResponseWriter, r *http.Request) { + userID, _ := r.Context().Value(authmw.UserIDKey).(uint) + var user models.User + if err := h.db.First(&user, userID).Error; err != nil { + w.Header().Set("Content-Type", "application/json") + http.Error(w, `{"error":"user not found"}`, http.StatusNotFound) + return + } + w.Header().Set("Content-Type", "application/json") + _ = json.NewEncoder(w).Encode(map[string]any{"data": user}) +} diff --git a/plugins/go/template/addons/jwt/internal/middleware/auth.go b/plugins/go/template/addons/jwt/internal/middleware/auth.go new file mode 100644 index 0000000..86c14cb --- /dev/null +++ b/plugins/go/template/addons/jwt/internal/middleware/auth.go @@ -0,0 +1,34 @@ +package middleware + +import ( + "context" + "net/http" + "strings" + + "{{MODULE_PATH}}/internal/services" +) + +type contextKey string + +const UserIDKey contextKey = "userID" + +func Auth(authSvc *services.AuthService) func(http.Handler) http.Handler { + return func(next http.Handler) http.Handler { + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + header := r.Header.Get("Authorization") + if !strings.HasPrefix(header, "Bearer ") { + w.Header().Set("Content-Type", "application/json") + http.Error(w, `{"error":"missing or invalid authorization header"}`, http.StatusUnauthorized) + return + } + claims, err := authSvc.ValidateToken(strings.TrimPrefix(header, "Bearer ")) + if err != nil { + w.Header().Set("Content-Type", "application/json") + http.Error(w, `{"error":"invalid token"}`, http.StatusUnauthorized) + return + } + ctx := context.WithValue(r.Context(), UserIDKey, claims.UserID) + next.ServeHTTP(w, r.WithContext(ctx)) + }) + } +} diff --git a/plugins/go/template/addons/jwt/internal/models/user.go b/plugins/go/template/addons/jwt/internal/models/user.go new file mode 100644 index 0000000..c884f9a --- /dev/null +++ b/plugins/go/template/addons/jwt/internal/models/user.go @@ -0,0 +1,17 @@ +package models + +import ( + "time" + + "gorm.io/gorm" +) + +type User struct { + ID uint `gorm:"primaryKey" json:"id"` + Username string `gorm:"uniqueIndex;not null" json:"username"` + Email string `gorm:"uniqueIndex;not null" json:"email"` + Password string `gorm:"not null" json:"-"` + CreatedAt time.Time `json:"createdAt"` + UpdatedAt time.Time `json:"updatedAt"` + DeletedAt gorm.DeletedAt `gorm:"index" json:"-"` +} diff --git a/plugins/go/template/addons/jwt/internal/router/router.go b/plugins/go/template/addons/jwt/internal/router/router.go new file mode 100644 index 0000000..1722879 --- /dev/null +++ b/plugins/go/template/addons/jwt/internal/router/router.go @@ -0,0 +1,38 @@ +package router + +import ( + "{{MODULE_PATH}}/internal/config" + "{{MODULE_PATH}}/internal/handlers" + authmw "{{MODULE_PATH}}/internal/middleware" + "{{MODULE_PATH}}/internal/services" + "github.com/go-chi/chi/v5" + chimw "github.com/go-chi/chi/v5/middleware" + "gorm.io/gorm" +) + +func New(cfg *config.Config, db *gorm.DB) *chi.Mux { + r := chi.NewRouter() + + r.Use(chimw.Logger) + r.Use(chimw.Recoverer) + r.Use(chimw.RequestID) + + authSvc := services.NewAuthService(db, cfg.JWTSecret) + h := handlers.New(db, authSvc) + + r.Get("/health", h.Health) + + r.Route("/api/v1", func(r chi.Router) { + r.Post("/auth/register", h.Register) + r.Post("/auth/login", h.Login) + + r.Group(func(r chi.Router) { + r.Use(authmw.Auth(authSvc)) + r.Get("/users", h.ListUsers) + r.Get("/users/me", h.Me) + r.Get("/users/{id}", h.GetUser) + }) + }) + + return r +} diff --git a/plugins/go/template/addons/jwt/internal/services/auth.go b/plugins/go/template/addons/jwt/internal/services/auth.go new file mode 100644 index 0000000..dd61d5c --- /dev/null +++ b/plugins/go/template/addons/jwt/internal/services/auth.go @@ -0,0 +1,79 @@ +package services + +import ( + "errors" + "time" + + "{{MODULE_PATH}}/internal/models" + "github.com/golang-jwt/jwt/v5" + "golang.org/x/crypto/bcrypt" + "gorm.io/gorm" +) + +type AuthService struct { + db *gorm.DB + jwtSecret []byte +} + +func NewAuthService(db *gorm.DB, secret string) *AuthService { + return &AuthService{db: db, jwtSecret: []byte(secret)} +} + +type Claims struct { + UserID uint `json:"userId"` + Email string `json:"email"` + jwt.RegisteredClaims +} + +func (s *AuthService) Register(username, email, password string) (*models.User, error) { + hash, err := bcrypt.GenerateFromPassword([]byte(password), bcrypt.DefaultCost) + if err != nil { + return nil, err + } + user := &models.User{ + Username: username, + Email: email, + Password: string(hash), + } + if err := s.db.Create(user).Error; err != nil { + return nil, err + } + return user, nil +} + +func (s *AuthService) Login(email, password string) (string, error) { + var user models.User + if err := s.db.Where("email = ?", email).First(&user).Error; err != nil { + return "", errors.New("invalid credentials") + } + if err := bcrypt.CompareHashAndPassword([]byte(user.Password), []byte(password)); err != nil { + return "", errors.New("invalid credentials") + } + claims := Claims{ + UserID: user.ID, + Email: user.Email, + RegisteredClaims: jwt.RegisteredClaims{ + ExpiresAt: jwt.NewNumericDate(time.Now().Add(7 * 24 * time.Hour)), + IssuedAt: jwt.NewNumericDate(time.Now()), + }, + } + token := jwt.NewWithClaims(jwt.SigningMethodHS256, claims) + return token.SignedString(s.jwtSecret) +} + +func (s *AuthService) ValidateToken(tokenStr string) (*Claims, error) { + token, err := jwt.ParseWithClaims(tokenStr, &Claims{}, func(t *jwt.Token) (interface{}, error) { + if _, ok := t.Method.(*jwt.SigningMethodHMAC); !ok { + return nil, errors.New("unexpected signing method") + } + return s.jwtSecret, nil + }) + if err != nil { + return nil, err + } + claims, ok := token.Claims.(*Claims) + if !ok || !token.Valid { + return nil, errors.New("invalid token") + } + return claims, nil +} diff --git a/plugins/go/template/addons/jwt/migrations/000002_create_users.down.sql b/plugins/go/template/addons/jwt/migrations/000002_create_users.down.sql new file mode 100644 index 0000000..c99ddcd --- /dev/null +++ b/plugins/go/template/addons/jwt/migrations/000002_create_users.down.sql @@ -0,0 +1 @@ +DROP TABLE IF EXISTS users; diff --git a/plugins/go/template/addons/jwt/migrations/000002_create_users.up.sql b/plugins/go/template/addons/jwt/migrations/000002_create_users.up.sql new file mode 100644 index 0000000..520fced --- /dev/null +++ b/plugins/go/template/addons/jwt/migrations/000002_create_users.up.sql @@ -0,0 +1,12 @@ +CREATE TABLE IF NOT EXISTS users ( + id SERIAL PRIMARY KEY, + username VARCHAR(255) UNIQUE NOT NULL, + email VARCHAR(255) UNIQUE NOT NULL, + password VARCHAR(255) NOT NULL, + created_at TIMESTAMPTZ DEFAULT NOW(), + updated_at TIMESTAMPTZ DEFAULT NOW(), + deleted_at TIMESTAMPTZ +); + +CREATE INDEX IF NOT EXISTS idx_users_email ON users(email); +CREATE INDEX IF NOT EXISTS idx_users_deleted_at ON users(deleted_at); diff --git a/plugins/go/template/addons/testing/tests/health_test.go b/plugins/go/template/addons/testing/tests/health_test.go new file mode 100644 index 0000000..a7f9384 --- /dev/null +++ b/plugins/go/template/addons/testing/tests/health_test.go @@ -0,0 +1,32 @@ +package tests + +import ( + "encoding/json" + "net/http" + "net/http/httptest" + "testing" + + "{{MODULE_PATH}}/internal/config" + "{{MODULE_PATH}}/internal/router" +) + +func TestHealthEndpoint(t *testing.T) { + cfg := &config.Config{Port: "8080", JWTSecret: "test-secret"} + r := router.New(cfg, nil) + + req := httptest.NewRequest(http.MethodGet, "/health", nil) + w := httptest.NewRecorder() + r.ServeHTTP(w, req) + + if w.Code != http.StatusOK { + t.Errorf("expected 200, got %d", w.Code) + } + + var body map[string]string + if err := json.NewDecoder(w.Body).Decode(&body); err != nil { + t.Fatalf("failed to decode response: %v", err) + } + if body["status"] != "ok" { + t.Errorf("expected status=ok, got %q", body["status"]) + } +} diff --git a/plugins/go/template/base/.air.toml b/plugins/go/template/base/.air.toml new file mode 100644 index 0000000..4b530b0 --- /dev/null +++ b/plugins/go/template/base/.air.toml @@ -0,0 +1,20 @@ +root = "." +tmp_dir = "tmp" + +[build] +cmd = "go build -o ./tmp/main ./cmd/server" +bin = "./tmp/main" +delay = 1000 +exclude_dir = ["tmp", "vendor", "testdata", "dist"] +include_ext = ["go", "mod"] +kill_delay = "0s" +rerun_delay = 500 + +[log] +time = false + +[color] +main = "magenta" +watcher = "cyan" +build = "yellow" +runner = "green" diff --git a/plugins/go/template/base/.editorconfig b/plugins/go/template/base/.editorconfig new file mode 100644 index 0000000..24c5daa --- /dev/null +++ b/plugins/go/template/base/.editorconfig @@ -0,0 +1,18 @@ +root = true + +[*] +end_of_line = lf +insert_final_newline = true +charset = utf-8 +trim_trailing_whitespace = true + +[*.go] +indent_style = tab +indent_size = 4 + +[*.{yml,yaml,json,toml}] +indent_style = space +indent_size = 2 + +[Makefile] +indent_style = tab diff --git a/plugins/go/template/base/.env.example b/plugins/go/template/base/.env.example new file mode 100644 index 0000000..9e2315f --- /dev/null +++ b/plugins/go/template/base/.env.example @@ -0,0 +1,3 @@ +PORT=8080 +DATABASE_URL=postgres://postgres:postgres@localhost:5432/{{PROJECT_NAME}}?sslmode=disable +JWT_SECRET=changeme-use-a-long-random-string-in-production diff --git a/plugins/go/template/base/.gitignore b/plugins/go/template/base/.gitignore new file mode 100644 index 0000000..2eed455 --- /dev/null +++ b/plugins/go/template/base/.gitignore @@ -0,0 +1,15 @@ +# Binaries +bin/ +tmp/ +*.exe + +# Environment +.env + +# Go +vendor/ +*.test + +# IDE +.idea/ +.vscode/ diff --git a/plugins/go/template/base/Makefile b/plugins/go/template/base/Makefile new file mode 100644 index 0000000..386fa66 --- /dev/null +++ b/plugins/go/template/base/Makefile @@ -0,0 +1,22 @@ +.PHONY: dev build run migrate-up migrate-down test lint + +dev: + air + +build: + go build -o bin/{{PROJECT_NAME}} ./cmd/server + +run: + ./bin/{{PROJECT_NAME}} + +migrate-up: + migrate -path ./migrations -database "$(DATABASE_URL)" up + +migrate-down: + migrate -path ./migrations -database "$(DATABASE_URL)" down 1 + +test: + go test ./... + +lint: + golangci-lint run ./... diff --git a/plugins/go/template/base/cmd/server/main.go b/plugins/go/template/base/cmd/server/main.go new file mode 100644 index 0000000..a7d56b7 --- /dev/null +++ b/plugins/go/template/base/cmd/server/main.go @@ -0,0 +1,58 @@ +package main + +import ( + "context" + "fmt" + "log/slog" + "net/http" + "os" + "os/signal" + "syscall" + "time" + + "{{MODULE_PATH}}/internal/config" + "{{MODULE_PATH}}/internal/database" + "{{MODULE_PATH}}/internal/router" +) + +func main() { + cfg := config.Load() + + db, err := database.Connect(cfg) + if err != nil { + slog.Error("failed to connect to database", "error", err) + os.Exit(1) + } + sqlDB, _ := db.DB() + defer sqlDB.Close() + + r := router.New(cfg, db) + + srv := &http.Server{ + Addr: fmt.Sprintf(":%s", cfg.Port), + Handler: r, + ReadTimeout: 15 * time.Second, + WriteTimeout: 15 * time.Second, + IdleTimeout: 60 * time.Second, + } + + go func() { + slog.Info("server started", "port", cfg.Port) + if err := srv.ListenAndServe(); err != nil && err != http.ErrServerClosed { + slog.Error("server error", "error", err) + os.Exit(1) + } + }() + + quit := make(chan os.Signal, 1) + signal.Notify(quit, syscall.SIGINT, syscall.SIGTERM) + <-quit + + ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second) + defer cancel() + + if err := srv.Shutdown(ctx); err != nil { + slog.Error("server shutdown error", "error", err) + } + slog.Info("server stopped") +} diff --git a/plugins/go/template/base/go.mod b/plugins/go/template/base/go.mod new file mode 100644 index 0000000..181aa05 --- /dev/null +++ b/plugins/go/template/base/go.mod @@ -0,0 +1,11 @@ +module {{MODULE_PATH}} + +go 1.22 + +require ( + github.com/go-chi/chi/v5 v5.1.0 + github.com/golang-migrate/migrate/v4 v4.18.1 + github.com/joho/godotenv v1.5.1 + gorm.io/driver/postgres v1.5.9 + gorm.io/gorm v1.25.12 +) diff --git a/plugins/go/template/base/internal/config/config.go b/plugins/go/template/base/internal/config/config.go new file mode 100644 index 0000000..302401b --- /dev/null +++ b/plugins/go/template/base/internal/config/config.go @@ -0,0 +1,32 @@ +package config + +import ( + "log/slog" + "os" + + "github.com/joho/godotenv" +) + +type Config struct { + Port string + DBUrl string + JWTSecret string +} + +func Load() *Config { + if err := godotenv.Load(); err != nil { + slog.Warn("no .env file found, using environment variables") + } + return &Config{ + Port: getEnv("PORT", "8080"), + DBUrl: getEnv("DATABASE_URL", ""), + JWTSecret: getEnv("JWT_SECRET", ""), + } +} + +func getEnv(key, fallback string) string { + if v := os.Getenv(key); v != "" { + return v + } + return fallback +} diff --git a/plugins/go/template/base/internal/database/database.go b/plugins/go/template/base/internal/database/database.go new file mode 100644 index 0000000..ecec363 --- /dev/null +++ b/plugins/go/template/base/internal/database/database.go @@ -0,0 +1,24 @@ +package database + +import ( + "{{MODULE_PATH}}/internal/config" + "gorm.io/driver/postgres" + "gorm.io/gorm" + "gorm.io/gorm/logger" +) + +func Connect(cfg *config.Config) (*gorm.DB, error) { + db, err := gorm.Open(postgres.Open(cfg.DBUrl), &gorm.Config{ + Logger: logger.Default.LogMode(logger.Info), + }) + if err != nil { + return nil, err + } + sqlDB, err := db.DB() + if err != nil { + return nil, err + } + sqlDB.SetMaxOpenConns(25) + sqlDB.SetMaxIdleConns(5) + return db, nil +} diff --git a/plugins/go/template/base/internal/handlers/handlers.go b/plugins/go/template/base/internal/handlers/handlers.go new file mode 100644 index 0000000..ffdfd19 --- /dev/null +++ b/plugins/go/template/base/internal/handlers/handlers.go @@ -0,0 +1,11 @@ +package handlers + +import "gorm.io/gorm" + +type Handler struct { + db *gorm.DB +} + +func New(db *gorm.DB) *Handler { + return &Handler{db: db} +} diff --git a/plugins/go/template/base/internal/handlers/health.go b/plugins/go/template/base/internal/handlers/health.go new file mode 100644 index 0000000..8910819 --- /dev/null +++ b/plugins/go/template/base/internal/handlers/health.go @@ -0,0 +1,11 @@ +package handlers + +import ( + "encoding/json" + "net/http" +) + +func (h *Handler) Health(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "application/json") + _ = json.NewEncoder(w).Encode(map[string]string{"status": "ok"}) +} diff --git a/plugins/go/template/base/internal/router/router.go b/plugins/go/template/base/internal/router/router.go new file mode 100644 index 0000000..04fcdc5 --- /dev/null +++ b/plugins/go/template/base/internal/router/router.go @@ -0,0 +1,23 @@ +package router + +import ( + "{{MODULE_PATH}}/internal/config" + "{{MODULE_PATH}}/internal/handlers" + "github.com/go-chi/chi/v5" + "github.com/go-chi/chi/v5/middleware" + "gorm.io/gorm" +) + +func New(cfg *config.Config, db *gorm.DB) *chi.Mux { + r := chi.NewRouter() + + r.Use(middleware.Logger) + r.Use(middleware.Recoverer) + r.Use(middleware.RequestID) + + h := handlers.New(db) + + r.Get("/health", h.Health) + + return r +} diff --git a/plugins/go/template/base/migrations/000001_init.down.sql b/plugins/go/template/base/migrations/000001_init.down.sql new file mode 100644 index 0000000..cb9ad7e --- /dev/null +++ b/plugins/go/template/base/migrations/000001_init.down.sql @@ -0,0 +1 @@ +-- Rollback initial migration diff --git a/plugins/go/template/base/migrations/000001_init.up.sql b/plugins/go/template/base/migrations/000001_init.up.sql new file mode 100644 index 0000000..991123a --- /dev/null +++ b/plugins/go/template/base/migrations/000001_init.up.sql @@ -0,0 +1,2 @@ +-- Initial migration +-- Add your schema here diff --git a/plugins/node/config.ts b/plugins/node/config.ts index 1c986bc..9414c34 100644 --- a/plugins/node/config.ts +++ b/plugins/node/config.ts @@ -3,5 +3,5 @@ import { PluginConfig } from "../../types"; export const nodeConfig: PluginConfig = { name: "node-typescript", description: "Node.js Typescript backend with Fastify", - addons: ["docker", "testing", "ci", "husky", "websocket", "oauth", "api-docs", "claude-code", "cursor", "email", "s3", "queue"], + addons: ["docker", "testing", "ci", "husky", "websocket", "oauth", "api-docs", "claude-code", "cursor", "email", "s3", "queue", "observability"], }; diff --git a/plugins/node/index.ts b/plugins/node/index.ts index 42f54fc..7f28d6c 100644 --- a/plugins/node/index.ts +++ b/plugins/node/index.ts @@ -102,6 +102,11 @@ export class NodePlugin extends BasePlugin { path: path.join(addonsPath, "queue"), label: "Queue (BullMQ)", }, + { + condition: !!options.observability, + path: path.join(addonsPath, "observability"), + label: "Observability (OTel + Prometheus)", + }, ]; } @@ -123,6 +128,13 @@ export class NodePlugin extends BasePlugin { extraDeps["@aws-sdk/s3-request-presigner"] = "^3.600.0"; } if (addon === "queue") extraDeps["bullmq"] = "^5.0.0"; + if (addon === "observability") { + extraDeps["@opentelemetry/sdk-node"] = "^0.51.0"; + extraDeps["@opentelemetry/auto-instrumentations-node"] = "^0.46.0"; + extraDeps["@opentelemetry/exporter-trace-otlp-http"] = "^0.51.0"; + extraDeps["prom-client"] = "^15.1.0"; + if (options.sentry) extraDeps["@sentry/node"] = "^8.0.0"; + } if (Object.keys(extraDeps).length === 0) return; @@ -154,6 +166,13 @@ export class NodePlugin extends BasePlugin { extraDeps["@aws-sdk/s3-request-presigner"] = "^3.600.0"; } if (options.queue) extraDeps["bullmq"] = "^5.0.0"; + if (options.observability) { + extraDeps["@opentelemetry/sdk-node"] = "^0.51.0"; + extraDeps["@opentelemetry/auto-instrumentations-node"] = "^0.46.0"; + extraDeps["@opentelemetry/exporter-trace-otlp-http"] = "^0.51.0"; + extraDeps["prom-client"] = "^15.1.0"; + if (options.sentry) extraDeps["@sentry/node"] = "^8.0.0"; + } if (Object.keys(extraDeps).length === 0) return; @@ -244,6 +263,24 @@ export class NodePlugin extends BasePlugin { console.log(" Use fastify.queues.getQueue('example') to enqueue jobs"); console.log(" Run workers: node dist/src/workers/example.worker.js"); } + if (options.observability) { + console.log(""); + console.log(" Observability — add to the TOP of src/index.ts:"); + console.log(` import './telemetry'; // must be first import`); + console.log(` Then register metricsPlugin in src/app.ts:`); + console.log(` import metricsPlugin from './plugins/metrics.plugin';`); + console.log(` await app.register(metricsPlugin);`); + console.log(` Metrics available at: http://localhost:3000/metrics`); + console.log(` Grafana dashboard: observability/grafana-dashboard.json`); + console.log(` Stack: docker compose -f observability/docker-compose.observability.yml up -d`); + if (options.sentry) { + console.log(""); + console.log(" Sentry — call initSentry() in src/index.ts before buildApp():"); + console.log(` import { initSentry } from './config/sentry';`); + console.log(` initSentry();`); + console.log(` Set SENTRY_DSN in .env`); + } + } console.log(""); } } diff --git a/plugins/node/template/addons/observability/.env.observability.example b/plugins/node/template/addons/observability/.env.observability.example new file mode 100644 index 0000000..3850178 --- /dev/null +++ b/plugins/node/template/addons/observability/.env.observability.example @@ -0,0 +1,7 @@ +# OpenTelemetry +OTEL_SERVICE_NAME={{PROJECT_NAME}} +OTEL_EXPORTER_OTLP_ENDPOINT=http://localhost:4318/v1/traces + +# Sentry (optional — leave blank to disable) +SENTRY_DSN= +SENTRY_TRACES_SAMPLE_RATE=0.1 diff --git a/plugins/node/template/addons/observability/observability/docker-compose.observability.yml b/plugins/node/template/addons/observability/observability/docker-compose.observability.yml new file mode 100644 index 0000000..64e62c4 --- /dev/null +++ b/plugins/node/template/addons/observability/observability/docker-compose.observability.yml @@ -0,0 +1,32 @@ +version: '3.9' + +services: + prometheus: + image: prom/prometheus:v2.51.0 + ports: + - '9090:9090' + volumes: + - ./observability/prometheus.yml:/etc/prometheus/prometheus.yml + extra_hosts: + - 'host.docker.internal:host-gateway' + + grafana: + image: grafana/grafana:10.4.0 + ports: + - '3001:3000' + environment: + - GF_SECURITY_ADMIN_PASSWORD=admin + volumes: + - grafana_data:/var/lib/grafana + + otel-collector: + image: otel/opentelemetry-collector-contrib:0.98.0 + ports: + - '4317:4317' + - '4318:4318' + command: ['--config=/etc/otelcol-contrib/config.yaml'] + volumes: + - ./observability/otel-collector.yml:/etc/otelcol-contrib/config.yaml + +volumes: + grafana_data: diff --git a/plugins/node/template/addons/observability/observability/grafana-dashboard.json b/plugins/node/template/addons/observability/observability/grafana-dashboard.json new file mode 100644 index 0000000..18bb588 --- /dev/null +++ b/plugins/node/template/addons/observability/observability/grafana-dashboard.json @@ -0,0 +1,57 @@ +{ + "title": "{{PROJECT_NAME}} — API Overview", + "uid": "api-overview", + "schemaVersion": 38, + "version": 1, + "refresh": "30s", + "panels": [ + { + "id": 1, + "type": "timeseries", + "title": "Request Rate (req/s)", + "gridPos": { "h": 8, "w": 12, "x": 0, "y": 0 }, + "targets": [ + { + "expr": "rate(http_request_duration_seconds_count[1m])", + "legendFormat": "{{method}} {{route}}" + } + ] + }, + { + "id": 2, + "type": "timeseries", + "title": "P95 Latency (s)", + "gridPos": { "h": 8, "w": 12, "x": 12, "y": 0 }, + "targets": [ + { + "expr": "histogram_quantile(0.95, rate(http_request_duration_seconds_bucket[1m]))", + "legendFormat": "p95 {{route}}" + } + ] + }, + { + "id": 3, + "type": "timeseries", + "title": "Error Rate (5xx)", + "gridPos": { "h": 8, "w": 12, "x": 0, "y": 8 }, + "targets": [ + { + "expr": "rate(http_request_duration_seconds_count{status_code=~\"5..\"}[1m])", + "legendFormat": "errors {{route}}" + } + ] + }, + { + "id": 4, + "type": "stat", + "title": "Active Handles", + "gridPos": { "h": 8, "w": 12, "x": 12, "y": 8 }, + "targets": [ + { + "expr": "nodejs_active_handles_total", + "legendFormat": "handles" + } + ] + } + ] +} diff --git a/plugins/node/template/addons/observability/observability/otel-collector.yml b/plugins/node/template/addons/observability/observability/otel-collector.yml new file mode 100644 index 0000000..74f3f8e --- /dev/null +++ b/plugins/node/template/addons/observability/observability/otel-collector.yml @@ -0,0 +1,21 @@ +receivers: + otlp: + protocols: + grpc: + endpoint: 0.0.0.0:4317 + http: + endpoint: 0.0.0.0:4318 + +processors: + batch: + +exporters: + debug: + verbosity: detailed + +service: + pipelines: + traces: + receivers: [otlp] + processors: [batch] + exporters: [debug] diff --git a/plugins/node/template/addons/observability/observability/prometheus.yml b/plugins/node/template/addons/observability/observability/prometheus.yml new file mode 100644 index 0000000..d641fdc --- /dev/null +++ b/plugins/node/template/addons/observability/observability/prometheus.yml @@ -0,0 +1,9 @@ +global: + scrape_interval: 15s + evaluation_interval: 15s + +scrape_configs: + - job_name: '{{PROJECT_NAME}}' + static_configs: + - targets: ['host.docker.internal:3000'] + metrics_path: '/metrics' diff --git a/plugins/node/template/addons/observability/src/config/sentry.ts b/plugins/node/template/addons/observability/src/config/sentry.ts new file mode 100644 index 0000000..4a9bdc4 --- /dev/null +++ b/plugins/node/template/addons/observability/src/config/sentry.ts @@ -0,0 +1,12 @@ +import * as Sentry from '@sentry/node'; + +export function initSentry(): void { + const dsn = process.env.SENTRY_DSN; + if (!dsn) return; + + Sentry.init({ + dsn, + tracesSampleRate: parseFloat(process.env.SENTRY_TRACES_SAMPLE_RATE ?? '0.1'), + environment: process.env.NODE_ENV ?? 'development', + }); +} diff --git a/plugins/node/template/addons/observability/src/plugins/metrics.plugin.ts b/plugins/node/template/addons/observability/src/plugins/metrics.plugin.ts new file mode 100644 index 0000000..a1f4613 --- /dev/null +++ b/plugins/node/template/addons/observability/src/plugins/metrics.plugin.ts @@ -0,0 +1,37 @@ +import fp from 'fastify-plugin'; +import { FastifyPluginAsync } from 'fastify'; +import client from 'prom-client'; + +const metricsPlugin: FastifyPluginAsync = fp(async (fastify) => { + const register = new client.Registry(); + client.collectDefaultMetrics({ register }); + + const httpRequestDuration = new client.Histogram({ + name: 'http_request_duration_seconds', + help: 'Duration of HTTP requests in seconds', + labelNames: ['method', 'route', 'status_code'], + buckets: [0.005, 0.01, 0.025, 0.05, 0.1, 0.25, 0.5, 1, 2.5, 5], + registers: [register], + }); + + fastify.addHook('onRequest', async (request) => { + (request as typeof request & { _metricsStart: number })._metricsStart = Date.now(); + }); + + fastify.addHook('onResponse', async (request, reply) => { + const start = (request as typeof request & { _metricsStart: number })._metricsStart; + if (start) { + httpRequestDuration.observe( + { method: request.method, route: request.routeOptions.url ?? request.url, status_code: reply.statusCode }, + (Date.now() - start) / 1000, + ); + } + }); + + fastify.get('/metrics', async (_req, reply) => { + reply.header('Content-Type', register.contentType); + return register.metrics(); + }); +}); + +export default metricsPlugin; diff --git a/plugins/node/template/addons/observability/src/telemetry.ts b/plugins/node/template/addons/observability/src/telemetry.ts new file mode 100644 index 0000000..a87fd67 --- /dev/null +++ b/plugins/node/template/addons/observability/src/telemetry.ts @@ -0,0 +1,17 @@ +import { NodeSDK } from '@opentelemetry/sdk-node'; +import { getNodeAutoInstrumentations } from '@opentelemetry/auto-instrumentations-node'; +import { OTLPTraceExporter } from '@opentelemetry/exporter-trace-otlp-http'; + +const sdk = new NodeSDK({ + traceExporter: new OTLPTraceExporter({ + url: process.env.OTEL_EXPORTER_OTLP_ENDPOINT ?? 'http://localhost:4318/v1/traces', + }), + instrumentations: [getNodeAutoInstrumentations()], + serviceName: process.env.OTEL_SERVICE_NAME ?? '{{PROJECT_NAME}}', +}); + +sdk.start(); + +process.on('SIGTERM', () => { + sdk.shutdown().finally(() => process.exit(0)); +}); diff --git a/plugins/node/template/addons/testing/jest.config.js b/plugins/node/template/addons/testing/jest.config.js new file mode 100644 index 0000000..57a7c34 --- /dev/null +++ b/plugins/node/template/addons/testing/jest.config.js @@ -0,0 +1,28 @@ +/** @type {import('jest').Config} */ +module.exports = { + preset: 'ts-jest', + testEnvironment: 'node', + roots: ['/tests'], + testMatch: ['**/*.test.ts', '**/*.spec.ts'], + transform: { + '^.+\\.ts$': 'ts-jest', + }, + moduleNameMapper: { + '^@/(.*)$': '/src/$1', + }, + collectCoverageFrom: [ + 'src/**/*.ts', + '!src/**/*.d.ts', + '!src/index.ts', + ], + coverageDirectory: 'coverage', + coverageThreshold: { + global: { + branches: 80, + functions: 80, + lines: 80, + statements: 80, + }, + }, + clearMocks: true, +}; diff --git a/plugins/node/template/addons/testing/tests/auth.test.ts b/plugins/node/template/addons/testing/tests/auth.test.ts index c9d0af1..8c6c64e 100644 --- a/plugins/node/template/addons/testing/tests/auth.test.ts +++ b/plugins/node/template/addons/testing/tests/auth.test.ts @@ -1,4 +1,7 @@ -import { buildApp } from "../../../base/src/app"; +import { buildApp } from '../src/app'; + +const TEST_EMAIL = 'auth-test@example.com'; +const TEST_PASSWORD = 'Test1234!'; describe('Auth endpoints', () => { let app: Awaited>; @@ -11,29 +14,57 @@ describe('Auth endpoints', () => { await app.close(); }); - it('POST /auth/register → 201', async () => { + it('POST /api/v1/auth/register → 201', async () => { const res = await app.inject({ method: 'POST', - url: '/auth/register', + url: '/api/v1/auth/register', payload: { - email: 'test@example.com', - password: 'Test1234!', - name: 'Test User', + email: TEST_EMAIL, + password: TEST_PASSWORD, + username: 'testuser', }, }); expect(res.statusCode).toBe(201); + expect(res.json()).toHaveProperty('success', true); + }); + + it('POST /api/v1/auth/register duplicate → 409', async () => { + const res = await app.inject({ + method: 'POST', + url: '/api/v1/auth/register', + payload: { + email: TEST_EMAIL, + password: TEST_PASSWORD, + username: 'testuser2', + }, + }); + expect(res.statusCode).toBe(409); }); - it('POST /auth/login → 200', async () => { + it('POST /api/v1/auth/login → 200 with token', async () => { const res = await app.inject({ method: 'POST', - url: '/auth/login', + url: '/api/v1/auth/login', payload: { - email: 'test@example.com', - password: 'Test1234!', + email: TEST_EMAIL, + password: TEST_PASSWORD, }, }); expect(res.statusCode).toBe(200); - expect(res.json()).toHaveProperty('data.token'); + const body = res.json(); + expect(body).toHaveProperty('success', true); + expect(body).toHaveProperty('data.accessToken'); + }); + + it('POST /api/v1/auth/login wrong password → 401', async () => { + const res = await app.inject({ + method: 'POST', + url: '/api/v1/auth/login', + payload: { + email: TEST_EMAIL, + password: 'WrongPassword!', + }, + }); + expect(res.statusCode).toBe(401); }); }); diff --git a/plugins/node/template/addons/testing/tests/health.test.ts b/plugins/node/template/addons/testing/tests/health.test.ts index 5d59c7b..737c2e5 100644 --- a/plugins/node/template/addons/testing/tests/health.test.ts +++ b/plugins/node/template/addons/testing/tests/health.test.ts @@ -1,4 +1,4 @@ -import { buildApp } from "../../../base/src/app"; +import { buildApp } from '../src/app'; describe('Health endpoints', () => { let app: Awaited>; diff --git a/plugins/node/template/addons/testing/tests/users.test.ts b/plugins/node/template/addons/testing/tests/users.test.ts new file mode 100644 index 0000000..cc5cd2c --- /dev/null +++ b/plugins/node/template/addons/testing/tests/users.test.ts @@ -0,0 +1,59 @@ +import { buildApp } from '../src/app'; + +describe('Users endpoints', () => { + let app: Awaited>; + let authToken: string; + + beforeAll(async () => { + app = await buildApp(); + + // Register + login to get token + await app.inject({ + method: 'POST', + url: '/api/v1/auth/register', + payload: { + email: 'users-test@example.com', + password: 'Test1234!', + username: 'userstestaccount', + }, + }); + + const loginRes = await app.inject({ + method: 'POST', + url: '/api/v1/auth/login', + payload: { + email: 'users-test@example.com', + password: 'Test1234!', + }, + }); + authToken = loginRes.json()?.data?.accessToken ?? ''; + }); + + afterAll(async () => { + await app.close(); + }); + + it('GET /api/v1/users without token → 401', async () => { + const res = await app.inject({ method: 'GET', url: '/api/v1/users' }); + expect(res.statusCode).toBe(401); + }); + + it('GET /api/v1/users with token → 200', async () => { + const res = await app.inject({ + method: 'GET', + url: '/api/v1/users', + headers: { Authorization: `Bearer ${authToken}` }, + }); + expect(res.statusCode).toBe(200); + expect(res.json()).toHaveProperty('success', true); + }); + + it('GET /api/v1/users/:id not found → 404', async () => { + const res = await app.inject({ + method: 'GET', + url: '/api/v1/users/99999', + headers: { Authorization: `Bearer ${authToken}` }, + }); + expect(res.statusCode).toBe(404); + }); +}); diff --git a/plugins/python/config.ts b/plugins/python/config.ts index 595c075..4d7df99 100644 --- a/plugins/python/config.ts +++ b/plugins/python/config.ts @@ -4,5 +4,5 @@ import { PluginConfig } from "../../types"; export const pythonConfig: PluginConfig = { name: "python-fastapi", description: "Python FastAPI backend with full production features", - addons: ["docker", "testing", "ci", "claude-code", "cursor", "email", "s3", "oauth", "api-docs", "websocket", "queue"] + addons: ["docker", "testing", "ci", "claude-code", "cursor", "email", "s3", "oauth", "api-docs", "websocket", "queue", "pre-commit", "observability"] } diff --git a/plugins/python/index.ts b/plugins/python/index.ts index d73c3f2..aadda6d 100644 --- a/plugins/python/index.ts +++ b/plugins/python/index.ts @@ -97,6 +97,16 @@ export class PythonPlugin extends BasePlugin { path: path.join(addonsPath, "queue"), label: "Queue (arq + Redis)", }, + { + condition: !!options.preCommit, + path: path.join(addonsPath, "pre-commit"), + label: "pre-commit hooks (ruff + black + mypy)", + }, + { + condition: !!options.observability, + path: path.join(addonsPath, "observability"), + label: "Observability (OTel + Prometheus + Sentry stub)", + }, ]; } @@ -104,46 +114,94 @@ export class PythonPlugin extends BasePlugin { await super.applyAddon(projectPath, addon, options); if (options.dryRun) return; + const pyprojectPath = path.join(projectPath, "pyproject.toml"); if (addon === "s3") { - const pyprojectPath = path.join(projectPath, "pyproject.toml"); await this._injectPyprojectDep(pyprojectPath, "boto3>=1.35.0"); logger.info("Updated pyproject.toml with boto3"); } if (addon === "queue") { - const pyprojectPath = path.join(projectPath, "pyproject.toml"); await this._injectPyprojectDep(pyprojectPath, "arq>=0.26.0"); logger.info("Updated pyproject.toml with arq"); } + if (addon === "testing") { + await this._injectPyprojectDevDeps(pyprojectPath, ["aiosqlite>=0.20.0"]); + logger.info("Updated pyproject.toml with aiosqlite"); + } + if (addon === "pre-commit") { + await this._injectPyprojectDevDeps(pyprojectPath, ["pre-commit>=3.7.0"]); + logger.info("Updated pyproject.toml with pre-commit"); + } + if (addon === "observability") { + await this._injectPyprojectDeps(pyprojectPath, [ + "opentelemetry-sdk>=1.24.0", + "opentelemetry-instrumentation-fastapi>=0.45b0", + "opentelemetry-exporter-otlp-proto-http>=1.24.0", + "prometheus-fastapi-instrumentator>=7.0.0", + "structlog>=24.1.0", + ]); + logger.info("Updated pyproject.toml with observability deps"); + } } async generate(projectName: string, options: GenerateOptions): Promise { await super.generate(projectName, options); if (options.dryRun) return; - if (options.s3) { - const outputPath = options.outputDir ?? path.join(process.cwd(), projectName); - const pyprojectPath = path.join(outputPath, "pyproject.toml"); - await this._injectPyprojectDep(pyprojectPath, "boto3>=1.35.0"); - } - if (options.queue) { - const outputPath = options.outputDir ?? path.join(process.cwd(), projectName); - const pyprojectPath = path.join(outputPath, "pyproject.toml"); - await this._injectPyprojectDep(pyprojectPath, "arq>=0.26.0"); + const outputPath = options.outputDir ?? path.join(process.cwd(), projectName); + const pyprojectPath = path.join(outputPath, "pyproject.toml"); + + if (options.s3) await this._injectPyprojectDep(pyprojectPath, "boto3>=1.35.0"); + if (options.queue) await this._injectPyprojectDep(pyprojectPath, "arq>=0.26.0"); + if (options.testing) await this._injectPyprojectDevDeps(pyprojectPath, ["aiosqlite>=0.20.0"]); + if (options.preCommit) await this._injectPyprojectDevDeps(pyprojectPath, ["pre-commit>=3.7.0"]); + if (options.observability) { + await this._injectPyprojectDeps(pyprojectPath, [ + "opentelemetry-sdk>=1.24.0", + "opentelemetry-instrumentation-fastapi>=0.45b0", + "opentelemetry-exporter-otlp-proto-http>=1.24.0", + "prometheus-fastapi-instrumentator>=7.0.0", + "structlog>=24.1.0", + ]); + if (options.sentry) { + await this._injectPyprojectDeps(pyprojectPath, ["sentry-sdk[fastapi]>=2.0.0"]); + } } } private async _injectPyprojectDep(pyprojectPath: string, dep: string): Promise { + return this._injectPyprojectDeps(pyprojectPath, [dep]); + } + + private async _injectPyprojectDeps(pyprojectPath: string, deps: string[]): Promise { try { let content = await fs.readFile(pyprojectPath, "utf8"); - if (content.includes(dep.split(">=")[0])) return; - // Insert before the closing ] of the dependencies array + const toAdd = deps.filter((d) => !content.includes(d.split(">=")[0].split("[")[0])); + if (toAdd.length === 0) return; + const lines = toAdd.map((d) => ` "${d}",`).join("\n"); content = content.replace( /^(\s*"pyjwt[^"]*",?\n)(\])/m, - `$1 "${dep}",\n$2`, + `$1${lines}\n$2`, ); await fs.writeFile(pyprojectPath, content, "utf8"); } catch { - logger.warn(`Could not inject ${dep} into pyproject.toml`); + logger.warn(`Could not inject deps into pyproject.toml`); + } + } + + private async _injectPyprojectDevDeps(pyprojectPath: string, deps: string[]): Promise { + try { + let content = await fs.readFile(pyprojectPath, "utf8"); + const toAdd = deps.filter((d) => !content.includes(d.split(">=")[0].split("[")[0])); + if (toAdd.length === 0) return; + const lines = toAdd.map((d) => ` "${d}",`).join("\n"); + // Inject before the closing ] of [project.optional-dependencies] dev array + content = content.replace( + /^(\s*"mypy[^"]*",?\n)(\])/m, + `$1${lines}\n$2`, + ); + await fs.writeFile(pyprojectPath, content, "utf8"); + } catch { + logger.warn(`Could not inject dev deps into pyproject.toml`); } } @@ -222,6 +280,29 @@ export class PythonPlugin extends BasePlugin { console.log(""); console.log(" Cursor — .cursor/skills/ ready, open project in Cursor to use agent skills"); } + if (options.preCommit) { + console.log(""); + console.log(" pre-commit — install hooks after setting up your virtualenv:"); + console.log(` pip install pre-commit`); + console.log(` pre-commit install`); + console.log(` # Hooks run automatically on each git commit (ruff + black + mypy)`); + } + if (options.observability) { + console.log(""); + console.log(" Observability — add to main.py after creating the FastAPI app:"); + console.log(` from app.core.observability import instrument_app`); + console.log(` instrument_app(app)`); + console.log(` Metrics available at: http://localhost:8000/metrics`); + console.log(` Grafana dashboard: observability/grafana-dashboard.json`); + console.log(` Stack: docker compose -f observability/docker-compose.observability.yml up -d`); + if (options.sentry) { + console.log(""); + console.log(" Sentry — add to main.py before creating the app:"); + console.log(` from app.core.sentry import init_sentry`); + console.log(` init_sentry()`); + console.log(` Set SENTRY_DSN in .env`); + } + } console.log(""); } } diff --git a/plugins/python/template/addons/observability/.env.observability.example b/plugins/python/template/addons/observability/.env.observability.example new file mode 100644 index 0000000..4447473 --- /dev/null +++ b/plugins/python/template/addons/observability/.env.observability.example @@ -0,0 +1,11 @@ +# OpenTelemetry +OTEL_SERVICE_NAME={{PROJECT_NAME}} +OTEL_EXPORTER_OTLP_ENDPOINT=http://localhost:4318/v1/traces + +# Structured logging +LOG_FORMAT=json +LOG_LEVEL=INFO + +# Sentry (optional — leave blank to disable) +SENTRY_DSN= +SENTRY_TRACES_SAMPLE_RATE=0.1 diff --git a/plugins/python/template/addons/observability/app/core/observability.py b/plugins/python/template/addons/observability/app/core/observability.py new file mode 100644 index 0000000..b5cf460 --- /dev/null +++ b/plugins/python/template/addons/observability/app/core/observability.py @@ -0,0 +1,47 @@ +from __future__ import annotations + +import os + +import structlog +from opentelemetry import trace +from opentelemetry.exporter.otlp.proto.http.trace_exporter import OTLPSpanExporter +from opentelemetry.instrumentation.fastapi import FastAPIInstrumentor +from opentelemetry.sdk.resources import SERVICE_NAME, Resource +from opentelemetry.sdk.trace import TracerProvider +from opentelemetry.sdk.trace.export import BatchSpanProcessor +from prometheus_fastapi_instrumentator import Instrumentator + +from fastapi import FastAPI + + +def setup_logging() -> None: + structlog.configure( + processors=[ + structlog.contextvars.merge_contextvars, + structlog.processors.add_log_level, + structlog.processors.TimeStamper(fmt="iso"), + structlog.dev.ConsoleRenderer() if os.getenv("LOG_FORMAT") != "json" + else structlog.processors.JSONRenderer(), + ], + wrapper_class=structlog.make_filtering_bound_logger( + getattr(__import__("logging"), os.getenv("LOG_LEVEL", "INFO")) + ), + context_class=dict, + logger_factory=structlog.PrintLoggerFactory(), + ) + + +def setup_tracing() -> None: + endpoint = os.getenv("OTEL_EXPORTER_OTLP_ENDPOINT", "http://localhost:4318/v1/traces") + service_name = os.getenv("OTEL_SERVICE_NAME", "{{PROJECT_NAME}}") + + resource = Resource(attributes={SERVICE_NAME: service_name}) + provider = TracerProvider(resource=resource) + provider.add_span_processor(BatchSpanProcessor(OTLPSpanExporter(endpoint=endpoint))) + trace.set_tracer_provider(provider) + + +def instrument_app(app: FastAPI) -> None: + setup_tracing() + FastAPIInstrumentor.instrument_app(app) + Instrumentator().instrument(app).expose(app, endpoint="/metrics") diff --git a/plugins/python/template/addons/observability/app/core/sentry.py b/plugins/python/template/addons/observability/app/core/sentry.py new file mode 100644 index 0000000..3782bed --- /dev/null +++ b/plugins/python/template/addons/observability/app/core/sentry.py @@ -0,0 +1,21 @@ +from __future__ import annotations + +import os + + +def init_sentry() -> None: + dsn = os.getenv("SENTRY_DSN") + if not dsn: + return + + import sentry_sdk + from sentry_sdk.integrations.fastapi import FastApiIntegration + from sentry_sdk.integrations.sqlalchemy import SqlalchemyIntegration + + sentry_sdk.init( + dsn=dsn, + integrations=[FastApiIntegration(), SqlalchemyIntegration()], + traces_sample_rate=float(os.getenv("SENTRY_TRACES_SAMPLE_RATE", "0.1")), + environment=os.getenv("ENVIRONMENT", "development"), + send_default_pii=False, + ) diff --git a/plugins/python/template/addons/observability/observability/docker-compose.observability.yml b/plugins/python/template/addons/observability/observability/docker-compose.observability.yml new file mode 100644 index 0000000..64e62c4 --- /dev/null +++ b/plugins/python/template/addons/observability/observability/docker-compose.observability.yml @@ -0,0 +1,32 @@ +version: '3.9' + +services: + prometheus: + image: prom/prometheus:v2.51.0 + ports: + - '9090:9090' + volumes: + - ./observability/prometheus.yml:/etc/prometheus/prometheus.yml + extra_hosts: + - 'host.docker.internal:host-gateway' + + grafana: + image: grafana/grafana:10.4.0 + ports: + - '3001:3000' + environment: + - GF_SECURITY_ADMIN_PASSWORD=admin + volumes: + - grafana_data:/var/lib/grafana + + otel-collector: + image: otel/opentelemetry-collector-contrib:0.98.0 + ports: + - '4317:4317' + - '4318:4318' + command: ['--config=/etc/otelcol-contrib/config.yaml'] + volumes: + - ./observability/otel-collector.yml:/etc/otelcol-contrib/config.yaml + +volumes: + grafana_data: diff --git a/plugins/python/template/addons/observability/observability/grafana-dashboard.json b/plugins/python/template/addons/observability/observability/grafana-dashboard.json new file mode 100644 index 0000000..2054fab --- /dev/null +++ b/plugins/python/template/addons/observability/observability/grafana-dashboard.json @@ -0,0 +1,57 @@ +{ + "title": "{{PROJECT_NAME}} — API Overview", + "uid": "api-overview-py", + "schemaVersion": 38, + "version": 1, + "refresh": "30s", + "panels": [ + { + "id": 1, + "type": "timeseries", + "title": "Request Rate (req/s)", + "gridPos": { "h": 8, "w": 12, "x": 0, "y": 0 }, + "targets": [ + { + "expr": "rate(http_requests_total[1m])", + "legendFormat": "{{method}} {{handler}}" + } + ] + }, + { + "id": 2, + "type": "timeseries", + "title": "P95 Latency (s)", + "gridPos": { "h": 8, "w": 12, "x": 12, "y": 0 }, + "targets": [ + { + "expr": "histogram_quantile(0.95, rate(http_request_duration_highr_seconds_bucket[1m]))", + "legendFormat": "p95 {{handler}}" + } + ] + }, + { + "id": 3, + "type": "timeseries", + "title": "Error Rate (5xx)", + "gridPos": { "h": 8, "w": 12, "x": 0, "y": 8 }, + "targets": [ + { + "expr": "rate(http_requests_total{status=~\"5..\"}[1m])", + "legendFormat": "errors {{handler}}" + } + ] + }, + { + "id": 4, + "type": "stat", + "title": "In Progress Requests", + "gridPos": { "h": 8, "w": 12, "x": 12, "y": 8 }, + "targets": [ + { + "expr": "http_requests_inprogress", + "legendFormat": "in-progress" + } + ] + } + ] +} diff --git a/plugins/python/template/addons/observability/observability/otel-collector.yml b/plugins/python/template/addons/observability/observability/otel-collector.yml new file mode 100644 index 0000000..74f3f8e --- /dev/null +++ b/plugins/python/template/addons/observability/observability/otel-collector.yml @@ -0,0 +1,21 @@ +receivers: + otlp: + protocols: + grpc: + endpoint: 0.0.0.0:4317 + http: + endpoint: 0.0.0.0:4318 + +processors: + batch: + +exporters: + debug: + verbosity: detailed + +service: + pipelines: + traces: + receivers: [otlp] + processors: [batch] + exporters: [debug] diff --git a/plugins/python/template/addons/observability/observability/prometheus.yml b/plugins/python/template/addons/observability/observability/prometheus.yml new file mode 100644 index 0000000..703427a --- /dev/null +++ b/plugins/python/template/addons/observability/observability/prometheus.yml @@ -0,0 +1,9 @@ +global: + scrape_interval: 15s + evaluation_interval: 15s + +scrape_configs: + - job_name: '{{PROJECT_NAME}}' + static_configs: + - targets: ['host.docker.internal:8000'] + metrics_path: '/metrics' diff --git a/plugins/python/template/addons/pre-commit/.pre-commit-config.yaml b/plugins/python/template/addons/pre-commit/.pre-commit-config.yaml new file mode 100644 index 0000000..bb43277 --- /dev/null +++ b/plugins/python/template/addons/pre-commit/.pre-commit-config.yaml @@ -0,0 +1,28 @@ +repos: + - repo: https://github.com/astral-sh/ruff-pre-commit + rev: v0.3.0 + hooks: + - id: ruff + args: [--fix] + - id: ruff-format + + - repo: https://github.com/psf/black + rev: 24.3.0 + hooks: + - id: black + language_version: python3.11 + + - repo: https://github.com/pre-commit/mirrors-mypy + rev: v1.9.0 + hooks: + - id: mypy + additional_dependencies: [types-all] + args: [--ignore-missing-imports] + + - repo: https://github.com/pre-commit/pre-commit-hooks + rev: v4.5.0 + hooks: + - id: trailing-whitespace + - id: end-of-file-fixer + - id: check-yaml + - id: check-added-large-files diff --git a/plugins/python/template/addons/testing/pytest.ini b/plugins/python/template/addons/testing/pytest.ini new file mode 100644 index 0000000..7092ba0 --- /dev/null +++ b/plugins/python/template/addons/testing/pytest.ini @@ -0,0 +1,4 @@ +[pytest] +asyncio_mode = auto +testpaths = tests +addopts = --cov=app --cov-report=term-missing --cov-fail-under=80 diff --git a/plugins/python/template/addons/testing/tests/conftest.py b/plugins/python/template/addons/testing/tests/conftest.py index 2c73c3a..87d4883 100644 --- a/plugins/python/template/addons/testing/tests/conftest.py +++ b/plugins/python/template/addons/testing/tests/conftest.py @@ -2,29 +2,40 @@ from httpx import ASGITransport, AsyncClient from sqlalchemy.ext.asyncio import AsyncSession, async_sessionmaker, create_async_engine -from app.core.database import Base +from app.core.database import Base, get_db from main import app -TEST_DATABASE_URL = "sqlite+aiosqlite:///:memory:" +TEST_DATABASE_URL = "sqlite+aiosqlite:///:memory:" -@pytest_asyncio.fixture -async def db_session(): - """Create test database session""" - engine = create_async_engine(TEST_DATABASE_URL, echo=False) +_test_engine = create_async_engine(TEST_DATABASE_URL, echo=False) +_test_session_factory = async_sessionmaker(_test_engine, class_=AsyncSession, expire_on_commit=False) - async with engine.begin() as conn: - await conn.run_sync(Base.metadata.create_all) - async_session = async_sessionmaker(engine, class_=AsyncSession, expire_on_commit=False) - async with async_session() as session: +async def _override_get_db(): + async with _test_session_factory() as session: yield session - async with engine.begin() as conn: + + +app.dependency_overrides[get_db] = _override_get_db + + +@pytest_asyncio.fixture(scope="session", autouse=True) +async def _setup_db(): + async with _test_engine.begin() as conn: + await conn.run_sync(Base.metadata.create_all) + yield + async with _test_engine.begin() as conn: await conn.run_sync(Base.metadata.drop_all) - await engine.dispose() + await _test_engine.dispose() + + +@pytest_asyncio.fixture +async def db_session(): + async with _test_session_factory() as session: + yield session @pytest_asyncio.fixture async def client(): - """Create test client""" - async with AsyncClient(transport=ASGITransport(app=app), base_url="http://testserver") as client: - yield client + async with AsyncClient(transport=ASGITransport(app=app), base_url="http://testserver") as c: + yield c diff --git a/plugins/python/template/addons/testing/tests/test_auth.py b/plugins/python/template/addons/testing/tests/test_auth.py new file mode 100644 index 0000000..f6f3e40 --- /dev/null +++ b/plugins/python/template/addons/testing/tests/test_auth.py @@ -0,0 +1,62 @@ +import pytest +from httpx import AsyncClient + +REGISTER_URL = "/api/v1/auth/register" +LOGIN_URL = "/api/v1/auth/login" + +TEST_EMAIL = "auth@testexample.com" +TEST_PASSWORD = "SecurePass123!" +TEST_USERNAME = "authtestuser" + + +@pytest.mark.asyncio +async def test_register_success(client: AsyncClient): + response = await client.post( + REGISTER_URL, + json={"email": TEST_EMAIL, "password": TEST_PASSWORD, "username": TEST_USERNAME}, + ) + assert response.status_code == 201 + data = response.json() + assert data["success"] is True + + +@pytest.mark.asyncio +async def test_register_duplicate_email(client: AsyncClient): + payload = {"email": TEST_EMAIL, "password": TEST_PASSWORD, "username": "another"} + await client.post(REGISTER_URL, json=payload) + response = await client.post(REGISTER_URL, json=payload) + assert response.status_code == 409 + + +@pytest.mark.asyncio +async def test_login_success(client: AsyncClient): + await client.post( + REGISTER_URL, + json={"email": TEST_EMAIL, "password": TEST_PASSWORD, "username": TEST_USERNAME}, + ) + response = await client.post( + LOGIN_URL, + json={"email": TEST_EMAIL, "password": TEST_PASSWORD}, + ) + assert response.status_code == 200 + data = response.json() + assert data["success"] is True + assert "access_token" in data["data"] + + +@pytest.mark.asyncio +async def test_login_wrong_password(client: AsyncClient): + response = await client.post( + LOGIN_URL, + json={"email": TEST_EMAIL, "password": "WrongPassword!"}, + ) + assert response.status_code == 401 + + +@pytest.mark.asyncio +async def test_login_unknown_email(client: AsyncClient): + response = await client.post( + LOGIN_URL, + json={"email": "nobody@example.com", "password": TEST_PASSWORD}, + ) + assert response.status_code == 401 diff --git a/plugins/python/template/addons/testing/tests/test_users.py b/plugins/python/template/addons/testing/tests/test_users.py new file mode 100644 index 0000000..bcd9c84 --- /dev/null +++ b/plugins/python/template/addons/testing/tests/test_users.py @@ -0,0 +1,42 @@ +import pytest +from httpx import AsyncClient + +REGISTER_URL = "/api/v1/auth/register" +LOGIN_URL = "/api/v1/auth/login" +USERS_URL = "/api/v1/users" + + +async def _get_token(client: AsyncClient, email: str, password: str, username: str) -> str: + await client.post(REGISTER_URL, json={"email": email, "password": password, "username": username}) + res = await client.post(LOGIN_URL, json={"email": email, "password": password}) + return res.json()["data"]["access_token"] + + +@pytest.mark.asyncio +async def test_list_users_unauthenticated(client: AsyncClient): + response = await client.get(USERS_URL) + assert response.status_code == 401 + + +@pytest.mark.asyncio +async def test_list_users_authenticated(client: AsyncClient): + token = await _get_token(client, "userslist@example.com", "Pass1234!", "userslistuser") + response = await client.get(USERS_URL, headers={"Authorization": f"Bearer {token}"}) + assert response.status_code == 200 + data = response.json() + assert data["success"] is True + assert isinstance(data["data"], list) + + +@pytest.mark.asyncio +async def test_get_user_not_found(client: AsyncClient): + token = await _get_token(client, "usersget@example.com", "Pass1234!", "usersgetuser") + response = await client.get(f"{USERS_URL}/99999", headers={"Authorization": f"Bearer {token}"}) + assert response.status_code == 404 + + +@pytest.mark.asyncio +async def test_get_current_user(client: AsyncClient): + token = await _get_token(client, "userme@example.com", "Pass1234!", "usermeuser") + response = await client.get("/api/v1/users/me", headers={"Authorization": f"Bearer {token}"}) + assert response.status_code in (200, 404) diff --git a/tests/unit/observability-addon.test.ts b/tests/unit/observability-addon.test.ts new file mode 100644 index 0000000..e9f62ad --- /dev/null +++ b/tests/unit/observability-addon.test.ts @@ -0,0 +1,296 @@ +import { describe, it, expect, vi, beforeEach } from "vitest"; +import path from "path"; + +const { mockFs, mockProcessTemplate, mockFsExtra } = vi.hoisted(() => { + const mockProcessTemplate = vi.fn().mockResolvedValue([]); + const mockFs = { + exists: vi.fn().mockReturnValue(true), + removeDir: vi.fn().mockResolvedValue(undefined), + ensureDir: vi.fn().mockResolvedValue(undefined), + writeFile: vi.fn().mockResolvedValue(undefined), + readFile: vi.fn().mockResolvedValue(""), + copyFile: vi.fn().mockResolvedValue(undefined), + getAllFiles: vi.fn().mockResolvedValue([]), + }; + const mockFsExtra = { + readJson: vi.fn().mockResolvedValue({ dependencies: {} }), + writeJson: vi.fn().mockResolvedValue(undefined), + readFile: vi.fn().mockResolvedValue('name = "my-api"\n"pyjwt>=2.11.0",\n]'), + writeFile: vi.fn().mockResolvedValue(undefined), + }; + return { mockFs, mockProcessTemplate, mockFsExtra }; +}); + +vi.mock("../../core/file-system", () => ({ + // eslint-disable-next-line @typescript-eslint/no-explicit-any + FileSystem: vi.fn().mockImplementation(function (this: any) { return mockFs; }), +})); + +vi.mock("../../core/template-engine", () => ({ + // eslint-disable-next-line @typescript-eslint/no-explicit-any + TemplateEngine: vi.fn().mockImplementation(function (this: any) { + return { processTemplate: mockProcessTemplate }; + }), +})); + +vi.mock("fs-extra", () => ({ default: mockFsExtra, ...mockFsExtra })); + +import { NodePlugin } from "../../plugins/node"; +import { PythonPlugin } from "../../plugins/python"; + +// ─── Node observability — config ───────────────────────────────────────────── + +describe("Node observability — config", () => { + let plugin: NodePlugin; + + beforeEach(() => { + vi.clearAllMocks(); + mockProcessTemplate.mockResolvedValue([]); + mockFs.exists.mockReturnValue(true); + mockFsExtra.readJson.mockResolvedValue({ dependencies: {} }); + plugin = new NodePlugin(); + }); + + it("exposes observability in addon list", () => { + expect(plugin.addons).toContain("observability"); + }); +}); + +// ─── Node observability — generate() ──────────────────────────────────────── + +describe("Node observability — generate()", () => { + let plugin: NodePlugin; + + beforeEach(() => { + vi.clearAllMocks(); + mockProcessTemplate.mockResolvedValue([]); + mockFs.exists.mockReturnValue(true); + mockFsExtra.readJson.mockResolvedValue({ dependencies: {} }); + plugin = new NodePlugin(); + }); + + it("applies observability addon when observability=true", async () => { + await plugin.generate("my-app", { language: "node", observability: true, outputDir: "/tmp/my-app" }); + const calls = mockProcessTemplate.mock.calls.map((c) => c[0] as string); + expect(calls.some((p) => p.includes(path.join("addons", "observability")))).toBe(true); + }); + + it("skips observability addon when observability=false", async () => { + await plugin.generate("my-app", { language: "node", observability: false, outputDir: "/tmp/my-app" }); + const calls = mockProcessTemplate.mock.calls.map((c) => c[0] as string); + expect(calls.some((p) => p.includes(path.join("addons", "observability")))).toBe(false); + }); + + it("skips observability addon when undefined", async () => { + await plugin.generate("my-app", { language: "node", outputDir: "/tmp/my-app" }); + const calls = mockProcessTemplate.mock.calls.map((c) => c[0] as string); + expect(calls.some((p) => p.includes(path.join("addons", "observability")))).toBe(false); + }); + + it("injects OTel + prom-client deps when observability=true", async () => { + mockFsExtra.readJson.mockResolvedValue({ dependencies: { fastify: "^5.0.0" } }); + await plugin.generate("my-app", { language: "node", observability: true, outputDir: "/tmp/my-app" }); + expect(mockFsExtra.writeJson).toHaveBeenCalledWith( + expect.stringContaining("package.json"), + expect.objectContaining({ + dependencies: expect.objectContaining({ + "@opentelemetry/sdk-node": expect.any(String), + "prom-client": expect.any(String), + }), + }), + expect.any(Object), + ); + }); + + it("injects @sentry/node when observability=true and sentry=true", async () => { + mockFsExtra.readJson.mockResolvedValue({ dependencies: { fastify: "^5.0.0" } }); + await plugin.generate("my-app", { language: "node", observability: true, sentry: true, outputDir: "/tmp/my-app" }); + expect(mockFsExtra.writeJson).toHaveBeenCalledWith( + expect.stringContaining("package.json"), + expect.objectContaining({ + dependencies: expect.objectContaining({ "@sentry/node": expect.any(String) }), + }), + expect.any(Object), + ); + }); + + it("does NOT inject @sentry/node when sentry=false", async () => { + mockFsExtra.readJson.mockResolvedValue({ dependencies: {} }); + await plugin.generate("my-app", { language: "node", observability: true, sentry: false, outputDir: "/tmp/my-app" }); + const call = mockFsExtra.writeJson.mock.calls[0]?.[1] as { dependencies: Record } | undefined; + expect(call?.dependencies?.["@sentry/node"]).toBeUndefined(); + }); + + it("does NOT inject deps when dry-run", async () => { + await plugin.generate("my-app", { language: "node", observability: true, outputDir: "/tmp/my-app", dryRun: true }); + expect(mockFsExtra.writeJson).not.toHaveBeenCalled(); + }); + + it("does NOT overwrite app.ts when observability is applied", async () => { + await plugin.generate("my-app", { language: "node", observability: true, outputDir: "/tmp/my-app" }); + const writtenFiles = mockProcessTemplate.mock.calls.map((c) => c[0] as string); + const obsCall = writtenFiles.find((p) => p.includes("observability")); + // Observability addon dir should not contain app.ts override + expect(obsCall).toBeDefined(); + // processTemplate is called for the overlay dir — the overlay itself doesn't ship app.ts + // (verified by the absence of any app.ts write in the call args) + const writeFileCalls = mockFs.writeFile.mock.calls.map((c) => c[0] as string); + expect(writeFileCalls.some((p) => p.endsWith("app.ts"))).toBe(false); + }); +}); + +// ─── Node observability — applyAddon() ────────────────────────────────────── + +describe("Node observability — applyAddon()", () => { + let plugin: NodePlugin; + + beforeEach(() => { + vi.clearAllMocks(); + mockProcessTemplate.mockResolvedValue([]); + mockFs.exists.mockReturnValue(true); + mockFs.readFile.mockResolvedValue(JSON.stringify({ name: "my-project" })); + mockFsExtra.readJson.mockResolvedValue({ dependencies: {} }); + plugin = new NodePlugin(); + }); + + it("injects OTel deps via applyAddon", async () => { + await plugin.applyAddon("/tmp/my-project", "observability", { dryRun: false }); + expect(mockFsExtra.writeJson).toHaveBeenCalledWith( + expect.stringContaining("package.json"), + expect.objectContaining({ + dependencies: expect.objectContaining({ + "@opentelemetry/sdk-node": expect.any(String), + "prom-client": expect.any(String), + }), + }), + expect.any(Object), + ); + }); + + it("injects @sentry/node via applyAddon when sentry=true", async () => { + await plugin.applyAddon("/tmp/my-project", "observability", { dryRun: false, sentry: true }); + expect(mockFsExtra.writeJson).toHaveBeenCalledWith( + expect.stringContaining("package.json"), + expect.objectContaining({ + dependencies: expect.objectContaining({ "@sentry/node": expect.any(String) }), + }), + expect.any(Object), + ); + }); + + it("does NOT write package.json when dry-run", async () => { + await plugin.applyAddon("/tmp/my-project", "observability", { dryRun: true }); + expect(mockFsExtra.writeJson).not.toHaveBeenCalled(); + }); +}); + +// ─── Python pre-commit — config ────────────────────────────────────────────── + +describe("Python pre-commit — config", () => { + let plugin: PythonPlugin; + + beforeEach(() => { + vi.clearAllMocks(); + mockProcessTemplate.mockResolvedValue([]); + mockFs.exists.mockReturnValue(true); + plugin = new PythonPlugin(); + }); + + it("exposes pre-commit in addon list", () => { + expect(plugin.addons).toContain("pre-commit"); + }); +}); + +// ─── Python pre-commit — generate() ───────────────────────────────────────── + +describe("Python pre-commit — generate()", () => { + let plugin: PythonPlugin; + + beforeEach(() => { + vi.clearAllMocks(); + mockProcessTemplate.mockResolvedValue([]); + mockFs.exists.mockReturnValue(true); + mockFsExtra.readFile.mockResolvedValue('name = "my-api"\n"pyjwt>=2.11.0",\n]\n[project.optional-dependencies]\ndev = [\n "mypy>=1.8.0",\n]'); + mockFsExtra.writeFile.mockResolvedValue(undefined); + plugin = new PythonPlugin(); + }); + + it("applies pre-commit addon when preCommit=true", async () => { + await plugin.generate("my-api", { language: "python", preCommit: true, outputDir: "/tmp/my-api" }); + const calls = mockProcessTemplate.mock.calls.map((c) => c[0] as string); + expect(calls.some((p) => p.includes(path.join("addons", "pre-commit")))).toBe(true); + }); + + it("skips pre-commit addon when preCommit=false", async () => { + await plugin.generate("my-api", { language: "python", preCommit: false, outputDir: "/tmp/my-api" }); + const calls = mockProcessTemplate.mock.calls.map((c) => c[0] as string); + expect(calls.some((p) => p.includes(path.join("addons", "pre-commit")))).toBe(false); + }); + + it("skips pre-commit addon when undefined", async () => { + await plugin.generate("my-api", { language: "python", outputDir: "/tmp/my-api" }); + const calls = mockProcessTemplate.mock.calls.map((c) => c[0] as string); + expect(calls.some((p) => p.includes(path.join("addons", "pre-commit")))).toBe(false); + }); +}); + +// ─── Python observability — generate() ────────────────────────────────────── + +describe("Python observability — generate()", () => { + let plugin: PythonPlugin; + + beforeEach(() => { + vi.clearAllMocks(); + mockProcessTemplate.mockResolvedValue([]); + mockFs.exists.mockReturnValue(true); + mockFsExtra.readFile.mockResolvedValue('name = "my-api"\n"pyjwt>=2.11.0",\n]\n[project.optional-dependencies]\ndev = [\n "mypy>=1.8.0",\n]'); + mockFsExtra.writeFile.mockResolvedValue(undefined); + plugin = new PythonPlugin(); + }); + + it("exposes observability in addon list", () => { + expect(plugin.addons).toContain("observability"); + }); + + it("applies observability addon when observability=true", async () => { + await plugin.generate("my-api", { language: "python", observability: true, outputDir: "/tmp/my-api" }); + const calls = mockProcessTemplate.mock.calls.map((c) => c[0] as string); + expect(calls.some((p) => p.includes(path.join("addons", "observability")))).toBe(true); + }); + + it("skips observability addon when observability=false", async () => { + await plugin.generate("my-api", { language: "python", observability: false, outputDir: "/tmp/my-api" }); + const calls = mockProcessTemplate.mock.calls.map((c) => c[0] as string); + expect(calls.some((p) => p.includes(path.join("addons", "observability")))).toBe(false); + }); +}); + +// ─── Python testing — aiosqlite dep injection ──────────────────────────────── + +describe("Python testing — aiosqlite dep", () => { + let plugin: PythonPlugin; + + beforeEach(() => { + vi.clearAllMocks(); + mockProcessTemplate.mockResolvedValue([]); + mockFs.exists.mockReturnValue(true); + mockFs.readFile.mockResolvedValue('name = "my-api"\n'); + mockFsExtra.readFile.mockResolvedValue('name = "my-api"\n"pyjwt>=2.11.0",\n]\n[project.optional-dependencies]\ndev = [\n "mypy>=1.8.0",\n]'); + mockFsExtra.writeFile.mockResolvedValue(undefined); + plugin = new PythonPlugin(); + }); + + it("injects aiosqlite when testing=true via generate()", async () => { + await plugin.generate("my-api", { language: "python", testing: true, outputDir: "/tmp/my-api" }); + const writeCalls = mockFsExtra.writeFile.mock.calls; + const hasAiosqlite = writeCalls.some((c) => (c[1] as string).includes("aiosqlite")); + expect(hasAiosqlite).toBe(true); + }); + + it("does NOT inject aiosqlite when testing=false", async () => { + await plugin.generate("my-api", { language: "python", testing: false, outputDir: "/tmp/my-api" }); + const writeCalls = mockFsExtra.writeFile.mock.calls; + const hasAiosqlite = writeCalls.some((c) => (c[1] as string).includes("aiosqlite")); + expect(hasAiosqlite).toBe(false); + }); +}); diff --git a/tsup.config.ts b/tsup.config.ts index 77a894c..90672e7 100644 --- a/tsup.config.ts +++ b/tsup.config.ts @@ -16,6 +16,7 @@ export default defineConfig({ shims: true, loader: { ".py": "copy", + ".go": "copy", ".md": "copy", ".json": "copy", ".toml": "copy", @@ -24,6 +25,9 @@ export default defineConfig({ ".yaml": "copy", ".txt": "copy", ".example": "copy", + ".mod": "copy", + ".sum": "copy", + ".air": "copy", }, onSuccess: async () => { const __dirname = dirname(fileURLToPath(import.meta.url)); @@ -40,6 +44,12 @@ export default defineConfig({ { filter: templateFilter }, ); + await copy( + join(__dirname, "plugins/go/template"), + join(__dirname, "dist/plugins/go/template"), + { filter: templateFilter }, + ); + console.log("✓ Template files copied"); }, }); diff --git a/types/index.ts b/types/index.ts index d14446a..c9c484f 100644 --- a/types/index.ts +++ b/types/index.ts @@ -19,6 +19,13 @@ export interface GenerateOptions { email?: boolean; s3?: boolean; queue?: boolean; + preCommit?: boolean; + observability?: boolean; + sentry?: boolean; + /** Go module path, e.g. github.com/username/my-app */ + modulePath?: string; + /** JWT authentication addon (Go only) */ + jwt?: boolean; /** Resolved absolute output path — set internally by ArchGen before calling plugin.generate() */ outputDir?: string; } @@ -37,6 +44,7 @@ export interface StackInfo { export interface AddAddonOptions { dryRun?: boolean; + sentry?: boolean; } export interface Plugin {