qwen-code/packages/core/src/extension/extensionManager.ts
2026-01-22 14:09:08 +08:00

1287 lines
38 KiB
TypeScript

/**
* @license
* Copyright 2025 Google LLC
* SPDX-License-Identifier: Apache-2.0
*/
import type {
MCPServerConfig,
ExtensionInstallMetadata,
SkillConfig,
SubagentConfig,
} from '../index.js';
import {
Storage,
Config,
logExtensionEnable,
logExtensionInstallEvent,
logExtensionUninstall,
logExtensionDisable,
} from '../index.js';
import * as fs from 'node:fs';
import * as path from 'node:path';
import * as os from 'node:os';
import { getErrorMessage } from '../utils/errors.js';
import {
EXTENSIONS_CONFIG_FILENAME,
INSTALL_METADATA_FILENAME,
recursivelyHydrateStrings,
} from './variables.js';
import { resolveEnvVarsInObject } from '../utils/envVarResolver.js';
import {
checkForExtensionUpdate,
cloneFromGit,
downloadFromGitHubRelease,
parseGitHubRepoForReleases,
} from './github.js';
import type { LoadExtensionContext } from './variableSchema.js';
import { Override, type AllExtensionsEnablementConfig } from './override.js';
import { parseMarketplaceSource } from './marketplace.js';
import {
isGeminiExtensionConfig,
convertGeminiExtensionPackage,
} from './gemini-converter.js';
import { glob } from 'glob';
import { createHash } from 'node:crypto';
import { ExtensionStorage } from './storage.js';
import {
getEnvContents,
maybePromptForSettings,
promptForSetting,
} from './extensionSettings.js';
import type {
ExtensionSetting,
ResolvedExtensionSetting,
} from './extensionSettings.js';
import type { TelemetrySettings } from '../config/config.js';
import { logExtensionUpdateEvent } from '../telemetry/loggers.js';
import {
ExtensionDisableEvent,
ExtensionEnableEvent,
ExtensionInstallEvent,
ExtensionUninstallEvent,
ExtensionUpdateEvent,
} from '../telemetry/types.js';
import { stat } from 'node:fs/promises';
import { loadSkillsFromDir } from '../skills/skill-load.js';
import { convertClaudePluginPackage } from './claude-converter.js';
import { loadSubagentFromDir } from '../subagents/subagent-manager.js';
// ============================================================================
// Types and Interfaces
// ============================================================================
export enum SettingScope {
User = 'User',
Workspace = 'Workspace',
System = 'System',
SystemDefaults = 'SystemDefaults',
}
export interface Extension {
id: string;
name: string;
version: string;
isActive: boolean;
path: string;
config: ExtensionConfig;
installMetadata?: ExtensionInstallMetadata;
mcpServers?: Record<string, MCPServerConfig>;
contextFiles: string[];
settings?: ExtensionSetting[];
resolvedSettings?: ResolvedExtensionSetting[];
commands?: string[];
skills?: SkillConfig[];
agents?: SubagentConfig[];
}
export interface ExtensionConfig {
name: string;
version: string;
mcpServers?: Record<string, MCPServerConfig>;
contextFileName?: string | string[];
commands?: string | string[];
skills?: string | string[];
agents?: string | string[];
settings?: ExtensionSetting[];
}
export interface ExtensionUpdateInfo {
name: string;
originalVersion: string;
updatedVersion: string;
}
export interface ExtensionUpdateStatus {
status: ExtensionUpdateState;
processed: boolean;
}
export enum ExtensionUpdateState {
CHECKING_FOR_UPDATES = 'checking for updates',
UPDATED_NEEDS_RESTART = 'updated, needs restart',
UPDATING = 'updating',
UPDATED = 'updated',
UPDATE_AVAILABLE = 'update available',
UP_TO_DATE = 'up to date',
ERROR = 'error',
NOT_UPDATABLE = 'not updatable',
UNKNOWN = 'unknown',
}
export type ExtensionRequestOptions = {
extensionConfig: ExtensionConfig;
commands?: string[];
skills?: SkillConfig[];
subagents?: SubagentConfig[];
previousExtensionConfig?: ExtensionConfig;
previousCommands?: string[];
previousSkills?: SkillConfig[];
previousSubagents?: SubagentConfig[];
};
export interface ExtensionManagerOptions {
/** Working directory for project-level extensions */
workspaceDir?: string;
/** Override list of enabled extension names (from CLI -e flag) */
enabledExtensionOverrides?: string[];
isWorkspaceTrusted: boolean;
telemetrySettings?: TelemetrySettings;
config?: Config;
requestConsent?: (options?: ExtensionRequestOptions) => Promise<void>;
requestSetting?: (setting: ExtensionSetting) => Promise<string>;
}
// ============================================================================
// Helper Functions
// ============================================================================
function ensureLeadingAndTrailingSlash(dirPath: string): string {
let result = dirPath.replace(/\\/g, '/');
if (result.charAt(0) !== '/') {
result = '/' + result;
}
if (result.charAt(result.length - 1) !== '/') {
result = result + '/';
}
return result;
}
function getTelemetryConfig(
cwd: string,
telemetrySettings?: TelemetrySettings,
) {
const config = new Config({
telemetry: telemetrySettings,
interactive: false,
targetDir: cwd,
cwd,
model: '',
debugMode: false,
});
return config;
}
function filterMcpConfig(original: MCPServerConfig): MCPServerConfig {
// eslint-disable-next-line @typescript-eslint/no-unused-vars
const { trust, ...rest } = original;
return Object.freeze(rest);
}
function getContextFileNames(config: ExtensionConfig): string[] {
if (!config.contextFileName) {
return ['QWEN.md'];
} else if (!Array.isArray(config.contextFileName)) {
return [config.contextFileName];
}
return config.contextFileName;
}
async function loadCommandsFromDir(dir: string): Promise<string[]> {
const globOptions = {
nodir: true,
dot: true,
follow: true,
};
try {
const mdFiles = await glob('**/*.md', {
...globOptions,
cwd: dir,
});
const commandNames = mdFiles.map((file) => {
const relativePathWithExt = path.relative(dir, path.join(dir, file));
const relativePath = relativePathWithExt.substring(
0,
relativePathWithExt.length - 3,
);
const commandName = relativePath
.split(path.sep)
.map((segment) => segment.replaceAll(':', '_'))
.join(':');
return commandName;
});
return commandNames;
} catch (error) {
const isEnoent = (error as NodeJS.ErrnoException).code === 'ENOENT';
const isAbortError = error instanceof Error && error.name === 'AbortError';
if (!isEnoent && !isAbortError) {
console.error(`Error loading commands from ${dir}:`, error);
}
return [];
}
}
async function convertGeminiOrClaudeExtension(
extensionDir: string,
pluginName?: string,
) {
let newExtensionDir = extensionDir;
const configFilePath = path.join(extensionDir, EXTENSIONS_CONFIG_FILENAME);
if (fs.existsSync(configFilePath)) {
newExtensionDir = extensionDir;
} else if (isGeminiExtensionConfig(extensionDir)) {
newExtensionDir = (await convertGeminiExtensionPackage(extensionDir))
.convertedDir;
} else if (pluginName) {
newExtensionDir = (
await convertClaudePluginPackage(extensionDir, pluginName)
).convertedDir;
}
// Claude plugin conversion not yet implemented
return newExtensionDir;
}
// ============================================================================
// ExtensionManager Class
// ============================================================================
export class ExtensionManager {
private extensionCache: Map<string, Extension> | null = null;
// Enablement configuration (directly implemented)
private readonly configDir: string;
private readonly configFilePath: string;
private readonly enabledExtensionNamesOverride: string[];
private readonly workspaceDir: string;
private config?: Config;
private telemetrySettings?: TelemetrySettings;
private isWorkspaceTrusted: boolean;
private requestConsent: (options?: ExtensionRequestOptions) => Promise<void>;
private requestSetting?: (setting: ExtensionSetting) => Promise<string>;
constructor(options: ExtensionManagerOptions) {
this.workspaceDir = options.workspaceDir ?? process.cwd();
this.enabledExtensionNamesOverride =
options.enabledExtensionOverrides?.map((name) => name.toLowerCase()) ??
[];
this.configDir = ExtensionStorage.getUserExtensionsDir();
this.configFilePath = path.join(
this.configDir,
'extension-enablement.json',
);
this.requestSetting = options.requestSetting;
this.requestConsent = options.requestConsent || (() => Promise.resolve());
this.config = options.config;
this.telemetrySettings = options.telemetrySettings;
this.isWorkspaceTrusted = options.isWorkspaceTrusted;
}
setConfig(config: Config): void {
this.config = config;
}
setRequestConsent(
requestConsent: (options?: ExtensionRequestOptions) => Promise<void>,
): void {
this.requestConsent = requestConsent;
}
setRequestSetting(
requestSetting?: (setting: ExtensionSetting) => Promise<string>,
): void {
this.requestSetting = requestSetting;
}
// ==========================================================================
// Enablement functionality (directly implemented)
// ==========================================================================
/**
* Validates that override extension names exist in the extensions list.
*/
validateExtensionOverrides(extensions: Extension[]): void {
for (const name of this.enabledExtensionNamesOverride) {
if (name === 'none') continue;
if (
!extensions.some(
(ext) => ext.config.name.toLowerCase() === name.toLowerCase(),
)
) {
console.error(`Extension not found: ${name}`);
}
}
}
/**
* Determines if an extension is enabled based on its name and the current path.
*/
isEnabled(extensionName: string, currentPath?: string): boolean {
const checkPath = currentPath ?? this.workspaceDir;
// If we have a single override called 'none', this disables all extensions.
if (
this.enabledExtensionNamesOverride.length === 1 &&
this.enabledExtensionNamesOverride[0] === 'none'
) {
return false;
}
// If we have explicit overrides, only enable those extensions.
if (this.enabledExtensionNamesOverride.length > 0) {
return this.enabledExtensionNamesOverride.includes(
extensionName.toLowerCase(),
);
}
// Otherwise, use the configuration settings
const config = this.readEnablementConfig();
const extensionConfig = config[extensionName];
let enabled = true;
const allOverrides = extensionConfig?.overrides ?? [];
for (const rule of allOverrides) {
const override = Override.fromFileRule(rule);
if (override.matchesPath(ensureLeadingAndTrailingSlash(checkPath))) {
enabled = !override.isDisable;
}
}
return enabled;
}
/**
* Enables an extension at the specified scope.
*/
async enableExtension(
name: string,
scope: SettingScope,
cwd?: string,
): Promise<void> {
const currentDir = cwd ?? this.workspaceDir;
if (
scope === SettingScope.System ||
scope === SettingScope.SystemDefaults
) {
throw new Error('System and SystemDefaults scopes are not supported.');
}
const extension = this.getLoadedExtensions().find(
(ext) => ext.name === name,
);
if (!extension) {
throw new Error(`Extension with name ${name} does not exist.`);
}
const scopePath =
scope === SettingScope.Workspace ? currentDir : os.homedir();
this.enableByPath(name, true, scopePath);
const config = getTelemetryConfig(currentDir, this.telemetrySettings);
logExtensionEnable(config, new ExtensionEnableEvent(name, scope));
extension.isActive = true;
await this.refreshTools();
}
/**
* Disables an extension at the specified scope.
*/
async disableExtension(
name: string,
scope: SettingScope,
cwd?: string,
): Promise<void> {
const currentDir = cwd ?? this.workspaceDir;
const config = getTelemetryConfig(currentDir, this.telemetrySettings);
if (
scope === SettingScope.System ||
scope === SettingScope.SystemDefaults
) {
throw new Error('System and SystemDefaults scopes are not supported.');
}
const extension = this.getLoadedExtensions().find(
(ext) => ext.name === name,
);
if (!extension) {
throw new Error(`Extension with name ${name} does not exist.`);
}
const scopePath =
scope === SettingScope.Workspace ? currentDir : os.homedir();
this.disableByPath(name, true, scopePath);
logExtensionDisable(config, new ExtensionDisableEvent(name, scope));
extension.isActive = false;
await this.refreshTools();
}
/**
* Removes enablement configuration for an extension.
*/
removeEnablementConfig(extensionName: string): void {
const config = this.readEnablementConfig();
if (config[extensionName]) {
delete config[extensionName];
this.writeEnablementConfig(config);
}
}
private enableByPath(
extensionName: string,
includeSubdirs: boolean,
scopePath: string,
): void {
const config = this.readEnablementConfig();
if (!config[extensionName]) {
config[extensionName] = { overrides: [] };
}
const override = Override.fromInput(scopePath, includeSubdirs);
const overrides = config[extensionName].overrides.filter((rule) => {
const fileOverride = Override.fromFileRule(rule);
if (
fileOverride.conflictsWith(override) ||
fileOverride.isEqualTo(override)
) {
return false;
}
return !fileOverride.isChildOf(override);
});
overrides.push(override.output());
config[extensionName].overrides = overrides;
this.writeEnablementConfig(config);
}
private disableByPath(
extensionName: string,
includeSubdirs: boolean,
scopePath: string,
): void {
this.enableByPath(extensionName, includeSubdirs, `!${scopePath}`);
}
private readEnablementConfig(): AllExtensionsEnablementConfig {
try {
const content = fs.readFileSync(this.configFilePath, 'utf-8');
return JSON.parse(content);
} catch (error) {
if (
error instanceof Error &&
'code' in error &&
error.code === 'ENOENT'
) {
return {};
}
console.error('Error reading extension enablement config:', error);
return {};
}
}
private writeEnablementConfig(config: AllExtensionsEnablementConfig): void {
fs.mkdirSync(this.configDir, { recursive: true });
fs.writeFileSync(this.configFilePath, JSON.stringify(config, null, 2));
}
/**
* Refreshes the extension cache from disk.
*/
async refreshCache(): Promise<void> {
this.extensionCache = new Map<string, Extension>();
const extensions = await this.loadExtensionsFromDir(os.homedir());
extensions.forEach((extension) => {
this.extensionCache!.set(extension.name, extension);
});
}
getLoadedExtensions(): Extension[] {
if (!this.extensionCache) {
return [];
}
return [...this.extensionCache!.values()];
}
// ==========================================================================
// Extension loading methods
// ==========================================================================
/**
* Loads an extension by name.
*/
async loadExtensionByName(
name: string,
workspaceDir?: string,
): Promise<Extension | null> {
const cwd = workspaceDir ?? this.workspaceDir;
const userExtensionsDir = ExtensionStorage.getUserExtensionsDir();
if (!fs.existsSync(userExtensionsDir)) {
return null;
}
for (const subdir of fs.readdirSync(userExtensionsDir)) {
const extensionDir = path.join(userExtensionsDir, subdir);
if (!fs.statSync(extensionDir).isDirectory()) {
continue;
}
const extension = await this.loadExtension({
extensionDir,
workspaceDir: cwd,
});
if (
extension &&
extension.config.name.toLowerCase() === name.toLowerCase()
) {
return extension;
}
}
return null;
}
async loadExtensionsFromDir(dir: string): Promise<Extension[]> {
const storage = new Storage(dir);
const extensionsDir = storage.getExtensionsDir();
let subdirs: string[];
try {
subdirs = fs.readdirSync(extensionsDir);
} catch {
// Directory doesn't exist or is inaccessible
return [];
}
const extensions: Extension[] = [];
for (const subdir of subdirs) {
const extensionDir = path.join(extensionsDir, subdir);
const extension = await this.loadExtension({
extensionDir,
workspaceDir: dir,
});
if (extension != null) {
extensions.push(extension);
}
}
return extensions;
}
async loadExtension(
context: LoadExtensionContext,
): Promise<Extension | null> {
const { extensionDir, workspaceDir } = context;
if (!fs.statSync(extensionDir).isDirectory()) {
return null;
}
const installMetadata = this.loadInstallMetadata(extensionDir);
let effectiveExtensionPath = extensionDir;
if (installMetadata?.type === 'link') {
effectiveExtensionPath = installMetadata.source;
}
try {
let config = this.loadExtensionConfig({
extensionDir: effectiveExtensionPath,
workspaceDir,
});
config = resolveEnvVarsInObject(config);
const extension: Extension = {
id: getExtensionId(config, installMetadata),
name: config.name,
version: config.version,
path: effectiveExtensionPath,
installMetadata,
isActive: this.isEnabled(config.name, this.workspaceDir),
config,
settings: config.settings,
contextFiles: [],
};
if (config.mcpServers) {
extension.mcpServers = Object.fromEntries(
Object.entries(config.mcpServers).map(([key, value]) => [
key,
filterMcpConfig(value),
]),
);
}
extension.commands = await loadCommandsFromDir(
`${effectiveExtensionPath}/commands`,
);
extension.contextFiles = getContextFileNames(config)
.map((contextFileName) =>
path.join(effectiveExtensionPath, contextFileName),
)
.filter((contextFilePath) => fs.existsSync(contextFilePath));
extension.skills = await loadSkillsFromDir(
`${effectiveExtensionPath}/skills`,
);
extension.agents = await loadSubagentFromDir(
`${effectiveExtensionPath}/agents`,
);
return extension;
} catch (e) {
console.error(
`Warning: Skipping extension in ${effectiveExtensionPath}: ${getErrorMessage(
e,
)}`,
);
return null;
}
}
loadInstallMetadata(
extensionDir: string,
): ExtensionInstallMetadata | undefined {
const metadataFilePath = path.join(extensionDir, INSTALL_METADATA_FILENAME);
try {
const configContent = fs.readFileSync(metadataFilePath, 'utf-8');
const metadata = JSON.parse(configContent) as ExtensionInstallMetadata;
return metadata;
} catch (_e) {
return undefined;
}
}
loadExtensionConfig(context: LoadExtensionContext): ExtensionConfig {
const { extensionDir, workspaceDir = this.workspaceDir } = context;
const configFilePath = path.join(extensionDir, EXTENSIONS_CONFIG_FILENAME);
if (!fs.existsSync(configFilePath)) {
throw new Error(`Configuration file not found at ${configFilePath}`);
}
try {
const configContent = fs.readFileSync(configFilePath, 'utf-8');
const config = recursivelyHydrateStrings(JSON.parse(configContent), {
extensionPath: extensionDir,
CLAUDE_PLUGIN_ROOT: extensionDir,
workspacePath: workspaceDir,
'/': path.sep,
pathSeparator: path.sep,
}) as unknown as ExtensionConfig;
if (!config.name || !config.version) {
throw new Error(
`Invalid configuration in ${configFilePath}: missing ${!config.name ? '"name"' : '"version"'}`,
);
}
validateName(config.name);
return config;
} catch (e) {
throw new Error(
`Failed to load extension config from ${configFilePath}: ${getErrorMessage(
e,
)}`,
);
}
}
// ==========================================================================
// Extension installation/uninstallation
// ==========================================================================
/**
* Installs an extension.
*/
async installExtension(
installMetadata: ExtensionInstallMetadata,
requestConsent?: (options?: ExtensionRequestOptions) => Promise<void>,
requestSetting?: (setting: ExtensionSetting) => Promise<string>,
cwd?: string,
previousExtensionConfig?: ExtensionConfig,
): Promise<Extension> {
const currentDir = cwd ?? this.workspaceDir;
const telemetryConfig = getTelemetryConfig(
currentDir,
this.telemetrySettings,
);
let extension: Extension | null;
const isUpdate = !!previousExtensionConfig;
let newExtensionConfig: ExtensionConfig | null = null;
let localSourcePath: string | undefined;
try {
if (!this.isWorkspaceTrusted) {
throw new Error(
`Could not install extension from untrusted folder at ${installMetadata.source}`,
);
}
const extensionsDir = ExtensionStorage.getUserExtensionsDir();
await fs.promises.mkdir(extensionsDir, { recursive: true });
if (
!path.isAbsolute(installMetadata.source) &&
(installMetadata.type === 'local' || installMetadata.type === 'link')
) {
installMetadata.source = path.resolve(
currentDir,
installMetadata.source,
);
}
let tempDir: string | undefined;
let claudePluginName: string | undefined;
// Handle marketplace installation
if (installMetadata.type === 'marketplace') {
const marketplaceParsed = parseMarketplaceSource(
installMetadata.source,
);
if (!marketplaceParsed) {
throw new Error(
`Invalid marketplace source format: ${installMetadata.source}. Expected format: marketplace-url:plugin-name`,
);
}
tempDir = await ExtensionStorage.createTmpDir();
try {
await downloadFromGitHubRelease(
{
source: marketplaceParsed.marketplaceSource,
type: 'git',
},
tempDir,
);
} catch (_error) {
await cloneFromGit(installMetadata, tempDir);
installMetadata.type = 'git';
}
localSourcePath = tempDir;
claudePluginName = marketplaceParsed.pluginName;
} else if (
installMetadata.type === 'git' ||
installMetadata.type === 'github-release'
) {
tempDir = await ExtensionStorage.createTmpDir();
try {
const result = await downloadFromGitHubRelease(
installMetadata,
tempDir,
);
installMetadata.type = result.type;
installMetadata.releaseTag = result.tagName;
} catch (_error) {
await cloneFromGit(installMetadata, tempDir);
installMetadata.type = 'git';
}
localSourcePath = tempDir;
} else if (
installMetadata.type === 'local' ||
installMetadata.type === 'link'
) {
localSourcePath = installMetadata.source;
} else {
throw new Error(`Unsupported install type: ${installMetadata.type}`);
}
try {
localSourcePath = await convertGeminiOrClaudeExtension(
localSourcePath,
claudePluginName,
);
newExtensionConfig = this.loadExtensionConfig({
extensionDir: localSourcePath,
workspaceDir: currentDir,
});
if (isUpdate && installMetadata.autoUpdate) {
const oldSettings = new Set(
previousExtensionConfig.settings?.map((s) => s.name) || [],
);
const newSettings = new Set(
newExtensionConfig.settings?.map((s) => s.name) || [],
);
const settingsAreEqual =
oldSettings.size === newSettings.size &&
[...oldSettings].every((value) => newSettings.has(value));
if (!settingsAreEqual && installMetadata.autoUpdate) {
throw new Error(
`Extension "${newExtensionConfig.name}" has settings changes and cannot be auto-updated. Please update manually.`,
);
}
}
const newExtensionName = newExtensionConfig.name;
const previous = this.getLoadedExtensions().find(
(installed) => installed.name === newExtensionName,
);
if (isUpdate && !previous) {
throw new Error(
`Extension "${newExtensionName}" was not already installed, cannot update it.`,
);
} else if (!isUpdate && previous) {
throw new Error(
`Extension "${newExtensionName}" is already installed. Please uninstall it first.`,
);
}
const commands = await loadCommandsFromDir(
`${localSourcePath}/commands`,
);
const previousCommands = previous?.commands ?? [];
const skills = await loadSkillsFromDir(`${localSourcePath}/skills`);
const previousSkills = previous?.skills ?? [];
const subagents = await loadSubagentFromDir(
`${localSourcePath}/agents`,
);
const previousSubagents = previous?.agents ?? [];
if (requestConsent) {
await requestConsent({
extensionConfig: newExtensionConfig,
commands,
skills,
subagents,
previousExtensionConfig,
previousCommands,
previousSkills,
previousSubagents,
});
} else {
await this.requestConsent({
extensionConfig: newExtensionConfig,
commands,
skills,
subagents,
previousExtensionConfig,
previousCommands,
previousSkills,
previousSubagents,
});
}
const extensionStorage = new ExtensionStorage(newExtensionName);
const destinationPath = extensionStorage.getExtensionDir();
const extensionId = getExtensionId(newExtensionConfig, installMetadata);
let previousSettings: Record<string, string> | undefined;
if (isUpdate) {
previousSettings = await getEnvContents(
previousExtensionConfig,
extensionId,
);
await this.uninstallExtension(newExtensionName, isUpdate);
}
await fs.promises.mkdir(destinationPath, { recursive: true });
if (isUpdate) {
await maybePromptForSettings(
newExtensionConfig,
extensionId,
requestSetting || this.requestSetting || promptForSetting,
previousExtensionConfig,
previousSettings,
);
} else {
await maybePromptForSettings(
newExtensionConfig,
extensionId,
requestSetting || this.requestSetting || promptForSetting,
);
}
if (
installMetadata.type === 'local' ||
installMetadata.type === 'git' ||
installMetadata.type === 'github-release' ||
installMetadata.type === 'marketplace'
) {
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);
extension = await this.loadExtension({ extensionDir: destinationPath });
if (!extension) {
throw new Error(`Extension not found`);
}
if (this.extensionCache) {
this.extensionCache.set(extension.name, extension);
}
if (isUpdate) {
logExtensionUpdateEvent(
telemetryConfig,
new ExtensionUpdateEvent(
newExtensionConfig.name,
getExtensionId(newExtensionConfig, installMetadata),
newExtensionConfig.version,
previousExtensionConfig.version,
installMetadata.type,
'success',
),
);
this.refreshTools();
} else {
logExtensionInstallEvent(
telemetryConfig,
new ExtensionInstallEvent(
newExtensionConfig.name,
newExtensionConfig!.version,
installMetadata.source,
'success',
),
);
this.enableExtension(newExtensionConfig.name, SettingScope.User);
}
} finally {
if (tempDir) {
await fs.promises.rm(tempDir, { recursive: true, force: true });
}
if (
localSourcePath !== tempDir &&
installMetadata.type !== 'link' &&
installMetadata.type !== 'local'
) {
await fs.promises.rm(localSourcePath, {
recursive: true,
force: true,
});
}
}
return extension;
} catch (error) {
if (!newExtensionConfig && localSourcePath) {
try {
newExtensionConfig = this.loadExtensionConfig({
extensionDir: localSourcePath,
workspaceDir: currentDir,
});
} catch {
// Ignore error
}
}
const config = newExtensionConfig ?? previousExtensionConfig;
const extensionId = config
? getExtensionId(config, installMetadata)
: undefined;
if (isUpdate) {
logExtensionUpdateEvent(
telemetryConfig,
new ExtensionUpdateEvent(
config?.name ?? '',
extensionId ?? '',
newExtensionConfig?.version ?? '',
previousExtensionConfig.version,
installMetadata.type,
'error',
),
);
} else {
logExtensionInstallEvent(
telemetryConfig,
new ExtensionInstallEvent(
newExtensionConfig?.name ?? '',
newExtensionConfig?.version ?? '',
installMetadata.source,
'error',
),
);
}
throw error;
}
}
/**
* Uninstalls an extension.
*/
async uninstallExtension(
extensionIdentifier: string,
isUpdate: boolean,
cwd?: string,
): Promise<void> {
const currentDir = cwd ?? this.workspaceDir;
const telemetryConfig = getTelemetryConfig(
currentDir,
this.telemetrySettings,
);
const installedExtensions = this.getLoadedExtensions();
const extension = installedExtensions.find(
(installed) =>
installed.config.name.toLowerCase() ===
extensionIdentifier.toLowerCase() ||
installed.installMetadata?.source.toLowerCase() ===
extensionIdentifier.toLowerCase(),
);
if (!extension) {
throw new Error(`Extension not found.`);
}
const storage = new ExtensionStorage(
extension.installMetadata?.type === 'link'
? extension.name
: path.basename(extension.path),
);
await fs.promises.rm(storage.getExtensionDir(), {
recursive: true,
force: true,
});
if (this.extensionCache) {
this.extensionCache.delete(extension.name);
}
if (isUpdate) return;
this.removeEnablementConfig(extension.name);
this.refreshTools();
logExtensionUninstall(
telemetryConfig,
new ExtensionUninstallEvent(extension.name, 'success'),
);
}
async performWorkspaceExtensionMigration(
extensions: Extension[],
requestConsent: (options?: ExtensionRequestOptions) => Promise<void>,
requestSetting?: (setting: ExtensionSetting) => Promise<string>,
): Promise<string[]> {
const failedInstallNames: string[] = [];
for (const extension of extensions) {
try {
const installMetadata: ExtensionInstallMetadata = {
source: extension.path,
type: 'local',
};
await this.installExtension(
installMetadata,
requestConsent,
requestSetting,
);
} catch (_) {
failedInstallNames.push(extension.config.name);
}
}
return failedInstallNames;
}
async checkForAllExtensionUpdates(
callback: (extensionName: string, state: ExtensionUpdateState) => void,
): Promise<void> {
const extensions = this.getLoadedExtensions();
const promises: Array<Promise<void>> = [];
for (const extension of extensions) {
if (!extension.installMetadata) {
callback(extension.name, ExtensionUpdateState.NOT_UPDATABLE);
continue;
}
callback(extension.name, ExtensionUpdateState.CHECKING_FOR_UPDATES);
promises.push(
checkForExtensionUpdate(extension, this).then((state) =>
callback(extension.name, state),
),
);
}
await Promise.all(promises);
}
async updateExtension(
extension: Extension,
currentState: ExtensionUpdateState,
callback: (extensionName: string, state: ExtensionUpdateState) => void,
enableExtensionReloading: boolean = true,
): Promise<ExtensionUpdateInfo | undefined> {
if (currentState === ExtensionUpdateState.UPDATING) {
return undefined;
}
callback(extension.name, ExtensionUpdateState.UPDATING);
const installMetadata = this.loadInstallMetadata(extension.path);
if (!installMetadata?.type) {
callback(extension.name, ExtensionUpdateState.ERROR);
throw new Error(
`Extension ${extension.name} cannot be updated, type is unknown.`,
);
}
if (installMetadata?.type === 'link') {
callback(extension.name, ExtensionUpdateState.UP_TO_DATE);
throw new Error(`Extension is linked so does not need to be updated`);
}
const originalVersion = extension.version;
const tempDir = await ExtensionStorage.createTmpDir();
try {
const previousExtensionConfig = this.loadExtensionConfig({
extensionDir: extension.path,
});
let updatedExtension: Extension;
try {
updatedExtension = await this.installExtension(
installMetadata,
undefined,
undefined,
undefined,
previousExtensionConfig,
);
} catch (e) {
callback(extension.name, ExtensionUpdateState.ERROR);
throw new Error(
`Updated extension not found after installation, got error:\n${e}`,
);
}
const updatedVersion = updatedExtension.version;
callback(
extension.name,
enableExtensionReloading
? ExtensionUpdateState.UPDATED
: ExtensionUpdateState.UPDATED_NEEDS_RESTART,
);
return {
name: extension.name,
originalVersion,
updatedVersion,
};
} catch (e) {
console.error(
`Error updating extension, rolling back. ${getErrorMessage(e)}`,
);
callback(extension.name, ExtensionUpdateState.ERROR);
await copyExtension(tempDir, extension.path);
throw e;
} finally {
await fs.promises.rm(tempDir, { recursive: true, force: true });
}
}
async updateAllUpdatableExtensions(
extensionsState: Map<string, ExtensionUpdateStatus>,
callback: (extensionName: string, state: ExtensionUpdateState) => void,
enableExtensionReloading: boolean = true,
): Promise<ExtensionUpdateInfo[]> {
const extensions = this.getLoadedExtensions();
return (
await Promise.all(
extensions
.filter(
(extension) =>
extensionsState.get(extension.name)?.status ===
ExtensionUpdateState.UPDATE_AVAILABLE,
)
.map((extension) =>
this.updateExtension(
extension,
extensionsState.get(extension.name)!.status,
callback,
enableExtensionReloading,
),
),
)
).filter((updateInfo) => !!updateInfo);
}
async refreshMemory(): Promise<void> {
if (!this.config) return;
// refresh mcp servers
this.config.getToolRegistry().restartMcpServers();
// refresh skills
this.config.getSkillManager()?.refreshCache();
// refresh subagents
this.config.getSubagentManager().refreshCache();
// refresh context files
this.config.refreshHierarchicalMemory();
}
async refreshTools(): Promise<void> {
if (!this.config) return;
// FIXME: restart all mcp servers now, this can be optimized by only restarting changed ones at here
this.refreshMemory();
}
}
export async function copyExtension(
source: string,
destination: string,
): Promise<void> {
await fs.promises.cp(source, destination, { recursive: true });
}
export function getExtensionId(
config: ExtensionConfig,
installMetadata?: ExtensionInstallMetadata,
): string {
let idValue = config.name;
const githubUrlParts =
installMetadata &&
(installMetadata.type === 'git' ||
installMetadata.type === 'github-release')
? parseGitHubRepoForReleases(installMetadata.source)
: null;
if (githubUrlParts) {
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');
}
export 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 parseInstallSource(
source: string,
): Promise<ExtensionInstallMetadata> {
let installMetadata: ExtensionInstallMetadata;
const marketplaceParsed = parseMarketplaceSource(source);
if (marketplaceParsed) {
installMetadata = {
source,
type: 'marketplace',
marketplace: marketplaceParsed,
};
} else if (
source.startsWith('http://') ||
source.startsWith('https://') ||
source.startsWith('git@') ||
source.startsWith('sso://')
) {
installMetadata = {
source,
type: 'git',
};
} else {
try {
await stat(source);
installMetadata = {
source,
type: 'local',
};
} catch {
throw new Error('Install source not found.');
}
}
return installMetadata;
}