UNPKG

snow-flow

Version:

Snow-Flow v3.2.0: Complete ServiceNow Enterprise Suite with 180+ MCP Tools. ATF Testing, Knowledge Management, Service Catalog, Change Management with CAB scheduling, Virtual Agent chatbots with NLU, Performance Analytics KPIs, Flow Designer automation, A

1,097 lines 130 kB
#!/usr/bin/env node "use strict"; /** * ServiceNow API Client * Handles all ServiceNow API operations with OAuth authentication */ var __importDefault = (this && this.__importDefault) || function (mod) { return (mod && mod.__esModule) ? mod : { "default": mod }; }; Object.defineProperty(exports, "__esModule", { value: true }); exports.ServiceNowClient = void 0; const axios_1 = __importDefault(require("axios")); const https_1 = __importDefault(require("https")); const snow_oauth_1 = require("./snow-oauth"); const action_type_cache_1 = require("./action-type-cache"); const widget_template_generator_js_1 = require("./widget-template-generator.js"); const logger_1 = require("./logger"); const unified_auth_store_js_1 = require("./unified-auth-store.js"); const timeout_manager_js_1 = require("./timeout-manager.js"); class ServiceNowClient { constructor() { this.credentials = null; // 🔴 SNOW-003 FIX: Token refresh synchronization to prevent race conditions this.tokenRefreshPromise = null; this.lastTokenRefresh = 0; this.logger = new logger_1.Logger('ServiceNowClient'); this.oauth = new snow_oauth_1.ServiceNowOAuth(); // 🔒 SSL/TLS Certificate Validation Fix: Allow self-signed certificates for ServiceNow dev instances const httpsAgent = new https_1.default.Agent({ rejectUnauthorized: false, // Allow self-signed certificates for dev instances (fixes "certificate has expired" errors) checkServerIdentity: (hostname, cert) => { // Custom certificate validation for ServiceNow instances // Allow *.service-now.com and user-configured instances const validHosts = [ /.*\.service-now\.com$/, /.*\.servicenow\.com$/ ]; // Get configured instance hostname const instanceUrl = process.env.SNOW_INSTANCE; if (instanceUrl) { try { const instanceHostname = new URL(instanceUrl.startsWith('http') ? instanceUrl : `https://${instanceUrl}`).hostname; validHosts.push(new RegExp(`^${instanceHostname.replace(/\./g, '\\.')}$`)); } catch (error) { // Invalid URL format, rely on default validation } } // Check if hostname matches allowed patterns if (validHosts.some(pattern => pattern.test(hostname))) { return undefined; // Valid hostname } // For development/testing environments, allow localhost/127.0.0.1 if (process.env.NODE_ENV === 'development' && (hostname === 'localhost' || hostname === '127.0.0.1' || hostname.startsWith('192.168.'))) { return undefined; } return new Error(`Certificate hostname mismatch: ${hostname} not allowed`); }, // secureProtocol removed to avoid conflict with minVersion/maxVersion ciphers: [ 'ECDHE-RSA-AES128-GCM-SHA256', 'ECDHE-RSA-AES256-GCM-SHA384', 'ECDHE-RSA-AES128-SHA256', 'ECDHE-RSA-AES256-SHA384' ].join(':'), // Only allow secure ciphers honorCipherOrder: true, maxVersion: 'TLSv1.3', minVersion: 'TLSv1.2' }); // Use intelligent timeout based on operation type (default to TABLE_QUERY) const defaultTimeout = (0, timeout_manager_js_1.getTimeoutConfig)(timeout_manager_js_1.OperationType.TABLE_QUERY).baseTimeout; this.client = axios_1.default.create({ timeout: parseInt(process.env.SNOW_API_TIMEOUT || String(defaultTimeout)), headers: { 'Content-Type': 'application/json', 'Accept': 'application/json' }, httpsAgent, // Use secure HTTPS agent with certificate validation // Additional security headers maxRedirects: 5, validateStatus: (status) => status < 400 // Reject 4xx and 5xx by default }); // Store deployment timeout from environment this.deploymentTimeout = parseInt(process.env.SNOW_DEPLOYMENT_TIMEOUT || '300000'); // 5 minutes default // 🔧 CRITICAL FIX: Add makeRequest method to Axios instance to fix phantom calls // Some code expects makeRequest to exist on this.client (the Axios instance) this.client.makeRequest = async (config) => { this.logger.debug('🔧 AXIOS makeRequest called! Config:', config); this.logger.debug('🔧 Routing to appropriate HTTP method...'); // Route to the appropriate Axios method based on the request config const method = (config.method || 'GET').toLowerCase(); const url = config.url || config.endpoint; const data = config.data || config.body; // CRITICAL FIX: Properly merge headers to allow content-type overrides for XML requests const requestConfig = { ...config, headers: { ...(this.client.defaults?.headers?.common || {}), ...(this.client.defaults.headers[method] || {}), ...config.headers // This ensures custom headers (like Content-Type: application/xml) override defaults } }; this.logger.debug('🔧 Final request config headers:', requestConfig.headers); switch (method) { case 'get': return this.client.get(url, { params: config.params, ...requestConfig }); case 'post': return this.client.post(url, data, requestConfig); case 'put': return this.client.put(url, data, requestConfig); case 'patch': return this.client.patch(url, data, requestConfig); case 'delete': return this.client.delete(url, requestConfig); default: throw new Error(`Unsupported HTTP method: ${method}`); } }; this.actionTypeCache = new action_type_cache_1.ActionTypeCache(this); // Add request interceptor for authentication with automatic token refresh this.client.interceptors.request.use(async (config) => { await this.ensureAuthenticated(); // Get fresh access token (automatically refreshes if expired) const accessToken = await this.oauth.getAccessToken(); if (accessToken) { config.headers['Authorization'] = `Bearer ${accessToken}`; // Update local credentials with fresh token if (this.credentials) { this.credentials.accessToken = accessToken; } } else { console.warn('⚠️ No access token available - request may fail'); } return config; }); // Add response interceptor for error handling with retry logic this.client.interceptors.response.use((response) => response, async (error) => { const originalRequest = error.config; // 🔴 SNOW-003 FIX: Prevent token refresh race conditions if (error.response?.status === 401 && !originalRequest._retry) { originalRequest._retry = true; // 🔴 CRITICAL: Use singleton token refresh to prevent multiple concurrent refreshes return this.handleTokenRefreshWithLock(originalRequest); } // Log other errors for debugging if (error.response?.status === 403) { console.error('❌ 403 Forbidden - Check ServiceNow permissions'); } else if (error.response?.status >= 500) { console.error('❌ ServiceNow server error:', error.response.status); } return Promise.reject(error); }); } /** * Public getter for credentials */ get credentialsInstance() { return this.credentials; } /** * 🔴 SNOW-003 FIX: Handle token refresh with locking to prevent concurrent refreshes * This prevents cascade failures caused by multiple simultaneous token refresh attempts */ async handleTokenRefreshWithLock(originalRequest) { const now = Date.now(); // 🔴 CRITICAL: If another refresh is in progress, wait for it if (this.tokenRefreshPromise) { this.logger.info('🔄 Token refresh already in progress, waiting...'); try { await this.tokenRefreshPromise; // After waiting, check if we have valid credentials now if (this.credentials?.accessToken) { this.logger.info('✅ Using refreshed token from concurrent refresh'); originalRequest.headers['Authorization'] = `Bearer ${this.credentials.accessToken}`; return this.client.request(originalRequest); } } catch (error) { this.logger.warn('Concurrent token refresh failed:', error); } } // 🔴 CRITICAL: Rate limit token refresh to prevent excessive API calls if (now - this.lastTokenRefresh < 5000) { // 5 second rate limit this.logger.warn('⚠️ Token refresh rate limited - too many attempts'); throw new Error('Token refresh rate limited. Please wait before retrying.'); } // 🔴 CRITICAL: Create new refresh promise with timeout and error handling this.tokenRefreshPromise = this.performTokenRefreshWithTimeout(); this.lastTokenRefresh = now; try { this.logger.info('🔄 Starting token refresh process...'); const refreshResult = await this.tokenRefreshPromise; if (refreshResult.success && refreshResult.accessToken) { this.logger.info('✅ Token refreshed successfully, retrying original request...'); // Update local credentials if (this.credentials) { this.credentials.accessToken = refreshResult.accessToken; this.credentials.expiresAt = refreshResult.expiresAt; } // Update the authorization header with new token originalRequest.headers['Authorization'] = `Bearer ${refreshResult.accessToken}`; // Clear the promise since we're done this.tokenRefreshPromise = null; // Retry the original request return this.client.request(originalRequest); } else { // Clear the promise on failure this.tokenRefreshPromise = null; const errorMsg = `Token refresh failed: ${refreshResult.error || 'Unknown error'}`; this.logger.error('❌ ' + errorMsg); console.error('💡 Please run "snow-flow auth login" to re-authenticate'); throw new Error(errorMsg); } } catch (error) { // Clear the promise on any error this.tokenRefreshPromise = null; this.logger.error('🔴 Token refresh process failed:', error); throw error; } } /** * 🔴 SNOW-003 FIX: Token refresh with timeout to prevent hanging requests */ async performTokenRefreshWithTimeout() { const timeout = 15000; // 15 second timeout for token refresh return Promise.race([ this.oauth.refreshAccessToken(), new Promise((_, reject) => setTimeout(() => reject(new Error('Token refresh timeout')), timeout)), ]); } /** * Ensure we have valid authentication with improved error handling */ async ensureAuthenticated() { if (!this.credentials) { // Try unified auth store first (most reliable) const tokens = await unified_auth_store_js_1.unifiedAuthStore.getTokens(); if (tokens) { this.credentials = { instance: tokens.instance, clientId: tokens.clientId, clientSecret: tokens.clientSecret, accessToken: tokens.accessToken, refreshToken: tokens.refreshToken, expiresAt: tokens.expiresAt }; } else { // Fallback to OAuth loadCredentials this.credentials = await this.oauth.loadCredentials(); } } if (!this.credentials) { console.error('❌ No ServiceNow credentials found'); console.error(''); console.error('🔧 To fix this:'); console.error(' 1. Ensure .env file has OAuth credentials:'); console.error(' SNOW_INSTANCE=your-instance.service-now.com'); console.error(' SNOW_CLIENT_ID=your_oauth_client_id'); console.error(' SNOW_CLIENT_SECRET=your_oauth_client_secret'); console.error(' 2. Run: snow-flow auth login'); console.error(''); throw new Error('No ServiceNow credentials found. Set up .env file and run "snow-flow auth login".'); } // Check if we have credentials but no access token (OAuth login needed) if (!this.credentials.accessToken) { console.error('🔐 OAuth authentication required'); console.error(''); console.error('✅ Your .env file has OAuth credentials'); console.error('❌ But no active OAuth session found'); console.error(''); console.error('🔧 To authenticate:'); console.error(' Run: snow-flow auth login'); console.error(' This will open your browser for OAuth login.'); console.error(''); throw new Error('OAuth login required. Run "snow-flow auth login" to authenticate.'); } // Check if token is valid/authenticated const isAuth = await this.oauth.isAuthenticated(); if (!isAuth) { console.error('⏰ OAuth token expired'); console.error(''); console.error('🔄 Attempting automatic token refresh...'); // Try to refresh the token const refreshResult = await this.oauth.refreshAccessToken(); if (refreshResult.success && refreshResult.accessToken) { this.logger.info('✅ Token refreshed successfully'); // Update local credentials this.credentials.accessToken = refreshResult.accessToken; return; // Success! } console.error('❌ Token refresh failed:', refreshResult.error); console.error(''); console.error('🔧 To fix this:'); console.error(' Run: snow-flow auth login'); console.error(' This will re-authenticate with ServiceNow.'); console.error(''); throw new Error('OAuth token expired and refresh failed. Run "snow-flow auth login" to re-authenticate.'); } } /** * Proactively refresh token if it's about to expire * Useful for long-running operations */ async refreshTokenIfNeeded() { try { const tokens = await this.oauth.loadTokens(); if (!tokens) return false; const expiresAt = new Date(tokens.expiresAt); const now = new Date(); const fiveMinutesFromNow = new Date(now.getTime() + 5 * 60 * 1000); // If token expires in the next 5 minutes, refresh it if (expiresAt <= fiveMinutesFromNow) { this.logger.info('🔄 Token expiring soon, refreshing proactively...'); const refreshResult = await this.oauth.refreshAccessToken(); if (refreshResult.success) { this.logger.info('✅ Token refreshed proactively'); if (this.credentials && refreshResult.accessToken) { this.credentials.accessToken = refreshResult.accessToken; } return true; } else { console.error('❌ Proactive token refresh failed'); return false; } } return true; // Token still valid } catch (error) { console.error('Error checking token expiry:', error); return false; } } /** * Get base URL for ServiceNow instance */ getBaseUrl() { if (!this.credentials) { throw new Error('No credentials available'); } // Remove trailing slash from instance URL to prevent double slashes in API calls const instance = this.credentials.instance.replace(/\/$/, ''); return `https://${instance}`; } /** * Sanitize a flow name for use as internal_name */ sanitizeInternalName(name) { return name .toLowerCase() .replace(/[^a-z0-9_\s]/g, '') // Remove special characters except underscores and spaces .replace(/\s+/g, '_') // Replace spaces with underscores .replace(/_+/g, '_') // Replace multiple underscores with single .replace(/^_|_$/g, '') // Remove leading/trailing underscores .substring(0, 80); // Limit length to 80 characters } /** * Validate deployment permissions and diagnose authentication issues */ async validateDeploymentPermissions() { // Ensure we have credentials before running diagnostics await this.ensureAuthenticated(); const diagnostics = { instance_url: this.getBaseUrl(), timestamp: new Date().toISOString(), tests: {} }; const tests = [ { name: 'Read Access', description: 'Test basic read permissions', test: async () => { const response = await this.client.get(`${this.getBaseUrl()}/api/now/table/sys_user?sysparm_limit=1`); return { success: true, data: response.data }; } }, { name: 'Widget Read Access', description: 'Test Service Portal widget read access', test: async () => { const response = await this.client.get(`${this.getBaseUrl()}/api/now/table/sp_widget?sysparm_limit=1`); return { success: true, data: response.data }; } }, { name: 'Update Set Access', description: 'Test Update Set management permissions', test: async () => { const response = await this.client.get(`${this.getBaseUrl()}/api/now/table/sys_update_set?sysparm_limit=1`); return { success: true, data: response.data }; } }, { name: 'Widget Write Test', description: 'Test Service Portal widget write permissions (dry run)', test: async () => { // Try to create a minimal test widget that we'll immediately delete const testWidget = { name: `test_widget_${Date.now()}`, id: `test_${Date.now()}`, title: 'Test Widget (Will be deleted)', template: '<div>Test</div>', css: '', script: '', option_schema: '[]' }; try { const createResponse = await this.client.post(`${this.getBaseUrl()}/api/now/table/sp_widget`, testWidget); // CRITICAL FIX: Add null safety for response processing if (!createResponse || !createResponse.data || !createResponse.data.result || !createResponse.data.result.sys_id) { throw new Error('Widget creation succeeded but response structure is unexpected - unable to verify sys_id'); } const sys_id = createResponse.data.result.sys_id; // Immediately delete the test widget with error handling try { await this.client.delete(`${this.getBaseUrl()}/api/now/table/sp_widget/${sys_id}`); } catch (deleteError) { this.logger.warn(`Test widget created but cleanup failed: ${deleteError instanceof Error ? deleteError.message : String(deleteError)}`); // Don't fail the test just because cleanup failed } return { success: true, data: { message: 'Widget write permissions confirmed' } }; } catch (error) { throw error; } } }, { name: 'User Role Check', description: 'Check user roles and permissions', test: async () => { const response = await this.client.get(`${this.getBaseUrl()}/api/now/table/sys_user_role?sysparm_query=user=javascript:gs.getUserID()`); // CRITICAL FIX: Add null safety for response processing if (!response || !response.data || !response.data.result) { return { success: true, data: { roles: [], warning: 'Unable to retrieve user roles - response structure unexpected' } }; } // Safely process roles with null checks const roles = Array.isArray(response.data.result) ? response.data.result.map((r) => { if (!r || typeof r !== 'object') return 'Unknown Role'; return r.role?.display_value || r.role || 'Unknown Role'; }).filter(role => role && role !== 'Unknown Role') : []; return { success: true, data: { roles } }; } } ]; for (const test of tests) { try { this.logger.info(`Running diagnostic: ${test.name}...`); const result = await test.test(); diagnostics.tests[test.name] = { status: '✅ PASS', description: test.description, result: result.data, error: null }; } catch (error) { const errorMessage = error instanceof Error ? error.message : String(error); diagnostics.tests[test.name] = { status: '❌ FAIL', description: test.description, result: null, error: errorMessage, http_status: this.extractHttpStatus(errorMessage) }; } } // Analyze results with null safety const failedTests = diagnostics.tests && typeof diagnostics.tests === 'object' ? Object.values(diagnostics.tests).filter((test) => test && test.status && test.status.includes('FAIL')) : []; const passedTests = diagnostics.tests && typeof diagnostics.tests === 'object' ? Object.values(diagnostics.tests).filter((test) => test && test.status && test.status.includes('PASS')) : []; diagnostics.summary = { total_tests: tests.length || 0, passed: passedTests.length || 0, failed: failedTests.length || 0, overall_status: failedTests.length === 0 && passedTests.length > 0 ? '✅ ALL SYSTEMS GO' : '⚠️ ISSUES DETECTED' }; // Generate recommendations with null safety try { diagnostics.recommendations = this.generateAuthRecommendations(diagnostics.tests); } catch (recError) { this.logger.error('Error generating recommendations:', recError); diagnostics.recommendations = ['⚠️ Unable to generate recommendations due to error']; } return { success: failedTests.length === 0, data: diagnostics, error: failedTests.length > 0 ? 'Some deployment permissions are missing' : null }; } /** * Extract HTTP status from error message */ extractHttpStatus(errorMessage) { const statusMatch = errorMessage.match(/status code (\d+)/i); return statusMatch ? parseInt(statusMatch[1], 10) : null; } /** * Generate authentication recommendations based on test results */ generateAuthRecommendations(tests) { const recommendations = []; // CRITICAL FIX: Add comprehensive null safety checks if (!tests || typeof tests !== 'object') { recommendations.push('⚠️ Unable to analyze test results due to missing test data'); return recommendations; } // Safe check for Widget Write Test const widgetTest = tests['Widget Write Test']; if (widgetTest?.status && typeof widgetTest.status === 'string' && widgetTest.status.includes('FAIL')) { const error = widgetTest.error; if (error && typeof error === 'string') { if (error.includes('403')) { recommendations.push('🔐 Widget deployment failed with 403 Forbidden. Check OAuth scopes: ensure \'useraccount\' and \'glide_system_administration\' scopes are enabled'); recommendations.push('👤 Verify user has sp_portal_manager or admin role in ServiceNow'); recommendations.push('🛡️ Check if instance has deployment restrictions for external applications'); } if (error.includes('401')) { recommendations.push('🔑 Authentication failed. Re-run: snow-flow auth login'); } } else { recommendations.push('🔐 Widget write test failed with unknown error. Check ServiceNow permissions'); } } // Safe check for User Role Check const roleTest = tests['User Role Check']; if (roleTest?.status && typeof roleTest.status === 'string' && roleTest.status.includes('PASS')) { const roles = roleTest.result?.roles; if (Array.isArray(roles)) { // Debug: Log actual roles for troubleshooting this.logger.info(`User roles found: ${roles.join(', ')}`); const hasRequiredRole = roles.some((role) => { if (typeof role === 'string') { const roleLower = role.toLowerCase(); // Check for admin roles: admin, system_administrator, etc. const isAdmin = roleLower.includes('admin'); // Check for portal manager roles: sp_portal_manager, sp_admin, etc. const isPortalManager = roleLower.includes('sp_portal_manager') || roleLower.includes('sp_admin') || roleLower.includes('portal_manager'); if (isAdmin || isPortalManager) { this.logger.info(`✅ Found qualifying role: ${role}`); } return isAdmin || isPortalManager; } return false; }); if (!hasRequiredRole) { this.logger.warn(`❌ No qualifying roles found in: ${roles.join(', ')}`); recommendations.push(`⚠️ User roles (${roles.join(', ')}) don't include admin or portal management roles. Contact ServiceNow admin to assign appropriate roles`); } else { this.logger.info('✅ User has sufficient roles for deployment'); } } else { recommendations.push('⚠️ Unable to verify user roles. Check if user has admin or portal management permissions'); } } // Safe check for Update Set Access const updateSetTest = tests['Update Set Access']; if (updateSetTest?.status && typeof updateSetTest.status === 'string' && updateSetTest.status.includes('FAIL')) { recommendations.push('📦 Update Set access failed. Ensure user has update_set_manager or admin role'); } if (recommendations.length === 0) { recommendations.push('✅ All authentication checks passed! Deployment should work correctly'); } return recommendations; } /** * Test connection to ServiceNow */ async testConnection() { try { // Ensure we have credentials first await this.ensureAuthenticated(); // Use the /api/now/v2/table/sys_user?sysparm_limit=1 endpoint to test // This is a more reliable endpoint that should work on all instances const response = await this.client.get(`${this.getBaseUrl()}/api/now/v2/table/sys_user?sysparm_limit=1&sysparm_query=user_name=admin`); // If we can query users, we're connected return { success: true, data: { name: 'ServiceNow Instance', user_name: 'Connected', email: `${this.credentials?.instance}`, message: 'Connection successful' } }; } catch (error) { // Try a simpler endpoint if the first one fails try { const response = await this.client.get(`${this.getBaseUrl()}/api/now/table/sys_properties?sysparm_limit=1`); return { success: true, data: { name: 'ServiceNow Instance', user_name: 'Connected', email: `${this.credentials?.instance}`, message: 'Connection successful (limited access)' } }; } catch (secondError) { return { success: false, error: error instanceof Error ? error.message : String(error) }; } } } /** * Create a new ServiceNow widget */ async createWidget(widget) { try { this.logger.info('🎨 Creating ServiceNow widget...'); this.logger.info(`📋 Widget Name: ${widget.name}`); // Add pre-deployment validation for widgets if (!widget.name || widget.name.trim() === '') { throw new Error('Widget name is required'); } if (!widget.title || widget.title.trim() === '') { throw new Error('Widget title is required'); } if (!widget.template || widget.template.trim() === '') { console.warn('⚠️ Widget has no template content - generating functional template automatically'); // Generate a functional template instead of deploying an empty widget const generatedWidget = widget_template_generator_js_1.widgetTemplateGenerator.generateWidget({ title: widget.title, instruction: widget.description || widget.name || 'auto-generated widget', type: 'info', // Default to info widget for auto-generated templates theme: 'default', responsive: true }); // Apply generated components to the widget widget.template = generatedWidget.template; if (!widget.css || widget.css.trim() === '') { widget.css = generatedWidget.css; } if (!widget.client_script || widget.client_script.trim() === '') { widget.client_script = generatedWidget.clientScript; } if (!widget.server_script || widget.server_script.trim() === '') { widget.server_script = generatedWidget.serverScript; } if (!widget.option_schema || widget.option_schema.trim() === '' || widget.option_schema === '[]') { widget.option_schema = generatedWidget.optionSchema; } this.logger.info('✅ Generated functional widget template automatically'); } // Ensure we have credentials before making the API call await this.ensureAuthenticated(); // Log the request details for debugging const widgetData = { name: widget.name, id: widget.id || widget.name, // Ensure id is set title: widget.title, description: widget.description || '', template: widget.template, css: widget.css || '', client_script: widget.client_script || '', script: widget.server_script || '', // Service Portal uses 'script' not 'server_script' option_schema: widget.option_schema || '[]', demo_data: widget.demo_data || '{}', has_preview: widget.has_preview !== false, // Default to true category: widget.category || 'custom', active: true // Ensure widget is active }; this.logger.info('Widget data to be sent:', widgetData); const response = await this.client.post(`${this.getBaseUrl()}/api/now/table/sp_widget`, widgetData, { timeout: this.deploymentTimeout, // Use deployment-specific timeout headers: { 'X-Operation-Type': 'deployment' // Mark as deployment operation } }); this.logger.info('✅ Widget created successfully!'); // Handle different response structures from ServiceNow const widgetResult = response.data.result || response.data; const sysId = widgetResult.sys_id; if (!sysId) { this.logger.warn('⚠️ Widget created but no sys_id returned. Response:', response.data); throw new Error('Widget creation succeeded but no sys_id was returned'); } this.logger.info(`🆔 Widget ID: ${sysId}`); // Add post-deployment verification await this.verifyDeployment(sysId, 'widget'); return { success: true, data: widgetResult }; } catch (error) { console.error('❌ Failed to create widget:', error); // Better error handling for axios errors let errorMessage = 'Unknown error'; let errorDetails = {}; if (error.response) { // The request was made and the server responded with a status code // that falls out of the range of 2xx errorMessage = `HTTP ${error.response.status}: ${error.response.statusText || 'Request failed'}`; errorDetails = { status: error.response.status, statusText: error.response.statusText, data: error.response.data, headers: error.response.headers }; // Extract ServiceNow specific error message if available if (error.response.data?.error?.message) { errorMessage = `ServiceNow Error: ${error.response.data.error.message}`; } else if (error.response.data?.error) { errorMessage = `ServiceNow Error: ${JSON.stringify(error.response.data.error)}`; } else if (error.response.status === 401) { errorMessage = 'Authentication failed: Invalid or expired token. Run: snow-flow auth login'; } else if (error.response.status === 403) { errorMessage = 'Permission denied: User lacks sp_admin role or widget creation permissions'; } else if (error.response.status === 404) { errorMessage = 'API endpoint not found: ServiceNow instance may not have Service Portal installed'; } } else if (error.request) { // The request was made but no response was received errorMessage = 'No response from ServiceNow - check network connection and instance URL'; errorDetails = { request: error.config?.url }; } else { // Something happened in setting up the request that triggered an Error errorMessage = error.message || String(error); } this.logger.error('Widget creation error details:', errorDetails); return { success: false, error: errorMessage, details: errorDetails }; } } /** * Update an existing ServiceNow widget */ async updateWidget(sysId, widget) { try { this.logger.info(`🔄 Updating widget ${sysId}...`); // Ensure we have credentials before making the API call await this.ensureAuthenticated(); // Map fields for Service Portal widget API const mappedWidget = { ...widget }; if (mappedWidget.server_script !== undefined) { mappedWidget.script = mappedWidget.server_script; delete mappedWidget.server_script; } const response = await this.client.patch(`${this.getBaseUrl()}/api/now/table/sp_widget/${sysId}`, mappedWidget, { timeout: this.deploymentTimeout, // Use deployment-specific timeout headers: { 'X-Operation-Type': 'deployment' // Mark as deployment operation } }); this.logger.info('✅ Widget updated successfully!'); return { success: true, data: response.data.result }; } catch (error) { console.error('❌ Failed to update widget:', error); return { success: false, error: error instanceof Error ? error.message : String(error) }; } } /** * Get widget by ID */ async getWidget(widgetId) { try { // Ensure we have credentials before making the API call await this.ensureAuthenticated(); const response = await this.client.get(`${this.getBaseUrl()}/api/now/table/sp_widget?sysparm_query=id=${widgetId}`); if (response.data.result.length === 0) { return { success: false, error: 'Widget not found' }; } return { success: true, data: response.data.result[0] }; } catch (error) { return { success: false, error: error instanceof Error ? error.message : String(error) }; } } /** * Create a new ServiceNow workflow */ async createWorkflow(workflow) { try { this.logger.info('🔄 Creating ServiceNow workflow...'); this.logger.info(`📋 Workflow Name: ${workflow.name}`); const response = await this.client.post(`${this.getBaseUrl()}/api/now/table/wf_workflow`, { name: workflow.name, description: workflow.description, active: workflow.active, workflow_version: workflow.workflow_version, table: workflow.table || '', condition: workflow.condition || '' }); this.logger.info('✅ Workflow created successfully!'); this.logger.info(`🆔 Workflow ID: ${response.data.result.sys_id}`); return { success: true, data: response.data.result }; } catch (error) { console.error('❌ Failed to create workflow:', error); return { success: false, error: error instanceof Error ? error.message : String(error) }; } } /** * Create a new ServiceNow application */ async createApplication(application) { try { this.logger.info('🏗️ Creating ServiceNow application...'); this.logger.info(`📋 Application Name: ${application.name}`); const response = await this.client.post(`${this.getBaseUrl()}/api/now/table/sys_app`, { name: application.name, scope: application.scope, version: application.version, short_description: application.short_description, description: application.description, vendor: application.vendor, vendor_prefix: application.vendor_prefix, template: application.template || '', logo: application.logo || '', active: application.active }); this.logger.info('✅ Application created successfully!'); this.logger.info(`🆔 Application ID: ${response.data.result.sys_id}`); return { success: true, data: response.data.result }; } catch (error) { console.error('❌ Failed to create application:', error); return { success: false, error: error instanceof Error ? error.message : String(error) }; } } /** * Execute a ServiceNow script */ async executeScript(script) { try { this.logger.info('⚡ Executing ServiceNow script...'); const response = await this.client.post(`${this.getBaseUrl()}/api/now/table/sys_script_execution`, { script: script, type: 'server' }); this.logger.info('✅ Script executed successfully!'); return { success: true, data: response.data.result }; } catch (error) { console.error('❌ Failed to execute script:', error); return { success: false, error: error instanceof Error ? error.message : String(error) }; } } /** * Get all widgets */ async getWidgets() { try { const response = await this.client.get(`${this.getBaseUrl()}/api/now/table/sp_widget?sysparm_limit=100`); return { success: true, result: response.data.result }; } catch (error) { return { success: false, error: error instanceof Error ? error.message : String(error) }; } } /** * Get all workflows */ async getWorkflows() { try { const response = await this.client.get(`${this.getBaseUrl()}/api/now/table/wf_workflow?sysparm_limit=100`); return { success: true, result: response.data.result }; } catch (error) { return { success: false, error: error instanceof Error ? error.message : String(error) }; } } /** * Get all applications */ async getApplications() { try { const response = await this.client.get(`${this.getBaseUrl()}/api/now/table/sys_app?sysparm_limit=100`); return { success: true, result: response.data.result }; } catch (error) { return { success: false, error: error instanceof Error ? error.message : String(error) }; } } /** * Get default flow structure from ServiceNow */ async getFlowDefaults() { try { // Try to get an existing flow to see the structure const response = await this.client.get(`${this.getBaseUrl()}/api/now/table/sys_hub_flow?sysparm_limit=1&sysparm_fields=sys_class_name,type,status,access,source_ui,sys_domain,sys_domain_path`); if (response.data.result && response.data.result.length > 0) { const sample = response.data.result[0]; return { sys_class_name: sample.sys_class_name || 'sys_hub_flow', type: sample.type || 'flow', status: sample.status || 'published', access: sample.access || 'public', source_ui: sample.source_ui || 'flow_designer', sys_domain: sample.sys_domain || 'global', sys_domain_path: sample.sys_domain_path || '/' }; } } catch (error) { this.logger.warn('Could not fetch flow defaults, using minimal defaults'); } // Return minimal defaults if we can't get from ServiceNow return { sys_class_name: 'sys_hub_flow', type: 'flow', status: 'published', access: 'public' }; } /** * Get instance info */ async getInstanceInfo() { try { const response = await this.client.get(`${this.getBaseUrl()}/api/now/table/sys_properties?sysparm_query=name=instance.name`); return { success: true, data: response.data.result[0] }; } catch (error) { return { success: false, error: error instanceof Error ? error.message : String(error) }; } } /** * Get a specific record by sys_id */ async getRecord(table, sys_id) { try { // Ensure we have credentials before making the API call await this.ensureAuthenticated(); const response = await this.client.get(`${this.getBaseUrl()}/api/now/table/${table}/${sys_id}`); return response.data.result; } catch (error) { console.error(`Failed to get record from ${table}:`, error); throw error; } } /** * Get multiple records from a table */ async getRecords(table, params) { try { // Ensure we have credentials before making the API call await this.ensureAuthenticated(); const response = await this.client.get(`${this.getBaseUrl()}/api/now/table/${table}`, { params }); return { success: true, data: response.data.result }; } catch (error) { console.error(`Failed to get records from ${table}:`, error); return { success: false, error: error instanceof Error ? error.message : String(error) }; } } /** * Search records in a table using encoded query */ async searchRecords(table, query, limit = 10) { try { await this.ensureAuthenticated(); // Detect operation type for intelligent timeout const operationType = (0, timeout_manager_js_1.detectOperationType)({ action: 'query', table, limit }); // Use retry wrapper with intelligent timeout const result = await (0, timeout_manager_js_1.withRetry)(async () => { const response = await this.client.get(`${this.getBaseUrl()}/api/now/table/${table}`, { params: { sysparm_query: query, sysparm_limit: limit }, // Override timeout for this specific request timeout: (0, timeout_manager_js_1.getTimeoutConfig)(operationType).baseTimeout }); return response; }, operationType, `Search ${table} (${limit} records)`); return { success: true, data: { result: result.data.result || [] } }; } catch (error) { this.logger.error(`Failed to search records in ${table}:`, error); return { success: false, error: error instanceof Error ? error.message : String(error) }; } } /** * Search records with offset for pagination/streaming */ async searchRecordsWithOffset(table, query, limit = 10, offset = 0) { try { await this.ensureAuthenticated(); // Detect operation type for intelligent timeout const operationType = (0, timeout_manager_js_1.detectOperationType)({ action: 'query', table, limit }); // Use retry wra