( source: MarketplaceSource, onProgress?: MarketplaceProgressCallback, )
| 1780 | * @throws If source format is invalid or marketplace cannot be loaded |
| 1781 | */ |
| 1782 | export async function addMarketplaceSource( |
| 1783 | source: MarketplaceSource, |
| 1784 | onProgress?: MarketplaceProgressCallback, |
| 1785 | ): Promise<{ |
| 1786 | name: string |
| 1787 | alreadyMaterialized: boolean |
| 1788 | resolvedSource: MarketplaceSource |
| 1789 | }> { |
| 1790 | // Resolve relative directory/file paths to absolute so state is cwd-independent |
| 1791 | let resolvedSource = source |
| 1792 | if (isLocalMarketplaceSource(source) && !isAbsolute(source.path)) { |
| 1793 | resolvedSource = { ...source, path: resolve(source.path) } |
| 1794 | } |
| 1795 | |
| 1796 | // Check policy FIRST, before any network/filesystem operations |
| 1797 | // This prevents downloading/cloning when the source is blocked |
| 1798 | if (!isSourceAllowedByPolicy(resolvedSource)) { |
| 1799 | // Check if explicitly blocked vs not in allowlist for better error messages |
| 1800 | if (isSourceInBlocklist(resolvedSource)) { |
| 1801 | throw new Error( |
| 1802 | `Marketplace source '${formatSourceForDisplay(resolvedSource)}' is blocked by enterprise policy.`, |
| 1803 | ) |
| 1804 | } |
| 1805 | // Not in allowlist - build helpful error message |
| 1806 | const allowlist = getStrictKnownMarketplaces() || [] |
| 1807 | const hostPatterns = getHostPatternsFromAllowlist() |
| 1808 | const sourceHost = extractHostFromSource(resolvedSource) |
| 1809 | |
| 1810 | let errorMessage = `Marketplace source '${formatSourceForDisplay(resolvedSource)}'` |
| 1811 | if (sourceHost) { |
| 1812 | errorMessage += ` (${sourceHost})` |
| 1813 | } |
| 1814 | errorMessage += ' is blocked by enterprise policy.' |
| 1815 | |
| 1816 | if (allowlist.length > 0) { |
| 1817 | errorMessage += ` Allowed sources: ${allowlist.map(s => formatSourceForDisplay(s)).join(', ')}` |
| 1818 | } else { |
| 1819 | errorMessage += ' No external marketplaces are allowed.' |
| 1820 | } |
| 1821 | |
| 1822 | // If source is a github shorthand and there are hostPatterns, suggest using full URL |
| 1823 | if (resolvedSource.source === 'github' && hostPatterns.length > 0) { |
| 1824 | errorMessage += |
| 1825 | `\n\nTip: The shorthand "${resolvedSource.repo}" assumes github.com. ` + |
| 1826 | `For internal GitHub Enterprise, use the full URL:\n` + |
| 1827 | ` git@your-github-host.com:${resolvedSource.repo}.git` |
| 1828 | } |
| 1829 | |
| 1830 | throw new Error(errorMessage) |
| 1831 | } |
| 1832 | |
| 1833 | // Source-idempotency: if this exact source already exists, skip clone |
| 1834 | const existingConfig = await loadKnownMarketplacesConfig() |
| 1835 | for (const [existingName, existingEntry] of Object.entries(existingConfig)) { |
| 1836 | if (isEqual(existingEntry.source, resolvedSource)) { |
| 1837 | logForDebugging( |
| 1838 | `Source already materialized as '${existingName}', skipping clone`, |
| 1839 | ) |
no test coverage detected