diff --git a/.DS_Store b/.DS_Store index 3b298125..1e7a2d81 100644 Binary files a/.DS_Store and b/.DS_Store differ diff --git a/.gitignore b/.gitignore index 5f7c2cf1..33a0a665 100644 --- a/.gitignore +++ b/.gitignore @@ -2,3 +2,60 @@ /embedding_qs_series_2/node_modules /embedding_jwt/jwt/node_modules embedding_qs_series_2/.env + +# Node.js dependencies - prevent large file issues +node_modules/ +**/node_modules/ + +# Next.js build artifacts and binaries - prevent 123MB file issues +.next/ +.next/** +out/ +build/ +dist/ + +# Large Next.js native binaries that caused previous PR failures +node_modules/@next/swc-darwin-arm64/next-swc.darwin-arm64.node +node_modules/@next/swc-darwin-x64/next-swc.darwin-x64.node +node_modules/@next/swc-linux-x64-gnu/next-swc.linux-x64-gnu.node +node_modules/@next/swc-linux-x64-musl/next-swc.linux-x64-musl.node +node_modules/@next/swc-win32-x64-msvc/next-swc.win32-x64-msvc.node + +# Environment files +.env +.env.local +.env.production +.env.development +**/.env + +# IDE and editor files +.vscode/ +.idea/ +*.swp +*.swo + +# Logs +logs/ +*.log +npm-debug.log* +yarn-debug.log* +yarn-error.log* + +# Runtime data +pids/ +*.pid +*.seed +*.pid.lock + +# Coverage directory used by tools like istanbul +coverage/ +*.lcov + +# OS generated files +.DS_Store +.DS_Store? +._* +.Spotlight-V100 +.Trashes +ehthumbs.db +Thumbs.db diff --git a/recipe-portal/.eslintrc.json b/recipe-portal/.eslintrc.json new file mode 100644 index 00000000..0e81f9b9 --- /dev/null +++ b/recipe-portal/.eslintrc.json @@ -0,0 +1,3 @@ +{ + "extends": "next/core-web-vitals" +} \ No newline at end of file diff --git a/recipe-portal/.gitignore b/recipe-portal/.gitignore new file mode 100644 index 00000000..46ad8fe7 --- /dev/null +++ b/recipe-portal/.gitignore @@ -0,0 +1,45 @@ +# See https://help.github.com/articles/ignoring-files/ for more about ignoring files. + +# dependencies +/node_modules +/.pnp +.pnp.js + +# testing +/coverage + +# next.js +/.next/ +/out/ + +# production +/build + +# misc +.DS_Store +*.pem + +# debug +npm-debug.log* +yarn-debug.log* +yarn-error.log* + +# local env files +.env*.local + +# vercel +.vercel + +# typescript +*.tsbuildinfo +next-env.d.ts + +# Sigma API encrypted credentials (security) +.sigma-portal/ +sigma-portal-keys.json + +# Environment files +.env + +# Claude config +/recipe-portal/.claude \ No newline at end of file diff --git a/recipe-portal/README.md b/recipe-portal/README.md new file mode 100644 index 00000000..ce05f86e --- /dev/null +++ b/recipe-portal/README.md @@ -0,0 +1,129 @@ +# QuickStarts API Toolkit +Experiment with Sigma API calls and learn common request flows + +## Features + +### Recipes: +- **Smart Parameter Detection**: Automatically detects and provides dropdown selection for Sigma resources (teams, members, workbooks, etc.) +- **Interactive Execution**: Run recipes directly in the browser with real-time results +- **Parameter Summary**: View which parameters were used in each request +- **Code Viewing**: Browse the actual JavaScript code for each recipe + +### Quick API Explorer: +- **Common Endpoints**: Curated list of the most useful Sigma API endpoints +- **Zero Setup**: List endpoints require no parameters - perfect for quick exploration +- **One Parameter**: Detail endpoints need just one ID to get specific resource information +- **Alphabetical Organization**: Easy to find the endpoint you need + +## Authentication & Config Management + +### Smart Config System: +- **Complete Configuration Storage**: Server endpoints + API credentials stored together as named "configs" +- **Multi-Environment Support**: Easily switch between Production, Staging, Development environments +- **One-Click Environment Switching**: Load complete configurations instantly +- **Encrypted Local Storage**: AES-256 encryption for credential security + +### Config Management Features: +- **Quick Start**: Load saved configs with one click - no manual entry needed +- **Create New Configs**: Mix and match server endpoints with credentials +- **Update Existing Configs**: Modify and save changes to existing configurations +- **Delete Configs**: Remove configs you no longer need +- **Auto-Save**: Configs saved automatically during authentication when enabled +- **Manual Save**: Explicit save button for immediate config storage + +### Token Management: +- **File-Based Storage**: Authentication tokens cached in system temp directory +- **Persistent Sessions**: Tokens survive browser/server restarts for the full hour +- **Automatic Expiration**: Tokens expire after 1 hour (Sigma's standard lifetime) +- **Auto-Cleanup**: Expired tokens automatically detected and removed +- **Manual Session End**: Clear authentication anytime with 🚪 End Session button + +### Storage Locations + +**Config Storage (encrypted)**: +- **macOS**: `~/Library/Application Support/.sigma-portal/encrypted-keys.json` +- **Windows**: `%APPDATA%\.sigma-portal\encrypted-keys.json` +- **Linux**: `~/.config/.sigma-portal/encrypted-keys.json` + +**Token Cache (temporary)**: +- **macOS**: `/var/folders/.../sigma-portal-token.json` +- **Windows**: `%TEMP%\sigma-portal-token.json` +- **Linux**: `/tmp/sigma-portal-token.json` + +### Developer Experience Benefits +- **Environment Switching**: Instant switch between Production ↔ Staging ↔ Development +- **Zero Re-entry**: Load complete configs without typing credentials repeatedly +- **Secure Storage**: Military-grade AES-256 encryption for stored credentials +- **Clean Separation**: Configs stored outside project directory (never committed to git) +- **Visual Feedback**: Clear indicators show saved/unsaved state and notifications +- **Flexible Workflow**: Session-only credentials OR persistent named configs + +### Config Workflow +1. **First Time**: Enter server endpoint + credentials → Save as named config (e.g., "Production") +2. **Daily Use**: Quick Start → Select "Production" → Instantly loaded and ready +3. **Environment Switch**: Quick Start → Select "Staging" → Switched in one click +4. **New Environment**: "✨ New Config" → Enter details → Save with new name + +## Getting Started +Sigma_QuickStart_Public_Repo + + +1. **Setup**: `npm install && npm run dev` +2. **First-Time Config**: Open any recipe → **Config** tab → Enter server endpoint + credentials → Save as named config +3. **Daily Use**: **Quick Start** section → Select your saved config → Ready to go! +4. **Explore**: Use the ⚔ Quick API tab to explore common endpoints with smart parameters +5. **Run Recipes**: Browse recipes by category and execute them with real-time results + +### Config Tab Features +- **Quick Start**: Load saved configs instantly (appears when configs exist) +- **Server Endpoint**: Choose your Sigma organization's server location +- **API Credentials**: Enter Client ID and Client Secret +- **Config Storage**: Save complete configurations with names like "Production", "Staging" +- **Save Config**: Manual save button for immediate storage +- **New Config**: Clear form to create fresh configurations +- **Delete**: Remove configs you no longer need (šŸ—‘ļø button when config selected) + +## Requirements +- Node.js 18+ +- Sigma API credentials (Client ID and Secret) +- Valid Sigma organization access + +## Development +```bash +npm install +npm run dev +``` + +Navigate to `http://localhost:3001` to start exploring the Sigma API. + +## Project Structure +``` +recipe-portal/ +ā”œā”€ā”€ app/ # Next.js app directory +│ ā”œā”€ā”€ api/ # API routes +│ │ ā”œā”€ā”€ execute/ # Recipe execution +│ │ ā”œā”€ā”€ resources/ # Resource fetching for dropdowns +│ │ ā”œā”€ā”€ keys/ # Config management (CRUD operations) +│ │ ā”œā”€ā”€ token/ # Token management & caching +│ │ └── call/ # Quick API endpoint calls +ā”œā”€ā”€ components/ # React components +│ ā”œā”€ā”€ QuickApiExplorer.tsx # Quick API exploration interface +│ ā”œā”€ā”€ QuickApiModal.tsx # API endpoint execution modal +│ ā”œā”€ā”€ SmartParameterForm.tsx # Smart parameter detection & forms +│ ā”œā”€ā”€ CodeViewer.tsx # Recipe viewer with Config tab +│ ā”œā”€ā”€ AuthRecipeCard.tsx # Authentication recipe card +│ └── RecipeCard.tsx # Standard recipe cards +ā”œā”€ā”€ lib/ # Utilities +│ ā”œā”€ā”€ smartParameters.ts # Parameter detection logic +│ ā”œā”€ā”€ keyStorage.ts # Encrypted config storage +│ └── recipeScanner.ts # Recipe discovery & analysis +└── recipes/ # Self-contained recipe files (copied from sigma-api-recipes) + ā”œā”€ā”€ connections/ # Connection-related recipes + ā”œā”€ā”€ members/ # Member management recipes + ā”œā”€ā”€ teams/ # Team management recipes + ā”œā”€ā”€ workbooks/ # Workbook operations + ā”œā”€ā”€ embedding/ # Embedding examples + └── get-access-token.js # Authentication helper +``` + +For setup instructions and API credential creation, visit the QuickStart: [Sigma REST API Recipes](https://quickstarts.sigmacomputing.com/guide/developers_api_code_samples/index.html?index=..%2F..index#0) \ No newline at end of file diff --git a/recipe-portal/app/api/call/route.ts b/recipe-portal/app/api/call/route.ts new file mode 100644 index 00000000..2cf67e0a --- /dev/null +++ b/recipe-portal/app/api/call/route.ts @@ -0,0 +1,168 @@ +import { NextResponse } from 'next/server'; +import axios from 'axios'; +import fs from 'fs'; +import path from 'path'; +import os from 'os'; + +// Configuration-specific token caching (matches other working routes) +function getTokenCacheFile(clientId: string) { + const configHash = clientId ? clientId.substring(0, 8) : 'default'; + return path.join(os.tmpdir(), `sigma-portal-token-${configHash}.json`); +} + +function getCachedToken(): { token: string; clientId: string } | null { + try { + // Look for the most recent valid token across all configurations + const tempDir = os.tmpdir(); + const files = fs.readdirSync(tempDir); + const tokenFiles = files.filter(file => file.startsWith('sigma-portal-token-') && file.endsWith('.json')); + + let mostRecentToken = null; + let mostRecentTime = 0; + + for (const file of tokenFiles) { + try { + const filePath = path.join(tempDir, file); + const tokenData = JSON.parse(fs.readFileSync(filePath, 'utf8')); + const now = Date.now(); + + // Check if token is still valid (not expired) + if (tokenData.expiresAt && now < tokenData.expiresAt) { + const lastAccessTime = tokenData.lastAccessed || tokenData.createdAt; + + if (lastAccessTime > mostRecentTime) { + mostRecentTime = lastAccessTime; + mostRecentToken = { + token: tokenData.token, + clientId: tokenData.clientId + }; + } + } else { + // Token expired, remove file + fs.unlinkSync(filePath); + } + } catch (err) { + // Skip invalid token files + } + } + + return mostRecentToken; + } catch (error) { + // Ignore errors, just return null + } + return null; +} + +export async function POST(request: Request) { + try { + const { endpoint, method, parameters = {}, requestBody } = await request.json(); + + if (!endpoint) { + return NextResponse.json( + { error: 'Endpoint is required' }, + { status: 400 } + ); + } + + // Get cached token + const tokenData = getCachedToken(); + if (!tokenData) { + return NextResponse.json( + { + error: 'Authentication required', + message: 'No valid authentication token found. Please authenticate first.' + }, + { status: 401 } + ); + } + + // Build the full URL + const baseURL = process.env.SIGMA_BASE_URL || 'https://aws-api.sigmacomputing.com/v2'; + let url = `${baseURL}${endpoint}`; + + // Add query parameters + if (parameters.query && Object.keys(parameters.query).length > 0) { + const queryParams = new URLSearchParams(); + Object.entries(parameters.query).forEach(([key, value]) => { + if (value !== undefined && value !== '') { + queryParams.append(key, String(value)); + } + }); + if (queryParams.toString()) { + url += `?${queryParams.toString()}`; + } + } + + // Prepare headers + const headers: Record = { + 'Authorization': `Bearer ${tokenData.token}`, + 'Accept': 'application/json', + 'Content-Type': 'application/json' + }; + + // Add header parameters + if (parameters.header) { + Object.entries(parameters.header).forEach(([key, value]) => { + if (value !== undefined && value !== '') { + headers[key] = String(value); + } + }); + } + + // Make the API call + const response = await axios({ + method: method.toLowerCase(), + url, + headers, + data: requestBody, + timeout: 30000 // 30 second timeout + }); + + // Return successful response + return NextResponse.json({ + output: JSON.stringify(response.data, null, 2), + error: '', + success: true, + timestamp: new Date().toISOString(), + httpStatus: response.status, + httpStatusText: response.statusText, + requestUrl: url, + requestMethod: method + }); + + } catch (error: any) { + console.error('API call error:', error); + + let errorMessage = 'Unknown error occurred'; + let httpStatus = 500; + let httpStatusText = 'Internal Server Error'; + + if (axios.isAxiosError(error)) { + if (error.response) { + // Server responded with error status + httpStatus = error.response.status; + httpStatusText = error.response.statusText; + errorMessage = error.response.data?.message || error.response.data?.error || `HTTP ${httpStatus}: ${httpStatusText}`; + } else if (error.request) { + // Request made but no response + errorMessage = 'No response received from server'; + httpStatus = 0; + httpStatusText = 'Network Error'; + } else { + // Error setting up request + errorMessage = error.message; + } + } else { + errorMessage = error.message || 'Unknown error'; + } + + return NextResponse.json({ + output: '', + error: errorMessage, + success: false, + timestamp: new Date().toISOString(), + httpStatus, + httpStatusText + }); + } +} \ No newline at end of file diff --git a/recipe-portal/app/api/code/route.ts b/recipe-portal/app/api/code/route.ts new file mode 100644 index 00000000..68d68863 --- /dev/null +++ b/recipe-portal/app/api/code/route.ts @@ -0,0 +1,60 @@ +import { NextResponse } from 'next/server'; +import fs from 'fs'; +import path from 'path'; + +export async function GET(request: Request) { + try { + const { searchParams } = new URL(request.url); + const filePath = searchParams.get('path'); + + if (!filePath) { + return NextResponse.json( + { error: 'File path is required' }, + { status: 400 } + ); + } + + // Security check: ensure the file is within the recipes directory + const recipesPath = path.join(process.cwd(), 'recipes'); + const resolvedPath = path.resolve(filePath); + const resolvedRecipesPath = path.resolve(recipesPath); + + if (!resolvedPath.startsWith(resolvedRecipesPath)) { + return NextResponse.json( + { error: 'Access denied: File must be within recipes directory' }, + { status: 403 } + ); + } + + // Check if file exists and is a JavaScript file + if (!fs.existsSync(resolvedPath)) { + return NextResponse.json( + { error: 'File not found' }, + { status: 404 } + ); + } + + if (!resolvedPath.endsWith('.js')) { + return NextResponse.json( + { error: 'Only JavaScript files are allowed' }, + { status: 400 } + ); + } + + // Read the file content + const content = fs.readFileSync(resolvedPath, 'utf-8'); + + return NextResponse.json({ + content, + filePath: resolvedPath, + timestamp: new Date().toISOString() + }); + + } catch (error) { + console.error('Error reading file:', error); + return NextResponse.json( + { error: 'Failed to read file' }, + { status: 500 } + ); + } +} \ No newline at end of file diff --git a/recipe-portal/app/api/download-stream/route.ts b/recipe-portal/app/api/download-stream/route.ts new file mode 100644 index 00000000..52855d24 --- /dev/null +++ b/recipe-portal/app/api/download-stream/route.ts @@ -0,0 +1,732 @@ +import { NextResponse } from 'next/server'; +import { spawn } from 'child_process'; +import fs from 'fs'; +import path from 'path'; +import os from 'os'; + +export async function POST(request: Request) { + try { + // Log request details for debugging + console.log('Download stream request received from:', request.headers.get('referer')); + console.log('User agent:', request.headers.get('user-agent')); + + // Check if request has a body + const body = await request.text(); + if (!body || body.trim() === '') { + console.warn('Empty request body to download-stream endpoint'); + return NextResponse.json( + { error: 'Request body is empty. This endpoint is only for file download scripts.' }, + { status: 400 } + ); + } + + let parsedBody; + try { + parsedBody = JSON.parse(body); + } catch (parseError) { + return NextResponse.json( + { error: 'Invalid JSON in request body. This endpoint is only for file download scripts.' }, + { status: 400 } + ); + } + + const { filePath, envVariables, filename, contentType } = parsedBody; + + if (!filePath) { + return NextResponse.json( + { error: 'File path is required' }, + { status: 400 } + ); + } + + // Security check: ensure the file is within the recipes directory + const recipesPath = path.join(process.cwd(), 'recipes'); + const resolvedPath = path.resolve(filePath); + const resolvedRecipesPath = path.resolve(recipesPath); + + if (!resolvedPath.startsWith(resolvedRecipesPath)) { + return NextResponse.json( + { error: 'Access denied: File must be within recipes directory' }, + { status: 403 } + ); + } + + // Check if file exists + if (!fs.existsSync(resolvedPath)) { + return NextResponse.json( + { error: 'File not found' }, + { status: 404 } + ); + } + + // Create a readable stream for server-sent events + const stream = new ReadableStream({ + start(controller) { + executeDownloadWithProgress(resolvedPath, envVariables, controller); + } + }); + + return new Response(stream, { + headers: { + 'Content-Type': 'text/event-stream', + 'Cache-Control': 'no-cache', + 'Connection': 'keep-alive', + }, + }); + + } catch (error) { + console.error('Error executing download stream:', error); + return NextResponse.json( + { error: 'Failed to start download stream' }, + { status: 500 } + ); + } +} + +async function executeDownloadWithProgress( + scriptPath: string, + envVariables: Record, + controller: ReadableStreamDefaultController +) { + const scriptDir = path.dirname(scriptPath); + const recipesRoot = path.join(scriptDir, '..'); + + // Create temporary .env file + const tempEnvPath = path.join(os.tmpdir(), `.env-${Date.now()}`); + let envContent = ''; + + if (envVariables && typeof envVariables === 'object') { + for (const [key, value] of Object.entries(envVariables)) { + if (typeof value === 'string') { + envContent += `${key}=${value}\n`; + } + } + } + + // Add common variables if not provided + if (envVariables && !envVariables.authURL && (envVariables.CLIENT_ID || envVariables.SECRET)) { + envContent += `authURL=https://aws-api.sigmacomputing.com/v2/auth/token\n`; + } + if (envVariables && !envVariables.baseURL && (envVariables.CLIENT_ID || envVariables.SECRET)) { + envContent += `baseURL=https://aws-api.sigmacomputing.com/v2\n`; + } + + envContent += `ENV_FILE_PATH=${tempEnvPath}\n`; + + + fs.writeFileSync(tempEnvPath, envContent); + + const sendProgress = (type: string, message: string, data?: any) => { + // Handle large content safely for JSON stringification + let safeData = data; + if (data && data.content && typeof data.content === 'string' && data.content.length > 10000) { + // For large content, create a truncated version for the JSON but keep the full content accessible + safeData = { + ...data, + content: '[Large content: ' + data.content.length + ' characters]', + _fullContent: data.content, // Store full content separately + _isLargeContent: true + }; + } + + try { + const event = `data: ${JSON.stringify({ type, message, data: safeData, timestamp: new Date().toISOString() })}\n\n`; + controller.enqueue(new TextEncoder().encode(event)); + } catch (error) { + // Fallback for JSON stringification errors + const fallbackEvent = `data: ${JSON.stringify({ type, message: message + ' (JSON error)', timestamp: new Date().toISOString() })}\n\n`; + controller.enqueue(new TextEncoder().encode(fallbackEvent)); + } + }; + + try { + sendProgress('info', 'Using cached authentication token'); + + // Create wrapper script for streaming progress + const scriptName = path.basename(scriptPath); + const wrapperScript = ` +process.chdir('${recipesRoot}'); + +const fs = require('fs'); +const path = require('path'); +const os = require('os'); + +// Set up environment variables +const envContent = fs.readFileSync('${tempEnvPath}', 'utf-8'); +const envLines = envContent.split('\\n'); + +envLines.forEach(line => { + const match = line.match(/^([^=]+)=(.*)$/); + if (match) { + process.env[match[1]] = match[2]; + } +}); + +// Token caching +function getTokenCacheFile(clientId: string) { + const configHash = clientId ? clientId.substring(0, 8) : "default"; + return path.join(os.tmpdir(), `sigma-portal-token-${configHash}.json`); +} + +function getCachedToken(): { token: string; clientId: string } | null { + try { + // Look for the most recent valid token across all configurations + const tempDir = os.tmpdir(); + const files = fs.readdirSync(tempDir); + const tokenFiles = files.filter(file => file.startsWith('sigma-portal-token-') && file.endsWith('.json')); + + let mostRecentToken = null; + let mostRecentTime = 0; + + for (const file of tokenFiles) { + try { + const filePath = path.join(tempDir, file); + const tokenData = JSON.parse(fs.readFileSync(filePath, 'utf8')); + const now = Date.now(); + + // Check if token is still valid (not expired) + if (tokenData.expiresAt && now < tokenData.expiresAt) { + const lastAccessTime = tokenData.lastAccessed || tokenData.createdAt; + + if (lastAccessTime > mostRecentTime) { + mostRecentTime = lastAccessTime; + mostRecentToken = { + token: tokenData.token, + clientId: tokenData.clientId + }; + } + } else { + // Token expired, remove file + fs.unlinkSync(filePath); + } + } catch (err) { + // Skip invalid token files + } + } + + return mostRecentToken; + } catch (error) { + // Ignore errors, just return null + } + return null; + } else { + fs.unlinkSync(TOKEN_CACHE_FILE); + } + } + } catch (error) {} + return null; +} + +// Global variables for capture +global.DOWNLOAD_CONTENT = null; +global.DOWNLOAD_FILENAME = null; +global.STREAM_FINISHED = false; +global.CAPTURE_IN_PROGRESS = false; + +// Override console.log to capture progress +const originalConsoleLog = console.log; +console.log = function(...args) { + const message = args.map(arg => String(arg)).join(' '); + + // For debugging - show ALL messages for now + process.stdout.write('PROGRESS:debug:' + message + '\\n'); + + // Also call original for any other logging + originalConsoleLog.apply(console, args); +}; + +// File capture system +const originalWriteFileSync = fs.writeFileSync; +const originalCreateWriteStream = fs.createWriteStream; + +fs.writeFileSync = function(filePath, data, options) { + if (filePath.endsWith('.json')) { + global.DOWNLOAD_CONTENT = typeof data === 'string' ? data : JSON.stringify(data, null, 2); + global.DOWNLOAD_FILENAME = path.basename(filePath); + + // Write download data to temp file instead of stdout to avoid truncation + const downloadData = { + content: global.DOWNLOAD_CONTENT, + filename: global.DOWNLOAD_FILENAME + }; + + try { + const resultFile = require('path').join(require('os').tmpdir(), \`download-result-\${Date.now()}.json\`); + require('fs').writeFileSync(resultFile, JSON.stringify(downloadData)); + process.stdout.write('DOWNLOAD_FILE:' + resultFile + '\\n'); + } catch (err) { + process.stdout.write(\`PROGRESS:error:Failed to write download result: \${err.message}\\n\`); + } + + process.exit(0); // Exit immediately after successful capture + return; + } + return originalWriteFileSync.call(this, filePath, data, options); +}; + +fs.createWriteStream = function(filePath, options) { + global.DOWNLOAD_FILENAME = path.basename(filePath); + global.DOWNLOAD_FILEPATH = filePath; + const tempFilePath = filePath + '.temp'; + + const realStream = originalCreateWriteStream.call(this, tempFilePath, options); + let totalBytesWritten = 0; + let writeCount = 0; + let lastWriteTime = Date.now(); + let inactivityTimer = null; + + const finishDownload = () => { + if (global.CAPTURE_IN_PROGRESS) return; // Prevent multiple captures + global.CAPTURE_IN_PROGRESS = true; + + process.stdout.write('PROGRESS:info:Finishing download, reading file...\\n'); + try { + realStream.end(); + setTimeout(() => { + process.stdout.write(\`PROGRESS:debug:Looking for temp file at: \${tempFilePath}\\n\`); + if (fs.existsSync(tempFilePath)) { + const fileData = fs.readFileSync(tempFilePath); + process.stdout.write(\`PROGRESS:debug:Successfully read \${fileData.length} bytes from temp file\\n\`); + global.DOWNLOAD_CONTENT = fileData.toString('base64'); + global.STREAM_FINISHED = true; + + // Write download data to temp file instead of stdout to avoid truncation + const downloadData = { + content: global.DOWNLOAD_CONTENT, + filename: global.DOWNLOAD_FILENAME || 'export.pdf' + }; + + try { + const os = require('os'); + const path = require('path'); + const fs = require('fs'); + const tempDir = os.tmpdir(); + const resultFile = path.join(tempDir, \`download-result-\${Date.now()}-\${Math.random().toString(36).substring(7)}.json\`); + + fs.writeFileSync(resultFile, JSON.stringify(downloadData)); + process.stdout.write('DOWNLOAD_FILE:' + resultFile + '\\n'); + } catch (err) { + process.stdout.write(\`PROGRESS:error:Failed to write download result: \${err.message}\\n\`); + } + + try { fs.unlinkSync(tempFilePath); } catch (e) {} + process.exit(0); // Exit immediately after successful capture + } + }, 500); + } catch (err) { + process.stdout.write(\`PROGRESS:error:Error finishing download: \${err.message}\\n\`); + } + }; + + const mockStream = { + write: function(chunk) { + writeCount++; + totalBytesWritten += chunk.length; + lastWriteTime = Date.now(); + + // Clear any existing inactivity timer + if (inactivityTimer) { + clearTimeout(inactivityTimer); + } + + // Set a new inactivity timer - if no writes for 3 seconds, consider download complete + inactivityTimer = setTimeout(() => { + process.stdout.write('PROGRESS:info:Download appears complete (3s inactivity)\\n'); + finishDownload(); + }, 3000); + + // Show progress for first write and every 1000 writes to avoid spam + if (writeCount === 1 || writeCount % 1000 === 0) { + process.stdout.write(\`PROGRESS:info:Downloaded \${Math.round(totalBytesWritten/1024)}KB...\\n\`); + } + + return realStream.write(chunk); + }, + end: function(chunk) { + if (chunk) { + totalBytesWritten += chunk.length; + } + return realStream.end(chunk); + }, + on: function(event, callback) { + if (event === 'finish') { + realStream.on('finish', () => { + if (global.CAPTURE_IN_PROGRESS) return; // Prevent multiple captures + global.CAPTURE_IN_PROGRESS = true; + + process.stdout.write('PROGRESS:info:Stream finished, capturing file...\\n'); + try { + process.stdout.write(\`PROGRESS:debug:Stream finish - looking for temp file at: \${tempFilePath}\\n\`); + if (fs.existsSync(tempFilePath)) { + const fileData = fs.readFileSync(tempFilePath); + process.stdout.write(\`PROGRESS:debug:Stream finish - successfully read \${fileData.length} bytes\\n\`); + global.DOWNLOAD_CONTENT = fileData.toString('base64'); + + // Write download data to temp file instead of stdout to avoid truncation + const downloadData = { + content: global.DOWNLOAD_CONTENT, + filename: global.DOWNLOAD_FILENAME || 'export.pdf' + }; + + try { + const os = require('os'); + const path = require('path'); + const fs = require('fs'); + const tempDir = os.tmpdir(); + const resultFile = path.join(tempDir, \`download-result-\${Date.now()}-\${Math.random().toString(36).substring(7)}.json\`); + + // Validate content first + if (!downloadData.content) { + throw new Error('Download content is null or undefined'); + } + + // Log details in one write to reduce race conditions + const debugInfo = [ + 'PROGRESS:debug:About to write result file: ' + resultFile, + 'PROGRESS:debug:Content length: ' + downloadData.content.length + ' chars', + 'PROGRESS:debug:Filename: ' + downloadData.filename + ].join('\\n') + '\\n'; + process.stdout.write(debugInfo); + + // Create JSON and write file + const jsonData = JSON.stringify(downloadData); + process.stdout.write('PROGRESS:debug:JSON data size: ' + jsonData.length + ' chars\\n'); + + // Write the file synchronously + fs.writeFileSync(resultFile, jsonData, 'utf8'); + + // Verify the file was written correctly + if (!fs.existsSync(resultFile)) { + throw new Error('Result file was not created'); + } + + const fileSize = fs.statSync(resultFile).size; + if (fileSize === 0) { + throw new Error('Result file is empty'); + } + + // Success - output file path and success message + const successInfo = [ + 'PROGRESS:debug:Result file written successfully (size: ' + fileSize + ' bytes)', + 'DOWNLOAD_FILE:' + resultFile + ].join('\\n') + '\\n'; + process.stdout.write(successInfo); + + } catch (err) { + const errorInfo = [ + 'PROGRESS:error:Failed to write download result: ' + err.message, + 'PROGRESS:error:Stack: ' + err.stack, + 'PROGRESS:error:Content available: ' + !!downloadData.content, + 'PROGRESS:error:Content length: ' + (downloadData.content ? downloadData.content.length : 'N/A') + ].join('\\n') + '\\n'; + process.stdout.write(errorInfo); + } + + try { fs.unlinkSync(tempFilePath); } catch (e) {} + + // Ensure stdout is flushed before exit + process.stdout.write('', () => { + process.exit(0); + }); + } + callback(); + } catch (err) { + process.stdout.write(\`PROGRESS:error:Error reading temp file: \${err.message}\\n\`); + callback(); + } + }); + return this; + } + if (event === 'error') { + realStream.on('error', callback); + return this; + } + return realStream.on(event, callback); + }, + once: function(event, callback) { + return realStream.once(event, callback); + }, + pipe: function(source) { + return source.pipe(realStream); + }, + close: function() { + return realStream.close(); + }, + destroy: function() { + return realStream.destroy(); + }, + writable: true, + readable: false + }; + + // Ensure the mock stream has all necessary EventEmitter methods + Object.setPrototypeOf(mockStream, realStream); + + return mockStream; +}; + +// Get cached token and execute +const cachedToken = getCachedToken(); +if (cachedToken) { + + let scriptContent = fs.readFileSync('${scriptPath}', 'utf-8'); + + const modifiedScript = scriptContent.replace( + /const getBearerToken = require\\(['"][^'"]*get-access-token['"]\\);/g, + 'const getBearerToken = async () => { return "' + cachedToken + '"; };' + ).replace( + /if \\(require\\.main === module\\) \\{([\\s\\S]*?)\\}/g, + '{ $1 }' + ).replace( + // Change the 10 second delay to 30 seconds for large datasets + /setTimeout\\(resolve, 10000\\)/g, + 'setTimeout(resolve, 30000)' + ).replace( + // Also update any 10000 millisecond delays + /await new Promise\\(resolve => setTimeout\\(resolve, 10000\\)\\)/g, + 'await new Promise(resolve => setTimeout(resolve, 30000))' + ); + + const tempScriptPath = '${scriptPath}' + '.stream.js'; + fs.writeFileSync(tempScriptPath, modifiedScript); + + try { + + delete require.cache[require.resolve(tempScriptPath)]; + require(tempScriptPath); + + // Check for completion + let checkCount = 0; + const maxChecks = 30; // 4 minutes max + + const checkForCompletion = () => { + checkCount++; + + if (global.DOWNLOAD_CONTENT) { + process.stdout.write('DOWNLOAD_RESULT:' + JSON.stringify({ + content: global.DOWNLOAD_CONTENT, + filename: global.DOWNLOAD_FILENAME || 'export.pdf' + }) + '\\n'); + process.exit(0); + } else if (checkCount >= maxChecks) { + process.stdout.write('PROGRESS:timeout:Download timeout - export may have failed\\n'); + process.exit(1); + } else { + setTimeout(checkForCompletion, 8000); // Check every 8 seconds + } + }; + + setTimeout(checkForCompletion, 10000); // Wait 10 seconds for stream to finish before first check + + } finally { + try { + fs.unlinkSync(tempScriptPath); + } catch (err) {} + } +} else { + process.stdout.write('PROGRESS:error:No cached authentication token found\\n'); + process.exit(1); +} +`; + + const tempScriptPath = path.join(os.tmpdir(), `temp-stream-wrapper-${Date.now()}.js`); + fs.writeFileSync(tempScriptPath, wrapperScript); + + const child = spawn('node', [tempScriptPath], { + cwd: recipesRoot, + timeout: 600000, // 10 minute timeout for large datasets + }); + + let fileContent: string | null = null; + let filename: string | null = null; + + let downloadResultCapture = false; + let capturedFilename = ''; + let capturedContent = ''; + let downloadCompleted = false; // Flag to prevent duplicate success messages + + child.stdout?.on('data', (data) => { + const output = data.toString(); + const lines = output.split('\n'); + + for (const line of lines) { + if (line === 'DOWNLOAD_RESULT_START') { + downloadResultCapture = true; + sendProgress('info', 'Capturing download result...'); + } else if (line === 'DOWNLOAD_RESULT_END') { + downloadResultCapture = false; + // Process the captured data immediately + if (capturedFilename && capturedContent) { + try { + // Write content to JSON file and use the existing DOWNLOAD_FILE protocol + const tempResultPath = path.join(os.tmpdir(), `download-result-${Date.now()}.json`); + const downloadData = { + content: capturedContent, + filename: capturedFilename + }; + fs.writeFileSync(tempResultPath, JSON.stringify(downloadData)); + + // Don't use sendProgress for DOWNLOAD_FILE - it needs to be processed differently + // Store the file path for later processing + fileContent = capturedContent; + filename = capturedFilename; + sendProgress('debug', `Stored fileContent length: ${fileContent?.length || 0}, filename: ${filename || 'none'}`); + + // Also store as global variables as backup + (global as any).FINAL_DOWNLOAD_CONTENT = capturedContent; + (global as any).FINAL_DOWNLOAD_FILENAME = capturedFilename; + + sendProgress('success', 'Download completed!', { + filename: capturedFilename, + size: Math.round(capturedContent.length * 0.75) // Rough base64 to bytes + }); + + downloadCompleted = true; // Mark as completed to prevent duplicate messages + + } catch (err) { + sendProgress('error', 'Failed to process download result: ' + (err instanceof Error ? err.message : String(err))); + } + } + } else if (downloadResultCapture) { + if (line.startsWith('FILENAME:')) { + capturedFilename = line.substring(9); + sendProgress('debug', `Captured filename: ${capturedFilename}`); + } else if (line.startsWith('CONTENT:')) { + capturedContent = line.substring(8); + sendProgress('debug', `Captured content length: ${capturedContent.length} chars`); + } else if (line.trim() && capturedContent) { + // Append additional lines that are part of the base64 content + capturedContent += line; + sendProgress('debug', `Appended content, total length: ${capturedContent.length} chars`); + } + } else if (line.startsWith('PROGRESS:')) { + const [, type, message] = line.split(':', 3); + sendProgress(type, message); + } else if (line.startsWith('DOWNLOAD_FILE:')) { + try { + const filePath = line.substring(14); + sendProgress('debug', `Reading download file: ${filePath}`); + + if (!fs.existsSync(filePath)) { + throw new Error(`Download file does not exist: ${filePath}`); + } + + const fileStats = fs.statSync(filePath); + sendProgress('debug', `File size: ${fileStats.size} bytes`); + + const downloadData = JSON.parse(fs.readFileSync(filePath, 'utf8')); + fileContent = downloadData.content; + filename = downloadData.filename; + + sendProgress('debug', `File content length: ${fileContent ? fileContent.length : 'null'}`); + sendProgress('debug', `Filename: ${filename}`); + + sendProgress('success', 'Download completed!', { + filename, + size: Math.round((fileContent?.length || 0) * 0.75) // Rough base64 to bytes + }); + + // Clean up temp file + try { fs.unlinkSync(filePath); } catch (e) {} + } catch (e) { + sendProgress('error', `Failed to read download result file: ${e instanceof Error ? e.message : String(e)}`); + } + } else if (line.startsWith('DOWNLOAD_RESULT:')) { + // Keep old method as fallback for smaller files + try { + const jsonString = line.substring(16); + const downloadData = JSON.parse(jsonString); + fileContent = downloadData.content; + filename = downloadData.filename; + sendProgress('success', 'Download completed!', { + filename, + size: Math.round((fileContent?.length || 0) * 0.75) // Rough base64 to bytes + }); + } catch (e) { + sendProgress('error', `Failed to parse download result: ${e instanceof Error ? e.message : String(e)}`); + } + } + } + }); + + child.stderr?.on('data', (data) => { + const output = data.toString(); + // Handle our direct log messages separately from real errors + if (output.includes('DIRECT_LOG:')) { + const message = output.replace('DIRECT_LOG:', '').trim(); + sendProgress('info', message); + } else { + sendProgress('error', `Error: ${output}`); + } + }); + + child.on('close', (code) => { + // Clean up + try { + fs.unlinkSync(tempScriptPath); + fs.unlinkSync(tempEnvPath); + } catch (err) {} + + sendProgress('debug', `Process closed with code: ${code}`); + sendProgress('debug', `File content available: ${!!fileContent}`); + sendProgress('debug', `Filename: ${filename || 'none'}`); + + // Check backup global variables if local ones are empty + if (!fileContent && (global as any).FINAL_DOWNLOAD_CONTENT) { + fileContent = (global as any).FINAL_DOWNLOAD_CONTENT; + filename = (global as any).FINAL_DOWNLOAD_FILENAME; + sendProgress('debug', `Using backup global variables - content length: ${fileContent?.length || 0}, filename: ${filename || 'none'}`); + } + + if (downloadCompleted) { + // Download was already processed successfully via DOWNLOAD_RESULT protocol + sendProgress('debug', 'Download already completed via DOWNLOAD_RESULT protocol'); + } else if (code === 0 && fileContent) { + try { + // Read content from temp file if it's a file path, otherwise treat as direct content + let actualContent: string; + if (fileContent.startsWith('/') && fs.existsSync(fileContent)) { + // Read from temp file + actualContent = fs.readFileSync(fileContent, 'utf8'); + // Clean up temp file + fs.unlinkSync(fileContent); + } else { + // Direct content (fallback) + actualContent = fileContent; + } + + // Simple completion message - file is already saved locally by the recipe + sendProgress('success', `File "${filename}" saved successfully!`, { + filename: filename, + localPath: path.resolve('downloaded-files', filename || 'download'), + size: Math.round(actualContent.length * 0.75) // Rough base64 to bytes + }); + } catch (err) { + sendProgress('error', `Failed to process download file: ${err instanceof Error ? err.message : String(err)}`); + } + } else if (code !== 0) { + sendProgress('error', `Process exited with code ${code}`); + } else if (!fileContent && !downloadCompleted) { + sendProgress('error', 'No file content captured'); + } + + controller.close(); + }); + + child.on('error', (error) => { + sendProgress('error', `Execution error: ${error.message}`); + controller.close(); + }); + + } catch (error) { + sendProgress('error', `Failed to start download: ${error}`); + controller.close(); + } +} + +function getContentTypeFromFilename(filename: string): string { + if (filename.endsWith('.pdf')) return 'application/pdf'; + if (filename.endsWith('.csv')) return 'text/csv'; + if (filename.endsWith('.json')) return 'application/json'; + return 'application/octet-stream'; +} \ No newline at end of file diff --git a/recipe-portal/app/api/download-stream/route.ts.backup b/recipe-portal/app/api/download-stream/route.ts.backup new file mode 100644 index 00000000..7753ae73 --- /dev/null +++ b/recipe-portal/app/api/download-stream/route.ts.backup @@ -0,0 +1,445 @@ +import { NextResponse } from 'next/server'; +import { spawn } from 'child_process'; +import fs from 'fs'; +import path from 'path'; +import os from 'os'; + +export async function POST(request: Request) { + try { + const { filePath, envVariables, filename, contentType } = await request.json(); + + if (!filePath) { + return NextResponse.json( + { error: 'File path is required' }, + { status: 400 } + ); + } + + // Security check: ensure the file is within the recipes directory + const recipesPath = path.join(process.cwd(), 'recipes'); + const resolvedPath = path.resolve(filePath); + const resolvedRecipesPath = path.resolve(recipesPath); + + if (!resolvedPath.startsWith(resolvedRecipesPath)) { + return NextResponse.json( + { error: 'Access denied: File must be within recipes directory' }, + { status: 403 } + ); + } + + // Check if file exists + if (!fs.existsSync(resolvedPath)) { + return NextResponse.json( + { error: 'File not found' }, + { status: 404 } + ); + } + + // Create temporary .env file with provided variables + const tempEnvPath = path.join(os.tmpdir(), `.env-${Date.now()}`); + let envContent = ''; + + if (envVariables && typeof envVariables === 'object') { + for (const [key, value] of Object.entries(envVariables)) { + if (typeof value === 'string') { + envContent += `${key}=${value}\n`; + } + } + } + + // Add common variables if not provided + if (envVariables && !envVariables.authURL && (envVariables.CLIENT_ID || envVariables.SECRET)) { + envContent += `authURL=https://aws-api.sigmacomputing.com/v2/auth/token\n`; + } + if (envVariables && !envVariables.baseURL && (envVariables.CLIENT_ID || envVariables.SECRET)) { + envContent += `baseURL=https://aws-api.sigmacomputing.com/v2\n`; + } + + // Add the path to the env file in the content + envContent += `ENV_FILE_PATH=${tempEnvPath}\n`; + + fs.writeFileSync(tempEnvPath, envContent); + + // Execute the script and capture file content + const result = await executeDownloadScript(resolvedPath, tempEnvPath); + + // Clean up temp file + try { + fs.unlinkSync(tempEnvPath); + } catch (err) { + console.warn('Failed to cleanup temp env file:', err); + } + + if (result.success && result.fileContent) { + // Return the file content for browser download + return NextResponse.json({ + fileContent: result.fileContent, + filename: filename || 'download', + contentType: contentType || 'application/octet-stream', + success: true, + output: result.stdout, + timestamp: new Date().toISOString() + }); + } else { + return NextResponse.json({ + output: result.stdout, + error: result.stderr, + success: false, + timestamp: new Date().toISOString(), + httpStatus: 500, + httpStatusText: 'Download Failed' + }); + } + + } catch (error) { + console.error('Error executing download script:', error); + return NextResponse.json( + { error: 'Failed to execute download script' }, + { status: 500 } + ); + } +} + +function executeDownloadScript(scriptPath: string, envFilePath: string): Promise<{ + stdout: string; + stderr: string; + success: boolean; + fileContent?: string; +}> { + return new Promise((resolve) => { + const scriptDir = path.dirname(scriptPath); + const recipesRoot = path.join(scriptDir, '..'); + + // Create a wrapper script that captures file content instead of writing to disk + const scriptName = path.basename(scriptPath); + const wrapperScript = ` +// Change to the recipes directory for proper module resolution +process.chdir('${recipesRoot}'); + +// Import required modules +const fs = require('fs'); +const path = require('path'); +const os = require('os'); + +// Set up environment variables from our temp file +const envContent = fs.readFileSync('${envFilePath}', 'utf-8'); +const envLines = envContent.split('\\n'); + +envLines.forEach(line => { + const match = line.match(/^([^=]+)=(.*)$/); + if (match) { + process.env[match[1]] = match[2]; + } +}); + +// File-based token caching +function getTokenCacheFile(clientId: string) { + const configHash = clientId ? clientId.substring(0, 8) : "default"; + return path.join(os.tmpdir(), `sigma-portal-token-${configHash}.json`); +} + +function getCachedToken(): { token: string; clientId: string } | null { + try { + // Look for the most recent valid token across all configurations + const tempDir = os.tmpdir(); + const files = fs.readdirSync(tempDir); + const tokenFiles = files.filter(file => file.startsWith('sigma-portal-token-') && file.endsWith('.json')); + + let mostRecentToken = null; + let mostRecentTime = 0; + + for (const file of tokenFiles) { + try { + const filePath = path.join(tempDir, file); + const tokenData = JSON.parse(fs.readFileSync(filePath, 'utf8')); + const now = Date.now(); + + // Check if token is still valid (not expired) + if (tokenData.expiresAt && now < tokenData.expiresAt) { + const lastAccessTime = tokenData.lastAccessed || tokenData.createdAt; + + if (lastAccessTime > mostRecentTime) { + mostRecentTime = lastAccessTime; + mostRecentToken = { + token: tokenData.token, + clientId: tokenData.clientId + }; + } + } else { + // Token expired, remove file + fs.unlinkSync(filePath); + } + } catch (err) { + // Skip invalid token files + } + } + + return mostRecentToken; + } catch (error) { + // Ignore errors, just return null + } + return null; +} + } else { + fs.unlinkSync(TOKEN_CACHE_FILE); + } + } + } catch (error) { + // Ignore errors + } + return null; +} + +function cacheToken(token) { + try { + const tokenData = { + token: token, + expiresAt: Date.now() + (60 * 60 * 1000), + createdAt: Date.now() + }; + fs.writeFileSync(TOKEN_CACHE_FILE, JSON.stringify(tokenData)); + } catch (error) { + console.error('Failed to cache token:', error.message); + } +} + +// Global variable to capture file content for download +global.DOWNLOAD_CONTENT = null; +global.DOWNLOAD_FILENAME = null; + +// Override file writing functions to capture content +const originalWriteFileSync = fs.writeFileSync; +const originalCreateWriteStream = fs.createWriteStream; +const originalReadFileSync = fs.readFileSync; +const originalUnlinkSync = fs.unlinkSync; + +console.log('WRAPPER: Setting up filesystem overrides'); + +fs.writeFileSync = function(filePath, data, options) { + // For JSON files, capture the content + if (filePath.endsWith('.json')) { + global.DOWNLOAD_CONTENT = typeof data === 'string' ? data : JSON.stringify(data, null, 2); + global.DOWNLOAD_FILENAME = path.basename(filePath); + console.log(\`Download ready: \${global.DOWNLOAD_FILENAME}\`); + return; + } + // For other files, use original behavior as fallback + return originalWriteFileSync.call(this, filePath, data, options); +}; + +// Override stream writing for binary files +fs.createWriteStream = function(filePath, options) { + console.log(\`WRAPPER: Intercepted createWriteStream for: \${path.basename(filePath)}\`); + global.DOWNLOAD_FILENAME = path.basename(filePath); + global.DOWNLOAD_FILEPATH = filePath; + + // Use a temporary file to capture the actual data + const tempFilePath = filePath + '.temp'; + const realStream = originalCreateWriteStream.call(this, tempFilePath, options); + + // Create a proper writable stream proxy that captures completion + const mockStream = { + write: function(chunk) { + return realStream.write(chunk); + }, + end: function(chunk) { + if (chunk) realStream.write(chunk); + return realStream.end(); + }, + destroy: function() { + return realStream.destroy(); + }, + on: function(event, callback) { + if (event === 'finish') { + // When the real stream finishes, read the file and store content + realStream.on('finish', () => { + console.log(\`WRAPPER: Reading completed file...\`); + try { + // Give the filesystem a moment to flush + setTimeout(() => { + if (fs.existsSync(tempFilePath)) { + const fileData = originalReadFileSync(tempFilePath); + global.DOWNLOAD_CONTENT = fileData.toString('base64'); + console.log(\`WRAPPER: Successfully captured \${fileData.length} bytes\`); + console.log(\`Download ready: \${global.DOWNLOAD_FILENAME}\`); + // Clean up temp file + try { originalUnlinkSync(tempFilePath); } catch (e) {} + } else { + console.error(\`WRAPPER: Temp file missing: \${tempFilePath}\`); + } + // Always call the callback to let the recipe know we're done + if (callback) callback(); + }, 200); + } catch (err) { + console.error('WRAPPER: Error reading file:', err); + if (callback) callback(); + } + }); + return this; + } + return realStream.on(event, callback); + }, + // Implement writable stream interface properly + writable: true, + readable: false, + close: function() { + return realStream.close(); + } + }; + + return mockStream; +}; + +// Override getBearerToken function for cached tokens +async function getBearerToken() { + const cached = getCachedToken(); + if (cached) { + return cached; + } + + const originalConsoleLog = console.log; + const originalConsoleError = console.error; + console.log = () => {}; + console.error = () => {}; + + const originalGetBearerToken = require('${recipesRoot}/get-access-token'); + const newToken = await originalGetBearerToken(); + + console.log = originalConsoleLog; + console.error = originalConsoleError; + + if (newToken) { + cacheToken(newToken); + } + + return newToken; +} + +// Execute the script +try { + const cachedToken = getCachedToken(); + + if (cachedToken) { + console.log('Using cached authentication token for download'); + + let scriptContent = fs.readFileSync('${scriptPath}', 'utf-8'); + + // Replace the getBearerToken import with cached token + const modifiedScript = scriptContent.replace( + /const getBearerToken = require\\(['"][^'"]*get-access-token['"]\\);/g, + 'const getBearerToken = async () => { return "' + cachedToken + '"; };' + ).replace( + /if \\(require\\.main === module\\) \\{([\\s\\S]*?)\\}/g, + '{ $1 }' + ); + + const tempScriptPath = '${scriptPath}' + '.download.js'; + fs.writeFileSync(tempScriptPath, modifiedScript); + + try { + delete require.cache[require.resolve(tempScriptPath)]; + require(tempScriptPath); + + // Wait longer for async operations to complete (PDF exports can take time) + let checkCount = 0; + const maxChecks = 30; // 30 checks * 2 seconds = 60 seconds max + + const checkForCompletion = () => { + checkCount++; + if (global.DOWNLOAD_CONTENT) { + console.log('DOWNLOAD_RESULT:' + JSON.stringify({ + content: global.DOWNLOAD_CONTENT, + filename: global.DOWNLOAD_FILENAME + })); + process.exit(0); + } else if (checkCount >= maxChecks) { + console.log('Download timeout - export may have failed or taken too long'); + process.exit(1); + } else { + // Check again in 2 seconds + setTimeout(checkForCompletion, 2000); + } + }; + + // Start checking after initial delay + setTimeout(checkForCompletion, 3000); + + } finally { + try { + fs.unlinkSync(tempScriptPath); + } catch (err) {} + } + } else { + console.log('No cached token found for download script.'); + process.exit(1); + } +} catch (error) { + console.error('Script execution error:', error.message); + process.exit(1); +} +`; + + const tempScriptPath = path.join(os.tmpdir(), `temp-download-wrapper-${Date.now()}.js`); + fs.writeFileSync(tempScriptPath, wrapperScript); + + const child = spawn('node', [tempScriptPath], { + cwd: recipesRoot, + timeout: 120000, // 120 second timeout for downloads (PDF exports can take time) + }); + + let stdout = ''; + let stderr = ''; + let fileContent: string | null = null; + + child.stdout?.on('data', (data) => { + const output = data.toString(); + stdout += output; + + // Look for download result in output + const downloadMatch = output.match(/DOWNLOAD_RESULT:(.+)/); + if (downloadMatch) { + try { + const downloadData = JSON.parse(downloadMatch[1]); + fileContent = downloadData.content; + } catch (e) { + console.error('Failed to parse download result:', e); + } + } + }); + + child.stderr?.on('data', (data) => { + stderr += data.toString(); + }); + + child.on('close', (code) => { + // Clean up temp script file + try { + fs.unlinkSync(tempScriptPath); + } catch (err) { + console.warn('Failed to cleanup temp script file:', err); + } + + resolve({ + stdout: stdout || 'Download script executed', + stderr: stderr || '', + success: code === 0 && fileContent !== null, + fileContent: fileContent || undefined + }); + }); + + child.on('error', (error) => { + // Clean up temp script file + try { + fs.unlinkSync(tempScriptPath); + } catch (err) { + console.warn('Failed to cleanup temp script file:', err); + } + + resolve({ + stdout: '', + stderr: `Execution error: ${error.message}`, + success: false + }); + }); + }); +} \ No newline at end of file diff --git a/recipe-portal/app/api/download/route.ts b/recipe-portal/app/api/download/route.ts new file mode 100644 index 00000000..7753ae73 --- /dev/null +++ b/recipe-portal/app/api/download/route.ts @@ -0,0 +1,445 @@ +import { NextResponse } from 'next/server'; +import { spawn } from 'child_process'; +import fs from 'fs'; +import path from 'path'; +import os from 'os'; + +export async function POST(request: Request) { + try { + const { filePath, envVariables, filename, contentType } = await request.json(); + + if (!filePath) { + return NextResponse.json( + { error: 'File path is required' }, + { status: 400 } + ); + } + + // Security check: ensure the file is within the recipes directory + const recipesPath = path.join(process.cwd(), 'recipes'); + const resolvedPath = path.resolve(filePath); + const resolvedRecipesPath = path.resolve(recipesPath); + + if (!resolvedPath.startsWith(resolvedRecipesPath)) { + return NextResponse.json( + { error: 'Access denied: File must be within recipes directory' }, + { status: 403 } + ); + } + + // Check if file exists + if (!fs.existsSync(resolvedPath)) { + return NextResponse.json( + { error: 'File not found' }, + { status: 404 } + ); + } + + // Create temporary .env file with provided variables + const tempEnvPath = path.join(os.tmpdir(), `.env-${Date.now()}`); + let envContent = ''; + + if (envVariables && typeof envVariables === 'object') { + for (const [key, value] of Object.entries(envVariables)) { + if (typeof value === 'string') { + envContent += `${key}=${value}\n`; + } + } + } + + // Add common variables if not provided + if (envVariables && !envVariables.authURL && (envVariables.CLIENT_ID || envVariables.SECRET)) { + envContent += `authURL=https://aws-api.sigmacomputing.com/v2/auth/token\n`; + } + if (envVariables && !envVariables.baseURL && (envVariables.CLIENT_ID || envVariables.SECRET)) { + envContent += `baseURL=https://aws-api.sigmacomputing.com/v2\n`; + } + + // Add the path to the env file in the content + envContent += `ENV_FILE_PATH=${tempEnvPath}\n`; + + fs.writeFileSync(tempEnvPath, envContent); + + // Execute the script and capture file content + const result = await executeDownloadScript(resolvedPath, tempEnvPath); + + // Clean up temp file + try { + fs.unlinkSync(tempEnvPath); + } catch (err) { + console.warn('Failed to cleanup temp env file:', err); + } + + if (result.success && result.fileContent) { + // Return the file content for browser download + return NextResponse.json({ + fileContent: result.fileContent, + filename: filename || 'download', + contentType: contentType || 'application/octet-stream', + success: true, + output: result.stdout, + timestamp: new Date().toISOString() + }); + } else { + return NextResponse.json({ + output: result.stdout, + error: result.stderr, + success: false, + timestamp: new Date().toISOString(), + httpStatus: 500, + httpStatusText: 'Download Failed' + }); + } + + } catch (error) { + console.error('Error executing download script:', error); + return NextResponse.json( + { error: 'Failed to execute download script' }, + { status: 500 } + ); + } +} + +function executeDownloadScript(scriptPath: string, envFilePath: string): Promise<{ + stdout: string; + stderr: string; + success: boolean; + fileContent?: string; +}> { + return new Promise((resolve) => { + const scriptDir = path.dirname(scriptPath); + const recipesRoot = path.join(scriptDir, '..'); + + // Create a wrapper script that captures file content instead of writing to disk + const scriptName = path.basename(scriptPath); + const wrapperScript = ` +// Change to the recipes directory for proper module resolution +process.chdir('${recipesRoot}'); + +// Import required modules +const fs = require('fs'); +const path = require('path'); +const os = require('os'); + +// Set up environment variables from our temp file +const envContent = fs.readFileSync('${envFilePath}', 'utf-8'); +const envLines = envContent.split('\\n'); + +envLines.forEach(line => { + const match = line.match(/^([^=]+)=(.*)$/); + if (match) { + process.env[match[1]] = match[2]; + } +}); + +// File-based token caching +function getTokenCacheFile(clientId: string) { + const configHash = clientId ? clientId.substring(0, 8) : "default"; + return path.join(os.tmpdir(), `sigma-portal-token-${configHash}.json`); +} + +function getCachedToken(): { token: string; clientId: string } | null { + try { + // Look for the most recent valid token across all configurations + const tempDir = os.tmpdir(); + const files = fs.readdirSync(tempDir); + const tokenFiles = files.filter(file => file.startsWith('sigma-portal-token-') && file.endsWith('.json')); + + let mostRecentToken = null; + let mostRecentTime = 0; + + for (const file of tokenFiles) { + try { + const filePath = path.join(tempDir, file); + const tokenData = JSON.parse(fs.readFileSync(filePath, 'utf8')); + const now = Date.now(); + + // Check if token is still valid (not expired) + if (tokenData.expiresAt && now < tokenData.expiresAt) { + const lastAccessTime = tokenData.lastAccessed || tokenData.createdAt; + + if (lastAccessTime > mostRecentTime) { + mostRecentTime = lastAccessTime; + mostRecentToken = { + token: tokenData.token, + clientId: tokenData.clientId + }; + } + } else { + // Token expired, remove file + fs.unlinkSync(filePath); + } + } catch (err) { + // Skip invalid token files + } + } + + return mostRecentToken; + } catch (error) { + // Ignore errors, just return null + } + return null; +} + } else { + fs.unlinkSync(TOKEN_CACHE_FILE); + } + } + } catch (error) { + // Ignore errors + } + return null; +} + +function cacheToken(token) { + try { + const tokenData = { + token: token, + expiresAt: Date.now() + (60 * 60 * 1000), + createdAt: Date.now() + }; + fs.writeFileSync(TOKEN_CACHE_FILE, JSON.stringify(tokenData)); + } catch (error) { + console.error('Failed to cache token:', error.message); + } +} + +// Global variable to capture file content for download +global.DOWNLOAD_CONTENT = null; +global.DOWNLOAD_FILENAME = null; + +// Override file writing functions to capture content +const originalWriteFileSync = fs.writeFileSync; +const originalCreateWriteStream = fs.createWriteStream; +const originalReadFileSync = fs.readFileSync; +const originalUnlinkSync = fs.unlinkSync; + +console.log('WRAPPER: Setting up filesystem overrides'); + +fs.writeFileSync = function(filePath, data, options) { + // For JSON files, capture the content + if (filePath.endsWith('.json')) { + global.DOWNLOAD_CONTENT = typeof data === 'string' ? data : JSON.stringify(data, null, 2); + global.DOWNLOAD_FILENAME = path.basename(filePath); + console.log(\`Download ready: \${global.DOWNLOAD_FILENAME}\`); + return; + } + // For other files, use original behavior as fallback + return originalWriteFileSync.call(this, filePath, data, options); +}; + +// Override stream writing for binary files +fs.createWriteStream = function(filePath, options) { + console.log(\`WRAPPER: Intercepted createWriteStream for: \${path.basename(filePath)}\`); + global.DOWNLOAD_FILENAME = path.basename(filePath); + global.DOWNLOAD_FILEPATH = filePath; + + // Use a temporary file to capture the actual data + const tempFilePath = filePath + '.temp'; + const realStream = originalCreateWriteStream.call(this, tempFilePath, options); + + // Create a proper writable stream proxy that captures completion + const mockStream = { + write: function(chunk) { + return realStream.write(chunk); + }, + end: function(chunk) { + if (chunk) realStream.write(chunk); + return realStream.end(); + }, + destroy: function() { + return realStream.destroy(); + }, + on: function(event, callback) { + if (event === 'finish') { + // When the real stream finishes, read the file and store content + realStream.on('finish', () => { + console.log(\`WRAPPER: Reading completed file...\`); + try { + // Give the filesystem a moment to flush + setTimeout(() => { + if (fs.existsSync(tempFilePath)) { + const fileData = originalReadFileSync(tempFilePath); + global.DOWNLOAD_CONTENT = fileData.toString('base64'); + console.log(\`WRAPPER: Successfully captured \${fileData.length} bytes\`); + console.log(\`Download ready: \${global.DOWNLOAD_FILENAME}\`); + // Clean up temp file + try { originalUnlinkSync(tempFilePath); } catch (e) {} + } else { + console.error(\`WRAPPER: Temp file missing: \${tempFilePath}\`); + } + // Always call the callback to let the recipe know we're done + if (callback) callback(); + }, 200); + } catch (err) { + console.error('WRAPPER: Error reading file:', err); + if (callback) callback(); + } + }); + return this; + } + return realStream.on(event, callback); + }, + // Implement writable stream interface properly + writable: true, + readable: false, + close: function() { + return realStream.close(); + } + }; + + return mockStream; +}; + +// Override getBearerToken function for cached tokens +async function getBearerToken() { + const cached = getCachedToken(); + if (cached) { + return cached; + } + + const originalConsoleLog = console.log; + const originalConsoleError = console.error; + console.log = () => {}; + console.error = () => {}; + + const originalGetBearerToken = require('${recipesRoot}/get-access-token'); + const newToken = await originalGetBearerToken(); + + console.log = originalConsoleLog; + console.error = originalConsoleError; + + if (newToken) { + cacheToken(newToken); + } + + return newToken; +} + +// Execute the script +try { + const cachedToken = getCachedToken(); + + if (cachedToken) { + console.log('Using cached authentication token for download'); + + let scriptContent = fs.readFileSync('${scriptPath}', 'utf-8'); + + // Replace the getBearerToken import with cached token + const modifiedScript = scriptContent.replace( + /const getBearerToken = require\\(['"][^'"]*get-access-token['"]\\);/g, + 'const getBearerToken = async () => { return "' + cachedToken + '"; };' + ).replace( + /if \\(require\\.main === module\\) \\{([\\s\\S]*?)\\}/g, + '{ $1 }' + ); + + const tempScriptPath = '${scriptPath}' + '.download.js'; + fs.writeFileSync(tempScriptPath, modifiedScript); + + try { + delete require.cache[require.resolve(tempScriptPath)]; + require(tempScriptPath); + + // Wait longer for async operations to complete (PDF exports can take time) + let checkCount = 0; + const maxChecks = 30; // 30 checks * 2 seconds = 60 seconds max + + const checkForCompletion = () => { + checkCount++; + if (global.DOWNLOAD_CONTENT) { + console.log('DOWNLOAD_RESULT:' + JSON.stringify({ + content: global.DOWNLOAD_CONTENT, + filename: global.DOWNLOAD_FILENAME + })); + process.exit(0); + } else if (checkCount >= maxChecks) { + console.log('Download timeout - export may have failed or taken too long'); + process.exit(1); + } else { + // Check again in 2 seconds + setTimeout(checkForCompletion, 2000); + } + }; + + // Start checking after initial delay + setTimeout(checkForCompletion, 3000); + + } finally { + try { + fs.unlinkSync(tempScriptPath); + } catch (err) {} + } + } else { + console.log('No cached token found for download script.'); + process.exit(1); + } +} catch (error) { + console.error('Script execution error:', error.message); + process.exit(1); +} +`; + + const tempScriptPath = path.join(os.tmpdir(), `temp-download-wrapper-${Date.now()}.js`); + fs.writeFileSync(tempScriptPath, wrapperScript); + + const child = spawn('node', [tempScriptPath], { + cwd: recipesRoot, + timeout: 120000, // 120 second timeout for downloads (PDF exports can take time) + }); + + let stdout = ''; + let stderr = ''; + let fileContent: string | null = null; + + child.stdout?.on('data', (data) => { + const output = data.toString(); + stdout += output; + + // Look for download result in output + const downloadMatch = output.match(/DOWNLOAD_RESULT:(.+)/); + if (downloadMatch) { + try { + const downloadData = JSON.parse(downloadMatch[1]); + fileContent = downloadData.content; + } catch (e) { + console.error('Failed to parse download result:', e); + } + } + }); + + child.stderr?.on('data', (data) => { + stderr += data.toString(); + }); + + child.on('close', (code) => { + // Clean up temp script file + try { + fs.unlinkSync(tempScriptPath); + } catch (err) { + console.warn('Failed to cleanup temp script file:', err); + } + + resolve({ + stdout: stdout || 'Download script executed', + stderr: stderr || '', + success: code === 0 && fileContent !== null, + fileContent: fileContent || undefined + }); + }); + + child.on('error', (error) => { + // Clean up temp script file + try { + fs.unlinkSync(tempScriptPath); + } catch (err) { + console.warn('Failed to cleanup temp script file:', err); + } + + resolve({ + stdout: '', + stderr: `Execution error: ${error.message}`, + success: false + }); + }); + }); +} \ No newline at end of file diff --git a/recipe-portal/app/api/env/route.ts b/recipe-portal/app/api/env/route.ts new file mode 100644 index 00000000..f2cdde4c --- /dev/null +++ b/recipe-portal/app/api/env/route.ts @@ -0,0 +1,55 @@ +import { NextResponse } from 'next/server'; +import fs from 'fs'; +import path from 'path'; + +export async function GET() { + try { + const envFilePath = path.join(process.cwd(), 'recipes', '.env'); + + if (!fs.existsSync(envFilePath)) { + return NextResponse.json({ + values: {}, + exists: false, + message: 'Environment file not found' + }); + } + + const envContent = fs.readFileSync(envFilePath, 'utf-8'); + const envValues: Record = {}; + + // Parse the .env file + const lines = envContent.split('\n'); + for (const line of lines) { + const trimmed = line.trim(); + // Skip comments and empty lines + if (trimmed && !trimmed.startsWith('#')) { + const match = trimmed.match(/^([^=]+)=(.*)$/); + if (match) { + const key = match[1].trim(); + const value = match[2].trim(); + // Only include non-empty values + if (value && value !== '') { + envValues[key] = value; + } + } + } + } + + return NextResponse.json({ + values: envValues, + exists: true, + timestamp: new Date().toISOString() + }); + + } catch (error) { + console.error('Error reading .env file:', error); + return NextResponse.json( + { + error: 'Failed to read environment file', + values: {}, + exists: false + }, + { status: 500 } + ); + } +} \ No newline at end of file diff --git a/recipe-portal/app/api/execute/route.ts b/recipe-portal/app/api/execute/route.ts new file mode 100644 index 00000000..3969e0dd --- /dev/null +++ b/recipe-portal/app/api/execute/route.ts @@ -0,0 +1,345 @@ +import { NextResponse } from 'next/server'; +import { spawn } from 'child_process'; +import fs from 'fs'; +import path from 'path'; +import os from 'os'; + +export async function POST(request: Request) { + try { + const { filePath, envVariables } = await request.json(); + + if (!filePath) { + return NextResponse.json( + { error: 'File path is required' }, + { status: 400 } + ); + } + + // Security check: ensure the file is within the recipes directory + const recipesPath = path.join(process.cwd(), 'recipes'); + const resolvedPath = path.resolve(filePath); + const resolvedRecipesPath = path.resolve(recipesPath); + + if (!resolvedPath.startsWith(resolvedRecipesPath)) { + return NextResponse.json( + { error: 'Access denied: File must be within recipes directory' }, + { status: 403 } + ); + } + + // Check if file exists + if (!fs.existsSync(resolvedPath)) { + return NextResponse.json( + { error: 'File not found' }, + { status: 404 } + ); + } + + // Create temporary .env file with provided variables + const tempEnvPath = path.join(os.tmpdir(), `.env-${Date.now()}`); + let envContent = ''; + + if (envVariables && typeof envVariables === 'object') { + for (const [key, value] of Object.entries(envVariables)) { + if (typeof value === 'string') { + envContent += `${key}=${value}\n`; + } + } + } + + // Add common variables if not provided + if (envVariables && !envVariables.authURL && (envVariables.CLIENT_ID || envVariables.SECRET)) { + envContent += `authURL=https://aws-api.sigmacomputing.com/v2/auth/token\n`; + } + if (envVariables && !envVariables.baseURL && (envVariables.CLIENT_ID || envVariables.SECRET)) { + envContent += `baseURL=https://aws-api.sigmacomputing.com/v2\n`; + } + + // Add the path to the env file in the content + envContent += `ENV_FILE_PATH=${tempEnvPath}\n`; + + fs.writeFileSync(tempEnvPath, envContent); + + // Execute the script with timeout + const output = await executeScript(resolvedPath, tempEnvPath, envVariables?.CLIENT_ID); + + // Clean up temp file + try { + fs.unlinkSync(tempEnvPath); + } catch (err) { + console.warn('Failed to cleanup temp env file:', err); + } + + + return NextResponse.json({ + output: output.stdout, + error: output.stderr, + success: output.success, + timestamp: new Date().toISOString(), + httpStatus: output.success ? 200 : 500, + httpStatusText: output.success ? 'OK' : 'Internal Server Error' + }); + + } catch (error) { + console.error('Error executing script:', error); + return NextResponse.json( + { error: 'Failed to execute script' }, + { status: 500 } + ); + } +} + +function executeScript(scriptPath: string, envFilePath: string, clientId: string = null): Promise<{ + stdout: string; + stderr: string; + success: boolean; +}> { + return new Promise((resolve) => { + const scriptDir = path.dirname(scriptPath); + const recipesRoot = path.join(scriptDir, '..'); + + // Create a wrapper script that handles module resolution and environment setup + const scriptName = path.basename(scriptPath); + const isMasterScript = scriptPath.includes('master-script.js'); + const wrapperScript = ` +// Change to the recipes directory for proper module resolution +process.chdir('${recipesRoot}'); + +// Import required modules +const fs = require('fs'); +const path = require('path'); +const os = require('os'); + +// Set up environment variables from our temp file +const envContent = fs.readFileSync('${envFilePath}', 'utf-8'); +const envLines = envContent.split('\\n'); + +envLines.forEach(line => { + const match = line.match(/^([^=]+)=(.*)$/); + if (match) { + process.env[match[1]] = match[2]; + } +}); + +// Configuration-specific token caching +function getTokenCacheFile(clientId) { + // Create a safe filename using first 8 chars of clientId + const configHash = clientId ? clientId.substring(0, 8) : 'default'; + return path.join(os.tmpdir(), 'sigma-portal-token-' + configHash + '.json'); +} + +function getCachedToken(clientId = null) { + try { + const TOKEN_CACHE_FILE = getTokenCacheFile(clientId); + if (fs.existsSync(TOKEN_CACHE_FILE)) { + const tokenData = JSON.parse(fs.readFileSync(TOKEN_CACHE_FILE, 'utf8')); + const now = Date.now(); + + // Check if token is still valid (not expired) + if (tokenData.expiresAt && now < tokenData.expiresAt) { + return tokenData.token; + } else { + // Token expired, remove file + fs.unlinkSync(TOKEN_CACHE_FILE); + } + } + } catch (error) { + // Ignore errors, just return null + } + return null; +} + +function cacheToken(token, clientId = null) { + try { + const TOKEN_CACHE_FILE = getTokenCacheFile(clientId); + const tokenData = { + token: token, + clientId: clientId, + expiresAt: Date.now() + (60 * 60 * 1000), // 1 hour from now + createdAt: Date.now() + }; + fs.writeFileSync(TOKEN_CACHE_FILE, JSON.stringify(tokenData)); + } catch (error) { + console.error('Failed to cache token:', error.message); + } +} + +// Override getBearerToken function for recipes that use cached tokens +async function getBearerToken(clientId = null) { + // First check for cached token + const cached = getCachedToken(clientId); + if (cached) { + // Don't log anything about tokens in regular recipes + return cached; + } + + // If no cached token, get a new one silently + // Temporarily suppress console output when getting token for recipes + const originalConsoleLog = console.log; + const originalConsoleError = console.error; + console.log = () => {}; // Suppress logs + console.error = () => {}; // Suppress errors + + const originalGetBearerToken = require('${recipesRoot}/get-access-token'); + const newToken = await originalGetBearerToken(); + + // Restore console output + console.log = originalConsoleLog; + console.error = originalConsoleError; + + if (newToken) { + cacheToken(newToken); + } + + return newToken; +} + +// Import and run the original script +try { + ${scriptName === 'get-access-token.js' ? ` + // Special handling for auth script to show token and cache it + const originalGetBearerToken = require('${scriptPath}'); + originalGetBearerToken().then((token) => { + if (token) { + console.log('āœ… Bearer token obtained successfully!'); + console.log('Token:', token); + console.log('Token will expire in 1 hour'); + console.log('HTTP Status: 200 OK - Authentication successful'); + + // Cache the token for future use + cacheToken(token, '${clientId}'); + } else { + console.log('āŒ Failed to obtain bearer token'); + process.exit(1); + } + }).catch(error => { + console.error('Authentication failed:', error.message); + process.exit(1); + }); + ` : ` + // For regular scripts, check for cached token first + const cachedToken = getCachedToken('${clientId}'); + + if (cachedToken) { + console.log('Using cached authentication token'); + console.log('Cached token: ' + cachedToken.substring(0, 20) + '...'); + + // Read and modify the script content to use cached token + const fs = require('fs'); + let scriptContent = fs.readFileSync('${scriptPath}', 'utf-8'); + + // Replace the getBearerToken import with a function that returns cached token + const modifiedScript = scriptContent.replace( + /const getBearerToken = require\\(['"][^'"]*get-access-token['"]\\);/g, + 'const getBearerToken = async () => { console.log("Using cached token from file cache"); return "' + cachedToken + '"; };' + ).replace( + /if \\(require\\.main === module\\) \\{([\\s\\S]*?)\\}/g, + '{ $1 }' // Remove the require.main check so the script always executes + ); + + // For master-script.js, we need to override the get-access-token module globally + // so that when sub-scripts import it, they get the cached token + const isMasterScript = '${scriptPath}'.includes('master-script.js'); + const finalScript = isMasterScript ? + '// Override get-access-token module globally for sub-scripts\\n' + + 'const Module = require(\\'module\\');\\n' + + 'const originalRequire = Module.prototype.require;\\n' + + '\\n' + + 'Module.prototype.require = function(id) {\\n' + + ' if (id === \\'../get-access-token\\' || id.endsWith(\\'get-access-token\\')) {\\n' + + ' return async () => {\\n' + + ' console.log("Using master script cached token for sub-operation");\\n' + + ' return "' + cachedToken + '";\\n' + + ' };\\n' + + ' }\\n' + + ' return originalRequire.apply(this, arguments);\\n' + + '};\\n' + + '\\n' + + modifiedScript : modifiedScript; + + // Write to a temporary file and require it + const tempScriptPath = '${scriptPath}' + '.cached.js'; + fs.writeFileSync(tempScriptPath, finalScript); + + try { + // Clear require cache to ensure fresh execution + delete require.cache[require.resolve(tempScriptPath)]; + require(tempScriptPath); + } finally { + // Clean up temp file + try { + fs.unlinkSync(tempScriptPath); + } catch (err) { + console.warn('Failed to cleanup temp script file:', err); + } + } + } else { + console.log('No cached token found. Script will authenticate normally.'); + + // Execute original script + const script = require('${scriptPath}'); + if (typeof script === 'function') { + script(); + } + } + `} +} catch (error) { + console.error('Script execution error:', error.message); + process.exit(1); +} +`; + + const tempScriptPath = path.join(os.tmpdir(), `temp-wrapper-${Date.now()}.js`); + fs.writeFileSync(tempScriptPath, wrapperScript); + + // Set timeout based on script type - materialization takes longer + const isMaterializationScript = scriptPath.includes('initiate-materialization.js'); + const timeout = isMaterializationScript ? 300000 : 30000; // 5 minutes for materialization, 30 seconds for others + + const child = spawn('node', [tempScriptPath], { + cwd: recipesRoot, + timeout: timeout, + }); + + let stdout = ''; + let stderr = ''; + + child.stdout?.on('data', (data) => { + stdout += data.toString(); + }); + + child.stderr?.on('data', (data) => { + stderr += data.toString(); + }); + + child.on('close', (code) => { + // Clean up temp script file + try { + fs.unlinkSync(tempScriptPath); + } catch (err) { + console.warn('Failed to cleanup temp script file:', err); + } + + resolve({ + stdout: stdout || 'Script executed successfully (no output)', + stderr: stderr || '', + success: code === 0 + }); + }); + + child.on('error', (error) => { + // Clean up temp script file + try { + fs.unlinkSync(tempScriptPath); + } catch (err) { + console.warn('Failed to cleanup temp script file:', err); + } + + resolve({ + stdout: '', + stderr: `Execution error: ${error.message}`, + success: false + }); + }); + }); +} \ No newline at end of file diff --git a/recipe-portal/app/api/keys/route.ts b/recipe-portal/app/api/keys/route.ts new file mode 100644 index 00000000..7d22e041 --- /dev/null +++ b/recipe-portal/app/api/keys/route.ts @@ -0,0 +1,148 @@ +import { NextResponse } from 'next/server'; +import { + storeCredentials, + getStoredCredentials, + hasStoredCredentials, + clearStoredCredentials, + getStoredCredentialNames, + getDefaultCredentialSetName, + setDefaultCredentialSet +} from '../../../lib/keyStorage'; + +// GET - Check if stored credentials exist and optionally retrieve them +export async function GET(request: Request) { + try { + const { searchParams } = new URL(request.url); + const retrieve = searchParams.get('retrieve') === 'true'; + const list = searchParams.get('list') === 'true'; + const setName = searchParams.get('set'); + + const hasKeys = await hasStoredCredentials(); + + if (!hasKeys) { + return NextResponse.json({ + hasStoredKeys: false, + credentials: null, + credentialSets: [], + defaultSet: null + }); + } + + const credentialSets = await getStoredCredentialNames(); + const defaultSet = await getDefaultCredentialSetName(); + + if (list) { + // Return list of available sets + return NextResponse.json({ + hasStoredKeys: true, + credentialSets, + defaultSet, + credentials: null + }); + } + + if (retrieve) { + const credentials = await getStoredCredentials(setName || undefined); + return NextResponse.json({ + hasStoredKeys: true, + credentials: credentials || null, + credentialSets, + defaultSet + }); + } + + return NextResponse.json({ + hasStoredKeys: true, + credentials: null, + credentialSets, + defaultSet + }); + + } catch (error) { + console.error('Error checking stored keys:', error); + return NextResponse.json( + { error: 'Failed to check stored credentials' }, + { status: 500 } + ); + } +} + +// POST - Store configuration (credentials + server settings) +export async function POST(request: Request) { + try { + const { clientId, clientSecret, name, setAsDefault, baseURL, authURL } = await request.json(); + + if (!clientId || !clientSecret) { + return NextResponse.json( + { error: 'Client ID and Client Secret are required' }, + { status: 400 } + ); + } + + if (!name || name.trim() === '') { + return NextResponse.json( + { error: 'Credential set name is required' }, + { status: 400 } + ); + } + const credentialSetName = name.trim(); + const success = await storeCredentials(clientId, clientSecret, credentialSetName, baseURL, authURL); + + // Set as default if requested + if (success && setAsDefault) { + await setDefaultCredentialSet(credentialSetName); + } + + if (success) { + return NextResponse.json({ + success: true, + message: 'Credentials stored successfully' + }); + } else { + return NextResponse.json( + { error: 'Failed to store credentials' }, + { status: 500 } + ); + } + + } catch (error) { + console.error('Error storing credentials:', error); + return NextResponse.json( + { error: 'Failed to store credentials' }, + { status: 500 } + ); + } +} + +// DELETE - Clear stored credentials (all or specific config) +export async function DELETE(request: Request) { + try { + const { searchParams } = new URL(request.url); + const configName = searchParams.get('config'); + + const success = await clearStoredCredentials(configName || undefined); + + if (success) { + const message = configName + ? `Config "${configName}" deleted successfully` + : 'All stored credentials cleared successfully'; + + return NextResponse.json({ + success: true, + message + }); + } else { + return NextResponse.json( + { error: 'Failed to clear stored credentials' }, + { status: 500 } + ); + } + + } catch (error) { + console.error('Error clearing stored credentials:', error); + return NextResponse.json( + { error: 'Failed to clear stored credentials' }, + { status: 500 } + ); + } +} \ No newline at end of file diff --git a/recipe-portal/app/api/open-folder/route.ts b/recipe-portal/app/api/open-folder/route.ts new file mode 100644 index 00000000..53a9b454 --- /dev/null +++ b/recipe-portal/app/api/open-folder/route.ts @@ -0,0 +1,43 @@ +import { NextResponse } from 'next/server'; +import { exec } from 'child_process'; +import path from 'path'; + +export async function POST(request: Request) { + try { + const { folder } = await request.json(); + + if (!folder || folder !== 'downloaded-files') { + return NextResponse.json( + { error: 'Invalid folder specified' }, + { status: 400 } + ); + } + + const folderPath = path.resolve(folder); + + // Open folder using system command based on OS + let command: string; + if (process.platform === 'win32') { + command = `explorer "${folderPath}"`; + } else if (process.platform === 'darwin') { + command = `open "${folderPath}"`; + } else { + command = `xdg-open "${folderPath}"`; + } + + exec(command, (error) => { + if (error) { + console.error('Error opening folder:', error); + } + }); + + return NextResponse.json({ success: true }); + + } catch (error) { + console.error('Error opening folder:', error); + return NextResponse.json( + { error: 'Failed to open folder' }, + { status: 500 } + ); + } +} \ No newline at end of file diff --git a/recipe-portal/app/api/readme/route.ts b/recipe-portal/app/api/readme/route.ts new file mode 100644 index 00000000..bd5c6eb5 --- /dev/null +++ b/recipe-portal/app/api/readme/route.ts @@ -0,0 +1,168 @@ +import { NextResponse } from 'next/server'; +import fs from 'fs'; +import path from 'path'; + +function convertMarkdownToHtml(markdown: string): string { + // First, normalize line endings and remove excessive whitespace + let html = markdown + .replace(/\r\n/g, '\n') + .replace(/\r/g, '\n') + // Remove excessive blank lines + .replace(/\n{3,}/g, '\n\n') + // Trim each line + .split('\n') + .map(line => line.trim()) + .join('\n'); + + return html + // Code blocks (do this first to preserve their content) + .replace(/```[\s\S]*?```/g, (match) => { + const code = match.replace(/```\w*\n?/, '').replace(/\n?```$/, ''); + return `
${code.replace(//g, '>')}
`; + }) + // Headers + .replace(/^### (.+)$/gm, '

$1

') + .replace(/^## (.+)$/gm, '

$1

') + .replace(/^# (.+)$/gm, '

$1

') + // Inline code + .replace(/`([^`]+)`/g, '$1') + // Links + .replace(/\[([^\]]+)\]\(([^)]+)\)/g, '$1') + // Bold text + .replace(/\*\*([^*]+)\*\*/g, '$1') + // Lists + .replace(/^- (.+)$/gm, '
  • $1
  • ') + .replace(/^\* (.+)$/gm, '
  • $1
  • ') + .replace(/^(\d+)\. (.+)$/gm, '
  • $2
  • ') + // Wrap consecutive list items in proper containers + .replace(/(
  • .*?<\/li>(?:\s*
  • .*?<\/li>)*)/g, (match) => { + const items = match.trim(); + return `
      ${items}
    `; + }) + // Convert double line breaks to paragraph breaks + .replace(/\n\s*\n/g, '

    ') + // Wrap remaining content in paragraphs + .replace(/^(?![<])/gm, '

    ') + // Clean up paragraph wrapping around headers and other elements + .replace(/

    (<[h123]||<\/pre>|<\/ul>)

    /g, '$1') + // Remove trailing paragraph tags + .replace(/<\/p>$/g, '') + // Remove empty paragraphs + .replace(/

    <\/p>/g, ''); +} + +export async function GET(request: Request) { + try { + const { searchParams } = new URL(request.url); + const readmePath = searchParams.get('path'); + const format = searchParams.get('format'); // Check if HTML format is requested + + if (!readmePath) { + return NextResponse.json( + { error: 'README path is required' }, + { status: 400 } + ); + } + + // Security check: ensure the file is within the recipes directory or is the main README + const recipesPath = path.join(process.cwd(), 'recipes'); + const mainReadmePath = path.join(process.cwd(), 'README.md'); + const resolvedPath = path.resolve(readmePath); + const resolvedRecipesPath = path.resolve(recipesPath); + const resolvedMainReadmePath = path.resolve(mainReadmePath); + + if (!resolvedPath.startsWith(resolvedRecipesPath) && resolvedPath !== resolvedMainReadmePath) { + return NextResponse.json( + { error: 'Access denied: File must be within recipes directory or be the main README' }, + { status: 403 } + ); + } + + // Check if file exists + if (!fs.existsSync(resolvedPath)) { + return NextResponse.json( + { error: 'README file not found' }, + { status: 404 } + ); + } + + const content = fs.readFileSync(resolvedPath, 'utf-8'); + + // If accessed directly in browser (no explicit JSON format requested), return HTML + if (format !== 'json') { + const htmlContent = ` + + + Recipe Instructions + + + + + āœ• Close +

    ${convertMarkdownToHtml(content)}
    + +`; + + return new NextResponse(htmlContent, { + headers: { + 'Content-Type': 'text/html; charset=utf-8', + }, + }); + } + + // Return JSON for API calls + return NextResponse.json({ + content, + success: true + }); + + } catch (error) { + console.error('Error reading README file:', error); + return NextResponse.json( + { error: 'Failed to read README file' }, + { status: 500 } + ); + } +} \ No newline at end of file diff --git a/recipe-portal/app/api/recipes/route.ts b/recipe-portal/app/api/recipes/route.ts new file mode 100644 index 00000000..0ca059dc --- /dev/null +++ b/recipe-portal/app/api/recipes/route.ts @@ -0,0 +1,21 @@ +import { NextResponse } from 'next/server'; +import { scanAllRecipes, getAuthRecipe } from '../../../lib/recipeScanner'; + +export async function GET() { + try { + const categories = scanAllRecipes(); + const authRecipe = getAuthRecipe(); + + return NextResponse.json({ + categories, + authRecipe, + timestamp: new Date().toISOString() + }); + } catch (error) { + console.error('Error in recipes API:', error); + return NextResponse.json( + { error: 'Failed to scan recipes' }, + { status: 500 } + ); + } +} \ No newline at end of file diff --git a/recipe-portal/app/api/resources/route.ts b/recipe-portal/app/api/resources/route.ts new file mode 100644 index 00000000..ac45f521 --- /dev/null +++ b/recipe-portal/app/api/resources/route.ts @@ -0,0 +1,281 @@ +import { NextResponse } from 'next/server'; +import axios from 'axios'; + +// Base resource fetching function +async function fetchWithAuth(endpoint: string, token: string) { + try { + const baseURL = process.env.SIGMA_BASE_URL || 'https://aws-api.sigmacomputing.com/v2'; + const url = `${baseURL}${endpoint}`; + console.log(`Fetching: ${url}`); + const response = await axios.get(url, { + headers: { + 'Authorization': `Bearer ${token}`, + 'Accept': 'application/json' + } + }); + console.log(`Response status for ${endpoint}:`, response.status); + return response.data; + } catch (error) { + console.error(`Error fetching ${endpoint}:`, (error as any).response?.data || (error as any).message); + throw error; + } +} + +export async function GET(request: Request) { + try { + const { searchParams } = new URL(request.url); + const type = searchParams.get('type'); + const token = searchParams.get('token'); + + if (!token) { + return NextResponse.json( + { error: 'Authentication token is required' }, + { status: 401 } + ); + } + + if (!type) { + return NextResponse.json( + { error: 'Resource type is required. Use: teams, members, workbooks, connections, workspaces, bookmarks, templates, datasets, dataModels, accountTypes, workbookElements, materializationSchedules' }, + { status: 400 } + ); + } + + let data: any; + let transformedData: any[]; + + switch (type) { + case 'teams': + data = await fetchWithAuth('/teams', token); + transformedData = (data.entries || data).map((team: any) => ({ + id: team.teamId, + name: team.name, + description: team.description || '', + memberCount: team.memberCount || 0 + })); + break; + + case 'members': + data = await fetchWithAuth('/members', token); + // Filter out potentially inactive members and map to display format + const activeMembers = (data.entries || data).filter((member: any) => { + // Add filters for inactive members based on patterns you identify + // For now, keeping all members - you can modify this filter + return true; + }); + + transformedData = activeMembers.map((member: any) => ({ + id: member.memberId, + name: `${member.firstName} ${member.lastName}`.trim(), + email: member.email, + firstName: member.firstName, + lastName: member.lastName, + type: member.memberType + })); + break; + + case 'workbooks': + data = await fetchWithAuth('/workbooks', token); + transformedData = (data.entries || data).map((workbook: any) => ({ + id: workbook.workbookId, + name: workbook.name, + path: workbook.path, + ownerId: workbook.ownerId, + createdBy: workbook.createdBy, + url: workbook.url + })); + break; + + case 'connections': + data = await fetchWithAuth('/connections', token); + transformedData = (data.entries || data).map((connection: any) => ({ + id: connection.connectionId, + name: connection.name, + type: connection.type, + description: connection.description || '' + })); + break; + + case 'workspaces': + data = await fetchWithAuth('/workspaces', token); + transformedData = (data.entries || data).map((workspace: any) => ({ + id: workspace.workspaceId, + name: workspace.name, + description: workspace.description || '' + })); + break; + + case 'bookmarks': + // Using favorites endpoint since bookmarks API maps to favorites + data = await fetchWithAuth('/favorites', token); + transformedData = (data.entries || data).map((favorite: any) => ({ + id: favorite.favoriteId || favorite.inodeId, + name: favorite.name || favorite.title, + description: favorite.description || '', + type: favorite.type || 'favorite', + url: favorite.url + })); + break; + + case 'templates': + data = await fetchWithAuth('/templates', token); + transformedData = (data.entries || data).map((template: any) => ({ + id: template.templateId, + name: template.name, + description: template.description || '', + type: template.type + })); + break; + + case 'datasets': + data = await fetchWithAuth('/datasets', token); + transformedData = (data.entries || data).map((dataset: any) => ({ + id: dataset.datasetId, + name: dataset.name, + description: dataset.description || '', + type: dataset.type + })); + break; + + case 'dataModels': + data = await fetchWithAuth('/dataModels', token); + transformedData = (data.entries || data).map((dataModel: any) => ({ + id: dataModel.dataModelId, + name: dataModel.name, + description: dataModel.description || '', + type: dataModel.type || 'dataModel' + })); + break; + + case 'accountTypes': + data = await fetchWithAuth('/accountTypes', token); + console.log('AccountTypes raw data:', JSON.stringify(data, null, 2)); + transformedData = (data.entries || data).map((accountType: any) => ({ + id: accountType.accountTypeName, + name: accountType.accountTypeName, + description: accountType.description || '', + type: accountType.isCustom ? 'custom' : 'built-in', + isCustom: accountType.isCustom + })); + break; + + case 'workbookElements': + const workbookId = searchParams.get('workbookId'); + if (!workbookId) { + return NextResponse.json( + { error: 'workbookId parameter is required for workbookElements' }, + { status: 400 } + ); + } + + try { + // First, get all pages from the workbook + console.log(`Fetching pages for workbook: ${workbookId}`); + const pagesData = await fetchWithAuth(`/workbooks/${workbookId}/pages`, token); + console.log('Pages data:', JSON.stringify(pagesData, null, 2)); + + const pages = pagesData.entries || pagesData || []; + let allElements: any[] = []; + + // For each page, get its elements + for (const page of pages) { + const pageId = page.pageId || page.id; + if (pageId) { + try { + console.log(`Fetching elements for page: ${pageId}`); + const elementsData = await fetchWithAuth(`/workbooks/${workbookId}/pages/${pageId}/elements`, token); + console.log(`Elements data for page ${pageId}:`, JSON.stringify(elementsData, null, 2)); + + const pageElements = elementsData.entries || elementsData || []; + + // Add page information to each element + const elementsWithPageInfo = pageElements.map((element: any) => ({ + ...element, + pageId: pageId, + pageName: page.name || page.title || `Page ${pageId}` + })); + + allElements = allElements.concat(elementsWithPageInfo); + } catch (pageError) { + console.warn(`Failed to fetch elements for page ${pageId}:`, pageError); + // Continue with other pages even if one fails + } + } + } + + console.log('All extracted elements:', allElements); + + transformedData = allElements.map((element: any) => ({ + id: element.elementId || element.id || element.elementUid, + name: element.name || element.title || element.displayName || `${element.pageName} - ${element.name || element.title || element.displayName || 'Unnamed Element'}`, + type: element.type || element.elementType || 'element', + description: element.description || `Element on page: ${element.pageName}`, + pageId: element.pageId, + pageName: element.pageName + })); + + } catch (error) { + console.error('Error fetching workbook elements:', error); + transformedData = []; + } + + console.log('Final transformed elements data:', transformedData); + break; + + case 'materializationSchedules': + const workbookIdForMat = searchParams.get('workbookId'); + if (!workbookIdForMat) { + return NextResponse.json( + { error: 'workbookId parameter is required for materializationSchedules' }, + { status: 400 } + ); + } + + try { + console.log(`Fetching materialization schedules for workbook: ${workbookIdForMat}`); + const schedulesData = await fetchWithAuth(`/workbooks/${workbookIdForMat}/materialization-schedules`, token); + console.log('Materialization schedules data:', JSON.stringify(schedulesData, null, 2)); + + const schedules = schedulesData.entries || schedulesData || []; + + transformedData = schedules.map((schedule: any) => ({ + id: schedule.sheetId, // Use sheetId as the value that will be sent to the script + name: schedule.elementName, // Display the element name to the user + description: `${schedule.schedule.cronSpec} ${schedule.schedule.timezone}${schedule.paused ? ' - PAUSED' : ''}`, + type: 'materializationSchedule', + sheetId: schedule.sheetId, + elementName: schedule.elementName, + cronSpec: schedule.schedule.cronSpec, + timezone: schedule.schedule.timezone, + paused: schedule.paused + })); + + } catch (error) { + console.error('Error fetching materialization schedules:', error); + transformedData = []; + } + + console.log('Final transformed schedules data:', transformedData); + break; + + default: + return NextResponse.json( + { error: `Unsupported resource type: ${type}` }, + { status: 400 } + ); + } + + return NextResponse.json({ + type, + count: transformedData.length, + data: transformedData.sort((a: any, b: any) => (a.name || '').localeCompare(b.name || '')) + }); + + } catch (error) { + console.error('Error in resources API:', error); + return NextResponse.json( + { error: 'Failed to fetch resources' }, + { status: 500 } + ); + } +} \ No newline at end of file diff --git a/recipe-portal/app/api/token/clear/route.ts b/recipe-portal/app/api/token/clear/route.ts new file mode 100644 index 00000000..4c0f4b01 --- /dev/null +++ b/recipe-portal/app/api/token/clear/route.ts @@ -0,0 +1,65 @@ +import { NextResponse } from 'next/server'; +import fs from 'fs'; +import path from 'path'; +import os from 'os'; + +// Configuration-specific token caching +function getTokenCacheFile(clientId) { + // Create a safe filename using first 8 chars of clientId + const configHash = clientId ? clientId.substring(0, 8) : 'default'; + return path.join(os.tmpdir(), `sigma-portal-token-${configHash}.json`); +} + +export async function POST(request: Request) { + try { + const { clientId, clearAll } = await request.json(); + + console.log('Token clear request:', { clientId, clearAll }); + + if (clearAll) { + // Clear all token cache files + const tempDir = os.tmpdir(); + const files = fs.readdirSync(tempDir); + const tokenFiles = files.filter(file => file.startsWith('sigma-portal-token-') && file.endsWith('.json')); + + console.log('Clearing all tokens:', tokenFiles); + + let clearedCount = 0; + for (const file of tokenFiles) { + try { + fs.unlinkSync(path.join(tempDir, file)); + clearedCount++; + console.log(`Cleared token file: ${file}`); + } catch (err) { + console.warn(`Failed to delete token file ${file}:`, err); + } + } + + return NextResponse.json({ + success: true, + message: `Cleared ${clearedCount} authentication token(s)` + }); + } else { + // Clear specific configuration's token + const TOKEN_CACHE_FILE = getTokenCacheFile(clientId); + + if (fs.existsSync(TOKEN_CACHE_FILE)) { + fs.unlinkSync(TOKEN_CACHE_FILE); + } + + return NextResponse.json({ + success: true, + message: 'Authentication token cleared successfully' + }); + } + } catch (error) { + console.error('Error clearing token:', error); + return NextResponse.json( + { + success: false, + error: 'Failed to clear authentication token' + }, + { status: 500 } + ); + } +} \ No newline at end of file diff --git a/recipe-portal/app/api/token/route.ts b/recipe-portal/app/api/token/route.ts new file mode 100644 index 00000000..01e78b35 --- /dev/null +++ b/recipe-portal/app/api/token/route.ts @@ -0,0 +1,87 @@ +import { NextResponse } from 'next/server'; +import fs from 'fs'; +import path from 'path'; +import os from 'os'; + +// Configuration-specific token caching +function getTokenCacheFile(clientId: string) { + // Create a safe filename using first 8 chars of clientId + const configHash = clientId ? clientId.substring(0, 8) : 'default'; + return path.join(os.tmpdir(), `sigma-portal-token-${configHash}.json`); +} + +export async function GET() { + try { + // Look for the most recent valid token across all configurations + const tempDir = os.tmpdir(); + const files = fs.readdirSync(tempDir); + const tokenFiles = files.filter(file => file.startsWith('sigma-portal-token-') && file.endsWith('.json')); + + let mostRecentToken = null; + let mostRecentTime = 0; + + console.log('Found token files:', tokenFiles); + + for (const file of tokenFiles) { + try { + const filePath = path.join(tempDir, file); + const tokenData = JSON.parse(fs.readFileSync(filePath, 'utf8')); + const now = Date.now(); + + // Check if token is still valid (not expired) + if (tokenData.expiresAt && now < tokenData.expiresAt) { + // Use the most recently created/accessed token + const lastAccessTime = tokenData.lastAccessed || tokenData.createdAt; + console.log(`Token ${file}: clientId=${tokenData.clientId?.substring(0,8)}, createdAt=${new Date(tokenData.createdAt)}, lastAccessed=${tokenData.lastAccessed ? new Date(tokenData.lastAccessed) : 'none'}, lastAccessTime=${lastAccessTime}`); + + if (lastAccessTime > mostRecentTime) { + console.log(` -> This is the most recent token so far`); + mostRecentTime = lastAccessTime; + mostRecentToken = { + hasValidToken: true, + token: tokenData.token, + expiresAt: tokenData.expiresAt, + timeRemaining: Math.round((tokenData.expiresAt - now) / 1000 / 60), // minutes + clientId: tokenData.clientId, + filePath: filePath // Keep track of which file this came from + }; + } + } else { + // Token expired, remove file + fs.unlinkSync(filePath); + } + } catch (err) { + // Skip invalid token files + console.warn(`Failed to read token file ${file}:`, err); + } + } + + if (mostRecentToken) { + console.log(`Selected token: clientId=${mostRecentToken.clientId?.substring(0,8)}`); + + // Update the last accessed time for this token + try { + const tokenData = JSON.parse(fs.readFileSync(mostRecentToken.filePath, 'utf8')); + tokenData.lastAccessed = Date.now(); + fs.writeFileSync(mostRecentToken.filePath, JSON.stringify(tokenData)); + } catch (err) { + console.warn('Failed to update token access time:', err); + } + + // Remove filePath from response + const { filePath, ...responseData } = mostRecentToken; + return NextResponse.json(responseData); + } + + return NextResponse.json({ + hasValidToken: false, + token: null + }); + } catch (error) { + console.error('Error checking token:', error); + return NextResponse.json({ + hasValidToken: false, + token: null + }); + } +} \ No newline at end of file diff --git a/recipe-portal/app/globals.css b/recipe-portal/app/globals.css new file mode 100644 index 00000000..bd6213e1 --- /dev/null +++ b/recipe-portal/app/globals.css @@ -0,0 +1,3 @@ +@tailwind base; +@tailwind components; +@tailwind utilities; \ No newline at end of file diff --git a/recipe-portal/app/layout.tsx b/recipe-portal/app/layout.tsx new file mode 100644 index 00000000..6b24a7ca --- /dev/null +++ b/recipe-portal/app/layout.tsx @@ -0,0 +1,26 @@ +import type { Metadata } from 'next' +import './globals.css' + +export const metadata: Metadata = { + title: 'QuickStarts API Toolkit', + description: 'Experiment with Sigma API calls and learn common request flows', + icons: { + icon: '/crane.png', + shortcut: '/crane.png', + apple: '/crane.png', + }, +} + +export default function RootLayout({ + children, +}: { + children: React.ReactNode +}) { + return ( + + + {children} + + + ) +} \ No newline at end of file diff --git a/recipe-portal/app/page.tsx b/recipe-portal/app/page.tsx new file mode 100644 index 00000000..101a566a --- /dev/null +++ b/recipe-portal/app/page.tsx @@ -0,0 +1,332 @@ +'use client'; + +import { useState, useEffect, useCallback } from 'react'; +import { RecipeCard } from '../components/RecipeCard'; +import { CodeViewer } from '../components/CodeViewer'; +import { QuickApiExplorer } from '../components/QuickApiExplorer'; + +interface Recipe { + id: string; + name: string; + description: string; + category: string; + filePath: string; + envVariables: string[]; + isAuthRequired: boolean; +} + +interface RecipeCategory { + name: string; + recipes: Recipe[]; +} + +interface RecipeData { + categories: RecipeCategory[]; + authRecipe: Recipe | null; + timestamp: string; +} + +export default function Home() { + const [recipeData, setRecipeData] = useState(null); + const [loading, setLoading] = useState(true); + const [error, setError] = useState(null); + const [activeTopTab, setActiveTopTab] = useState<'recipes' | 'quickapi'>('recipes'); + const [activeCategoryTab, setActiveCategoryTab] = useState(''); + const [authToken, setAuthToken] = useState(null); + const [hasValidToken, setHasValidToken] = useState(false); + const [showAuthModal, setShowAuthModal] = useState(false); + const [clearingToken, setClearingToken] = useState(false); + const [quickApiKey, setQuickApiKey] = useState(0); + + // Function to check auth status (reusable) + const checkAuthStatus = useCallback(async () => { + try { + const response = await fetch('/api/token'); + if (response.ok) { + const data = await response.json(); + if (data.hasValidToken) { + setHasValidToken(true); + setAuthToken(data.token); + } else { + setHasValidToken(false); + setAuthToken(null); + } + } else { + setHasValidToken(false); + setAuthToken(null); + } + } catch (error) { + setHasValidToken(false); + setAuthToken(null); + } + }, []); + + useEffect(() => { + async function fetchRecipes() { + try { + const response = await fetch('/api/recipes'); + if (!response.ok) { + throw new Error('Failed to fetch recipes'); + } + const data = await response.json(); + setRecipeData(data); + // Set first category as active by default + if (data.categories.length > 0) { + setActiveCategoryTab(data.categories[0].name); + } + } catch (err) { + setError(err instanceof Error ? err.message : 'Unknown error'); + } finally { + setLoading(false); + } + } + + fetchRecipes(); + checkAuthStatus(); + }, [checkAuthStatus]); + + // Periodically check auth status every 30 seconds + useEffect(() => { + const interval = setInterval(checkAuthStatus, 30000); + return () => clearInterval(interval); + }, [checkAuthStatus]); + + const clearToken = async () => { + setClearingToken(true); + try { + const response = await fetch('/api/token/clear', { + method: 'POST', + headers: { + 'Content-Type': 'application/json' + }, + body: JSON.stringify({ clearAll: true }) + }); + + if (response.ok) { + setHasValidToken(false); + setAuthToken(null); + // If auth modal is open, close it to trigger form reset on next open + if (showAuthModal) { + setShowAuthModal(false); + } + } else { + console.error('Failed to clear token'); + } + } catch (error) { + console.error('Error clearing token:', error); + } finally { + setClearingToken(false); + } + }; + + if (loading) { + return ( +
    +
    +
    +

    Loading recipes...

    +
    +
    + ); + } + + if (error) { + return ( +
    +
    +

    Error loading recipes: {error}

    + +
    +
    + ); + } + + // Sort categories alphabetically + const sortedCategories = recipeData?.categories.sort((a, b) => a.name.localeCompare(b.name)) || []; + const activeCategory = sortedCategories.find(cat => cat.name === activeCategoryTab); + + return ( +
    +
    + {/* Header */} +
    +
    +
    +
    + Sigma Logo +

    + QuickStarts API Toolkit +

    +
    + + +

    + Experiment with Sigma API calls and learn common request flows +

    +
    + + {/* Action Buttons */} +
    + + + {hasValidToken ? ( +
    + + +
    + ) : ( + + )} +
    +
    +
    + + {/* Main Content Container */} +
    + {/* Top Level Tabs */} +
    + +
    + + {/* Tab Content */} + {activeTopTab === 'recipes' ? ( + <> + {/* Category Tabs */} +
    + +
    + + {/* Category Content */} +
    + {activeCategory && ( +
    + {activeCategory.recipes.map((recipe) => ( + + ))} +
    + )} +
    + + ) : ( + + )} + + {/* Footer */} +
    +

    Ā© Sigma 2025

    +

    Last updated: {recipeData ? new Date(recipeData.timestamp).toLocaleDateString() : '—'}

    +
    +
    + + {/* Authentication Modal */} + {recipeData?.authRecipe && ( + setShowAuthModal(false)} + filePath={recipeData.authRecipe.filePath} + fileName="get-access-token.js" + envVariables={['CLIENT_ID', 'SECRET', 'authURL', 'baseURL']} + useEnvFile={false} + onTokenObtained={() => { + setHasValidToken(true); + // Refresh auth status to get the token + setTimeout(async () => { + try { + const response = await fetch('/api/token'); + if (response.ok) { + const data = await response.json(); + if (data.hasValidToken) { + setAuthToken(data.token); + } + } + } catch (error) { + // Ignore errors + } + }, 1000); + }} + onTokenCleared={() => { + setHasValidToken(false); + setAuthToken(null); + }} + defaultTab="readme" + hasValidToken={hasValidToken} + /> + )} +
    +
    + ); +} diff --git a/recipe-portal/app/test/page.tsx b/recipe-portal/app/test/page.tsx new file mode 100644 index 00000000..5f2bebef --- /dev/null +++ b/recipe-portal/app/test/page.tsx @@ -0,0 +1,8 @@ +export default function TestPage() { + return ( +
    +

    Test Page

    +

    If you can see this, routing is working.

    +
    + ); +} \ No newline at end of file diff --git a/recipe-portal/components/AuthRecipeCard.tsx b/recipe-portal/components/AuthRecipeCard.tsx new file mode 100644 index 00000000..f4300095 --- /dev/null +++ b/recipe-portal/components/AuthRecipeCard.tsx @@ -0,0 +1,91 @@ +'use client'; + +import { useState } from 'react'; +import { Recipe } from '../lib/recipeScanner'; +import { CodeViewer } from './CodeViewer'; + +interface AuthRecipeCardProps { + recipe: Recipe; + useEnvFile?: boolean; + onTokenObtained?: () => void; +} + +export function AuthRecipeCard({ recipe, useEnvFile = false, onTokenObtained }: AuthRecipeCardProps) { + const [showCodeViewer, setShowCodeViewer] = useState(false); + return ( +
    +
    +
    +
    + šŸ” +

    + Authentication Setup +

    + + Required First + +
    + +

    + Configure your API credentials and generate a bearer token for accessing Sigma’s REST API. Tokens are cached for reuse across recipes during your session. +

    + + {recipe.envVariables.length > 0 && ( +
    +

    Required Environment Variables:

    +
    + {recipe.envVariables.map((envVar) => ( + + {envVar} + + ))} +
    +
    + )} + +
    + + + + + Start Here + + + Instructions → + +
    +
    + +
    +
    + Token Duration: 1 hour (cached for session) +
    + +
    +
    + + setShowCodeViewer(false)} + filePath={recipe.filePath} + fileName="get-access-token.js" + envVariables={useEnvFile ? recipe.envVariables : ['CLIENT_ID', 'SECRET', 'authURL', 'baseURL']} + useEnvFile={useEnvFile} + onTokenObtained={onTokenObtained} + /> +
    + ); +} \ No newline at end of file diff --git a/recipe-portal/components/CodeViewer.tsx b/recipe-portal/components/CodeViewer.tsx new file mode 100644 index 00000000..cf889b90 --- /dev/null +++ b/recipe-portal/components/CodeViewer.tsx @@ -0,0 +1,1554 @@ +'use client'; + +import { useState, useEffect } from 'react'; +import { detectSmartParameters, SmartParameter, analyzeRecipeCode } from '../lib/smartParameters'; +import { SmartParameterForm } from './SmartParameterForm'; + +interface CodeViewerProps { + isOpen: boolean; + onClose: () => void; + filePath: string; + fileName: string; + envVariables?: string[]; + useEnvFile?: boolean; + onTokenObtained?: () => void; + onTokenCleared?: () => void; + defaultTab?: 'params' | 'run' | 'code' | 'readme'; + hasValidToken?: boolean; + readmePath?: string; +} + +interface ExecutionResult { + output: string; + error: string; + success: boolean | null; + timestamp: string; + httpStatus?: number; + httpStatusText?: string; + downloadInfo?: { + filename: string; + localPath: string; + size: number; + }; +} + +// Function to open the downloads folder via API +const openDownloadsFolder = async () => { + try { + await fetch('/api/open-folder', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ folder: 'downloaded-files' }) + }); + } catch (error) { + console.log('Could not open folder automatically. Please navigate to the downloaded-files folder manually.'); + } +}; + +export function CodeViewer({ isOpen, onClose, filePath, fileName, envVariables = [], useEnvFile = false, onTokenObtained, onTokenCleared, defaultTab = 'params', hasValidToken = false, readmePath }: CodeViewerProps) { + const [code, setCode] = useState(''); + const [loading, setLoading] = useState(false); + const [error, setError] = useState(null); + const [activeTab, setActiveTab] = useState<'params' | 'run' | 'code' | 'readme'>(defaultTab); + const [envValues, setEnvValues] = useState>({}); + const [envFileValues, setEnvFileValues] = useState>({}); + const [executing, setExecuting] = useState(false); + const [executionResult, setExecutionResult] = useState(null); + const [smartParameters, setSmartParameters] = useState([]); + const [authToken, setAuthToken] = useState(null); + const [clearingToken, setClearingToken] = useState(false); + const [storeKeysLocally, setStoreKeysLocally] = useState(false); + const [hasStoredKeys, setHasStoredKeys] = useState(false); + const [currentFormIsStored, setCurrentFormIsStored] = useState(false); + + // Reset form when modal is closed + useEffect(() => { + if (!isOpen) { + setEnvValues({}); + setExecutionResult(null); + setActiveTab(defaultTab); + setError(null); + setSetAsDefault(false); + setCopyButtonText('Copy Output'); + } + }, [isOpen, defaultTab]); + const [saveNotification, setSaveNotification] = useState(null); + const [credentialSetName, setCredentialSetName] = useState(''); + const [availableCredentialSets, setAvailableCredentialSets] = useState([]); + const [selectedCredentialSet, setSelectedCredentialSet] = useState(''); + const [setAsDefault, setSetAsDefault] = useState(false); + const [defaultCredentialSet, setDefaultCredentialSet] = useState(null); + const [copyButtonText, setCopyButtonText] = useState('Copy Output'); + const [customReadme, setCustomReadme] = useState(null); + const [readmeLoading, setReadmeLoading] = useState(false); + + useEffect(() => { + if (isOpen && filePath) { + // Smart default tab selection based on whether script has parameters + let smartDefaultTab: 'params' | 'run' | 'code' | 'readme'; + if (fileName === 'get-access-token.js') { + // Auth script: README first + smartDefaultTab = 'readme'; + } else if (smartParameters.length > 0) { + // Has parameters: Request first + smartDefaultTab = 'params'; + } else { + // No parameters: Run Script (Response) first + smartDefaultTab = 'run'; + } + + // Only set the tab if it's not already set to avoid switching during execution + // Don't switch tabs if we're currently executing or if we have results to show + if (!executing && !executionResult && (activeTab === defaultTab || (activeTab === 'run' && smartParameters.length > 0))) { + setActiveTab(smartDefaultTab); + } + fetchCode(); + checkAuthToken(); + if (useEnvFile) { + fetchEnvFile(); + } + } else if (!isOpen) { + // Reset form when modal is closed + if (fileName === 'get-access-token.js') { + setEnvValues({ + 'baseURL': 'https://aws-api.sigmacomputing.com/v2', + 'authURL': 'https://aws-api.sigmacomputing.com/v2/auth/token', + 'CLIENT_ID': '', + 'SECRET': '' + }); + } + setExecutionResult(null); + } + }, [isOpen, filePath, useEnvFile, fileName, executing, smartParameters.length, executionResult]); + + // Set default auth values for authentication script + useEffect(() => { + if (fileName === 'get-access-token.js' && !envValues['baseURL']) { + // Set defaults for auth script + handleEnvChange('baseURL', 'https://aws-api.sigmacomputing.com/v2'); + handleEnvChange('authURL', 'https://aws-api.sigmacomputing.com/v2/auth/token'); + } + }, [fileName, envValues]); + + // Sync internal auth state with parent + useEffect(() => { + if (!hasValidToken) { + setAuthToken(null); + + // Clear form fields when session is ended from main page + if (fileName === 'get-access-token.js') { + setEnvValues({ + 'baseURL': 'https://aws-api.sigmacomputing.com/v2', + 'authURL': 'https://aws-api.sigmacomputing.com/v2/auth/token', + 'CLIENT_ID': '', + 'SECRET': '' + }); + } + } + }, [hasValidToken, fileName]); + + // Load custom README if available + useEffect(() => { + if (readmePath && isOpen) { + setReadmeLoading(true); + fetch(`/api/readme?path=${encodeURIComponent(readmePath)}&format=json`) + .then(response => response.json()) + .then(data => { + if (data.success) { + setCustomReadme(data.content); + } + }) + .catch(error => { + console.error('Failed to load custom README:', error); + }) + .finally(() => { + setReadmeLoading(false); + }); + } else { + setCustomReadme(null); + } + }, [readmePath, isOpen]); + + // Detect smart parameters when code changes + useEffect(() => { + if (code) { + // Analyze code to find parameters + const analysis = analyzeRecipeCode(code, { filePath }); + const detected = detectSmartParameters(analysis.suggestedParameters, { filePath }); + setSmartParameters(detected); + } + }, [code, filePath]); + + // Check for stored credentials when auth modal opens + // Only auto-populate if form is empty (app startup scenario) + useEffect(() => { + const checkStoredCredentials = async () => { + if (isOpen && fileName === 'get-access-token.js') { + try { + const response = await fetch('/api/keys?retrieve=true'); + if (response.ok) { + const data = await response.json(); + setHasStoredKeys(data.hasStoredKeys); + setAvailableCredentialSets(data.credentialSets || []); + setDefaultCredentialSet(data.defaultSet || null); + + // Only auto-populate if fields are empty AND we have a valid token + // This prevents re-population after "End Session" is clicked + const hasEmptyFields = !envValues['CLIENT_ID'] && !envValues['SECRET']; + + if (data.hasStoredKeys && data.credentials && hasEmptyFields && hasValidToken) { + // Auto-populate form with complete config on startup + handleEnvChange('CLIENT_ID', data.credentials.clientId); + handleEnvChange('SECRET', data.credentials.clientSecret); + handleEnvChange('baseURL', data.credentials.baseURL); + handleEnvChange('authURL', data.credentials.authURL); + setStoreKeysLocally(true); // Check the checkbox since keys are stored + setSelectedCredentialSet(data.defaultSet || ''); + setCurrentFormIsStored(true); // Mark current form as representing stored data + } + } + } catch (error) { + console.log('Error checking stored credentials:', error); + } + } + }; + + checkStoredCredentials(); + }, [isOpen, fileName]); + + const checkAuthToken = async () => { + try { + console.log('checkAuthToken: Fetching current token from /api/token'); + const response = await fetch('/api/token'); + if (response.ok) { + const data = await response.json(); + console.log('checkAuthToken: Response from /api/token:', { hasValidToken: data.hasValidToken, clientId: data.clientId?.substring(0,8) }); + if (data.hasValidToken && data.token) { + console.log('checkAuthToken: Updating authToken state'); + setAuthToken(data.token); + } + } + } catch (error) { + console.log('No cached token available'); + } + }; + + const clearToken = async () => { + setClearingToken(true); + try { + // Clear the session token + const response = await fetch('/api/token/clear', { + method: 'POST', + headers: { + 'Content-Type': 'application/json' + }, + body: JSON.stringify({ clearAll: true }) + }); + + if (response.ok) { + setAuthToken(null); + + // Handle stored keys logic for auth script + if (fileName === 'get-access-token.js') { + if (!storeKeysLocally && hasStoredKeys) { + // User unchecked the box - clear stored keys + await fetch('/api/keys', { method: 'DELETE' }); + setHasStoredKeys(false); + } + + // Always clear form fields on End Session + // This implements the new UX flow: + // - Session-only: fields cleared + // - Storage enabled: fields cleared (will be restored on next startup) + setEnvValues({ + 'baseURL': 'https://aws-api.sigmacomputing.com/v2', + 'authURL': 'https://aws-api.sigmacomputing.com/v2/auth/token', + 'CLIENT_ID': '', + 'SECRET': '' + }); + } + + if (onTokenCleared) { + onTokenCleared(); + } + } else { + console.error('Failed to clear token'); + } + } catch (error) { + console.error('Error clearing token:', error); + } finally { + setClearingToken(false); + } + }; + + + const fetchCode = async () => { + setLoading(true); + setError(null); + + try { + const response = await fetch(`/api/code?path=${encodeURIComponent(filePath)}`); + if (!response.ok) { + throw new Error('Failed to fetch code'); + } + const data = await response.json(); + setCode(data.content); + } catch (err) { + setError(err instanceof Error ? err.message : 'Unknown error'); + } finally { + setLoading(false); + } + }; + + const copyToClipboard = () => { + navigator.clipboard.writeText(code); + alert('Code copied to clipboard!'); + }; + + const copyFilePath = () => { + navigator.clipboard.writeText(filePath); + alert('File path copied to clipboard!'); + }; + + const fetchEnvFile = async () => { + try { + const response = await fetch('/api/env'); + if (response.ok) { + const data = await response.json(); + setEnvFileValues(data.values); + // Pre-fill envValues with file values when useEnvFile is true + if (data.values) { + setEnvValues(data.values); + } + } + } catch (err) { + console.error('Failed to fetch env file values:', err); + } + }; + + const getDownloadFilename = (fileName: string, envValues: Record) => { + switch (fileName) { + case 'export-workbook-element-csv.js': + return envValues['EXPORT_FILENAME'] || 'export.csv'; + case 'export-workbook-pdf.js': + return 'workbook-export.pdf'; + default: + return 'download'; + } + }; + + const getDownloadContentType = (fileName: string) => { + switch (fileName) { + case 'export-workbook-element-csv.js': + return 'text/csv'; + case 'export-workbook-pdf.js': + return 'application/pdf'; + default: + return 'application/octet-stream'; + } + }; + + const createBlobFromContent = (content: string, contentType: string) => { + // All content from DOWNLOAD_RESULT protocol is base64 encoded + try { + const byteCharacters = atob(content); + const byteNumbers = new Array(byteCharacters.length); + for (let i = 0; i < byteCharacters.length; i++) { + byteNumbers[i] = byteCharacters.charCodeAt(i); + } + const byteArray = new Uint8Array(byteNumbers); + return new Blob([byteArray], { type: contentType }); + } catch (error) { + // Fallback for non-base64 content (shouldn't happen with new protocol) + console.warn('Failed to decode base64 content, treating as text:', error); + return new Blob([content], { type: contentType }); + } + }; + + const handleStreamingDownload = async (filePath: string, envVariables: Record, filename: string, contentType: string) => { + try { + const response = await fetch('/api/download-stream', { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + }, + body: JSON.stringify({ + filePath, + envVariables, + filename, + contentType + }) + }); + + if (!response.ok) { + throw new Error('Failed to start download stream'); + } + + const reader = response.body?.getReader(); + if (!reader) { + throw new Error('No response body'); + } + + let outputMessages: string[] = []; + let jsonBuffer = ''; // Persistent buffer for handling split JSON messages + + // Initialize with starting message + const startingMessage = `${new Date().toLocaleTimeString()} - Starting export process...`; + outputMessages.push(startingMessage); + + setExecutionResult({ + output: startingMessage + '\n', + error: '', + success: null, // null indicates "in progress" + timestamp: new Date().toISOString() + }); + + const decoder = new TextDecoder(); + + while (true) { + const { done, value } = await reader.read(); + + if (done) break; + + const chunk = decoder.decode(value); + const lines = chunk.split('\n'); + + for (const line of lines) { + if (line.startsWith('data: ') && line.trim() !== 'data: ') { + const jsonPart = line.substring(6); + jsonBuffer += jsonPart; + + // Try to parse the accumulated JSON + try { + const data = JSON.parse(jsonBuffer); + // Success! Reset buffer and process the data + jsonBuffer = ''; + const timestamp = new Date(data.timestamp).toLocaleTimeString(); + + // Add message to beginning of array (newest first) + // Show debug messages during development + const prefix = ''; + const newMessage = `${timestamp} - ${prefix}${data.message}`; + outputMessages.unshift(newMessage); + + // Keep only last 100 messages to see debug info + if (outputMessages.length > 100) { + outputMessages = outputMessages.slice(0, 100); + } + + // Update the execution result with progressive output (newest first) + setExecutionResult({ + output: outputMessages.join('\n') + '\n', + error: '', + success: null, // Keep as "in progress" until completion + timestamp: data.timestamp + }); + + // Handle download completion with folder link + if (data.type === 'success' && data.data && data.data.filename) { + // Create clickable message to open downloads folder + const folderMessage = `${timestamp} - šŸ“ File saved! Click here to open downloads folder`; + const fileInfo = `${timestamp} - āœ… ${data.data.filename} (${Math.round(data.data.size / 1024)}KB) saved to downloaded-files/`; + outputMessages.unshift(folderMessage); + outputMessages.unshift(fileInfo); + + setExecutionResult({ + output: outputMessages.join('\n') + '\n', + error: '', + success: true, + timestamp: data.timestamp, + downloadInfo: { + filename: data.data.filename, + localPath: data.data.localPath, + size: data.data.size + } + }); + + // Switch to Response tab to show the completion message + setActiveTab('run'); + } + + // Handle errors + if (data.type === 'error') { + setExecutionResult({ + output: outputMessages.join('\n') + '\n', + error: data.message, + success: false, + timestamp: data.timestamp + }); + break; + } + + } catch (e) { + // JSON parsing failed - this might be a partial message + // Keep the buffer and wait for more data, but limit buffer size to prevent memory issues + if (jsonBuffer.length > 500000) { // 500KB limit + console.error('JSON buffer too large, discarding:', jsonBuffer.substring(0, 100) + '...'); + jsonBuffer = ''; + } + // Don't log every parse error as they're expected for partial messages + } + } else if (line.trim() === '' && jsonBuffer) { + // Empty line might indicate end of an SSE message - try to parse what we have + try { + const data = JSON.parse(jsonBuffer); + jsonBuffer = ''; // Reset on successful parse + + const timestamp = new Date(data.timestamp).toLocaleTimeString(); + const newMessage = `${timestamp} - ${data.message}`; + outputMessages.unshift(newMessage); + + if (outputMessages.length > 100) { + outputMessages = outputMessages.slice(0, 100); + } + + setExecutionResult({ + output: outputMessages.join('\n') + '\n', + error: '', + success: null, + timestamp: data.timestamp + }); + + // Handle download completion (same logic as above) + if (data.type === 'success' && data.data && data.data.filename) { + const folderMessage = `${timestamp} - šŸ“ File saved! Click here to open downloads folder`; + const fileInfo = `${timestamp} - āœ… ${data.data.filename} (${Math.round(data.data.size / 1024)}KB) saved to downloaded-files/`; + outputMessages.unshift(folderMessage); + outputMessages.unshift(fileInfo); + + setExecutionResult({ + output: outputMessages.join('\n') + '\n', + error: '', + success: true, + timestamp: data.timestamp, + downloadInfo: { + filename: data.data.filename, + localPath: data.data.localPath, + size: data.data.size + } + }); + + setActiveTab('run'); + } + + if (data.type === 'error') { + setExecutionResult({ + output: outputMessages.join('\n') + '\n', + error: data.message, + success: false, + timestamp: data.timestamp + }); + return; // Exit the stream processing + } + + } catch (e) { + // Still couldn't parse - keep waiting for more data + } + } + } + } + + } catch (error) { + setExecutionResult({ + output: '', + error: error instanceof Error ? error.message : 'Unknown streaming error', + success: false, + timestamp: new Date().toISOString() + }); + } + }; + + const executeScript = async () => { + console.log('executeScript called'); + setExecuting(true); + setExecutionResult(null); + + try { + let currentEnvValues = envValues; + + // If using env file, refresh the values before execution + if (useEnvFile) { + const response = await fetch('/api/env'); + if (response.ok) { + const data = await response.json(); + setEnvFileValues(data.values); + // Use the fresh values directly instead of waiting for state update + currentEnvValues = data.values; + setEnvValues(data.values); + } + } + + // Add core auth variables (will be filled from centralized auth, direct input, or env file) + const coreAuthVars = { + 'CLIENT_ID': useEnvFile ? (currentEnvValues['CLIENT_ID'] || envFileValues['CLIENT_ID'] || '') : (currentEnvValues['CLIENT_ID'] || ''), + 'SECRET': useEnvFile ? (currentEnvValues['SECRET'] || envFileValues['SECRET'] || '') : (currentEnvValues['SECRET'] || ''), + 'authURL': useEnvFile ? (envFileValues['authURL'] || 'https://aws-api.sigmacomputing.com/v2/auth/token') : 'https://aws-api.sigmacomputing.com/v2/auth/token', + 'baseURL': useEnvFile ? (envFileValues['baseURL'] || 'https://aws-api.sigmacomputing.com/v2') : 'https://aws-api.sigmacomputing.com/v2' + }; + + // Validate that required auth credentials are provided (for auth script only) + console.log('Validating auth credentials:', { fileName, coreAuthVars }); + if (fileName === 'get-access-token.js' && (!coreAuthVars.CLIENT_ID || !coreAuthVars.SECRET)) { + console.log('Validation failed - missing credentials'); + setExecutionResult({ + output: '', + error: 'Authentication required: Please provide CLIENT_ID and SECRET credentials in the Config tab.', + success: false, + timestamp: new Date().toISOString(), + httpStatus: 401, + httpStatusText: 'Unauthorized' + }); + setExecuting(false); + return; + } + + console.log('Validation passed, continuing execution...'); + + const allEnvVariables = { ...coreAuthVars, ...currentEnvValues }; + console.log('About to make API request with variables:', Object.keys(allEnvVariables)); + + + + // Check if this is a download recipe + const isDownloadRecipe = ['export-workbook-element-csv.js', 'export-workbook-pdf.js'].includes(fileName); + + let result; + let response; + + if (isDownloadRecipe) { + // Handle download recipes with streaming progress + await handleStreamingDownload(filePath, allEnvVariables, getDownloadFilename(fileName, currentEnvValues), getDownloadContentType(fileName)); + return; // Exit early since streaming handles everything + } else { + // Handle regular recipes + response = await fetch('/api/execute', { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + }, + body: JSON.stringify({ + filePath, + envVariables: allEnvVariables + }) + }); + + console.log('API response received:', response.status, response.statusText); + + result = await response.json(); + console.log('API result:', result); + setExecutionResult(result); + console.log('ExecutionResult set, switching to run tab'); + setActiveTab('run'); + } + + // If this is an auth script and execution was successful, notify parent and refresh token + if (result.success && fileName === 'get-access-token.js' && onTokenObtained) { + onTokenObtained(); + + // Switch to Response tab to show authentication result + setActiveTab('run'); + + // Store complete config (credentials + server settings) if user checked the box + if (storeKeysLocally && allEnvVariables['CLIENT_ID'] && allEnvVariables['SECRET']) { + try { + const setName = credentialSetName.trim(); + if (!setName) { + console.warn('Cannot save credentials without a name during authentication'); + // Continue with authentication but don't save + setTimeout(() => checkAuthToken(), 1000); + return; + } + await fetch('/api/keys', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + clientId: allEnvVariables['CLIENT_ID'], + clientSecret: allEnvVariables['SECRET'], + baseURL: allEnvVariables['baseURL'], + authURL: allEnvVariables['authURL'], + name: setName, + setAsDefault: setAsDefault + }) + }); + setHasStoredKeys(true); + setCurrentFormIsStored(true); // Mark current form as stored + + // Show success notification for auto-save during authentication + showSaveNotification(`Config "${setName}" saved during authentication!`); + + // Update available sets + const updatedResponse = await fetch('/api/keys?list=true'); + if (updatedResponse.ok) { + const updatedData = await updatedResponse.json(); + setAvailableCredentialSets(updatedData.credentialSets || []); + setDefaultCredentialSet(updatedData.defaultSet || null); + } + } catch (error) { + console.error('Failed to store credentials:', error); + } + } + + // Refresh the auth token for smart parameter dropdowns + setTimeout(() => checkAuthToken(), 1000); + } + + if (!response.ok) { + throw new Error(result.error || 'Execution failed'); + } + } catch (err) { + setExecutionResult({ + output: '', + error: err instanceof Error ? err.message : 'Unknown error', + success: false, + timestamp: new Date().toISOString() + }); + } finally { + setExecuting(false); + } + }; + + const loadCredentialSet = async (setName: string) => { + try { + const response = await fetch(`/api/keys?retrieve=true&set=${encodeURIComponent(setName)}`); + if (response.ok) { + const data = await response.json(); + if (data.credentials) { + // Load complete config: credentials + server settings + handleEnvChange('CLIENT_ID', data.credentials.clientId); + handleEnvChange('SECRET', data.credentials.clientSecret); + handleEnvChange('baseURL', data.credentials.baseURL); + handleEnvChange('authURL', data.credentials.authURL); + setCredentialSetName(setName); + setCurrentFormIsStored(true); // Mark current form as representing stored data + } + } + } catch (error) { + console.error('Failed to load credential set:', error); + } + }; + + const handleEnvChange = (key: string, value: string) => { + setEnvValues(prev => ({ + ...prev, + [key]: value + })); + + // Mark form as unsaved when credentials or server settings change + if (['CLIENT_ID', 'SECRET', 'baseURL', 'authURL'].includes(key)) { + setCurrentFormIsStored(false); + } + }; + + const showSaveNotification = (message: string) => { + setSaveNotification(message); + setTimeout(() => setSaveNotification(null), 3000); // Auto-hide after 3 seconds + }; + + const deleteConfig = async (configName: string) => { + try { + await fetch(`/api/keys?config=${encodeURIComponent(configName)}`, { + method: 'DELETE' + }); + + // Clear form if we deleted the currently selected config + if (selectedCredentialSet === configName) { + setSelectedCredentialSet(''); + setCredentialSetName(''); + handleEnvChange('CLIENT_ID', ''); + handleEnvChange('SECRET', ''); + handleEnvChange('baseURL', 'https://aws-api.sigmacomputing.com/v2'); + handleEnvChange('authURL', 'https://aws-api.sigmacomputing.com/v2/auth/token'); + setCurrentFormIsStored(false); + } + + // Update available sets + const updatedResponse = await fetch('/api/keys?list=true'); + if (updatedResponse.ok) { + const updatedData = await updatedResponse.json(); + setAvailableCredentialSets(updatedData.credentialSets || []); + setDefaultCredentialSet(updatedData.defaultSet || null); + setHasStoredKeys(updatedData.credentialSets?.length > 0); + } + + showSaveNotification(`Config "${configName}" deleted successfully!`); + } catch (error) { + console.error('Failed to delete config:', error); + showSaveNotification('Failed to delete config. Please try again.'); + } + }; + + if (!isOpen) return null; + + return ( +
    +
    + {/* Header */} +
    +
    +

    {fileName}

    +
    + +
    + + {/* Tabs */} +
    + {fileName === 'get-access-token.js' ? ( + // Auth script tab order: README → Config + <> + + + + + + ) : ( + // Regular recipe tab order: Config → Response → README → View Recipe (if params exist) + // Or: Response → README → View Recipe (if no params) + <> + {smartParameters.length > 0 && ( + + )} + + + + + )} +
    + + {/* Content */} +
    + {activeTab === 'readme' ? ( +
    + {fileName === 'get-access-token.js' ? ( +
    +
    +
    + šŸ” +

    Authentication Setup

    + + Required First + +
    + +

    + Configure your API credentials and generate a bearer token for accessing Sigma’s REST API. + Tokens are cached for reuse across recipes during your session. +

    + +
    +

    Required Environment Variables:

    +
    + CLIENT_ID + SECRET + authURL + baseURL +
    +
    + +
    + + + + + Start Here + + + Instructions → + +
    + +
    + Token Duration: 1 hour (cached for session) +
    +
    +
    + ) : ( +
    + {readmeLoading ? ( +
    +
    +

    Loading README...

    +
    + ) : customReadme ? ( +
    +
    { + let html = customReadme; + + // Handle headers + html = html.replace(/^# (.+)$/gm, '

    $1

    '); + html = html.replace(/^## (.+)$/gm, '

    $1

    '); + html = html.replace(/^### (.+)$/gm, '

    $1

    '); + + // Handle inline code + html = html.replace(/`([^`]+)`/g, '$1'); + + // Handle bold text + html = html.replace(/\*\*([^*]+)\*\*/g, '$1'); + + // Handle links + html = html.replace(/\[([^\]]+)\]\(([^)]+)\)/g, '$1'); + + // Process lists line by line + const lines = html.split('\n'); + const processed = []; + let inBulletList = false; + let inNumberList = false; + + for (let i = 0; i < lines.length; i++) { + const line = lines[i]; + const trimmed = line.trim(); + + if (trimmed.startsWith('- ')) { + if (!inBulletList) { + processed.push('
      '); + inBulletList = true; + } + if (inNumberList) { + processed.push(''); + inNumberList = false; + } + processed.push(`
    • ${trimmed.substring(2)}
    • `); + } else if (/^\d+\. /.test(trimmed)) { + if (!inNumberList) { + processed.push('
        '); + inNumberList = true; + } + if (inBulletList) { + processed.push('
    '); + inBulletList = false; + } + processed.push(`
  • ${trimmed.replace(/^\d+\. /, '')}
  • `); + } else { + if (inBulletList) { + processed.push(''); + inBulletList = false; + } + if (inNumberList) { + processed.push(''); + inNumberList = false; + } + if (trimmed === '') { + // Only add break if we're not between sections + const nextLine = lines[i + 1]?.trim(); + if (nextLine && !nextLine.startsWith('#')) { + processed.push('
    '); + } + } else if (trimmed.startsWith('#')) { + // Headers are already processed, just add the line + processed.push(line); + } else { + // Regular text - just add it with minimal spacing + processed.push(`
    ${line}
    `); + } + } + } + + // Close any open lists + if (inBulletList) processed.push(''); + if (inNumberList) processed.push(''); + + return processed.join('\n'); + })() + }} + /> + + ) : ( +
    +

    Recipe Information

    +

    + This recipe demonstrates how to use the Sigma API for specific use cases. + Refer to the code and run the script to see the results. +

    +
    + )} + + )} + + ) : activeTab === 'params' ? ( +
    + {fileName === 'get-access-token.js' ? ( +
    + {/* Header with Setup Guide in top-right corner */} +
    +
    +

    šŸ” Authentication Request

    +

    + Configure your Sigma API credentials to access the platform +

    +

    + Once authenticated, use the "End Session" button in the header to clear your authentication +

    +
    + + šŸ“š Setup Guide + +
    + + {/* Load Existing Config - FIRST thing user does */} + {availableCredentialSets.length > 0 && ( +
    +
    ⚔ Quick Start - Load Saved Config
    +
    +
    + + +
    + {selectedCredentialSet && ( + + )} + + +
    +

    + {availableCredentialSets.length} saved config{availableCredentialSets.length !== 1 ? 's' : ''} available +

    +
    + )} + + {/* Server Endpoint - Manual Configuration */} +
    + + +
    + + {/* API Credentials and Storage - Combined intelligently */} +
    +
    šŸ” API Credentials
    + +
    +
    + + handleEnvChange('CLIENT_ID', e.target.value)} + placeholder="Enter Client ID" + className="w-full px-2 py-1 border border-blue-300 rounded text-sm font-mono focus:border-blue-500 focus:ring-1 focus:ring-blue-500 bg-white" + /> +
    +
    + + handleEnvChange('SECRET', e.target.value)} + placeholder="Enter Client Secret" + className="w-full px-2 py-1 border border-blue-300 rounded text-sm font-mono focus:border-blue-500 focus:ring-1 focus:ring-blue-500 bg-white" + /> +
    +
    + + {/* Storage Options - Integrated into credentials section */} +
    +
    +
    + setStoreKeysLocally(e.target.checked)} + className="h-4 w-4 text-blue-600 focus:ring-blue-500 border-gray-300 rounded" + /> + +
    + {currentFormIsStored && ( + + āœ“ Stored + + )} +
    + + {storeKeysLocally && ( +
    + {/* Save notification */} + {saveNotification && ( +
    + {saveNotification} +
    + )} + +
    +
    + + { + setCredentialSetName(e.target.value); + setCurrentFormIsStored(false); // Mark as unsaved when name changes + // Reset default checkbox when changing config name + setSetAsDefault(false); + }} + placeholder="e.g., Production, Staging" + className="w-full px-2 py-1 border border-blue-300 rounded text-sm focus:border-blue-500 focus:ring-1 focus:ring-blue-500 bg-white" + /> +
    + +
    + setSetAsDefault(e.target.checked)} + className="h-4 w-4 text-blue-600 focus:ring-blue-500 border-blue-300 rounded" + /> + + {setAsDefault && ( + ⭐ + )} +
    + + +
    +
    + )} +
    +
    + + {/* Authentication Status */} + {(authToken || hasValidToken) && ( +
    +
    + āœ… + Currently Authenticated +
    +
    + )} + + {useEnvFile && ( +
    +

    + šŸ“ Environment file mode is enabled. Values above will be ignored in favor of the .env file. +

    +
    + )} +
    + ) : ( + { + console.log('SmartParameterForm authToken:', authToken); + // Switch to Response tab immediately so user can see progress + setActiveTab('run'); + if (!executing) { + executeScript(); + } + }} + executing={executing} + onShowReadme={() => setActiveTab('readme')} + /> + )} +
    + ) : activeTab === 'run' ? ( +
    + {/* Copy Code and Run Script Buttons */} +
    + + +
    + + {/* Parameter Summary */} + {Object.keys(envValues).length > 0 && Object.values(envValues).some(v => v && v.trim()) && ( +
    +

    Request Parameters

    +
    + {smartParameters.map(param => { + const value = envValues[param.name]; + if (!value || !value.trim()) return null; + + return ( +
    + {param.friendlyName}: {value} +
    + ); + })} +
    +
    + )} + + {/* Execution Results */} + {executionResult && ( +
    + {/* Header with Status and Response Code */} +
    +
    +
    + + {executionResult.success === true ? 'āœ…' : + executionResult.success === false ? 'āŒ' : + 'ā³'} + + + {executionResult.success === true + ? `Success${executionResult.httpStatus ? ` (${executionResult.httpStatus})` : ''}` + : executionResult.success === false + ? `Error${executionResult.httpStatus ? ` (${executionResult.httpStatus})` : ''}` + : 'Processing...' + } + +
    + + {new Date(executionResult.timestamp).toLocaleTimeString()} + +
    +
    + + {/* Response Body */} +
    + {executionResult.output && ( +
    +
    +

    Console Output:

    + +
    +
    + {executionResult.output.split('\n').map((line, index) => ( +
    + {line.includes('šŸ“ File saved! Click here to open downloads folder') ? ( + + {line.split('šŸ“ File saved! Click here to open downloads folder')[0]} + + + ) : ( + line + )} +
    + ))} +
    +
    + )} + {executionResult.error && ( +
    +
    +

    Error Details:

    + +
    +
    +                          {executionResult.error}
    +                        
    +
    + )} +
    +
    + )} +
    + ) : ( + // Code tab + loading ? ( +
    +
    + Loading code... +
    + ) : error ? ( +
    +

    Error loading code: {error}

    + +
    + ) : ( +
    +
    + +
    +
    +                  {code}
    +                
    +
    + ) + )} + + + + + ); +} \ No newline at end of file diff --git a/recipe-portal/components/QuickApiExplorer.tsx b/recipe-portal/components/QuickApiExplorer.tsx new file mode 100644 index 00000000..ea38aed1 --- /dev/null +++ b/recipe-portal/components/QuickApiExplorer.tsx @@ -0,0 +1,287 @@ +'use client'; + +import { useState, useEffect } from 'react'; +import { detectSmartParameters, SmartParameter } from '../lib/smartParameters'; +import { QuickApiModal } from './QuickApiModal'; + +interface QuickApiEndpoint { + id: string; + name: string; + method: 'GET'; + path: string; + description: string; + category: 'List All' | 'Get Details'; + parameters: SmartParameter[]; + example?: string; +} + +interface QuickApiExplorerProps { + hasValidToken: boolean; + authToken?: string | null; +} + +const QUICK_ENDPOINTS: QuickApiEndpoint[] = [ + // Zero parameter endpoints + { + id: 'list-accounttypes', + name: 'Account Types', + method: 'GET', + path: '/accountTypes', + description: 'Get a list of all account types', + category: 'List All', + parameters: [], + example: 'View different account type configurations' + }, + { + id: 'list-connections', + name: 'Connections', + method: 'GET', + path: '/connections', + description: 'Get a list of all data connections', + category: 'List All', + parameters: [], + example: 'See all configured data sources' + }, + { + id: 'list-datamodels', + name: 'Data Models', + method: 'GET', + path: '/dataModels', + description: 'Get a list of all data models', + category: 'List All', + parameters: [], + example: 'See all available data models' + }, + { + id: 'list-members', + name: 'Members', + method: 'GET', + path: '/members', + description: 'Get a list of all members in your organization', + category: 'List All', + parameters: [], + example: 'See all users and their details' + }, + { + id: 'list-teams', + name: 'Teams', + method: 'GET', + path: '/teams', + description: 'Get a list of all teams in your organization', + category: 'List All', + parameters: [], + example: 'Perfect for seeing all available teams' + }, + { + id: 'list-templates', + name: 'Templates', + method: 'GET', + path: '/templates', + description: 'Get a list of available templates', + category: 'List All', + parameters: [], + example: 'Browse reusable templates' + }, + { + id: 'list-workbooks', + name: 'Workbooks', + method: 'GET', + path: '/workbooks', + description: 'Get a list of all workbooks you have access to', + category: 'List All', + parameters: [], + example: 'Browse all available workbooks' + }, + { + id: 'list-workspaces', + name: 'Workspaces', + method: 'GET', + path: '/workspaces', + description: 'Get a list of all workspaces', + category: 'List All', + parameters: [], + example: 'View organizational structure' + }, + // Single parameter endpoints + { + id: 'get-datamodel', + name: 'Data Model Details', + method: 'GET', + path: '/dataModels/{dataModelId}', + description: 'Get detailed information about a specific data model', + category: 'Get Details', + parameters: detectSmartParameters(['dataModelId']), + example: 'Get data model structure and metadata' + }, + { + id: 'get-member', + name: 'Member Details', + method: 'GET', + path: '/members/{memberId}', + description: 'Get detailed information about a specific member', + category: 'Get Details', + parameters: detectSmartParameters(['memberId']), + example: 'Get user profile and permissions' + }, + { + id: 'get-team', + name: 'Team Details', + method: 'GET', + path: '/teams/{teamId}', + description: 'Get detailed information about a specific team', + category: 'Get Details', + parameters: detectSmartParameters(['teamId']), + example: 'Get team members and permissions' + }, + { + id: 'get-workbook', + name: 'Workbook Details', + method: 'GET', + path: '/workbooks/{workbookId}', + description: 'Get detailed information about a specific workbook', + category: 'Get Details', + parameters: detectSmartParameters(['workbookId']), + example: 'Get workbook metadata and structure' + }, + { + id: 'get-workbook-pages', + name: 'Workbook Pages', + method: 'GET', + path: '/workbooks/{workbookId}/pages', + description: 'Get all pages in a workbook with their metadata', + category: 'Get Details', + parameters: detectSmartParameters(['workbookId']), + example: 'See all pages and their structure in the workbook' + } +]; + +export function QuickApiExplorer({ hasValidToken, authToken }: QuickApiExplorerProps) { + const [activeCategory, setActiveCategory] = useState<'List All' | 'Get Details'>('List All'); + const [selectedEndpoint, setSelectedEndpoint] = useState(null); + const [showModal, setShowModal] = useState(false); + + const categories = ['List All', 'Get Details'] as const; + const filteredEndpoints = QUICK_ENDPOINTS.filter(endpoint => endpoint.category === activeCategory); + + const handleEndpointClick = (endpoint: QuickApiEndpoint) => { + setSelectedEndpoint(endpoint); + setShowModal(true); + }; + + const handleCloseModal = () => { + setShowModal(false); + setSelectedEndpoint(null); + }; + + // Clear results when component mounts/unmounts + useEffect(() => { + return () => { + setSelectedEndpoint(null); + }; + }, []); + + return ( +
    +
    + {/* Header */} +
    +

    Common GET Methods

    +

    + Quickly test common Sigma API endpoints with minimal setup. Perfect for exploring your data and getting familiar with the API. +

    +
    + + {!hasValidToken && ( +
    +
    + šŸ” +
    +

    Authentication Required

    +

    Please authenticate first to test these API endpoints.

    +
    +
    +
    + )} + + {/* Category Tabs */} +
    +
    + {categories.map((category) => ( + + ))} +
    +
    + + {/* Endpoint Grid */} +
    + {filteredEndpoints.map((endpoint) => ( +
    handleEndpointClick(endpoint)} + > + {/* Header */} +
    +
    +
    + + {endpoint.method} + +

    + {endpoint.name} +

    +
    + {endpoint.parameters.length > 0 && ( + + {endpoint.parameters.length} param{endpoint.parameters.length !== 1 ? 's' : ''} + + )} +
    + +
    + + {/* Description */} +

    + {endpoint.description} +

    + + {/* API Path */} +
    +

    API Endpoint:

    + + {endpoint.method} {endpoint.path} + +
    +
    + ))} +
    + + {/* Modal */} + {selectedEndpoint && ( + + )} +
    +
    + ); +} \ No newline at end of file diff --git a/recipe-portal/components/QuickApiModal.tsx b/recipe-portal/components/QuickApiModal.tsx new file mode 100644 index 00000000..ffb18d16 --- /dev/null +++ b/recipe-portal/components/QuickApiModal.tsx @@ -0,0 +1,259 @@ +'use client'; + +import { useState } from 'react'; +import { SmartParameterForm } from './SmartParameterForm'; +import { SmartParameter } from '../lib/smartParameters'; + +interface QuickApiEndpoint { + id: string; + name: string; + method: 'GET'; + path: string; + description: string; + category: 'List All' | 'Get Details'; + parameters: SmartParameter[]; + example?: string; +} + +interface QuickApiModalProps { + isOpen: boolean; + onClose: () => void; + endpoint: QuickApiEndpoint; + hasValidToken: boolean; + authToken?: string | null; +} + +interface ExecutionResult { + output: string; + error: string; + success: boolean; + timestamp: string; + httpStatus?: number; + requestUrl?: string; + requestMethod?: string; +} + +export function QuickApiModal({ isOpen, onClose, endpoint, hasValidToken, authToken }: QuickApiModalProps) { + const [paramValues, setParamValues] = useState>({}); + const [executing, setExecuting] = useState(false); + const [executionResult, setExecutionResult] = useState(null); + + const executeEndpoint = async () => { + setExecuting(true); + setExecutionResult(null); + + try { + // Build URL with path parameters + let url = endpoint.path; + endpoint.parameters.forEach(param => { + const value = paramValues[param.name]; + if (value) { + url = url.replace(`{${param.name}}`, value); + } + }); + + const response = await fetch('/api/call', { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + }, + body: JSON.stringify({ + endpoint: url, + method: endpoint.method, + parameters: { + path: {}, + query: {}, + header: {} + } + }) + }); + + const result = await response.json(); + setExecutionResult({ + ...result, + requestUrl: url, + requestMethod: endpoint.method + }); + + } catch (err) { + setExecutionResult({ + output: '', + error: err instanceof Error ? err.message : 'Unknown error', + success: false, + timestamp: new Date().toISOString() + }); + } finally { + setExecuting(false); + } + }; + + if (!isOpen) return null; + + return ( +
    +
    + {/* Header */} +
    +
    +
    + + {endpoint.method} + +

    {endpoint.name}

    +
    + + {endpoint.path} + +
    + +
    + + {/* Content */} +
    +

    {endpoint.description}

    + {endpoint.example && ( +

    {endpoint.example}

    + )} + + {!hasValidToken && ( +
    +
    + šŸ” +
    +

    Authentication Required

    +

    Please authenticate first to test this API endpoint.

    +
    +
    +
    + )} + + {/* Parameters */} + {endpoint.parameters.length > 0 && ( +
    + +
    + )} + + {/* Execute Button */} +
    + + {!hasValidToken && ( +

    + Authentication required to call API +

    + )} +
    + + {/* Parameter Summary */} + {Object.keys(paramValues).length > 0 && Object.values(paramValues).some(v => v && v.trim()) && ( +
    +

    Request Parameters

    +
    + {endpoint.parameters.map(param => { + const value = paramValues[param.name]; + if (!value || !value.trim()) return null; + + return ( +
    + {param.friendlyName}: {value} +
    + ); + })} +
    +
    + )} + + {/* Results */} + {executionResult && ( +
    +
    +
    +
    + + {executionResult.success ? 'āœ…' : 'āŒ'} + +
    + + {executionResult.success + ? `Success${executionResult.httpStatus ? ` (${executionResult.httpStatus})` : ''}` + : `Error${executionResult.httpStatus ? ` (${executionResult.httpStatus})` : ''}` + } + + {executionResult.requestUrl && ( +
    + {executionResult.requestMethod} {executionResult.requestUrl} +
    + )} +
    +
    + + {new Date(executionResult.timestamp).toLocaleTimeString()} + +
    +
    + +
    + {executionResult.output && ( +
    +
    +

    Response:

    + +
    +
    +                      {executionResult.output}
    +                    
    +
    + )} + {executionResult.error && ( +
    +

    Error:

    +
    +                      {executionResult.error}
    +                    
    +
    + )} +
    +
    + )} +
    +
    +
    + ); +} \ No newline at end of file diff --git a/recipe-portal/components/RecipeCard.tsx b/recipe-portal/components/RecipeCard.tsx new file mode 100644 index 00000000..940bff19 --- /dev/null +++ b/recipe-portal/components/RecipeCard.tsx @@ -0,0 +1,122 @@ +'use client'; + +import { useState } from 'react'; +import { Recipe } from '../lib/recipeScanner'; +import { CodeViewer } from './CodeViewer'; + +interface RecipeCardProps { + recipe: Recipe; + hasValidToken?: boolean; +} + +function getStatusBadge(recipe: Recipe, hasValidToken: boolean) { + const badges = []; + + // Check if this is a download recipe + const downloadRecipes = ['export-workbook-element-csv.js', 'export-workbook-pdf.js']; + const isDownloadRecipe = downloadRecipes.some(downloadFileName => + recipe.filePath.endsWith(downloadFileName) + ); + + if (isDownloadRecipe) { + badges.push({ + text: 'ā¬‡ļø Download', + className: 'bg-blue-100 text-blue-800' + }); + } + + // Only show "Ready to Run" if recipe has no variables AND (doesn't need auth OR has valid token) + if (recipe.envVariables.length === 0 && (!recipe.isAuthRequired || hasValidToken)) { + badges.push({ + text: 'Ready to Run', + className: 'bg-green-100 text-green-800' + }); + } + + return badges.length > 0 ? badges : null; +} + +function getCategoryIcon(category: string) { + const icons: Record = { + 'connections': 'šŸ”—', + 'members': 'šŸ‘„', + 'teams': 'šŸ‘«', + 'workbooks': 'šŸ“Š', + 'embedding': 'šŸ”§', + 'authentication': 'šŸ”' + }; + + return icons[category.toLowerCase()] || 'šŸ“„'; +} + +export function RecipeCard({ recipe, hasValidToken = false }: RecipeCardProps) { + const [showCodeViewer, setShowCodeViewer] = useState(false); + const badges = getStatusBadge(recipe, hasValidToken); + const icon = getCategoryIcon(recipe.category); + + return ( +
    + {/* Header */} +
    +
    +
    + {icon} +

    + {recipe.name} +

    +
    + {badges && badges.map((badge, index) => ( + + {badge.text} + + ))} +
    + +
    + + {/* Description */} +

    + {recipe.description} +

    + + {/* Environment Variables */} + {recipe.envVariables.length > 0 && ( +
    +

    Required Variables:

    +
    + {recipe.envVariables.slice(0, 3).map((envVar) => ( + + {envVar} + + ))} + {recipe.envVariables.length > 3 && ( + + +{recipe.envVariables.length - 3} more + + )} +
    +
    + )} + + + setShowCodeViewer(false)} + filePath={recipe.filePath} + fileName={recipe.filePath.split('/').pop() || 'recipe.js'} + envVariables={recipe.envVariables} + useEnvFile={false} + defaultTab="run" + readmePath={recipe.readmePath} + /> +
    + ); +} \ No newline at end of file diff --git a/recipe-portal/components/SmartParameterForm.tsx b/recipe-portal/components/SmartParameterForm.tsx new file mode 100644 index 00000000..cb9b061e --- /dev/null +++ b/recipe-portal/components/SmartParameterForm.tsx @@ -0,0 +1,455 @@ +'use client'; + +import { useState, useEffect } from 'react'; +import { SmartParameter } from '../lib/smartParameters'; + +interface SmartParameterFormProps { + parameters: SmartParameter[]; + values: Record; + onChange: (values: Record) => void; + authToken?: string | null; + onRunScript?: () => void; + executing?: boolean; + context?: 'recipe' | 'api'; + onShowReadme?: () => void; +} + +interface ResourceData { + id: string; + name: string; + description?: string; + [key: string]: any; +} + +export function SmartParameterForm({ + parameters, + values, + onChange, + authToken, + onRunScript, + executing = false, + context = 'recipe', + onShowReadme +}: SmartParameterFormProps) { + const [resourceData, setResourceData] = useState>({}); + const [loadingResources, setLoadingResources] = useState>({}); + + // Fetch resource data for dropdown parameters + useEffect(() => { + console.log('SmartParameterForm useEffect triggered - authToken changed:', authToken?.substring(0,20) + '...'); + + if (!authToken) { + setResourceData({}); + return; + } + + // Create an abort controller for this effect run + const abortController = new AbortController(); + let isCancelled = false; + + const fetchResources = async () => { + // Clear existing resource data at start + setResourceData({}); + + const resourceTypes = new Set(); + parameters.forEach(param => { + if (param.resourceType) { + resourceTypes.add(param.resourceType); + } + }); + + for (const resourceType of Array.from(resourceTypes)) { + // Check if this effect was cancelled + if (abortController.signal.aborted || isCancelled) { + console.log(`Effect cancelled, aborting ${resourceType} request`); + return; + } + + // Check if this resource type has dependencies + const param = parameters.find(p => p.resourceType === resourceType); + const dependentValue = param?.dependsOn ? values[param.dependsOn] : null; + + // Create a cache key that includes dependencies + const cacheKey = param?.dependsOn ? `${resourceType}_${dependentValue}` : resourceType; + + // Skip loading if dependency is not met + if (param?.dependsOn && !dependentValue) { + continue; + } + + setLoadingResources(prev => ({ ...prev, [resourceType]: true })); + + try { + let url = `/api/resources?type=${resourceType}&token=${encodeURIComponent(authToken)}`; + if (param?.dependsOn && dependentValue) { + // Map parameter names to expected API parameter names + const paramMapping: Record = { + 'WORKBOOK_ID': 'workbookId', + 'MEMBER_ID': 'memberId', + 'TEAM_ID': 'teamId' + }; + const apiParamName = paramMapping[param.dependsOn] || param.dependsOn.toLowerCase(); + url += `&${apiParamName}=${encodeURIComponent(dependentValue)}`; + } + + console.log(`Fetching ${resourceType} with token ${authToken?.substring(0,20)}...`); + console.log(`Dependent value for ${param?.dependsOn}:`, dependentValue); + + const response = await fetch(url, { signal: abortController.signal }); + + // Final check before processing response + if (abortController.signal.aborted || isCancelled) { + console.log(`Effect cancelled after ${resourceType} response, discarding results`); + return; + } + + if (response.ok) { + const data = await response.json(); + console.log(`Received ${resourceType} data (${data.data?.length || 0} items) with token ${authToken?.substring(0,20)}...`); + + // Only update state if not cancelled + if (!abortController.signal.aborted && !isCancelled) { + setResourceData(prev => ({ ...prev, [cacheKey]: data.data || [] })); + } + } else { + console.warn(`Failed to fetch ${resourceType}:`, response.statusText); + const errorText = await response.text(); + console.warn(`Error response:`, errorText); + } + } catch (error) { + if (error.name === 'AbortError') { + console.log(`Fetch ${resourceType} aborted`); + } else { + console.warn(`Error fetching ${resourceType}:`, error); + } + } finally { + if (!abortController.signal.aborted && !isCancelled) { + setLoadingResources(prev => ({ ...prev, [resourceType]: false })); + } + } + } + }; + + fetchResources(); + + // Cleanup function to cancel ongoing requests when token changes + return () => { + console.log('Cleaning up SmartParameterForm effect - cancelling ongoing requests'); + isCancelled = true; + abortController.abort(); + }; + }, [parameters, authToken, values]); + + const handleChange = (paramName: string, value: string) => { + const newValues = { ...values, [paramName]: value }; + + + // Clear dependent parameters when a parent parameter changes + parameters.forEach(param => { + if (param.dependsOn === paramName) { + newValues[param.name] = ''; + // Also clear cached resource data for dependent parameters + const dependentCacheKey = `${param.resourceType}_${value}`; + setResourceData(prev => { + const updated = { ...prev }; + Object.keys(updated).forEach(key => { + if (key.startsWith(`${param.resourceType}_`) && key !== dependentCacheKey) { + delete updated[key]; + } + }); + return updated; + }); + } + }); + + onChange(newValues); + }; + + const renderParameter = (param: SmartParameter) => { + const currentValue = values[param.name] || ''; + + // Handle date inputs + if (param.type === 'date') { + // Convert stored value to YYYY-MM-DD format if it's in MM/DD/YYYY format + const convertToISODate = (dateStr: string): string => { + if (!dateStr) return ''; + + // If it's already in YYYY-MM-DD format, return as-is + if (/^\d{4}-\d{2}-\d{2}$/.test(dateStr)) { + return dateStr; + } + + // If it's in MM/DD/YYYY format, convert it + if (/^\d{1,2}\/\d{1,2}\/\d{4}$/.test(dateStr)) { + const [month, day, year] = dateStr.split('/'); + return `${year}-${month.padStart(2, '0')}-${day.padStart(2, '0')}`; + } + + return dateStr; + }; + + // Convert from YYYY-MM-DD to display format and back + const displayValue = currentValue; + + return ( +
    + +

    {param.description} (Format: YYYY-MM-DD)

    + { + // HTML date input always provides YYYY-MM-DD format + handleChange(param.name, e.target.value); + }} + className="w-full px-3 py-2 border border-gray-300 rounded-md text-sm focus:border-blue-500 focus:ring-1 focus:ring-blue-500" + /> + {currentValue && ( +

    + API format: {convertToISODate(currentValue)} +

    + )} +
    + ); + } + + // Handle resource-based dropdowns + if (param.resourceType && authToken) { + // Check if this parameter depends on another parameter + const dependentValue = param.dependsOn ? values[param.dependsOn] : null; + const cacheKey = param.dependsOn ? `${param.resourceType}_${dependentValue}` : param.resourceType; + + const resources = resourceData[cacheKey] || []; + const isLoading = loadingResources[param.resourceType]; + const isDisabled = param.dependsOn && !dependentValue; + + // Debug logging + if (param.resourceType === 'accountTypes') { + console.log('AccountTypes debug:', { + resources, + isLoading, + resourceDataKeys: Object.keys(resourceData), + fullResourceData: resourceData + }); + } + + return ( +
    + +

    {param.description}

    + + {isDisabled ? ( +
    + Please select {param.dependsOn?.replace('_', ' ').toLowerCase()} first +
    + ) : isLoading ? ( +
    +
    + Loading {param.resourceType}... +
    + ) : resources.length > 0 ? ( + + ) : param.options && param.options.length > 0 ? ( + + ) : ( +
    + No {param.resourceType} available or authentication required +
    + )} + + {currentValue && ( +

    + Selected: {currentValue} +

    + )} +
    + ); + } + + // Handle predefined options + if (param.type === 'select' && param.options) { + return ( +
    + +

    {param.description}

    + +
    + ); + } + + // Handle boolean parameters + if (param.type === 'boolean') { + return ( +
    + +

    {param.description}

    + +
    + ); + } + + // Handle JSON parameters + if (param.type === 'json') { + return ( +
    + +

    {param.description}

    +