diff --git a/package-lock.json b/package-lock.json index c845189..22083cb 100644 --- a/package-lock.json +++ b/package-lock.json @@ -16,6 +16,7 @@ "zod": "^3.23.0" }, "devDependencies": { + "@types/node": "^24.10.2", "patch-package": "^8.0.1", "typescript": "^5.5.0", "vitest": "^2.0.0", @@ -1781,12 +1782,12 @@ "license": "MIT" }, "node_modules/@types/node": { - "version": "18.19.130", - "resolved": "https://registry.npmjs.org/@types/node/-/node-18.19.130.tgz", - "integrity": "sha512-GRaXQx6jGfL8sKfaIDD6OupbIHBr9jv7Jnaml9tB7l4v068PAOXqfcujMMo5PhbIs6ggR1XODELqahT2R8v0fg==", + "version": "24.10.2", + "resolved": "https://registry.npmjs.org/@types/node/-/node-24.10.2.tgz", + "integrity": "sha512-WOhQTZ4G8xZ1tjJTvKOpyEVSGgOTvJAfDK3FNFgELyaTpzhdgHVHeqW8V+UJvzF5BT+/B54T/1S2K6gd9c7bbA==", "license": "MIT", "dependencies": { - "undici-types": "~5.26.4" + "undici-types": "~7.16.0" } }, "node_modules/@types/node-fetch": { @@ -2166,6 +2167,21 @@ "web-streams-polyfill": "^3.2.1" } }, + "node_modules/cloudflare/node_modules/@types/node": { + "version": "18.19.130", + "resolved": "https://registry.npmjs.org/@types/node/-/node-18.19.130.tgz", + "integrity": "sha512-GRaXQx6jGfL8sKfaIDD6OupbIHBr9jv7Jnaml9tB7l4v068PAOXqfcujMMo5PhbIs6ggR1XODELqahT2R8v0fg==", + "license": "MIT", + "dependencies": { + "undici-types": "~5.26.4" + } + }, + "node_modules/cloudflare/node_modules/undici-types": { + "version": "5.26.5", + "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-5.26.5.tgz", + "integrity": "sha512-JlCMO+ehdEIKqlFxk6IfVoAUVmgz7cU7zD/h9XZ0qzeosSHmUJVOzSQvvYSYWXkFXC+IfLKSIffhv0sVZup6pA==", + "license": "MIT" + }, "node_modules/color": { "version": "4.2.3", "resolved": "https://registry.npmjs.org/color/-/color-4.2.3.tgz", @@ -4216,9 +4232,9 @@ } }, "node_modules/undici-types": { - "version": "5.26.5", - "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-5.26.5.tgz", - "integrity": "sha512-JlCMO+ehdEIKqlFxk6IfVoAUVmgz7cU7zD/h9XZ0qzeosSHmUJVOzSQvvYSYWXkFXC+IfLKSIffhv0sVZup6pA==", + "version": "7.16.0", + "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-7.16.0.tgz", + "integrity": "sha512-Zz+aZWSj8LE6zoxD+xrjh4VfkIG8Ya6LvYkZqtUQGJPZjYl53ypCaUwWqo7eI0x66KBGeRo+mlBEkMSeSZ38Nw==", "license": "MIT" }, "node_modules/unenv": { diff --git a/package.json b/package.json index e4b7105..ec6b7da 100644 --- a/package.json +++ b/package.json @@ -36,6 +36,7 @@ "zod": "^3.23.0" }, "devDependencies": { + "@types/node": "^24.10.2", "patch-package": "^8.0.1", "typescript": "^5.5.0", "vitest": "^2.0.0", diff --git a/src/consultationSessionDO.ts b/src/consultationSessionDO.ts index e8a3d98..8e37c33 100644 --- a/src/consultationSessionDO.ts +++ b/src/consultationSessionDO.ts @@ -118,7 +118,7 @@ export class ConsultationSessionDO extends DurableObject { } // Use blockConcurrencyWhile to ensure atomic updates - await this.ctx.storage.blockConcurrencyWhile(async () => { + await this.ctx.blockConcurrencyWhile(async () => { const updates = (await this.ctx.storage.get('updates') as any[]) || []; updates.push({ ...update, diff --git a/src/index.ts b/src/index.ts index e2349d8..905f472 100644 --- a/src/index.ts +++ b/src/index.ts @@ -20,6 +20,9 @@ export { ConsultationSessionDO } from './consultationSessionDO'; // Export RPC Entrypoint for Service Bindings export { CloudflareManagerRPC } from './rpc-entrypoint'; +// Export Workflow Entrypoints +export { ProvisioningWorkflow } from './workflows/provision'; + // Create Hono app const app = new Hono<{ Bindings: Env; Variables: Variables }>(); @@ -50,20 +53,19 @@ const authMiddleware = async (c: any, next: any) => { /** * Cloudflare SDK Initialization Middleware * Initializes SDK with worker's own CLOUDFLARE_TOKEN + * + * OPTIMIZATION: Cloudflare client and AI instances are initialized once per request + * and stored in context (c.set) to avoid re-instantiating heavy objects. + * This follows the singleton-per-request pattern for optimal performance. */ const cfInitMiddleware = async (c: any, next: any) => { - // Temporarily log the types of all secrets to debug the binding issue - // console.log('--- Secret Binding Types ---'); - // console.log('typeof c.env.CLOUDFLARE_ACCOUNT_ID:', typeof c.env.CLOUDFLARE_ACCOUNT_ID); - // console.log('typeof c.env.CLOUDFLARE_TOKEN:', typeof c.env.CLOUDFLARE_TOKEN); - // console.log('typeof c.env.CLIENT_AUTH_TOKEN:', typeof c.env.CLIENT_AUTH_TOKEN); - // console.log('--------------------------'); - + // Initialize Cloudflare SDK client (once per request) const cf = new Cloudflare({ apiToken: c.env.CLOUDFLARE_TOKEN }); // Extract account ID from environment const accountId = c.env.CLOUDFLARE_ACCOUNT_ID; + // Store in context for reuse throughout the request lifecycle c.set('cf', cf); c.set('accountId', accountId); c.set('startTime', Date.now()); @@ -73,7 +75,7 @@ const cfInitMiddleware = async (c: any, next: any) => { }; // PATCHED: Token middleware fix for /api/tokens routes -const apiClientMiddleware = async (c: Context<{ Bindings: Env; Variables: Variables }>, next: Next) => { +const apiClientMiddleware = async (c: any, next: any) => { const urlPath = new URL(c.req.url).pathname; const isUserTokenRoute = urlPath.startsWith('/api/tokens'); @@ -317,58 +319,141 @@ app.post('/mcp', async (c) => { }); /** - * AI Agent Endpoint - * Natural language interface with cloudflare-docs integration + * AI Agent Endpoint with ReAct Loop + * Uses Llama 3 for reasoning and tool execution */ app.post('/agent', async (c) => { try { - const { prompt } = await c.req.json(); + const { prompt, conversationHistory = [] } = await c.req.json(); + + // Check if AI binding is available + if (!c.env.AI) { + return c.json({ + success: false, + error: 'AI binding not configured. Please add AI binding to wrangler.jsonc' + }, 500); + } + const cf = c.get('cf'); const accountId = c.get('accountId'); + const { listMCPTools, getMCPTool } = await import('./mcp/index'); + const tools = listMCPTools(); + + // Build system prompt with available tools + const systemPrompt = `You are an AI assistant that helps manage Cloudflare infrastructure. + +You have access to the following tools: +${tools.map(tool => `- ${tool.name}: ${tool.description}`).join('\n')} + +When you need to use a tool, respond with a JSON object in this format: +{ + "tool": "tool_name", + "arguments": { ...tool arguments... }, + "reasoning": "Why you're using this tool" +} - // Basic intent detection (in production, use Workers AI or external LLM) - const promptLower = prompt.toLowerCase(); +After receiving tool results, provide a natural language response to the user. + +Always be helpful, concise, and accurate. If you're unsure, ask for clarification.`; + + // ReAct Loop - Maximum 5 iterations to prevent infinite loops + const maxIterations = 5; + let iteration = 0; const actions: any[] = []; - let response = ''; - - if (promptLower.includes('create') && promptLower.includes('token')) { - // Token creation flow - // In production, agent would: - // 1. Use cloudflare-docs MCP to lookup permissions - // 2. Determine exact permissions needed - // 3. Call /flows/token/create - response = `To create a token, I need to know: -1. What will this token be used for? -2. Which resources does it need access to? -3. Should it have an expiration (TTL)? - -I can use the cloudflare-docs to determine the exact permissions needed. Please provide more details about the token's purpose.`; - } else if (promptLower.includes('list') && promptLower.includes('worker')) { - const workers = await cf.workers.scripts.list({ account_id: accountId }); - console.log(JSON.stringify(workers)); - actions.push({ type: 'list_workers', result: workers }); - const workerCount = Array.isArray(workers.result) ? workers.result.length : 0; - response = `Found ${workerCount} workers in your account.`; - } else { - response = `I can help you manage your Cloudflare infrastructure. I can: - -- Create managed API tokens (with secure storage and auditing) -- List and manage Workers, Pages, and storage resources -- Create complete project stacks with bindings -- Setup CI/CD pipelines -- And more... - -What would you like to do?`; + let finalResponse = ''; + + // Build conversation context + const messages = [ + { role: 'system', content: systemPrompt }, + ...conversationHistory, + { role: 'user', content: prompt }, + ]; + + while (iteration < maxIterations) { + iteration++; + + // Call Llama 3 model + const aiResponse = await c.env.AI.run('@cf/meta/llama-3-8b-instruct', { + messages, + max_tokens: 512, + }); + + const responseText = aiResponse.response || ''; + + // Check if the response contains a tool call (JSON format) + let toolCall = null; + try { + // Try to extract JSON from the response - look for complete JSON objects + const jsonMatch = responseText.match(/\{[\s\S]*?\}(?=\s|$)/); + if (jsonMatch) { + const parsed = JSON.parse(jsonMatch[0]); + // Validate it has the expected structure for a tool call + if (parsed.tool && typeof parsed.tool === 'string') { + toolCall = parsed; + } + } + } catch (e) { + // Not a valid tool call, treat as final response + } + + if (toolCall && toolCall.tool) { + // Execute the tool + const tool = getMCPTool(toolCall.tool); + + if (!tool) { + messages.push({ + role: 'assistant', + content: `Error: Tool '${toolCall.tool}' not found. Available tools: ${tools.map(t => t.name).join(', ')}` + }); + continue; + } + + try { + const toolResult = await tool.handler(toolCall.arguments || {}, c.env); + + actions.push({ + tool: toolCall.tool, + arguments: toolCall.arguments, + reasoning: toolCall.reasoning, + result: toolResult, + }); + + // Add tool result to conversation + messages.push({ + role: 'assistant', + content: `Used tool: ${toolCall.tool}` + }); + messages.push({ + role: 'user', + content: `Tool result: ${JSON.stringify(toolResult)}. Now provide a natural language response to the user.` + }); + } catch (error: any) { + messages.push({ + role: 'assistant', + content: `Error executing tool ${toolCall.tool}: ${error.message}` + }); + } + } else { + // No tool call, this is the final response + finalResponse = responseText; + break; + } + } + + if (iteration >= maxIterations && !finalResponse) { + finalResponse = 'I apologize, but I reached the maximum number of reasoning steps. Please try rephrasing your request or breaking it into smaller tasks.'; } return c.json({ success: true, result: { - message: response, + message: finalResponse, actions, + iterations: iteration, }, }); } catch (error: any) { + console.error('Agent error:', error); return c.json({ success: false, error: error.message }, 500); } }); diff --git a/src/mcp/agent-tools.ts b/src/mcp/agent-tools.ts new file mode 100644 index 0000000..0a35061 --- /dev/null +++ b/src/mcp/agent-tools.ts @@ -0,0 +1,212 @@ +/** + * MCP Agent Tools + * Additional tools for the AI agent to manage deployments and workflows + */ + +import type { Env } from '../types'; + +export interface GetDeploymentLogsInput { + scriptName: string; + limit?: number; +} + +export interface RollbackWorkerInput { + scriptName: string; + versionId?: string; +} + +export interface GetProvisioningStatusInput { + instanceId: string; +} + +/** + * Tool: get_deployment_logs + * Fetch deployment logs for a specific worker + */ +export const getDeploymentLogsTool = { + name: 'get_deployment_logs', + description: 'Fetch deployment logs for a specific Cloudflare Worker to analyze failures and debug issues. Returns recent deployment history and logs.', + inputSchema: { + type: 'object', + properties: { + scriptName: { + type: 'string', + description: 'Name of the worker script to fetch logs for', + }, + limit: { + type: 'number', + description: 'Number of log entries to return (default: 10)', + default: 10, + }, + }, + required: ['scriptName'], + }, + + async handler(input: GetDeploymentLogsInput, env: Env): Promise { + const { scriptName, limit = 10 } = input; + + try { + const Cloudflare = (await import('cloudflare')).default; + const cf = new Cloudflare({ apiToken: env.CLOUDFLARE_TOKEN }); + const accountId = env.CLOUDFLARE_ACCOUNT_ID; + + // Get deployment history + const deploymentsResponse = await (cf.workers.scripts.deployments as any).list({ + account_id: accountId, + script_name: scriptName, + } as any); + + const deployments: any = await deploymentsResponse.json(); + const recentDeployments = (deployments.result || []).slice(0, limit); + + // Get worker details + let workerInfo; + try { + const workerResponse = await cf.workers.scripts.get(scriptName, { + account_id: accountId, + }); + workerInfo = await workerResponse.json(); + } catch (error: any) { + workerInfo = { error: 'Could not fetch worker details', message: error.message }; + } + + return { + success: true, + scriptName, + deployments: recentDeployments.map((d: any) => ({ + id: d.id, + created_on: d.created_on, + source: d.source, + author_email: d.author_email, + })), + currentWorker: workerInfo, + message: `Found ${recentDeployments.length} recent deployments for ${scriptName}`, + }; + } catch (error: any) { + throw new Error(`Failed to get deployment logs: ${error.message}`); + } + }, +}; + +/** + * Tool: get_rollback_info + * Get information about previous deployments for potential rollback + */ +export const rollbackWorkerTool = { + name: 'get_rollback_info', + description: 'Get information about previous deployments of a Cloudflare Worker for potential rollback. Note: This retrieves rollback information only; actual rollback requires stored worker content or Workers Versions API.', + inputSchema: { + type: 'object', + properties: { + scriptName: { + type: 'string', + description: 'Name of the worker script to rollback', + }, + versionId: { + type: 'string', + description: 'Optional: specific deployment version ID to rollback to. If not provided, rolls back to the previous version.', + }, + }, + required: ['scriptName'], + }, + + async handler(input: RollbackWorkerInput, env: Env): Promise { + const { scriptName, versionId } = input; + + try { + const Cloudflare = (await import('cloudflare')).default; + const cf = new Cloudflare({ apiToken: env.CLOUDFLARE_TOKEN }); + const accountId = env.CLOUDFLARE_ACCOUNT_ID; + + // Get deployment history + const deploymentsResponse = await (cf.workers.scripts.deployments as any).list({ + account_id: accountId, + script_name: scriptName, + } as any); + + const deployments: any = await deploymentsResponse.json(); + + if (!deployments.result || deployments.result.length < 2) { + throw new Error('No previous deployments found to rollback to'); + } + + // Find target deployment + const targetDeployment = versionId + ? deployments.result.find((d: any) => d.id === versionId) + : deployments.result[1]; // Previous deployment + + if (!targetDeployment) { + throw new Error('Target deployment not found'); + } + + return { + success: true, + scriptName, + targetDeployment: { + id: targetDeployment.id, + created_on: targetDeployment.created_on, + source: targetDeployment.source, + }, + message: `Rollback information retrieved for ${scriptName}. Note: Actual rollback requires stored worker content. Consider using Cloudflare Workers Versions API or storing versions in R2.`, + recommendation: 'To enable full rollback functionality, implement version storage using R2 or Workers Versions API', + }; + } catch (error: any) { + throw new Error(`Failed to rollback worker: ${error.message}`); + } + }, +}; + +/** + * Tool: get_provisioning_status + * Check the status of a provisioning workflow instance + */ +export const getProvisioningStatusTool = { + name: 'get_provisioning_status', + description: 'Check the status of a Provisioning Workflow instance. Use this to monitor the progress of resource creation for a new project.', + inputSchema: { + type: 'object', + properties: { + instanceId: { + type: 'string', + description: 'The workflow instance ID returned when starting provisioning', + }, + }, + required: ['instanceId'], + }, + + async handler(input: GetProvisioningStatusInput, env: Env): Promise { + const { instanceId } = input; + + try { + // Note: Workflow bindings require specific configuration + // This is a placeholder that demonstrates the expected interface + + if (!env.PROVISIONING_WORKFLOW) { + throw new Error('Provisioning workflow binding not configured. Add PROVISIONING_WORKFLOW binding to wrangler.jsonc'); + } + + const instance = await (env.PROVISIONING_WORKFLOW as any).get(instanceId); + const status = await instance.status(); + + return { + success: true, + instanceId, + status: status.status, + output: status.output, + error: status.error, + message: `Provisioning workflow ${instanceId} is ${status.status}`, + }; + } catch (error: any) { + throw new Error(`Failed to get provisioning status: ${error.message}`); + } + }, +}; + +/** + * Export all agent MCP tools + */ +export const agentMCPTools = [ + getDeploymentLogsTool, + rollbackWorkerTool, + getProvisioningStatusTool, +]; diff --git a/src/mcp/index.ts b/src/mcp/index.ts index c2d2610..a9e0dc8 100644 --- a/src/mcp/index.ts +++ b/src/mcp/index.ts @@ -5,6 +5,7 @@ import { consultationMCPTools } from './consultation'; import { workerCreationMCPTools } from './create-worker'; +import { agentMCPTools } from './agent-tools'; /** * All available MCP tools @@ -12,6 +13,7 @@ import { workerCreationMCPTools } from './create-worker'; export const allMCPTools = [ ...consultationMCPTools, ...workerCreationMCPTools, + ...agentMCPTools, ]; /** diff --git a/src/routes/flows/deploy.ts b/src/routes/flows/deploy.ts index f9568e6..eb93daa 100644 --- a/src/routes/flows/deploy.ts +++ b/src/routes/flows/deploy.ts @@ -63,19 +63,28 @@ deployFlows.post('/from-content', async (c) => { if (error.status === 404) { // Subdomain doesn't exist, create it. // !! CRUCIAL: This is a one-time operation for the account. - // !! You MUST replace this placeholder with your desired subdomain. - const desiredSubdomain = "hacolby"; + const desiredSubdomain = c.env.WORKERS_DEV_SUBDOMAIN || "hacolby"; - if (desiredSubdomain === "your-account-subdomain") { - throw new Error("Subdomain check failed: 'your-account-subdomain' is a placeholder. Please edit src/routes/flows/deploy.ts with your desired account subdomain."); + if (!c.env.WORKERS_DEV_SUBDOMAIN) { + console.warn('WORKERS_DEV_SUBDOMAIN not set, using default: hacolby'); } console.log(`Attempting to create workers.dev subdomain: ${desiredSubdomain}`); - await cf.workers.subdomains.create({ - account_id: accountId, - body: { subdomain: desiredSubdomain } - }); - result.steps_completed.push('subdomain_created'); + try { + await (cf.workers.subdomains as any).create({ + account_id: accountId, + body: { subdomain: desiredSubdomain } + }); + result.steps_completed.push('subdomain_created'); + } catch (createError: any) { + // Gracefully handle "Subdomain already exists" errors + if (createError.message?.includes('already exists') || createError.message?.includes('already taken')) { + console.log(`Subdomain '${desiredSubdomain}' already exists, continuing...`); + result.steps_completed.push('subdomain_already_exists'); + } else { + throw createError; + } + } } else { // A different error occurred (e.g., auth) throw new Error(`Failed to verify workers.dev subdomain: ${error.message}`); @@ -205,15 +214,27 @@ deployFlows.post('/from-canvas', async (c) => { result.steps_completed.push('subdomain_verified'); } catch (error: any) { if (error.status === 404) { - const desiredSubdomain = "hacolby"; // !! MUST BE CHANGED - if (desiredSubdomain === "your-account-subdomain") { - throw new Error("Subdomain check failed: 'your-account-subdomain' is a placeholder. Please edit src/routes/flows/deploy.ts with your desired account subdomain."); + const desiredSubdomain = c.env.WORKERS_DEV_SUBDOMAIN || "hacolby"; + + if (!c.env.WORKERS_DEV_SUBDOMAIN) { + console.warn('WORKERS_DEV_SUBDOMAIN not set, using default: hacolby'); + } + + try { + await (cf.workers.subdomains as any).create({ + account_id: accountId, + body: { subdomain: desiredSubdomain } + }); + result.steps_completed.push('subdomain_created'); + } catch (createError: any) { + // Gracefully handle "Subdomain already exists" errors + if (createError.message?.includes('already exists') || createError.message?.includes('already taken')) { + console.log(`Subdomain '${desiredSubdomain}' already exists, continuing...`); + result.steps_completed.push('subdomain_already_exists'); + } else { + throw createError; + } } - await cf.workers.subdomains.create({ - account_id: accountId, - body: { subdomain: desiredSubdomain } - }); - result.steps_completed.push('subdomain_created'); } else { throw new Error(`Failed to verify workers.dev subdomain: ${error.message}`); } @@ -414,15 +435,27 @@ deployFlows.post('/with-config', async (c) => { result.steps_completed.push('subdomain_verified'); } catch (error: any) { if (error.status === 404) { - const desiredSubdomain = "hacolby"; // !! MUST BE CHANGED - if (desiredSubdomain === "your-account-subdomain") { - throw new Error("Subdomain check failed: 'your-account-subdomain' is a placeholder. Please edit src/routes/flows/deploy.ts with your desired account subdomain."); + const desiredSubdomain = c.env.WORKERS_DEV_SUBDOMAIN || "hacolby"; + + if (!c.env.WORKERS_DEV_SUBDOMAIN) { + console.warn('WORKERS_DEV_SUBDOMAIN not set, using default: hacolby'); + } + + try { + await (cf.workers.subdomains as any).create({ + account_id: accountId, + body: { subdomain: desiredSubdomain } + }); + result.steps_completed.push('subdomain_created'); + } catch (createError: any) { + // Gracefully handle "Subdomain already exists" errors + if (createError.message?.includes('already exists') || createError.message?.includes('already taken')) { + console.log(`Subdomain '${desiredSubdomain}' already exists, continuing...`); + result.steps_completed.push('subdomain_already_exists'); + } else { + throw createError; + } } - await cf.workers.subdomains.create({ - account_id: accountId, - body: { subdomain: desiredSubdomain } - }); - result.steps_completed.push('subdomain_created'); } else { throw new Error(`Failed to verify workers.dev subdomain: ${error.message}`); } @@ -824,7 +857,7 @@ deployFlows.get('/status/:scriptName', async (c) => { // Get deployment history try { // Note: cf.workers.scripts.deployments.list returns a response, not raw JSON - const deploymentsResponse = await cf.workers.scripts.deployments.list({ + const deploymentsResponse = await (cf.workers.scripts.deployments as any).list({ account_id: accountId, script_name: scriptName, } as any); diff --git a/src/services/create-worker-service.ts b/src/services/create-worker-service.ts index 05923c9..c21f7d0 100644 --- a/src/services/create-worker-service.ts +++ b/src/services/create-worker-service.ts @@ -743,6 +743,7 @@ export async function createWorker( // Use custom domain from env or default to hacolby.workers.dev const WORKERS_DEV_DOMAIN = 'hacolby.workers.dev'; const workersDomain = env.WORKERS_DEV_DOMAIN || WORKERS_DEV_DOMAIN; + const workerUrl = `${request.project_name}.${workersDomain.replace('https://', '').replace('http://', '')}`; // Generate help page HTML const helpPageHtml = generateHelpPage( diff --git a/src/types.ts b/src/types.ts index aa52f59..13274b8 100644 --- a/src/types.ts +++ b/src/types.ts @@ -21,6 +21,7 @@ export interface Env { OBSERVABILITY_AE?: AnalyticsEngineDataset; WORKER_URL?: string; WORKERS_DEV_DOMAIN?: string; // Custom workers.dev subdomain (defaults to 'hacolby.workers.dev') + WORKERS_DEV_SUBDOMAIN?: string; // Custom workers.dev subdomain name (e.g., 'hacolby') // Context Coach Durable Object CONTEXT_COACH: DurableObjectNamespace; // Durable Object for context coaching @@ -36,6 +37,9 @@ export interface Env { // Workers AI binding (optional) AI?: Ai; + + // Workflow bindings + PROVISIONING_WORKFLOW?: any; // Workflow binding for provisioning } // Context variables diff --git a/src/workflows/provision.ts b/src/workflows/provision.ts new file mode 100644 index 0000000..4980467 --- /dev/null +++ b/src/workflows/provision.ts @@ -0,0 +1,333 @@ +/** + * Provisioning Workflow + * + * Infrastructure "Easy Button" that prepares a project for Dashboard CI/CD. + * Creates Workers, D1 databases, KV namespaces, R2 buckets, and generates + * wrangler configuration snippets ready for copy-paste. + */ + +import { WorkflowEntrypoint, WorkflowStep, WorkflowEvent } from 'cloudflare:workers'; +import type { Env } from '../types'; +import Cloudflare from 'cloudflare'; + +export interface ProvisioningInput { + projectName: string; + requestedBindings?: { + kv?: string[]; + d1?: string[]; + r2?: string[]; + queues?: string[]; + }; +} + +export interface ResourceCreationResult { + success: boolean; + id?: string; + name?: string; + error?: string; +} + +export interface KVNamespaceResult { + binding: string; + id: string; + name: string; +} + +export interface D1DatabaseResult { + binding: string; + id: string; + name: string; +} + +export interface R2BucketResult { + binding: string; + name: string; +} + +export interface QueueResult { + binding: string; + name: string; +} + +export interface ProvisioningOutput { + success: boolean; + projectName: string; + workerCreated: boolean; + resourcesCreated: { + kv: KVNamespaceResult[]; + d1: D1DatabaseResult[]; + r2: R2BucketResult[]; + queues: QueueResult[]; + }; + wranglerConfig: string; + errors?: string[]; +} + +export class ProvisioningWorkflow extends WorkflowEntrypoint { + async run(event: WorkflowEvent, step: WorkflowStep): Promise { + const { projectName, requestedBindings = {} } = event.payload; + + const result: ProvisioningOutput = { + success: false, + projectName, + workerCreated: false, + resourcesCreated: { + kv: [], + d1: [], + r2: [], + queues: [], + }, + wranglerConfig: '', + errors: [], + }; + + // Step 1: Create Worker placeholder + const workerResult = await step.do('create-worker', async () => { + try { + const cf = new Cloudflare({ apiToken: this.env.CLOUDFLARE_TOKEN }); + const accountId = this.env.CLOUDFLARE_ACCOUNT_ID; + + // Create a minimal worker script + const workerContent = ` +export default { + async fetch(request, env, ctx) { + return new Response('Worker "${projectName}" is ready for deployment!', { + headers: { 'Content-Type': 'text/plain' } + }); + } +}; +`; + + // Deploy the placeholder worker + // Note: Bindings are left empty intentionally - they will be configured + // via Dashboard CI/CD using the generated wrangler.jsonc + await cf.workers.scripts.update(projectName, { + account_id: accountId, + body: workerContent, + metadata: { + main_module: 'index.js', + compatibility_date: '2024-06-01', + bindings: [], + }, + } as any); + + return { success: true }; + } catch (error: any) { + console.error('Failed to create worker:', error); + return { success: false, error: error.message }; + } + }); + + if (workerResult.success) { + result.workerCreated = true; + } else { + result.errors!.push(`Worker creation failed: ${workerResult.error}`); + } + + // Step 2: Create KV Namespaces + if (requestedBindings.kv && requestedBindings.kv.length > 0) { + for (const bindingName of requestedBindings.kv) { + const kvResult = await step.do(`create-kv-${bindingName}`, async (): Promise => { + try { + const cf = new Cloudflare({ apiToken: this.env.CLOUDFLARE_TOKEN }); + const accountId = this.env.CLOUDFLARE_ACCOUNT_ID; + + const namespace = await cf.kv.namespaces.create({ + account_id: accountId, + title: `${projectName}-${bindingName}`, + }); + + // Type assertion with explanation: SDK response structure varies + const namespaceData = namespace as { id: string }; + return { + success: true, + id: namespaceData.id, + name: `${projectName}-${bindingName}`, + }; + } catch (error: any) { + console.error(`Failed to create KV namespace ${bindingName}:`, error); + return { success: false, error: error.message }; + } + }); + + if (kvResult.success) { + result.resourcesCreated.kv.push({ + binding: bindingName, + id: kvResult.id!, + name: kvResult.name!, + }); + } else { + result.errors!.push(`KV ${bindingName} creation failed: ${kvResult.error}`); + } + } + } + + // Step 3: Create D1 Databases + if (requestedBindings.d1 && requestedBindings.d1.length > 0) { + for (const bindingName of requestedBindings.d1) { + const d1Result = await step.do(`create-d1-${bindingName}`, async (): Promise => { + try { + const cf = new Cloudflare({ apiToken: this.env.CLOUDFLARE_TOKEN }); + const accountId = this.env.CLOUDFLARE_ACCOUNT_ID; + + const database = await cf.d1.database.create({ + account_id: accountId, + name: `${projectName}-${bindingName}`, + }); + + // Type assertion with explanation: SDK uses 'uuid' for D1 database IDs + const databaseData = database as { uuid: string }; + return { + success: true, + id: databaseData.uuid, + name: `${projectName}-${bindingName}`, + }; + } catch (error: any) { + console.error(`Failed to create D1 database ${bindingName}:`, error); + return { success: false, error: error.message }; + } + }); + + if (d1Result.success) { + result.resourcesCreated.d1.push({ + binding: bindingName, + id: d1Result.id!, + name: d1Result.name!, + }); + } else { + result.errors!.push(`D1 ${bindingName} creation failed: ${d1Result.error}`); + } + } + } + + // Step 4: Create R2 Buckets + if (requestedBindings.r2 && requestedBindings.r2.length > 0) { + for (const bindingName of requestedBindings.r2) { + const r2Result = await step.do(`create-r2-${bindingName}`, async () => { + try { + const cf = new Cloudflare({ apiToken: this.env.CLOUDFLARE_TOKEN }); + const accountId = this.env.CLOUDFLARE_ACCOUNT_ID; + + await cf.r2.buckets.create({ + account_id: accountId, + name: `${projectName}-${bindingName}`, + }); + + return { + success: true, + name: `${projectName}-${bindingName}`, + }; + } catch (error: any) { + console.error(`Failed to create R2 bucket ${bindingName}:`, error); + return { success: false, error: error.message }; + } + }); + + if (r2Result.success) { + result.resourcesCreated.r2.push({ + binding: bindingName, + name: r2Result.name!, + }); + } else { + result.errors!.push(`R2 ${bindingName} creation failed: ${r2Result.error}`); + } + } + } + + // Step 5: Create Queues + if (requestedBindings.queues && requestedBindings.queues.length > 0) { + for (const bindingName of requestedBindings.queues) { + const queueResult = await step.do(`create-queue-${bindingName}`, async () => { + try { + const cf = new Cloudflare({ apiToken: this.env.CLOUDFLARE_TOKEN }); + const accountId = this.env.CLOUDFLARE_ACCOUNT_ID; + + await cf.queues.create({ + account_id: accountId, + body: { + queue_name: `${projectName}-${bindingName}`, + }, + } as any); + + return { + success: true, + name: `${projectName}-${bindingName}`, + }; + } catch (error: any) { + console.error(`Failed to create Queue ${bindingName}:`, error); + return { success: false, error: error.message }; + } + }); + + if (queueResult.success) { + result.resourcesCreated.queues.push({ + binding: bindingName, + name: queueResult.name!, + }); + } else { + result.errors!.push(`Queue ${bindingName} creation failed: ${queueResult.error}`); + } + } + } + + // Step 6: Generate wrangler.jsonc config + result.wranglerConfig = await step.do('generate-config', async () => { + let config = `{\n "name": "${projectName}",\n "main": "src/index.ts",\n "compatibility_date": "2024-06-01",\n`; + + // Add KV namespaces + if (result.resourcesCreated.kv.length > 0) { + config += ` "kv_namespaces": [\n`; + result.resourcesCreated.kv.forEach((kv, index) => { + const comma = index < result.resourcesCreated.kv.length - 1 ? ',' : ''; + config += ` { "binding": "${kv.binding}", "id": "${kv.id}" }${comma}\n`; + }); + config += ` ],\n`; + } + + // Add D1 databases + if (result.resourcesCreated.d1.length > 0) { + config += ` "d1_databases": [\n`; + result.resourcesCreated.d1.forEach((d1, index) => { + const comma = index < result.resourcesCreated.d1.length - 1 ? ',' : ''; + config += ` { "binding": "${d1.binding}", "database_name": "${d1.name}", "database_id": "${d1.id}" }${comma}\n`; + }); + config += ` ],\n`; + } + + // Add R2 buckets + if (result.resourcesCreated.r2.length > 0) { + config += ` "r2_buckets": [\n`; + result.resourcesCreated.r2.forEach((r2, index) => { + const comma = index < result.resourcesCreated.r2.length - 1 ? ',' : ''; + config += ` { "binding": "${r2.binding}", "bucket_name": "${r2.name}" }${comma}\n`; + }); + config += ` ],\n`; + } + + // Add Queues + if (result.resourcesCreated.queues.length > 0) { + config += ` "queues": {\n`; + config += ` "producers": [\n`; + result.resourcesCreated.queues.forEach((queue, index) => { + const comma = index < result.resourcesCreated.queues.length - 1 ? ',' : ''; + config += ` { "binding": "${queue.binding}", "queue": "${queue.name}" }${comma}\n`; + }); + config += ` ]\n`; + config += ` },\n`; + } + + // Remove trailing comma from last section more robustly + config = config.trimEnd(); + if (config.endsWith(',')) { + config = config.slice(0, -1); + } + config += `\n}\n`; + + return config; + }); + + result.success = result.workerCreated && (!result.errors || result.errors.length === 0); + + return result; + } +}