Add gemini extensions link command (#7241)

Co-authored-by: Bryan Morgan <bryanmorgan@google.com>
This commit is contained in:
christine betts
2025-09-02 10:15:42 -07:00
committed by GitHub
parent 997136ae25
commit 6a581a695f
15 changed files with 461 additions and 20 deletions

View File

@@ -217,6 +217,36 @@ describe('loadExtensions', () => {
expect(loadedConfig.mcpServers?.['test-server'].cwd).toBe(expectedCwd);
});
it('should load a linked extension correctly', async () => {
const sourceExtDir = createExtension({
extensionsDir: tempWorkspaceDir,
name: 'my-linked-extension',
version: '1.0.0',
contextFileName: 'context.md',
});
fs.writeFileSync(path.join(sourceExtDir, 'context.md'), 'linked context');
const extensionName = await installExtension({
source: sourceExtDir,
type: 'link',
});
expect(extensionName).toEqual('my-linked-extension');
const extensions = loadExtensions(tempHomeDir);
expect(extensions).toHaveLength(1);
const linkedExt = extensions[0];
expect(linkedExt.config.name).toBe('my-linked-extension');
expect(linkedExt.path).toBe(sourceExtDir);
expect(linkedExt.installMetadata).toEqual({
source: sourceExtDir,
type: 'link',
});
expect(linkedExt.contextFiles).toEqual([
path.join(sourceExtDir, 'context.md'),
]);
});
it('should resolve environment variables in extension configuration', () => {
process.env.TEST_API_KEY = 'test-api-key-123';
process.env.TEST_DB_URL = 'postgresql://localhost:5432/testdb';
@@ -402,12 +432,12 @@ describe('installExtension', () => {
fs.rmSync(userExtensionsDir, { recursive: true, force: true });
fs.mkdirSync(userExtensionsDir, { recursive: true });
vi.mocked(isWorkspaceTrusted).mockReturnValue(true);
vi.mocked(execSync).mockClear();
});
afterEach(() => {
fs.rmSync(tempHomeDir, { recursive: true, force: true });
fs.rmSync(userExtensionsDir, { recursive: true, force: true });
});
it('should install an extension from a local path', async () => {
@@ -487,6 +517,31 @@ describe('installExtension', () => {
});
fs.rmSync(targetExtDir, { recursive: true, force: true });
});
it('should install a linked extension', async () => {
const sourceExtDir = createExtension({
extensionsDir: tempHomeDir,
name: 'my-linked-extension',
version: '1.0.0',
});
const targetExtDir = path.join(userExtensionsDir, 'my-linked-extension');
const metadataPath = path.join(targetExtDir, INSTALL_METADATA_FILENAME);
const configPath = path.join(targetExtDir, EXTENSIONS_CONFIG_FILENAME);
await installExtension({ source: sourceExtDir, type: 'link' });
expect(fs.existsSync(targetExtDir)).toBe(true);
expect(fs.existsSync(metadataPath)).toBe(true);
expect(fs.existsSync(configPath)).toBe(false);
const metadata = JSON.parse(fs.readFileSync(metadataPath, 'utf-8'));
expect(metadata).toEqual({
source: sourceExtDir,
type: 'link',
});
fs.rmSync(targetExtDir, { recursive: true, force: true });
});
});
describe('uninstallExtension', () => {
@@ -701,7 +756,9 @@ function createExtension({
}
if (contextFileName) {
fs.writeFileSync(path.join(extDir, contextFileName), 'context');
const contextPath = path.join(extDir, contextFileName);
fs.mkdirSync(path.dirname(contextPath), { recursive: true });
fs.writeFileSync(contextPath, 'context');
}
return extDir;
}

View File

@@ -41,7 +41,7 @@ export interface ExtensionConfig {
export interface ExtensionInstallMetadata {
source: string;
type: 'git' | 'local';
type: 'git' | 'local' | 'link';
}
export interface ExtensionUpdateInfo {
@@ -175,10 +175,20 @@ export function loadExtension(extensionDir: string): Extension | null {
return null;
}
const configFilePath = path.join(extensionDir, EXTENSIONS_CONFIG_FILENAME);
const installMetadata = loadInstallMetadata(extensionDir);
let effectiveExtensionPath = extensionDir;
if (installMetadata?.type === 'link') {
effectiveExtensionPath = installMetadata.source;
}
const configFilePath = path.join(
effectiveExtensionPath,
EXTENSIONS_CONFIG_FILENAME,
);
if (!fs.existsSync(configFilePath)) {
console.error(
`Warning: extension directory ${extensionDir} does not contain a config file ${configFilePath}.`,
`Warning: extension directory ${effectiveExtensionPath} does not contain a config file ${configFilePath}.`,
);
return null;
}
@@ -200,14 +210,16 @@ export function loadExtension(extensionDir: string): Extension | null {
config = resolveEnvVarsInObject(config);
const contextFiles = getContextFileNames(config)
.map((contextFileName) => path.join(extensionDir, contextFileName))
.map((contextFileName) =>
path.join(effectiveExtensionPath, contextFileName),
)
.filter((contextFilePath) => fs.existsSync(contextFilePath));
return {
path: extensionDir,
path: effectiveExtensionPath,
config,
contextFiles,
installMetadata: loadInstallMetadata(extensionDir),
installMetadata,
};
} catch (e) {
console.error(
@@ -343,32 +355,38 @@ export async function installExtension(
// Convert relative paths to absolute paths for the metadata file.
if (
installMetadata.type === 'local' &&
!path.isAbsolute(installMetadata.source)
!path.isAbsolute(installMetadata.source) &&
(installMetadata.type === 'local' || installMetadata.type === 'link')
) {
installMetadata.source = path.resolve(cwd, installMetadata.source);
}
let localSourcePath: string;
let tempDir: string | undefined;
let newExtensionName: string | undefined;
if (installMetadata.type === 'git') {
tempDir = await ExtensionStorage.createTmpDir();
await cloneFromGit(installMetadata.source, tempDir);
localSourcePath = tempDir;
} else {
} else if (
installMetadata.type === 'local' ||
installMetadata.type === 'link'
) {
localSourcePath = installMetadata.source;
} else {
throw new Error(`Unsupported install type: ${installMetadata.type}`);
}
let newExtensionName: string | undefined;
try {
const newExtension = loadExtension(localSourcePath);
if (!newExtension) {
const newExtensionConfig = await loadExtensionConfig(localSourcePath);
if (!newExtensionConfig) {
throw new Error(
`Invalid extension at ${installMetadata.source}. Please make sure it has a valid gemini-extension.json file.`,
);
}
// ~/.gemini/extensions/{ExtensionConfig.name}.
newExtensionName = newExtension.config.name;
newExtensionName = newExtensionConfig.name;
const extensionStorage = new ExtensionStorage(newExtensionName);
const destinationPath = extensionStorage.getExtensionDir();
@@ -383,7 +401,11 @@ export async function installExtension(
);
}
await copyExtension(localSourcePath, destinationPath);
await fs.promises.mkdir(destinationPath, { recursive: true });
if (installMetadata.type === 'local' || installMetadata.type === 'git') {
await copyExtension(localSourcePath, destinationPath);
}
const metadataString = JSON.stringify(installMetadata, null, 2);
const metadataPath = path.join(destinationPath, INSTALL_METADATA_FILENAME);
@@ -397,6 +419,29 @@ export async function installExtension(
return newExtensionName;
}
async function loadExtensionConfig(
extensionDir: string,
): Promise<ExtensionConfig | null> {
const configFilePath = path.join(extensionDir, EXTENSIONS_CONFIG_FILENAME);
if (!fs.existsSync(configFilePath)) {
return null;
}
try {
const configContent = fs.readFileSync(configFilePath, 'utf-8');
const config = recursivelyHydrateStrings(JSON.parse(configContent), {
extensionPath: extensionDir,
'/': path.sep,
pathSeparator: path.sep,
}) as unknown as ExtensionConfig;
if (!config.name || !config.version) {
return null;
}
return config;
} catch (_) {
return null;
}
}
export async function uninstallExtension(
extensionName: string,
cwd: string = process.cwd(),
@@ -425,7 +470,7 @@ export function toOutputString(extension: Extension): string {
let output = `${extension.config.name} (${extension.config.version})`;
output += `\n Path: ${extension.path}`;
if (extension.installMetadata) {
output += `\n Source: ${extension.installMetadata.source}`;
output += `\n Source: ${extension.installMetadata.source} (Type: ${extension.installMetadata.type})`;
}
if (extension.contextFiles.length > 0) {
output += `\n Context files:`;
@@ -471,14 +516,21 @@ export async function updateExtension(
if (!extension.installMetadata) {
throw new Error(`Extension ${extension.config.name} cannot be updated.`);
}
if (extension.installMetadata.type === 'link') {
throw new Error(`Extension is linked so does not need to be updated`);
}
const originalVersion = extension.config.version;
const tempDir = await ExtensionStorage.createTmpDir();
try {
await copyExtension(extension.path, tempDir);
await uninstallExtension(extension.config.name, cwd);
await installExtension(extension.installMetadata, cwd);
const updatedExtension = loadExtension(extension.path);
const updatedExtensionStorage = new ExtensionStorage(extension.config.name);
const updatedExtension = loadExtension(
updatedExtensionStorage.getExtensionDir(),
);
if (!updatedExtension) {
throw new Error('Updated extension not found after installation.');
}