chore: import upstream snapshot with attribution
This commit is contained in:
+187
@@ -0,0 +1,187 @@
|
||||
# API - Vercel Serverless Functions
|
||||
|
||||
Critical infrastructure for claude-code-templates component ecosystem.
|
||||
|
||||
## ⚠️ CRITICAL ENDPOINTS
|
||||
|
||||
These endpoints are essential for component download metrics. **DO NOT BREAK THEM.**
|
||||
|
||||
### `/api/track-download-supabase` 🔴
|
||||
|
||||
Tracks every component installation from the CLI tool.
|
||||
|
||||
**Used by**: `cli-tool/bin/create-claude-config.js`
|
||||
|
||||
**Called on**: Every `--agent`, `--command`, `--mcp`, `--hook`, `--setting`, `--skill` installation
|
||||
|
||||
**Database**: Supabase (component_downloads, download_stats)
|
||||
|
||||
### `/api/discord/interactions` 🟡
|
||||
|
||||
Discord bot for component discovery and search.
|
||||
|
||||
**Features**: `/search`, `/info`, `/install`, `/popular`, `/random`
|
||||
|
||||
### `/api/claude-code-check` 🟢
|
||||
|
||||
Monitors Claude Code releases and sends Discord notifications.
|
||||
|
||||
**Frequency**: Every 4 hours (Vercel Cron)
|
||||
|
||||
**Database**: Neon (claude_code_versions, claude_code_changes)
|
||||
|
||||
## 🧪 Testing
|
||||
|
||||
**ALWAYS run tests before deploying:**
|
||||
|
||||
```bash
|
||||
# Run all tests
|
||||
npm test
|
||||
|
||||
# Run only critical endpoint tests
|
||||
npm run test:api
|
||||
|
||||
# Watch mode
|
||||
npm run test:watch
|
||||
|
||||
# With coverage
|
||||
npm run test:coverage
|
||||
```
|
||||
|
||||
## 🚀 Deployment
|
||||
|
||||
### Pre-Deployment Checklist
|
||||
|
||||
```bash
|
||||
# 1. Run validation script (from project root)
|
||||
./scripts/predeploy-check.sh
|
||||
|
||||
# 2. If checks pass, deploy
|
||||
vercel --prod
|
||||
```
|
||||
|
||||
### Manual Deploy Steps
|
||||
|
||||
```bash
|
||||
# 1. Install dependencies
|
||||
npm install
|
||||
|
||||
# 2. Run tests
|
||||
npm run test:api
|
||||
|
||||
# 3. Deploy
|
||||
cd ..
|
||||
vercel --prod
|
||||
```
|
||||
|
||||
## 📁 File Structure
|
||||
|
||||
```
|
||||
api/
|
||||
├── track-download-supabase.js # Component download tracking (CRITICAL)
|
||||
├── claude-code-check.js # Claude Code changelog monitor
|
||||
├── _parser-claude.js # Changelog parser utility
|
||||
├── discord/
|
||||
│ └── interactions.js # Discord bot handler
|
||||
├── claude-code-monitor/
|
||||
│ ├── README.md # Detailed docs
|
||||
│ ├── check-version.js # Version checker
|
||||
│ ├── discord-notifier.js # Discord notifications
|
||||
│ ├── parser.js # Changelog parser
|
||||
│ └── webhook.js # NPM webhook handler
|
||||
├── __tests__/
|
||||
│ └── endpoints.test.js # Critical endpoint tests
|
||||
├── jest.config.cjs # Jest configuration
|
||||
├── package.json # Dependencies & scripts
|
||||
└── README.md # This file
|
||||
```
|
||||
|
||||
## 🔧 Environment Variables
|
||||
|
||||
Required in Vercel Dashboard:
|
||||
|
||||
```bash
|
||||
# Supabase
|
||||
SUPABASE_URL=xxx
|
||||
SUPABASE_SERVICE_ROLE_KEY=xxx
|
||||
|
||||
# Neon Database
|
||||
NEON_DATABASE_URL=xxx
|
||||
|
||||
# Discord
|
||||
DISCORD_APP_ID=xxx
|
||||
DISCORD_BOT_TOKEN=xxx
|
||||
DISCORD_PUBLIC_KEY=xxx
|
||||
DISCORD_WEBHOOK_URL_CHANGELOG=xxx
|
||||
```
|
||||
|
||||
## 🐛 Troubleshooting
|
||||
|
||||
### Tests Failing?
|
||||
|
||||
```bash
|
||||
# Test against production
|
||||
API_BASE_URL=https://aitmpl.com npm run test:api
|
||||
|
||||
# Check specific endpoint
|
||||
curl -X POST https://aitmpl.com/api/track-download-supabase \
|
||||
-H "Content-Type: application/json" \
|
||||
-d '{"type":"agent","name":"test","path":"test/path"}'
|
||||
```
|
||||
|
||||
### Endpoint Not Found After Deploy?
|
||||
|
||||
1. Check Vercel function logs: `vercel logs aitmpl.com --follow`
|
||||
2. Verify file is in `/api` root (not nested)
|
||||
3. Ensure proper export: `export default async function handler(req, res) {}`
|
||||
|
||||
### No Download Tracking Data?
|
||||
|
||||
1. Check Vercel logs
|
||||
2. Verify environment variables are set
|
||||
3. Test endpoint manually (see above)
|
||||
4. Check Supabase table: `select * from component_downloads order by created_at desc limit 10;`
|
||||
|
||||
## 📊 Monitoring
|
||||
|
||||
### Vercel Dashboard
|
||||
|
||||
https://vercel.com/dashboard → aitmpl → Functions
|
||||
|
||||
### Real-time Logs
|
||||
|
||||
```bash
|
||||
vercel logs aitmpl.com --follow
|
||||
```
|
||||
|
||||
### Database Queries
|
||||
|
||||
**Supabase**:
|
||||
```sql
|
||||
SELECT type, name, COUNT(*) as downloads
|
||||
FROM component_downloads
|
||||
WHERE download_timestamp > NOW() - INTERVAL '7 days'
|
||||
GROUP BY type, name
|
||||
ORDER BY downloads DESC;
|
||||
```
|
||||
|
||||
**Neon**:
|
||||
```sql
|
||||
SELECT version, published_at, discord_notified
|
||||
FROM claude_code_versions
|
||||
ORDER BY published_at DESC;
|
||||
```
|
||||
|
||||
## 🆘 Emergency Rollback
|
||||
|
||||
```bash
|
||||
# 1. List recent deployments
|
||||
vercel ls
|
||||
|
||||
# 2. Promote previous working deployment
|
||||
vercel promote <previous-deployment-url>
|
||||
```
|
||||
|
||||
## 📖 More Info
|
||||
|
||||
See `../CLAUDE.md` section "API Architecture & Deployment" for detailed documentation.
|
||||
@@ -0,0 +1,421 @@
|
||||
/**
|
||||
* API Endpoints Test Suite
|
||||
*
|
||||
* CRITICAL: These tests MUST pass before deploying to production.
|
||||
* Tracking endpoints are essential for component metrics.
|
||||
*
|
||||
* Run: npm run test:api
|
||||
*/
|
||||
|
||||
const axios = require('axios');
|
||||
|
||||
// Configuration
|
||||
const BASE_URL = process.env.API_BASE_URL || 'https://aitmpl.com';
|
||||
const TIMEOUT = 30000; // 30 seconds
|
||||
|
||||
describe('API Endpoints - Critical Tests', () => {
|
||||
|
||||
describe('🔴 CRITICAL: Component Download Tracking', () => {
|
||||
|
||||
test('POST /api/track-download-supabase should be available', async () => {
|
||||
const response = await axios.post(
|
||||
`${BASE_URL}/api/track-download-supabase`,
|
||||
{
|
||||
type: 'agent',
|
||||
name: 'test-agent',
|
||||
path: 'agents/test',
|
||||
category: 'test',
|
||||
cliVersion: '1.0.0'
|
||||
},
|
||||
{
|
||||
timeout: TIMEOUT,
|
||||
validateStatus: (status) => status < 500 // Accept any non-5xx status
|
||||
}
|
||||
);
|
||||
|
||||
// Endpoint must respond (can be 200, 400, etc. but NOT 500)
|
||||
expect(response.status).toBeLessThan(500);
|
||||
expect(response.status).toBeGreaterThanOrEqual(200);
|
||||
}, TIMEOUT);
|
||||
|
||||
test('POST /api/track-download-supabase should reject requests without data', async () => {
|
||||
const response = await axios.post(
|
||||
`${BASE_URL}/api/track-download-supabase`,
|
||||
{},
|
||||
{
|
||||
timeout: TIMEOUT,
|
||||
validateStatus: () => true // Accept any status
|
||||
}
|
||||
);
|
||||
|
||||
// Should return 400 Bad Request
|
||||
expect(response.status).toBe(400);
|
||||
expect(response.data).toHaveProperty('error');
|
||||
}, TIMEOUT);
|
||||
|
||||
test('POST /api/track-download-supabase should validate component type', async () => {
|
||||
const response = await axios.post(
|
||||
`${BASE_URL}/api/track-download-supabase`,
|
||||
{
|
||||
type: 'invalid-type',
|
||||
name: 'test',
|
||||
path: 'test/path'
|
||||
},
|
||||
{
|
||||
timeout: TIMEOUT,
|
||||
validateStatus: () => true
|
||||
}
|
||||
);
|
||||
|
||||
expect(response.status).toBe(400);
|
||||
expect(response.data.error).toContain('type');
|
||||
}, TIMEOUT);
|
||||
|
||||
});
|
||||
|
||||
describe('🟡 Discord Bot Integration', () => {
|
||||
|
||||
test('POST /api/discord/interactions should be available', async () => {
|
||||
const response = await axios.post(
|
||||
`${BASE_URL}/api/discord/interactions`,
|
||||
{},
|
||||
{
|
||||
timeout: TIMEOUT,
|
||||
validateStatus: () => true
|
||||
}
|
||||
);
|
||||
|
||||
// Endpoint should respond (even if Discord validation fails)
|
||||
expect(response.status).toBeLessThan(500);
|
||||
}, TIMEOUT);
|
||||
|
||||
});
|
||||
|
||||
describe('🟢 Claude Code Changelog Monitor', () => {
|
||||
|
||||
test('GET /api/claude-code-check should be available', async () => {
|
||||
const response = await axios.get(
|
||||
`${BASE_URL}/api/claude-code-check`,
|
||||
{
|
||||
timeout: TIMEOUT,
|
||||
validateStatus: () => true
|
||||
}
|
||||
);
|
||||
|
||||
// Endpoint should respond
|
||||
expect(response.status).toBeLessThan(500);
|
||||
}, TIMEOUT);
|
||||
|
||||
});
|
||||
|
||||
describe('🔵 Command Usage Tracking', () => {
|
||||
|
||||
test('POST /api/track-command-usage should be available', async () => {
|
||||
const response = await axios.post(
|
||||
`${BASE_URL}/api/track-command-usage`,
|
||||
{
|
||||
command: 'analytics',
|
||||
cliVersion: '1.26.3',
|
||||
nodeVersion: 'v18.0.0',
|
||||
platform: 'darwin',
|
||||
arch: 'arm64',
|
||||
sessionId: 'test-session-123',
|
||||
metadata: { tunnel: false }
|
||||
},
|
||||
{
|
||||
timeout: TIMEOUT,
|
||||
validateStatus: (status) => status < 500
|
||||
}
|
||||
);
|
||||
|
||||
// Endpoint must respond (can be 200, 400, etc. but NOT 500)
|
||||
expect(response.status).toBeLessThan(500);
|
||||
expect(response.status).toBeGreaterThanOrEqual(200);
|
||||
}, TIMEOUT);
|
||||
|
||||
test('POST /api/track-command-usage should reject invalid commands', async () => {
|
||||
const response = await axios.post(
|
||||
`${BASE_URL}/api/track-command-usage`,
|
||||
{
|
||||
command: 'invalid-command',
|
||||
cliVersion: '1.26.3',
|
||||
nodeVersion: 'v18.0.0',
|
||||
platform: 'darwin'
|
||||
},
|
||||
{
|
||||
timeout: TIMEOUT,
|
||||
validateStatus: () => true
|
||||
}
|
||||
);
|
||||
|
||||
expect(response.status).toBe(400);
|
||||
expect(response.data).toHaveProperty('error');
|
||||
expect(response.data.error).toContain('Invalid command');
|
||||
}, TIMEOUT);
|
||||
|
||||
test('POST /api/track-command-usage should reject requests without command', async () => {
|
||||
const response = await axios.post(
|
||||
`${BASE_URL}/api/track-command-usage`,
|
||||
{
|
||||
cliVersion: '1.26.3',
|
||||
platform: 'darwin'
|
||||
},
|
||||
{
|
||||
timeout: TIMEOUT,
|
||||
validateStatus: () => true
|
||||
}
|
||||
);
|
||||
|
||||
expect(response.status).toBe(400);
|
||||
expect(response.data).toHaveProperty('error');
|
||||
}, TIMEOUT);
|
||||
|
||||
});
|
||||
|
||||
describe('📊 API Health Check', () => {
|
||||
|
||||
test('All critical endpoints should respond within 30s', async () => {
|
||||
const startTime = Date.now();
|
||||
|
||||
const endpoints = [
|
||||
'/api/track-download-supabase',
|
||||
'/api/track-command-usage',
|
||||
'/api/discord/interactions',
|
||||
'/api/claude-code-check'
|
||||
];
|
||||
|
||||
for (const endpoint of endpoints) {
|
||||
const method = endpoint.includes('track-') || endpoint.includes('discord')
|
||||
? 'post'
|
||||
: 'get';
|
||||
|
||||
try {
|
||||
await axios[method](
|
||||
`${BASE_URL}${endpoint}`,
|
||||
method === 'post' ? {} : undefined,
|
||||
{
|
||||
timeout: TIMEOUT,
|
||||
validateStatus: () => true
|
||||
}
|
||||
);
|
||||
} catch (error) {
|
||||
// If it fails due to timeout or connection, test should fail
|
||||
if (error.code === 'ECONNABORTED' || error.code === 'ENOTFOUND') {
|
||||
throw new Error(`Endpoint ${endpoint} not responding: ${error.message}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const totalTime = Date.now() - startTime;
|
||||
expect(totalTime).toBeLessThan(30000);
|
||||
}, 45000); // Total timeout of 45s for this test
|
||||
|
||||
});
|
||||
|
||||
});
|
||||
|
||||
describe('API Endpoints - Functional Tests', () => {
|
||||
|
||||
describe('Component Download Tracking - Data Validation', () => {
|
||||
|
||||
const validTypes = ['agent', 'command', 'setting', 'hook', 'mcp', 'skill', 'template'];
|
||||
|
||||
test('should accept all valid component types', async () => {
|
||||
for (const type of validTypes) {
|
||||
const response = await axios.post(
|
||||
`${BASE_URL}/api/track-download-supabase`,
|
||||
{
|
||||
type,
|
||||
name: `test-${type}`,
|
||||
path: `${type}s/test`
|
||||
},
|
||||
{
|
||||
timeout: TIMEOUT,
|
||||
validateStatus: () => true
|
||||
}
|
||||
);
|
||||
|
||||
// Should be 200 (success) or 500 if DB fails, but NOT 400 (validation)
|
||||
expect([200, 500]).toContain(response.status);
|
||||
}
|
||||
}, TIMEOUT * validTypes.length);
|
||||
|
||||
test('should reject names that are too long', async () => {
|
||||
const longName = 'a'.repeat(300); // More than 255 characters
|
||||
|
||||
const response = await axios.post(
|
||||
`${BASE_URL}/api/track-download-supabase`,
|
||||
{
|
||||
type: 'agent',
|
||||
name: longName,
|
||||
path: 'agents/test'
|
||||
},
|
||||
{
|
||||
timeout: TIMEOUT,
|
||||
validateStatus: () => true
|
||||
}
|
||||
);
|
||||
|
||||
expect(response.status).toBe(400);
|
||||
}, TIMEOUT);
|
||||
|
||||
});
|
||||
|
||||
describe('Claude Code Monitor - Parser Tests', () => {
|
||||
|
||||
test('GET /api/claude-code-check should return valid structure', async () => {
|
||||
const response = await axios.get(
|
||||
`${BASE_URL}/api/claude-code-check`,
|
||||
{
|
||||
timeout: TIMEOUT,
|
||||
validateStatus: () => true
|
||||
}
|
||||
);
|
||||
|
||||
if (response.status === 200) {
|
||||
// If successful, should have correct structure
|
||||
expect(response.data).toHaveProperty('status');
|
||||
|
||||
if (response.data.status === 'success') {
|
||||
expect(response.data).toHaveProperty('version');
|
||||
expect(response.data).toHaveProperty('changes');
|
||||
expect(response.data).toHaveProperty('discord');
|
||||
}
|
||||
}
|
||||
}, TIMEOUT);
|
||||
|
||||
});
|
||||
|
||||
describe('Command Usage Tracking - Data Validation', () => {
|
||||
|
||||
const validCommands = [
|
||||
'chats',
|
||||
'analytics',
|
||||
'health-check',
|
||||
'plugins',
|
||||
'sandbox',
|
||||
'agents',
|
||||
'chats-mobile',
|
||||
'studio',
|
||||
'command-stats',
|
||||
'hook-stats',
|
||||
'mcp-stats'
|
||||
];
|
||||
|
||||
test('should accept all valid commands', async () => {
|
||||
for (const command of validCommands) {
|
||||
const response = await axios.post(
|
||||
`${BASE_URL}/api/track-command-usage`,
|
||||
{
|
||||
command,
|
||||
cliVersion: '1.26.3',
|
||||
nodeVersion: 'v18.0.0',
|
||||
platform: 'darwin',
|
||||
arch: 'arm64',
|
||||
sessionId: 'test-session',
|
||||
metadata: { test: true }
|
||||
},
|
||||
{
|
||||
timeout: TIMEOUT,
|
||||
validateStatus: () => true
|
||||
}
|
||||
);
|
||||
|
||||
// Should be 200 (success) or 500 if DB fails, but NOT 400 (validation)
|
||||
expect([200, 500]).toContain(response.status);
|
||||
}
|
||||
}, TIMEOUT * validCommands.length);
|
||||
|
||||
test('should handle metadata correctly', async () => {
|
||||
const response = await axios.post(
|
||||
`${BASE_URL}/api/track-command-usage`,
|
||||
{
|
||||
command: 'analytics',
|
||||
cliVersion: '1.26.3',
|
||||
nodeVersion: 'v18.0.0',
|
||||
platform: 'darwin',
|
||||
arch: 'arm64',
|
||||
sessionId: 'test-session',
|
||||
metadata: {
|
||||
tunnel: true,
|
||||
customData: 'test-value'
|
||||
}
|
||||
},
|
||||
{
|
||||
timeout: TIMEOUT,
|
||||
validateStatus: () => true
|
||||
}
|
||||
);
|
||||
|
||||
// Should accept metadata as JSONB
|
||||
expect([200, 500]).toContain(response.status);
|
||||
}, TIMEOUT);
|
||||
|
||||
test('should reject commands that are too long', async () => {
|
||||
const longCommand = 'a'.repeat(150);
|
||||
|
||||
const response = await axios.post(
|
||||
`${BASE_URL}/api/track-command-usage`,
|
||||
{
|
||||
command: longCommand,
|
||||
cliVersion: '1.26.3',
|
||||
platform: 'darwin'
|
||||
},
|
||||
{
|
||||
timeout: TIMEOUT,
|
||||
validateStatus: () => true
|
||||
}
|
||||
);
|
||||
|
||||
expect(response.status).toBe(400);
|
||||
}, TIMEOUT);
|
||||
|
||||
test('should handle missing optional fields', async () => {
|
||||
const response = await axios.post(
|
||||
`${BASE_URL}/api/track-command-usage`,
|
||||
{
|
||||
command: 'analytics'
|
||||
// Missing optional fields like cliVersion, sessionId, metadata
|
||||
},
|
||||
{
|
||||
timeout: TIMEOUT,
|
||||
validateStatus: () => true
|
||||
}
|
||||
);
|
||||
|
||||
// Should still work with just command name
|
||||
expect([200, 500]).toContain(response.status);
|
||||
}, TIMEOUT);
|
||||
|
||||
});
|
||||
|
||||
});
|
||||
|
||||
describe('API Security & CORS', () => {
|
||||
|
||||
test('endpoints should have correct CORS headers', async () => {
|
||||
const response = await axios.options(
|
||||
`${BASE_URL}/api/track-download-supabase`,
|
||||
{
|
||||
timeout: TIMEOUT,
|
||||
validateStatus: () => true
|
||||
}
|
||||
);
|
||||
|
||||
expect(response.headers['access-control-allow-origin']).toBeDefined();
|
||||
}, TIMEOUT);
|
||||
|
||||
test('endpoints should handle incorrect HTTP methods', async () => {
|
||||
const response = await axios.get(
|
||||
`${BASE_URL}/api/track-download-supabase`,
|
||||
{
|
||||
timeout: TIMEOUT,
|
||||
validateStatus: () => true
|
||||
}
|
||||
);
|
||||
|
||||
// track-download only accepts POST, should return 405
|
||||
expect(response.status).toBe(405);
|
||||
}, TIMEOUT);
|
||||
|
||||
});
|
||||
@@ -0,0 +1,26 @@
|
||||
import { verifyToken } from '@clerk/backend';
|
||||
|
||||
/**
|
||||
* Extract and verify Clerk JWT from Authorization header.
|
||||
* Returns the userId on success, or sends an error response and returns null.
|
||||
*/
|
||||
export async function authenticateRequest(req, res) {
|
||||
const authHeader = req.headers.authorization;
|
||||
if (!authHeader?.startsWith('Bearer ')) {
|
||||
res.status(401).json({ error: 'Missing or invalid Authorization header' });
|
||||
return null;
|
||||
}
|
||||
|
||||
const token = authHeader.slice(7);
|
||||
|
||||
try {
|
||||
const payload = await verifyToken(token, {
|
||||
secretKey: process.env.CLERK_SECRET_KEY,
|
||||
});
|
||||
return payload.sub;
|
||||
} catch (err) {
|
||||
console.error('Clerk token verification failed:', err.message);
|
||||
res.status(401).json({ error: 'Invalid or expired token' });
|
||||
return null;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
import { neon } from '@neondatabase/serverless';
|
||||
|
||||
export function getNeonClient() {
|
||||
const connectionString = process.env.NEON_DATABASE_URL;
|
||||
if (!connectionString) {
|
||||
throw new Error('NEON_DATABASE_URL not configured');
|
||||
}
|
||||
return neon(connectionString);
|
||||
}
|
||||
@@ -0,0 +1,288 @@
|
||||
// Parser del CHANGELOG.md de Claude Code
|
||||
|
||||
/**
|
||||
* Extrae la sección de una versión específica del changelog
|
||||
* @param {string} changelog - Contenido completo del CHANGELOG.md
|
||||
* @param {string} version - Versión a extraer (ej: "2.0.31")
|
||||
* @returns {object} - Información parseada de la versión
|
||||
*/
|
||||
export function parseVersionChangelog(changelog, version) {
|
||||
// Normalizar versión (remover 'v' si existe)
|
||||
const cleanVersion = version.replace(/^v/, '');
|
||||
|
||||
// Buscar el inicio de esta versión
|
||||
const versionRegex = new RegExp(`^##\\s+(?:v)?${cleanVersion.replace(/\./g, '\\.')}`, 'm');
|
||||
const match = changelog.match(versionRegex);
|
||||
|
||||
if (!match) {
|
||||
return {
|
||||
version: cleanVersion,
|
||||
content: null,
|
||||
changes: [],
|
||||
error: 'Version not found in changelog'
|
||||
};
|
||||
}
|
||||
|
||||
// Encontrar el índice de inicio
|
||||
const startIndex = match.index;
|
||||
|
||||
// Encontrar el índice de fin (siguiente versión o fin del documento)
|
||||
const nextVersionRegex = /^##\s+(?:v)?\d+\.\d+\.\d+/m;
|
||||
const remainingChangelog = changelog.substring(startIndex + match[0].length);
|
||||
const nextMatch = remainingChangelog.match(nextVersionRegex);
|
||||
|
||||
const endIndex = nextMatch
|
||||
? startIndex + match[0].length + nextMatch.index
|
||||
: changelog.length;
|
||||
|
||||
// Extraer contenido de esta versión
|
||||
const versionContent = changelog.substring(startIndex, endIndex).trim();
|
||||
|
||||
// Parsear los cambios
|
||||
const changes = parseChanges(versionContent);
|
||||
|
||||
return {
|
||||
version: cleanVersion,
|
||||
content: versionContent,
|
||||
changes,
|
||||
changeCount: changes.length
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Parsea los cambios individuales de una sección de versión
|
||||
* @param {string} content - Contenido de la sección de versión
|
||||
* @returns {Array} - Array de cambios parseados
|
||||
*/
|
||||
function parseChanges(content) {
|
||||
const changes = [];
|
||||
|
||||
// Dividir por líneas
|
||||
const lines = content.split('\n');
|
||||
|
||||
let currentCategory = null;
|
||||
|
||||
for (const line of lines) {
|
||||
const trimmed = line.trim();
|
||||
|
||||
// Ignorar líneas vacías y headers de versión
|
||||
if (!trimmed || trimmed.startsWith('##')) {
|
||||
continue;
|
||||
}
|
||||
|
||||
// Detectar categorías (opcional, por si usan headers)
|
||||
if (trimmed.startsWith('###')) {
|
||||
currentCategory = trimmed.replace(/^###\s*/, '').trim();
|
||||
continue;
|
||||
}
|
||||
|
||||
// Detectar bullets de cambios
|
||||
if (trimmed.startsWith('-') || trimmed.startsWith('*')) {
|
||||
const description = trimmed.replace(/^[-*]\s*/, '').trim();
|
||||
|
||||
if (!description) continue;
|
||||
|
||||
// Intentar clasificar el tipo de cambio
|
||||
const changeType = classifyChange(description);
|
||||
|
||||
changes.push({
|
||||
type: changeType,
|
||||
description,
|
||||
category: currentCategory || detectCategory(description),
|
||||
raw: line
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
return changes;
|
||||
}
|
||||
|
||||
/**
|
||||
* Clasifica un cambio por tipo basándose en keywords
|
||||
* @param {string} description - Descripción del cambio
|
||||
* @returns {string} - Tipo de cambio
|
||||
*/
|
||||
function classifyChange(description) {
|
||||
const lower = description.toLowerCase();
|
||||
|
||||
// Breaking changes
|
||||
if (lower.includes('breaking') || lower.includes('removed') || lower.includes('deprecated')) {
|
||||
return 'breaking';
|
||||
}
|
||||
|
||||
// Features
|
||||
if (
|
||||
lower.includes('add') ||
|
||||
lower.includes('new') ||
|
||||
lower.includes('introduce') ||
|
||||
lower.includes('support for') ||
|
||||
lower.startsWith('✨') ||
|
||||
lower.startsWith('🎉')
|
||||
) {
|
||||
return 'feature';
|
||||
}
|
||||
|
||||
// Fixes
|
||||
if (
|
||||
lower.includes('fix') ||
|
||||
lower.includes('resolve') ||
|
||||
lower.includes('correct') ||
|
||||
lower.includes('patch') ||
|
||||
lower.startsWith('🐛')
|
||||
) {
|
||||
return 'fix';
|
||||
}
|
||||
|
||||
// Improvements
|
||||
if (
|
||||
lower.includes('improve') ||
|
||||
lower.includes('enhance') ||
|
||||
lower.includes('optimize') ||
|
||||
lower.includes('better') ||
|
||||
lower.includes('refactor') ||
|
||||
lower.startsWith('⚡') ||
|
||||
lower.startsWith('♻️')
|
||||
) {
|
||||
return 'improvement';
|
||||
}
|
||||
|
||||
// Deprecations
|
||||
if (lower.includes('deprecate')) {
|
||||
return 'deprecation';
|
||||
}
|
||||
|
||||
// Performance
|
||||
if (lower.includes('performance') || lower.includes('speed') || lower.includes('faster')) {
|
||||
return 'performance';
|
||||
}
|
||||
|
||||
// Documentation
|
||||
if (lower.includes('docs') || lower.includes('documentation')) {
|
||||
return 'documentation';
|
||||
}
|
||||
|
||||
// Default: other
|
||||
return 'other';
|
||||
}
|
||||
|
||||
/**
|
||||
* Detecta la categoría de un cambio basándose en keywords
|
||||
* @param {string} description - Descripción del cambio
|
||||
* @returns {string|null} - Categoría detectada
|
||||
*/
|
||||
function detectCategory(description) {
|
||||
const lower = description.toLowerCase();
|
||||
|
||||
const categories = {
|
||||
'Plugin System': ['plugin', 'plugins', 'marketplace'],
|
||||
'CLI': ['cli', 'command', 'terminal', 'bash'],
|
||||
'Performance': ['performance', 'speed', 'faster', 'optimize', 'cache'],
|
||||
'UI/UX': ['ui', 'ux', 'interface', 'display', 'output'],
|
||||
'API': ['api', 'endpoint', 'rest', 'graphql'],
|
||||
'Models': ['model', 'sonnet', 'opus', 'haiku', 'claude'],
|
||||
'MCP': ['mcp', 'model context protocol'],
|
||||
'Agents': ['agent', 'subagent', 'explore'],
|
||||
'Settings': ['setting', 'config', 'configuration'],
|
||||
'Hooks': ['hook', 'trigger', 'event'],
|
||||
'Security': ['security', 'auth', 'authentication', 'permission'],
|
||||
'Documentation': ['docs', 'documentation', 'readme'],
|
||||
'Windows': ['windows', 'win32'],
|
||||
'macOS': ['macos', 'darwin', 'mac'],
|
||||
'Linux': ['linux', 'unix']
|
||||
};
|
||||
|
||||
for (const [category, keywords] of Object.entries(categories)) {
|
||||
if (keywords.some(keyword => lower.includes(keyword))) {
|
||||
return category;
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Genera un resumen de los cambios
|
||||
* @param {Array} changes - Array de cambios
|
||||
* @returns {object} - Resumen estadístico
|
||||
*/
|
||||
export function generateSummary(changes) {
|
||||
const summary = {
|
||||
total: changes.length,
|
||||
byType: {},
|
||||
byCategory: {},
|
||||
highlights: []
|
||||
};
|
||||
|
||||
// Contar por tipo
|
||||
changes.forEach(change => {
|
||||
summary.byType[change.type] = (summary.byType[change.type] || 0) + 1;
|
||||
|
||||
if (change.category) {
|
||||
summary.byCategory[change.category] = (summary.byCategory[change.category] || 0) + 1;
|
||||
}
|
||||
|
||||
// Detectar highlights (breaking changes o features importantes)
|
||||
if (change.type === 'breaking' || change.type === 'feature') {
|
||||
summary.highlights.push(change);
|
||||
}
|
||||
});
|
||||
|
||||
return summary;
|
||||
}
|
||||
|
||||
/**
|
||||
* Formatea los cambios para Discord
|
||||
* @param {Array} changes - Array de cambios
|
||||
* @param {number} maxLength - Longitud máxima del texto
|
||||
* @returns {object} - Cambios agrupados para Discord
|
||||
*/
|
||||
export function formatForDiscord(changes, maxLength = 1024) {
|
||||
const grouped = {
|
||||
features: [],
|
||||
fixes: [],
|
||||
improvements: [],
|
||||
breaking: [],
|
||||
other: []
|
||||
};
|
||||
|
||||
// Agrupar cambios
|
||||
changes.forEach(change => {
|
||||
switch (change.type) {
|
||||
case 'feature':
|
||||
grouped.features.push(change.description);
|
||||
break;
|
||||
case 'fix':
|
||||
grouped.fixes.push(change.description);
|
||||
break;
|
||||
case 'improvement':
|
||||
case 'performance':
|
||||
grouped.improvements.push(change.description);
|
||||
break;
|
||||
case 'breaking':
|
||||
case 'deprecation':
|
||||
grouped.breaking.push(change.description);
|
||||
break;
|
||||
default:
|
||||
grouped.other.push(change.description);
|
||||
}
|
||||
});
|
||||
|
||||
// Truncar si es necesario
|
||||
const truncate = (items, max) => {
|
||||
const text = items.map(item => `• ${item}`).join('\n');
|
||||
if (text.length > max) {
|
||||
const truncated = text.substring(0, max - 20);
|
||||
const lastNewline = truncated.lastIndexOf('\n');
|
||||
return truncated.substring(0, lastNewline) + '\n... [Read more in changelog]';
|
||||
}
|
||||
return text;
|
||||
};
|
||||
|
||||
return {
|
||||
features: truncate(grouped.features, maxLength),
|
||||
fixes: truncate(grouped.fixes, maxLength),
|
||||
improvements: truncate(grouped.improvements, maxLength),
|
||||
breaking: truncate(grouped.breaking, maxLength),
|
||||
other: truncate(grouped.other, maxLength)
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,302 @@
|
||||
// Endpoint principal: Verifica nueva versión y notifica a Discord
|
||||
// Este endpoint hace todo el proceso completo en una sola llamada
|
||||
|
||||
import { neon } from '@neondatabase/serverless';
|
||||
import axios from 'axios';
|
||||
import { parseVersionChangelog, formatForDiscord, generateSummary } from './_parser-claude.js';
|
||||
|
||||
const NPM_PACKAGE = '@anthropic-ai/claude-code';
|
||||
const CHANGELOG_URL = 'https://raw.githubusercontent.com/anthropics/claude-code/main/CHANGELOG.md';
|
||||
|
||||
// Obtener la última versión de NPM
|
||||
async function getLatestNPMVersion() {
|
||||
const response = await axios.get(`https://registry.npmjs.org/${NPM_PACKAGE}/latest`);
|
||||
return {
|
||||
version: response.data.version,
|
||||
publishedAt: response.data.time?.modified || new Date().toISOString(),
|
||||
npmUrl: `https://www.npmjs.com/package/${NPM_PACKAGE}/v/${response.data.version}`
|
||||
};
|
||||
}
|
||||
|
||||
// Enviar a Discord
|
||||
async function sendToDiscord(versionData, parsed, formatted, summary) {
|
||||
const webhookUrl = process.env.DISCORD_WEBHOOK_URL_CHANGELOG || process.env.DISCORD_WEBHOOK_URL;
|
||||
|
||||
if (!webhookUrl) {
|
||||
throw new Error('Discord webhook URL not configured');
|
||||
}
|
||||
|
||||
const embed = {
|
||||
title: `🚀 Claude Code ${versionData.version} Released`,
|
||||
description: `A new version of Claude Code is available with **${summary.total} changes**!`,
|
||||
url: versionData.githubUrl,
|
||||
color: 0x8B5CF6, // Purple
|
||||
fields: [],
|
||||
footer: {
|
||||
text: 'Claude Code Changelog Monitor',
|
||||
icon_url: 'https://avatars.githubusercontent.com/u/100788936?s=200&v=4'
|
||||
},
|
||||
timestamp: new Date().toISOString()
|
||||
};
|
||||
|
||||
// Breaking changes
|
||||
if (formatted.breaking && formatted.breaking.length > 0) {
|
||||
embed.fields.push({
|
||||
name: '⚠️ Breaking Changes',
|
||||
value: formatted.breaking,
|
||||
inline: false
|
||||
});
|
||||
}
|
||||
|
||||
// Features
|
||||
if (formatted.features && formatted.features.length > 0) {
|
||||
embed.fields.push({
|
||||
name: '✨ New Features',
|
||||
value: formatted.features,
|
||||
inline: false
|
||||
});
|
||||
}
|
||||
|
||||
// Improvements
|
||||
if (formatted.improvements && formatted.improvements.length > 0) {
|
||||
embed.fields.push({
|
||||
name: '⚡ Improvements',
|
||||
value: formatted.improvements,
|
||||
inline: false
|
||||
});
|
||||
}
|
||||
|
||||
// Bug Fixes
|
||||
if (formatted.fixes && formatted.fixes.length > 0) {
|
||||
embed.fields.push({
|
||||
name: '🐛 Bug Fixes',
|
||||
value: formatted.fixes,
|
||||
inline: false
|
||||
});
|
||||
}
|
||||
|
||||
// Installation
|
||||
embed.fields.push({
|
||||
name: '📦 Installation',
|
||||
value: `\`\`\`bash\nnpm install -g @anthropic-ai/claude-code@${versionData.version}\n\`\`\``,
|
||||
inline: false
|
||||
});
|
||||
|
||||
// Links
|
||||
embed.fields.push({
|
||||
name: '🔗 Links',
|
||||
value: `[NPM Package](${versionData.npmUrl}) • [Full Changelog](${versionData.githubUrl})`,
|
||||
inline: false
|
||||
});
|
||||
|
||||
const payload = {
|
||||
username: 'Claude Code Monitor',
|
||||
avatar_url: 'https://raw.githubusercontent.com/anthropics/claude-code/main/assets/icon.png',
|
||||
embeds: [embed]
|
||||
};
|
||||
|
||||
const response = await axios.post(webhookUrl, payload);
|
||||
return { success: true, status: response.status, payload };
|
||||
}
|
||||
|
||||
// Handler principal
|
||||
export default async function handler(req, res) {
|
||||
// CORS
|
||||
res.setHeader('Access-Control-Allow-Origin', '*');
|
||||
res.setHeader('Access-Control-Allow-Methods', 'POST, GET, OPTIONS');
|
||||
res.setHeader('Access-Control-Allow-Headers', 'Content-Type');
|
||||
|
||||
if (req.method === 'OPTIONS') {
|
||||
return res.status(200).json({ ok: true });
|
||||
}
|
||||
|
||||
try {
|
||||
const sql = neon(process.env.NEON_DATABASE_URL);
|
||||
|
||||
console.log('🔍 Checking for new Claude Code version...');
|
||||
|
||||
// 1. Obtener última versión de NPM
|
||||
const latestVersion = await getLatestNPMVersion();
|
||||
console.log(`📦 Latest NPM version: ${latestVersion.version}`);
|
||||
|
||||
// 2. Verificar si ya fue procesada
|
||||
const existing = await sql`
|
||||
SELECT id, discord_notified
|
||||
FROM claude_code_versions
|
||||
WHERE version = ${latestVersion.version}
|
||||
`;
|
||||
|
||||
if (existing.length > 0 && existing[0].discord_notified) {
|
||||
console.log(`✓ Version ${latestVersion.version} already processed and notified`);
|
||||
|
||||
await sql`
|
||||
UPDATE monitoring_metadata
|
||||
SET last_check_at = NOW(), last_version_found = ${latestVersion.version}
|
||||
WHERE id = 1
|
||||
`;
|
||||
|
||||
return res.status(200).json({
|
||||
status: 'already_processed',
|
||||
version: latestVersion.version,
|
||||
message: 'Version already notified to Discord'
|
||||
});
|
||||
}
|
||||
|
||||
console.log(`🆕 New version detected: ${latestVersion.version}`);
|
||||
|
||||
// 3. Obtener changelog
|
||||
const changelogResponse = await axios.get(CHANGELOG_URL);
|
||||
const fullChangelog = changelogResponse.data;
|
||||
|
||||
// 4. Parsear changelog de esta versión
|
||||
const parsed = parseVersionChangelog(fullChangelog, latestVersion.version);
|
||||
|
||||
if (!parsed.content) {
|
||||
throw new Error(`Could not find version ${latestVersion.version} in changelog`);
|
||||
}
|
||||
|
||||
console.log(`📝 Parsed ${parsed.changeCount} changes`);
|
||||
|
||||
// 5. Formatear para Discord
|
||||
const formatted = formatForDiscord(parsed.changes);
|
||||
const summary = generateSummary(parsed.changes);
|
||||
|
||||
// 6. Guardar en base de datos
|
||||
const githubUrl = `https://github.com/anthropics/claude-code/blob/main/CHANGELOG.md#${latestVersion.version.replace(/\./g, '')}`;
|
||||
|
||||
const versionData = {
|
||||
version: latestVersion.version,
|
||||
publishedAt: latestVersion.publishedAt,
|
||||
npmUrl: latestVersion.npmUrl,
|
||||
githubUrl,
|
||||
changelogContent: fullChangelog.substring(0, 50000)
|
||||
};
|
||||
|
||||
let versionId;
|
||||
|
||||
if (existing.length > 0) {
|
||||
// Ya existe pero no fue notificada
|
||||
versionId = existing[0].id;
|
||||
console.log(`📝 Updating existing version record (ID: ${versionId})`);
|
||||
} else {
|
||||
// Crear nueva entrada
|
||||
const insertResult = await sql`
|
||||
INSERT INTO claude_code_versions (
|
||||
version,
|
||||
published_at,
|
||||
changelog_content,
|
||||
npm_url,
|
||||
github_url,
|
||||
discord_notified
|
||||
) VALUES (
|
||||
${versionData.version},
|
||||
${versionData.publishedAt},
|
||||
${versionData.changelogContent},
|
||||
${versionData.npmUrl},
|
||||
${versionData.githubUrl},
|
||||
false
|
||||
)
|
||||
RETURNING id
|
||||
`;
|
||||
versionId = insertResult[0].id;
|
||||
console.log(`💾 Version saved to database (ID: ${versionId})`);
|
||||
}
|
||||
|
||||
// 7. Guardar cambios individuales
|
||||
for (const change of parsed.changes) {
|
||||
await sql`
|
||||
INSERT INTO claude_code_changes (
|
||||
version_id,
|
||||
change_type,
|
||||
description,
|
||||
category
|
||||
) VALUES (
|
||||
${versionId},
|
||||
${change.type},
|
||||
${change.description},
|
||||
${change.category}
|
||||
)
|
||||
`;
|
||||
}
|
||||
console.log(`💾 Saved ${parsed.changes.length} individual changes`);
|
||||
|
||||
// 8. Enviar a Discord
|
||||
console.log('📢 Sending Discord notification...');
|
||||
const discordResult = await sendToDiscord(versionData, parsed, formatted, summary);
|
||||
console.log('✅ Discord notification sent successfully!');
|
||||
|
||||
// 9. Guardar log de notificación
|
||||
await sql`
|
||||
INSERT INTO discord_notifications_log (
|
||||
version_id,
|
||||
webhook_url,
|
||||
payload,
|
||||
response_status,
|
||||
response_body
|
||||
) VALUES (
|
||||
${versionId},
|
||||
${process.env.DISCORD_WEBHOOK_URL_CHANGELOG || process.env.DISCORD_WEBHOOK_URL},
|
||||
${JSON.stringify(discordResult.payload)},
|
||||
${discordResult.status},
|
||||
${'Success'}
|
||||
)
|
||||
`;
|
||||
|
||||
// 10. Marcar como notificada
|
||||
await sql`
|
||||
UPDATE claude_code_versions
|
||||
SET discord_notified = true, discord_notification_sent_at = NOW()
|
||||
WHERE id = ${versionId}
|
||||
`;
|
||||
|
||||
// 11. Actualizar metadata
|
||||
await sql`
|
||||
UPDATE monitoring_metadata
|
||||
SET
|
||||
last_check_at = NOW(),
|
||||
last_version_found = ${latestVersion.version},
|
||||
check_count = check_count + 1
|
||||
WHERE id = 1
|
||||
`;
|
||||
|
||||
console.log('🎉 Process completed successfully!');
|
||||
|
||||
return res.status(200).json({
|
||||
status: 'success',
|
||||
version: latestVersion.version,
|
||||
versionId,
|
||||
changes: {
|
||||
total: summary.total,
|
||||
byType: summary.byType
|
||||
},
|
||||
discord: {
|
||||
sent: true,
|
||||
status: discordResult.status
|
||||
}
|
||||
});
|
||||
|
||||
} catch (error) {
|
||||
console.error('❌ Error:', error);
|
||||
|
||||
// Actualizar metadata con error
|
||||
try {
|
||||
const sql = neon(process.env.NEON_DATABASE_URL);
|
||||
await sql`
|
||||
UPDATE monitoring_metadata
|
||||
SET
|
||||
last_check_at = NOW(),
|
||||
error_count = error_count + 1,
|
||||
last_error = ${error.message}
|
||||
WHERE id = 1
|
||||
`;
|
||||
} catch (metaError) {
|
||||
console.error('Failed to update metadata:', metaError);
|
||||
}
|
||||
|
||||
return res.status(500).json({
|
||||
error: 'Internal server error',
|
||||
message: error.message,
|
||||
details: process.env.NODE_ENV === 'development' ? error.stack : undefined
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,398 @@
|
||||
# Claude Code Changelog Monitor
|
||||
|
||||
Sistema automatizado para monitorear releases de Claude Code y enviar notificaciones a Discord.
|
||||
|
||||
## 🚀 Características
|
||||
|
||||
- ✅ **Detección automática** de nuevas versiones en NPM
|
||||
- ✅ **Parseo inteligente** del CHANGELOG.md
|
||||
- ✅ **Notificaciones Discord** con embeds formateados
|
||||
- ✅ **Base de datos Neon** para tracking y logs
|
||||
- ✅ **Clasificación automática** de cambios (features, fixes, improvements)
|
||||
- ✅ **Sin cron jobs** - trigger directo desde NPM webhooks o manual
|
||||
|
||||
## 📋 Arquitectura
|
||||
|
||||
```
|
||||
NPM Release
|
||||
↓
|
||||
[Vercel Function] /api/claude-code-monitor
|
||||
↓
|
||||
[Fetch CHANGELOG.md] ← GitHub
|
||||
↓
|
||||
[Parse Changes] → Clasificar por tipo
|
||||
↓
|
||||
[Save to Neon DB]
|
||||
↓
|
||||
[Send to Discord] → Webhook
|
||||
↓
|
||||
[Log Result]
|
||||
```
|
||||
|
||||
## 🛠️ Setup
|
||||
|
||||
### 1. Crear Base de Datos en Neon
|
||||
|
||||
1. Ve a [Neon Console](https://console.neon.tech/)
|
||||
2. Crea un nuevo proyecto
|
||||
3. Copia la connection string
|
||||
4. Ejecuta el script de migración:
|
||||
|
||||
```bash
|
||||
psql "YOUR_NEON_CONNECTION_STRING" < database/migrations/001_create_claude_code_versions.sql
|
||||
```
|
||||
|
||||
### 2. Configurar Variables de Entorno
|
||||
|
||||
En Vercel, agrega estas variables:
|
||||
|
||||
```bash
|
||||
# Neon Database
|
||||
NEON_DATABASE_URL=postgresql://user:password@host/database?sslmode=require
|
||||
|
||||
# Discord Webhook (específico para Claude Code changelog)
|
||||
DISCORD_WEBHOOK_URL_CHANGELOG=https://discord.com/api/webhooks/YOUR_WEBHOOK_ID/YOUR_WEBHOOK_TOKEN
|
||||
|
||||
# O usa el webhook general (fallback)
|
||||
DISCORD_WEBHOOK_URL=https://discord.com/api/webhooks/YOUR_WEBHOOK_ID/YOUR_WEBHOOK_TOKEN
|
||||
```
|
||||
|
||||
### 3. Deploy a Vercel
|
||||
|
||||
```bash
|
||||
npm install
|
||||
vercel --prod
|
||||
```
|
||||
|
||||
## 📡 Endpoints
|
||||
|
||||
### `GET/POST /api/claude-code-monitor`
|
||||
|
||||
Endpoint principal que hace todo el proceso:
|
||||
|
||||
1. Verifica última versión en NPM
|
||||
2. Descarga CHANGELOG.md
|
||||
3. Parsea cambios
|
||||
4. Guarda en Neon DB
|
||||
5. Envía a Discord
|
||||
6. Registra logs
|
||||
|
||||
**Respuesta exitosa:**
|
||||
|
||||
```json
|
||||
{
|
||||
"status": "success",
|
||||
"version": "2.0.31",
|
||||
"versionId": 1,
|
||||
"changes": {
|
||||
"total": 15,
|
||||
"byType": {
|
||||
"feature": 8,
|
||||
"fix": 5,
|
||||
"improvement": 2
|
||||
}
|
||||
},
|
||||
"discord": {
|
||||
"sent": true,
|
||||
"status": 200
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**Respuesta si ya fue procesada:**
|
||||
|
||||
```json
|
||||
{
|
||||
"status": "already_processed",
|
||||
"version": "2.0.31",
|
||||
"message": "Version already notified to Discord"
|
||||
}
|
||||
```
|
||||
|
||||
### `POST /api/claude-code-monitor/webhook`
|
||||
|
||||
Webhook para recibir notificaciones de NPM (alternativa).
|
||||
|
||||
### `POST /api/claude-code-monitor/discord-notifier`
|
||||
|
||||
Procesa y notifica una versión ya guardada en la DB.
|
||||
|
||||
**Body:**
|
||||
|
||||
```json
|
||||
{
|
||||
"versionId": 1
|
||||
}
|
||||
```
|
||||
|
||||
## 🔄 Configurar Trigger Automático
|
||||
|
||||
### Opción 1: NPM Webhooks (Recomendada)
|
||||
|
||||
NPM puede enviar webhooks cuando se publica un paquete, pero requiere cuenta Pro.
|
||||
|
||||
Si tienes NPM Pro:
|
||||
|
||||
1. Ve a la configuración del paquete
|
||||
2. Agrega webhook: `https://your-domain.vercel.app/api/claude-code-monitor/webhook`
|
||||
|
||||
### Opción 2: GitHub Actions (Gratis)
|
||||
|
||||
Crea `.github/workflows/check-claude-code.yml`:
|
||||
|
||||
```yaml
|
||||
name: Check Claude Code Updates
|
||||
|
||||
on:
|
||||
schedule:
|
||||
- cron: '0 */4 * * *' # Cada 4 horas
|
||||
workflow_dispatch: # Manual trigger
|
||||
|
||||
jobs:
|
||||
check:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Trigger Vercel Function
|
||||
run: |
|
||||
curl -X POST https://your-domain.vercel.app/api/claude-code-monitor
|
||||
```
|
||||
|
||||
### Opción 3: Vercel Cron Jobs
|
||||
|
||||
En `vercel.json`:
|
||||
|
||||
```json
|
||||
{
|
||||
"crons": [
|
||||
{
|
||||
"path": "/api/claude-code-monitor",
|
||||
"schedule": "0 */4 * * *"
|
||||
}
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
### Opción 4: Manual
|
||||
|
||||
Simplemente abre en el navegador o haz un GET request:
|
||||
|
||||
```bash
|
||||
curl https://your-domain.vercel.app/api/claude-code-monitor
|
||||
```
|
||||
|
||||
## 📊 Schema de Base de Datos
|
||||
|
||||
### `claude_code_versions`
|
||||
|
||||
Almacena todas las versiones detectadas.
|
||||
|
||||
| Campo | Tipo | Descripción |
|
||||
| ------------------------------- | --------- | -------------------------------- |
|
||||
| id | SERIAL | ID único |
|
||||
| version | VARCHAR | Número de versión (ej: "2.0.31") |
|
||||
| published_at | TIMESTAMP | Fecha de publicación |
|
||||
| changelog_content | TEXT | Contenido completo del changelog |
|
||||
| npm_url | VARCHAR | URL del paquete en NPM |
|
||||
| github_url | VARCHAR | URL del changelog en GitHub |
|
||||
| discord_notified | BOOLEAN | Si ya se notificó a Discord |
|
||||
| discord_notification_sent_at | TIMESTAMP | Cuándo se envió la notificación |
|
||||
|
||||
### `claude_code_changes`
|
||||
|
||||
Cambios individuales parseados.
|
||||
|
||||
| Campo | Tipo | Descripción |
|
||||
| ----------- | ------- | ----------------------------------------- |
|
||||
| id | SERIAL | ID único |
|
||||
| version_id | INTEGER | FK a claude_code_versions |
|
||||
| change_type | VARCHAR | feature, fix, improvement, breaking, etc. |
|
||||
| description | TEXT | Descripción del cambio |
|
||||
| category | VARCHAR | Plugin System, CLI, Performance, etc. |
|
||||
|
||||
### `discord_notifications_log`
|
||||
|
||||
Log de todas las notificaciones enviadas.
|
||||
|
||||
| Campo | Tipo | Descripción |
|
||||
| --------------- | --------- | ----------------------------- |
|
||||
| id | SERIAL | ID único |
|
||||
| version_id | INTEGER | FK a claude_code_versions |
|
||||
| webhook_url | VARCHAR | URL del webhook usado |
|
||||
| payload | JSONB | Payload completo enviado |
|
||||
| response_status | INTEGER | HTTP status code de respuesta |
|
||||
| response_body | TEXT | Respuesta del webhook |
|
||||
| error_message | TEXT | Error si hubo |
|
||||
| sent_at | TIMESTAMP | Cuándo se envió |
|
||||
|
||||
### `monitoring_metadata`
|
||||
|
||||
Metadata del sistema de monitoreo.
|
||||
|
||||
| Campo | Tipo | Descripción |
|
||||
| ------------------ | --------- | -------------------------------- |
|
||||
| id | SERIAL | ID único (siempre 1) |
|
||||
| last_check_at | TIMESTAMP | Última verificación |
|
||||
| last_version_found | VARCHAR | Última versión encontrada |
|
||||
| check_count | INTEGER | Número de verificaciones |
|
||||
| error_count | INTEGER | Número de errores |
|
||||
| last_error | TEXT | Último error (si hubo) |
|
||||
|
||||
## 🧪 Testing
|
||||
|
||||
### Test Manual
|
||||
|
||||
```bash
|
||||
# Verificar endpoint
|
||||
curl https://your-domain.vercel.app/api/claude-code-monitor
|
||||
|
||||
# Ver respuesta completa
|
||||
curl -v https://your-domain.vercel.app/api/claude-code-monitor
|
||||
```
|
||||
|
||||
### Test Local
|
||||
|
||||
```bash
|
||||
# Instalar dependencias
|
||||
cd api
|
||||
npm install
|
||||
|
||||
# Configurar .env
|
||||
echo "NEON_DATABASE_URL=your_connection_string" > .env
|
||||
echo "DISCORD_WEBHOOK_URL=your_webhook_url" >> .env
|
||||
|
||||
# Ejecutar con Vercel Dev
|
||||
vercel dev
|
||||
|
||||
# En otra terminal
|
||||
curl http://localhost:3000/api/claude-code-monitor
|
||||
```
|
||||
|
||||
## 📝 Parser de Changelog
|
||||
|
||||
El parser clasifica automáticamente los cambios:
|
||||
|
||||
### Tipos de Cambios
|
||||
|
||||
- **feature**: Add, New, Introduce, Support for
|
||||
- **fix**: Fix, Resolve, Correct, Patch
|
||||
- **improvement**: Improve, Enhance, Optimize, Better
|
||||
- **breaking**: Breaking, Removed, Deprecated
|
||||
- **performance**: Performance, Speed, Faster
|
||||
- **documentation**: Docs, Documentation
|
||||
|
||||
### Categorías Detectadas
|
||||
|
||||
- Plugin System
|
||||
- CLI
|
||||
- Performance
|
||||
- UI/UX
|
||||
- API
|
||||
- Models (Sonnet, Opus, Haiku)
|
||||
- MCP (Model Context Protocol)
|
||||
- Agents/Subagents
|
||||
- Settings
|
||||
- Hooks
|
||||
- Security
|
||||
- Platform-specific (Windows, macOS, Linux)
|
||||
|
||||
## 🎨 Formato de Discord
|
||||
|
||||
El embed incluye:
|
||||
|
||||
- **Título**: 🚀 Claude Code [version] Released
|
||||
- **Color**: Purple (#8B5CF6) - color de Claude
|
||||
- **Fields**:
|
||||
- ⚠️ Breaking Changes (si hay)
|
||||
- ✨ New Features
|
||||
- ⚡ Improvements
|
||||
- 🐛 Bug Fixes
|
||||
- 📦 Installation command
|
||||
- 🔗 Links (NPM + GitHub)
|
||||
|
||||
## 🐛 Troubleshooting
|
||||
|
||||
### Error: "NEON_DATABASE_URL not configured"
|
||||
|
||||
**Solución**: Agrega la variable de entorno en Vercel Settings → Environment Variables
|
||||
|
||||
### Error: "Discord webhook URL not configured"
|
||||
|
||||
**Solución**: Agrega `DISCORD_WEBHOOK_URL_CHANGELOG` o `DISCORD_WEBHOOK_URL`
|
||||
|
||||
### Error: "Version not found in changelog"
|
||||
|
||||
**Causa**: La versión en NPM aún no está en el CHANGELOG.md de GitHub
|
||||
|
||||
**Solución**: Esperar a que Anthropic actualice el changelog, o verificar manualmente
|
||||
|
||||
### Notificación duplicada
|
||||
|
||||
**Causa**: El sistema está configurado con múltiples triggers
|
||||
|
||||
**Solución**: Revisa que no tengas cron jobs duplicados en GitHub Actions + Vercel
|
||||
|
||||
### Base de datos no conecta
|
||||
|
||||
**Solución**:
|
||||
|
||||
```bash
|
||||
# Test connection string
|
||||
psql "$NEON_DATABASE_URL" -c "SELECT 1"
|
||||
|
||||
# Verificar que el proyecto de Neon esté activo
|
||||
# Verificar que la IP de Vercel no esté bloqueada
|
||||
```
|
||||
|
||||
## 📊 Queries Útiles
|
||||
|
||||
```sql
|
||||
-- Ver últimas versiones procesadas
|
||||
SELECT version, published_at, discord_notified
|
||||
FROM claude_code_versions
|
||||
ORDER BY published_at DESC
|
||||
LIMIT 10;
|
||||
|
||||
-- Ver estadísticas de cambios por tipo
|
||||
SELECT
|
||||
v.version,
|
||||
c.change_type,
|
||||
COUNT(*) as count
|
||||
FROM claude_code_versions v
|
||||
JOIN claude_code_changes c ON c.version_id = v.id
|
||||
GROUP BY v.version, c.change_type
|
||||
ORDER BY v.published_at DESC;
|
||||
|
||||
-- Ver logs de Discord
|
||||
SELECT
|
||||
v.version,
|
||||
dl.response_status,
|
||||
dl.sent_at,
|
||||
dl.error_message
|
||||
FROM discord_notifications_log dl
|
||||
JOIN claude_code_versions v ON v.id = dl.version_id
|
||||
ORDER BY dl.sent_at DESC;
|
||||
|
||||
-- Ver metadata de monitoreo
|
||||
SELECT * FROM monitoring_metadata;
|
||||
```
|
||||
|
||||
## 🚀 Próximas Mejoras
|
||||
|
||||
- [ ] Command Discord `/claude-changelog [version]`
|
||||
- [ ] Comparación entre versiones
|
||||
- [ ] Estadísticas de adopción
|
||||
- [ ] Alertas para breaking changes
|
||||
- [ ] Integración con Slack/Telegram
|
||||
- [ ] Dashboard web para visualizar releases
|
||||
|
||||
## 📚 Referencias
|
||||
|
||||
- [Claude Code GitHub](https://github.com/anthropics/claude-code)
|
||||
- [Claude Code NPM](https://www.npmjs.com/package/@anthropic-ai/claude-code)
|
||||
- [Neon Database Docs](https://neon.tech/docs)
|
||||
- [Discord Webhooks](https://discord.com/developers/docs/resources/webhook)
|
||||
- [Vercel Functions](https://vercel.com/docs/functions)
|
||||
|
||||
---
|
||||
|
||||
**Made with ❤️ for the Claude Code community**
|
||||
@@ -0,0 +1,302 @@
|
||||
// Endpoint principal: Verifica nueva versión y notifica a Discord
|
||||
// Este endpoint hace todo el proceso completo en una sola llamada
|
||||
|
||||
import { neon } from '@neondatabase/serverless';
|
||||
import axios from 'axios';
|
||||
import { parseVersionChangelog, formatForDiscord, generateSummary } from './parser.js';
|
||||
|
||||
const NPM_PACKAGE = '@anthropic-ai/claude-code';
|
||||
const CHANGELOG_URL = 'https://raw.githubusercontent.com/anthropics/claude-code/main/CHANGELOG.md';
|
||||
|
||||
// Obtener la última versión de NPM
|
||||
async function getLatestNPMVersion() {
|
||||
const response = await axios.get(`https://registry.npmjs.org/${NPM_PACKAGE}/latest`);
|
||||
return {
|
||||
version: response.data.version,
|
||||
publishedAt: response.data.time?.modified || new Date().toISOString(),
|
||||
npmUrl: `https://www.npmjs.com/package/${NPM_PACKAGE}/v/${response.data.version}`
|
||||
};
|
||||
}
|
||||
|
||||
// Enviar a Discord
|
||||
async function sendToDiscord(versionData, parsed, formatted, summary) {
|
||||
const webhookUrl = process.env.DISCORD_WEBHOOK_URL_CHANGELOG || process.env.DISCORD_WEBHOOK_URL;
|
||||
|
||||
if (!webhookUrl) {
|
||||
throw new Error('Discord webhook URL not configured');
|
||||
}
|
||||
|
||||
const embed = {
|
||||
title: `🚀 Claude Code ${versionData.version} Released`,
|
||||
description: `A new version of Claude Code is available with **${summary.total} changes**!`,
|
||||
url: versionData.githubUrl,
|
||||
color: 0x8B5CF6, // Purple
|
||||
fields: [],
|
||||
footer: {
|
||||
text: 'Claude Code Changelog Monitor',
|
||||
icon_url: 'https://avatars.githubusercontent.com/u/100788936?s=200&v=4'
|
||||
},
|
||||
timestamp: new Date().toISOString()
|
||||
};
|
||||
|
||||
// Breaking changes
|
||||
if (formatted.breaking && formatted.breaking.length > 0) {
|
||||
embed.fields.push({
|
||||
name: '⚠️ Breaking Changes',
|
||||
value: formatted.breaking,
|
||||
inline: false
|
||||
});
|
||||
}
|
||||
|
||||
// Features
|
||||
if (formatted.features && formatted.features.length > 0) {
|
||||
embed.fields.push({
|
||||
name: '✨ New Features',
|
||||
value: formatted.features,
|
||||
inline: false
|
||||
});
|
||||
}
|
||||
|
||||
// Improvements
|
||||
if (formatted.improvements && formatted.improvements.length > 0) {
|
||||
embed.fields.push({
|
||||
name: '⚡ Improvements',
|
||||
value: formatted.improvements,
|
||||
inline: false
|
||||
});
|
||||
}
|
||||
|
||||
// Bug Fixes
|
||||
if (formatted.fixes && formatted.fixes.length > 0) {
|
||||
embed.fields.push({
|
||||
name: '🐛 Bug Fixes',
|
||||
value: formatted.fixes,
|
||||
inline: false
|
||||
});
|
||||
}
|
||||
|
||||
// Installation
|
||||
embed.fields.push({
|
||||
name: '📦 Installation',
|
||||
value: `\`\`\`bash\nnpm install -g @anthropic-ai/claude-code@${versionData.version}\n\`\`\``,
|
||||
inline: false
|
||||
});
|
||||
|
||||
// Links
|
||||
embed.fields.push({
|
||||
name: '🔗 Links',
|
||||
value: `[NPM Package](${versionData.npmUrl}) • [Full Changelog](${versionData.githubUrl})`,
|
||||
inline: false
|
||||
});
|
||||
|
||||
const payload = {
|
||||
username: 'Claude Code Monitor',
|
||||
avatar_url: 'https://raw.githubusercontent.com/anthropics/claude-code/main/assets/icon.png',
|
||||
embeds: [embed]
|
||||
};
|
||||
|
||||
const response = await axios.post(webhookUrl, payload);
|
||||
return { success: true, status: response.status, payload };
|
||||
}
|
||||
|
||||
// Handler principal
|
||||
export default async function handler(req, res) {
|
||||
// CORS
|
||||
res.setHeader('Access-Control-Allow-Origin', '*');
|
||||
res.setHeader('Access-Control-Allow-Methods', 'POST, GET, OPTIONS');
|
||||
res.setHeader('Access-Control-Allow-Headers', 'Content-Type');
|
||||
|
||||
if (req.method === 'OPTIONS') {
|
||||
return res.status(200).json({ ok: true });
|
||||
}
|
||||
|
||||
try {
|
||||
const sql = neon(process.env.NEON_DATABASE_URL);
|
||||
|
||||
console.log('🔍 Checking for new Claude Code version...');
|
||||
|
||||
// 1. Obtener última versión de NPM
|
||||
const latestVersion = await getLatestNPMVersion();
|
||||
console.log(`📦 Latest NPM version: ${latestVersion.version}`);
|
||||
|
||||
// 2. Verificar si ya fue procesada
|
||||
const existing = await sql`
|
||||
SELECT id, discord_notified
|
||||
FROM claude_code_versions
|
||||
WHERE version = ${latestVersion.version}
|
||||
`;
|
||||
|
||||
if (existing.length > 0 && existing[0].discord_notified) {
|
||||
console.log(`✓ Version ${latestVersion.version} already processed and notified`);
|
||||
|
||||
await sql`
|
||||
UPDATE monitoring_metadata
|
||||
SET last_check_at = NOW(), last_version_found = ${latestVersion.version}
|
||||
WHERE id = 1
|
||||
`;
|
||||
|
||||
return res.status(200).json({
|
||||
status: 'already_processed',
|
||||
version: latestVersion.version,
|
||||
message: 'Version already notified to Discord'
|
||||
});
|
||||
}
|
||||
|
||||
console.log(`🆕 New version detected: ${latestVersion.version}`);
|
||||
|
||||
// 3. Obtener changelog
|
||||
const changelogResponse = await axios.get(CHANGELOG_URL);
|
||||
const fullChangelog = changelogResponse.data;
|
||||
|
||||
// 4. Parsear changelog de esta versión
|
||||
const parsed = parseVersionChangelog(fullChangelog, latestVersion.version);
|
||||
|
||||
if (!parsed.content) {
|
||||
throw new Error(`Could not find version ${latestVersion.version} in changelog`);
|
||||
}
|
||||
|
||||
console.log(`📝 Parsed ${parsed.changeCount} changes`);
|
||||
|
||||
// 5. Formatear para Discord
|
||||
const formatted = formatForDiscord(parsed.changes);
|
||||
const summary = generateSummary(parsed.changes);
|
||||
|
||||
// 6. Guardar en base de datos
|
||||
const githubUrl = `https://github.com/anthropics/claude-code/blob/main/CHANGELOG.md#${latestVersion.version.replace(/\./g, '')}`;
|
||||
|
||||
const versionData = {
|
||||
version: latestVersion.version,
|
||||
publishedAt: latestVersion.publishedAt,
|
||||
npmUrl: latestVersion.npmUrl,
|
||||
githubUrl,
|
||||
changelogContent: fullChangelog.substring(0, 50000)
|
||||
};
|
||||
|
||||
let versionId;
|
||||
|
||||
if (existing.length > 0) {
|
||||
// Ya existe pero no fue notificada
|
||||
versionId = existing[0].id;
|
||||
console.log(`📝 Updating existing version record (ID: ${versionId})`);
|
||||
} else {
|
||||
// Crear nueva entrada
|
||||
const insertResult = await sql`
|
||||
INSERT INTO claude_code_versions (
|
||||
version,
|
||||
published_at,
|
||||
changelog_content,
|
||||
npm_url,
|
||||
github_url,
|
||||
discord_notified
|
||||
) VALUES (
|
||||
${versionData.version},
|
||||
${versionData.publishedAt},
|
||||
${versionData.changelogContent},
|
||||
${versionData.npmUrl},
|
||||
${versionData.githubUrl},
|
||||
false
|
||||
)
|
||||
RETURNING id
|
||||
`;
|
||||
versionId = insertResult[0].id;
|
||||
console.log(`💾 Version saved to database (ID: ${versionId})`);
|
||||
}
|
||||
|
||||
// 7. Guardar cambios individuales
|
||||
for (const change of parsed.changes) {
|
||||
await sql`
|
||||
INSERT INTO claude_code_changes (
|
||||
version_id,
|
||||
change_type,
|
||||
description,
|
||||
category
|
||||
) VALUES (
|
||||
${versionId},
|
||||
${change.type},
|
||||
${change.description},
|
||||
${change.category}
|
||||
)
|
||||
`;
|
||||
}
|
||||
console.log(`💾 Saved ${parsed.changes.length} individual changes`);
|
||||
|
||||
// 8. Enviar a Discord
|
||||
console.log('📢 Sending Discord notification...');
|
||||
const discordResult = await sendToDiscord(versionData, parsed, formatted, summary);
|
||||
console.log('✅ Discord notification sent successfully!');
|
||||
|
||||
// 9. Guardar log de notificación
|
||||
await sql`
|
||||
INSERT INTO discord_notifications_log (
|
||||
version_id,
|
||||
webhook_url,
|
||||
payload,
|
||||
response_status,
|
||||
response_body
|
||||
) VALUES (
|
||||
${versionId},
|
||||
${process.env.DISCORD_WEBHOOK_URL_CHANGELOG || process.env.DISCORD_WEBHOOK_URL},
|
||||
${JSON.stringify(discordResult.payload)},
|
||||
${discordResult.status},
|
||||
${'Success'}
|
||||
)
|
||||
`;
|
||||
|
||||
// 10. Marcar como notificada
|
||||
await sql`
|
||||
UPDATE claude_code_versions
|
||||
SET discord_notified = true, discord_notification_sent_at = NOW()
|
||||
WHERE id = ${versionId}
|
||||
`;
|
||||
|
||||
// 11. Actualizar metadata
|
||||
await sql`
|
||||
UPDATE monitoring_metadata
|
||||
SET
|
||||
last_check_at = NOW(),
|
||||
last_version_found = ${latestVersion.version},
|
||||
check_count = check_count + 1
|
||||
WHERE id = 1
|
||||
`;
|
||||
|
||||
console.log('🎉 Process completed successfully!');
|
||||
|
||||
return res.status(200).json({
|
||||
status: 'success',
|
||||
version: latestVersion.version,
|
||||
versionId,
|
||||
changes: {
|
||||
total: summary.total,
|
||||
byType: summary.byType
|
||||
},
|
||||
discord: {
|
||||
sent: true,
|
||||
status: discordResult.status
|
||||
}
|
||||
});
|
||||
|
||||
} catch (error) {
|
||||
console.error('❌ Error:', error);
|
||||
|
||||
// Actualizar metadata con error
|
||||
try {
|
||||
const sql = neon(process.env.NEON_DATABASE_URL);
|
||||
await sql`
|
||||
UPDATE monitoring_metadata
|
||||
SET
|
||||
last_check_at = NOW(),
|
||||
error_count = error_count + 1,
|
||||
last_error = ${error.message}
|
||||
WHERE id = 1
|
||||
`;
|
||||
} catch (metaError) {
|
||||
console.error('Failed to update metadata:', metaError);
|
||||
}
|
||||
|
||||
return res.status(500).json({
|
||||
error: 'Internal server error',
|
||||
message: error.message,
|
||||
details: process.env.NODE_ENV === 'development' ? error.stack : undefined
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,261 @@
|
||||
// Notificador de Discord para Claude Code Changelog
|
||||
|
||||
import { neon } from '@neondatabase/serverless';
|
||||
import axios from 'axios';
|
||||
import { parseVersionChangelog, formatForDiscord, generateSummary } from './parser.js';
|
||||
|
||||
/**
|
||||
* Envía notificación a Discord sobre una nueva versión
|
||||
* @param {object} versionData - Datos de la versión
|
||||
* @returns {object} - Resultado del envío
|
||||
*/
|
||||
export async function sendDiscordNotification(versionData) {
|
||||
const { version, changelog, npmUrl, githubUrl } = versionData;
|
||||
|
||||
// Parsear changelog
|
||||
const parsed = parseVersionChangelog(changelog, version);
|
||||
|
||||
if (!parsed.content) {
|
||||
throw new Error('Failed to parse changelog for version ' + version);
|
||||
}
|
||||
|
||||
// Formatear para Discord
|
||||
const formatted = formatForDiscord(parsed.changes);
|
||||
const summary = generateSummary(parsed.changes);
|
||||
|
||||
// Construir embed
|
||||
const embed = buildDiscordEmbed({
|
||||
version,
|
||||
changes: formatted,
|
||||
summary,
|
||||
npmUrl,
|
||||
githubUrl
|
||||
});
|
||||
|
||||
// Enviar a Discord
|
||||
const webhookUrl = process.env.DISCORD_WEBHOOK_URL_CHANGELOG || process.env.DISCORD_WEBHOOK_URL;
|
||||
|
||||
if (!webhookUrl) {
|
||||
throw new Error('Discord webhook URL not configured');
|
||||
}
|
||||
|
||||
const payload = {
|
||||
username: 'Claude Code Monitor',
|
||||
avatar_url: 'https://raw.githubusercontent.com/anthropics/claude-code/main/assets/icon.png',
|
||||
embeds: [embed]
|
||||
};
|
||||
|
||||
try {
|
||||
const response = await axios.post(webhookUrl, payload);
|
||||
|
||||
return {
|
||||
success: true,
|
||||
status: response.status,
|
||||
webhookUrl,
|
||||
payload
|
||||
};
|
||||
} catch (error) {
|
||||
console.error('Discord webhook error:', error.response?.data || error.message);
|
||||
throw new Error(`Discord notification failed: ${error.message}`);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Construye el embed de Discord
|
||||
*/
|
||||
function buildDiscordEmbed({ version, changes, summary, npmUrl, githubUrl }) {
|
||||
const embed = {
|
||||
title: `🚀 Claude Code ${version} Released`,
|
||||
description: `A new version of Claude Code is available with **${summary.total} changes**!`,
|
||||
url: githubUrl,
|
||||
color: 0x8B5CF6, // Purple (Claude color)
|
||||
fields: [],
|
||||
footer: {
|
||||
text: 'Claude Code Changelog Monitor',
|
||||
icon_url: 'https://avatars.githubusercontent.com/u/100788936?s=200&v=4'
|
||||
},
|
||||
timestamp: new Date().toISOString()
|
||||
};
|
||||
|
||||
// Breaking changes (prioritario)
|
||||
if (changes.breaking && changes.breaking.length > 0) {
|
||||
embed.fields.push({
|
||||
name: '⚠️ Breaking Changes',
|
||||
value: changes.breaking || 'None',
|
||||
inline: false
|
||||
});
|
||||
}
|
||||
|
||||
// Features
|
||||
if (changes.features && changes.features.length > 0) {
|
||||
embed.fields.push({
|
||||
name: '✨ New Features',
|
||||
value: changes.features || 'None',
|
||||
inline: false
|
||||
});
|
||||
}
|
||||
|
||||
// Improvements
|
||||
if (changes.improvements && changes.improvements.length > 0) {
|
||||
embed.fields.push({
|
||||
name: '⚡ Improvements',
|
||||
value: changes.improvements || 'None',
|
||||
inline: false
|
||||
});
|
||||
}
|
||||
|
||||
// Bug Fixes
|
||||
if (changes.fixes && changes.fixes.length > 0) {
|
||||
embed.fields.push({
|
||||
name: '🐛 Bug Fixes',
|
||||
value: changes.fixes || 'None',
|
||||
inline: false
|
||||
});
|
||||
}
|
||||
|
||||
// Links
|
||||
embed.fields.push({
|
||||
name: '📦 Installation',
|
||||
value: `\`\`\`bash\nnpm install -g @anthropic-ai/claude-code@${version}\n\`\`\``,
|
||||
inline: false
|
||||
});
|
||||
|
||||
embed.fields.push({
|
||||
name: '🔗 Links',
|
||||
value: `[NPM Package](${npmUrl}) • [Full Changelog](${githubUrl})`,
|
||||
inline: false
|
||||
});
|
||||
|
||||
return embed;
|
||||
}
|
||||
|
||||
/**
|
||||
* Guarda el log de la notificación en la base de datos
|
||||
*/
|
||||
async function logNotification(sql, versionId, notificationResult) {
|
||||
const { success, status, webhookUrl, payload } = notificationResult;
|
||||
|
||||
await sql`
|
||||
INSERT INTO discord_notifications_log (
|
||||
version_id,
|
||||
webhook_url,
|
||||
payload,
|
||||
response_status,
|
||||
response_body,
|
||||
error_message
|
||||
) VALUES (
|
||||
${versionId},
|
||||
${webhookUrl},
|
||||
${JSON.stringify(payload)},
|
||||
${status},
|
||||
${success ? 'Success' : 'Failed'},
|
||||
${success ? null : 'Failed to send'}
|
||||
)
|
||||
`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Marca la versión como notificada en Discord
|
||||
*/
|
||||
async function markAsNotified(sql, versionId) {
|
||||
await sql`
|
||||
UPDATE claude_code_versions
|
||||
SET
|
||||
discord_notified = true,
|
||||
discord_notification_sent_at = NOW()
|
||||
WHERE id = ${versionId}
|
||||
`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Procesa y notifica una versión específica
|
||||
* Esta es la función principal que se llama desde el webhook
|
||||
*/
|
||||
export async function processAndNotify(versionId) {
|
||||
const sql = neon(process.env.NEON_DATABASE_URL);
|
||||
|
||||
// Obtener datos de la versión
|
||||
const versionResult = await sql`
|
||||
SELECT *
|
||||
FROM claude_code_versions
|
||||
WHERE id = ${versionId}
|
||||
`;
|
||||
|
||||
if (versionResult.length === 0) {
|
||||
throw new Error(`Version ID ${versionId} not found`);
|
||||
}
|
||||
|
||||
const versionData = versionResult[0];
|
||||
|
||||
// Verificar si ya fue notificada
|
||||
if (versionData.discord_notified) {
|
||||
return {
|
||||
status: 'already_notified',
|
||||
version: versionData.version,
|
||||
message: 'Version already notified to Discord'
|
||||
};
|
||||
}
|
||||
|
||||
// Enviar notificación
|
||||
const notificationResult = await sendDiscordNotification({
|
||||
version: versionData.version,
|
||||
changelog: versionData.changelog_content,
|
||||
npmUrl: versionData.npm_url,
|
||||
githubUrl: versionData.github_url
|
||||
});
|
||||
|
||||
// Guardar log
|
||||
await logNotification(sql, versionId, notificationResult);
|
||||
|
||||
// Marcar como notificada
|
||||
await markAsNotified(sql, versionId);
|
||||
|
||||
return {
|
||||
status: 'notified',
|
||||
version: versionData.version,
|
||||
notificationResult
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Handler de Vercel para procesar notificaciones
|
||||
*/
|
||||
export default async function handler(req, res) {
|
||||
// CORS
|
||||
res.setHeader('Access-Control-Allow-Origin', '*');
|
||||
res.setHeader('Access-Control-Allow-Methods', 'POST, OPTIONS');
|
||||
res.setHeader('Access-Control-Allow-Headers', 'Content-Type');
|
||||
|
||||
if (req.method === 'OPTIONS') {
|
||||
return res.status(200).json({ ok: true });
|
||||
}
|
||||
|
||||
if (req.method !== 'POST') {
|
||||
return res.status(405).json({ error: 'Method not allowed' });
|
||||
}
|
||||
|
||||
try {
|
||||
const { versionId } = req.body;
|
||||
|
||||
if (!versionId) {
|
||||
return res.status(400).json({ error: 'versionId required' });
|
||||
}
|
||||
|
||||
console.log(`📢 Processing Discord notification for version ID: ${versionId}`);
|
||||
|
||||
const result = await processAndNotify(versionId);
|
||||
|
||||
console.log(`✅ Notification processed: ${result.status}`);
|
||||
|
||||
return res.status(200).json(result);
|
||||
|
||||
} catch (error) {
|
||||
console.error('❌ Discord notification error:', error);
|
||||
|
||||
return res.status(500).json({
|
||||
error: 'Failed to send Discord notification',
|
||||
message: error.message,
|
||||
details: process.env.NODE_ENV === 'development' ? error.stack : undefined
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,288 @@
|
||||
// Parser del CHANGELOG.md de Claude Code
|
||||
|
||||
/**
|
||||
* Extrae la sección de una versión específica del changelog
|
||||
* @param {string} changelog - Contenido completo del CHANGELOG.md
|
||||
* @param {string} version - Versión a extraer (ej: "2.0.31")
|
||||
* @returns {object} - Información parseada de la versión
|
||||
*/
|
||||
export function parseVersionChangelog(changelog, version) {
|
||||
// Normalizar versión (remover 'v' si existe)
|
||||
const cleanVersion = version.replace(/^v/, '');
|
||||
|
||||
// Buscar el inicio de esta versión
|
||||
const versionRegex = new RegExp(`^##\\s+(?:v)?${cleanVersion.replace(/\./g, '\\.')}`, 'm');
|
||||
const match = changelog.match(versionRegex);
|
||||
|
||||
if (!match) {
|
||||
return {
|
||||
version: cleanVersion,
|
||||
content: null,
|
||||
changes: [],
|
||||
error: 'Version not found in changelog'
|
||||
};
|
||||
}
|
||||
|
||||
// Encontrar el índice de inicio
|
||||
const startIndex = match.index;
|
||||
|
||||
// Encontrar el índice de fin (siguiente versión o fin del documento)
|
||||
const nextVersionRegex = /^##\s+(?:v)?\d+\.\d+\.\d+/m;
|
||||
const remainingChangelog = changelog.substring(startIndex + match[0].length);
|
||||
const nextMatch = remainingChangelog.match(nextVersionRegex);
|
||||
|
||||
const endIndex = nextMatch
|
||||
? startIndex + match[0].length + nextMatch.index
|
||||
: changelog.length;
|
||||
|
||||
// Extraer contenido de esta versión
|
||||
const versionContent = changelog.substring(startIndex, endIndex).trim();
|
||||
|
||||
// Parsear los cambios
|
||||
const changes = parseChanges(versionContent);
|
||||
|
||||
return {
|
||||
version: cleanVersion,
|
||||
content: versionContent,
|
||||
changes,
|
||||
changeCount: changes.length
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Parsea los cambios individuales de una sección de versión
|
||||
* @param {string} content - Contenido de la sección de versión
|
||||
* @returns {Array} - Array de cambios parseados
|
||||
*/
|
||||
function parseChanges(content) {
|
||||
const changes = [];
|
||||
|
||||
// Dividir por líneas
|
||||
const lines = content.split('\n');
|
||||
|
||||
let currentCategory = null;
|
||||
|
||||
for (const line of lines) {
|
||||
const trimmed = line.trim();
|
||||
|
||||
// Ignorar líneas vacías y headers de versión
|
||||
if (!trimmed || trimmed.startsWith('##')) {
|
||||
continue;
|
||||
}
|
||||
|
||||
// Detectar categorías (opcional, por si usan headers)
|
||||
if (trimmed.startsWith('###')) {
|
||||
currentCategory = trimmed.replace(/^###\s*/, '').trim();
|
||||
continue;
|
||||
}
|
||||
|
||||
// Detectar bullets de cambios
|
||||
if (trimmed.startsWith('-') || trimmed.startsWith('*')) {
|
||||
const description = trimmed.replace(/^[-*]\s*/, '').trim();
|
||||
|
||||
if (!description) continue;
|
||||
|
||||
// Intentar clasificar el tipo de cambio
|
||||
const changeType = classifyChange(description);
|
||||
|
||||
changes.push({
|
||||
type: changeType,
|
||||
description,
|
||||
category: currentCategory || detectCategory(description),
|
||||
raw: line
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
return changes;
|
||||
}
|
||||
|
||||
/**
|
||||
* Clasifica un cambio por tipo basándose en keywords
|
||||
* @param {string} description - Descripción del cambio
|
||||
* @returns {string} - Tipo de cambio
|
||||
*/
|
||||
function classifyChange(description) {
|
||||
const lower = description.toLowerCase();
|
||||
|
||||
// Breaking changes
|
||||
if (lower.includes('breaking') || lower.includes('removed') || lower.includes('deprecated')) {
|
||||
return 'breaking';
|
||||
}
|
||||
|
||||
// Features
|
||||
if (
|
||||
lower.includes('add') ||
|
||||
lower.includes('new') ||
|
||||
lower.includes('introduce') ||
|
||||
lower.includes('support for') ||
|
||||
lower.startsWith('✨') ||
|
||||
lower.startsWith('🎉')
|
||||
) {
|
||||
return 'feature';
|
||||
}
|
||||
|
||||
// Fixes
|
||||
if (
|
||||
lower.includes('fix') ||
|
||||
lower.includes('resolve') ||
|
||||
lower.includes('correct') ||
|
||||
lower.includes('patch') ||
|
||||
lower.startsWith('🐛')
|
||||
) {
|
||||
return 'fix';
|
||||
}
|
||||
|
||||
// Improvements
|
||||
if (
|
||||
lower.includes('improve') ||
|
||||
lower.includes('enhance') ||
|
||||
lower.includes('optimize') ||
|
||||
lower.includes('better') ||
|
||||
lower.includes('refactor') ||
|
||||
lower.startsWith('⚡') ||
|
||||
lower.startsWith('♻️')
|
||||
) {
|
||||
return 'improvement';
|
||||
}
|
||||
|
||||
// Deprecations
|
||||
if (lower.includes('deprecate')) {
|
||||
return 'deprecation';
|
||||
}
|
||||
|
||||
// Performance
|
||||
if (lower.includes('performance') || lower.includes('speed') || lower.includes('faster')) {
|
||||
return 'performance';
|
||||
}
|
||||
|
||||
// Documentation
|
||||
if (lower.includes('docs') || lower.includes('documentation')) {
|
||||
return 'documentation';
|
||||
}
|
||||
|
||||
// Default: other
|
||||
return 'other';
|
||||
}
|
||||
|
||||
/**
|
||||
* Detecta la categoría de un cambio basándose en keywords
|
||||
* @param {string} description - Descripción del cambio
|
||||
* @returns {string|null} - Categoría detectada
|
||||
*/
|
||||
function detectCategory(description) {
|
||||
const lower = description.toLowerCase();
|
||||
|
||||
const categories = {
|
||||
'Plugin System': ['plugin', 'plugins', 'marketplace'],
|
||||
'CLI': ['cli', 'command', 'terminal', 'bash'],
|
||||
'Performance': ['performance', 'speed', 'faster', 'optimize', 'cache'],
|
||||
'UI/UX': ['ui', 'ux', 'interface', 'display', 'output'],
|
||||
'API': ['api', 'endpoint', 'rest', 'graphql'],
|
||||
'Models': ['model', 'sonnet', 'opus', 'haiku', 'claude'],
|
||||
'MCP': ['mcp', 'model context protocol'],
|
||||
'Agents': ['agent', 'subagent', 'explore'],
|
||||
'Settings': ['setting', 'config', 'configuration'],
|
||||
'Hooks': ['hook', 'trigger', 'event'],
|
||||
'Security': ['security', 'auth', 'authentication', 'permission'],
|
||||
'Documentation': ['docs', 'documentation', 'readme'],
|
||||
'Windows': ['windows', 'win32'],
|
||||
'macOS': ['macos', 'darwin', 'mac'],
|
||||
'Linux': ['linux', 'unix']
|
||||
};
|
||||
|
||||
for (const [category, keywords] of Object.entries(categories)) {
|
||||
if (keywords.some(keyword => lower.includes(keyword))) {
|
||||
return category;
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Genera un resumen de los cambios
|
||||
* @param {Array} changes - Array de cambios
|
||||
* @returns {object} - Resumen estadístico
|
||||
*/
|
||||
export function generateSummary(changes) {
|
||||
const summary = {
|
||||
total: changes.length,
|
||||
byType: {},
|
||||
byCategory: {},
|
||||
highlights: []
|
||||
};
|
||||
|
||||
// Contar por tipo
|
||||
changes.forEach(change => {
|
||||
summary.byType[change.type] = (summary.byType[change.type] || 0) + 1;
|
||||
|
||||
if (change.category) {
|
||||
summary.byCategory[change.category] = (summary.byCategory[change.category] || 0) + 1;
|
||||
}
|
||||
|
||||
// Detectar highlights (breaking changes o features importantes)
|
||||
if (change.type === 'breaking' || change.type === 'feature') {
|
||||
summary.highlights.push(change);
|
||||
}
|
||||
});
|
||||
|
||||
return summary;
|
||||
}
|
||||
|
||||
/**
|
||||
* Formatea los cambios para Discord
|
||||
* @param {Array} changes - Array de cambios
|
||||
* @param {number} maxLength - Longitud máxima del texto
|
||||
* @returns {object} - Cambios agrupados para Discord
|
||||
*/
|
||||
export function formatForDiscord(changes, maxLength = 1024) {
|
||||
const grouped = {
|
||||
features: [],
|
||||
fixes: [],
|
||||
improvements: [],
|
||||
breaking: [],
|
||||
other: []
|
||||
};
|
||||
|
||||
// Agrupar cambios
|
||||
changes.forEach(change => {
|
||||
switch (change.type) {
|
||||
case 'feature':
|
||||
grouped.features.push(change.description);
|
||||
break;
|
||||
case 'fix':
|
||||
grouped.fixes.push(change.description);
|
||||
break;
|
||||
case 'improvement':
|
||||
case 'performance':
|
||||
grouped.improvements.push(change.description);
|
||||
break;
|
||||
case 'breaking':
|
||||
case 'deprecation':
|
||||
grouped.breaking.push(change.description);
|
||||
break;
|
||||
default:
|
||||
grouped.other.push(change.description);
|
||||
}
|
||||
});
|
||||
|
||||
// Truncar si es necesario
|
||||
const truncate = (items, max) => {
|
||||
const text = items.map(item => `• ${item}`).join('\n');
|
||||
if (text.length > max) {
|
||||
const truncated = text.substring(0, max - 20);
|
||||
const lastNewline = truncated.lastIndexOf('\n');
|
||||
return truncated.substring(0, lastNewline) + '\n... [Read more in changelog]';
|
||||
}
|
||||
return text;
|
||||
};
|
||||
|
||||
return {
|
||||
features: truncate(grouped.features, maxLength),
|
||||
fixes: truncate(grouped.fixes, maxLength),
|
||||
improvements: truncate(grouped.improvements, maxLength),
|
||||
breaking: truncate(grouped.breaking, maxLength),
|
||||
other: truncate(grouped.other, maxLength)
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,196 @@
|
||||
// Vercel Function: Monitor de Claude Code Changelog
|
||||
// Se activa con webhooks de NPM o puede ser llamado manualmente
|
||||
|
||||
import { neon } from '@neondatabase/serverless';
|
||||
import axios from 'axios';
|
||||
|
||||
const NPM_PACKAGE = '@anthropic-ai/claude-code';
|
||||
const CHANGELOG_URL = 'https://raw.githubusercontent.com/anthropics/claude-code/main/CHANGELOG.md';
|
||||
|
||||
// Inicializar cliente de Neon
|
||||
function getNeonClient() {
|
||||
const connectionString = process.env.NEON_DATABASE_URL;
|
||||
|
||||
if (!connectionString) {
|
||||
throw new Error('NEON_DATABASE_URL not configured');
|
||||
}
|
||||
|
||||
return neon(connectionString);
|
||||
}
|
||||
|
||||
// Obtener la última versión de NPM
|
||||
async function getLatestNPMVersion() {
|
||||
try {
|
||||
const response = await axios.get(`https://registry.npmjs.org/${NPM_PACKAGE}/latest`);
|
||||
return {
|
||||
version: response.data.version,
|
||||
publishedAt: response.data.time?.modified || new Date().toISOString(),
|
||||
npmUrl: `https://www.npmjs.com/package/${NPM_PACKAGE}/v/${response.data.version}`
|
||||
};
|
||||
} catch (error) {
|
||||
console.error('Error fetching NPM version:', error);
|
||||
throw new Error(`Failed to fetch NPM version: ${error.message}`);
|
||||
}
|
||||
}
|
||||
|
||||
// Verificar si la versión ya fue procesada
|
||||
async function isVersionProcessed(sql, version) {
|
||||
const result = await sql`
|
||||
SELECT id, discord_notified
|
||||
FROM claude_code_versions
|
||||
WHERE version = ${version}
|
||||
`;
|
||||
return result.length > 0 ? result[0] : null;
|
||||
}
|
||||
|
||||
// Guardar nueva versión en la base de datos
|
||||
async function saveVersion(sql, versionData) {
|
||||
const { version, publishedAt, npmUrl, changelogContent, githubUrl } = versionData;
|
||||
|
||||
const result = await sql`
|
||||
INSERT INTO claude_code_versions (
|
||||
version,
|
||||
published_at,
|
||||
changelog_content,
|
||||
npm_url,
|
||||
github_url,
|
||||
discord_notified
|
||||
) VALUES (
|
||||
${version},
|
||||
${publishedAt},
|
||||
${changelogContent},
|
||||
${npmUrl},
|
||||
${githubUrl},
|
||||
false
|
||||
)
|
||||
RETURNING id, version
|
||||
`;
|
||||
|
||||
return result[0];
|
||||
}
|
||||
|
||||
// Actualizar metadata de monitoreo
|
||||
async function updateMonitoringMetadata(sql, version, errorMessage = null) {
|
||||
await sql`
|
||||
UPDATE monitoring_metadata
|
||||
SET
|
||||
last_check_at = NOW(),
|
||||
last_version_found = ${version},
|
||||
check_count = check_count + 1,
|
||||
error_count = error_count + ${errorMessage ? 1 : 0},
|
||||
last_error = ${errorMessage}
|
||||
WHERE id = 1
|
||||
`;
|
||||
}
|
||||
|
||||
// Handler principal
|
||||
export default async function handler(req, res) {
|
||||
// CORS headers
|
||||
res.setHeader('Access-Control-Allow-Origin', '*');
|
||||
res.setHeader('Access-Control-Allow-Methods', 'POST, GET, OPTIONS');
|
||||
res.setHeader('Access-Control-Allow-Headers', 'Content-Type');
|
||||
|
||||
if (req.method === 'OPTIONS') {
|
||||
return res.status(200).json({ ok: true });
|
||||
}
|
||||
|
||||
// Aceptar GET y POST
|
||||
if (req.method !== 'POST' && req.method !== 'GET') {
|
||||
return res.status(405).json({ error: 'Method not allowed' });
|
||||
}
|
||||
|
||||
try {
|
||||
const sql = getNeonClient();
|
||||
|
||||
console.log('🔍 Checking for new Claude Code version...');
|
||||
|
||||
// Obtener última versión de NPM
|
||||
const latestVersion = await getLatestNPMVersion();
|
||||
console.log(`📦 Latest NPM version: ${latestVersion.version}`);
|
||||
|
||||
// Verificar si ya fue procesada
|
||||
const existingVersion = await isVersionProcessed(sql, latestVersion.version);
|
||||
|
||||
if (existingVersion) {
|
||||
console.log(`✓ Version ${latestVersion.version} already processed`);
|
||||
|
||||
// Si ya existe pero no fue notificada a Discord, podemos reintentarlo
|
||||
if (!existingVersion.discord_notified) {
|
||||
console.log('⚠️ Version exists but Discord notification pending');
|
||||
|
||||
// Actualizar metadata
|
||||
await updateMonitoringMetadata(sql, latestVersion.version);
|
||||
|
||||
return res.status(200).json({
|
||||
status: 'pending_notification',
|
||||
version: latestVersion.version,
|
||||
message: 'Version exists, Discord notification pending',
|
||||
versionId: existingVersion.id
|
||||
});
|
||||
}
|
||||
|
||||
// Actualizar metadata
|
||||
await updateMonitoringMetadata(sql, latestVersion.version);
|
||||
|
||||
return res.status(200).json({
|
||||
status: 'already_processed',
|
||||
version: latestVersion.version,
|
||||
message: 'Version already processed and notified'
|
||||
});
|
||||
}
|
||||
|
||||
console.log(`🆕 New version detected: ${latestVersion.version}`);
|
||||
|
||||
// Obtener el changelog desde GitHub
|
||||
console.log('📄 Fetching CHANGELOG.md...');
|
||||
const changelogResponse = await axios.get(CHANGELOG_URL);
|
||||
const fullChangelog = changelogResponse.data;
|
||||
|
||||
// Guardar la nueva versión (sin parsear aún)
|
||||
const githubUrl = `https://github.com/anthropics/claude-code/blob/main/CHANGELOG.md#${latestVersion.version.replace(/\./g, '')}`;
|
||||
|
||||
const savedVersion = await saveVersion(sql, {
|
||||
version: latestVersion.version,
|
||||
publishedAt: latestVersion.publishedAt,
|
||||
npmUrl: latestVersion.npmUrl,
|
||||
githubUrl,
|
||||
changelogContent: fullChangelog.substring(0, 50000) // Limitar a 50KB
|
||||
});
|
||||
|
||||
console.log(`✅ Version ${savedVersion.version} saved to database (ID: ${savedVersion.id})`);
|
||||
|
||||
// Actualizar metadata
|
||||
await updateMonitoringMetadata(sql, latestVersion.version);
|
||||
|
||||
// Responder con éxito
|
||||
return res.status(200).json({
|
||||
status: 'new_version_detected',
|
||||
version: savedVersion.version,
|
||||
versionId: savedVersion.id,
|
||||
message: 'New version saved, ready for processing',
|
||||
data: {
|
||||
version: latestVersion.version,
|
||||
publishedAt: latestVersion.publishedAt,
|
||||
npmUrl: latestVersion.npmUrl,
|
||||
githubUrl
|
||||
}
|
||||
});
|
||||
|
||||
} catch (error) {
|
||||
console.error('❌ Error in webhook handler:', error);
|
||||
|
||||
// Intentar actualizar metadata con el error
|
||||
try {
|
||||
const sql = getNeonClient();
|
||||
await updateMonitoringMetadata(sql, null, error.message);
|
||||
} catch (metaError) {
|
||||
console.error('Failed to update metadata:', metaError);
|
||||
}
|
||||
|
||||
return res.status(500).json({
|
||||
error: 'Internal server error',
|
||||
message: error.message,
|
||||
details: process.env.NODE_ENV === 'development' ? error.stack : undefined
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,83 @@
|
||||
import { authenticateRequest } from './_lib/auth.js';
|
||||
import { getNeonClient } from './_lib/neon.js';
|
||||
|
||||
export default async function handler(req, res) {
|
||||
res.setHeader('Access-Control-Allow-Origin', '*');
|
||||
res.setHeader('Access-Control-Allow-Methods', 'GET, POST, OPTIONS');
|
||||
res.setHeader('Access-Control-Allow-Headers', 'Content-Type, Authorization');
|
||||
|
||||
if (req.method === 'OPTIONS') {
|
||||
return res.status(200).json({ ok: true });
|
||||
}
|
||||
|
||||
const userId = await authenticateRequest(req, res);
|
||||
if (!userId) return;
|
||||
|
||||
const sql = getNeonClient();
|
||||
|
||||
try {
|
||||
if (req.method === 'GET') {
|
||||
const collections = await sql`
|
||||
SELECT * FROM user_collections
|
||||
WHERE clerk_user_id = ${userId}
|
||||
ORDER BY position ASC, created_at ASC
|
||||
`;
|
||||
|
||||
const items = collections.length > 0
|
||||
? await sql`
|
||||
SELECT * FROM collection_items
|
||||
WHERE collection_id = ANY(${collections.map(c => c.id)})
|
||||
ORDER BY added_at ASC
|
||||
`
|
||||
: [];
|
||||
|
||||
const itemsByCollection = {};
|
||||
for (const item of items) {
|
||||
if (!itemsByCollection[item.collection_id]) {
|
||||
itemsByCollection[item.collection_id] = [];
|
||||
}
|
||||
itemsByCollection[item.collection_id].push(item);
|
||||
}
|
||||
|
||||
const result = collections.map(c => ({
|
||||
...c,
|
||||
collection_items: itemsByCollection[c.id] || [],
|
||||
}));
|
||||
|
||||
return res.status(200).json({ collections: result });
|
||||
}
|
||||
|
||||
if (req.method === 'POST') {
|
||||
const { name } = req.body;
|
||||
if (!name || typeof name !== 'string' || name.trim().length === 0) {
|
||||
return res.status(400).json({ error: 'Collection name is required' });
|
||||
}
|
||||
|
||||
if (name.length > 100) {
|
||||
return res.status(400).json({ error: 'Collection name too long (max 100 characters)' });
|
||||
}
|
||||
|
||||
const maxPos = await sql`
|
||||
SELECT COALESCE(MAX(position), -1) AS max_pos
|
||||
FROM user_collections
|
||||
WHERE clerk_user_id = ${userId}
|
||||
`;
|
||||
|
||||
const newPosition = maxPos[0].max_pos + 1;
|
||||
|
||||
const rows = await sql`
|
||||
INSERT INTO user_collections (clerk_user_id, name, position)
|
||||
VALUES (${userId}, ${name.trim()}, ${newPosition})
|
||||
RETURNING *
|
||||
`;
|
||||
|
||||
const collection = { ...rows[0], collection_items: [] };
|
||||
return res.status(201).json({ collection });
|
||||
}
|
||||
|
||||
return res.status(405).json({ error: 'Method not allowed', allowed: ['GET', 'POST'] });
|
||||
} catch (error) {
|
||||
console.error('Collections error:', error);
|
||||
return res.status(500).json({ error: 'Internal server error' });
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,73 @@
|
||||
import { authenticateRequest } from '../_lib/auth.js';
|
||||
import { getNeonClient } from '../_lib/neon.js';
|
||||
|
||||
export default async function handler(req, res) {
|
||||
res.setHeader('Access-Control-Allow-Origin', '*');
|
||||
res.setHeader('Access-Control-Allow-Methods', 'PATCH, DELETE, OPTIONS');
|
||||
res.setHeader('Access-Control-Allow-Headers', 'Content-Type, Authorization');
|
||||
|
||||
if (req.method === 'OPTIONS') {
|
||||
return res.status(200).json({ ok: true });
|
||||
}
|
||||
|
||||
const userId = await authenticateRequest(req, res);
|
||||
if (!userId) return;
|
||||
|
||||
const { id } = req.query;
|
||||
if (!id) {
|
||||
return res.status(400).json({ error: 'Collection ID is required' });
|
||||
}
|
||||
|
||||
const sql = getNeonClient();
|
||||
|
||||
try {
|
||||
// Verify ownership
|
||||
const existing = await sql`
|
||||
SELECT id FROM user_collections
|
||||
WHERE id = ${id} AND clerk_user_id = ${userId}
|
||||
`;
|
||||
|
||||
if (existing.length === 0) {
|
||||
return res.status(404).json({ error: 'Collection not found' });
|
||||
}
|
||||
|
||||
if (req.method === 'PATCH') {
|
||||
const { name } = req.body;
|
||||
if (!name || typeof name !== 'string' || name.trim().length === 0) {
|
||||
return res.status(400).json({ error: 'Collection name is required' });
|
||||
}
|
||||
|
||||
if (name.length > 100) {
|
||||
return res.status(400).json({ error: 'Collection name too long (max 100 characters)' });
|
||||
}
|
||||
|
||||
const rows = await sql`
|
||||
UPDATE user_collections
|
||||
SET name = ${name.trim()}, updated_at = NOW()
|
||||
WHERE id = ${id} AND clerk_user_id = ${userId}
|
||||
RETURNING *
|
||||
`;
|
||||
|
||||
const items = await sql`
|
||||
SELECT * FROM collection_items
|
||||
WHERE collection_id = ${id}
|
||||
ORDER BY added_at ASC
|
||||
`;
|
||||
|
||||
const collection = { ...rows[0], collection_items: items };
|
||||
return res.status(200).json({ collection });
|
||||
}
|
||||
|
||||
if (req.method === 'DELETE') {
|
||||
await sql`DELETE FROM collection_items WHERE collection_id = ${id}`;
|
||||
await sql`DELETE FROM user_collections WHERE id = ${id} AND clerk_user_id = ${userId}`;
|
||||
|
||||
return res.status(200).json({ success: true });
|
||||
}
|
||||
|
||||
return res.status(405).json({ error: 'Method not allowed', allowed: ['PATCH', 'DELETE'] });
|
||||
} catch (error) {
|
||||
console.error('Collection [id] error:', error);
|
||||
return res.status(500).json({ error: 'Internal server error' });
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,112 @@
|
||||
import { authenticateRequest } from '../_lib/auth.js';
|
||||
import { getNeonClient } from '../_lib/neon.js';
|
||||
|
||||
export default async function handler(req, res) {
|
||||
res.setHeader('Access-Control-Allow-Origin', '*');
|
||||
res.setHeader('Access-Control-Allow-Methods', 'POST, DELETE, PATCH, OPTIONS');
|
||||
res.setHeader('Access-Control-Allow-Headers', 'Content-Type, Authorization');
|
||||
|
||||
if (req.method === 'OPTIONS') {
|
||||
return res.status(200).json({ ok: true });
|
||||
}
|
||||
|
||||
const userId = await authenticateRequest(req, res);
|
||||
if (!userId) return;
|
||||
|
||||
const sql = getNeonClient();
|
||||
|
||||
try {
|
||||
if (req.method === 'POST') {
|
||||
const { collectionId, componentType, componentPath, componentName, componentCategory } = req.body;
|
||||
|
||||
if (!collectionId || !componentType || !componentPath || !componentName) {
|
||||
return res.status(400).json({ error: 'collectionId, componentType, componentPath, and componentName are required' });
|
||||
}
|
||||
|
||||
// Verify collection ownership
|
||||
const col = await sql`
|
||||
SELECT id FROM user_collections
|
||||
WHERE id = ${collectionId} AND clerk_user_id = ${userId}
|
||||
`;
|
||||
if (col.length === 0) {
|
||||
return res.status(404).json({ error: 'Collection not found' });
|
||||
}
|
||||
|
||||
// Check for duplicate
|
||||
const dup = await sql`
|
||||
SELECT id FROM collection_items
|
||||
WHERE collection_id = ${collectionId} AND component_path = ${componentPath}
|
||||
`;
|
||||
if (dup.length > 0) {
|
||||
return res.status(409).json({ error: 'Component already in this collection' });
|
||||
}
|
||||
|
||||
const rows = await sql`
|
||||
INSERT INTO collection_items (collection_id, component_type, component_path, component_name, component_category)
|
||||
VALUES (${collectionId}, ${componentType}, ${componentPath}, ${componentName}, ${componentCategory || null})
|
||||
RETURNING *
|
||||
`;
|
||||
|
||||
return res.status(201).json({ item: rows[0] });
|
||||
}
|
||||
|
||||
if (req.method === 'DELETE') {
|
||||
const { itemId, collectionId } = req.body;
|
||||
|
||||
if (!itemId || !collectionId) {
|
||||
return res.status(400).json({ error: 'itemId and collectionId are required' });
|
||||
}
|
||||
|
||||
// Verify collection ownership
|
||||
const col = await sql`
|
||||
SELECT id FROM user_collections
|
||||
WHERE id = ${collectionId} AND clerk_user_id = ${userId}
|
||||
`;
|
||||
if (col.length === 0) {
|
||||
return res.status(404).json({ error: 'Collection not found' });
|
||||
}
|
||||
|
||||
await sql`
|
||||
DELETE FROM collection_items
|
||||
WHERE id = ${itemId} AND collection_id = ${collectionId}
|
||||
`;
|
||||
|
||||
return res.status(200).json({ success: true });
|
||||
}
|
||||
|
||||
if (req.method === 'PATCH') {
|
||||
const { itemId, fromCollectionId, toCollectionId } = req.body;
|
||||
|
||||
if (!itemId || !fromCollectionId || !toCollectionId) {
|
||||
return res.status(400).json({ error: 'itemId, fromCollectionId, and toCollectionId are required' });
|
||||
}
|
||||
|
||||
// Verify ownership of both collections
|
||||
const cols = await sql`
|
||||
SELECT id FROM user_collections
|
||||
WHERE id = ANY(${[fromCollectionId, toCollectionId]}) AND clerk_user_id = ${userId}
|
||||
`;
|
||||
if (cols.length < 2) {
|
||||
return res.status(404).json({ error: 'One or both collections not found' });
|
||||
}
|
||||
|
||||
const rows = await sql`
|
||||
UPDATE collection_items
|
||||
SET collection_id = ${toCollectionId}
|
||||
WHERE id = ${itemId} AND collection_id = ${fromCollectionId}
|
||||
RETURNING *
|
||||
`;
|
||||
|
||||
if (rows.length === 0) {
|
||||
return res.status(404).json({ error: 'Item not found in source collection' });
|
||||
}
|
||||
|
||||
return res.status(200).json({ item: rows[0] });
|
||||
}
|
||||
|
||||
return res.status(405).json({ error: 'Method not allowed', allowed: ['POST', 'DELETE', 'PATCH'] });
|
||||
} catch (error) {
|
||||
console.error('Collection items error:', error);
|
||||
return res.status(500).json({ error: 'Internal server error' });
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,199 @@
|
||||
import { verifyKey, InteractionType, InteractionResponseType } from 'discord-interactions';
|
||||
import axios from 'axios';
|
||||
|
||||
const componentTypes = {
|
||||
skills: { icon: '🎨', color: 0x9B59B6 },
|
||||
agents: { icon: '🤖', color: 0xFF6B6B },
|
||||
commands: { icon: '⚡', color: 0x4ECDC4 },
|
||||
mcps: { icon: '🔌', color: 0x95E1D3 },
|
||||
settings: { icon: '⚙️', color: 0xF9CA24 },
|
||||
hooks: { icon: '🪝', color: 0x6C5CE7 },
|
||||
templates: { icon: '📋', color: 0xA8E6CF },
|
||||
plugins: { icon: '🧩', color: 0xFFD93D },
|
||||
};
|
||||
|
||||
let cachedComponents = null;
|
||||
let cacheTimestamp = null;
|
||||
const CACHE_DURATION = 5 * 60 * 1000;
|
||||
|
||||
async function getComponents() {
|
||||
const now = Date.now();
|
||||
if (cachedComponents && cacheTimestamp && (now - cacheTimestamp) < CACHE_DURATION) {
|
||||
return cachedComponents;
|
||||
}
|
||||
const response = await axios.get('https://aitmpl.com/components.json', { timeout: 10000 });
|
||||
cachedComponents = response.data;
|
||||
cacheTimestamp = now;
|
||||
return cachedComponents;
|
||||
}
|
||||
|
||||
function searchComponents(components, query, type = null) {
|
||||
const results = [];
|
||||
const lowerQuery = query.toLowerCase();
|
||||
const typesToSearch = type ? [type] : Object.keys(componentTypes);
|
||||
|
||||
for (const componentType of typesToSearch) {
|
||||
const componentList = components[componentType] || [];
|
||||
for (const component of componentList) {
|
||||
if (component.name.toLowerCase().includes(lowerQuery) || component.category?.toLowerCase().includes(lowerQuery)) {
|
||||
results.push({
|
||||
...component,
|
||||
type: componentType,
|
||||
score: component.name.toLowerCase() === lowerQuery ? 100 : component.name.toLowerCase().startsWith(lowerQuery) ? 50 : 20
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
return results.sort((a, b) => b.score - a.score).slice(0, 10);
|
||||
}
|
||||
|
||||
function createEmbed(component, type = 'info') {
|
||||
const typeConfig = componentTypes[component.type];
|
||||
const icon = typeConfig?.icon || '📦';
|
||||
const color = typeConfig?.color || 0x00D9FF;
|
||||
|
||||
const typeLabel = component.type === 'agents' ? 'agent' : component.type === 'commands' ? 'command' : component.type === 'mcps' ? 'mcp' : component.type === 'settings' ? 'setting' : component.type === 'hooks' ? 'hook' : component.type;
|
||||
const category = component.category || 'general';
|
||||
const url = `https://www.aitmpl.com/component/${typeLabel}/${category}/${component.name}`;
|
||||
|
||||
if (type === 'install') {
|
||||
const flagName = component.type === 'templates' ? 'template' : component.type;
|
||||
const installCommand = `npx claude-code-templates@latest --${flagName} ${component.name}`;
|
||||
return {
|
||||
title: `${icon} Install ${component.name}`,
|
||||
description: 'Copy and paste this command in your terminal:',
|
||||
color: 0x00D9FF,
|
||||
url: url,
|
||||
fields: [
|
||||
{ name: 'Installation Command', value: `\`\`\`bash\n${installCommand}\n\`\`\``, inline: false },
|
||||
{ name: 'Component Page', value: `[View on aitmpl.com](${url})`, inline: false }
|
||||
],
|
||||
timestamp: new Date().toISOString(),
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
title: `${icon} ${component.name}`,
|
||||
url: url,
|
||||
description: component.description || component.content?.substring(0, 200) || 'No description',
|
||||
color: color,
|
||||
fields: [
|
||||
{ name: 'Type', value: `\`${component.type}\``, inline: true },
|
||||
{ name: 'Category', value: component.category || 'N/A', inline: true },
|
||||
{ name: 'Downloads', value: `${component.downloads || 0}`, inline: true },
|
||||
{ name: 'Component Page', value: `[View on aitmpl.com](${url})`, inline: false }
|
||||
],
|
||||
timestamp: new Date().toISOString(),
|
||||
};
|
||||
}
|
||||
|
||||
export default async function handler(req, res) {
|
||||
if (req.method !== 'POST') {
|
||||
return res.status(405).json({ error: 'Method not allowed' });
|
||||
}
|
||||
|
||||
const signature = req.headers['x-signature-ed25519'];
|
||||
const timestamp = req.headers['x-signature-timestamp'];
|
||||
const rawBody = JSON.stringify(req.body);
|
||||
|
||||
const publicKey = process.env.DISCORD_PUBLIC_KEY;
|
||||
if (!publicKey) {
|
||||
return res.status(500).json({ error: 'Server configuration error' });
|
||||
}
|
||||
|
||||
const isValidRequest = verifyKey(rawBody, signature, timestamp, publicKey);
|
||||
if (!isValidRequest) {
|
||||
return res.status(401).json({ error: 'Invalid request signature' });
|
||||
}
|
||||
|
||||
const interaction = req.body;
|
||||
|
||||
if (interaction.type === InteractionType.PING) {
|
||||
return res.status(200).json({ type: InteractionResponseType.PONG });
|
||||
}
|
||||
|
||||
if (interaction.type === InteractionType.APPLICATION_COMMAND) {
|
||||
try {
|
||||
const components = await getComponents();
|
||||
const commandName = interaction.data.name;
|
||||
const options = interaction.data.options || [];
|
||||
let response;
|
||||
|
||||
if (commandName === 'search') {
|
||||
const query = options.find(o => o.name === 'query')?.value;
|
||||
const type = options.find(o => o.name === 'type')?.value;
|
||||
const results = searchComponents(components, query, type);
|
||||
response = {
|
||||
type: InteractionResponseType.CHANNEL_MESSAGE_WITH_SOURCE,
|
||||
data: {
|
||||
embeds: [{
|
||||
title: `🔍 Search Results for "${query}"`,
|
||||
description: `Found ${results.length} result(s)`,
|
||||
color: 0x00D9FF,
|
||||
fields: results.map((c, i) => {
|
||||
const typeLabel = c.type === 'agents' ? 'agent' : c.type === 'commands' ? 'command' : c.type === 'mcps' ? 'mcp' : c.type === 'settings' ? 'setting' : c.type === 'hooks' ? 'hook' : c.type;
|
||||
const category = c.category || 'general';
|
||||
const url = `https://www.aitmpl.com/component/${typeLabel}/${category}/${c.name}`;
|
||||
return {
|
||||
name: `${i + 1}. ${componentTypes[c.type].icon} ${c.name}`,
|
||||
value: `**Type:** ${c.type} | **Downloads:** ${c.downloads || 0}\n[View on aitmpl.com](${url})`,
|
||||
inline: false
|
||||
};
|
||||
}),
|
||||
timestamp: new Date().toISOString()
|
||||
}]
|
||||
}
|
||||
};
|
||||
} else if (commandName === 'info' || commandName === 'install') {
|
||||
const name = options.find(o => o.name === 'name')?.value;
|
||||
const type = options.find(o => o.name === 'type')?.value;
|
||||
let component = null;
|
||||
const types = type ? [type] : Object.keys(componentTypes);
|
||||
for (const t of types) {
|
||||
const found = components[t]?.find(c => c.name === name);
|
||||
if (found) {
|
||||
component = { ...found, type: t };
|
||||
break;
|
||||
}
|
||||
}
|
||||
if (!component) {
|
||||
response = {
|
||||
type: InteractionResponseType.CHANNEL_MESSAGE_WITH_SOURCE,
|
||||
data: { content: `Component "${name}" not found. Use \`/search\` to find components.`, flags: 64 }
|
||||
};
|
||||
} else {
|
||||
response = {
|
||||
type: InteractionResponseType.CHANNEL_MESSAGE_WITH_SOURCE,
|
||||
data: { embeds: [createEmbed(component, commandName)] }
|
||||
};
|
||||
}
|
||||
} else if (commandName === 'popular' || commandName === 'random') {
|
||||
const type = options.find(o => o.name === 'type')?.value;
|
||||
const componentList = components[type] || [];
|
||||
let component;
|
||||
if (commandName === 'popular') {
|
||||
const sorted = [...componentList].sort((a, b) => (b.downloads || 0) - (a.downloads || 0));
|
||||
component = sorted[0];
|
||||
} else {
|
||||
component = componentList[Math.floor(Math.random() * componentList.length)];
|
||||
}
|
||||
if (component) {
|
||||
response = {
|
||||
type: InteractionResponseType.CHANNEL_MESSAGE_WITH_SOURCE,
|
||||
data: { embeds: [createEmbed({ ...component, type })] }
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
return res.status(200).json(response);
|
||||
} catch (error) {
|
||||
console.error('Error:', error);
|
||||
return res.status(200).json({
|
||||
type: InteractionResponseType.CHANNEL_MESSAGE_WITH_SOURCE,
|
||||
data: { content: '❌ An error occurred', flags: 64 }
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
return res.status(400).json({ error: 'Unknown interaction type' });
|
||||
}
|
||||
@@ -0,0 +1,150 @@
|
||||
// API Health Check Endpoint - Neon Database
|
||||
// Monitors critical API endpoints and logs results
|
||||
// Called by Vercel Cron every 15 minutes
|
||||
|
||||
import { neon } from '@neondatabase/serverless';
|
||||
|
||||
function getNeonClient() {
|
||||
const connectionString = process.env.NEON_DATABASE_URL;
|
||||
if (!connectionString) {
|
||||
throw new Error('NEON_DATABASE_URL not configured');
|
||||
}
|
||||
return neon(connectionString);
|
||||
}
|
||||
|
||||
const ENDPOINTS_TO_CHECK = [
|
||||
{ url: 'https://www.aitmpl.com/api/track-download-supabase', method: 'OPTIONS' },
|
||||
{ url: 'https://www.aitmpl.com/api/track-command-usage', method: 'OPTIONS' },
|
||||
{ url: 'https://www.aitmpl.com/api/track-website-events', method: 'OPTIONS' }
|
||||
];
|
||||
|
||||
const TIMEOUT_MS = 10000;
|
||||
|
||||
async function checkEndpoint(endpoint) {
|
||||
const startTime = Date.now();
|
||||
|
||||
try {
|
||||
const controller = new AbortController();
|
||||
const timeoutId = setTimeout(() => controller.abort(), TIMEOUT_MS);
|
||||
|
||||
const response = await fetch(endpoint.url, {
|
||||
method: endpoint.method,
|
||||
signal: controller.signal
|
||||
});
|
||||
|
||||
clearTimeout(timeoutId);
|
||||
const responseTimeMs = Date.now() - startTime;
|
||||
|
||||
return {
|
||||
endpoint: endpoint.url,
|
||||
method: endpoint.method,
|
||||
statusCode: response.status,
|
||||
responseTimeMs,
|
||||
errorMessage: null
|
||||
};
|
||||
} catch (error) {
|
||||
const responseTimeMs = Date.now() - startTime;
|
||||
return {
|
||||
endpoint: endpoint.url,
|
||||
method: endpoint.method,
|
||||
statusCode: 0,
|
||||
responseTimeMs,
|
||||
errorMessage: error.name === 'AbortError' ? 'Timeout' : error.message
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
async function sendDiscordAlert(failures) {
|
||||
const webhookUrl = process.env.DISCORD_WEBHOOK_URL_CHANGELOG;
|
||||
if (!webhookUrl || failures.length === 0) return;
|
||||
|
||||
const failureList = failures.map(f =>
|
||||
`- \`${f.endpoint}\`: ${f.errorMessage || `HTTP ${f.statusCode}`} (${f.responseTimeMs}ms)`
|
||||
).join('\n');
|
||||
|
||||
try {
|
||||
await fetch(webhookUrl, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
embeds: [{
|
||||
title: 'API Health Alert',
|
||||
description: `The following endpoints are experiencing issues:\n${failureList}`,
|
||||
color: 0xff4444,
|
||||
timestamp: new Date().toISOString()
|
||||
}]
|
||||
})
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('Failed to send Discord alert:', error.message);
|
||||
}
|
||||
}
|
||||
|
||||
export default async function handler(req, res) {
|
||||
// CORS headers
|
||||
res.setHeader('Access-Control-Allow-Origin', '*');
|
||||
res.setHeader('Access-Control-Allow-Methods', 'GET, OPTIONS');
|
||||
res.setHeader('Access-Control-Allow-Headers', 'Content-Type');
|
||||
|
||||
if (req.method === 'OPTIONS') {
|
||||
return res.status(200).json({ ok: true });
|
||||
}
|
||||
|
||||
if (req.method !== 'GET') {
|
||||
return res.status(405).json({ error: 'Method not allowed', allowed: ['GET'] });
|
||||
}
|
||||
|
||||
try {
|
||||
// Check all endpoints in parallel
|
||||
const results = await Promise.all(ENDPOINTS_TO_CHECK.map(checkEndpoint));
|
||||
|
||||
const sql = getNeonClient();
|
||||
|
||||
// Log each result
|
||||
for (const result of results) {
|
||||
await sql`
|
||||
INSERT INTO api_health_logs (
|
||||
endpoint, method, status_code, response_time_ms, error_message
|
||||
) VALUES (
|
||||
${result.endpoint},
|
||||
${result.method},
|
||||
${result.statusCode},
|
||||
${result.responseTimeMs},
|
||||
${result.errorMessage}
|
||||
)
|
||||
`;
|
||||
}
|
||||
|
||||
// Identify failures (status >= 500, status 0 for network errors, or timeout > 10s)
|
||||
const failures = results.filter(r =>
|
||||
r.statusCode === 0 || r.statusCode >= 500 || r.responseTimeMs >= TIMEOUT_MS
|
||||
);
|
||||
|
||||
// Send Discord alert if there are failures
|
||||
if (failures.length > 0) {
|
||||
await sendDiscordAlert(failures);
|
||||
}
|
||||
|
||||
const allHealthy = failures.length === 0;
|
||||
|
||||
res.status(200).json({
|
||||
success: true,
|
||||
healthy: allHealthy,
|
||||
results: results.map(r => ({
|
||||
endpoint: r.endpoint,
|
||||
status: r.statusCode,
|
||||
responseTime: r.responseTimeMs,
|
||||
error: r.errorMessage
|
||||
})),
|
||||
timestamp: new Date().toISOString()
|
||||
});
|
||||
|
||||
} catch (error) {
|
||||
console.error('Health check error:', error);
|
||||
res.status(500).json({
|
||||
error: 'Internal server error',
|
||||
message: 'Health check failed',
|
||||
details: process.env.NODE_ENV === 'development' ? error.message : undefined
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
<h1>AITMPL.COM</h1>
|
||||
@@ -0,0 +1,23 @@
|
||||
/**
|
||||
* Jest Configuration for API Tests
|
||||
*
|
||||
* This configuration is designed to test production API endpoints
|
||||
* to ensure critical functionality doesn't break during deployments.
|
||||
*/
|
||||
|
||||
module.exports = {
|
||||
testEnvironment: 'node',
|
||||
testMatch: ['**/__tests__/**/*.test.js'],
|
||||
collectCoverageFrom: [
|
||||
'*.js',
|
||||
'!node_modules/**',
|
||||
'!__tests__/**',
|
||||
'!jest.config.cjs',
|
||||
'!coverage/**'
|
||||
],
|
||||
coverageDirectory: 'coverage',
|
||||
verbose: true,
|
||||
testTimeout: 30000, // 30 seconds default timeout
|
||||
bail: false, // Run all tests even if some fail
|
||||
maxWorkers: 4, // Run tests in parallel
|
||||
};
|
||||
Generated
+4463
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,24 @@
|
||||
{
|
||||
"type": "module",
|
||||
"name": "claude-code-templates-api",
|
||||
"version": "1.0.0",
|
||||
"description": "API endpoints for claude-code-templates",
|
||||
"scripts": {
|
||||
"test": "jest --config jest.config.cjs",
|
||||
"test:api": "jest __tests__/endpoints.test.js --config jest.config.cjs",
|
||||
"test:watch": "jest --watch --config jest.config.cjs",
|
||||
"test:coverage": "jest --coverage --config jest.config.cjs"
|
||||
},
|
||||
"dependencies": {
|
||||
"@vercel/postgres": "^0.10.0",
|
||||
"@supabase/supabase-js": "^2.45.0",
|
||||
"@neondatabase/serverless": "^0.10.1",
|
||||
"@clerk/backend": "^1.0.0",
|
||||
"axios": "^1.6.2",
|
||||
"discord-interactions": "^3.4.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"jest": "^29.7.0",
|
||||
"@types/jest": "^29.5.11"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,148 @@
|
||||
// Command Usage Tracking API Endpoint - Neon Database
|
||||
// Tracks CLI command executions for community analytics
|
||||
|
||||
import { neon } from '@neondatabase/serverless';
|
||||
|
||||
// Initialize Neon client
|
||||
function getNeonClient() {
|
||||
const connectionString = process.env.NEON_DATABASE_URL;
|
||||
|
||||
if (!connectionString) {
|
||||
throw new Error('NEON_DATABASE_URL not configured');
|
||||
}
|
||||
|
||||
return neon(connectionString);
|
||||
}
|
||||
|
||||
// Validate command data
|
||||
function validateCommandData(data) {
|
||||
const { command, cliVersion, nodeVersion, platform } = data;
|
||||
|
||||
if (!command) {
|
||||
return { valid: false, error: 'Command name is required' };
|
||||
}
|
||||
|
||||
// Valid commands to track
|
||||
const validCommands = [
|
||||
'chats',
|
||||
'analytics',
|
||||
'health-check',
|
||||
'plugins',
|
||||
'sandbox',
|
||||
'agents',
|
||||
'chats-mobile',
|
||||
'studio',
|
||||
'command-stats',
|
||||
'hook-stats',
|
||||
'mcp-stats',
|
||||
'skills-manager',
|
||||
'2025-year-in-review'
|
||||
];
|
||||
|
||||
if (!validCommands.includes(command)) {
|
||||
return { valid: false, error: 'Invalid command name' };
|
||||
}
|
||||
|
||||
if (command.length > 100) {
|
||||
return { valid: false, error: 'Command name too long' };
|
||||
}
|
||||
|
||||
return { valid: true };
|
||||
}
|
||||
|
||||
// Get client information from request
|
||||
function getClientInfo(req) {
|
||||
const userAgent = req.headers['user-agent'] || '';
|
||||
const ip = req.headers['x-forwarded-for']?.split(',')[0] ||
|
||||
req.headers['x-real-ip'] ||
|
||||
'unknown';
|
||||
|
||||
return { userAgent, ip };
|
||||
}
|
||||
|
||||
// Main handler
|
||||
export default async function handler(req, res) {
|
||||
// CORS headers
|
||||
res.setHeader('Access-Control-Allow-Origin', '*');
|
||||
res.setHeader('Access-Control-Allow-Methods', 'POST, OPTIONS');
|
||||
res.setHeader('Access-Control-Allow-Headers', 'Content-Type, User-Agent');
|
||||
|
||||
// Handle preflight requests
|
||||
if (req.method === 'OPTIONS') {
|
||||
return res.status(200).json({ ok: true });
|
||||
}
|
||||
|
||||
// Only accept POST requests
|
||||
if (req.method !== 'POST') {
|
||||
return res.status(405).json({
|
||||
error: 'Method not allowed',
|
||||
allowed: ['POST']
|
||||
});
|
||||
}
|
||||
|
||||
try {
|
||||
// Extract and validate request body
|
||||
const {
|
||||
command,
|
||||
cliVersion,
|
||||
nodeVersion,
|
||||
platform,
|
||||
arch,
|
||||
sessionId,
|
||||
metadata
|
||||
} = req.body;
|
||||
|
||||
const validation = validateCommandData({ command, cliVersion, nodeVersion, platform });
|
||||
|
||||
if (!validation.valid) {
|
||||
return res.status(400).json({ error: validation.error });
|
||||
}
|
||||
|
||||
// Get client information
|
||||
const clientInfo = getClientInfo(req);
|
||||
|
||||
// Initialize Neon client
|
||||
const sql = getNeonClient();
|
||||
|
||||
// Insert command log
|
||||
await sql`
|
||||
INSERT INTO command_usage_logs (
|
||||
command_name,
|
||||
cli_version,
|
||||
node_version,
|
||||
platform,
|
||||
arch,
|
||||
session_id,
|
||||
metadata
|
||||
) VALUES (
|
||||
${command},
|
||||
${cliVersion || 'unknown'},
|
||||
${nodeVersion || 'unknown'},
|
||||
${platform || 'unknown'},
|
||||
${arch || 'unknown'},
|
||||
${sessionId || null},
|
||||
${metadata ? JSON.stringify(metadata) : null}
|
||||
)
|
||||
`;
|
||||
|
||||
// Return success response
|
||||
res.status(200).json({
|
||||
success: true,
|
||||
message: 'Command execution tracked successfully',
|
||||
data: {
|
||||
command,
|
||||
timestamp: new Date().toISOString()
|
||||
}
|
||||
});
|
||||
|
||||
} catch (error) {
|
||||
console.error('Command tracking error:', error);
|
||||
|
||||
// Return error response
|
||||
res.status(500).json({
|
||||
error: 'Internal server error',
|
||||
message: 'Failed to track command execution',
|
||||
details: process.env.NODE_ENV === 'development' ? error.message : undefined
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,150 @@
|
||||
// Download tracking API endpoint using Supabase client
|
||||
import { createClient } from '@supabase/supabase-js';
|
||||
|
||||
// Initialize Supabase client
|
||||
function getSupabaseClient() {
|
||||
const supabaseUrl = process.env.SUPABASE_URL;
|
||||
const supabaseServiceKey = process.env.SUPABASE_SERVICE_ROLE_KEY;
|
||||
|
||||
if (!supabaseUrl || !supabaseServiceKey) {
|
||||
throw new Error('Missing Supabase configuration');
|
||||
}
|
||||
|
||||
return createClient(supabaseUrl, supabaseServiceKey);
|
||||
}
|
||||
|
||||
// Validate component data
|
||||
function validateComponentData(data) {
|
||||
const { type, name, path, category } = data;
|
||||
|
||||
if (!type || !name) {
|
||||
return { valid: false, error: 'Component type and name are required' };
|
||||
}
|
||||
|
||||
const validTypes = ['agent', 'command', 'setting', 'hook', 'mcp', 'skill', 'template'];
|
||||
if (!validTypes.includes(type)) {
|
||||
return { valid: false, error: 'Invalid component type' };
|
||||
}
|
||||
|
||||
if (name.length > 255) {
|
||||
return { valid: false, error: 'Component name too long' };
|
||||
}
|
||||
|
||||
return { valid: true };
|
||||
}
|
||||
|
||||
// Get IP address from request
|
||||
function getClientIP(req) {
|
||||
return req.headers['x-forwarded-for']?.split(',')[0] ||
|
||||
req.headers['x-real-ip'] ||
|
||||
req.connection?.remoteAddress ||
|
||||
req.socket?.remoteAddress ||
|
||||
'127.0.0.1';
|
||||
}
|
||||
|
||||
// Get country from Vercel geo headers
|
||||
function getCountry(req) {
|
||||
return req.headers['x-vercel-ip-country'] || null;
|
||||
}
|
||||
|
||||
export default async function handler(req, res) {
|
||||
// Set CORS headers
|
||||
res.setHeader('Access-Control-Allow-Origin', '*');
|
||||
res.setHeader('Access-Control-Allow-Methods', 'POST, OPTIONS');
|
||||
res.setHeader('Access-Control-Allow-Headers', 'Content-Type, User-Agent');
|
||||
|
||||
// Handle preflight requests
|
||||
if (req.method === 'OPTIONS') {
|
||||
return res.status(200).json({ ok: true });
|
||||
}
|
||||
|
||||
// Only accept POST requests
|
||||
if (req.method !== 'POST') {
|
||||
return res.status(405).json({
|
||||
error: 'Method not allowed',
|
||||
allowed: ['POST']
|
||||
});
|
||||
}
|
||||
|
||||
try {
|
||||
// Validate request body
|
||||
const { type, name, path, category, cliVersion } = req.body;
|
||||
const validation = validateComponentData({ type, name, path, category });
|
||||
|
||||
if (!validation.valid) {
|
||||
return res.status(400).json({ error: validation.error });
|
||||
}
|
||||
|
||||
// Get client information
|
||||
const ipAddress = getClientIP(req);
|
||||
const country = getCountry(req);
|
||||
const userAgent = req.headers['user-agent'];
|
||||
|
||||
// Initialize Supabase client
|
||||
const supabase = getSupabaseClient();
|
||||
|
||||
// Insert download record
|
||||
const { data: insertData, error: insertError } = await supabase
|
||||
.from('component_downloads')
|
||||
.insert({
|
||||
component_type: type,
|
||||
component_name: name,
|
||||
component_path: path,
|
||||
category: category,
|
||||
user_agent: userAgent,
|
||||
ip_address: ipAddress,
|
||||
country: country,
|
||||
cli_version: cliVersion,
|
||||
download_timestamp: new Date().toISOString(),
|
||||
created_at: new Date().toISOString()
|
||||
});
|
||||
|
||||
if (insertError) {
|
||||
console.error('Supabase insert error:', insertError);
|
||||
throw new Error(`Database insert failed: ${insertError.message}`);
|
||||
}
|
||||
|
||||
// Update aggregated stats (upsert)
|
||||
const { error: upsertError } = await supabase
|
||||
.from('download_stats')
|
||||
.upsert(
|
||||
{
|
||||
component_type: type,
|
||||
component_name: name,
|
||||
total_downloads: 1,
|
||||
last_download: new Date().toISOString(),
|
||||
updated_at: new Date().toISOString()
|
||||
},
|
||||
{
|
||||
onConflict: 'component_type,component_name',
|
||||
ignoreDuplicates: false
|
||||
}
|
||||
);
|
||||
|
||||
if (upsertError) {
|
||||
console.error('Supabase upsert error:', upsertError);
|
||||
// Don't fail the request for stats update errors
|
||||
}
|
||||
|
||||
// Return success response
|
||||
res.status(200).json({
|
||||
success: true,
|
||||
message: 'Download tracked successfully',
|
||||
data: {
|
||||
type,
|
||||
name,
|
||||
timestamp: new Date().toISOString()
|
||||
}
|
||||
});
|
||||
|
||||
} catch (error) {
|
||||
console.error('Download tracking error:', error);
|
||||
|
||||
// Return error response
|
||||
res.status(500).json({
|
||||
error: 'Internal server error',
|
||||
message: 'Failed to track download',
|
||||
details: process.env.NODE_ENV === 'development' ? error.message : undefined
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,108 @@
|
||||
// Installation Outcome Tracking API Endpoint - Neon Database
|
||||
// Tracks CLI installation results (success/failure) for reliability analytics
|
||||
|
||||
import { neon } from '@neondatabase/serverless';
|
||||
|
||||
function getNeonClient() {
|
||||
const connectionString = process.env.NEON_DATABASE_URL;
|
||||
if (!connectionString) {
|
||||
throw new Error('NEON_DATABASE_URL not configured');
|
||||
}
|
||||
return neon(connectionString);
|
||||
}
|
||||
|
||||
function validateOutcomeData(data) {
|
||||
const { componentType, componentName, outcome } = data;
|
||||
|
||||
if (!componentType || !componentName || !outcome) {
|
||||
return { valid: false, error: 'componentType, componentName, and outcome are required' };
|
||||
}
|
||||
|
||||
const validTypes = ['agent', 'command', 'mcp', 'setting', 'hook', 'skill', 'template'];
|
||||
if (!validTypes.includes(componentType)) {
|
||||
return { valid: false, error: 'Invalid component type' };
|
||||
}
|
||||
|
||||
const validOutcomes = ['success', 'failure', 'partial'];
|
||||
if (!validOutcomes.includes(outcome)) {
|
||||
return { valid: false, error: 'Invalid outcome. Must be: success, failure, or partial' };
|
||||
}
|
||||
|
||||
if (componentName.length > 255) {
|
||||
return { valid: false, error: 'Component name too long' };
|
||||
}
|
||||
|
||||
return { valid: true };
|
||||
}
|
||||
|
||||
export default async function handler(req, res) {
|
||||
// CORS headers
|
||||
res.setHeader('Access-Control-Allow-Origin', '*');
|
||||
res.setHeader('Access-Control-Allow-Methods', 'POST, OPTIONS');
|
||||
res.setHeader('Access-Control-Allow-Headers', 'Content-Type, User-Agent');
|
||||
|
||||
if (req.method === 'OPTIONS') {
|
||||
return res.status(200).json({ ok: true });
|
||||
}
|
||||
|
||||
if (req.method !== 'POST') {
|
||||
return res.status(405).json({ error: 'Method not allowed', allowed: ['POST'] });
|
||||
}
|
||||
|
||||
try {
|
||||
const {
|
||||
componentType,
|
||||
componentName,
|
||||
outcome,
|
||||
errorType,
|
||||
errorMessage,
|
||||
durationMs,
|
||||
cliVersion,
|
||||
nodeVersion,
|
||||
platform,
|
||||
arch,
|
||||
batchId
|
||||
} = req.body;
|
||||
|
||||
const validation = validateOutcomeData({ componentType, componentName, outcome });
|
||||
if (!validation.valid) {
|
||||
return res.status(400).json({ error: validation.error });
|
||||
}
|
||||
|
||||
const sql = getNeonClient();
|
||||
|
||||
await sql`
|
||||
INSERT INTO installation_outcomes (
|
||||
component_type, component_name, outcome,
|
||||
error_type, error_message, duration_ms,
|
||||
cli_version, node_version, platform, arch, batch_id
|
||||
) VALUES (
|
||||
${componentType},
|
||||
${componentName},
|
||||
${outcome},
|
||||
${errorType || null},
|
||||
${errorMessage ? errorMessage.substring(0, 1000) : null},
|
||||
${durationMs || null},
|
||||
${cliVersion || 'unknown'},
|
||||
${nodeVersion || 'unknown'},
|
||||
${platform || 'unknown'},
|
||||
${arch || 'unknown'},
|
||||
${batchId || null}
|
||||
)
|
||||
`;
|
||||
|
||||
res.status(200).json({
|
||||
success: true,
|
||||
message: 'Installation outcome tracked',
|
||||
data: { componentType, componentName, outcome, timestamp: new Date().toISOString() }
|
||||
});
|
||||
|
||||
} catch (error) {
|
||||
console.error('Installation outcome tracking error:', error);
|
||||
res.status(500).json({
|
||||
error: 'Internal server error',
|
||||
message: 'Failed to track installation outcome',
|
||||
details: process.env.NODE_ENV === 'development' ? error.message : undefined
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,114 @@
|
||||
// Website Events Tracking API Endpoint - Neon Database
|
||||
// Tracks search, cart, and component view events from the website
|
||||
|
||||
import { neon } from '@neondatabase/serverless';
|
||||
|
||||
function getNeonClient() {
|
||||
const connectionString = process.env.NEON_DATABASE_URL;
|
||||
if (!connectionString) {
|
||||
throw new Error('NEON_DATABASE_URL not configured');
|
||||
}
|
||||
return neon(connectionString);
|
||||
}
|
||||
|
||||
const VALID_EVENT_TYPES = [
|
||||
'search',
|
||||
'cart_add',
|
||||
'cart_remove',
|
||||
'cart_checkout',
|
||||
'component_view',
|
||||
'copy_command'
|
||||
];
|
||||
|
||||
const MAX_EVENTS_PER_BATCH = 50;
|
||||
|
||||
function validateEventsData(data) {
|
||||
const { events } = data;
|
||||
|
||||
if (!events || !Array.isArray(events) || events.length === 0) {
|
||||
return { valid: false, error: 'events array is required and must not be empty' };
|
||||
}
|
||||
|
||||
if (events.length > MAX_EVENTS_PER_BATCH) {
|
||||
return { valid: false, error: `Maximum ${MAX_EVENTS_PER_BATCH} events per batch` };
|
||||
}
|
||||
|
||||
for (const event of events) {
|
||||
if (!event.event_type || !VALID_EVENT_TYPES.includes(event.event_type)) {
|
||||
return { valid: false, error: `Invalid event_type: ${event.event_type}` };
|
||||
}
|
||||
}
|
||||
|
||||
return { valid: true };
|
||||
}
|
||||
|
||||
export default async function handler(req, res) {
|
||||
// CORS headers
|
||||
res.setHeader('Access-Control-Allow-Origin', '*');
|
||||
res.setHeader('Access-Control-Allow-Methods', 'POST, OPTIONS');
|
||||
res.setHeader('Access-Control-Allow-Headers', 'Content-Type');
|
||||
|
||||
if (req.method === 'OPTIONS') {
|
||||
return res.status(200).json({ ok: true });
|
||||
}
|
||||
|
||||
if (req.method !== 'POST') {
|
||||
return res.status(405).json({ error: 'Method not allowed', allowed: ['POST'] });
|
||||
}
|
||||
|
||||
try {
|
||||
const {
|
||||
events,
|
||||
session_id,
|
||||
visitor_id,
|
||||
screen_width,
|
||||
referrer
|
||||
} = req.body;
|
||||
|
||||
const validation = validateEventsData({ events });
|
||||
if (!validation.valid) {
|
||||
return res.status(400).json({ error: validation.error });
|
||||
}
|
||||
|
||||
// Extract country from Vercel header
|
||||
const country = req.headers['x-vercel-ip-country'] || null;
|
||||
|
||||
const sql = getNeonClient();
|
||||
|
||||
// Insert each event
|
||||
let inserted = 0;
|
||||
for (const event of events) {
|
||||
await sql`
|
||||
INSERT INTO website_events (
|
||||
event_type, event_data, page_path,
|
||||
referrer, session_id, visitor_id,
|
||||
country, screen_width
|
||||
) VALUES (
|
||||
${event.event_type},
|
||||
${event.event_data ? JSON.stringify(event.event_data) : null},
|
||||
${event.page_path || null},
|
||||
${referrer ? referrer.substring(0, 1000) : null},
|
||||
${session_id || null},
|
||||
${visitor_id || null},
|
||||
${country},
|
||||
${screen_width || null}
|
||||
)
|
||||
`;
|
||||
inserted++;
|
||||
}
|
||||
|
||||
res.status(200).json({
|
||||
success: true,
|
||||
message: `${inserted} events tracked`,
|
||||
data: { count: inserted, timestamp: new Date().toISOString() }
|
||||
});
|
||||
|
||||
} catch (error) {
|
||||
console.error('Website events tracking error:', error);
|
||||
res.status(500).json({
|
||||
error: 'Internal server error',
|
||||
message: 'Failed to track website events',
|
||||
details: process.env.NODE_ENV === 'development' ? error.message : undefined
|
||||
});
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user