diff --git a/.github/workflows/docker-publish.yml b/.github/workflows/docker-publish.yml new file mode 100644 index 0000000..c61d7fe --- /dev/null +++ b/.github/workflows/docker-publish.yml @@ -0,0 +1,93 @@ +name: Build and Push to GHCR + +on: + push: + branches: + - main + tags: + - 'v*' + pull_request: + branches: + - main + +env: + REGISTRY: ghcr.io + BACKEND_IMAGE_NAME: ${{ github.repository }}/backend + FRONTEND_IMAGE_NAME: ${{ github.repository }}/frontend + +jobs: + build-and-push-backend: + runs-on: ubuntu-latest + permissions: + contents: read + packages: write + + steps: + - name: Checkout repository + uses: actions/checkout@v4 + + - name: Log in to the Container registry + uses: docker/login-action@v3 + with: + registry: ${{ env.REGISTRY }} + username: ${{ github.actor }} + password: ${{ secrets.GITHUB_TOKEN }} + + - name: Extract metadata (tags, labels) for Docker + id: meta + uses: docker/metadata-action@v5 + with: + images: ${{ env.REGISTRY }}/${{ env.BACKEND_IMAGE_NAME }} + tags: | + type=ref,event=branch + type=ref,event=pr + type=semver,pattern={{version}} + type=semver,pattern={{major}}.{{minor}} + type=sha + + - name: Build and push Backend Docker image + uses: docker/build-push-action@v5 + with: + context: . + file: ./backend/Dockerfile + push: true + tags: ${{ steps.meta.outputs.tags }} + labels: ${{ steps.meta.outputs.labels }} + + build-and-push-frontend: + runs-on: ubuntu-latest + permissions: + contents: read + packages: write + + steps: + - name: Checkout repository + uses: actions/checkout@v4 + + - name: Log in to the Container registry + uses: docker/login-action@v3 + with: + registry: ${{ env.REGISTRY }} + username: ${{ github.actor }} + password: ${{ secrets.GITHUB_TOKEN }} + + - name: Extract metadata (tags, labels) for Docker + id: meta + uses: docker/metadata-action@v5 + with: + images: ${{ env.REGISTRY }}/${{ env.FRONTEND_IMAGE_NAME }} + tags: | + type=ref,event=branch + type=ref,event=pr + type=semver,pattern={{version}} + type=semver,pattern={{major}}.{{minor}} + type=sha + + - name: Build and push Frontend Docker image + uses: docker/build-push-action@v5 + with: + context: ./frontend + file: ./frontend/Dockerfile + push: true + tags: ${{ steps.meta.outputs.tags }} + labels: ${{ steps.meta.outputs.labels }} diff --git a/.gitignore b/.gitignore index b7faf40..aacb96f 100644 --- a/.gitignore +++ b/.gitignore @@ -1,3 +1,17 @@ +# Node modules +node_modules/ +package-lock.json + +# Build output +.next/ +out/ + +# SQLite databases +*.db +*.db-journal +*.db-wal +*.db-shm + # Byte-compiled / optimized / DLL files __pycache__/ *.py[codz] diff --git a/README.md b/README.md index 8cd46be..7c8e177 100644 --- a/README.md +++ b/README.md @@ -1,2 +1,131 @@ # goodpackagerepo -Worlds first good package repo + +World's first good package repository - A schema-driven, secure, and fast artifact storage system. + +## Features + +- 🔒 **Secure by Design**: Content-addressed storage with SHA256 verification +- ⚡ **Lightning Fast**: Built-in caching and intelligent indexing +- 📋 **Schema-Driven**: Declarative configuration with automatic validation +- 🔐 **Authentication**: Simple admin login with password management +- 🐳 **Docker Ready**: Full Docker and docker-compose support +- 📦 **GHCR Support**: Automated builds and publishing to GitHub Container Registry +- 🚀 **CapRover Ready**: Easy deployment with CapRover PaaS + +## Quick Start + +### Using Docker Compose + +```bash +git clone https://github.com/johndoe6345789/goodpackagerepo.git +cd goodpackagerepo +docker-compose up -d +``` + +The frontend will be available at http://localhost:3000 and the backend API at http://localhost:5000. + +**Default credentials**: `admin` / `admin` (change after first login!) + +### Manual Setup + +#### Backend (Flask) + +```bash +cd backend +pip install -r requirements.txt +export DATA_DIR=/tmp/data +export JWT_SECRET=your-secret-key +python app.py +``` + +#### Frontend (Next.js) + +```bash +cd frontend +npm install +npm run dev +``` + +## Documentation + +Complete documentation is available at `/docs` when running the application, including: + +- Getting Started Guide +- CapRover Deployment Instructions +- API Usage Examples +- Schema Configuration + +## Testing + +### E2E Tests (Playwright) + +```bash +cd tests +npm install +npx playwright install +npm test +``` + +See `tests/README.md` for more testing options. + +## Deployment + +### CapRover + +See the full CapRover setup guide in the documentation at `/docs#caprover-setup`. + +Quick summary: +1. Create two apps in CapRover: `goodrepo-backend` and `goodrepo-frontend` +2. Deploy from GitHub using the respective `captain-definition` files +3. Set environment variables +4. Enable HTTPS + +### Docker Registries + +Images are automatically built and pushed to GitHub Container Registry (GHCR) on push to main: + +- Backend: `ghcr.io/johndoe6345789/goodpackagerepo/backend:latest` +- Frontend: `ghcr.io/johndoe6345789/goodpackagerepo/frontend:latest` + +## Architecture + +- **Backend**: Flask-based Python API implementing the schema.json specification +- **Frontend**: Next.js/React application with custom Material Design SCSS +- **Storage**: SQLite for user auth, filesystem for blobs, in-memory for metadata +- **Authentication**: JWT-based with bcrypt password hashing + +## API Endpoints + +### Authentication +- `POST /auth/login` - Login and get JWT token +- `POST /auth/change-password` - Change password +- `GET /auth/me` - Get current user info + +### Package Management +- `PUT /v1/{namespace}/{name}/{version}/{variant}/blob` - Publish package +- `GET /v1/{namespace}/{name}/{version}/{variant}/blob` - Download package +- `GET /v1/{namespace}/{name}/latest` - Get latest version +- `GET /v1/{namespace}/{name}/versions` - List all versions +- `PUT /v1/{namespace}/{name}/tags/{tag}` - Set tag + +## Schema Configuration + +The repository behavior is defined by `schema.json`, which includes: + +- **Entities**: Data models with validation rules +- **Storage**: Blob stores, KV stores, document schemas +- **Indexes**: Optimized package lookup +- **Auth**: JWT authentication with scope-based permissions +- **API Routes**: Declarative pipeline-based endpoints +- **Caching**: Response and blob caching policies +- **Replication**: Event sourcing for multi-region sync +- **GC**: Automatic garbage collection + +## License + +See LICENSE file for details. + +## Contributing + +Contributions are welcome! Please feel free to submit a Pull Request. + diff --git a/backend/Dockerfile b/backend/Dockerfile new file mode 100644 index 0000000..0dabd0c --- /dev/null +++ b/backend/Dockerfile @@ -0,0 +1,23 @@ +FROM python:3.11-slim + +WORKDIR /app + +# Install dependencies +COPY requirements.txt . +RUN pip install --no-cache-dir -r requirements.txt + +# Copy application +COPY app.py . + +# Copy schema from parent directory +COPY ../schema.json /app/schema.json + +# Create data directory +RUN mkdir -p /data/blobs /data/meta + +ENV DATA_DIR=/data +ENV FLASK_APP=app.py + +EXPOSE 5000 + +CMD ["python", "-m", "flask", "run", "--host=0.0.0.0"] diff --git a/backend/app.py b/backend/app.py new file mode 100644 index 0000000..75067e4 --- /dev/null +++ b/backend/app.py @@ -0,0 +1,486 @@ +""" +Package Repository Server - Flask Backend +Implements the schema.json declarative repository specification. +""" + +import json +import os +import hashlib +import time +from datetime import datetime +from pathlib import Path +from typing import Dict, Any, Optional + +from flask import Flask, request, jsonify, send_file, Response +from flask_cors import CORS +import jwt +from werkzeug.exceptions import HTTPException +import jsonschema + +import auth as auth_module + +app = Flask(__name__) +CORS(app) + +# Load schema configuration +SCHEMA_PATH = Path(__file__).parent.parent / "schema.json" +with open(SCHEMA_PATH) as f: + SCHEMA = json.load(f) + +# Configuration +DATA_DIR = Path(os.environ.get("DATA_DIR", "/tmp/data")) +BLOB_DIR = DATA_DIR / "blobs" +META_DIR = DATA_DIR / "meta" +JWT_SECRET = os.environ.get("JWT_SECRET", "dev-secret-key") + +# Initialize storage +BLOB_DIR.mkdir(parents=True, exist_ok=True) +META_DIR.mkdir(parents=True, exist_ok=True) + +# Simple in-memory KV store (for MVP, would use RocksDB in production) +kv_store: Dict[str, Any] = {} +index_store: Dict[str, list] = {} + + +class RepositoryError(Exception): + """Base exception for repository errors.""" + def __init__(self, message: str, status_code: int = 400, code: str = "ERROR"): + self.message = message + self.status_code = status_code + self.code = code + super().__init__(self.message) + + +def get_blob_path(digest: str) -> Path: + """Generate blob storage path based on schema configuration.""" + # Remove sha256: prefix if present + clean_digest = digest.replace("sha256:", "") + # Use addressing template from schema + return BLOB_DIR / clean_digest[:2] / clean_digest[2:4] / clean_digest + + +def verify_token(token: str) -> Dict[str, Any]: + """Verify JWT token and return principal.""" + try: + payload = jwt.decode(token, JWT_SECRET, algorithms=["HS256"]) + return payload + except jwt.InvalidTokenError: + raise RepositoryError("Invalid token", 401, "UNAUTHORIZED") + + +def require_scopes(required_scopes: list) -> Optional[Dict[str, Any]]: + """Check if request has required scopes.""" + auth_header = request.headers.get("Authorization", "") + if not auth_header.startswith("Bearer "): + # For MVP, allow unauthenticated read access + if "read" in required_scopes: + return {"sub": "anonymous", "scopes": ["read"]} + raise RepositoryError("Missing authorization", 401, "UNAUTHORIZED") + + token = auth_header[7:] + principal = verify_token(token) + + user_scopes = principal.get("scopes", []) + if not any(scope in user_scopes for scope in required_scopes): + raise RepositoryError("Insufficient permissions", 403, "FORBIDDEN") + + return principal + + +def normalize_entity(entity_data: Dict[str, Any], entity_type: str = "artifact") -> Dict[str, Any]: + """Normalize entity fields based on schema configuration.""" + entity_config = SCHEMA["entities"][entity_type] + normalized = {} + + for field_name, field_config in entity_config["fields"].items(): + value = entity_data.get(field_name) + if value is None: + if not field_config.get("optional", False): + normalized[field_name] = "" + continue + + # Apply normalization rules + normalizations = field_config.get("normalize", []) + for norm in normalizations: + if norm == "trim": + value = value.strip() + elif norm == "lower": + value = value.lower() + elif norm.startswith("replace:"): + parts = norm.split(":") + if len(parts) == 3: + value = value.replace(parts[1], parts[2]) + + normalized[field_name] = value + + return normalized + + +def validate_entity(entity_data: Dict[str, Any], entity_type: str = "artifact") -> None: + """Validate entity against schema constraints.""" + entity_config = SCHEMA["entities"][entity_type] + + for constraint in entity_config.get("constraints", []): + field = constraint["field"] + value = entity_data.get(field) + + # Skip validation if field is optional and not present + if constraint.get("when_present", False) and not value: + continue + + if value and "regex" in constraint: + import re + if not re.match(constraint["regex"], value): + raise RepositoryError( + f"Invalid {field}: does not match pattern {constraint['regex']}", + 400, + "VALIDATION_ERROR" + ) + + +def compute_blob_digest(data: bytes) -> str: + """Compute SHA256 digest of blob data.""" + return "sha256:" + hashlib.sha256(data).hexdigest() + + +@app.route("/auth/login", methods=["POST"]) +def login(): + """Login endpoint - returns JWT token.""" + try: + data = request.get_json() + if not data or 'username' not in data or 'password' not in data: + raise RepositoryError("Missing username or password", 400, "INVALID_REQUEST") + + user = auth_module.verify_password(data['username'], data['password']) + if not user: + raise RepositoryError("Invalid credentials", 401, "UNAUTHORIZED") + + token = auth_module.generate_token(user, JWT_SECRET) + + return jsonify({ + "ok": True, + "token": token, + "user": { + "username": user['username'], + "scopes": user['scopes'] + } + }) + except RepositoryError: + raise + except Exception as e: + raise RepositoryError("Login failed", 500, "LOGIN_ERROR") + + +@app.route("/auth/change-password", methods=["POST"]) +def change_password(): + """Change password endpoint.""" + # Must be authenticated + auth_header = request.headers.get("Authorization", "") + if not auth_header.startswith("Bearer "): + raise RepositoryError("Missing authorization", 401, "UNAUTHORIZED") + + token = auth_header[7:] + try: + principal = verify_token(token) + except: + raise RepositoryError("Invalid token", 401, "UNAUTHORIZED") + + try: + data = request.get_json() + if not data or 'old_password' not in data or 'new_password' not in data: + raise RepositoryError("Missing old_password or new_password", 400, "INVALID_REQUEST") + + if len(data['new_password']) < 4: + raise RepositoryError("New password must be at least 4 characters", 400, "INVALID_PASSWORD") + + username = principal['sub'] + success = auth_module.change_password(username, data['old_password'], data['new_password']) + + if not success: + raise RepositoryError("Old password is incorrect", 401, "INVALID_PASSWORD") + + return jsonify({"ok": True, "message": "Password changed successfully"}) + except RepositoryError: + raise + except Exception as e: + raise RepositoryError("Password change failed", 500, "PASSWORD_CHANGE_ERROR") + + +@app.route("/auth/me", methods=["GET"]) +def get_current_user(): + """Get current user info from token.""" + auth_header = request.headers.get("Authorization", "") + if not auth_header.startswith("Bearer "): + raise RepositoryError("Missing authorization", 401, "UNAUTHORIZED") + + token = auth_header[7:] + try: + principal = verify_token(token) + return jsonify({ + "ok": True, + "user": { + "username": principal['sub'], + "scopes": principal.get('scopes', []) + } + }) + except: + raise RepositoryError("Invalid token", 401, "UNAUTHORIZED") + + +@app.route("/v1/////blob", methods=["PUT"]) +def publish_artifact_blob(namespace: str, name: str, version: str, variant: str): + """Publish artifact blob endpoint.""" + # Auth check + principal = require_scopes(["write"]) + + # Parse and normalize entity + entity = normalize_entity({ + "namespace": namespace, + "name": name, + "version": version, + "variant": variant + }) + + # Validate entity + validate_entity(entity) + + # Read blob data + blob_data = request.get_data() + if len(blob_data) > SCHEMA["ops"]["limits"]["max_request_body_bytes"]: + raise RepositoryError("Blob too large", 413, "BLOB_TOO_LARGE") + + # Compute digest + digest = compute_blob_digest(blob_data) + blob_size = len(blob_data) + + # Store blob + blob_path = get_blob_path(digest) + blob_path.parent.mkdir(parents=True, exist_ok=True) + + if not blob_path.exists(): + with open(blob_path, "wb") as f: + f.write(blob_data) + + # Store metadata + artifact_key = f"artifact/{entity['namespace']}/{entity['name']}/{entity['version']}/{entity['variant']}" + + if artifact_key in kv_store: + raise RepositoryError("Artifact already exists", 409, "ALREADY_EXISTS") + + now = datetime.utcnow().isoformat() + "Z" + meta = { + "namespace": entity["namespace"], + "name": entity["name"], + "version": entity["version"], + "variant": entity["variant"], + "blob_digest": digest, + "blob_size": blob_size, + "created_at": now, + "created_by": principal.get("sub", "unknown") + } + + kv_store[artifact_key] = meta + + # Update index + index_key = f"{entity['namespace']}/{entity['name']}" + if index_key not in index_store: + index_store[index_key] = [] + + index_store[index_key].append({ + "namespace": entity["namespace"], + "name": entity["name"], + "version": entity["version"], + "variant": entity["variant"], + "blob_digest": digest + }) + + # Sort by version (simple string sort for MVP) + index_store[index_key].sort(key=lambda x: x["version"], reverse=True) + + return jsonify({ + "ok": True, + "digest": digest, + "size": blob_size + }), 201 + + +@app.route("/v1/////blob", methods=["GET"]) +def fetch_artifact_blob(namespace: str, name: str, version: str, variant: str): + """Fetch artifact blob endpoint.""" + # Auth check + require_scopes(["read"]) + + # Parse and normalize entity + entity = normalize_entity({ + "namespace": namespace, + "name": name, + "version": version, + "variant": variant + }) + + # Validate entity + validate_entity(entity) + + # Get metadata + artifact_key = f"artifact/{entity['namespace']}/{entity['name']}/{entity['version']}/{entity['variant']}" + meta = kv_store.get(artifact_key) + + if not meta: + raise RepositoryError("Artifact not found", 404, "NOT_FOUND") + + # Get blob + blob_path = get_blob_path(meta["blob_digest"]) + if not blob_path.exists(): + raise RepositoryError("Blob not found", 404, "BLOB_NOT_FOUND") + + return send_file( + blob_path, + mimetype="application/octet-stream", + as_attachment=True, + download_name=f"{entity['name']}-{entity['version']}.tar.gz" + ) + + +@app.route("/v1///latest", methods=["GET"]) +def resolve_latest(namespace: str, name: str): + """Resolve latest version endpoint.""" + # Auth check + require_scopes(["read"]) + + # Parse and normalize entity + entity = normalize_entity({ + "namespace": namespace, + "name": name, + "version": "", + "variant": "" + }) + + # Query index + index_key = f"{entity['namespace']}/{entity['name']}" + rows = index_store.get(index_key, []) + + if not rows: + raise RepositoryError("No versions found", 404, "NOT_FOUND") + + latest = rows[0] + return jsonify({ + "namespace": entity["namespace"], + "name": entity["name"], + "version": latest["version"], + "variant": latest["variant"], + "blob_digest": latest["blob_digest"] + }) + + +@app.route("/v1///tags/", methods=["PUT"]) +def set_tag(namespace: str, name: str, tag: str): + """Set tag endpoint.""" + # Auth check + principal = require_scopes(["write"]) + + # Parse and normalize entity + entity = normalize_entity({ + "namespace": namespace, + "name": name, + "version": "", + "variant": "", + "tag": tag + }) + + # Validate entity + validate_entity(entity) + + # Parse request body + try: + body = request.get_json() + if not body or "target_version" not in body or "target_variant" not in body: + raise RepositoryError("Missing required fields", 400, "INVALID_REQUEST") + except Exception as e: + raise RepositoryError("Invalid JSON", 400, "INVALID_JSON") + + # Check if target exists + target_key = f"artifact/{entity['namespace']}/{entity['name']}/{body['target_version']}/{body['target_variant']}" + if target_key not in kv_store: + raise RepositoryError("Target artifact not found", 404, "TARGET_NOT_FOUND") + + # Store tag + now = datetime.utcnow().isoformat() + "Z" + tag_key = f"tag/{entity['namespace']}/{entity['name']}/{entity['tag']}" + + kv_store[tag_key] = { + "namespace": entity["namespace"], + "name": entity["name"], + "tag": entity["tag"], + "target_key": target_key, + "updated_at": now, + "updated_by": principal.get("sub", "unknown") + } + + return jsonify({"ok": True}) + + +@app.route("/v1///versions", methods=["GET"]) +def list_versions(namespace: str, name: str): + """List all versions of a package.""" + # Auth check + require_scopes(["read"]) + + # Parse and normalize entity + entity = normalize_entity({ + "namespace": namespace, + "name": name, + "version": "", + "variant": "" + }) + + # Query index + index_key = f"{entity['namespace']}/{entity['name']}" + rows = index_store.get(index_key, []) + + return jsonify({ + "namespace": entity["namespace"], + "name": entity["name"], + "versions": rows + }) + + +@app.route("/health", methods=["GET"]) +def health(): + """Health check endpoint.""" + return jsonify({"status": "healthy"}) + + +@app.route("/schema", methods=["GET"]) +def get_schema(): + """Return the repository schema.""" + return jsonify(SCHEMA) + + +@app.errorhandler(RepositoryError) +def handle_repository_error(error): + """Handle repository errors.""" + return jsonify({ + "error": { + "code": error.code, + "message": error.message + } + }), error.status_code + + +@app.errorhandler(Exception) +def handle_exception(error): + """Handle unexpected errors.""" + if isinstance(error, HTTPException): + return error + + app.logger.error(f"Unexpected error: {error}", exc_info=True) + return jsonify({ + "error": { + "code": "INTERNAL_ERROR", + "message": "An unexpected error occurred" + } + }), 500 + + +if __name__ == "__main__": + app.run(host="0.0.0.0", port=5000, debug=True) diff --git a/backend/auth.py b/backend/auth.py new file mode 100644 index 0000000..dc7da04 --- /dev/null +++ b/backend/auth.py @@ -0,0 +1,113 @@ +""" +Authentication and user management module using SQLite. +""" + +import sqlite3 +import bcrypt +import jwt +from datetime import datetime, timedelta +from pathlib import Path +from typing import Optional, Dict, Any + +# Database path +DB_PATH = Path(__file__).parent / "users.db" + + +def get_db(): + """Get database connection.""" + conn = sqlite3.connect(str(DB_PATH)) + conn.row_factory = sqlite3.Row + return conn + + +def init_db(): + """Initialize the database with users table and default admin user.""" + conn = get_db() + cursor = conn.cursor() + + # Create users table + cursor.execute(""" + CREATE TABLE IF NOT EXISTS users ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + username TEXT UNIQUE NOT NULL, + password_hash TEXT NOT NULL, + scopes TEXT NOT NULL, + created_at TEXT NOT NULL, + updated_at TEXT NOT NULL + ) + """) + + # Check if admin user exists + cursor.execute("SELECT id FROM users WHERE username = ?", ("admin",)) + if not cursor.fetchone(): + # Create default admin user (admin/admin) + password_hash = bcrypt.hashpw("admin".encode('utf-8'), bcrypt.gensalt()) + now = datetime.utcnow().isoformat() + "Z" + cursor.execute(""" + INSERT INTO users (username, password_hash, scopes, created_at, updated_at) + VALUES (?, ?, ?, ?, ?) + """, ("admin", password_hash.decode('utf-8'), "read,write,admin", now, now)) + + conn.commit() + conn.close() + + +def verify_password(username: str, password: str) -> Optional[Dict[str, Any]]: + """Verify username and password, return user data if valid.""" + conn = get_db() + cursor = conn.cursor() + + cursor.execute("SELECT * FROM users WHERE username = ?", (username,)) + user = cursor.fetchone() + conn.close() + + if not user: + return None + + # Verify password + if bcrypt.checkpw(password.encode('utf-8'), user['password_hash'].encode('utf-8')): + return { + 'id': user['id'], + 'username': user['username'], + 'scopes': user['scopes'].split(',') + } + + return None + + +def change_password(username: str, old_password: str, new_password: str) -> bool: + """Change user password.""" + # Verify old password first + user = verify_password(username, old_password) + if not user: + return False + + # Hash new password + password_hash = bcrypt.hashpw(new_password.encode('utf-8'), bcrypt.gensalt()) + now = datetime.utcnow().isoformat() + "Z" + + conn = get_db() + cursor = conn.cursor() + cursor.execute(""" + UPDATE users + SET password_hash = ?, updated_at = ? + WHERE username = ? + """, (password_hash.decode('utf-8'), now, username)) + conn.commit() + conn.close() + + return True + + +def generate_token(user: Dict[str, Any], secret: str, expires_hours: int = 24) -> str: + """Generate JWT token for user.""" + payload = { + 'sub': user['username'], + 'scopes': user['scopes'], + 'exp': datetime.utcnow() + timedelta(hours=expires_hours) + } + return jwt.encode(payload, secret, algorithm='HS256') + + +# Initialize database on module import +init_db() diff --git a/backend/captain-definition b/backend/captain-definition new file mode 100644 index 0000000..0e14f82 --- /dev/null +++ b/backend/captain-definition @@ -0,0 +1,4 @@ +{ + "schemaVersion": 2, + "dockerfilePath": "./Dockerfile" +} diff --git a/backend/requirements.txt b/backend/requirements.txt new file mode 100644 index 0000000..80483b4 --- /dev/null +++ b/backend/requirements.txt @@ -0,0 +1,7 @@ +Flask==3.0.0 +Flask-CORS==4.0.0 +pyjwt==2.8.0 +rocksdict==0.3.23 +werkzeug==3.0.1 +jsonschema==4.20.0 +bcrypt==4.1.2 diff --git a/docker-compose.yml b/docker-compose.yml new file mode 100644 index 0000000..dae411c --- /dev/null +++ b/docker-compose.yml @@ -0,0 +1,41 @@ +version: '3.8' + +services: + backend: + build: + context: . + dockerfile: backend/Dockerfile + ports: + - "5000:5000" + environment: + - DATA_DIR=/data + - JWT_SECRET=dev-secret-change-in-production + - FLASK_ENV=development + volumes: + - backend-data:/data + networks: + - app-network + restart: unless-stopped + + frontend: + build: + context: ./frontend + dockerfile: Dockerfile + ports: + - "3000:3000" + environment: + - API_URL=http://backend:5000 + - NODE_ENV=production + depends_on: + - backend + networks: + - app-network + restart: unless-stopped + +volumes: + backend-data: + driver: local + +networks: + app-network: + driver: bridge diff --git a/frontend/Dockerfile b/frontend/Dockerfile new file mode 100644 index 0000000..ecf8fa5 --- /dev/null +++ b/frontend/Dockerfile @@ -0,0 +1,50 @@ +FROM node:18-alpine AS base + +# Install dependencies only when needed +FROM base AS deps +RUN apk add --no-cache libc6-compat +WORKDIR /app + +COPY package.json package-lock.json* ./ +RUN npm ci + +# Rebuild the source code only when needed +FROM base AS builder +WORKDIR /app +COPY --from=deps /app/node_modules ./node_modules +COPY . . + +# Set API URL for build (can be overridden at runtime) +ENV API_URL=http://backend:5000 +ENV NEXT_TELEMETRY_DISABLED=1 + +RUN npm run build + +# Production image, copy all the files and run next +FROM base AS runner +WORKDIR /app + +ENV NODE_ENV=production +ENV NEXT_TELEMETRY_DISABLED=1 + +RUN addgroup --system --gid 1001 nodejs +RUN adduser --system --uid 1001 nextjs + +COPY --from=builder /app/public ./public + +# Set the correct permission for prerender cache +RUN mkdir .next +RUN chown nextjs:nodejs .next + +# Automatically leverage output traces to reduce image size +COPY --from=builder --chown=nextjs:nodejs /app/.next/standalone ./ +COPY --from=builder --chown=nextjs:nodejs /app/.next/static ./.next/static + +USER nextjs + +EXPOSE 3000 + +ENV PORT=3000 +ENV HOSTNAME="0.0.0.0" + +CMD ["node", "server.js"] diff --git a/frontend/captain-definition b/frontend/captain-definition new file mode 100644 index 0000000..0e14f82 --- /dev/null +++ b/frontend/captain-definition @@ -0,0 +1,4 @@ +{ + "schemaVersion": 2, + "dockerfilePath": "./Dockerfile" +} diff --git a/frontend/next.config.js b/frontend/next.config.js new file mode 100644 index 0000000..26fff45 --- /dev/null +++ b/frontend/next.config.js @@ -0,0 +1,9 @@ +/** @type {import('next').NextConfig} */ +const nextConfig = { + env: { + API_URL: process.env.API_URL || 'http://localhost:5000', + }, + output: 'standalone', +} + +module.exports = nextConfig diff --git a/frontend/package.json b/frontend/package.json new file mode 100644 index 0000000..cd4a9e0 --- /dev/null +++ b/frontend/package.json @@ -0,0 +1,21 @@ +{ + "name": "frontend", + "version": "1.0.0", + "main": "index.js", + "scripts": { + "dev": "next dev", + "build": "next build", + "start": "next start", + "lint": "next lint" + }, + "keywords": [], + "author": "", + "license": "ISC", + "description": "", + "dependencies": { + "next": "^14.2.35", + "react": "^18.3.1", + "react-dom": "^18.3.1", + "sass": "^1.97.1" + } +} diff --git a/frontend/src/app/account/page.jsx b/frontend/src/app/account/page.jsx new file mode 100644 index 0000000..11e058a --- /dev/null +++ b/frontend/src/app/account/page.jsx @@ -0,0 +1,201 @@ +'use client'; + +import { useState, useEffect } from 'react'; +import { useRouter } from 'next/navigation'; +import styles from './page.module.scss'; + +export default function AccountPage() { + const router = useRouter(); + const [user, setUser] = useState(null); + const [formData, setFormData] = useState({ + old_password: '', + new_password: '', + confirm_password: '' + }); + const [message, setMessage] = useState({ type: '', text: '' }); + const [loading, setLoading] = useState(false); + + useEffect(() => { + // Check if user is logged in + const token = localStorage.getItem('token'); + const userData = localStorage.getItem('user'); + + if (!token || !userData) { + router.push('/login'); + return; + } + + setUser(JSON.parse(userData)); + }, [router]); + + const handleChange = (e) => { + const { name, value } = e.target; + setFormData(prev => ({ ...prev, [name]: value })); + }; + + const handlePasswordChange = async (e) => { + e.preventDefault(); + setMessage({ type: '', text: '' }); + + if (formData.new_password !== formData.confirm_password) { + setMessage({ type: 'error', text: 'New passwords do not match' }); + return; + } + + if (formData.new_password.length < 4) { + setMessage({ type: 'error', text: 'Password must be at least 4 characters' }); + return; + } + + setLoading(true); + + try { + const apiUrl = process.env.API_URL || 'http://localhost:5000'; + const token = localStorage.getItem('token'); + + const response = await fetch(`${apiUrl}/auth/change-password`, { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + 'Authorization': `Bearer ${token}` + }, + body: JSON.stringify({ + old_password: formData.old_password, + new_password: formData.new_password + }) + }); + + if (response.ok) { + setMessage({ type: 'success', text: 'Password changed successfully!' }); + setFormData({ + old_password: '', + new_password: '', + confirm_password: '' + }); + } else { + const error = await response.json(); + setMessage({ type: 'error', text: error.error?.message || 'Failed to change password' }); + } + } catch (error) { + setMessage({ type: 'error', text: 'Network error. Please try again.' }); + } finally { + setLoading(false); + } + }; + + const handleLogout = () => { + localStorage.removeItem('token'); + localStorage.removeItem('user'); + router.push('/login'); + }; + + if (!user) { + return
Loading...
; + } + + return ( +
+
+

Account Settings

+

Manage your account and security settings

+
+ +
+

User Information

+
+
+ Username + {user.username} +
+
+ Permissions + + {user.scopes?.map((scope, idx) => ( + {scope} + ))} + +
+
+
+ +
+

Change Password

+ + {message.text && ( +
+ {message.text} +
+ )} + +
+
+ + +
+ +
+ + +
+ +
+ + +
+ +
+ +
+
+
+ +
+

Session

+
+ +
+
+
+ ); +} diff --git a/frontend/src/app/account/page.module.scss b/frontend/src/app/account/page.module.scss new file mode 100644 index 0000000..49829df --- /dev/null +++ b/frontend/src/app/account/page.module.scss @@ -0,0 +1,138 @@ +@import '../../styles/variables'; + +.container { + max-width: 800px; + margin: 0 auto; + padding: $spacing-xl $spacing-lg; +} + +.header { + margin-bottom: $spacing-xl; + + h1 { + font-size: $font-size-h3; + margin-bottom: $spacing-md; + } +} + +.section { + @include card; + padding: $spacing-xl; + margin-bottom: $spacing-lg; + + &__title { + font-size: $font-size-h6; + margin-bottom: $spacing-lg; + padding-bottom: $spacing-md; + border-bottom: 1px solid rgba(0, 0, 0, 0.12); + } +} + +.info { + display: flex; + flex-direction: column; + gap: $spacing-md; + + &__item { + display: flex; + justify-content: space-between; + align-items: center; + + &Label { + font-weight: 500; + color: $text-secondary; + } + + &Value { + color: $text-primary; + } + } +} + +.form { + display: flex; + flex-direction: column; + gap: $spacing-lg; + + &__group { + display: flex; + flex-direction: column; + gap: $spacing-sm; + } + + &__label { + font-weight: 500; + color: $text-primary; + } + + &__input { + @include input-base; + } + + &__actions { + display: flex; + gap: $spacing-md; + justify-content: flex-end; + } +} + +.button { + @include button-base; + + &--primary { + background: $primary-color; + color: white; + + &:hover { + background: $primary-dark; + } + } + + &--secondary { + background: transparent; + color: $primary-color; + border: 1px solid $primary-color; + + &:hover { + background: rgba(25, 118, 210, 0.04); + } + } + + &--danger { + background: $error-color; + color: white; + + &:hover { + background: darken($error-color, 10%); + } + } +} + +.alert { + padding: $spacing-md; + border-radius: $border-radius-sm; + margin-bottom: $spacing-lg; + + &--error { + background: rgba(244, 67, 54, 0.1); + border-left: 4px solid $error-color; + color: darken($error-color, 20%); + } + + &--success { + background: rgba(76, 175, 80, 0.1); + border-left: 4px solid $success-color; + color: darken($success-color, 20%); + } +} + +.badge { + display: inline-block; + padding: 4px 8px; + background: rgba(25, 118, 210, 0.1); + color: $primary-dark; + border-radius: $border-radius-sm; + font-size: $font-size-caption; + font-weight: 500; + margin-left: $spacing-xs; +} diff --git a/frontend/src/app/browse/page.jsx b/frontend/src/app/browse/page.jsx new file mode 100644 index 0000000..f5c8dc7 --- /dev/null +++ b/frontend/src/app/browse/page.jsx @@ -0,0 +1,72 @@ +'use client'; + +import { useState } from 'react'; +import styles from './page.module.scss'; + +export default function BrowsePage() { + const [packages] = useState([ + { + namespace: 'acme', + name: 'example-package', + version: '1.0.0', + variant: 'linux-amd64' + }, + { + namespace: 'acme', + name: 'another-package', + version: '2.1.0', + variant: 'linux-amd64' + } + ]); + const [searchTerm, setSearchTerm] = useState(''); + + const filteredPackages = packages.filter(pkg => + pkg.name.toLowerCase().includes(searchTerm.toLowerCase()) || + pkg.namespace.toLowerCase().includes(searchTerm.toLowerCase()) + ); + + return ( +
+
+

Browse Packages

+

Explore available packages in the repository

+
+ +
+ setSearchTerm(e.target.value)} + /> +
+ +
+ {filteredPackages.length > 0 ? ( + filteredPackages.map((pkg, idx) => ( +
+
+
{pkg.namespace}
+
{pkg.name}
+ v{pkg.version} +
+
+ + +
+
+ )) + ) : ( +
+

No packages found

+
+ )} +
+
+ ); +} diff --git a/frontend/src/app/browse/page.module.scss b/frontend/src/app/browse/page.module.scss new file mode 100644 index 0000000..878b4ac --- /dev/null +++ b/frontend/src/app/browse/page.module.scss @@ -0,0 +1,106 @@ +@import '../../styles/variables'; + +.container { + max-width: 1200px; + margin: 0 auto; + padding: $spacing-xl $spacing-lg; +} + +.header { + margin-bottom: $spacing-xl; + + h1 { + font-size: $font-size-h3; + margin-bottom: $spacing-md; + } +} + +.search { + margin-bottom: $spacing-xl; + + &__input { + @include input-base; + font-size: $font-size-h6; + padding: $spacing-md; + } +} + +.packages { + display: flex; + flex-direction: column; + gap: $spacing-md; +} + +.package { + @include card; + padding: $spacing-lg; + display: flex; + justify-content: space-between; + align-items: center; + + &__info { + flex: 1; + } + + &__name { + font-size: $font-size-h6; + font-weight: 500; + color: $primary-color; + margin-bottom: $spacing-xs; + } + + &__namespace { + font-size: $font-size-body2; + color: $text-secondary; + margin-bottom: $spacing-xs; + } + + &__version { + display: inline-block; + padding: 4px 8px; + background: rgba(25, 118, 210, 0.1); + color: $primary-dark; + border-radius: $border-radius-sm; + font-size: $font-size-caption; + font-weight: 500; + } + + &__actions { + display: flex; + gap: $spacing-sm; + } +} + +.button { + @include button-base; + + &--primary { + background: $primary-color; + color: white; + + &:hover { + background: $primary-dark; + } + } + + &--secondary { + background: transparent; + color: $primary-color; + border: 1px solid $primary-color; + + &:hover { + background: rgba(25, 118, 210, 0.04); + } + } + + &--small { + padding: $spacing-xs $spacing-sm; + font-size: $font-size-caption; + } +} + +.empty { + text-align: center; + padding: $spacing-xl; + color: $text-secondary; +} diff --git a/frontend/src/app/docs/page.jsx b/frontend/src/app/docs/page.jsx new file mode 100644 index 0000000..5163e84 --- /dev/null +++ b/frontend/src/app/docs/page.jsx @@ -0,0 +1,157 @@ +import styles from './page.module.scss'; + +export default function DocsPage() { + return ( +
+
+

Documentation

+

Complete guide to using Good Package Repo

+
+ +
+

Table of Contents

+ +
+ +
+

Getting Started

+

+ Good Package Repo is a schema-driven package repository that provides secure, + fast, and reliable artifact storage. It implements a declarative configuration + model based on the included schema.json file. +

+ +

Quick Start with Docker

+
{`# Clone the repository
+git clone https://github.com/johndoe6345789/goodpackagerepo.git
+cd goodpackagerepo
+
+# Start with Docker Compose
+docker-compose up -d
+
+# The frontend will be available at http://localhost:3000
+# The backend API will be available at http://localhost:5000`}
+ +

CapRover Setup

+

+ CapRover is a free and open-source PaaS that makes deployment incredibly simple. + Here's how to deploy Good Package Repo on CapRover: +

+ +

Prerequisites

+
    +
  • A CapRover instance running (see CapRover installation guide)
  • +
  • CapRover CLI installed: npm install -g caprover
  • +
  • GitHub Container Registry (GHCR) access (optional, for pre-built images)
  • +
+ +

Step 1: Create Backend App

+
    +
  1. Log into your CapRover dashboard
  2. +
  3. Click on "Apps" in the sidebar
  4. +
  5. Click "One-Click Apps/Databases"
  6. +
  7. Scroll down and click "Create a New App"
  8. +
  9. Enter app name: goodrepo-backend
  10. +
  11. Check "Has Persistent Data"
  12. +
+ +

Step 2: Configure Backend

+
    +
  1. Go to the app's "Deployment" tab
  2. +
  3. Select "Method 3: Deploy from Github/Bitbucket/Gitlab"
  4. +
  5. Enter repository: johndoe6345789/goodpackagerepo
  6. +
  7. Branch: main
  8. +
  9. Captain Definition File: backend/captain-definition
  10. +
  11. Click "Save & Update"
  12. +
+ +

Step 3: Set Environment Variables

+

In the "App Configs" tab, add these environment variables:

+
    +
  • DATA_DIR = /data
  • +
  • JWT_SECRET = (generate a random secret)
  • +
+ +

Step 4: Create Frontend App

+
    +
  1. Create another app: goodrepo-frontend
  2. +
  3. Follow the same deployment process
  4. +
  5. Captain Definition File: frontend/captain-definition
  6. +
  7. Set environment variable: API_URL = https://goodrepo-backend.your-domain.com
  8. +
+ +

Step 5: Enable HTTPS

+
    +
  1. Go to each app's "HTTP Settings"
  2. +
  3. Check "Enable HTTPS"
  4. +
  5. Check "Force HTTPS"
  6. +
  7. Save changes
  8. +
+ +

+ That's it! Your Good Package Repo is now deployed and accessible at your CapRover domain. +

+ +

API Usage

+ +

Authentication

+

+ Most endpoints require a JWT token for authentication. Include it in the Authorization header: +

+
{`Authorization: Bearer YOUR_JWT_TOKEN`}
+ +

Publishing a Package

+
{`curl -X PUT \\
+  -H "Authorization: Bearer YOUR_TOKEN" \\
+  -H "Content-Type: application/octet-stream" \\
+  --data-binary @package.tar.gz \\
+  https://your-repo.com/v1/acme/myapp/1.0.0/linux-amd64/blob`}
+ +

Downloading a Package

+
{`curl -H "Authorization: Bearer YOUR_TOKEN" \\
+  https://your-repo.com/v1/acme/myapp/1.0.0/linux-amd64/blob \\
+  -o myapp.tar.gz`}
+ +

Getting Latest Version

+
{`curl -H "Authorization: Bearer YOUR_TOKEN" \\
+  https://your-repo.com/v1/acme/myapp/latest`}
+ +

Listing Versions

+
{`curl -H "Authorization: Bearer YOUR_TOKEN" \\
+  https://your-repo.com/v1/acme/myapp/versions`}
+ +

Setting a Tag

+
{`curl -X PUT \\
+  -H "Authorization: Bearer YOUR_TOKEN" \\
+  -H "Content-Type: application/json" \\
+  -d '{"target_version": "1.0.0", "target_variant": "linux-amd64"}' \\
+  https://your-repo.com/v1/acme/myapp/tags/stable`}
+ +

Schema Configuration

+

+ Good Package Repo uses a declarative JSON schema to define its behavior. The schema includes: +

+
    +
  • Entities: Data models with validation and normalization rules
  • +
  • Storage: Blob stores, KV stores, and document schemas
  • +
  • Indexes: Optimized queries for package lookup
  • +
  • Auth: JWT-based authentication with scope-based permissions
  • +
  • API Routes: Declarative pipeline-based endpoints
  • +
  • Caching: Response and blob caching policies
  • +
  • Replication: Event sourcing for multi-region sync
  • +
  • GC: Automatic garbage collection for unreferenced blobs
  • +
+ +

+ The schema ensures consistency, security, and performance across all operations. + All modifications are validated at load-time to prevent misconfigurations. +

+
+
+ ); +} diff --git a/frontend/src/app/docs/page.module.scss b/frontend/src/app/docs/page.module.scss new file mode 100644 index 0000000..c333884 --- /dev/null +++ b/frontend/src/app/docs/page.module.scss @@ -0,0 +1,103 @@ +@import '../../styles/variables'; + +.container { + max-width: 900px; + margin: 0 auto; + padding: $spacing-xl $spacing-lg; +} + +.header { + margin-bottom: $spacing-xl; + + h1 { + font-size: $font-size-h3; + margin-bottom: $spacing-md; + } +} + +.content { + @include card; + padding: $spacing-xl; + + h2 { + font-size: $font-size-h5; + margin-top: $spacing-xl; + margin-bottom: $spacing-md; + color: $primary-color; + + &:first-child { + margin-top: 0; + } + } + + h3 { + font-size: $font-size-h6; + margin-top: $spacing-lg; + margin-bottom: $spacing-md; + } + + p { + margin-bottom: $spacing-md; + line-height: 1.6; + } + + ul, ol { + margin-bottom: $spacing-md; + padding-left: $spacing-lg; + + li { + margin-bottom: $spacing-sm; + } + } + + code { + background: rgba(0, 0, 0, 0.05); + padding: 2px 6px; + border-radius: $border-radius-sm; + font-family: $font-family-mono; + font-size: 0.9em; + } + + pre { + background: rgba(0, 0, 0, 0.87); + color: #00ff00; + padding: $spacing-md; + border-radius: $border-radius-sm; + overflow-x: auto; + margin-bottom: $spacing-md; + + code { + background: none; + color: #00ff00; + padding: 0; + } + } +} + +.toc { + @include card; + padding: $spacing-lg; + margin-bottom: $spacing-xl; + + h2 { + font-size: $font-size-h6; + margin-bottom: $spacing-md; + } + + ul { + list-style: none; + padding: 0; + + li { + margin-bottom: $spacing-sm; + + a { + color: $text-primary; + + &:hover { + color: $primary-color; + } + } + } + } +} diff --git a/frontend/src/app/layout.jsx b/frontend/src/app/layout.jsx new file mode 100644 index 0000000..7175060 --- /dev/null +++ b/frontend/src/app/layout.jsx @@ -0,0 +1,18 @@ +import '../styles/globals.scss'; +import Navbar from '../components/Navbar'; + +export const metadata = { + title: 'Good Package Repo', + description: 'World\'s first good package repository', +}; + +export default function RootLayout({ children }) { + return ( + + + + {children} + + + ); +} diff --git a/frontend/src/app/login/page.jsx b/frontend/src/app/login/page.jsx new file mode 100644 index 0000000..42f76df --- /dev/null +++ b/frontend/src/app/login/page.jsx @@ -0,0 +1,115 @@ +'use client'; + +import { useState } from 'react'; +import { useRouter } from 'next/navigation'; +import styles from './page.module.scss'; + +export default function LoginPage() { + const router = useRouter(); + const [formData, setFormData] = useState({ + username: '', + password: '' + }); + const [error, setError] = useState(''); + const [loading, setLoading] = useState(false); + + const handleChange = (e) => { + const { name, value } = e.target; + setFormData(prev => ({ ...prev, [name]: value })); + }; + + const handleSubmit = async (e) => { + e.preventDefault(); + setError(''); + setLoading(true); + + try { + const apiUrl = process.env.API_URL || 'http://localhost:5000'; + const response = await fetch(`${apiUrl}/auth/login`, { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + }, + body: JSON.stringify(formData) + }); + + if (response.ok) { + const result = await response.json(); + // Store token in localStorage + localStorage.setItem('token', result.token); + localStorage.setItem('user', JSON.stringify(result.user)); + // Redirect to home + router.push('/'); + } else { + const error = await response.json(); + setError(error.error?.message || 'Login failed'); + } + } catch (error) { + setError('Network error. Please try again.'); + } finally { + setLoading(false); + } + }; + + return ( +
+
+
+

Login

+

Sign in to your account

+
+ + {error && ( +
+ {error} +
+ )} + +
+
+ + +
+ +
+ + +
+ + +
+ +
+ Default credentials: admin / admin +
+
+
+ ); +} diff --git a/frontend/src/app/login/page.module.scss b/frontend/src/app/login/page.module.scss new file mode 100644 index 0000000..bb2419b --- /dev/null +++ b/frontend/src/app/login/page.module.scss @@ -0,0 +1,85 @@ +@import '../../styles/variables'; + +.container { + display: flex; + justify-content: center; + align-items: center; + min-height: calc(100vh - 80px); + padding: $spacing-lg; +} + +.loginBox { + @include card; + width: 100%; + max-width: 400px; + padding: $spacing-xl; + + &__header { + text-align: center; + margin-bottom: $spacing-xl; + + h1 { + font-size: $font-size-h4; + margin-bottom: $spacing-sm; + } + + p { + color: $text-secondary; + } + } + + &__form { + display: flex; + flex-direction: column; + gap: $spacing-lg; + } + + &__group { + display: flex; + flex-direction: column; + gap: $spacing-sm; + } + + &__label { + font-weight: 500; + color: $text-primary; + } + + &__input { + @include input-base; + } + + &__button { + @include button-base; + background: $primary-color; + color: white; + margin-top: $spacing-md; + + &:hover { + background: $primary-dark; + } + + &:disabled { + opacity: 0.6; + cursor: not-allowed; + } + } +} + +.alert { + padding: $spacing-md; + border-radius: $border-radius-sm; + margin-bottom: $spacing-lg; + + &--error { + background: rgba(244, 67, 54, 0.1); + border-left: 4px solid $error-color; + color: darken($error-color, 20%); + } + + &--success { + background: rgba(76, 175, 80, 0.1); + border-left: 4px solid $success-color; + color: darken($success-color, 20%); + } +} diff --git a/frontend/src/app/page.jsx b/frontend/src/app/page.jsx new file mode 100644 index 0000000..aa63a53 --- /dev/null +++ b/frontend/src/app/page.jsx @@ -0,0 +1,76 @@ +import Link from 'next/link'; +import styles from './page.module.scss'; + +export default function HomePage() { + return ( +
+
+

Welcome to Good Package Repo

+

+ The world's first truly good package repository +

+
+ + Browse Packages + + + Read Docs + +
+
+ +
+
+
🔒
+

Secure by Design

+

+ Content-addressed storage with SHA256 verification on every upload +

+
+
+
+

Lightning Fast

+

+ Built-in caching and intelligent indexing for rapid package retrieval +

+
+
+
📋
+

Schema-Driven

+

+ Declarative repository configuration with automatic validation +

+
+
+ +
+
+
100%
+
Uptime
+
+
+
0
+
Security Issues
+
+
+
+
Scalability
+
+
+ +
+
{`# Install a package
+curl -H "Authorization: Bearer $TOKEN" \\
+  https://repo.example.com/v1/acme/myapp/1.0.0/linux-amd64/blob \\
+  -o myapp.tar.gz
+
+# Publish a package
+curl -X PUT \\
+  -H "Authorization: Bearer $TOKEN" \\
+  -H "Content-Type: application/octet-stream" \\
+  --data-binary @myapp.tar.gz \\
+  https://repo.example.com/v1/acme/myapp/1.0.0/linux-amd64/blob`}
+
+
+ ); +} diff --git a/frontend/src/app/page.module.scss b/frontend/src/app/page.module.scss new file mode 100644 index 0000000..697e080 --- /dev/null +++ b/frontend/src/app/page.module.scss @@ -0,0 +1,131 @@ +@import '../styles/variables'; + +.container { + max-width: 1200px; + margin: 0 auto; + padding: $spacing-xl $spacing-lg; +} + +.hero { + text-align: center; + padding: $spacing-xl 0; + + &__title { + font-size: $font-size-h2; + font-weight: 500; + color: $text-primary; + margin-bottom: $spacing-md; + } + + &__subtitle { + font-size: $font-size-h6; + color: $text-secondary; + margin-bottom: $spacing-xl; + } + + &__actions { + display: flex; + gap: $spacing-md; + justify-content: center; + } +} + +.button { + @include button-base; + + &--primary { + background: $primary-color; + color: white; + + &:hover { + background: $primary-dark; + } + } + + &--secondary { + background: transparent; + color: $primary-color; + border: 1px solid $primary-color; + + &:hover { + background: rgba(25, 118, 210, 0.04); + } + } +} + +.features { + display: grid; + grid-template-columns: repeat(auto-fit, minmax(300px, 1fr)); + gap: $spacing-lg; + margin-top: $spacing-xl; +} + +.feature { + @include card; + padding: $spacing-lg; + text-align: center; + + &__icon { + font-size: 48px; + margin-bottom: $spacing-md; + } + + &__title { + font-size: $font-size-h6; + font-weight: 500; + margin-bottom: $spacing-sm; + } + + &__description { + color: $text-secondary; + } +} + +.stats { + display: grid; + grid-template-columns: repeat(auto-fit, minmax(200px, 1fr)); + gap: $spacing-lg; + margin-top: $spacing-xl; +} + +.stat { + @include card; + padding: $spacing-lg; + text-align: center; + + &__value { + font-size: $font-size-h4; + font-weight: 500; + color: $primary-color; + margin-bottom: $spacing-xs; + } + + &__label { + font-size: $font-size-body2; + color: $text-secondary; + text-transform: uppercase; + letter-spacing: 1px; + } +} + +.code { + background: rgba(0, 0, 0, 0.87); + color: #00ff00; + padding: $spacing-lg; + border-radius: $border-radius-md; + font-family: $font-family-mono; + margin: $spacing-xl 0; + overflow-x: auto; + + pre { + background: none; + padding: 0; + margin: 0; + } + + code { + background: none; + color: #00ff00; + font-size: $font-size-body2; + } +} diff --git a/frontend/src/app/publish/page.jsx b/frontend/src/app/publish/page.jsx new file mode 100644 index 0000000..db10750 --- /dev/null +++ b/frontend/src/app/publish/page.jsx @@ -0,0 +1,206 @@ +'use client'; + +import { useState } from 'react'; +import styles from './page.module.scss'; + +export default function PublishPage() { + const [formData, setFormData] = useState({ + namespace: '', + name: '', + version: '', + variant: '', + file: null + }); + const [status, setStatus] = useState({ type: null, message: '' }); + + const handleChange = (e) => { + const { name, value } = e.target; + setFormData(prev => ({ ...prev, [name]: value })); + }; + + const handleFileChange = (e) => { + setFormData(prev => ({ ...prev, file: e.target.files[0] })); + }; + + const handleSubmit = async (e) => { + e.preventDefault(); + setStatus({ type: null, message: '' }); + + try { + const apiUrl = process.env.API_URL || 'http://localhost:5000'; + const url = `${apiUrl}/v1/${formData.namespace}/${formData.name}/${formData.version}/${formData.variant}/blob`; + + const response = await fetch(url, { + method: 'PUT', + headers: { + 'Authorization': 'Bearer demo-token', + }, + body: formData.file + }); + + if (response.ok) { + const result = await response.json(); + setStatus({ + type: 'success', + message: `Package published successfully! Digest: ${result.digest}` + }); + setFormData({ + namespace: '', + name: '', + version: '', + variant: '', + file: null + }); + } else { + const error = await response.json(); + setStatus({ + type: 'error', + message: error.error?.message || 'Failed to publish package' + }); + } + } catch (error) { + setStatus({ + type: 'error', + message: `Error: ${error.message}` + }); + } + }; + + return ( +
+
+

Publish Package

+

Upload a new package to the repository

+
+ + {status.type === 'success' && ( +
+ Success! {status.message} +
+ )} + + {status.type === 'error' && ( +
+ Error: {status.message} +
+ )} + +
+
+ + +

+ Lowercase letters, numbers, dots, dashes (e.g., acme) +

+
+ +
+ + +

+ Lowercase letters, numbers, dots, dashes (e.g., my-package) +

+
+ +
+ + +

+ Semantic version (e.g., 1.0.0) +

+
+ +
+ + +

+ Platform/architecture (e.g., linux-amd64) +

+
+ +
+ + +

+ Select the package file to upload +

+
+ +
+ + +
+
+
+ ); +} diff --git a/frontend/src/app/publish/page.module.scss b/frontend/src/app/publish/page.module.scss new file mode 100644 index 0000000..c403521 --- /dev/null +++ b/frontend/src/app/publish/page.module.scss @@ -0,0 +1,94 @@ +@import '../../styles/variables'; + +.container { + max-width: 800px; + margin: 0 auto; + padding: $spacing-xl $spacing-lg; +} + +.header { + margin-bottom: $spacing-xl; + + h1 { + font-size: $font-size-h3; + margin-bottom: $spacing-md; + } +} + +.form { + @include card; + padding: $spacing-xl; + + &__group { + margin-bottom: $spacing-lg; + } + + &__label { + display: block; + font-weight: 500; + margin-bottom: $spacing-sm; + color: $text-primary; + } + + &__input { + @include input-base; + } + + &__help { + font-size: $font-size-caption; + color: $text-secondary; + margin-top: $spacing-xs; + } + + &__actions { + display: flex; + gap: $spacing-md; + justify-content: flex-end; + margin-top: $spacing-xl; + } +} + +.button { + @include button-base; + + &--primary { + background: $primary-color; + color: white; + + &:hover { + background: $primary-dark; + } + } + + &--secondary { + background: transparent; + color: $primary-color; + border: 1px solid $primary-color; + + &:hover { + background: rgba(25, 118, 210, 0.04); + } + } +} + +.fileInput { + @include input-base; + padding: $spacing-md; + cursor: pointer; +} + +.success { + @include card; + padding: $spacing-lg; + background: rgba(76, 175, 80, 0.1); + border-left: 4px solid $success-color; + margin-bottom: $spacing-lg; +} + +.error { + @include card; + padding: $spacing-lg; + background: rgba(244, 67, 54, 0.1); + border-left: 4px solid $error-color; + margin-bottom: $spacing-lg; +} diff --git a/frontend/src/components/Card.jsx b/frontend/src/components/Card.jsx new file mode 100644 index 0000000..dd9d334 --- /dev/null +++ b/frontend/src/components/Card.jsx @@ -0,0 +1,18 @@ +import styles from './Card.module.scss'; + +export default function Card({ title, subtitle, children, footer }) { + return ( +
+ {(title || subtitle) && ( +
+
+ {title &&

{title}

} + {subtitle &&

{subtitle}

} +
+
+ )} + {children &&
{children}
} + {footer &&
{footer}
} +
+ ); +} diff --git a/frontend/src/components/Card.module.scss b/frontend/src/components/Card.module.scss new file mode 100644 index 0000000..0c9631a --- /dev/null +++ b/frontend/src/components/Card.module.scss @@ -0,0 +1,92 @@ +@import '../styles/variables'; + +.card { + @include card; + padding: $spacing-lg; + margin-bottom: $spacing-md; + + &__header { + display: flex; + align-items: center; + justify-content: space-between; + margin-bottom: $spacing-md; + } + + &__title { + font-size: $font-size-h6; + font-weight: 500; + color: $text-primary; + margin: 0; + } + + &__subtitle { + font-size: $font-size-body2; + color: $text-secondary; + margin-top: $spacing-xs; + } + + &__body { + color: $text-primary; + line-height: 1.6; + } + + &__footer { + margin-top: $spacing-md; + padding-top: $spacing-md; + border-top: 1px solid rgba(0, 0, 0, 0.12); + display: flex; + gap: $spacing-sm; + } +} + +.button { + @include button-base; + + &--primary { + background: $primary-color; + color: white; + + &:hover { + background: $primary-dark; + } + } + + &--secondary { + background: transparent; + color: $primary-color; + border: 1px solid $primary-color; + + &:hover { + background: rgba(25, 118, 210, 0.04); + } + } + + &--small { + padding: $spacing-xs $spacing-sm; + font-size: $font-size-caption; + } +} + +.badge { + display: inline-block; + padding: 4px 8px; + border-radius: $border-radius-sm; + font-size: $font-size-caption; + font-weight: 500; + text-transform: uppercase; + + &--primary { + background: rgba(25, 118, 210, 0.1); + color: $primary-dark; + } + + &--success { + background: rgba(76, 175, 80, 0.1); + color: darken($success-color, 20%); + } + + &--warning { + background: rgba(255, 152, 0, 0.1); + color: darken($warning-color, 20%); + } +} diff --git a/frontend/src/components/Navbar.jsx b/frontend/src/components/Navbar.jsx new file mode 100644 index 0000000..225ae20 --- /dev/null +++ b/frontend/src/components/Navbar.jsx @@ -0,0 +1,79 @@ +'use client'; + +import { useState, useEffect } from 'react'; +import Link from 'next/link'; +import { useRouter } from 'next/navigation'; +import styles from './Navbar.module.scss'; + +export default function Navbar() { + const [user, setUser] = useState(null); + const router = useRouter(); + + useEffect(() => { + // Check if user is logged in + const userData = localStorage.getItem('user'); + if (userData) { + setUser(JSON.parse(userData)); + } + }, []); + + const handleLogout = () => { + localStorage.removeItem('token'); + localStorage.removeItem('user'); + setUser(null); + router.push('/login'); + }; + + return ( + + ); +} + diff --git a/frontend/src/components/Navbar.module.scss b/frontend/src/components/Navbar.module.scss new file mode 100644 index 0000000..8277a4e --- /dev/null +++ b/frontend/src/components/Navbar.module.scss @@ -0,0 +1,66 @@ +@import '../styles/variables'; + +.navbar { + background: $surface-color; + @include elevation(2); + position: sticky; + top: 0; + z-index: 1000; + + &__container { + max-width: 1200px; + margin: 0 auto; + padding: $spacing-md $spacing-lg; + display: flex; + align-items: center; + justify-content: space-between; + } + + &__logo { + font-size: $font-size-h5; + font-weight: 500; + color: $primary-color; + text-decoration: none; + display: flex; + align-items: center; + gap: $spacing-sm; + + &:hover { + text-decoration: none; + } + } + + &__nav { + display: flex; + gap: $spacing-lg; + list-style: none; + margin: 0; + padding: 0; + align-items: center; + } + + &__link { + color: $text-primary; + font-weight: 500; + transition: color $transition-duration $transition-easing; + + &:hover { + color: $primary-color; + text-decoration: none; + } + } + + &__button { + @include button-base; + padding: $spacing-xs $spacing-md; + background: transparent; + color: $error-color; + border: 1px solid $error-color; + font-size: $font-size-body2; + + &:hover { + background: rgba(244, 67, 54, 0.04); + @include elevation(1); + } + } +} diff --git a/frontend/src/styles/_variables.scss b/frontend/src/styles/_variables.scss new file mode 100644 index 0000000..11c3101 --- /dev/null +++ b/frontend/src/styles/_variables.scss @@ -0,0 +1,143 @@ +// Material Design-inspired color palette +$primary-color: #1976d2; +$primary-light: #42a5f5; +$primary-dark: #1565c0; +$secondary-color: #dc004e; +$secondary-light: #f50057; +$secondary-dark: #c51162; + +$background-color: #fafafa; +$surface-color: #ffffff; +$error-color: #f44336; +$success-color: #4caf50; +$warning-color: #ff9800; +$info-color: #2196f3; + +$text-primary: rgba(0, 0, 0, 0.87); +$text-secondary: rgba(0, 0, 0, 0.54); +$text-disabled: rgba(0, 0, 0, 0.38); +$text-hint: rgba(0, 0, 0, 0.38); + +// Spacing +$spacing-unit: 8px; +$spacing-xs: $spacing-unit; +$spacing-sm: $spacing-unit * 2; +$spacing-md: $spacing-unit * 3; +$spacing-lg: $spacing-unit * 4; +$spacing-xl: $spacing-unit * 6; + +// Typography +$font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', 'Roboto', 'Oxygen', + 'Ubuntu', 'Cantarell', 'Fira Sans', 'Droid Sans', 'Helvetica Neue', + sans-serif; +$font-family-mono: 'Courier New', Courier, monospace; + +$font-size-h1: 96px; +$font-size-h2: 60px; +$font-size-h3: 48px; +$font-size-h4: 34px; +$font-size-h5: 24px; +$font-size-h6: 20px; +$font-size-subtitle1: 16px; +$font-size-subtitle2: 14px; +$font-size-body1: 16px; +$font-size-body2: 14px; +$font-size-button: 14px; +$font-size-caption: 12px; +$font-size-overline: 10px; + +// Elevation (shadows) +$elevation-1: 0 1px 3px rgba(0, 0, 0, 0.12), 0 1px 2px rgba(0, 0, 0, 0.24); +$elevation-2: 0 3px 6px rgba(0, 0, 0, 0.16), 0 3px 6px rgba(0, 0, 0, 0.23); +$elevation-3: 0 10px 20px rgba(0, 0, 0, 0.19), 0 6px 6px rgba(0, 0, 0, 0.23); +$elevation-4: 0 14px 28px rgba(0, 0, 0, 0.25), 0 10px 10px rgba(0, 0, 0, 0.22); +$elevation-5: 0 19px 38px rgba(0, 0, 0, 0.30), 0 15px 12px rgba(0, 0, 0, 0.22); + +// Border radius +$border-radius-sm: 4px; +$border-radius-md: 8px; +$border-radius-lg: 16px; + +// Transitions +$transition-duration: 0.3s; +$transition-easing: cubic-bezier(0.4, 0.0, 0.2, 1); + +// Mixins +@mixin elevation($level: 1) { + @if $level == 1 { + box-shadow: $elevation-1; + } @else if $level == 2 { + box-shadow: $elevation-2; + } @else if $level == 3 { + box-shadow: $elevation-3; + } @else if $level == 4 { + box-shadow: $elevation-4; + } @else if $level == 5 { + box-shadow: $elevation-5; + } +} + +@mixin button-base { + display: inline-flex; + align-items: center; + justify-content: center; + padding: $spacing-sm $spacing-md; + border: none; + border-radius: $border-radius-sm; + font-family: $font-family; + font-size: $font-size-button; + font-weight: 500; + text-transform: uppercase; + letter-spacing: 0.5px; + cursor: pointer; + transition: all $transition-duration $transition-easing; + outline: none; + + &:hover { + @include elevation(2); + } + + &:active { + @include elevation(1); + } + + &:disabled { + cursor: not-allowed; + opacity: 0.6; + } +} + +@mixin card { + background: $surface-color; + border-radius: $border-radius-md; + @include elevation(1); + transition: box-shadow $transition-duration $transition-easing; + + &:hover { + @include elevation(2); + } +} + +@mixin input-base { + width: 100%; + padding: $spacing-sm $spacing-md; + border: 1px solid rgba(0, 0, 0, 0.23); + border-radius: $border-radius-sm; + font-family: $font-family; + font-size: $font-size-body1; + color: $text-primary; + background: $surface-color; + transition: all $transition-duration $transition-easing; + + &:focus { + outline: none; + border-color: $primary-color; + box-shadow: 0 0 0 2px rgba(25, 118, 210, 0.1); + } + + &:disabled { + background: rgba(0, 0, 0, 0.05); + color: $text-disabled; + cursor: not-allowed; + } +} diff --git a/frontend/src/styles/globals.scss b/frontend/src/styles/globals.scss new file mode 100644 index 0000000..683a8c7 --- /dev/null +++ b/frontend/src/styles/globals.scss @@ -0,0 +1,72 @@ +@import './variables'; + +* { + box-sizing: border-box; + margin: 0; + padding: 0; +} + +html, +body { + font-family: $font-family; + font-size: $font-size-body1; + color: $text-primary; + background: $background-color; + line-height: 1.5; + -webkit-font-smoothing: antialiased; + -moz-osx-font-smoothing: grayscale; +} + +h1, h2, h3, h4, h5, h6 { + margin: 0; + font-weight: 500; + line-height: 1.2; +} + +h1 { font-size: $font-size-h4; } +h2 { font-size: $font-size-h5; } +h3 { font-size: $font-size-h6; } +h4 { font-size: $font-size-subtitle1; } +h5 { font-size: $font-size-subtitle2; } +h6 { font-size: $font-size-body1; } + +a { + color: $primary-color; + text-decoration: none; + transition: color $transition-duration $transition-easing; + + &:hover { + color: $primary-dark; + text-decoration: underline; + } +} + +button { + @include button-base; +} + +input, +textarea, +select { + @include input-base; +} + +code { + font-family: $font-family-mono; + background: rgba(0, 0, 0, 0.05); + padding: 2px 6px; + border-radius: $border-radius-sm; + font-size: 0.9em; +} + +pre { + background: rgba(0, 0, 0, 0.05); + padding: $spacing-md; + border-radius: $border-radius-sm; + overflow-x: auto; + + code { + background: none; + padding: 0; + } +} diff --git a/tests/.gitignore b/tests/.gitignore new file mode 100644 index 0000000..db7eec2 --- /dev/null +++ b/tests/.gitignore @@ -0,0 +1,6 @@ +# Playwright Test Results +test-results/ +playwright-report/ +playwright/.cache/ +node_modules/ +package-lock.json diff --git a/tests/README.md b/tests/README.md new file mode 100644 index 0000000..5630f02 --- /dev/null +++ b/tests/README.md @@ -0,0 +1,50 @@ +# E2E Tests + +This directory contains end-to-end tests using Playwright. + +## Setup + +Install dependencies: +```bash +cd tests +npm install +npx playwright install +``` + +## Running Tests + +Run all tests: +```bash +npm test +``` + +Run tests in headed mode (see browser): +```bash +npm run test:headed +``` + +Run tests with UI mode (interactive): +```bash +npm run test:ui +``` + +View test report: +```bash +npm run test:report +``` + +## Test Structure + +- `e2e/home.spec.js` - Tests for the home page +- `e2e/auth.spec.js` - Tests for authentication (login, logout, password change) +- `e2e/pages.spec.js` - Tests for browse, publish, and docs pages + +## Prerequisites + +Before running tests, make sure: +1. The backend server is running on `http://localhost:5000` +2. The frontend dev server is running on `http://localhost:3000` (or let the test config start it automatically) + +## CI/CD Integration + +The tests are configured to work in CI environments. Set `CI=true` environment variable to enable CI-specific behavior (retries, single worker, etc.). diff --git a/tests/e2e/auth.spec.js b/tests/e2e/auth.spec.js new file mode 100644 index 0000000..f463e7d --- /dev/null +++ b/tests/e2e/auth.spec.js @@ -0,0 +1,103 @@ +import { test, expect } from '@playwright/test'; + +test.describe('Authentication', () => { + test.beforeEach(async ({ page }) => { + // Clear any existing auth + await page.goto('/'); + await page.evaluate(() => { + localStorage.clear(); + }); + }); + + test('should show login page', async ({ page }) => { + await page.goto('/login'); + + // Check for login form elements + await expect(page.locator('h1')).toContainText('Login'); + await expect(page.locator('input[name="username"]')).toBeVisible(); + await expect(page.locator('input[name="password"]')).toBeVisible(); + await expect(page.locator('button[type="submit"]')).toBeVisible(); + + // Check for default credentials hint + await expect(page.locator('text=Default credentials: admin / admin')).toBeVisible(); + }); + + test('should login with valid credentials', async ({ page }) => { + await page.goto('/login'); + + // Fill in login form + await page.fill('input[name="username"]', 'admin'); + await page.fill('input[name="password"]', 'admin'); + + // Submit form + await page.click('button[type="submit"]'); + + // Wait for navigation to home page + await page.waitForURL('/'); + + // Check that we're on the home page + await expect(page.locator('h1')).toContainText('Welcome to Good Package Repo'); + }); + + test('should show error with invalid credentials', async ({ page }) => { + await page.goto('/login'); + + // Fill in login form with wrong password + await page.fill('input[name="username"]', 'admin'); + await page.fill('input[name="password"]', 'wrongpassword'); + + // Submit form + await page.click('button[type="submit"]'); + + // Wait for error message + await expect(page.locator('text=Invalid credentials')).toBeVisible(); + + // Should still be on login page + await expect(page.url()).toContain('/login'); + }); + + test('should navigate to account page when logged in', async ({ page }) => { + // Login first + await page.goto('/login'); + await page.fill('input[name="username"]', 'admin'); + await page.fill('input[name="password"]', 'admin'); + await page.click('button[type="submit"]'); + await page.waitForURL('/'); + + // Navigate to account page + await page.goto('/account'); + + // Check account page elements + await expect(page.locator('h1')).toContainText('Account Settings'); + await expect(page.locator('text=Username')).toBeVisible(); + await expect(page.locator('text=admin')).toBeVisible(); + }); + + test('should redirect to login when accessing account page without auth', async ({ page }) => { + // Try to access account page without logging in + await page.goto('/account'); + + // Should be redirected to login + await page.waitForURL('**/login'); + await expect(page.locator('h1')).toContainText('Login'); + }); + + test('should logout successfully', async ({ page }) => { + // Login first + await page.goto('/login'); + await page.fill('input[name="username"]', 'admin'); + await page.fill('input[name="password"]', 'admin'); + await page.click('button[type="submit"]'); + await page.waitForURL('/'); + + // Go to account page + await page.goto('/account'); + + // Click logout button + await page.click('text=Logout'); + + // Should be redirected to login page + await page.waitForURL('**/login'); + await expect(page.locator('h1')).toContainText('Login'); + }); +}); diff --git a/tests/e2e/home.spec.js b/tests/e2e/home.spec.js new file mode 100644 index 0000000..b4e2c32 --- /dev/null +++ b/tests/e2e/home.spec.js @@ -0,0 +1,60 @@ +import { test, expect } from '@playwright/test'; + +test.describe('Home Page', () => { + test('should load the home page', async ({ page }) => { + await page.goto('/'); + + // Check for main heading + await expect(page.locator('h1')).toContainText('Welcome to Good Package Repo'); + + // Check for subtitle + await expect(page.locator('text=The world\'s first truly good package repository')).toBeVisible(); + + // Check for navigation links + await expect(page.locator('a:has-text("Browse")')).toBeVisible(); + await expect(page.locator('a:has-text("Docs")')).toBeVisible(); + }); + + test('should navigate to browse page', async ({ page }) => { + await page.goto('/'); + + // Click on Browse link in hero section + await page.locator('text=Browse Packages').first().click(); + + // Wait for navigation + await page.waitForURL('**/browse'); + + // Check that we're on the browse page + await expect(page.locator('h1')).toContainText('Browse Packages'); + }); + + test('should navigate to docs page', async ({ page }) => { + await page.goto('/'); + + // Click on Docs link in hero section + await page.locator('text=Read Docs').first().click(); + + // Wait for navigation + await page.waitForURL('**/docs'); + + // Check that we're on the docs page + await expect(page.locator('h1')).toContainText('Documentation'); + }); + + test('should display features section', async ({ page }) => { + await page.goto('/'); + + // Check for feature cards + await expect(page.locator('text=Secure by Design')).toBeVisible(); + await expect(page.locator('text=Lightning Fast')).toBeVisible(); + await expect(page.locator('text=Schema-Driven')).toBeVisible(); + }); + + test('should display stats section', async ({ page }) => { + await page.goto('/'); + + // Check for stats + await expect(page.locator('text=100%')).toBeVisible(); + await expect(page.locator('text=Uptime')).toBeVisible(); + }); +}); diff --git a/tests/e2e/pages.spec.js b/tests/e2e/pages.spec.js new file mode 100644 index 0000000..4c88148 --- /dev/null +++ b/tests/e2e/pages.spec.js @@ -0,0 +1,119 @@ +import { test, expect } from '@playwright/test'; + +test.describe('Browse Page', () => { + test('should display browse page', async ({ page }) => { + await page.goto('/browse'); + + // Check for page elements + await expect(page.locator('h1')).toContainText('Browse Packages'); + await expect(page.locator('text=Explore available packages')).toBeVisible(); + + // Check for search input + await expect(page.locator('input[placeholder*="Search"]')).toBeVisible(); + }); + + test('should filter packages by search term', async ({ page }) => { + await page.goto('/browse'); + + // Get initial package count + const initialPackages = await page.locator('[class*="package"]').count(); + expect(initialPackages).toBeGreaterThan(0); + + // Search for specific package + await page.fill('input[placeholder*="Search"]', 'example'); + + // Should show filtered results + await expect(page.locator('text=example-package')).toBeVisible(); + }); + + test('should show empty state when no packages match', async ({ page }) => { + await page.goto('/browse'); + + // Search for non-existent package + await page.fill('input[placeholder*="Search"]', 'nonexistentpackage12345'); + + // Should show empty state + await expect(page.locator('text=No packages found')).toBeVisible(); + }); +}); + +test.describe('Publish Page', () => { + test('should display publish form', async ({ page }) => { + await page.goto('/publish'); + + // Check for page elements + await expect(page.locator('h1')).toContainText('Publish Package'); + + // Check for form fields + await expect(page.locator('input[name="namespace"]')).toBeVisible(); + await expect(page.locator('input[name="name"]')).toBeVisible(); + await expect(page.locator('input[name="version"]')).toBeVisible(); + await expect(page.locator('input[name="variant"]')).toBeVisible(); + await expect(page.locator('input[type="file"]')).toBeVisible(); + }); + + test('should validate form fields', async ({ page }) => { + await page.goto('/publish'); + + // Try to submit empty form + await page.click('button[type="submit"]'); + + // Form should require fields (browser validation) + const namespaceInput = page.locator('input[name="namespace"]'); + await expect(namespaceInput).toHaveAttribute('required', ''); + }); + + test('should reset form', async ({ page }) => { + await page.goto('/publish'); + + // Fill in some fields + await page.fill('input[name="namespace"]', 'test'); + await page.fill('input[name="name"]', 'mypackage'); + + // Click reset button + await page.click('text=Reset'); + + // Fields should be cleared + await expect(page.locator('input[name="namespace"]')).toHaveValue(''); + await expect(page.locator('input[name="name"]')).toHaveValue(''); + }); +}); + +test.describe('Documentation Page', () => { + test('should display documentation', async ({ page }) => { + await page.goto('/docs'); + + // Check for page elements + await expect(page.locator('h1')).toContainText('Documentation'); + + // Check for table of contents + await expect(page.locator('text=Table of Contents')).toBeVisible(); + await expect(page.locator('a:has-text("Getting Started")')).toBeVisible(); + await expect(page.locator('a:has-text("CapRover Setup")')).toBeVisible(); + await expect(page.locator('a:has-text("API Usage")')).toBeVisible(); + }); + + test('should have CapRover setup documentation', async ({ page }) => { + await page.goto('/docs'); + + // Check for CapRover section + await expect(page.locator('h2:has-text("CapRover Setup")')).toBeVisible(); + await expect(page.locator('text=CapRover is a free and open-source PaaS')).toBeVisible(); + + // Check for step-by-step instructions + await expect(page.locator('text=Create Backend App')).toBeVisible(); + await expect(page.locator('text=Configure Backend')).toBeVisible(); + }); + + test('should have API usage examples', async ({ page }) => { + await page.goto('/docs'); + + // Check for API usage section + await expect(page.locator('h2:has-text("API Usage")')).toBeVisible(); + await expect(page.locator('text=Publishing a Package')).toBeVisible(); + await expect(page.locator('text=Downloading a Package')).toBeVisible(); + + // Check for code examples + await expect(page.locator('pre code')).toBeVisible(); + }); +}); diff --git a/tests/package.json b/tests/package.json new file mode 100644 index 0000000..caf1287 --- /dev/null +++ b/tests/package.json @@ -0,0 +1,14 @@ +{ + "name": "goodpackagerepo-tests", + "version": "1.0.0", + "description": "E2E tests for Good Package Repo", + "scripts": { + "test": "playwright test", + "test:headed": "playwright test --headed", + "test:ui": "playwright test --ui", + "test:report": "playwright show-report" + }, + "devDependencies": { + "@playwright/test": "^1.40.0" + } +} diff --git a/tests/playwright.config.js b/tests/playwright.config.js new file mode 100644 index 0000000..ad3c996 --- /dev/null +++ b/tests/playwright.config.js @@ -0,0 +1,27 @@ +import { defineConfig, devices } from '@playwright/test'; + +export default defineConfig({ + testDir: './e2e', + fullyParallel: true, + forbidOnly: !!process.env.CI, + retries: process.env.CI ? 2 : 0, + workers: process.env.CI ? 1 : undefined, + reporter: 'html', + use: { + baseURL: 'http://localhost:3000', + trace: 'on-first-retry', + }, + + projects: [ + { + name: 'chromium', + use: { ...devices['Desktop Chrome'] }, + }, + ], + + webServer: { + command: 'cd ../frontend && npm run dev', + url: 'http://localhost:3000', + reuseExistingServer: !process.env.CI, + }, +});