mirror of
https://github.com/google-gemini/gemini-cli.git
synced 2026-03-21 11:30:38 -07:00
add extension IDs (#11377)
This commit is contained in:
@@ -8,6 +8,7 @@ import { vi } from 'vitest';
|
||||
import * as fs from 'node:fs';
|
||||
import * as os from 'node:os';
|
||||
import * as path from 'node:path';
|
||||
import { createHash } from 'node:crypto';
|
||||
import {
|
||||
EXTENSIONS_CONFIG_FILENAME,
|
||||
ExtensionStorage,
|
||||
@@ -509,6 +510,163 @@ describe('extension tests', () => {
|
||||
);
|
||||
consoleSpy.mockRestore();
|
||||
});
|
||||
|
||||
describe('id generation', () => {
|
||||
it('should generate id from source for non-github git urls', () => {
|
||||
const extensionDir = createExtension({
|
||||
extensionsDir: userExtensionsDir,
|
||||
name: 'my-ext',
|
||||
version: '1.0.0',
|
||||
installMetadata: {
|
||||
type: 'git',
|
||||
source: 'http://somehost.com/foo/bar',
|
||||
},
|
||||
});
|
||||
|
||||
const extension = loadExtension({
|
||||
extensionDir,
|
||||
workspaceDir: tempWorkspaceDir,
|
||||
});
|
||||
|
||||
const expectedHash = createHash('sha256')
|
||||
.update('http://somehost.com/foo/bar')
|
||||
.digest('hex');
|
||||
expect(extension?.id).toBe(expectedHash);
|
||||
});
|
||||
|
||||
it('should generate id from owner/repo for github http urls', () => {
|
||||
const extensionDir = createExtension({
|
||||
extensionsDir: userExtensionsDir,
|
||||
name: 'my-ext',
|
||||
version: '1.0.0',
|
||||
installMetadata: {
|
||||
type: 'git',
|
||||
source: 'http://github.com/foo/bar',
|
||||
},
|
||||
});
|
||||
|
||||
const extension = loadExtension({
|
||||
extensionDir,
|
||||
workspaceDir: tempWorkspaceDir,
|
||||
});
|
||||
|
||||
const expectedHash = createHash('sha256')
|
||||
.update('https://github.com/foo/bar')
|
||||
.digest('hex');
|
||||
expect(extension?.id).toBe(expectedHash);
|
||||
});
|
||||
|
||||
it('should generate id from owner/repo for github ssh urls', () => {
|
||||
const extensionDir = createExtension({
|
||||
extensionsDir: userExtensionsDir,
|
||||
name: 'my-ext',
|
||||
version: '1.0.0',
|
||||
installMetadata: {
|
||||
type: 'git',
|
||||
source: 'git@github.com:foo/bar',
|
||||
},
|
||||
});
|
||||
|
||||
const extension = loadExtension({
|
||||
extensionDir,
|
||||
workspaceDir: tempWorkspaceDir,
|
||||
});
|
||||
|
||||
const expectedHash = createHash('sha256')
|
||||
.update('https://github.com/foo/bar')
|
||||
.digest('hex');
|
||||
expect(extension?.id).toBe(expectedHash);
|
||||
});
|
||||
|
||||
it('should generate id from source for github-release extension', () => {
|
||||
const extensionDir = createExtension({
|
||||
extensionsDir: userExtensionsDir,
|
||||
name: 'my-ext',
|
||||
version: '1.0.0',
|
||||
installMetadata: {
|
||||
type: 'github-release',
|
||||
source: 'https://github.com/foo/bar',
|
||||
},
|
||||
});
|
||||
|
||||
const extension = loadExtension({
|
||||
extensionDir,
|
||||
workspaceDir: tempWorkspaceDir,
|
||||
});
|
||||
|
||||
const expectedHash = createHash('sha256')
|
||||
.update('https://github.com/foo/bar')
|
||||
.digest('hex');
|
||||
expect(extension?.id).toBe(expectedHash);
|
||||
});
|
||||
|
||||
it('should generate id from the original source for local extension', () => {
|
||||
const extensionDir = createExtension({
|
||||
extensionsDir: userExtensionsDir,
|
||||
name: 'local-ext-name',
|
||||
version: '1.0.0',
|
||||
installMetadata: {
|
||||
type: 'local',
|
||||
source: '/some/path',
|
||||
},
|
||||
});
|
||||
|
||||
const extension = loadExtension({
|
||||
extensionDir,
|
||||
workspaceDir: tempWorkspaceDir,
|
||||
});
|
||||
|
||||
const expectedHash = createHash('sha256')
|
||||
.update('/some/path')
|
||||
.digest('hex');
|
||||
expect(extension?.id).toBe(expectedHash);
|
||||
});
|
||||
|
||||
it('should generate id from the original source for linked extensions', async () => {
|
||||
const extDevelopmentDir = path.join(tempHomeDir, 'local_extensions');
|
||||
const actualExtensionDir = createExtension({
|
||||
extensionsDir: extDevelopmentDir,
|
||||
name: 'link-ext-name',
|
||||
version: '1.0.0',
|
||||
});
|
||||
const extensionName = await installOrUpdateExtension(
|
||||
{
|
||||
type: 'link',
|
||||
source: actualExtensionDir,
|
||||
},
|
||||
async () => true,
|
||||
tempWorkspaceDir,
|
||||
);
|
||||
|
||||
const extension = loadExtension({
|
||||
extensionDir: new ExtensionStorage(extensionName).getExtensionDir(),
|
||||
workspaceDir: tempWorkspaceDir,
|
||||
});
|
||||
|
||||
const expectedHash = createHash('sha256')
|
||||
.update(actualExtensionDir)
|
||||
.digest('hex');
|
||||
expect(extension?.id).toBe(expectedHash);
|
||||
});
|
||||
|
||||
it('should generate id from name for extension with no install metadata', () => {
|
||||
const extensionDir = createExtension({
|
||||
extensionsDir: userExtensionsDir,
|
||||
name: 'no-meta-name',
|
||||
version: '1.0.0',
|
||||
});
|
||||
|
||||
const extension = loadExtension({
|
||||
extensionDir,
|
||||
workspaceDir: tempWorkspaceDir,
|
||||
});
|
||||
|
||||
const expectedHash = createHash('sha256')
|
||||
.update('no-meta-name')
|
||||
.digest('hex');
|
||||
expect(extension?.id).toBe(expectedHash);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('annotateActiveExtensions', () => {
|
||||
|
||||
@@ -35,10 +35,11 @@ import {
|
||||
} from './extensions/variables.js';
|
||||
import { isWorkspaceTrusted } from './trustedFolders.js';
|
||||
import { resolveEnvVarsInObject } from '../utils/envVarResolver.js';
|
||||
import { randomUUID } from 'node:crypto';
|
||||
import { randomUUID, createHash } from 'node:crypto';
|
||||
import {
|
||||
cloneFromGit,
|
||||
downloadFromGitHubRelease,
|
||||
tryParseGithubUrl,
|
||||
} from './extensions/github.js';
|
||||
import type { LoadExtensionContext } from './extensions/variableSchema.js';
|
||||
import { ExtensionEnablementManager } from './extensions/extensionEnablement.js';
|
||||
@@ -253,6 +254,28 @@ export function loadExtension(
|
||||
)
|
||||
.filter((contextFilePath) => fs.existsSync(contextFilePath));
|
||||
|
||||
// IDs are created by hashing details of the installation source in order to
|
||||
// deduplicate extensions with conflicting names and also obfuscate any
|
||||
// potentially sensitive information such as private git urls, system paths,
|
||||
// or project names.
|
||||
const hash = createHash('sha256');
|
||||
const githubUrlParts =
|
||||
installMetadata &&
|
||||
(installMetadata.type === 'git' ||
|
||||
installMetadata.type === 'github-release')
|
||||
? tryParseGithubUrl(installMetadata.source)
|
||||
: null;
|
||||
if (githubUrlParts) {
|
||||
// For github repos, we use the https URI to the repo as the ID.
|
||||
hash.update(
|
||||
`https://github.com/${githubUrlParts.owner}/${githubUrlParts.repo}`,
|
||||
);
|
||||
} else {
|
||||
hash.update(installMetadata?.source ?? config.name);
|
||||
}
|
||||
|
||||
const id = hash.digest('hex');
|
||||
|
||||
return {
|
||||
name: config.name,
|
||||
version: config.version,
|
||||
@@ -262,6 +285,7 @@ export function loadExtension(
|
||||
mcpServers: config.mcpServers,
|
||||
excludeTools: config.excludeTools,
|
||||
isActive: true, // Barring any other signals extensions should be considered Active.
|
||||
id,
|
||||
};
|
||||
} catch (e) {
|
||||
console.error(
|
||||
@@ -465,26 +489,36 @@ export async function installOrUpdateExtension(
|
||||
installMetadata.type === 'github-release'
|
||||
) {
|
||||
tempDir = await ExtensionStorage.createTmpDir();
|
||||
const result = await downloadFromGitHubRelease(installMetadata, tempDir);
|
||||
if (result.success) {
|
||||
installMetadata.type = result.type;
|
||||
installMetadata.releaseTag = result.tagName;
|
||||
} else if (
|
||||
// This repo has no github releases, and wasn't explicitly installed
|
||||
// from a github release, unconditionally just clone it.
|
||||
(result.failureReason === 'no release data' &&
|
||||
installMetadata.type === 'git') ||
|
||||
// Otherwise ask the user if they would like to try a git clone.
|
||||
(await requestConsent(
|
||||
`Error downloading github release for ${installMetadata.source} with the following error: ${result.errorMessage}.\n\nWould you like to attempt to install via "git clone" instead?`,
|
||||
))
|
||||
) {
|
||||
const parsedGithubParts = tryParseGithubUrl(installMetadata.source);
|
||||
if (!parsedGithubParts) {
|
||||
await cloneFromGit(installMetadata, tempDir);
|
||||
installMetadata.type = 'git';
|
||||
} else {
|
||||
throw new Error(
|
||||
`Failed to install extension ${installMetadata.source}: ${result.errorMessage}`,
|
||||
const result = await downloadFromGitHubRelease(
|
||||
installMetadata,
|
||||
tempDir,
|
||||
parsedGithubParts,
|
||||
);
|
||||
if (result.success) {
|
||||
installMetadata.type = result.type;
|
||||
installMetadata.releaseTag = result.tagName;
|
||||
} else if (
|
||||
// This repo has no github releases, and wasn't explicitly installed
|
||||
// from a github release, unconditionally just clone it.
|
||||
(result.failureReason === 'no release data' &&
|
||||
installMetadata.type === 'git') ||
|
||||
// Otherwise ask the user if they would like to try a git clone.
|
||||
(await requestConsent(
|
||||
`Error downloading github release for ${installMetadata.source} with the following error: ${result.errorMessage}.\n\nWould you like to attempt to install via "git clone" instead?`,
|
||||
))
|
||||
) {
|
||||
await cloneFromGit(installMetadata, tempDir);
|
||||
installMetadata.type = 'git';
|
||||
} else {
|
||||
throw new Error(
|
||||
`Failed to install extension ${installMetadata.source}: ${result.errorMessage}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
localSourcePath = tempDir;
|
||||
} else if (
|
||||
|
||||
@@ -11,7 +11,7 @@ import {
|
||||
extractFile,
|
||||
findReleaseAsset,
|
||||
fetchReleaseFromGithub,
|
||||
parseGitHubRepoForReleases,
|
||||
tryParseGithubUrl,
|
||||
} from './github.js';
|
||||
import { simpleGit, type SimpleGit } from 'simple-git';
|
||||
import { ExtensionUpdateState } from '../../ui/state/extensions.js';
|
||||
@@ -348,61 +348,62 @@ describe('git extension helpers', () => {
|
||||
describe('parseGitHubRepoForReleases', () => {
|
||||
it('should parse owner and repo from a full GitHub URL', () => {
|
||||
const source = 'https://github.com/owner/repo.git';
|
||||
const { owner, repo } = parseGitHubRepoForReleases(source)!;
|
||||
const { owner, repo } = tryParseGithubUrl(source)!;
|
||||
expect(owner).toBe('owner');
|
||||
expect(repo).toBe('repo');
|
||||
});
|
||||
|
||||
it('should parse owner and repo from a full GitHub URL without .git', () => {
|
||||
const source = 'https://github.com/owner/repo';
|
||||
const { owner, repo } = parseGitHubRepoForReleases(source)!;
|
||||
const { owner, repo } = tryParseGithubUrl(source)!;
|
||||
expect(owner).toBe('owner');
|
||||
expect(repo).toBe('repo');
|
||||
});
|
||||
|
||||
it('should parse owner and repo from a full GitHub URL with a trailing slash', () => {
|
||||
const source = 'https://github.com/owner/repo/';
|
||||
const { owner, repo } = parseGitHubRepoForReleases(source)!;
|
||||
const { owner, repo } = tryParseGithubUrl(source)!;
|
||||
expect(owner).toBe('owner');
|
||||
expect(repo).toBe('repo');
|
||||
});
|
||||
|
||||
it('should fail on a GitHub SSH URL', () => {
|
||||
it('should parse owner and repo from a GitHub SSH URL', () => {
|
||||
const source = 'git@github.com:owner/repo.git';
|
||||
expect(() => parseGitHubRepoForReleases(source)).toThrow(
|
||||
'GitHub release-based extensions are not supported for SSH. You must use an HTTPS URI with a personal access token to download releases from private repositories. You can set your personal access token in the GITHUB_TOKEN environment variable and install the extension via SSH.',
|
||||
);
|
||||
|
||||
const { owner, repo } = tryParseGithubUrl(source)!;
|
||||
expect(owner).toBe('owner');
|
||||
expect(repo).toBe('repo');
|
||||
});
|
||||
|
||||
it('should return null on a non-GitHub URL', () => {
|
||||
const source = 'https://example.com/owner/repo.git';
|
||||
expect(parseGitHubRepoForReleases(source)).toBe(null);
|
||||
expect(tryParseGithubUrl(source)).toBe(null);
|
||||
});
|
||||
|
||||
it('should parse owner and repo from a shorthand string', () => {
|
||||
const source = 'owner/repo';
|
||||
const { owner, repo } = parseGitHubRepoForReleases(source)!;
|
||||
const { owner, repo } = tryParseGithubUrl(source)!;
|
||||
expect(owner).toBe('owner');
|
||||
expect(repo).toBe('repo');
|
||||
});
|
||||
|
||||
it('should handle .git suffix in repo name', () => {
|
||||
const source = 'owner/repo.git';
|
||||
const { owner, repo } = parseGitHubRepoForReleases(source)!;
|
||||
const { owner, repo } = tryParseGithubUrl(source)!;
|
||||
expect(owner).toBe('owner');
|
||||
expect(repo).toBe('repo');
|
||||
});
|
||||
|
||||
it('should throw error for invalid source format', () => {
|
||||
const source = 'invalid-format';
|
||||
expect(() => parseGitHubRepoForReleases(source)).toThrow(
|
||||
expect(() => tryParseGithubUrl(source)).toThrow(
|
||||
'Invalid GitHub repository source: invalid-format. Expected "owner/repo" or a github repo uri.',
|
||||
);
|
||||
});
|
||||
|
||||
it('should throw error for source with too many parts', () => {
|
||||
const source = 'https://github.com/owner/repo/extra';
|
||||
expect(() => parseGitHubRepoForReleases(source)).toThrow(
|
||||
expect(() => tryParseGithubUrl(source)).toThrow(
|
||||
'Invalid GitHub repository source: https://github.com/owner/repo/extra. Expected "owner/repo" or a github repo uri.',
|
||||
);
|
||||
});
|
||||
|
||||
@@ -76,24 +76,30 @@ export async function cloneFromGit(
|
||||
}
|
||||
}
|
||||
|
||||
export function parseGitHubRepoForReleases(source: string): {
|
||||
export interface GithubRepoInfo {
|
||||
owner: string;
|
||||
repo: string;
|
||||
} | null {
|
||||
}
|
||||
|
||||
export function tryParseGithubUrl(source: string): GithubRepoInfo | null {
|
||||
// First step in normalizing a github ssh URI to the https form.
|
||||
if (source.startsWith('git@github.com:')) {
|
||||
source = source.replace('git@github.com:', '');
|
||||
}
|
||||
// Default to a github repo path, so `source` can be just an org/repo
|
||||
const parsedUrl = URL.parse(source, 'https://github.com');
|
||||
if (!parsedUrl) {
|
||||
throw new Error(`Invalid repo URL: ${source}`);
|
||||
}
|
||||
// The pathname should be "/owner/repo".
|
||||
const parts = parsedUrl?.pathname
|
||||
.substring(1)
|
||||
.split('/')
|
||||
// Remove the empty segments, fixes trailing slashes
|
||||
.filter((part) => part !== '');
|
||||
if (parsedUrl?.host !== 'github.com') {
|
||||
return null;
|
||||
}
|
||||
// The pathname should be "/owner/repo".
|
||||
const parts = parsedUrl?.pathname
|
||||
.split('/')
|
||||
// Remove the empty segments, fixes trailing and leading slashes
|
||||
.filter((part) => part !== '');
|
||||
|
||||
if (parts?.length !== 2) {
|
||||
throw new Error(
|
||||
`Invalid GitHub repository source: ${source}. Expected "owner/repo" or a github repo uri.`,
|
||||
@@ -102,13 +108,10 @@ export function parseGitHubRepoForReleases(source: string): {
|
||||
const owner = parts[0];
|
||||
const repo = parts[1].replace('.git', '');
|
||||
|
||||
if (owner.startsWith('git@github.com')) {
|
||||
throw new Error(
|
||||
`GitHub release-based extensions are not supported for SSH. You must use an HTTPS URI with a personal access token to download releases from private repositories. You can set your personal access token in the GITHUB_TOKEN environment variable and install the extension via SSH.`,
|
||||
);
|
||||
}
|
||||
|
||||
return { owner, repo };
|
||||
return {
|
||||
owner,
|
||||
repo,
|
||||
};
|
||||
}
|
||||
|
||||
export async function fetchReleaseFromGithub(
|
||||
@@ -217,7 +220,7 @@ export async function checkForExtensionUpdate(
|
||||
console.error(`No "source" provided for extension.`);
|
||||
return ExtensionUpdateState.ERROR;
|
||||
}
|
||||
const repoInfo = parseGitHubRepoForReleases(source);
|
||||
const repoInfo = tryParseGithubUrl(source);
|
||||
if (!repoInfo) {
|
||||
console.error(
|
||||
`Source is not a valid GitHub repository for release checks: ${source}`,
|
||||
@@ -248,39 +251,35 @@ export async function checkForExtensionUpdate(
|
||||
}
|
||||
}
|
||||
|
||||
export interface GitHubDownloadResult {
|
||||
tagName?: string;
|
||||
type: 'git' | 'github-release';
|
||||
success: boolean;
|
||||
failureReason?:
|
||||
| 'failed to fetch release data'
|
||||
| 'no release data'
|
||||
| 'no release asset found'
|
||||
| 'failed to download asset'
|
||||
| 'failed to extract asset'
|
||||
| 'unknown';
|
||||
errorMessage?: string;
|
||||
}
|
||||
|
||||
export type GitHubDownloadResult =
|
||||
| {
|
||||
tagName?: string;
|
||||
type: 'git' | 'github-release';
|
||||
success: false;
|
||||
failureReason:
|
||||
| 'failed to fetch release data'
|
||||
| 'no release data'
|
||||
| 'no release asset found'
|
||||
| 'failed to download asset'
|
||||
| 'failed to extract asset'
|
||||
| 'unknown';
|
||||
errorMessage: string;
|
||||
}
|
||||
| {
|
||||
tagName?: string;
|
||||
type: 'git' | 'github-release';
|
||||
success: true;
|
||||
};
|
||||
export async function downloadFromGitHubRelease(
|
||||
installMetadata: ExtensionInstallMetadata,
|
||||
destination: string,
|
||||
githubRepoInfo: GithubRepoInfo,
|
||||
): Promise<GitHubDownloadResult> {
|
||||
const { source, ref, allowPreRelease: preRelease } = installMetadata;
|
||||
const { ref, allowPreRelease: preRelease } = installMetadata;
|
||||
const { owner, repo } = githubRepoInfo;
|
||||
let releaseData: GithubReleaseData | null = null;
|
||||
|
||||
try {
|
||||
const parts = parseGitHubRepoForReleases(source);
|
||||
if (!parts) {
|
||||
return {
|
||||
failureReason: 'no release data',
|
||||
success: false,
|
||||
type: 'github-release',
|
||||
errorMessage: `Not a github repo: ${source}`,
|
||||
};
|
||||
}
|
||||
const { owner, repo } = parts;
|
||||
|
||||
try {
|
||||
releaseData = await fetchReleaseFromGithub(owner, repo, ref, preRelease);
|
||||
if (!releaseData) {
|
||||
|
||||
@@ -141,6 +141,7 @@ export interface GeminiCLIExtension {
|
||||
mcpServers?: Record<string, MCPServerConfig>;
|
||||
contextFiles: string[];
|
||||
excludeTools?: string[];
|
||||
id?: string;
|
||||
}
|
||||
|
||||
export interface ExtensionInstallMetadata {
|
||||
|
||||
Reference in New Issue
Block a user