/ utils / plugins / parseMarketplaceInput.ts
parseMarketplaceInput.ts
  1  import { homedir } from 'os'
  2  import { resolve } from 'path'
  3  import { getErrnoCode } from '../errors.js'
  4  import { getFsImplementation } from '../fsOperations.js'
  5  import type { MarketplaceSource } from './schemas.js'
  6  
  7  /**
  8   * Parses a marketplace input string and returns the appropriate marketplace source type.
  9   * Handles various input formats:
 10   * - Git SSH URLs (user@host:path or user@host:path.git)
 11   *   - Standard: git@github.com:owner/repo.git
 12   *   - GitHub Enterprise SSH certificates: org-123456@github.com:owner/repo.git
 13   *   - Custom usernames: deploy@gitlab.com:group/project.git
 14   *   - Self-hosted: user@192.168.10.123:path/to/repo
 15   * - HTTP/HTTPS URLs
 16   * - GitHub shorthand (owner/repo)
 17   * - Local file paths (.json files)
 18   * - Local directory paths
 19   *
 20   * @param input The marketplace source input string
 21   * @returns MarketplaceSource object, error object, or null if format is unrecognized
 22   */
 23  export async function parseMarketplaceInput(
 24    input: string,
 25  ): Promise<MarketplaceSource | { error: string } | null> {
 26    const trimmed = input.trim()
 27    const fs = getFsImplementation()
 28  
 29    // Handle git SSH URLs with any valid username (not just 'git')
 30    // Supports: user@host:path, user@host:path.git, and with #ref suffix
 31    // Username can contain: alphanumeric, dots, underscores, hyphens
 32    const sshMatch = trimmed.match(
 33      /^([a-zA-Z0-9._-]+@[^:]+:.+?(?:\.git)?)(#(.+))?$/,
 34    )
 35    if (sshMatch?.[1]) {
 36      const url = sshMatch[1]
 37      const ref = sshMatch[3]
 38      return ref ? { source: 'git', url, ref } : { source: 'git', url }
 39    }
 40  
 41    // Handle URLs
 42    if (trimmed.startsWith('http://') || trimmed.startsWith('https://')) {
 43      // Extract fragment (ref) from URL if present
 44      const fragmentMatch = trimmed.match(/^([^#]+)(#(.+))?$/)
 45      const urlWithoutFragment = fragmentMatch?.[1] || trimmed
 46      const ref = fragmentMatch?.[3]
 47  
 48      // When user explicitly provides an HTTPS/HTTP URL that looks like a git
 49      // repo, use the git source type so we clone rather than fetch-as-JSON.
 50      // The .git suffix is a GitHub/GitLab/Bitbucket convention. Azure DevOps
 51      // uses /_git/ in the path with NO suffix (appending .git breaks ADO:
 52      // TF401019 "repo does not exist"). Without this check, an ADO URL falls
 53      // through to source:'url' below, which tries to fetch it as a raw
 54      // marketplace.json — the HTML response parses as "expected object,
 55      // received string". (gh-31256 / CC-299)
 56      if (
 57        urlWithoutFragment.endsWith('.git') ||
 58        urlWithoutFragment.includes('/_git/')
 59      ) {
 60        return ref
 61          ? { source: 'git', url: urlWithoutFragment, ref }
 62          : { source: 'git', url: urlWithoutFragment }
 63      }
 64      // Parse URL to check hostname
 65      let url: URL
 66      try {
 67        url = new URL(urlWithoutFragment)
 68      } catch (_err) {
 69        // Not a valid URL for parsing, treat as generic URL
 70        // new URL() throws TypeError for invalid URLs
 71        return { source: 'url', url: urlWithoutFragment }
 72      }
 73  
 74      if (url.hostname === 'github.com' || url.hostname === 'www.github.com') {
 75        const match = url.pathname.match(/^\/([^/]+\/[^/]+?)(\/|\.git|$)/)
 76        if (match?.[1]) {
 77          // User explicitly provided HTTPS URL - keep it as HTTPS via 'git' type
 78          // Add .git suffix if not present for proper git clone
 79          const gitUrl = urlWithoutFragment.endsWith('.git')
 80            ? urlWithoutFragment
 81            : `${urlWithoutFragment}.git`
 82          return ref
 83            ? { source: 'git', url: gitUrl, ref }
 84            : { source: 'git', url: gitUrl }
 85        }
 86      }
 87      return { source: 'url', url: urlWithoutFragment }
 88    }
 89  
 90    // Handle local paths
 91    // On Windows, also recognize backslash-relative (.\, ..\) and drive letter paths (C:\)
 92    // These are Windows-only because backslashes are valid filename chars on Unix
 93    const isWindows = process.platform === 'win32'
 94    const isWindowsPath =
 95      isWindows &&
 96      (trimmed.startsWith('.\\') ||
 97        trimmed.startsWith('..\\') ||
 98        /^[a-zA-Z]:[/\\]/.test(trimmed))
 99    if (
100      trimmed.startsWith('./') ||
101      trimmed.startsWith('../') ||
102      trimmed.startsWith('/') ||
103      trimmed.startsWith('~') ||
104      isWindowsPath
105    ) {
106      const resolvedPath = resolve(
107        trimmed.startsWith('~') ? trimmed.replace(/^~/, homedir()) : trimmed,
108      )
109  
110      // Stat the path to determine if it's a file or directory. Swallow all stat
111      // errors (ENOENT, EACCES, EPERM, etc.) and return an error result instead
112      // of throwing — matches the old existsSync behavior which never threw.
113      let stats
114      try {
115        stats = await fs.stat(resolvedPath)
116      } catch (e: unknown) {
117        const code = getErrnoCode(e)
118        return {
119          error:
120            code === 'ENOENT'
121              ? `Path does not exist: ${resolvedPath}`
122              : `Cannot access path: ${resolvedPath} (${code ?? e})`,
123        }
124      }
125  
126      if (stats.isFile()) {
127        if (resolvedPath.endsWith('.json')) {
128          return { source: 'file', path: resolvedPath }
129        } else {
130          return {
131            error: `File path must point to a .json file (marketplace.json), but got: ${resolvedPath}`,
132          }
133        }
134      } else if (stats.isDirectory()) {
135        return { source: 'directory', path: resolvedPath }
136      } else {
137        return {
138          error: `Path is neither a file nor a directory: ${resolvedPath}`,
139        }
140      }
141    }
142  
143    // Handle GitHub shorthand (owner/repo, owner/repo#ref, or owner/repo@ref)
144    // Accept both # and @ as ref separators — the display formatter uses @, so users
145    // naturally type @ when copying from error messages or managed settings.
146    if (trimmed.includes('/') && !trimmed.startsWith('@')) {
147      if (trimmed.includes(':')) {
148        return null
149      }
150      // Extract ref if present (either #ref or @ref)
151      const fragmentMatch = trimmed.match(/^([^#@]+)(?:[#@](.+))?$/)
152      const repo = fragmentMatch?.[1] || trimmed
153      const ref = fragmentMatch?.[2]
154      // Assume it's a GitHub repo
155      return ref ? { source: 'github', repo, ref } : { source: 'github', repo }
156    }
157  
158    // NPM packages not yet implemented
159    // Returning null for unrecognized input
160  
161    return null
162  }