source dump of claude code
at main 162 lines 6.1 kB view raw
1import { homedir } from 'os' 2import { resolve } from 'path' 3import { getErrnoCode } from '../errors.js' 4import { getFsImplementation } from '../fsOperations.js' 5import 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 */ 23export 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}