import AdmZip from "adm-zip"; import yaml from "js-yaml"; import { APP_TEMPLATES, AppTemplate } from "../../config/app-templates"; export type CatalogSourceMode = | "local" | "auto-detect" | "manifest-url" | "github-archive"; export interface CatalogMeta { source: CatalogSourceMode; label: string; url?: string; fetchedAt: string; isRemote: boolean; cached?: boolean; expiresAt?: string; format?: string; } export interface CatalogSyncResult { templates: AppTemplate[]; meta: CatalogMeta; } export interface CatalogSourceOption { id: CatalogSourceMode; label: string; description: string; requiresUrl: boolean; placeholder?: string; exampleUrl?: string; } interface CacheEntry { templates: AppTemplate[]; meta: CatalogMeta; expiresAt: number; } const REMOTE_CATALOG_CACHE = new Map(); const CACHE_TTL_MS = 5 * 60 * 1000; export const CATALOG_SOURCE_OPTIONS: CatalogSourceOption[] = [ { id: "local", label: "Built-in Catalog", description: "Use the local catalog bundled with this backend.", requiresUrl: false, }, { id: "auto-detect", label: "Custom Source", description: "Paste a remote URL and let the backend detect whether it is a manifest, compose/config file, or ZIP app store archive.", requiresUrl: true, placeholder: "https://github.com/bigbeartechworld/big-bear-casaos/archive/refs/heads/master.zip", exampleUrl: "https://casaos-appstore.paodayag.dev/linuxserver.zip", }, { id: "manifest-url", label: "Manifest URL", description: "Fetch app entries from a remote JSON, YAML, CasaOS config, compose, or Umbrel manifest URL.", requiresUrl: true, placeholder: "https://example.com/apps.json", exampleUrl: "https://raw.githubusercontent.com/bigbeartechworld/big-bear-casaos/master/Apps/2fauth/config.json", }, { id: "github-archive", label: "GitHub Archive ZIP", description: "Download a GitHub archive ZIP and scan CasaOS app folders, config.json, docker-compose.yml, JSON, or YAML manifests.", requiresUrl: true, placeholder: "https://github.com/bigbeartechworld/big-bear-casaos/archive/refs/heads/master.zip", exampleUrl: "https://github.com/bigbeartechworld/big-bear-casaos/archive/refs/heads/master.zip", }, ]; function slugify(value: string): string { return value .toLowerCase() .replace(/[^a-z0-9]+/g, "-") .replace(/^-+|-+$/g, "") .slice(0, 60); } function asString(value: unknown): string | undefined { return typeof value === "string" ? value.trim() : undefined; } function isAbsoluteHttpUrl(value: string): boolean { return /^https?:\/\//i.test(value.trim()); } function resolveRemoteAssetUrl( value: unknown, baseUrl?: string, ): string | undefined { const raw = asString(value); if (!raw) return undefined; if (isAbsoluteHttpUrl(raw)) return raw; if (!baseUrl) return undefined; try { return new URL(raw, baseUrl).toString(); } catch { return undefined; } } function asNumber(value: unknown): number | undefined { if (typeof value === "number" && Number.isFinite(value)) { return value; } if (typeof value === "string") { const parsed = Number(value.trim()); return Number.isFinite(parsed) ? parsed : undefined; } return undefined; } function asRecord(value: unknown): Record | undefined { if (!value || typeof value !== "object" || Array.isArray(value)) { return undefined; } return value as Record; } function toStringArray(value: unknown): string[] { if (Array.isArray(value)) { return value .map((item) => (typeof item === "string" ? item.trim() : "")) .filter(Boolean); } if (typeof value === "string") { return value .split(",") .map((item) => item.trim()) .filter(Boolean); } return []; } function buildImageWithVersion(image?: string, version?: string): string { const imageValue = image?.trim() ?? ""; const versionValue = version?.trim() ?? ""; if (!imageValue) return ""; if (!versionValue) return imageValue; const lastColon = imageValue.lastIndexOf(":"); const lastSlash = imageValue.lastIndexOf("/"); if (lastColon > lastSlash) { return imageValue; } return `${imageValue}:${versionValue}`; } function normalizeTags(entry: Record): string[] { const explicit = toStringArray( entry.tags ?? entry.keywords ?? entry.categories ?? entry.category, ); return explicit.slice(0, 8); } function normalizePorts(value: unknown): string { if (typeof value === "string") { return value.trim(); } if (typeof value === "number") { return `${value}:${value}`; } if (Array.isArray(value)) { return value .map((item) => { if (typeof item === "string") return item.trim(); if (typeof item === "number") return `${item}:${item}`; const record = asRecord(item); if (!record) return ""; const host = String( record.host ?? record.hostPort ?? record.public ?? record.port ?? "", ).trim(); const container = String( record.container ?? record.containerPort ?? record.private ?? record.target ?? record.port ?? "", ).trim(); const protocol = String(record.protocol ?? "tcp") .trim() .toLowerCase(); if (!host || !container) return ""; return protocol === "udp" ? `${host}:${container}/udp` : `${host}:${container}`; }) .filter(Boolean) .join(","); } const record = asRecord(value); if (!record) return ""; return Object.entries(record) .map(([container, host]) => { const hostStr = String(host ?? "").trim(); const containerStr = container.trim(); return hostStr && containerStr ? `${hostStr}:${containerStr}` : ""; }) .filter(Boolean) .join(","); } function normalizeEnv(value: unknown): string { if (typeof value === "string") { return value.trim(); } if (Array.isArray(value)) { return value .map((item) => { if (typeof item === "string") return item.trim(); const record = asRecord(item); if (!record) return ""; const key = String( record.key ?? record.name ?? record.label ?? "", ).trim(); const val = String( record.value ?? record.default ?? record.defaultValue ?? "", ).trim(); return key ? `${key}=${val}` : ""; }) .filter(Boolean) .join("\n"); } const record = asRecord(value); if (!record) return ""; return Object.entries(record) .map(([key, val]) => `${key}=${String(val ?? "").trim()}`) .filter(Boolean) .join("\n"); } function normalizeVolumes(value: unknown): string { if (typeof value === "string") { return value.trim(); } if (Array.isArray(value)) { return value .map((item) => { if (typeof item === "string") return item.trim(); const record = asRecord(item); if (!record) return ""; const host = String( record.host ?? record.source ?? record.src ?? record.bind ?? "", ).trim(); const container = String( record.container ?? record.target ?? record.dst ?? record.destination ?? "", ).trim(); return host && container ? `${host}:${container}` : ""; }) .filter(Boolean) .join(","); } const record = asRecord(value); if (!record) return ""; return Object.entries(record) .map(([container, host]) => { const hostStr = String(host ?? "").trim(); return hostStr && container.trim() ? `${hostStr}:${container.trim()}` : ""; }) .filter(Boolean) .join(","); } function normalizePresetList(entry: Record) { const presetsRaw = Array.isArray(entry.presets) ? entry.presets : []; return presetsRaw .map((preset) => { const record = asRecord(preset); if (!record) return null; const name = asString(record.name) ?? asString(record.title) ?? asString(record.id) ?? "Preset"; const id = asString(record.id) ?? slugify(name); return { id, name, desc: asString(record.desc) ?? asString(record.description) ?? "", defaultPort: normalizePorts( record.defaultPort ?? record.ports ?? record.port_map, ), defaultEnv: normalizeEnv( record.defaultEnv ?? record.env ?? record.environment, ), defaultVolumes: normalizeVolumes( record.defaultVolumes ?? record.volumes ?? record.storage, ), defaultCommand: asString(record.defaultCommand) ?? asString(record.command), defaultNetwork: asString(record.defaultNetwork) ?? asString(record.network), restartPolicy: asString(record.restartPolicy) ?? asString(record.restart), }; }) .filter((preset): preset is NonNullable => Boolean(preset)); } function normalizeIcon( entry: Record, baseUrl?: string, ): string | undefined { const iconBlock = asRecord(entry.icon); const logoBlock = asRecord(entry.logo); return ( resolveRemoteAssetUrl(entry.icon, baseUrl) ?? resolveRemoteAssetUrl(entry.iconUrl, baseUrl) ?? resolveRemoteAssetUrl(entry.icon_url, baseUrl) ?? resolveRemoteAssetUrl(entry.logo, baseUrl) ?? resolveRemoteAssetUrl(entry.logoUrl, baseUrl) ?? resolveRemoteAssetUrl(entry.logo_url, baseUrl) ?? resolveRemoteAssetUrl(entry.thumbnail, baseUrl) ?? resolveRemoteAssetUrl(entry.thumb, baseUrl) ?? resolveRemoteAssetUrl(entry.avatar, baseUrl) ?? resolveRemoteAssetUrl(iconBlock?.en_us, baseUrl) ?? resolveRemoteAssetUrl(logoBlock?.en_us, baseUrl) ); } function normalizeEntry( entry: Record, index: number, baseUrl?: string, ): AppTemplate | null { const name = asString(entry.name) ?? asString(entry.title) ?? asString(entry.app_name) ?? ""; const image = asString(entry.image) ?? asString(entry.docker_image) ?? asString(entry.container_image) ?? asString(entry.repo) ?? buildImageWithVersion(asString(entry.image), asString(entry.version)); if (!name || !image) { return null; } const id = (asString(entry.id) ?? asString(entry.slug) ?? asString(entry.appid) ?? slugify(name)) || `remote-${index}`; const category = asString(entry.category) ?? asString(entry.type) ?? asString(entry.section) ?? "Remote"; const desc = asString(entry.desc) ?? asString(entry.description) ?? asString(entry.short_desc) ?? asString(entry.subtitle) ?? "Remote app catalog entry"; const defaultPort = normalizePorts( entry.defaultPort ?? entry.ports ?? entry.port_map ?? entry.portMap ?? entry.port, ); const defaultEnv = normalizeEnv( entry.defaultEnv ?? entry.env ?? entry.environment ?? entry.envs, ); const defaultVolumes = normalizeVolumes( entry.defaultVolumes ?? entry.volumes ?? entry.storage ?? entry.mounts, ); const presets = normalizePresetList(entry); return { id, name, desc, category, icon: normalizeIcon(entry, baseUrl), image, defaultPort, defaultEnv, defaultVolumes: defaultVolumes || undefined, defaultCommand: asString(entry.defaultCommand) ?? asString(entry.command), defaultNetwork: asString(entry.defaultNetwork) ?? asString(entry.network), restartPolicy: asString(entry.restartPolicy) ?? asString(entry.restart) ?? asString(entry.restart_policy) ?? "unless-stopped", popular: Boolean(entry.popular ?? entry.featured ?? false), tags: normalizeTags(entry), presets: presets.length > 0 ? presets : undefined, }; } function extractCandidateEntries(payload: unknown): Record[] { if (Array.isArray(payload)) { return payload .map((item) => asRecord(item)) .filter((item): item is Record => Boolean(item)); } const record = asRecord(payload); if (!record) return []; const collectionKeys = [ "apps", "templates", "data", "items", "catalog", "entries", ]; for (const key of collectionKeys) { const value = record[key]; if (Array.isArray(value)) { return value .map((item) => asRecord(item)) .filter((item): item is Record => Boolean(item)); } } return [record]; } function dedupeTemplates(templates: AppTemplate[]): AppTemplate[] { const seen = new Set(); return templates.filter((template) => { if (seen.has(template.id)) return false; seen.add(template.id); return true; }); } function normalizeGithubManifestUrl(url: string): string { const parsed = new URL(url); if (parsed.hostname === "github.com" && parsed.pathname.includes("/blob/")) { const parts = parsed.pathname.split("/").filter(Boolean); const owner = parts[0]; const repo = parts[1]; const branch = parts[3]; const filePath = parts.slice(4).join("/"); return `https://raw.githubusercontent.com/${owner}/${repo}/${branch}/${filePath}`; } return url; } function normalizeGithubArchiveUrl(url: string): string { const parsed = new URL(url); if (parsed.hostname === "github.com") { const match = parsed.pathname.match( /^\/([^/]+)\/([^/]+)\/archive\/refs\/(heads|tags)\/([^/]+)\.zip$/, ); if (match) { const [, owner, repo, refType, refName] = match; return `https://codeload.github.com/${owner}/${repo}/zip/refs/${refType}/${refName}`; } } return url; } function getFallbackIdFromUrl(url: string): string { try { const parsed = new URL(url); const segments = parsed.pathname.split("/").filter(Boolean); const lastSegment = segments.at(-1) ?? "remote-manifest"; const normalizedLast = lastSegment.toLowerCase(); if ( /^(config\.json|docker-compose\.ya?ml|compose\.ya?ml|umbrel-app\.ya?ml|app\.json|manifest\.json|manifest\.ya?ml)$/.test( normalizedLast, ) ) { return slugify(segments.at(-2) ?? lastSegment); } return slugify(lastSegment.replace(/\.(json|ya?ml)$/i, "")); } catch { return slugify(url.split("/").pop() ?? "remote-manifest"); } } function isZipUrl(url: string): boolean { try { const parsed = new URL(url); return /\.zip$/i.test(parsed.pathname); } catch { return /\.zip($|\?)/i.test(url); } } function isZipContentType(contentType?: string | null): boolean { if (!contentType) return false; const normalized = contentType.toLowerCase(); return ( normalized.includes("zip") || normalized.includes("compressed") || normalized.includes("x-zip") ); } function getContentDispositionFileName(header?: string | null): string { if (!header) return ""; const utf8Match = header.match(/filename\*=UTF-8''([^;]+)/i); if (utf8Match?.[1]) { try { return decodeURIComponent(utf8Match[1]); } catch { return utf8Match[1]; } } const plainMatch = header.match(/filename="?([^";]+)"?/i); return plainMatch?.[1] ?? ""; } function isZipResponse(response: Response, url: string): boolean { const contentType = response.headers.get("content-type"); const fileName = getContentDispositionFileName( response.headers.get("content-disposition"), ); return ( isZipUrl(url) || isZipContentType(contentType) || /\.zip$/i.test(fileName) ); } function getCacheKey(source: CatalogSourceMode, url?: string): string { return `${source}:${(url ?? "").trim()}`; } function getCachedCatalog(key: string): CacheEntry | undefined { const entry = REMOTE_CATALOG_CACHE.get(key); if (!entry) return undefined; if (entry.expiresAt <= Date.now()) { REMOTE_CATALOG_CACHE.delete(key); return undefined; } return entry; } function setCachedCatalog( key: string, templates: AppTemplate[], meta: CatalogMeta, ): CatalogSyncResult { const expiresAt = Date.now() + CACHE_TTL_MS; const cacheMeta: CatalogMeta = { ...meta, cached: false, expiresAt: new Date(expiresAt).toISOString(), }; REMOTE_CATALOG_CACHE.set(key, { templates, meta: cacheMeta, expiresAt, }); return { templates, meta: cacheMeta }; } function makeCachedResult(entry: CacheEntry): CatalogSyncResult { return { templates: entry.templates, meta: { ...entry.meta, cached: true, expiresAt: new Date(entry.expiresAt).toISOString(), }, }; } function parseStructuredText(text: string): unknown { const trimmed = text.trim(); if (!trimmed) return {}; if (trimmed.startsWith("{") || trimmed.startsWith("[")) { return JSON.parse(trimmed); } return yaml.load(trimmed); } function toEnvStringFromCompose(value: unknown): string { return normalizeEnv(value); } function toVolumeStringFromCompose(value: unknown): string { return normalizeVolumes(value); } function getMainService( composeRecord: Record, ): Record | undefined { const services = asRecord(composeRecord.services); if (!services) return undefined; const casaOs = asRecord(composeRecord["x-casaos"]); const mainName = asString(casaOs?.main); if (mainName) { const service = asRecord(services[mainName]); if (service) return service; } const firstKey = Object.keys(services)[0]; return firstKey ? asRecord(services[firstKey]) : undefined; } function parseCasaOsCompose( composeRecord: Record, fallbackId: string, baseUrl?: string, ): AppTemplate | null { const casaOs = asRecord(composeRecord["x-casaos"]); const mainService = getMainService(composeRecord); const titleBlock = asRecord(casaOs?.title); const descBlock = asRecord(casaOs?.description); const taglineBlock = asRecord(casaOs?.tagline); const name = asString(titleBlock?.en_us) ?? asString(casaOs?.title) ?? asString(casaOs?.name) ?? fallbackId; const description = asString(descBlock?.en_us) ?? asString(taglineBlock?.en_us) ?? asString(casaOs?.description) ?? "Community app compose manifest"; const image = asString(mainService?.image) ?? buildImageWithVersion(asString(casaOs?.image), asString(casaOs?.version)); if (!image) return null; return { id: asString(casaOs?.id) ?? slugify(fallbackId), name, desc: description, category: asString(casaOs?.category) ?? "Community", icon: normalizeIcon({ icon: casaOs?.icon, logo: casaOs?.logo }, baseUrl), image, defaultPort: normalizePorts(casaOs?.port_map ?? mainService?.ports), defaultEnv: toEnvStringFromCompose(mainService?.environment), defaultVolumes: (() => { const volumes = toVolumeStringFromCompose(mainService?.volumes); return volumes || undefined; })(), defaultCommand: asString(mainService?.command), defaultNetwork: asString(mainService?.network_mode) ?? asString(casaOs?.network), restartPolicy: asString(mainService?.restart) ?? asString(casaOs?.restart_policy) ?? "unless-stopped", popular: false, tags: [ ...new Set( [ asString(casaOs?.category), "community", ...toStringArray(casaOs?.architectures), ].filter((tag): tag is string => Boolean(tag)), ), ], }; } function parseCasaOsConfigJson( record: Record, fallbackId: string, baseUrl?: string, ): AppTemplate | null { const id = asString(record.id) ?? slugify(fallbackId); const name = asString(record.name) ?? asString(asRecord(record.title)?.en_us) ?? asString(record.id) ?? fallbackId; const image = buildImageWithVersion( asString(record.image), asString(record.version), ); if (!image) return null; return { id, name, icon: normalizeIcon(record, baseUrl), desc: asString(record.description) ?? asString(record.short_desc) ?? "Community app config", category: toStringArray(record.categories)[0] ?? "Community", image, defaultPort: normalizePorts(record.port), defaultEnv: normalizeEnv(record.form_fields), restartPolicy: "unless-stopped", popular: Boolean(record.featured ?? false), tags: [...new Set([...toStringArray(record.categories), "community"])], }; } function parseUmbrelManifest( record: Record, fallbackId: string, baseUrl?: string, ): AppTemplate | null { const name = asString(record.name) ?? fallbackId; const id = asString(record.id) ?? slugify(fallbackId); const port = asNumber(record.port); const image = asString(record.image) ?? buildImageWithVersion(asString(record.repo), asString(record.version)); if (!name || !image) return null; return { id, name, icon: normalizeIcon(record, baseUrl), desc: asString(record.description) ?? asString(record.tagline) ?? "Umbrel manifest", category: asString(record.category) ?? "Umbrel", image, defaultPort: port ? `${port}:${port}` : normalizePorts(record.port), defaultEnv: "", defaultVolumes: undefined, defaultCommand: undefined, defaultNetwork: undefined, restartPolicy: "unless-stopped", popular: false, tags: [ ...new Set(["umbrel", asString(record.category) ?? ""].filter(Boolean)), ], }; } function normalizeSingleManifest( payload: unknown, fallbackId: string, baseUrl?: string, ): AppTemplate[] { const record = asRecord(payload); if (!record) return []; const compose = parseCasaOsCompose(record, fallbackId, baseUrl); if (compose) return [compose]; const casaConfig = parseCasaOsConfigJson(record, fallbackId, baseUrl); if (casaConfig) return [casaConfig]; const umbrel = parseUmbrelManifest(record, fallbackId, baseUrl); if (umbrel) return [umbrel]; const genericEntries = extractCandidateEntries(payload) .map((entry, index) => normalizeEntry(entry, index, baseUrl)) .filter((entry): entry is AppTemplate => Boolean(entry)); return genericEntries; } function getAppDirKey(entryName: string): string | undefined { const match = entryName.match(/(^|\/)Apps\/([^/]+)\//i); return match?.[2]?.toLowerCase(); } function parseCasaOsArchiveCatalog(zip: AdmZip): AppTemplate[] { const configEntries = new Map(); const composeEntries = new Map(); const results: AppTemplate[] = []; zip.getEntries().forEach((entry) => { if (entry.isDirectory) return; const appDirKey = getAppDirKey(entry.entryName); if (!appDirKey) return; const lower = entry.entryName.toLowerCase(); if (lower.endsWith("/config.json")) { configEntries.set(appDirKey, entry.entryName); } if ( lower.endsWith("/docker-compose.yml") || lower.endsWith("/docker-compose.yaml") ) { composeEntries.set(appDirKey, entry.entryName); } }); Array.from( new Set([...configEntries.keys(), ...composeEntries.keys()]), ).forEach((appDirKey) => { const configPath = configEntries.get(appDirKey); const composePath = composeEntries.get(appDirKey); try { const configPayload = configPath ? JSON.parse(zip.readAsText(configPath)) : undefined; const composePayload = composePath ? parseStructuredText(zip.readAsText(composePath)) : undefined; let template: AppTemplate | null = null; if (composePayload) { template = parseCasaOsCompose( asRecord(composePayload) ?? {}, appDirKey, ); } if (!template && configPayload) { template = parseCasaOsConfigJson( asRecord(configPayload) ?? {}, appDirKey, ); } if (template) { if (configPayload && !template.defaultPort) { template.defaultPort = normalizePorts(asRecord(configPayload)?.port); } if (configPayload && template.tags.length === 0) { template.tags = toStringArray(asRecord(configPayload)?.categories); } results.push(template); } } catch { // Ignore malformed app folder data. } }); return results; } function parseGenericArchiveCatalog(zip: AdmZip): AppTemplate[] { const templates: AppTemplate[] = []; const candidateEntries = zip .getEntries() .filter((entry) => { if (entry.isDirectory) return false; const lower = entry.entryName.toLowerCase(); if (!/\.(json|ya?ml)$/.test(lower)) return false; return /(app|catalog|manifest|template|store|config|compose|umbrel)/.test( lower, ); }) .slice(0, 300); candidateEntries.forEach((entry) => { try { const payload = parseStructuredText(entry.getData().toString("utf8")); const fallbackId = slugify( entry.entryName.split("/").slice(-2, -1)[0] ?? entry.name, ); const parsed = normalizeSingleManifest(payload, fallbackId); templates.push(...parsed); } catch { // Ignore malformed files. } }); return templates; } async function fetchRemoteResponse(url: string): Promise { const response = await fetch(url, { headers: { Accept: "application/json, application/yaml, text/yaml, text/x-yaml, text/plain, application/octet-stream, */*", "User-Agent": "vps-panel-app-catalog/1.0", }, redirect: "follow", }); if (!response.ok) { throw new Error( `Remote request failed with status ${response.status} for ${response.url}`, ); } return response; } async function parseArchiveResponse( response: Response, source: CatalogSourceMode, label: string, url: string, ): Promise { const buffer = Buffer.from(await response.arrayBuffer()); const zip = new AdmZip(buffer); const casaOsTemplates = parseCasaOsArchiveCatalog(zip); const genericTemplates = parseGenericArchiveCatalog(zip); const templates = dedupeTemplates([...casaOsTemplates, ...genericTemplates]); return { templates, meta: { source, label, url, fetchedAt: new Date().toISOString(), isRemote: true, format: casaOsTemplates.length > 0 ? "casaos-archive" : "archive", }, }; } async function loadManifestUrlCatalog(url: string): Promise { const normalizedUrl = normalizeGithubArchiveUrl( normalizeGithubManifestUrl(url), ); const response = await fetchRemoteResponse(normalizedUrl); if (isZipResponse(response, normalizedUrl)) { return parseArchiveResponse( response, "manifest-url", "Remote URL Catalog", normalizedUrl, ); } const text = await response.text(); const payload = parseStructuredText(text); const fallbackId = getFallbackIdFromUrl(normalizedUrl); const templates = dedupeTemplates( normalizeSingleManifest(payload, fallbackId, normalizedUrl), ); return { templates, meta: { source: "manifest-url", label: "Remote Manifest URL", url: normalizedUrl, fetchedAt: new Date().toISOString(), isRemote: true, format: text.trim().startsWith("{") || text.trim().startsWith("[") ? "json" : "yaml", }, }; } async function loadAutoDetectCatalog(url: string): Promise { const normalizedUrl = normalizeGithubArchiveUrl( normalizeGithubManifestUrl(url), ); const response = await fetchRemoteResponse(normalizedUrl); if (isZipResponse(response, normalizedUrl)) { return parseArchiveResponse( response, "auto-detect", "Custom Source", normalizedUrl, ); } const text = await response.text(); const payload = parseStructuredText(text); const fallbackId = getFallbackIdFromUrl(normalizedUrl); const templates = dedupeTemplates( normalizeSingleManifest(payload, fallbackId, normalizedUrl), ); return { templates, meta: { source: "auto-detect", label: "Custom Source", url: normalizedUrl, fetchedAt: new Date().toISOString(), isRemote: true, format: text.trim().startsWith("{") || text.trim().startsWith("[") ? "json" : "yaml", }, }; } async function loadGithubArchiveCatalog( url: string, ): Promise { const normalizedUrl = normalizeGithubArchiveUrl(url); const response = await fetchRemoteResponse(normalizedUrl); return parseArchiveResponse( response, "github-archive", "GitHub Archive ZIP", normalizedUrl, ); } export async function getCatalogTemplates( source: CatalogSourceMode, url?: string, ): Promise { const fetchedAt = new Date().toISOString(); if (source === "local") { return { templates: APP_TEMPLATES, meta: { source, label: "Built-in Catalog", fetchedAt, isRemote: false, cached: false, }, }; } if (!url?.trim()) { throw new Error("Remote catalog URL is required"); } const trimmedUrl = url.trim(); const cacheKey = getCacheKey(source, trimmedUrl); const cachedEntry = getCachedCatalog(cacheKey); if (cachedEntry) { return makeCachedResult(cachedEntry); } const result = source === "auto-detect" ? await loadAutoDetectCatalog(trimmedUrl) : source === "manifest-url" ? await loadManifestUrlCatalog(trimmedUrl) : await loadGithubArchiveCatalog(trimmedUrl); if (result.templates.length === 0) { throw new Error( "No app templates could be extracted from the remote source", ); } return setCachedCatalog(cacheKey, result.templates, result.meta); }