File size: 6,075 Bytes
064bfd6 | 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 161 162 163 | import { homedir } from 'os'
import { resolve } from 'path'
import { getErrnoCode } from '../errors.js'
import { getFsImplementation } from '../fsOperations.js'
import type { MarketplaceSource } from './schemas.js'
/**
* Parses a marketplace input string and returns the appropriate marketplace source type.
* Handles various input formats:
* - Git SSH URLs (user@host:path or user@host:path.git)
* - Standard: git@github.com:owner/repo.git
* - GitHub Enterprise SSH certificates: org-123456@github.com:owner/repo.git
* - Custom usernames: deploy@gitlab.com:group/project.git
* - Self-hosted: user@192.168.10.123:path/to/repo
* - HTTP/HTTPS URLs
* - GitHub shorthand (owner/repo)
* - Local file paths (.json files)
* - Local directory paths
*
* @param input The marketplace source input string
* @returns MarketplaceSource object, error object, or null if format is unrecognized
*/
export async function parseMarketplaceInput(
input: string,
): Promise<MarketplaceSource | { error: string } | null> {
const trimmed = input.trim()
const fs = getFsImplementation()
// Handle git SSH URLs with any valid username (not just 'git')
// Supports: user@host:path, user@host:path.git, and with #ref suffix
// Username can contain: alphanumeric, dots, underscores, hyphens
const sshMatch = trimmed.match(
/^([a-zA-Z0-9._-]+@[^:]+:.+?(?:\.git)?)(#(.+))?$/,
)
if (sshMatch?.[1]) {
const url = sshMatch[1]
const ref = sshMatch[3]
return ref ? { source: 'git', url, ref } : { source: 'git', url }
}
// Handle URLs
if (trimmed.startsWith('http://') || trimmed.startsWith('https://')) {
// Extract fragment (ref) from URL if present
const fragmentMatch = trimmed.match(/^([^#]+)(#(.+))?$/)
const urlWithoutFragment = fragmentMatch?.[1] || trimmed
const ref = fragmentMatch?.[3]
// When user explicitly provides an HTTPS/HTTP URL that looks like a git
// repo, use the git source type so we clone rather than fetch-as-JSON.
// The .git suffix is a GitHub/GitLab/Bitbucket convention. Azure DevOps
// uses /_git/ in the path with NO suffix (appending .git breaks ADO:
// TF401019 "repo does not exist"). Without this check, an ADO URL falls
// through to source:'url' below, which tries to fetch it as a raw
// marketplace.json — the HTML response parses as "expected object,
// received string". (gh-31256 / CC-299)
if (
urlWithoutFragment.endsWith('.git') ||
urlWithoutFragment.includes('/_git/')
) {
return ref
? { source: 'git', url: urlWithoutFragment, ref }
: { source: 'git', url: urlWithoutFragment }
}
// Parse URL to check hostname
let url: URL
try {
url = new URL(urlWithoutFragment)
} catch (_err) {
// Not a valid URL for parsing, treat as generic URL
// new URL() throws TypeError for invalid URLs
return { source: 'url', url: urlWithoutFragment }
}
if (url.hostname === 'github.com' || url.hostname === 'www.github.com') {
const match = url.pathname.match(/^\/([^/]+\/[^/]+?)(\/|\.git|$)/)
if (match?.[1]) {
// User explicitly provided HTTPS URL - keep it as HTTPS via 'git' type
// Add .git suffix if not present for proper git clone
const gitUrl = urlWithoutFragment.endsWith('.git')
? urlWithoutFragment
: `${urlWithoutFragment}.git`
return ref
? { source: 'git', url: gitUrl, ref }
: { source: 'git', url: gitUrl }
}
}
return { source: 'url', url: urlWithoutFragment }
}
// Handle local paths
// On Windows, also recognize backslash-relative (.\, ..\) and drive letter paths (C:\)
// These are Windows-only because backslashes are valid filename chars on Unix
const isWindows = process.platform === 'win32'
const isWindowsPath =
isWindows &&
(trimmed.startsWith('.\\') ||
trimmed.startsWith('..\\') ||
/^[a-zA-Z]:[/\\]/.test(trimmed))
if (
trimmed.startsWith('./') ||
trimmed.startsWith('../') ||
trimmed.startsWith('/') ||
trimmed.startsWith('~') ||
isWindowsPath
) {
const resolvedPath = resolve(
trimmed.startsWith('~') ? trimmed.replace(/^~/, homedir()) : trimmed,
)
// Stat the path to determine if it's a file or directory. Swallow all stat
// errors (ENOENT, EACCES, EPERM, etc.) and return an error result instead
// of throwing — matches the old existsSync behavior which never threw.
let stats
try {
stats = await fs.stat(resolvedPath)
} catch (e: unknown) {
const code = getErrnoCode(e)
return {
error:
code === 'ENOENT'
? `Path does not exist: ${resolvedPath}`
: `Cannot access path: ${resolvedPath} (${code ?? e})`,
}
}
if (stats.isFile()) {
if (resolvedPath.endsWith('.json')) {
return { source: 'file', path: resolvedPath }
} else {
return {
error: `File path must point to a .json file (marketplace.json), but got: ${resolvedPath}`,
}
}
} else if (stats.isDirectory()) {
return { source: 'directory', path: resolvedPath }
} else {
return {
error: `Path is neither a file nor a directory: ${resolvedPath}`,
}
}
}
// Handle GitHub shorthand (owner/repo, owner/repo#ref, or owner/repo@ref)
// Accept both # and @ as ref separators — the display formatter uses @, so users
// naturally type @ when copying from error messages or managed settings.
if (trimmed.includes('/') && !trimmed.startsWith('@')) {
if (trimmed.includes(':')) {
return null
}
// Extract ref if present (either #ref or @ref)
const fragmentMatch = trimmed.match(/^([^#@]+)(?:[#@](.+))?$/)
const repo = fragmentMatch?.[1] || trimmed
const ref = fragmentMatch?.[2]
// Assume it's a GitHub repo
return ref ? { source: 'github', repo, ref } : { source: 'github', repo }
}
// NPM packages not yet implemented
// Returning null for unrecognized input
return null
}
|