File size: 4,311 Bytes
84aa3bf
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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
/**
 * @license
 * Copyright 2026 Google LLC
 * SPDX-License-Identifier: Apache-2.0
 */

import * as fs from 'node:fs/promises';
import {
  fetchWithTimeout,
  resolveToRealPath,
  isPrivateIp,
} from '@google/gemini-cli-core';
import { AsyncFzf } from 'fzf';

export interface RegistryExtension {
  id: string;
  rank: number;
  url: string;
  fullName: string;
  repoDescription: string;
  stars: number;
  lastUpdated: string;
  extensionName: string;
  extensionVersion: string;
  extensionDescription: string;
  avatarUrl: string;
  hasMCP: boolean;
  hasContext: boolean;
  hasHooks: boolean;
  hasSkills: boolean;
  hasCustomCommands: boolean;
  isGoogleOwned: boolean;
  licenseKey: string;
}

export class ExtensionRegistryClient {
  static readonly DEFAULT_REGISTRY_URL =
    'https://geminicli.com/extensions.json';
  private static readonly FETCH_TIMEOUT_MS = 10000; // 10 seconds

  private static fetchPromise: Promise<RegistryExtension[]> | null = null;

  private readonly registryURI: string;

  constructor(registryURI?: string) {
    this.registryURI =
      registryURI || ExtensionRegistryClient.DEFAULT_REGISTRY_URL;
  }

  /** @internal */
  static resetCache() {
    ExtensionRegistryClient.fetchPromise = null;
  }

  async getExtensions(
    page: number = 1,
    limit: number = 10,
    orderBy: 'ranking' | 'alphabetical' = 'ranking',
  ): Promise<{ extensions: RegistryExtension[]; total: number }> {
    const allExtensions = [...(await this.fetchAllExtensions())];

    switch (orderBy) {
      case 'ranking':
        allExtensions.sort((a, b) => a.rank - b.rank);
        break;
      case 'alphabetical':
        allExtensions.sort((a, b) =>
          a.extensionName.localeCompare(b.extensionName),
        );
        break;
      default: {
        const _exhaustiveCheck: never = orderBy;
        throw new Error(`Unhandled orderBy: ${_exhaustiveCheck}`);
      }
    }

    const startIndex = (page - 1) * limit;
    const endIndex = startIndex + limit;
    return {
      extensions: allExtensions.slice(startIndex, endIndex),
      total: allExtensions.length,
    };
  }

  async searchExtensions(query: string): Promise<RegistryExtension[]> {
    const allExtensions = await this.fetchAllExtensions();
    if (!query.trim()) {
      return allExtensions;
    }

    const fzf = new AsyncFzf(allExtensions, {
      selector: (ext: RegistryExtension) =>
        `${ext.extensionName} ${ext.extensionDescription} ${ext.fullName}`,
      fuzzy: true,
    });
    // eslint-disable-next-line @typescript-eslint/no-unsafe-assignment
    const results: Array<{ item: RegistryExtension }> = await fzf.find(query);
    return results.map((r) => r.item);
  }

  async getExtension(id: string): Promise<RegistryExtension | undefined> {
    const allExtensions = await this.fetchAllExtensions();
    return allExtensions.find((ext) => ext.id === id);
  }

  private async fetchAllExtensions(): Promise<RegistryExtension[]> {
    if (ExtensionRegistryClient.fetchPromise) {
      return ExtensionRegistryClient.fetchPromise;
    }

    const uri = this.registryURI;
    ExtensionRegistryClient.fetchPromise = (async () => {
      try {
        if (uri.startsWith('http')) {
          if (await isPrivateIp(uri)) {
            throw new Error(
              'Private IP addresses are not allowed for the extension registry.',
            );
          }
          const response = await fetchWithTimeout(
            uri,
            ExtensionRegistryClient.FETCH_TIMEOUT_MS,
          );
          if (!response.ok) {
            throw new Error(
              `Failed to fetch extensions: ${response.statusText}`,
            );
          }

          // eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion
          return (await response.json()) as RegistryExtension[];
        } else {
          // Handle local file path
          const filePath = resolveToRealPath(uri);
          const content = await fs.readFile(filePath, 'utf-8');
          // eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion
          return JSON.parse(content) as RegistryExtension[];
        }
      } catch (error) {
        ExtensionRegistryClient.fetchPromise = null;
        throw error;
      }
    })();

    return ExtensionRegistryClient.fetchPromise;
  }
}