commit 97b427831a7920fdf38116ea9f68daa2c9bcdda3 Author: airano Date: Thu Feb 12 06:08:51 2026 +0330 Initial release v1.0.0 Open-source marketplace for AI Agent skills. Features: - Next.js 15 web app with i18n (en/fa) - CLI tool for skill installation (npx skillhub) - GitHub crawler/indexer with multi-strategy discovery - Security scanning for all indexed skills - Self-hostable with Docker Co-Authored-By: Claude Opus 4.6 diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 0000000..d8c922c --- /dev/null +++ b/.dockerignore @@ -0,0 +1,28 @@ +# Dependencies - let Docker rebuild these +node_modules +**/node_modules + +# Build outputs +.next +**/.next +dist +**/dist + +# Git +.git +.gitignore + +# IDE +.vscode +.idea + +# Misc +*.log +.env.local +.env.*.local +.DS_Store +Thumbs.db + +# Turbo +.turbo +**/.turbo diff --git a/.env.example b/.env.example new file mode 100644 index 0000000..ca27000 --- /dev/null +++ b/.env.example @@ -0,0 +1,85 @@ +# SkillHub Environment Configuration +# Copy this file to .env and update the values + +# ===================== +# Database Configuration +# ===================== +DATABASE_URL=postgresql://postgres:postgres@localhost:5432/skillhub +POSTGRES_PASSWORD=postgres + +# ===================== +# Redis Configuration +# ===================== +REDIS_URL=redis://localhost:6379 + +# ===================== +# GitHub API +# ===================== +# Create a GitHub Personal Access Token with 'public_repo' scope +# https://github.com/settings/tokens +GITHUB_TOKEN= + +# GitHub OAuth (for user authentication) +GITHUB_CLIENT_ID= +GITHUB_CLIENT_SECRET= + +# ===================== +# Meilisearch +# ===================== +MEILI_URL=http://localhost:7700 +MEILI_MASTER_KEY=skillhub-dev-key +MEILI_ENV=development + +# ===================== +# Application +# ===================== +NEXT_PUBLIC_APP_URL=http://localhost:3000 +NEXT_PUBLIC_API_URL=http://localhost:3000/api +NODE_ENV=development + +# ===================== +# Indexer Configuration +# ===================== +INDEXER_CONCURRENCY=5 +INDEXER_MIN_STARS=2 + +# ===================== +# Sentry Error Monitoring +# ===================== +# Get your DSN from https://sentry.io +NEXT_PUBLIC_SENTRY_DSN= +# Optional: Auth token for source map uploads +SENTRY_AUTH_TOKEN= +SENTRY_ORG= +SENTRY_PROJECT= +# Optional: Release version tracking +NEXT_PUBLIC_SENTRY_RELEASE= + +# ===================== +# Authentication +# ===================== +# Generate a secure secret: openssl rand -base64 32 +AUTH_SECRET= +AUTH_URL=http://localhost:3000 + +# ===================== +# Multiple GitHub Tokens (for higher rate limits) +# ===================== +# Comma-separated tokens for distributed API access +GITHUB_TOKENS= +GITHUB_TOKEN_NAMES= + +# ===================== +# Admin Users +# ===================== +# Comma-separated GitHub usernames that get admin privileges on login +ADMIN_GITHUB_USERS= + + +# ===================== +# Email (Resend) +# ===================== +# Get your API key from https://resend.com +RESEND_API_KEY= +# From address (must be verified domain in Resend) +RESEND_FROM_EMAIL= diff --git a/.eslintrc.cjs b/.eslintrc.cjs new file mode 100644 index 0000000..2e424ac --- /dev/null +++ b/.eslintrc.cjs @@ -0,0 +1,47 @@ +/** @type {import('eslint').Linter.Config} */ +module.exports = { + root: true, + env: { + node: true, + es2022: true, + }, + extends: [ + 'eslint:recommended', + 'plugin:@typescript-eslint/recommended', + ], + parser: '@typescript-eslint/parser', + parserOptions: { + ecmaVersion: 'latest', + sourceType: 'module', + }, + plugins: ['@typescript-eslint'], + rules: { + '@typescript-eslint/no-unused-vars': ['error', { argsIgnorePattern: '^_' }], + '@typescript-eslint/no-explicit-any': 'warn', + '@typescript-eslint/consistent-type-imports': ['error', { prefer: 'type-imports' }], + 'no-console': ['warn', { allow: ['warn', 'error'] }], + }, + ignorePatterns: ['dist', 'node_modules', '.turbo', 'coverage'], + overrides: [ + { + files: ['*.tsx'], + extends: ['plugin:react/recommended', 'plugin:react-hooks/recommended'], + plugins: ['react', 'react-hooks'], + settings: { + react: { + version: 'detect', + }, + }, + rules: { + 'react/react-in-jsx-scope': 'off', + 'react/prop-types': 'off', + }, + }, + { + files: ['*.test.ts', '*.test.tsx', '*.spec.ts', '*.spec.tsx'], + rules: { + '@typescript-eslint/no-explicit-any': 'off', + }, + }, + ], +}; diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..3e7e6e0 --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,150 @@ +name: CI + +on: + push: + branches: [main] + pull_request: + branches: [main] + +concurrency: + group: ${{ github.workflow }}-${{ github.ref }} + cancel-in-progress: true + +jobs: + lint: + name: Lint + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + + - uses: pnpm/action-setup@v4 + + - uses: actions/setup-node@v4 + with: + node-version: '20' + cache: 'pnpm' + + - name: Install dependencies + run: pnpm install --frozen-lockfile + + - name: Lint and Type check + run: pnpm turbo lint typecheck + + test: + name: Unit Tests + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + + - uses: pnpm/action-setup@v4 + + - uses: actions/setup-node@v4 + with: + node-version: '20' + cache: 'pnpm' + + - name: Install dependencies + run: pnpm install --frozen-lockfile + + - name: Run core tests + run: pnpm --filter @skillhub/core test:run + + - name: Run db tests + run: pnpm --filter @skillhub/db test:run + + - name: Run web API tests + run: pnpm --filter @skillhub/web test:run + + - name: Run CLI tests + run: pnpm --filter @skillhub/cli test:run + + e2e: + name: E2E Tests + runs-on: ubuntu-latest + needs: [build] + # TODO: Enable when CI has database/redis services + if: false + steps: + - uses: actions/checkout@v4 + + - uses: pnpm/action-setup@v4 + + - uses: actions/setup-node@v4 + with: + node-version: '20' + cache: 'pnpm' + + - name: Install dependencies + run: pnpm install --frozen-lockfile + + - name: Install Playwright browsers + run: pnpm --filter @skillhub/web exec playwright install --with-deps chromium + + - name: Build web app and dependencies + run: pnpm turbo build --filter=@skillhub/web + + - name: Run E2E tests + run: pnpm --filter @skillhub/web test:e2e --project=chromium + env: + PLAYWRIGHT_BASE_URL: http://localhost:3000 + + build: + name: Build + runs-on: ubuntu-latest + needs: [lint, test] + steps: + - uses: actions/checkout@v4 + + - uses: pnpm/action-setup@v4 + + - uses: actions/setup-node@v4 + with: + node-version: '20' + cache: 'pnpm' + + - name: Install dependencies + run: pnpm install --frozen-lockfile + + - name: Build + run: pnpm build + + docker: + name: Build Docker Images + runs-on: ubuntu-latest + needs: [build] + if: github.event_name == 'push' && github.ref == 'refs/heads/main' + permissions: + contents: read + packages: write + steps: + - uses: actions/checkout@v4 + + - name: Set up Docker Buildx + uses: docker/setup-buildx-action@v3 + + - name: Login to Container Registry + uses: docker/login-action@v3 + with: + registry: ghcr.io + username: ${{ github.actor }} + password: ${{ secrets.GITHUB_TOKEN }} + + - name: Build Web image + uses: docker/build-push-action@v5 + with: + context: . + file: ./apps/web/Dockerfile + push: false + tags: ghcr.io/${{ github.repository_owner }}/skillhub-web:latest + cache-from: type=gha + cache-to: type=gha,mode=max + + - name: Build Indexer image + uses: docker/build-push-action@v5 + with: + context: . + file: ./services/indexer/Dockerfile + push: false + tags: ghcr.io/${{ github.repository_owner }}/skillhub-indexer:latest + cache-from: type=gha + cache-to: type=gha,mode=max diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..6e6042f --- /dev/null +++ b/.gitignore @@ -0,0 +1,52 @@ +# Dependencies +node_modules +.pnpm-store +# scripts/ uses npm separately, we use pnpm +scripts/package-lock.json + +# Build outputs +dist +.next +.turbo +*.tsbuildinfo + +# Environment +.env +.env.local +.env.*.local + +# IDE +.vscode +.idea +*.swp +*.swo + +# OS +.DS_Store +Thumbs.db + +# Logs +*.log +npm-debug.log* +pnpm-debug.log* + +# Testing +coverage + +# Tauri +apps/desktop/src-tauri/target + +# Database +*.sqlite +*.db +drizzle/*.sql +*.dump +data/backups/ + +# Claude Code +.claude/skills/ +.claude/settings.local.json +# Keep agents and council_reasoning.md in version control: +# !.claude/agents/ +# !.claude/council_reasoning.md +.claude/settings.json diff --git a/.prettierignore b/.prettierignore new file mode 100644 index 0000000..ea5108b --- /dev/null +++ b/.prettierignore @@ -0,0 +1,6 @@ +dist +node_modules +.turbo +.next +coverage +pnpm-lock.yaml diff --git a/.prettierrc b/.prettierrc new file mode 100644 index 0000000..b6b0fde --- /dev/null +++ b/.prettierrc @@ -0,0 +1,10 @@ +{ + "semi": true, + "singleQuote": true, + "tabWidth": 2, + "trailingComma": "es5", + "printWidth": 100, + "bracketSpacing": true, + "arrowParens": "always", + "endOfLine": "lf" +} diff --git a/CLAUDE.md b/CLAUDE.md new file mode 100644 index 0000000..40b55c5 --- /dev/null +++ b/CLAUDE.md @@ -0,0 +1,113 @@ +# CLAUDE.md + +## Project Overview + +SkillHub is an open-source marketplace for **Agent Skills** — folders containing a `SKILL.md` file with instructions that AI agents can load dynamically. + +**Live Site:** https://skills.palebluedot.live | **Status:** Production (`GET /api/stats`) + +**Components:** +- **Web App** (`apps/web`): Next.js 15 marketplace +- **CLI** (`apps/cli`): Skill installer (`npm install -g skillhub`) +- **Indexer** (`services/indexer`): GitHub crawler +- **Packages** (`packages/*`): Core logic, DB, UI components + +## Common Commands + +```bash +pnpm install # Install dependencies +pnpm dev # Start all apps +pnpm --filter @skillhub/web dev # Start only web app +pnpm build # Build all packages +pnpm test # Run all tests (248 total) +pnpm --filter @skillhub/web test:e2e # E2E tests (requires running app) +pnpm db:push # Push schema changes +docker compose up -d # Start all services +``` + +## Architecture + +``` +skillhub/ +├── apps/ +│ ├── web/ # Next.js 15 + App Router + next-intl (i18n) +│ └── cli/ # Commander.js CLI, builds with tsup +├── packages/ +│ ├── core/ # SKILL.md parser, validator, security scanner +│ ├── db/ # Drizzle ORM schema and queries +│ └── ui/ # Shared shadcn/ui components +├── services/ +│ └── indexer/ # BullMQ worker for GitHub crawling +└── scripts/ # Database initialization scripts +``` + +## Database + +**Schema files** (keep in sync): +- `scripts/init-db.sql` — SQL schema +- `packages/db/src/schema.ts` — Drizzle ORM schema +- `scripts/categories.sql` — Production categories +- `scripts/seed-data.sql` — Dev sample data + +**Caching:** Skill files cached in `cached_files` JSONB column, invalidated when `commitSha` changes. + +## Important Patterns + +### Next.js 15 Async Params +`params` and `searchParams` are Promises: +```typescript +export default async function Page({ params }: { params: Promise<{ locale: string }> }) { + const { locale } = await params; +} +``` + +### i18n (next-intl) +Routes use `[locale]` segment. Use `setRequestLocale(locale)` for static rendering. + +### Dynamic Rendering +Add `export const dynamic = 'force-dynamic'` to pages that fetch from database. + +### Package Imports +- `skillhub-core` — Parser/validator +- `@skillhub/db` — Database +- `@skillhub/ui` — UI components + +### API Route Pattern +Skill IDs = `owner/repo/skill-name`. Use catch-all: `/api/skills/[...id]/route.ts` + +### CLI Skill ID Encoding +```typescript +const encodedPath = id.split('/').map(encodeURIComponent).join('/'); +``` + +### Meilisearch IDs +Sanitized: `anthropics/skills/pdf` → `anthropics__skills__pdf` + +## Environment Variables + +`DATABASE_URL`, `REDIS_URL`, `GITHUB_TOKEN`, `MEILI_URL` (optional), `MEILI_MASTER_KEY`, `GITHUB_CLIENT_ID`, `GITHUB_CLIENT_SECRET`, `AUTH_SECRET` + +## Indexer + +```bash +docker compose exec indexer node dist/crawl.js full # Full crawl +docker compose exec indexer node dist/crawl.js incremental # Last 24h +docker compose exec indexer node dist/crawl.js sync-meili # Sync to Meilisearch +docker compose exec indexer node dist/crawl.js deep-scan # Scan discovered repos +``` + +## Security + +Status stored in `security_status` column: **PASS** (green) / **WARNING** (yellow) / **FAIL** (red). Indexer scans for dangerous commands, prompt injection, and data exfiltration. + +## Critical Files + +| Category | Files | +|----------|-------| +| Database | `packages/db/src/schema.ts`, `packages/db/src/queries.ts` | +| API Routes | `apps/web/app/api/skills/route.ts`, `apps/web/app/api/skill-files/route.ts` | +| Auth | `apps/web/lib/auth.ts`, `apps/web/components/AuthButton.tsx` | +| Caching | `apps/web/lib/cache.ts` (Redis with TTL) | +| Rate Limiting | `apps/web/lib/rate-limit.ts` | +| Indexer | `services/indexer/src/crawler.ts`, `services/indexer/src/strategies/` | +| CLI | `apps/cli/src/commands/install.ts`, `apps/cli/src/utils/api.ts` | diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md new file mode 100644 index 0000000..449d81a --- /dev/null +++ b/CONTRIBUTING.md @@ -0,0 +1,281 @@ +# Contributing to SkillHub + +Thank you for your interest in contributing to SkillHub! This document provides guidelines and instructions for contributing. + +--- + +## Table of Contents + +- [Code of Conduct](#code-of-conduct) +- [How Can I Contribute?](#how-can-i-contribute) +- [Development Setup](#development-setup) +- [Pull Request Process](#pull-request-process) +- [Coding Standards](#coding-standards) +- [Commit Messages](#commit-messages) + +--- + +## Code of Conduct + +This project follows the [Contributor Covenant](https://www.contributor-covenant.org/version/2/1/code_of_conduct/). Please be respectful and constructive in all interactions. + +--- + +## How Can I Contribute? + +### Report Bugs + +Found a bug? Please open an issue with: + +1. **Clear title** describing the problem +2. **Steps to reproduce** the issue +3. **Expected behavior** vs actual behavior +4. **Environment details** (OS, Node version, browser) +5. **Screenshots** if applicable + +### Suggest Features + +Have an idea? Open an issue with: + +1. **Use case** - What problem does it solve? +2. **Proposed solution** - How should it work? +3. **Alternatives considered** - What else did you think about? + +### Submit Skills + +Want to add your skill to SkillHub? + +1. Ensure your repository has a valid `SKILL.md` file +2. Go to [skills.palebluedot.live/claim](https://skills.palebluedot.live/claim) +3. Sign in with GitHub +4. Submit your repository URL + +### Code Contributions + +We welcome code contributions for: + +- Bug fixes +- New features +- Performance improvements +- Documentation updates +- Test coverage +- Translations (i18n) + +--- + +## Development Setup + +### Prerequisites + +- Node.js 18+ +- pnpm 9+ +- Docker (for database and services) +- Git + +### Setup Steps + +```bash +# Clone the repository +git clone https://github.com/airano-ir/skillhub.git +cd skillhub + +# Install dependencies +pnpm install + +# Copy environment file +cp .env.example .env + +# Start infrastructure (database, redis, meilisearch) +docker compose up -d db redis meilisearch + +# Run database migrations +pnpm db:push + +# Seed sample data (optional) +docker exec -i skillhub-db psql -U postgres -d skillhub < scripts/seed-data.sql + +# Start development servers +pnpm dev +``` + +### Project Scripts + +```bash +pnpm dev # Start all apps in dev mode +pnpm build # Build all packages +pnpm test # Run all tests +pnpm lint # Lint all packages +pnpm typecheck # TypeScript type checking +``` + +### Package-Specific Commands + +```bash +# Web app +pnpm --filter @skillhub/web dev +pnpm --filter @skillhub/web build + +# CLI +pnpm --filter skillhub dev +pnpm --filter skillhub build + +# Core library +pnpm --filter skillhub-core test + +# Database +pnpm --filter @skillhub/db studio # Open Drizzle Studio +``` + +--- + +## Pull Request Process + +### Before Submitting + +1. **Create an issue first** - Discuss your change before implementing +2. **Fork the repository** - Work on your own fork +3. **Create a feature branch** - `git checkout -b feature/your-feature` +4. **Write tests** - Add tests for new functionality +5. **Run tests locally** - `pnpm test` +6. **Lint your code** - `pnpm lint` + +### PR Guidelines + +1. **Title format:** `type(scope): description` + - Types: `feat`, `fix`, `docs`, `style`, `refactor`, `test`, `chore` + - Example: `feat(cli): add update --all command` + +2. **Description should include:** + - What changes were made + - Why the changes were needed + - How to test the changes + - Screenshots (for UI changes) + +3. **Keep PRs focused** - One feature/fix per PR + +4. **Update documentation** - If your change affects user-facing features + +### Review Process + +1. Maintainers will review within 3-5 business days +2. Address feedback promptly +3. Once approved, a maintainer will merge + +--- + +## Coding Standards + +### TypeScript + +- Use TypeScript for all new code +- Enable strict mode +- Avoid `any` type - use `unknown` or proper types +- Use interfaces over types when possible + +```typescript +// Good +interface SkillMetadata { + name: string; + description: string; + version?: string; +} + +// Avoid +const skill: any = fetchSkill(); +``` + +### React/Next.js + +- Use functional components with hooks +- Prefer server components where possible +- Use `next-intl` for translations +- Follow Next.js 15 patterns (async params, etc.) + +```typescript +// Good - Next.js 15 pattern +export default async function Page({ params }: { params: Promise<{ id: string }> }) { + const { id } = await params; + // ... +} +``` + +### Styling + +- Use Tailwind CSS +- Follow design token system (`bg-surface`, `text-text-primary`) +- Avoid hardcoded colors +- Support dark mode + +```tsx +// Good +
+ +// Avoid +
+``` + +### Testing + +- Write unit tests for utilities and functions +- Write integration tests for API routes +- Use Vitest for unit tests +- Use Playwright for E2E tests + +```typescript +// Example test +import { describe, it, expect } from 'vitest'; + +describe('parseSkillMd', () => { + it('should parse valid SKILL.md', () => { + const result = parseSkillMd(validInput); + expect(result.name).toBe('expected-name'); + }); +}); +``` + +--- + +## Commit Messages + +Follow [Conventional Commits](https://www.conventionalcommits.org/): + +``` +type(scope): description + +[optional body] + +[optional footer] +``` + +### Types + +| Type | Description | +|------|-------------| +| `feat` | New feature | +| `fix` | Bug fix | +| `docs` | Documentation | +| `style` | Formatting (no code change) | +| `refactor` | Code restructuring | +| `test` | Adding tests | +| `chore` | Maintenance | + +### Examples + +```bash +feat(cli): add search command with category filter +fix(web): resolve RTL layout issues on skill page +docs: update README with new installation steps +test(core): add tests for SKILL.md parser +``` + +--- + +## Questions? + +- **GitHub Discussions:** For general questions +- **GitHub Issues:** For bugs and feature requests +- **Discord:** Coming soon + +--- + +Thank you for contributing to SkillHub! diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..656e28b --- /dev/null +++ b/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2026 SkillHub Contributors + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/README.md b/README.md new file mode 100644 index 0000000..367804b --- /dev/null +++ b/README.md @@ -0,0 +1,219 @@ +# SkillHub + +
+ +![SkillHub](https://img.shields.io/badge/SkillHub-AI%20Agent%20Skills-blue?style=for-the-badge) + +**The open-source marketplace for AI Agent skills** + +[Documentation](./docs) | [CLI](./apps/cli) | [Self-Host](./docs/self-hosting.md) + +[![License: MIT](https://img.shields.io/badge/License-MIT-yellow.svg)](https://opensource.org/licenses/MIT) +[![TypeScript](https://img.shields.io/badge/TypeScript-5.0-blue)](https://www.typescriptlang.org/) +[![Skills](https://img.shields.io/badge/Skills-170K%2B-green)](https://skills.palebluedot.live/api/stats) +[![PRs Welcome](https://img.shields.io/badge/PRs-welcome-brightgreen.svg)](./CONTRIBUTING.md) + +
+ +--- + +## What is SkillHub? + +SkillHub indexes **hundreds of thousands of AI agent skills** from GitHub and makes them discoverable and installable. Skills are SKILL.md files that teach AI agents (Claude, Codex, Copilot) specialized capabilities - from PDF manipulation to database queries. + +```bash +# Install a skill in seconds +npx skillhub install anthropics/skills/pdf +``` + +--- + +## Features + +- **Massive Skill Catalog** - Search by category, platform, or keyword +- **One-Line Install** - `npx skillhub install ` +- **Multi-Platform** - Works with Claude, OpenAI Codex, GitHub Copilot +- **Security Scanning** - Every skill scanned for malicious patterns +- **Self-Hostable** - Run your own instance with Docker +- **Fully Open Source** - MIT licensed, free forever + +--- + +## Quick Start + +### Install the CLI + +```bash +npm install -g skillhub +``` + +### Search for Skills + +```bash +skillhub search "pdf processing" +skillhub search "database" --category data-database +``` + +### Install a Skill + +```bash +# For Claude Code (global) +skillhub install anthropics/skills/pdf + +# For a specific project +skillhub install anthropics/skills/pdf --project + +# For OpenAI Codex +skillhub install anthropics/skills/pdf --platform codex +``` + +### List Installed Skills + +```bash +skillhub list +skillhub list --all # Show both global and project skills +``` + +--- + +## Self-Hosting + +```bash +# Clone the repository +git clone https://github.com/airano-ir/skillhub.git +cd skillhub + +# Copy environment file +cp .env.example .env + +# Start with Docker Compose +docker compose up -d + +# Open http://localhost:3000 +``` + +For production deployment, see [Self-Hosting Guide](./docs/self-hosting.md). + +--- + +## Project Structure + +``` +skillhub/ +├── apps/ +│ ├── web/ # Next.js 15 web application +│ └── cli/ # CLI tool (npm: skillhub) +├── packages/ +│ ├── core/ # SKILL.md parser & validator +│ ├── db/ # PostgreSQL + Drizzle ORM +│ └── ui/ # Shared shadcn/ui components +├── services/ +│ └── indexer/ # GitHub skill crawler +└── docs/ # Documentation +``` + +--- + +## Technology Stack + +| Layer | Technology | +|-------|------------| +| Frontend | Next.js 15, React 18, Tailwind CSS, shadcn/ui | +| Backend | Next.js API Routes, PostgreSQL | +| Search | Meilisearch | +| CLI | Commander.js | +| DevOps | Docker, GitHub Actions | + +--- + +## API + +SkillHub provides a public REST API: + +```bash +# Get skill info +curl https://skills.palebluedot.live/api/skills/anthropics/skills/pdf + +# Search skills +curl "https://skills.palebluedot.live/api/skills?q=pdf&limit=10" + +# Get stats +curl https://skills.palebluedot.live/api/stats +``` + +See [API Documentation](./docs/API.md) for full reference. + +--- + +## Contributing + +We welcome contributions! See [CONTRIBUTING.md](./CONTRIBUTING.md) for guidelines. + +### Development Setup + +```bash +# Install dependencies +pnpm install + +# Start development servers +pnpm dev + +# Run tests +pnpm test + +# Build all packages +pnpm build +``` + +--- + +## Security + +SkillHub scans all indexed skills for: + +- Dangerous shell commands (rm -rf, curl | sh) +- Prompt injection patterns +- Data exfiltration attempts + +**Security Status:** +- **PASS** - No issues detected +- **WARNING** - Potential issues flagged +- **FAIL** - Dangerous patterns detected + +Always review skill source code before installing. + +--- + +## Roadmap + +- [x] Web marketplace with growing skill catalog +- [x] CLI tool +- [x] Multi-platform support +- [x] Security scanning +- [ ] Claude Code MCP plugin +- [ ] AI-powered recommendations +- [ ] Enterprise features + +--- + +## License + +MIT - See [LICENSE](./LICENSE) for details. + +--- + +## Links + +- **Live Site:** https://skills.palebluedot.live +- **GitHub:** https://github.com/airano-ir/skillhub +- **npm:** https://www.npmjs.com/package/skillhub + +--- + +
+ +**[Browse Skills](https://skills.palebluedot.live/browse)** | **[Star on GitHub](https://github.com/airano-ir/skillhub)** + +*Built with love for the AI agent community* + +
diff --git a/SECURITY.md b/SECURITY.md new file mode 100644 index 0000000..f3831ff --- /dev/null +++ b/SECURITY.md @@ -0,0 +1,65 @@ +# Security Policy + +## Supported Versions + +| Version | Supported | +|---------|-----------| +| Latest | Yes | + +## Reporting a Vulnerability + +If you discover a security vulnerability in SkillHub, please report it responsibly. + +**Do NOT open a public GitHub issue for security vulnerabilities.** + +### How to Report + +1. **Email:** Send details to [dev@airano.ir](mailto:dev@airano.ir) +2. **Subject:** `[SECURITY] Brief description` +3. **Include:** + - Description of the vulnerability + - Steps to reproduce + - Potential impact + - Suggested fix (if any) + +### What to Expect + +- **Acknowledgment:** Within 48 hours +- **Assessment:** Within 7 days +- **Fix timeline:** Depends on severity (critical: 24-72h, high: 1-2 weeks) + +### Scope + +The following are in scope: +- SkillHub web application +- SkillHub CLI (`skillhub` npm package) +- SkillHub API endpoints +- Indexer/crawler service + +The following are out of scope: +- Third-party services (GitHub, Meilisearch, Redis) +- Issues in dependencies (report to upstream) +- Social engineering attacks + +## Security Measures + +SkillHub implements the following security measures: + +- **CSRF Protection:** All state-changing operations +- **Input Sanitization:** Query parameters and user inputs +- **Rate Limiting:** Per-endpoint rate limits +- **Security Headers:** CSP, HSTS, X-Frame-Options +- **Authentication:** GitHub OAuth via NextAuth.js +- **Skill Scanning:** Automated security analysis (PASS/WARNING/FAIL) for all indexed skills +- **SQL Injection Prevention:** Parameterized queries via Drizzle ORM + +## Responsible Disclosure + +We follow responsible disclosure practices. We ask that you: + +- Allow reasonable time for a fix before public disclosure +- Do not access or modify other users' data +- Do not perform denial-of-service attacks +- Act in good faith + +We will credit reporters in our changelog (unless you prefer to remain anonymous). diff --git a/apps/cli/README.md b/apps/cli/README.md new file mode 100644 index 0000000..4c01855 --- /dev/null +++ b/apps/cli/README.md @@ -0,0 +1,121 @@ +# @skillhub/cli + +Command-line tool for installing and managing AI Agent skills from [SkillHub](https://skills.palebluedot.live). + +## Installation + +```bash +# Install globally +npm install -g @skillhub/cli + +# Or use directly with npx +npx @skillhub/cli +``` + +## Usage + +### Search for skills + +```bash +skillhub search pdf +skillhub search "code review" --platform claude --limit 20 +``` + +### Install a skill + +```bash +skillhub install anthropics/skills/pdf +skillhub install obra/superpowers/brainstorming --platform codex +skillhub install anthropics/skills/docx --project # Install in current project +``` + +### List installed skills + +```bash +skillhub list +skillhub list --platform claude +``` + +### Update skills + +```bash +skillhub update anthropics/skills/pdf # Update specific skill +skillhub update --all # Update all installed skills +``` + +### Uninstall a skill + +```bash +skillhub uninstall pdf +skillhub uninstall brainstorming --platform codex +``` + +### Configuration + +```bash +skillhub config --list # Show all config +skillhub config --get defaultPlatform # Get specific value +skillhub config --set defaultPlatform=claude # Set value +``` + +## Platform Support + +SkillHub CLI supports multiple AI agent platforms: + +| Platform | Flag | Install Path | +|----------|------|--------------| +| Claude | `--platform claude` | `~/.claude/skills/` | +| OpenAI Codex | `--platform codex` | `~/.codex/skills/` | +| GitHub Copilot | `--platform copilot` | `~/.github/skills/` | +| Cursor | `--platform cursor` | `~/.cursor/skills/` | +| Windsurf | `--platform windsurf` | `~/.windsurf/skills/` | + +Default platform: `claude` + +## Options + +### Global Options + +- `--platform ` - Target platform (claude, codex, copilot, cursor, windsurf) +- `--project` - Install in current project instead of user directory +- `--force` - Overwrite existing installation +- `--help` - Show help information +- `--version` - Show version number + +### Environment Variables + +- `SKILLHUB_API_URL` - Override API endpoint (default: https://skills.palebluedot.live/api) +- `GITHUB_TOKEN` - GitHub token for API rate limits (optional) + +## Configuration File + +Configuration is stored in `~/.skillhub/config.json`: + +```json +{ + "defaultPlatform": "claude", + "apiUrl": "https://skills.palebluedot.live/api", + "githubToken": "ghp_..." +} +``` + +## Examples + +```bash +# Search and install a skill +skillhub search "document processing" +skillhub install anthropics/skills/pdf + +# Install skill for Codex +skillhub install obra/superpowers/brainstorming --platform codex + +# Update all installed skills +skillhub update --all + +# Check what's installed +skillhub list +``` + +## License + +MIT diff --git a/apps/cli/package.json b/apps/cli/package.json new file mode 100644 index 0000000..199d063 --- /dev/null +++ b/apps/cli/package.json @@ -0,0 +1,61 @@ +{ + "name": "skillhub", + "version": "0.2.4", + "description": "CLI tool for managing AI Agent skills - search, install, and update skills for Claude, Codex, Copilot, and more", + "author": "SkillHub Contributors", + "license": "MIT", + "repository": { + "type": "git", + "url": "https://github.com/airano-ir/skillhub.git", + "directory": "apps/cli" + }, + "homepage": "https://skills.palebluedot.live", + "type": "module", + "bin": { + "skillhub": "./dist/index.js" + }, + "files": [ + "dist" + ], + "scripts": { + "build": "tsup", + "dev": "tsx src/index.ts", + "start": "node dist/index.js", + "lint": "eslint src/", + "typecheck": "tsc --noEmit", + "test": "vitest", + "test:run": "vitest run" + }, + "dependencies": { + "@octokit/rest": "^20.0.2", + "chalk": "^5.3.0", + "commander": "^11.1.0", + "fs-extra": "^11.2.0", + "ora": "^8.0.1", + "prompts": "^2.4.2" + }, + "devDependencies": { + "skillhub-core": "workspace:*", + "@types/fs-extra": "^11.0.4", + "@types/node": "^20.10.0", + "@types/prompts": "^2.4.9", + "tsup": "^8.0.1", + "tsx": "^4.7.0", + "typescript": "^5.3.0", + "vitest": "^1.2.0" + }, + "engines": { + "node": ">=18.0.0" + }, + "keywords": [ + "ai", + "agent", + "skills", + "cli", + "claude", + "codex", + "copilot", + "cursor", + "windsurf" + ] +} diff --git a/apps/cli/src/commands/config.ts b/apps/cli/src/commands/config.ts new file mode 100644 index 0000000..8b09931 --- /dev/null +++ b/apps/cli/src/commands/config.ts @@ -0,0 +1,79 @@ +import chalk from 'chalk'; +import { loadConfig, saveConfig, getConfigPath } from '../utils/paths.js'; + +interface ConfigOptions { + set?: string; + get?: string; + list?: boolean; +} + +/** + * Manage CLI configuration + */ +export async function config(options: ConfigOptions): Promise { + const currentConfig = await loadConfig(); + + // List all config + if (options.list || (!options.set && !options.get)) { + console.log(chalk.bold('SkillHub CLI Configuration:\n')); + console.log(chalk.dim(`Config file: ${getConfigPath()}\n`)); + + if (Object.keys(currentConfig).length === 0) { + console.log(chalk.yellow('No configuration set.')); + console.log(chalk.dim('\nAvailable settings:')); + console.log(chalk.dim(' defaultPlatform - Default platform for installations (claude, codex, copilot)')); + console.log(chalk.dim(' apiUrl - SkillHub API URL')); + console.log(chalk.dim(' githubToken - GitHub personal access token for private repos')); + return; + } + + for (const [key, value] of Object.entries(currentConfig)) { + const displayValue = key.toLowerCase().includes('token') + ? maskSecret(String(value)) + : String(value); + console.log(` ${chalk.cyan(key)}: ${displayValue}`); + } + return; + } + + // Get a config value + if (options.get) { + const value = currentConfig[options.get]; + if (value === undefined) { + console.log(chalk.yellow(`Config '${options.get}' is not set.`)); + return; + } + + const displayValue = options.get.toLowerCase().includes('token') + ? maskSecret(String(value)) + : String(value); + console.log(displayValue); + return; + } + + // Set a config value + if (options.set) { + const [key, ...valueParts] = options.set.split('='); + const value = valueParts.join('='); + + if (!key || value === undefined) { + console.error(chalk.red('Invalid format. Use: --set key=value')); + process.exit(1); + } + + currentConfig[key] = value; + await saveConfig(currentConfig); + + const displayValue = key.toLowerCase().includes('token') + ? maskSecret(value) + : value; + console.log(chalk.green(`Set ${chalk.cyan(key)} = ${displayValue}`)); + } +} + +function maskSecret(value: string): string { + if (value.length <= 8) { + return '*'.repeat(value.length); + } + return value.slice(0, 4) + '*'.repeat(value.length - 8) + value.slice(-4); +} diff --git a/apps/cli/src/commands/install.ts b/apps/cli/src/commands/install.ts new file mode 100644 index 0000000..59252b6 --- /dev/null +++ b/apps/cli/src/commands/install.ts @@ -0,0 +1,391 @@ +import fs from 'fs-extra'; +import * as path from 'path'; +import chalk from 'chalk'; +import ora from 'ora'; +import { parseSkillMd, parseGenericInstructionFile, INSTRUCTION_FILE_PATTERNS, type SourceFormat } from 'skillhub-core'; +import { getSkillPath, ensureSkillsDir, isSkillInstalled, isFlatFilePlatform, getPlatformFilePath, type Platform } from '../utils/paths.js'; +import { getSkill, trackInstall, getSkillFiles, type SkillFilesResponse } from '../utils/api.js'; +import { fetchSkillContent, getDefaultBranch, type SkillContent } from '../utils/github.js'; +import { getPlatformFileName, transformForPlatform, shouldKeepOriginal } from '../utils/transform.js'; + +interface InstallOptions { + platform: Platform; + project?: boolean; + force?: boolean; + noApi?: boolean; +} + +/** + * Install a skill from the registry + */ +export async function install(skillId: string, options: InstallOptions): Promise { + const spinner = ora('Parsing skill ID...').start(); + + try { + // Parse skill ID + const parts = skillId.split('/'); + if (parts.length < 2) { + spinner.fail('Invalid skill ID format. Use: owner/repo or owner/repo/skill-name'); + process.exit(1); + } + + const [owner, repo, ...rest] = parts; + let skillPath = rest.join('/'); + + // Try to get skill info from API (unless --no-api) + let skillInfo; + if (!options.noApi) { + spinner.text = `Connecting to ${process.env.SKILLHUB_API_URL || 'https://skills.palebluedot.live'}...`; + try { + skillInfo = await getSkill(skillId); + if (skillInfo) { + spinner.succeed(`Found in registry: ${skillInfo.name}`); + spinner.start('Preparing installation...'); + } + } catch (error) { + spinner.warn(`API unavailable: ${(error as Error).message}`); + spinner.start('Falling back to direct GitHub fetch...'); + } + } else { + spinner.text = 'Skipping API lookup (--no-api flag)'; + } + + let skillName: string; + let branch = 'main'; + let sourceFormat: SourceFormat = 'skill.md'; + + if (skillInfo) { + skillName = skillInfo.name; + // Use the actual skillPath from database (e.g., 'skills/nuxt-ui' not 'nuxt-ui') + skillPath = skillInfo.skillPath; + branch = skillInfo.branch || 'main'; + sourceFormat = (skillInfo.sourceFormat as SourceFormat) || 'skill.md'; + spinner.text = `Found skill: ${chalk.cyan(skillName)}`; + } else { + // Fall back to fetching directly from GitHub + spinner.text = 'Skill not in registry, fetching from GitHub...'; + try { + branch = await getDefaultBranch(owner, repo); + skillName = skillPath || repo; + } catch (error) { + spinner.fail('Failed to connect to GitHub'); + throw error; + } + } + + // Check if already installed + const installed = await isSkillInstalled(options.platform, skillName, options.project); + if (installed && !options.force) { + spinner.fail( + `Skill ${chalk.cyan(skillName)} is already installed. Use ${chalk.yellow('--force')} to overwrite.` + ); + process.exit(1); + } + + // Ensure skills directory exists + await ensureSkillsDir(options.platform, options.project); + + // Fetch skill content - try API first, fall back to GitHub + spinner.text = `Downloading ${skillInfo?.name || skillId}...`; + let content: SkillContent | undefined; + let apiWasReachable = false; + + // Try API first (unless --no-api) + if (!options.noApi && skillInfo) { + apiWasReachable = true; // API responded to getSkill, so it's reachable + try { + spinner.text = 'Downloading skill files...'; + const cachedFiles = await getSkillFiles(skillInfo.id); + + if (cachedFiles && cachedFiles.files.length > 0) { + // Use sourceFormat from API response if available + if (cachedFiles.sourceFormat) { + sourceFormat = cachedFiles.sourceFormat as SourceFormat; + } + // Convert API response to SkillContent format + content = convertCachedFilesToSkillContent(cachedFiles, sourceFormat); + spinner.text = cachedFiles.fromCache + ? `Using cached files (${cachedFiles.files.length} files)` + : `Downloaded ${cachedFiles.files.length} files via API`; + } + } catch { + // API was reachable but file fetch failed (timeout, server error, etc.) + spinner.text = 'API file fetch failed, falling back to GitHub...'; + } + } + + // Fall back to direct GitHub fetch only if: + // - API was not used (--no-api or skill not in registry) + // - OR API file fetch returned empty/null (not a timeout - timeout means server is working on it) + if (!content) { + if (apiWasReachable) { + // API was reachable but couldn't provide files - still try GitHub as last resort + spinner.text = `Falling back to GitHub: ${owner}/${repo}/${skillPath || ''}...`; + } else { + spinner.text = `Downloading from GitHub: ${owner}/${repo}/${skillPath || ''}...`; + } + try { + content = await fetchSkillContent(owner, repo, skillPath, branch, sourceFormat); + spinner.text = `Downloaded ${content.scripts.length} scripts, ${content.references.length} references`; + } catch (error) { + spinner.fail('Failed to download skill files'); + console.error(chalk.red((error as Error).message)); + console.log(); + console.log(chalk.yellow('Troubleshooting tips:')); + console.log(chalk.dim(' 1. Check your internet connection')); + if (apiWasReachable) { + console.log(chalk.dim(' 2. The API server could not fetch files either - try again in a minute')); + console.log(chalk.dim(' 3. The server may be caching the files now - retry shortly')); + } else { + console.log(chalk.dim(' 2. If behind a proxy, configure HTTP_PROXY/HTTPS_PROXY environment variables')); + } + console.log(chalk.dim(` ${apiWasReachable ? '4' : '3'}. Set GITHUB_TOKEN environment variable for higher rate limits`)); + process.exit(1); + } + } + + // Ensure content is available (TypeScript narrowing) + if (!content) { + spinner.fail('Failed to download skill content'); + process.exit(1); + } + + // Parse and validate (format-aware) + const parsed = sourceFormat === 'skill.md' + ? parseSkillMd(content.skillMd) + : parseGenericInstructionFile(content.skillMd, sourceFormat, { + name: skillName, + description: skillInfo?.description || null, + owner, + }); + if (!parsed.validation.isValid) { + spinner.warn('Skill has validation issues:'); + for (const error of parsed.validation.errors) { + console.log(chalk.yellow(` - ${error.message}`)); + } + } + + // Get the actual skill name from metadata + const actualName = parsed.metadata.name || skillName; + const installPath = getSkillPath(options.platform, actualName, options.project); + + // Check for name collision with different skill + const metadataPath = path.join(installPath, '.skillhub.json'); + if (await fs.pathExists(metadataPath)) { + try { + const existingMetadata = await fs.readJson(metadataPath); + if (existingMetadata.skillId && existingMetadata.skillId !== skillId) { + spinner.warn(`Name collision detected!`); + console.log(chalk.yellow(`\nA different skill is already installed with the name "${actualName}":`)); + console.log(chalk.dim(` Existing: ${existingMetadata.skillId}`)); + console.log(chalk.dim(` New: ${skillId}`)); + console.log(); + + if (!options.force) { + console.log(chalk.red('Installation cancelled to prevent overwriting.')); + console.log(chalk.dim('Use --force to overwrite the existing skill.')); + process.exit(1); + } else { + console.log(chalk.yellow('Overwriting existing skill (--force flag used).\n')); + } + } + } catch { + // Ignore metadata read errors + } + } + + // Remove existing if force + if (installed && options.force) { + await fs.remove(installPath); + } + + // Create skill directory and write files + spinner.text = 'Installing skill...'; + await fs.ensureDir(installPath); + + // Transform content for target platform + const platformFileName = getPlatformFileName(options.platform, actualName); + const { content: transformedContent, warnings: transformWarnings } = + transformForPlatform(options.platform, content.skillMd, parsed); + + for (const warning of transformWarnings) { + console.log(chalk.yellow(` Warning: ${warning}`)); + } + + // Write the platform-specific file + if (isFlatFilePlatform(options.platform)) { + const platformFilePath = getPlatformFilePath( + options.platform, actualName, platformFileName, options.project + ); + await fs.writeFile(platformFilePath, transformedContent); + } else { + await fs.writeFile(path.join(installPath, platformFileName), transformedContent); + } + + // Keep original SKILL.md in tracking directory for re-transformation + if (shouldKeepOriginal(options.platform)) { + await fs.writeFile(path.join(installPath, 'SKILL.md'), content.skillMd); + } + + // Write metadata file for update tracking + // Use canonical ID from registry if available for proper tracking + const canonicalId = skillInfo?.id || skillId; + const platformFilePath = isFlatFilePlatform(options.platform) + ? getPlatformFilePath(options.platform, actualName, platformFileName, options.project) + : null; + await fs.writeJson(path.join(installPath, '.skillhub.json'), { + skillId: canonicalId, + installedAt: new Date().toISOString(), + platform: options.platform, + version: parsed.metadata.version || null, + platformFileName, + platformFilePath, + }); + + // Write scripts + if (content.scripts.length > 0) { + const scriptsDir = path.join(installPath, 'scripts'); + await fs.ensureDir(scriptsDir); + + for (const script of content.scripts) { + const scriptPath = path.join(scriptsDir, script.name); + await fs.writeFile(scriptPath, script.content); + await fs.chmod(scriptPath, '755'); + } + } + + // Write references + if (content.references.length > 0) { + const refsDir = path.join(installPath, 'references'); + await fs.ensureDir(refsDir); + + for (const ref of content.references) { + await fs.writeFile(path.join(refsDir, ref.name), ref.content); + } + } + + // Track installation using canonical skill ID from registry (if available) + // This ensures the tracking matches the database record + const trackingId = skillInfo?.id || skillId; + await trackInstall(trackingId, options.platform, 'cli'); + + spinner.succeed(`Skill ${chalk.green(actualName)} installed successfully!`); + + // Print info + console.log(); + console.log(chalk.dim(`Path: ${installPath}`)); + console.log(); + + if (parsed.metadata.description) { + console.log(chalk.dim(parsed.metadata.description)); + console.log(); + } + + console.log(chalk.yellow('Usage:')); + console.log( + ` This skill will be automatically activated when your ${getPlatformName(options.platform)} agent recognizes it's relevant.` + ); + + const setupInstructions = getPlatformSetupInstructions(options.platform, installPath); + if (setupInstructions) { + console.log(); + console.log(chalk.cyan('Next Steps:')); + console.log(setupInstructions); + } + + if (content.scripts.length > 0) { + console.log(); + console.log(chalk.dim(`Scripts: ${content.scripts.map((s) => s.name).join(', ')}`)); + } + } catch (error) { + spinner.fail('Installation failed'); + console.error(chalk.red((error as Error).message)); + process.exit(1); + } +} + +function getPlatformName(platform: Platform): string { + const names: Record = { + claude: 'Claude', + codex: 'OpenAI Codex', + copilot: 'GitHub Copilot', + cursor: 'Cursor', + windsurf: 'Windsurf', + }; + return names[platform]; +} + +function getPlatformSetupInstructions(platform: Platform, installPath: string): string | null { + switch (platform) { + case 'claude': + return chalk.dim(' Skills in .claude/skills/ are automatically discovered by Claude Code.'); + case 'codex': + return chalk.dim(` Reference this skill in your AGENTS.md:\n @import ${installPath}/SKILL.md`); + case 'copilot': + return chalk.dim(' Instructions in .github/instructions/ are automatically loaded by GitHub Copilot.'); + case 'cursor': + return chalk.dim(' Rules in .cursor/rules/ are automatically loaded by Cursor.'); + case 'windsurf': + return chalk.dim(' Rules in .windsurf/rules/ are automatically loaded by Windsurf.'); + default: + return null; + } +} + +/** + * All known main instruction file names across platforms + */ +const MAIN_FILE_NAMES = INSTRUCTION_FILE_PATTERNS.map(p => p.filename); + +/** + * Convert cached files API response to SkillContent format. + * Detects the main instruction file by name (SKILL.md, AGENTS.md, .cursorrules, etc.) + */ +function convertCachedFilesToSkillContent( + response: SkillFilesResponse, + sourceFormat: SourceFormat = 'skill.md' +): SkillContent { + let skillMd = ''; + const scripts: SkillContent['scripts'] = []; + const references: SkillContent['references'] = []; + + // Find the expected main filename for this format + const expectedPattern = INSTRUCTION_FILE_PATTERNS.find(p => p.format === sourceFormat); + const expectedFilename = expectedPattern?.filename || 'SKILL.md'; + + for (const file of response.files) { + // Skip files without content (binary files) + if (!file.content) continue; + + // Main instruction file: match expected filename or any known instruction file + if (!skillMd && (file.name === expectedFilename || MAIN_FILE_NAMES.includes(file.name)) && + file.path === file.name) { + skillMd = file.content; + continue; + } + + // Scripts folder + if (file.path.startsWith('scripts/')) { + scripts.push({ + name: file.name, + content: file.content, + }); + continue; + } + + // References folder + if (file.path.startsWith('references/')) { + references.push({ + name: file.name, + content: file.content, + }); + } + } + + return { + skillMd, + scripts, + references, + assets: [], + }; +} diff --git a/apps/cli/src/commands/list.ts b/apps/cli/src/commands/list.ts new file mode 100644 index 0000000..98ec6fc --- /dev/null +++ b/apps/cli/src/commands/list.ts @@ -0,0 +1,177 @@ +import fs from 'fs-extra'; +import * as path from 'path'; +import chalk from 'chalk'; +import { parseSkillMd } from 'skillhub-core'; +import { getSkillsPath, type Platform } from '../utils/paths.js'; + +interface ListOptions { + platform?: Platform; + project?: boolean; + all?: boolean; +} + +const ALL_PLATFORMS: Platform[] = ['claude', 'codex', 'copilot', 'cursor', 'windsurf']; + +/** + * List installed skills + */ +export async function list(options: ListOptions): Promise { + const platforms = options.platform ? [options.platform] : ALL_PLATFORMS; + let totalSkills = 0; + + // Determine which locations to check + const checkGlobal = options.all || !options.project; + const checkProject = options.all || options.project; + + // List global skills + if (checkGlobal) { + for (const platform of platforms) { + const skills = await getInstalledSkills(platform, false); + + if (skills.length === 0) { + continue; + } + + totalSkills += skills.length; + + const header = options.all + ? `${getPlatformName(platform)} - Global (${skills.length} skills)` + : `${getPlatformName(platform)} (${skills.length} skills)`; + + console.log(chalk.bold(`\n${header}:`)); + console.log(chalk.dim('─'.repeat(60))); + + for (const skill of skills) { + const version = skill.version ? chalk.dim(`v${skill.version}`) : ''; + console.log(` ${chalk.cyan(skill.name.padEnd(25))} ${version}`); + + if (skill.description) { + console.log(` ${chalk.dim(skill.description.slice(0, 55))}${skill.description.length > 55 ? '...' : ''}`); + } + } + } + } + + // List project skills + if (checkProject) { + for (const platform of platforms) { + const skills = await getInstalledSkills(platform, true); + + if (skills.length === 0) { + continue; + } + + totalSkills += skills.length; + + const header = options.all + ? `${getPlatformName(platform)} - Project (${skills.length} skills)` + : `${getPlatformName(platform)} (${skills.length} skills)`; + + console.log(chalk.bold(`\n${header}:`)); + console.log(chalk.dim('─'.repeat(60))); + console.log(chalk.dim(` Path: ${getSkillsPath(platform, true)}`)); + + for (const skill of skills) { + const version = skill.version ? chalk.dim(`v${skill.version}`) : ''; + console.log(` ${chalk.cyan(skill.name.padEnd(25))} ${version}`); + + if (skill.description) { + console.log(` ${chalk.dim(skill.description.slice(0, 55))}${skill.description.length > 55 ? '...' : ''}`); + } + } + } + } + + if (totalSkills === 0) { + if (options.project) { + console.log(chalk.yellow('\nNo skills installed in project.')); + console.log(chalk.dim('Install a skill with: npx skillhub install --project')); + } else if (options.all) { + console.log(chalk.yellow('\nNo skills installed (global or project).')); + } else { + console.log(chalk.yellow('\nNo skills installed globally.')); + console.log(chalk.dim('Use --project to list project-level skills')); + console.log(chalk.dim('Use --all to list both global and project skills')); + } + console.log(chalk.dim('\nSearch for skills with: npx skillhub search ')); + console.log(chalk.dim('Install a skill with: npx skillhub install ')); + } else { + console.log(chalk.dim(`\n${totalSkills} total skill(s) installed.`)); + } +} + +interface InstalledSkill { + name: string; + description?: string; + version?: string; + path: string; +} + +async function getInstalledSkills(platform: Platform, project: boolean = false): Promise { + const skillsPath = getSkillsPath(platform, project); + const skills: InstalledSkill[] = []; + + if (!(await fs.pathExists(skillsPath))) { + return skills; + } + + const entries = await fs.readdir(skillsPath, { withFileTypes: true }); + + for (const entry of entries) { + if (!entry.isDirectory()) { + continue; + } + + const skillPath = path.join(skillsPath, entry.name); + const skillMdPath = path.join(skillPath, 'SKILL.md'); + const metadataPath = path.join(skillPath, '.skillhub.json'); + + const hasSkillMd = await fs.pathExists(skillMdPath); + const hasMetadata = await fs.pathExists(metadataPath); + + if (!hasSkillMd && !hasMetadata) { + continue; + } + + try { + if (hasSkillMd) { + const content = await fs.readFile(skillMdPath, 'utf-8'); + const parsed = parseSkillMd(content); + + skills.push({ + name: parsed.metadata.name || entry.name, + description: parsed.metadata.description, + version: parsed.metadata.version, + path: skillPath, + }); + } else if (hasMetadata) { + const metadata = await fs.readJson(metadataPath); + skills.push({ + name: entry.name, + description: undefined, + version: metadata.version || undefined, + path: skillPath, + }); + } + } catch { + // Skip invalid skills + skills.push({ + name: entry.name, + path: skillPath, + }); + } + } + + return skills.sort((a, b) => a.name.localeCompare(b.name)); +} + +function getPlatformName(platform: Platform): string { + const names: Record = { + claude: 'Claude', + codex: 'OpenAI Codex', + copilot: 'GitHub Copilot', + cursor: 'Cursor', + windsurf: 'Windsurf', + }; + return names[platform]; +} diff --git a/apps/cli/src/commands/search.test.ts b/apps/cli/src/commands/search.test.ts new file mode 100644 index 0000000..24a41e3 --- /dev/null +++ b/apps/cli/src/commands/search.test.ts @@ -0,0 +1,144 @@ +import { describe, it, expect, vi, beforeEach, afterEach, type MockInstance } from 'vitest'; + +// Mock dependencies +vi.mock('chalk', () => ({ + default: { + bold: vi.fn((s) => s), + dim: vi.fn((s) => s), + cyan: vi.fn((s) => s), + yellow: vi.fn((s) => s), + red: vi.fn((s) => s), + green: vi.fn((s) => s), + white: vi.fn((s) => s), + }, +})); + +vi.mock('ora', () => ({ + default: vi.fn(() => ({ + start: vi.fn().mockReturnThis(), + stop: vi.fn(), + fail: vi.fn(), + })), +})); + +vi.mock('../utils/api.js', () => ({ + searchSkills: vi.fn(), +})); + +import { search } from './search.js'; +import { searchSkills } from '../utils/api.js'; + +describe('search command', () => { + let consoleLogSpy: MockInstance; + let consoleErrorSpy: MockInstance; + // eslint-disable-next-line @typescript-eslint/no-explicit-any + let processExitSpy: any; + + beforeEach(() => { + vi.clearAllMocks(); + consoleLogSpy = vi.spyOn(console, 'log').mockImplementation(() => {}); + consoleErrorSpy = vi.spyOn(console, 'error').mockImplementation(() => {}); + processExitSpy = vi.spyOn(process, 'exit').mockImplementation((() => {}) as any); + }); + + afterEach(() => { + consoleLogSpy.mockRestore(); + consoleErrorSpy.mockRestore(); + processExitSpy.mockRestore(); + }); + + it('should search skills via API', async () => { + vi.mocked(searchSkills).mockResolvedValue({ + skills: [ + { + id: 'test/skill', + name: 'test-skill', + description: 'A test skill', + githubOwner: 'test', + githubRepo: 'skill', + skillPath: 'skills/test', + branch: 'main', + githubStars: 100, + downloadCount: 50, + securityScore: 85, + isVerified: true, + compatibility: { platforms: ['claude', 'codex'] }, + }, + ], + pagination: { page: 1, limit: 10, total: 1, totalPages: 1 }, + }); + + await search('test', {}); + + expect(searchSkills).toHaveBeenCalledWith('test', { platform: undefined, limit: 10, page: 1, sort: 'downloads' }); + expect(consoleLogSpy).toHaveBeenCalled(); + }); + + it('should show "no skills found" message', async () => { + vi.mocked(searchSkills).mockResolvedValue({ + skills: [], + pagination: { page: 1, limit: 10, total: 0, totalPages: 0 }, + }); + + await search('nonexistent', {}); + + expect(consoleLogSpy).toHaveBeenCalledWith(expect.stringContaining('No skills found')); + }); + + it('should filter by platform', async () => { + vi.mocked(searchSkills).mockResolvedValue({ + skills: [], + pagination: { page: 1, limit: 10, total: 0, totalPages: 0 }, + }); + + await search('test', { platform: 'claude' }); + + expect(searchSkills).toHaveBeenCalledWith('test', expect.objectContaining({ platform: 'claude' })); + }); + + it('should respect limit option', async () => { + vi.mocked(searchSkills).mockResolvedValue({ + skills: [], + pagination: { page: 1, limit: 20, total: 0, totalPages: 0 }, + }); + + await search('test', { limit: '20' }); + + expect(searchSkills).toHaveBeenCalledWith('test', expect.objectContaining({ limit: 20 })); + }); + + it('should handle API errors gracefully', async () => { + vi.mocked(searchSkills).mockRejectedValue(new Error('API error')); + + await search('test', {}); + + expect(consoleErrorSpy).toHaveBeenCalledWith(expect.stringContaining('API error')); + expect(processExitSpy).toHaveBeenCalledWith(1); + }); + + it('should display pagination info when more results available', async () => { + vi.mocked(searchSkills).mockResolvedValue({ + skills: [ + { + id: 'test/skill', + name: 'test-skill', + description: 'A test skill', + githubOwner: 'test', + githubRepo: 'skill', + skillPath: 'skills/test', + branch: 'main', + githubStars: 100, + downloadCount: 50, + securityScore: 85, + isVerified: false, + compatibility: { platforms: ['claude'] }, + }, + ], + pagination: { page: 1, limit: 10, total: 50, totalPages: 5 }, + }); + + await search('test', {}); + + expect(consoleLogSpy).toHaveBeenCalledWith(expect.stringContaining('Page 1 of 5')); + }); +}); diff --git a/apps/cli/src/commands/search.ts b/apps/cli/src/commands/search.ts new file mode 100644 index 0000000..48c25b6 --- /dev/null +++ b/apps/cli/src/commands/search.ts @@ -0,0 +1,132 @@ +import chalk from 'chalk'; +import ora from 'ora'; +import { searchSkills } from '../utils/api.js'; + +interface SearchOptions { + platform?: string; + sort?: string; + limit?: string; + page?: string; +} + +/** + * Search for skills in the registry + */ +export async function search(query: string, options: SearchOptions): Promise { + const spinner = ora('Searching skills...').start(); + + try { + const limit = parseInt(options.limit || '10'); + const page = parseInt(options.page || '1'); + const sort = options.sort || 'downloads'; + const result = await searchSkills(query, { + platform: options.platform, + limit, + page, + sort, + }); + + spinner.stop(); + + if (result.skills.length === 0) { + console.log(chalk.yellow('No skills found.')); + console.log(chalk.dim('Try a different search term or check the spelling.')); + return; + } + + const sortLabel = { downloads: 'downloads', stars: 'GitHub stars', rating: 'rating', recent: 'recently updated' }[sort] || sort; + console.log(chalk.bold(`Found ${result.pagination.total} skills (sorted by ${sortLabel}):\n`)); + + // Print results as a table + console.log( + chalk.dim('─'.repeat(80)) + ); + + const startIndex = (page - 1) * limit; + for (let i = 0; i < result.skills.length; i++) { + const skill = result.skills[i]; + const num = chalk.dim(`[${startIndex + i + 1}]`); + const verified = skill.isVerified ? chalk.green('✓') : ' '; + // Use securityStatus if available, otherwise fall back to score-based badge + const security = skill.securityStatus + ? getSecurityStatusBadge(skill.securityStatus) + : getSecurityBadge(skill.securityScore); + + // First line: number, ID, security + console.log( + `${num} ${verified} ${chalk.cyan(skill.id.padEnd(38))} ${security}` + ); + + // Second line: downloads, stars, description + console.log( + ` ⬇ ${formatNumber(skill.downloadCount).padStart(6)} ⭐ ${formatNumber(skill.githubStars).padStart(6)} ${chalk.dim(skill.description.slice(0, 55))}${skill.description.length > 55 ? '...' : ''}` + ); + + // Third line: Rating (only if ratingCount >= 3) + const showRating = (skill.ratingCount ?? 0) >= 3; + if (showRating && skill.rating) { + console.log( + ` ${chalk.yellow('★')} ${skill.rating.toFixed(1)} ${chalk.dim(`(${skill.ratingCount} ratings)`)}` + ); + } + + console.log(chalk.dim('─'.repeat(80))); + } + + console.log(); + console.log(chalk.dim(`Install with: ${chalk.white('npx skillhub install ')}`)); + + const totalPages = result.pagination.totalPages; + if (totalPages > 1) { + console.log( + chalk.dim(`Page ${page} of ${totalPages}. Use ${chalk.white(`--page ${page + 1}`)} for next page.`) + ); + } + + if (sort === 'downloads') { + console.log(chalk.dim(`Sort options: ${chalk.white('--sort stars|rating|recent')}`)); + } + } catch (error) { + spinner.fail('Search failed'); + const err = error as Error; + console.error(chalk.red(err.message || 'Unknown error')); + if (process.env.DEBUG) { + console.error(chalk.dim('Stack:'), err.stack); + } + process.exit(1); + } +} + +function formatNumber(num: number): string { + if (num >= 1000) { + return (num / 1000).toFixed(1) + 'k'; + } + return num.toString(); +} + +/** + * Get security badge from securityStatus (new system) + */ +function getSecurityStatusBadge(status: string): string { + switch (status) { + case 'pass': + return chalk.green('🛡️ Pass'); + case 'warning': + return chalk.yellow('⚠️ Warn'); + case 'fail': + return chalk.red('❌ Fail'); + default: + return chalk.dim('- N/A'); + } +} + +/** + * Get security badge from securityScore (legacy system) + */ +function getSecurityBadge(score: number): string { + if (score >= 90) return chalk.green('●●●●●'); + if (score >= 70) return chalk.yellow('●●●●○'); + if (score >= 50) return chalk.yellow('●●●○○'); + if (score >= 30) return chalk.red('●●○○○'); + return chalk.red('●○○○○'); +} diff --git a/apps/cli/src/index.ts b/apps/cli/src/index.ts new file mode 100644 index 0000000..7725ee0 --- /dev/null +++ b/apps/cli/src/index.ts @@ -0,0 +1,239 @@ +#!/usr/bin/env node + +import { Command } from 'commander'; +import chalk from 'chalk'; +import { createRequire } from 'module'; +import { install } from './commands/install.js'; +import { search } from './commands/search.js'; +import { list } from './commands/list.js'; +import { config } from './commands/config.js'; +import { loadConfig, type Platform } from './utils/paths.js'; + +const require = createRequire(import.meta.url); +const pkg = require('../package.json'); +const VERSION = pkg.version; + +const program = new Command(); + +program + .name('skillhub') + .description('CLI for managing AI Agent skills') + .version(VERSION); + +// Install command +program + .command('install ') + .description('Install a skill from the registry') + .option('-p, --platform ', 'Target platform (claude, codex, copilot, cursor, windsurf)') + .option('--project', 'Install in the current project instead of globally') + .option('-f, --force', 'Overwrite existing skill') + .option('--no-api', 'Skip API lookup and fetch directly from GitHub') + .action(async (skillId: string, options) => { + // Load config to get default platform + const userConfig = await loadConfig(); + const platform = options.platform || (userConfig.defaultPlatform as Platform) || 'claude'; + + await install(skillId, { + platform, + project: options.project, + force: options.force, + noApi: !options.api, // Commander converts --no-api to api: false + }); + }); + +// Search command +program + .command('search ') + .description('Search for skills in the registry') + .option('-p, --platform ', 'Filter by platform') + .option('-s, --sort ', 'Sort by: downloads, stars, rating, recent', 'downloads') + .option('-l, --limit ', 'Number of results', '10') + .option('--page ', 'Page number', '1') + .action(async (query: string, options) => { + await search(query, options); + }); + +// List command +program + .command('list') + .description('List installed skills') + .option('-p, --platform ', 'Filter by platform') + .option('--project', 'List skills in the current project') + .option('--all', 'List both global and project skills') + .action(async (options) => { + await list(options); + }); + +// Config command +program + .command('config') + .description('Manage CLI configuration') + .option('--set ', 'Set a configuration value') + .option('--get ', 'Get a configuration value') + .option('--list', 'List all configuration values') + .action(async (options) => { + await config(options); + }); + +// Uninstall command +program + .command('uninstall ') + .description('Uninstall a skill') + .option('-p, --platform ', 'Target platform') + .option('--project', 'Uninstall from project instead of globally') + .action(async (skillName: string, options) => { + const fs = await import('fs-extra'); + const pathModule = await import('path'); + const { getSkillPath, isSkillInstalled } = await import('./utils/paths.js'); + + // Load config to get default platform + const userConfig = await loadConfig(); + const platform = options.platform || (userConfig.defaultPlatform as Platform) || 'claude'; + + const installed = await isSkillInstalled(platform, skillName, options.project); + if (!installed) { + console.log(chalk.yellow(`Skill ${skillName} is not installed.`)); + process.exit(1); + } + + const skillPath = getSkillPath(platform, skillName, options.project); + + // Clean up flat platform file if present + const metadataPath = pathModule.join(skillPath, '.skillhub.json'); + if (await fs.default.pathExists(metadataPath)) { + try { + const metadata = await fs.default.readJson(metadataPath); + if (metadata.platformFilePath) { + await fs.default.remove(metadata.platformFilePath); + } + } catch { + // Ignore metadata read errors + } + } + + await fs.default.remove(skillPath); + console.log(chalk.green(`Skill ${skillName} uninstalled successfully.`)); + }); + +// Update command +program + .command('update [skill-name]') + .description('Update installed skills') + .option('-p, --platform ', 'Target platform') + .option('--all', 'Update all installed skills') + .action(async (skillName: string | undefined, options) => { + const fsExtra = await import('fs-extra'); + const pathModule = await import('path'); + const { getSkillsPath, getSkillPath } = await import('./utils/paths.js'); + + // Load config to get default platform + const userConfig = await loadConfig(); + const platform = options.platform || (userConfig.defaultPlatform as Platform) || 'claude'; + + const ALL_PLATFORMS: Platform[] = ['claude', 'codex', 'copilot', 'cursor', 'windsurf']; + + if (options.all) { + console.log(chalk.cyan('\nUpdating all installed skills...\n')); + + const platforms = options.platform ? [platform] : ALL_PLATFORMS; + let updated = 0; + let failed = 0; + let skipped = 0; + + for (const p of platforms) { + const skillsPath = getSkillsPath(p); + + if (!(await fsExtra.default.pathExists(skillsPath))) { + continue; + } + + const entries = await fsExtra.default.readdir(skillsPath, { withFileTypes: true }); + + for (const entry of entries) { + if (!entry.isDirectory()) continue; + + const skillPath = pathModule.join(skillsPath, entry.name); + const metadataPath = pathModule.join(skillPath, '.skillhub.json'); + + // Check if skill has metadata with skillId + if (!(await fsExtra.default.pathExists(metadataPath))) { + console.log(chalk.yellow(` Skipping ${entry.name}: No metadata (reinstall with CLI to enable updates)`)); + skipped++; + continue; + } + + try { + const metadata = await fsExtra.default.readJson(metadataPath); + const skillId = metadata.skillId; + + if (!skillId) { + console.log(chalk.yellow(` Skipping ${entry.name}: No skill ID in metadata`)); + skipped++; + continue; + } + + console.log(chalk.dim(` Updating ${entry.name}...`)); + + // Re-install with force + await install(skillId, { + platform: p, + force: true, + }); + + updated++; + } catch (error) { + console.log(chalk.red(` Failed to update ${entry.name}: ${(error as Error).message}`)); + failed++; + } + } + } + + console.log(); + console.log(chalk.green(`Updated: ${updated}`)); + if (failed > 0) console.log(chalk.red(`Failed: ${failed}`)); + if (skipped > 0) console.log(chalk.yellow(`Skipped: ${skipped}`)); + return; + } + + if (!skillName) { + console.log(chalk.red('Please specify a skill name or use --all.')); + process.exit(1); + } + + // Update single skill by name + const skillPath = getSkillPath(platform, skillName); + const metadataPath = pathModule.join(skillPath, '.skillhub.json'); + + if (!(await fsExtra.default.pathExists(metadataPath))) { + console.log(chalk.yellow(`Skill ${skillName} was not installed via CLI.`)); + console.log(chalk.dim('To update, reinstall with: npx skillhub install --force')); + process.exit(1); + } + + try { + const metadata = await fsExtra.default.readJson(metadataPath); + const skillId = metadata.skillId; + + if (!skillId) { + console.log(chalk.red('No skill ID found in metadata.')); + process.exit(1); + } + + console.log(chalk.dim(`Updating ${skillName}...`)); + await install(skillId, { + platform, + force: true, + }); + } catch (error) { + console.log(chalk.red(`Failed to update: ${(error as Error).message}`)); + process.exit(1); + } + }); + +// Parse arguments +program.parse(); + +// Show help if no command +if (!process.argv.slice(2).length) { + program.help(); +} diff --git a/apps/cli/src/utils/api.test.ts b/apps/cli/src/utils/api.test.ts new file mode 100644 index 0000000..c072a4d --- /dev/null +++ b/apps/cli/src/utils/api.test.ts @@ -0,0 +1,176 @@ +import { describe, it, expect, vi, beforeEach } from 'vitest'; +import { PassThrough } from 'stream'; + +// Use vi.hoisted to make mock state available to hoisted vi.mock calls +const mockState = vi.hoisted(() => ({ + responseData: { + skills: [{ id: 'test-skill', name: 'Test Skill' }], + pagination: { page: 1, limit: 10, total: 1, totalPages: 1 }, + } as unknown, + statusCode: 200, + shouldError: false, +})); + +// Mock https module +vi.mock('https', () => { + return { + default: { + request: vi.fn().mockImplementation((_options: unknown, callback: (res: unknown) => void) => { + const req = new PassThrough(); + + if (mockState.shouldError) { + Object.assign(req, { + on: vi.fn().mockImplementation((event: string, handler: (err?: Error) => void) => { + if (event === 'error') { + setImmediate(() => handler(new Error('Network error'))); + } + return req; + }), + write: vi.fn(), + end: vi.fn(), + destroy: vi.fn(), + }); + return req; + } + + const res = new PassThrough(); + Object.assign(res, { + statusCode: mockState.statusCode, + setTimeout: vi.fn(), + }); + + setImmediate(() => { + callback(res); + res.push(JSON.stringify(mockState.responseData)); + res.push(null); + }); + + Object.assign(req, { + on: vi.fn().mockReturnThis(), + write: vi.fn(), + end: vi.fn(), + destroy: vi.fn(), + }); + return req; + }), + }, + request: vi.fn().mockImplementation((_options: unknown, callback: (res: unknown) => void) => { + const req = new PassThrough(); + + if (mockState.shouldError) { + Object.assign(req, { + on: vi.fn().mockImplementation((event: string, handler: (err?: Error) => void) => { + if (event === 'error') { + setImmediate(() => handler(new Error('Network error'))); + } + return req; + }), + write: vi.fn(), + end: vi.fn(), + destroy: vi.fn(), + }); + return req; + } + + const res = new PassThrough(); + Object.assign(res, { + statusCode: mockState.statusCode, + setTimeout: vi.fn(), + }); + + setImmediate(() => { + callback(res); + res.push(JSON.stringify(mockState.responseData)); + res.push(null); + }); + + Object.assign(req, { + on: vi.fn().mockReturnThis(), + write: vi.fn(), + end: vi.fn(), + destroy: vi.fn(), + }); + return req; + }), + }; +}); + +// Mock http module +vi.mock('http', () => ({ + default: { request: vi.fn() }, + request: vi.fn(), +})); + +// Import after mocking +import { searchSkills, getSkill, trackInstall } from './api.js'; + +describe('API Utils', () => { + beforeEach(() => { + vi.clearAllMocks(); + // Reset to default + mockState.responseData = { + skills: [{ id: 'test-skill', name: 'Test Skill' }], + pagination: { page: 1, limit: 10, total: 1, totalPages: 1 }, + }; + mockState.statusCode = 200; + mockState.shouldError = false; + }); + + describe('searchSkills', () => { + it('should search skills with query', async () => { + const result = await searchSkills('test'); + + expect(result.skills).toBeDefined(); + expect(result.pagination).toBeDefined(); + }); + + it('should return skills array', async () => { + const result = await searchSkills('test'); + + expect(Array.isArray(result.skills)).toBe(true); + }); + + it('should return pagination info', async () => { + const result = await searchSkills('test'); + + expect(result.pagination.page).toBeDefined(); + expect(result.pagination.limit).toBeDefined(); + }); + }); + + describe('getSkill', () => { + it('should return skill data when found', async () => { + mockState.responseData = { id: 'test/repo/skill', name: 'Test Skill' }; + + const result = await getSkill('test/repo/skill'); + + expect(result).toBeDefined(); + expect(result?.id).toBe('test/repo/skill'); + }); + + it('should return null for 404', async () => { + mockState.statusCode = 404; + mockState.responseData = { error: 'Not found' }; + + const result = await getSkill('nonexistent'); + + expect(result).toBeNull(); + }); + }); + + describe('trackInstall', () => { + it('should not throw on success', async () => { + mockState.responseData = { success: true }; + + // Should not throw + await expect(trackInstall('test/skill', 'claude', 'cli')).resolves.not.toThrow(); + }); + + it('should not throw on failure', async () => { + mockState.shouldError = true; + + // Should not throw - tracking failures are silently ignored + await expect(trackInstall('test/skill', 'claude')).resolves.not.toThrow(); + }); + }); +}); diff --git a/apps/cli/src/utils/api.ts b/apps/cli/src/utils/api.ts new file mode 100644 index 0000000..666a918 --- /dev/null +++ b/apps/cli/src/utils/api.ts @@ -0,0 +1,260 @@ +import https from 'https'; +import http from 'http'; + +const API_BASE_URL = process.env.SKILLHUB_API_URL || 'https://skills.palebluedot.live/api'; +const API_TIMEOUT = parseInt(process.env.SKILLHUB_API_TIMEOUT || '20000'); // 20 seconds default +const API_FILES_TIMEOUT = parseInt(process.env.SKILLHUB_API_FILES_TIMEOUT || '45000'); // 45 seconds for file fetching (server may need to fetch from GitHub on cache miss) + +interface HttpResponse { + statusCode: number; + data: string; +} + +/** + * Make an HTTPS request using native Node.js module + * Handles Cloudflare chunked encoding issues by detecting complete JSON responses + */ +function httpsRequest( + url: string, + options: { + method?: string; + headers?: Record; + body?: string; + timeout?: number; + } = {} +): Promise { + return new Promise((resolve, reject) => { + const parsedUrl = new URL(url); + const isHttps = parsedUrl.protocol === 'https:'; + const lib = isHttps ? https : http; + const requestTimeout = options.timeout || API_TIMEOUT; + + const reqOptions = { + hostname: parsedUrl.hostname, + port: parsedUrl.port || (isHttps ? 443 : 80), + path: parsedUrl.pathname + parsedUrl.search, + method: options.method || 'GET', + headers: { + 'User-Agent': 'skillhub-cli', + 'Accept': 'application/json', + ...options.headers, + }, + timeout: requestTimeout, + }; + + const req = lib.request(reqOptions, (res) => { + const chunks: Buffer[] = []; + let resolved = false; + + const tryResolve = () => { + if (resolved) return; + const data = Buffer.concat(chunks).toString(); + + // For JSON responses, detect complete objects + // This works around Cloudflare chunked encoding issues + if (data.startsWith('{') || data.startsWith('[')) { + try { + JSON.parse(data); // Validate complete JSON + resolved = true; + res.destroy(); // Force close connection + resolve({ + statusCode: res.statusCode || 0, + data, + }); + } catch { + // JSON not complete yet, continue waiting + } + } + }; + + res.on('data', (chunk) => { + chunks.push(chunk); + tryResolve(); + }); + + res.on('end', () => { + if (!resolved) { + resolve({ + statusCode: res.statusCode || 0, + data: Buffer.concat(chunks).toString(), + }); + } + }); + + // Set a per-request timeout for receiving data + res.setTimeout(requestTimeout, () => { + if (!resolved) { + res.destroy(); + reject(new Error(`Response timeout after ${requestTimeout / 1000}s`)); + } + }); + }); + + req.on('error', (err) => { + // Ignore stream destroyed errors (we caused them intentionally) + if ((err as NodeJS.ErrnoException).code === 'ERR_STREAM_DESTROYED') return; + reject(new Error(`Network error: ${err.message}`)); + }); + + req.on('timeout', () => { + req.destroy(); + reject(new Error(`Request timeout after ${requestTimeout / 1000}s`)); + }); + + if (options.body) { + req.write(options.body); + } + + req.end(); + }); +} + +export interface SkillInfo { + id: string; + name: string; + description: string; + githubOwner: string; + githubRepo: string; + skillPath: string; + branch: string; + version?: string; + license?: string; + githubStars: number; + downloadCount: number; + securityScore: number; + securityStatus?: 'pass' | 'warning' | 'fail' | null; + sourceFormat?: string; + rating?: number | null; + ratingCount?: number | null; + isVerified: boolean; + compatibility: { + platforms: string[]; + }; +} + +export interface SearchResult { + skills: SkillInfo[]; + pagination: { + page: number; + limit: number; + total: number; + totalPages: number; + }; +} + +/** + * Search for skills + */ +export async function searchSkills( + query: string, + options: { + platform?: string; + limit?: number; + page?: number; + sort?: string; + } = {} +): Promise { + const params = new URLSearchParams({ + q: query, + limit: String(options.limit || 10), + page: String(options.page || 1), + sort: options.sort || 'downloads', + }); + + if (options.platform) { + params.set('platform', options.platform); + } + + const response = await httpsRequest(`${API_BASE_URL}/skills?${params}`); + + if (response.statusCode !== 200) { + throw new Error(`API error ${response.statusCode}: ${response.data || 'Unknown error'}`); + } + + return JSON.parse(response.data); +} + +/** + * Get skill details + */ +export async function getSkill(id: string): Promise { + // Encode each segment separately to preserve slashes in URL path + const encodedPath = id.split('/').map(encodeURIComponent).join('/'); + const response = await httpsRequest(`${API_BASE_URL}/skills/${encodedPath}`); + + if (response.statusCode === 404) { + return null; + } + + if (response.statusCode !== 200) { + throw new Error(`API error ${response.statusCode}: ${response.data || 'Unknown error'}`); + } + + return JSON.parse(response.data); +} + +/** + * Track an installation + */ +export async function trackInstall( + skillId: string, + platform: string, + method = 'cli' +): Promise { + try { + await httpsRequest(`${API_BASE_URL}/skills/install`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ skillId, platform, method }), + }); + } catch { + // Silently fail - tracking is not critical + } +} + +export interface SkillFile { + name: string; + path: string; + type: 'file'; + size: number; + content?: string; // Text file content + downloadUrl?: string; // For binary files +} + +export interface SkillFilesResponse { + skillId: string; + githubOwner: string; + githubRepo: string; + skillPath: string; + branch: string; + sourceFormat?: string; + files: SkillFile[]; + fromCache: boolean; + cachedAt?: string; +} + +/** + * Get skill files from API (uses server-side cache) + * Returns null if API unavailable or skill not found + */ +export async function getSkillFiles(id: string): Promise { + try { + const response = await httpsRequest( + `${API_BASE_URL}/skill-files?id=${encodeURIComponent(id)}`, + { timeout: API_FILES_TIMEOUT } + ); + + if (response.statusCode === 404) { + return null; + } + + if (response.statusCode !== 200) { + return null; + } + + return JSON.parse(response.data); + } catch { + // API unavailable, return null to fall back to GitHub + return null; + } +} diff --git a/apps/cli/src/utils/github.ts b/apps/cli/src/utils/github.ts new file mode 100644 index 0000000..a34974e --- /dev/null +++ b/apps/cli/src/utils/github.ts @@ -0,0 +1,251 @@ +import { Octokit } from '@octokit/rest'; +import { INSTRUCTION_FILE_PATTERNS, type SourceFormat } from 'skillhub-core'; +import https from 'https'; + +let octokit: Octokit | null = null; + +function getOctokit(): Octokit { + if (!octokit) { + octokit = new Octokit({ + auth: process.env.GITHUB_TOKEN, + userAgent: 'SkillHub-CLI/1.0', + request: { + timeout: 30000, // 30 second timeout + }, + }); + } + return octokit; +} + +export interface SkillContent { + skillMd: string; + scripts: Array<{ name: string; content: string }>; + references: Array<{ name: string; content: string }>; + assets: Array<{ name: string; content: string }>; // base64 encoded +} + +/** + * Fetch file from raw.githubusercontent.com (fallback for network issues) + */ +async function fetchRawFile( + owner: string, + repo: string, + path: string, + branch: string +): Promise { + return new Promise((resolve, reject) => { + const url = `https://raw.githubusercontent.com/${owner}/${repo}/${branch}/${path}`; + + https.get(url, { timeout: 10000 }, (res) => { + if (res.statusCode === 404) { + reject(new Error('File not found')); + return; + } + + if (res.statusCode !== 200) { + reject(new Error(`HTTP ${res.statusCode}`)); + return; + } + + let data = ''; + res.on('data', (chunk) => data += chunk); + res.on('end', () => resolve(data)); + }).on('error', (err) => { + reject(err); + }).on('timeout', () => { + reject(new Error('Request timeout')); + }); + }); +} + +/** + * Get the filename to look for based on sourceFormat + */ +function getInstructionFilename(sourceFormat: SourceFormat): string { + const pattern = INSTRUCTION_FILE_PATTERNS.find(p => p.format === sourceFormat); + return pattern?.filename || 'SKILL.md'; +} + +/** + * Fetch skill content from GitHub + */ +export async function fetchSkillContent( + owner: string, + repo: string, + skillPath: string, + branch = 'main', + sourceFormat: SourceFormat = 'skill.md' +): Promise { + const client = getOctokit(); + const filename = getInstructionFilename(sourceFormat); + const isStandalone = sourceFormat !== 'skill.md'; + + // Build paths to try for the instruction file + const basePath = skillPath ? `${skillPath}/${filename}` : filename; + let skillMdResponse; + + // For standalone formats (.cursorrules, .windsurfrules), they're at specific locations + let pathsToTry: string[]; + if (sourceFormat === 'cursorrules' || sourceFormat === 'windsurfrules') { + // Root-only files + pathsToTry = [filename]; + } else if (sourceFormat === 'copilot-instructions') { + // Must be in .github/ + pathsToTry = [`.github/${filename}`]; + } else if (sourceFormat === 'agents.md') { + // Can be in root or subdirectories + pathsToTry = [basePath]; + if (skillPath) { + pathsToTry.push(filename); // Also try root + } + } else { + // SKILL.md - try multiple common paths + pathsToTry = [ + basePath, + ...(skillPath && !skillPath.startsWith('skills/') ? [`skills/${skillPath}/SKILL.md`] : []), + ...(skillPath && !skillPath.startsWith('.claude/') ? [`.claude/skills/${skillPath}/SKILL.md`] : []), + ...(skillPath && !skillPath.startsWith('.github/') ? [`.github/skills/${skillPath}/SKILL.md`] : []), + ]; + } + + for (const pathToTry of pathsToTry) { + try { + skillMdResponse = await client.repos.getContent({ + owner, + repo, + path: pathToTry, + ref: branch, + }); + // Success! Break out of loop + break; + } catch (error: any) { + // If it's a timeout or network error, try raw.githubusercontent.com fallback + if (error.message?.includes('timeout') || error.message?.includes('network')) { + try { + const rawContent = await fetchRawFile(owner, repo, pathToTry, branch); + // Create a mock response compatible with Octokit + skillMdResponse = { + data: { + content: Buffer.from(rawContent).toString('base64'), + encoding: 'base64' as const, + } + } as any; + break; + } catch (rawError) { + // If raw fetch also fails, throw original error + throw new Error(`GitHub API timeout. Try using --no-api flag or check your network connection.`); + } + } + // If 404, try next path + if (error.status === 404) { + continue; + } + // Other errors, throw immediately + throw new Error(`Failed to fetch from GitHub: ${error.message}`); + } + } + + if (!skillMdResponse) { + throw new Error(`${filename} not found at ${owner}/${repo} (tried ${pathsToTry.length} paths)`); + } + + if (!('content' in skillMdResponse.data)) { + throw new Error(`${filename} not found`); + } + + const skillMd = Buffer.from(skillMdResponse.data.content, 'base64').toString('utf-8'); + + // For standalone formats, skip scripts/references (they don't have subdirectories) + if (isStandalone) { + return { + skillMd, + scripts: [], + references: [], + assets: [], + }; + } + + // Fetch scripts + const scripts: SkillContent['scripts'] = []; + try { + const scriptsPath = skillPath ? `${skillPath}/scripts` : 'scripts'; + const scriptsResponse = await client.repos.getContent({ + owner, + repo, + path: scriptsPath, + ref: branch, + }); + + if (Array.isArray(scriptsResponse.data)) { + for (const file of scriptsResponse.data) { + if (file.type === 'file') { + const fileResponse = await client.repos.getContent({ + owner, + repo, + path: file.path, + ref: branch, + }); + + if ('content' in fileResponse.data) { + scripts.push({ + name: file.name, + content: Buffer.from(fileResponse.data.content, 'base64').toString('utf-8'), + }); + } + } + } + } + } catch { + // No scripts directory + } + + // Fetch references + const references: SkillContent['references'] = []; + try { + const refsPath = skillPath ? `${skillPath}/references` : 'references'; + const refsResponse = await client.repos.getContent({ + owner, + repo, + path: refsPath, + ref: branch, + }); + + if (Array.isArray(refsResponse.data)) { + for (const file of refsResponse.data) { + if (file.type === 'file' && file.size && file.size < 100000) { + const fileResponse = await client.repos.getContent({ + owner, + repo, + path: file.path, + ref: branch, + }); + + if ('content' in fileResponse.data) { + references.push({ + name: file.name, + content: Buffer.from(fileResponse.data.content, 'base64').toString('utf-8'), + }); + } + } + } + } + } catch { + // No references directory + } + + return { + skillMd, + scripts, + references, + assets: [], // TODO: handle binary assets + }; +} + +/** + * Get default branch for a repository + */ +export async function getDefaultBranch(owner: string, repo: string): Promise { + const client = getOctokit(); + const response = await client.repos.get({ owner, repo }); + return response.data.default_branch; +} diff --git a/apps/cli/src/utils/paths.test.ts b/apps/cli/src/utils/paths.test.ts new file mode 100644 index 0000000..c804721 --- /dev/null +++ b/apps/cli/src/utils/paths.test.ts @@ -0,0 +1,208 @@ +import { describe, it, expect, vi, beforeEach } from 'vitest'; + +// Mock fs-extra before importing paths +vi.mock('fs-extra', () => ({ + default: { + pathExists: vi.fn(), + ensureDir: vi.fn(), + readJson: vi.fn(), + writeJson: vi.fn(), + }, +})); + +import fs from 'fs-extra'; +import { + getSkillsPath, + getSkillPath, + ensureSkillsDir, + isSkillInstalled, + getConfigPath, + loadConfig, + saveConfig, + isFlatFilePlatform, + getPlatformFilePath, +} from './paths.js'; + +describe('Path Utils', () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + describe('getSkillsPath', () => { + it('should return path ending with .claude/skills for claude', () => { + const result = getSkillsPath('claude'); + expect(result).toContain('.claude'); + expect(result).toContain('skills'); + }); + + it('should return path ending with .codex/skills for codex', () => { + const result = getSkillsPath('codex'); + expect(result).toContain('.codex'); + expect(result).toContain('skills'); + }); + + it('should return path ending with .github/instructions for copilot', () => { + const result = getSkillsPath('copilot'); + expect(result).toContain('.github'); + expect(result).toContain('instructions'); + }); + + it('should return path ending with .cursor/rules for cursor', () => { + const result = getSkillsPath('cursor'); + expect(result).toContain('.cursor'); + expect(result).toContain('rules'); + }); + + it('should return path ending with .windsurf/rules for windsurf', () => { + const result = getSkillsPath('windsurf'); + expect(result).toContain('.windsurf'); + expect(result).toContain('rules'); + }); + + it('should return different path for user vs project', () => { + const userPath = getSkillsPath('claude', false); + const projectPath = getSkillsPath('claude', true); + expect(userPath).not.toBe(projectPath); + }); + }); + + describe('getSkillPath', () => { + it('should append skill name to base path', () => { + const result = getSkillPath('claude', 'my-skill'); + expect(result).toContain('my-skill'); + expect(result).toContain('.claude'); + }); + + it('should handle project path', () => { + const result = getSkillPath('codex', 'my-skill', true); + expect(result).toContain('my-skill'); + expect(result).toContain('.codex'); + }); + }); + + describe('ensureSkillsDir', () => { + it('should call fs.ensureDir', async () => { + vi.mocked(fs.ensureDir).mockResolvedValue(undefined); + + const result = await ensureSkillsDir('claude'); + + expect(fs.ensureDir).toHaveBeenCalled(); + expect(result).toContain('.claude'); + }); + }); + + describe('isSkillInstalled', () => { + it('should return true when skill exists', async () => { + vi.mocked(fs.pathExists).mockResolvedValue(true as never); + + const result = await isSkillInstalled('claude', 'my-skill'); + + expect(result).toBe(true); + expect(fs.pathExists).toHaveBeenCalled(); + }); + + it('should return false when skill does not exist', async () => { + vi.mocked(fs.pathExists).mockResolvedValue(false as never); + + const result = await isSkillInstalled('claude', 'my-skill'); + + expect(result).toBe(false); + }); + }); + + describe('getConfigPath', () => { + it('should return path ending with .skillhub/config.json', () => { + const result = getConfigPath(); + expect(result).toContain('.skillhub'); + expect(result).toContain('config.json'); + }); + }); + + describe('loadConfig', () => { + it('should load config from file when exists', async () => { + const mockConfig = { defaultPlatform: 'claude' }; + vi.mocked(fs.pathExists).mockResolvedValue(true as never); + vi.mocked(fs.readJson).mockResolvedValue(mockConfig as never); + + const result = await loadConfig(); + + expect(result).toEqual(mockConfig); + expect(fs.readJson).toHaveBeenCalled(); + }); + + it('should return empty object when config does not exist', async () => { + vi.mocked(fs.pathExists).mockResolvedValue(false as never); + + const result = await loadConfig(); + + expect(result).toEqual({}); + expect(fs.readJson).not.toHaveBeenCalled(); + }); + }); + + describe('saveConfig', () => { + it('should save config to file', async () => { + vi.mocked(fs.ensureDir).mockResolvedValue(undefined); + vi.mocked(fs.writeJson).mockResolvedValue(undefined); + + await saveConfig({ defaultPlatform: 'claude' }); + + expect(fs.ensureDir).toHaveBeenCalled(); + expect(fs.writeJson).toHaveBeenCalledWith( + expect.stringContaining('config.json'), + { defaultPlatform: 'claude' }, + { spaces: 2 } + ); + }); + }); + + describe('isFlatFilePlatform', () => { + it('returns false for claude', () => { + expect(isFlatFilePlatform('claude')).toBe(false); + }); + + it('returns false for codex', () => { + expect(isFlatFilePlatform('codex')).toBe(false); + }); + + it('returns true for cursor', () => { + expect(isFlatFilePlatform('cursor')).toBe(true); + }); + + it('returns true for windsurf', () => { + expect(isFlatFilePlatform('windsurf')).toBe(true); + }); + + it('returns true for copilot', () => { + expect(isFlatFilePlatform('copilot')).toBe(true); + }); + }); + + describe('getPlatformFilePath', () => { + it('returns file inside skill subdirectory for claude', () => { + const result = getPlatformFilePath('claude', 'my-skill', 'SKILL.md'); + expect(result).toContain('my-skill'); + expect(result).toContain('SKILL.md'); + }); + + it('returns file in flat directory for cursor', () => { + const result = getPlatformFilePath('cursor', 'my-skill', 'my-skill.mdc'); + expect(result).toContain('rules'); + expect(result).toContain('my-skill.mdc'); + // Should not have skill name as a directory segment + expect(result).not.toMatch(/rules[/\\]my-skill[/\\]my-skill\.mdc/); + }); + + it('returns file in flat directory for windsurf', () => { + const result = getPlatformFilePath('windsurf', 'my-skill', 'my-skill.md'); + expect(result).toContain('rules'); + expect(result).toContain('my-skill.md'); + }); + + it('returns file in flat directory for copilot', () => { + const result = getPlatformFilePath('copilot', 'my-skill', 'my-skill.instructions.md'); + expect(result).toContain('instructions'); + expect(result).toContain('my-skill.instructions.md'); + }); + }); +}); diff --git a/apps/cli/src/utils/paths.ts b/apps/cli/src/utils/paths.ts new file mode 100644 index 0000000..1053890 --- /dev/null +++ b/apps/cli/src/utils/paths.ts @@ -0,0 +1,152 @@ +import * as os from 'os'; +import * as path from 'path'; +import fs from 'fs-extra'; + +export type Platform = 'claude' | 'codex' | 'copilot' | 'cursor' | 'windsurf'; + +const PLATFORM_PATHS: Record = { + claude: { + user: '.claude/skills', + project: '.claude/skills', + }, + codex: { + user: '.codex/skills', + project: '.codex/skills', + }, + copilot: { + user: '.github/instructions', + project: '.github/instructions', + }, + cursor: { + user: '.cursor/rules', + project: '.cursor/rules', + }, + windsurf: { + user: '.windsurf/rules', + project: '.windsurf/rules', + }, +}; + +const FLAT_FILE_PLATFORMS: Platform[] = ['cursor', 'windsurf', 'copilot']; + +/** + * Whether this platform uses a flat file structure + * (skill file in base dir, tracking data in subdirectory) + */ +export function isFlatFilePlatform(platform: Platform): boolean { + return FLAT_FILE_PLATFORMS.includes(platform); +} + +/** + * Get the path where the platform-specific skill file should be written. + * For flat-file platforms, this is the base directory + fileName. + * For subdirectory platforms, this is the skill's subdirectory + fileName. + */ +export function getPlatformFilePath( + platform: Platform, + skillName: string, + fileName: string, + project = false +): string { + const basePath = getSkillsPath(platform, project); + if (isFlatFilePlatform(platform)) { + return path.join(basePath, fileName); + } + return path.join(basePath, skillName, fileName); +} + +/** + * Get the skills directory path for a platform + */ +export function getSkillsPath(platform: Platform, project = false): string { + const home = os.homedir(); + const cwd = process.cwd(); + + const paths = PLATFORM_PATHS[platform]; + + if (project) { + return path.join(cwd, paths.project); + } + + return path.join(home, paths.user); +} + +/** + * Get the path for a specific skill + */ +export function getSkillPath(platform: Platform, skillName: string, project = false): string { + const basePath = getSkillsPath(platform, project); + return path.join(basePath, skillName); +} + +/** + * Ensure the skills directory exists + */ +export async function ensureSkillsDir(platform: Platform, project = false): Promise { + const skillsPath = getSkillsPath(platform, project); + await fs.ensureDir(skillsPath); + return skillsPath; +} + +/** + * Check if a skill is already installed + */ +export async function isSkillInstalled( + platform: Platform, + skillName: string, + project = false +): Promise { + const skillPath = getSkillPath(platform, skillName, project); + return fs.pathExists(skillPath); +} + +/** + * Detect which platform is being used in the current directory + */ +export async function detectPlatform(): Promise { + const cwd = process.cwd(); + + for (const platform of Object.keys(PLATFORM_PATHS) as Platform[]) { + const configPath = path.join(cwd, `.${platform}`); + if (await fs.pathExists(configPath)) { + return platform; + } + } + + // Check for common config files + if (await fs.pathExists(path.join(cwd, '.github'))) { + return 'copilot'; + } + + return null; +} + +/** + * Get config file path + */ +export function getConfigPath(): string { + const home = os.homedir(); + return path.join(home, '.skillhub', 'config.json'); +} + +/** + * Load CLI config + */ +export async function loadConfig(): Promise> { + const configPath = getConfigPath(); + + if (await fs.pathExists(configPath)) { + return fs.readJson(configPath); + } + + return {}; +} + +/** + * Save CLI config + */ +export async function saveConfig(config: Record): Promise { + const configPath = getConfigPath(); + await fs.ensureDir(path.dirname(configPath)); + await fs.writeJson(configPath, config, { spaces: 2 }); +} diff --git a/apps/cli/src/utils/platform-config.test.ts b/apps/cli/src/utils/platform-config.test.ts new file mode 100644 index 0000000..6e9117d --- /dev/null +++ b/apps/cli/src/utils/platform-config.test.ts @@ -0,0 +1,72 @@ +import { describe, it, expect, vi } from 'vitest'; + +vi.mock('fs-extra', () => ({ + default: { + pathExists: vi.fn(), + ensureDir: vi.fn(), + readJson: vi.fn(), + writeJson: vi.fn(), + }, +})); + +import { getSkillsPath, type Platform } from './paths.js'; + +const ALL_PLATFORMS: Platform[] = ['claude', 'codex', 'copilot', 'cursor', 'windsurf']; + +describe('Platform Configuration Completeness', () => { + describe('All platforms have path configuration', () => { + ALL_PLATFORMS.forEach((platform) => { + it(`should have user path for ${platform}`, () => { + const userPath = getSkillsPath(platform, false); + expect(userPath).toBeTruthy(); + }); + + it(`should have project path for ${platform}`, () => { + const projectPath = getSkillsPath(platform, true); + expect(projectPath).toBeTruthy(); + }); + }); + }); + + describe('Platform paths are unique', () => { + it('should have different user paths for each platform', () => { + const paths = ALL_PLATFORMS.map((p) => getSkillsPath(p, false)); + const uniquePaths = new Set(paths); + expect(uniquePaths.size).toBe(ALL_PLATFORMS.length); + }); + + it('should have different project paths for each platform', () => { + const paths = ALL_PLATFORMS.map((p) => getSkillsPath(p, true)); + const uniquePaths = new Set(paths); + expect(uniquePaths.size).toBe(ALL_PLATFORMS.length); + }); + }); + + describe('Platform paths match expected patterns', () => { + it('claude should use .claude/skills', () => { + expect(getSkillsPath('claude', false)).toContain('.claude'); + }); + + it('codex should use .codex/skills', () => { + expect(getSkillsPath('codex', false)).toContain('.codex'); + }); + + it('copilot should use .github/instructions', () => { + const p = getSkillsPath('copilot', false); + expect(p).toContain('.github'); + expect(p).toContain('instructions'); + }); + + it('cursor should use .cursor/rules', () => { + const p = getSkillsPath('cursor', false); + expect(p).toContain('.cursor'); + expect(p).toContain('rules'); + }); + + it('windsurf should use .windsurf/rules', () => { + const p = getSkillsPath('windsurf', false); + expect(p).toContain('.windsurf'); + expect(p).toContain('rules'); + }); + }); +}); diff --git a/apps/cli/src/utils/transform.test.ts b/apps/cli/src/utils/transform.test.ts new file mode 100644 index 0000000..6b8c463 --- /dev/null +++ b/apps/cli/src/utils/transform.test.ts @@ -0,0 +1,197 @@ +import { describe, it, expect } from 'vitest'; +import { getPlatformFileName, transformForPlatform, shouldKeepOriginal } from './transform.js'; +import type { ParsedSkill } from 'skillhub-core'; + +function makeParsed(overrides: Partial = {}, body = '# Test Skill\n\nThis is a test skill.\n\n## Usage\n\nUse this skill for testing.'): ParsedSkill { + return { + metadata: { + name: 'test-skill', + description: 'A comprehensive test skill', + version: '1.0.0', + ...overrides, + }, + content: body, + resources: { scripts: [], references: [], assets: [] }, + validation: { isValid: true, errors: [], warnings: [] }, + rawFrontmatter: { name: 'test-skill', description: 'A comprehensive test skill', version: '1.0.0', ...overrides }, + } as ParsedSkill; +} + +const RAW_SKILL_MD = `--- +name: test-skill +description: A comprehensive test skill +version: 1.0.0 +--- + +# Test Skill + +This is a test skill. + +## Usage + +Use this skill for testing. +`; + +describe('getPlatformFileName', () => { + it('returns SKILL.md for claude', () => { + expect(getPlatformFileName('claude', 'my-skill')).toBe('SKILL.md'); + }); + + it('returns SKILL.md for codex', () => { + expect(getPlatformFileName('codex', 'my-skill')).toBe('SKILL.md'); + }); + + it('returns .mdc for cursor', () => { + expect(getPlatformFileName('cursor', 'my-skill')).toBe('my-skill.mdc'); + }); + + it('returns .md for windsurf', () => { + expect(getPlatformFileName('windsurf', 'my-skill')).toBe('my-skill.md'); + }); + + it('returns .instructions.md for copilot', () => { + expect(getPlatformFileName('copilot', 'my-skill')).toBe('my-skill.instructions.md'); + }); +}); + +describe('shouldKeepOriginal', () => { + it('returns false for claude', () => { + expect(shouldKeepOriginal('claude')).toBe(false); + }); + + it('returns false for codex', () => { + expect(shouldKeepOriginal('codex')).toBe(false); + }); + + it('returns true for cursor', () => { + expect(shouldKeepOriginal('cursor')).toBe(true); + }); + + it('returns true for windsurf', () => { + expect(shouldKeepOriginal('windsurf')).toBe(true); + }); + + it('returns true for copilot', () => { + expect(shouldKeepOriginal('copilot')).toBe(true); + }); +}); + +describe('transformForPlatform', () => { + describe('claude', () => { + it('returns content unchanged', () => { + const parsed = makeParsed(); + const result = transformForPlatform('claude', RAW_SKILL_MD, parsed); + expect(result.content).toBe(RAW_SKILL_MD); + expect(result.warnings).toHaveLength(0); + }); + }); + + describe('codex', () => { + it('returns content unchanged', () => { + const parsed = makeParsed(); + const result = transformForPlatform('codex', RAW_SKILL_MD, parsed); + expect(result.content).toBe(RAW_SKILL_MD); + expect(result.warnings).toHaveLength(0); + }); + }); + + describe('cursor (MDC format)', () => { + it('produces MDC with description and alwaysApply', () => { + const parsed = makeParsed(); + const result = transformForPlatform('cursor', RAW_SKILL_MD, parsed); + expect(result.content).toContain('---'); + expect(result.content).toContain('description: A comprehensive test skill'); + expect(result.content).toContain('alwaysApply: true'); + expect(result.warnings).toHaveLength(0); + }); + + it('maps triggers.filePatterns to globs', () => { + const parsed = makeParsed({ + triggers: { filePatterns: ['*.tsx', '*.jsx'] }, + }); + const result = transformForPlatform('cursor', RAW_SKILL_MD, parsed); + expect(result.content).toContain('globs: *.tsx, *.jsx'); + expect(result.content).toContain('alwaysApply: false'); + }); + + it('sets alwaysApply true when no triggers', () => { + const parsed = makeParsed({ triggers: undefined }); + const result = transformForPlatform('cursor', RAW_SKILL_MD, parsed); + expect(result.content).toContain('alwaysApply: true'); + expect(result.content).not.toContain('globs:'); + }); + + it('strips original YAML frontmatter fields', () => { + const parsed = makeParsed(); + const result = transformForPlatform('cursor', RAW_SKILL_MD, parsed); + expect(result.content).not.toContain('version: 1.0.0'); + expect(result.content).not.toContain('name: test-skill'); + }); + + it('preserves markdown body', () => { + const parsed = makeParsed(); + const result = transformForPlatform('cursor', RAW_SKILL_MD, parsed); + expect(result.content).toContain('# Test Skill'); + expect(result.content).toContain('## Usage'); + }); + }); + + describe('windsurf', () => { + it('strips YAML frontmatter', () => { + const parsed = makeParsed(); + const result = transformForPlatform('windsurf', RAW_SKILL_MD, parsed); + expect(result.content).not.toContain('---'); + expect(result.content).not.toContain('version:'); + expect(result.content).toContain('# Test Skill'); + }); + + it('adds heading when body does not start with one', () => { + const parsed = makeParsed({}, 'Some content without heading.'); + const result = transformForPlatform('windsurf', RAW_SKILL_MD, parsed); + expect(result.content).toMatch(/^# test-skill\n/); + }); + + it('does not add heading when body starts with one', () => { + const parsed = makeParsed(); + const result = transformForPlatform('windsurf', RAW_SKILL_MD, parsed); + expect(result.content).not.toMatch(/^# test-skill\n/); + expect(result.content).toMatch(/^# Test Skill\n/); + }); + + it('truncates and warns when content exceeds 6K limit', () => { + const longBody = '# Long Skill\n\n' + 'Lorem ipsum dolor sit amet. '.repeat(300); + const parsed = makeParsed({}, longBody); + const result = transformForPlatform('windsurf', RAW_SKILL_MD, parsed); + expect(result.warnings.length).toBeGreaterThan(0); + expect(result.warnings[0]).toContain('6000'); + expect(result.content.length).toBeLessThanOrEqual(6000); + }); + + it('no warning when content is under limit', () => { + const parsed = makeParsed(); + const result = transformForPlatform('windsurf', RAW_SKILL_MD, parsed); + expect(result.warnings).toHaveLength(0); + }); + }); + + describe('copilot', () => { + it('strips YAML frontmatter', () => { + const parsed = makeParsed(); + const result = transformForPlatform('copilot', RAW_SKILL_MD, parsed); + expect(result.content).not.toContain('---'); + expect(result.content).toContain('# Test Skill'); + }); + + it('adds heading when body does not start with one', () => { + const parsed = makeParsed({}, 'Some content without heading.'); + const result = transformForPlatform('copilot', RAW_SKILL_MD, parsed); + expect(result.content).toMatch(/^# test-skill\n/); + }); + + it('returns no warnings', () => { + const parsed = makeParsed(); + const result = transformForPlatform('copilot', RAW_SKILL_MD, parsed); + expect(result.warnings).toHaveLength(0); + }); + }); +}); diff --git a/apps/cli/src/utils/transform.ts b/apps/cli/src/utils/transform.ts new file mode 100644 index 0000000..a8e462f --- /dev/null +++ b/apps/cli/src/utils/transform.ts @@ -0,0 +1,150 @@ +import type { Platform } from './paths.js'; +import type { ParsedSkill } from 'skillhub-core'; + +interface TransformResult { + content: string; + warnings: string[]; +} + +interface PlatformFileConfig { + getFileName: (skillName: string) => string; + keepOriginal: boolean; + transform: (rawSkillMd: string, parsed: ParsedSkill) => TransformResult; +} + +const WINDSURF_CHAR_LIMIT = 6000; + +const PLATFORM_FILE_CONFIGS: Record = { + claude: { + getFileName: () => 'SKILL.md', + keepOriginal: false, + transform: (raw) => ({ content: raw, warnings: [] }), + }, + codex: { + getFileName: () => 'SKILL.md', + keepOriginal: false, + transform: (raw) => ({ content: raw, warnings: [] }), + }, + cursor: { + getFileName: (skillName) => `${skillName}.mdc`, + keepOriginal: true, + transform: transformForCursor, + }, + windsurf: { + getFileName: (skillName) => `${skillName}.md`, + keepOriginal: true, + transform: transformForWindsurf, + }, + copilot: { + getFileName: (skillName) => `${skillName}.instructions.md`, + keepOriginal: true, + transform: transformForCopilot, + }, +}; + +/** + * Get the platform-specific output filename + */ +export function getPlatformFileName(platform: Platform, skillName: string): string { + return PLATFORM_FILE_CONFIGS[platform].getFileName(skillName); +} + +/** + * Transform SKILL.md content for a target platform + */ +export function transformForPlatform( + platform: Platform, + rawSkillMd: string, + parsed: ParsedSkill +): TransformResult { + return PLATFORM_FILE_CONFIGS[platform].transform(rawSkillMd, parsed); +} + +/** + * Whether the platform needs the original SKILL.md kept alongside + */ +export function shouldKeepOriginal(platform: Platform): boolean { + return PLATFORM_FILE_CONFIGS[platform].keepOriginal; +} + +/** + * Convert SKILL.md to Cursor's MDC format + * MDC uses its own frontmatter: description, globs, alwaysApply + */ +function transformForCursor(_raw: string, parsed: ParsedSkill): TransformResult { + const warnings: string[] = []; + const mdcFields: string[] = []; + + if (parsed.metadata.description) { + mdcFields.push(`description: ${parsed.metadata.description}`); + } + + const filePatterns = parsed.metadata.triggers?.filePatterns; + if (filePatterns && filePatterns.length > 0) { + mdcFields.push(`globs: ${filePatterns.join(', ')}`); + mdcFields.push('alwaysApply: false'); + } else { + mdcFields.push('alwaysApply: true'); + } + + const body = parsed.content.trim(); + const mdcContent = `---\n${mdcFields.join('\n')}\n---\n${body}\n`; + return { content: mdcContent, warnings }; +} + +/** + * Convert SKILL.md to Windsurf format: plain markdown, 6K char limit + */ +function transformForWindsurf(_raw: string, parsed: ParsedSkill): TransformResult { + const warnings: string[] = []; + let body = parsed.content.trim(); + + if (!body.startsWith('# ')) { + body = `# ${parsed.metadata.name}\n\n${body}`; + } + + if (body.length > WINDSURF_CHAR_LIMIT) { + warnings.push( + `Content exceeds Windsurf's ${WINDSURF_CHAR_LIMIT} character limit (${body.length} chars). Truncating.` + ); + body = truncateAtSectionBoundary(body, WINDSURF_CHAR_LIMIT); + } + + return { content: body + '\n', warnings }; +} + +/** + * Convert SKILL.md to Copilot format: plain markdown, no frontmatter + */ +function transformForCopilot(_raw: string, parsed: ParsedSkill): TransformResult { + let body = parsed.content.trim(); + + if (!body.startsWith('# ')) { + body = `# ${parsed.metadata.name}\n\n${body}`; + } + + return { content: body + '\n', warnings: [] }; +} + +function truncateAtSectionBoundary(content: string, limit: number): string { + const notice = '\n\n\n'; + const maxLen = limit - notice.length; + + if (content.length <= maxLen) return content; + + const truncated = content.slice(0, maxLen); + const lastHeading = truncated.lastIndexOf('\n## '); + const lastH1 = truncated.lastIndexOf('\n# '); + const cutPoint = Math.max(lastHeading, lastH1); + + if (cutPoint > 0) { + return truncated.slice(0, cutPoint) + notice; + } + + const lastParagraph = truncated.lastIndexOf('\n\n'); + if (lastParagraph > 0) { + return truncated.slice(0, lastParagraph) + notice; + } + + return truncated + notice; +} diff --git a/apps/cli/tsconfig.json b/apps/cli/tsconfig.json new file mode 100644 index 0000000..b813d42 --- /dev/null +++ b/apps/cli/tsconfig.json @@ -0,0 +1,9 @@ +{ + "extends": "../../tsconfig.base.json", + "compilerOptions": { + "outDir": "./dist", + "rootDir": "./src" + }, + "include": ["src/**/*"], + "exclude": ["node_modules", "dist"] +} diff --git a/apps/cli/tsup.config.ts b/apps/cli/tsup.config.ts new file mode 100644 index 0000000..974e8ed --- /dev/null +++ b/apps/cli/tsup.config.ts @@ -0,0 +1,16 @@ +import { defineConfig } from 'tsup'; + +export default defineConfig({ + entry: ['src/index.ts'], + format: ['esm'], + target: 'node20', + clean: true, + // Bundle skillhub-core into the CLI so the published package + // doesn't depend on a separate npm package that may be outdated + noExternal: ['skillhub-core'], + // gray-matter (dep of skillhub-core) uses CJS require('fs') which + // breaks in ESM bundles - provide a require shim for Node builtins + banner: { + js: `import { createRequire as __createRequire } from 'module'; const require = __createRequire(import.meta.url);`, + }, +}); diff --git a/apps/cli/vitest.config.ts b/apps/cli/vitest.config.ts new file mode 100644 index 0000000..d59e9fe --- /dev/null +++ b/apps/cli/vitest.config.ts @@ -0,0 +1,15 @@ +import { defineConfig } from 'vitest/config'; + +export default defineConfig({ + test: { + globals: true, + environment: 'node', + include: ['src/**/*.test.ts'], + coverage: { + provider: 'v8', + reporter: ['text', 'json', 'html'], + include: ['src/**/*.ts'], + exclude: ['src/**/*.test.ts', 'src/index.ts'], + }, + }, +}); diff --git a/apps/web/Dockerfile b/apps/web/Dockerfile new file mode 100644 index 0000000..849fe6f --- /dev/null +++ b/apps/web/Dockerfile @@ -0,0 +1,92 @@ +# Stage 1: Dependencies +FROM node:20-alpine AS deps +RUN npm install -g pnpm@9 + +WORKDIR /app + +# Copy workspace configuration and all package.json files +COPY package.json pnpm-lock.yaml pnpm-workspace.yaml turbo.json ./ +COPY apps/web/package.json ./apps/web/ +COPY packages/core/package.json ./packages/core/ +COPY packages/db/package.json ./packages/db/ +COPY packages/ui/package.json ./packages/ui/ + +# Install all dependencies +RUN pnpm install --frozen-lockfile + +# Stage 2: Builder +FROM node:20-alpine AS builder +RUN npm install -g pnpm@9 + +WORKDIR /app + +# Copy all source code first +COPY . . + +# Copy node_modules from deps stage (this overwrites the empty directories) +COPY --from=deps /app/node_modules ./node_modules +COPY --from=deps /app/apps/web/node_modules ./apps/web/node_modules +COPY --from=deps /app/packages/core/node_modules ./packages/core/node_modules +COPY --from=deps /app/packages/db/node_modules ./packages/db/node_modules +COPY --from=deps /app/packages/ui/node_modules ./packages/ui/node_modules + +# Set build-time environment variables +ENV NEXT_TELEMETRY_DISABLED=1 +ENV NODE_ENV=production + +# Build-time arguments for NEXT_PUBLIC_* variables +# These are embedded into the JavaScript bundle at build time +ARG NEXT_PUBLIC_APP_URL +ARG NEXT_PUBLIC_API_URL +ARG NEXT_PUBLIC_SENTRY_DSN +ARG NEXT_PUBLIC_SENTRY_RELEASE +ARG NEXT_PUBLIC_OPENPANEL_CLIENT_ID +ARG NEXT_PUBLIC_OPENPANEL_API_URL +ARG NEXT_PUBLIC_OPENPANEL_SCRIPT_URL +ARG NEXT_PUBLIC_UMAMI_WEBSITE_ID +ARG NEXT_PUBLIC_UMAMI_URL + +# Convert ARGs to ENVs so they're available during build +ENV NEXT_PUBLIC_APP_URL=$NEXT_PUBLIC_APP_URL +ENV NEXT_PUBLIC_API_URL=$NEXT_PUBLIC_API_URL +ENV NEXT_PUBLIC_SENTRY_DSN=$NEXT_PUBLIC_SENTRY_DSN +ENV NEXT_PUBLIC_SENTRY_RELEASE=$NEXT_PUBLIC_SENTRY_RELEASE +ENV NEXT_PUBLIC_OPENPANEL_CLIENT_ID=$NEXT_PUBLIC_OPENPANEL_CLIENT_ID +ENV NEXT_PUBLIC_OPENPANEL_API_URL=$NEXT_PUBLIC_OPENPANEL_API_URL +ENV NEXT_PUBLIC_OPENPANEL_SCRIPT_URL=$NEXT_PUBLIC_OPENPANEL_SCRIPT_URL +ENV NEXT_PUBLIC_UMAMI_WEBSITE_ID=$NEXT_PUBLIC_UMAMI_WEBSITE_ID +ENV NEXT_PUBLIC_UMAMI_URL=$NEXT_PUBLIC_UMAMI_URL + +# Build the application (turbo will build dependencies first) +RUN pnpm turbo run build --filter=@skillhub/web + +# Stage 3: Runner +FROM node:20-alpine AS runner +WORKDIR /app + +# Install curl for health checks +RUN apk add --no-cache curl + +ENV NODE_ENV=production +ENV NEXT_TELEMETRY_DISABLED=1 + +# Create non-root user +RUN addgroup --system --gid 1001 nodejs +RUN adduser --system --uid 1001 nextjs + +# Copy built application (monorepo standalone preserves directory structure) +COPY --from=builder /app/apps/web/public ./apps/web/public +COPY --from=builder --chown=nextjs:nodejs /app/apps/web/.next/standalone ./ +COPY --from=builder --chown=nextjs:nodejs /app/apps/web/.next/static ./apps/web/.next/static + +# Copy messages folder for internationalization (next-intl requires this at runtime) +COPY --from=builder --chown=nextjs:nodejs /app/apps/web/messages ./apps/web/messages + +USER nextjs + +EXPOSE 3000 + +ENV PORT=3000 +ENV HOSTNAME="0.0.0.0" + +CMD ["node", "apps/web/server.js"] diff --git a/apps/web/app/[locale]/about/page.tsx b/apps/web/app/[locale]/about/page.tsx new file mode 100644 index 0000000..a671700 --- /dev/null +++ b/apps/web/app/[locale]/about/page.tsx @@ -0,0 +1,69 @@ +import { getTranslations, setRequestLocale } from 'next-intl/server'; +import { Header } from '@/components/Header'; +import { Footer } from '@/components/Footer'; +import { Code2, Globe, Shield } from 'lucide-react'; + +export default async function AboutPage({ + params, +}: { + params: Promise<{ locale: string }>; +}) { + const { locale } = await params; + setRequestLocale(locale); + const t = await getTranslations('about'); + + const features = [ + { + icon: Code2, + title: t('features.openSource'), + description: t('features.openSourceDesc'), + }, + { + icon: Globe, + title: t('features.multiPlatform'), + description: t('features.multiPlatformDesc'), + }, + { + icon: Shield, + title: t('features.secure'), + description: t('features.secureDesc'), + }, + ]; + + return ( +
+
+
+
+
+

{t('title')}

+

{t('subtitle')}

+
+
+ +
+
+
+

{t('mission.title')}

+

{t('mission.description')}

+
+ +

{t('features.title')}

+
+ {features.map((feature, index) => ( +
+
+ +
+

{feature.title}

+

{feature.description}

+
+ ))} +
+
+
+
+
+
+ ); +} diff --git a/apps/web/app/[locale]/attribution/page.tsx b/apps/web/app/[locale]/attribution/page.tsx new file mode 100644 index 0000000..bcdb2ee --- /dev/null +++ b/apps/web/app/[locale]/attribution/page.tsx @@ -0,0 +1,311 @@ +import { getTranslations, setRequestLocale } from 'next-intl/server'; +import Link from 'next/link'; +import { Header } from '@/components/Header'; +import { Footer } from '@/components/Footer'; +import { Github, Heart, Users, Code, GitFork, ExternalLink, Database, Clock } from 'lucide-react'; + +export const dynamic = 'force-dynamic'; + +interface AttributionStats { + totalSkills: number; + totalContributors: number; + totalRepos: number; + awesomeLists: { + count: number; + totalRepos: number; + }; + forkNetworks: number; + licenseDistribution: Array<{ + license: string; + count: number; + percentage: number; + }>; + discoveryBySource: Array<{ + source: string; + count: number; + withSkills: number; + }>; + lastUpdated: string; +} + +async function getAttributionStats(): Promise { + try { + const baseUrl = process.env.NEXT_PUBLIC_API_URL || 'http://localhost:3000'; + const res = await fetch(`${baseUrl}/api/attribution`, { + next: { revalidate: 3600 }, // Cache for 1 hour + }); + if (!res.ok) return null; + return res.json(); + } catch { + return null; + } +} + +function formatNumber(num: number): string { + if (num >= 1000000) { + return `${(num / 1000000).toFixed(1)}M`; + } + if (num >= 1000) { + return `${(num / 1000).toFixed(1)}K`; + } + return num.toLocaleString(); +} + +function formatLicenseName(license: string, locale: string): string { + const licenseNames: Record = { + 'Unspecified': { en: 'Not Specified', fa: 'مشخص نشده' }, + 'NOASSERTION': { en: 'Not Declared', fa: 'اعلام نشده' }, + 'Complete terms in LICENSE.txt': { en: 'Custom License', fa: 'لایسنس سفارشی' }, + 'Proprietary. LICENSE.txt has complete terms': { en: 'Proprietary', fa: 'اختصاصی' }, + 'MIT license': { en: 'MIT', fa: 'MIT' }, + 'BSD-3-Clause license': { en: 'BSD-3-Clause', fa: 'BSD-3-Clause' }, + 'Unknown': { en: 'Unknown', fa: 'نامشخص' }, + }; + const names = licenseNames[license]; + return names ? names[locale as 'en' | 'fa'] || names.en : license; +} + +export default async function AttributionPage({ + params, +}: { + params: Promise<{ locale: string }>; +}) { + const { locale } = await params; + setRequestLocale(locale); + const t = await getTranslations('attribution'); + const stats = await getAttributionStats(); + + // Fallback data if API fails + const sources = [ + { + name: locale === 'fa' ? 'مهارت‌های ایندکس شده' : 'Indexed Skills', + icon: Database, + description: locale === 'fa' + ? 'مهارت‌های ایندکس شده در تمام پلتفرم‌ها' + : 'Skills indexed across all supported platforms', + count: stats ? formatNumber(stats.totalSkills) : '170K+', + }, + { + name: locale === 'fa' ? 'مخازن کشف شده' : 'Repositories Discovered', + icon: Github, + description: locale === 'fa' + ? 'مخازن عمومی GitHub اسکن شده برای مهارت‌ها' + : 'Public GitHub repositories scanned for skills', + count: stats ? formatNumber(stats.totalRepos) : '50K+', + }, + { + name: locale === 'fa' ? 'شبکه Fork‌ها' : 'Fork Networks', + icon: GitFork, + description: locale === 'fa' + ? 'شبکه Fork‌های مخازن معروف مهارت' + : 'Fork networks of popular skill repositories', + count: stats ? formatNumber(stats.forkNetworks) : '500+', + }, + { + name: locale === 'fa' ? 'مشارکت‌کنندگان' : 'Contributors', + icon: Users, + description: locale === 'fa' + ? 'توسعه‌دهندگانی که مهارت‌ها را ایجاد و نگهداری می‌کنند' + : 'Developers who create and maintain skills', + count: stats ? formatNumber(stats.totalContributors) : '6K+', + }, + ]; + + // Use real license data if available, otherwise fallback + const licenses = stats?.licenseDistribution.slice(0, 5).map((l) => ({ + name: formatLicenseName(l.license, locale), + percentage: l.percentage, + count: l.count, + })) || [ + { name: 'MIT', percentage: 65, count: 0 }, + { name: 'Apache 2.0', percentage: 20, count: 0 }, + { name: 'BSD', percentage: 8, count: 0 }, + { name: 'GPL', percentage: 5, count: 0 }, + { name: locale === 'fa' ? 'سایر' : 'Other', percentage: 2, count: 0 }, + ]; + + return ( +
+
+
+ {/* Hero Section */} +
+
+

{t('title')}

+

{t('subtitle')}

+ {stats && ( +
+ + + {locale === 'fa' ? 'آخرین به‌روزرسانی: ' : 'Last updated: '} + {new Date(stats.lastUpdated).toLocaleDateString(locale === 'fa' ? 'fa-IR' : 'en-US', { + year: 'numeric', + month: 'short', + day: 'numeric', + hour: '2-digit', + minute: '2-digit', + })} + +
+ )} +
+
+ + {/* Main Content */} +
+
+ {/* Sources Section */} +
+

+ {t('sources.title')} +

+
+ {sources.map((source) => { + const Icon = source.icon; + return ( +
+
+
+ +
+
+
+

{source.name}

+ + {source.count} + +
+

{source.description}

+
+
+
+ ); + })} +
+
+ + {/* License Compliance Section */} +
+

+ {t('licenses.title')} +

+
+

{t('licenses.description')}

+
+ {licenses.map((license) => ( +
+ {license.name} +
+
+
+ + {license.percentage}% + {stats && license.count > 0 && ( + ({formatNumber(license.count)}) + )} + +
+ ))} +
+
+
+ + {/* How It Works Section */} +
+

+ {t('howItWorks.title')} +

+
+
+
+
1
+

{t('howItWorks.step1')}

+
+
+
2
+

{t('howItWorks.step2')}

+
+
+
3
+

{t('howItWorks.step3')}

+
+
+
4
+

{t('howItWorks.step4')}

+
+
+
+
+ + {/* Special Thanks Section */} +
+

+ {t('thanks.title')} +

+
+
+ + {t('thanks.subtitle')} +
+
    +
  • + + + Anthropic + + - {locale === 'fa' ? 'استاندارد SKILL.md و Agent Skills' : 'SKILL.md and Agent Skills standard'} +
  • +
  • + + + anthropics/skills + + - {locale === 'fa' ? 'مخزن رسمی مهارت‌ها' : 'Official skills repository'} +
  • +
  • + + + {locale === 'fa' + ? 'OpenAI، GitHub، Cursor و Windsurf' + : 'OpenAI, GitHub, Cursor & Windsurf'} + + - {locale === 'fa' ? 'پلتفرم‌های پشتیبانی شده' : 'Supported platforms'} +
  • +
  • + + + {locale === 'fa' + ? `همه ${stats ? formatNumber(stats.totalContributors) : ''} مشارکت‌کنندگان متن‌باز` + : `All ${stats ? formatNumber(stats.totalContributors) : ''} open-source contributors`} + +
  • +
+
+
+ + {/* Your Rights Section */} +
+

+ {t('rights.title')} +

+
+

{t('rights.description')}

+ + {t('rights.claimLink')} + + +
+
+
+
+
+
+
+ ); +} diff --git a/apps/web/app/[locale]/browse/page.tsx b/apps/web/app/[locale]/browse/page.tsx new file mode 100644 index 0000000..c8ada34 --- /dev/null +++ b/apps/web/app/[locale]/browse/page.tsx @@ -0,0 +1,286 @@ +import { getTranslations, setRequestLocale } from 'next-intl/server'; +import { Header } from '@/components/Header'; +import { Footer } from '@/components/Footer'; +import { createDb, skillQueries, categoryQueries } from '@skillhub/db'; +import { BrowseFilters, SearchBar, Pagination, ActiveFilters, EmptyState } from '@/components/BrowseFilters'; +import { SkillCard } from '@/components/SkillCard'; +import { toPersianNumber } from '@/lib/format-number'; + +// Force dynamic rendering to fetch fresh data from database +export const dynamic = 'force-dynamic'; + +interface BrowsePageProps { + params: Promise<{ locale: string }>; + searchParams: Promise<{ + q?: string; + category?: string; + platform?: string; + format?: string; + sort?: string; + page?: string; + }>; +} + +// Get skills directly from database with filters - all filtering at database level +async function getSkills(params: { + q?: string; + platform?: string; + format?: string; + sort?: string; + page?: string; + category?: string; +}) { + try { + const db = createDb(); + const limit = 20; + const page = parseInt(params.page || '1'); + const offset = (page - 1) * limit; + + const sortMap: Record = { + 'stars': 'stars', + 'downloads': 'downloads', + 'recent': 'updated', + 'rating': 'rating', + 'lastDownloaded': 'lastDownloaded', + }; + + // Build filter options - push ALL filters to database level + const filterOptions = { + query: params.q, + category: params.category, + platform: params.platform && params.platform !== 'all' ? params.platform : undefined, + sourceFormat: params.format || 'skill.md', + sortBy: sortMap[params.sort || 'lastDownloaded'] || 'lastDownloaded', + sortOrder: 'desc' as const, + limit, + offset, + }; + + // Fetch paginated results directly from database + const skills = await skillQueries.search(db, filterOptions); + + // Get accurate total count for pagination + const total = await skillQueries.count(db, { + query: params.q, + category: params.category, + platform: params.platform && params.platform !== 'all' ? params.platform : undefined, + sourceFormat: params.format || 'skill.md', + }); + + const totalPages = Math.ceil(total / limit); + + return { + skills, + pagination: { total, page, totalPages }, + }; + } catch (error) { + console.error('Error fetching skills:', error); + return { skills: [], pagination: { total: 0, page: 1, totalPages: 1 } }; + } +} + +export default async function BrowsePage({ params, searchParams }: BrowsePageProps) { + const { locale } = await params; + setRequestLocale(locale); + const searchParamsResolved = await searchParams; + const t = await getTranslations('browse'); + const tCommon = await getTranslations('common'); + + const sortOptions = [ + { id: 'lastDownloaded', name: t('filters.sortOptions.lastDownloaded') }, + { id: 'downloads', name: t('filters.sortOptions.downloads') }, + { id: 'stars', name: t('filters.sortOptions.stars') }, + { id: 'recent', name: t('filters.sortOptions.recent') }, + { id: 'rating', name: t('filters.sortOptions.rating') }, + ]; + + // Fetch categories hierarchically for filter dropdown with translations + const tCategories = await getTranslations('categories'); + type HierarchicalCategory = { + id: string; + name: string; + slug: string; + skillCount: number; + children?: { id: string; name: string; slug: string; skillCount: number }[]; + }; + let categories: HierarchicalCategory[] = []; + try { + const db = createDb(); + const rawCategories = await categoryQueries.getHierarchical(db); + categories = rawCategories.map(parent => ({ + id: parent.id, + name: tCategories(`parents.${parent.slug}`) || parent.name, + slug: parent.slug, + skillCount: parent.skillCount ?? 0, + children: parent.children?.map(cat => ({ + id: cat.id, + name: tCategories(`names.${cat.slug}`) || cat.name, + slug: cat.slug, + skillCount: cat.skillCount ?? 0, + })), + })); + } catch (error) { + console.error('Error fetching categories:', error); + } + + const filterTranslations = { + category: t('filters.category') || 'Category', + allCategories: t('filters.allCategories') || 'All Categories', + sort: t('filters.sort'), + format: t('filters.format') || 'Format', + allFormats: t('filters.allFormats') || 'All Formats', + agentSkills: t('filters.agentSkills') || 'Agent Skills (SKILL.md)', + searching: t('searching') || 'Searching...', + viewFeatured: t('filters.viewFeatured') || 'View Featured Skills', + }; + + const paginationTranslations = { + previous: t('pagination.previous') || 'Previous', + next: t('pagination.next') || 'Next', + page: t('pagination.page') || 'Page', + of: t('pagination.of') || 'of', + }; + + const activeFiltersTranslations = { + search: t('activeFilters.search') || 'Search', + category: t('activeFilters.category') || 'Category', + sortBy: t('activeFilters.sortBy') || 'Sorted by', + clearAll: t('activeFilters.clearAll') || 'Clear all', + }; + + const emptyStateTranslations = { + noResults: t('noResults') || 'No skills found', + noResultsWithQuery: t('noResultsWithQuery') || 'No results for "{query}"', + tryDifferent: t('emptyState.tryDifferent') || 'Try different search terms or adjust your filters', + clearFilters: t('emptyState.clearFilters') || 'Clear filters', + browseAll: t('emptyState.browseAll') || 'Browse Featured Skills', + }; + + const searchPlaceholder = tCommon('search'); + + // Fetch skills from API with all filters + const { skills, pagination } = await getSkills(searchParamsResolved); + const limit = 20; + const startItem = (pagination.page - 1) * limit + 1; + const endItem = Math.min(pagination.page * limit, pagination.total); + + // Get category name for active filters display + const currentCategory = searchParamsResolved.category; + const currentSort = searchParamsResolved.sort || 'lastDownloaded'; + const currentFormat = searchParamsResolved.format || ''; + const hasActiveFilters = !!(searchParamsResolved.q || currentCategory || (currentSort && currentSort !== 'lastDownloaded') || currentFormat); + + // Find category name from hierarchical categories + let categoryName = ''; + if (currentCategory) { + for (const parent of categories) { + if (parent.id === currentCategory) { + categoryName = parent.name; + break; + } + const child = parent.children?.find(c => c.id === currentCategory); + if (child) { + categoryName = child.name; + break; + } + } + } + + // Find sort option name + const sortName = sortOptions.find(o => o.id === currentSort)?.name || ''; + + + return ( +
+
+ +
+ {/* Page Header */} +
+
+

+ {t('title')} +

+

+ {t('subtitle')} +

+
+
+ +
+
+ {/* Filters Sidebar - Client Component */} + + + {/* Skills Grid */} +
+ {/* Search Bar - Client Component */} + + + {/* Active Filters - Shows applied filters as removable chips */} + + + {/* Results count with range */} + {pagination.total > 0 && ( +

+ {t('resultsRange', { + start: locale === 'fa' ? toPersianNumber(startItem) : startItem, + end: locale === 'fa' ? toPersianNumber(endItem) : endItem, + total: locale === 'fa' ? toPersianNumber(pagination.total) : pagination.total + }) || `Showing ${startItem}-${endItem} of ${pagination.total} skills`} +

+ )} + + {/* Skills Grid or Empty State */} + {skills.length > 0 ? ( +
+ {skills.map((skill) => ( + + ))} +
+ ) : ( + + )} + + {/* Pagination */} + {skills.length > 0 && ( + + )} +
+
+
+
+ +
+
+ ); +} diff --git a/apps/web/app/[locale]/categories/page.tsx b/apps/web/app/[locale]/categories/page.tsx new file mode 100644 index 0000000..60fab49 --- /dev/null +++ b/apps/web/app/[locale]/categories/page.tsx @@ -0,0 +1,176 @@ +import { getTranslations, setRequestLocale } from 'next-intl/server'; +import { Header } from '@/components/Header'; +import { Footer } from '@/components/Footer'; +import Link from 'next/link'; +import { + Brain, + Bot, + Sparkles, + Monitor, + Server, + Cloud, + Database, + GitBranch, + CheckCircle, + Shield, + FileText, + PenTool, + Smartphone, + Layers, + Code, + Code2, + Package, + StickyNote, + Home, + Music, + MessageCircle, + Briefcase, + Calculator, + Coins, + type LucideIcon, +} from 'lucide-react'; +import { createDb, categoryQueries } from '@skillhub/db'; +import { formatNumber } from '@/lib/format-number'; + +// Force dynamic rendering to fetch fresh data from database +export const dynamic = 'force-dynamic'; + +// Map category slugs to Lucide icons (23 categories + 7 parents) +const iconMap: Record = { + // Original 16 categories + 'ai-llm': Brain, + 'git-version-control': GitBranch, + 'data-database': Database, + 'backend-apis': Server, + 'frontend-ui': Monitor, + 'agents-orchestration': Bot, + 'testing-qa': CheckCircle, + 'devops-cloud': Cloud, + 'programming-languages': Code, + 'documents-files': FileText, + 'security-auth': Shield, + 'mcp-skills': Layers, + 'prompts-instructions': Sparkles, + 'content-writing': PenTool, + 'mobile-development': Smartphone, + 'other-utilities': Package, + + // New categories from Phase 1 + 'productivity-notes': StickyNote, + 'smart-home-iot': Home, + 'multimedia-audio-video': Music, + 'social-communications': MessageCircle, + 'business-finance': Briefcase, + 'science-mathematics': Calculator, + 'blockchain-web3': Coins, + + // Parent categories from Phase 2 + 'development': Code2, + 'ai-automation': Brain, + 'data-documents': Database, + 'devops-security': Cloud, + 'business-productivity': Briefcase, + 'media-iot': Music, + 'specialized': Sparkles, +}; + +// Color map for parent categories +const parentColorMap: Record = { + 'development': 'bg-blue-50 text-blue-600 dark:bg-blue-950 dark:text-blue-400', + 'ai-automation': 'bg-purple-50 text-purple-600 dark:bg-purple-950 dark:text-purple-400', + 'data-documents': 'bg-emerald-50 text-emerald-600 dark:bg-emerald-950 dark:text-emerald-400', + 'devops-security': 'bg-orange-50 text-orange-600 dark:bg-orange-950 dark:text-orange-400', + 'business-productivity': 'bg-green-50 text-green-600 dark:bg-green-950 dark:text-green-400', + 'media-iot': 'bg-rose-50 text-rose-600 dark:bg-rose-950 dark:text-rose-400', + 'specialized': 'bg-gray-50 text-gray-600 dark:bg-gray-800 dark:text-gray-400', +}; + +// Get categories hierarchically from database +async function getHierarchicalCategories() { + try { + const db = createDb(); + return await categoryQueries.getHierarchical(db); + } catch (error) { + console.error('Error fetching categories:', error); + return []; + } +} + +export default async function CategoriesPage({ + params, +}: { + params: Promise<{ locale: string }>; +}) { + const { locale } = await params; + setRequestLocale(locale); + const t = await getTranslations('categories'); + + const hierarchicalCategories = await getHierarchicalCategories(); + + return ( +
+
+
+
+
+

{t('title')}

+

{t('subtitle')}

+
+
+ +
+
+ {hierarchicalCategories.map((parent) => { + const ParentIcon = iconMap[parent.slug] || Code; + const colorClass = parentColorMap[parent.slug] || 'bg-primary-50 text-primary-600'; + + return ( +
+ {/* Parent Section Header */} +
+
+ +
+
+

+ {t(`parents.${parent.slug}`) || parent.name} +

+

{parent.description}

+
+
+ + {/* Child Categories Grid */} +
+ {parent.children?.map((category) => { + const IconComponent = iconMap[category.slug] || Code; + return ( + +
+ +
+
+

+ {t(`names.${category.slug}`) || category.name} +

+

+ {formatNumber(category.skillCount || 0, locale)} {t('skillCount')} +

+
+ + ); + })} +
+
+ ); + })} +
+
+
+
+
+ ); +} diff --git a/apps/web/app/[locale]/claim/claim.test.ts b/apps/web/app/[locale]/claim/claim.test.ts new file mode 100644 index 0000000..69c57ea --- /dev/null +++ b/apps/web/app/[locale]/claim/claim.test.ts @@ -0,0 +1,190 @@ +import { describe, it, expect } from 'vitest'; + +/** + * Claim Form Validation Tests + * + * These tests validate the URL validation logic used in the claim form. + * Full component tests require React Testing Library setup with jsdom environment. + */ + +// Validate GitHub URL format (duplicated from ClaimForm for testing) +function isValidGitHubUrl(url: string): boolean { + try { + const urlObj = new URL(url); + return urlObj.hostname === 'github.com' && urlObj.pathname.split('/').filter(Boolean).length >= 2; + } catch { + return false; + } +} + +describe('Claim Form - URL Validation', () => { + describe('isValidGitHubUrl', () => { + it('should accept valid GitHub repository URL', () => { + expect(isValidGitHubUrl('https://github.com/owner/repo')).toBe(true); + }); + + it('should accept valid GitHub repository URL with trailing slash', () => { + expect(isValidGitHubUrl('https://github.com/owner/repo/')).toBe(true); + }); + + it('should accept valid GitHub repository URL with tree path', () => { + expect(isValidGitHubUrl('https://github.com/owner/repo/tree/main/path')).toBe(true); + }); + + it('should reject non-GitHub URLs', () => { + expect(isValidGitHubUrl('https://example.com/owner/repo')).toBe(false); + }); + + it('should reject GitHub URL without owner', () => { + expect(isValidGitHubUrl('https://github.com/owner')).toBe(false); + }); + + it('should reject GitHub homepage', () => { + expect(isValidGitHubUrl('https://github.com')).toBe(false); + }); + + it('should reject invalid URLs', () => { + expect(isValidGitHubUrl('not a url')).toBe(false); + }); + + it('should reject empty string', () => { + expect(isValidGitHubUrl('')).toBe(false); + }); + + it('should reject relative paths', () => { + expect(isValidGitHubUrl('/owner/repo')).toBe(false); + }); + + it('should reject GitHub gist URLs', () => { + expect(isValidGitHubUrl('https://gist.github.com/user/abc123')).toBe(false); + }); + }); +}); + +describe('Claim Form - Error Code Handling', () => { + it('should map RATE_LIMIT_EXCEEDED error code correctly', () => { + const errorCodes = { + 'RATE_LIMIT_EXCEEDED': 'GitHub API rate limit exceeded. Please try again in a few minutes.', + 'INVALID_REPO': 'The repository was not found or is not accessible.', + 'NETWORK_TIMEOUT': 'Request timed out while checking the repository.', + 'INVALID_URL': 'Please enter a valid GitHub repository URL.', + }; + + expect(errorCodes['RATE_LIMIT_EXCEEDED']).toBe('GitHub API rate limit exceeded. Please try again in a few minutes.'); + }); + + it('should have distinct error messages for each error code', () => { + const errorCodes = [ + 'RATE_LIMIT_EXCEEDED', + 'INVALID_REPO', + 'NETWORK_TIMEOUT', + 'INVALID_URL', + 'ALREADY_PENDING', + ]; + + // All error codes should be unique + expect(new Set(errorCodes).size).toBe(errorCodes.length); + }); +}); + +describe('Claim Form - API Response Scenarios', () => { + describe('Add Request Success Scenarios', () => { + it('should handle single skill found', () => { + const response = { + success: true, + hasSkillMd: true, + skillCount: 1, + skillPaths: ['skills/my-skill'], + }; + + expect(response.skillCount).toBe(1); + expect(response.hasSkillMd).toBe(true); + }); + + it('should handle multiple skills found', () => { + const response = { + success: true, + hasSkillMd: true, + skillCount: 3, + skillPaths: ['skills/skill1', 'skills/skill2', 'skills/skill3'], + }; + + expect(response.skillCount).toBe(3); + expect(response.skillPaths.length).toBe(3); + }); + + it('should handle no skills found', () => { + const response = { + success: true, + hasSkillMd: false, + skillCount: 0, + skillPaths: [], + }; + + expect(response.skillCount).toBe(0); + expect(response.hasSkillMd).toBe(false); + expect(response.skillPaths.length).toBe(0); + }); + }); + + describe('Add Request Error Scenarios', () => { + it('should handle rate limit error', () => { + const errorResponse = { + error: 'GitHub API rate limit exceeded', + code: 'RATE_LIMIT_EXCEEDED', + }; + + expect(errorResponse.code).toBe('RATE_LIMIT_EXCEEDED'); + }); + + it('should handle invalid repository error', () => { + const errorResponse = { + error: 'Repository not found', + code: 'INVALID_REPO', + }; + + expect(errorResponse.code).toBe('INVALID_REPO'); + }); + + it('should handle network timeout error', () => { + const errorResponse = { + error: 'Request timed out', + code: 'NETWORK_TIMEOUT', + }; + + expect(errorResponse.code).toBe('NETWORK_TIMEOUT'); + }); + }); +}); + +describe('Repository Validation - Error Message Clarity', () => { + it('should provide specific error for 404 (not found)', () => { + const error = 'Repository not found. Please check the URL and ensure the repository exists.'; + expect(error).toContain('not found'); + expect(error).toContain('check the URL'); + }); + + it('should provide specific error for rate limit', () => { + const error = 'GitHub API rate limit exceeded. Please try again later.'; + expect(error).toContain('rate limit'); + expect(error).toContain('try again'); + }); + + it('should provide specific error for private repository', () => { + const error = 'Repository is private or you do not have access. Please ensure the repository is public.'; + expect(error).toContain('private'); + expect(error).toContain('public'); + }); + + it('should provide specific error for timeout', () => { + const error = 'Request timed out while checking repository. Please try again.'; + expect(error).toContain('timed out'); + expect(error).toContain('try again'); + }); + + it('should provide generic error for network issues', () => { + const error = 'Network error while verifying repository. Please check your connection and try again.'; + expect(error).toContain('Network error'); + expect(error).toContain('connection'); + }); +}); diff --git a/apps/web/app/[locale]/claim/page.tsx b/apps/web/app/[locale]/claim/page.tsx new file mode 100644 index 0000000..a3fb861 --- /dev/null +++ b/apps/web/app/[locale]/claim/page.tsx @@ -0,0 +1,115 @@ +import { getTranslations, setRequestLocale } from 'next-intl/server'; +import { Header } from '@/components/Header'; +import { Footer } from '@/components/Footer'; +import { ClaimForm } from '@/components/ClaimForm'; + +export const dynamic = 'force-dynamic'; + +export default async function ClaimPage({ + params, +}: { + params: Promise<{ locale: string }>; +}) { + const { locale } = await params; + setRequestLocale(locale); + const t = await getTranslations('claim'); + + const translations = { + title: t('title'), + subtitle: t('subtitle'), + loginRequired: t('loginRequired'), + signIn: t('signIn'), + optional: t('optional'), + mirror: { + title: t('mirror.title'), + description: t('mirror.description'), + button: t('mirror.button'), + }, + tabs: { + remove: t('tabs.remove'), + add: t('tabs.add'), + }, + form: { + skillId: t('form.skillId'), + skillIdPlaceholder: t('form.skillIdPlaceholder'), + skillIdHelp: t('form.skillIdHelp'), + reason: t('form.reason'), + reasonPlaceholder: t('form.reasonPlaceholder'), + submit: t('form.submit'), + submitting: t('form.submitting'), + }, + addForm: { + repositoryUrl: t('addForm.repositoryUrl'), + repositoryUrlPlaceholder: t('addForm.repositoryUrlPlaceholder'), + repositoryUrlHelp: t('addForm.repositoryUrlHelp'), + reason: t('addForm.reason'), + reasonPlaceholder: t('addForm.reasonPlaceholder'), + submit: t('addForm.submit'), + submitting: t('addForm.submitting'), + }, + success: { + title: t('success.title'), + description: t('success.description'), + viewRequests: t('success.viewRequests'), + }, + addSuccess: { + title: t('addSuccess.title'), + description: t('addSuccess.description'), + descriptionNoSkillMd: t('addSuccess.descriptionNoSkillMd'), + descriptionMultiplePrefix: t('addSuccess.descriptionMultiplePrefix'), + descriptionMultipleSuffix: t('addSuccess.descriptionMultipleSuffix'), + viewRequests: t('addSuccess.viewRequests'), + foundSkillsIn: t('addSuccess.foundSkillsIn'), + root: t('addSuccess.root'), + andMore: t.raw('addSuccess.andMore') as string, + }, + error: { + notOwner: t('error.notOwner'), + skillNotFound: t('error.skillNotFound'), + alreadyPending: t('error.alreadyPending'), + githubError: t('error.githubError'), + invalidSkill: t('error.invalidSkill'), + invalidUrl: t('error.invalidUrl'), + invalidRepo: t('error.invalidRepo'), + rateLimitExceeded: t('error.rateLimitExceeded'), + networkTimeout: t('error.networkTimeout'), + generic: t('error.generic'), + }, + myRequests: { + title: t('myRequests.title'), + empty: t('myRequests.empty'), + status: { + pending: t('myRequests.status.pending'), + approved: t('myRequests.status.approved'), + rejected: t('myRequests.status.rejected'), + indexed: t('myRequests.status.indexed'), + }, + skillsFoundPrefix: t('myRequests.skillsFoundPrefix'), + skillsFoundSuffix: t('myRequests.skillsFoundSuffix'), + showLess: t('myRequests.showLess'), + showAllPrefix: t('myRequests.showAllPrefix'), + showAllSuffix: t('myRequests.showAllSuffix'), + }, + }; + + return ( +
+
+
+
+
+

{translations.title}

+

{translations.subtitle}

+
+
+ +
+
+ +
+
+
+
+
+ ); +} diff --git a/apps/web/app/[locale]/claude-plugin/page.tsx b/apps/web/app/[locale]/claude-plugin/page.tsx new file mode 100644 index 0000000..4279253 --- /dev/null +++ b/apps/web/app/[locale]/claude-plugin/page.tsx @@ -0,0 +1,285 @@ +import { getTranslations, setRequestLocale } from 'next-intl/server'; +import Link from 'next/link'; +import type { Metadata } from 'next'; +import { + Zap, + Search, + Shield, + ArrowRight, + ArrowLeft, + Sparkles, + Terminal, + Clock, + Star, +} from 'lucide-react'; +import { Header } from '@/components/Header'; +import { Footer } from '@/components/Footer'; +import { EarlyAccessForm } from '@/components/EarlyAccessForm'; +import { createDb, skills, sql } from '@skillhub/db'; +import { formatCompactNumber } from '@/lib/format-number'; + +export const dynamic = 'force-dynamic'; + +export async function generateMetadata({ + params, +}: { + params: Promise<{ locale: string }>; +}): Promise { + const { locale } = await params; + const t = await getTranslations({ locale, namespace: 'claudePlugin' }); + + return { + title: t('metadata.title'), + description: t('metadata.description'), + openGraph: { + title: t('metadata.title'), + description: t('metadata.description'), + }, + }; +} + +async function getStats() { + try { + const db = createDb(); + const skillsResult = await db + .select({ count: sql`count(*)::int` }) + .from(skills); + return skillsResult[0]?.count ?? 0; + } catch { + return 119000; + } +} + +export default async function ClaudePluginPage({ + params, + searchParams, +}: { + params: Promise<{ locale: string }>; + searchParams: Promise<{ variant?: string }>; +}) { + const { locale } = await params; + const { variant } = await searchParams; + setRequestLocale(locale); + + const t = await getTranslations('claudePlugin'); + const isRTL = locale === 'fa'; + const ArrowIcon = isRTL ? ArrowLeft : ArrowRight; + + const totalSkills = await getStats(); + + // A/B Test: variant A (default) = "Get early access", variant B = "Help us prioritize" + const isVariantB = variant === 'b'; + + const benefits = [ + { + icon: Search, + title: t('benefits.discovery.title'), + description: t('benefits.discovery.description'), + }, + { + icon: Terminal, + title: t('benefits.install.title'), + description: t('benefits.install.description'), + }, + { + icon: Zap, + title: t('benefits.instant.title'), + description: t('benefits.instant.description'), + }, + { + icon: Shield, + title: t('benefits.secure.title'), + description: t('benefits.secure.description'), + }, + ]; + + const stats = [ + { + value: formatCompactNumber(totalSkills, locale), + label: t('stats.skills'), + icon: Sparkles, + }, + { + value: '5+', + label: t('stats.platforms'), + icon: Terminal, + }, + { + value: '4.9', + label: t('stats.rating'), + icon: Star, + }, + ]; + + return ( +
+
+ +
+ {/* Hero Section */} +
+ {/* Background decoration */} +
+
+
+
+ +
+
+ {/* Badge */} +
+ + {t('hero.badge')} +
+ + {/* Title */} +

+ {t('hero.title')} +

+ + {/* Subtitle */} +

+ {isVariantB ? t('hero.subtitleB') : t('hero.subtitleA', { count: formatCompactNumber(totalSkills, locale) })} +

+ + {/* Stats */} +
+ {stats.map((stat, i) => ( +
+
+ + {stat.value} +
+
{stat.label}
+
+ ))} +
+ + {/* Email Signup Form */} +
+ +
+
+
+
+ + {/* Benefits Section */} +
+
+

+ {t('benefits.title')} +

+ +
+ {benefits.map((benefit, i) => ( +
+
+ +
+

+ {benefit.title} +

+

{benefit.description}

+
+ ))} +
+
+
+ + {/* How It Works Preview */} +
+
+

+ {t('howItWorks.title')} +

+

+ {t('howItWorks.subtitle')} +

+ +
+ {/* Code example */} +
+
# {t('howItWorks.example.comment')}
+
+ User: {t('howItWorks.example.userMessage')} +
+
# {t('howItWorks.example.claudeComment')}
+
+ Claude:{' '} + search_skills( + query= + "pdf") +
+
+ {t('howItWorks.example.result')} +
+
+
+
+
+ + {/* FAQ Section */} +
+
+

+ {t('faq.title')} +

+ +
+ {[1, 2, 3].map((i) => ( +
+ +

+ {t(`faq.q${i}.question`)} +

+ +
+

{t(`faq.q${i}.answer`)}

+
+ ))} +
+
+
+ + {/* CTA Section */} +
+
+
+

+ {t('cta.title')} +

+

+ {t('cta.description', { count: formatCompactNumber(totalSkills, locale) })} +

+
+ + {t('cta.browseSkills')} + + + + {t('cta.learnMore')} + +
+
+
+
+
+ +
+
+ ); +} diff --git a/apps/web/app/[locale]/contact/page.tsx b/apps/web/app/[locale]/contact/page.tsx new file mode 100644 index 0000000..804d671 --- /dev/null +++ b/apps/web/app/[locale]/contact/page.tsx @@ -0,0 +1,10 @@ +import { redirect } from 'next/navigation'; + +export default async function ContactPage({ + params, +}: { + params: Promise<{ locale: string }>; +}) { + const { locale } = await params; + redirect(`/${locale}/support`); +} diff --git a/apps/web/app/[locale]/docs/api/page.tsx b/apps/web/app/[locale]/docs/api/page.tsx new file mode 100644 index 0000000..b4b7b29 --- /dev/null +++ b/apps/web/app/[locale]/docs/api/page.tsx @@ -0,0 +1,556 @@ +import { getTranslations, setRequestLocale } from 'next-intl/server'; +import { Header } from '@/components/Header'; +import { Footer } from '@/components/Footer'; +import { ApiEndpointSection } from '@/components/ApiEndpointSection'; +import type { EndpointDef } from '@/components/ApiEndpointSection'; +import Link from 'next/link'; +import { ArrowLeft, ArrowRight, Search, FileCode, Users, Compass, Mail } from 'lucide-react'; + +export default async function ApiDocsPage({ + params, +}: { + params: Promise<{ locale: string }>; +}) { + const { locale } = await params; + setRequestLocale(locale); + const t = await getTranslations('docs'); + const isRTL = locale === 'fa'; + const ArrowIcon = isRTL ? ArrowLeft : ArrowRight; + const siteUrl = process.env.NEXT_PUBLIC_APP_URL || 'https://skills.palebluedot.live'; + + const labels = { + parameters: t('api.labels.parameters'), + requestBody: t('api.labels.requestBody'), + required: t('api.labels.required'), + optional: t('api.labels.optional'), + default: t('api.labels.default'), + responseExample: t('api.labels.responseExample'), + rateLimit: t('api.labels.rateLimit'), + cache: t('api.labels.cache'), + authRequired: t('api.labels.authRequired'), + notes: t('api.labels.notes'), + }; + + // --- Section 1: Skills --- + const skillsEndpoints: EndpointDef[] = [ + { + method: 'GET', + path: '/api/skills', + description: t('api.endpoints.searchSkills'), + auth: false, + rateLimit: '60 req/min', + cacheTTL: '5 min', + params: [ + { name: 'q', type: 'string', required: false, description: t('api.params.q') }, + { name: 'category', type: 'string', required: false, description: t('api.params.category') }, + { name: 'platform', type: 'string', required: false, description: t('api.params.platform') + ' (claude, codex, copilot, cursor, windsurf)' }, + { name: 'format', type: 'string', required: false, description: t('api.params.format'), default: 'skill.md' }, + { name: 'verified', type: 'boolean', required: false, description: t('api.params.verified') }, + { name: 'minStars', type: 'number', required: false, description: t('api.params.minStars') }, + { name: 'sort', type: 'string', required: false, description: t('api.params.sort') + ' (stars, downloads, rating, recent)', default: 'downloads' }, + { name: 'page', type: 'number', required: false, description: t('api.params.page'), default: '1' }, + { name: 'limit', type: 'number', required: false, description: t('api.params.limit'), default: '20' }, + ], + responseExample: `{ + "skills": [ + { + "id": "anthropic/skills/code-review", + "name": "code-review", + "description": "AI-powered code review assistant", + "githubOwner": "anthropic", + "githubRepo": "skills", + "githubStars": 1234, + "downloadCount": 567, + "securityStatus": "PASS", + "rating": 4.5, + "ratingCount": 23, + "isVerified": true, + "compatibility": { "platforms": ["claude", "cursor"] } + } + ], + "pagination": { + "page": 1, + "limit": 20, + "total": 150, + "totalPages": 8 + }, + "searchEngine": "meilisearch" +}`, + notes: t('api.notes.searchFallback'), + }, + { + method: 'GET', + path: '/api/skills/:id', + description: t('api.endpoints.getSkill'), + auth: false, + rateLimit: '120 req/min', + responseExample: `{ + "id": "anthropic/skills/code-review", + "name": "code-review", + "description": "AI-powered code review assistant", + "githubOwner": "anthropic", + "githubRepo": "skills", + "skillPath": "code-review", + "branch": "main", + "version": "1.0.0", + "license": "MIT", + "githubStars": 1234, + "downloadCount": 567, + "securityScore": 95, + "securityStatus": "PASS", + "isVerified": true, + "compatibility": { "platforms": ["claude"] }, + "rawContent": "# Code Review\\n...", + "sourceFormat": "skill.md" +}`, + notes: t('api.notes.viewCount'), + }, + { + method: 'GET', + path: '/api/skills/featured', + description: t('api.endpoints.featuredSkills'), + auth: false, + rateLimit: '120 req/min', + cacheTTL: '2 hours', + params: [ + { name: 'limit', type: 'number', required: false, description: t('api.params.limitNum'), default: '6' }, + ], + }, + { + method: 'GET', + path: '/api/skills/recent', + description: t('api.endpoints.recentSkills'), + auth: false, + rateLimit: '120 req/min', + cacheTTL: '1 hour', + params: [ + { name: 'limit', type: 'number', required: false, description: t('api.params.limitNum'), default: '10' }, + ], + }, + { + method: 'POST', + path: '/api/skills/install', + description: t('api.endpoints.trackInstall'), + auth: false, + bodyParams: [ + { name: 'skillId', type: 'string', required: true, description: t('api.params.skillId') }, + { name: 'platform', type: 'string', required: false, description: t('api.params.platform') }, + { name: 'method', type: 'string', required: false, description: t('api.params.method') }, + ], + responseExample: `{ "success": true, "skillId": "owner/repo/skill", "platform": "claude", "method": "cli" }`, + notes: t('api.notes.installDedup'), + }, + { + method: 'POST', + path: '/api/skills/add-request', + description: t('api.endpoints.addRequest'), + auth: true, + bodyParams: [ + { name: 'repositoryUrl', type: 'string', required: true, description: t('api.params.repositoryUrl') }, + { name: 'reason', type: 'string', required: false, description: t('api.params.reason') }, + ], + responseExample: `{ "success": true, "requestId": "uuid", "hasSkillMd": true, "skillCount": 3 }`, + }, + { + method: 'POST', + path: '/api/skills/removal-request', + description: t('api.endpoints.removalRequest'), + auth: true, + bodyParams: [ + { name: 'skillId', type: 'string', required: true, description: t('api.params.skillId') }, + { name: 'reason', type: 'string', required: false, description: t('api.params.reason') }, + ], + responseExample: `{ "success": true, "requestId": "uuid", "blocked": true }`, + }, + ]; + + // --- Section 2: Skill Files --- + const skillFilesEndpoints: EndpointDef[] = [ + { + method: 'GET', + path: '/api/skill-files', + description: t('api.endpoints.getSkillFiles'), + auth: false, + rateLimit: '60 req/min', + params: [ + { name: 'id', type: 'string', required: true, description: t('api.params.skillId') }, + ], + responseExample: `{ + "skillId": "owner/repo/skill", + "githubOwner": "owner", + "githubRepo": "repo", + "skillPath": "skill", + "branch": "main", + "files": [ + { + "name": "SKILL.md", + "path": "SKILL.md", + "type": "file", + "size": 2048, + "content": "# Skill Name\\n..." + } + ], + "fromCache": true, + "cachedAt": "2026-01-15T10:30:00Z" +}`, + notes: t('api.notes.cacheInDB'), + }, + { + method: 'GET', + path: '/api/skill-files/zip', + description: t('api.endpoints.downloadZip'), + auth: false, + rateLimit: '60 req/min', + params: [ + { name: 'id', type: 'string', required: true, description: t('api.params.skillId') }, + { name: 'platform', type: 'string', required: false, description: t('api.params.platformZip'), default: 'claude' }, + ], + notes: t('api.notes.zipTransform'), + }, + ]; + + // --- Section 3: User Actions --- + const userActionsEndpoints: EndpointDef[] = [ + { + method: 'GET', + path: '/api/ratings', + description: t('api.endpoints.getRatings'), + auth: false, + rateLimit: '120 req/min', + params: [ + { name: 'skillId', type: 'string', required: true, description: t('api.params.skillId') }, + { name: 'limit', type: 'number', required: false, description: t('api.params.limitNum'), default: '10' }, + { name: 'offset', type: 'number', required: false, description: t('api.params.offset'), default: '0' }, + ], + responseExample: `{ + "ratings": [ + { + "id": "uuid", + "rating": 5, + "review": "Great skill!", + "createdAt": "2026-01-10T00:00:00Z", + "user": { "id": "uid", "username": "user1", "avatarUrl": "https://..." } + } + ], + "summary": { "average": 4.5, "count": 23 } +}`, + }, + { + method: 'POST', + path: '/api/ratings', + description: t('api.endpoints.createRating'), + auth: true, + rateLimit: '600 req/min', + bodyParams: [ + { name: 'skillId', type: 'string', required: true, description: t('api.params.skillId') }, + { name: 'rating', type: 'number', required: true, description: t('api.params.rating') }, + { name: 'review', type: 'string', required: false, description: t('api.params.review') }, + ], + responseExample: `{ "rating": { "id": "uuid", "rating": 5, "review": "..." }, "summary": { "average": 4.5, "count": 24 } }`, + }, + { + method: 'GET', + path: '/api/ratings/me', + description: t('api.endpoints.getMyRating'), + auth: true, + rateLimit: '600 req/min', + params: [ + { name: 'skillId', type: 'string', required: true, description: t('api.params.skillId') }, + ], + responseExample: `{ "rating": { "id": "uuid", "rating": 5, "review": "Great!" } }`, + }, + { + method: 'GET', + path: '/api/favorites', + description: t('api.endpoints.getFavorites'), + auth: true, + rateLimit: '600 req/min', + responseExample: `{ + "favorites": [ + { + "id": "owner/repo/skill", + "name": "skill-name", + "description": "...", + "githubStars": 100, + "downloadCount": 50, + "securityStatus": "PASS", + "isVerified": true, + "rating": 4.5, + "ratingCount": 10 + } + ] +}`, + }, + { + method: 'POST', + path: '/api/favorites', + description: t('api.endpoints.addFavorite'), + auth: true, + rateLimit: '600 req/min', + bodyParams: [ + { name: 'skillId', type: 'string', required: true, description: t('api.params.skillId') }, + ], + responseExample: `{ "success": true, "favorited": true }`, + }, + { + method: 'DELETE', + path: '/api/favorites', + description: t('api.endpoints.removeFavorite'), + auth: true, + rateLimit: '600 req/min', + bodyParams: [ + { name: 'skillId', type: 'string', required: true, description: t('api.params.skillId') }, + ], + responseExample: `{ "success": true, "favorited": false }`, + }, + { + method: 'POST', + path: '/api/favorites/check', + description: t('api.endpoints.checkFavorites'), + auth: true, + rateLimit: '600 req/min', + bodyParams: [ + { name: 'skillIds', type: 'string[]', required: true, description: t('api.params.skillIds') }, + ], + responseExample: `{ "favorited": { "owner/repo/skill-a": true, "owner/repo/skill-b": false } }`, + }, + ]; + + // --- Section 4: Discovery & Metadata --- + const discoveryEndpoints: EndpointDef[] = [ + { + method: 'GET', + path: '/api/categories', + description: t('api.endpoints.getCategories'), + auth: false, + rateLimit: '120 req/min', + cacheTTL: '12 hours', + responseExample: `{ + "categories": [ + { + "id": "cat-id", + "name": "Code Quality", + "slug": "code-quality", + "description": "...", + "icon": "🔍", + "skillCount": 15, + "sortOrder": 1 + } + ] +}`, + }, + { + method: 'GET', + path: '/api/stats', + description: t('api.endpoints.getStats'), + auth: false, + rateLimit: '120 req/min', + cacheTTL: '1 hour', + responseExample: `{ + "totalSkills": 850, + "totalDownloads": 12500, + "totalCategories": 23, + "totalContributors": 320, + "platforms": 5 +}`, + }, + { + method: 'GET', + path: '/api/health', + description: t('api.endpoints.healthCheck'), + auth: false, + responseExample: `{ + "status": "healthy", + "timestamp": "2026-02-11T12:00:00Z", + "version": "0.2.4", + "isPrimary": true, + "services": { + "database": { "status": "healthy", "latency": 5 }, + "meilisearch": { "status": "healthy", "latency": 12 }, + "redis": { "status": "healthy", "latency": 3 } + } +}`, + }, + { + method: 'GET', + path: '/api/attribution', + description: t('api.endpoints.getAttribution'), + auth: false, + rateLimit: '120 req/min', + cacheTTL: '1 hour', + responseExample: `{ + "totalSkills": 850, + "totalContributors": 320, + "totalRepos": 150, + "licenseDistribution": [ + { "license": "MIT", "count": 50, "percentage": 50 } + ], + "lastUpdated": "2026-02-11T00:00:00Z" +}`, + }, + ]; + + // --- Section 5: Newsletter --- + const newsletterEndpoints: EndpointDef[] = [ + { + method: 'POST', + path: '/api/newsletter/subscribe', + description: t('api.endpoints.subscribe'), + auth: false, + rateLimit: '60 req/min', + bodyParams: [ + { name: 'email', type: 'string', required: true, description: t('api.params.email') }, + { name: 'source', type: 'string', required: false, description: t('api.params.source') }, + { name: 'locale', type: 'string', required: false, description: t('api.params.locale'), default: 'en' }, + ], + responseExample: `{ "success": true, "subscribed": true }`, + }, + { + method: 'POST', + path: '/api/newsletter/unsubscribe', + description: t('api.endpoints.unsubscribe'), + auth: false, + rateLimit: '60 req/min', + bodyParams: [ + { name: 'email', type: 'string', required: true, description: t('api.params.email') }, + ], + responseExample: `{ "success": true, "unsubscribed": true }`, + notes: t('api.notes.alwaysSuccess'), + }, + ]; + + const sections = [ + { title: t('api.sections.skills.title'), description: t('api.sections.skills.description'), icon: Search, endpoints: skillsEndpoints }, + { title: t('api.sections.skillFiles.title'), description: t('api.sections.skillFiles.description'), icon: FileCode, endpoints: skillFilesEndpoints }, + { title: t('api.sections.userActions.title'), description: t('api.sections.userActions.description'), icon: Users, endpoints: userActionsEndpoints }, + { title: t('api.sections.discovery.title'), description: t('api.sections.discovery.description'), icon: Compass, endpoints: discoveryEndpoints }, + { title: t('api.sections.newsletter.title'), description: t('api.sections.newsletter.description'), icon: Mail, endpoints: newsletterEndpoints }, + ]; + + return ( +
+
+
+
+
+
+ + + {t('title')} + + +

{t('api.title')}

+

{t('api.description')}

+ + {/* Base URL */} +
+

{t('api.baseUrl')}

+
+ {siteUrl} +
+
+ + {/* Authentication */} +
+

{t('api.authSection.title')}

+

{t('api.authSection.description')}

+

{t('api.authSection.howTo')}

+
+ + {/* Rate Limiting */} +
+

{t('api.rateLimitSection.title')}

+

{t('api.rateLimitSection.description')}

+
+ + + + + + + + + + + + + + + + + + + + + + + + + +
{t('api.rateLimitSection.tier')}{t('api.rateLimitSection.limit')}{t('api.rateLimitSection.use')}
{t('api.rateLimitSection.anonymous')}120 req/min{t('api.rateLimitSection.anonymousUse')}
{t('api.rateLimitSection.search')}60 req/min{t('api.rateLimitSection.searchUse')}
{t('api.rateLimitSection.authenticated')}600 req/min{t('api.rateLimitSection.authenticatedUse')}
+
+
+

{t('api.rateLimitSection.headersTitle')}

+
+

X-RateLimit-Limit — {t('api.rateLimitSection.headerLimit')}

+

X-RateLimit-Remaining — {t('api.rateLimitSection.headerRemaining')}

+

X-RateLimit-Reset — {t('api.rateLimitSection.headerReset')}

+
+
+
+ + {/* Error Responses */} +
+

{t('api.errorSection.title')}

+

{t('api.errorSection.description')}

+
+
{`{
+  "error": "Too Many Requests",
+  "message": "Rate limit exceeded",
+  "retryAfter": 45,
+  "limit": 60
+}`}
+
+
+

{t('api.errorSection.commonCodes')}

+
+

400 — {t('api.errorSection.code400')}

+

401 — {t('api.errorSection.code401')}

+

404 — {t('api.errorSection.code404')}

+

429 — {t('api.errorSection.code429')}

+

500 — {t('api.errorSection.code500')}

+
+
+
+ + {/* Quick example */} +
+

Quick Example

+
+ {`curl "${siteUrl}/api/skills?q=code-review&limit=5"`} +
+
+ +
+ + {/* Endpoint Sections */} + {sections.map((section, index) => ( + + ))} +
+
+
+
+
+
+ ); +} diff --git a/apps/web/app/[locale]/docs/cli/page.tsx b/apps/web/app/[locale]/docs/cli/page.tsx new file mode 100644 index 0000000..6382ee6 --- /dev/null +++ b/apps/web/app/[locale]/docs/cli/page.tsx @@ -0,0 +1,152 @@ +import { getTranslations, setRequestLocale } from 'next-intl/server'; +import { Header } from '@/components/Header'; +import { Footer } from '@/components/Footer'; +import Link from 'next/link'; +import { ArrowLeft, ArrowRight } from 'lucide-react'; + +export default async function CliDocsPage({ + params, +}: { + params: Promise<{ locale: string }>; +}) { + const { locale } = await params; + setRequestLocale(locale); + const t = await getTranslations('docs'); + const tContent = await getTranslations('docs.content'); + const isRTL = locale === 'fa'; + const ArrowIcon = isRTL ? ArrowLeft : ArrowRight; + + const commands = [ + { name: 'install ', desc: tContent('cmdInstallDesc') }, + { name: 'search ', desc: tContent('cmdSearchDesc') }, + { name: 'list', desc: tContent('cmdListDesc') }, + { name: 'update [skill-name]', desc: tContent('cmdUpdateDesc') }, + { name: 'uninstall ', desc: tContent('cmdUninstallDesc') }, + { name: 'config', desc: tContent('cmdConfigDesc') }, + ]; + + const platforms = [ + { name: 'Claude', path: tContent('platformClaudePath') }, + { name: 'Codex', path: tContent('platformCodexPath') }, + { name: 'Copilot', path: tContent('platformCopilotPath') }, + { name: 'Cursor', path: tContent('platformCursorPath') }, + { name: 'Windsurf', path: tContent('platformWindsurfPath') }, + ]; + + const configKeys = [ + { key: 'defaultPlatform', desc: tContent('configDefaultPlatform') }, + { key: 'apiUrl', desc: tContent('configApiUrl') }, + { key: 'githubToken', desc: tContent('configGithubToken') }, + ]; + + return ( +
+
+
+
+
+
+ + + {t('title')} + + +

{t('cli.title')}

+

{t('cli.description')}

+ +
+

{tContent('installation')}

+
+ npm install -g skillhub +
+ +

{tContent('commands')}

+
+ {commands.map((cmd, index) => ( +
+ skillhub {cmd.name} +

{cmd.desc}

+
+ ))} +
+ +

{tContent('examples')}

+
+ {/* Install globally */} +
+

{tContent('exInstallGlobal')}

+ + $ npx skillhub install anthropics/skills/pdf + + + ✓ Skill installed to ~/.claude/skills/pdf/ + +
+ {/* Install in project */} +
+

{tContent('exInstallProject')}

+ + $ npx skillhub install anthropics/skills/pdf --project + + + ✓ Skill installed to ./.claude/skills/pdf/ + +
+ {/* Search */} +
+

{tContent('exSearch')}

+ + $ npx skillhub search pdf + +
+ {/* Search with sort */} +
+

{tContent('exSearchSort')}

+ + $ npx skillhub search "code review" --sort stars --limit 5 + +
+ {/* Update all */} +
+

{tContent('exUpdateAll')}

+ + $ npx skillhub update --all + +
+
+ + {/* Supported Platforms */} +

{tContent('platformsTitle')}

+

{tContent('platformsDesc')}

+
+ {platforms.map((p) => ( +
+ {p.name} + {p.path} +
+ ))} +
+ + {/* Configuration */} +

{tContent('configTitle')}

+

{tContent('configDesc')}

+
+ {configKeys.map((c) => ( +
+ {c.key} +

{c.desc}

+
+ ))} +
+
+
+
+
+
+
+
+ ); +} diff --git a/apps/web/app/[locale]/docs/getting-started/page.tsx b/apps/web/app/[locale]/docs/getting-started/page.tsx new file mode 100644 index 0000000..09d711e --- /dev/null +++ b/apps/web/app/[locale]/docs/getting-started/page.tsx @@ -0,0 +1,206 @@ +import { getTranslations, setRequestLocale } from 'next-intl/server'; +import { Header } from '@/components/Header'; +import { Footer } from '@/components/Footer'; +import { DiscoveryModes } from '@/components/DiscoveryModes'; +import Link from 'next/link'; +import { ArrowLeft, ArrowRight } from 'lucide-react'; +import { createDb, skills, sql } from '@skillhub/db'; +import { formatPromptSkillCount } from '@/lib/format-number'; + +export const dynamic = 'force-dynamic'; + +async function getSkillCount(): Promise { + try { + const db = createDb(); + const result = await db + .select({ count: sql`count(*)::int` }) + .from(skills); + return formatPromptSkillCount(result[0]?.count ?? 170000); + } catch { + return '170,000+'; + } +} + +function getDiscoveryPrompts(count: string) { + const siteUrl = process.env.NEXT_PUBLIC_APP_URL || 'https://skills.palebluedot.live'; + return { + standard: `## SkillHub - AI Agent Skills + +${count} skills available at ${siteUrl} + +Search when working with unfamiliar tech, complex workflows, or after a failed attempt: + +\`\`\`bash +npx skillhub search "" --limit 5 +npx skillhub install --project +npx skillhub uninstall # when done +\`\`\` + +Rules: +- Only install skills with security status "Pass" +- After installing, read the SKILL.md and follow its instructions +- Search during planning phase for best results`, + + compact: `## SkillHub (${count} skills) -- ${siteUrl} + +\`\`\`bash +npx skillhub search "" --limit 5 +npx skillhub install --project +npx skillhub uninstall +\`\`\` + +Search for unfamiliar tech or complex tasks. Only install "Pass" security status. Read SKILL.md after install.`, + + oneshot: `You have access to SkillHub (${count} AI agent skills, ${siteUrl}). For unfamiliar or complex tasks: \`npx skillhub search "" --limit 5\` then \`npx skillhub install --project\`. Only "Pass" security. Read SKILL.md after install. Uninstall when done.`, + }; +} + +export default async function GettingStartedPage({ + params, +}: { + params: Promise<{ locale: string }>; +}) { + const { locale } = await params; + setRequestLocale(locale); + const t = await getTranslations('docs'); + const tContent = await getTranslations('docs.content'); + const isRTL = locale === 'fa'; + const ArrowIcon = isRTL ? ArrowLeft : ArrowRight; + + const skillCount = await getSkillCount(); + const prompts = getDiscoveryPrompts(skillCount); + + return ( +
+
+
+
+
+
+ + + {t('title')} + + +

{t('gettingStarted.title')}

+

{t('gettingStarted.description')}

+ +
+

{tContent('installation')}

+

{tContent('installCli')}

+
+ npm install -g skillhub +
+ +

{tContent('usage')}

+ +

{tContent('searchSkills')}

+
+ skillhub search code-review +
+ +

{tContent('installSkill')}

+
+ skillhub install anthropic/skills/code-review +
+ +

{tContent('listInstalled')}

+
+ skillhub list +
+ + {/* Web-based Installation */} +
+

{tContent('webInstallTitle')}

+

{tContent('webInstallDesc')}

+
+

{tContent('webInstallStep1')}

+

{tContent('webInstallStep2')}

+

{tContent('webInstallStep3')}

+
    +
  • {tContent('webInstallMethodFolder')}
  • +
  • {tContent('webInstallMethodZip')}
  • +
+
+ + {/* Dynamic Skill Discovery */} +
+

{tContent('discoveryTitle')}

+

{tContent('discoveryIntro')}

+ +

{tContent('discoveryModesTitle')}

+

{tContent('selectMode')}

+ + +

{tContent('crossPlatformTitle')}

+

{tContent('crossPlatformDesc')}

+
+
{tContent('platformClaude')}
+
{tContent('platformCodex')}
+
{tContent('platformCopilot')}
+
{tContent('platformCursor')}
+
{tContent('platformWindsurf')}
+
+ +

+ {tContent('fullDocsLink')}{' '} + + CLI Reference + +

+
+
+
+
+
+
+
+ ); +} diff --git a/apps/web/app/[locale]/docs/page.tsx b/apps/web/app/[locale]/docs/page.tsx new file mode 100644 index 0000000..4818113 --- /dev/null +++ b/apps/web/app/[locale]/docs/page.tsx @@ -0,0 +1,71 @@ +import { getTranslations, setRequestLocale } from 'next-intl/server'; +import { Header } from '@/components/Header'; +import { Footer } from '@/components/Footer'; +import { BookOpen, Terminal, Code } from 'lucide-react'; +import Link from 'next/link'; + +export default async function DocsPage({ + params, +}: { + params: Promise<{ locale: string }>; +}) { + const { locale } = await params; + setRequestLocale(locale); + const t = await getTranslations('docs'); + + const docs = [ + { + icon: BookOpen, + title: t('gettingStarted.title'), + description: t('gettingStarted.description'), + href: `/${locale}/docs/getting-started`, + }, + { + icon: Terminal, + title: t('cli.title'), + description: t('cli.description'), + href: `/${locale}/docs/cli`, + }, + { + icon: Code, + title: t('api.title'), + description: t('api.description'), + href: `/${locale}/docs/api`, + }, + ]; + + return ( +
+
+
+
+
+

{t('title')}

+

{t('subtitle')}

+
+
+ +
+
+
+ {docs.map((doc, index) => ( + +
+ +
+

{doc.title}

+

{doc.description}

+ + ))} +
+
+
+
+
+
+ ); +} diff --git a/apps/web/app/[locale]/favorites/page.tsx b/apps/web/app/[locale]/favorites/page.tsx new file mode 100644 index 0000000..93f6603 --- /dev/null +++ b/apps/web/app/[locale]/favorites/page.tsx @@ -0,0 +1,76 @@ +import { getTranslations, setRequestLocale } from 'next-intl/server'; +import { Heart } from 'lucide-react'; +import { auth } from '@/lib/auth'; +import { Header } from '@/components/Header'; +import { Footer } from '@/components/Footer'; +import { FavoritesList } from '@/components/FavoritesList'; +import { FavoritesSignIn } from '@/components/FavoritesSignIn'; +import { createDb, userQueries } from '@skillhub/db'; + +// Force dynamic rendering +export const dynamic = 'force-dynamic'; + +interface FavoritesPageProps { + params: Promise<{ locale: string }>; +} + +export default async function FavoritesPage({ params }: FavoritesPageProps) { + const { locale } = await params; + setRequestLocale(locale); + const t = await getTranslations('favorites'); + const tCommon = await getTranslations('common'); + + const session = await auth(); + + const isLoggedIn = !!session?.user?.githubId; + + // Get favorites only if logged in + let favorites: Awaited> = []; + if (isLoggedIn) { + const db = createDb(); + const dbUser = await userQueries.getByGithubId(db, session.user.githubId!); + favorites = dbUser ? await userQueries.getFavorites(db, dbUser.id) : []; + } + + return ( +
+
+ +
+
+
+
+ +

{t('title')}

+
+

{t('subtitle')}

+
+
+ +
+ {isLoggedIn ? ( + + ) : ( + + )} +
+
+ +
+
+ ); +} diff --git a/apps/web/app/[locale]/featured/page.tsx b/apps/web/app/[locale]/featured/page.tsx new file mode 100644 index 0000000..7f48c83 --- /dev/null +++ b/apps/web/app/[locale]/featured/page.tsx @@ -0,0 +1,112 @@ +import { getTranslations, setRequestLocale } from 'next-intl/server'; +import { Header } from '@/components/Header'; +import { Footer } from '@/components/Footer'; +import { createDb, skillQueries } from '@skillhub/db'; +import { toPersianNumber } from '@/lib/format-number'; +import { Pagination } from '@/components/BrowseFilters'; +import { SkillCard } from '@/components/SkillCard'; + +// Force dynamic rendering to fetch fresh data from database +export const dynamic = 'force-dynamic'; + +interface FeaturedPageProps { + params: Promise<{ locale: string }>; + searchParams: Promise<{ page?: string }>; +} + +// Get featured skills with pagination +async function getFeaturedSkills(page: number, limit: number) { + try { + const db = createDb(); + const offset = (page - 1) * limit; + + // Try featured first, fall back to combined popularity score + let featuredSkills = await skillQueries.getFeatured(db, limit, offset); + let total = await skillQueries.countFeatured(db); + + // If no featured skills, use adaptive popularity with owner/repo diversity + if (total === 0) { + featuredSkills = await skillQueries.getFeaturedWithDiversity(db, limit, 2, 3); + total = await skillQueries.countAll(db); + } + + return { skills: featuredSkills, total }; + } catch (error) { + console.error('Error fetching featured skills:', error); + return { skills: [], total: 0 }; + } +} + +export default async function FeaturedPage({ + params, + searchParams, +}: FeaturedPageProps) { + const { locale } = await params; + const searchParamsResolved = await searchParams; + setRequestLocale(locale); + const t = await getTranslations('featured'); + const tBrowse = await getTranslations('browse'); + + const limit = 12; + const page = parseInt(searchParamsResolved.page || '1'); + const { skills: featuredSkills, total } = await getFeaturedSkills(page, limit); + const totalPages = Math.ceil(total / limit); + + const startItem = (page - 1) * limit + 1; + const endItem = Math.min(page * limit, total); + + const paginationTranslations = { + previous: tBrowse('pagination.previous'), + next: tBrowse('pagination.next'), + page: tBrowse('pagination.page'), + of: tBrowse('pagination.of'), + }; + + return ( +
+
+
+
+
+

{t('title')}

+

{t('subtitle')}

+
+
+ +
+
+ {/* Results count */} + {total > 0 && ( +

+ {tBrowse('resultsRange', { + start: locale === 'fa' ? toPersianNumber(startItem) : startItem, + end: locale === 'fa' ? toPersianNumber(endItem) : endItem, + total: locale === 'fa' ? toPersianNumber(total) : total + })} +

+ )} + +
+ {featuredSkills.map((skill) => ( + + ))} +
+ + {/* Pagination */} + +
+
+
+
+
+ ); +} diff --git a/apps/web/app/[locale]/layout.tsx b/apps/web/app/[locale]/layout.tsx new file mode 100644 index 0000000..8d73b20 --- /dev/null +++ b/apps/web/app/[locale]/layout.tsx @@ -0,0 +1,79 @@ +import type { Metadata } from 'next'; +import { NextIntlClientProvider } from 'next-intl'; +import { getMessages, getTranslations, setRequestLocale } from 'next-intl/server'; +import { notFound } from 'next/navigation'; +import { locales, localeDirection, type Locale } from '@/i18n'; +import { Providers } from '../providers'; +import { Suspense } from 'react'; +import { QueryNotification } from '@/components/QueryNotification'; +import '../globals.css'; + +export async function generateMetadata({ + params, +}: { + params: Promise<{ locale: string }>; +}): Promise { + const { locale } = await params; + const t = await getTranslations({ locale, namespace: 'metadata' }); + + const primaryDomain = process.env.NEXT_PUBLIC_APP_URL || 'https://skills.palebluedot.live'; + + return { + title: { + default: t('title'), + template: `%s | SkillHub`, + }, + description: t('description'), + keywords: ['AI', 'Agent', 'Skills', 'Claude', 'Codex', 'Copilot', 'Marketplace'], + authors: [{ name: 'SkillHub' }], + icons: { + icon: '/logo.svg', + shortcut: '/logo.svg', + apple: '/logo.svg', + }, + openGraph: { + title: t('title'), + description: t('description'), + type: 'website', + locale: locale === 'fa' ? 'fa_IR' : 'en_US', + url: `${primaryDomain}/${locale}`, + }, + }; +} + +export function generateStaticParams() { + return locales.map((locale) => ({ locale })); +} + +export default async function LocaleLayout({ + children, + params, +}: { + children: React.ReactNode; + params: Promise<{ locale: string }>; +}) { + const { locale } = await params; + if (!locales.includes(locale as Locale)) { + notFound(); + } + + setRequestLocale(locale); + + const messages = await getMessages(); + const dir = localeDirection[locale as Locale]; + + return ( + + + + + + + + {children} + + + + + ); +} diff --git a/apps/web/app/[locale]/new/page.tsx b/apps/web/app/[locale]/new/page.tsx new file mode 100644 index 0000000..f4e21bc --- /dev/null +++ b/apps/web/app/[locale]/new/page.tsx @@ -0,0 +1,193 @@ +import { getTranslations, setRequestLocale } from 'next-intl/server'; +import { Header } from '@/components/Header'; +import { Footer } from '@/components/Footer'; +import { NewSkillsTabs } from '@/components/NewSkillsTabs'; +import { SkillCard } from '@/components/SkillCard'; +import { Clock, RefreshCw } from 'lucide-react'; +import { createDb, skillQueries } from '@skillhub/db'; +import { toPersianNumber } from '@/lib/format-number'; +import { Pagination } from '@/components/BrowseFilters'; + +// Force dynamic rendering to fetch fresh data from database +export const dynamic = 'force-dynamic'; + +interface NewSkillsPageProps { + params: Promise<{ locale: string }>; + searchParams: Promise<{ page?: string; tab?: string }>; +} + +// Format date to "X hours/days ago" with locale support +function formatTimeAgo(date: Date | null, locale: string): string { + if (!date) return locale === 'fa' ? 'اخیراً' : 'Recently'; + const now = new Date(); + const diffMs = now.getTime() - date.getTime(); + const diffHours = Math.floor(diffMs / (1000 * 60 * 60)); + const diffDays = Math.floor(diffHours / 24); + + if (locale === 'fa') { + if (diffDays > 0) return `${toPersianNumber(diffDays)} روز پیش`; + if (diffHours > 0) return `${toPersianNumber(diffHours)} ساعت پیش`; + return 'همین الان'; + } + + if (diffDays > 0) { + return `${diffDays} day${diffDays > 1 ? 's' : ''} ago`; + } + if (diffHours > 0) { + return `${diffHours} hour${diffHours > 1 ? 's' : ''} ago`; + } + return 'Just now'; +} + +// Get skills based on tab with pagination +async function getSkillsForTab(tab: 'new' | 'updated', page: number, limit: number) { + try { + const db = createDb(); + const offset = (page - 1) * limit; + + if (tab === 'new') { + const skills = await skillQueries.getNewSkills(db, limit, offset); + const total = await skillQueries.countNewSkills(db); + return { skills, total }; + } else { + const skills = await skillQueries.getUpdatedSkills(db, limit, offset); + const total = await skillQueries.countUpdatedSkills(db); + return { skills, total }; + } + } catch (error) { + console.error('Error fetching skills:', error); + return { skills: [], total: 0 }; + } +} + +// Get counts for both tabs +async function getTabCounts() { + try { + const db = createDb(); + const [newCount, updatedCount] = await Promise.all([ + skillQueries.countNewSkills(db), + skillQueries.countUpdatedSkills(db), + ]); + return { newCount, updatedCount }; + } catch (error) { + console.error('Error fetching counts:', error); + return { newCount: 0, updatedCount: 0 }; + } +} + +export default async function NewSkillsPage({ + params, + searchParams, +}: NewSkillsPageProps) { + const { locale } = await params; + const searchParamsResolved = await searchParams; + setRequestLocale(locale); + const t = await getTranslations('new'); + const tBrowse = await getTranslations('browse'); + + const limit = 12; + const page = parseInt(searchParamsResolved.page || '1'); + const tab = (searchParamsResolved.tab as 'new' | 'updated') || 'new'; + + // Fetch data in parallel + const [{ skills, total }, { newCount, updatedCount }] = await Promise.all([ + getSkillsForTab(tab, page, limit), + getTabCounts(), + ]); + + const totalPages = Math.ceil(total / limit); + + const startItem = total > 0 ? (page - 1) * limit + 1 : 0; + const endItem = Math.min(page * limit, total); + + const paginationTranslations = { + previous: tBrowse('pagination.previous'), + next: tBrowse('pagination.next'), + page: tBrowse('pagination.page'), + of: tBrowse('pagination.of'), + }; + + const tabsTranslations = { + new: t('tabs.new'), + updated: t('tabs.updated'), + newDescription: t('newDescription'), + updatedDescription: t('updatedDescription'), + }; + + const noSkillsMessage = tab === 'new' ? t('noNewSkills') : t('noUpdatedSkills'); + + return ( +
+
+
+
+
+

{t('title')}

+

{t('subtitle')}

+
+
+ +
+
+ {/* Tabs Component */} + + + {/* Results count */} + {total > 0 && ( +

+ {tBrowse('resultsRange', { + start: locale === 'fa' ? toPersianNumber(startItem) : startItem, + end: locale === 'fa' ? toPersianNumber(endItem) : endItem, + total: locale === 'fa' ? toPersianNumber(total) : total + })} +

+ )} + + {/* Skills Grid */} + {skills.length > 0 ? ( +
+ {skills.map((skill) => ( + + ))} +
+ ) : ( +
+
+ {tab === 'new' ? ( + + ) : ( + + )} +
+

{noSkillsMessage}

+
+ )} + + {/* Pagination */} + {totalPages > 1 && ( + + )} +
+
+
+
+
+ ); +} diff --git a/apps/web/app/[locale]/owner/[username]/page.tsx b/apps/web/app/[locale]/owner/[username]/page.tsx new file mode 100644 index 0000000..52c49d3 --- /dev/null +++ b/apps/web/app/[locale]/owner/[username]/page.tsx @@ -0,0 +1,307 @@ +import { getTranslations, setRequestLocale } from 'next-intl/server'; +import { Header } from '@/components/Header'; +import { Footer } from '@/components/Footer'; +import { SkillCard } from '@/components/SkillCard'; +import { Pagination } from '@/components/BrowseFilters'; +import { createDb, skillQueries } from '@skillhub/db'; +import { formatCompactNumber, toPersianNumber } from '@/lib/format-number'; +import { ExternalLink, Download, Package, Eye, GitFork, ArrowUpDown, FolderGit2 } from 'lucide-react'; +import Link from 'next/link'; + +export const dynamic = 'force-dynamic'; + +const ITEMS_PER_PAGE = 24; + +type SortOption = 'popularity' | 'downloads' | 'stars'; + +interface OwnerPageProps { + params: Promise<{ locale: string; username: string }>; + searchParams: Promise<{ page?: string; sort?: string; repo?: string }>; +} + +export default async function OwnerPage({ params, searchParams }: OwnerPageProps) { + const { locale, username: rawUsername } = await params; + const searchParamsResolved = await searchParams; + setRequestLocale(locale); + + const t = await getTranslations('owner'); + const tBrowse = await getTranslations('browse'); + const username = decodeURIComponent(rawUsername); + const page = Math.max(1, parseInt(searchParamsResolved.page || '1')); + const sort = (['popularity', 'downloads', 'stars'].includes(searchParamsResolved.sort || '') + ? searchParamsResolved.sort + : 'popularity') as SortOption; + const activeRepo = searchParamsResolved.repo || ''; + + const db = createDb(); + + // Fetch stats, count, and repo list in parallel + const [stats, totalSkills, ownerRepos] = await Promise.all([ + skillQueries.getOwnerStats(db, username), + skillQueries.countByOwner(db, username, activeRepo || undefined), + skillQueries.getOwnerRepos(db, username), + ]); + + if (stats.totalSkills === 0) { + return ( +
+
+
+

{t('notFound')}

+

+ {t('notFoundDescription', { username })} +

+ + {t('browseAll')} + +
+
+
+ ); + } + + const offset = (page - 1) * ITEMS_PER_PAGE; + const totalPages = Math.ceil(totalSkills / ITEMS_PER_PAGE); + + const skills = await skillQueries.getByOwner(db, username, { + limit: ITEMS_PER_PAGE, + offset, + sortBy: sort, + repo: activeRepo || undefined, + }); + + // Group fetched skills by repo for display + const repoMap = new Map(); + + for (const skill of skills) { + const repo = skill.githubRepo; + if (!repoMap.has(repo)) { + repoMap.set(repo, { + name: repo, + stars: skill.githubStars ?? 0, + skills: [], + }); + } + repoMap.get(repo)!.skills.push(skill); + } + + const repos = Array.from(repoMap.values()); + + const formatNum = (n: number) => + locale === 'fa' ? toPersianNumber(formatCompactNumber(n, locale)) : formatCompactNumber(n, locale); + + const startItem = offset + 1; + const endItem = Math.min(offset + ITEMS_PER_PAGE, totalSkills); + + const paginationTranslations = { + previous: tBrowse('pagination.previous'), + next: tBrowse('pagination.next'), + page: tBrowse('pagination.page'), + of: tBrowse('pagination.of'), + }; + + const sortOptions: { value: SortOption; label: string }[] = [ + { value: 'popularity', label: t('sort.popularity') }, + { value: 'downloads', label: t('sort.downloads') }, + { value: 'stars', label: t('sort.stars') }, + ]; + + // Build URL helper preserving sort/repo params + const buildUrl = (overrides: { sort?: string; repo?: string; page?: number }) => { + const params = new URLSearchParams(); + const s = overrides.sort ?? sort; + const r = overrides.repo ?? activeRepo; + const p = overrides.page ?? 1; + if (s && s !== 'popularity') params.set('sort', s); + if (r) params.set('repo', r); + if (p > 1) params.set('page', String(p)); + const qs = params.toString(); + return `/${locale}/owner/${rawUsername}${qs ? `?${qs}` : ''}`; + }; + + return ( +
+
+
+ {/* Owner Header */} +
+
+
+ {username} +
+

+ {t('title', { username })} + + + +

+ + {/* Stats */} +
+ + + {t('stats.skills', { count: stats.totalSkills })} + + + + {formatNum(stats.totalDownloads)} {t('stats.downloads')} + + + + {formatNum(stats.totalViews)} {t('stats.views')} + + + + {t('stats.repos', { count: stats.totalRepos })} + +
+
+
+
+
+ +
+ {/* Repo filter chips (only if > 1 repo) */} + {ownerRepos.length > 1 && ( +
+ + {t('repo.filter')}: + + {t('repo.all')} ({locale === 'fa' ? toPersianNumber(stats.totalSkills) : stats.totalSkills}) + + {ownerRepos.map((r) => ( + + {r.repo} ({locale === 'fa' ? toPersianNumber(r.skillCount) : r.skillCount}) + + ))} +
+ )} + + {/* Controls: Sort + Results count */} +
+ {/* Results count */} +

+ {t('pagination.showing', { + start: locale === 'fa' ? toPersianNumber(startItem) : startItem, + end: locale === 'fa' ? toPersianNumber(endItem) : endItem, + total: locale === 'fa' ? toPersianNumber(totalSkills) : totalSkills, + })} +

+ + {/* Sort selector */} +
+ + {t('sort.label')}: +
+ {sortOptions.map((opt) => ( + + {opt.label} + + ))} +
+
+
+ + {/* Claim CTA - subtle inline hint */} +
+ {t('claimCta')} + + {t('claimButton')} + +
+ + {/* Repos + Skills */} + {repos.map((repo) => ( +
+
+

{repo.name}

+ + {t('repo.skills', { count: repo.skills.length })} + + + {t('repo.viewOnGithub')} + +
+
+ {repo.skills.map((skill) => ( + + ))} +
+
+ ))} + + {/* Pagination */} + + + {/* Browse CTA - link to owner page and CLI for owners with long enough usernames */} +
+

+ {t('installCta')} +

+ {skills.length > 0 && ( + + npx skillhub install {skills[0].id} + + )} +
+
+
+
+
+ ); +} diff --git a/apps/web/app/[locale]/page.tsx b/apps/web/app/[locale]/page.tsx new file mode 100644 index 0000000..d3d2c5a --- /dev/null +++ b/apps/web/app/[locale]/page.tsx @@ -0,0 +1,274 @@ +import { getTranslations, setRequestLocale } from 'next-intl/server'; +import Link from 'next/link'; +import { Search, ArrowLeft, ArrowRight, Download, Users, Layers, Sparkles, Terminal, Zap } from 'lucide-react'; +import { Header } from '@/components/Header'; +import { Footer } from '@/components/Footer'; +import { HeroSearch } from '@/components/HeroSearch'; +import { SkillCard } from '@/components/SkillCard'; +import { createDb, categoryQueries, skillQueries, skills, sql } from '@skillhub/db'; +import { formatCompactNumber } from '@/lib/format-number'; + +// Force dynamic rendering to fetch fresh data from database +export const dynamic = 'force-dynamic'; + +// Get stats directly from database +async function getStats() { + try { + const db = createDb(); + + // Get total skills count (SKILL.md only - real reusable skills) + const skillsResult = await db + .select({ count: sql`count(*)::int` }) + .from(skills) + .where(sql`${skills.sourceFormat} = 'skill.md' AND ${skills.isBlocked} = false`); + const totalSkills = skillsResult[0]?.count ?? 0; + + // Get total downloads + const downloadsResult = await db + .select({ sum: sql`coalesce(sum(${skills.downloadCount}), 0)::int` }) + .from(skills); + const totalDownloads = downloadsResult[0]?.sum ?? 0; + + // Get total categories + const categories = await categoryQueries.getAll(db); + const totalCategories = categories.length; + + // Get unique contributors (github owners) + const contributorsResult = await db + .select({ count: sql`count(distinct ${skills.githubOwner})::int` }) + .from(skills); + const totalContributors = contributorsResult[0]?.count ?? 0; + + return { + totalSkills, + totalDownloads, + totalCategories, + totalContributors, + platforms: 5, + }; + } catch (error) { + console.error('Error fetching stats:', error); + return null; + } +} + +// Get featured skills directly from database +async function getFeaturedSkills() { + try { + const db = createDb(); + // Get featured skills, or top skills by popularity if none are featured + let featuredSkills = await skillQueries.getFeatured(db, 6); + if (featuredSkills.length === 0) { + // Fallback to adaptive popularity with owner/repo diversity + featuredSkills = await skillQueries.getFeaturedWithDiversity(db, 6, 2, 3); + } + return featuredSkills; + } catch (error) { + console.error('Error fetching featured skills:', error); + return []; + } +} + + +export default async function HomePage({ + params, +}: { + params: Promise<{ locale: string }>; +}) { + const { locale } = await params; + setRequestLocale(locale); + const t = await getTranslations('home'); + const tCommon = await getTranslations('common'); + const isRTL = locale === 'fa'; + const ArrowIcon = isRTL ? ArrowLeft : ArrowRight; + + // Fetch real data + const [statsData, featuredSkills] = await Promise.all([ + getStats(), + getFeaturedSkills(), + ]); + + const stats = [ + { value: statsData ? formatCompactNumber(statsData.totalSkills, locale) : '۰', label: t('stats.skills'), icon: Layers }, + { value: statsData ? formatCompactNumber(statsData.totalDownloads, locale) : '۰', label: t('stats.downloads'), icon: Download }, + { value: statsData ? formatCompactNumber(statsData.totalContributors, locale) : '۰', label: t('stats.contributors'), icon: Users }, + { value: statsData ? formatCompactNumber(statsData.totalCategories || 8, locale) : '۸', label: t('stats.categories'), icon: Sparkles }, + ]; + + const steps = [ + { + icon: Search, + title: t('howItWorks.step1.title'), + description: t('howItWorks.step1.description'), + }, + { + icon: Terminal, + title: t('howItWorks.step2.title'), + description: t('howItWorks.step2.description'), + }, + { + icon: Zap, + title: t('howItWorks.step3.title'), + description: t('howItWorks.step3.description'), + }, + ]; + + return ( +
+
+ +
+ {/* Hero Section */} +
+
+
+ {/* Tagline */} +

+ {t('hero.tagline')} +

+ + {/* Title */} +

+ {t('hero.title')} +

+ + {/* Subtitle */} +

+ {t('hero.subtitle')} +

+ + {/* Search - Client Component */} + + + {/* CTA Buttons */} +
+ + {t('hero.cta')} + + + + {t('hero.ctaSecondary')} + +
+
+
+ + {/* Decorative elements */} +
+
+
+
+
+ + {/* Stats Section */} +
+
+
+ {stats.map((stat, index) => ( +
+
+ +
+
+ {stat.value} +
+
+ {stat.label} +
+
+ ))} +
+
+
+ + {/* Featured Skills */} +
+
+
+

{t('featured.title')}

+

{t('featured.subtitle')}

+
+ +
+ {featuredSkills.length > 0 ? ( + featuredSkills.map((skill) => ( + + )) + ) : ( + // Fallback placeholder cards if no skills + [1, 2, 3, 4, 5, 6].map((i) => ( +
+
+
+
+
+
+
+
+ )) + )} +
+ +
+ + {tCommon('viewAll')} + + +
+
+
+ + {/* How It Works */} +
+
+
+

{t('howItWorks.title')}

+
+ +
+ {steps.map((step, index) => ( +
+
+ +
+

+ {step.title} +

+

+ {step.description} +

+
+ ))} +
+ + {/* CLI Example */} +
+
+
+
+
+
+
+ + $ npx skillhub install anthropics/skills/pdf + + + ✓ Skill installed to ~/.claude/skills/pdf/ + +
+
+
+
+
+ +
+
+ ); +} diff --git a/apps/web/app/[locale]/privacy/page.tsx b/apps/web/app/[locale]/privacy/page.tsx new file mode 100644 index 0000000..da400d7 --- /dev/null +++ b/apps/web/app/[locale]/privacy/page.tsx @@ -0,0 +1,90 @@ +import { getTranslations, setRequestLocale } from 'next-intl/server'; +import Link from 'next/link'; +import { Header } from '@/components/Header'; +import { Footer } from '@/components/Footer'; +import { Shield, Database, Cookie, Users, Lock, Mail, ArrowRight, ArrowLeft } from 'lucide-react'; + +export const dynamic = 'force-dynamic'; + +const sectionIcons = { + dataCollected: Database, + cookies: Cookie, + thirdParty: Users, + retention: Lock, + rights: Shield, + contact: Mail, +}; + +export default async function PrivacyPage({ + params, +}: { + params: Promise<{ locale: string }>; +}) { + const { locale } = await params; + setRequestLocale(locale); + const t = await getTranslations('privacy'); + const isRTL = locale === 'fa'; + const ArrowIcon = isRTL ? ArrowLeft : ArrowRight; + + const sections = [ + 'dataCollected', + 'cookies', + 'thirdParty', + 'retention', + 'rights', + 'contact', + ] as const; + + return ( +
+
+
+
+
+

{t('title')}

+

{t('subtitle')}

+

{t('lastUpdated')}

+
+
+ +
+
+
+ {sections.map((sectionKey) => { + const Icon = sectionIcons[sectionKey]; + const showClaimLink = sectionKey === 'rights'; + return ( +
+
+
+ +
+
+

+ {t(`sections.${sectionKey}.title`)} +

+

+ {t(`sections.${sectionKey}.description`)} +

+ {showClaimLink && ( + + {locale === 'fa' ? 'صفحه مدیریت مهارت‌ها' : 'Manage Skills Page'} + + + )} +
+
+
+ ); + })} +
+
+
+
+
+
+ ); +} diff --git a/apps/web/app/[locale]/skill/[...id]/page.tsx b/apps/web/app/[locale]/skill/[...id]/page.tsx new file mode 100644 index 0000000..a09f2e9 --- /dev/null +++ b/apps/web/app/[locale]/skill/[...id]/page.tsx @@ -0,0 +1,518 @@ +import { getTranslations, setRequestLocale } from 'next-intl/server'; +import Link from 'next/link'; +import { notFound } from 'next/navigation'; +import { headers } from 'next/headers'; +import { + Star, Download, Shield, CheckCircle, Copy, + ExternalLink, Github, Calendar, User, Tag, ChevronRight, Eye +} from 'lucide-react'; +import { Header } from '@/components/Header'; +import { Footer } from '@/components/Footer'; +import { FavoriteButton } from '@/components/FavoriteButton'; +import { RatingStars } from '@/components/RatingStars'; +import { InstallSection } from '@/components/InstallSection'; +import { ShareButton } from '@/components/ShareButton'; +import { createDb, skillQueries } from '@skillhub/db'; +import { FORMAT_LABELS } from 'skillhub-core'; +import { formatCompactNumber } from '@/lib/format-number'; +import { shouldCountView } from '@/lib/cache'; + +// Force dynamic rendering to fetch fresh data from database +export const dynamic = 'force-dynamic'; + +interface SkillPageProps { + params: Promise<{ locale: string; id: string[] }>; +} + +// Get skill directly from database +async function getSkill(skillId: string) { + try { + const db = createDb(); + return await skillQueries.getById(db, skillId); + } catch (error) { + console.error('Error fetching skill:', error); + return null; + } +} + +export default async function SkillPage({ params }: SkillPageProps) { + const { locale, id } = await params; + setRequestLocale(locale); + const t = await getTranslations('skill'); + const tCommon = await getTranslations('common'); + + const skillId = id.join('/'); + const isRTL = locale === 'fa'; + + // Get skill from database + const dbSkill = await getSkill(skillId); + + if (!dbSkill) { + notFound(); + } + + // Check if skill is blocked (removed by owner request) + if (dbSkill.isBlocked) { + notFound(); + } + + // Track view count with IP-based rate limiting (1 hour cooldown per IP) + // Get client IP from headers (works with Cloudflare, nginx, etc.) + const headersList = await headers(); + const clientIp = + headersList.get('cf-connecting-ip') || + headersList.get('x-real-ip') || + headersList.get('x-forwarded-for')?.split(',')[0].trim() || + 'unknown'; + + // Only increment on primary server (mirror DB is read-only) + const isPrimary = process.env.IS_PRIMARY_SERVER !== 'false'; + if (isPrimary) { + const db = createDb(); + shouldCountView(dbSkill.id, clientIp).then((shouldCount) => { + if (shouldCount) { + skillQueries.incrementViews(db, dbSkill.id).catch(() => {}); + } + }).catch(() => {}); + } + + // Map database response to expected format + const skill = { + id: dbSkill.id, + name: dbSkill.name, + description: dbSkill.description, + longDescription: dbSkill.rawContent || dbSkill.description, + version: dbSkill.version || null, + license: dbSkill.license || 'MIT', + author: dbSkill.githubOwner, + repo: dbSkill.githubRepo, + repository: `https://github.com/${dbSkill.githubOwner}/${dbSkill.githubRepo}`, + homepage: dbSkill.homepage || null, + stars: dbSkill.githubStars || 0, + downloads: dbSkill.downloadCount || 0, + views: dbSkill.viewCount || 0, + securityStatus: dbSkill.securityStatus || 'pass', + isVerified: dbSkill.isVerified || false, + createdAt: dbSkill.createdAt, + updatedAt: dbSkill.updatedAt ? dbSkill.updatedAt.toLocaleDateString(locale === 'fa' ? 'fa-IR' : 'en-US') : 'N/A', + rating: dbSkill.rating || 0, + ratingCount: dbSkill.ratingCount || 0, + sourceFormat: dbSkill.sourceFormat || 'skill.md', + }; + + // Content section title based on source format (uses FORMAT_LABELS from skillhub-core) + const getContentTitle = (format: string) => { + const label = FORMAT_LABELS[format as keyof typeof FORMAT_LABELS] || FORMAT_LABELS['skill.md']; + if (format === 'copilot-instructions') { + return isRTL ? 'دستورالعمل Copilot' : label; + } + return isRTL ? `محتوای ${label}` : `${label} Content`; + }; + + // Source format badge configuration (for non-SKILL.md formats) + const FORMAT_PLATFORMS: Record = { + 'agents.md': 'Codex', + 'cursorrules': 'Cursor', + 'windsurfrules': 'Windsurf', + 'copilot-instructions': 'Copilot', + }; + + const getSourceFormatBadge = (format: string) => { + const platform = FORMAT_PLATFORMS[format]; + if (!platform) return null; + const label = FORMAT_LABELS[format as keyof typeof FORMAT_LABELS] || format; + return { label, platform }; + }; + + const sourceFormatBadge = getSourceFormatBadge(skill.sourceFormat); + + const getSecurityConfig = (status: string) => { + switch (status) { + case 'pass': return { + label: t('security.pass'), + icon: '✓', + bg: 'bg-success/10', + text: 'text-success', + border: 'border-success/20' + }; + case 'warning': return { + label: t('security.warning'), + icon: '⚠', + bg: 'bg-warning/10', + text: 'text-warning', + border: 'border-warning/20' + }; + case 'fail': return { + label: t('security.fail'), + icon: '✕', + bg: 'bg-error/10', + text: 'text-error', + border: 'border-error/20' + }; + default: return { + label: t('security.pass'), + icon: '✓', + bg: 'bg-success/10', + text: 'text-success', + border: 'border-success/20' + }; + } + }; + + const securityConfig = getSecurityConfig(skill.securityStatus); + + const installCommands = { + claude: { + cli: `npx skillhub install ${skillId}`, + path: `~/.claude/skills/${skill.name}/`, + }, + codex: { + cli: `npx skillhub install ${skillId} --platform codex`, + path: `~/.codex/skills/${skill.name}/`, + }, + copilot: { + cli: `npx skillhub install ${skillId} --platform copilot`, + path: `.github/instructions/${skill.name}.instructions.md`, + }, + cursor: { + cli: `npx skillhub install ${skillId} --platform cursor`, + path: `.cursor/rules/${skill.name}.mdc`, + }, + windsurf: { + cli: `npx skillhub install ${skillId} --platform windsurf`, + path: `.windsurf/rules/${skill.name}.md`, + }, + }; + + return ( +
+
+ +
+ {/* Hero Section */} +
+
+ {/* Breadcrumb */} + + + {/* Main Header */} +
+ {/* Left: Title & Description */} +
+
+

+ {skill.name} +

+ {skill.isVerified && ( + + + {tCommon('verified')} + + )} + + + {securityConfig.label} + + {sourceFormatBadge && ( + + {sourceFormatBadge.platform} + + )} +
+ +

+ {skill.description} +

+ + {/* Author, Version, License & Last Update */} +
+ +
+ +
+ @{skill.author} +
+ {skill.version && ( + <> + + + + v{skill.version} + + + )} + + + {skill.license} + + + + + {skill.updatedAt} + +
+
+ + {/* Right: Actions */} +
+ + + + + +
+
+
+
+ + {/* Project Configuration Warning Banner (non-SKILL.md) */} + {sourceFormatBadge && ( +
+
+
+ +
+

+ {isRTL + ? `این یک فایل پیکربندی اختصاصی پروژه (${sourceFormatBadge.label}) است، نه یک مهارت عامل قابل استفاده مجدد. حاوی دستورالعمل‌هایی است که برای مخزن ${skill.repo} طراحی شده و ممکن است در پروژه‌های دیگر کاربردی نباشد.` + : `This is a project-specific configuration file (${sourceFormatBadge.label}), not a reusable Agent Skill. It contains instructions designed for the ${skill.repo} repository and may not be applicable to other projects.`} +

+ + {isRTL ? 'مرور مهارت‌های قابل استفاده مجدد' : 'Browse reusable skills'} + + +
+
+
+
+ )} + + {/* Stats Bar */} +
+
+
+
+ +
+
+
+ + + {formatCompactNumber(skill.stars, locale)} + + {tCommon('stars')} +
+
+
+ + + {formatCompactNumber(skill.downloads, locale)} + + {tCommon('downloads')} +
+
+
+ + + {formatCompactNumber(skill.views, locale)} + + {tCommon('views')} +
+
+
+
+ + {/* Main Content */} +
+
+ {/* Left: README Content */} +
+ {/* Quick Install (Mobile) */} +
+ +
+ + {/* README Section */} +
+
+

+ + {getContentTitle(skill.sourceFormat)} +

+
+
+
+
+                      {skill.longDescription}
+                    
+
+
+
+ + {/* Source Links (Mobile) */} + +
+ + {/* Right: Sidebar (Desktop) */} +
+
+ {/* Install Section */} + + + {/* Links Card (Desktop) */} + {skill.homepage && ( +
+

+ {isRTL ? 'لینک‌ها' : 'Links'} +

+ + + {t('meta.homepage')} + + +
+ )} +
+
+
+
+
+ +
+
+ ); +} diff --git a/apps/web/app/[locale]/support/page.tsx b/apps/web/app/[locale]/support/page.tsx new file mode 100644 index 0000000..ed3c7b6 --- /dev/null +++ b/apps/web/app/[locale]/support/page.tsx @@ -0,0 +1,117 @@ +import { getTranslations, setRequestLocale } from 'next-intl/server'; +import { Header } from '@/components/Header'; +import { Footer } from '@/components/Footer'; +import { Mail, Bitcoin, ExternalLink } from 'lucide-react'; + +export const dynamic = 'force-dynamic'; + +interface SupportPageProps { + params: Promise<{ locale: string }>; +} + +export default async function SupportPage({ params }: SupportPageProps) { + const { locale } = await params; + setRequestLocale(locale); + const t = await getTranslations('support'); + const isRTL = locale === 'fa'; + + const supportOptions = [ + { + icon: Mail, + title: t('email'), + description: t('emailDesc'), + href: 'mailto:hi.airano@gmail.com', + cta: t('sendEmail'), + color: 'bg-surface-subtle text-text-secondary', + }, + { + icon: Bitcoin, + title: t('donate'), + description: t('donateDesc'), + href: 'https://nowpayments.io/donation/airano', + cta: t('donateNow'), + color: 'bg-amber-100 dark:bg-amber-900/30 text-amber-700 dark:text-amber-400', + }, + ]; + + return ( +
+
+ +
+ {/* Hero Section */} +
+
+

{t('title')}

+

{t('subtitle')}

+
+
+ + {/* Support Options */} +
+
+
+ {supportOptions.map((option, index) => ( + +
+ +
+

+ {option.title} +

+

+ {option.description} +

+ + {option.cta} + + +
+ ))} +
+ + {/* Why Crypto Only */} +
+
+

+ {t('whyCryptoTitle')} +

+

+ {t('whyCryptoDesc')} +

+
+
+ + {/* Crypto Info */} +
+

+ {t('popularCoins')} +

+
+ {['Bitcoin', 'Ethereum', 'TON', 'Tron', 'Solana', 'Litecoin', 'Dogecoin'].map((crypto) => ( + + {crypto} + + ))} +
+

+ {t('andMoreCrypto')} +

+
+
+
+
+ +
+
+ ); +} diff --git a/apps/web/app/[locale]/terms/page.tsx b/apps/web/app/[locale]/terms/page.tsx new file mode 100644 index 0000000..c8644be --- /dev/null +++ b/apps/web/app/[locale]/terms/page.tsx @@ -0,0 +1,90 @@ +import { getTranslations, setRequestLocale } from 'next-intl/server'; +import Link from 'next/link'; +import { Header } from '@/components/Header'; +import { Footer } from '@/components/Footer'; +import { FileText, UserCheck, AlertTriangle, Scale, Trash2, RefreshCw, ArrowRight, ArrowLeft } from 'lucide-react'; + +export const dynamic = 'force-dynamic'; + +const sectionIcons = { + service: FileText, + responsibilities: UserCheck, + disclaimer: AlertTriangle, + liability: Scale, + takedown: Trash2, + changes: RefreshCw, +}; + +export default async function TermsPage({ + params, +}: { + params: Promise<{ locale: string }>; +}) { + const { locale } = await params; + setRequestLocale(locale); + const t = await getTranslations('terms'); + const isRTL = locale === 'fa'; + const ArrowIcon = isRTL ? ArrowLeft : ArrowRight; + + const sections = [ + 'service', + 'responsibilities', + 'disclaimer', + 'liability', + 'takedown', + 'changes', + ] as const; + + return ( +
+
+
+
+
+

{t('title')}

+

{t('subtitle')}

+

{t('lastUpdated')}

+
+
+ +
+
+
+ {sections.map((sectionKey) => { + const Icon = sectionIcons[sectionKey]; + const showClaimLink = sectionKey === 'takedown'; + return ( +
+
+
+ +
+
+

+ {t(`sections.${sectionKey}.title`)} +

+

+ {t(`sections.${sectionKey}.description`)} +

+ {showClaimLink && ( + + {locale === 'fa' ? 'صفحه مدیریت مهارت‌ها' : 'Manage Skills Page'} + + + )} +
+
+
+ ); + })} +
+
+
+
+
+
+ ); +} diff --git a/apps/web/app/api/__tests__/helpers.ts b/apps/web/app/api/__tests__/helpers.ts new file mode 100644 index 0000000..c1a26cd --- /dev/null +++ b/apps/web/app/api/__tests__/helpers.ts @@ -0,0 +1,205 @@ +/** + * Test helpers for API route tests + */ + +import { vi } from 'vitest'; +import { NextRequest } from 'next/server'; + +/** + * Create a mock NextRequest for testing API routes + */ +export function createMockRequest( + url: string, + options?: { + method?: string; + body?: unknown; + headers?: Record; + searchParams?: Record; + } +): NextRequest { + const { method = 'GET', body, headers = {}, searchParams = {} } = options || {}; + + // Build URL with search params + const urlObj = new URL(url, 'http://localhost:3000'); + Object.entries(searchParams).forEach(([key, value]) => { + urlObj.searchParams.set(key, value); + }); + + const requestInit: { method: string; headers: Headers; body?: string } = { + method, + headers: new Headers({ + 'Content-Type': 'application/json', + ...headers, + }), + }; + + if (body && method !== 'GET') { + requestInit.body = JSON.stringify(body); + } + + // Cast to any to avoid Next.js RequestInit vs global RequestInit type mismatch + // eslint-disable-next-line @typescript-eslint/no-explicit-any + return new NextRequest(urlObj, requestInit as any); +} + +/** + * Mock authenticated session + */ +export function mockAuthSession(user?: { + githubId: string; + username: string; + email?: string; + avatarUrl?: string; +}) { + const mockAuth = vi.fn().mockResolvedValue( + user + ? { + user: { + ...user, + name: user.username, + }, + } + : null + ); + + vi.doMock('@/lib/auth', () => ({ + auth: mockAuth, + })); + + return mockAuth; +} + +/** + * Create a mock user for testing + */ +export function createMockUser(overrides: Partial<{ + id: string; + githubId: string; + username: string; + displayName: string; + email: string; + avatarUrl: string; +}> = {}) { + return { + id: 'user-123', + githubId: 'gh-12345', + username: 'testuser', + displayName: 'Test User', + email: 'test@example.com', + avatarUrl: 'https://example.com/avatar.png', + isAdmin: false, + createdAt: new Date(), + updatedAt: new Date(), + lastLoginAt: new Date(), + ...overrides, + }; +} + +/** + * Create a mock skill for testing + */ +export function createMockSkill(overrides: Partial<{ + id: string; + name: string; + description: string; + githubOwner: string; + githubRepo: string; + githubStars: number; + downloadCount: number; + securityScore: number; + isVerified: boolean; + isFeatured: boolean; + rating: number; + ratingCount: number; + compatibility: { platforms?: string[] }; +}> = {}) { + return { + id: 'test-owner/test-repo/test-skill', + name: 'test-skill', + description: 'A test skill', + githubOwner: 'test-owner', + githubRepo: 'test-repo', + skillPath: 'skills/test-skill', + branch: 'main', + version: '1.0.0', + license: 'MIT', + author: 'Test Author', + githubStars: 100, + githubForks: 10, + downloadCount: 50, + viewCount: 200, + rating: 4, + ratingCount: 5, + ratingSum: 20, + securityScore: 85, + isVerified: false, + isFeatured: false, + compatibility: { platforms: ['claude', 'codex'] }, + createdAt: new Date(), + updatedAt: new Date(), + indexedAt: new Date(), + ...overrides, + }; +} + +/** + * Create a mock category for testing + */ +export function createMockCategory(overrides: Partial<{ + id: string; + name: string; + slug: string; + description: string; + icon: string; + skillCount: number; + sortOrder: number; +}> = {}) { + return { + id: 'cat-1', + name: 'Test Category', + slug: 'test-category', + description: 'A test category', + icon: 'folder', + color: '#3B82F6', + skillCount: 10, + sortOrder: 0, + createdAt: new Date(), + ...overrides, + }; +} + +/** + * Create a mock rating for testing + */ +export function createMockRating(overrides: Partial<{ + id: string; + skillId: string; + userId: string; + rating: number; + review: string; +}> = {}) { + return { + id: 'rating-1', + skillId: 'test-owner/test-repo/test-skill', + userId: 'user-123', + rating: 4, + review: 'Great skill!', + createdAt: new Date(), + updatedAt: new Date(), + ...overrides, + }; +} + +/** + * Parse JSON response from NextResponse + */ +export async function parseResponse(response: Response): Promise<{ + status: number; + data: T; +}> { + const data = await response.json(); + return { + status: response.status, + data, + }; +} diff --git a/apps/web/app/api/__tests__/setup.ts b/apps/web/app/api/__tests__/setup.ts new file mode 100644 index 0000000..771cc0d --- /dev/null +++ b/apps/web/app/api/__tests__/setup.ts @@ -0,0 +1,62 @@ +/** + * Test setup for API route tests + * + * This file is loaded before each test file via vitest setupFiles + */ + +import { vi } from 'vitest'; + +// Mock environment variables +process.env.DATABASE_URL = 'postgresql://test:test@localhost:5432/test'; +process.env.GITHUB_CLIENT_ID = 'test-client-id'; +process.env.GITHUB_CLIENT_SECRET = 'test-client-secret'; +process.env.AUTH_SECRET = 'test-auth-secret'; + +// Mock @skillhub/db module +vi.mock('@skillhub/db', () => ({ + createDb: vi.fn(() => ({})), + skillQueries: { + getById: vi.fn(), + search: vi.fn(), + getFeatured: vi.fn(), + getTrending: vi.fn(), + getRecent: vi.fn(), + upsert: vi.fn(), + incrementDownloads: vi.fn(), + incrementViews: vi.fn(), + updateRating: vi.fn(), + }, + categoryQueries: { + getAll: vi.fn(), + getBySlug: vi.fn(), + getSkills: vi.fn(), + updateSkillCount: vi.fn(), + }, + userQueries: { + getByGithubId: vi.fn(), + upsertFromGithub: vi.fn(), + getFavorites: vi.fn(), + getById: vi.fn(), + }, + ratingQueries: { + upsert: vi.fn(), + getForSkill: vi.fn(), + getUserRating: vi.fn(), + }, + favoriteQueries: { + add: vi.fn(), + remove: vi.fn(), + isFavorited: vi.fn(), + getFavoritedIds: vi.fn(), + }, + installationQueries: { + track: vi.fn(), + getStats: vi.fn(), + }, + skills: {}, + categories: {}, + sql: vi.fn((strings: TemplateStringsArray, ...values: unknown[]) => ({ + sql: strings.join('?'), + values, + })), +})); diff --git a/apps/web/app/api/attribution/route.ts b/apps/web/app/api/attribution/route.ts new file mode 100644 index 0000000..535710f --- /dev/null +++ b/apps/web/app/api/attribution/route.ts @@ -0,0 +1,139 @@ +import { type NextRequest, NextResponse } from 'next/server'; +import { createDb, skills, discoveredRepos, awesomeLists, sql } from '@skillhub/db'; +import { getCached, setCache, cacheTTL } from '@/lib/cache'; +import { withRateLimit, createRateLimitResponse, createRateLimitHeaders } from '@/lib/rate-limit'; + +const db = createDb(); + +interface AttributionStats { + totalSkills: number; + totalContributors: number; + totalRepos: number; + awesomeLists: { + count: number; + totalRepos: number; + }; + forkNetworks: number; + licenseDistribution: Array<{ + license: string; + count: number; + percentage: number; + }>; + discoveryBySource: Array<{ + source: string; + count: number; + withSkills: number; + }>; + lastUpdated: string; +} + +export async function GET(request: NextRequest) { + // Rate limiting + const rateLimitResult = await withRateLimit(request, 'anonymous'); + if (!rateLimitResult.allowed) { + return createRateLimitResponse(rateLimitResult); + } + + try { + // Try to get from cache first (cache for 1 hour) + const cacheKey = 'attribution:stats'; + const cached = await getCached(cacheKey); + if (cached) { + return NextResponse.json(cached, { + headers: { + 'X-Cache': 'HIT', + 'Cache-Control': 'public, max-age=3600, stale-while-revalidate=7200', + ...createRateLimitHeaders(rateLimitResult), + }, + }); + } + + // Get total skills and contributors + const skillStats = await db + .select({ + totalSkills: sql`count(*)::int`, + totalContributors: sql`count(distinct ${skills.githubOwner})::int`, + }) + .from(skills); + + // Get license distribution + const licenseStats = await db + .select({ + license: sql`coalesce(${skills.license}, 'Unspecified')`, + count: sql`count(*)::int`, + }) + .from(skills) + .groupBy(sql`coalesce(${skills.license}, 'Unspecified')`) + .orderBy(sql`count(*) desc`) + .limit(10); + + const totalForPercentage = skillStats[0]?.totalSkills ?? 1; + const licenseDistribution = licenseStats.map((l) => ({ + license: l.license || 'Unspecified', + count: l.count, + percentage: Math.round((l.count / totalForPercentage) * 100), + })); + + // Get discovered repos stats + const repoStats = await db + .select({ count: sql`count(*)::int` }) + .from(discoveredRepos); + + // Get discovery by source + const bySource = await db + .select({ + source: discoveredRepos.discoveredVia, + count: sql`count(*)::int`, + withSkills: sql`sum(case when has_skill_md then 1 else 0 end)::int`, + }) + .from(discoveredRepos) + .groupBy(discoveredRepos.discoveredVia); + + // Get fork networks count + const forkCount = bySource.find((s) => s.source === 'fork-network'); + + // Get awesome lists stats + const awesomeStats = await db + .select({ + count: sql`count(*)::int`, + totalRepos: sql`coalesce(sum(${awesomeLists.repoCount}), 0)::int`, + }) + .from(awesomeLists) + .where(sql`${awesomeLists.isActive} = true`); + + const data: AttributionStats = { + totalSkills: skillStats[0]?.totalSkills ?? 0, + totalContributors: skillStats[0]?.totalContributors ?? 0, + totalRepos: repoStats[0]?.count ?? 0, + awesomeLists: { + count: awesomeStats[0]?.count ?? 0, + totalRepos: awesomeStats[0]?.totalRepos ?? 0, + }, + forkNetworks: forkCount?.count ?? 0, + licenseDistribution, + discoveryBySource: bySource.map((s) => ({ + source: s.source ?? 'unknown', + count: s.count, + withSkills: s.withSkills ?? 0, + })), + lastUpdated: new Date().toISOString(), + }; + + // Cache the result for 1 hour + await setCache(cacheKey, data, cacheTTL.stats); + + return NextResponse.json(data, { + headers: { + 'X-Cache': 'MISS', + 'Cache-Control': 'public, max-age=3600, stale-while-revalidate=7200', + ...createRateLimitHeaders(rateLimitResult), + }, + }); + } catch (error) { + console.error('Error fetching attribution stats:', error); + return NextResponse.json( + { error: 'Failed to fetch attribution stats' }, + { status: 500 } + ); + } +} diff --git a/apps/web/app/api/auth/[...nextauth]/route.ts b/apps/web/app/api/auth/[...nextauth]/route.ts new file mode 100644 index 0000000..5ef28c1 --- /dev/null +++ b/apps/web/app/api/auth/[...nextauth]/route.ts @@ -0,0 +1,3 @@ +import { handlers } from '@/lib/auth'; + +export const { GET, POST } = handlers; diff --git a/apps/web/app/api/categories/route.test.ts b/apps/web/app/api/categories/route.test.ts new file mode 100644 index 0000000..2e57cfd --- /dev/null +++ b/apps/web/app/api/categories/route.test.ts @@ -0,0 +1,122 @@ +import { describe, it, expect, vi, beforeEach } from 'vitest'; +import { NextRequest } from 'next/server'; + +// Mock rate limiting - must be before route import +vi.mock('@/lib/rate-limit', () => ({ + withRateLimit: vi.fn().mockResolvedValue({ allowed: true, remaining: 100, limit: 120, resetAt: Date.now() + 60000 }), + createRateLimitResponse: vi.fn(), + createRateLimitHeaders: vi.fn().mockReturnValue({}), +})); + +// Mock cache - must be before route import +vi.mock('@/lib/cache', () => ({ + getCached: vi.fn().mockResolvedValue(null), + setCache: vi.fn().mockResolvedValue(undefined), + cacheKeys: { categories: () => 'categories' }, + cacheTTL: { categories: 43200 }, +})); + +// Create mock category helper +function createMockCategory(overrides: Partial<{ + id: string; + name: string; + slug: string; + description: string | null; + icon: string | null; + skillCount: number | null; + sortOrder: number | null; + color: string | null; + parentId: string | null; +}> = {}) { + return { + id: 'cat-1', + name: 'Test Category', + slug: 'test-category', + description: 'A test category', + icon: 'folder', + color: '#3B82F6', + skillCount: 10, + sortOrder: 0, + parentId: null, + createdAt: new Date(), + ...overrides, + }; +} + +// Mock the db module +vi.mock('@skillhub/db', () => ({ + createDb: vi.fn(() => ({})), + categoryQueries: { + getLeafCategories: vi.fn(), + }, +})); + +import { GET } from './route'; +// Import after mocking +import { categoryQueries } from '@skillhub/db'; + +// Helper to create mock request +function createMockRequest(url = 'http://localhost:3000/api/categories') { + return new NextRequest(url); +} + +describe('GET /api/categories', () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + it('should return all categories', async () => { + const mockCategories = [ + createMockCategory({ id: 'cat-1', name: 'Development', slug: 'development', skillCount: 20 }), + createMockCategory({ id: 'cat-2', name: 'Testing', slug: 'testing', skillCount: 15 }), + ]; + vi.mocked(categoryQueries.getLeafCategories).mockResolvedValue(mockCategories); + + const request = createMockRequest(); + const response = await GET(request); + const data = await response.json(); + + expect(response.status).toBe(200); + expect(data.categories).toBeDefined(); + expect(Array.isArray(data.categories)).toBe(true); + expect(data.categories.length).toBe(2); + }); + + it('should include skillCount for each category', async () => { + const mockCategories = [ + createMockCategory({ skillCount: 25 }), + ]; + vi.mocked(categoryQueries.getLeafCategories).mockResolvedValue(mockCategories); + + const request = createMockRequest(); + const response = await GET(request); + const data = await response.json(); + + expect(data.categories[0].skillCount).toBeDefined(); + expect(typeof data.categories[0].skillCount).toBe('number'); + }); + + it('should handle database errors gracefully', async () => { + vi.mocked(categoryQueries.getLeafCategories).mockRejectedValue(new Error('Database error')); + + const request = createMockRequest(); + const response = await GET(request); + const data = await response.json(); + + expect(response.status).toBe(500); + expect(data.error).toBeDefined(); + }); + + it('should return empty array when no categories exist', async () => { + vi.mocked(categoryQueries.getLeafCategories).mockResolvedValue([]); + + const request = createMockRequest(); + const response = await GET(request); + const data = await response.json(); + + expect(response.status).toBe(200); + expect(data.categories).toBeDefined(); + expect(Array.isArray(data.categories)).toBe(true); + expect(data.categories.length).toBe(0); + }); +}); diff --git a/apps/web/app/api/categories/route.ts b/apps/web/app/api/categories/route.ts new file mode 100644 index 0000000..499d9bc --- /dev/null +++ b/apps/web/app/api/categories/route.ts @@ -0,0 +1,67 @@ +import { type NextRequest, NextResponse } from 'next/server'; +import { createDb, categoryQueries } from '@skillhub/db'; +import { getCached, setCache, cacheKeys, cacheTTL } from '@/lib/cache'; +import { withRateLimit, createRateLimitResponse, createRateLimitHeaders } from '@/lib/rate-limit'; + +const db = createDb(); + +interface CategoryData { + id: string; + name: string; + slug: string; + description: string | null; + icon: string | null; + skillCount: number; + sortOrder: number; +} + +interface CategoriesResponse { + categories: CategoryData[]; +} + +export async function GET(request: NextRequest) { + // Rate limiting + const rateLimitResult = await withRateLimit(request, 'anonymous'); + if (!rateLimitResult.allowed) { + return createRateLimitResponse(rateLimitResult); + } + + try { + // Try to get from cache first + const cacheKey = cacheKeys.categories(); + const cached = await getCached(cacheKey); + if (cached) { + return NextResponse.json(cached, { + headers: { 'X-Cache': 'HIT', ...createRateLimitHeaders(rateLimitResult) }, + }); + } + + // Get only leaf categories (filter out parent categories) + const categories = await categoryQueries.getLeafCategories(db); + + const data: CategoriesResponse = { + categories: categories.map((cat) => ({ + id: cat.id, + name: cat.name, + slug: cat.slug, + description: cat.description, + icon: cat.icon, + skillCount: cat.skillCount ?? 0, + sortOrder: cat.sortOrder ?? 0, + })), + }; + + // Cache the result (12 hours - categories rarely change) + await setCache(cacheKey, data, cacheTTL.categories); + + return NextResponse.json(data, { + headers: { 'X-Cache': 'MISS', ...createRateLimitHeaders(rateLimitResult) }, + }); + } catch (error) { + console.error('Error fetching categories:', error); + return NextResponse.json( + { error: 'Failed to fetch categories' }, + { status: 500 } + ); + } +} diff --git a/apps/web/app/api/early-access/route.ts b/apps/web/app/api/early-access/route.ts new file mode 100644 index 0000000..d24035d --- /dev/null +++ b/apps/web/app/api/early-access/route.ts @@ -0,0 +1,136 @@ +import type { NextRequest } from 'next/server'; +import { NextResponse } from 'next/server'; +import { createDb, sql } from '@skillhub/db'; + +// Simple rate limiting +const rateLimitMap = new Map(); +const RATE_LIMIT = 5; // 5 requests per minute +const RATE_LIMIT_WINDOW = 60 * 1000; // 1 minute + +function checkRateLimit(ip: string): boolean { + const now = Date.now(); + const record = rateLimitMap.get(ip); + + if (!record || now - record.timestamp > RATE_LIMIT_WINDOW) { + rateLimitMap.set(ip, { count: 1, timestamp: now }); + return true; + } + + if (record.count >= RATE_LIMIT) { + return false; + } + + record.count++; + return true; +} + +export async function POST(request: NextRequest) { + try { + // Rate limiting + const ip = request.headers.get('x-forwarded-for') || 'unknown'; + if (!checkRateLimit(ip)) { + return NextResponse.json( + { error: 'Too many requests. Please try again later.' }, + { status: 429 } + ); + } + + const body = await request.json(); + const { email, variant, locale, source } = body; + + // Validate email + if (!email || typeof email !== 'string' || !email.includes('@')) { + return NextResponse.json( + { error: 'Invalid email address' }, + { status: 400 } + ); + } + + // Sanitize email + const sanitizedEmail = email.toLowerCase().trim().slice(0, 255); + + // Validate variant + const validVariant = variant === 'a' || variant === 'b' ? variant : 'a'; + + // Store in database + const db = createDb(); + + // Create table if not exists (for first-time setup) + await db.execute(sql` + CREATE TABLE IF NOT EXISTS early_access_signups ( + id SERIAL PRIMARY KEY, + email VARCHAR(255) NOT NULL UNIQUE, + variant VARCHAR(1) NOT NULL DEFAULT 'a', + locale VARCHAR(10), + source VARCHAR(100), + ip_address VARCHAR(45), + user_agent TEXT, + created_at TIMESTAMP WITH TIME ZONE DEFAULT CURRENT_TIMESTAMP + ) + `); + + // Insert or update (upsert) + await db.execute(sql` + INSERT INTO early_access_signups (email, variant, locale, source, ip_address, user_agent) + VALUES ( + ${sanitizedEmail}, + ${validVariant}, + ${locale || null}, + ${source || 'unknown'}, + ${ip}, + ${request.headers.get('user-agent') || null} + ) + ON CONFLICT (email) DO UPDATE SET + variant = EXCLUDED.variant, + locale = EXCLUDED.locale, + source = EXCLUDED.source + `); + + return NextResponse.json({ success: true }); + } catch (error) { + console.error('Early access signup error:', error); + return NextResponse.json( + { error: 'An error occurred. Please try again.' }, + { status: 500 } + ); + } +} + +// Get signup stats (for internal use) +export async function GET(request: NextRequest) { + try { + const authHeader = request.headers.get('authorization'); + const expectedToken = process.env.ADMIN_API_TOKEN; + + if (!expectedToken || authHeader !== `Bearer ${expectedToken}`) { + return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }); + } + + const db = createDb(); + + // Get counts by variant + const stats = await db.execute(sql` + SELECT + variant, + COUNT(*) as count, + MAX(created_at) as last_signup + FROM early_access_signups + GROUP BY variant + `) as unknown as { rows: Array<{ variant: string; count: number; last_signup: Date }> }; + + const totalCount = await db.execute(sql` + SELECT COUNT(*) as total FROM early_access_signups + `) as unknown as { rows: Array<{ total: number }> }; + + return NextResponse.json({ + total: totalCount.rows[0]?.total || 0, + byVariant: stats.rows, + }); + } catch (error) { + console.error('Error fetching early access stats:', error); + return NextResponse.json( + { error: 'Failed to fetch stats' }, + { status: 500 } + ); + } +} diff --git a/apps/web/app/api/favorites/check/route.ts b/apps/web/app/api/favorites/check/route.ts new file mode 100644 index 0000000..851392c --- /dev/null +++ b/apps/web/app/api/favorites/check/route.ts @@ -0,0 +1,48 @@ +import { NextResponse, type NextRequest } from 'next/server'; +import { createDb, userQueries, favoriteQueries } from '@skillhub/db'; +import { auth } from '@/lib/auth'; +import { withRateLimit, createRateLimitResponse, createRateLimitHeaders } from '@/lib/rate-limit'; + +const db = createDb(); + +export async function POST(request: NextRequest) { + // Rate limiting (authenticated) + const rateLimitResult = await withRateLimit(request, 'authenticated'); + if (!rateLimitResult.allowed) { + return createRateLimitResponse(rateLimitResult); + } + + try { + const session = await auth(); + if (!session?.user?.githubId) { + return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }); + } + + const body = await request.json(); + const { skillIds } = body; + + if (!Array.isArray(skillIds)) { + return NextResponse.json({ error: 'skillIds must be an array' }, { status: 400 }); + } + + const dbUser = await userQueries.getByGithubId(db, session.user.githubId); + if (!dbUser) { + return NextResponse.json({ favorited: {} }, { + headers: createRateLimitHeaders(rateLimitResult), + }); + } + + const favoritedIds = await favoriteQueries.getFavoritedIds(db, dbUser.id, skillIds); + const favorited: Record = {}; + skillIds.forEach((id: string) => { + favorited[id] = favoritedIds.includes(id); + }); + + return NextResponse.json({ favorited }, { + headers: createRateLimitHeaders(rateLimitResult), + }); + } catch (error) { + console.error('Error checking favorites:', error); + return NextResponse.json({ error: 'Failed to check favorites' }, { status: 500 }); + } +} diff --git a/apps/web/app/api/favorites/route.test.ts b/apps/web/app/api/favorites/route.test.ts new file mode 100644 index 0000000..b17c4b4 --- /dev/null +++ b/apps/web/app/api/favorites/route.test.ts @@ -0,0 +1,227 @@ +import { describe, it, expect, vi, beforeEach } from 'vitest'; +import { NextRequest } from 'next/server'; + +// Mock rate limiting - must be before route import +vi.mock('@/lib/rate-limit', () => ({ + withRateLimit: vi.fn().mockResolvedValue({ allowed: true, remaining: 100, limit: 120, resetAt: Date.now() + 60000 }), + createRateLimitResponse: vi.fn(), + createRateLimitHeaders: vi.fn().mockReturnValue({}), +})); + +// Helper to create mock skill +function createMockSkill(overrides: Partial<{ + id: string; + name: string; + description: string; +}> = {}) { + return { + id: 'test-owner/test-repo/test-skill', + name: 'test-skill', + description: 'A test skill', + githubOwner: 'test-owner', + githubRepo: 'test-repo', + githubStars: 100, + downloadCount: 50, + securityScore: 85, + isVerified: false, + compatibility: { platforms: ['claude'] }, + rating: 4, + ratingCount: 5, + ...overrides, + }; +} + +// Helper to create mock user +function createMockUser(overrides: Partial<{ id: string; githubId: string }> = {}) { + return { + id: 'user-123', + githubId: 'gh-12345', + username: 'testuser', + ...overrides, + }; +} + +// Mock auth +const mockAuth = vi.fn(); +vi.mock('@/lib/auth', () => ({ + auth: () => mockAuth(), +})); + +// Mock db +vi.mock('@skillhub/db', () => ({ + createDb: vi.fn(() => ({})), + userQueries: { + getByGithubId: vi.fn(), + getFavorites: vi.fn(), + }, + skillQueries: { + getById: vi.fn(), + }, + favoriteQueries: { + add: vi.fn(), + remove: vi.fn(), + }, +})); + +import { GET, POST, DELETE } from './route'; +import { userQueries, skillQueries, favoriteQueries } from '@skillhub/db'; + +// Helper to create mock GET request +function createMockGetRequest() { + return new NextRequest('http://localhost:3000/api/favorites'); +} + +describe('/api/favorites', () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + describe('GET', () => { + it('should return 401 when not authenticated', async () => { + mockAuth.mockResolvedValue(null); + + const response = await GET(createMockGetRequest()); + const data = await response.json(); + + expect(response.status).toBe(401); + expect(data.error).toBe('Unauthorized'); + }); + + it('should return empty array for new user', async () => { + mockAuth.mockResolvedValue({ user: { githubId: 'gh-12345' } }); + vi.mocked(userQueries.getByGithubId).mockResolvedValue(null as any); + + const response = await GET(createMockGetRequest()); + const data = await response.json(); + + expect(response.status).toBe(200); + expect(data.favorites).toEqual([]); + }); + + it('should return user favorites', async () => { + const mockUser = createMockUser(); + const mockFavorites = [ + { skill: createMockSkill({ id: 'skill-1' }) }, + { skill: createMockSkill({ id: 'skill-2' }) }, + ]; + + mockAuth.mockResolvedValue({ user: { githubId: 'gh-12345' } }); + vi.mocked(userQueries.getByGithubId).mockResolvedValue(mockUser as any); + vi.mocked(userQueries.getFavorites).mockResolvedValue(mockFavorites as any); + + const response = await GET(createMockGetRequest()); + const data = await response.json(); + + expect(response.status).toBe(200); + expect(data.favorites).toHaveLength(2); + }); + }); + + describe('POST', () => { + const createRequest = (body: unknown) => { + return new NextRequest('http://localhost:3000/api/favorites', { + method: 'POST', + body: JSON.stringify(body), + headers: { 'Content-Type': 'application/json' }, + }); + }; + + it('should return 401 when not authenticated', async () => { + mockAuth.mockResolvedValue(null); + + const request = createRequest({ skillId: 'test-skill' }); + const response = await POST(request); + const data = await response.json(); + + expect(response.status).toBe(401); + expect(data.error).toBe('Unauthorized'); + }); + + it('should return 400 when skillId missing', async () => { + mockAuth.mockResolvedValue({ user: { githubId: 'gh-12345' } }); + + const request = createRequest({}); + const response = await POST(request); + const data = await response.json(); + + expect(response.status).toBe(400); + expect(data.error).toContain('skillId'); + }); + + it('should return 404 when skill not found', async () => { + const mockUser = createMockUser(); + mockAuth.mockResolvedValue({ user: { githubId: 'gh-12345' } }); + vi.mocked(userQueries.getByGithubId).mockResolvedValue(mockUser as any); + vi.mocked(skillQueries.getById).mockResolvedValue(null as any); + + const request = createRequest({ skillId: 'nonexistent' }); + const response = await POST(request); + await response.json(); + + expect(response.status).toBe(404); + }); + + it('should add favorite successfully', async () => { + const mockUser = createMockUser(); + const mockSkill = createMockSkill(); + mockAuth.mockResolvedValue({ user: { githubId: 'gh-12345' } }); + vi.mocked(userQueries.getByGithubId).mockResolvedValue(mockUser as any); + vi.mocked(skillQueries.getById).mockResolvedValue(mockSkill as any); + vi.mocked(favoriteQueries.add).mockResolvedValue(undefined); + + const request = createRequest({ skillId: 'test-owner/test-repo/test-skill' }); + const response = await POST(request); + const data = await response.json(); + + expect(response.status).toBe(200); + expect(data.success).toBe(true); + expect(data.favorited).toBe(true); + }); + }); + + describe('DELETE', () => { + const createRequest = (body: unknown) => { + return new NextRequest('http://localhost:3000/api/favorites', { + method: 'DELETE', + body: JSON.stringify(body), + headers: { 'Content-Type': 'application/json' }, + }); + }; + + it('should return 401 when not authenticated', async () => { + mockAuth.mockResolvedValue(null); + + const request = createRequest({ skillId: 'test-skill' }); + const response = await DELETE(request); + const data = await response.json(); + + expect(response.status).toBe(401); + expect(data.error).toBe('Unauthorized'); + }); + + it('should return 400 when skillId missing', async () => { + mockAuth.mockResolvedValue({ user: { githubId: 'gh-12345' } }); + + const request = createRequest({}); + const response = await DELETE(request); + await response.json(); + + expect(response.status).toBe(400); + }); + + it('should remove favorite successfully', async () => { + const mockUser = createMockUser(); + mockAuth.mockResolvedValue({ user: { githubId: 'gh-12345' } }); + vi.mocked(userQueries.getByGithubId).mockResolvedValue(mockUser as any); + vi.mocked(favoriteQueries.remove).mockResolvedValue(undefined); + + const request = createRequest({ skillId: 'test-owner/test-repo/test-skill' }); + const response = await DELETE(request); + const data = await response.json(); + + expect(response.status).toBe(200); + expect(data.success).toBe(true); + expect(data.favorited).toBe(false); + }); + }); +}); diff --git a/apps/web/app/api/favorites/route.ts b/apps/web/app/api/favorites/route.ts new file mode 100644 index 0000000..0e0795b --- /dev/null +++ b/apps/web/app/api/favorites/route.ts @@ -0,0 +1,127 @@ +import { NextResponse, type NextRequest } from 'next/server'; +import { createDb, userQueries, favoriteQueries, skillQueries } from '@skillhub/db'; +import { auth } from '@/lib/auth'; +import { withRateLimit, createRateLimitResponse, createRateLimitHeaders } from '@/lib/rate-limit'; + +const db = createDb(); + +export async function GET(request: NextRequest) { + // Rate limiting (authenticated) + const rateLimitResult = await withRateLimit(request, 'authenticated'); + if (!rateLimitResult.allowed) { + return createRateLimitResponse(rateLimitResult); + } + + try { + const session = await auth(); + if (!session?.user?.githubId) { + return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }); + } + + const dbUser = await userQueries.getByGithubId(db, session.user.githubId); + if (!dbUser) { + return NextResponse.json({ favorites: [] }, { + headers: createRateLimitHeaders(rateLimitResult), + }); + } + + const favorites = await userQueries.getFavorites(db, dbUser.id); + return NextResponse.json({ + favorites: favorites.map((f) => ({ + id: f.skill.id, + name: f.skill.name, + description: f.skill.description, + githubOwner: f.skill.githubOwner, + githubRepo: f.skill.githubRepo, + githubStars: f.skill.githubStars, + downloadCount: f.skill.downloadCount, + securityStatus: f.skill.securityStatus, + isVerified: f.skill.isVerified, + compatibility: f.skill.compatibility, + rating: f.skill.rating, + ratingCount: f.skill.ratingCount, + })), + }, { + headers: createRateLimitHeaders(rateLimitResult), + }); + } catch (error) { + console.error('Error fetching favorites:', error); + return NextResponse.json({ error: 'Failed to fetch favorites' }, { status: 500 }); + } +} + +export async function POST(request: NextRequest) { + // Rate limiting (authenticated) + const rateLimitResult = await withRateLimit(request, 'authenticated'); + if (!rateLimitResult.allowed) { + return createRateLimitResponse(rateLimitResult); + } + + try { + const session = await auth(); + if (!session?.user?.githubId) { + return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }); + } + + const body = await request.json(); + const { skillId } = body; + + if (!skillId) { + return NextResponse.json({ error: 'skillId is required' }, { status: 400 }); + } + + const dbUser = await userQueries.getByGithubId(db, session.user.githubId); + if (!dbUser) { + return NextResponse.json({ error: 'User not found' }, { status: 404 }); + } + + // Verify skill exists + const skill = await skillQueries.getById(db, skillId); + if (!skill) { + return NextResponse.json({ error: 'Skill not found' }, { status: 404 }); + } + + await favoriteQueries.add(db, dbUser.id, skillId); + return NextResponse.json({ success: true, favorited: true }, { + headers: createRateLimitHeaders(rateLimitResult), + }); + } catch (error) { + console.error('Error adding favorite:', error); + return NextResponse.json({ error: 'Failed to add favorite' }, { status: 500 }); + } +} + +export async function DELETE(request: NextRequest) { + // Rate limiting (authenticated) + const rateLimitResult = await withRateLimit(request, 'authenticated'); + if (!rateLimitResult.allowed) { + return createRateLimitResponse(rateLimitResult); + } + + try { + const session = await auth(); + if (!session?.user?.githubId) { + return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }); + } + + const body = await request.json(); + const { skillId } = body; + + if (!skillId) { + return NextResponse.json({ error: 'skillId is required' }, { status: 400 }); + } + + const dbUser = await userQueries.getByGithubId(db, session.user.githubId); + if (!dbUser) { + return NextResponse.json({ error: 'User not found' }, { status: 404 }); + } + + await favoriteQueries.remove(db, dbUser.id, skillId); + return NextResponse.json({ success: true, favorited: false }, { + headers: createRateLimitHeaders(rateLimitResult), + }); + } catch (error) { + console.error('Error removing favorite:', error); + return NextResponse.json({ error: 'Failed to remove favorite' }, { status: 500 }); + } +} diff --git a/apps/web/app/api/health/route.test.ts b/apps/web/app/api/health/route.test.ts new file mode 100644 index 0000000..5cfc724 --- /dev/null +++ b/apps/web/app/api/health/route.test.ts @@ -0,0 +1,49 @@ +import { describe, it, expect, vi } from 'vitest'; +import { GET } from './route'; + +// Mock the database module +vi.mock('@skillhub/db', () => ({ + createDb: () => ({ + execute: vi.fn().mockResolvedValue([{ '?column?': 1 }]), + }), + sql: (strings: TemplateStringsArray, ...values: unknown[]) => ({ strings, values }), + isMeilisearchHealthy: vi.fn().mockResolvedValue(true), +})); + +describe('GET /api/health', () => { + it('should return status 200', async () => { + const response = await GET(); + expect(response.status).toBe(200); + }); + + it('should return health status', async () => { + const response = await GET(); + const data = await response.json(); + expect(['healthy', 'degraded', 'unhealthy']).toContain(data.status); + }); + + it('should return current timestamp', async () => { + const before = new Date().toISOString(); + const response = await GET(); + const data = await response.json(); + const after = new Date().toISOString(); + + expect(data.timestamp).toBeDefined(); + expect(new Date(data.timestamp).getTime()).toBeGreaterThanOrEqual(new Date(before).getTime()); + expect(new Date(data.timestamp).getTime()).toBeLessThanOrEqual(new Date(after).getTime()); + }); + + it('should return version', async () => { + const response = await GET(); + const data = await response.json(); + expect(data.version).toBe('0.1.0'); + }); + + it('should return services status', async () => { + const response = await GET(); + const data = await response.json(); + expect(data.services).toBeDefined(); + expect(data.services.database).toBeDefined(); + expect(data.services.meilisearch).toBeDefined(); + }); +}); diff --git a/apps/web/app/api/health/route.ts b/apps/web/app/api/health/route.ts new file mode 100644 index 0000000..4cd14fc --- /dev/null +++ b/apps/web/app/api/health/route.ts @@ -0,0 +1,190 @@ +import { NextResponse } from 'next/server'; +import { createDb, sql, isMeilisearchHealthy } from '@skillhub/db'; +import { isCacheAvailable } from '@/lib/cache'; + +interface ServiceStatus { + status: 'healthy' | 'unhealthy' | 'degraded'; + latency?: number; + error?: string; +} + +interface ReplicationStatus extends ServiceStatus { + isReplica?: boolean; + lagSeconds?: number; +} + +interface GitHubStatus extends ServiceStatus { + // GitHub accessibility status (for OAuth on mirror) +} + +interface HealthResponse { + status: 'healthy' | 'unhealthy' | 'degraded'; + timestamp: string; + version: string; + isPrimary: boolean; + services: { + database: ServiceStatus; + meilisearch: ServiceStatus; + redis: ServiceStatus; + replication?: ReplicationStatus; + github?: GitHubStatus; + }; +} + +export async function GET() { + const isPrimary = process.env.IS_PRIMARY_SERVER !== 'false'; + + const services: HealthResponse['services'] = { + database: { status: 'unhealthy' }, + meilisearch: { status: 'unhealthy' }, + redis: { status: 'unhealthy' }, + }; + + // Check database connectivity + try { + const dbStart = Date.now(); + const db = createDb(); + await db.execute(sql`SELECT 1`); + services.database = { + status: 'healthy', + latency: Date.now() - dbStart, + }; + } catch (error) { + // Don't expose DATABASE_URL or connection details in error messages + const errorMessage = error instanceof Error ? error.message : 'Unknown error'; + // Sanitize error message - remove any potential credentials + const sanitizedError = errorMessage + .replace(/postgresql:\/\/[^@]*@/gi, 'postgresql://***@') + .replace(/password[=:][^\s&]*/gi, 'password=***'); + + services.database = { + status: 'unhealthy', + error: process.env.DATABASE_URL ? sanitizedError : 'DATABASE_URL not configured', + }; + } + + // Check Meilisearch connectivity (optional service) + try { + const meiliStart = Date.now(); + const meiliHealthy = await isMeilisearchHealthy(); + if (meiliHealthy) { + services.meilisearch = { + status: 'healthy', + latency: Date.now() - meiliStart, + }; + } else { + services.meilisearch = { + status: 'degraded', + error: 'Meilisearch not responding or not configured', + }; + } + } catch (error) { + services.meilisearch = { + status: 'degraded', + error: error instanceof Error ? error.message : 'Unknown error', + }; + } + + // Check Redis connectivity (optional service for caching and rate limiting) + try { + const redisStart = Date.now(); + const redisHealthy = await isCacheAvailable(); + if (redisHealthy) { + services.redis = { + status: 'healthy', + latency: Date.now() - redisStart, + }; + } else { + services.redis = { + status: 'degraded', + error: 'Redis not responding or not configured', + }; + } + } catch (error) { + services.redis = { + status: 'degraded', + error: error instanceof Error ? error.message : 'Unknown error', + }; + } + + // Mirror server only: Check PostgreSQL replication status + if (!isPrimary) { + try { + const db = createDb(); + // drizzle-orm/postgres-js returns results directly as an array + const lagResult = await db.execute<{ is_replica: boolean; lag_seconds: number | null }>(sql` + SELECT + pg_is_in_recovery() as is_replica, + EXTRACT(EPOCH FROM (now() - pg_last_xact_replay_timestamp()))::int as lag_seconds + `); + + const row = lagResult[0]; + const isReplica = row?.is_replica ?? false; + const lagSeconds = row?.lag_seconds ?? 0; + + // Healthy if lag is under 5 minutes (300 seconds) + services.replication = { + status: isReplica && lagSeconds < 300 ? 'healthy' : 'degraded', + isReplica, + lagSeconds, + latency: undefined, + }; + } catch (error) { + services.replication = { + status: 'degraded', + error: error instanceof Error ? error.message : 'Failed to check replication status', + isReplica: false, + lagSeconds: undefined, + }; + } + + // Mirror server only: Check GitHub accessibility (for OAuth) + try { + const controller = new AbortController(); + const timeoutId = setTimeout(() => controller.abort(), 5000); + + const githubStart = Date.now(); + const githubCheck = await fetch('https://github.com', { + method: 'HEAD', + signal: controller.signal, + }); + clearTimeout(timeoutId); + + services.github = { + status: githubCheck.ok ? 'healthy' : 'degraded', + latency: Date.now() - githubStart, + }; + } catch { + // GitHub likely filtered/blocked in Iran + services.github = { + status: 'degraded', + error: 'GitHub unreachable (may be filtered)', + }; + } + } + + // Determine overall status + let overallStatus: 'healthy' | 'unhealthy' | 'degraded' = 'healthy'; + + // Database is critical - if it's down, the whole system is unhealthy + if (services.database.status === 'unhealthy') { + overallStatus = 'unhealthy'; + } + // Meilisearch and Redis are optional - if either is down, system is degraded but still functional + else if (services.meilisearch.status !== 'healthy' || services.redis.status !== 'healthy') { + overallStatus = 'degraded'; + } + + const response: HealthResponse = { + status: overallStatus, + timestamp: new Date().toISOString(), + version: process.env.npm_package_version || '0.1.0', + isPrimary, + services, + }; + + // Return appropriate HTTP status code for load balancers/orchestrators + // 200 = healthy, 503 = unhealthy (critical service down) + const statusCode = overallStatus === 'unhealthy' ? 503 : 200; + return NextResponse.json(response, { status: statusCode }); +} diff --git a/apps/web/app/api/newsletter/subscribe/route.ts b/apps/web/app/api/newsletter/subscribe/route.ts new file mode 100644 index 0000000..48fb859 --- /dev/null +++ b/apps/web/app/api/newsletter/subscribe/route.ts @@ -0,0 +1,114 @@ +import type { NextRequest } from 'next/server'; +import { NextResponse } from 'next/server'; +import { createDb } from '@skillhub/db'; +import { emailSubscriptionQueries } from '@skillhub/db'; +import { withRateLimit, createRateLimitResponse } from '@/lib/rate-limit'; +import { sendNewsletterWelcomeEmail } from '@/lib/email'; + +const PRIMARY_URL = process.env.PRIMARY_URL || process.env.NEXT_PUBLIC_APP_URL || 'https://skills.palebluedot.live'; + +// Email validation regex (RFC 5322 simplified) +const EMAIL_REGEX = /^[a-zA-Z0-9.!#$%&'*+/=?^_`{|}~-]+@[a-zA-Z0-9](?:[a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?(?:\.[a-zA-Z0-9](?:[a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?)*$/; + +/** + * GET handler for one-click newsletter subscription from email links + * Subscribes and redirects to homepage with confirmation + */ +export async function GET(request: NextRequest) { + try { + const email = request.nextUrl.searchParams.get('email'); + const locale = request.nextUrl.searchParams.get('locale') || 'en'; + + if (!email) { + return NextResponse.redirect(new URL('/', PRIMARY_URL)); + } + + const sanitizedEmail = email.toLowerCase().trim().slice(0, 255); + if (!EMAIL_REGEX.test(sanitizedEmail)) { + return NextResponse.redirect(new URL('/', PRIMARY_URL)); + } + + const db = createDb(); + await emailSubscriptionQueries.subscribe(db, { + email: sanitizedEmail, + source: 'newsletter', + marketingConsent: true, + }); + + const validLocales = ['en', 'fa']; + const sanitizedLocale = validLocales.includes(locale) ? locale : 'en'; + + sendNewsletterWelcomeEmail(sanitizedEmail, sanitizedLocale as 'en' | 'fa').catch((err) => { + console.error('[Newsletter] Failed to send newsletter welcome email:', err); + }); + + const redirectUrl = new URL('/', PRIMARY_URL); + redirectUrl.searchParams.set('subscribed', 'true'); + return NextResponse.redirect(redirectUrl); + } catch (error) { + console.error('[Newsletter] Subscribe GET error:', error); + return NextResponse.redirect(new URL('/', PRIMARY_URL)); + } +} + +export async function POST(request: NextRequest) { + try { + // Rate limiting (reuse 'search' tier: 60/min) + const rateLimitResult = await withRateLimit(request, 'search'); + if (!rateLimitResult.allowed) { + return createRateLimitResponse(rateLimitResult, 'search'); + } + + const body = await request.json(); + const { email, source, marketingConsent, locale } = body; + + // Validate email + if (!email || typeof email !== 'string') { + return NextResponse.json( + { error: 'Email is required' }, + { status: 400 } + ); + } + + const sanitizedEmail = email.toLowerCase().trim().slice(0, 255); + + if (!EMAIL_REGEX.test(sanitizedEmail)) { + return NextResponse.json( + { error: 'Invalid email address' }, + { status: 400 } + ); + } + + // Validate source + const validSources = ['newsletter', 'oauth', 'claim', 'early-access']; + const sanitizedSource = validSources.includes(source) ? source : 'newsletter'; + + // Store in database + const db = createDb(); + await emailSubscriptionQueries.subscribe(db, { + email: sanitizedEmail, + source: sanitizedSource, + marketingConsent: Boolean(marketingConsent), + }); + + // Validate locale + const validLocales = ['en', 'fa']; + const sanitizedLocale = validLocales.includes(locale) ? locale : 'en'; + + // Send newsletter welcome email (non-blocking, don't fail if email sending fails) + sendNewsletterWelcomeEmail(sanitizedEmail, sanitizedLocale).catch((err) => { + console.error('[Newsletter] Failed to send newsletter welcome email:', err); + }); + + return NextResponse.json({ + success: true, + subscribed: true, + }); + } catch (error) { + console.error('[Newsletter] Subscribe error:', error); + return NextResponse.json( + { error: 'An error occurred. Please try again.' }, + { status: 500 } + ); + } +} diff --git a/apps/web/app/api/newsletter/unsubscribe/route.ts b/apps/web/app/api/newsletter/unsubscribe/route.ts new file mode 100644 index 0000000..c355618 --- /dev/null +++ b/apps/web/app/api/newsletter/unsubscribe/route.ts @@ -0,0 +1,77 @@ +import type { NextRequest } from 'next/server'; +import { NextResponse } from 'next/server'; +import { createDb } from '@skillhub/db'; +import { emailSubscriptionQueries } from '@skillhub/db'; +import { withRateLimit, createRateLimitResponse } from '@/lib/rate-limit'; + +const PRIMARY_URL = process.env.PRIMARY_URL || process.env.NEXT_PUBLIC_APP_URL || 'https://skills.palebluedot.live'; + +export async function POST(request: NextRequest) { + try { + // Rate limiting (reuse 'search' tier: 60/min) + const rateLimitResult = await withRateLimit(request, 'search'); + if (!rateLimitResult.allowed) { + return createRateLimitResponse(rateLimitResult, 'search'); + } + + // POST is already blocked by middleware on mirror servers (503) + const body = await request.json(); + const { email } = body; + + if (!email || typeof email !== 'string') { + return NextResponse.json( + { error: 'Email is required' }, + { status: 400 } + ); + } + + const db = createDb(); + await emailSubscriptionQueries.unsubscribe(db, email); + + // Always return success to prevent email enumeration + return NextResponse.json({ + success: true, + unsubscribed: true, + }); + } catch (error) { + console.error('[Newsletter] Unsubscribe error:', error); + return NextResponse.json( + { error: 'An error occurred. Please try again.' }, + { status: 500 } + ); + } +} + +/** + * GET handler for one-click unsubscribe links in emails + * Redirects to homepage after unsubscribing + * On mirror servers, redirects to primary server for the unsubscribe action + */ +export async function GET(request: NextRequest) { + try { + const email = request.nextUrl.searchParams.get('email'); + + if (!email) { + return NextResponse.redirect(new URL('/', PRIMARY_URL)); + } + + // On mirror server, redirect to primary for write operation + const isPrimary = process.env.IS_PRIMARY_SERVER !== 'false'; + if (!isPrimary) { + return NextResponse.redirect( + `${PRIMARY_URL}/api/newsletter/unsubscribe?email=${encodeURIComponent(email)}` + ); + } + + const db = createDb(); + await emailSubscriptionQueries.unsubscribe(db, email); + + // Redirect to homepage with unsubscribe confirmation + const redirectUrl = new URL('/', PRIMARY_URL); + redirectUrl.searchParams.set('unsubscribed', 'true'); + return NextResponse.redirect(redirectUrl); + } catch (error) { + console.error('[Newsletter] Unsubscribe GET error:', error); + return NextResponse.redirect(new URL('/', request.url)); + } +} diff --git a/apps/web/app/api/ratings/me/route.ts b/apps/web/app/api/ratings/me/route.ts new file mode 100644 index 0000000..d30448c --- /dev/null +++ b/apps/web/app/api/ratings/me/route.ts @@ -0,0 +1,43 @@ +import { NextResponse, type NextRequest } from 'next/server'; +import { createDb, ratingQueries, userQueries } from '@skillhub/db'; +import { auth } from '@/lib/auth'; +import { withRateLimit, createRateLimitResponse, createRateLimitHeaders } from '@/lib/rate-limit'; + +const db = createDb(); + +export async function GET(request: NextRequest) { + // Rate limiting (authenticated) + const rateLimitResult = await withRateLimit(request, 'authenticated'); + if (!rateLimitResult.allowed) { + return createRateLimitResponse(rateLimitResult); + } + + try { + const session = await auth(); + if (!session?.user?.githubId) { + return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }); + } + + const { searchParams } = new URL(request.url); + const skillId = searchParams.get('skillId'); + + if (!skillId) { + return NextResponse.json({ error: 'skillId is required' }, { status: 400 }); + } + + const dbUser = await userQueries.getByGithubId(db, session.user.githubId); + if (!dbUser) { + return NextResponse.json({ rating: null }, { + headers: createRateLimitHeaders(rateLimitResult), + }); + } + + const rating = await ratingQueries.getUserRating(db, dbUser.id, skillId); + return NextResponse.json({ rating }, { + headers: createRateLimitHeaders(rateLimitResult), + }); + } catch (error) { + console.error('Error fetching user rating:', error); + return NextResponse.json({ error: 'Failed to fetch rating' }, { status: 500 }); + } +} diff --git a/apps/web/app/api/ratings/route.test.ts b/apps/web/app/api/ratings/route.test.ts new file mode 100644 index 0000000..3e33723 --- /dev/null +++ b/apps/web/app/api/ratings/route.test.ts @@ -0,0 +1,281 @@ +import { describe, it, expect, vi, beforeEach } from 'vitest'; +import { NextRequest } from 'next/server'; + +// Helper to create mock skill +function createMockSkill(overrides: Partial<{ + id: string; + rating: number; + ratingCount: number; +}> = {}) { + return { + id: 'test-owner/test-repo/test-skill', + name: 'test-skill', + rating: 4, + ratingCount: 10, + ...overrides, + }; +} + +// Helper to create mock user +function createMockUser(overrides: Partial<{ id: string; githubId: string; username: string }> = {}) { + return { + id: 'user-123', + githubId: 'gh-12345', + username: 'testuser', + avatarUrl: 'https://example.com/avatar.png', + ...overrides, + }; +} + +// Helper to create mock rating +function createMockRating(overrides: Partial<{ + id: string; + rating: number; + review: string; +}> = {}) { + return { + id: 'rating-1', + skillId: 'test-owner/test-repo/test-skill', + userId: 'user-123', + rating: 4, + review: 'Great skill!', + createdAt: new Date(), + updatedAt: new Date(), + ...overrides, + }; +} + +// Mock auth +const mockAuth = vi.fn(); +vi.mock('@/lib/auth', () => ({ + auth: () => mockAuth(), +})); + +// Mock db +vi.mock('@skillhub/db', () => ({ + createDb: vi.fn(() => ({})), + userQueries: { + getByGithubId: vi.fn(), + }, + skillQueries: { + getById: vi.fn(), + }, + ratingQueries: { + getForSkill: vi.fn(), + upsert: vi.fn(), + }, +})); + +import { GET, POST } from './route'; +import { userQueries, skillQueries, ratingQueries } from '@skillhub/db'; + +describe('/api/ratings', () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + describe('GET', () => { + const createRequest = (searchParams: Record = {}) => { + const url = new URL('http://localhost:3000/api/ratings'); + Object.entries(searchParams).forEach(([key, value]) => { + url.searchParams.set(key, value); + }); + return new NextRequest(url); + }; + + it('should return 400 when skillId missing', async () => { + const request = createRequest(); + const response = await GET(request); + const data = await response.json(); + + expect(response.status).toBe(400); + expect(data.error).toContain('skillId'); + }); + + it('should return ratings for skill', async () => { + const mockSkill = createMockSkill(); + const mockRatings = [ + { + rating: createMockRating({ id: 'rating-1' }), + user: createMockUser(), + }, + ]; + + vi.mocked(skillQueries.getById).mockResolvedValue(mockSkill as any); + vi.mocked(ratingQueries.getForSkill).mockResolvedValue(mockRatings as any); + + const request = createRequest({ skillId: 'test-owner/test-repo/test-skill' }); + const response = await GET(request); + const data = await response.json(); + + expect(response.status).toBe(200); + expect(data.ratings).toBeDefined(); + expect(Array.isArray(data.ratings)).toBe(true); + }); + + it('should include user info', async () => { + const mockSkill = createMockSkill(); + const mockRatings = [ + { + rating: createMockRating(), + user: createMockUser({ username: 'testuser' }), + }, + ]; + + vi.mocked(skillQueries.getById).mockResolvedValue(mockSkill as any); + vi.mocked(ratingQueries.getForSkill).mockResolvedValue(mockRatings as any); + + const request = createRequest({ skillId: 'test-owner/test-repo/test-skill' }); + const response = await GET(request); + const data = await response.json(); + + expect(data.ratings[0].user).toBeDefined(); + expect(data.ratings[0].user.username).toBe('testuser'); + }); + + it('should include rating summary', async () => { + const mockSkill = createMockSkill({ rating: 4, ratingCount: 10 }); + vi.mocked(skillQueries.getById).mockResolvedValue(mockSkill as any); + vi.mocked(ratingQueries.getForSkill).mockResolvedValue([]); + + const request = createRequest({ skillId: 'test-owner/test-repo/test-skill' }); + const response = await GET(request); + const data = await response.json(); + + expect(data.summary).toBeDefined(); + expect(data.summary.average).toBe(4); + expect(data.summary.count).toBe(10); + }); + + it('should respect pagination', async () => { + const mockSkill = createMockSkill(); + vi.mocked(skillQueries.getById).mockResolvedValue(mockSkill as any); + vi.mocked(ratingQueries.getForSkill).mockResolvedValue([]); + + const request = createRequest({ + skillId: 'test-owner/test-repo/test-skill', + limit: '5', + offset: '10', + }); + const response = await GET(request); + + expect(response.status).toBe(200); + expect(ratingQueries.getForSkill).toHaveBeenCalledWith( + expect.anything(), + 'test-owner/test-repo/test-skill', + 5, + 10 + ); + }); + }); + + describe('POST', () => { + const createRequest = (body: unknown) => { + return new NextRequest('http://localhost:3000/api/ratings', { + method: 'POST', + body: JSON.stringify(body), + headers: { 'Content-Type': 'application/json' }, + }); + }; + + it('should return 401 when not authenticated', async () => { + mockAuth.mockResolvedValue(null); + + const request = createRequest({ skillId: 'test-skill', rating: 5 }); + const response = await POST(request); + const data = await response.json(); + + expect(response.status).toBe(401); + expect(data.error).toBe('Unauthorized'); + }); + + it('should return 400 when skillId missing', async () => { + mockAuth.mockResolvedValue({ user: { githubId: 'gh-12345' } }); + + const request = createRequest({ rating: 5 }); + const response = await POST(request); + const data = await response.json(); + + expect(response.status).toBe(400); + expect(data.error).toContain('skillId'); + }); + + it('should return 400 when rating invalid', async () => { + mockAuth.mockResolvedValue({ user: { githubId: 'gh-12345' } }); + + const request = createRequest({ skillId: 'test-skill', rating: 6 }); + const response = await POST(request); + const data = await response.json(); + + expect(response.status).toBe(400); + expect(data.error).toContain('between 1 and 5'); + }); + + it('should return 400 when rating is zero', async () => { + mockAuth.mockResolvedValue({ user: { githubId: 'gh-12345' } }); + + const request = createRequest({ skillId: 'test-skill', rating: 0 }); + const response = await POST(request); + await response.json(); + + expect(response.status).toBe(400); + }); + + it('should return 404 when skill not found', async () => { + const mockUser = createMockUser(); + mockAuth.mockResolvedValue({ user: { githubId: 'gh-12345' } }); + vi.mocked(userQueries.getByGithubId).mockResolvedValue(mockUser as any); + vi.mocked(skillQueries.getById).mockResolvedValue(null as any); + + const request = createRequest({ skillId: 'nonexistent', rating: 5 }); + const response = await POST(request); + await response.json(); + + expect(response.status).toBe(404); + }); + + it('should create rating successfully', async () => { + const mockUser = createMockUser(); + const mockSkill = createMockSkill(); + const mockRating = createMockRating({ rating: 5 }); + + mockAuth.mockResolvedValue({ user: { githubId: 'gh-12345' } }); + vi.mocked(userQueries.getByGithubId).mockResolvedValue(mockUser as any); + vi.mocked(skillQueries.getById).mockResolvedValue(mockSkill as any); + vi.mocked(ratingQueries.upsert).mockResolvedValue(mockRating as any); + + const request = createRequest({ + skillId: 'test-owner/test-repo/test-skill', + rating: 5, + review: 'Excellent!', + }); + const response = await POST(request); + const data = await response.json(); + + expect(response.status).toBe(200); + expect(data.rating).toBeDefined(); + expect(data.summary).toBeDefined(); + }); + + it('should update existing rating', async () => { + const mockUser = createMockUser(); + const mockSkill = createMockSkill({ rating: 4, ratingCount: 1 }); + const mockRating = createMockRating({ rating: 4 }); + + mockAuth.mockResolvedValue({ user: { githubId: 'gh-12345' } }); + vi.mocked(userQueries.getByGithubId).mockResolvedValue(mockUser as any); + vi.mocked(skillQueries.getById).mockResolvedValue(mockSkill as any); + vi.mocked(ratingQueries.upsert).mockResolvedValue(mockRating as any); + + const request = createRequest({ + skillId: 'test-owner/test-repo/test-skill', + rating: 4, + }); + const response = await POST(request); + await response.json(); + + expect(response.status).toBe(200); + expect(ratingQueries.upsert).toHaveBeenCalled(); + }); + }); +}); diff --git a/apps/web/app/api/ratings/route.ts b/apps/web/app/api/ratings/route.ts new file mode 100644 index 0000000..a035082 --- /dev/null +++ b/apps/web/app/api/ratings/route.ts @@ -0,0 +1,122 @@ +import { NextResponse, type NextRequest } from 'next/server'; +import { createDb, ratingQueries, skillQueries, userQueries } from '@skillhub/db'; +import { auth } from '@/lib/auth'; +import { withRateLimit, createRateLimitResponse, createRateLimitHeaders } from '@/lib/rate-limit'; +import { sanitizeReview } from '@/lib/sanitize'; + +const db = createDb(); + +export async function GET(request: NextRequest) { + // Rate limiting + const rateLimitResult = await withRateLimit(request, 'anonymous'); + if (!rateLimitResult.allowed) { + return createRateLimitResponse(rateLimitResult); + } + + try { + const { searchParams } = new URL(request.url); + const skillId = searchParams.get('skillId'); + + // Parse and validate pagination parameters + const limitRaw = parseInt(searchParams.get('limit') || '10'); + const limit = isNaN(limitRaw) || limitRaw < 1 ? 10 : Math.min(limitRaw, 100); + + const offsetRaw = parseInt(searchParams.get('offset') || '0'); + const offset = isNaN(offsetRaw) || offsetRaw < 0 ? 0 : offsetRaw; + + if (!skillId) { + return NextResponse.json({ error: 'skillId is required' }, { status: 400 }); + } + + const ratings = await ratingQueries.getForSkill(db, skillId, limit, offset); + const skill = await skillQueries.getById(db, skillId); + + return NextResponse.json({ + ratings: ratings.map((r) => ({ + id: r.rating.id, + rating: r.rating.rating, + review: r.rating.review, + createdAt: r.rating.createdAt, + updatedAt: r.rating.updatedAt, + user: { + id: r.user.id, + username: r.user.username, + avatarUrl: r.user.avatarUrl, + }, + })), + summary: { + average: skill?.rating || 0, + count: skill?.ratingCount || 0, + }, + }, { + headers: createRateLimitHeaders(rateLimitResult), + }); + } catch (error) { + console.error('Error fetching ratings:', error); + return NextResponse.json({ error: 'Failed to fetch ratings' }, { status: 500 }); + } +} + +export async function POST(request: NextRequest) { + // Rate limiting (authenticated tier for POST) + const rateLimitResult = await withRateLimit(request, 'authenticated'); + if (!rateLimitResult.allowed) { + return createRateLimitResponse(rateLimitResult); + } + + try { + const session = await auth(); + if (!session?.user?.githubId) { + return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }); + } + + const body = await request.json(); + const { skillId, rating, review } = body; + + if (!skillId) { + return NextResponse.json({ error: 'skillId is required' }, { status: 400 }); + } + + // Validate rating + const ratingValue = parseInt(rating); + if (isNaN(ratingValue) || ratingValue < 1 || ratingValue > 5) { + return NextResponse.json({ error: 'Rating must be between 1 and 5' }, { status: 400 }); + } + + // Get database user ID + const dbUser = await userQueries.getByGithubId(db, session.user.githubId); + if (!dbUser) { + return NextResponse.json({ error: 'User not found' }, { status: 404 }); + } + + // Verify skill exists + const skill = await skillQueries.getById(db, skillId); + if (!skill) { + return NextResponse.json({ error: 'Skill not found' }, { status: 404 }); + } + + // Upsert rating with sanitized review + const result = await ratingQueries.upsert(db, { + skillId, + userId: dbUser.id, + rating: ratingValue, + review: sanitizeReview(review) ?? undefined, + }); + + // Get updated skill aggregates + const updatedSkill = await skillQueries.getById(db, skillId); + + return NextResponse.json({ + rating: result, + summary: { + average: updatedSkill?.rating || 0, + count: updatedSkill?.ratingCount || 0, + }, + }, { + headers: createRateLimitHeaders(rateLimitResult), + }); + } catch (error) { + console.error('Error creating rating:', error); + return NextResponse.json({ error: 'Failed to create rating' }, { status: 500 }); + } +} diff --git a/apps/web/app/api/skill-files/route.ts b/apps/web/app/api/skill-files/route.ts new file mode 100644 index 0000000..116b266 --- /dev/null +++ b/apps/web/app/api/skill-files/route.ts @@ -0,0 +1,380 @@ +import { NextResponse, type NextRequest } from 'next/server'; +import { createDb, skillQueries } from '@skillhub/db'; +import { withRateLimit, createRateLimitResponse, createRateLimitHeaders } from '@/lib/rate-limit'; +import { getGitHubHeaders, updateTokenStats } from '@/lib/github-token-manager'; + +// Route configuration for longer timeout (skills with many files) +export const maxDuration = 60; // 60 seconds + +// Maximum recursion depth to prevent infinite loops +const MAX_DEPTH = 5; + +// Fetch timeout in milliseconds +const FETCH_TIMEOUT = 30000; // 30 seconds + +// Create database connection +const db = createDb(); + +interface GitHubFile { + name: string; + path: string; + sha: string; + size: number; + type: 'file' | 'dir'; + download_url: string | null; +} + +interface SkillFile { + name: string; + path: string; + content: string; + size: number; + isBinary: boolean; +} + +interface CachedFiles { + fetchedAt: string; + commitSha: string; + totalSize: number; + items: SkillFile[]; +} + +/** + * GET /api/skill-files?id=anthropics/skills/pdf + * Returns all files in a skill folder, using cache when available + * + * Flow: + * 1. Check if cached files exist in database + * 2. If cache is valid (commitSha matches), return from cache (FAST) + * 3. If cache is stale or missing, fetch from GitHub + * 4. Save to cache for future requests + * 5. Return files + */ +export async function GET(request: NextRequest) { + // Rate limiting (use search tier as this is an expensive operation) + const rateLimitResult = await withRateLimit(request, 'search'); + if (!rateLimitResult.allowed) { + return createRateLimitResponse(rateLimitResult); + } + + try { + const skillId = request.nextUrl.searchParams.get('id'); + + if (!skillId) { + return NextResponse.json( + { error: 'Missing skill ID parameter', code: 'MISSING_ID' }, + { status: 400 } + ); + } + + // Get skill from database + const skill = await skillQueries.getById(db, skillId); + + if (!skill) { + return NextResponse.json( + { error: 'Skill not found', code: 'NOT_FOUND' }, + { status: 404 } + ); + } + + const { githubOwner, githubRepo, skillPath, branch, commitSha, sourceFormat } = skill; + + // === CACHE CHECK === + // Try to get cached files first (this is the fast path) + const cachedFiles = await skillQueries.getCachedFiles(db, skillId); + + if (cachedFiles) { + // eslint-disable-next-line no-console + console.log(`[skill-files] Cache HIT for ${skillId}`); + + // Note: Download count is NOT incremented here. + // It should only be incremented in /api/skills/install after successful download. + // This endpoint is just for fetching files - the download may still fail + // (e.g., if JSZip fails to load from CDN). + + return NextResponse.json({ + skillId, + githubOwner, + githubRepo, + skillPath, + branch: branch || 'main', + sourceFormat: sourceFormat || 'skill.md', + files: cachedFiles.items.map(item => ({ + name: item.name, + path: item.path, + type: 'file' as const, + size: item.size, + content: item.isBinary ? undefined : item.content, + downloadUrl: item.isBinary ? `https://raw.githubusercontent.com/${githubOwner}/${githubRepo}/${branch || 'main'}/${skillPath}/${item.path}` : undefined, + })), + fromCache: true, + cachedAt: cachedFiles.fetchedAt, + }, { + headers: createRateLimitHeaders(rateLimitResult), + }); + } + + // === CACHE MISS - Fetch from GitHub === + // eslint-disable-next-line no-console + console.log(`[skill-files] Cache MISS for ${skillId}, fetching from GitHub...`); + + // Get GitHub headers with token rotation + const { headers: githubHeaders, token } = await getGitHubHeaders(); + + // Warn if no GitHub token (limited to 60 req/hr) + if (!token) { + console.warn('No GITHUB_TOKEN configured - limited to 60 requests/hour'); + } + + // Fetch skill folder contents from GitHub + const files = await fetchSkillFiles( + githubOwner, + githubRepo, + skillPath, + branch || 'main', + 0, // Start at depth 0 + githubHeaders, + token + ); + + // === SAVE TO CACHE === + // Prepare cached files structure + const filesToCache: CachedFiles = { + fetchedAt: new Date().toISOString(), + commitSha: commitSha || 'unknown', + totalSize: files.reduce((sum, f) => sum + f.size, 0), + items: files, + }; + + // Save to database (async, don't block response) + skillQueries.updateCachedFiles(db, skillId, filesToCache).catch((err) => { + console.error(`[skill-files] Failed to cache files for ${skillId}:`, err); + }); + + // Note: Download count is NOT incremented here. + // It should only be incremented in /api/skills/install after successful download. + + // Return response with files + return NextResponse.json({ + skillId, + githubOwner, + githubRepo, + skillPath, + branch: branch || 'main', + sourceFormat: sourceFormat || 'skill.md', + files: files.map(item => ({ + name: item.name, + path: item.path, + type: 'file' as const, + size: item.size, + content: item.isBinary ? undefined : item.content, + downloadUrl: item.isBinary ? `https://raw.githubusercontent.com/${githubOwner}/${githubRepo}/${branch || 'main'}/${skillPath}/${item.path}` : undefined, + })), + fromCache: false, + }, { + headers: createRateLimitHeaders(rateLimitResult), + }); + } catch (error) { + console.error('Error fetching skill files:', error); + + // Return specific error codes for different failure types + const errorMessage = error instanceof Error ? error.message : 'Unknown error'; + + if (errorMessage.includes('rate limit') || errorMessage.includes('403')) { + return NextResponse.json( + { error: 'GitHub API rate limit exceeded. Please try again later.', code: 'RATE_LIMIT' }, + { status: 429 } + ); + } + + if (errorMessage.includes('timeout') || errorMessage.includes('AbortError')) { + return NextResponse.json( + { error: 'Request timed out. The skill may have too many files.', code: 'TIMEOUT' }, + { status: 504 } + ); + } + + if (errorMessage.includes('404')) { + return NextResponse.json( + { error: 'Skill files not found on GitHub', code: 'GITHUB_NOT_FOUND' }, + { status: 404 } + ); + } + + return NextResponse.json( + { error: 'Failed to fetch skill files', code: 'FETCH_ERROR' }, + { status: 500 } + ); + } +} + +/** + * Recursively fetch all files in a skill folder from GitHub + * @param depth - Current recursion depth (max MAX_DEPTH levels) + * @param headers - GitHub API headers (with token rotation) + * @param token - Current token for stats tracking + */ +async function fetchSkillFiles( + owner: string, + repo: string, + path: string, + ref: string, + depth: number = 0, + headers: Record, + token: string | null +): Promise { + // Prevent infinite recursion + if (depth > MAX_DEPTH) { + console.warn(`Max depth (${MAX_DEPTH}) reached at: ${path}`); + return []; + } + + const files: SkillFile[] = []; + + // Fetch directory contents with timeout + const apiUrl = `https://api.github.com/repos/${owner}/${repo}/contents/${path}?ref=${ref}`; + const response = await fetch(apiUrl, { + headers, + signal: AbortSignal.timeout(FETCH_TIMEOUT), + }); + + // Update token stats for rotation + if (token) { + await updateTokenStats(token, response.headers); + } + + if (!response.ok) { + // Check for rate limiting + if (response.status === 403) { + const rateLimitRemaining = response.headers.get('x-ratelimit-remaining'); + if (rateLimitRemaining === '0') { + throw new Error('GitHub API rate limit exceeded'); + } + } + throw new Error(`GitHub API error: ${response.status}`); + } + + const contents: GitHubFile[] = await response.json(); + + // Process each item + for (const item of contents) { + if (item.type === 'file') { + // Determine if file is binary + const isBinary = !isTextFile(item.name); + + if (!isBinary && item.size < 1024 * 1024) { + // For text files (< 1MB), fetch content + try { + const content = await fetchFileContent(owner, repo, item.path, ref, headers, token); + files.push({ + name: item.name, + path: item.path.replace(`${path}/`, '').replace(path, '') || item.name, + content, + size: item.size, + isBinary: false, + }); + } catch { + // If content fetch fails, mark as binary (will use download URL) + files.push({ + name: item.name, + path: item.path.replace(`${path}/`, '').replace(path, '') || item.name, + content: '', + size: item.size, + isBinary: true, + }); + } + } else { + // For binary or large files, don't store content (use download URL) + files.push({ + name: item.name, + path: item.path.replace(`${path}/`, '').replace(path, '') || item.name, + content: '', + size: item.size, + isBinary: true, + }); + } + } else if (item.type === 'dir') { + // Recursively fetch subdirectory (with depth limit) + const subPath = `${path}/${item.name}`; + const subFiles = await fetchSkillFiles(owner, repo, subPath, ref, depth + 1, headers, token); + + // Add subdirectory files with proper paths + for (const subFile of subFiles) { + files.push({ + ...subFile, + path: `${item.name}/${subFile.path}`, + }); + } + } + } + + return files; +} + +/** + * Fetch file content from GitHub with timeout + */ +async function fetchFileContent( + owner: string, + repo: string, + path: string, + ref: string, + headers: Record, + token: string | null +): Promise { + const apiUrl = `https://api.github.com/repos/${owner}/${repo}/contents/${path}?ref=${ref}`; + const response = await fetch(apiUrl, { + headers, + signal: AbortSignal.timeout(FETCH_TIMEOUT), + }); + + // Update token stats for rotation + if (token) { + await updateTokenStats(token, response.headers); + } + + if (!response.ok) { + throw new Error(`Failed to fetch file: ${path} (${response.status})`); + } + + const data = await response.json(); + + if (data.content && data.encoding === 'base64') { + return Buffer.from(data.content, 'base64').toString('utf-8'); + } + + throw new Error(`Invalid file content for: ${path}`); +} + +/** + * Check if file is a text file based on extension + * Note: .env files are excluded for security (could contain secrets) + */ +/** + * Known instruction file names that are always plain text + */ +const KNOWN_TEXT_FILENAMES = ['SKILL.md', 'AGENTS.md', '.cursorrules', '.windsurfrules', 'copilot-instructions.md']; + +function isTextFile(filename: string): boolean { + // Exclude potentially sensitive files + const sensitivePatterns = ['.env', '.secret', '.key', '.pem', '.credential']; + const lowerFilename = filename.toLowerCase(); + if (sensitivePatterns.some((p) => lowerFilename.includes(p))) { + return false; + } + + // Known instruction dotfiles that are plain text + if (KNOWN_TEXT_FILENAMES.includes(filename)) { + return true; + } + + const textExtensions = [ + '.md', '.txt', '.json', '.yaml', '.yml', '.xml', '.html', '.css', + '.js', '.ts', '.jsx', '.tsx', '.py', '.sh', '.bash', '.ps1', + '.rb', '.go', '.rs', '.java', '.c', '.cpp', '.h', '.hpp', + '.toml', '.ini', '.cfg', '.conf', '.gitignore', '.editorconfig', + ]; + + const ext = filename.substring(filename.lastIndexOf('.')).toLowerCase(); + return textExtensions.includes(ext) || !filename.includes('.'); +} diff --git a/apps/web/app/api/skill-files/zip/route.ts b/apps/web/app/api/skill-files/zip/route.ts new file mode 100644 index 0000000..5d4e6da --- /dev/null +++ b/apps/web/app/api/skill-files/zip/route.ts @@ -0,0 +1,438 @@ +import { type NextRequest } from 'next/server'; +import { createDb, skillQueries } from '@skillhub/db'; +import { withRateLimit, createRateLimitHeaders } from '@/lib/rate-limit'; +import { getGitHubHeaders, updateTokenStats } from '@/lib/github-token-manager'; +import { INSTRUCTION_FILE_PATTERNS } from 'skillhub-core'; +import archiver from 'archiver'; +import { Readable } from 'stream'; + +// Route configuration for longer timeout +export const maxDuration = 60; + +// Create database connection +const db = createDb(); + +// Fetch timeout in milliseconds +const FETCH_TIMEOUT = 30000; + +// Maximum recursion depth +const MAX_DEPTH = 5; + +type Platform = 'claude' | 'codex' | 'copilot' | 'cursor' | 'windsurf'; + +const VALID_PLATFORMS: Platform[] = ['claude', 'codex', 'copilot', 'cursor', 'windsurf']; + +// All known main instruction file names +const MAIN_FILE_NAMES = INSTRUCTION_FILE_PATTERNS.map(p => p.filename); + +interface GitHubFile { + name: string; + path: string; + sha: string; + size: number; + type: 'file' | 'dir'; + download_url: string | null; +} + +interface SkillFile { + name: string; + path: string; + content: string; + size: number; + isBinary: boolean; +} + +interface CachedFiles { + fetchedAt: string; + commitSha: string; + totalSize: number; + items: SkillFile[]; +} + +// --- Platform transformation (mirrors InstallSection.tsx logic) --- + +function stripFrontmatter(content: string): { body: string; description?: string; filePatterns?: string[] } { + const match = content.match(/^---\n([\s\S]*?)\n---\n([\s\S]*)$/); + if (!match) return { body: content }; + + const yaml = match[1]; + const body = match[2].trim(); + + const descMatch = yaml.match(/^description:\s*(.+)$/m); + const description = descMatch ? descMatch[1].trim() : undefined; + + const patternsMatch = yaml.match(/filePatterns:\s*\n((?:\s+-\s+.+\n?)+)/); + let filePatterns: string[] | undefined; + if (patternsMatch) { + filePatterns = patternsMatch[1] + .split('\n') + .map(l => l.replace(/^\s+-\s+/, '').replace(/["']/g, '').trim()) + .filter(Boolean); + } + + return { body, description, filePatterns }; +} + +function getPlatformFileName(platform: Platform, skillName: string): string { + switch (platform) { + case 'claude': + case 'codex': + return 'SKILL.md'; + case 'cursor': + return `${skillName}.mdc`; + case 'windsurf': + return `${skillName}.md`; + case 'copilot': + return `${skillName}.instructions.md`; + } +} + +function transformContent(platform: Platform, content: string, skillName: string): string { + if (platform === 'claude' || platform === 'codex') return content; + + const { body, description, filePatterns } = stripFrontmatter(content); + + if (platform === 'cursor') { + const mdcFields: string[] = []; + if (description) mdcFields.push(`description: ${description}`); + if (filePatterns && filePatterns.length > 0) { + mdcFields.push(`globs: ${filePatterns.join(', ')}`); + mdcFields.push('alwaysApply: false'); + } else { + mdcFields.push('alwaysApply: true'); + } + return `---\n${mdcFields.join('\n')}\n---\n${body}\n`; + } + + // windsurf / copilot: plain markdown + let plainBody = body; + if (!plainBody.startsWith('# ')) { + plainBody = `# ${skillName}\n\n${plainBody}`; + } + return plainBody + '\n'; +} + +function isMainInstructionFile(fileName: string): boolean { + return MAIN_FILE_NAMES.includes(fileName); +} + +/** + * GET /api/skill-files/zip?id=owner/repo/skill-name&platform=cursor + * Returns a ZIP file containing all skill files, transformed for the target platform + */ +export async function GET(request: NextRequest) { + // Rate limiting + const rateLimitResult = await withRateLimit(request, 'search'); + if (!rateLimitResult.allowed) { + return new Response( + JSON.stringify({ error: 'Rate limit exceeded', code: 'RATE_LIMIT' }), + { + status: 429, + headers: { + 'Content-Type': 'application/json', + ...createRateLimitHeaders(rateLimitResult), + }, + } + ); + } + + try { + const skillId = request.nextUrl.searchParams.get('id'); + const platformParam = request.nextUrl.searchParams.get('platform'); + const platform: Platform = (platformParam && VALID_PLATFORMS.includes(platformParam as Platform)) + ? platformParam as Platform + : 'claude'; + + if (!skillId) { + return new Response( + JSON.stringify({ error: 'Missing skill ID parameter', code: 'MISSING_ID' }), + { status: 400, headers: { 'Content-Type': 'application/json' } } + ); + } + + // Get skill from database + const skill = await skillQueries.getById(db, skillId); + + if (!skill) { + return new Response( + JSON.stringify({ error: 'Skill not found', code: 'NOT_FOUND' }), + { status: 404, headers: { 'Content-Type': 'application/json' } } + ); + } + + const { githubOwner, githubRepo, skillPath, branch, name: skillName } = skill; + + // Try to get cached files first + let files: SkillFile[]; + const cachedFiles = await skillQueries.getCachedFiles(db, skillId); + + if (cachedFiles) { + // eslint-disable-next-line no-console + console.log(`[skill-files/zip] Cache HIT for ${skillId}`); + files = cachedFiles.items; + } else { + // Fetch from GitHub + // eslint-disable-next-line no-console + console.log(`[skill-files/zip] Cache MISS for ${skillId}, fetching from GitHub...`); + const { headers: githubHeaders, token } = await getGitHubHeaders(); + + files = await fetchSkillFiles( + githubOwner, + githubRepo, + skillPath, + branch || 'main', + 0, + githubHeaders, + token + ); + + // Save to cache (async, don't block) + const filesToCache: CachedFiles = { + fetchedAt: new Date().toISOString(), + commitSha: skill.commitSha || 'unknown', + totalSize: files.reduce((sum, f) => sum + f.size, 0), + items: files, + }; + skillQueries.updateCachedFiles(db, skillId, filesToCache).catch((err) => { + console.error(`[skill-files/zip] Failed to cache files for ${skillId}:`, err); + }); + } + + if (files.length === 0) { + return new Response( + JSON.stringify({ error: 'No files found in skill', code: 'NO_FILES' }), + { status: 404, headers: { 'Content-Type': 'application/json' } } + ); + } + + // Determine if this is a flat-file platform + const isFlatPlatform = ['cursor', 'windsurf', 'copilot'].includes(platform); + + // Create ZIP archive using streaming + const archive = archiver('zip', { zlib: { level: 6 } }); + + // Add files to archive with platform-specific transformation + for (const file of files) { + if (file.content && !file.isBinary) { + const isMainFile = isMainInstructionFile(file.name) && file.path === file.name; + + if (isMainFile) { + const platformFileName = getPlatformFileName(platform, skillName); + const transformed = transformContent(platform, file.content, skillName); + if (isFlatPlatform) { + archive.append(transformed, { name: platformFileName }); + } else { + archive.append(transformed, { name: `${skillName}/${platformFileName}` }); + } + } else { + // Supporting files always go in subfolder + archive.append(file.content, { name: `${skillName}/${file.path}` }); + } + } else if (file.isBinary) { + // For binary files, we need to fetch them + const downloadUrl = `https://raw.githubusercontent.com/${githubOwner}/${githubRepo}/${branch || 'main'}/${skillPath}/${file.path}`; + try { + const response = await fetch(downloadUrl, { + signal: AbortSignal.timeout(FETCH_TIMEOUT), + }); + if (response.ok) { + const buffer = await response.arrayBuffer(); + archive.append(Buffer.from(buffer), { name: `${skillName}/${file.path}` }); + } + } catch { + console.warn(`[skill-files/zip] Failed to fetch binary file: ${file.path}`); + } + } + } + + // Finalize archive + archive.finalize(); + + // Convert Node.js stream to Web ReadableStream + const webStream = Readable.toWeb(archive) as ReadableStream; + + // Sanitize filename for Content-Disposition header + const safeFileName = skillName.replace(/[^a-zA-Z0-9_-]/g, '_'); + + return new Response(webStream, { + status: 200, + headers: { + 'Content-Type': 'application/zip', + 'Content-Disposition': `attachment; filename="${safeFileName}.zip"`, + ...createRateLimitHeaders(rateLimitResult), + }, + }); + } catch (error) { + console.error('[skill-files/zip] Error:', error); + + const errorMessage = error instanceof Error ? error.message : 'Unknown error'; + + if (errorMessage.includes('rate limit') || errorMessage.includes('403')) { + return new Response( + JSON.stringify({ error: 'GitHub API rate limit exceeded', code: 'RATE_LIMIT' }), + { status: 429, headers: { 'Content-Type': 'application/json' } } + ); + } + + if (errorMessage.includes('timeout') || errorMessage.includes('AbortError')) { + return new Response( + JSON.stringify({ error: 'Request timed out', code: 'TIMEOUT' }), + { status: 504, headers: { 'Content-Type': 'application/json' } } + ); + } + + return new Response( + JSON.stringify({ error: 'Failed to generate ZIP', code: 'INTERNAL_ERROR' }), + { status: 500, headers: { 'Content-Type': 'application/json' } } + ); + } +} + +/** + * Recursively fetch all files in a skill folder from GitHub + */ +async function fetchSkillFiles( + owner: string, + repo: string, + path: string, + ref: string, + depth: number = 0, + headers: Record, + token: string | null +): Promise { + if (depth > MAX_DEPTH) { + console.warn(`Max depth (${MAX_DEPTH}) reached at: ${path}`); + return []; + } + + const files: SkillFile[] = []; + const apiUrl = `https://api.github.com/repos/${owner}/${repo}/contents/${path}?ref=${ref}`; + + const response = await fetch(apiUrl, { + headers, + signal: AbortSignal.timeout(FETCH_TIMEOUT), + }); + + if (token) { + await updateTokenStats(token, response.headers); + } + + if (!response.ok) { + if (response.status === 403) { + const rateLimitRemaining = response.headers.get('x-ratelimit-remaining'); + if (rateLimitRemaining === '0') { + throw new Error('GitHub API rate limit exceeded'); + } + } + throw new Error(`GitHub API error: ${response.status}`); + } + + const contents: GitHubFile[] = await response.json(); + + for (const item of contents) { + if (item.type === 'file') { + const isBinary = !isTextFile(item.name); + + if (!isBinary && item.size < 1024 * 1024) { + try { + const content = await fetchFileContent(owner, repo, item.path, ref, headers, token); + files.push({ + name: item.name, + path: item.path.replace(`${path}/`, '').replace(path, '') || item.name, + content, + size: item.size, + isBinary: false, + }); + } catch { + files.push({ + name: item.name, + path: item.path.replace(`${path}/`, '').replace(path, '') || item.name, + content: '', + size: item.size, + isBinary: true, + }); + } + } else { + files.push({ + name: item.name, + path: item.path.replace(`${path}/`, '').replace(path, '') || item.name, + content: '', + size: item.size, + isBinary: true, + }); + } + } else if (item.type === 'dir') { + const subPath = `${path}/${item.name}`; + const subFiles = await fetchSkillFiles(owner, repo, subPath, ref, depth + 1, headers, token); + + for (const subFile of subFiles) { + files.push({ + ...subFile, + path: `${item.name}/${subFile.path}`, + }); + } + } + } + + return files; +} + +/** + * Fetch file content from GitHub + */ +async function fetchFileContent( + owner: string, + repo: string, + path: string, + ref: string, + headers: Record, + token: string | null +): Promise { + const apiUrl = `https://api.github.com/repos/${owner}/${repo}/contents/${path}?ref=${ref}`; + const response = await fetch(apiUrl, { + headers, + signal: AbortSignal.timeout(FETCH_TIMEOUT), + }); + + if (token) { + await updateTokenStats(token, response.headers); + } + + if (!response.ok) { + throw new Error(`Failed to fetch file: ${path} (${response.status})`); + } + + const data = await response.json(); + + if (data.content && data.encoding === 'base64') { + return Buffer.from(data.content, 'base64').toString('utf-8'); + } + + throw new Error(`Invalid file content for: ${path}`); +} + +/** + * Check if file is a text file based on extension + */ +function isTextFile(filename: string): boolean { + const sensitivePatterns = ['.env', '.secret', '.key', '.pem', '.credential']; + const lowerFilename = filename.toLowerCase(); + if (sensitivePatterns.some((p) => lowerFilename.includes(p))) { + return false; + } + + // Known instruction dotfiles that are plain text + if (MAIN_FILE_NAMES.includes(filename)) { + return true; + } + + const textExtensions = [ + '.md', '.txt', '.json', '.yaml', '.yml', '.xml', '.html', '.css', + '.js', '.ts', '.jsx', '.tsx', '.py', '.sh', '.bash', '.ps1', + '.rb', '.go', '.rs', '.java', '.c', '.cpp', '.h', '.hpp', + '.toml', '.ini', '.cfg', '.conf', '.gitignore', '.editorconfig', + ]; + + const ext = filename.substring(filename.lastIndexOf('.')).toLowerCase(); + return textExtensions.includes(ext) || !filename.includes('.'); +} diff --git a/apps/web/app/api/skills/[...id]/route.ts b/apps/web/app/api/skills/[...id]/route.ts new file mode 100644 index 0000000..d721f53 --- /dev/null +++ b/apps/web/app/api/skills/[...id]/route.ts @@ -0,0 +1,93 @@ +import { NextResponse, type NextRequest } from 'next/server'; +import { createDb, skillQueries } from '@skillhub/db'; +import { shouldCountView } from '@/lib/cache'; + +// Create database connection +const db = createDb(); + +/** + * Get client IP from request headers + * Handles various proxy headers (Cloudflare, nginx, etc.) + */ +function getClientIp(request: NextRequest): string { + // Try various headers in order of preference + const cfConnectingIp = request.headers.get('cf-connecting-ip'); + if (cfConnectingIp) return cfConnectingIp; + + const xRealIp = request.headers.get('x-real-ip'); + if (xRealIp) return xRealIp; + + const xForwardedFor = request.headers.get('x-forwarded-for'); + if (xForwardedFor) { + // x-forwarded-for can contain multiple IPs, take the first one + return xForwardedFor.split(',')[0].trim(); + } + + // Fallback to a default (shouldn't happen in production) + return 'unknown'; +} + +export async function GET( + request: NextRequest, + { params }: { params: Promise<{ id: string[] }> } +) { + try { + const { id } = await params; + const skillId = id.join('/'); + + // Get skill from database + const skill = await skillQueries.getById(db, skillId); + + if (!skill) { + return NextResponse.json( + { error: 'Skill not found' }, + { status: 404 } + ); + } + + // Increment view count only on primary server (mirror DB is read-only) + const isPrimary = process.env.IS_PRIMARY_SERVER !== 'false'; + if (isPrimary) { + const clientIp = getClientIp(request); + const shouldCount = await shouldCountView(skillId, clientIp); + + if (shouldCount) { + await skillQueries.incrementViews(db, skillId); + } + } + + return NextResponse.json({ + id: skill.id, + name: skill.name, + description: skill.description, + githubOwner: skill.githubOwner, + githubRepo: skill.githubRepo, + skillPath: skill.skillPath, + branch: skill.branch, + version: skill.version, + license: skill.license, + author: skill.author, + homepage: skill.homepage, + githubStars: skill.githubStars, + githubForks: skill.githubForks, + downloadCount: skill.downloadCount, + viewCount: skill.viewCount, + securityScore: skill.securityScore, + isVerified: skill.isVerified, + isFeatured: skill.isFeatured, + compatibility: skill.compatibility, + triggers: skill.triggers, + rawContent: skill.rawContent, + sourceFormat: skill.sourceFormat || 'skill.md', + createdAt: skill.createdAt, + updatedAt: skill.updatedAt, + indexedAt: skill.indexedAt, + }); + } catch (error) { + console.error('Error fetching skill:', error); + return NextResponse.json( + { error: 'Failed to fetch skill' }, + { status: 500 } + ); + } +} diff --git a/apps/web/app/api/skills/add-request/route.ts b/apps/web/app/api/skills/add-request/route.ts new file mode 100644 index 0000000..286431f --- /dev/null +++ b/apps/web/app/api/skills/add-request/route.ts @@ -0,0 +1,429 @@ +import type { NextRequest } from 'next/server'; +import { NextResponse } from 'next/server'; +import { auth } from '@/lib/auth'; +import { createDb, addRequestQueries, userQueries } from '@skillhub/db'; +import { sanitizeReason } from '@/lib/sanitize'; +import { sendClaimSubmittedEmail } from '@/lib/email'; + +export const dynamic = 'force-dynamic'; + +const db = createDb(); + +// Parse GitHub URL to extract owner and repo +function parseGitHubUrl(url: string): { owner: string; repo: string; path?: string } | null { + try { + const urlObj = new URL(url); + if (!urlObj.hostname.includes('github.com')) { + return null; + } + + // Handle various GitHub URL formats: + // https://github.com/owner/repo + // https://github.com/owner/repo/tree/main/path/to/skill + // https://github.com/owner/repo/blob/main/SKILL.md + const pathParts = urlObj.pathname.split('/').filter(Boolean); + + if (pathParts.length < 2) { + return null; + } + + const owner = pathParts[0]; + const repo = pathParts[1]; + + // Extract path if present (after tree/branch or blob/branch) + let skillPath: string | undefined; + if (pathParts.length > 3 && (pathParts[2] === 'tree' || pathParts[2] === 'blob')) { + // Skip 'tree' or 'blob' and branch name + const remainingPath = pathParts.slice(4).join('/'); + if (remainingPath && !remainingPath.endsWith('.md')) { + skillPath = remainingPath; + } + } + + return { owner, repo, path: skillPath }; + } catch { + return null; + } +} + +// Find all SKILL.md files in a repository using GitHub Tree API +async function findSkillMdFiles( + owner: string, + repo: string, + defaultBranch: string +): Promise { + try { + // Get the full repository tree recursively + const treeResponse = await fetch( + `https://api.github.com/repos/${owner}/${repo}/git/trees/${defaultBranch}?recursive=1`, + { + headers: { + Accept: 'application/vnd.github.v3+json', + 'User-Agent': 'SkillHub', + ...(process.env.GITHUB_TOKEN && { + Authorization: `Bearer ${process.env.GITHUB_TOKEN}`, + }), + }, + signal: AbortSignal.timeout(30000), + } + ); + + if (!treeResponse.ok) { + console.error(`Failed to fetch repository tree (HTTP ${treeResponse.status}):`, treeResponse.statusText); + return []; + } + + const treeData = await treeResponse.json() as { + tree: Array<{ path: string; type: string }>; + truncated?: boolean; + }; + + if (treeData.truncated) { + console.warn(`Repository tree for ${owner}/${repo} is truncated - some SKILL.md files may be missed`); + } + + // Find all SKILL.md files (case-sensitive) + const skillMdPaths = treeData.tree + .filter((item) => item.type === 'blob' && item.path.endsWith('/SKILL.md')) + .map((item) => { + // Extract the directory path (remove /SKILL.md) + const parts = item.path.split('/'); + parts.pop(); // Remove SKILL.md + return parts.join('/'); + }); + + // Also check for SKILL.md at root level + const hasRootSkillMd = treeData.tree.some( + (item) => item.type === 'blob' && item.path === 'SKILL.md' + ); + + if (hasRootSkillMd) { + skillMdPaths.unshift(''); // Empty string means root + } + + return skillMdPaths; + } catch (error) { + console.error('Error finding SKILL.md files:', error); + return []; + } +} + +// Validate GitHub repository exists and check for SKILL.md +async function validateGitHubRepo( + owner: string, + repo: string, + skillPath?: string +): Promise<{ + valid: boolean; + hasSkillMd: boolean; + skillPaths: string[]; + defaultBranch: string; + error?: string; +}> { + try { + // Check if repository exists and get default branch + const repoResponse = await fetch( + `https://api.github.com/repos/${owner}/${repo}`, + { + headers: { + Accept: 'application/vnd.github.v3+json', + 'User-Agent': 'SkillHub', + ...(process.env.GITHUB_TOKEN && { + Authorization: `Bearer ${process.env.GITHUB_TOKEN}`, + }), + }, + signal: AbortSignal.timeout(10000), + } + ); + + if (!repoResponse.ok) { + if (repoResponse.status === 404) { + return { + valid: false, + hasSkillMd: false, + skillPaths: [], + defaultBranch: 'main', + error: 'Repository not found. Please check the URL and ensure the repository exists.' + }; + } + if (repoResponse.status === 403) { + // Check if it's rate limit or forbidden access + const rateLimitRemaining = repoResponse.headers.get('x-ratelimit-remaining'); + if (rateLimitRemaining === '0') { + return { + valid: false, + hasSkillMd: false, + skillPaths: [], + defaultBranch: 'main', + error: 'GitHub API rate limit exceeded. Please try again later.' + }; + } + return { + valid: false, + hasSkillMd: false, + skillPaths: [], + defaultBranch: 'main', + error: 'Repository is private or you do not have access. Please ensure the repository is public.' + }; + } + return { + valid: false, + hasSkillMd: false, + skillPaths: [], + defaultBranch: 'main', + error: `Failed to access repository (HTTP ${repoResponse.status})` + }; + } + + const repoData = await repoResponse.json() as { default_branch: string; private?: boolean }; + const defaultBranch = repoData.default_branch || 'main'; + + // Additional check for private repos (in case 200 but private) + if (repoData.private) { + return { + valid: false, + hasSkillMd: false, + skillPaths: [], + defaultBranch, + error: 'Repository is private. SkillHub only indexes public repositories.' + }; + } + + // If a specific path is provided, only check that path + if (skillPath) { + const skillMdPath = `${skillPath}/SKILL.md`; + const fileResponse = await fetch( + `https://api.github.com/repos/${owner}/${repo}/contents/${skillMdPath}`, + { + headers: { + Accept: 'application/vnd.github.v3+json', + 'User-Agent': 'SkillHub', + ...(process.env.GITHUB_TOKEN && { + Authorization: `Bearer ${process.env.GITHUB_TOKEN}`, + }), + }, + signal: AbortSignal.timeout(10000), + } + ); + + const hasSkillMd = fileResponse.ok; + return { + valid: true, + hasSkillMd, + skillPaths: hasSkillMd ? [skillPath] : [], + defaultBranch, + }; + } + + // No specific path - do a deep scan for all SKILL.md files + const skillPaths = await findSkillMdFiles(owner, repo, defaultBranch); + + return { + valid: true, + hasSkillMd: skillPaths.length > 0, + skillPaths, + defaultBranch, + }; + } catch (error) { + console.error('GitHub API error:', error); + + // Distinguish timeout errors + if (error instanceof Error && error.name === 'TimeoutError') { + return { + valid: false, + hasSkillMd: false, + skillPaths: [], + defaultBranch: 'main', + error: 'Request timed out while checking repository. Please try again.' + }; + } + + return { + valid: false, + hasSkillMd: false, + skillPaths: [], + defaultBranch: 'main', + error: 'Network error while verifying repository. Please check your connection and try again.' + }; + } +} + +/** + * GET /api/skills/add-request - Get user's add requests + */ +export async function GET() { + try { + const session = await auth(); + + if (!session?.user?.githubId) { + return NextResponse.json( + { error: 'Authentication required' }, + { status: 401 } + ); + } + + // Get user from database + const dbUser = await userQueries.getByGithubId(db, session.user.githubId); + if (!dbUser) { + return NextResponse.json({ requests: [] }); + } + + const requests = await addRequestQueries.getByUser(db, dbUser.id); + + return NextResponse.json({ requests }); + } catch (error) { + console.error('Error fetching add requests:', error); + return NextResponse.json( + { error: 'Internal server error' }, + { status: 500 } + ); + } +} + +/** + * POST /api/skills/add-request - Submit an add request + */ +export async function POST(request: NextRequest) { + try { + const session = await auth(); + + if (!session?.user?.githubId || !session.user.username) { + return NextResponse.json( + { error: 'Authentication required', code: 'AUTH_REQUIRED' }, + { status: 401 } + ); + } + + const body = await request.json(); + const { repositoryUrl, reason } = body; + + if (!repositoryUrl) { + return NextResponse.json( + { error: 'repositoryUrl is required', code: 'INVALID_INPUT' }, + { status: 400 } + ); + } + + // Parse GitHub URL + const parsed = parseGitHubUrl(repositoryUrl); + if (!parsed) { + return NextResponse.json( + { error: 'Invalid GitHub URL', code: 'INVALID_URL' }, + { status: 400 } + ); + } + + // Normalize the repository URL + const normalizedUrl = `https://github.com/${parsed.owner}/${parsed.repo}`; + const finalSkillPath = undefined; + + // Get or create user in database + let dbUser = await userQueries.getByGithubId(db, session.user.githubId); + + if (!dbUser) { + dbUser = await userQueries.upsertFromGithub(db, { + githubId: session.user.githubId, + username: session.user.username, + displayName: session.user.name || undefined, + email: session.user.email || undefined, + avatarUrl: session.user.image || undefined, + }); + } + + if (!dbUser) { + return NextResponse.json( + { error: 'Failed to create user record', code: 'USER_CREATE_FAILED' }, + { status: 500 } + ); + } + + // Check if user already has a pending request for this repository + path combination + const hasPending = await addRequestQueries.hasPendingRequest( + db, + dbUser.id, + normalizedUrl, + finalSkillPath || null + ); + if (hasPending) { + return NextResponse.json( + { error: 'You already have a pending request for this skill path', code: 'ALREADY_PENDING' }, + { status: 409 } + ); + } + + // Validate the GitHub repository + const validation = await validateGitHubRepo(parsed.owner, parsed.repo, finalSkillPath); + + if (!validation.valid) { + let errorCode = 'INVALID_REPO'; + + // Map specific error messages to error codes + if (validation.error?.includes('rate limit')) { + errorCode = 'RATE_LIMIT_EXCEEDED'; + } else if (validation.error?.includes('not found')) { + errorCode = 'INVALID_REPO'; + } else if (validation.error?.includes('timeout') || validation.error?.includes('timed out')) { + errorCode = 'NETWORK_TIMEOUT'; + } + + return NextResponse.json( + { error: validation.error || 'Invalid repository', code: errorCode }, + { status: 400 } + ); + } + + // Create the add request with found skill paths + const skillPathsJson = validation.skillPaths.length > 0 + ? validation.skillPaths.join(',') + : undefined; + + // Sanitize user-provided reason + const sanitizedReason = reason ? sanitizeReason(reason) : 'No reason provided'; + + const requestId = await addRequestQueries.create(db, { + userId: dbUser.id, + repositoryUrl: normalizedUrl, + skillPath: skillPathsJson, + reason: sanitizedReason, + validRepo: validation.valid, + hasSkillMd: validation.hasSkillMd, + }); + + // Build appropriate response message + let message: string; + if (validation.skillPaths.length > 1) { + message = `Request submitted. Found ${validation.skillPaths.length} skills in the repository.`; + } else if (validation.skillPaths.length === 1) { + const pathInfo = validation.skillPaths[0] === '' ? 'at root' : `in ${validation.skillPaths[0]}`; + message = `Request submitted. SKILL.md found ${pathInfo}.`; + } else { + message = 'Request submitted. No SKILL.md found - repository will be reviewed.'; + } + + // Send confirmation email (non-blocking) - ONLY if skills were found + if (dbUser.email && validation.skillPaths.length > 0) { + const locale = (dbUser.preferredLocale === 'fa' ? 'fa' : 'en') as 'en' | 'fa'; + sendClaimSubmittedEmail(dbUser.email, locale, 'add', { + repositoryUrl: normalizedUrl, + skillCount: validation.skillPaths.length, + }).catch((err) => { + console.error('[Claim] Failed to send add confirmation email:', err); + }); + } + + return NextResponse.json({ + success: true, + requestId, + hasSkillMd: validation.hasSkillMd, + skillCount: validation.skillPaths.length, + skillPaths: validation.skillPaths, + message, + }); + } catch (error) { + console.error('Error creating add request:', error); + return NextResponse.json( + { error: 'Internal server error', code: 'SERVER_ERROR' }, + { status: 500 } + ); + } +} diff --git a/apps/web/app/api/skills/featured/route.test.ts b/apps/web/app/api/skills/featured/route.test.ts new file mode 100644 index 0000000..67e2fd2 --- /dev/null +++ b/apps/web/app/api/skills/featured/route.test.ts @@ -0,0 +1,102 @@ +import { describe, it, expect, vi, beforeEach } from 'vitest'; +import { NextRequest } from 'next/server'; + +// Mock rate limiting - must be before route import +vi.mock('@/lib/rate-limit', () => ({ + withRateLimit: vi.fn().mockResolvedValue({ allowed: true, remaining: 100, limit: 120, resetAt: Date.now() + 60000 }), + createRateLimitResponse: vi.fn(), + createRateLimitHeaders: vi.fn().mockReturnValue({}), +})); + +// Helper to create mock skill +function createMockSkill(overrides: Partial<{ + id: string; + name: string; + isFeatured: boolean; + githubStars: number; +}> = {}) { + return { + id: 'test-owner/test-repo/test-skill', + name: 'test-skill', + description: 'A test skill', + githubOwner: 'test-owner', + githubRepo: 'test-repo', + githubStars: 100, + downloadCount: 50, + securityScore: 85, + isVerified: false, + isFeatured: true, + compatibility: { platforms: ['claude'] }, + ...overrides, + }; +} + +// Mock db +vi.mock('@skillhub/db', () => ({ + createDb: vi.fn(() => ({})), + skillQueries: { + getFeatured: vi.fn(), + getByPopularity: vi.fn(), + getFeaturedWithDiversity: vi.fn(), + }, + skills: {}, +})); + +import { GET } from './route'; +import { skillQueries } from '@skillhub/db'; + +describe('GET /api/skills/featured', () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + it('should return featured skills', async () => { + const mockSkills = [ + createMockSkill({ id: 'skill-1', isFeatured: true }), + createMockSkill({ id: 'skill-2', isFeatured: true }), + ]; + vi.mocked(skillQueries.getFeatured).mockResolvedValue(mockSkills as any); + + const request = new NextRequest('http://localhost:3000/api/skills/featured'); + const response = await GET(request); + const data = await response.json(); + + expect(response.status).toBe(200); + expect(data.skills).toBeDefined(); + expect(Array.isArray(data.skills)).toBe(true); + expect(data.skills.length).toBe(2); + }); + + it('should respect limit parameter', async () => { + const mockSkills = [createMockSkill()]; + vi.mocked(skillQueries.getFeatured).mockResolvedValue(mockSkills as any); + + const request = new NextRequest('http://localhost:3000/api/skills/featured?limit=5'); + await GET(request); + + expect(skillQueries.getFeatured).toHaveBeenCalledWith(expect.anything(), 5); + }); + + it('should fallback to diversity-based popularity when no featured', async () => { + vi.mocked(skillQueries.getFeatured).mockResolvedValue([]); + vi.mocked(skillQueries.getFeaturedWithDiversity).mockResolvedValue([createMockSkill()] as any); + + const request = new NextRequest('http://localhost:3000/api/skills/featured'); + const response = await GET(request); + const data = await response.json(); + + expect(skillQueries.getFeaturedWithDiversity).toHaveBeenCalled(); + expect(data.skills.length).toBeGreaterThan(0); + }); + + it('should handle database errors gracefully', async () => { + vi.mocked(skillQueries.getFeatured).mockRejectedValue(new Error('Database error')); + + const request = new NextRequest('http://localhost:3000/api/skills/featured'); + const response = await GET(request); + const data = await response.json(); + + expect(response.status).toBe(500); + expect(data.error).toBeDefined(); + }); +}); diff --git a/apps/web/app/api/skills/featured/route.ts b/apps/web/app/api/skills/featured/route.ts new file mode 100644 index 0000000..bc5ec14 --- /dev/null +++ b/apps/web/app/api/skills/featured/route.ts @@ -0,0 +1,88 @@ +import { NextResponse, type NextRequest } from 'next/server'; +import { createDb, skillQueries, type skills } from '@skillhub/db'; +import { getCached, setCache, cacheKeys, cacheTTL } from '@/lib/cache'; +import { withRateLimit, createRateLimitResponse, createRateLimitHeaders } from '@/lib/rate-limit'; + +const db = createDb(); + +type Skill = typeof skills.$inferSelect; + +interface SkillData { + id: string; + name: string; + description: string | null; + githubOwner: string; + githubRepo: string; + githubStars: number | null; + downloadCount: number | null; + securityStatus: string | null; + isVerified: boolean | null; + compatibility: unknown; +} + +interface FeaturedResponse { + skills: SkillData[]; +} + +export async function GET(request: NextRequest) { + // Rate limiting + const rateLimitResult = await withRateLimit(request, 'anonymous'); + if (!rateLimitResult.allowed) { + return createRateLimitResponse(rateLimitResult); + } + + try { + const searchParams = request.nextUrl.searchParams; + const limit = parseInt(searchParams.get('limit') || '6'); + + // Try to get from cache first (only for default limit) + const cacheKey = cacheKeys.featuredSkills(); + if (limit === 6) { + const cached = await getCached(cacheKey); + if (cached) { + return NextResponse.json(cached, { + headers: { 'X-Cache': 'HIT', ...createRateLimitHeaders(rateLimitResult) }, + }); + } + } + + // Get featured skills, fallback to popularity-based ranking + // Uses adaptive algorithm: quality + freshness + engagement + let featuredSkills = await skillQueries.getFeatured(db, limit); + + // If no manually featured skills, use adaptive popularity with owner/repo diversity + if (featuredSkills.length === 0) { + featuredSkills = await skillQueries.getFeaturedWithDiversity(db, limit, 2, 3); + } + + const data: FeaturedResponse = { + skills: featuredSkills.map((skill: Skill) => ({ + id: skill.id, + name: skill.name, + description: skill.description, + githubOwner: skill.githubOwner, + githubRepo: skill.githubRepo, + githubStars: skill.githubStars, + downloadCount: skill.downloadCount, + securityStatus: skill.securityStatus, + isVerified: skill.isVerified, + compatibility: skill.compatibility, + })), + }; + + // Cache the result (2 hours) + if (limit === 6) { + await setCache(cacheKey, data, cacheTTL.featured); + } + + return NextResponse.json(data, { + headers: { 'X-Cache': 'MISS', ...createRateLimitHeaders(rateLimitResult) }, + }); + } catch (error) { + console.error('Error fetching featured skills:', error); + return NextResponse.json( + { error: 'Failed to fetch featured skills' }, + { status: 500 } + ); + } +} diff --git a/apps/web/app/api/skills/install/route.ts b/apps/web/app/api/skills/install/route.ts new file mode 100644 index 0000000..593be5d --- /dev/null +++ b/apps/web/app/api/skills/install/route.ts @@ -0,0 +1,93 @@ +import { NextResponse, type NextRequest } from 'next/server'; +import { createDb, skillQueries } from '@skillhub/db'; +import { invalidateCache, cacheKeys, shouldCountDownload } from '@/lib/cache'; + +// Create database connection +const db = createDb(); + +/** + * Get client IP from request headers + */ +function getClientIp(request: NextRequest): string { + const cfConnectingIp = request.headers.get('cf-connecting-ip'); + if (cfConnectingIp) return cfConnectingIp; + + const xRealIp = request.headers.get('x-real-ip'); + if (xRealIp) return xRealIp; + + const xForwardedFor = request.headers.get('x-forwarded-for'); + if (xForwardedFor) { + return xForwardedFor.split(',')[0].trim(); + } + + return 'unknown'; +} + +/** + * POST /api/skills/install + * Track a skill installation from CLI or other sources + * + * Body: { skillId: string, platform?: string, method?: string } + */ +export async function POST(request: NextRequest) { + try { + // Parse request body + let body: { skillId?: string; platform?: string; method?: string }; + try { + body = await request.json(); + } catch { + return NextResponse.json( + { error: 'Invalid request body' }, + { status: 400 } + ); + } + + const { skillId, platform = 'unknown', method = 'unknown' } = body; + + if (!skillId) { + return NextResponse.json( + { error: 'skillId is required' }, + { status: 400 } + ); + } + + // Verify skill exists + const skill = await skillQueries.getById(db, skillId); + if (!skill) { + return NextResponse.json( + { error: 'Skill not found' }, + { status: 404 } + ); + } + + // Rate limit: same IP can only count 1 download per skill per 5 minutes + const clientIp = getClientIp(request); + const shouldCount = await shouldCountDownload(skillId, clientIp); + + // Only increment if this is a new download from this IP + if (shouldCount) { + await skillQueries.incrementDownloads(db, skillId); + } + + // Invalidate relevant caches so featured/recent lists reflect the new download + await Promise.all([ + invalidateCache(cacheKeys.featuredSkills()), + invalidateCache(cacheKeys.recentSkills()), + invalidateCache(cacheKeys.stats()), + invalidateCache(cacheKeys.skill(skillId)), + ]); + + return NextResponse.json({ + success: true, + skillId, + platform, + method, + }); + } catch (error) { + console.error('Error tracking install:', error); + return NextResponse.json( + { error: 'Failed to track install' }, + { status: 500 } + ); + } +} diff --git a/apps/web/app/api/skills/recent/route.test.ts b/apps/web/app/api/skills/recent/route.test.ts new file mode 100644 index 0000000..ec56f37 --- /dev/null +++ b/apps/web/app/api/skills/recent/route.test.ts @@ -0,0 +1,94 @@ +import { describe, it, expect, vi, beforeEach } from 'vitest'; +import { NextRequest } from 'next/server'; + +// Helper to create mock skill +function createMockSkill(overrides: Partial<{ + id: string; + name: string; + updatedAt: Date; +}> = {}) { + return { + id: 'test-owner/test-repo/test-skill', + name: 'test-skill', + description: 'A test skill', + githubOwner: 'test-owner', + githubRepo: 'test-repo', + githubStars: 100, + downloadCount: 50, + securityScore: 85, + isVerified: false, + compatibility: { platforms: ['claude'] }, + updatedAt: new Date(), + createdAt: new Date(), + ...overrides, + }; +} + +// Mock db +vi.mock('@skillhub/db', () => ({ + createDb: vi.fn(() => ({})), + skillQueries: { + getRecent: vi.fn(), + }, + skills: {}, +})); + +import { GET } from './route'; +import { skillQueries } from '@skillhub/db'; + +describe('GET /api/skills/recent', () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + it('should return recent skills', async () => { + const mockSkills = [ + createMockSkill({ id: 'skill-1', updatedAt: new Date('2024-01-02') }), + createMockSkill({ id: 'skill-2', updatedAt: new Date('2024-01-01') }), + ]; + vi.mocked(skillQueries.getRecent).mockResolvedValue(mockSkills as any); + + const request = new NextRequest('http://localhost:3000/api/skills/recent'); + const response = await GET(request); + const data = await response.json(); + + expect(response.status).toBe(200); + expect(data.skills).toBeDefined(); + expect(Array.isArray(data.skills)).toBe(true); + expect(data.skills.length).toBe(2); + }); + + it('should order by updatedAt descending', async () => { + const mockSkills = [ + createMockSkill({ id: 'skill-1', updatedAt: new Date('2024-01-02') }), + createMockSkill({ id: 'skill-2', updatedAt: new Date('2024-01-01') }), + ]; + vi.mocked(skillQueries.getRecent).mockResolvedValue(mockSkills as any); + + const request = new NextRequest('http://localhost:3000/api/skills/recent'); + const response = await GET(request); + const data = await response.json(); + + expect(data.skills[0].updatedAt).toBeDefined(); + }); + + it('should respect limit parameter', async () => { + vi.mocked(skillQueries.getRecent).mockResolvedValue([]); + + const request = new NextRequest('http://localhost:3000/api/skills/recent?limit=5'); + await GET(request); + + expect(skillQueries.getRecent).toHaveBeenCalledWith(expect.anything(), 5); + }); + + it('should handle database errors gracefully', async () => { + vi.mocked(skillQueries.getRecent).mockRejectedValue(new Error('Database error')); + + const request = new NextRequest('http://localhost:3000/api/skills/recent'); + const response = await GET(request); + const data = await response.json(); + + expect(response.status).toBe(500); + expect(data.error).toBeDefined(); + }); +}); diff --git a/apps/web/app/api/skills/recent/route.ts b/apps/web/app/api/skills/recent/route.ts new file mode 100644 index 0000000..85d1314 --- /dev/null +++ b/apps/web/app/api/skills/recent/route.ts @@ -0,0 +1,85 @@ +import { NextResponse, type NextRequest } from 'next/server'; +import { createDb, skillQueries, type skills } from '@skillhub/db'; +import { getCached, setCache, cacheKeys, cacheTTL } from '@/lib/cache'; +import { withRateLimit, createRateLimitResponse, createRateLimitHeaders } from '@/lib/rate-limit'; + +const db = createDb(); + +type Skill = typeof skills.$inferSelect; + +interface SkillData { + id: string; + name: string; + description: string | null; + githubOwner: string; + githubRepo: string; + githubStars: number | null; + downloadCount: number | null; + securityStatus: string | null; + isVerified: boolean | null; + compatibility: unknown; + updatedAt: Date | null; + createdAt: Date | null; +} + +interface RecentResponse { + skills: SkillData[]; +} + +export async function GET(request: NextRequest) { + // Rate limiting + const rateLimitResult = await withRateLimit(request, 'anonymous'); + if (!rateLimitResult.allowed) { + return createRateLimitResponse(rateLimitResult); + } + + try { + const searchParams = request.nextUrl.searchParams; + const limit = parseInt(searchParams.get('limit') || '10'); + + // Try to get from cache first (only for default limit) + const cacheKey = cacheKeys.recentSkills(); + if (limit === 10) { + const cached = await getCached(cacheKey); + if (cached) { + return NextResponse.json(cached, { + headers: { 'X-Cache': 'HIT', ...createRateLimitHeaders(rateLimitResult) }, + }); + } + } + + const recentSkills = await skillQueries.getRecent(db, limit); + + const data: RecentResponse = { + skills: recentSkills.map((skill: Skill) => ({ + id: skill.id, + name: skill.name, + description: skill.description, + githubOwner: skill.githubOwner, + githubRepo: skill.githubRepo, + githubStars: skill.githubStars, + downloadCount: skill.downloadCount, + securityStatus: skill.securityStatus, + isVerified: skill.isVerified, + compatibility: skill.compatibility, + updatedAt: skill.updatedAt, + createdAt: skill.createdAt, + })), + }; + + // Cache the result (1 hour) + if (limit === 10) { + await setCache(cacheKey, data, cacheTTL.recent); + } + + return NextResponse.json(data, { + headers: { 'X-Cache': 'MISS', ...createRateLimitHeaders(rateLimitResult) }, + }); + } catch (error) { + console.error('Error fetching recent skills:', error); + return NextResponse.json( + { error: 'Failed to fetch recent skills' }, + { status: 500 } + ); + } +} diff --git a/apps/web/app/api/skills/removal-request/route.ts b/apps/web/app/api/skills/removal-request/route.ts new file mode 100644 index 0000000..80c7782 --- /dev/null +++ b/apps/web/app/api/skills/removal-request/route.ts @@ -0,0 +1,210 @@ +import type { NextRequest } from 'next/server'; +import { NextResponse } from 'next/server'; +import { auth } from '@/lib/auth'; +import { createDb, skillQueries, removalRequestQueries, userQueries } from '@skillhub/db'; +import { sanitizeReason } from '@/lib/sanitize'; +import { sendClaimSubmittedEmail } from '@/lib/email'; + +export const dynamic = 'force-dynamic'; + +const db = createDb(); + +/** + * GET /api/skills/removal-request - Get user's removal requests + */ +export async function GET() { + try { + const session = await auth(); + + if (!session?.user?.githubId) { + return NextResponse.json( + { error: 'Authentication required' }, + { status: 401 } + ); + } + + // Get user from database + const dbUser = await userQueries.getByGithubId(db, session.user.githubId); + if (!dbUser) { + return NextResponse.json({ requests: [] }); + } + + const requests = await removalRequestQueries.getByUser(db, dbUser.id); + + return NextResponse.json({ requests }); + } catch (error) { + console.error('Error fetching removal requests:', error); + return NextResponse.json( + { error: 'Internal server error' }, + { status: 500 } + ); + } +} + +/** + * POST /api/skills/removal-request - Submit a removal request + */ +export async function POST(request: NextRequest) { + try { + const session = await auth(); + + if (!session?.user?.githubId || !session.user.username) { + return NextResponse.json( + { error: 'Authentication required', code: 'AUTH_REQUIRED' }, + { status: 401 } + ); + } + + const body = await request.json(); + const { skillId, reason } = body; + + if (!skillId) { + return NextResponse.json( + { error: 'skillId is required', code: 'INVALID_INPUT' }, + { status: 400 } + ); + } + + // Check if skill exists + const skill = await skillQueries.getById(db, skillId); + if (!skill) { + return NextResponse.json( + { error: 'Skill not found', code: 'SKILL_NOT_FOUND' }, + { status: 404 } + ); + } + + // Get or create user in database + let dbUser = await userQueries.getByGithubId(db, session.user.githubId); + + // Auto-create user if not in database (first API request after OAuth) + if (!dbUser) { + dbUser = await userQueries.upsertFromGithub(db, { + githubId: session.user.githubId, + username: session.user.username, + displayName: session.user.name || undefined, + email: session.user.email || undefined, + avatarUrl: session.user.image || undefined, + }); + } + + if (!dbUser) { + return NextResponse.json( + { error: 'Failed to create user record', code: 'USER_CREATE_FAILED' }, + { status: 500 } + ); + } + + // Check if user already has a pending request for this skill + const hasPending = await removalRequestQueries.hasPendingRequest( + db, + dbUser.id, + skillId + ); + if (hasPending) { + return NextResponse.json( + { error: 'You already have a pending request for this skill', code: 'ALREADY_PENDING' }, + { status: 409 } + ); + } + + // Verify ownership via GitHub API + const owner = skill.githubOwner; + const repo = skill.githubRepo; + const username = session.user.username; + + // Check if skill has required GitHub info + if (!owner || !repo) { + console.error('Skill missing GitHub info:', { skillId, owner, repo }); + return NextResponse.json( + { error: 'Skill does not have valid GitHub repository information', code: 'INVALID_SKILL' }, + { status: 400 } + ); + } + + // Check repository ownership using public API (no token needed for public repos) + let isOwner = false; + let githubError: string | null = null; + + try { + const repoResponse = await fetch( + `https://api.github.com/repos/${owner}/${repo}`, + { + headers: { + Accept: 'application/vnd.github.v3+json', + 'User-Agent': 'SkillHub', + }, + signal: AbortSignal.timeout(10000), // 10 second timeout + } + ); + + if (repoResponse.ok) { + const repoData = await repoResponse.json(); + if (repoData.owner?.login?.toLowerCase() === username.toLowerCase()) { + isOwner = true; + } + } else if (repoResponse.status === 404) { + githubError = 'Repository not found on GitHub'; + } else if (repoResponse.status === 403) { + githubError = 'GitHub API rate limit exceeded'; + } + } catch (fetchError) { + console.error('GitHub API fetch error:', fetchError); + githubError = 'Failed to verify repository ownership'; + } + + if (githubError) { + return NextResponse.json( + { error: githubError, code: 'GITHUB_ERROR' }, + { status: 502 } + ); + } + + if (!isOwner) { + return NextResponse.json( + { error: 'You are not the owner of this repository', code: 'NOT_OWNER' }, + { status: 403 } + ); + } + + // Create the removal request (auto-approved since owner is verified) + const sanitizedReason = reason ? sanitizeReason(reason) : 'No reason provided'; + const requestId = await removalRequestQueries.create(db, { + userId: dbUser.id, + skillId, + reason: sanitizedReason, + verifiedOwner: true, + }); + + // Auto-approve: Block the skill from being re-indexed + await skillQueries.block(db, skillId); + + // Update the request status to approved + await removalRequestQueries.resolve(db, requestId, { + status: 'approved', + resolvedBy: dbUser.id, + resolutionNote: 'Auto-approved: Owner verified via GitHub API', + }); + + // Send confirmation email (non-blocking) + if (dbUser.email) { + const locale = (dbUser.preferredLocale === 'fa' ? 'fa' : 'en') as 'en' | 'fa'; + sendClaimSubmittedEmail(dbUser.email, locale, 'remove', { skillId }).catch((err) => { + console.error('[Claim] Failed to send removal confirmation email:', err); + }); + } + + return NextResponse.json({ + success: true, + requestId, + message: 'Skill has been blocked from indexing', + blocked: true, + }); + } catch (error) { + console.error('Error creating removal request:', error); + return NextResponse.json( + { error: 'Internal server error', code: 'SERVER_ERROR' }, + { status: 500 } + ); + } +} diff --git a/apps/web/app/api/skills/route.ts b/apps/web/app/api/skills/route.ts new file mode 100644 index 0000000..ca32743 --- /dev/null +++ b/apps/web/app/api/skills/route.ts @@ -0,0 +1,252 @@ +import { NextResponse, type NextRequest } from 'next/server'; +import { createDb, skillQueries, type skills, isMeilisearchHealthy, searchSkills as meilisearchSearch } from '@skillhub/db'; +import { withRateLimit, createRateLimitResponse, createRateLimitHeaders } from '@/lib/rate-limit'; +import { getCached, setCache, hashSearchParams, cacheKeys } from '@/lib/cache'; +import { captureException, log } from '@/lib/sentry'; + +// Create database connection +const db = createDb(); + +type Skill = typeof skills.$inferSelect; + +/** + * Restore skill ID from Meilisearch format + * Converts sanitized IDs back to original format: + * "anthropics__skills__pdf" -> "anthropics/skills/pdf" + * "bdmorin___dot_claude__git" -> "bdmorin/.claude/git" + * "user__repo_dot_name__skill" -> "user/repo.name/skill" + */ +function restoreIdFromMeili(meiliId: string): string { + return meiliId + .replace(/_dot_/g, '.') // _dot_ -> dot (do this FIRST) + .replace(/__/g, '/'); // double underscore -> slash +} + +export async function GET(request: NextRequest) { + // Apply rate limiting (search is more expensive, use lower limit) + const rateLimitResult = await withRateLimit(request, 'search'); + if (!rateLimitResult.allowed) { + return createRateLimitResponse(rateLimitResult); + } + + try { + const searchParams = request.nextUrl.searchParams; + + const query = searchParams.get('q') || undefined; + const platform = searchParams.get('platform') || undefined; + const category = searchParams.get('category') || undefined; + const format = searchParams.get('format') || 'skill.md'; + const verified = searchParams.get('verified') === 'true'; + + // Parse and validate numeric parameters + const minStarsRaw = parseInt(searchParams.get('minStars') || '0'); + const minStars = isNaN(minStarsRaw) || minStarsRaw < 0 ? 0 : minStarsRaw; + + const sort = searchParams.get('sort') || 'stars'; + + const pageRaw = parseInt(searchParams.get('page') || '1'); + const page = isNaN(pageRaw) || pageRaw < 1 ? 1 : pageRaw; + + const limitRaw = parseInt(searchParams.get('limit') || '20'); + const limit = isNaN(limitRaw) || limitRaw < 1 ? 20 : Math.min(limitRaw, 100); // Max 100 per page + + const offset = (page - 1) * limit; + + // Create cache key from search parameters + const searchHash = hashSearchParams({ + q: query, + category, + platform, + format, + verified: verified ? 'true' : undefined, + sort, + page, + limit, + minStars: minStars > 0 ? minStars : undefined, + }); + const cacheKey = cacheKeys.searchSkills(searchHash); + + // Check cache first + const cached = await getCached<{ + skills: Skill[]; + total: number; + searchEngine: string; + }>(cacheKey); + + if (cached) { + return NextResponse.json( + { + skills: cached.skills, + pagination: { + page, + limit, + total: cached.total, + totalPages: Math.ceil(cached.total / limit), + }, + searchEngine: 'cache', + cachedFrom: cached.searchEngine, + }, + { + headers: createRateLimitHeaders(rateLimitResult), + } + ); + } + + // Try Meilisearch for text search queries + if (query) { + const useMeilisearch = await isMeilisearchHealthy(); + + if (useMeilisearch) { + // Use Meilisearch for full-text search with relevance ranking + // Note: category filter not supported in Meilisearch, handled in PostgreSQL fallback + const meiliResult = await meilisearchSearch({ + query, + filters: { + platforms: platform && platform !== 'all' ? [platform] : undefined, + minStars: minStars > 0 ? minStars : undefined, + verified: verified ? true : undefined, + }, + sort: sort as 'stars' | 'downloads' | 'rating' | 'recent', + limit, + offset, + }); + + if (meiliResult) { + const skills = meiliResult.hits.map((hit) => ({ + id: restoreIdFromMeili(hit.id), + name: hit.name, + description: hit.description, + githubOwner: hit.githubOwner, + githubRepo: hit.githubRepo, + githubStars: hit.githubStars, + downloadCount: hit.downloadCount, + securityScore: hit.securityScore, + securityStatus: null, // Not available in Meilisearch yet + rating: hit.rating, + ratingCount: null, // Not available in Meilisearch yet + isVerified: hit.isVerified, + compatibility: { platforms: hit.platforms }, + })); + + // Cache the result (5 minutes TTL) + await setCache( + cacheKey, + { + skills, + total: meiliResult.estimatedTotalHits, + searchEngine: 'meilisearch', + }, + 5 * 60 + ); + + return NextResponse.json({ + skills, + pagination: { + page, + limit, + total: meiliResult.estimatedTotalHits, + totalPages: Math.ceil(meiliResult.estimatedTotalHits / limit), + }, + searchEngine: 'meilisearch', + processingTimeMs: meiliResult.processingTimeMs, + }, { + headers: createRateLimitHeaders(rateLimitResult), + }); + } + // If Meilisearch search failed, fall through to PostgreSQL + } + } + + // Fall back to PostgreSQL search + // Map sort parameter to database column + const sortByMap: Record = { + stars: 'stars', + downloads: 'downloads', + rating: 'rating', + recent: 'updated', + lastDownloaded: 'lastDownloaded', + security: 'stars', // Use stars as fallback + }; + const sortBy = sortByMap[sort] || 'downloads'; + + // Build filter options for database query + const filterOptions = { + query, + category: category || undefined, + platform: platform && platform !== 'all' ? platform : undefined, + sourceFormat: format, + minStars, + verified: verified || undefined, + }; + + // Get paginated results directly from database (no in-memory filtering) + const paginatedResults = await skillQueries.search(db, { + ...filterOptions, + limit, + offset, + sortBy, + sortOrder: 'desc', + }); + + // Get total count for pagination + const total = await skillQueries.count(db, filterOptions); + + const skills = paginatedResults.map((skill: Skill) => ({ + id: skill.id, + name: skill.name, + description: skill.description, + githubOwner: skill.githubOwner, + githubRepo: skill.githubRepo, + skillPath: skill.skillPath, + version: skill.version, + license: skill.license, + githubStars: skill.githubStars, + downloadCount: skill.downloadCount, + securityScore: skill.securityScore, + securityStatus: skill.securityStatus, + rating: skill.rating, + ratingCount: skill.ratingCount, + isVerified: skill.isVerified, + compatibility: skill.compatibility, + updatedAt: skill.updatedAt, + })); + + // Cache the result (5 minutes TTL) + await setCache( + cacheKey, + { + skills, + total, + searchEngine: 'postgresql', + }, + 5 * 60 + ); + + return NextResponse.json({ + skills, + pagination: { + page, + limit, + total, + totalPages: Math.ceil(total / limit), + }, + searchEngine: 'postgresql', + }, { + headers: createRateLimitHeaders(rateLimitResult), + }); + } catch (error) { + // Log and report error to Sentry + log.error('Error fetching skills', { + error: error instanceof Error ? error.message : String(error), + }); + captureException(error, { + tags: { route: '/api/skills' }, + extra: { searchParams: Object.fromEntries(request.nextUrl.searchParams) }, + }); + + return NextResponse.json( + { error: 'Failed to fetch skills' }, + { status: 500 } + ); + } +} diff --git a/apps/web/app/api/skills/verify-ownership/route.ts b/apps/web/app/api/skills/verify-ownership/route.ts new file mode 100644 index 0000000..416b70b --- /dev/null +++ b/apps/web/app/api/skills/verify-ownership/route.ts @@ -0,0 +1,78 @@ +import type { NextRequest } from 'next/server'; +import { NextResponse } from 'next/server'; +import { auth } from '@/lib/auth'; + +export const dynamic = 'force-dynamic'; + +/** + * Verify if the current user is the owner of a GitHub repository + * GET /api/skills/verify-ownership?owner=...&repo=... + */ +export async function GET(request: NextRequest) { + try { + const session = await auth(); + + if (!session?.user?.username) { + return NextResponse.json( + { error: 'Authentication required', isOwner: false }, + { status: 401 } + ); + } + + const { searchParams } = new URL(request.url); + const owner = searchParams.get('owner'); + const repo = searchParams.get('repo'); + + if (!owner || !repo) { + return NextResponse.json( + { error: 'owner and repo parameters are required', isOwner: false }, + { status: 400 } + ); + } + + // Get the GitHub username from session + const username = session.user.username; + + // Check if user is the repo owner using public API + const repoResponse = await fetch( + `https://api.github.com/repos/${owner}/${repo}`, + { + headers: { + Accept: 'application/vnd.github.v3+json', + 'User-Agent': 'SkillHub', + }, + } + ); + + if (!repoResponse.ok) { + if (repoResponse.status === 404) { + return NextResponse.json( + { error: 'Repository not found', isOwner: false }, + { status: 404 } + ); + } + return NextResponse.json( + { error: 'Failed to verify repository', isOwner: false }, + { status: 500 } + ); + } + + const repoData = await repoResponse.json(); + + // Check if the user is the owner + const isOwner = repoData.owner?.login?.toLowerCase() === username.toLowerCase(); + + return NextResponse.json({ + isOwner, + permission: isOwner ? 'owner' : 'none', + username, + repoOwner: repoData.owner?.login, + }); + } catch (error) { + console.error('Error verifying ownership:', error); + return NextResponse.json( + { error: 'Internal server error', isOwner: false }, + { status: 500 } + ); + } +} diff --git a/apps/web/app/api/stats/route.test.ts b/apps/web/app/api/stats/route.test.ts new file mode 100644 index 0000000..13c9086 --- /dev/null +++ b/apps/web/app/api/stats/route.test.ts @@ -0,0 +1,91 @@ +import { describe, it, expect, vi, beforeEach } from 'vitest'; +import { NextRequest } from 'next/server'; + +// Mock rate limiting - must be before route import +vi.mock('@/lib/rate-limit', () => ({ + withRateLimit: vi.fn().mockResolvedValue({ allowed: true, remaining: 100, limit: 120, resetAt: Date.now() + 60000 }), + createRateLimitResponse: vi.fn(), + createRateLimitHeaders: vi.fn().mockReturnValue({}), +})); + +// Mock cache - must be before route import +vi.mock('@/lib/cache', () => ({ + getCached: vi.fn().mockResolvedValue(null), + setCache: vi.fn().mockResolvedValue(undefined), + cacheKeys: { stats: () => 'stats' }, + cacheTTL: { stats: 3600 }, +})); + +// Mock the db module - must be before imports that use it +vi.mock('@skillhub/db', () => { + return { + createDb: vi.fn(() => ({ + select: vi.fn().mockReturnValue({ + from: vi.fn().mockResolvedValue([ + { totalSkills: 100, totalDownloads: 5000, totalContributors: 50 }, + ]), + }), + })), + skills: { downloadCount: 'download_count', githubOwner: 'github_owner' }, + categories: {}, + sql: vi.fn(() => 'mock-sql'), + }; +}); + +import { GET } from './route'; + +// Helper to create mock request +function createMockRequest(url = 'http://localhost:3000/api/stats') { + return new NextRequest(url); +} + +describe('GET /api/stats', () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + it('should return totalSkills count', async () => { + const request = createMockRequest(); + const response = await GET(request); + const data = await response.json(); + + expect(response.status).toBe(200); + expect(data.totalSkills).toBeDefined(); + expect(typeof data.totalSkills).toBe('number'); + }); + + it('should return totalDownloads sum', async () => { + const request = createMockRequest(); + const response = await GET(request); + const data = await response.json(); + + expect(data.totalDownloads).toBeDefined(); + expect(typeof data.totalDownloads).toBe('number'); + }); + + it('should return totalCategories count', async () => { + const request = createMockRequest(); + const response = await GET(request); + const data = await response.json(); + + expect(data.totalCategories).toBeDefined(); + expect(typeof data.totalCategories).toBe('number'); + }); + + it('should return totalContributors count', async () => { + const request = createMockRequest(); + const response = await GET(request); + const data = await response.json(); + + expect(data.totalContributors).toBeDefined(); + expect(typeof data.totalContributors).toBe('number'); + }); + + it('should return platforms count', async () => { + const request = createMockRequest(); + const response = await GET(request); + const data = await response.json(); + + expect(data.platforms).toBe(5); + }); +}); diff --git a/apps/web/app/api/stats/route.ts b/apps/web/app/api/stats/route.ts new file mode 100644 index 0000000..e86dd84 --- /dev/null +++ b/apps/web/app/api/stats/route.ts @@ -0,0 +1,79 @@ +import { type NextRequest, NextResponse } from 'next/server'; +import { createDb, skills, categories, sql } from '@skillhub/db'; +import { getCached, setCache, cacheKeys, cacheTTL } from '@/lib/cache'; +import { withRateLimit, createRateLimitResponse, createRateLimitHeaders } from '@/lib/rate-limit'; + +const db = createDb(); + +interface StatsData { + totalSkills: number; + totalDownloads: number; + totalCategories: number; + totalContributors: number; + platforms: number; +} + +export async function GET(request: NextRequest) { + // Rate limiting + const rateLimitResult = await withRateLimit(request, 'anonymous'); + if (!rateLimitResult.allowed) { + return createRateLimitResponse(rateLimitResult); + } + + try { + // Try to get from cache first + const cacheKey = cacheKeys.stats(); + const cached = await getCached(cacheKey); + if (cached) { + return NextResponse.json(cached, { + headers: { + 'X-Cache': 'HIT', + 'Cache-Control': 'public, max-age=3600, stale-while-revalidate=7200', + ...createRateLimitHeaders(rateLimitResult), + }, + }); + } + + // Consolidate all skill stats into a single query for better performance + const statsResult = await db + .select({ + totalSkills: sql`count(*)::int`, + totalDownloads: sql`coalesce(sum(${skills.downloadCount}), 0)::int`, + totalContributors: sql`count(distinct ${skills.githubOwner})::int`, + }) + .from(skills); + + // Get category count in separate query (different table) + const categoryResult = await db + .select({ count: sql`count(*)::int` }) + .from(categories); + + const stats = statsResult[0]; + const totalCategories = categoryResult[0]?.count ?? 0; + + const data: StatsData = { + totalSkills: stats?.totalSkills ?? 0, + totalDownloads: stats?.totalDownloads ?? 0, + totalCategories, + totalContributors: stats?.totalContributors ?? 0, + platforms: 5, // Claude, Codex, Copilot, Cursor, Windsurf + }; + + // Cache the result + await setCache(cacheKey, data, cacheTTL.stats); + + return NextResponse.json(data, { + headers: { + 'X-Cache': 'MISS', + 'Cache-Control': 'public, max-age=3600, stale-while-revalidate=7200', + ...createRateLimitHeaders(rateLimitResult), + }, + }); + } catch (error) { + console.error('Error fetching stats:', error); + return NextResponse.json( + { error: 'Failed to fetch stats' }, + { status: 500 } + ); + } +} diff --git a/apps/web/app/globals.css b/apps/web/app/globals.css new file mode 100644 index 0000000..ed89ec8 --- /dev/null +++ b/apps/web/app/globals.css @@ -0,0 +1,399 @@ +/* Font import must be at the very top */ +@import url('https://cdn.jsdelivr.net/gh/rastikerdar/vazirmatn@v33.003/Vazirmatn-font-face.css'); + +@tailwind base; +@tailwind components; +@tailwind utilities; + +/* ============================================ + CSS Variables - Design Tokens + ============================================ */ +:root { + /* Primary Colors - Sky Blue */ + --color-primary-50: #f0f9ff; + --color-primary-100: #e0f2fe; + --color-primary-200: #bae6fd; + --color-primary-300: #7dd3fc; + --color-primary-400: #38bdf8; + --color-primary-500: #66b3e6; + --color-primary-600: #0284c7; + --color-primary-700: #0369a1; + --color-primary-800: #075985; + --color-primary-900: #0c4a6e; + + /* Accent - Gold */ + --color-accent-gold: #f7c150; + --color-accent-gold-light: #fbd87a; + --color-accent-gold-dark: #d4a43d; + + /* Gradients */ + --gradient-primary: linear-gradient(135deg, #66b3e6 0%, #3b82f6 100%); + --gradient-hero: linear-gradient(180deg, rgba(102, 179, 230, 0.08) 0%, transparent 60%); + --gradient-gold: linear-gradient(135deg, #f7c150 0%, #f59e0b 100%); + + /* Text Colors */ + --color-text-primary: #0f172a; + --color-text-secondary: #475569; + --color-text-muted: #94a3b8; + + /* Surface Colors */ + --color-surface: #ffffff; + --color-surface-elevated: #ffffff; + --color-surface-muted: #f8fafc; + --color-surface-subtle: #f1f5f9; + + /* Borders */ + --color-border: #e2e8f0; + --color-border-light: #f1f5f9; + + /* States */ + --color-success: #22c55e; + --color-success-bg: #f0fdf4; + --color-warning: #f59e0b; + --color-warning-bg: #fffbeb; + --color-error: #ef4444; + --color-error-bg: #fef2f2; + + /* Shadows */ + --shadow-sm: 0 1px 2px 0 rgba(0, 0, 0, 0.05); + --shadow-md: 0 4px 6px -1px rgba(0, 0, 0, 0.1), 0 2px 4px -1px rgba(0, 0, 0, 0.06); + --shadow-lg: 0 10px 15px -3px rgba(0, 0, 0, 0.1), 0 4px 6px -2px rgba(0, 0, 0, 0.05); + --shadow-xl: 0 20px 25px -5px rgba(0, 0, 0, 0.1), 0 10px 10px -5px rgba(0, 0, 0, 0.04); + + /* Animation */ + --duration-fast: 150ms; + --duration-normal: 200ms; + --duration-slow: 300ms; + --ease-out: cubic-bezier(0, 0, 0.2, 1); + --ease-in-out: cubic-bezier(0.4, 0, 0.2, 1); + + /* Spacing */ + --space-xs: 0.25rem; + --space-sm: 0.5rem; + --space-md: 1rem; + --space-lg: 1.5rem; + --space-xl: 2rem; + --space-2xl: 3rem; + --space-3xl: 4rem; + + /* Border Radius */ + --radius-sm: 0.375rem; + --radius-md: 0.5rem; + --radius-lg: 0.75rem; + --radius-xl: 1rem; + --radius-2xl: 1.5rem; + --radius-full: 9999px; +} + +/* ============================================ + Dark Mode Colors + ============================================ */ +.dark { + /* Primary Colors - Adjusted for dark mode */ + --color-primary-50: #0c3d5a; + --color-primary-100: #0f4c6e; + --color-primary-200: #125e87; + --color-primary-300: #1c79a8; + --color-primary-400: #3b9ac9; + --color-primary-500: #66b3e6; + --color-primary-600: #8cc7ed; + --color-primary-700: #b3dbf5; + --color-primary-800: #d9effa; + --color-primary-900: #ecf7fd; + + /* Accent - Gold */ + --color-accent-gold: #f7c150; + --color-accent-gold-light: #fbd87a; + --color-accent-gold-dark: #c48f30; + + /* Gradients */ + --gradient-primary: linear-gradient(135deg, #66b3e6 0%, #3b82f6 100%); + --gradient-hero: linear-gradient(180deg, rgba(102, 179, 230, 0.15) 0%, transparent 60%); + + /* Text Colors */ + --color-text-primary: #f1f5f9; + --color-text-secondary: #94a3b8; + --color-text-muted: #64748b; + + /* Surface Colors */ + --color-surface: #0f172a; + --color-surface-elevated: #1e293b; + --color-surface-muted: #1e293b; + --color-surface-subtle: #334155; + + /* Borders */ + --color-border: #334155; + --color-border-light: #1e293b; + + /* States */ + --color-success: #4ade80; + --color-success-bg: #14532d; + --color-warning: #fbbf24; + --color-warning-bg: #78350f; + --color-error: #f87171; + --color-error-bg: #7f1d1d; + + /* Shadows - Softer for dark mode */ + --shadow-sm: 0 1px 2px 0 rgba(0, 0, 0, 0.3); + --shadow-md: 0 4px 6px -1px rgba(0, 0, 0, 0.4), 0 2px 4px -1px rgba(0, 0, 0, 0.3); + --shadow-lg: 0 10px 15px -3px rgba(0, 0, 0, 0.4), 0 4px 6px -2px rgba(0, 0, 0, 0.3); + --shadow-xl: 0 20px 25px -5px rgba(0, 0, 0, 0.4), 0 10px 10px -5px rgba(0, 0, 0, 0.3); +} + +/* ============================================ + Font Faces - Vazirmatn (Open Source Persian Font) + https://github.com/rastikerdar/vazirmatn + Note: @import is at the top of this file + ============================================ */ + +/* ============================================ + Base Styles + ============================================ */ +@layer base { + html { + @apply antialiased; + scroll-behavior: smooth; + } + + /* RTL Typography - Persian optimized with Vazirmatn */ + html[dir="rtl"] { + font-family: 'Vazirmatn', system-ui, sans-serif; + font-size: 16px; + line-height: 1.8; + } + + /* LTR Typography - English */ + html[dir="ltr"] { + font-family: system-ui, 'Vazirmatn', sans-serif; + font-size: 16px; + line-height: 1.6; + } + + body { + @apply bg-surface text-text-primary; + } + + /* Persian headings - slightly tighter line-height */ + html[dir="rtl"] h1, + html[dir="rtl"] h2, + html[dir="rtl"] h3 { + line-height: 1.4; + } + + /* Avoid bold in Persian - use medium weight instead */ + html[dir="rtl"] strong, + html[dir="rtl"] b { + font-weight: 500; + } + + /* Focus visible for accessibility */ + :focus-visible { + outline: 2px solid var(--color-primary-500); + outline-offset: 2px; + } + + :focus:not(:focus-visible) { + outline: none; + } +} + +/* ============================================ + Component Styles + ============================================ */ +@layer components { + /* Container */ + .container-main { + @apply container mx-auto px-4; + max-width: 1200px; + } + + /* Primary Button */ + .btn-primary { + @apply inline-flex items-center justify-center; + @apply px-6 py-3 rounded-xl; + @apply font-medium text-white; + @apply transition-all duration-200; + background: var(--gradient-primary); + box-shadow: 0 4px 15px rgba(102, 179, 230, 0.4); + min-height: 44px; + } + + .btn-primary:hover { + transform: translateY(-2px); + box-shadow: 0 6px 20px rgba(102, 179, 230, 0.5); + } + + .btn-primary:active { + transform: translateY(0); + } + + .btn-primary:disabled { + @apply opacity-50 cursor-not-allowed; + transform: none; + } + + /* Secondary Button */ + .btn-secondary { + @apply inline-flex items-center justify-center; + @apply px-6 py-3 rounded-xl; + @apply font-medium; + @apply border-2 border-border; + @apply bg-surface text-text-primary; + @apply transition-all duration-200; + min-height: 44px; + } + + .btn-secondary:hover { + @apply border-primary-500 text-primary-600 bg-primary-50; + } + + /* Ghost Button */ + .btn-ghost { + @apply inline-flex items-center justify-center; + @apply px-6 py-3 rounded-xl; + @apply font-medium text-text-secondary; + @apply transition-all duration-200; + @apply bg-transparent; + min-height: 44px; + } + + .btn-ghost:hover { + @apply bg-surface-subtle text-text-primary; + } + + /* Card */ + .card { + @apply bg-surface-elevated rounded-2xl; + @apply transition-all duration-300; + box-shadow: var(--shadow-md); + } + + .card:hover { + transform: translateY(-4px); + box-shadow: var(--shadow-xl); + } + + /* Glass Card */ + .glass-card { + background: rgba(255, 255, 255, 0.8); + backdrop-filter: blur(20px); + -webkit-backdrop-filter: blur(20px); + border: 1px solid rgba(255, 255, 255, 0.3); + border-radius: var(--radius-2xl); + box-shadow: 0 8px 32px rgba(0, 0, 0, 0.1); + } + + .dark .glass-card { + background: rgba(30, 41, 59, 0.8); + border: 1px solid rgba(51, 65, 85, 0.3); + } + + /* Input Field */ + .input-field { + @apply w-full px-4 py-3 rounded-xl; + @apply border-2 border-border; + @apply bg-surface text-text-primary; + @apply transition-all duration-200; + @apply placeholder-text-muted; + min-height: 44px; + } + + .input-field:focus { + @apply border-primary-500; + @apply outline-none; + box-shadow: 0 0 0 3px rgba(102, 179, 230, 0.15); + } + + /* Hero Typography */ + .hero-title { + font-size: clamp(2.5rem, 8vw, 4rem); + font-weight: 700; + letter-spacing: -0.02em; + line-height: 1.1; + color: var(--color-text-primary); + } + + .hero-tagline { + font-size: clamp(1rem, 2.5vw, 1.25rem); + color: var(--color-primary-500); + font-weight: 500; + } + + .hero-subtitle { + font-size: clamp(1.125rem, 3vw, 1.25rem); + color: var(--color-text-secondary); + line-height: 1.6; + } + + /* Section */ + .section { + @apply py-16 lg:py-24; + } + + /* Section following a header - no top padding to avoid double spacing */ + .section-header + .section, + .section.no-top-padding { + @apply pt-0; + } + + /* Section header - reduced padding for hero/header sections */ + .section-header { + @apply py-12 lg:py-16; + } + + .section-title { + @apply text-3xl lg:text-4xl font-bold text-center mb-4; + color: var(--color-text-primary); + } + + .section-subtitle { + @apply text-lg text-center max-w-2xl mx-auto; + color: var(--color-text-secondary); + } +} + +/* ============================================ + Utility Classes + ============================================ */ +@layer utilities { + /* Keep numbers LTR in RTL context */ + .ltr-nums { + direction: ltr; + unicode-bidi: embed; + } + + /* Gradient text */ + .text-gradient { + background: var(--gradient-primary); + -webkit-background-clip: text; + -webkit-text-fill-color: transparent; + background-clip: text; + } + + /* Subtle gradient background */ + .bg-gradient-subtle { + background: var(--gradient-hero); + } + + /* Animation delay utilities */ + .animation-delay-100 { animation-delay: 0.1s; } + .animation-delay-200 { animation-delay: 0.2s; } + .animation-delay-300 { animation-delay: 0.3s; } + .animation-delay-400 { animation-delay: 0.4s; } + .animation-delay-500 { animation-delay: 0.5s; } + + /* Line clamp */ + .line-clamp-2 { + display: -webkit-box; + -webkit-line-clamp: 2; + -webkit-box-orient: vertical; + overflow: hidden; + } + + .line-clamp-3 { + display: -webkit-box; + -webkit-line-clamp: 3; + -webkit-box-orient: vertical; + overflow: hidden; + } +} diff --git a/apps/web/app/providers.tsx b/apps/web/app/providers.tsx new file mode 100644 index 0000000..bc9d4e1 --- /dev/null +++ b/apps/web/app/providers.tsx @@ -0,0 +1,21 @@ +'use client'; + +import { SessionProvider } from 'next-auth/react'; +import { ThemeProvider } from 'next-themes'; +import { CsrfProvider } from '@/components/CsrfProvider'; + +interface ProvidersProps { + children: React.ReactNode; +} + +export function Providers({ children }: ProvidersProps) { + return ( + + + + {children} + + + + ); +} diff --git a/apps/web/app/robots.ts b/apps/web/app/robots.ts new file mode 100644 index 0000000..7d09177 --- /dev/null +++ b/apps/web/app/robots.ts @@ -0,0 +1,37 @@ +import type { MetadataRoute } from 'next'; + +/** + * Dynamic robots.txt for mirror server support + * + * Primary server (IS_PRIMARY_SERVER=true): Allow all crawlers + * Mirror server (IS_PRIMARY_SERVER=false): Block all crawlers to prevent duplicate content + */ +export default function robots(): MetadataRoute.Robots { + const isPrimary = process.env.IS_PRIMARY_SERVER !== 'false'; + const primaryDomain = process.env.NEXT_PUBLIC_APP_URL || 'https://skills.palebluedot.live'; + + if (isPrimary) { + return { + rules: [ + { + userAgent: '*', + allow: '/', + disallow: ['/api/', '/monitoring/'], + }, + ], + sitemap: `${primaryDomain}/sitemap.xml`, + }; + } + + // Mirror server: Block all crawlers to prevent duplicate content indexing + return { + rules: [ + { + userAgent: '*', + disallow: '/', + }, + ], + // Point to primary sitemap even on mirror + sitemap: `${primaryDomain}/sitemap.xml`, + }; +} diff --git a/apps/web/components/ApiEndpointSection.tsx b/apps/web/components/ApiEndpointSection.tsx new file mode 100644 index 0000000..da452a7 --- /dev/null +++ b/apps/web/components/ApiEndpointSection.tsx @@ -0,0 +1,214 @@ +'use client'; + +import { useState } from 'react'; +import { ChevronDown, Lock } from 'lucide-react'; + +export interface EndpointParam { + name: string; + type: string; + required: boolean; + description: string; + default?: string; +} + +export interface EndpointDef { + method: 'GET' | 'POST' | 'DELETE'; + path: string; + description: string; + auth: boolean; + rateLimit?: string; + cacheTTL?: string; + params?: EndpointParam[]; + bodyParams?: EndpointParam[]; + responseExample?: string; + notes?: string; +} + +interface ApiEndpointSectionProps { + title: string; + description: string; + endpoints: EndpointDef[]; + labels: { + parameters: string; + requestBody: string; + required: string; + optional: string; + default: string; + responseExample: string; + rateLimit: string; + cache: string; + authRequired: string; + notes: string; + }; +} + +const METHOD_STYLES: Record = { + GET: 'bg-success/20 text-success', + POST: 'bg-primary-100 text-primary-600', + DELETE: 'bg-red-100 text-red-600', +}; + +export function ApiEndpointSection({ title, description, endpoints, labels }: ApiEndpointSectionProps) { + const [expandedIndexes, setExpandedIndexes] = useState>(new Set()); + + const toggle = (index: number) => { + setExpandedIndexes(prev => { + const next = new Set(prev); + if (next.has(index)) { + next.delete(index); + } else { + next.add(index); + } + return next; + }); + }; + + return ( +
+

{title}

+

{description}

+
+ {endpoints.map((ep, index) => { + const expanded = expandedIndexes.has(index); + const hasDetails = ep.params?.length || ep.bodyParams?.length || ep.responseExample || ep.notes; + + return ( +
+ + + {/* Description visible on mobile (below sm) */} +
+

{ep.description}

+
+ + {expanded && hasDetails && ( +
+ {/* Rate limit & cache badges */} + {(ep.rateLimit || ep.cacheTTL) && ( +
+ {ep.rateLimit && ( + + {labels.rateLimit}: {ep.rateLimit} + + )} + {ep.cacheTTL && ( + + {labels.cache}: {ep.cacheTTL} + + )} +
+ )} + + {/* Query parameters */} + {ep.params && ep.params.length > 0 && ( +
+

{labels.parameters}

+
+ + + + + + + + + + + {ep.params.map((p) => ( + + + + + + + ))} + +
NameTypeDescription
{p.name}{p.type} + + {p.required ? labels.required : labels.optional} + + + {p.description} + {p.default && (default: {p.default})} +
+
+
+ )} + + {/* Body parameters */} + {ep.bodyParams && ep.bodyParams.length > 0 && ( +
+

{labels.requestBody}

+
+ + + + + + + + + + + {ep.bodyParams.map((p) => ( + + + + + + + ))} + +
NameTypeDescription
{p.name}{p.type} + + {p.required ? labels.required : labels.optional} + + {p.description}
+
+
+ )} + + {/* Response example */} + {ep.responseExample && ( +
+

{labels.responseExample}

+
+
{ep.responseExample}
+
+
+ )} + + {/* Notes */} + {ep.notes && ( +
+ {labels.notes}: {ep.notes} +
+ )} +
+ )} +
+ ); + })} +
+
+ ); +} diff --git a/apps/web/components/AuthButton.tsx b/apps/web/components/AuthButton.tsx new file mode 100644 index 0000000..c04824c --- /dev/null +++ b/apps/web/components/AuthButton.tsx @@ -0,0 +1,114 @@ +'use client'; + +import { useState, useRef, useEffect } from 'react'; +import { useSession, signIn, signOut } from 'next-auth/react'; +import { useTranslations, useLocale } from 'next-intl'; +import Link from 'next/link'; +import { LogIn, LogOut, User, Heart, ChevronDown, Settings } from 'lucide-react'; +import { clsx } from 'clsx'; + +export function AuthButton() { + const { data: session, status } = useSession(); + const t = useTranslations('auth'); + const locale = useLocale(); + const [dropdownOpen, setDropdownOpen] = useState(false); + const dropdownRef = useRef(null); + + // Close dropdown when clicking outside + useEffect(() => { + function handleClickOutside(event: MouseEvent) { + if (dropdownRef.current && !dropdownRef.current.contains(event.target as Node)) { + setDropdownOpen(false); + } + } + document.addEventListener('mousedown', handleClickOutside); + return () => document.removeEventListener('mousedown', handleClickOutside); + }, []); + + if (status === 'loading') { + return ( +
+ ); + } + + if (!session) { + return ( + + ); + } + + return ( +
+ + + {dropdownOpen && ( +
+ {/* User info */} +
+

+ {session.user?.name || session.user?.username} +

+

+ @{session.user?.username} +

+
+ + {/* Menu items */} + setDropdownOpen(false)} + className="flex items-center gap-2 px-4 py-2 text-sm text-text-secondary hover:bg-surface-subtle transition-colors" + > + + {t('favorites')} + + + setDropdownOpen(false)} + className="flex items-center gap-2 px-4 py-2 text-sm text-text-secondary hover:bg-surface-subtle transition-colors" + > + + {t('manageSkills')} + + + +
+ )} +
+ ); +} diff --git a/apps/web/components/BetaBanner.tsx b/apps/web/components/BetaBanner.tsx new file mode 100644 index 0000000..98503b7 --- /dev/null +++ b/apps/web/components/BetaBanner.tsx @@ -0,0 +1,62 @@ +'use client'; + +import { useState, useEffect } from 'react'; +import { X, Zap } from 'lucide-react'; +import { useLocale, useTranslations } from 'next-intl'; +import Link from 'next/link'; +import { formatCompactNumber } from '@/lib/format-number'; + +export function BetaBanner() { + const [isVisible, setIsVisible] = useState(false); + const [skillCount, setSkillCount] = useState(null); + const locale = useLocale(); + const t = useTranslations('banner'); + + useEffect(() => { + const dismissed = localStorage.getItem('promo-banner-dismissed'); + if (!dismissed) { + setIsVisible(true); + } + + fetch('/api/stats') + .then((res) => res.json()) + .then((data) => { + if (data.totalSkills) { + setSkillCount(formatCompactNumber(data.totalSkills, locale)); + } + }) + .catch(() => {}); + }, [locale]); + + const handleDismiss = () => { + setIsVisible(false); + localStorage.setItem('promo-banner-dismissed', 'true'); + }; + + if (!isVisible) return null; + + const count = skillCount || (locale === 'fa' ? '۱۷۲k' : '172k'); + + return ( +
+
+ + {t('text', { count })} + | + + {t('feedback')} + + +
+
+ ); +} diff --git a/apps/web/components/BrowseFilters.tsx b/apps/web/components/BrowseFilters.tsx new file mode 100644 index 0000000..d5fa894 --- /dev/null +++ b/apps/web/components/BrowseFilters.tsx @@ -0,0 +1,661 @@ +'use client'; + +import { useRouter, useSearchParams, usePathname } from 'next/navigation'; +import { useCallback, useState, useTransition, useEffect, type FormEvent } from 'react'; +import { Search, Filter, ChevronLeft, ChevronRight, Loader2, X, Star, SlidersHorizontal, Sparkles } from 'lucide-react'; +import Link from 'next/link'; + +// Persian number conversion +const persianDigits = ['۰', '۱', '۲', '۳', '۴', '۵', '۶', '۷', '۸', '۹']; +function toPersianNumber(num: number | string): string { + return String(num).replace(/\d/g, (d) => persianDigits[parseInt(d, 10)]); +} + +interface SortOption { + id: string; + name: string; +} + +interface Category { + id: string; + name: string; + slug: string; + skillCount: number; + parentId?: string | null; +} + +interface HierarchicalCategory extends Category { + children?: Category[]; +} + +interface BrowseFiltersProps { + sortOptions: SortOption[]; + categories: Category[] | HierarchicalCategory[]; + locale?: string; + translations: { + category: string; + allCategories: string; + sort: string; + format?: string; + allFormats?: string; + agentSkills?: string; + searching: string; + viewFeatured?: string; + }; +} + +export function BrowseFilters({ + sortOptions, + categories, + locale = 'en', + translations, +}: BrowseFiltersProps) { + const router = useRouter(); + const pathname = usePathname(); + const searchParams = useSearchParams(); + const [isPending, startTransition] = useTransition(); + const [isOpen, setIsOpen] = useState(false); + + // Get current values from URL + const currentCategory = searchParams.get('category') || ''; + const currentSort = searchParams.get('sort') || 'lastDownloaded'; + const currentFormat = searchParams.get('format') || ''; + + // Count active filters for mobile badge + const activeFilterCount = [ + currentCategory ? 1 : 0, + currentSort !== 'lastDownloaded' ? 1 : 0, + currentFormat ? 1 : 0, + ].reduce((a, b) => a + b, 0); + + // Update URL with new params + const updateParams = useCallback( + (updates: Record) => { + const params = new URLSearchParams(searchParams.toString()); + + Object.entries(updates).forEach(([key, value]) => { + if (value === null || value === '' || value === 'all' || value === 'false') { + params.delete(key); + } else { + params.set(key, value); + } + }); + + // Reset page when filters change + params.delete('page'); + + startTransition(() => { + router.push(`${pathname}?${params.toString()}`, { scroll: false }); + }); + }, + [pathname, router, searchParams] + ); + + // Handle category change + const handleCategoryChange = (categoryId: string) => { + updateParams({ category: categoryId === '' ? null : categoryId }); + }; + + // Handle sort change + const handleSortChange = (e: React.ChangeEvent) => { + updateParams({ sort: e.target.value === 'lastDownloaded' ? null : e.target.value }); + }; + + // Handle format change + const handleFormatChange = (e: React.ChangeEvent) => { + updateParams({ format: e.target.value === '' ? null : e.target.value }); + }; + + // Filter content (shared between mobile and desktop) + const filterContent = ( + <> +

+ + {translations.category} +

+ + {/* Category Filter */} +
+ + {currentCategory && ( + + )} +
+ + {/* Sort */} +

+ {translations.sort} +

+ + + {/* Format Filter */} + {translations.format && ( + <> +

+ {translations.format} +

+ + + )} + + {/* View Featured Link */} + {translations.viewFeatured && ( + + + {translations.viewFeatured} + + )} + + {/* Loading indicator */} + {isPending && ( +
+ + {translations.searching} +
+ )} + + ); + + return ( + <> + {/* Mobile Filter Toggle */} +
+ + + {/* Mobile Filter Panel */} + {isOpen && ( +
+ {filterContent} +
+ )} +
+ + {/* Desktop Sidebar */} + + + ); +} + +// Search Bar Component with Debounce +interface SearchBarProps { + placeholder: string; + defaultValue?: string; +} + +export function SearchBar({ placeholder, defaultValue = '' }: SearchBarProps) { + const router = useRouter(); + const pathname = usePathname(); + const searchParams = useSearchParams(); + const [isPending, startTransition] = useTransition(); + const [searchQuery, setSearchQuery] = useState(defaultValue); + + // Sync internal state when URL changes externally (e.g., ActiveFilters clearAll) + useEffect(() => { + setSearchQuery(defaultValue); + }, [defaultValue]); + + const handleSearch = (e: FormEvent) => { + e.preventDefault(); + const params = new URLSearchParams(searchParams.toString()); + + if (searchQuery) { + params.set('q', searchQuery); + } else { + params.delete('q'); + } + params.delete('page'); + + startTransition(() => { + router.push(`${pathname}?${params.toString()}`, { scroll: false }); + }); + }; + + const clearSearch = () => { + setSearchQuery(''); + const params = new URLSearchParams(searchParams.toString()); + params.delete('q'); + params.delete('page'); + startTransition(() => { + router.push(`${pathname}?${params.toString()}`, { scroll: false }); + }); + }; + + return ( +
+
+ + setSearchQuery(e.target.value)} + disabled={isPending} + className="input-field ps-12 pe-24 disabled:opacity-50" + /> + {searchQuery && ( + + )} + +
+
+ ); +} + +// Active Filters Display - Shows applied filters as removable chips +interface ActiveFiltersProps { + query?: string; + categoryId?: string; + categoryName?: string; + sortBy?: string; + sortName?: string; + translations: { + search: string; + category: string; + sortBy: string; + clearAll: string; + }; +} + +export function ActiveFilters({ + query, + categoryId, + categoryName, + sortBy, + sortName, + translations, +}: ActiveFiltersProps) { + const router = useRouter(); + const pathname = usePathname(); + const searchParams = useSearchParams(); + const [isPending, startTransition] = useTransition(); + + const hasFilters = query || categoryId || (sortBy && sortBy !== 'lastDownloaded'); + + if (!hasFilters) return null; + + const removeFilter = (key: string) => { + const params = new URLSearchParams(searchParams.toString()); + params.delete(key); + params.delete('page'); + startTransition(() => { + router.push(`${pathname}?${params.toString()}`, { scroll: false }); + }); + }; + + const clearAll = () => { + startTransition(() => { + router.push(pathname, { scroll: false }); + }); + }; + + return ( +
+ {query && ( + + + "{query}" + + + )} + + {categoryId && categoryName && ( + + + {categoryName} + + + )} + + {sortBy && sortBy !== 'lastDownloaded' && sortName && ( + + + {sortName} + + + )} + + {(query || categoryId) && ( + + )} + + {isPending && } +
+ ); +} + +// Empty State Component +interface EmptyStateProps { + query?: string; + hasFilters: boolean; + locale?: string; + translations: { + noResults: string; + noResultsWithQuery: string; + tryDifferent: string; + clearFilters: string; + browseAll: string; + }; +} + +export function EmptyState({ query, hasFilters, locale = 'en', translations }: EmptyStateProps) { + const router = useRouter(); + const pathname = usePathname(); + const [isPending, startTransition] = useTransition(); + + const clearFilters = () => { + startTransition(() => { + router.push(pathname, { scroll: false }); + }); + }; + + return ( +
+
+ +
+

+ {query ? translations.noResultsWithQuery.replace('{query}', query) : translations.noResults} +

+

+ {translations.tryDifferent} +

+ {hasFilters && ( + + )} + {!hasFilters && ( + + + {translations.browseAll} + + )} +
+ ); +} + +// Pagination Component +interface PaginationProps { + currentPage: number; + totalPages: number; + locale?: string; + translations: { + previous: string; + next: string; + page: string; + of: string; + }; +} + +// Helper: Generate page numbers with ellipsis +function generatePageNumbers(current: number, total: number): (number | '...')[] { + if (total <= 7) { + return Array.from({ length: total }, (_, i) => i + 1); + } + + if (current <= 3) { + return [1, 2, 3, 4, '...', total]; + } + + if (current >= total - 2) { + return [1, '...', total - 3, total - 2, total - 1, total]; + } + + return [1, '...', current - 1, current, current + 1, '...', total]; +} + +export function Pagination({ currentPage, totalPages, locale = 'en', translations }: PaginationProps) { + // Helper to format numbers based on locale + const formatPageNumber = (num: number | string) => locale === 'fa' ? toPersianNumber(num) : String(num); + const router = useRouter(); + const pathname = usePathname(); + const searchParams = useSearchParams(); + const [isPending, startTransition] = useTransition(); + + const goToPage = (page: number) => { + if (page < 1 || page > totalPages || page === currentPage) return; + + const params = new URLSearchParams(searchParams.toString()); + if (page === 1) { + params.delete('page'); + } else { + params.set('page', String(page)); + } + + startTransition(() => { + router.push(`${pathname}?${params.toString()}`); + }); + }; + + // Don't show pagination if only one page + if (totalPages <= 1) return null; + + const pages = generatePageNumbers(currentPage, totalPages); + + return ( +
+ {/* Page indicator */} +

+ {translations.page} {formatPageNumber(currentPage)} {translations.of} {formatPageNumber(totalPages)} +

+ + {/* Pagination controls */} +
+ {/* Previous Button */} + + + {/* Page Numbers */} +
+ {pages.map((page, index) => ( + page === '...' ? ( + + ... + + ) : ( + + ) + ))} +
+ + {/* Next Button */} + +
+ + {/* Loading indicator */} + {isPending && ( +
+ +
+ )} +
+ ); +} + +// Legacy LoadMoreButton for backward compatibility (if needed elsewhere) +interface LoadMoreButtonProps { + hasMore: boolean; + currentPage: number; + label: string; +} + +export function LoadMoreButton({ hasMore, currentPage, label }: LoadMoreButtonProps) { + const router = useRouter(); + const pathname = usePathname(); + const searchParams = useSearchParams(); + const [isPending, startTransition] = useTransition(); + + const handleLoadMore = () => { + const params = new URLSearchParams(searchParams.toString()); + params.set('page', String(currentPage + 1)); + + startTransition(() => { + router.push(`${pathname}?${params.toString()}`); + }); + }; + + if (!hasMore) return null; + + return ( +
+ +
+ ); +} diff --git a/apps/web/components/ClaimForm.tsx b/apps/web/components/ClaimForm.tsx new file mode 100644 index 0000000..cbde2a8 --- /dev/null +++ b/apps/web/components/ClaimForm.tsx @@ -0,0 +1,831 @@ +'use client'; + +import { useState, useEffect, useCallback } from 'react'; +import { useSession, signIn } from 'next-auth/react'; +import { Loader2, CheckCircle, AlertCircle, Github, Clock, Plus, Minus, ChevronDown, ChevronUp, ExternalLink } from 'lucide-react'; +import { fetchWithCsrf } from '@/lib/csrf-client'; + +const isMirror = process.env.NEXT_PUBLIC_IS_PRIMARY === 'false'; +const PRIMARY_URL = process.env.NEXT_PUBLIC_PRIMARY_URL || process.env.NEXT_PUBLIC_APP_URL || 'https://skills.palebluedot.live'; + +// Validate GitHub URL format +function isValidGitHubUrl(url: string): boolean { + try { + const urlObj = new URL(url); + return urlObj.hostname === 'github.com' && urlObj.pathname.split('/').filter(Boolean).length >= 2; + } catch { + return false; + } +} + +interface ClaimFormProps { + translations: { + title: string; + subtitle: string; + loginRequired: string; + signIn: string; + optional: string; + mirror: { + title: string; + description: string; + button: string; + }; + tabs: { + remove: string; + add: string; + }; + form: { + skillId: string; + skillIdPlaceholder: string; + skillIdHelp: string; + reason: string; + reasonPlaceholder: string; + submit: string; + submitting: string; + }; + addForm: { + repositoryUrl: string; + repositoryUrlPlaceholder: string; + repositoryUrlHelp: string; + reason: string; + reasonPlaceholder: string; + submit: string; + submitting: string; + }; + success: { + title: string; + description: string; + viewRequests: string; + }; + addSuccess: { + title: string; + description: string; + descriptionNoSkillMd: string; + descriptionMultiplePrefix: string; + descriptionMultipleSuffix: string; + viewRequests: string; + foundSkillsIn: string; + root: string; + andMore: string; + }; + error: { + notOwner: string; + skillNotFound: string; + alreadyPending: string; + githubError: string; + invalidSkill: string; + invalidUrl: string; + invalidRepo: string; + rateLimitExceeded: string; + networkTimeout: string; + generic: string; + }; + myRequests: { + title: string; + empty: string; + status: { + pending: string; + approved: string; + rejected: string; + indexed: string; + }; + skillsFoundPrefix: string; + skillsFoundSuffix: string; + showLess: string; + showAllPrefix: string; + showAllSuffix: string; + }; + }; +} + +interface RemovalRequest { + id: string; + skillId: string; + reason: string; + status: string; + createdAt: string; +} + +interface AddRequest { + id: string; + repositoryUrl: string; + skillPath: string | null; + reason: string; + status: string; + hasSkillMd: boolean; + createdAt: string; + indexedSkillId: string | null; + errorMessage: string | null; +} + +export function ClaimForm({ translations }: ClaimFormProps) { + const { data: session, status } = useSession(); + const [activeTab, setActiveTab] = useState<'remove' | 'add'>('add'); // Default to 'add' tab + const [expandedRequests, setExpandedRequests] = useState>(new Set()); + + // Read tab from URL hash on mount and update on hash change + useEffect(() => { + const readHashTab = () => { + const hash = window.location.hash.slice(1); + if (hash === 'remove' || hash === 'add') { + setActiveTab(hash); + } + }; + readHashTab(); + window.addEventListener('hashchange', readHashTab); + return () => window.removeEventListener('hashchange', readHashTab); + }, []); + + // Update URL hash when tab changes + const handleTabChange = useCallback((tab: 'remove' | 'add') => { + setActiveTab(tab); + window.history.replaceState(null, '', `#${tab}`); + }, []); + + // Toggle expanded state for a request + const toggleExpanded = useCallback((requestId: string) => { + setExpandedRequests(prev => { + const next = new Set(prev); + if (next.has(requestId)) { + next.delete(requestId); + } else { + next.add(requestId); + } + return next; + }); + }, []); + + // Remove form state + const [skillId, setSkillId] = useState(''); + const [removeReason, setRemoveReason] = useState(''); + const [isSubmittingRemove, setIsSubmittingRemove] = useState(false); + const [removeError, setRemoveError] = useState(''); + const [removeSuccess, setRemoveSuccess] = useState(false); + const [removalRequests, setRemovalRequests] = useState([]); + const [loadingRemovalRequests, setLoadingRemovalRequests] = useState(false); + + // Add form state + const [repositoryUrl, setRepositoryUrl] = useState(''); + const [repositoryUrlError, setRepositoryUrlError] = useState(''); + const [addReason, setAddReason] = useState(''); + const [isSubmittingAdd, setIsSubmittingAdd] = useState(false); + const [addError, setAddError] = useState(''); + const [addSuccess, setAddSuccess] = useState(false); + const [addHasSkillMd, setAddHasSkillMd] = useState(true); + const [addSkillCount, setAddSkillCount] = useState(0); + const [addSkillPaths, setAddSkillPaths] = useState([]); + const [addRequests, setAddRequests] = useState([]); + const [loadingAddRequests, setLoadingAddRequests] = useState(false); + + // Fetch user's existing requests + useEffect(() => { + if (session) { + fetchRemovalRequests(); + fetchAddRequests(); + } + }, [session]); + + const fetchRemovalRequests = async () => { + setLoadingRemovalRequests(true); + try { + const res = await fetch('/api/skills/removal-request'); + if (res.ok) { + const data = await res.json(); + setRemovalRequests(data.requests || []); + } + } catch { + // Ignore errors + } finally { + setLoadingRemovalRequests(false); + } + }; + + const fetchAddRequests = async () => { + setLoadingAddRequests(true); + try { + const res = await fetch('/api/skills/add-request'); + if (res.ok) { + const data = await res.json(); + setAddRequests(data.requests || []); + } + } catch { + // Ignore errors + } finally { + setLoadingAddRequests(false); + } + }; + + const handleRemoveSubmit = async (e: React.FormEvent) => { + e.preventDefault(); + if (!session) { + signIn('github'); + return; + } + + if (!skillId.trim()) { + return; + } + + setIsSubmittingRemove(true); + setRemoveError(''); + setRemoveSuccess(false); + + try { + const res = await fetchWithCsrf('/api/skills/removal-request', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ skillId: skillId.trim(), reason: removeReason.trim() }), + }); + + const data = await res.json(); + + if (!res.ok) { + switch (data.code) { + case 'NOT_OWNER': + setRemoveError(translations.error.notOwner); + break; + case 'SKILL_NOT_FOUND': + setRemoveError(translations.error.skillNotFound); + break; + case 'ALREADY_PENDING': + setRemoveError(translations.error.alreadyPending); + break; + case 'GITHUB_ERROR': + setRemoveError(translations.error.githubError); + break; + case 'INVALID_SKILL': + setRemoveError(translations.error.invalidSkill); + break; + case 'AUTH_REQUIRED': + signIn('github'); + return; + default: + console.error('Claim form error:', data); + setRemoveError(translations.error.generic); + } + return; + } + + setRemoveSuccess(true); + setSkillId(''); + setRemoveReason(''); + fetchRemovalRequests(); + } catch { + setRemoveError(translations.error.generic); + } finally { + setIsSubmittingRemove(false); + } + }; + + const handleAddSubmit = async (e: React.FormEvent) => { + e.preventDefault(); + if (!session) { + signIn('github'); + return; + } + + if (!repositoryUrl.trim()) { + return; + } + + // Client-side validation + if (!isValidGitHubUrl(repositoryUrl.trim())) { + setAddError(translations.error.invalidUrl); + return; + } + + setIsSubmittingAdd(true); + setAddError(''); + setAddSuccess(false); + + try { + const res = await fetchWithCsrf('/api/skills/add-request', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + repositoryUrl: repositoryUrl.trim(), + reason: addReason.trim() || 'No reason provided', + }), + }); + + const data = await res.json(); + + if (!res.ok) { + switch (data.code) { + case 'INVALID_URL': + setAddError(translations.error.invalidUrl); + break; + case 'INVALID_REPO': + setAddError(translations.error.invalidRepo); + break; + case 'RATE_LIMIT_EXCEEDED': + setAddError(translations.error.rateLimitExceeded); + break; + case 'NETWORK_TIMEOUT': + setAddError(translations.error.networkTimeout); + break; + case 'ALREADY_PENDING': + setAddError(translations.error.alreadyPending); + break; + case 'AUTH_REQUIRED': + signIn('github'); + return; + default: + console.error('Add form error:', data); + setAddError(translations.error.generic); + } + return; + } + + setAddSuccess(true); + setAddHasSkillMd(data.hasSkillMd ?? true); + setAddSkillCount(data.skillCount ?? 0); + setAddSkillPaths(data.skillPaths ?? []); + setRepositoryUrl(''); + setAddReason(''); + fetchAddRequests(); + } catch { + setAddError(translations.error.generic); + } finally { + setIsSubmittingAdd(false); + } + }; + + const getStatusBadge = (requestStatus: string) => { + switch (requestStatus) { + case 'pending': + return ( + + + {translations.myRequests.status.pending} + + ); + case 'approved': + return ( + + + {translations.myRequests.status.approved} + + ); + case 'rejected': + return ( + + + {translations.myRequests.status.rejected} + + ); + case 'indexed': + return ( + + + {translations.myRequests.status.indexed} + + ); + default: + return null; + } + }; + + // Loading state + if (status === 'loading') { + return ( +
+ +
+ ); + } + + // Mirror server - redirect to primary + if (isMirror) { + return ( +
+ +

{translations.mirror.title}

+

{translations.mirror.description}

+ + + {translations.mirror.button} + +
+ ); + } + + // Not logged in + if (!session) { + return ( +
+ +

{translations.loginRequired}

+ +
+ ); + } + + // Remove success state + if (removeSuccess) { + return ( +
+ +

+ {translations.success.title} +

+

{translations.success.description}

+ +
+ ); + } + + // Add success state + if (addSuccess) { + // Determine which message to show + let successDescription: string | React.ReactNode; + if (addSkillCount > 1) { + successDescription = ( + <> + {translations.addSuccess.descriptionMultiplePrefix} + {addSkillCount} + {translations.addSuccess.descriptionMultipleSuffix} + + ); + } else if (addHasSkillMd) { + successDescription = translations.addSuccess.description; + } else { + successDescription = translations.addSuccess.descriptionNoSkillMd; + } + + return ( +
+ +

+ {translations.addSuccess.title} +

+

+ {successDescription} +

+ {addSkillCount > 1 && addSkillPaths.length > 0 && ( +
+

{translations.addSuccess.foundSkillsIn}

+
    + {addSkillPaths.slice(0, 10).map((path, index) => ( +
  • + {path === '' ? translations.addSuccess.root : path} +
  • + ))} + {addSkillPaths.length > 10 && ( +
  • {translations.addSuccess.andMore.replace('{count}', String(addSkillPaths.length - 10))}
  • + )} +
+
+ )} + +
+ ); + } + + return ( +
+ {/* Tabs */} +
+ + +
+ + {/* Remove Form */} + {activeTab === 'remove' && ( + <> +
+
+
+ + setSkillId(e.target.value)} + placeholder={translations.form.skillIdPlaceholder} + className="w-full px-4 py-2 border border-border rounded-lg bg-surface text-text-primary placeholder-text-muted focus:outline-none focus:ring-2 focus:ring-primary-500" + dir="ltr" + required + /> +

+ {translations.form.skillIdHelp} +

+
+ +
+ +