From 97b427831a7920fdf38116ea9f68daa2c9bcdda3 Mon Sep 17 00:00:00 2001 From: airano Date: Thu, 12 Feb 2026 06:08:51 +0330 Subject: [PATCH] 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 --- .dockerignore | 28 + .env.example | 85 + .eslintrc.cjs | 47 + .github/workflows/ci.yml | 150 + .gitignore | 52 + .prettierignore | 6 + .prettierrc | 10 + CLAUDE.md | 113 + CONTRIBUTING.md | 281 + LICENSE | 21 + README.md | 219 + SECURITY.md | 65 + apps/cli/README.md | 121 + apps/cli/package.json | 61 + apps/cli/src/commands/config.ts | 79 + apps/cli/src/commands/install.ts | 391 + apps/cli/src/commands/list.ts | 177 + apps/cli/src/commands/search.test.ts | 144 + apps/cli/src/commands/search.ts | 132 + apps/cli/src/index.ts | 239 + apps/cli/src/utils/api.test.ts | 176 + apps/cli/src/utils/api.ts | 260 + apps/cli/src/utils/github.ts | 251 + apps/cli/src/utils/paths.test.ts | 208 + apps/cli/src/utils/paths.ts | 152 + apps/cli/src/utils/platform-config.test.ts | 72 + apps/cli/src/utils/transform.test.ts | 197 + apps/cli/src/utils/transform.ts | 150 + apps/cli/tsconfig.json | 9 + apps/cli/tsup.config.ts | 16 + apps/cli/vitest.config.ts | 15 + apps/web/Dockerfile | 92 + apps/web/app/[locale]/about/page.tsx | 69 + apps/web/app/[locale]/attribution/page.tsx | 311 + apps/web/app/[locale]/browse/page.tsx | 286 + apps/web/app/[locale]/categories/page.tsx | 176 + apps/web/app/[locale]/claim/claim.test.ts | 190 + apps/web/app/[locale]/claim/page.tsx | 115 + apps/web/app/[locale]/claude-plugin/page.tsx | 285 + apps/web/app/[locale]/contact/page.tsx | 10 + apps/web/app/[locale]/docs/api/page.tsx | 556 + apps/web/app/[locale]/docs/cli/page.tsx | 152 + .../[locale]/docs/getting-started/page.tsx | 206 + apps/web/app/[locale]/docs/page.tsx | 71 + apps/web/app/[locale]/favorites/page.tsx | 76 + apps/web/app/[locale]/featured/page.tsx | 112 + apps/web/app/[locale]/layout.tsx | 79 + apps/web/app/[locale]/new/page.tsx | 193 + .../app/[locale]/owner/[username]/page.tsx | 307 + apps/web/app/[locale]/page.tsx | 274 + apps/web/app/[locale]/privacy/page.tsx | 90 + apps/web/app/[locale]/skill/[...id]/page.tsx | 518 + apps/web/app/[locale]/support/page.tsx | 117 + apps/web/app/[locale]/terms/page.tsx | 90 + apps/web/app/api/__tests__/helpers.ts | 205 + apps/web/app/api/__tests__/setup.ts | 62 + apps/web/app/api/attribution/route.ts | 139 + apps/web/app/api/auth/[...nextauth]/route.ts | 3 + apps/web/app/api/categories/route.test.ts | 122 + apps/web/app/api/categories/route.ts | 67 + apps/web/app/api/early-access/route.ts | 136 + apps/web/app/api/favorites/check/route.ts | 48 + apps/web/app/api/favorites/route.test.ts | 227 + apps/web/app/api/favorites/route.ts | 127 + apps/web/app/api/health/route.test.ts | 49 + apps/web/app/api/health/route.ts | 190 + .../web/app/api/newsletter/subscribe/route.ts | 114 + .../app/api/newsletter/unsubscribe/route.ts | 77 + apps/web/app/api/ratings/me/route.ts | 43 + apps/web/app/api/ratings/route.test.ts | 281 + apps/web/app/api/ratings/route.ts | 122 + apps/web/app/api/skill-files/route.ts | 380 + apps/web/app/api/skill-files/zip/route.ts | 438 + apps/web/app/api/skills/[...id]/route.ts | 93 + apps/web/app/api/skills/add-request/route.ts | 429 + .../web/app/api/skills/featured/route.test.ts | 102 + apps/web/app/api/skills/featured/route.ts | 88 + apps/web/app/api/skills/install/route.ts | 93 + apps/web/app/api/skills/recent/route.test.ts | 94 + apps/web/app/api/skills/recent/route.ts | 85 + .../app/api/skills/removal-request/route.ts | 210 + apps/web/app/api/skills/route.ts | 252 + .../app/api/skills/verify-ownership/route.ts | 78 + apps/web/app/api/stats/route.test.ts | 91 + apps/web/app/api/stats/route.ts | 79 + apps/web/app/globals.css | 399 + apps/web/app/providers.tsx | 21 + apps/web/app/robots.ts | 37 + apps/web/components/ApiEndpointSection.tsx | 214 + apps/web/components/AuthButton.tsx | 114 + apps/web/components/BetaBanner.tsx | 62 + apps/web/components/BrowseFilters.tsx | 661 + apps/web/components/ClaimForm.tsx | 831 ++ apps/web/components/CsrfProvider.tsx | 21 + apps/web/components/DiscoveryModes.tsx | 127 + apps/web/components/EarlyAccessForm.tsx | 107 + apps/web/components/ErrorBoundary.tsx | 134 + apps/web/components/FavoriteButton.tsx | 164 + apps/web/components/FavoritesList.tsx | 156 + apps/web/components/FavoritesSignIn.tsx | 27 + apps/web/components/Footer.tsx | 172 + apps/web/components/Header.tsx | 142 + apps/web/components/HeroSearch.tsx | 45 + apps/web/components/InstallSection.tsx | 689 + apps/web/components/NewSkillsTabs.tsx | 100 + apps/web/components/QueryNotification.tsx | 80 + apps/web/components/RatingStars.tsx | 152 + apps/web/components/ShareButton.tsx | 71 + apps/web/components/SkillCard.tsx | 129 + apps/web/components/ThemeToggle.tsx | 62 + apps/web/e2e/api.spec.ts | 71 + apps/web/e2e/browse.spec.ts | 53 + apps/web/e2e/categories.spec.ts | 39 + apps/web/e2e/homepage.spec.ts | 42 + apps/web/e2e/i18n.spec.ts | 75 + apps/web/e2e/mobile.spec.ts | 80 + apps/web/e2e/skill-detail.spec.ts | 63 + apps/web/i18n.ts | 14 + apps/web/i18n/navigation.ts | 8 + apps/web/i18n/request.ts | 16 + apps/web/instrumentation.ts | 35 + apps/web/lib/auth.ts | 105 + apps/web/lib/cache.ts | 224 + apps/web/lib/csrf-client.ts | 114 + apps/web/lib/csrf.ts | 63 + apps/web/lib/email-templates.ts | 438 + apps/web/lib/email.ts | 179 + apps/web/lib/env.ts | 94 + apps/web/lib/format-number.ts | 64 + apps/web/lib/github-token-manager.ts | 280 + apps/web/lib/rate-limit.ts | 263 + apps/web/lib/sanitize.ts | 107 + apps/web/lib/sentry.ts | 210 + apps/web/messages/en.json | 908 ++ apps/web/messages/fa.json | 908 ++ apps/web/middleware.ts | 89 + apps/web/next-env.d.ts | 6 + apps/web/next.config.js | 145 + apps/web/package.json | 48 + apps/web/playwright.config.ts | 35 + apps/web/postcss.config.js | 6 + apps/web/public/.gitkeep | 0 apps/web/public/logo.svg | 1 + apps/web/public/og/og-default.png | Bin 0 -> 1389809 bytes apps/web/sentry.client.config.ts | 58 + apps/web/sentry.edge.config.ts | 34 + apps/web/sentry.server.config.ts | 52 + apps/web/tailwind.config.ts | 133 + apps/web/tsconfig.json | 28 + apps/web/types/next-auth.d.ts | 20 + apps/web/vitest.config.ts | 23 + docker-compose.yml | 129 + docs/API.md | 326 + docs/MANUAL_TEST_CHECKLIST.md | 196 + docs/features/claim-system.md | 93 + docs/self-hosting.md | 240 + nginx/nginx.conf | 125 + package.json | 59 + packages/core/package.json | 50 + packages/core/src/index.ts | 45 + packages/core/src/security.test.ts | 223 + packages/core/src/security.ts | 444 + packages/core/src/skill-parser.test.ts | 307 + packages/core/src/skill-parser.ts | 481 + packages/core/src/types.ts | 169 + packages/core/src/validator.test.ts | 191 + packages/core/src/validator.ts | 196 + packages/core/tsconfig.json | 9 + packages/db/drizzle.config.ts | 10 + packages/db/package.json | 45 + packages/db/src/index.ts | 58 + packages/db/src/meilisearch.test.ts | 405 + packages/db/src/meilisearch.ts | 332 + packages/db/src/queries.test.ts | 661 + packages/db/src/queries.ts | 2010 +++ packages/db/src/schema.ts | 472 + packages/db/src/test-utils.ts | 214 + packages/db/tsconfig.json | 9 + packages/db/vitest.config.ts | 15 + packages/ui/package.json | 56 + packages/ui/src/components/badge.tsx | 34 + packages/ui/src/components/button.tsx | 47 + packages/ui/src/components/card.tsx | 56 + packages/ui/src/components/input.tsx | 24 + packages/ui/src/components/security-badge.tsx | 99 + packages/ui/src/components/skill-card.tsx | 99 + packages/ui/src/index.ts | 19 + packages/ui/src/lib/utils.ts | 6 + packages/ui/src/styles/globals.css | 59 + packages/ui/tailwind.config.js | 71 + packages/ui/tsconfig.json | 11 + pnpm-lock.yaml | 10989 ++++++++++++++++ pnpm-workspace.yaml | 4 + scripts/add-new-categories.sql | 26 + scripts/add-parent-categories.sql | 96 + scripts/categories.sql | 53 + scripts/generate-cli-demo.mjs | 228 + scripts/generate-marketing-assets.mjs | 280 + scripts/init-db.sql | 466 + scripts/migrate-add-source-format.sql | 5 + scripts/migrations/add-skill-management.sql | 44 + scripts/package.json | 16 + scripts/seed-data.sql | 58 + services/indexer/.eslintrc.json | 5 + services/indexer/Dockerfile | 49 + services/indexer/package.json | 31 + services/indexer/src/analyzer.ts | 365 + services/indexer/src/crawl.ts | 779 ++ services/indexer/src/crawler.ts | 883 ++ services/indexer/src/direct-crawl.ts | 190 + services/indexer/src/index.ts | 30 + services/indexer/src/meilisearch-sync.ts | 306 + services/indexer/src/octokit-pool.ts | 44 + services/indexer/src/queue.ts | 311 + services/indexer/src/skill-indexer.ts | 119 + .../indexer/src/strategies/awesome-list.ts | 203 + services/indexer/src/strategies/deep-scan.ts | 261 + .../indexer/src/strategies/fork-network.ts | 194 + services/indexer/src/strategies/index.ts | 292 + .../indexer/src/strategies/topic-search.ts | 314 + services/indexer/src/token-manager.ts | 239 + services/indexer/src/worker.ts | 649 + services/indexer/tsconfig.json | 9 + services/indexer/tsup.config.ts | 39 + tsconfig.base.json | 23 + tsconfig.json | 17 + turbo.json | 25 + 227 files changed, 48411 insertions(+) create mode 100644 .dockerignore create mode 100644 .env.example create mode 100644 .eslintrc.cjs create mode 100644 .github/workflows/ci.yml create mode 100644 .gitignore create mode 100644 .prettierignore create mode 100644 .prettierrc create mode 100644 CLAUDE.md create mode 100644 CONTRIBUTING.md create mode 100644 LICENSE create mode 100644 README.md create mode 100644 SECURITY.md create mode 100644 apps/cli/README.md create mode 100644 apps/cli/package.json create mode 100644 apps/cli/src/commands/config.ts create mode 100644 apps/cli/src/commands/install.ts create mode 100644 apps/cli/src/commands/list.ts create mode 100644 apps/cli/src/commands/search.test.ts create mode 100644 apps/cli/src/commands/search.ts create mode 100644 apps/cli/src/index.ts create mode 100644 apps/cli/src/utils/api.test.ts create mode 100644 apps/cli/src/utils/api.ts create mode 100644 apps/cli/src/utils/github.ts create mode 100644 apps/cli/src/utils/paths.test.ts create mode 100644 apps/cli/src/utils/paths.ts create mode 100644 apps/cli/src/utils/platform-config.test.ts create mode 100644 apps/cli/src/utils/transform.test.ts create mode 100644 apps/cli/src/utils/transform.ts create mode 100644 apps/cli/tsconfig.json create mode 100644 apps/cli/tsup.config.ts create mode 100644 apps/cli/vitest.config.ts create mode 100644 apps/web/Dockerfile create mode 100644 apps/web/app/[locale]/about/page.tsx create mode 100644 apps/web/app/[locale]/attribution/page.tsx create mode 100644 apps/web/app/[locale]/browse/page.tsx create mode 100644 apps/web/app/[locale]/categories/page.tsx create mode 100644 apps/web/app/[locale]/claim/claim.test.ts create mode 100644 apps/web/app/[locale]/claim/page.tsx create mode 100644 apps/web/app/[locale]/claude-plugin/page.tsx create mode 100644 apps/web/app/[locale]/contact/page.tsx create mode 100644 apps/web/app/[locale]/docs/api/page.tsx create mode 100644 apps/web/app/[locale]/docs/cli/page.tsx create mode 100644 apps/web/app/[locale]/docs/getting-started/page.tsx create mode 100644 apps/web/app/[locale]/docs/page.tsx create mode 100644 apps/web/app/[locale]/favorites/page.tsx create mode 100644 apps/web/app/[locale]/featured/page.tsx create mode 100644 apps/web/app/[locale]/layout.tsx create mode 100644 apps/web/app/[locale]/new/page.tsx create mode 100644 apps/web/app/[locale]/owner/[username]/page.tsx create mode 100644 apps/web/app/[locale]/page.tsx create mode 100644 apps/web/app/[locale]/privacy/page.tsx create mode 100644 apps/web/app/[locale]/skill/[...id]/page.tsx create mode 100644 apps/web/app/[locale]/support/page.tsx create mode 100644 apps/web/app/[locale]/terms/page.tsx create mode 100644 apps/web/app/api/__tests__/helpers.ts create mode 100644 apps/web/app/api/__tests__/setup.ts create mode 100644 apps/web/app/api/attribution/route.ts create mode 100644 apps/web/app/api/auth/[...nextauth]/route.ts create mode 100644 apps/web/app/api/categories/route.test.ts create mode 100644 apps/web/app/api/categories/route.ts create mode 100644 apps/web/app/api/early-access/route.ts create mode 100644 apps/web/app/api/favorites/check/route.ts create mode 100644 apps/web/app/api/favorites/route.test.ts create mode 100644 apps/web/app/api/favorites/route.ts create mode 100644 apps/web/app/api/health/route.test.ts create mode 100644 apps/web/app/api/health/route.ts create mode 100644 apps/web/app/api/newsletter/subscribe/route.ts create mode 100644 apps/web/app/api/newsletter/unsubscribe/route.ts create mode 100644 apps/web/app/api/ratings/me/route.ts create mode 100644 apps/web/app/api/ratings/route.test.ts create mode 100644 apps/web/app/api/ratings/route.ts create mode 100644 apps/web/app/api/skill-files/route.ts create mode 100644 apps/web/app/api/skill-files/zip/route.ts create mode 100644 apps/web/app/api/skills/[...id]/route.ts create mode 100644 apps/web/app/api/skills/add-request/route.ts create mode 100644 apps/web/app/api/skills/featured/route.test.ts create mode 100644 apps/web/app/api/skills/featured/route.ts create mode 100644 apps/web/app/api/skills/install/route.ts create mode 100644 apps/web/app/api/skills/recent/route.test.ts create mode 100644 apps/web/app/api/skills/recent/route.ts create mode 100644 apps/web/app/api/skills/removal-request/route.ts create mode 100644 apps/web/app/api/skills/route.ts create mode 100644 apps/web/app/api/skills/verify-ownership/route.ts create mode 100644 apps/web/app/api/stats/route.test.ts create mode 100644 apps/web/app/api/stats/route.ts create mode 100644 apps/web/app/globals.css create mode 100644 apps/web/app/providers.tsx create mode 100644 apps/web/app/robots.ts create mode 100644 apps/web/components/ApiEndpointSection.tsx create mode 100644 apps/web/components/AuthButton.tsx create mode 100644 apps/web/components/BetaBanner.tsx create mode 100644 apps/web/components/BrowseFilters.tsx create mode 100644 apps/web/components/ClaimForm.tsx create mode 100644 apps/web/components/CsrfProvider.tsx create mode 100644 apps/web/components/DiscoveryModes.tsx create mode 100644 apps/web/components/EarlyAccessForm.tsx create mode 100644 apps/web/components/ErrorBoundary.tsx create mode 100644 apps/web/components/FavoriteButton.tsx create mode 100644 apps/web/components/FavoritesList.tsx create mode 100644 apps/web/components/FavoritesSignIn.tsx create mode 100644 apps/web/components/Footer.tsx create mode 100644 apps/web/components/Header.tsx create mode 100644 apps/web/components/HeroSearch.tsx create mode 100644 apps/web/components/InstallSection.tsx create mode 100644 apps/web/components/NewSkillsTabs.tsx create mode 100644 apps/web/components/QueryNotification.tsx create mode 100644 apps/web/components/RatingStars.tsx create mode 100644 apps/web/components/ShareButton.tsx create mode 100644 apps/web/components/SkillCard.tsx create mode 100644 apps/web/components/ThemeToggle.tsx create mode 100644 apps/web/e2e/api.spec.ts create mode 100644 apps/web/e2e/browse.spec.ts create mode 100644 apps/web/e2e/categories.spec.ts create mode 100644 apps/web/e2e/homepage.spec.ts create mode 100644 apps/web/e2e/i18n.spec.ts create mode 100644 apps/web/e2e/mobile.spec.ts create mode 100644 apps/web/e2e/skill-detail.spec.ts create mode 100644 apps/web/i18n.ts create mode 100644 apps/web/i18n/navigation.ts create mode 100644 apps/web/i18n/request.ts create mode 100644 apps/web/instrumentation.ts create mode 100644 apps/web/lib/auth.ts create mode 100644 apps/web/lib/cache.ts create mode 100644 apps/web/lib/csrf-client.ts create mode 100644 apps/web/lib/csrf.ts create mode 100644 apps/web/lib/email-templates.ts create mode 100644 apps/web/lib/email.ts create mode 100644 apps/web/lib/env.ts create mode 100644 apps/web/lib/format-number.ts create mode 100644 apps/web/lib/github-token-manager.ts create mode 100644 apps/web/lib/rate-limit.ts create mode 100644 apps/web/lib/sanitize.ts create mode 100644 apps/web/lib/sentry.ts create mode 100644 apps/web/messages/en.json create mode 100644 apps/web/messages/fa.json create mode 100644 apps/web/middleware.ts create mode 100644 apps/web/next-env.d.ts create mode 100644 apps/web/next.config.js create mode 100644 apps/web/package.json create mode 100644 apps/web/playwright.config.ts create mode 100644 apps/web/postcss.config.js create mode 100644 apps/web/public/.gitkeep create mode 100644 apps/web/public/logo.svg create mode 100644 apps/web/public/og/og-default.png create mode 100644 apps/web/sentry.client.config.ts create mode 100644 apps/web/sentry.edge.config.ts create mode 100644 apps/web/sentry.server.config.ts create mode 100644 apps/web/tailwind.config.ts create mode 100644 apps/web/tsconfig.json create mode 100644 apps/web/types/next-auth.d.ts create mode 100644 apps/web/vitest.config.ts create mode 100644 docker-compose.yml create mode 100644 docs/API.md create mode 100644 docs/MANUAL_TEST_CHECKLIST.md create mode 100644 docs/features/claim-system.md create mode 100644 docs/self-hosting.md create mode 100644 nginx/nginx.conf create mode 100644 package.json create mode 100644 packages/core/package.json create mode 100644 packages/core/src/index.ts create mode 100644 packages/core/src/security.test.ts create mode 100644 packages/core/src/security.ts create mode 100644 packages/core/src/skill-parser.test.ts create mode 100644 packages/core/src/skill-parser.ts create mode 100644 packages/core/src/types.ts create mode 100644 packages/core/src/validator.test.ts create mode 100644 packages/core/src/validator.ts create mode 100644 packages/core/tsconfig.json create mode 100644 packages/db/drizzle.config.ts create mode 100644 packages/db/package.json create mode 100644 packages/db/src/index.ts create mode 100644 packages/db/src/meilisearch.test.ts create mode 100644 packages/db/src/meilisearch.ts create mode 100644 packages/db/src/queries.test.ts create mode 100644 packages/db/src/queries.ts create mode 100644 packages/db/src/schema.ts create mode 100644 packages/db/src/test-utils.ts create mode 100644 packages/db/tsconfig.json create mode 100644 packages/db/vitest.config.ts create mode 100644 packages/ui/package.json create mode 100644 packages/ui/src/components/badge.tsx create mode 100644 packages/ui/src/components/button.tsx create mode 100644 packages/ui/src/components/card.tsx create mode 100644 packages/ui/src/components/input.tsx create mode 100644 packages/ui/src/components/security-badge.tsx create mode 100644 packages/ui/src/components/skill-card.tsx create mode 100644 packages/ui/src/index.ts create mode 100644 packages/ui/src/lib/utils.ts create mode 100644 packages/ui/src/styles/globals.css create mode 100644 packages/ui/tailwind.config.js create mode 100644 packages/ui/tsconfig.json create mode 100644 pnpm-lock.yaml create mode 100644 pnpm-workspace.yaml create mode 100644 scripts/add-new-categories.sql create mode 100644 scripts/add-parent-categories.sql create mode 100644 scripts/categories.sql create mode 100644 scripts/generate-cli-demo.mjs create mode 100644 scripts/generate-marketing-assets.mjs create mode 100644 scripts/init-db.sql create mode 100644 scripts/migrate-add-source-format.sql create mode 100644 scripts/migrations/add-skill-management.sql create mode 100644 scripts/package.json create mode 100644 scripts/seed-data.sql create mode 100644 services/indexer/.eslintrc.json create mode 100644 services/indexer/Dockerfile create mode 100644 services/indexer/package.json create mode 100644 services/indexer/src/analyzer.ts create mode 100644 services/indexer/src/crawl.ts create mode 100644 services/indexer/src/crawler.ts create mode 100644 services/indexer/src/direct-crawl.ts create mode 100644 services/indexer/src/index.ts create mode 100644 services/indexer/src/meilisearch-sync.ts create mode 100644 services/indexer/src/octokit-pool.ts create mode 100644 services/indexer/src/queue.ts create mode 100644 services/indexer/src/skill-indexer.ts create mode 100644 services/indexer/src/strategies/awesome-list.ts create mode 100644 services/indexer/src/strategies/deep-scan.ts create mode 100644 services/indexer/src/strategies/fork-network.ts create mode 100644 services/indexer/src/strategies/index.ts create mode 100644 services/indexer/src/strategies/topic-search.ts create mode 100644 services/indexer/src/token-manager.ts create mode 100644 services/indexer/src/worker.ts create mode 100644 services/indexer/tsconfig.json create mode 100644 services/indexer/tsup.config.ts create mode 100644 tsconfig.base.json create mode 100644 tsconfig.json create mode 100644 turbo.json 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} +

+
+ +
+ +