All files / src/cli/init connection.ts

97.77% Statements 44/45
93.1% Branches 27/29
100% Functions 4/4
97.77% Lines 44/45

Press n or j to go to the next uncovered block, b, p or k for the previous block.

1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160          3x         3x         13x 13x   13x       13x                   10x 3x 1x         2x 1x         1x           7x               7x 7x                   6x 5x 5x           7x                 3x 1x           2x 1x           1x                   3x 8x 1x       7x 2x     5x 5x 4x     4x   1x                   3x 13x 13x 11x   11x   2x               3x 5x   5x 5x    
/**
 * Connection testing for init wizard
 */
 
import { ConnectionTestResult } from './types';
import { enhancedFetch, GitLabTimeoutError } from '../../utils/fetch';
 
/**
 * Test GitLab connection with provided credentials
 */
export async function testConnection(
  instanceUrl: string,
  token: string,
): Promise<ConnectionTestResult> {
  // Normalize URL: strip trailing slash and /api/v4 suffix if present
  const baseUrl = instanceUrl.replace(/\/$/, '').replace(/\/api\/v4\/?$/, '');
  const apiUrl = `${baseUrl}/api/v4`;
 
  try {
    // Test /user endpoint to verify token.
    // Timeout is handled by enhancedFetch's Undici dispatcher (connect + headers timeouts)
    // rather than a manual AbortController — consistent with all other fetch calls.
    const userResponse = await enhancedFetch(`${apiUrl}/user`, {
      headers: {
        'PRIVATE-TOKEN': token,
        Accept: 'application/json',
      },
      retry: false,
      skipAuth: true,
      rateLimit: false,
    });
 
    if (!userResponse.ok) {
      if (userResponse.status === 401) {
        return {
          success: false,
          error: 'Invalid token - authentication failed',
        };
      }
      if (userResponse.status === 403) {
        return {
          success: false,
          error: 'Token lacks required permissions',
        };
      }
      return {
        success: false,
        error: `GitLab API error: ${userResponse.status} ${userResponse.statusText}`,
      };
    }
 
    const userData = (await userResponse.json()) as {
      username?: string;
      email?: string;
      is_admin?: boolean;
    };
 
    // Get GitLab version
    let gitlabVersion: string | undefined;
    try {
      const versionResponse = await enhancedFetch(`${apiUrl}/version`, {
        headers: {
          'PRIVATE-TOKEN': token,
          Accept: 'application/json',
        },
        retry: false,
        skipAuth: true,
        rateLimit: false,
      });
 
      if (versionResponse.ok) {
        const versionData = (await versionResponse.json()) as { version?: string };
        gitlabVersion = versionData.version;
      }
    } catch {
      // Version endpoint may not be available, continue without it
    }
 
    return {
      success: true,
      username: userData.username,
      email: userData.email,
      isAdmin: userData.is_admin,
      gitlabVersion,
    };
  } catch (error) {
    // Handle timeout — doFetch throws GitLabTimeoutError for all Undici timeout types
    if (error instanceof GitLabTimeoutError) {
      return {
        success: false,
        error: `Connection timeout - ${instanceUrl} did not respond`,
      };
    }
    // Handle network errors
    if (error instanceof TypeError && error.message.includes('fetch')) {
      return {
        success: false,
        error: `Cannot connect to ${instanceUrl} - check URL and network`,
      };
    }
 
    return {
      success: false,
      error: error instanceof Error ? error.message : String(error),
    };
  }
}
 
/**
 * Validate GitLab URL format
 */
export function validateGitLabUrl(url: string): { valid: boolean; error?: string } {
  if (!url) {
    return { valid: false, error: 'URL is required' };
  }
 
  // Must start with https:// or http://
  if (!url.startsWith('https://') && !url.startsWith('http://')) {
    return { valid: false, error: 'URL must start with https:// or http://' };
  }
 
  try {
    const parsed = new URL(url);
    Iif (!parsed.hostname) {
      return { valid: false, error: 'Invalid URL hostname' };
    }
    return { valid: true };
  } catch {
    return { valid: false, error: 'Invalid URL format' };
  }
}
 
/**
 * Check if URL is GitLab SaaS (gitlab.com)
 * Uses strict hostname matching to avoid false positives from URLs like:
 * - notgitlab.com (contains "gitlab.com" as substring)
 * - gitlab.company.com (contains "gitlab.com" as substring)
 */
export function isGitLabSaas(url: string): boolean {
  try {
    const parsed = new URL(url);
    const hostname = parsed.hostname.toLowerCase();
    // Strict match: exactly gitlab.com or subdomain of gitlab.com
    return hostname === 'gitlab.com' || hostname.endsWith('.gitlab.com');
  } catch {
    return false;
  }
}
 
/**
 * Generate PAT creation URL for GitLab instance
 * Uses least-privilege scopes based on read-only mode
 */
export function getPatCreationUrl(instanceUrl: string, readOnly = false): string {
  const baseUrl = instanceUrl.replace(/\/$/, '');
  // Use minimal scopes for read-only mode, full api for write access
  const scopes = readOnly ? 'read_api,read_user' : 'api,read_user';
  return `${baseUrl}/-/user_settings/personal_access_tokens?name=gitlab-mcp&scopes=${scopes}`;
}