Download packages/cli/src/config/extension-manager.ts from SaylorTwift/gemini-cli: direct link, hf CLI and curl.
- Browser
- Download file 48.5 kB
-
https://huggingface.co/SaylorTwift/gemini-cli/resolve/main/packages/cli/src/config/extension-manager.ts
- Command line
-
hf download hf://SaylorTwift/gemini-cli/packages/cli/src/config/extension-manager.ts
-
curl -L -o extension-manager.ts https://huggingface.co/SaylorTwift/gemini-cli/resolve/main/packages/cli/src/config/extension-manager.ts
48.5 kB
| /** | |
| * @license | |
| * Copyright 2025 Google LLC | |
| * SPDX-License-Identifier: Apache-2.0 | |
| */ | |
| import * as fs from 'node:fs'; | |
| import * as path from 'node:path'; | |
| import { stat } from 'node:fs/promises'; | |
| import chalk from 'chalk'; | |
| import { ExtensionEnablementManager } from './extensions/extensionEnablement.js'; | |
| import { type MergedSettings, SettingScope } from './settings.js'; | |
| import { createHash, randomUUID } from 'node:crypto'; | |
| import { loadInstallMetadata, type ExtensionConfig } from './extension.js'; | |
| import { | |
| isWorkspaceTrusted, | |
| loadTrustedFolders, | |
| TrustLevel, | |
| } from './trustedFolders.js'; | |
| import { | |
| cloneFromGit, | |
| downloadFromGitHubRelease, | |
| tryParseGithubUrl, | |
| } from './extensions/github.js'; | |
| import { | |
| Config, | |
| debugLogger, | |
| ExtensionDisableEvent, | |
| ExtensionEnableEvent, | |
| ExtensionInstallEvent, | |
| ExtensionLoader, | |
| ExtensionUninstallEvent, | |
| ExtensionUpdateEvent, | |
| getErrorMessage, | |
| getRealPath, | |
| logExtensionDisable, | |
| logExtensionEnable, | |
| logExtensionInstallEvent, | |
| logExtensionUninstall, | |
| logExtensionUpdateEvent, | |
| loadSkillsFromDir, | |
| loadAgentsFromDirectory, | |
| homedir, | |
| ExtensionIntegrityManager, | |
| type IExtensionIntegrity, | |
| type IntegrityDataStatus, | |
| type ExtensionEvents, | |
| type MCPServerConfig, | |
| type ExtensionInstallMetadata, | |
| type GeminiCLIExtension, | |
| type HookDefinition, | |
| type HookEventName, | |
| type ResolvedExtensionSetting, | |
| coreEvents, | |
| applyAdminAllowlist, | |
| getAdminBlockedMcpServersMessage, | |
| CoreToolCallStatus, | |
| loadExtensionPolicies, | |
| isSubpath, | |
| type PolicyRule, | |
| type SafetyCheckerRule, | |
| HookType, | |
| } from '@google/gemini-cli-core'; | |
| import { maybeRequestConsentOrFail } from './extensions/consent.js'; | |
| import { resolveEnvVarsInObject } from '../utils/envVarResolver.js'; | |
| import { ExtensionStorage } from './extensions/storage.js'; | |
| import { | |
| EXTENSIONS_CONFIG_FILENAME, | |
| INSTALL_METADATA_FILENAME, | |
| recursivelyHydrateStrings, | |
| type JsonObject, | |
| type VariableContext, | |
| } from './extensions/variables.js'; | |
| import { | |
| getEnvContents, | |
| getEnvFilePath, | |
| maybePromptForSettings, | |
| getMissingSettings, | |
| type ExtensionSetting, | |
| getScopedEnvContents, | |
| ExtensionSettingScope, | |
| } from './extensions/extensionSettings.js'; | |
| import type { EventEmitter } from 'node:stream'; | |
| import { themeManager } from '../ui/themes/theme-manager.js'; | |
| import { getFormattedSettingValue } from '../commands/extensions/utils.js'; | |
| interface ExtensionManagerParams { | |
| enabledExtensionOverrides?: string[]; | |
| settings: MergedSettings; | |
| requestConsent: (consent: string) => Promise<boolean>; | |
| requestSetting: | |
| | ((setting: ExtensionSetting) => Promise<string | undefined>) | |
| | null; | |
| workspaceDir: string; | |
| eventEmitter?: EventEmitter<ExtensionEvents>; | |
| clientVersion?: string; | |
| integrityManager?: IExtensionIntegrity; | |
| } | |
| /** | |
| * Actual implementation of an ExtensionLoader. | |
| * | |
| * You must call `loadExtensions` prior to calling other methods on this class. | |
| */ | |
| export class ExtensionManager extends ExtensionLoader { | |
| private extensionEnablementManager: ExtensionEnablementManager; | |
| private integrityManager: IExtensionIntegrity; | |
| private settings: MergedSettings; | |
| private requestConsent: (consent: string) => Promise<boolean>; | |
| private requestSetting: | |
| | ((setting: ExtensionSetting) => Promise<string | undefined>) | |
| | undefined; | |
| private telemetryConfig: Config; | |
| private workspaceDir: string; | |
| private loadedExtensions: GeminiCLIExtension[] | undefined; | |
| private loadingPromise: Promise<GeminiCLIExtension[]> | null = null; | |
| constructor(options: ExtensionManagerParams) { | |
| super(options.eventEmitter); | |
| this.workspaceDir = options.workspaceDir; | |
| this.extensionEnablementManager = new ExtensionEnablementManager( | |
| options.enabledExtensionOverrides, | |
| ); | |
| this.settings = options.settings; | |
| this.telemetryConfig = new Config({ | |
| telemetry: options.settings.telemetry, | |
| interactive: false, | |
| sessionId: randomUUID(), | |
| clientVersion: options.clientVersion ?? 'unknown', | |
| targetDir: options.workspaceDir, | |
| cwd: options.workspaceDir, | |
| model: '', | |
| debugMode: false, | |
| }); | |
| this.requestConsent = options.requestConsent; | |
| this.requestSetting = options.requestSetting ?? undefined; | |
| this.integrityManager = | |
| options.integrityManager ?? new ExtensionIntegrityManager(); | |
| } | |
| getEnablementManager(): ExtensionEnablementManager { | |
| return this.extensionEnablementManager; | |
| } | |
| async verifyExtensionIntegrity( | |
| extensionName: string, | |
| metadata: ExtensionInstallMetadata | undefined, | |
| ): Promise<IntegrityDataStatus> { | |
| return this.integrityManager.verify(extensionName, metadata); | |
| } | |
| async storeExtensionIntegrity( | |
| extensionName: string, | |
| metadata: ExtensionInstallMetadata, | |
| ): Promise<void> { | |
| return this.integrityManager.store(extensionName, metadata); | |
| } | |
| setRequestConsent( | |
| requestConsent: (consent: string) => Promise<boolean>, | |
| ): void { | |
| this.requestConsent = requestConsent; | |
| } | |
| setRequestSetting( | |
| requestSetting?: (setting: ExtensionSetting) => Promise<string | undefined>, | |
| ): void { | |
| this.requestSetting = requestSetting; | |
| } | |
| getExtensions(): GeminiCLIExtension[] { | |
| if (!this.loadedExtensions) { | |
| throw new Error( | |
| 'Extensions not yet loaded, must call `loadExtensions` first', | |
| ); | |
| } | |
| return this.loadedExtensions; | |
| } | |
| async installOrUpdateExtension( | |
| installMetadata: ExtensionInstallMetadata, | |
| previousExtensionConfig?: ExtensionConfig, | |
| requestConsentOverride?: (consent: string) => Promise<boolean>, | |
| ): Promise<GeminiCLIExtension> { | |
| if ((this.settings.security?.allowedExtensions?.length ?? 0) > 0) { | |
| const extensionAllowed = this.settings.security?.allowedExtensions.some( | |
| (pattern) => { | |
| try { | |
| return new RegExp(pattern).test( | |
| getRealPath(installMetadata.source), | |
| ); | |
| } catch (e) { | |
| throw new Error( | |
| `Invalid regex pattern in allowedExtensions setting: "${pattern}. Error: ${getErrorMessage(e)}`, | |
| ); | |
| } | |
| }, | |
| ); | |
| if (!extensionAllowed) { | |
| throw new Error( | |
| `Installing extension from source "${installMetadata.source}" is not allowed by the "allowedExtensions" security setting.`, | |
| ); | |
| } | |
| } else if ( | |
| (installMetadata.type === 'git' || | |
| installMetadata.type === 'github-release') && | |
| this.settings.security.blockGitExtensions | |
| ) { | |
| throw new Error( | |
| 'Installing extensions from remote sources is disallowed by your current settings.', | |
| ); | |
| } | |
| const isUpdate = !!previousExtensionConfig; | |
| let newExtensionConfig: ExtensionConfig | null = null; | |
| let localSourcePath: string | undefined; | |
| let extension: GeminiCLIExtension | null; | |
| try { | |
| if (!isWorkspaceTrusted(this.settings).isTrusted) { | |
| if ( | |
| await this.requestConsent( | |
| `The current workspace at "${this.workspaceDir}" is not trusted. Do you want to trust this workspace to install extensions?`, | |
| ) | |
| ) { | |
| const trustedFolders = loadTrustedFolders(); | |
| await trustedFolders.setValue( | |
| this.workspaceDir, | |
| TrustLevel.TRUST_FOLDER, | |
| ); | |
| } else { | |
| throw new Error( | |
| `Could not install extension because the current workspace at ${this.workspaceDir} is not trusted.`, | |
| ); | |
| } | |
| } | |
| const extensionsDir = ExtensionStorage.getUserExtensionsDir(); | |
| await fs.promises.mkdir(extensionsDir, { recursive: true }); | |
| if (installMetadata.type === 'local' || installMetadata.type === 'link') { | |
| installMetadata.source = path.isAbsolute(installMetadata.source) | |
| ? installMetadata.source | |
| : path.resolve(this.workspaceDir, installMetadata.source); | |
| } | |
| let tempDir: string | undefined; | |
| if ( | |
| installMetadata.type === 'git' || | |
| installMetadata.type === 'github-release' | |
| ) { | |
| tempDir = await ExtensionStorage.createTmpDir(); | |
| const parsedGithubParts = tryParseGithubUrl(installMetadata.source); | |
| if (!parsedGithubParts) { | |
| await cloneFromGit(installMetadata, tempDir); | |
| installMetadata.type = 'git'; | |
| } else { | |
| 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 (requestConsentOverride ?? this.requestConsent)( | |
| `Error downloading github release for ${installMetadata.source} with the following error: ${result.errorMessage}. | |
| Would 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 ( | |
| installMetadata.type === 'local' || | |
| installMetadata.type === 'link' | |
| ) { | |
| localSourcePath = getRealPath(installMetadata.source); | |
| } else { | |
| throw new Error(`Unsupported install type: ${installMetadata.type}`); | |
| } | |
| try { | |
| newExtensionConfig = await this.loadExtensionConfig(localSourcePath); | |
| const newExtensionName = newExtensionConfig.name; | |
| const previousName = previousExtensionConfig?.name ?? newExtensionName; | |
| const previous = this.getExtensions().find( | |
| (installed) => installed.name === previousName, | |
| ); | |
| const nameConflict = this.getExtensions().find( | |
| (installed) => | |
| installed.name === newExtensionName && | |
| installed.name !== previousName, | |
| ); | |
| if (isUpdate && !previous) { | |
| throw new Error( | |
| `Extension "${previousName}" was not already installed, cannot update it.`, | |
| ); | |
| } else if (!isUpdate && previous) { | |
| throw new Error( | |
| `Extension "${newExtensionName}" is already installed. Please uninstall it first.`, | |
| ); | |
| } else if (isUpdate && nameConflict) { | |
| throw new Error( | |
| `Cannot update to "${newExtensionName}" because an extension with that name is already installed.`, | |
| ); | |
| } | |
| const newHasHooks = fs.existsSync( | |
| path.join(localSourcePath, 'hooks', 'hooks.json'), | |
| ); | |
| const previousHasHooks = !!( | |
| isUpdate && | |
| previous && | |
| previous.hooks && | |
| Object.keys(previous.hooks).length > 0 | |
| ); | |
| const newSkills = await loadSkillsFromDir( | |
| path.join(localSourcePath, 'skills'), | |
| ); | |
| const previousSkills = previous?.skills ?? []; | |
| const isMigrating = Boolean( | |
| previous && | |
| previous.installMetadata && | |
| previous.installMetadata.source !== installMetadata.source, | |
| ); | |
| await maybeRequestConsentOrFail( | |
| newExtensionConfig, | |
| requestConsentOverride ?? this.requestConsent, | |
| newHasHooks, | |
| previousExtensionConfig, | |
| previousHasHooks, | |
| newSkills, | |
| previousSkills, | |
| isMigrating, | |
| ); | |
| const extensionId = getExtensionId(newExtensionConfig, installMetadata); | |
| const destinationPath = new ExtensionStorage( | |
| newExtensionName, | |
| ).getExtensionDir(); | |
| if ( | |
| (!isUpdate || newExtensionName !== previousName) && | |
| fs.existsSync(destinationPath) | |
| ) { | |
| throw new Error( | |
| `Cannot install extension "${newExtensionName}" because a directory with that name already exists. Please remove it manually.`, | |
| ); | |
| } | |
| let previousSettings: Record<string, string> | undefined; | |
| let wasEnabledGlobally = false; | |
| let wasEnabledWorkspace = false; | |
| if (isUpdate && previousExtensionConfig) { | |
| const previousExtensionId = previous?.installMetadata | |
| ? getExtensionId(previousExtensionConfig, previous.installMetadata) | |
| : extensionId; | |
| previousSettings = await getEnvContents( | |
| previousExtensionConfig, | |
| previousExtensionId, | |
| this.workspaceDir, | |
| ); | |
| if (newExtensionName !== previousName) { | |
| wasEnabledGlobally = this.extensionEnablementManager.isEnabled( | |
| previousName, | |
| homedir(), | |
| ); | |
| wasEnabledWorkspace = this.extensionEnablementManager.isEnabled( | |
| previousName, | |
| this.workspaceDir, | |
| ); | |
| this.extensionEnablementManager.remove(previousName); | |
| } | |
| await this.uninstallExtension(previousName, isUpdate); | |
| } | |
| await fs.promises.mkdir(destinationPath, { recursive: true }); | |
| if (this.requestSetting && this.settings.experimental.extensionConfig) { | |
| if (isUpdate) { | |
| await maybePromptForSettings( | |
| newExtensionConfig, | |
| extensionId, | |
| this.requestSetting, | |
| previousExtensionConfig, | |
| previousSettings, | |
| ); | |
| } else { | |
| await maybePromptForSettings( | |
| newExtensionConfig, | |
| extensionId, | |
| this.requestSetting, | |
| ); | |
| } | |
| } | |
| const missingSettings = this.settings.experimental.extensionConfig | |
| ? await getMissingSettings( | |
| newExtensionConfig, | |
| extensionId, | |
| this.workspaceDir, | |
| ) | |
| : []; | |
| if (missingSettings.length > 0) { | |
| const message = `Extension "${newExtensionConfig.name}" has missing settings: ${missingSettings | |
| .map((s) => s.name) | |
| .join( | |
| ', ', | |
| )}. Please run "gemini extensions config ${newExtensionConfig.name} [setting-name]" to configure them.`; | |
| debugLogger.warn(message); | |
| coreEvents.emitFeedback('warning', message); | |
| } | |
| if ( | |
| installMetadata.type === 'local' || | |
| installMetadata.type === 'git' || | |
| installMetadata.type === 'github-release' | |
| ) { | |
| await copyExtension(localSourcePath, destinationPath); | |
| } | |
| const metadataString = JSON.stringify(installMetadata, null, 2); | |
| const metadataPath = path.join( | |
| destinationPath, | |
| INSTALL_METADATA_FILENAME, | |
| ); | |
| await fs.promises.writeFile(metadataPath, metadataString); | |
| // Establish trust at point of installation | |
| await this.storeExtensionIntegrity( | |
| newExtensionConfig.name, | |
| installMetadata, | |
| ); | |
| // TODO: Gracefully handle this call failing, we should back up the old | |
| // extension prior to overwriting it and then restore and restart it. | |
| extension = await this.loadExtension(destinationPath); | |
| if (!extension) { | |
| throw new Error(`Extension not found`); | |
| } | |
| if (isUpdate) { | |
| await logExtensionUpdateEvent( | |
| this.telemetryConfig, | |
| new ExtensionUpdateEvent( | |
| newExtensionConfig.name, | |
| hashValue(newExtensionConfig.name), | |
| getExtensionId(newExtensionConfig, installMetadata), | |
| newExtensionConfig.version, | |
| previousExtensionConfig.version, | |
| installMetadata.type, | |
| CoreToolCallStatus.Success, | |
| ), | |
| ); | |
| if (newExtensionName !== previousName) { | |
| if (wasEnabledGlobally) { | |
| await this.enableExtension(newExtensionName, SettingScope.User); | |
| } | |
| if (wasEnabledWorkspace) { | |
| await this.enableExtension( | |
| newExtensionName, | |
| SettingScope.Workspace, | |
| ); | |
| } | |
| } | |
| } else { | |
| await logExtensionInstallEvent( | |
| this.telemetryConfig, | |
| new ExtensionInstallEvent( | |
| newExtensionConfig.name, | |
| hashValue(newExtensionConfig.name), | |
| getExtensionId(newExtensionConfig, installMetadata), | |
| newExtensionConfig.version, | |
| installMetadata.type, | |
| CoreToolCallStatus.Success, | |
| ), | |
| ); | |
| await this.enableExtension( | |
| newExtensionConfig.name, | |
| SettingScope.User, | |
| ); | |
| } | |
| } finally { | |
| if (tempDir) { | |
| await fs.promises.rm(tempDir, { recursive: true, force: true }); | |
| } | |
| } | |
| return extension; | |
| } catch (error) { | |
| // Attempt to load config from the source path even if installation fails | |
| // to get the name and version for logging. | |
| if (!newExtensionConfig && localSourcePath) { | |
| try { | |
| newExtensionConfig = await this.loadExtensionConfig(localSourcePath); | |
| } catch { | |
| // Ignore error, this is just for logging. | |
| } | |
| } | |
| const config = newExtensionConfig ?? previousExtensionConfig; | |
| const extensionId = config | |
| ? getExtensionId(config, installMetadata) | |
| : undefined; | |
| if (isUpdate) { | |
| await logExtensionUpdateEvent( | |
| this.telemetryConfig, | |
| new ExtensionUpdateEvent( | |
| config?.name ?? '', | |
| hashValue(config?.name ?? ''), | |
| extensionId ?? '', | |
| newExtensionConfig?.version ?? '', | |
| previousExtensionConfig.version, | |
| installMetadata.type, | |
| CoreToolCallStatus.Error, | |
| ), | |
| ); | |
| } else { | |
| await logExtensionInstallEvent( | |
| this.telemetryConfig, | |
| new ExtensionInstallEvent( | |
| newExtensionConfig?.name ?? '', | |
| hashValue(newExtensionConfig?.name ?? ''), | |
| extensionId ?? '', | |
| newExtensionConfig?.version ?? '', | |
| installMetadata.type, | |
| CoreToolCallStatus.Error, | |
| ), | |
| ); | |
| } | |
| throw error; | |
| } | |
| } | |
| async uninstallExtension( | |
| extensionIdentifier: string, | |
| isUpdate: boolean, | |
| ): Promise<void> { | |
| const installedExtensions = this.getExtensions(); | |
| const extension = installedExtensions.find( | |
| (installed) => | |
| installed.name.toLowerCase() === extensionIdentifier.toLowerCase() || | |
| installed.installMetadata?.source.toLowerCase() === | |
| extensionIdentifier.toLowerCase(), | |
| ); | |
| if (!extension) { | |
| throw new Error(`Extension not found.`); | |
| } | |
| await this.unloadExtension(extension); | |
| const storage = new ExtensionStorage( | |
| extension.installMetadata?.type === 'link' | |
| ? extension.name | |
| : path.basename(extension.path), | |
| ); | |
| await fs.promises.rm(storage.getExtensionDir(), { | |
| recursive: true, | |
| force: true, | |
| }); | |
| // The rest of the cleanup below here is only for true uninstalls, not | |
| // uninstalls related to updates. | |
| if (isUpdate) return; | |
| this.extensionEnablementManager.remove(extension.name); | |
| await logExtensionUninstall( | |
| this.telemetryConfig, | |
| new ExtensionUninstallEvent( | |
| extension.name, | |
| hashValue(extension.name), | |
| extension.id, | |
| CoreToolCallStatus.Success, | |
| ), | |
| ); | |
| } | |
| protected override async startExtension(extension: GeminiCLIExtension) { | |
| await super.startExtension(extension); | |
| if (extension.themes && !themeManager.hasExtensionThemes(extension.name)) { | |
| themeManager.registerExtensionThemes(extension.name, extension.themes); | |
| } | |
| } | |
| protected override async stopExtension(extension: GeminiCLIExtension) { | |
| await super.stopExtension(extension); | |
| if (extension.themes) { | |
| themeManager.unregisterExtensionThemes(extension.name, extension.themes); | |
| } | |
| } | |
| /** | |
| * Loads all installed extensions, should only be called once. | |
| */ | |
| async loadExtensions(): Promise<GeminiCLIExtension[]> { | |
| if (this.loadedExtensions) { | |
| throw new Error('Extensions already loaded, only load extensions once.'); | |
| } | |
| if (this.loadingPromise) { | |
| return this.loadingPromise; | |
| } | |
| this.loadingPromise = (async () => { | |
| try { | |
| if (this.settings.admin?.extensions?.enabled === false) { | |
| this.loadedExtensions = []; | |
| return this.loadedExtensions; | |
| } | |
| const extensionsDir = ExtensionStorage.getUserExtensionsDir(); | |
| if (!fs.existsSync(extensionsDir)) { | |
| this.loadedExtensions = []; | |
| return this.loadedExtensions; | |
| } | |
| const subdirs = await fs.promises.readdir(extensionsDir); | |
| const extensionPromises = subdirs.map((subdir) => { | |
| const extensionDir = path.join(extensionsDir, subdir); | |
| return this._buildExtension(extensionDir); | |
| }); | |
| const builtExtensionsOrNull = await Promise.all(extensionPromises); | |
| const builtExtensions = builtExtensionsOrNull.filter( | |
| (ext): ext is GeminiCLIExtension => ext !== null, | |
| ); | |
| const seenNames = new Set<string>(); | |
| for (const ext of builtExtensions) { | |
| if (seenNames.has(ext.name)) { | |
| throw new Error( | |
| `Extension with name ${ext.name} already was loaded.`, | |
| ); | |
| } | |
| seenNames.add(ext.name); | |
| } | |
| this.loadedExtensions = builtExtensions; | |
| // Register extension themes early so they're available at startup. | |
| for (const ext of this.loadedExtensions) { | |
| if (ext.isActive && ext.themes) { | |
| themeManager.registerExtensionThemes(ext.name, ext.themes); | |
| } | |
| } | |
| await Promise.all( | |
| this.loadedExtensions.map((ext) => this.maybeStartExtension(ext)), | |
| ); | |
| return this.loadedExtensions; | |
| } finally { | |
| this.loadingPromise = null; | |
| } | |
| })(); | |
| return this.loadingPromise; | |
| } | |
| /** | |
| * Adds `extension` to the list of extensions and starts it if appropriate. | |
| * | |
| * @internal visible for testing only | |
| */ | |
| async loadExtension( | |
| extensionDir: string, | |
| ): Promise<GeminiCLIExtension | null> { | |
| if (this.loadingPromise) { | |
| await this.loadingPromise; | |
| } | |
| this.loadedExtensions ??= []; | |
| const extension = await this._buildExtension(extensionDir); | |
| if (!extension) { | |
| return null; | |
| } | |
| if ( | |
| this.getExtensions().find( | |
| (installed) => installed.name === extension.name, | |
| ) | |
| ) { | |
| throw new Error( | |
| `Extension with name ${extension.name} already was loaded.`, | |
| ); | |
| } | |
| this.loadedExtensions = [...this.loadedExtensions, extension]; | |
| await this.maybeStartExtension(extension); | |
| return extension; | |
| } | |
| /** | |
| * Builds an extension without side effects (does not mutate loadedExtensions or start it). | |
| */ | |
| private async _buildExtension( | |
| extensionDir: string, | |
| ): Promise<GeminiCLIExtension | null> { | |
| try { | |
| const stats = await fs.promises.stat(extensionDir); | |
| if (!stats.isDirectory()) { | |
| return null; | |
| } | |
| } catch { | |
| return null; | |
| } | |
| const installMetadata = loadInstallMetadata(extensionDir); | |
| let effectiveExtensionPath = extensionDir; | |
| if ((this.settings.security?.allowedExtensions?.length ?? 0) > 0) { | |
| if (!installMetadata?.source) { | |
| throw new Error( | |
| `Failed to load extension ${extensionDir}. The ${INSTALL_METADATA_FILENAME} file is missing or misconfigured.`, | |
| ); | |
| } | |
| const extensionAllowed = this.settings.security?.allowedExtensions.some( | |
| (pattern) => { | |
| try { | |
| return new RegExp(pattern).test( | |
| getRealPath(installMetadata?.source ?? ''), | |
| ); | |
| } catch (e) { | |
| throw new Error( | |
| `Invalid regex pattern in allowedExtensions setting: "${pattern}. Error: ${getErrorMessage(e)}`, | |
| ); | |
| } | |
| }, | |
| ); | |
| if (!extensionAllowed) { | |
| debugLogger.warn( | |
| `Failed to load extension ${extensionDir}. This extension is not allowed by the "allowedExtensions" security setting.`, | |
| ); | |
| return null; | |
| } | |
| } else if ( | |
| (installMetadata?.type === 'git' || | |
| installMetadata?.type === 'github-release') && | |
| this.settings.security.blockGitExtensions | |
| ) { | |
| debugLogger.warn( | |
| `Failed to load extension ${extensionDir}. Extensions from remote sources is disallowed by your current settings.`, | |
| ); | |
| return null; | |
| } | |
| if (installMetadata?.type === 'link') { | |
| effectiveExtensionPath = installMetadata.source; | |
| } | |
| try { | |
| let config = await this.loadExtensionConfig(effectiveExtensionPath); | |
| const extensionId = getExtensionId(config, installMetadata); | |
| let userSettings: Record<string, string> = {}; | |
| let workspaceSettings: Record<string, string> = {}; | |
| if (this.settings.experimental.extensionConfig) { | |
| userSettings = await getScopedEnvContents( | |
| config, | |
| extensionId, | |
| ExtensionSettingScope.USER, | |
| ); | |
| if (isWorkspaceTrusted(this.settings).isTrusted) { | |
| workspaceSettings = await getScopedEnvContents( | |
| config, | |
| extensionId, | |
| ExtensionSettingScope.WORKSPACE, | |
| this.workspaceDir, | |
| ); | |
| } | |
| } | |
| const customEnv = { ...userSettings, ...workspaceSettings }; | |
| config = resolveEnvVarsInObject(config, customEnv); | |
| const resolvedSettings: ResolvedExtensionSetting[] = []; | |
| if (config.settings && this.settings.experimental.extensionConfig) { | |
| for (const setting of config.settings) { | |
| const value = customEnv[setting.envVar]; | |
| let scope: 'user' | 'workspace' | undefined; | |
| let source: string | undefined; | |
| // Note: strict check for undefined, as empty string is a valid value | |
| if (workspaceSettings[setting.envVar] !== undefined) { | |
| scope = 'workspace'; | |
| if (setting.sensitive) { | |
| source = 'Keychain'; | |
| } else { | |
| source = getEnvFilePath( | |
| config.name, | |
| ExtensionSettingScope.WORKSPACE, | |
| this.workspaceDir, | |
| ); | |
| } | |
| } else if (userSettings[setting.envVar] !== undefined) { | |
| scope = 'user'; | |
| if (setting.sensitive) { | |
| source = 'Keychain'; | |
| } else { | |
| source = getEnvFilePath(config.name, ExtensionSettingScope.USER); | |
| } | |
| } | |
| resolvedSettings.push({ | |
| name: setting.name, | |
| envVar: setting.envVar, | |
| value, | |
| sensitive: setting.sensitive ?? false, | |
| scope, | |
| source, | |
| }); | |
| } | |
| } | |
| if (config.mcpServers) { | |
| if (this.settings.admin?.mcp?.enabled === false) { | |
| config.mcpServers = undefined; | |
| } else { | |
| // Apply admin allowlist if configured | |
| const adminAllowlist = this.settings.admin?.mcp?.config; | |
| if (adminAllowlist && Object.keys(adminAllowlist).length > 0) { | |
| const result = applyAdminAllowlist( | |
| config.mcpServers, | |
| adminAllowlist, | |
| ); | |
| config.mcpServers = result.mcpServers; | |
| if (result.blockedServerNames.length > 0) { | |
| const message = getAdminBlockedMcpServersMessage( | |
| result.blockedServerNames, | |
| undefined, | |
| ); | |
| coreEvents.emitConsoleLog('warn', message); | |
| } | |
| } | |
| // Then apply local filtering/sanitization | |
| if (config.mcpServers) { | |
| config.mcpServers = Object.fromEntries( | |
| Object.entries(config.mcpServers).map(([key, value]) => [ | |
| key, | |
| filterMcpConfig(value), | |
| ]), | |
| ); | |
| } | |
| } | |
| } | |
| const contextFiles = resolveContextFilePaths( | |
| config, | |
| effectiveExtensionPath, | |
| ); | |
| const hydrationContext: VariableContext = { | |
| extensionPath: effectiveExtensionPath, | |
| workspacePath: this.workspaceDir, | |
| '/': path.sep, | |
| pathSeparator: path.sep, | |
| ...customEnv, | |
| }; | |
| let hooks: { [K in HookEventName]?: HookDefinition[] } | undefined; | |
| if (this.settings.hooksConfig.enabled) { | |
| hooks = await this.loadExtensionHooks( | |
| effectiveExtensionPath, | |
| hydrationContext, | |
| ); | |
| } | |
| // Hydrate hooks with extension settings as environment variables | |
| if (hooks && config.settings) { | |
| const hookEnv: Record<string, string> = {}; | |
| for (const setting of config.settings) { | |
| const value = customEnv[setting.envVar]; | |
| if (value !== undefined) { | |
| hookEnv[setting.envVar] = value; | |
| } | |
| } | |
| if (Object.keys(hookEnv).length > 0) { | |
| for (const eventName of Object.keys(hooks)) { | |
| // eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion | |
| const eventHooks = hooks[eventName as HookEventName]; | |
| if (eventHooks) { | |
| for (const definition of eventHooks) { | |
| for (const hook of definition.hooks) { | |
| if (hook.type === HookType.Command) { | |
| // Merge existing env with new env vars, giving extension settings precedence. | |
| hook.env = { ...hook.env, ...hookEnv }; | |
| } | |
| } | |
| } | |
| } | |
| } | |
| } | |
| } | |
| let skills = await loadSkillsFromDir( | |
| path.join(effectiveExtensionPath, 'skills'), | |
| ); | |
| skills = skills.map((skill) => ({ | |
| ...recursivelyHydrateStrings(skill, hydrationContext), | |
| extensionName: config.name, | |
| })); | |
| let rules: PolicyRule[] | undefined; | |
| let checkers: SafetyCheckerRule[] | undefined; | |
| const policyDir = path.join(effectiveExtensionPath, 'policies'); | |
| if (fs.existsSync(policyDir)) { | |
| const result = await loadExtensionPolicies(config.name, policyDir); | |
| rules = result.rules; | |
| checkers = result.checkers; | |
| if (result.errors.length > 0) { | |
| for (const error of result.errors) { | |
| debugLogger.warn( | |
| `[ExtensionManager] Error loading policies from ${config.name}: ${error.message}${error.details ? `\nDetails: ${error.details}` : ''}`, | |
| ); | |
| } | |
| } | |
| } | |
| const agentLoadResult = await loadAgentsFromDirectory( | |
| path.join(effectiveExtensionPath, 'agents'), | |
| ); | |
| agentLoadResult.agents = agentLoadResult.agents.map((agent) => ({ | |
| ...recursivelyHydrateStrings(agent, hydrationContext), | |
| extensionName: config.name, | |
| })); | |
| // Log errors but don't fail the entire extension load | |
| for (const error of agentLoadResult.errors) { | |
| debugLogger.warn( | |
| `[ExtensionManager] Error loading agent from ${config.name}: ${error.message}`, | |
| ); | |
| } | |
| let plan = config.plan; | |
| if (plan?.directory) { | |
| if ( | |
| typeof plan.directory !== 'string' || | |
| path.isAbsolute(plan.directory) || | |
| path.win32.isAbsolute(plan.directory) || | |
| plan.directory.includes('..') | |
| ) { | |
| debugLogger.warn( | |
| `[ExtensionManager] Invalid plan.directory in extension "${config.name}": "${plan.directory}". Relative parent navigation and absolute paths are not allowed.`, | |
| ); | |
| plan = undefined; | |
| } | |
| } | |
| return { | |
| name: config.name, | |
| version: config.version, | |
| path: effectiveExtensionPath, | |
| contextFiles, | |
| installMetadata, | |
| migratedTo: config.migratedTo, | |
| mcpServers: config.mcpServers, | |
| excludeTools: config.excludeTools, | |
| hooks, | |
| isActive: this.extensionEnablementManager.isEnabled( | |
| config.name, | |
| this.workspaceDir, | |
| ), | |
| id: getExtensionId(config, installMetadata), | |
| settings: config.settings, | |
| resolvedSettings, | |
| skills, | |
| agents: agentLoadResult.agents, | |
| themes: config.themes, | |
| rules, | |
| checkers, | |
| plan, | |
| }; | |
| } catch (e) { | |
| debugLogger.error( | |
| `Warning: Skipping extension in ${effectiveExtensionPath}: ${getErrorMessage( | |
| e, | |
| )}`, | |
| ); | |
| return null; | |
| } | |
| } | |
| override async restartExtension( | |
| extension: GeminiCLIExtension, | |
| ): Promise<void> { | |
| const extensionDir = extension.path; | |
| await this.unloadExtension(extension); | |
| await this.loadExtension(extensionDir); | |
| } | |
| /** | |
| * Removes `extension` from the list of extensions and stops it if | |
| * appropriate. | |
| */ | |
| private unloadExtension( | |
| extension: GeminiCLIExtension, | |
| ): Promise<void> | undefined { | |
| this.loadedExtensions = this.getExtensions().filter( | |
| (entry) => extension !== entry, | |
| ); | |
| return this.maybeStopExtension(extension); | |
| } | |
| async loadExtensionConfig(extensionDir: string): Promise<ExtensionConfig> { | |
| const configFilePath = path.join(extensionDir, EXTENSIONS_CONFIG_FILENAME); | |
| if (!fs.existsSync(configFilePath)) { | |
| throw new Error(`Configuration file not found at ${configFilePath}`); | |
| } | |
| try { | |
| const configContent = await fs.promises.readFile(configFilePath, 'utf-8'); | |
| // eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion | |
| const rawConfig = JSON.parse(configContent) as ExtensionConfig; | |
| if (!rawConfig.name || !rawConfig.version) { | |
| throw new Error( | |
| `Invalid configuration in ${configFilePath}: missing ${!rawConfig.name ? '"name"' : '"version"'}`, | |
| ); | |
| } | |
| // eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion | |
| const config = recursivelyHydrateStrings( | |
| // eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion | |
| rawConfig as unknown as JsonObject, | |
| { | |
| extensionPath: extensionDir, | |
| workspacePath: this.workspaceDir, | |
| '/': path.sep, | |
| pathSeparator: path.sep, | |
| }, | |
| ) as unknown as ExtensionConfig; | |
| validateName(config.name); | |
| return config; | |
| } catch (e) { | |
| throw new Error( | |
| `Failed to load extension config from ${configFilePath}: ${getErrorMessage( | |
| e, | |
| )}`, | |
| ); | |
| } | |
| } | |
| private async loadExtensionHooks( | |
| extensionDir: string, | |
| context: VariableContext, | |
| ): Promise<{ [K in HookEventName]?: HookDefinition[] } | undefined> { | |
| const hooksFilePath = path.join(extensionDir, 'hooks', 'hooks.json'); | |
| try { | |
| const hooksContent = await fs.promises.readFile(hooksFilePath, 'utf-8'); | |
| // eslint-disable-next-line @typescript-eslint/no-unsafe-assignment | |
| const rawHooks = JSON.parse(hooksContent); | |
| if ( | |
| !rawHooks || | |
| typeof rawHooks !== 'object' || | |
| typeof rawHooks.hooks !== 'object' || | |
| rawHooks.hooks === null || | |
| Array.isArray(rawHooks.hooks) | |
| ) { | |
| debugLogger.warn( | |
| `Invalid hooks configuration in ${hooksFilePath}: "hooks" property must be an object`, | |
| ); | |
| return undefined; | |
| } | |
| // Hydrate variables in the hooks configuration | |
| const hydratedHooks = recursivelyHydrateStrings( | |
| // eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion | |
| rawHooks.hooks as unknown as JsonObject, | |
| { | |
| ...context, | |
| '/': path.sep, | |
| pathSeparator: path.sep, | |
| }, | |
| ) as { [K in HookEventName]?: HookDefinition[] }; | |
| return hydratedHooks; | |
| } catch (e) { | |
| // eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion | |
| if ((e as NodeJS.ErrnoException).code === 'ENOENT') { | |
| return undefined; // File not found is not an error here. | |
| } | |
| debugLogger.warn( | |
| `Failed to load extension hooks from ${hooksFilePath}: ${getErrorMessage( | |
| e, | |
| )}`, | |
| ); | |
| return undefined; | |
| } | |
| } | |
| toOutputString(extension: GeminiCLIExtension): string { | |
| const userEnabled = this.extensionEnablementManager.isEnabled( | |
| extension.name, | |
| homedir(), | |
| ); | |
| const workspaceEnabled = this.extensionEnablementManager.isEnabled( | |
| extension.name, | |
| this.workspaceDir, | |
| ); | |
| const status = workspaceEnabled ? chalk.green('✓') : chalk.red('✗'); | |
| let output = `${status} ${extension.name} (${extension.version})`; | |
| output += `\n ID: ${extension.id}`; | |
| output += `\n name: ${hashValue(extension.name)}`; | |
| output += `\n Path: ${extension.path}`; | |
| if (extension.installMetadata) { | |
| output += `\n Source: ${extension.installMetadata.source} (Type: ${extension.installMetadata.type})`; | |
| if (extension.installMetadata.ref) { | |
| output += `\n Ref: ${extension.installMetadata.ref}`; | |
| } | |
| if (extension.installMetadata.releaseTag) { | |
| output += `\n Release tag: ${extension.installMetadata.releaseTag}`; | |
| } | |
| } | |
| output += `\n Enabled (User): ${userEnabled}`; | |
| output += `\n Enabled (Workspace): ${workspaceEnabled}`; | |
| if (extension.contextFiles.length > 0) { | |
| output += `\n Context files:`; | |
| extension.contextFiles.forEach((contextFile) => { | |
| output += `\n ${contextFile}`; | |
| }); | |
| } | |
| if (extension.mcpServers) { | |
| output += `\n MCP servers:`; | |
| Object.keys(extension.mcpServers).forEach((key) => { | |
| output += `\n ${key}`; | |
| }); | |
| } | |
| if (extension.excludeTools) { | |
| output += `\n Excluded tools:`; | |
| extension.excludeTools.forEach((tool) => { | |
| output += `\n ${tool}`; | |
| }); | |
| } | |
| if (extension.skills && extension.skills.length > 0) { | |
| output += `\n Agent skills:`; | |
| extension.skills.forEach((skill) => { | |
| output += `\n ${skill.name}: ${skill.description}`; | |
| }); | |
| } | |
| const resolvedSettings = extension.resolvedSettings; | |
| if (resolvedSettings && resolvedSettings.length > 0) { | |
| output += `\n Settings:`; | |
| resolvedSettings.forEach((setting) => { | |
| let scope = ''; | |
| if (setting.scope) { | |
| scope = setting.scope === 'workspace' ? '(Workspace' : '(User'; | |
| if (setting.source) { | |
| scope += ` - ${setting.source}`; | |
| } | |
| scope += ')'; | |
| } | |
| output += `\n ${setting.name}: ${getFormattedSettingValue(setting)} ${scope}`; | |
| }); | |
| } | |
| return output; | |
| } | |
| async disableExtension(name: string, scope: SettingScope) { | |
| if ( | |
| scope === SettingScope.System || | |
| scope === SettingScope.SystemDefaults | |
| ) { | |
| throw new Error('System and SystemDefaults scopes are not supported.'); | |
| } | |
| const extension = this.getExtensions().find( | |
| (extension) => extension.name === name, | |
| ); | |
| if (!extension) { | |
| throw new Error(`Extension with name ${name} does not exist.`); | |
| } | |
| if (scope !== SettingScope.Session) { | |
| const scopePath = | |
| scope === SettingScope.Workspace ? this.workspaceDir : homedir(); | |
| this.extensionEnablementManager.disable(name, true, scopePath); | |
| } | |
| await logExtensionDisable( | |
| this.telemetryConfig, | |
| new ExtensionDisableEvent(name, hashValue(name), extension.id, scope), | |
| ); | |
| if (!this.config || this.config.getEnableExtensionReloading()) { | |
| // Only toggle the isActive state if we are actually going to disable it | |
| // in the current session, or we haven't been initialized yet. | |
| extension.isActive = false; | |
| } | |
| await this.maybeStopExtension(extension); | |
| } | |
| /** | |
| * Enables an existing extension for a given scope, and starts it if | |
| * appropriate. | |
| */ | |
| async enableExtension(name: string, scope: SettingScope) { | |
| if ( | |
| scope === SettingScope.System || | |
| scope === SettingScope.SystemDefaults | |
| ) { | |
| throw new Error('System and SystemDefaults scopes are not supported.'); | |
| } | |
| const extension = this.getExtensions().find( | |
| (extension) => extension.name === name, | |
| ); | |
| if (!extension) { | |
| throw new Error(`Extension with name ${name} does not exist.`); | |
| } | |
| if (scope !== SettingScope.Session) { | |
| const scopePath = | |
| scope === SettingScope.Workspace ? this.workspaceDir : homedir(); | |
| this.extensionEnablementManager.enable(name, true, scopePath); | |
| } | |
| await logExtensionEnable( | |
| this.telemetryConfig, | |
| new ExtensionEnableEvent(name, hashValue(name), extension.id, scope), | |
| ); | |
| if (!this.config || this.config.getEnableExtensionReloading()) { | |
| // Only toggle the isActive state if we are actually going to disable it | |
| // in the current session, or we haven't been initialized yet. | |
| extension.isActive = true; | |
| } | |
| await this.maybeStartExtension(extension); | |
| } | |
| } | |
| function filterMcpConfig(original: MCPServerConfig): MCPServerConfig { | |
| // eslint-disable-next-line @typescript-eslint/no-unused-vars | |
| const { trust, ...rest } = original; | |
| return Object.freeze(rest); | |
| } | |
| /** | |
| * Recursively ensures that the owner has write permissions for all files | |
| * and directories within the target path. | |
| */ | |
| async function makeWritableRecursive(targetPath: string): Promise<void> { | |
| const stats = await fs.promises.lstat(targetPath); | |
| if (stats.isDirectory()) { | |
| // Ensure directory is rwx for the owner (0o700) | |
| await fs.promises.chmod(targetPath, stats.mode | 0o700); | |
| const children = await fs.promises.readdir(targetPath); | |
| for (const child of children) { | |
| await makeWritableRecursive(path.join(targetPath, child)); | |
| } | |
| } else if (stats.isFile()) { | |
| // Ensure file is rw for the owner (0o600) | |
| await fs.promises.chmod(targetPath, stats.mode | 0o600); | |
| } | |
| } | |
| export async function copyExtension( | |
| source: string, | |
| destination: string, | |
| ): Promise<void> { | |
| await fs.promises.cp(source, destination, { recursive: true }); | |
| await makeWritableRecursive(destination); | |
| } | |
| function getContextFileNames(config: ExtensionConfig): string[] { | |
| if (!config.contextFileName) { | |
| return ['GEMINI.md']; | |
| } else if (!Array.isArray(config.contextFileName)) { | |
| return [config.contextFileName]; | |
| } | |
| return config.contextFileName; | |
| } | |
| /** | |
| * Context file candidate path validation result. | |
| */ | |
| export interface ContextPathValidationResult { | |
| isValid: boolean; | |
| resolvedPath?: string; | |
| errorMessage?: string; | |
| } | |
| /** | |
| * Validates a single context file candidate path against the extension root. | |
| * Enforces directory boundary and path formatting by: | |
| * - Requiring a non-empty string without null bytes. | |
| * - Rejecting absolute paths (both POSIX and Windows formats). | |
| * - Disallowing relative parent directory references (e.g. '..'), including URL-encoded variations. | |
| * - Verifying that the resolved path stays strictly within the extension root directory. | |
| */ | |
| export function validateContextFilePath( | |
| contextFileName: unknown, | |
| effectiveExtensionPath: string, | |
| ): ContextPathValidationResult { | |
| if (typeof contextFileName !== 'string' || !contextFileName.trim()) { | |
| return { | |
| isValid: false, | |
| errorMessage: 'expected a non-empty string.', | |
| }; | |
| } | |
| if (contextFileName.includes('\0')) { | |
| return { | |
| isValid: false, | |
| errorMessage: 'null bytes are not allowed.', | |
| }; | |
| } | |
| // Reject absolute paths upfront (both POSIX and Windows formats) | |
| if ( | |
| path.isAbsolute(contextFileName) || | |
| path.win32.isAbsolute(contextFileName) | |
| ) { | |
| return { | |
| isValid: false, | |
| errorMessage: 'Absolute paths are not allowed.', | |
| }; | |
| } | |
| // Disallow relative parent directory references (e.g. ..) | |
| let decodedFileName = contextFileName; | |
| try { | |
| decodedFileName = decodeURIComponent(contextFileName); | |
| } catch { | |
| debugLogger.debug( | |
| `[ExtensionManager] Could not decode URI component in contextFileName: "${contextFileName}". Proceeding with undecoded name.`, | |
| ); | |
| } | |
| if (contextFileName.includes('..') || decodedFileName.includes('..')) { | |
| return { | |
| isValid: false, | |
| errorMessage: | |
| 'Relative parent directory references ("..") are not allowed.', | |
| }; | |
| } | |
| const resolvedExtensionRoot = path.resolve(effectiveExtensionPath); | |
| const contextFilePath = path.resolve(resolvedExtensionRoot, contextFileName); | |
| // Verify that contextFilePath is strictly within resolvedExtensionRoot | |
| if (!isSubpath(resolvedExtensionRoot, contextFilePath)) { | |
| return { | |
| isValid: false, | |
| errorMessage: `escapes the extension directory boundary ("${resolvedExtensionRoot}").`, | |
| }; | |
| } | |
| return { | |
| isValid: true, | |
| resolvedPath: contextFilePath, | |
| }; | |
| } | |
| /** | |
| * Resolves and validates context file paths defined in an extension configuration. | |
| * Hardens path resolution and enforces directory boundaries by: | |
| * - Validating each candidate context file path using validateContextFilePath. | |
| * - Logging a warning when an invalid path is detected and skipping loading that context file. | |
| * - Returning only paths that exist on disk. | |
| */ | |
| export function resolveContextFilePaths( | |
| config: ExtensionConfig, | |
| effectiveExtensionPath: string, | |
| ): string[] { | |
| return getContextFileNames(config) | |
| .map((contextFileName) => { | |
| const validation = validateContextFilePath( | |
| contextFileName, | |
| effectiveExtensionPath, | |
| ); | |
| if (!validation.isValid) { | |
| debugLogger.warn( | |
| `[ExtensionManager] Invalid contextFileName: "${contextFileName}" in extension "${config.name}". ${validation.errorMessage}`, | |
| ); | |
| return null; | |
| } | |
| return validation.resolvedPath!; | |
| }) | |
| .filter( | |
| (contextFilePath): contextFilePath is string => | |
| contextFilePath !== null && fs.existsSync(contextFilePath), | |
| ); | |
| } | |
| function validateName(name: string) { | |
| if (!/^[a-zA-Z0-9-]+$/.test(name)) { | |
| throw new Error( | |
| `Invalid extension name: "${name}". Only letters (a-z, A-Z), numbers (0-9), and dashes (-) are allowed.`, | |
| ); | |
| } | |
| } | |
| export async function inferInstallMetadata( | |
| source: string, | |
| args: { | |
| ref?: string; | |
| autoUpdate?: boolean; | |
| allowPreRelease?: boolean; | |
| } = {}, | |
| ): Promise<ExtensionInstallMetadata> { | |
| if ( | |
| source.startsWith('http://') || | |
| source.startsWith('https://') || | |
| source.startsWith('git@') || | |
| source.startsWith('sso://') || | |
| source.startsWith('github:') || | |
| source.startsWith('gitlab:') || | |
| source.startsWith('ssh://') | |
| ) { | |
| return { | |
| source, | |
| type: 'git', | |
| ref: args.ref, | |
| autoUpdate: args.autoUpdate, | |
| allowPreRelease: args.allowPreRelease, | |
| }; | |
| } else { | |
| if (args.ref || args.autoUpdate) { | |
| throw new Error( | |
| '--ref and --auto-update are not applicable for local extensions.', | |
| ); | |
| } | |
| try { | |
| await stat(source); | |
| return { | |
| source, | |
| type: 'local', | |
| }; | |
| } catch { | |
| throw new Error('Install source not found.'); | |
| } | |
| } | |
| } | |
| export function getExtensionId( | |
| config: ExtensionConfig, | |
| installMetadata?: ExtensionInstallMetadata, | |
| ): string { | |
| // 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. | |
| let idValue = config.name; | |
| 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. | |
| idValue = `https://github.com/${githubUrlParts.owner}/${githubUrlParts.repo}`; | |
| } else { | |
| idValue = installMetadata?.source ?? config.name; | |
| } | |
| return hashValue(idValue); | |
| } | |
| export function hashValue(value: string): string { | |
| return createHash('sha256').update(value).digest('hex'); | |
| } | |