163 lines
5.9 KiB
TypeScript
163 lines
5.9 KiB
TypeScript
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
|
|
}
|