diff --git a/packages/atxp/src/commands/paas/analytics.test.ts b/packages/atxp/src/commands/paas/analytics.test.ts new file mode 100644 index 0000000..45048ca --- /dev/null +++ b/packages/atxp/src/commands/paas/analytics.test.ts @@ -0,0 +1,150 @@ +import { describe, it, expect, vi, beforeEach } from 'vitest'; + +// Mock the call-tool module +vi.mock('../../call-tool.js', () => ({ + callTool: vi.fn(), +})); + +import { callTool } from '../../call-tool.js'; +import { + analyticsQueryCommand, + analyticsEventsCommand, + analyticsStatsCommand, +} from './analytics.js'; + +describe('Analytics Commands', () => { + beforeEach(() => { + vi.clearAllMocks(); + vi.spyOn(console, 'log').mockImplementation(() => {}); + vi.spyOn(console, 'error').mockImplementation(() => {}); + vi.spyOn(process, 'exit').mockImplementation(() => { + throw new Error('process.exit called'); + }); + }); + + describe('analyticsQueryCommand', () => { + it('should execute an analytics query', async () => { + vi.mocked(callTool).mockResolvedValue('{"success": true, "data": []}'); + + await analyticsQueryCommand({ sql: 'SELECT COUNT(*) FROM analytics_data' }); + + expect(callTool).toHaveBeenCalledWith('paas.mcp.atxp.ai', 'query_analytics', { + sql: 'SELECT COUNT(*) FROM analytics_data', + }); + }); + + it('should pass time range option', async () => { + vi.mocked(callTool).mockResolvedValue('{"success": true, "data": []}'); + + await analyticsQueryCommand({ + sql: 'SELECT COUNT(*) FROM analytics_data', + range: '7d', + }); + + expect(callTool).toHaveBeenCalledWith('paas.mcp.atxp.ai', 'query_analytics', { + sql: 'SELECT COUNT(*) FROM analytics_data', + time_range: '7d', + }); + }); + + it('should exit with error when sql is missing', async () => { + await expect(analyticsQueryCommand({})).rejects.toThrow('process.exit called'); + expect(console.error).toHaveBeenCalled(); + }); + + it('should exit with error for invalid time range', async () => { + await expect( + analyticsQueryCommand({ + sql: 'SELECT * FROM analytics_data', + range: 'invalid', + }) + ).rejects.toThrow('process.exit called'); + expect(console.error).toHaveBeenCalled(); + }); + }); + + describe('analyticsEventsCommand', () => { + it('should list analytics events', async () => { + vi.mocked(callTool).mockResolvedValue('{"success": true, "events": []}'); + + await analyticsEventsCommand({}); + + expect(callTool).toHaveBeenCalledWith('paas.mcp.atxp.ai', 'list_analytics_events', {}); + }); + + it('should filter by event name', async () => { + vi.mocked(callTool).mockResolvedValue('{"success": true, "events": []}'); + + await analyticsEventsCommand({ event: 'page_view' }); + + expect(callTool).toHaveBeenCalledWith('paas.mcp.atxp.ai', 'list_analytics_events', { + event_name: 'page_view', + }); + }); + + it('should pass limit and time range options', async () => { + vi.mocked(callTool).mockResolvedValue('{"success": true, "events": []}'); + + await analyticsEventsCommand({ + limit: 50, + range: '24h', + }); + + expect(callTool).toHaveBeenCalledWith('paas.mcp.atxp.ai', 'list_analytics_events', { + limit: 50, + time_range: '24h', + }); + }); + + it('should exit with error for invalid time range', async () => { + await expect(analyticsEventsCommand({ range: 'invalid' })).rejects.toThrow( + 'process.exit called' + ); + expect(console.error).toHaveBeenCalled(); + }); + }); + + describe('analyticsStatsCommand', () => { + it('should get analytics stats', async () => { + vi.mocked(callTool).mockResolvedValue('{"success": true, "stats": []}'); + + await analyticsStatsCommand({}); + + expect(callTool).toHaveBeenCalledWith('paas.mcp.atxp.ai', 'get_analytics_stats', {}); + }); + + it('should pass group by option', async () => { + vi.mocked(callTool).mockResolvedValue('{"success": true, "stats": []}'); + + await analyticsStatsCommand({ groupBy: 'hour' }); + + expect(callTool).toHaveBeenCalledWith('paas.mcp.atxp.ai', 'get_analytics_stats', { + group_by: 'hour', + }); + }); + + it('should pass time range option', async () => { + vi.mocked(callTool).mockResolvedValue('{"success": true, "stats": []}'); + + await analyticsStatsCommand({ range: '30d' }); + + expect(callTool).toHaveBeenCalledWith('paas.mcp.atxp.ai', 'get_analytics_stats', { + time_range: '30d', + }); + }); + + it('should exit with error for invalid group by value', async () => { + await expect(analyticsStatsCommand({ groupBy: 'invalid' })).rejects.toThrow( + 'process.exit called' + ); + expect(console.error).toHaveBeenCalled(); + }); + + it('should exit with error for invalid time range', async () => { + await expect(analyticsStatsCommand({ range: 'invalid' })).rejects.toThrow( + 'process.exit called' + ); + expect(console.error).toHaveBeenCalled(); + }); + }); +}); diff --git a/packages/atxp/src/commands/paas/analytics.ts b/packages/atxp/src/commands/paas/analytics.ts new file mode 100644 index 0000000..b7f032a --- /dev/null +++ b/packages/atxp/src/commands/paas/analytics.ts @@ -0,0 +1,92 @@ +import { callTool } from '../../call-tool.js'; +import chalk from 'chalk'; + +const SERVER = 'paas.mcp.atxp.ai'; + +interface AnalyticsQueryOptions { + sql?: string; + range?: string; +} + +interface AnalyticsEventsOptions { + event?: string; + limit?: number; + range?: string; +} + +interface AnalyticsStatsOptions { + groupBy?: string; + range?: string; +} + +export async function analyticsQueryCommand(options: AnalyticsQueryOptions): Promise { + if (!options.sql) { + console.error(chalk.red('Error: --sql flag is required')); + console.log(`Usage: ${chalk.cyan('npx atxp paas analytics query --sql ')}`); + console.log(); + console.log('Example queries:'); + console.log(' --sql "SELECT blob1 as event, COUNT(*) as count FROM analytics_data GROUP BY blob1"'); + console.log(' --sql "SELECT SUM(double1) as total FROM analytics_data"'); + process.exit(1); + } + + const args: Record = { sql: options.sql }; + + if (options.range) { + const validRanges = ['1h', '6h', '24h', '7d', '30d']; + if (!validRanges.includes(options.range)) { + console.error(chalk.red(`Error: Invalid time range. Must be one of: ${validRanges.join(', ')}`)); + process.exit(1); + } + args.time_range = options.range; + } + + const result = await callTool(SERVER, 'query_analytics', args); + console.log(result); +} + +export async function analyticsEventsCommand(options: AnalyticsEventsOptions): Promise { + const args: Record = {}; + + if (options.event) { + args.event_name = options.event; + } + if (options.limit !== undefined) { + args.limit = options.limit; + } + if (options.range) { + const validRanges = ['1h', '6h', '24h', '7d', '30d']; + if (!validRanges.includes(options.range)) { + console.error(chalk.red(`Error: Invalid time range. Must be one of: ${validRanges.join(', ')}`)); + process.exit(1); + } + args.time_range = options.range; + } + + const result = await callTool(SERVER, 'list_analytics_events', args); + console.log(result); +} + +export async function analyticsStatsCommand(options: AnalyticsStatsOptions): Promise { + const args: Record = {}; + + if (options.groupBy) { + const validGroupBy = ['event_name', 'hour', 'day']; + if (!validGroupBy.includes(options.groupBy)) { + console.error(chalk.red(`Error: Invalid group-by value. Must be one of: ${validGroupBy.join(', ')}`)); + process.exit(1); + } + args.group_by = options.groupBy; + } + if (options.range) { + const validRanges = ['1h', '6h', '24h', '7d', '30d']; + if (!validRanges.includes(options.range)) { + console.error(chalk.red(`Error: Invalid time range. Must be one of: ${validRanges.join(', ')}`)); + process.exit(1); + } + args.time_range = options.range; + } + + const result = await callTool(SERVER, 'get_analytics_stats', args); + console.log(result); +} diff --git a/packages/atxp/src/commands/paas/db.test.ts b/packages/atxp/src/commands/paas/db.test.ts new file mode 100644 index 0000000..e690dcd --- /dev/null +++ b/packages/atxp/src/commands/paas/db.test.ts @@ -0,0 +1,102 @@ +import { describe, it, expect, vi, beforeEach } from 'vitest'; + +// Mock the call-tool module +vi.mock('../../call-tool.js', () => ({ + callTool: vi.fn(), +})); + +import { callTool } from '../../call-tool.js'; +import { + dbCreateCommand, + dbListCommand, + dbQueryCommand, + dbDeleteCommand, +} from './db.js'; + +describe('Database Commands', () => { + beforeEach(() => { + vi.clearAllMocks(); + vi.spyOn(console, 'log').mockImplementation(() => {}); + vi.spyOn(console, 'error').mockImplementation(() => {}); + vi.spyOn(process, 'exit').mockImplementation(() => { + throw new Error('process.exit called'); + }); + }); + + describe('dbCreateCommand', () => { + it('should create a database', async () => { + vi.mocked(callTool).mockResolvedValue('{"success": true}'); + + await dbCreateCommand('my-database'); + + expect(callTool).toHaveBeenCalledWith('paas.mcp.atxp.ai', 'create_database', { + name: 'my-database', + }); + }); + }); + + describe('dbListCommand', () => { + it('should list all databases', async () => { + vi.mocked(callTool).mockResolvedValue('{"success": true, "databases": []}'); + + await dbListCommand(); + + expect(callTool).toHaveBeenCalledWith('paas.mcp.atxp.ai', 'list_databases', {}); + }); + }); + + describe('dbQueryCommand', () => { + it('should execute a SQL query', async () => { + vi.mocked(callTool).mockResolvedValue('{"success": true, "results": []}'); + + await dbQueryCommand('my-database', { sql: 'SELECT * FROM users' }); + + expect(callTool).toHaveBeenCalledWith('paas.mcp.atxp.ai', 'query', { + database: 'my-database', + sql: 'SELECT * FROM users', + }); + }); + + it('should pass params when provided as JSON', async () => { + vi.mocked(callTool).mockResolvedValue('{"success": true, "results": []}'); + + await dbQueryCommand('my-database', { + sql: 'SELECT * FROM users WHERE id = ?', + params: '["123"]', + }); + + expect(callTool).toHaveBeenCalledWith('paas.mcp.atxp.ai', 'query', { + database: 'my-database', + sql: 'SELECT * FROM users WHERE id = ?', + params: ['123'], + }); + }); + + it('should exit with error when sql flag is missing', async () => { + await expect(dbQueryCommand('my-database', {})).rejects.toThrow('process.exit called'); + expect(console.error).toHaveBeenCalled(); + }); + + it('should exit with error when params is invalid JSON', async () => { + await expect( + dbQueryCommand('my-database', { + sql: 'SELECT * FROM users', + params: 'invalid-json', + }) + ).rejects.toThrow('process.exit called'); + expect(console.error).toHaveBeenCalled(); + }); + }); + + describe('dbDeleteCommand', () => { + it('should delete a database', async () => { + vi.mocked(callTool).mockResolvedValue('{"success": true}'); + + await dbDeleteCommand('my-database'); + + expect(callTool).toHaveBeenCalledWith('paas.mcp.atxp.ai', 'delete_database', { + name: 'my-database', + }); + }); + }); +}); diff --git a/packages/atxp/src/commands/paas/db.ts b/packages/atxp/src/commands/paas/db.ts new file mode 100644 index 0000000..e3a7c1d --- /dev/null +++ b/packages/atxp/src/commands/paas/db.ts @@ -0,0 +1,53 @@ +import { callTool } from '../../call-tool.js'; +import chalk from 'chalk'; + +const SERVER = 'paas.mcp.atxp.ai'; + +interface DbQueryOptions { + sql?: string; + params?: string; +} + +export async function dbCreateCommand(name: string): Promise { + const result = await callTool(SERVER, 'create_database', { name }); + console.log(result); +} + +export async function dbListCommand(): Promise { + const result = await callTool(SERVER, 'list_databases', {}); + console.log(result); +} + +export async function dbQueryCommand( + database: string, + options: DbQueryOptions +): Promise { + if (!options.sql) { + console.error(chalk.red('Error: --sql flag is required')); + console.log(`Usage: ${chalk.cyan('npx atxp paas db query --sql ')}`); + process.exit(1); + } + + const args: Record = { + database, + sql: options.sql, + }; + + if (options.params) { + try { + args.params = JSON.parse(options.params); + } catch { + console.error(chalk.red('Error: --params must be valid JSON array')); + console.log(`Example: ${chalk.cyan('--params \'["value1", "value2"]\'')}`); + process.exit(1); + } + } + + const result = await callTool(SERVER, 'query', args); + console.log(result); +} + +export async function dbDeleteCommand(name: string): Promise { + const result = await callTool(SERVER, 'delete_database', { name }); + console.log(result); +} diff --git a/packages/atxp/src/commands/paas/dns.test.ts b/packages/atxp/src/commands/paas/dns.test.ts new file mode 100644 index 0000000..111570d --- /dev/null +++ b/packages/atxp/src/commands/paas/dns.test.ts @@ -0,0 +1,218 @@ +import { describe, it, expect, vi, beforeEach } from 'vitest'; + +// Mock the call-tool module +vi.mock('../../call-tool.js', () => ({ + callTool: vi.fn(), +})); + +import { callTool } from '../../call-tool.js'; +import { + dnsAddCommand, + dnsListCommand, + dnsRecordCreateCommand, + dnsRecordListCommand, + dnsRecordDeleteCommand, + dnsConnectCommand, +} from './dns.js'; + +describe('DNS Commands', () => { + beforeEach(() => { + vi.clearAllMocks(); + vi.spyOn(console, 'log').mockImplementation(() => {}); + vi.spyOn(console, 'error').mockImplementation(() => {}); + vi.spyOn(process, 'exit').mockImplementation(() => { + throw new Error('process.exit called'); + }); + }); + + describe('dnsAddCommand', () => { + it('should add a domain', async () => { + vi.mocked(callTool).mockResolvedValue('{"success": true}'); + + await dnsAddCommand('example.com'); + + expect(callTool).toHaveBeenCalledWith('paas.mcp.atxp.ai', 'add_domain', { + domain: 'example.com', + }); + }); + }); + + describe('dnsListCommand', () => { + it('should list all domains', async () => { + vi.mocked(callTool).mockResolvedValue('{"success": true, "domains": []}'); + + await dnsListCommand(); + + expect(callTool).toHaveBeenCalledWith('paas.mcp.atxp.ai', 'list_domains', {}); + }); + }); + + describe('dnsRecordCreateCommand', () => { + it('should create an A record', async () => { + vi.mocked(callTool).mockResolvedValue('{"success": true}'); + + await dnsRecordCreateCommand('example.com', { + type: 'A', + name: 'www', + content: '192.168.1.1', + }); + + expect(callTool).toHaveBeenCalledWith('paas.mcp.atxp.ai', 'create_dns_record', { + domain: 'example.com', + type: 'A', + name: 'www', + content: '192.168.1.1', + }); + }); + + it('should create a CNAME record with optional fields', async () => { + vi.mocked(callTool).mockResolvedValue('{"success": true}'); + + await dnsRecordCreateCommand('example.com', { + type: 'CNAME', + name: 'blog', + content: 'example.com', + ttl: 3600, + proxied: true, + }); + + expect(callTool).toHaveBeenCalledWith('paas.mcp.atxp.ai', 'create_dns_record', { + domain: 'example.com', + type: 'CNAME', + name: 'blog', + content: 'example.com', + ttl: 3600, + proxied: true, + }); + }); + + it('should create an MX record with priority', async () => { + vi.mocked(callTool).mockResolvedValue('{"success": true}'); + + await dnsRecordCreateCommand('example.com', { + type: 'MX', + name: '@', + content: 'mail.example.com', + priority: 10, + }); + + expect(callTool).toHaveBeenCalledWith('paas.mcp.atxp.ai', 'create_dns_record', { + domain: 'example.com', + type: 'MX', + name: '@', + content: 'mail.example.com', + priority: 10, + }); + }); + + it('should normalize record type to uppercase', async () => { + vi.mocked(callTool).mockResolvedValue('{"success": true}'); + + await dnsRecordCreateCommand('example.com', { + type: 'txt', + name: '@', + content: 'v=spf1 include:_spf.example.com ~all', + }); + + expect(callTool).toHaveBeenCalledWith('paas.mcp.atxp.ai', 'create_dns_record', { + domain: 'example.com', + type: 'TXT', + name: '@', + content: 'v=spf1 include:_spf.example.com ~all', + }); + }); + + it('should exit with error when type is missing', async () => { + await expect( + dnsRecordCreateCommand('example.com', { name: 'www', content: '1.2.3.4' }) + ).rejects.toThrow('process.exit called'); + expect(console.error).toHaveBeenCalled(); + }); + + it('should exit with error when name is missing', async () => { + await expect( + dnsRecordCreateCommand('example.com', { type: 'A', content: '1.2.3.4' }) + ).rejects.toThrow('process.exit called'); + expect(console.error).toHaveBeenCalled(); + }); + + it('should exit with error when content is missing', async () => { + await expect( + dnsRecordCreateCommand('example.com', { type: 'A', name: 'www' }) + ).rejects.toThrow('process.exit called'); + expect(console.error).toHaveBeenCalled(); + }); + + it('should exit with error for invalid record type', async () => { + await expect( + dnsRecordCreateCommand('example.com', { + type: 'INVALID', + name: 'www', + content: '1.2.3.4', + }) + ).rejects.toThrow('process.exit called'); + expect(console.error).toHaveBeenCalled(); + }); + }); + + describe('dnsRecordListCommand', () => { + it('should list all DNS records', async () => { + vi.mocked(callTool).mockResolvedValue('{"success": true, "records": []}'); + + await dnsRecordListCommand('example.com', {}); + + expect(callTool).toHaveBeenCalledWith('paas.mcp.atxp.ai', 'list_dns_records', { + domain: 'example.com', + }); + }); + + it('should filter by record type', async () => { + vi.mocked(callTool).mockResolvedValue('{"success": true, "records": []}'); + + await dnsRecordListCommand('example.com', { type: 'a' }); + + expect(callTool).toHaveBeenCalledWith('paas.mcp.atxp.ai', 'list_dns_records', { + domain: 'example.com', + type: 'A', + }); + }); + }); + + describe('dnsRecordDeleteCommand', () => { + it('should delete a DNS record', async () => { + vi.mocked(callTool).mockResolvedValue('{"success": true}'); + + await dnsRecordDeleteCommand('example.com', 'record-123'); + + expect(callTool).toHaveBeenCalledWith('paas.mcp.atxp.ai', 'delete_dns_record', { + domain: 'example.com', + record_id: 'record-123', + }); + }); + }); + + describe('dnsConnectCommand', () => { + it('should connect a domain to a worker', async () => { + vi.mocked(callTool).mockResolvedValue('{"success": true}'); + + await dnsConnectCommand('example.com', 'my-worker', {}); + + expect(callTool).toHaveBeenCalledWith('paas.mcp.atxp.ai', 'connect_domain_to_worker', { + domain: 'example.com', + worker_name: 'my-worker', + }); + }); + + it('should connect with subdomain', async () => { + vi.mocked(callTool).mockResolvedValue('{"success": true}'); + + await dnsConnectCommand('example.com', 'my-api', { subdomain: 'api' }); + + expect(callTool).toHaveBeenCalledWith('paas.mcp.atxp.ai', 'connect_domain_to_worker', { + domain: 'example.com', + worker_name: 'my-api', + subdomain: 'api', + }); + }); + }); +}); diff --git a/packages/atxp/src/commands/paas/dns.ts b/packages/atxp/src/commands/paas/dns.ts new file mode 100644 index 0000000..52d6178 --- /dev/null +++ b/packages/atxp/src/commands/paas/dns.ts @@ -0,0 +1,123 @@ +import { callTool } from '../../call-tool.js'; +import chalk from 'chalk'; + +const SERVER = 'paas.mcp.atxp.ai'; + +interface DnsRecordCreateOptions { + type?: string; + name?: string; + content?: string; + ttl?: number; + proxied?: boolean; + priority?: number; +} + +interface DnsRecordListOptions { + type?: string; +} + +interface DnsConnectOptions { + subdomain?: string; +} + +export async function dnsAddCommand(domain: string): Promise { + const result = await callTool(SERVER, 'add_domain', { domain }); + console.log(result); +} + +export async function dnsListCommand(): Promise { + const result = await callTool(SERVER, 'list_domains', {}); + console.log(result); +} + +export async function dnsRecordCreateCommand( + domain: string, + options: DnsRecordCreateOptions +): Promise { + if (!options.type) { + console.error(chalk.red('Error: --type flag is required')); + console.log(`Usage: ${chalk.cyan('npx atxp paas dns record create --type A --name www --content ')}`); + process.exit(1); + } + + if (!options.name) { + console.error(chalk.red('Error: --name flag is required')); + console.log(`Usage: ${chalk.cyan('npx atxp paas dns record create --type A --name www --content ')}`); + process.exit(1); + } + + if (!options.content) { + console.error(chalk.red('Error: --content flag is required')); + console.log(`Usage: ${chalk.cyan('npx atxp paas dns record create --type A --name www --content ')}`); + process.exit(1); + } + + const validTypes = ['A', 'AAAA', 'CNAME', 'TXT', 'MX']; + if (!validTypes.includes(options.type.toUpperCase())) { + console.error(chalk.red(`Error: Invalid record type. Must be one of: ${validTypes.join(', ')}`)); + process.exit(1); + } + + const args: Record = { + domain, + type: options.type.toUpperCase(), + name: options.name, + content: options.content, + }; + + if (options.ttl !== undefined) { + args.ttl = options.ttl; + } + if (options.proxied !== undefined) { + args.proxied = options.proxied; + } + if (options.priority !== undefined) { + args.priority = options.priority; + } + + const result = await callTool(SERVER, 'create_dns_record', args); + console.log(result); +} + +export async function dnsRecordListCommand( + domain: string, + options: DnsRecordListOptions +): Promise { + const args: Record = { domain }; + + if (options.type) { + args.type = options.type.toUpperCase(); + } + + const result = await callTool(SERVER, 'list_dns_records', args); + console.log(result); +} + +export async function dnsRecordDeleteCommand( + domain: string, + recordId: string +): Promise { + const result = await callTool(SERVER, 'delete_dns_record', { + domain, + record_id: recordId, + }); + console.log(result); +} + +export async function dnsConnectCommand( + domain: string, + workerName: string, + options: DnsConnectOptions +): Promise { + const args: Record = { + domain, + worker_name: workerName, + }; + + if (options.subdomain) { + args.subdomain = options.subdomain; + } + + const result = await callTool(SERVER, 'connect_domain_to_worker', args); + console.log(result); +} diff --git a/packages/atxp/src/commands/paas/index.ts b/packages/atxp/src/commands/paas/index.ts new file mode 100644 index 0000000..6ce6e94 --- /dev/null +++ b/packages/atxp/src/commands/paas/index.ts @@ -0,0 +1,465 @@ +import chalk from 'chalk'; +import { + workerDeployCommand, + workerListCommand, + workerLogsCommand, + workerDeleteCommand, +} from './worker.js'; +import { + dbCreateCommand, + dbListCommand, + dbQueryCommand, + dbDeleteCommand, +} from './db.js'; +import { + storageCreateCommand, + storageListCommand, + storageUploadCommand, + storageDownloadCommand, + storageFilesCommand, + storageDeleteBucketCommand, + storageDeleteFileCommand, +} from './storage.js'; +import { + dnsAddCommand, + dnsListCommand, + dnsRecordCreateCommand, + dnsRecordListCommand, + dnsRecordDeleteCommand, + dnsConnectCommand, +} from './dns.js'; +import { + analyticsQueryCommand, + analyticsEventsCommand, + analyticsStatsCommand, +} from './analytics.js'; + +interface PaasOptions { + code?: string; + db?: string[]; + bucket?: string[]; + limit?: number; + level?: string; + since?: string; + sql?: string; + params?: string; + file?: string; + content?: string; + output?: string; + prefix?: string; + type?: string; + name?: string; + recordContent?: string; + ttl?: number; + proxied?: boolean; + priority?: number; + subdomain?: string; + range?: string; + event?: string; + groupBy?: string; + enableAnalytics?: boolean; +} + +function showPaasHelp(): void { + console.log(chalk.bold('ATXP PAAS Commands')); + console.log(chalk.gray('Deploy workers, databases, storage, and more')); + console.log(); + + console.log(chalk.bold('Worker Commands:')); + console.log(' ' + chalk.cyan('paas worker deploy') + ' ' + chalk.yellow('') + ' Deploy a worker'); + console.log(' ' + chalk.cyan('paas worker list') + ' List all workers'); + console.log(' ' + chalk.cyan('paas worker logs') + ' ' + chalk.yellow('') + ' Get worker logs'); + console.log(' ' + chalk.cyan('paas worker delete') + ' ' + chalk.yellow('') + ' Delete a worker'); + console.log(); + + console.log(chalk.bold('Database Commands:')); + console.log(' ' + chalk.cyan('paas db create') + ' ' + chalk.yellow('') + ' Create a database'); + console.log(' ' + chalk.cyan('paas db list') + ' List all databases'); + console.log(' ' + chalk.cyan('paas db query') + ' ' + chalk.yellow('') + ' Execute SQL query'); + console.log(' ' + chalk.cyan('paas db delete') + ' ' + chalk.yellow('') + ' Delete a database'); + console.log(); + + console.log(chalk.bold('Storage Commands:')); + console.log(' ' + chalk.cyan('paas storage create') + ' ' + chalk.yellow('') + ' Create a bucket'); + console.log(' ' + chalk.cyan('paas storage list') + ' List all buckets'); + console.log(' ' + chalk.cyan('paas storage upload') + ' ' + chalk.yellow(' ') + ' Upload a file'); + console.log(' ' + chalk.cyan('paas storage download') + ' ' + chalk.yellow(' ') + ' Download a file'); + console.log(' ' + chalk.cyan('paas storage files') + ' ' + chalk.yellow('') + ' List files in bucket'); + console.log(' ' + chalk.cyan('paas storage delete-bucket') + ' ' + chalk.yellow('') + ' Delete a bucket'); + console.log(' ' + chalk.cyan('paas storage delete-file') + ' ' + chalk.yellow(' ') + ' Delete a file'); + console.log(); + + console.log(chalk.bold('DNS Commands:')); + console.log(' ' + chalk.cyan('paas dns add') + ' ' + chalk.yellow('') + ' Add a domain'); + console.log(' ' + chalk.cyan('paas dns list') + ' List all domains'); + console.log(' ' + chalk.cyan('paas dns record create') + ' ' + chalk.yellow('') + ' Create DNS record'); + console.log(' ' + chalk.cyan('paas dns record list') + ' ' + chalk.yellow('') + ' List DNS records'); + console.log(' ' + chalk.cyan('paas dns record delete') + ' ' + chalk.yellow(' ') + ' Delete DNS record'); + console.log(' ' + chalk.cyan('paas dns connect') + ' ' + chalk.yellow(' ') + ' Connect domain to worker'); + console.log(); + + console.log(chalk.bold('Analytics Commands:')); + console.log(' ' + chalk.cyan('paas analytics query') + ' Query analytics data'); + console.log(' ' + chalk.cyan('paas analytics events') + ' List analytics events'); + console.log(' ' + chalk.cyan('paas analytics stats') + ' Get analytics statistics'); + console.log(); + + console.log(chalk.bold('Examples:')); + console.log(' npx atxp paas worker deploy my-api --code ./worker.js'); + console.log(' npx atxp paas db create my-database'); + console.log(' npx atxp paas db query my-database --sql "SELECT * FROM users"'); + console.log(' npx atxp paas storage upload my-bucket images/logo.png --file ./logo.png'); + console.log(' npx atxp paas dns add example.com'); + console.log(' npx atxp paas dns connect example.com my-api'); +} + +export async function paasCommand(args: string[], options: PaasOptions): Promise { + const category = args[0]; + const subCommand = args[1]; + const restArgs = args.slice(2); + + if (!category || category === 'help') { + showPaasHelp(); + return; + } + + switch (category) { + case 'worker': + await handleWorkerCommand(subCommand, restArgs, options); + break; + + case 'db': + await handleDbCommand(subCommand, restArgs, options); + break; + + case 'storage': + await handleStorageCommand(subCommand, restArgs, options); + break; + + case 'dns': + await handleDnsCommand(subCommand, restArgs, options); + break; + + case 'analytics': + await handleAnalyticsCommand(subCommand, restArgs, options); + break; + + default: + console.error(chalk.red(`Unknown PAAS category: ${category}`)); + console.log('Available categories: worker, db, storage, dns, analytics'); + console.log(`Run ${chalk.cyan('npx atxp paas help')} for usage information.`); + process.exit(1); + } +} + +async function handleWorkerCommand( + subCommand: string, + args: string[], + options: PaasOptions +): Promise { + const name = args[0]; + + switch (subCommand) { + case 'deploy': + if (!name) { + console.error(chalk.red('Error: Worker name is required')); + console.log(`Usage: ${chalk.cyan('npx atxp paas worker deploy --code ')}`); + process.exit(1); + } + await workerDeployCommand(name, { + code: options.code, + db: options.db, + bucket: options.bucket, + enableAnalytics: options.enableAnalytics, + }); + break; + + case 'list': + await workerListCommand(); + break; + + case 'logs': + if (!name) { + console.error(chalk.red('Error: Worker name is required')); + console.log(`Usage: ${chalk.cyan('npx atxp paas worker logs ')}`); + process.exit(1); + } + await workerLogsCommand(name, { + limit: options.limit, + level: options.level, + since: options.since, + }); + break; + + case 'delete': + if (!name) { + console.error(chalk.red('Error: Worker name is required')); + console.log(`Usage: ${chalk.cyan('npx atxp paas worker delete ')}`); + process.exit(1); + } + await workerDeleteCommand(name); + break; + + default: + console.error(chalk.red(`Unknown worker command: ${subCommand}`)); + console.log('Available commands: deploy, list, logs, delete'); + process.exit(1); + } +} + +async function handleDbCommand( + subCommand: string, + args: string[], + options: PaasOptions +): Promise { + const name = args[0]; + + switch (subCommand) { + case 'create': + if (!name) { + console.error(chalk.red('Error: Database name is required')); + console.log(`Usage: ${chalk.cyan('npx atxp paas db create ')}`); + process.exit(1); + } + await dbCreateCommand(name); + break; + + case 'list': + await dbListCommand(); + break; + + case 'query': + if (!name) { + console.error(chalk.red('Error: Database name is required')); + console.log(`Usage: ${chalk.cyan('npx atxp paas db query --sql ')}`); + process.exit(1); + } + await dbQueryCommand(name, { + sql: options.sql, + params: options.params, + }); + break; + + case 'delete': + if (!name) { + console.error(chalk.red('Error: Database name is required')); + console.log(`Usage: ${chalk.cyan('npx atxp paas db delete ')}`); + process.exit(1); + } + await dbDeleteCommand(name); + break; + + default: + console.error(chalk.red(`Unknown db command: ${subCommand}`)); + console.log('Available commands: create, list, query, delete'); + process.exit(1); + } +} + +async function handleStorageCommand( + subCommand: string, + args: string[], + options: PaasOptions +): Promise { + const bucket = args[0]; + const key = args[1]; + + switch (subCommand) { + case 'create': + if (!bucket) { + console.error(chalk.red('Error: Bucket name is required')); + console.log(`Usage: ${chalk.cyan('npx atxp paas storage create ')}`); + process.exit(1); + } + await storageCreateCommand(bucket); + break; + + case 'list': + await storageListCommand(); + break; + + case 'upload': + if (!bucket || !key) { + console.error(chalk.red('Error: Bucket and key are required')); + console.log(`Usage: ${chalk.cyan('npx atxp paas storage upload --file ')}`); + process.exit(1); + } + await storageUploadCommand(bucket, key, { + file: options.file, + content: options.content, + }); + break; + + case 'download': + if (!bucket || !key) { + console.error(chalk.red('Error: Bucket and key are required')); + console.log(`Usage: ${chalk.cyan('npx atxp paas storage download ')}`); + process.exit(1); + } + await storageDownloadCommand(bucket, key, { + output: options.output, + }); + break; + + case 'files': + if (!bucket) { + console.error(chalk.red('Error: Bucket name is required')); + console.log(`Usage: ${chalk.cyan('npx atxp paas storage files ')}`); + process.exit(1); + } + await storageFilesCommand(bucket, { + prefix: options.prefix, + limit: options.limit, + }); + break; + + case 'delete-bucket': + if (!bucket) { + console.error(chalk.red('Error: Bucket name is required')); + console.log(`Usage: ${chalk.cyan('npx atxp paas storage delete-bucket ')}`); + process.exit(1); + } + await storageDeleteBucketCommand(bucket); + break; + + case 'delete-file': + if (!bucket || !key) { + console.error(chalk.red('Error: Bucket and key are required')); + console.log(`Usage: ${chalk.cyan('npx atxp paas storage delete-file ')}`); + process.exit(1); + } + await storageDeleteFileCommand(bucket, key); + break; + + default: + console.error(chalk.red(`Unknown storage command: ${subCommand}`)); + console.log('Available commands: create, list, upload, download, files, delete-bucket, delete-file'); + process.exit(1); + } +} + +async function handleDnsCommand( + subCommand: string, + args: string[], + options: PaasOptions +): Promise { + const domain = args[0]; + + switch (subCommand) { + case 'add': + if (!domain) { + console.error(chalk.red('Error: Domain name is required')); + console.log(`Usage: ${chalk.cyan('npx atxp paas dns add ')}`); + process.exit(1); + } + await dnsAddCommand(domain); + break; + + case 'list': + await dnsListCommand(); + break; + + case 'record': { + const recordSubCommand = args[0]; + const recordDomain = args[1]; + const recordId = args[2]; + + switch (recordSubCommand) { + case 'create': + if (!recordDomain) { + console.error(chalk.red('Error: Domain name is required')); + console.log(`Usage: ${chalk.cyan('npx atxp paas dns record create --type A --name www --content ')}`); + process.exit(1); + } + await dnsRecordCreateCommand(recordDomain, { + type: options.type, + name: options.name, + content: options.recordContent, + ttl: options.ttl, + proxied: options.proxied, + priority: options.priority, + }); + break; + + case 'list': + if (!recordDomain) { + console.error(chalk.red('Error: Domain name is required')); + console.log(`Usage: ${chalk.cyan('npx atxp paas dns record list ')}`); + process.exit(1); + } + await dnsRecordListCommand(recordDomain, { + type: options.type, + }); + break; + + case 'delete': + if (!recordDomain || !recordId) { + console.error(chalk.red('Error: Domain and record ID are required')); + console.log(`Usage: ${chalk.cyan('npx atxp paas dns record delete ')}`); + process.exit(1); + } + await dnsRecordDeleteCommand(recordDomain, recordId); + break; + + default: + console.error(chalk.red(`Unknown dns record command: ${recordSubCommand}`)); + console.log('Available commands: create, list, delete'); + process.exit(1); + } + return; + } + + case 'connect': { + const workerName = args[1]; + if (!domain || !workerName) { + console.error(chalk.red('Error: Domain and worker name are required')); + console.log(`Usage: ${chalk.cyan('npx atxp paas dns connect ')}`); + process.exit(1); + } + await dnsConnectCommand(domain, workerName, { + subdomain: options.subdomain, + }); + break; + } + + default: + console.error(chalk.red(`Unknown dns command: ${subCommand}`)); + console.log('Available commands: add, list, record, connect'); + process.exit(1); + } +} + +async function handleAnalyticsCommand( + subCommand: string, + _args: string[], + options: PaasOptions +): Promise { + switch (subCommand) { + case 'query': + await analyticsQueryCommand({ + sql: options.sql, + range: options.range, + }); + break; + + case 'events': + await analyticsEventsCommand({ + event: options.event, + limit: options.limit, + range: options.range, + }); + break; + + case 'stats': + await analyticsStatsCommand({ + groupBy: options.groupBy, + range: options.range, + }); + break; + + default: + console.error(chalk.red(`Unknown analytics command: ${subCommand}`)); + console.log('Available commands: query, events, stats'); + process.exit(1); + } +} diff --git a/packages/atxp/src/commands/paas/storage.test.ts b/packages/atxp/src/commands/paas/storage.test.ts new file mode 100644 index 0000000..ac90cd6 --- /dev/null +++ b/packages/atxp/src/commands/paas/storage.test.ts @@ -0,0 +1,215 @@ +import { describe, it, expect, vi, beforeEach } from 'vitest'; +import fs from 'fs'; + +// Mock the call-tool module +vi.mock('../../call-tool.js', () => ({ + callTool: vi.fn(), +})); + +// Mock fs +vi.mock('fs', () => ({ + default: { + existsSync: vi.fn(), + readFileSync: vi.fn(), + writeFileSync: vi.fn(), + }, +})); + +import { callTool } from '../../call-tool.js'; +import { + storageCreateCommand, + storageListCommand, + storageUploadCommand, + storageDownloadCommand, + storageFilesCommand, + storageDeleteBucketCommand, + storageDeleteFileCommand, +} from './storage.js'; + +describe('Storage Commands', () => { + beforeEach(() => { + vi.clearAllMocks(); + vi.spyOn(console, 'log').mockImplementation(() => {}); + vi.spyOn(console, 'error').mockImplementation(() => {}); + vi.spyOn(process, 'exit').mockImplementation(() => { + throw new Error('process.exit called'); + }); + }); + + describe('storageCreateCommand', () => { + it('should create a bucket', async () => { + vi.mocked(callTool).mockResolvedValue('{"success": true}'); + + await storageCreateCommand('my-bucket'); + + expect(callTool).toHaveBeenCalledWith('paas.mcp.atxp.ai', 'create_bucket', { + name: 'my-bucket', + }); + }); + }); + + describe('storageListCommand', () => { + it('should list all buckets', async () => { + vi.mocked(callTool).mockResolvedValue('{"success": true, "buckets": []}'); + + await storageListCommand(); + + expect(callTool).toHaveBeenCalledWith('paas.mcp.atxp.ai', 'list_buckets', {}); + }); + }); + + describe('storageUploadCommand', () => { + it('should upload a text file', async () => { + const mockContent = 'Hello, world!'; + vi.mocked(fs.existsSync).mockReturnValue(true); + vi.mocked(fs.readFileSync).mockReturnValue(Buffer.from(mockContent)); + vi.mocked(callTool).mockResolvedValue('{"success": true}'); + + await storageUploadCommand('my-bucket', 'hello.txt', { file: './hello.txt' }); + + expect(callTool).toHaveBeenCalledWith('paas.mcp.atxp.ai', 'upload_file', { + bucket: 'my-bucket', + key: 'hello.txt', + content: mockContent, + is_base64: false, + content_type: 'text/plain', + }); + }); + + it('should upload a binary file as base64', async () => { + const mockBuffer = Buffer.from([0x89, 0x50, 0x4e, 0x47]); // PNG magic bytes + vi.mocked(fs.existsSync).mockReturnValue(true); + vi.mocked(fs.readFileSync).mockReturnValue(mockBuffer); + vi.mocked(callTool).mockResolvedValue('{"success": true}'); + + await storageUploadCommand('my-bucket', 'image.png', { file: './image.png' }); + + expect(callTool).toHaveBeenCalledWith('paas.mcp.atxp.ai', 'upload_file', { + bucket: 'my-bucket', + key: 'image.png', + content: mockBuffer.toString('base64'), + is_base64: true, + content_type: 'image/png', + }); + }); + + it('should upload content directly', async () => { + vi.mocked(callTool).mockResolvedValue('{"success": true}'); + + await storageUploadCommand('my-bucket', 'data.json', { content: '{"key": "value"}' }); + + expect(callTool).toHaveBeenCalledWith('paas.mcp.atxp.ai', 'upload_file', { + bucket: 'my-bucket', + key: 'data.json', + content: '{"key": "value"}', + is_base64: false, + }); + }); + + it('should exit with error when neither file nor content is provided', async () => { + await expect(storageUploadCommand('my-bucket', 'file.txt', {})).rejects.toThrow( + 'process.exit called' + ); + expect(console.error).toHaveBeenCalled(); + }); + + it('should exit with error when file does not exist', async () => { + vi.mocked(fs.existsSync).mockReturnValue(false); + + await expect( + storageUploadCommand('my-bucket', 'file.txt', { file: './missing.txt' }) + ).rejects.toThrow('process.exit called'); + expect(console.error).toHaveBeenCalled(); + }); + }); + + describe('storageDownloadCommand', () => { + it('should download a file', async () => { + vi.mocked(callTool).mockResolvedValue('{"success": true, "file": {"content": "Hello"}}'); + + await storageDownloadCommand('my-bucket', 'hello.txt', {}); + + expect(callTool).toHaveBeenCalledWith('paas.mcp.atxp.ai', 'get_file', { + bucket: 'my-bucket', + key: 'hello.txt', + }); + }); + + it('should save text file to output path', async () => { + vi.mocked(callTool).mockResolvedValue( + JSON.stringify({ + success: true, + file: { content: 'Hello', is_base64: false }, + }) + ); + + await storageDownloadCommand('my-bucket', 'hello.txt', { output: './output.txt' }); + + expect(fs.writeFileSync).toHaveBeenCalled(); + expect(console.log).toHaveBeenCalled(); + }); + + it('should save binary file to output path', async () => { + const base64Content = Buffer.from('binary data').toString('base64'); + vi.mocked(callTool).mockResolvedValue( + JSON.stringify({ + success: true, + file: { content: base64Content, is_base64: true }, + }) + ); + + await storageDownloadCommand('my-bucket', 'file.bin', { output: './output.bin' }); + + expect(fs.writeFileSync).toHaveBeenCalled(); + }); + }); + + describe('storageFilesCommand', () => { + it('should list files in a bucket', async () => { + vi.mocked(callTool).mockResolvedValue('{"success": true, "files": []}'); + + await storageFilesCommand('my-bucket', {}); + + expect(callTool).toHaveBeenCalledWith('paas.mcp.atxp.ai', 'list_files', { + bucket: 'my-bucket', + }); + }); + + it('should pass prefix and limit options', async () => { + vi.mocked(callTool).mockResolvedValue('{"success": true, "files": []}'); + + await storageFilesCommand('my-bucket', { prefix: 'images/', limit: 50 }); + + expect(callTool).toHaveBeenCalledWith('paas.mcp.atxp.ai', 'list_files', { + bucket: 'my-bucket', + prefix: 'images/', + limit: 50, + }); + }); + }); + + describe('storageDeleteBucketCommand', () => { + it('should delete a bucket', async () => { + vi.mocked(callTool).mockResolvedValue('{"success": true}'); + + await storageDeleteBucketCommand('my-bucket'); + + expect(callTool).toHaveBeenCalledWith('paas.mcp.atxp.ai', 'delete_bucket', { + name: 'my-bucket', + }); + }); + }); + + describe('storageDeleteFileCommand', () => { + it('should delete a file', async () => { + vi.mocked(callTool).mockResolvedValue('{"success": true}'); + + await storageDeleteFileCommand('my-bucket', 'file.txt'); + + expect(callTool).toHaveBeenCalledWith('paas.mcp.atxp.ai', 'delete_file', { + bucket: 'my-bucket', + key: 'file.txt', + }); + }); + }); +}); diff --git a/packages/atxp/src/commands/paas/storage.ts b/packages/atxp/src/commands/paas/storage.ts new file mode 100644 index 0000000..8ce4f88 --- /dev/null +++ b/packages/atxp/src/commands/paas/storage.ts @@ -0,0 +1,171 @@ +import { callTool } from '../../call-tool.js'; +import chalk from 'chalk'; +import fs from 'fs'; +import path from 'path'; + +const SERVER = 'paas.mcp.atxp.ai'; + +interface StorageUploadOptions { + file?: string; + content?: string; +} + +interface StorageDownloadOptions { + output?: string; +} + +interface StorageFilesOptions { + prefix?: string; + limit?: number; +} + +export async function storageCreateCommand(name: string): Promise { + const result = await callTool(SERVER, 'create_bucket', { name }); + console.log(result); +} + +export async function storageListCommand(): Promise { + const result = await callTool(SERVER, 'list_buckets', {}); + console.log(result); +} + +export async function storageUploadCommand( + bucket: string, + key: string, + options: StorageUploadOptions +): Promise { + let content: string; + let isBase64 = false; + let contentType: string | undefined; + + if (options.file) { + const filePath = path.resolve(options.file); + if (!fs.existsSync(filePath)) { + console.error(chalk.red(`Error: File not found: ${filePath}`)); + process.exit(1); + } + + // Read file and determine if it's binary + const buffer = fs.readFileSync(filePath); + const ext = path.extname(filePath).toLowerCase(); + + // Common binary extensions + const binaryExtensions = [ + '.png', '.jpg', '.jpeg', '.gif', '.webp', '.ico', '.bmp', + '.pdf', '.zip', '.tar', '.gz', '.rar', + '.mp3', '.mp4', '.wav', '.avi', '.mov', + '.woff', '.woff2', '.ttf', '.otf', + '.exe', '.dll', '.so', '.dylib', + ]; + + if (binaryExtensions.includes(ext)) { + content = buffer.toString('base64'); + isBase64 = true; + } else { + content = buffer.toString('utf-8'); + } + + // Set content type based on extension + const mimeTypes: Record = { + '.html': 'text/html', + '.css': 'text/css', + '.js': 'application/javascript', + '.json': 'application/json', + '.txt': 'text/plain', + '.png': 'image/png', + '.jpg': 'image/jpeg', + '.jpeg': 'image/jpeg', + '.gif': 'image/gif', + '.webp': 'image/webp', + '.svg': 'image/svg+xml', + '.pdf': 'application/pdf', + '.zip': 'application/zip', + '.mp3': 'audio/mpeg', + '.mp4': 'video/mp4', + }; + contentType = mimeTypes[ext]; + } else if (options.content) { + content = options.content; + } else { + console.error(chalk.red('Error: Either --file or --content is required')); + console.log(`Usage: ${chalk.cyan('npx atxp paas storage upload --file ')}`); + process.exit(1); + } + + const args: Record = { + bucket, + key, + content, + is_base64: isBase64, + }; + + if (contentType) { + args.content_type = contentType; + } + + const result = await callTool(SERVER, 'upload_file', args); + console.log(result); +} + +export async function storageDownloadCommand( + bucket: string, + key: string, + options: StorageDownloadOptions +): Promise { + const result = await callTool(SERVER, 'get_file', { bucket, key }); + + // If output file specified, try to save the content + if (options.output) { + try { + const parsed = JSON.parse(result); + if (parsed.success && parsed.file) { + const outputPath = path.resolve(options.output); + let data: Buffer | string; + + if (parsed.file.is_base64) { + data = Buffer.from(parsed.file.content, 'base64'); + } else { + data = parsed.file.content; + } + + fs.writeFileSync(outputPath, data); + console.log(chalk.green(`File saved to: ${outputPath}`)); + return; + } + } catch { + // Fall through to print raw result + } + } + + console.log(result); +} + +export async function storageFilesCommand( + bucket: string, + options: StorageFilesOptions +): Promise { + const args: Record = { bucket }; + + if (options.prefix) { + args.prefix = options.prefix; + } + if (options.limit !== undefined) { + args.limit = options.limit; + } + + const result = await callTool(SERVER, 'list_files', args); + console.log(result); +} + +export async function storageDeleteBucketCommand(name: string): Promise { + const result = await callTool(SERVER, 'delete_bucket', { name }); + console.log(result); +} + +export async function storageDeleteFileCommand( + bucket: string, + key: string +): Promise { + const result = await callTool(SERVER, 'delete_file', { bucket, key }); + console.log(result); +} diff --git a/packages/atxp/src/commands/paas/worker.test.ts b/packages/atxp/src/commands/paas/worker.test.ts new file mode 100644 index 0000000..5a47b83 --- /dev/null +++ b/packages/atxp/src/commands/paas/worker.test.ts @@ -0,0 +1,169 @@ +import { describe, it, expect, vi, beforeEach } from 'vitest'; +import fs from 'fs'; + +// Mock the call-tool module +vi.mock('../../call-tool.js', () => ({ + callTool: vi.fn(), +})); + +// Mock fs +vi.mock('fs', () => ({ + default: { + existsSync: vi.fn(), + readFileSync: vi.fn(), + }, +})); + +import { callTool } from '../../call-tool.js'; +import { + workerDeployCommand, + workerListCommand, + workerLogsCommand, + workerDeleteCommand, +} from './worker.js'; + +describe('Worker Commands', () => { + beforeEach(() => { + vi.clearAllMocks(); + vi.spyOn(console, 'log').mockImplementation(() => {}); + vi.spyOn(console, 'error').mockImplementation(() => {}); + vi.spyOn(process, 'exit').mockImplementation(() => { + throw new Error('process.exit called'); + }); + }); + + describe('workerDeployCommand', () => { + it('should deploy a worker with code from file', async () => { + const mockCode = 'export default { fetch() { return new Response("Hello"); } }'; + vi.mocked(fs.existsSync).mockReturnValue(true); + vi.mocked(fs.readFileSync).mockReturnValue(mockCode); + vi.mocked(callTool).mockResolvedValue('{"success": true}'); + + await workerDeployCommand('my-worker', { code: './worker.js' }); + + expect(callTool).toHaveBeenCalledWith('paas.mcp.atxp.ai', 'deploy_worker', { + name: 'my-worker', + code: mockCode, + }); + }); + + it('should include database bindings when provided', async () => { + const mockCode = 'export default { fetch() { return new Response("Hello"); } }'; + vi.mocked(fs.existsSync).mockReturnValue(true); + vi.mocked(fs.readFileSync).mockReturnValue(mockCode); + vi.mocked(callTool).mockResolvedValue('{"success": true}'); + + await workerDeployCommand('my-worker', { + code: './worker.js', + db: ['DB:my-database'], + }); + + expect(callTool).toHaveBeenCalledWith('paas.mcp.atxp.ai', 'deploy_worker', { + name: 'my-worker', + code: mockCode, + database_bindings: [{ binding: 'DB', database_name: 'my-database' }], + }); + }); + + it('should include storage bindings when provided', async () => { + const mockCode = 'export default { fetch() { return new Response("Hello"); } }'; + vi.mocked(fs.existsSync).mockReturnValue(true); + vi.mocked(fs.readFileSync).mockReturnValue(mockCode); + vi.mocked(callTool).mockResolvedValue('{"success": true}'); + + await workerDeployCommand('my-worker', { + code: './worker.js', + bucket: ['BUCKET:my-bucket'], + }); + + expect(callTool).toHaveBeenCalledWith('paas.mcp.atxp.ai', 'deploy_worker', { + name: 'my-worker', + code: mockCode, + storage_bindings: [{ binding: 'BUCKET', bucket_name: 'my-bucket' }], + }); + }); + + it('should enable analytics when flag is set', async () => { + const mockCode = 'export default { fetch() { return new Response("Hello"); } }'; + vi.mocked(fs.existsSync).mockReturnValue(true); + vi.mocked(fs.readFileSync).mockReturnValue(mockCode); + vi.mocked(callTool).mockResolvedValue('{"success": true}'); + + await workerDeployCommand('my-worker', { + code: './worker.js', + enableAnalytics: true, + }); + + expect(callTool).toHaveBeenCalledWith('paas.mcp.atxp.ai', 'deploy_worker', { + name: 'my-worker', + code: mockCode, + enable_analytics: true, + }); + }); + + it('should exit with error when code flag is missing', async () => { + await expect(workerDeployCommand('my-worker', {})).rejects.toThrow('process.exit called'); + expect(console.error).toHaveBeenCalled(); + }); + + it('should exit with error when code file does not exist', async () => { + vi.mocked(fs.existsSync).mockReturnValue(false); + + await expect(workerDeployCommand('my-worker', { code: './missing.js' })).rejects.toThrow( + 'process.exit called' + ); + expect(console.error).toHaveBeenCalled(); + }); + }); + + describe('workerListCommand', () => { + it('should list all workers', async () => { + vi.mocked(callTool).mockResolvedValue('{"success": true, "workers": []}'); + + await workerListCommand(); + + expect(callTool).toHaveBeenCalledWith('paas.mcp.atxp.ai', 'list_deployments', {}); + }); + }); + + describe('workerLogsCommand', () => { + it('should get worker logs', async () => { + vi.mocked(callTool).mockResolvedValue('{"success": true, "logs": []}'); + + await workerLogsCommand('my-worker', {}); + + expect(callTool).toHaveBeenCalledWith('paas.mcp.atxp.ai', 'get_logs', { + name: 'my-worker', + }); + }); + + it('should pass optional filters', async () => { + vi.mocked(callTool).mockResolvedValue('{"success": true, "logs": []}'); + + await workerLogsCommand('my-worker', { + limit: 50, + level: 'error', + since: '2024-01-01T00:00:00Z', + }); + + expect(callTool).toHaveBeenCalledWith('paas.mcp.atxp.ai', 'get_logs', { + name: 'my-worker', + limit: 50, + level: 'error', + since: '2024-01-01T00:00:00Z', + }); + }); + }); + + describe('workerDeleteCommand', () => { + it('should delete a worker', async () => { + vi.mocked(callTool).mockResolvedValue('{"success": true}'); + + await workerDeleteCommand('my-worker'); + + expect(callTool).toHaveBeenCalledWith('paas.mcp.atxp.ai', 'delete_worker', { + name: 'my-worker', + }); + }); + }); +}); diff --git a/packages/atxp/src/commands/paas/worker.ts b/packages/atxp/src/commands/paas/worker.ts new file mode 100644 index 0000000..59fc9d6 --- /dev/null +++ b/packages/atxp/src/commands/paas/worker.ts @@ -0,0 +1,101 @@ +import { callTool } from '../../call-tool.js'; +import chalk from 'chalk'; +import fs from 'fs'; +import path from 'path'; + +const SERVER = 'paas.mcp.atxp.ai'; + +interface WorkerDeployOptions { + code?: string; + db?: string[]; + bucket?: string[]; + enableAnalytics?: boolean; +} + +interface WorkerLogsOptions { + limit?: number; + level?: string; + since?: string; +} + +export async function workerDeployCommand( + name: string, + options: WorkerDeployOptions +): Promise { + if (!options.code) { + console.error(chalk.red('Error: --code flag is required')); + console.log(`Usage: ${chalk.cyan('npx atxp paas worker deploy --code ')}`); + process.exit(1); + } + + // Read code from file + const codePath = path.resolve(options.code); + if (!fs.existsSync(codePath)) { + console.error(chalk.red(`Error: File not found: ${codePath}`)); + process.exit(1); + } + + const code = fs.readFileSync(codePath, 'utf-8'); + + // Build database bindings if provided + const databaseBindings = options.db?.map((binding) => { + const [bindingName, dbName] = binding.split(':'); + return { + binding: bindingName || 'DB', + database_name: dbName || bindingName, + }; + }); + + // Build storage bindings if provided + const storageBindings = options.bucket?.map((binding) => { + const [bindingName, bucketName] = binding.split(':'); + return { + binding: bindingName || 'BUCKET', + bucket_name: bucketName || bindingName, + }; + }); + + const args: Record = { name, code }; + if (databaseBindings && databaseBindings.length > 0) { + args.database_bindings = databaseBindings; + } + if (storageBindings && storageBindings.length > 0) { + args.storage_bindings = storageBindings; + } + if (options.enableAnalytics) { + args.enable_analytics = true; + } + + const result = await callTool(SERVER, 'deploy_worker', args); + console.log(result); +} + +export async function workerListCommand(): Promise { + const result = await callTool(SERVER, 'list_deployments', {}); + console.log(result); +} + +export async function workerLogsCommand( + name: string, + options: WorkerLogsOptions +): Promise { + const args: Record = { name }; + + if (options.limit !== undefined) { + args.limit = options.limit; + } + if (options.level) { + args.level = options.level; + } + if (options.since) { + args.since = options.since; + } + + const result = await callTool(SERVER, 'get_logs', args); + console.log(result); +} + +export async function workerDeleteCommand(name: string): Promise { + const result = await callTool(SERVER, 'delete_worker', { name }); + console.log(result); +} diff --git a/packages/atxp/src/help.ts b/packages/atxp/src/help.ts index abf1032..6bd0878 100644 --- a/packages/atxp/src/help.ts +++ b/packages/atxp/src/help.ts @@ -21,6 +21,14 @@ export function showHelp(): void { console.log(' ' + chalk.cyan('x') + ' ' + chalk.yellow('') + ' ' + 'Search X/Twitter'); console.log(); + console.log(chalk.bold('PAAS (Platform as a Service):')); + console.log(' ' + chalk.cyan('paas worker') + ' ' + 'Deploy and manage serverless workers'); + console.log(' ' + chalk.cyan('paas db') + ' ' + 'Create and query databases (D1)'); + console.log(' ' + chalk.cyan('paas storage') + ' ' + 'Manage file storage (R2)'); + console.log(' ' + chalk.cyan('paas dns') + ' ' + 'Manage domains and DNS records'); + console.log(' ' + chalk.cyan('paas analytics') + ' ' + 'Query analytics data'); + console.log(); + console.log(chalk.bold('Development:')); console.log(' ' + chalk.cyan('dev demo') + ' ' + 'Run the ATXP demo application'); console.log( @@ -66,6 +74,16 @@ export function showHelp(): void { console.log(' npx atxp dev create my-app # Create a new project'); console.log(); + console.log(chalk.bold('PAAS Examples:')); + console.log(' npx atxp paas worker deploy my-api --code ./worker.js'); + console.log(' npx atxp paas db create my-database'); + console.log(' npx atxp paas db query mydb --sql "SELECT * FROM users"'); + console.log(' npx atxp paas storage create my-bucket'); + console.log(' npx atxp paas storage upload my-bucket logo.png --file ./logo.png'); + console.log(' npx atxp paas dns add example.com'); + console.log(' npx atxp paas dns connect example.com my-api'); + console.log(); + console.log(chalk.bold('Learn more:')); console.log(' Website: ' + chalk.underline('https://atxp.dev')); console.log(' GitHub: ' + chalk.underline('https://github.com/atxp-dev/cli')); diff --git a/packages/atxp/src/index.test.ts b/packages/atxp/src/index.test.ts index 04d34c1..60100bc 100644 --- a/packages/atxp/src/index.test.ts +++ b/packages/atxp/src/index.test.ts @@ -141,5 +141,132 @@ describe('ATXP CLI', () => { expect(parseToolArgs(['node', 'script', 'x', 'trending'])).toBe('trending'); expect(parseToolArgs(['node', 'script', 'search'])).toBe(''); }); + + it('should identify paas command', () => { + const isPaasCommand = (command: string) => command === 'paas'; + + expect(isPaasCommand('paas')).toBe(true); + expect(isPaasCommand('search')).toBe(false); + }); + }); + + describe('PAAS command routing', () => { + it('should identify PAAS categories', () => { + const paasCategories = ['worker', 'db', 'storage', 'dns', 'analytics']; + + const isPaasCategory = (category: string) => { + return paasCategories.includes(category); + }; + + expect(isPaasCategory('worker')).toBe(true); + expect(isPaasCategory('db')).toBe(true); + expect(isPaasCategory('storage')).toBe(true); + expect(isPaasCategory('dns')).toBe(true); + expect(isPaasCategory('analytics')).toBe(true); + expect(isPaasCategory('search')).toBe(false); + }); + + it('should parse PAAS arguments', () => { + const parsePaasArgs = (argv: string[]) => { + const command = argv[2]; + if (command !== 'paas') return []; + return argv.slice(3).filter((arg) => !arg.startsWith('-')); + }; + + expect(parsePaasArgs(['node', 'script', 'paas', 'worker', 'deploy', 'my-api'])).toEqual([ + 'worker', + 'deploy', + 'my-api', + ]); + expect(parsePaasArgs(['node', 'script', 'paas', 'db', 'list'])).toEqual(['db', 'list']); + expect( + parsePaasArgs(['node', 'script', 'paas', 'storage', 'upload', 'bucket', 'key', '--file', 'path']) + ).toEqual(['storage', 'upload', 'bucket', 'key', 'path']); + expect(parsePaasArgs(['node', 'script', 'search', 'query'])).toEqual([]); + }); + + it('should parse PAAS options', () => { + const getArgValue = (argv: string[], flag: string): string | undefined => { + const index = argv.findIndex((arg) => arg === flag); + return index !== -1 ? argv[index + 1] : undefined; + }; + + const argv = ['node', 'script', 'paas', 'worker', 'deploy', 'my-api', '--code', './worker.js']; + expect(getArgValue(argv, '--code')).toBe('./worker.js'); + + const queryArgv = ['node', 'script', 'paas', 'db', 'query', 'mydb', '--sql', 'SELECT * FROM users']; + expect(getArgValue(queryArgv, '--sql')).toBe('SELECT * FROM users'); + }); + + it('should identify worker subcommands', () => { + const workerCommands = ['deploy', 'list', 'logs', 'delete']; + + const isWorkerCommand = (subCommand: string) => { + return workerCommands.includes(subCommand); + }; + + expect(isWorkerCommand('deploy')).toBe(true); + expect(isWorkerCommand('list')).toBe(true); + expect(isWorkerCommand('logs')).toBe(true); + expect(isWorkerCommand('delete')).toBe(true); + expect(isWorkerCommand('create')).toBe(false); + }); + + it('should identify db subcommands', () => { + const dbCommands = ['create', 'list', 'query', 'delete']; + + const isDbCommand = (subCommand: string) => { + return dbCommands.includes(subCommand); + }; + + expect(isDbCommand('create')).toBe(true); + expect(isDbCommand('list')).toBe(true); + expect(isDbCommand('query')).toBe(true); + expect(isDbCommand('delete')).toBe(true); + expect(isDbCommand('deploy')).toBe(false); + }); + + it('should identify storage subcommands', () => { + const storageCommands = ['create', 'list', 'upload', 'download', 'files', 'delete-bucket', 'delete-file']; + + const isStorageCommand = (subCommand: string) => { + return storageCommands.includes(subCommand); + }; + + expect(isStorageCommand('create')).toBe(true); + expect(isStorageCommand('upload')).toBe(true); + expect(isStorageCommand('download')).toBe(true); + expect(isStorageCommand('files')).toBe(true); + expect(isStorageCommand('delete-bucket')).toBe(true); + expect(isStorageCommand('delete-file')).toBe(true); + expect(isStorageCommand('deploy')).toBe(false); + }); + + it('should identify dns subcommands', () => { + const dnsCommands = ['add', 'list', 'record', 'connect']; + + const isDnsCommand = (subCommand: string) => { + return dnsCommands.includes(subCommand); + }; + + expect(isDnsCommand('add')).toBe(true); + expect(isDnsCommand('list')).toBe(true); + expect(isDnsCommand('record')).toBe(true); + expect(isDnsCommand('connect')).toBe(true); + expect(isDnsCommand('deploy')).toBe(false); + }); + + it('should identify analytics subcommands', () => { + const analyticsCommands = ['query', 'events', 'stats']; + + const isAnalyticsCommand = (subCommand: string) => { + return analyticsCommands.includes(subCommand); + }; + + expect(isAnalyticsCommand('query')).toBe(true); + expect(isAnalyticsCommand('events')).toBe(true); + expect(isAnalyticsCommand('stats')).toBe(true); + expect(isAnalyticsCommand('deploy')).toBe(false); + }); }); }); diff --git a/packages/atxp/src/index.ts b/packages/atxp/src/index.ts index 90667b2..327d807 100644 --- a/packages/atxp/src/index.ts +++ b/packages/atxp/src/index.ts @@ -12,6 +12,7 @@ import { imageCommand } from './commands/image.js'; import { musicCommand } from './commands/music.js'; import { videoCommand } from './commands/video.js'; import { xCommand } from './commands/x.js'; +import { paasCommand } from './commands/paas/index.js'; interface DemoOptions { port: number; @@ -32,6 +33,32 @@ interface LoginOptions { qr?: boolean; } +interface PaasOptions { + code?: string; + db?: string[]; + bucket?: string[]; + limit?: number; + level?: string; + since?: string; + sql?: string; + params?: string; + file?: string; + content?: string; + output?: string; + prefix?: string; + type?: string; + name?: string; + recordContent?: string; + ttl?: number; + proxied?: boolean; + priority?: number; + subdomain?: string; + range?: string; + event?: string; + groupBy?: string; + enableAnalytics?: boolean; +} + // Parse command line arguments function parseArgs(): { command: string; @@ -39,6 +66,8 @@ function parseArgs(): { demoOptions: DemoOptions; createOptions: CreateOptions; loginOptions: LoginOptions; + paasOptions: PaasOptions; + paasArgs: string[]; toolArgs: string; } { const command = process.argv[2]; @@ -51,6 +80,8 @@ function parseArgs(): { demoOptions: { port: 8017, dir: '', verbose: false, refresh: false }, createOptions: { framework: undefined, appName: undefined, git: undefined }, loginOptions: { force: false }, + paasOptions: {}, + paasArgs: [], toolArgs: '', }; } @@ -109,17 +140,62 @@ function parseArgs(): { // Get tool arguments (everything after the command) const toolArgs = process.argv.slice(3).filter((arg) => !arg.startsWith('-')).join(' '); + // Get all values for a repeatable flag (like --db can appear multiple times) + const getAllArgValues = (flag: string): string[] => { + const values: string[] = []; + for (let i = 0; i < process.argv.length; i++) { + if (process.argv[i] === flag && process.argv[i + 1]) { + values.push(process.argv[i + 1]); + } + } + return values; + }; + + // Parse PAAS options + const paasOptions: PaasOptions = { + code: getArgValue('--code', '-c'), + db: getAllArgValues('--db'), + bucket: getAllArgValues('--bucket'), + limit: getArgValue('--limit', '-l') ? parseInt(getArgValue('--limit', '-l')!, 10) : undefined, + level: getArgValue('--level', ''), + since: getArgValue('--since', ''), + sql: getArgValue('--sql', ''), + params: getArgValue('--params', ''), + file: getArgValue('--file', ''), + content: getArgValue('--content', ''), + output: getArgValue('--output', '-o'), + prefix: getArgValue('--prefix', ''), + type: getArgValue('--type', ''), + name: getArgValue('--name', ''), + recordContent: getArgValue('--record-content', ''), + ttl: getArgValue('--ttl', '') ? parseInt(getArgValue('--ttl', '')!, 10) : undefined, + proxied: process.argv.includes('--proxied'), + priority: getArgValue('--priority', '') ? parseInt(getArgValue('--priority', '')!, 10) : undefined, + subdomain: getArgValue('--subdomain', ''), + range: getArgValue('--range', ''), + event: getArgValue('--event', ''), + groupBy: getArgValue('--group-by', ''), + enableAnalytics: process.argv.includes('--enable-analytics'), + }; + + // Get PAAS args (everything after 'paas' that doesn't start with -) + const paasArgs = command === 'paas' + ? process.argv.slice(3).filter((arg) => !arg.startsWith('-')) + : []; + return { command, subCommand, demoOptions: { port, dir, verbose, refresh }, createOptions: { framework, appName, git }, loginOptions: { force, token, qr }, + paasOptions, + paasArgs, toolArgs, }; } -const { command, subCommand, demoOptions, createOptions, loginOptions, toolArgs } = parseArgs(); +const { command, subCommand, demoOptions, createOptions, loginOptions, paasOptions, paasArgs, toolArgs } = parseArgs(); // Detect if we're in create mode (npm create atxp or npx atxp create) const isCreateMode = @@ -191,6 +267,10 @@ async function main() { await xCommand(toolArgs); break; + case 'paas': + await paasCommand(paasArgs, paasOptions); + break; + case 'dev': // Dev subcommands (demo, create) if (subCommand === 'demo') {