mirror of
https://github.com/QwenLM/qwen-code.git
synced 2026-04-30 20:50:34 +00:00
feat: move extension to core package
This commit is contained in:
parent
74013bd8b2
commit
551e546974
71 changed files with 3222 additions and 3626 deletions
|
|
@ -9,7 +9,6 @@ import {
|
|||
AuthType,
|
||||
Config,
|
||||
DEFAULT_QWEN_EMBEDDING_MODEL,
|
||||
DEFAULT_MEMORY_FILE_FILTERING_OPTIONS,
|
||||
EditTool,
|
||||
FileDiscoveryService,
|
||||
getCurrentGeminiMdFilename,
|
||||
|
|
@ -25,7 +24,6 @@ import {
|
|||
SessionService,
|
||||
type ResumedSessionData,
|
||||
type FileFilteringOptions,
|
||||
type MCPServerConfig,
|
||||
} from '@qwen-code/qwen-code-core';
|
||||
import { extensionsCommand } from '../commands/extensions.js';
|
||||
import type { Settings } from './settings.js';
|
||||
|
|
@ -37,14 +35,11 @@ import { homedir } from 'node:os';
|
|||
|
||||
import { resolvePath } from '../utils/resolvePath.js';
|
||||
import { getCliVersion } from '../utils/version.js';
|
||||
import type { Extension } from './extension.js';
|
||||
import { annotateActiveExtensions } from './extension.js';
|
||||
import { loadSandboxConfig } from './sandboxConfig.js';
|
||||
import { appEvents } from '../utils/events.js';
|
||||
import { mcpCommand } from '../commands/mcp.js';
|
||||
|
||||
import { isWorkspaceTrusted } from './trustedFolders.js';
|
||||
import type { ExtensionEnablementManager } from './extensions/extensionEnablement.js';
|
||||
import { buildWebSearchConfig } from './webSearch.js';
|
||||
|
||||
// Simple console logger for now - replace with actual logger if available
|
||||
|
|
@ -162,7 +157,7 @@ function normalizeOutputFormat(
|
|||
return OutputFormat.TEXT;
|
||||
}
|
||||
|
||||
export async function parseArguments(settings: Settings): Promise<CliArgs> {
|
||||
export async function parseArguments(): Promise<CliArgs> {
|
||||
const rawArgv = hideBin(process.argv);
|
||||
const yargsInstance = yargs(rawArgv)
|
||||
.locale('en')
|
||||
|
|
@ -537,11 +532,9 @@ export async function parseArguments(settings: Settings): Promise<CliArgs> {
|
|||
}),
|
||||
)
|
||||
// Register MCP subcommands
|
||||
.command(mcpCommand);
|
||||
|
||||
if (settings?.experimental?.extensionManagement ?? true) {
|
||||
yargsInstance.command(extensionsCommand);
|
||||
}
|
||||
.command(mcpCommand)
|
||||
// Register Extension subcommands
|
||||
.command(extensionsCommand);
|
||||
|
||||
yargsInstance
|
||||
.version(await getCliVersion()) // This will enable the --version flag based on package.json
|
||||
|
|
@ -605,11 +598,11 @@ export async function loadHierarchicalGeminiMemory(
|
|||
includeDirectoriesToReadGemini: readonly string[] = [],
|
||||
debugMode: boolean,
|
||||
fileService: FileDiscoveryService,
|
||||
settings: Settings,
|
||||
extensionContextFilePaths: string[] = [],
|
||||
folderTrust: boolean,
|
||||
memoryImportFormat: 'flat' | 'tree' = 'tree',
|
||||
fileFilteringOptions?: FileFilteringOptions,
|
||||
maxDirs: number = 200,
|
||||
): Promise<{ memoryContent: string; fileCount: number }> {
|
||||
// FIX: Use real, canonical paths for a reliable comparison to handle symlinks.
|
||||
const realCwd = fs.realpathSync(path.resolve(currentWorkingDirectory));
|
||||
|
|
@ -636,7 +629,7 @@ export async function loadHierarchicalGeminiMemory(
|
|||
folderTrust,
|
||||
memoryImportFormat,
|
||||
fileFilteringOptions,
|
||||
settings.context?.discoveryMaxDirs,
|
||||
maxDirs,
|
||||
);
|
||||
}
|
||||
|
||||
|
|
@ -651,30 +644,17 @@ export function isDebugMode(argv: CliArgs): boolean {
|
|||
|
||||
export async function loadCliConfig(
|
||||
settings: Settings,
|
||||
extensions: Extension[],
|
||||
extensionEnablementManager: ExtensionEnablementManager,
|
||||
argv: CliArgs,
|
||||
cwd: string = process.cwd(),
|
||||
overrideExtensions?: string[],
|
||||
): Promise<Config> {
|
||||
const debugMode = isDebugMode(argv);
|
||||
|
||||
const memoryImportFormat = settings.context?.importFormat || 'tree';
|
||||
|
||||
const ideMode = settings.ide?.enabled ?? false;
|
||||
|
||||
const folderTrust = settings.security?.folderTrust?.enabled ?? false;
|
||||
const trustedFolder = isWorkspaceTrusted(settings)?.isTrusted ?? true;
|
||||
|
||||
const allExtensions = annotateActiveExtensions(
|
||||
extensions,
|
||||
cwd,
|
||||
extensionEnablementManager,
|
||||
);
|
||||
|
||||
const activeExtensions = extensions.filter(
|
||||
(_, i) => allExtensions[i].isActive,
|
||||
);
|
||||
|
||||
// Set the context filename in the server's memoryTool module BEFORE loading memory
|
||||
// TODO(b/343434939): This is a bit of a hack. The contextFileName should ideally be passed
|
||||
// directly to the Config constructor in core, and have core handle setGeminiMdFilename.
|
||||
|
|
@ -686,51 +666,27 @@ export async function loadCliConfig(
|
|||
setServerGeminiMdFilename(getCurrentGeminiMdFilename());
|
||||
}
|
||||
|
||||
const extensionContextFilePaths = activeExtensions.flatMap(
|
||||
(e) => e.contextFiles,
|
||||
);
|
||||
|
||||
// Automatically load output-language.md if it exists
|
||||
const outputLanguageFilePath = path.join(
|
||||
let outputLanguageFilePath: string | undefined = path.join(
|
||||
Storage.getGlobalQwenDir(),
|
||||
'output-language.md',
|
||||
);
|
||||
if (fs.existsSync(outputLanguageFilePath)) {
|
||||
extensionContextFilePaths.push(outputLanguageFilePath);
|
||||
if (debugMode) {
|
||||
logger.debug(
|
||||
`Found output-language.md, adding to context files: ${outputLanguageFilePath}`,
|
||||
);
|
||||
}
|
||||
} else {
|
||||
outputLanguageFilePath = undefined;
|
||||
}
|
||||
|
||||
const fileService = new FileDiscoveryService(cwd);
|
||||
|
||||
const fileFiltering = {
|
||||
...DEFAULT_MEMORY_FILE_FILTERING_OPTIONS,
|
||||
...settings.context?.fileFiltering,
|
||||
};
|
||||
|
||||
const includeDirectories = (settings.context?.includeDirectories || [])
|
||||
.map(resolvePath)
|
||||
.concat((argv.includeDirectories || []).map(resolvePath));
|
||||
|
||||
// Call the (now wrapper) loadHierarchicalGeminiMemory which calls the server's version
|
||||
const { memoryContent, fileCount } = await loadHierarchicalGeminiMemory(
|
||||
cwd,
|
||||
settings.context?.loadMemoryFromIncludeDirectories
|
||||
? includeDirectories
|
||||
: [],
|
||||
debugMode,
|
||||
fileService,
|
||||
settings,
|
||||
extensionContextFilePaths,
|
||||
trustedFolder,
|
||||
memoryImportFormat,
|
||||
fileFiltering,
|
||||
);
|
||||
|
||||
let mcpServers = mergeMcpServers(settings, activeExtensions);
|
||||
const question = argv.promptInteractive || argv.prompt || '';
|
||||
const inputFormat: InputFormat =
|
||||
(argv.inputFormat as InputFormat | undefined) ?? InputFormat.TEXT;
|
||||
|
|
@ -844,38 +800,18 @@ export async function loadCliConfig(
|
|||
|
||||
const excludeTools = mergeExcludeTools(
|
||||
settings,
|
||||
activeExtensions,
|
||||
extraExcludes.length > 0 ? extraExcludes : undefined,
|
||||
argv.excludeTools,
|
||||
);
|
||||
const blockedMcpServers: Array<{ name: string; extensionName: string }> = [];
|
||||
|
||||
if (!argv.allowedMcpServerNames) {
|
||||
if (settings.mcp?.allowed) {
|
||||
mcpServers = allowedMcpServers(
|
||||
mcpServers,
|
||||
settings.mcp.allowed,
|
||||
blockedMcpServers,
|
||||
);
|
||||
}
|
||||
|
||||
if (settings.mcp?.excluded) {
|
||||
const excludedNames = new Set(settings.mcp.excluded.filter(Boolean));
|
||||
if (excludedNames.size > 0) {
|
||||
mcpServers = Object.fromEntries(
|
||||
Object.entries(mcpServers).filter(([key]) => !excludedNames.has(key)),
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (argv.allowedMcpServerNames) {
|
||||
mcpServers = allowedMcpServers(
|
||||
mcpServers,
|
||||
argv.allowedMcpServerNames,
|
||||
blockedMcpServers,
|
||||
);
|
||||
}
|
||||
const allowedMcpServers = argv.allowedMcpServerNames
|
||||
? new Set(argv.allowedMcpServerNames.filter(Boolean))
|
||||
: settings.mcp?.allowed
|
||||
? new Set(settings.mcp.allowed.filter(Boolean))
|
||||
: undefined;
|
||||
const excludedMcpServers = settings.mcp?.excluded
|
||||
? new Set(settings.mcp.excluded.filter(Boolean))
|
||||
: undefined;
|
||||
|
||||
const selectedAuthType =
|
||||
(argv.authType as AuthType | undefined) ||
|
||||
|
|
@ -943,6 +879,8 @@ export async function loadCliConfig(
|
|||
includeDirectories,
|
||||
loadMemoryFromIncludeDirectories:
|
||||
settings.context?.loadMemoryFromIncludeDirectories || false,
|
||||
importFormat: settings.context?.importFormat || 'tree',
|
||||
discoveryMaxDirs: settings.context?.discoveryMaxDirs || 200,
|
||||
debugMode,
|
||||
question,
|
||||
fullContext: argv.allFiles || false,
|
||||
|
|
@ -952,9 +890,13 @@ export async function loadCliConfig(
|
|||
toolDiscoveryCommand: settings.tools?.discoveryCommand,
|
||||
toolCallCommand: settings.tools?.callCommand,
|
||||
mcpServerCommand: settings.mcp?.serverCommand,
|
||||
mcpServers,
|
||||
userMemory: memoryContent,
|
||||
geminiMdFileCount: fileCount,
|
||||
mcpServers: settings.mcpServers || {},
|
||||
allowedMcpServers: allowedMcpServers
|
||||
? Array.from(allowedMcpServers)
|
||||
: undefined,
|
||||
excludedMcpServers: excludedMcpServers
|
||||
? Array.from(excludedMcpServers)
|
||||
: undefined,
|
||||
approvalMode,
|
||||
showMemoryUsage:
|
||||
argv.showMemoryUsage || settings.ui?.showMemoryUsage || false,
|
||||
|
|
@ -977,15 +919,14 @@ export async function loadCliConfig(
|
|||
fileDiscoveryService: fileService,
|
||||
bugCommand: settings.advanced?.bugCommand,
|
||||
model: resolvedModel,
|
||||
extensionContextFilePaths,
|
||||
outputLanguageFilePath,
|
||||
sessionTokenLimit: settings.model?.sessionTokenLimit ?? -1,
|
||||
maxSessionTurns:
|
||||
argv.maxSessionTurns ?? settings.model?.maxSessionTurns ?? -1,
|
||||
experimentalZedIntegration: argv.experimentalAcp || false,
|
||||
experimentalSkills: argv.experimentalSkills || false,
|
||||
listExtensions: argv.listExtensions || false,
|
||||
extensions: allExtensions,
|
||||
blockedMcpServers,
|
||||
overrideExtensions: overrideExtensions || argv.extensions,
|
||||
noBrowser: !!process.env['NO_BROWSER'],
|
||||
authType: selectedAuthType,
|
||||
inputFormat,
|
||||
|
|
@ -1040,61 +981,8 @@ export async function loadCliConfig(
|
|||
});
|
||||
}
|
||||
|
||||
function allowedMcpServers(
|
||||
mcpServers: { [x: string]: MCPServerConfig },
|
||||
allowMCPServers: string[],
|
||||
blockedMcpServers: Array<{ name: string; extensionName: string }>,
|
||||
) {
|
||||
const allowedNames = new Set(allowMCPServers.filter(Boolean));
|
||||
if (allowedNames.size > 0) {
|
||||
mcpServers = Object.fromEntries(
|
||||
Object.entries(mcpServers).filter(([key, server]) => {
|
||||
const isAllowed = allowedNames.has(key);
|
||||
if (!isAllowed) {
|
||||
blockedMcpServers.push({
|
||||
name: key,
|
||||
extensionName: server.extensionName || '',
|
||||
});
|
||||
}
|
||||
return isAllowed;
|
||||
}),
|
||||
);
|
||||
} else {
|
||||
blockedMcpServers.push(
|
||||
...Object.entries(mcpServers).map(([key, server]) => ({
|
||||
name: key,
|
||||
extensionName: server.extensionName || '',
|
||||
})),
|
||||
);
|
||||
mcpServers = {};
|
||||
}
|
||||
return mcpServers;
|
||||
}
|
||||
|
||||
function mergeMcpServers(settings: Settings, extensions: Extension[]) {
|
||||
const mcpServers = { ...(settings.mcpServers || {}) };
|
||||
for (const extension of extensions) {
|
||||
Object.entries(extension.config.mcpServers || {}).forEach(
|
||||
([key, server]) => {
|
||||
if (mcpServers[key]) {
|
||||
logger.warn(
|
||||
`Skipping extension MCP config for server with key "${key}" as it already exists.`,
|
||||
);
|
||||
return;
|
||||
}
|
||||
mcpServers[key] = {
|
||||
...server,
|
||||
extensionName: extension.config.name,
|
||||
};
|
||||
},
|
||||
);
|
||||
}
|
||||
return mcpServers;
|
||||
}
|
||||
|
||||
function mergeExcludeTools(
|
||||
settings: Settings,
|
||||
extensions: Extension[],
|
||||
extraExcludes?: string[] | undefined,
|
||||
cliExcludeTools?: string[] | undefined,
|
||||
): string[] {
|
||||
|
|
@ -1103,10 +991,5 @@ function mergeExcludeTools(
|
|||
...(settings.tools?.exclude || []),
|
||||
...(extraExcludes || []),
|
||||
]);
|
||||
for (const extension of extensions) {
|
||||
for (const tool of extension.config.excludeTools || []) {
|
||||
allExcludeTools.add(tool);
|
||||
}
|
||||
}
|
||||
return [...allExcludeTools];
|
||||
}
|
||||
|
|
|
|||
File diff suppressed because it is too large
Load diff
|
|
@ -1,885 +0,0 @@
|
|||
/**
|
||||
* @license
|
||||
* Copyright 2025 Google LLC
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
import type {
|
||||
MCPServerConfig,
|
||||
GeminiCLIExtension,
|
||||
ExtensionInstallMetadata,
|
||||
} from '@qwen-code/qwen-code-core';
|
||||
import {
|
||||
Storage,
|
||||
Config,
|
||||
ExtensionInstallEvent,
|
||||
ExtensionUninstallEvent,
|
||||
ExtensionDisableEvent,
|
||||
ExtensionEnableEvent,
|
||||
logExtensionEnable,
|
||||
logExtensionInstallEvent,
|
||||
logExtensionUninstall,
|
||||
logExtensionDisable,
|
||||
} from '@qwen-code/qwen-code-core';
|
||||
import * as fs from 'node:fs';
|
||||
import * as path from 'node:path';
|
||||
import * as os from 'node:os';
|
||||
import { SettingScope, loadSettings } from '../config/settings.js';
|
||||
import { getErrorMessage } from '../utils/errors.js';
|
||||
import {
|
||||
EXTENSIONS_CONFIG_FILENAME,
|
||||
INSTALL_METADATA_FILENAME,
|
||||
recursivelyHydrateStrings,
|
||||
} from './extensions/variables.js';
|
||||
import { isWorkspaceTrusted } from './trustedFolders.js';
|
||||
import { resolveEnvVarsInObject } from '../utils/envVarResolver.js';
|
||||
import {
|
||||
cloneFromGit,
|
||||
downloadFromGitHubRelease,
|
||||
parseGitHubRepoForReleases,
|
||||
} from './extensions/github.js';
|
||||
import type { LoadExtensionContext } from './extensions/variableSchema.js';
|
||||
import { ExtensionEnablementManager } from './extensions/extensionEnablement.js';
|
||||
import chalk from 'chalk';
|
||||
import type { ConfirmationRequest } from '../ui/types.js';
|
||||
import {
|
||||
installFromMarketplace,
|
||||
parseMarketplaceSource,
|
||||
} from './extensions/marketplace.js';
|
||||
import { isClaudePluginConfig } from './extensions/claude-converter.js';
|
||||
import {
|
||||
isGeminiExtensionConfig,
|
||||
convertGeminiExtensionPackage,
|
||||
} from './extensions/gemini-converter.js';
|
||||
import { glob } from 'glob';
|
||||
import { createHash } from 'node:crypto';
|
||||
import { maybeRequestConsentOrFail } from './extensions/consent.js';
|
||||
import { ExtensionStorage } from './extensions/storage.js';
|
||||
import {
|
||||
maybePromptForSettings,
|
||||
promptForSetting,
|
||||
} from './extensions/extensionSettings.js';
|
||||
|
||||
export interface Extension {
|
||||
path: string;
|
||||
config: ExtensionConfig;
|
||||
contextFiles: string[];
|
||||
installMetadata?: ExtensionInstallMetadata | undefined;
|
||||
/** Absolute paths to skills directories from this extension */
|
||||
skillsPaths?: string[];
|
||||
/** Absolute paths to agents files from this extension */
|
||||
agentsPaths?: string[];
|
||||
}
|
||||
|
||||
export interface ExtensionConfig {
|
||||
name: string;
|
||||
version: string;
|
||||
mcpServers?: Record<string, MCPServerConfig>;
|
||||
contextFileName?: string | string[];
|
||||
excludeTools?: string[];
|
||||
commands?: string | string[];
|
||||
skills?: string | string[];
|
||||
agents?: string | string[];
|
||||
settings?: ExtensionSetting[];
|
||||
}
|
||||
|
||||
export interface ExtensionSetting {
|
||||
name: string;
|
||||
description: string;
|
||||
envVar: string;
|
||||
sensitive?: boolean;
|
||||
}
|
||||
|
||||
export interface ExtensionUpdateInfo {
|
||||
name: string;
|
||||
originalVersion: string;
|
||||
updatedVersion: string;
|
||||
}
|
||||
|
||||
export function getWorkspaceExtensions(workspaceDir: string): Extension[] {
|
||||
// If the workspace dir is the user extensions dir, there are no workspace extensions.
|
||||
if (path.resolve(workspaceDir) === path.resolve(os.homedir())) {
|
||||
return [];
|
||||
}
|
||||
return loadExtensionsFromDir(workspaceDir);
|
||||
}
|
||||
|
||||
export async function copyExtension(
|
||||
source: string,
|
||||
destination: string,
|
||||
): Promise<void> {
|
||||
await fs.promises.cp(source, destination, { recursive: true });
|
||||
}
|
||||
|
||||
export async function performWorkspaceExtensionMigration(
|
||||
extensions: Extension[],
|
||||
requestConsent: (consent: string) => Promise<boolean>,
|
||||
): Promise<string[]> {
|
||||
const failedInstallNames: string[] = [];
|
||||
|
||||
for (const extension of extensions) {
|
||||
try {
|
||||
const installMetadata: ExtensionInstallMetadata = {
|
||||
source: extension.path,
|
||||
type: 'local',
|
||||
};
|
||||
await installExtension(installMetadata, requestConsent);
|
||||
} catch (_) {
|
||||
failedInstallNames.push(extension.config.name);
|
||||
}
|
||||
}
|
||||
return failedInstallNames;
|
||||
}
|
||||
|
||||
function getTelemetryConfig(cwd: string) {
|
||||
const settings = loadSettings(cwd);
|
||||
const config = new Config({
|
||||
telemetry: settings.merged.telemetry,
|
||||
interactive: false,
|
||||
targetDir: cwd,
|
||||
cwd,
|
||||
model: '',
|
||||
debugMode: false,
|
||||
});
|
||||
return config;
|
||||
}
|
||||
|
||||
export function loadExtensions(
|
||||
extensionEnablementManager: ExtensionEnablementManager,
|
||||
workspaceDir: string = process.cwd(),
|
||||
): Extension[] {
|
||||
const settings = loadSettings(workspaceDir).merged;
|
||||
const allExtensions = [...loadUserExtensions()];
|
||||
|
||||
if (
|
||||
(isWorkspaceTrusted(settings) ?? true) &&
|
||||
// Default management setting to true
|
||||
!(settings.experimental?.extensionManagement ?? true)
|
||||
) {
|
||||
allExtensions.push(...getWorkspaceExtensions(workspaceDir));
|
||||
}
|
||||
|
||||
const uniqueExtensions = new Map<string, Extension>();
|
||||
|
||||
for (const extension of allExtensions) {
|
||||
if (
|
||||
!uniqueExtensions.has(extension.config.name) &&
|
||||
extensionEnablementManager.isEnabled(extension.config.name, workspaceDir)
|
||||
) {
|
||||
uniqueExtensions.set(extension.config.name, extension);
|
||||
}
|
||||
}
|
||||
|
||||
return Array.from(uniqueExtensions.values());
|
||||
}
|
||||
|
||||
export function loadUserExtensions(): Extension[] {
|
||||
const userExtensions = loadExtensionsFromDir(os.homedir());
|
||||
|
||||
const uniqueExtensions = new Map<string, Extension>();
|
||||
for (const extension of userExtensions) {
|
||||
if (!uniqueExtensions.has(extension.config.name)) {
|
||||
uniqueExtensions.set(extension.config.name, extension);
|
||||
}
|
||||
}
|
||||
|
||||
return Array.from(uniqueExtensions.values());
|
||||
}
|
||||
|
||||
export function loadExtensionsFromDir(dir: string): Extension[] {
|
||||
const storage = new Storage(dir);
|
||||
const extensionsDir = storage.getExtensionsDir();
|
||||
if (!fs.existsSync(extensionsDir)) {
|
||||
return [];
|
||||
}
|
||||
|
||||
const extensions: Extension[] = [];
|
||||
for (const subdir of fs.readdirSync(extensionsDir)) {
|
||||
const extensionDir = path.join(extensionsDir, subdir);
|
||||
|
||||
const extension = loadExtension({ extensionDir, workspaceDir: dir });
|
||||
if (extension != null) {
|
||||
extensions.push(extension);
|
||||
}
|
||||
}
|
||||
return extensions;
|
||||
}
|
||||
|
||||
export function loadExtension(context: LoadExtensionContext): Extension | null {
|
||||
const { extensionDir, workspaceDir } = context;
|
||||
if (!fs.statSync(extensionDir).isDirectory()) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const installMetadata = loadInstallMetadata(extensionDir);
|
||||
let effectiveExtensionPath = extensionDir;
|
||||
|
||||
if (installMetadata?.type === 'link') {
|
||||
effectiveExtensionPath = installMetadata.source;
|
||||
}
|
||||
|
||||
try {
|
||||
let config = loadExtensionConfig({
|
||||
extensionDir: effectiveExtensionPath,
|
||||
workspaceDir,
|
||||
});
|
||||
|
||||
config = resolveEnvVarsInObject(config);
|
||||
|
||||
if (config.mcpServers) {
|
||||
config.mcpServers = Object.fromEntries(
|
||||
Object.entries(config.mcpServers).map(([key, value]) => [
|
||||
key,
|
||||
filterMcpConfig(value),
|
||||
]),
|
||||
);
|
||||
}
|
||||
|
||||
const contextFiles = getContextFileNames(config)
|
||||
.map((contextFileName) =>
|
||||
path.join(effectiveExtensionPath, contextFileName),
|
||||
)
|
||||
.filter((contextFilePath) => fs.existsSync(contextFilePath));
|
||||
|
||||
const skillsPaths = collectPathsFromConfig(
|
||||
config.skills,
|
||||
effectiveExtensionPath,
|
||||
);
|
||||
const agentsPaths = collectPathsFromConfig(
|
||||
config.agents,
|
||||
effectiveExtensionPath,
|
||||
);
|
||||
|
||||
return {
|
||||
path: effectiveExtensionPath,
|
||||
config,
|
||||
contextFiles,
|
||||
installMetadata,
|
||||
skillsPaths,
|
||||
agentsPaths,
|
||||
};
|
||||
} catch (e) {
|
||||
console.error(
|
||||
`Warning: Skipping extension in ${effectiveExtensionPath}: ${getErrorMessage(
|
||||
e,
|
||||
)}`,
|
||||
);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
export function loadExtensionByName(
|
||||
name: string,
|
||||
workspaceDir: string = process.cwd(),
|
||||
): Extension | null {
|
||||
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 = loadExtension({ extensionDir, workspaceDir });
|
||||
if (
|
||||
extension &&
|
||||
extension.config.name.toLowerCase() === name.toLowerCase()
|
||||
) {
|
||||
return extension;
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
function filterMcpConfig(original: MCPServerConfig): MCPServerConfig {
|
||||
// eslint-disable-next-line @typescript-eslint/no-unused-vars
|
||||
const { trust, ...rest } = original;
|
||||
return Object.freeze(rest);
|
||||
}
|
||||
|
||||
export function 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;
|
||||
}
|
||||
}
|
||||
|
||||
function getContextFileNames(config: ExtensionConfig): string[] {
|
||||
if (!config.contextFileName) {
|
||||
return ['QWEN.md'];
|
||||
} else if (!Array.isArray(config.contextFileName)) {
|
||||
return [config.contextFileName];
|
||||
}
|
||||
return config.contextFileName;
|
||||
}
|
||||
|
||||
function collectPathsFromConfig(
|
||||
configValue: string | string[] | undefined,
|
||||
extensionPath: string,
|
||||
): string[] {
|
||||
if (!configValue) return [];
|
||||
|
||||
const pathArray = Array.isArray(configValue) ? configValue : [configValue];
|
||||
const absolutePaths: string[] = [];
|
||||
|
||||
for (const relativePath of pathArray) {
|
||||
const absolutePath = path.isAbsolute(relativePath)
|
||||
? relativePath
|
||||
: path.join(extensionPath, relativePath);
|
||||
|
||||
if (fs.existsSync(absolutePath)) {
|
||||
absolutePaths.push(absolutePath);
|
||||
}
|
||||
}
|
||||
return absolutePaths;
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns an annotated list of extensions. If an extension is listed in enabledExtensionNames, it will be active.
|
||||
* If enabledExtensionNames is empty, an extension is active unless it is disabled.
|
||||
* @param extensions The base list of extensions.
|
||||
* @param enabledExtensionNames The names of explicitly enabled extensions.
|
||||
* @param workspaceDir The current workspace directory.
|
||||
*/
|
||||
export function annotateActiveExtensions(
|
||||
extensions: Extension[],
|
||||
workspaceDir: string,
|
||||
manager: ExtensionEnablementManager,
|
||||
): GeminiCLIExtension[] {
|
||||
manager.validateExtensionOverrides(extensions);
|
||||
return extensions.map((extension) => ({
|
||||
name: extension.config.name,
|
||||
version: extension.config.version,
|
||||
isActive: manager.isEnabled(extension.config.name, workspaceDir),
|
||||
path: extension.path,
|
||||
installMetadata: extension.installMetadata,
|
||||
}));
|
||||
}
|
||||
|
||||
/**
|
||||
* Requests consent from the user to perform an action, by reading a Y/n
|
||||
* character from stdin.
|
||||
*
|
||||
* This should not be called from interactive mode as it will break the CLI.
|
||||
*
|
||||
* @param consentDescription The description of the thing they will be consenting to.
|
||||
* @returns boolean, whether they consented or not.
|
||||
*/
|
||||
export async function requestConsentNonInteractive(
|
||||
consentDescription: string,
|
||||
): Promise<boolean> {
|
||||
console.info(consentDescription);
|
||||
const result = await promptForConsentNonInteractive(
|
||||
'Do you want to continue? [Y/n]: ',
|
||||
);
|
||||
return result;
|
||||
}
|
||||
|
||||
/**
|
||||
* Requests consent from the user to perform an action, in interactive mode.
|
||||
*
|
||||
* This should not be called from non-interactive mode as it will not work.
|
||||
*
|
||||
* @param consentDescription The description of the thing they will be consenting to.
|
||||
* @param setExtensionUpdateConfirmationRequest A function to actually add a prompt to the UI.
|
||||
* @returns boolean, whether they consented or not.
|
||||
*/
|
||||
export async function requestConsentInteractive(
|
||||
consentDescription: string,
|
||||
addExtensionUpdateConfirmationRequest: (value: ConfirmationRequest) => void,
|
||||
): Promise<boolean> {
|
||||
return await promptForConsentInteractive(
|
||||
consentDescription + '\n\nDo you want to continue?',
|
||||
addExtensionUpdateConfirmationRequest,
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Asks users a prompt and awaits for a y/n response on stdin.
|
||||
*
|
||||
* This should not be called from interactive mode as it will break the CLI.
|
||||
*
|
||||
* @param prompt A yes/no prompt to ask the user
|
||||
* @returns Whether or not the user answers 'y' (yes). Defaults to 'yes' on enter.
|
||||
*/
|
||||
async function promptForConsentNonInteractive(
|
||||
prompt: string,
|
||||
): Promise<boolean> {
|
||||
const readline = await import('node:readline');
|
||||
const rl = readline.createInterface({
|
||||
input: process.stdin,
|
||||
output: process.stdout,
|
||||
});
|
||||
|
||||
return new Promise((resolve) => {
|
||||
rl.question(prompt, (answer) => {
|
||||
rl.close();
|
||||
resolve(['y', ''].includes(answer.trim().toLowerCase()));
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Asks users an interactive yes/no prompt.
|
||||
*
|
||||
* This should not be called from non-interactive mode as it will break the CLI.
|
||||
*
|
||||
* @param prompt A markdown prompt to ask the user
|
||||
* @param setExtensionUpdateConfirmationRequest Function to update the UI state with the confirmation request.
|
||||
* @returns Whether or not the user answers yes.
|
||||
*/
|
||||
async function promptForConsentInteractive(
|
||||
prompt: string,
|
||||
addExtensionUpdateConfirmationRequest: (value: ConfirmationRequest) => void,
|
||||
): Promise<boolean> {
|
||||
return await new Promise<boolean>((resolve) => {
|
||||
addExtensionUpdateConfirmationRequest({
|
||||
prompt,
|
||||
onConfirm: (resolvedConfirmed) => {
|
||||
resolve(resolvedConfirmed);
|
||||
},
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
export async function installExtension(
|
||||
installMetadata: ExtensionInstallMetadata,
|
||||
requestConsent: (consent: string) => Promise<boolean>,
|
||||
cwd: string = process.cwd(),
|
||||
previousExtensionConfig?: ExtensionConfig,
|
||||
): Promise<string> {
|
||||
const telemetryConfig = getTelemetryConfig(cwd);
|
||||
let newExtensionConfig: ExtensionConfig | null = null;
|
||||
let localSourcePath: string | undefined;
|
||||
|
||||
try {
|
||||
const settings = loadSettings(cwd).merged;
|
||||
if (!isWorkspaceTrusted(settings)) {
|
||||
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(cwd, installMetadata.source);
|
||||
}
|
||||
|
||||
let tempDir: 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();
|
||||
const marketplaceResult = await installFromMarketplace({
|
||||
marketplaceUrl: marketplaceParsed.marketplaceUrl,
|
||||
pluginName: marketplaceParsed.pluginName,
|
||||
tempDir,
|
||||
requestConsent,
|
||||
});
|
||||
|
||||
newExtensionConfig = marketplaceResult.config;
|
||||
localSourcePath = marketplaceResult.sourcePath;
|
||||
installMetadata = marketplaceResult.installMetadata;
|
||||
} 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);
|
||||
// Load extension config if not already loaded (from marketplace)
|
||||
if (!newExtensionConfig) {
|
||||
newExtensionConfig = loadExtensionConfig({
|
||||
extensionDir: localSourcePath,
|
||||
workspaceDir: cwd,
|
||||
});
|
||||
}
|
||||
|
||||
const newExtensionName = newExtensionConfig.name;
|
||||
const extensionStorage = new ExtensionStorage(newExtensionName);
|
||||
const destinationPath = extensionStorage.getExtensionDir();
|
||||
|
||||
const installedExtensions = loadUserExtensions();
|
||||
if (
|
||||
installedExtensions.some(
|
||||
(installed) => installed.config.name === newExtensionName,
|
||||
)
|
||||
) {
|
||||
throw new Error(
|
||||
`Extension "${newExtensionName}" is already installed. Please uninstall it first.`,
|
||||
);
|
||||
}
|
||||
|
||||
const commands = await loadCommandsFromDir(
|
||||
`${localSourcePath}/commands`,
|
||||
newExtensionConfig.name,
|
||||
);
|
||||
|
||||
await maybeRequestConsentOrFail(
|
||||
newExtensionConfig,
|
||||
requestConsent,
|
||||
commands,
|
||||
previousExtensionConfig,
|
||||
);
|
||||
const extensionId = getExtensionId(newExtensionConfig, installMetadata);
|
||||
await fs.promises.mkdir(destinationPath, { recursive: true });
|
||||
|
||||
await maybePromptForSettings(
|
||||
newExtensionConfig,
|
||||
extensionId,
|
||||
promptForSetting,
|
||||
);
|
||||
|
||||
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);
|
||||
} finally {
|
||||
if (tempDir) {
|
||||
await fs.promises.rm(tempDir, { recursive: true, force: true });
|
||||
}
|
||||
if (localSourcePath !== tempDir) {
|
||||
await fs.promises.rm(localSourcePath, { recursive: true, force: true });
|
||||
}
|
||||
}
|
||||
|
||||
logExtensionInstallEvent(
|
||||
telemetryConfig,
|
||||
new ExtensionInstallEvent(
|
||||
newExtensionConfig!.name,
|
||||
newExtensionConfig!.version,
|
||||
installMetadata.source,
|
||||
'success',
|
||||
),
|
||||
);
|
||||
|
||||
enableExtension(newExtensionConfig!.name, SettingScope.User);
|
||||
return newExtensionConfig!.name;
|
||||
} 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 = loadExtensionConfig({
|
||||
extensionDir: localSourcePath,
|
||||
workspaceDir: cwd,
|
||||
});
|
||||
} catch {
|
||||
// Ignore error, this is just for logging.
|
||||
}
|
||||
}
|
||||
logExtensionInstallEvent(
|
||||
telemetryConfig,
|
||||
new ExtensionInstallEvent(
|
||||
newExtensionConfig?.name ?? '',
|
||||
newExtensionConfig?.version ?? '',
|
||||
installMetadata.source,
|
||||
'error',
|
||||
),
|
||||
);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
async function loadCommandsFromDir(
|
||||
dir: string,
|
||||
extensionName: 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 baseCommandName = relativePath
|
||||
.split(path.sep)
|
||||
.map((segment) => segment.replaceAll(':', '_'))
|
||||
.join(':');
|
||||
|
||||
const commandName = `${extensionName}:${baseCommandName}`;
|
||||
return commandName;
|
||||
});
|
||||
|
||||
return commandNames;
|
||||
} catch (error) {
|
||||
// Ignore ENOENT (directory doesn't exist) and AbortError (operation was cancelled)
|
||||
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 [];
|
||||
}
|
||||
}
|
||||
|
||||
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.`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
async function convertGeminiOrClaudeExtension(extensionDir: 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 (isClaudePluginConfig(extensionDir)) {
|
||||
// claude
|
||||
}
|
||||
return newExtensionDir;
|
||||
}
|
||||
|
||||
export function loadExtensionConfig(
|
||||
context: LoadExtensionContext,
|
||||
): ExtensionConfig {
|
||||
const { extensionDir, 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,
|
||||
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,
|
||||
)}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
export async function uninstallExtension(
|
||||
extensionIdentifier: string,
|
||||
cwd: string = process.cwd(),
|
||||
): Promise<void> {
|
||||
const telemetryConfig = getTelemetryConfig(cwd);
|
||||
const installedExtensions = loadUserExtensions();
|
||||
const extensionName = installedExtensions.find(
|
||||
(installed) =>
|
||||
installed.config.name.toLowerCase() ===
|
||||
extensionIdentifier.toLowerCase() ||
|
||||
installed.installMetadata?.source.toLowerCase() ===
|
||||
extensionIdentifier.toLowerCase(),
|
||||
)?.config.name;
|
||||
if (!extensionName) {
|
||||
throw new Error(`Extension not found.`);
|
||||
}
|
||||
const manager = new ExtensionEnablementManager(
|
||||
ExtensionStorage.getUserExtensionsDir(),
|
||||
[extensionName],
|
||||
);
|
||||
manager.remove(extensionName);
|
||||
const storage = new ExtensionStorage(extensionName);
|
||||
|
||||
await fs.promises.rm(storage.getExtensionDir(), {
|
||||
recursive: true,
|
||||
force: true,
|
||||
});
|
||||
logExtensionUninstall(
|
||||
telemetryConfig,
|
||||
new ExtensionUninstallEvent(extensionName, 'success'),
|
||||
);
|
||||
}
|
||||
|
||||
export function toOutputString(
|
||||
extension: Extension,
|
||||
workspaceDir: string,
|
||||
): string {
|
||||
const manager = new ExtensionEnablementManager(
|
||||
ExtensionStorage.getUserExtensionsDir(),
|
||||
);
|
||||
const userEnabled = manager.isEnabled(extension.config.name, os.homedir());
|
||||
const workspaceEnabled = manager.isEnabled(
|
||||
extension.config.name,
|
||||
workspaceDir,
|
||||
);
|
||||
|
||||
const status = workspaceEnabled ? chalk.green('✓') : chalk.red('✗');
|
||||
let output = `${status} ${extension.config.name} (${extension.config.version})`;
|
||||
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.config.mcpServers) {
|
||||
output += `\n MCP servers:`;
|
||||
Object.keys(extension.config.mcpServers).forEach((key) => {
|
||||
output += `\n ${key}`;
|
||||
});
|
||||
}
|
||||
if (extension.config.excludeTools) {
|
||||
output += `\n Excluded tools:`;
|
||||
extension.config.excludeTools.forEach((tool) => {
|
||||
output += `\n ${tool}`;
|
||||
});
|
||||
}
|
||||
return output;
|
||||
}
|
||||
|
||||
export function disableExtension(
|
||||
name: string,
|
||||
scope: SettingScope,
|
||||
cwd: string = process.cwd(),
|
||||
) {
|
||||
const config = getTelemetryConfig(cwd);
|
||||
if (scope === SettingScope.System || scope === SettingScope.SystemDefaults) {
|
||||
throw new Error('System and SystemDefaults scopes are not supported.');
|
||||
}
|
||||
const extension = loadExtensionByName(name, cwd);
|
||||
if (!extension) {
|
||||
throw new Error(`Extension with name ${name} does not exist.`);
|
||||
}
|
||||
|
||||
const manager = new ExtensionEnablementManager(
|
||||
ExtensionStorage.getUserExtensionsDir(),
|
||||
[name],
|
||||
);
|
||||
const scopePath = scope === SettingScope.Workspace ? cwd : os.homedir();
|
||||
manager.disable(name, true, scopePath);
|
||||
logExtensionDisable(config, new ExtensionDisableEvent(name, scope));
|
||||
}
|
||||
|
||||
export function enableExtension(
|
||||
name: string,
|
||||
scope: SettingScope,
|
||||
cwd: string = process.cwd(),
|
||||
) {
|
||||
if (scope === SettingScope.System || scope === SettingScope.SystemDefaults) {
|
||||
throw new Error('System and SystemDefaults scopes are not supported.');
|
||||
}
|
||||
const extension = loadExtensionByName(name, cwd);
|
||||
if (!extension) {
|
||||
throw new Error(`Extension with name ${name} does not exist.`);
|
||||
}
|
||||
const manager = new ExtensionEnablementManager(
|
||||
ExtensionStorage.getUserExtensionsDir(),
|
||||
);
|
||||
const scopePath = scope === SettingScope.Workspace ? cwd : os.homedir();
|
||||
manager.enable(name, true, scopePath);
|
||||
const config = getTelemetryConfig(cwd);
|
||||
logExtensionEnable(config, new ExtensionEnableEvent(name, scope));
|
||||
}
|
||||
|
||||
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')
|
||||
? parseGitHubRepoForReleases(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');
|
||||
}
|
||||
|
|
@ -1,121 +0,0 @@
|
|||
/**
|
||||
* @license
|
||||
* Copyright 2025 Google LLC
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import {
|
||||
convertClaudeToQwenConfig,
|
||||
mergeClaudeConfigs,
|
||||
isClaudePluginConfig,
|
||||
type ClaudePluginConfig,
|
||||
type ClaudeMarketplacePluginConfig,
|
||||
} from './claude-converter.js';
|
||||
|
||||
describe('convertClaudeToQwenConfig', () => {
|
||||
it('should convert basic Claude config', () => {
|
||||
const claudeConfig: ClaudePluginConfig = {
|
||||
name: 'claude-plugin',
|
||||
version: '1.0.0',
|
||||
};
|
||||
|
||||
const result = convertClaudeToQwenConfig(claudeConfig);
|
||||
|
||||
expect(result.name).toBe('claude-plugin');
|
||||
expect(result.version).toBe('1.0.0');
|
||||
});
|
||||
|
||||
it('should convert config with commands and agents', () => {
|
||||
const claudeConfig: ClaudePluginConfig = {
|
||||
name: 'full-plugin',
|
||||
version: '1.0.0',
|
||||
commands: 'commands',
|
||||
agents: ['agents/agent1.md'],
|
||||
skills: ['skills/skill1'],
|
||||
};
|
||||
|
||||
const result = convertClaudeToQwenConfig(claudeConfig);
|
||||
|
||||
expect(result.commands).toBe('commands');
|
||||
expect(result.agents).toEqual(['agents/agent1.md']);
|
||||
expect(result.skills).toEqual(['skills/skill1']);
|
||||
});
|
||||
|
||||
it('should throw error for missing name', () => {
|
||||
const invalidConfig = {
|
||||
version: '1.0.0',
|
||||
} as ClaudePluginConfig;
|
||||
|
||||
expect(() => convertClaudeToQwenConfig(invalidConfig)).toThrow();
|
||||
});
|
||||
});
|
||||
|
||||
describe('mergeClaudeConfigs', () => {
|
||||
it('should merge marketplace and plugin configs', () => {
|
||||
const marketplacePlugin: ClaudeMarketplacePluginConfig = {
|
||||
name: 'marketplace-name',
|
||||
version: '2.0.0',
|
||||
source: 'github:org/repo',
|
||||
description: 'From marketplace',
|
||||
};
|
||||
|
||||
const pluginConfig: ClaudePluginConfig = {
|
||||
name: 'plugin-name',
|
||||
version: '1.0.0',
|
||||
commands: 'commands',
|
||||
};
|
||||
|
||||
const merged = mergeClaudeConfigs(marketplacePlugin, pluginConfig);
|
||||
|
||||
// Marketplace takes precedence
|
||||
expect(merged.name).toBe('marketplace-name');
|
||||
expect(merged.version).toBe('2.0.0');
|
||||
expect(merged.description).toBe('From marketplace');
|
||||
// Plugin fields preserved
|
||||
expect(merged.commands).toBe('commands');
|
||||
});
|
||||
|
||||
it('should work with strict=false and no plugin config', () => {
|
||||
const marketplacePlugin: ClaudeMarketplacePluginConfig = {
|
||||
name: 'standalone',
|
||||
version: '1.0.0',
|
||||
source: 'local',
|
||||
strict: false,
|
||||
commands: 'commands',
|
||||
};
|
||||
|
||||
const merged = mergeClaudeConfigs(marketplacePlugin);
|
||||
|
||||
expect(merged.name).toBe('standalone');
|
||||
expect(merged.commands).toBe('commands');
|
||||
});
|
||||
|
||||
it('should throw error for strict mode without plugin config', () => {
|
||||
const marketplacePlugin: ClaudeMarketplacePluginConfig = {
|
||||
name: 'strict-plugin',
|
||||
version: '1.0.0',
|
||||
source: 'github:org/repo',
|
||||
strict: true,
|
||||
};
|
||||
|
||||
expect(() => mergeClaudeConfigs(marketplacePlugin)).toThrow();
|
||||
});
|
||||
});
|
||||
|
||||
describe('isClaudePluginConfig', () => {
|
||||
it('should identify Claude config with agent field', () => {
|
||||
const config = {
|
||||
name: 'test',
|
||||
version: '1.0.0',
|
||||
agents: ['agent.md'],
|
||||
};
|
||||
|
||||
expect(isClaudePluginConfig(config)).toBe(true);
|
||||
});
|
||||
|
||||
it('should return false for invalid config', () => {
|
||||
expect(isClaudePluginConfig(null)).toBe(false);
|
||||
expect(isClaudePluginConfig({})).toBe(false);
|
||||
});
|
||||
});
|
||||
|
|
@ -1,181 +0,0 @@
|
|||
/**
|
||||
* @license
|
||||
* Copyright 2025 Google LLC
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
/**
|
||||
* Converter for Claude Code plugins to Qwen Code format.
|
||||
*/
|
||||
|
||||
import type { ExtensionConfig } from '../extension.js';
|
||||
import type { MCPServerConfig } from '@qwen-code/qwen-code-core';
|
||||
|
||||
export interface ClaudePluginConfig {
|
||||
name: string;
|
||||
version: string;
|
||||
description?: string;
|
||||
author?: { name?: string; email?: string; url?: string };
|
||||
homepage?: string;
|
||||
repository?: string;
|
||||
license?: string;
|
||||
keywords?: string[];
|
||||
commands?: string | string[];
|
||||
agents?: string | string[];
|
||||
skills?: string | string[];
|
||||
hooks?: string;
|
||||
mcpServers?: string | Record<string, MCPServerConfig>;
|
||||
outputStyles?: string | string[];
|
||||
lspServers?: string;
|
||||
}
|
||||
|
||||
export type ClaudePluginSource =
|
||||
| { source: 'github'; repo: string }
|
||||
| { source: 'url'; url: string };
|
||||
|
||||
export interface ClaudeMarketplacePluginConfig extends ClaudePluginConfig {
|
||||
source: string | ClaudePluginSource;
|
||||
category?: string;
|
||||
strict?: boolean;
|
||||
tags?: string[];
|
||||
}
|
||||
|
||||
export interface ClaudeMarketplaceConfig {
|
||||
name: string;
|
||||
owner: { name: string; email: string };
|
||||
plugins: ClaudeMarketplacePluginConfig[];
|
||||
metadata?: { description?: string; version?: string; pluginRoot?: string };
|
||||
}
|
||||
|
||||
/**
|
||||
* Converts a Claude plugin config to Qwen Code format.
|
||||
* @param claudeConfig Claude plugin configuration
|
||||
* @returns Qwen ExtensionConfig
|
||||
*/
|
||||
export function convertClaudeToQwenConfig(
|
||||
claudeConfig: ClaudePluginConfig,
|
||||
): ExtensionConfig {
|
||||
// Validate required fields
|
||||
if (!claudeConfig.name || !claudeConfig.version) {
|
||||
throw new Error('Claude plugin config must have name and version fields');
|
||||
}
|
||||
|
||||
// Parse MCP servers
|
||||
let mcpServers: Record<string, MCPServerConfig> | undefined;
|
||||
if (claudeConfig.mcpServers) {
|
||||
if (typeof claudeConfig.mcpServers === 'string') {
|
||||
// TODO: Load from file path
|
||||
console.warn(
|
||||
`[Claude Converter] MCP servers path not yet supported: ${claudeConfig.mcpServers}`,
|
||||
);
|
||||
} else {
|
||||
mcpServers = claudeConfig.mcpServers;
|
||||
}
|
||||
}
|
||||
|
||||
// Warn about unsupported fields
|
||||
if (claudeConfig.hooks) {
|
||||
console.warn(
|
||||
`[Claude Converter] Hooks are not yet supported in ${claudeConfig.name}`,
|
||||
);
|
||||
}
|
||||
if (claudeConfig.outputStyles) {
|
||||
console.warn(
|
||||
`[Claude Converter] Output styles are not yet supported in ${claudeConfig.name}`,
|
||||
);
|
||||
}
|
||||
if (claudeConfig.lspServers) {
|
||||
console.warn(
|
||||
`[Claude Converter] LSP servers are not yet supported in ${claudeConfig.name}`,
|
||||
);
|
||||
}
|
||||
|
||||
// Direct field mapping
|
||||
return {
|
||||
name: claudeConfig.name,
|
||||
version: claudeConfig.version,
|
||||
mcpServers,
|
||||
commands: claudeConfig.commands,
|
||||
skills: claudeConfig.skills,
|
||||
agents: claudeConfig.agents,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Merges marketplace plugin config with the actual plugin.json config.
|
||||
* Marketplace config takes precedence for conflicting fields.
|
||||
* @param marketplacePlugin Marketplace plugin definition
|
||||
* @param pluginConfig Actual plugin.json config (optional if strict=false)
|
||||
* @returns Merged Claude plugin config
|
||||
*/
|
||||
export function mergeClaudeConfigs(
|
||||
marketplacePlugin: ClaudeMarketplacePluginConfig,
|
||||
pluginConfig?: ClaudePluginConfig,
|
||||
): ClaudePluginConfig {
|
||||
if (!pluginConfig && marketplacePlugin.strict !== false) {
|
||||
throw new Error(
|
||||
`Plugin ${marketplacePlugin.name} requires plugin.json (strict mode)`,
|
||||
);
|
||||
}
|
||||
|
||||
// Start with plugin.json config (if exists)
|
||||
const merged: ClaudePluginConfig = pluginConfig
|
||||
? { ...pluginConfig }
|
||||
: {
|
||||
name: marketplacePlugin.name,
|
||||
version: '1.0.0', // Default version if not in marketplace
|
||||
};
|
||||
|
||||
// Overlay marketplace config (takes precedence)
|
||||
if (marketplacePlugin.name) merged.name = marketplacePlugin.name;
|
||||
if (marketplacePlugin.version) merged.version = marketplacePlugin.version;
|
||||
if (marketplacePlugin.description)
|
||||
merged.description = marketplacePlugin.description;
|
||||
if (marketplacePlugin.author) merged.author = marketplacePlugin.author;
|
||||
if (marketplacePlugin.homepage) merged.homepage = marketplacePlugin.homepage;
|
||||
if (marketplacePlugin.repository)
|
||||
merged.repository = marketplacePlugin.repository;
|
||||
if (marketplacePlugin.license) merged.license = marketplacePlugin.license;
|
||||
if (marketplacePlugin.keywords) merged.keywords = marketplacePlugin.keywords;
|
||||
if (marketplacePlugin.commands) merged.commands = marketplacePlugin.commands;
|
||||
if (marketplacePlugin.agents) merged.agents = marketplacePlugin.agents;
|
||||
if (marketplacePlugin.skills) merged.skills = marketplacePlugin.skills;
|
||||
if (marketplacePlugin.hooks) merged.hooks = marketplacePlugin.hooks;
|
||||
if (marketplacePlugin.mcpServers)
|
||||
merged.mcpServers = marketplacePlugin.mcpServers;
|
||||
if (marketplacePlugin.outputStyles)
|
||||
merged.outputStyles = marketplacePlugin.outputStyles;
|
||||
if (marketplacePlugin.lspServers)
|
||||
merged.lspServers = marketplacePlugin.lspServers;
|
||||
|
||||
return merged;
|
||||
}
|
||||
|
||||
/**
|
||||
* Checks if a config object is in Claude plugin format.
|
||||
* @param config Configuration object to check
|
||||
* @returns true if config appears to be Claude format
|
||||
*/
|
||||
export function isClaudePluginConfig(
|
||||
config: unknown,
|
||||
): config is ClaudePluginConfig {
|
||||
if (typeof config !== 'object' || config === null) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const obj = config as Record<string, unknown>;
|
||||
|
||||
// Must have name and version
|
||||
if (typeof obj['name'] !== 'string' || typeof obj['version'] !== 'string') {
|
||||
return false;
|
||||
}
|
||||
|
||||
// Check for Claude-specific fields
|
||||
const hasClaudeFields =
|
||||
'agents' in obj ||
|
||||
'hooks' in obj ||
|
||||
'outputStyles' in obj ||
|
||||
'lspServers' in obj;
|
||||
|
||||
return hasClaudeFields;
|
||||
}
|
||||
|
|
@ -1,157 +0,0 @@
|
|||
import type { ConfirmationRequest } from '../../ui/types.js';
|
||||
import type { ExtensionConfig } from '../extension.js';
|
||||
|
||||
/**
|
||||
* Requests consent from the user to perform an action, by reading a Y/n
|
||||
* character from stdin.
|
||||
*
|
||||
* This should not be called from interactive mode as it will break the CLI.
|
||||
*
|
||||
* @param consentDescription The description of the thing they will be consenting to.
|
||||
* @returns boolean, whether they consented or not.
|
||||
*/
|
||||
export async function requestConsentNonInteractive(): Promise<boolean> {
|
||||
const result = await promptForConsentNonInteractive(
|
||||
'Do you want to continue? [Y/n]: ',
|
||||
);
|
||||
return result;
|
||||
}
|
||||
|
||||
/**
|
||||
* Requests consent from the user to perform an action, in interactive mode.
|
||||
*
|
||||
* This should not be called from non-interactive mode as it will not work.
|
||||
*
|
||||
* @param consentDescription The description of the thing they will be consenting to.
|
||||
* @param addExtensionUpdateConfirmationRequest A function to actually add a prompt to the UI.
|
||||
* @returns boolean, whether they consented or not.
|
||||
*/
|
||||
export async function requestConsentInteractive(
|
||||
consentDescription: string,
|
||||
addExtensionUpdateConfirmationRequest: (value: ConfirmationRequest) => void,
|
||||
): Promise<boolean> {
|
||||
return promptForConsentInteractive(
|
||||
consentDescription + '\n\nDo you want to continue?',
|
||||
addExtensionUpdateConfirmationRequest,
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Asks users a prompt and awaits for a y/n response on stdin.
|
||||
*
|
||||
* This should not be called from interactive mode as it will break the CLI.
|
||||
*
|
||||
* @param prompt A yes/no prompt to ask the user
|
||||
* @returns Whether or not the user answers 'y' (yes). Defaults to 'yes' on enter.
|
||||
*/
|
||||
async function promptForConsentNonInteractive(
|
||||
prompt: string,
|
||||
): Promise<boolean> {
|
||||
const readline = await import('node:readline');
|
||||
const rl = readline.createInterface({
|
||||
input: process.stdin,
|
||||
output: process.stdout,
|
||||
});
|
||||
|
||||
return new Promise((resolve) => {
|
||||
rl.question(prompt, (answer) => {
|
||||
rl.close();
|
||||
resolve(['y', ''].includes(answer.trim().toLowerCase()));
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Asks users an interactive yes/no prompt.
|
||||
*
|
||||
* This should not be called from non-interactive mode as it will break the CLI.
|
||||
*
|
||||
* @param prompt A markdown prompt to ask the user
|
||||
* @param addExtensionUpdateConfirmationRequest Function to update the UI state with the confirmation request.
|
||||
* @returns Whether or not the user answers yes.
|
||||
*/
|
||||
async function promptForConsentInteractive(
|
||||
prompt: string,
|
||||
addExtensionUpdateConfirmationRequest: (value: ConfirmationRequest) => void,
|
||||
): Promise<boolean> {
|
||||
return new Promise<boolean>((resolve) => {
|
||||
addExtensionUpdateConfirmationRequest({
|
||||
prompt,
|
||||
onConfirm: (resolvedConfirmed) => {
|
||||
resolve(resolvedConfirmed);
|
||||
},
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Builds a consent string for installing an extension based on it's
|
||||
* extensionConfig.
|
||||
*/
|
||||
export function extensionConsentString(
|
||||
extensionConfig: ExtensionConfig,
|
||||
commands?: string[],
|
||||
): string {
|
||||
const output: string[] = [];
|
||||
const mcpServerEntries = Object.entries(extensionConfig.mcpServers || {});
|
||||
output.push(`Installing extension "${extensionConfig.name}".`);
|
||||
output.push(
|
||||
'**Extensions may introduce unexpected behavior. Ensure you have investigated the extension source and trust the author.**',
|
||||
);
|
||||
|
||||
if (mcpServerEntries.length) {
|
||||
output.push('This extension will run the following MCP servers:');
|
||||
for (const [key, mcpServer] of mcpServerEntries) {
|
||||
const isLocal = !!mcpServer.command;
|
||||
const source =
|
||||
mcpServer.httpUrl ??
|
||||
`${mcpServer.command || ''}${mcpServer.args ? ' ' + mcpServer.args.join(' ') : ''}`;
|
||||
output.push(` * ${key} (${isLocal ? 'local' : 'remote'}): ${source}`);
|
||||
}
|
||||
}
|
||||
if (commands && commands.length > 0) {
|
||||
output.push(
|
||||
`This extension will add the following commands: ${commands.join(', ')}.`,
|
||||
);
|
||||
}
|
||||
if (extensionConfig.contextFileName) {
|
||||
output.push(
|
||||
`This extension will append info to your QWEN.md context using ${extensionConfig.contextFileName}`,
|
||||
);
|
||||
}
|
||||
if (extensionConfig.excludeTools) {
|
||||
output.push(
|
||||
`This extension will exclude the following core tools: ${extensionConfig.excludeTools}`,
|
||||
);
|
||||
}
|
||||
return output.join('\n');
|
||||
}
|
||||
|
||||
/**
|
||||
* Requests consent from the user to install an extension (extensionConfig), if
|
||||
* there is any difference between the consent string for `extensionConfig` and
|
||||
* `previousExtensionConfig`.
|
||||
*
|
||||
* Always requests consent if previousExtensionConfig is null.
|
||||
*
|
||||
* Throws if the user does not consent.
|
||||
*/
|
||||
export async function maybeRequestConsentOrFail(
|
||||
extensionConfig: ExtensionConfig,
|
||||
requestConsent: (consent: string) => Promise<boolean>,
|
||||
commands: string[],
|
||||
previousExtensionConfig?: ExtensionConfig,
|
||||
) {
|
||||
const extensionConsent = extensionConsentString(extensionConfig, commands);
|
||||
if (previousExtensionConfig) {
|
||||
const previousExtensionConsent = extensionConsentString(
|
||||
previousExtensionConfig,
|
||||
);
|
||||
if (previousExtensionConsent === extensionConsent) {
|
||||
return;
|
||||
}
|
||||
}
|
||||
if (!(await requestConsent(extensionConsent))) {
|
||||
throw new Error(`Installation cancelled for "${extensionConfig.name}".`);
|
||||
}
|
||||
}
|
||||
|
|
@ -1,424 +0,0 @@
|
|||
/**
|
||||
* @license
|
||||
* Copyright 2025 Google LLC
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
import * as path from 'node:path';
|
||||
import fs from 'node:fs';
|
||||
import os from 'node:os';
|
||||
import { afterEach, beforeEach, describe, expect, it } from 'vitest';
|
||||
import { ExtensionEnablementManager, Override } from './extensionEnablement.js';
|
||||
import type { Extension } from '../extension.js';
|
||||
|
||||
// Helper to create a temporary directory for testing
|
||||
function createTestDir() {
|
||||
const dirPath = fs.mkdtempSync(path.join(os.tmpdir(), 'gemini-test-'));
|
||||
return {
|
||||
path: dirPath,
|
||||
cleanup: () => fs.rmSync(dirPath, { recursive: true, force: true }),
|
||||
};
|
||||
}
|
||||
|
||||
let testDir: { path: string; cleanup: () => void };
|
||||
let configDir: string;
|
||||
let manager: ExtensionEnablementManager;
|
||||
|
||||
describe('ExtensionEnablementManager', () => {
|
||||
beforeEach(() => {
|
||||
testDir = createTestDir();
|
||||
configDir = path.join(testDir.path, '.gemini');
|
||||
manager = new ExtensionEnablementManager(configDir);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
testDir.cleanup();
|
||||
// Reset the singleton instance for test isolation
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
(ExtensionEnablementManager as any).instance = undefined;
|
||||
});
|
||||
|
||||
describe('isEnabled', () => {
|
||||
it('should return true if extension is not configured', () => {
|
||||
expect(manager.isEnabled('ext-test', '/any/path')).toBe(true);
|
||||
});
|
||||
|
||||
it('should return true if no overrides match', () => {
|
||||
manager.disable('ext-test', false, '/another/path');
|
||||
expect(manager.isEnabled('ext-test', '/any/path')).toBe(true);
|
||||
});
|
||||
|
||||
it('should enable a path based on an override rule', () => {
|
||||
manager.disable('ext-test', true, '/');
|
||||
manager.enable('ext-test', true, '/home/user/projects/');
|
||||
expect(manager.isEnabled('ext-test', '/home/user/projects/my-app')).toBe(
|
||||
true,
|
||||
);
|
||||
});
|
||||
|
||||
it('should disable a path based on a disable override rule', () => {
|
||||
manager.enable('ext-test', true, '/');
|
||||
manager.disable('ext-test', true, '/home/user/projects/');
|
||||
expect(manager.isEnabled('ext-test', '/home/user/projects/my-app')).toBe(
|
||||
false,
|
||||
);
|
||||
});
|
||||
|
||||
it('should respect the last matching rule (enable wins)', () => {
|
||||
manager.disable('ext-test', true, '/home/user/projects/');
|
||||
manager.enable('ext-test', false, '/home/user/projects/my-app');
|
||||
expect(manager.isEnabled('ext-test', '/home/user/projects/my-app')).toBe(
|
||||
true,
|
||||
);
|
||||
});
|
||||
|
||||
it('should respect the last matching rule (disable wins)', () => {
|
||||
manager.enable('ext-test', true, '/home/user/projects/');
|
||||
manager.disable('ext-test', false, '/home/user/projects/my-app');
|
||||
expect(manager.isEnabled('ext-test', '/home/user/projects/my-app')).toBe(
|
||||
false,
|
||||
);
|
||||
});
|
||||
|
||||
it('should handle', () => {
|
||||
manager.enable('ext-test', true, '/home/user/projects');
|
||||
manager.disable('ext-test', false, '/home/user/projects/my-app');
|
||||
expect(manager.isEnabled('ext-test', '/home/user/projects/my-app')).toBe(
|
||||
false,
|
||||
);
|
||||
expect(
|
||||
manager.isEnabled('ext-test', '/home/user/projects/something-else'),
|
||||
).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe('includeSubdirs', () => {
|
||||
it('should add a glob when enabling with includeSubdirs', () => {
|
||||
manager.enable('ext-test', true, '/path/to/dir');
|
||||
const config = manager.readConfig();
|
||||
expect(config['ext-test'].overrides).toContain('/path/to/dir/*');
|
||||
});
|
||||
|
||||
it('should not add a glob when enabling without includeSubdirs', () => {
|
||||
manager.enable('ext-test', false, '/path/to/dir');
|
||||
const config = manager.readConfig();
|
||||
expect(config['ext-test'].overrides).toContain('/path/to/dir/');
|
||||
expect(config['ext-test'].overrides).not.toContain('/path/to/dir/*');
|
||||
});
|
||||
|
||||
it('should add a glob when disabling with includeSubdirs', () => {
|
||||
manager.disable('ext-test', true, '/path/to/dir');
|
||||
const config = manager.readConfig();
|
||||
expect(config['ext-test'].overrides).toContain('!/path/to/dir/*');
|
||||
});
|
||||
|
||||
it('should remove conflicting glob rule when enabling without subdirs', () => {
|
||||
manager.enable('ext-test', true, '/path/to/dir'); // Adds /path/to/dir*
|
||||
manager.enable('ext-test', false, '/path/to/dir'); // Should remove the glob
|
||||
const config = manager.readConfig();
|
||||
expect(config['ext-test'].overrides).toContain('/path/to/dir/');
|
||||
expect(config['ext-test'].overrides).not.toContain('/path/to/dir/*');
|
||||
});
|
||||
|
||||
it('should remove conflicting non-glob rule when enabling with subdirs', () => {
|
||||
manager.enable('ext-test', false, '/path/to/dir'); // Adds /path/to/dir
|
||||
manager.enable('ext-test', true, '/path/to/dir'); // Should remove the non-glob
|
||||
const config = manager.readConfig();
|
||||
expect(config['ext-test'].overrides).toContain('/path/to/dir/*');
|
||||
expect(config['ext-test'].overrides).not.toContain('/path/to/dir/');
|
||||
});
|
||||
|
||||
it('should remove conflicting rules when disabling', () => {
|
||||
manager.enable('ext-test', true, '/path/to/dir'); // enabled with glob
|
||||
manager.disable('ext-test', false, '/path/to/dir'); // disabled without
|
||||
const config = manager.readConfig();
|
||||
expect(config['ext-test'].overrides).toContain('!/path/to/dir/');
|
||||
expect(config['ext-test'].overrides).not.toContain('/path/to/dir/*');
|
||||
});
|
||||
|
||||
it('should correctly evaluate isEnabled with subdirs', () => {
|
||||
manager.disable('ext-test', true, '/');
|
||||
manager.enable('ext-test', true, '/path/to/dir');
|
||||
expect(manager.isEnabled('ext-test', '/path/to/dir/')).toBe(true);
|
||||
expect(manager.isEnabled('ext-test', '/path/to/dir/sub/')).toBe(true);
|
||||
expect(manager.isEnabled('ext-test', '/path/to/another/')).toBe(false);
|
||||
});
|
||||
|
||||
it('should correctly evaluate isEnabled without subdirs', () => {
|
||||
manager.disable('ext-test', true, '/*');
|
||||
manager.enable('ext-test', false, '/path/to/dir');
|
||||
expect(manager.isEnabled('ext-test', '/path/to/dir')).toBe(true);
|
||||
expect(manager.isEnabled('ext-test', '/path/to/dir/sub')).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('pruning child rules', () => {
|
||||
it('should remove child rules when enabling a parent with subdirs', () => {
|
||||
// Pre-existing rules for children
|
||||
manager.enable('ext-test', false, '/path/to/dir/subdir1');
|
||||
manager.disable('ext-test', true, '/path/to/dir/subdir2');
|
||||
manager.enable('ext-test', false, '/path/to/another/dir');
|
||||
|
||||
// Enable the parent directory
|
||||
manager.enable('ext-test', true, '/path/to/dir');
|
||||
|
||||
const config = manager.readConfig();
|
||||
const overrides = config['ext-test'].overrides;
|
||||
|
||||
// The new parent rule should be present
|
||||
expect(overrides).toContain(`/path/to/dir/*`);
|
||||
|
||||
// Child rules should be removed
|
||||
expect(overrides).not.toContain('/path/to/dir/subdir1/');
|
||||
expect(overrides).not.toContain(`!/path/to/dir/subdir2/*`);
|
||||
|
||||
// Unrelated rules should remain
|
||||
expect(overrides).toContain('/path/to/another/dir/');
|
||||
});
|
||||
|
||||
it('should remove child rules when disabling a parent with subdirs', () => {
|
||||
// Pre-existing rules for children
|
||||
manager.enable('ext-test', false, '/path/to/dir/subdir1');
|
||||
manager.disable('ext-test', true, '/path/to/dir/subdir2');
|
||||
manager.enable('ext-test', false, '/path/to/another/dir');
|
||||
|
||||
// Disable the parent directory
|
||||
manager.disable('ext-test', true, '/path/to/dir');
|
||||
|
||||
const config = manager.readConfig();
|
||||
const overrides = config['ext-test'].overrides;
|
||||
|
||||
// The new parent rule should be present
|
||||
expect(overrides).toContain(`!/path/to/dir/*`);
|
||||
|
||||
// Child rules should be removed
|
||||
expect(overrides).not.toContain('/path/to/dir/subdir1/');
|
||||
expect(overrides).not.toContain(`!/path/to/dir/subdir2/*`);
|
||||
|
||||
// Unrelated rules should remain
|
||||
expect(overrides).toContain('/path/to/another/dir/');
|
||||
});
|
||||
|
||||
it('should not remove child rules if includeSubdirs is false', () => {
|
||||
manager.enable('ext-test', false, '/path/to/dir/subdir1');
|
||||
manager.enable('ext-test', false, '/path/to/dir'); // Not including subdirs
|
||||
|
||||
const config = manager.readConfig();
|
||||
const overrides = config['ext-test'].overrides;
|
||||
|
||||
expect(overrides).toContain('/path/to/dir/subdir1/');
|
||||
expect(overrides).toContain('/path/to/dir/');
|
||||
});
|
||||
});
|
||||
|
||||
it('should enable a path based on an enable override', () => {
|
||||
manager.disable('ext-test', true, '/Users/chrstn');
|
||||
manager.enable('ext-test', true, '/Users/chrstn/gemini-cli');
|
||||
|
||||
expect(manager.isEnabled('ext-test', '/Users/chrstn/gemini-cli')).toBe(
|
||||
true,
|
||||
);
|
||||
});
|
||||
|
||||
it('should ignore subdirs', () => {
|
||||
manager.disable('ext-test', false, '/Users/chrstn');
|
||||
expect(manager.isEnabled('ext-test', '/Users/chrstn/gemini-cli')).toBe(
|
||||
true,
|
||||
);
|
||||
});
|
||||
|
||||
describe('extension overrides (-e <name>)', () => {
|
||||
beforeEach(() => {
|
||||
manager = new ExtensionEnablementManager(configDir, ['ext-test']);
|
||||
});
|
||||
|
||||
it('can enable extensions, case-insensitive', () => {
|
||||
manager.disable('ext-test', true, '/');
|
||||
expect(manager.isEnabled('ext-test', '/')).toBe(true);
|
||||
expect(manager.isEnabled('Ext-Test', '/')).toBe(true);
|
||||
// Double check that it would have been disabled otherwise
|
||||
expect(
|
||||
new ExtensionEnablementManager(configDir).isEnabled('ext-test', '/'),
|
||||
).toBe(false);
|
||||
});
|
||||
|
||||
it('disable all other extensions', () => {
|
||||
manager = new ExtensionEnablementManager(configDir, ['ext-test']);
|
||||
manager.enable('ext-test-2', true, '/');
|
||||
expect(manager.isEnabled('ext-test-2', '/')).toBe(false);
|
||||
// Double check that it would have been enabled otherwise
|
||||
expect(
|
||||
new ExtensionEnablementManager(configDir).isEnabled('ext-test-2', '/'),
|
||||
).toBe(true);
|
||||
});
|
||||
|
||||
it('none disables all extensions', () => {
|
||||
manager = new ExtensionEnablementManager(configDir, ['none']);
|
||||
manager.enable('ext-test', true, '/');
|
||||
expect(manager.isEnabled('ext-test', '/path/to/dir')).toBe(false);
|
||||
// Double check that it would have been enabled otherwise
|
||||
expect(
|
||||
new ExtensionEnablementManager(configDir).isEnabled('ext-test', '/'),
|
||||
).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe('validateExtensionOverrides', () => {
|
||||
let consoleErrorSpy: ReturnType<typeof vi.spyOn>;
|
||||
|
||||
beforeEach(() => {
|
||||
consoleErrorSpy = vi.spyOn(console, 'error').mockImplementation(() => {});
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
consoleErrorSpy.mockRestore();
|
||||
});
|
||||
|
||||
it('should not log an error if enabledExtensionNamesOverride is empty', () => {
|
||||
const manager = new ExtensionEnablementManager(configDir, []);
|
||||
manager.validateExtensionOverrides([]);
|
||||
expect(consoleErrorSpy).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should not log an error if all enabledExtensionNamesOverride are valid', () => {
|
||||
const manager = new ExtensionEnablementManager(configDir, [
|
||||
'ext-one',
|
||||
'ext-two',
|
||||
]);
|
||||
const extensions = [
|
||||
{ config: { name: 'ext-one' } },
|
||||
{ config: { name: 'ext-two' } },
|
||||
] as Extension[];
|
||||
manager.validateExtensionOverrides(extensions);
|
||||
expect(consoleErrorSpy).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should log an error for each invalid extension name in enabledExtensionNamesOverride', () => {
|
||||
const manager = new ExtensionEnablementManager(configDir, [
|
||||
'ext-one',
|
||||
'ext-invalid',
|
||||
'ext-another-invalid',
|
||||
]);
|
||||
const extensions = [
|
||||
{ config: { name: 'ext-one' } },
|
||||
{ config: { name: 'ext-two' } },
|
||||
] as Extension[];
|
||||
manager.validateExtensionOverrides(extensions);
|
||||
expect(consoleErrorSpy).toHaveBeenCalledTimes(2);
|
||||
expect(consoleErrorSpy).toHaveBeenCalledWith(
|
||||
'Extension not found: ext-invalid',
|
||||
);
|
||||
expect(consoleErrorSpy).toHaveBeenCalledWith(
|
||||
'Extension not found: ext-another-invalid',
|
||||
);
|
||||
});
|
||||
|
||||
it('should not log an error if "none" is in enabledExtensionNamesOverride', () => {
|
||||
const manager = new ExtensionEnablementManager(configDir, ['none']);
|
||||
manager.validateExtensionOverrides([]);
|
||||
expect(consoleErrorSpy).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('Override', () => {
|
||||
it('should create an override from input', () => {
|
||||
const override = Override.fromInput('/path/to/dir', true);
|
||||
expect(override.baseRule).toBe(`/path/to/dir/`);
|
||||
expect(override.isDisable).toBe(false);
|
||||
expect(override.includeSubdirs).toBe(true);
|
||||
});
|
||||
|
||||
it('should create a disable override from input', () => {
|
||||
const override = Override.fromInput('!/path/to/dir', false);
|
||||
expect(override.baseRule).toBe(`/path/to/dir/`);
|
||||
expect(override.isDisable).toBe(true);
|
||||
expect(override.includeSubdirs).toBe(false);
|
||||
});
|
||||
|
||||
it('should create an override from a file rule', () => {
|
||||
const override = Override.fromFileRule('/path/to/dir');
|
||||
expect(override.baseRule).toBe('/path/to/dir');
|
||||
expect(override.isDisable).toBe(false);
|
||||
expect(override.includeSubdirs).toBe(false);
|
||||
});
|
||||
|
||||
it('should create a disable override from a file rule', () => {
|
||||
const override = Override.fromFileRule('!/path/to/dir/');
|
||||
expect(override.isDisable).toBe(true);
|
||||
expect(override.baseRule).toBe('/path/to/dir/');
|
||||
expect(override.includeSubdirs).toBe(false);
|
||||
});
|
||||
|
||||
it('should create an override with subdirs from a file rule', () => {
|
||||
const override = Override.fromFileRule('/path/to/dir/*');
|
||||
expect(override.baseRule).toBe('/path/to/dir/');
|
||||
expect(override.isDisable).toBe(false);
|
||||
expect(override.includeSubdirs).toBe(true);
|
||||
});
|
||||
|
||||
it('should correctly identify conflicting overrides', () => {
|
||||
const override1 = Override.fromInput('/path/to/dir', true);
|
||||
const override2 = Override.fromInput('/path/to/dir', false);
|
||||
expect(override1.conflictsWith(override2)).toBe(true);
|
||||
});
|
||||
|
||||
it('should correctly identify non-conflicting overrides', () => {
|
||||
const override1 = Override.fromInput('/path/to/dir', true);
|
||||
const override2 = Override.fromInput('/path/to/another/dir', true);
|
||||
expect(override1.conflictsWith(override2)).toBe(false);
|
||||
});
|
||||
|
||||
it('should correctly identify equal overrides', () => {
|
||||
const override1 = Override.fromInput('/path/to/dir', true);
|
||||
const override2 = Override.fromInput('/path/to/dir', true);
|
||||
expect(override1.isEqualTo(override2)).toBe(true);
|
||||
});
|
||||
|
||||
it('should correctly identify unequal overrides', () => {
|
||||
const override1 = Override.fromInput('/path/to/dir', true);
|
||||
const override2 = Override.fromInput('!/path/to/dir', true);
|
||||
expect(override1.isEqualTo(override2)).toBe(false);
|
||||
});
|
||||
|
||||
it('should generate the correct regex', () => {
|
||||
const override = Override.fromInput('/path/to/dir', true);
|
||||
const regex = override.asRegex();
|
||||
expect(regex.test('/path/to/dir/')).toBe(true);
|
||||
expect(regex.test('/path/to/dir/subdir')).toBe(true);
|
||||
expect(regex.test('/path/to/another/dir')).toBe(false);
|
||||
});
|
||||
|
||||
it('should correctly identify child overrides', () => {
|
||||
const parent = Override.fromInput('/path/to/dir', true);
|
||||
const child = Override.fromInput('/path/to/dir/subdir', false);
|
||||
expect(child.isChildOf(parent)).toBe(true);
|
||||
});
|
||||
|
||||
it('should correctly identify child overrides with glob', () => {
|
||||
const parent = Override.fromInput('/path/to/dir/*', true);
|
||||
const child = Override.fromInput('/path/to/dir/subdir', false);
|
||||
expect(child.isChildOf(parent)).toBe(true);
|
||||
});
|
||||
|
||||
it('should correctly identify non-child overrides', () => {
|
||||
const parent = Override.fromInput('/path/to/dir', true);
|
||||
const other = Override.fromInput('/path/to/another/dir', false);
|
||||
expect(other.isChildOf(parent)).toBe(false);
|
||||
});
|
||||
|
||||
it('should generate the correct output string', () => {
|
||||
const override = Override.fromInput('/path/to/dir', true);
|
||||
expect(override.output()).toBe(`/path/to/dir/*`);
|
||||
});
|
||||
|
||||
it('should generate the correct output string for a disable override', () => {
|
||||
const override = Override.fromInput('!/path/to/dir', false);
|
||||
expect(override.output()).toBe(`!/path/to/dir/`);
|
||||
});
|
||||
|
||||
it('should disable a path based on a disable override rule', () => {
|
||||
const override = Override.fromInput('!/path/to/dir', false);
|
||||
expect(override.output()).toBe(`!/path/to/dir/`);
|
||||
});
|
||||
});
|
||||
|
|
@ -1,239 +0,0 @@
|
|||
/**
|
||||
* @license
|
||||
* Copyright 2025 Google LLC
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
import fs from 'node:fs';
|
||||
import path from 'node:path';
|
||||
import { type Extension } from '../extension.js';
|
||||
|
||||
export interface ExtensionEnablementConfig {
|
||||
overrides: string[];
|
||||
}
|
||||
|
||||
export interface AllExtensionsEnablementConfig {
|
||||
[extensionName: string]: ExtensionEnablementConfig;
|
||||
}
|
||||
|
||||
export class Override {
|
||||
constructor(
|
||||
public baseRule: string,
|
||||
public isDisable: boolean,
|
||||
public includeSubdirs: boolean,
|
||||
) {}
|
||||
|
||||
static fromInput(inputRule: string, includeSubdirs: boolean): Override {
|
||||
const isDisable = inputRule.startsWith('!');
|
||||
let baseRule = isDisable ? inputRule.substring(1) : inputRule;
|
||||
baseRule = ensureLeadingAndTrailingSlash(baseRule);
|
||||
return new Override(baseRule, isDisable, includeSubdirs);
|
||||
}
|
||||
|
||||
static fromFileRule(fileRule: string): Override {
|
||||
const isDisable = fileRule.startsWith('!');
|
||||
let baseRule = isDisable ? fileRule.substring(1) : fileRule;
|
||||
const includeSubdirs = baseRule.endsWith('*');
|
||||
baseRule = includeSubdirs
|
||||
? baseRule.substring(0, baseRule.length - 1)
|
||||
: baseRule;
|
||||
return new Override(baseRule, isDisable, includeSubdirs);
|
||||
}
|
||||
|
||||
conflictsWith(other: Override): boolean {
|
||||
if (this.baseRule === other.baseRule) {
|
||||
return (
|
||||
this.includeSubdirs !== other.includeSubdirs ||
|
||||
this.isDisable !== other.isDisable
|
||||
);
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
isEqualTo(other: Override): boolean {
|
||||
return (
|
||||
this.baseRule === other.baseRule &&
|
||||
this.includeSubdirs === other.includeSubdirs &&
|
||||
this.isDisable === other.isDisable
|
||||
);
|
||||
}
|
||||
|
||||
asRegex(): RegExp {
|
||||
return globToRegex(`${this.baseRule}${this.includeSubdirs ? '*' : ''}`);
|
||||
}
|
||||
|
||||
isChildOf(parent: Override) {
|
||||
if (!parent.includeSubdirs) {
|
||||
return false;
|
||||
}
|
||||
return parent.asRegex().test(this.baseRule);
|
||||
}
|
||||
|
||||
output(): string {
|
||||
return `${this.isDisable ? '!' : ''}${this.baseRule}${this.includeSubdirs ? '*' : ''}`;
|
||||
}
|
||||
|
||||
matchesPath(path: string) {
|
||||
return this.asRegex().test(path);
|
||||
}
|
||||
}
|
||||
|
||||
const ensureLeadingAndTrailingSlash = function (dirPath: string): string {
|
||||
// Normalize separators to forward slashes for consistent matching across platforms.
|
||||
let result = dirPath.replace(/\\/g, '/');
|
||||
if (result.charAt(0) !== '/') {
|
||||
result = '/' + result;
|
||||
}
|
||||
if (result.charAt(result.length - 1) !== '/') {
|
||||
result = result + '/';
|
||||
}
|
||||
return result;
|
||||
};
|
||||
|
||||
/**
|
||||
* Converts a glob pattern to a RegExp object.
|
||||
* This is a simplified implementation that supports `*`.
|
||||
*
|
||||
* @param glob The glob pattern to convert.
|
||||
* @returns A RegExp object.
|
||||
*/
|
||||
function globToRegex(glob: string): RegExp {
|
||||
const regexString = glob
|
||||
.replace(/[.+?^${}()|[\]\\]/g, '\\$&') // Escape special regex characters
|
||||
.replace(/(\/?)\*/g, '($1.*)?'); // Convert * to optional group
|
||||
|
||||
return new RegExp(`^${regexString}$`);
|
||||
}
|
||||
|
||||
export class ExtensionEnablementManager {
|
||||
private configFilePath: string;
|
||||
private configDir: string;
|
||||
// If non-empty, this overrides all other extension configuration and enables
|
||||
// only the ones in this list.
|
||||
private enabledExtensionNamesOverride: string[];
|
||||
|
||||
constructor(configDir: string, enabledExtensionNames?: string[]) {
|
||||
this.configDir = configDir;
|
||||
this.configFilePath = path.join(configDir, 'extension-enablement.json');
|
||||
this.enabledExtensionNamesOverride =
|
||||
enabledExtensionNames?.map((name) => name.toLowerCase()) ?? [];
|
||||
}
|
||||
|
||||
validateExtensionOverrides(extensions: Extension[]) {
|
||||
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. The last matching rule in the overrides list wins.
|
||||
*
|
||||
* @param extensionName The name of the extension.
|
||||
* @param currentPath The absolute path of the current working directory.
|
||||
* @returns True if the extension is enabled, false otherwise.
|
||||
*/
|
||||
isEnabled(extensionName: string, currentPath: string): boolean {
|
||||
// If we have a single override called 'none', this disables all extensions.
|
||||
// Typically, this comes from the user passing `-e none`.
|
||||
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) {
|
||||
// When checking against overrides ONLY, we use a case insensitive match.
|
||||
// The override names are already lowercased in the constructor.
|
||||
return this.enabledExtensionNamesOverride.includes(
|
||||
extensionName.toLocaleLowerCase(),
|
||||
);
|
||||
}
|
||||
|
||||
// Otherwise, we use the configuration settings
|
||||
const config = this.readConfig();
|
||||
const extensionConfig = config[extensionName];
|
||||
// Extensions are enabled by default.
|
||||
let enabled = true;
|
||||
const allOverrides = extensionConfig?.overrides ?? [];
|
||||
for (const rule of allOverrides) {
|
||||
const override = Override.fromFileRule(rule);
|
||||
if (override.matchesPath(ensureLeadingAndTrailingSlash(currentPath))) {
|
||||
enabled = !override.isDisable;
|
||||
}
|
||||
}
|
||||
return enabled;
|
||||
}
|
||||
|
||||
readConfig(): 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 {};
|
||||
}
|
||||
}
|
||||
|
||||
writeConfig(config: AllExtensionsEnablementConfig): void {
|
||||
fs.mkdirSync(this.configDir, { recursive: true });
|
||||
fs.writeFileSync(this.configFilePath, JSON.stringify(config, null, 2));
|
||||
}
|
||||
|
||||
enable(
|
||||
extensionName: string,
|
||||
includeSubdirs: boolean,
|
||||
scopePath: string,
|
||||
): void {
|
||||
const config = this.readConfig();
|
||||
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; // Remove conflicts and equivalent values.
|
||||
}
|
||||
return !fileOverride.isChildOf(override);
|
||||
});
|
||||
overrides.push(override.output());
|
||||
config[extensionName].overrides = overrides;
|
||||
this.writeConfig(config);
|
||||
}
|
||||
|
||||
disable(
|
||||
extensionName: string,
|
||||
includeSubdirs: boolean,
|
||||
scopePath: string,
|
||||
): void {
|
||||
this.enable(extensionName, includeSubdirs, `!${scopePath}`);
|
||||
}
|
||||
|
||||
remove(extensionName: string): void {
|
||||
const config = this.readConfig();
|
||||
if (config[extensionName]) {
|
||||
delete config[extensionName];
|
||||
this.writeConfig(config);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -1,725 +0,0 @@
|
|||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
import * as path from 'node:path';
|
||||
import * as os from 'node:os';
|
||||
import {
|
||||
getEnvContents,
|
||||
maybePromptForSettings,
|
||||
promptForSetting,
|
||||
type ExtensionSetting,
|
||||
updateSetting,
|
||||
ExtensionSettingScope,
|
||||
getScopedEnvContents,
|
||||
} from './extensionSettings.js';
|
||||
import type { ExtensionConfig } from '../extension.js';
|
||||
import { ExtensionStorage } from './storage.js';
|
||||
import prompts from 'prompts';
|
||||
import * as fsPromises from 'node:fs/promises';
|
||||
import * as fs from 'node:fs';
|
||||
import { KeychainTokenStorage } from '@qwen-code/qwen-code-core';
|
||||
import { EXTENSION_SETTINGS_FILENAME } from './variables.js';
|
||||
|
||||
vi.mock('prompts');
|
||||
vi.mock('os', async (importOriginal) => {
|
||||
const mockedOs = await importOriginal<typeof os>();
|
||||
return {
|
||||
...mockedOs,
|
||||
homedir: vi.fn(),
|
||||
};
|
||||
});
|
||||
|
||||
vi.mock('@qwen-code/qwen-code-core', async (importOriginal) => {
|
||||
const actual =
|
||||
await importOriginal<typeof import('@qwen-code/qwen-code-core')>();
|
||||
return {
|
||||
...actual,
|
||||
KeychainTokenStorage: vi.fn(),
|
||||
};
|
||||
});
|
||||
|
||||
describe('extensionSettings', () => {
|
||||
let tempHomeDir: string;
|
||||
let tempWorkspaceDir: string;
|
||||
let extensionDir: string;
|
||||
let mockKeychainData: Record<string, Record<string, string>>;
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
mockKeychainData = {};
|
||||
vi.mocked(KeychainTokenStorage).mockImplementation(
|
||||
(serviceName: string) => {
|
||||
if (!mockKeychainData[serviceName]) {
|
||||
mockKeychainData[serviceName] = {};
|
||||
}
|
||||
const keychainData = mockKeychainData[serviceName];
|
||||
return {
|
||||
getSecret: vi
|
||||
.fn()
|
||||
.mockImplementation(
|
||||
async (key: string) => keychainData[key] || null,
|
||||
),
|
||||
setSecret: vi
|
||||
.fn()
|
||||
.mockImplementation(async (key: string, value: string) => {
|
||||
keychainData[key] = value;
|
||||
}),
|
||||
deleteSecret: vi.fn().mockImplementation(async (key: string) => {
|
||||
delete keychainData[key];
|
||||
}),
|
||||
listSecrets: vi
|
||||
.fn()
|
||||
.mockImplementation(async () => Object.keys(keychainData)),
|
||||
isAvailable: vi.fn().mockResolvedValue(true),
|
||||
} as unknown as KeychainTokenStorage;
|
||||
},
|
||||
);
|
||||
tempHomeDir = os.tmpdir() + path.sep + `gemini-cli-test-home-${Date.now()}`;
|
||||
tempWorkspaceDir = path.join(
|
||||
os.tmpdir(),
|
||||
`gemini-cli-test-workspace-${Date.now()}`,
|
||||
);
|
||||
extensionDir = path.join(tempHomeDir, '.gemini', 'extensions', 'test-ext');
|
||||
// Spy and mock the method, but also create the directory so we can write to it.
|
||||
vi.spyOn(ExtensionStorage.prototype, 'getExtensionDir').mockReturnValue(
|
||||
extensionDir,
|
||||
);
|
||||
fs.mkdirSync(extensionDir, { recursive: true });
|
||||
fs.mkdirSync(tempWorkspaceDir, { recursive: true });
|
||||
vi.mocked(os.homedir).mockReturnValue(tempHomeDir);
|
||||
vi.spyOn(process, 'cwd').mockReturnValue(tempWorkspaceDir);
|
||||
vi.mocked(prompts).mockClear();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
fs.rmSync(tempHomeDir, { recursive: true, force: true });
|
||||
fs.rmSync(tempWorkspaceDir, { recursive: true, force: true });
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
|
||||
describe('maybePromptForSettings', () => {
|
||||
const mockRequestSetting = vi.fn(
|
||||
async (setting: ExtensionSetting) => `mock-${setting.envVar}`,
|
||||
);
|
||||
|
||||
beforeEach(() => {
|
||||
mockRequestSetting.mockClear();
|
||||
});
|
||||
|
||||
it('should do nothing if settings are undefined', async () => {
|
||||
const config: ExtensionConfig = { name: 'test-ext', version: '1.0.0' };
|
||||
await maybePromptForSettings(
|
||||
config,
|
||||
'12345',
|
||||
mockRequestSetting,
|
||||
undefined,
|
||||
undefined,
|
||||
);
|
||||
expect(mockRequestSetting).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should do nothing if settings are empty', async () => {
|
||||
const config: ExtensionConfig = {
|
||||
name: 'test-ext',
|
||||
version: '1.0.0',
|
||||
settings: [],
|
||||
};
|
||||
await maybePromptForSettings(
|
||||
config,
|
||||
'12345',
|
||||
mockRequestSetting,
|
||||
undefined,
|
||||
undefined,
|
||||
);
|
||||
expect(mockRequestSetting).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should prompt for all settings if there is no previous config', async () => {
|
||||
const config: ExtensionConfig = {
|
||||
name: 'test-ext',
|
||||
version: '1.0.0',
|
||||
settings: [
|
||||
{ name: 's1', description: 'd1', envVar: 'VAR1' },
|
||||
{ name: 's2', description: 'd2', envVar: 'VAR2' },
|
||||
],
|
||||
};
|
||||
await maybePromptForSettings(
|
||||
config,
|
||||
'12345',
|
||||
mockRequestSetting,
|
||||
undefined,
|
||||
undefined,
|
||||
);
|
||||
expect(mockRequestSetting).toHaveBeenCalledTimes(2);
|
||||
expect(mockRequestSetting).toHaveBeenCalledWith(config.settings![0]);
|
||||
expect(mockRequestSetting).toHaveBeenCalledWith(config.settings![1]);
|
||||
});
|
||||
|
||||
it('should only prompt for new settings', async () => {
|
||||
const previousConfig: ExtensionConfig = {
|
||||
name: 'test-ext',
|
||||
version: '1.0.0',
|
||||
settings: [{ name: 's1', description: 'd1', envVar: 'VAR1' }],
|
||||
};
|
||||
const newConfig: ExtensionConfig = {
|
||||
name: 'test-ext',
|
||||
version: '1.0.0',
|
||||
settings: [
|
||||
{ name: 's1', description: 'd1', envVar: 'VAR1' },
|
||||
{ name: 's2', description: 'd2', envVar: 'VAR2' },
|
||||
],
|
||||
};
|
||||
const previousSettings = { VAR1: 'previous-VAR1' };
|
||||
|
||||
await maybePromptForSettings(
|
||||
newConfig,
|
||||
'12345',
|
||||
mockRequestSetting,
|
||||
previousConfig,
|
||||
previousSettings,
|
||||
);
|
||||
|
||||
expect(mockRequestSetting).toHaveBeenCalledTimes(1);
|
||||
expect(mockRequestSetting).toHaveBeenCalledWith(newConfig.settings![1]);
|
||||
|
||||
const expectedEnvPath = path.join(extensionDir, '.env');
|
||||
const actualContent = await fsPromises.readFile(expectedEnvPath, 'utf-8');
|
||||
const expectedContent = 'VAR1=previous-VAR1\nVAR2=mock-VAR2\n';
|
||||
expect(actualContent).toBe(expectedContent);
|
||||
});
|
||||
|
||||
it('should clear settings if new config has no settings', async () => {
|
||||
const previousConfig: ExtensionConfig = {
|
||||
name: 'test-ext',
|
||||
version: '1.0.0',
|
||||
settings: [
|
||||
{ name: 's1', description: 'd1', envVar: 'VAR1' },
|
||||
{
|
||||
name: 's2',
|
||||
description: 'd2',
|
||||
envVar: 'SENSITIVE_VAR',
|
||||
sensitive: true,
|
||||
},
|
||||
],
|
||||
};
|
||||
const newConfig: ExtensionConfig = {
|
||||
name: 'test-ext',
|
||||
version: '1.0.0',
|
||||
settings: [],
|
||||
};
|
||||
const previousSettings = {
|
||||
VAR1: 'previous-VAR1',
|
||||
SENSITIVE_VAR: 'secret',
|
||||
};
|
||||
const userKeychain = new KeychainTokenStorage(
|
||||
`Gemini CLI Extensions test-ext 12345`,
|
||||
);
|
||||
await userKeychain.setSecret('SENSITIVE_VAR', 'secret');
|
||||
const envPath = path.join(extensionDir, '.env');
|
||||
await fsPromises.writeFile(envPath, 'VAR1=previous-VAR1');
|
||||
|
||||
await maybePromptForSettings(
|
||||
newConfig,
|
||||
'12345',
|
||||
mockRequestSetting,
|
||||
previousConfig,
|
||||
previousSettings,
|
||||
);
|
||||
|
||||
expect(mockRequestSetting).not.toHaveBeenCalled();
|
||||
const actualContent = await fsPromises.readFile(envPath, 'utf-8');
|
||||
expect(actualContent).toBe('');
|
||||
expect(await userKeychain.getSecret('SENSITIVE_VAR')).toBeNull();
|
||||
});
|
||||
|
||||
it('should remove sensitive settings from keychain', async () => {
|
||||
const previousConfig: ExtensionConfig = {
|
||||
name: 'test-ext',
|
||||
version: '1.0.0',
|
||||
settings: [
|
||||
{
|
||||
name: 's1',
|
||||
description: 'd1',
|
||||
envVar: 'SENSITIVE_VAR',
|
||||
sensitive: true,
|
||||
},
|
||||
],
|
||||
};
|
||||
const newConfig: ExtensionConfig = {
|
||||
name: 'test-ext',
|
||||
version: '1.0.0',
|
||||
settings: [],
|
||||
};
|
||||
const previousSettings = { SENSITIVE_VAR: 'secret' };
|
||||
const userKeychain = new KeychainTokenStorage(
|
||||
`Gemini CLI Extensions test-ext 12345`,
|
||||
);
|
||||
await userKeychain.setSecret('SENSITIVE_VAR', 'secret');
|
||||
|
||||
await maybePromptForSettings(
|
||||
newConfig,
|
||||
'12345',
|
||||
mockRequestSetting,
|
||||
previousConfig,
|
||||
previousSettings,
|
||||
);
|
||||
|
||||
expect(await userKeychain.getSecret('SENSITIVE_VAR')).toBeNull();
|
||||
});
|
||||
|
||||
it('should remove settings that are no longer in the config', async () => {
|
||||
const previousConfig: ExtensionConfig = {
|
||||
name: 'test-ext',
|
||||
version: '1.0.0',
|
||||
settings: [
|
||||
{ name: 's1', description: 'd1', envVar: 'VAR1' },
|
||||
{ name: 's2', description: 'd2', envVar: 'VAR2' },
|
||||
],
|
||||
};
|
||||
const newConfig: ExtensionConfig = {
|
||||
name: 'test-ext',
|
||||
version: '1.0.0',
|
||||
settings: [{ name: 's1', description: 'd1', envVar: 'VAR1' }],
|
||||
};
|
||||
const previousSettings = {
|
||||
VAR1: 'previous-VAR1',
|
||||
VAR2: 'previous-VAR2',
|
||||
};
|
||||
|
||||
await maybePromptForSettings(
|
||||
newConfig,
|
||||
'12345',
|
||||
mockRequestSetting,
|
||||
previousConfig,
|
||||
previousSettings,
|
||||
);
|
||||
|
||||
expect(mockRequestSetting).not.toHaveBeenCalled();
|
||||
|
||||
const expectedEnvPath = path.join(extensionDir, '.env');
|
||||
const actualContent = await fsPromises.readFile(expectedEnvPath, 'utf-8');
|
||||
const expectedContent = 'VAR1=previous-VAR1\n';
|
||||
expect(actualContent).toBe(expectedContent);
|
||||
});
|
||||
|
||||
it('should reprompt if a setting changes sensitivity', async () => {
|
||||
const previousConfig: ExtensionConfig = {
|
||||
name: 'test-ext',
|
||||
version: '1.0.0',
|
||||
settings: [
|
||||
{ name: 's1', description: 'd1', envVar: 'VAR1', sensitive: false },
|
||||
],
|
||||
};
|
||||
const newConfig: ExtensionConfig = {
|
||||
name: 'test-ext',
|
||||
version: '1.0.0',
|
||||
settings: [
|
||||
{ name: 's1', description: 'd1', envVar: 'VAR1', sensitive: true },
|
||||
],
|
||||
};
|
||||
const previousSettings = { VAR1: 'previous-VAR1' };
|
||||
|
||||
await maybePromptForSettings(
|
||||
newConfig,
|
||||
'12345',
|
||||
mockRequestSetting,
|
||||
previousConfig,
|
||||
previousSettings,
|
||||
);
|
||||
|
||||
expect(mockRequestSetting).toHaveBeenCalledTimes(1);
|
||||
expect(mockRequestSetting).toHaveBeenCalledWith(newConfig.settings![0]);
|
||||
|
||||
// The value should now be in keychain, not the .env file.
|
||||
const expectedEnvPath = path.join(extensionDir, '.env');
|
||||
const actualContent = await fsPromises.readFile(expectedEnvPath, 'utf-8');
|
||||
expect(actualContent).toBe('');
|
||||
});
|
||||
|
||||
it('should not prompt if settings are identical', async () => {
|
||||
const previousConfig: ExtensionConfig = {
|
||||
name: 'test-ext',
|
||||
version: '1.0.0',
|
||||
settings: [
|
||||
{ name: 's1', description: 'd1', envVar: 'VAR1' },
|
||||
{ name: 's2', description: 'd2', envVar: 'VAR2' },
|
||||
],
|
||||
};
|
||||
const newConfig: ExtensionConfig = {
|
||||
name: 'test-ext',
|
||||
version: '1.0.0',
|
||||
settings: [
|
||||
{ name: 's1', description: 'd1', envVar: 'VAR1' },
|
||||
{ name: 's2', description: 'd2', envVar: 'VAR2' },
|
||||
],
|
||||
};
|
||||
const previousSettings = {
|
||||
VAR1: 'previous-VAR1',
|
||||
VAR2: 'previous-VAR2',
|
||||
};
|
||||
|
||||
await maybePromptForSettings(
|
||||
newConfig,
|
||||
'12345',
|
||||
mockRequestSetting,
|
||||
previousConfig,
|
||||
previousSettings,
|
||||
);
|
||||
|
||||
expect(mockRequestSetting).not.toHaveBeenCalled();
|
||||
const expectedEnvPath = path.join(extensionDir, '.env');
|
||||
const actualContent = await fsPromises.readFile(expectedEnvPath, 'utf-8');
|
||||
const expectedContent = 'VAR1=previous-VAR1\nVAR2=previous-VAR2\n';
|
||||
expect(actualContent).toBe(expectedContent);
|
||||
});
|
||||
|
||||
it('should wrap values with spaces in quotes', async () => {
|
||||
const config: ExtensionConfig = {
|
||||
name: 'test-ext',
|
||||
version: '1.0.0',
|
||||
settings: [{ name: 's1', description: 'd1', envVar: 'VAR1' }],
|
||||
};
|
||||
mockRequestSetting.mockResolvedValue('a value with spaces');
|
||||
|
||||
await maybePromptForSettings(
|
||||
config,
|
||||
'12345',
|
||||
mockRequestSetting,
|
||||
undefined,
|
||||
undefined,
|
||||
);
|
||||
|
||||
const expectedEnvPath = path.join(extensionDir, '.env');
|
||||
const actualContent = await fsPromises.readFile(expectedEnvPath, 'utf-8');
|
||||
expect(actualContent).toBe('VAR1="a value with spaces"\n');
|
||||
});
|
||||
|
||||
it('should not attempt to clear secrets if keychain is unavailable', async () => {
|
||||
// Arrange
|
||||
const mockIsAvailable = vi.fn().mockResolvedValue(false);
|
||||
const mockListSecrets = vi.fn();
|
||||
|
||||
vi.mocked(KeychainTokenStorage).mockImplementation(
|
||||
() =>
|
||||
({
|
||||
isAvailable: mockIsAvailable,
|
||||
listSecrets: mockListSecrets,
|
||||
deleteSecret: vi.fn(),
|
||||
getSecret: vi.fn(),
|
||||
setSecret: vi.fn(),
|
||||
}) as unknown as KeychainTokenStorage,
|
||||
);
|
||||
|
||||
const config: ExtensionConfig = {
|
||||
name: 'test-ext',
|
||||
version: '1.0.0',
|
||||
settings: [], // Empty settings triggers clearSettings
|
||||
};
|
||||
|
||||
const previousConfig: ExtensionConfig = {
|
||||
name: 'test-ext',
|
||||
version: '1.0.0',
|
||||
settings: [{ name: 's1', description: 'd1', envVar: 'VAR1' }],
|
||||
};
|
||||
|
||||
// Act
|
||||
await maybePromptForSettings(
|
||||
config,
|
||||
'12345',
|
||||
mockRequestSetting,
|
||||
previousConfig,
|
||||
undefined,
|
||||
);
|
||||
|
||||
// Assert
|
||||
expect(mockIsAvailable).toHaveBeenCalled();
|
||||
expect(mockListSecrets).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe('promptForSetting', () => {
|
||||
it.each([
|
||||
{
|
||||
description:
|
||||
'should use prompts with type "password" for sensitive settings',
|
||||
setting: {
|
||||
name: 'API Key',
|
||||
description: 'Your secret key',
|
||||
envVar: 'API_KEY',
|
||||
sensitive: true,
|
||||
},
|
||||
expectedType: 'password',
|
||||
promptValue: 'secret-key',
|
||||
},
|
||||
{
|
||||
description:
|
||||
'should use prompts with type "text" for non-sensitive settings',
|
||||
setting: {
|
||||
name: 'Username',
|
||||
description: 'Your public username',
|
||||
envVar: 'USERNAME',
|
||||
sensitive: false,
|
||||
},
|
||||
expectedType: 'text',
|
||||
promptValue: 'test-user',
|
||||
},
|
||||
{
|
||||
description: 'should default to "text" if sensitive is undefined',
|
||||
setting: {
|
||||
name: 'Username',
|
||||
description: 'Your public username',
|
||||
envVar: 'USERNAME',
|
||||
},
|
||||
expectedType: 'text',
|
||||
promptValue: 'test-user',
|
||||
},
|
||||
])('$description', async ({ setting, expectedType, promptValue }) => {
|
||||
vi.mocked(prompts).mockResolvedValue({ value: promptValue });
|
||||
|
||||
const result = await promptForSetting(setting as ExtensionSetting);
|
||||
|
||||
expect(prompts).toHaveBeenCalledWith({
|
||||
type: expectedType,
|
||||
name: 'value',
|
||||
message: `${setting.name}\n${setting.description}`,
|
||||
});
|
||||
expect(result).toBe(promptValue);
|
||||
});
|
||||
|
||||
it('should return undefined if the user cancels the prompt', async () => {
|
||||
vi.mocked(prompts).mockResolvedValue({ value: undefined });
|
||||
const result = await promptForSetting({
|
||||
name: 'Test',
|
||||
description: 'Test desc',
|
||||
envVar: 'TEST_VAR',
|
||||
});
|
||||
expect(result).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
describe('getScopedEnvContents', () => {
|
||||
const config: ExtensionConfig = {
|
||||
name: 'test-ext',
|
||||
version: '1.0.0',
|
||||
settings: [
|
||||
{ name: 's1', description: 'd1', envVar: 'VAR1' },
|
||||
{
|
||||
name: 's2',
|
||||
description: 'd2',
|
||||
envVar: 'SENSITIVE_VAR',
|
||||
sensitive: true,
|
||||
},
|
||||
],
|
||||
};
|
||||
const extensionId = '12345';
|
||||
|
||||
it('should return combined contents from user .env and keychain for USER scope', async () => {
|
||||
const userEnvPath = path.join(extensionDir, EXTENSION_SETTINGS_FILENAME);
|
||||
await fsPromises.writeFile(userEnvPath, 'VAR1=user-value1');
|
||||
const userKeychain = new KeychainTokenStorage(
|
||||
`Gemini CLI Extensions test-ext 12345`,
|
||||
);
|
||||
await userKeychain.setSecret('SENSITIVE_VAR', 'user-secret');
|
||||
|
||||
const contents = await getScopedEnvContents(
|
||||
config,
|
||||
extensionId,
|
||||
ExtensionSettingScope.USER,
|
||||
);
|
||||
|
||||
expect(contents).toEqual({
|
||||
VAR1: 'user-value1',
|
||||
SENSITIVE_VAR: 'user-secret',
|
||||
});
|
||||
});
|
||||
|
||||
it('should return combined contents from workspace .env and keychain for WORKSPACE scope', async () => {
|
||||
const workspaceEnvPath = path.join(
|
||||
tempWorkspaceDir,
|
||||
EXTENSION_SETTINGS_FILENAME,
|
||||
);
|
||||
await fsPromises.writeFile(workspaceEnvPath, 'VAR1=workspace-value1');
|
||||
const workspaceKeychain = new KeychainTokenStorage(
|
||||
`Gemini CLI Extensions test-ext 12345 ${tempWorkspaceDir}`,
|
||||
);
|
||||
await workspaceKeychain.setSecret('SENSITIVE_VAR', 'workspace-secret');
|
||||
|
||||
const contents = await getScopedEnvContents(
|
||||
config,
|
||||
extensionId,
|
||||
ExtensionSettingScope.WORKSPACE,
|
||||
);
|
||||
|
||||
expect(contents).toEqual({
|
||||
VAR1: 'workspace-value1',
|
||||
SENSITIVE_VAR: 'workspace-secret',
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('getEnvContents (merged)', () => {
|
||||
const config: ExtensionConfig = {
|
||||
name: 'test-ext',
|
||||
version: '1.0.0',
|
||||
settings: [
|
||||
{ name: 's1', description: 'd1', envVar: 'VAR1' },
|
||||
{ name: 's2', description: 'd2', envVar: 'VAR2', sensitive: true },
|
||||
{ name: 's3', description: 'd3', envVar: 'VAR3' },
|
||||
],
|
||||
};
|
||||
const extensionId = '12345';
|
||||
|
||||
it('should merge user and workspace settings, with workspace taking precedence', async () => {
|
||||
// User settings
|
||||
const userEnvPath = path.join(extensionDir, EXTENSION_SETTINGS_FILENAME);
|
||||
await fsPromises.writeFile(
|
||||
userEnvPath,
|
||||
'VAR1=user-value1\nVAR3=user-value3',
|
||||
);
|
||||
const userKeychain = new KeychainTokenStorage(
|
||||
`Gemini CLI Extensions test-ext ${extensionId}`,
|
||||
);
|
||||
await userKeychain.setSecret('VAR2', 'user-secret2');
|
||||
|
||||
// Workspace settings
|
||||
const workspaceEnvPath = path.join(
|
||||
tempWorkspaceDir,
|
||||
EXTENSION_SETTINGS_FILENAME,
|
||||
);
|
||||
await fsPromises.writeFile(workspaceEnvPath, 'VAR1=workspace-value1');
|
||||
const workspaceKeychain = new KeychainTokenStorage(
|
||||
`Gemini CLI Extensions test-ext ${extensionId} ${tempWorkspaceDir}`,
|
||||
);
|
||||
await workspaceKeychain.setSecret('VAR2', 'workspace-secret2');
|
||||
|
||||
const contents = await getEnvContents(config, extensionId);
|
||||
|
||||
expect(contents).toEqual({
|
||||
VAR1: 'workspace-value1',
|
||||
VAR2: 'workspace-secret2',
|
||||
VAR3: 'user-value3',
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('updateSetting', () => {
|
||||
const config: ExtensionConfig = {
|
||||
name: 'test-ext',
|
||||
version: '1.0.0',
|
||||
settings: [
|
||||
{ name: 's1', description: 'd1', envVar: 'VAR1' },
|
||||
{ name: 's2', description: 'd2', envVar: 'VAR2', sensitive: true },
|
||||
],
|
||||
};
|
||||
const mockRequestSetting = vi.fn();
|
||||
|
||||
beforeEach(async () => {
|
||||
const userEnvPath = path.join(extensionDir, '.env');
|
||||
await fsPromises.writeFile(userEnvPath, 'VAR1=value1\n');
|
||||
const userKeychain = new KeychainTokenStorage(
|
||||
`Gemini CLI Extensions test-ext 12345`,
|
||||
);
|
||||
await userKeychain.setSecret('VAR2', 'value2');
|
||||
mockRequestSetting.mockClear();
|
||||
});
|
||||
|
||||
it('should update a non-sensitive setting in USER scope', async () => {
|
||||
mockRequestSetting.mockResolvedValue('new-value1');
|
||||
|
||||
await updateSetting(
|
||||
config,
|
||||
'12345',
|
||||
'VAR1',
|
||||
mockRequestSetting,
|
||||
ExtensionSettingScope.USER,
|
||||
);
|
||||
|
||||
const expectedEnvPath = path.join(extensionDir, '.env');
|
||||
const actualContent = await fsPromises.readFile(expectedEnvPath, 'utf-8');
|
||||
expect(actualContent).toContain('VAR1=new-value1');
|
||||
});
|
||||
|
||||
it('should update a non-sensitive setting in WORKSPACE scope', async () => {
|
||||
mockRequestSetting.mockResolvedValue('new-workspace-value');
|
||||
|
||||
await updateSetting(
|
||||
config,
|
||||
'12345',
|
||||
'VAR1',
|
||||
mockRequestSetting,
|
||||
ExtensionSettingScope.WORKSPACE,
|
||||
);
|
||||
|
||||
const expectedEnvPath = path.join(tempWorkspaceDir, '.env');
|
||||
const actualContent = await fsPromises.readFile(expectedEnvPath, 'utf-8');
|
||||
expect(actualContent).toContain('VAR1=new-workspace-value');
|
||||
});
|
||||
|
||||
it('should update a sensitive setting in USER scope', async () => {
|
||||
mockRequestSetting.mockResolvedValue('new-value2');
|
||||
|
||||
await updateSetting(
|
||||
config,
|
||||
'12345',
|
||||
'VAR2',
|
||||
mockRequestSetting,
|
||||
ExtensionSettingScope.USER,
|
||||
);
|
||||
|
||||
const userKeychain = new KeychainTokenStorage(
|
||||
`Gemini CLI Extensions test-ext 12345`,
|
||||
);
|
||||
expect(await userKeychain.getSecret('VAR2')).toBe('new-value2');
|
||||
});
|
||||
|
||||
it('should update a sensitive setting in WORKSPACE scope', async () => {
|
||||
mockRequestSetting.mockResolvedValue('new-workspace-secret');
|
||||
|
||||
await updateSetting(
|
||||
config,
|
||||
'12345',
|
||||
'VAR2',
|
||||
mockRequestSetting,
|
||||
ExtensionSettingScope.WORKSPACE,
|
||||
);
|
||||
|
||||
const workspaceKeychain = new KeychainTokenStorage(
|
||||
`Gemini CLI Extensions test-ext 12345 ${tempWorkspaceDir}`,
|
||||
);
|
||||
expect(await workspaceKeychain.getSecret('VAR2')).toBe(
|
||||
'new-workspace-secret',
|
||||
);
|
||||
});
|
||||
|
||||
it('should leave existing, unmanaged .env variables intact when updating in WORKSPACE scope', async () => {
|
||||
// Setup a pre-existing .env file in the workspace with unmanaged variables
|
||||
const workspaceEnvPath = path.join(tempWorkspaceDir, '.env');
|
||||
const originalEnvContent =
|
||||
'PROJECT_VAR_1=value_1\nPROJECT_VAR_2=value_2\nVAR1=original-value'; // VAR1 is managed by extension
|
||||
await fsPromises.writeFile(workspaceEnvPath, originalEnvContent);
|
||||
|
||||
// Simulate updating an extension-managed non-sensitive setting
|
||||
mockRequestSetting.mockResolvedValue('updated-value');
|
||||
await updateSetting(
|
||||
config,
|
||||
'12345',
|
||||
'VAR1',
|
||||
mockRequestSetting,
|
||||
ExtensionSettingScope.WORKSPACE,
|
||||
);
|
||||
|
||||
// Read the .env file after update
|
||||
const actualContent = await fsPromises.readFile(
|
||||
workspaceEnvPath,
|
||||
'utf-8',
|
||||
);
|
||||
|
||||
// Assert that original variables are intact and extension variable is updated
|
||||
expect(actualContent).toContain('PROJECT_VAR_1=value_1');
|
||||
expect(actualContent).toContain('PROJECT_VAR_2=value_2');
|
||||
expect(actualContent).toContain('VAR1=updated-value');
|
||||
|
||||
// Ensure no other unexpected changes or deletions
|
||||
const lines = actualContent.split('\n').filter((line) => line.length > 0);
|
||||
expect(lines).toHaveLength(3); // Should only have the three variables
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
@ -1,298 +0,0 @@
|
|||
/**
|
||||
* @license
|
||||
* Copyright 2025 Google LLC
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
import * as fs from 'node:fs/promises';
|
||||
import * as fsSync from 'node:fs';
|
||||
import * as dotenv from 'dotenv';
|
||||
import * as path from 'node:path';
|
||||
import { ExtensionStorage } from './storage.js';
|
||||
import type { ExtensionConfig } from '../extension.js';
|
||||
import prompts from 'prompts';
|
||||
import { EXTENSION_SETTINGS_FILENAME } from './variables.js';
|
||||
import { KeychainTokenStorage } from '@qwen-code/qwen-code-core';
|
||||
|
||||
export enum ExtensionSettingScope {
|
||||
USER = 'user',
|
||||
WORKSPACE = 'workspace',
|
||||
}
|
||||
|
||||
export interface ExtensionSetting {
|
||||
name: string;
|
||||
description: string;
|
||||
envVar: string;
|
||||
// NOTE: If no value is set, this setting will be considered NOT sensitive.
|
||||
sensitive?: boolean;
|
||||
}
|
||||
|
||||
const getKeychainStorageName = (
|
||||
extensionName: string,
|
||||
extensionId: string,
|
||||
scope: ExtensionSettingScope,
|
||||
): string => {
|
||||
const base = `Qwen Code Extensions ${extensionName} ${extensionId}`;
|
||||
if (scope === ExtensionSettingScope.WORKSPACE) {
|
||||
return `${base} ${process.cwd()}`;
|
||||
}
|
||||
return base;
|
||||
};
|
||||
|
||||
const getEnvFilePath = (
|
||||
extensionName: string,
|
||||
scope: ExtensionSettingScope,
|
||||
): string => {
|
||||
if (scope === ExtensionSettingScope.WORKSPACE) {
|
||||
return path.join(process.cwd(), EXTENSION_SETTINGS_FILENAME);
|
||||
}
|
||||
return new ExtensionStorage(extensionName).getEnvFilePath();
|
||||
};
|
||||
|
||||
export async function maybePromptForSettings(
|
||||
extensionConfig: ExtensionConfig,
|
||||
extensionId: string,
|
||||
requestSetting: (setting: ExtensionSetting) => Promise<string>,
|
||||
previousExtensionConfig?: ExtensionConfig,
|
||||
previousSettings?: Record<string, string>,
|
||||
): Promise<void> {
|
||||
const { name: extensionName, settings } = extensionConfig;
|
||||
if (
|
||||
(!settings || settings.length === 0) &&
|
||||
(!previousExtensionConfig?.settings ||
|
||||
previousExtensionConfig.settings.length === 0)
|
||||
) {
|
||||
return;
|
||||
}
|
||||
// We assume user scope here because we don't have a way to ask the user for scope during the initial setup.
|
||||
// The user can change the scope later using the `settings set` command.
|
||||
const scope = ExtensionSettingScope.USER;
|
||||
const envFilePath = getEnvFilePath(extensionName, scope);
|
||||
const keychain = new KeychainTokenStorage(
|
||||
getKeychainStorageName(extensionName, extensionId, scope),
|
||||
);
|
||||
|
||||
if (!settings || settings.length === 0) {
|
||||
await clearSettings(envFilePath, keychain);
|
||||
return;
|
||||
}
|
||||
|
||||
const settingsChanges = getSettingsChanges(
|
||||
settings,
|
||||
previousExtensionConfig?.settings ?? [],
|
||||
);
|
||||
|
||||
const allSettings: Record<string, string> = { ...previousSettings };
|
||||
|
||||
for (const removedEnvSetting of settingsChanges.removeEnv) {
|
||||
delete allSettings[removedEnvSetting.envVar];
|
||||
}
|
||||
|
||||
for (const removedSensitiveSetting of settingsChanges.removeSensitive) {
|
||||
await keychain.deleteSecret(removedSensitiveSetting.envVar);
|
||||
}
|
||||
|
||||
for (const setting of settingsChanges.promptForSensitive.concat(
|
||||
settingsChanges.promptForEnv,
|
||||
)) {
|
||||
const answer = await requestSetting(setting);
|
||||
allSettings[setting.envVar] = answer;
|
||||
}
|
||||
|
||||
const nonSensitiveSettings: Record<string, string> = {};
|
||||
for (const setting of settings) {
|
||||
const value = allSettings[setting.envVar];
|
||||
if (value === undefined) {
|
||||
continue;
|
||||
}
|
||||
if (setting.sensitive) {
|
||||
await keychain.setSecret(setting.envVar, value);
|
||||
} else {
|
||||
nonSensitiveSettings[setting.envVar] = value;
|
||||
}
|
||||
}
|
||||
|
||||
const envContent = formatEnvContent(nonSensitiveSettings);
|
||||
|
||||
await fs.writeFile(envFilePath, envContent);
|
||||
}
|
||||
|
||||
function formatEnvContent(settings: Record<string, string>): string {
|
||||
let envContent = '';
|
||||
for (const [key, value] of Object.entries(settings)) {
|
||||
const formattedValue = value.includes(' ') ? `"${value}"` : value;
|
||||
envContent += `${key}=${formattedValue}\n`;
|
||||
}
|
||||
return envContent;
|
||||
}
|
||||
|
||||
export async function promptForSetting(
|
||||
setting: ExtensionSetting,
|
||||
): Promise<string> {
|
||||
const response = await prompts({
|
||||
type: setting.sensitive ? 'password' : 'text',
|
||||
name: 'value',
|
||||
message: `${setting.name}\n${setting.description}`,
|
||||
});
|
||||
return response.value;
|
||||
}
|
||||
|
||||
export async function getScopedEnvContents(
|
||||
extensionConfig: ExtensionConfig,
|
||||
extensionId: string,
|
||||
scope: ExtensionSettingScope,
|
||||
): Promise<Record<string, string>> {
|
||||
const { name: extensionName } = extensionConfig;
|
||||
const keychain = new KeychainTokenStorage(
|
||||
getKeychainStorageName(extensionName, extensionId, scope),
|
||||
);
|
||||
const envFilePath = getEnvFilePath(extensionName, scope);
|
||||
let customEnv: Record<string, string> = {};
|
||||
if (fsSync.existsSync(envFilePath)) {
|
||||
const envFile = fsSync.readFileSync(envFilePath, 'utf-8');
|
||||
customEnv = dotenv.parse(envFile);
|
||||
}
|
||||
|
||||
if (extensionConfig.settings) {
|
||||
for (const setting of extensionConfig.settings) {
|
||||
if (setting.sensitive) {
|
||||
const secret = await keychain.getSecret(setting.envVar);
|
||||
if (secret) {
|
||||
customEnv[setting.envVar] = secret;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
return customEnv;
|
||||
}
|
||||
|
||||
export async function getEnvContents(
|
||||
extensionConfig: ExtensionConfig,
|
||||
extensionId: string,
|
||||
): Promise<Record<string, string>> {
|
||||
if (!extensionConfig.settings || extensionConfig.settings.length === 0) {
|
||||
return Promise.resolve({});
|
||||
}
|
||||
|
||||
const userSettings = await getScopedEnvContents(
|
||||
extensionConfig,
|
||||
extensionId,
|
||||
ExtensionSettingScope.USER,
|
||||
);
|
||||
const workspaceSettings = await getScopedEnvContents(
|
||||
extensionConfig,
|
||||
extensionId,
|
||||
ExtensionSettingScope.WORKSPACE,
|
||||
);
|
||||
|
||||
return { ...userSettings, ...workspaceSettings };
|
||||
}
|
||||
|
||||
export async function updateSetting(
|
||||
extensionConfig: ExtensionConfig,
|
||||
extensionId: string,
|
||||
settingKey: string,
|
||||
requestSetting: (setting: ExtensionSetting) => Promise<string>,
|
||||
scope: ExtensionSettingScope,
|
||||
): Promise<void> {
|
||||
const { name: extensionName, settings } = extensionConfig;
|
||||
if (!settings || settings.length === 0) {
|
||||
console.log('This extension does not have any settings.');
|
||||
return;
|
||||
}
|
||||
|
||||
const settingToUpdate = settings.find(
|
||||
(s) => s.name === settingKey || s.envVar === settingKey,
|
||||
);
|
||||
|
||||
if (!settingToUpdate) {
|
||||
console.log(`Setting ${settingKey} not found.`);
|
||||
return;
|
||||
}
|
||||
|
||||
const newValue = await requestSetting(settingToUpdate);
|
||||
const keychain = new KeychainTokenStorage(
|
||||
getKeychainStorageName(extensionName, extensionId, scope),
|
||||
);
|
||||
|
||||
if (settingToUpdate.sensitive) {
|
||||
await keychain.setSecret(settingToUpdate.envVar, newValue);
|
||||
return;
|
||||
}
|
||||
|
||||
// For non-sensitive settings, we need to read the existing .env file,
|
||||
// update the value, and write it back, preserving any other values.
|
||||
const envFilePath = getEnvFilePath(extensionName, scope);
|
||||
let envContent = '';
|
||||
if (fsSync.existsSync(envFilePath)) {
|
||||
envContent = await fs.readFile(envFilePath, 'utf-8');
|
||||
}
|
||||
|
||||
const parsedEnv = dotenv.parse(envContent);
|
||||
parsedEnv[settingToUpdate.envVar] = newValue;
|
||||
|
||||
// We only want to write back the variables that are not sensitive.
|
||||
const nonSensitiveSettings: Record<string, string> = {};
|
||||
const sensitiveEnvVars = new Set(
|
||||
settings.filter((s) => s.sensitive).map((s) => s.envVar),
|
||||
);
|
||||
for (const [key, value] of Object.entries(parsedEnv)) {
|
||||
if (!sensitiveEnvVars.has(key)) {
|
||||
nonSensitiveSettings[key] = value;
|
||||
}
|
||||
}
|
||||
|
||||
const newEnvContent = formatEnvContent(nonSensitiveSettings);
|
||||
await fs.writeFile(envFilePath, newEnvContent);
|
||||
}
|
||||
|
||||
interface settingsChanges {
|
||||
promptForSensitive: ExtensionSetting[];
|
||||
removeSensitive: ExtensionSetting[];
|
||||
promptForEnv: ExtensionSetting[];
|
||||
removeEnv: ExtensionSetting[];
|
||||
}
|
||||
function getSettingsChanges(
|
||||
settings: ExtensionSetting[],
|
||||
oldSettings: ExtensionSetting[],
|
||||
): settingsChanges {
|
||||
const isSameSetting = (a: ExtensionSetting, b: ExtensionSetting) =>
|
||||
a.envVar === b.envVar && (a.sensitive ?? false) === (b.sensitive ?? false);
|
||||
|
||||
const sensitiveOld = oldSettings.filter((s) => s.sensitive ?? false);
|
||||
const sensitiveNew = settings.filter((s) => s.sensitive ?? false);
|
||||
const envOld = oldSettings.filter((s) => !(s.sensitive ?? false));
|
||||
const envNew = settings.filter((s) => !(s.sensitive ?? false));
|
||||
|
||||
return {
|
||||
promptForSensitive: sensitiveNew.filter(
|
||||
(s) => !sensitiveOld.some((old) => isSameSetting(s, old)),
|
||||
),
|
||||
removeSensitive: sensitiveOld.filter(
|
||||
(s) => !sensitiveNew.some((neu) => isSameSetting(s, neu)),
|
||||
),
|
||||
promptForEnv: envNew.filter(
|
||||
(s) => !envOld.some((old) => isSameSetting(s, old)),
|
||||
),
|
||||
removeEnv: envOld.filter(
|
||||
(s) => !envNew.some((neu) => isSameSetting(s, neu)),
|
||||
),
|
||||
};
|
||||
}
|
||||
|
||||
async function clearSettings(
|
||||
envFilePath: string,
|
||||
keychain: KeychainTokenStorage,
|
||||
) {
|
||||
if (fsSync.existsSync(envFilePath)) {
|
||||
await fs.writeFile(envFilePath, '');
|
||||
}
|
||||
if (!(await keychain.isAvailable())) {
|
||||
return;
|
||||
}
|
||||
const secrets = await keychain.listSecrets();
|
||||
for (const secret of secrets) {
|
||||
await keychain.deleteSecret(secret);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
|
@ -1,179 +0,0 @@
|
|||
/**
|
||||
* @license
|
||||
* Copyright 2025 Google LLC
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
|
||||
import * as fs from 'node:fs';
|
||||
import * as path from 'node:path';
|
||||
import {
|
||||
convertGeminiToQwenConfig,
|
||||
isGeminiExtensionConfig,
|
||||
type GeminiExtensionConfig,
|
||||
} from './gemini-converter.js';
|
||||
|
||||
// Mock fs module
|
||||
vi.mock('node:fs', async (importOriginal) => {
|
||||
const actual = await importOriginal<typeof import('node:fs')>();
|
||||
return {
|
||||
...actual,
|
||||
existsSync: vi.fn(),
|
||||
readFileSync: vi.fn(),
|
||||
};
|
||||
});
|
||||
|
||||
describe('convertGeminiToQwenConfig', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
|
||||
it('should convert basic Gemini config from directory', () => {
|
||||
const mockDir = '/mock/extension/dir';
|
||||
const geminiConfig: GeminiExtensionConfig = {
|
||||
name: 'test-extension',
|
||||
version: '1.0.0',
|
||||
};
|
||||
|
||||
vi.mocked(fs.readFileSync).mockReturnValue(JSON.stringify(geminiConfig));
|
||||
|
||||
const result = convertGeminiToQwenConfig(mockDir);
|
||||
|
||||
expect(result.name).toBe('test-extension');
|
||||
expect(result.version).toBe('1.0.0');
|
||||
expect(fs.readFileSync).toHaveBeenCalledWith(
|
||||
path.join(mockDir, 'gemini-extension.json'),
|
||||
'utf-8',
|
||||
);
|
||||
});
|
||||
|
||||
it('should convert config with all optional fields', () => {
|
||||
const mockDir = '/mock/extension/dir';
|
||||
const geminiConfig = {
|
||||
name: 'full-extension',
|
||||
version: '2.0.0',
|
||||
mcpServers: { server1: {} },
|
||||
contextFileName: 'context.txt',
|
||||
excludeTools: ['tool1', 'tool2'],
|
||||
settings: [
|
||||
{ name: 'Setting1', envVar: 'VAR1', description: 'Test setting' },
|
||||
],
|
||||
};
|
||||
|
||||
vi.mocked(fs.readFileSync).mockReturnValue(JSON.stringify(geminiConfig));
|
||||
|
||||
const result = convertGeminiToQwenConfig(mockDir);
|
||||
|
||||
expect(result.name).toBe('full-extension');
|
||||
expect(result.version).toBe('2.0.0');
|
||||
expect(result.mcpServers).toEqual({ server1: {} });
|
||||
expect(result.contextFileName).toBe('context.txt');
|
||||
expect(result.excludeTools).toEqual(['tool1', 'tool2']);
|
||||
expect(result.settings).toHaveLength(1);
|
||||
expect(result.settings?.[0].name).toBe('Setting1');
|
||||
});
|
||||
|
||||
it('should throw error for missing name', () => {
|
||||
const mockDir = '/mock/extension/dir';
|
||||
const invalidConfig = {
|
||||
version: '1.0.0',
|
||||
};
|
||||
|
||||
vi.mocked(fs.readFileSync).mockReturnValue(JSON.stringify(invalidConfig));
|
||||
|
||||
expect(() => convertGeminiToQwenConfig(mockDir)).toThrow(
|
||||
'Gemini extension config must have name and version fields',
|
||||
);
|
||||
});
|
||||
|
||||
it('should throw error for missing version', () => {
|
||||
const mockDir = '/mock/extension/dir';
|
||||
const invalidConfig = {
|
||||
name: 'test-extension',
|
||||
};
|
||||
|
||||
vi.mocked(fs.readFileSync).mockReturnValue(JSON.stringify(invalidConfig));
|
||||
|
||||
expect(() => convertGeminiToQwenConfig(mockDir)).toThrow(
|
||||
'Gemini extension config must have name and version fields',
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe('isGeminiExtensionConfig', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
|
||||
it('should identify Gemini extension directory with valid config', () => {
|
||||
const mockDir = '/mock/extension/dir';
|
||||
const mockConfig = {
|
||||
name: 'test',
|
||||
version: '1.0.0',
|
||||
settings: [{ name: 'Test', envVar: 'TEST', description: 'Test' }],
|
||||
};
|
||||
|
||||
vi.mocked(fs.existsSync).mockReturnValue(true);
|
||||
vi.mocked(fs.readFileSync).mockReturnValue(JSON.stringify(mockConfig));
|
||||
|
||||
expect(isGeminiExtensionConfig(mockDir)).toBe(true);
|
||||
|
||||
expect(fs.existsSync).toHaveBeenCalledWith(
|
||||
path.join(mockDir, 'gemini-extension.json'),
|
||||
);
|
||||
});
|
||||
|
||||
it('should return false when gemini-extension.json does not exist', () => {
|
||||
const mockDir = '/mock/nonexistent/dir';
|
||||
|
||||
vi.mocked(fs.existsSync).mockReturnValue(false);
|
||||
|
||||
expect(isGeminiExtensionConfig(mockDir)).toBe(false);
|
||||
});
|
||||
|
||||
it('should return false for invalid config content', () => {
|
||||
const mockDir = '/mock/invalid/dir';
|
||||
|
||||
vi.mocked(fs.existsSync).mockReturnValue(true);
|
||||
vi.mocked(fs.readFileSync).mockReturnValue('null');
|
||||
|
||||
expect(isGeminiExtensionConfig(mockDir)).toBe(false);
|
||||
});
|
||||
|
||||
it('should return false for config missing required fields', () => {
|
||||
const mockDir = '/mock/invalid/dir';
|
||||
const invalidConfig = {
|
||||
name: 'test',
|
||||
// missing version
|
||||
};
|
||||
|
||||
vi.mocked(fs.existsSync).mockReturnValue(true);
|
||||
vi.mocked(fs.readFileSync).mockReturnValue(JSON.stringify(invalidConfig));
|
||||
|
||||
expect(isGeminiExtensionConfig(mockDir)).toBe(false);
|
||||
});
|
||||
|
||||
it('should return true for basic config without settings', () => {
|
||||
const mockDir = '/mock/extension/dir';
|
||||
const basicConfig = {
|
||||
name: 'test',
|
||||
version: '1.0.0',
|
||||
};
|
||||
|
||||
vi.mocked(fs.existsSync).mockReturnValue(true);
|
||||
vi.mocked(fs.readFileSync).mockReturnValue(JSON.stringify(basicConfig));
|
||||
|
||||
expect(isGeminiExtensionConfig(mockDir)).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
// Note: convertGeminiExtensionPackage() is tested through integration tests
|
||||
// as it requires real file system operations
|
||||
|
|
@ -1,217 +0,0 @@
|
|||
/**
|
||||
* @license
|
||||
* Copyright 2025 Google LLC
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
/**
|
||||
* Converter for Gemini CLI extensions to Qwen Code format.
|
||||
*/
|
||||
|
||||
import * as fs from 'node:fs';
|
||||
import * as path from 'node:path';
|
||||
import { glob } from 'glob';
|
||||
import type { ExtensionConfig, ExtensionSetting } from '../extension.js';
|
||||
import { ExtensionStorage } from '../extensions/storage.js';
|
||||
import { convertTomlToMarkdown } from '../../services/toml-to-markdown-converter.js';
|
||||
|
||||
export interface GeminiExtensionConfig {
|
||||
name: string;
|
||||
version: string;
|
||||
mcpServers?: Record<string, unknown>;
|
||||
contextFileName?: string | string[];
|
||||
excludeTools?: string[];
|
||||
settings?: ExtensionSetting[];
|
||||
}
|
||||
|
||||
/**
|
||||
* Converts a Gemini CLI extension config to Qwen Code format.
|
||||
* @param extensionDir Path to the Gemini extension directory
|
||||
* @returns Qwen ExtensionConfig
|
||||
*/
|
||||
export function convertGeminiToQwenConfig(
|
||||
extensionDir: string,
|
||||
): ExtensionConfig {
|
||||
const configFilePath = path.join(extensionDir, 'gemini-extension.json');
|
||||
const configContent = fs.readFileSync(configFilePath, 'utf-8');
|
||||
const geminiConfig: GeminiExtensionConfig = JSON.parse(configContent);
|
||||
// Validate required fields
|
||||
if (!geminiConfig.name || !geminiConfig.version) {
|
||||
throw new Error(
|
||||
'Gemini extension config must have name and version fields',
|
||||
);
|
||||
}
|
||||
|
||||
const settings: ExtensionSetting[] | undefined = geminiConfig.settings;
|
||||
|
||||
// Direct field mapping
|
||||
return {
|
||||
name: geminiConfig.name,
|
||||
version: geminiConfig.version,
|
||||
mcpServers: geminiConfig.mcpServers as ExtensionConfig['mcpServers'],
|
||||
contextFileName: geminiConfig.contextFileName,
|
||||
excludeTools: geminiConfig.excludeTools,
|
||||
settings,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Converts a complete Gemini extension package to Qwen Code format.
|
||||
* Creates a new temporary directory with:
|
||||
* 1. Converted qwen-extension.json
|
||||
* 2. Commands converted from TOML to MD
|
||||
* 3. All other files/folders preserved
|
||||
*
|
||||
* @param extensionDir Path to the Gemini extension directory
|
||||
* @returns Object containing converted config and the temporary directory path
|
||||
*/
|
||||
export async function convertGeminiExtensionPackage(
|
||||
extensionDir: string,
|
||||
): Promise<{ config: ExtensionConfig; convertedDir: string }> {
|
||||
const geminiConfig = convertGeminiToQwenConfig(extensionDir);
|
||||
|
||||
// Create temporary directory for converted extension
|
||||
const tmpDir = await ExtensionStorage.createTmpDir();
|
||||
|
||||
try {
|
||||
// Step 1: Copy all files and directories to temporary directory
|
||||
await copyDirectory(extensionDir, tmpDir);
|
||||
|
||||
// Step 2: Convert TOML commands to Markdown in commands folder
|
||||
const commandsDir = path.join(tmpDir, 'commands');
|
||||
if (fs.existsSync(commandsDir)) {
|
||||
await convertCommandsDirectory(commandsDir);
|
||||
}
|
||||
|
||||
// Step 3: Create qwen-extension.json with converted config
|
||||
const qwenConfigPath = path.join(tmpDir, 'qwen-extension.json');
|
||||
fs.writeFileSync(
|
||||
qwenConfigPath,
|
||||
JSON.stringify(geminiConfig, null, 2),
|
||||
'utf-8',
|
||||
);
|
||||
|
||||
return {
|
||||
config: geminiConfig,
|
||||
convertedDir: tmpDir,
|
||||
};
|
||||
} catch (error) {
|
||||
// Clean up temporary directory on error
|
||||
try {
|
||||
fs.rmSync(tmpDir, { recursive: true, force: true });
|
||||
} catch {
|
||||
// Ignore cleanup errors
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Recursively copies a directory and its contents.
|
||||
* @param source Source directory path
|
||||
* @param destination Destination directory path
|
||||
*/
|
||||
async function copyDirectory(
|
||||
source: string,
|
||||
destination: string,
|
||||
): Promise<void> {
|
||||
// Create destination directory if it doesn't exist
|
||||
if (!fs.existsSync(destination)) {
|
||||
fs.mkdirSync(destination, { recursive: true });
|
||||
}
|
||||
|
||||
const entries = fs.readdirSync(source, { withFileTypes: true });
|
||||
|
||||
for (const entry of entries) {
|
||||
const sourcePath = path.join(source, entry.name);
|
||||
const destPath = path.join(destination, entry.name);
|
||||
|
||||
if (entry.isDirectory()) {
|
||||
await copyDirectory(sourcePath, destPath);
|
||||
} else {
|
||||
fs.copyFileSync(sourcePath, destPath);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Converts all TOML command files in a directory to Markdown format.
|
||||
* @param commandsDir Path to the commands directory
|
||||
*/
|
||||
async function convertCommandsDirectory(commandsDir: string): Promise<void> {
|
||||
// Find all .toml files in the commands directory
|
||||
const tomlFiles = await glob('**/*.toml', {
|
||||
cwd: commandsDir,
|
||||
nodir: true,
|
||||
dot: false,
|
||||
});
|
||||
|
||||
// Convert each TOML file to Markdown
|
||||
for (const relativeFile of tomlFiles) {
|
||||
const tomlPath = path.join(commandsDir, relativeFile);
|
||||
|
||||
try {
|
||||
// Read TOML file
|
||||
const tomlContent = fs.readFileSync(tomlPath, 'utf-8');
|
||||
|
||||
// Convert to Markdown
|
||||
const markdownContent = convertTomlToMarkdown(tomlContent);
|
||||
|
||||
// Generate Markdown file path (same location, .md extension)
|
||||
const markdownPath = tomlPath.replace(/\.toml$/, '.md');
|
||||
|
||||
// Write Markdown file
|
||||
fs.writeFileSync(markdownPath, markdownContent, 'utf-8');
|
||||
|
||||
// Delete original TOML file
|
||||
fs.unlinkSync(tomlPath);
|
||||
} catch (error) {
|
||||
console.warn(
|
||||
`Warning: Failed to convert command file ${relativeFile}: ${error instanceof Error ? error.message : String(error)}`,
|
||||
);
|
||||
// Continue with other files even if one fails
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Checks if a config object is in Gemini format.
|
||||
* This is a heuristic check based on typical Gemini extension patterns.
|
||||
* @param config Configuration object to check
|
||||
* @returns true if config appears to be Gemini format
|
||||
*/
|
||||
export function isGeminiExtensionConfig(extensionDir: string) {
|
||||
const configFilePath = path.join(extensionDir, 'gemini-extension.json');
|
||||
if (!fs.existsSync(configFilePath)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const configContent = fs.readFileSync(configFilePath, 'utf-8');
|
||||
const parsedConfig = JSON.parse(configContent);
|
||||
|
||||
if (typeof parsedConfig !== 'object' || parsedConfig === null) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const obj = parsedConfig as Record<string, unknown>;
|
||||
|
||||
// Must have name and version
|
||||
if (typeof obj['name'] !== 'string' || typeof obj['version'] !== 'string') {
|
||||
return false;
|
||||
}
|
||||
|
||||
// Check for Gemini-specific settings format
|
||||
if (obj['settings'] && Array.isArray(obj['settings'])) {
|
||||
const firstSetting = obj['settings'][0];
|
||||
if (
|
||||
firstSetting &&
|
||||
typeof firstSetting === 'object' &&
|
||||
'envVar' in firstSetting
|
||||
) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
// If it has Gemini-specific fields but not Qwen-specific fields, likely Gemini
|
||||
return true;
|
||||
}
|
||||
|
|
@ -1,429 +0,0 @@
|
|||
/**
|
||||
* @license
|
||||
* Copyright 2025 Google LLC
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
import {
|
||||
checkForExtensionUpdate,
|
||||
cloneFromGit,
|
||||
extractFile,
|
||||
findReleaseAsset,
|
||||
parseGitHubRepoForReleases,
|
||||
} from './github.js';
|
||||
import { simpleGit, type SimpleGit } from 'simple-git';
|
||||
import { ExtensionUpdateState } from '../../ui/state/extensions.js';
|
||||
import * as os from 'node:os';
|
||||
import * as fs from 'node:fs/promises';
|
||||
import * as fsSync from 'node:fs';
|
||||
import * as path from 'node:path';
|
||||
import * as tar from 'tar';
|
||||
import * as archiver from 'archiver';
|
||||
import type { GeminiCLIExtension } from '@qwen-code/qwen-code-core';
|
||||
|
||||
const mockPlatform = vi.hoisted(() => vi.fn());
|
||||
const mockArch = vi.hoisted(() => vi.fn());
|
||||
vi.mock('node:os', async (importOriginal) => {
|
||||
const actual = await importOriginal<typeof os>();
|
||||
return {
|
||||
...actual,
|
||||
platform: mockPlatform,
|
||||
arch: mockArch,
|
||||
};
|
||||
});
|
||||
|
||||
vi.mock('simple-git');
|
||||
|
||||
describe('git extension helpers', () => {
|
||||
afterEach(() => {
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
|
||||
describe('cloneFromGit', () => {
|
||||
const mockGit = {
|
||||
clone: vi.fn(),
|
||||
getRemotes: vi.fn(),
|
||||
fetch: vi.fn(),
|
||||
checkout: vi.fn(),
|
||||
};
|
||||
|
||||
beforeEach(() => {
|
||||
vi.mocked(simpleGit).mockReturnValue(mockGit as unknown as SimpleGit);
|
||||
});
|
||||
|
||||
it('should clone, fetch and checkout a repo', async () => {
|
||||
const installMetadata = {
|
||||
source: 'http://my-repo.com',
|
||||
ref: 'my-ref',
|
||||
type: 'git' as const,
|
||||
};
|
||||
const destination = '/dest';
|
||||
mockGit.getRemotes.mockResolvedValue([
|
||||
{ name: 'origin', refs: { fetch: 'http://my-repo.com' } },
|
||||
]);
|
||||
|
||||
await cloneFromGit(installMetadata, destination);
|
||||
|
||||
expect(mockGit.clone).toHaveBeenCalledWith('http://my-repo.com', './', [
|
||||
'--depth',
|
||||
'1',
|
||||
]);
|
||||
expect(mockGit.getRemotes).toHaveBeenCalledWith(true);
|
||||
expect(mockGit.fetch).toHaveBeenCalledWith('origin', 'my-ref');
|
||||
expect(mockGit.checkout).toHaveBeenCalledWith('FETCH_HEAD');
|
||||
});
|
||||
|
||||
it('should use HEAD if ref is not provided', async () => {
|
||||
const installMetadata = {
|
||||
source: 'http://my-repo.com',
|
||||
type: 'git' as const,
|
||||
};
|
||||
const destination = '/dest';
|
||||
mockGit.getRemotes.mockResolvedValue([
|
||||
{ name: 'origin', refs: { fetch: 'http://my-repo.com' } },
|
||||
]);
|
||||
|
||||
await cloneFromGit(installMetadata, destination);
|
||||
|
||||
expect(mockGit.fetch).toHaveBeenCalledWith('origin', 'HEAD');
|
||||
});
|
||||
|
||||
it('should throw if no remotes are found', async () => {
|
||||
const installMetadata = {
|
||||
source: 'http://my-repo.com',
|
||||
type: 'git' as const,
|
||||
};
|
||||
const destination = '/dest';
|
||||
mockGit.getRemotes.mockResolvedValue([]);
|
||||
|
||||
await expect(cloneFromGit(installMetadata, destination)).rejects.toThrow(
|
||||
'Failed to clone Git repository from http://my-repo.com',
|
||||
);
|
||||
});
|
||||
|
||||
it('should throw on clone error', async () => {
|
||||
const installMetadata = {
|
||||
source: 'http://my-repo.com',
|
||||
type: 'git' as const,
|
||||
};
|
||||
const destination = '/dest';
|
||||
mockGit.clone.mockRejectedValue(new Error('clone failed'));
|
||||
|
||||
await expect(cloneFromGit(installMetadata, destination)).rejects.toThrow(
|
||||
'Failed to clone Git repository from http://my-repo.com',
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe('checkForExtensionUpdate', () => {
|
||||
const mockGit = {
|
||||
getRemotes: vi.fn(),
|
||||
listRemote: vi.fn(),
|
||||
revparse: vi.fn(),
|
||||
};
|
||||
|
||||
beforeEach(() => {
|
||||
vi.mocked(simpleGit).mockReturnValue(mockGit as unknown as SimpleGit);
|
||||
});
|
||||
|
||||
it('should return NOT_UPDATABLE for non-git extensions', async () => {
|
||||
const extension: GeminiCLIExtension = {
|
||||
name: 'test',
|
||||
path: '/ext',
|
||||
version: '1.0.0',
|
||||
isActive: true,
|
||||
installMetadata: {
|
||||
type: 'link',
|
||||
source: '',
|
||||
},
|
||||
};
|
||||
let result: ExtensionUpdateState | undefined = undefined;
|
||||
await checkForExtensionUpdate(
|
||||
extension,
|
||||
(newState) => (result = newState),
|
||||
);
|
||||
expect(result).toBe(ExtensionUpdateState.NOT_UPDATABLE);
|
||||
});
|
||||
|
||||
it('should return ERROR if no remotes found', async () => {
|
||||
const extension: GeminiCLIExtension = {
|
||||
name: 'test',
|
||||
path: '/ext',
|
||||
version: '1.0.0',
|
||||
isActive: true,
|
||||
installMetadata: {
|
||||
type: 'git',
|
||||
source: '',
|
||||
},
|
||||
};
|
||||
mockGit.getRemotes.mockResolvedValue([]);
|
||||
let result: ExtensionUpdateState | undefined = undefined;
|
||||
await checkForExtensionUpdate(
|
||||
extension,
|
||||
(newState) => (result = newState),
|
||||
);
|
||||
expect(result).toBe(ExtensionUpdateState.ERROR);
|
||||
});
|
||||
|
||||
it('should return UPDATE_AVAILABLE when remote hash is different', async () => {
|
||||
const extension: GeminiCLIExtension = {
|
||||
name: 'test',
|
||||
path: '/ext',
|
||||
version: '1.0.0',
|
||||
isActive: true,
|
||||
installMetadata: {
|
||||
type: 'git',
|
||||
source: 'my/ext',
|
||||
},
|
||||
};
|
||||
mockGit.getRemotes.mockResolvedValue([
|
||||
{ name: 'origin', refs: { fetch: 'http://my-repo.com' } },
|
||||
]);
|
||||
mockGit.listRemote.mockResolvedValue('remote-hash\tHEAD');
|
||||
mockGit.revparse.mockResolvedValue('local-hash');
|
||||
|
||||
let result: ExtensionUpdateState | undefined = undefined;
|
||||
await checkForExtensionUpdate(
|
||||
extension,
|
||||
(newState) => (result = newState),
|
||||
);
|
||||
expect(result).toBe(ExtensionUpdateState.UPDATE_AVAILABLE);
|
||||
});
|
||||
|
||||
it('should return UP_TO_DATE when remote and local hashes are the same', async () => {
|
||||
const extension: GeminiCLIExtension = {
|
||||
name: 'test',
|
||||
path: '/ext',
|
||||
version: '1.0.0',
|
||||
isActive: true,
|
||||
installMetadata: {
|
||||
type: 'git',
|
||||
source: 'my/ext',
|
||||
},
|
||||
};
|
||||
mockGit.getRemotes.mockResolvedValue([
|
||||
{ name: 'origin', refs: { fetch: 'http://my-repo.com' } },
|
||||
]);
|
||||
mockGit.listRemote.mockResolvedValue('same-hash\tHEAD');
|
||||
mockGit.revparse.mockResolvedValue('same-hash');
|
||||
|
||||
let result: ExtensionUpdateState | undefined = undefined;
|
||||
await checkForExtensionUpdate(
|
||||
extension,
|
||||
(newState) => (result = newState),
|
||||
);
|
||||
expect(result).toBe(ExtensionUpdateState.UP_TO_DATE);
|
||||
});
|
||||
|
||||
it('should return ERROR on git error', async () => {
|
||||
const extension: GeminiCLIExtension = {
|
||||
name: 'test',
|
||||
path: '/ext',
|
||||
version: '1.0.0',
|
||||
isActive: true,
|
||||
installMetadata: {
|
||||
type: 'git',
|
||||
source: 'my/ext',
|
||||
},
|
||||
};
|
||||
mockGit.getRemotes.mockRejectedValue(new Error('git error'));
|
||||
|
||||
let result: ExtensionUpdateState | undefined = undefined;
|
||||
await checkForExtensionUpdate(
|
||||
extension,
|
||||
(newState) => (result = newState),
|
||||
);
|
||||
expect(result).toBe(ExtensionUpdateState.ERROR);
|
||||
});
|
||||
});
|
||||
|
||||
describe('findReleaseAsset', () => {
|
||||
const assets = [
|
||||
{ name: 'darwin.arm64.extension.tar.gz', browser_download_url: 'url1' },
|
||||
{ name: 'darwin.x64.extension.tar.gz', browser_download_url: 'url2' },
|
||||
{ name: 'linux.x64.extension.tar.gz', browser_download_url: 'url3' },
|
||||
{ name: 'win32.x64.extension.tar.gz', browser_download_url: 'url4' },
|
||||
{ name: 'extension-generic.tar.gz', browser_download_url: 'url5' },
|
||||
];
|
||||
|
||||
it('should find asset matching platform and architecture', () => {
|
||||
mockPlatform.mockReturnValue('darwin');
|
||||
mockArch.mockReturnValue('arm64');
|
||||
const result = findReleaseAsset(assets);
|
||||
expect(result).toEqual(assets[0]);
|
||||
});
|
||||
|
||||
it('should find asset matching platform if arch does not match', () => {
|
||||
mockPlatform.mockReturnValue('linux');
|
||||
mockArch.mockReturnValue('arm64');
|
||||
const result = findReleaseAsset(assets);
|
||||
expect(result).toEqual(assets[2]);
|
||||
});
|
||||
|
||||
it('should return undefined if no matching asset is found', () => {
|
||||
mockPlatform.mockReturnValue('sunos');
|
||||
mockArch.mockReturnValue('x64');
|
||||
const result = findReleaseAsset(assets);
|
||||
expect(result).toBeUndefined();
|
||||
});
|
||||
|
||||
it('should find generic asset if it is the only one', () => {
|
||||
const singleAsset = [
|
||||
{ name: 'extension.tar.gz', browser_download_url: 'url' },
|
||||
];
|
||||
mockPlatform.mockReturnValue('darwin');
|
||||
mockArch.mockReturnValue('arm64');
|
||||
const result = findReleaseAsset(singleAsset);
|
||||
expect(result).toEqual(singleAsset[0]);
|
||||
});
|
||||
|
||||
it('should return undefined if multiple generic assets exist', () => {
|
||||
const multipleGenericAssets = [
|
||||
{ name: 'extension-1.tar.gz', browser_download_url: 'url1' },
|
||||
{ name: 'extension-2.tar.gz', browser_download_url: 'url2' },
|
||||
];
|
||||
mockPlatform.mockReturnValue('darwin');
|
||||
mockArch.mockReturnValue('arm64');
|
||||
const result = findReleaseAsset(multipleGenericAssets);
|
||||
expect(result).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
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);
|
||||
expect(owner).toBe('owner');
|
||||
expect(repo).toBe('repo');
|
||||
});
|
||||
|
||||
it('should parse owner and repo from a full GitHub UR without .git', () => {
|
||||
const source = 'https://github.com/owner/repo';
|
||||
const { owner, repo } = parseGitHubRepoForReleases(source);
|
||||
expect(owner).toBe('owner');
|
||||
expect(repo).toBe('repo');
|
||||
});
|
||||
|
||||
it('should fail on 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.',
|
||||
);
|
||||
});
|
||||
|
||||
it('should fail on a non-GitHub URL', () => {
|
||||
const source = 'https://example.com/owner/repo.git';
|
||||
expect(() => parseGitHubRepoForReleases(source)).toThrow(
|
||||
'Invalid GitHub repository source: https://example.com/owner/repo.git. Expected "owner/repo" or a github repo uri.',
|
||||
);
|
||||
});
|
||||
|
||||
it('should parse owner and repo from a shorthand string', () => {
|
||||
const source = 'owner/repo';
|
||||
const { owner, repo } = parseGitHubRepoForReleases(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);
|
||||
expect(owner).toBe('owner');
|
||||
expect(repo).toBe('repo');
|
||||
});
|
||||
|
||||
it('should throw error for invalid source format', () => {
|
||||
const source = 'invalid-format';
|
||||
expect(() => parseGitHubRepoForReleases(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(
|
||||
'Invalid GitHub repository source: https://github.com/owner/repo/extra. Expected "owner/repo" or a github repo uri.',
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe('extractFile', () => {
|
||||
let tempDir: string;
|
||||
|
||||
beforeEach(async () => {
|
||||
tempDir = await fs.mkdtemp(path.join(os.tmpdir(), 'gemini-test-'));
|
||||
});
|
||||
|
||||
afterEach(async () => {
|
||||
await fs.rm(tempDir, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
it('should extract a .tar.gz file', async () => {
|
||||
const archivePath = path.join(tempDir, 'test.tar.gz');
|
||||
const extractionDest = path.join(tempDir, 'extracted');
|
||||
await fs.mkdir(extractionDest);
|
||||
|
||||
// Create a dummy file to be archived
|
||||
const dummyFilePath = path.join(tempDir, 'test.txt');
|
||||
await fs.writeFile(dummyFilePath, 'hello tar');
|
||||
|
||||
// Create the tar.gz file
|
||||
await tar.c(
|
||||
{
|
||||
gzip: true,
|
||||
file: archivePath,
|
||||
cwd: tempDir,
|
||||
},
|
||||
['test.txt'],
|
||||
);
|
||||
|
||||
await extractFile(archivePath, extractionDest);
|
||||
|
||||
const extractedFilePath = path.join(extractionDest, 'test.txt');
|
||||
const content = await fs.readFile(extractedFilePath, 'utf-8');
|
||||
expect(content).toBe('hello tar');
|
||||
});
|
||||
|
||||
it('should extract a .zip file', async () => {
|
||||
const archivePath = path.join(tempDir, 'test.zip');
|
||||
const extractionDest = path.join(tempDir, 'extracted');
|
||||
await fs.mkdir(extractionDest);
|
||||
|
||||
// Create a dummy file to be archived
|
||||
const dummyFilePath = path.join(tempDir, 'test.txt');
|
||||
await fs.writeFile(dummyFilePath, 'hello zip');
|
||||
|
||||
// Create the zip file
|
||||
const output = fsSync.createWriteStream(archivePath);
|
||||
const archive = archiver.create('zip');
|
||||
|
||||
const streamFinished = new Promise((resolve, reject) => {
|
||||
output.on('close', () => resolve(null));
|
||||
archive.on('error', reject);
|
||||
});
|
||||
|
||||
archive.pipe(output);
|
||||
archive.file(dummyFilePath, { name: 'test.txt' });
|
||||
await archive.finalize();
|
||||
await streamFinished;
|
||||
|
||||
await extractFile(archivePath, extractionDest);
|
||||
|
||||
const extractedFilePath = path.join(extractionDest, 'test.txt');
|
||||
const content = await fs.readFile(extractedFilePath, 'utf-8');
|
||||
expect(content).toBe('hello zip');
|
||||
});
|
||||
|
||||
it('should throw an error for unsupported file types', async () => {
|
||||
const unsupportedFilePath = path.join(tempDir, 'test.txt');
|
||||
await fs.writeFile(unsupportedFilePath, 'some content');
|
||||
const extractionDest = path.join(tempDir, 'extracted');
|
||||
await fs.mkdir(extractionDest);
|
||||
|
||||
await expect(
|
||||
extractFile(unsupportedFilePath, extractionDest),
|
||||
).rejects.toThrow('Unsupported file extension for extraction:');
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
@ -1,432 +0,0 @@
|
|||
/**
|
||||
* @license
|
||||
* Copyright 2025 Google LLC
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
import { simpleGit } from 'simple-git';
|
||||
import { getErrorMessage } from '../../utils/errors.js';
|
||||
import type {
|
||||
ExtensionInstallMetadata,
|
||||
GeminiCLIExtension,
|
||||
} from '@qwen-code/qwen-code-core';
|
||||
import { ExtensionUpdateState } from '../../ui/state/extensions.js';
|
||||
import * as os from 'node:os';
|
||||
import * as https from 'node:https';
|
||||
import * as fs from 'node:fs';
|
||||
import * as path from 'node:path';
|
||||
import { loadExtension } from '../extension.js';
|
||||
import { EXTENSIONS_CONFIG_FILENAME } from './variables.js';
|
||||
import * as tar from 'tar';
|
||||
import extract from 'extract-zip';
|
||||
|
||||
function getGitHubToken(): string | undefined {
|
||||
return process.env['GITHUB_TOKEN'];
|
||||
}
|
||||
|
||||
/**
|
||||
* Clones a Git repository to a specified local path.
|
||||
* @param installMetadata The metadata for the extension to install.
|
||||
* @param destination The destination path to clone the repository to.
|
||||
*/
|
||||
export async function cloneFromGit(
|
||||
installMetadata: ExtensionInstallMetadata,
|
||||
destination: string,
|
||||
): Promise<void> {
|
||||
try {
|
||||
const git = simpleGit(destination);
|
||||
let sourceUrl = installMetadata.source;
|
||||
const token = getGitHubToken();
|
||||
if (token) {
|
||||
try {
|
||||
const parsedUrl = new URL(sourceUrl);
|
||||
if (
|
||||
parsedUrl.protocol === 'https:' &&
|
||||
parsedUrl.hostname === 'github.com'
|
||||
) {
|
||||
if (!parsedUrl.username) {
|
||||
parsedUrl.username = token;
|
||||
}
|
||||
sourceUrl = parsedUrl.toString();
|
||||
}
|
||||
} catch {
|
||||
// If source is not a valid URL, we don't inject the token.
|
||||
// We let git handle the source as is.
|
||||
}
|
||||
}
|
||||
await git.clone(sourceUrl, './', ['--depth', '1']);
|
||||
|
||||
const remotes = await git.getRemotes(true);
|
||||
if (remotes.length === 0) {
|
||||
throw new Error(
|
||||
`Unable to find any remotes for repo ${installMetadata.source}`,
|
||||
);
|
||||
}
|
||||
|
||||
const refToFetch = installMetadata.ref || 'HEAD';
|
||||
|
||||
await git.fetch(remotes[0].name, refToFetch);
|
||||
|
||||
// After fetching, checkout FETCH_HEAD to get the content of the fetched ref.
|
||||
// This results in a detached HEAD state, which is fine for this purpose.
|
||||
await git.checkout('FETCH_HEAD');
|
||||
} catch (error) {
|
||||
throw new Error(
|
||||
`Failed to clone Git repository from ${installMetadata.source} ${getErrorMessage(error)}`,
|
||||
{
|
||||
cause: error,
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
export function parseGitHubRepoForReleases(source: string): {
|
||||
owner: string;
|
||||
repo: string;
|
||||
} {
|
||||
// Default to a github repo path, so `source` can be just an org/repo
|
||||
const parsedUrl = URL.parse(source, 'https://github.com');
|
||||
// The pathname should be "/owner/repo".
|
||||
const parts = parsedUrl?.pathname.substring(1).split('/');
|
||||
if (parts?.length !== 2 || parsedUrl?.host !== 'github.com') {
|
||||
throw new Error(
|
||||
`Invalid GitHub repository source: ${source}. Expected "owner/repo" or a github repo uri.`,
|
||||
);
|
||||
}
|
||||
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 };
|
||||
}
|
||||
|
||||
async function fetchReleaseFromGithub(
|
||||
owner: string,
|
||||
repo: string,
|
||||
ref?: string,
|
||||
): Promise<GithubReleaseData> {
|
||||
const endpoint = ref ? `releases/tags/${ref}` : 'releases/latest';
|
||||
const url = `https://api.github.com/repos/${owner}/${repo}/${endpoint}`;
|
||||
return await fetchJson(url);
|
||||
}
|
||||
|
||||
export async function checkForExtensionUpdate(
|
||||
extension: GeminiCLIExtension,
|
||||
setExtensionUpdateState: (updateState: ExtensionUpdateState) => void,
|
||||
cwd: string = process.cwd(),
|
||||
): Promise<void> {
|
||||
setExtensionUpdateState(ExtensionUpdateState.CHECKING_FOR_UPDATES);
|
||||
const installMetadata = extension.installMetadata;
|
||||
if (installMetadata?.type === 'local') {
|
||||
const newExtension = loadExtension({
|
||||
extensionDir: installMetadata.source,
|
||||
workspaceDir: cwd,
|
||||
});
|
||||
if (!newExtension) {
|
||||
console.error(
|
||||
`Failed to check for update for local extension "${extension.name}". Could not load extension from source path: ${installMetadata.source}`,
|
||||
);
|
||||
setExtensionUpdateState(ExtensionUpdateState.ERROR);
|
||||
return;
|
||||
}
|
||||
if (newExtension.config.version !== extension.version) {
|
||||
setExtensionUpdateState(ExtensionUpdateState.UPDATE_AVAILABLE);
|
||||
return;
|
||||
}
|
||||
setExtensionUpdateState(ExtensionUpdateState.UP_TO_DATE);
|
||||
return;
|
||||
}
|
||||
if (
|
||||
!installMetadata ||
|
||||
(installMetadata.type !== 'git' &&
|
||||
installMetadata.type !== 'github-release')
|
||||
) {
|
||||
setExtensionUpdateState(ExtensionUpdateState.NOT_UPDATABLE);
|
||||
return;
|
||||
}
|
||||
try {
|
||||
if (installMetadata.type === 'git') {
|
||||
const git = simpleGit(extension.path);
|
||||
const remotes = await git.getRemotes(true);
|
||||
if (remotes.length === 0) {
|
||||
console.error('No git remotes found.');
|
||||
setExtensionUpdateState(ExtensionUpdateState.ERROR);
|
||||
return;
|
||||
}
|
||||
const remoteUrl = remotes[0].refs.fetch;
|
||||
if (!remoteUrl) {
|
||||
console.error(`No fetch URL found for git remote ${remotes[0].name}.`);
|
||||
setExtensionUpdateState(ExtensionUpdateState.ERROR);
|
||||
return;
|
||||
}
|
||||
|
||||
// Determine the ref to check on the remote.
|
||||
const refToCheck = installMetadata.ref || 'HEAD';
|
||||
|
||||
const lsRemoteOutput = await git.listRemote([remoteUrl, refToCheck]);
|
||||
|
||||
if (typeof lsRemoteOutput !== 'string' || lsRemoteOutput.trim() === '') {
|
||||
console.error(`Git ref ${refToCheck} not found.`);
|
||||
setExtensionUpdateState(ExtensionUpdateState.ERROR);
|
||||
return;
|
||||
}
|
||||
|
||||
const remoteHash = lsRemoteOutput.split('\t')[0];
|
||||
const localHash = await git.revparse(['HEAD']);
|
||||
|
||||
if (!remoteHash) {
|
||||
console.error(
|
||||
`Unable to parse hash from git ls-remote output "${lsRemoteOutput}"`,
|
||||
);
|
||||
setExtensionUpdateState(ExtensionUpdateState.ERROR);
|
||||
return;
|
||||
}
|
||||
if (remoteHash === localHash) {
|
||||
setExtensionUpdateState(ExtensionUpdateState.UP_TO_DATE);
|
||||
return;
|
||||
}
|
||||
setExtensionUpdateState(ExtensionUpdateState.UPDATE_AVAILABLE);
|
||||
return;
|
||||
} else {
|
||||
const { source, releaseTag } = installMetadata;
|
||||
if (!source) {
|
||||
console.error(`No "source" provided for extension.`);
|
||||
setExtensionUpdateState(ExtensionUpdateState.ERROR);
|
||||
return;
|
||||
}
|
||||
const { owner, repo } = parseGitHubRepoForReleases(source);
|
||||
|
||||
const releaseData = await fetchReleaseFromGithub(
|
||||
owner,
|
||||
repo,
|
||||
installMetadata.ref,
|
||||
);
|
||||
if (releaseData.tag_name !== releaseTag) {
|
||||
setExtensionUpdateState(ExtensionUpdateState.UPDATE_AVAILABLE);
|
||||
return;
|
||||
}
|
||||
setExtensionUpdateState(ExtensionUpdateState.UP_TO_DATE);
|
||||
return;
|
||||
}
|
||||
} catch (error) {
|
||||
console.error(
|
||||
`Failed to check for updates for extension "${installMetadata.source}": ${getErrorMessage(error)}`,
|
||||
);
|
||||
setExtensionUpdateState(ExtensionUpdateState.ERROR);
|
||||
return;
|
||||
}
|
||||
}
|
||||
export interface GitHubDownloadResult {
|
||||
tagName: string;
|
||||
type: 'git' | 'github-release';
|
||||
}
|
||||
export async function downloadFromGitHubRelease(
|
||||
installMetadata: ExtensionInstallMetadata,
|
||||
destination: string,
|
||||
): Promise<GitHubDownloadResult> {
|
||||
const { source, ref } = installMetadata;
|
||||
const { owner, repo } = parseGitHubRepoForReleases(source);
|
||||
|
||||
try {
|
||||
const releaseData = await fetchReleaseFromGithub(owner, repo, ref);
|
||||
if (!releaseData) {
|
||||
throw new Error(
|
||||
`No release data found for ${owner}/${repo} at tag ${ref}`,
|
||||
);
|
||||
}
|
||||
|
||||
const asset = findReleaseAsset(releaseData.assets);
|
||||
let archiveUrl: string | undefined;
|
||||
let isTar = false;
|
||||
let isZip = false;
|
||||
if (asset) {
|
||||
archiveUrl = asset.browser_download_url;
|
||||
} else {
|
||||
if (releaseData.tarball_url) {
|
||||
archiveUrl = releaseData.tarball_url;
|
||||
isTar = true;
|
||||
} else if (releaseData.zipball_url) {
|
||||
archiveUrl = releaseData.zipball_url;
|
||||
isZip = true;
|
||||
}
|
||||
}
|
||||
if (!archiveUrl) {
|
||||
throw new Error(
|
||||
`No assets found for release with tag ${releaseData.tag_name}`,
|
||||
);
|
||||
}
|
||||
let downloadedAssetPath = path.join(
|
||||
destination,
|
||||
path.basename(new URL(archiveUrl).pathname),
|
||||
);
|
||||
if (isTar && !downloadedAssetPath.endsWith('.tar.gz')) {
|
||||
downloadedAssetPath += '.tar.gz';
|
||||
} else if (isZip && !downloadedAssetPath.endsWith('.zip')) {
|
||||
downloadedAssetPath += '.zip';
|
||||
}
|
||||
|
||||
await downloadFile(archiveUrl, downloadedAssetPath);
|
||||
|
||||
await extractFile(downloadedAssetPath, destination);
|
||||
|
||||
// For regular github releases, the repository is put inside of a top level
|
||||
// directory. In this case we should see exactly two file in the destination
|
||||
// dir, the archive and the directory. If we see that, validate that the
|
||||
// dir has a qwen extension configuration file and then move all files
|
||||
// from the directory up one level into the destination directory.
|
||||
const entries = await fs.promises.readdir(destination, {
|
||||
withFileTypes: true,
|
||||
});
|
||||
if (entries.length === 2) {
|
||||
const lonelyDir = entries.find((entry) => entry.isDirectory());
|
||||
if (
|
||||
lonelyDir &&
|
||||
fs.existsSync(
|
||||
path.join(destination, lonelyDir.name, EXTENSIONS_CONFIG_FILENAME),
|
||||
)
|
||||
) {
|
||||
const dirPathToExtract = path.join(destination, lonelyDir.name);
|
||||
const extractedDirFiles = await fs.promises.readdir(dirPathToExtract);
|
||||
for (const file of extractedDirFiles) {
|
||||
await fs.promises.rename(
|
||||
path.join(dirPathToExtract, file),
|
||||
path.join(destination, file),
|
||||
);
|
||||
}
|
||||
await fs.promises.rmdir(dirPathToExtract);
|
||||
}
|
||||
}
|
||||
|
||||
await fs.promises.unlink(downloadedAssetPath);
|
||||
return {
|
||||
tagName: releaseData.tag_name,
|
||||
type: 'github-release',
|
||||
};
|
||||
} catch (error) {
|
||||
throw new Error(
|
||||
`Failed to download release from ${installMetadata.source}: ${getErrorMessage(error)}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
interface GithubReleaseData {
|
||||
assets: Asset[];
|
||||
tag_name: string;
|
||||
tarball_url?: string;
|
||||
zipball_url?: string;
|
||||
}
|
||||
|
||||
interface Asset {
|
||||
name: string;
|
||||
browser_download_url: string;
|
||||
}
|
||||
|
||||
export function findReleaseAsset(assets: Asset[]): Asset | undefined {
|
||||
const platform = os.platform();
|
||||
const arch = os.arch();
|
||||
|
||||
const platformArchPrefix = `${platform}.${arch}.`;
|
||||
const platformPrefix = `${platform}.`;
|
||||
|
||||
// Check for platform + architecture specific asset
|
||||
const platformArchAsset = assets.find((asset) =>
|
||||
asset.name.toLowerCase().startsWith(platformArchPrefix),
|
||||
);
|
||||
if (platformArchAsset) {
|
||||
return platformArchAsset;
|
||||
}
|
||||
|
||||
// Check for platform specific asset
|
||||
const platformAsset = assets.find((asset) =>
|
||||
asset.name.toLowerCase().startsWith(platformPrefix),
|
||||
);
|
||||
if (platformAsset) {
|
||||
return platformAsset;
|
||||
}
|
||||
|
||||
// Check for generic asset if only one is available
|
||||
const genericAsset = assets.find(
|
||||
(asset) =>
|
||||
!asset.name.toLowerCase().includes('darwin') &&
|
||||
!asset.name.toLowerCase().includes('linux') &&
|
||||
!asset.name.toLowerCase().includes('win32'),
|
||||
);
|
||||
if (assets.length === 1) {
|
||||
return genericAsset;
|
||||
}
|
||||
|
||||
return undefined;
|
||||
}
|
||||
|
||||
async function fetchJson<T>(url: string): Promise<T> {
|
||||
const headers: { 'User-Agent': string; Authorization?: string } = {
|
||||
'User-Agent': 'gemini-cli',
|
||||
};
|
||||
const token = getGitHubToken();
|
||||
if (token) {
|
||||
headers.Authorization = `token ${token}`;
|
||||
}
|
||||
return new Promise((resolve, reject) => {
|
||||
https
|
||||
.get(url, { headers }, (res) => {
|
||||
if (res.statusCode !== 200) {
|
||||
return reject(
|
||||
new Error(`Request failed with status code ${res.statusCode}`),
|
||||
);
|
||||
}
|
||||
const chunks: Buffer[] = [];
|
||||
res.on('data', (chunk) => chunks.push(chunk));
|
||||
res.on('end', () => {
|
||||
const data = Buffer.concat(chunks).toString();
|
||||
resolve(JSON.parse(data) as T);
|
||||
});
|
||||
})
|
||||
.on('error', reject);
|
||||
});
|
||||
}
|
||||
|
||||
async function downloadFile(url: string, dest: string): Promise<void> {
|
||||
const headers: { 'User-agent': string; Authorization?: string } = {
|
||||
'User-agent': 'gemini-cli',
|
||||
};
|
||||
const token = getGitHubToken();
|
||||
if (token) {
|
||||
headers.Authorization = `token ${token}`;
|
||||
}
|
||||
return new Promise((resolve, reject) => {
|
||||
https
|
||||
.get(url, { headers }, (res) => {
|
||||
if (res.statusCode === 302 || res.statusCode === 301) {
|
||||
downloadFile(res.headers.location!, dest).then(resolve).catch(reject);
|
||||
return;
|
||||
}
|
||||
if (res.statusCode !== 200) {
|
||||
return reject(
|
||||
new Error(`Request failed with status code ${res.statusCode}`),
|
||||
);
|
||||
}
|
||||
const file = fs.createWriteStream(dest);
|
||||
res.pipe(file);
|
||||
file.on('finish', () => file.close(resolve as () => void));
|
||||
})
|
||||
.on('error', reject);
|
||||
});
|
||||
}
|
||||
|
||||
export async function extractFile(file: string, dest: string): Promise<void> {
|
||||
if (file.endsWith('.tar.gz')) {
|
||||
await tar.x({
|
||||
file,
|
||||
cwd: dest,
|
||||
});
|
||||
} else if (file.endsWith('.zip')) {
|
||||
await extract(file, { dir: dest });
|
||||
} else {
|
||||
throw new Error(`Unsupported file extension for extraction: ${file}`);
|
||||
}
|
||||
}
|
||||
|
|
@ -1,78 +0,0 @@
|
|||
/**
|
||||
* @license
|
||||
* Copyright 2025 Google LLC
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import { parseMarketplaceSource } from './marketplace.js';
|
||||
|
||||
describe('Marketplace Installation', () => {
|
||||
describe('parseMarketplaceSource', () => {
|
||||
it('should parse valid marketplace source with http URL', () => {
|
||||
const result = parseMarketplaceSource(
|
||||
'http://example.com/marketplace:my-plugin',
|
||||
);
|
||||
expect(result).toEqual({
|
||||
marketplaceUrl: 'http://example.com/marketplace',
|
||||
pluginName: 'my-plugin',
|
||||
});
|
||||
});
|
||||
|
||||
it('should parse valid marketplace source with https URL', () => {
|
||||
const result = parseMarketplaceSource(
|
||||
'https://github.com/example/marketplace:awesome-plugin',
|
||||
);
|
||||
expect(result).toEqual({
|
||||
marketplaceUrl: 'https://github.com/example/marketplace',
|
||||
pluginName: 'awesome-plugin',
|
||||
});
|
||||
});
|
||||
|
||||
it('should handle plugin names with hyphens', () => {
|
||||
const result = parseMarketplaceSource(
|
||||
'https://example.com:my-super-plugin',
|
||||
);
|
||||
expect(result).toEqual({
|
||||
marketplaceUrl: 'https://example.com',
|
||||
pluginName: 'my-super-plugin',
|
||||
});
|
||||
});
|
||||
|
||||
it('should handle URLs with ports', () => {
|
||||
const result = parseMarketplaceSource(
|
||||
'https://example.com:8080/marketplace:plugin',
|
||||
);
|
||||
expect(result).toEqual({
|
||||
marketplaceUrl: 'https://example.com:8080/marketplace',
|
||||
pluginName: 'plugin',
|
||||
});
|
||||
});
|
||||
|
||||
it('should return null for source without colon separator', () => {
|
||||
const result = parseMarketplaceSource('https://example.com/plugin');
|
||||
expect(result).toBeNull();
|
||||
});
|
||||
|
||||
it('should return null for source without URL', () => {
|
||||
const result = parseMarketplaceSource('not-a-url:plugin');
|
||||
expect(result).toBeNull();
|
||||
});
|
||||
|
||||
it('should return null for source with empty plugin name', () => {
|
||||
const result = parseMarketplaceSource('https://example.com:');
|
||||
expect(result).toBeNull();
|
||||
});
|
||||
|
||||
it('should use last colon as separator', () => {
|
||||
// URLs with ports have colons, should use the last one
|
||||
const result = parseMarketplaceSource(
|
||||
'https://example.com:8080:my-plugin',
|
||||
);
|
||||
expect(result).toEqual({
|
||||
marketplaceUrl: 'https://example.com:8080',
|
||||
pluginName: 'my-plugin',
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
@ -1,259 +0,0 @@
|
|||
/**
|
||||
* @license
|
||||
* Copyright 2025 Google LLC
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
/**
|
||||
* This module handles installation of extensions from Claude marketplaces.
|
||||
*
|
||||
* A marketplace URL format: marketplace-url:plugin-name
|
||||
* Example: https://github.com/example/marketplace:my-plugin
|
||||
*/
|
||||
|
||||
import * as fs from 'node:fs';
|
||||
import * as path from 'node:path';
|
||||
import type { ExtensionConfig } from '../extension.js';
|
||||
import {
|
||||
convertClaudeToQwenConfig,
|
||||
mergeClaudeConfigs,
|
||||
type ClaudeMarketplaceConfig,
|
||||
type ClaudeMarketplacePluginConfig,
|
||||
type ClaudePluginConfig,
|
||||
} from './claude-converter.js';
|
||||
import { cloneFromGit, downloadFromGitHubRelease } from './github.js';
|
||||
import type { ExtensionInstallMetadata } from '@qwen-code/qwen-code-core';
|
||||
|
||||
export interface MarketplaceInstallOptions {
|
||||
marketplaceUrl: string;
|
||||
pluginName: string;
|
||||
tempDir: string;
|
||||
requestConsent: (consent: string) => Promise<boolean>;
|
||||
}
|
||||
|
||||
export interface MarketplaceInstallResult {
|
||||
config: ExtensionConfig;
|
||||
sourcePath: string;
|
||||
installMetadata: ExtensionInstallMetadata;
|
||||
}
|
||||
|
||||
/**
|
||||
* Parse marketplace install source string.
|
||||
* Format: marketplace-url:plugin-name
|
||||
*/
|
||||
export function parseMarketplaceSource(source: string): {
|
||||
marketplaceUrl: string;
|
||||
pluginName: string;
|
||||
} | null {
|
||||
// Check if source contains a colon separator
|
||||
const lastColonIndex = source.lastIndexOf(':');
|
||||
if (lastColonIndex === -1) {
|
||||
return null;
|
||||
}
|
||||
|
||||
// Split at the last colon to separate URL from plugin name
|
||||
const marketplaceUrl = source.substring(0, lastColonIndex);
|
||||
const pluginName = source.substring(lastColonIndex + 1);
|
||||
|
||||
// Validate that marketplace URL looks like a URL
|
||||
if (
|
||||
!marketplaceUrl.startsWith('http://') &&
|
||||
!marketplaceUrl.startsWith('https://')
|
||||
) {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (!pluginName || pluginName.length === 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return { marketplaceUrl, pluginName };
|
||||
}
|
||||
|
||||
/**
|
||||
* Install an extension from a Claude marketplace.
|
||||
*
|
||||
* Process:
|
||||
* 1. Download marketplace repository
|
||||
* 2. Parse marketplace.json
|
||||
* 3. Find the specified plugin
|
||||
* 4. Download/copy plugin source
|
||||
* 5. Merge configurations (if strict mode)
|
||||
* 6. Convert to Qwen format
|
||||
*/
|
||||
export async function installFromMarketplace(
|
||||
options: MarketplaceInstallOptions,
|
||||
): Promise<MarketplaceInstallResult> {
|
||||
const {
|
||||
marketplaceUrl,
|
||||
pluginName,
|
||||
tempDir,
|
||||
requestConsent: _requestConsent,
|
||||
} = options;
|
||||
|
||||
// Step 1: Download marketplace repository
|
||||
const marketplaceDir = path.join(tempDir, 'marketplace');
|
||||
await fs.promises.mkdir(marketplaceDir, { recursive: true });
|
||||
|
||||
console.log(`Downloading marketplace from ${marketplaceUrl}...`);
|
||||
const installMetadata: ExtensionInstallMetadata = {
|
||||
source: marketplaceUrl,
|
||||
type: 'git',
|
||||
};
|
||||
|
||||
try {
|
||||
await downloadFromGitHubRelease(installMetadata, marketplaceDir);
|
||||
} catch {
|
||||
await cloneFromGit(installMetadata, marketplaceDir);
|
||||
}
|
||||
|
||||
// Step 2: Parse marketplace.json
|
||||
const marketplaceConfigPath = path.join(marketplaceDir, 'marketplace.json');
|
||||
if (!fs.existsSync(marketplaceConfigPath)) {
|
||||
throw new Error(
|
||||
`Marketplace configuration not found at ${marketplaceConfigPath}`,
|
||||
);
|
||||
}
|
||||
|
||||
const marketplaceConfigContent = await fs.promises.readFile(
|
||||
marketplaceConfigPath,
|
||||
'utf-8',
|
||||
);
|
||||
const marketplaceConfig: ClaudeMarketplaceConfig = JSON.parse(
|
||||
marketplaceConfigContent,
|
||||
);
|
||||
|
||||
// Step 3: Find the plugin
|
||||
const pluginConfig = marketplaceConfig.plugins.find(
|
||||
(p) => p.name.toLowerCase() === pluginName.toLowerCase(),
|
||||
);
|
||||
|
||||
if (!pluginConfig) {
|
||||
throw new Error(
|
||||
`Plugin "${pluginName}" not found in marketplace. Available plugins: ${marketplaceConfig.plugins.map((p) => p.name).join(', ')}`,
|
||||
);
|
||||
}
|
||||
|
||||
// Step 4: Download/copy plugin source
|
||||
const pluginDir = path.join(tempDir, 'plugin');
|
||||
await fs.promises.mkdir(pluginDir, { recursive: true });
|
||||
|
||||
const pluginSource = await resolvePluginSource(
|
||||
pluginConfig,
|
||||
marketplaceDir,
|
||||
pluginDir,
|
||||
);
|
||||
|
||||
// Step 5: Merge configurations (if strict mode)
|
||||
let finalPluginConfig: ClaudePluginConfig;
|
||||
const strict = pluginConfig.strict ?? true;
|
||||
|
||||
if (strict) {
|
||||
// Read plugin.json from plugin source
|
||||
const pluginJsonPath = path.join(
|
||||
pluginSource,
|
||||
'.claude-plugin',
|
||||
'plugin.json',
|
||||
);
|
||||
if (!fs.existsSync(pluginJsonPath)) {
|
||||
throw new Error(
|
||||
`Strict mode requires plugin.json at ${pluginJsonPath}, but file not found`,
|
||||
);
|
||||
}
|
||||
|
||||
const pluginJsonContent = await fs.promises.readFile(
|
||||
pluginJsonPath,
|
||||
'utf-8',
|
||||
);
|
||||
const basePluginConfig: ClaudePluginConfig = JSON.parse(pluginJsonContent);
|
||||
|
||||
// Merge marketplace config with plugin config
|
||||
finalPluginConfig = mergeClaudeConfigs(pluginConfig, basePluginConfig);
|
||||
} else {
|
||||
// Use marketplace config directly
|
||||
finalPluginConfig = pluginConfig;
|
||||
}
|
||||
|
||||
// Step 6: Convert to Qwen format
|
||||
const qwenConfig = convertClaudeToQwenConfig(finalPluginConfig);
|
||||
|
||||
return {
|
||||
config: qwenConfig,
|
||||
sourcePath: pluginSource,
|
||||
installMetadata: {
|
||||
source: `${marketplaceUrl}:${pluginName}`,
|
||||
type: 'git', // Marketplace installs are treated as git installs
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve plugin source from marketplace plugin configuration.
|
||||
* Returns the absolute path to the plugin source directory.
|
||||
*/
|
||||
async function resolvePluginSource(
|
||||
pluginConfig: ClaudeMarketplacePluginConfig,
|
||||
marketplaceDir: string,
|
||||
pluginDir: string,
|
||||
): Promise<string> {
|
||||
const source = pluginConfig.source;
|
||||
|
||||
// Handle string source (relative path or URL)
|
||||
if (typeof source === 'string') {
|
||||
// Check if it's a URL
|
||||
if (source.startsWith('http://') || source.startsWith('https://')) {
|
||||
// Download from URL
|
||||
const installMetadata: ExtensionInstallMetadata = {
|
||||
source,
|
||||
type: 'git',
|
||||
};
|
||||
try {
|
||||
await downloadFromGitHubRelease(installMetadata, pluginDir);
|
||||
} catch {
|
||||
await cloneFromGit(installMetadata, pluginDir);
|
||||
}
|
||||
return pluginDir;
|
||||
}
|
||||
|
||||
// Relative path within marketplace
|
||||
const pluginRoot = marketplaceDir;
|
||||
const sourcePath = path.join(pluginRoot, source);
|
||||
|
||||
if (!fs.existsSync(sourcePath)) {
|
||||
throw new Error(`Plugin source not found at ${sourcePath}`);
|
||||
}
|
||||
|
||||
// Copy to plugin directory
|
||||
await fs.promises.cp(sourcePath, pluginDir, { recursive: true });
|
||||
return pluginDir;
|
||||
}
|
||||
|
||||
// Handle object source (github or url)
|
||||
if (source.source === 'github') {
|
||||
const installMetadata: ExtensionInstallMetadata = {
|
||||
source: `https://github.com/${source.repo}`,
|
||||
type: 'git',
|
||||
};
|
||||
try {
|
||||
await downloadFromGitHubRelease(installMetadata, pluginDir);
|
||||
} catch {
|
||||
await cloneFromGit(installMetadata, pluginDir);
|
||||
}
|
||||
return pluginDir;
|
||||
}
|
||||
|
||||
if (source.source === 'url') {
|
||||
const installMetadata: ExtensionInstallMetadata = {
|
||||
source: source.url,
|
||||
type: 'git',
|
||||
};
|
||||
try {
|
||||
await downloadFromGitHubRelease(installMetadata, pluginDir);
|
||||
} catch {
|
||||
await cloneFromGit(installMetadata, pluginDir);
|
||||
}
|
||||
return pluginDir;
|
||||
}
|
||||
|
||||
throw new Error(`Unsupported plugin source type: ${JSON.stringify(source)}`);
|
||||
}
|
||||
|
|
@ -1,138 +0,0 @@
|
|||
/**
|
||||
* @license
|
||||
* Copyright 2025 Google LLC
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import { parseEnvFile, generateEnvFile, validateSettings } from './settings.js';
|
||||
import type { ExtensionSetting } from '../extension.js';
|
||||
|
||||
describe('Extension Settings', () => {
|
||||
describe('parseEnvFile', () => {
|
||||
it('should parse simple KEY=VALUE pairs', () => {
|
||||
const content = 'API_KEY=abc123\nSERVER_URL=http://example.com';
|
||||
const result = parseEnvFile(content);
|
||||
expect(result).toEqual({
|
||||
API_KEY: 'abc123',
|
||||
SERVER_URL: 'http://example.com',
|
||||
});
|
||||
});
|
||||
|
||||
it('should skip empty lines and comments', () => {
|
||||
const content = `
|
||||
# This is a comment
|
||||
API_KEY=secret
|
||||
|
||||
# Another comment
|
||||
DEBUG=true
|
||||
`;
|
||||
const result = parseEnvFile(content);
|
||||
expect(result).toEqual({
|
||||
API_KEY: 'secret',
|
||||
DEBUG: 'true',
|
||||
});
|
||||
});
|
||||
|
||||
it('should handle quoted values', () => {
|
||||
const content = `API_KEY="my secret key"\nPATH='/usr/local/bin'`;
|
||||
const result = parseEnvFile(content);
|
||||
expect(result).toEqual({
|
||||
API_KEY: 'my secret key',
|
||||
PATH: '/usr/local/bin',
|
||||
});
|
||||
});
|
||||
|
||||
it('should ignore invalid lines', () => {
|
||||
const content = 'VALID=value\nINVALID LINE\nANOTHER=valid';
|
||||
const result = parseEnvFile(content);
|
||||
expect(result).toEqual({
|
||||
VALID: 'value',
|
||||
ANOTHER: 'valid',
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('generateEnvFile', () => {
|
||||
it('should generate properly formatted .env content', () => {
|
||||
const settings = {
|
||||
API_KEY: 'secret123',
|
||||
DEBUG: 'true',
|
||||
};
|
||||
const result = generateEnvFile(settings);
|
||||
expect(result).toContain('API_KEY=secret123');
|
||||
expect(result).toContain('DEBUG=true');
|
||||
expect(result).toContain('# Extension Settings');
|
||||
});
|
||||
|
||||
it('should quote values with spaces', () => {
|
||||
const settings = {
|
||||
MESSAGE: 'Hello World',
|
||||
PATH: '/usr/local/bin',
|
||||
};
|
||||
const result = generateEnvFile(settings);
|
||||
expect(result).toContain('MESSAGE="Hello World"');
|
||||
expect(result).toContain('PATH=/usr/local/bin');
|
||||
});
|
||||
});
|
||||
|
||||
describe('validateSettings', () => {
|
||||
it('should pass validation for valid string settings', () => {
|
||||
const settingsConfig: ExtensionSetting[] = [
|
||||
{
|
||||
name: 'API Key',
|
||||
description: 'Your API key for the service',
|
||||
envVar: 'API_KEY',
|
||||
},
|
||||
];
|
||||
const settings = { API_KEY: 'my-key' };
|
||||
const errors = validateSettings(settings, settingsConfig);
|
||||
expect(errors).toHaveLength(0);
|
||||
});
|
||||
|
||||
it('should fail validation for non-string values', () => {
|
||||
const settingsConfig: ExtensionSetting[] = [
|
||||
{
|
||||
name: 'API Key',
|
||||
description: 'Your API key for the service',
|
||||
envVar: 'API_KEY',
|
||||
},
|
||||
];
|
||||
// In TypeScript, this would be caught at compile time,
|
||||
// but at runtime we check the type
|
||||
const settings = { API_KEY: 123 as unknown as string };
|
||||
const errors = validateSettings(settings, settingsConfig);
|
||||
expect(errors).toHaveLength(1);
|
||||
expect(errors[0]).toContain('API Key');
|
||||
expect(errors[0]).toContain('string');
|
||||
});
|
||||
|
||||
it('should allow undefined/missing settings (all settings are optional)', () => {
|
||||
const settingsConfig: ExtensionSetting[] = [
|
||||
{
|
||||
name: 'Optional Setting',
|
||||
description: 'An optional setting',
|
||||
envVar: 'OPTIONAL_VAR',
|
||||
},
|
||||
];
|
||||
|
||||
const settings = {};
|
||||
const errors = validateSettings(settings, settingsConfig);
|
||||
expect(errors).toHaveLength(0);
|
||||
});
|
||||
|
||||
it('should validate sensitive settings the same way', () => {
|
||||
const settingsConfig: ExtensionSetting[] = [
|
||||
{
|
||||
name: 'Secret Key',
|
||||
description: 'Your secret key',
|
||||
envVar: 'SECRET_KEY',
|
||||
sensitive: true,
|
||||
},
|
||||
];
|
||||
|
||||
const validSettings = { SECRET_KEY: 'super-secret' };
|
||||
expect(validateSettings(validSettings, settingsConfig)).toHaveLength(0);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
@ -1,149 +0,0 @@
|
|||
/**
|
||||
* @license
|
||||
* Copyright 2025 Google LLC
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
/**
|
||||
* This module handles extension settings management.
|
||||
* Settings are stored in .env files within extension directories.
|
||||
*/
|
||||
|
||||
import * as fs from 'node:fs';
|
||||
import * as path from 'node:path';
|
||||
import type { ExtensionSetting } from '../extension.js';
|
||||
|
||||
/**
|
||||
* Parse .env file content into key-value pairs.
|
||||
* Simple parser that handles:
|
||||
* - KEY=VALUE format
|
||||
* - Comments starting with #
|
||||
* - Empty lines
|
||||
*/
|
||||
export function parseEnvFile(content: string): Record<string, string> {
|
||||
const result: Record<string, string> = {};
|
||||
const lines = content.split('\n');
|
||||
|
||||
for (const line of lines) {
|
||||
const trimmed = line.trim();
|
||||
|
||||
// Skip empty lines and comments
|
||||
if (!trimmed || trimmed.startsWith('#')) {
|
||||
continue;
|
||||
}
|
||||
|
||||
// Parse KEY=VALUE
|
||||
const equalIndex = trimmed.indexOf('=');
|
||||
if (equalIndex === -1) {
|
||||
continue; // Invalid line, skip
|
||||
}
|
||||
|
||||
const key = trimmed.substring(0, equalIndex).trim();
|
||||
const value = trimmed.substring(equalIndex + 1).trim();
|
||||
|
||||
// Remove quotes if present
|
||||
let cleanValue = value;
|
||||
if (
|
||||
(value.startsWith('"') && value.endsWith('"')) ||
|
||||
(value.startsWith("'") && value.endsWith("'"))
|
||||
) {
|
||||
cleanValue = value.substring(1, value.length - 1);
|
||||
}
|
||||
|
||||
result[key] = cleanValue;
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate .env file content from key-value pairs.
|
||||
*/
|
||||
export function generateEnvFile(settings: Record<string, string>): string {
|
||||
const lines: string[] = [];
|
||||
|
||||
lines.push('# Extension Settings');
|
||||
lines.push('# Generated by Qwen Code');
|
||||
lines.push('');
|
||||
|
||||
for (const [key, value] of Object.entries(settings)) {
|
||||
// Quote values that contain spaces
|
||||
const quotedValue = value.includes(' ') ? `"${value}"` : value;
|
||||
lines.push(`${key}=${quotedValue}`);
|
||||
}
|
||||
|
||||
return lines.join('\n') + '\n';
|
||||
}
|
||||
|
||||
/**
|
||||
* Load settings from extension .env file.
|
||||
*/
|
||||
export async function loadExtensionSettings(
|
||||
extensionPath: string,
|
||||
): Promise<Record<string, string>> {
|
||||
const envPath = path.join(extensionPath, '.env');
|
||||
|
||||
try {
|
||||
const content = await fs.promises.readFile(envPath, 'utf-8');
|
||||
return parseEnvFile(content);
|
||||
} catch (error) {
|
||||
// If .env file doesn't exist, return empty object
|
||||
if ((error as NodeJS.ErrnoException).code === 'ENOENT') {
|
||||
return {};
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Save settings to extension .env file.
|
||||
*/
|
||||
export async function saveExtensionSettings(
|
||||
extensionPath: string,
|
||||
settings: Record<string, string>,
|
||||
): Promise<void> {
|
||||
const envPath = path.join(extensionPath, '.env');
|
||||
const content = generateEnvFile(settings);
|
||||
await fs.promises.writeFile(envPath, content, 'utf-8');
|
||||
}
|
||||
|
||||
/**
|
||||
* Validate settings against configuration.
|
||||
* Returns array of validation errors (empty if valid).
|
||||
*
|
||||
* Note: This validates that environment variables are properly set.
|
||||
* In Gemini Extension format, all settings are treated as strings.
|
||||
*/
|
||||
export function validateSettings(
|
||||
settings: Record<string, string>,
|
||||
settingsConfig: ExtensionSetting[],
|
||||
): string[] {
|
||||
const errors: string[] = [];
|
||||
|
||||
for (const config of settingsConfig) {
|
||||
const value = settings[config.envVar];
|
||||
|
||||
// Basic validation - check if value exists and is not empty
|
||||
// Note: All settings are optional in Gemini Extension format
|
||||
if (value !== undefined && typeof value !== 'string') {
|
||||
errors.push(
|
||||
`Setting "${config.name}" (${config.envVar}) must be a string`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
return errors;
|
||||
}
|
||||
|
||||
/**
|
||||
* Merge extension settings into process environment.
|
||||
* This allows MCP servers and other extension components to access settings.
|
||||
*/
|
||||
export function applySettingsToEnv(settings: Record<string, string>): void {
|
||||
for (const [key, value] of Object.entries(settings)) {
|
||||
// Only set if not already defined in process.env
|
||||
if (process.env[key] === undefined) {
|
||||
process.env[key] = value;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -1,101 +0,0 @@
|
|||
/**
|
||||
* @license
|
||||
* Copyright 2025 Google LLC
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
|
||||
import { ExtensionStorage } from './storage.js';
|
||||
import * as os from 'node:os';
|
||||
import * as path from 'node:path';
|
||||
import * as fs from 'node:fs';
|
||||
import {
|
||||
EXTENSION_SETTINGS_FILENAME,
|
||||
EXTENSIONS_CONFIG_FILENAME,
|
||||
} from './variables.js';
|
||||
import { Storage } from '@qwen-code/qwen-code-core';
|
||||
|
||||
vi.mock('node:os');
|
||||
vi.mock('node:fs', async (importOriginal) => {
|
||||
const actual = await importOriginal<typeof fs>();
|
||||
return {
|
||||
...actual,
|
||||
promises: {
|
||||
...actual.promises,
|
||||
mkdtemp: vi.fn(),
|
||||
},
|
||||
};
|
||||
});
|
||||
vi.mock('@google/gemini-cli-core');
|
||||
|
||||
describe('ExtensionStorage', () => {
|
||||
const mockHomeDir = '/mock/home';
|
||||
const extensionName = 'test-extension';
|
||||
let storage: ExtensionStorage;
|
||||
|
||||
beforeEach(() => {
|
||||
vi.mocked(os.homedir).mockReturnValue(mockHomeDir);
|
||||
vi.mocked(Storage).mockImplementation(
|
||||
() =>
|
||||
({
|
||||
getExtensionsDir: () =>
|
||||
path.join(mockHomeDir, '.gemini', 'extensions'),
|
||||
}) as any, // eslint-disable-line @typescript-eslint/no-explicit-any
|
||||
);
|
||||
storage = new ExtensionStorage(extensionName);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
|
||||
it('should return the correct extension directory', () => {
|
||||
const expectedDir = path.join(
|
||||
mockHomeDir,
|
||||
'.gemini',
|
||||
'extensions',
|
||||
extensionName,
|
||||
);
|
||||
expect(storage.getExtensionDir()).toBe(expectedDir);
|
||||
});
|
||||
|
||||
it('should return the correct config path', () => {
|
||||
const expectedPath = path.join(
|
||||
mockHomeDir,
|
||||
'.gemini',
|
||||
'extensions',
|
||||
extensionName,
|
||||
EXTENSIONS_CONFIG_FILENAME, // EXTENSIONS_CONFIG_FILENAME
|
||||
);
|
||||
expect(storage.getConfigPath()).toBe(expectedPath);
|
||||
});
|
||||
|
||||
it('should return the correct env file path', () => {
|
||||
const expectedPath = path.join(
|
||||
mockHomeDir,
|
||||
'.gemini',
|
||||
'extensions',
|
||||
extensionName,
|
||||
EXTENSION_SETTINGS_FILENAME, // EXTENSION_SETTINGS_FILENAME
|
||||
);
|
||||
expect(storage.getEnvFilePath()).toBe(expectedPath);
|
||||
});
|
||||
|
||||
it('should return the correct user extensions directory', () => {
|
||||
const expectedDir = path.join(mockHomeDir, '.gemini', 'extensions');
|
||||
expect(ExtensionStorage.getUserExtensionsDir()).toBe(expectedDir);
|
||||
});
|
||||
|
||||
it('should create a temporary directory', async () => {
|
||||
const mockTmpDir = '/tmp/gemini-extension-123';
|
||||
vi.mocked(fs.promises.mkdtemp).mockResolvedValue(mockTmpDir);
|
||||
vi.mocked(os.tmpdir).mockReturnValue('/tmp');
|
||||
|
||||
const result = await ExtensionStorage.createTmpDir();
|
||||
|
||||
expect(fs.promises.mkdtemp).toHaveBeenCalledWith(
|
||||
path.join('/tmp', 'gemini-extension'),
|
||||
);
|
||||
expect(result).toBe(mockTmpDir);
|
||||
});
|
||||
});
|
||||
|
|
@ -1,40 +0,0 @@
|
|||
import { Storage } from '@qwen-code/qwen-code-core';
|
||||
import path from 'node:path';
|
||||
import * as os from 'node:os';
|
||||
import {
|
||||
EXTENSION_SETTINGS_FILENAME,
|
||||
EXTENSIONS_CONFIG_FILENAME,
|
||||
} from './variables.js';
|
||||
import * as fs from 'node:fs';
|
||||
|
||||
export class ExtensionStorage {
|
||||
private readonly extensionName: string;
|
||||
|
||||
constructor(extensionName: string) {
|
||||
this.extensionName = extensionName;
|
||||
}
|
||||
|
||||
getExtensionDir(): string {
|
||||
return path.join(
|
||||
ExtensionStorage.getUserExtensionsDir(),
|
||||
this.extensionName,
|
||||
);
|
||||
}
|
||||
|
||||
getConfigPath(): string {
|
||||
return path.join(this.getExtensionDir(), EXTENSIONS_CONFIG_FILENAME);
|
||||
}
|
||||
|
||||
getEnvFilePath(): string {
|
||||
return path.join(this.getExtensionDir(), EXTENSION_SETTINGS_FILENAME);
|
||||
}
|
||||
|
||||
static getUserExtensionsDir(): string {
|
||||
const storage = new Storage(os.homedir());
|
||||
return storage.getExtensionsDir();
|
||||
}
|
||||
|
||||
static async createTmpDir(): Promise<string> {
|
||||
return await fs.promises.mkdtemp(path.join(os.tmpdir(), 'qwen-extension'));
|
||||
}
|
||||
}
|
||||
|
|
@ -1,467 +0,0 @@
|
|||
/**
|
||||
* @license
|
||||
* Copyright 2025 Google LLC
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
import { vi } from 'vitest';
|
||||
import * as fs from 'node:fs';
|
||||
import * as os from 'node:os';
|
||||
import * as path from 'node:path';
|
||||
import { annotateActiveExtensions, loadExtension } from '../extension.js';
|
||||
import {
|
||||
EXTENSIONS_CONFIG_FILENAME,
|
||||
INSTALL_METADATA_FILENAME,
|
||||
} from './variables.js';
|
||||
import { ExtensionStorage } from './storage.js';
|
||||
import { checkForAllExtensionUpdates, updateExtension } from './update.js';
|
||||
import { QWEN_DIR } from '@qwen-code/qwen-code-core';
|
||||
import { isWorkspaceTrusted } from '../trustedFolders.js';
|
||||
import { ExtensionUpdateState } from '../../ui/state/extensions.js';
|
||||
import { createExtension } from '../../test-utils/createExtension.js';
|
||||
import { ExtensionEnablementManager } from './extensionEnablement.js';
|
||||
|
||||
const mockGit = {
|
||||
clone: vi.fn(),
|
||||
getRemotes: vi.fn(),
|
||||
fetch: vi.fn(),
|
||||
checkout: vi.fn(),
|
||||
listRemote: vi.fn(),
|
||||
revparse: vi.fn(),
|
||||
// Not a part of the actual API, but we need to use this to do the correct
|
||||
// file system interactions.
|
||||
path: vi.fn(),
|
||||
};
|
||||
|
||||
vi.mock('simple-git', () => ({
|
||||
simpleGit: vi.fn((path: string) => {
|
||||
mockGit.path.mockReturnValue(path);
|
||||
return mockGit;
|
||||
}),
|
||||
}));
|
||||
|
||||
vi.mock('../extensions/github.js', async (importOriginal) => {
|
||||
const actual =
|
||||
await importOriginal<typeof import('../extensions/github.js')>();
|
||||
return {
|
||||
...actual,
|
||||
downloadFromGitHubRelease: vi
|
||||
.fn()
|
||||
.mockRejectedValue(new Error('Mocked GitHub release download failure')),
|
||||
};
|
||||
});
|
||||
|
||||
vi.mock('os', async (importOriginal) => {
|
||||
const mockedOs = await importOriginal<typeof os>();
|
||||
return {
|
||||
...mockedOs,
|
||||
homedir: vi.fn(),
|
||||
};
|
||||
});
|
||||
|
||||
vi.mock('../trustedFolders.js', async (importOriginal) => {
|
||||
const actual = await importOriginal<typeof import('../trustedFolders.js')>();
|
||||
return {
|
||||
...actual,
|
||||
isWorkspaceTrusted: vi.fn(),
|
||||
};
|
||||
});
|
||||
|
||||
const mockLogExtensionInstallEvent = vi.hoisted(() => vi.fn());
|
||||
const mockLogExtensionUninstall = vi.hoisted(() => vi.fn());
|
||||
|
||||
vi.mock('@qwen-code/qwen-code-core', async (importOriginal) => {
|
||||
const actual =
|
||||
await importOriginal<typeof import('@qwen-code/qwen-code-core')>();
|
||||
return {
|
||||
...actual,
|
||||
logExtensionInstallEvent: mockLogExtensionInstallEvent,
|
||||
logExtensionUninstall: mockLogExtensionUninstall,
|
||||
ExtensionInstallEvent: vi.fn(),
|
||||
ExtensionUninstallEvent: vi.fn(),
|
||||
};
|
||||
});
|
||||
|
||||
describe('update tests', () => {
|
||||
let tempHomeDir: string;
|
||||
let tempWorkspaceDir: string;
|
||||
let userExtensionsDir: string;
|
||||
|
||||
beforeEach(() => {
|
||||
tempHomeDir = fs.mkdtempSync(
|
||||
path.join(os.tmpdir(), 'qwen-code-test-home-'),
|
||||
);
|
||||
tempWorkspaceDir = fs.mkdtempSync(
|
||||
path.join(tempHomeDir, 'qwen-code-test-workspace-'),
|
||||
);
|
||||
vi.mocked(os.homedir).mockReturnValue(tempHomeDir);
|
||||
userExtensionsDir = path.join(tempHomeDir, QWEN_DIR, 'extensions');
|
||||
// Clean up before each test
|
||||
fs.rmSync(userExtensionsDir, { recursive: true, force: true });
|
||||
fs.mkdirSync(userExtensionsDir, { recursive: true });
|
||||
vi.mocked(isWorkspaceTrusted).mockReturnValue({
|
||||
isTrusted: true,
|
||||
source: 'file',
|
||||
});
|
||||
vi.spyOn(process, 'cwd').mockReturnValue(tempWorkspaceDir);
|
||||
Object.values(mockGit).forEach((fn) => fn.mockReset());
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
fs.rmSync(tempHomeDir, { recursive: true, force: true });
|
||||
fs.rmSync(tempWorkspaceDir, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
describe('updateExtension', () => {
|
||||
it('should update a git-installed extension', async () => {
|
||||
const gitUrl = 'https://github.com/google/gemini-extensions.git';
|
||||
const extensionName = 'qwen-extensions';
|
||||
const targetExtDir = path.join(userExtensionsDir, extensionName);
|
||||
const metadataPath = path.join(targetExtDir, INSTALL_METADATA_FILENAME);
|
||||
|
||||
fs.mkdirSync(targetExtDir, { recursive: true });
|
||||
fs.writeFileSync(
|
||||
path.join(targetExtDir, EXTENSIONS_CONFIG_FILENAME),
|
||||
JSON.stringify({ name: extensionName, version: '1.0.0' }),
|
||||
);
|
||||
fs.writeFileSync(
|
||||
metadataPath,
|
||||
JSON.stringify({ source: gitUrl, type: 'git' }),
|
||||
);
|
||||
|
||||
mockGit.clone.mockImplementation(async (_, destination) => {
|
||||
fs.mkdirSync(path.join(mockGit.path(), destination), {
|
||||
recursive: true,
|
||||
});
|
||||
fs.writeFileSync(
|
||||
path.join(mockGit.path(), destination, EXTENSIONS_CONFIG_FILENAME),
|
||||
JSON.stringify({ name: extensionName, version: '1.1.0' }),
|
||||
);
|
||||
});
|
||||
mockGit.getRemotes.mockResolvedValue([{ name: 'origin' }]);
|
||||
const extension = annotateActiveExtensions(
|
||||
[
|
||||
loadExtension({
|
||||
extensionDir: targetExtDir,
|
||||
workspaceDir: tempWorkspaceDir,
|
||||
})!,
|
||||
],
|
||||
process.cwd(),
|
||||
new ExtensionEnablementManager(ExtensionStorage.getUserExtensionsDir()),
|
||||
)[0];
|
||||
const updateInfo = await updateExtension(
|
||||
extension,
|
||||
tempHomeDir,
|
||||
async (_) => true,
|
||||
ExtensionUpdateState.UPDATE_AVAILABLE,
|
||||
() => {},
|
||||
);
|
||||
|
||||
expect(updateInfo).toEqual({
|
||||
name: 'qwen-extensions',
|
||||
originalVersion: '1.0.0',
|
||||
updatedVersion: '1.1.0',
|
||||
});
|
||||
|
||||
const updatedConfig = JSON.parse(
|
||||
fs.readFileSync(
|
||||
path.join(targetExtDir, EXTENSIONS_CONFIG_FILENAME),
|
||||
'utf-8',
|
||||
),
|
||||
);
|
||||
expect(updatedConfig.version).toBe('1.1.0');
|
||||
});
|
||||
|
||||
it('should call setExtensionUpdateState with UPDATING and then UPDATED_NEEDS_RESTART on success', async () => {
|
||||
const extensionName = 'test-extension';
|
||||
const extensionDir = createExtension({
|
||||
extensionsDir: userExtensionsDir,
|
||||
name: extensionName,
|
||||
version: '1.0.0',
|
||||
installMetadata: {
|
||||
source: 'https://some.git/repo',
|
||||
type: 'git',
|
||||
},
|
||||
});
|
||||
|
||||
mockGit.clone.mockImplementation(async (_, destination) => {
|
||||
fs.mkdirSync(path.join(mockGit.path(), destination), {
|
||||
recursive: true,
|
||||
});
|
||||
fs.writeFileSync(
|
||||
path.join(mockGit.path(), destination, EXTENSIONS_CONFIG_FILENAME),
|
||||
JSON.stringify({ name: extensionName, version: '1.1.0' }),
|
||||
);
|
||||
});
|
||||
mockGit.getRemotes.mockResolvedValue([{ name: 'origin' }]);
|
||||
|
||||
const dispatch = vi.fn();
|
||||
const extension = annotateActiveExtensions(
|
||||
[
|
||||
loadExtension({
|
||||
extensionDir,
|
||||
workspaceDir: tempWorkspaceDir,
|
||||
})!,
|
||||
],
|
||||
process.cwd(),
|
||||
new ExtensionEnablementManager(ExtensionStorage.getUserExtensionsDir()),
|
||||
)[0];
|
||||
await updateExtension(
|
||||
extension,
|
||||
tempHomeDir,
|
||||
async (_) => true,
|
||||
ExtensionUpdateState.UPDATE_AVAILABLE,
|
||||
dispatch,
|
||||
);
|
||||
|
||||
expect(dispatch).toHaveBeenCalledWith({
|
||||
type: 'SET_STATE',
|
||||
payload: {
|
||||
name: extensionName,
|
||||
state: ExtensionUpdateState.UPDATING,
|
||||
},
|
||||
});
|
||||
expect(dispatch).toHaveBeenCalledWith({
|
||||
type: 'SET_STATE',
|
||||
payload: {
|
||||
name: extensionName,
|
||||
state: ExtensionUpdateState.UPDATED_NEEDS_RESTART,
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
it('should call setExtensionUpdateState with ERROR on failure', async () => {
|
||||
const extensionName = 'test-extension';
|
||||
const extensionDir = createExtension({
|
||||
extensionsDir: userExtensionsDir,
|
||||
name: extensionName,
|
||||
version: '1.0.0',
|
||||
installMetadata: {
|
||||
source: 'https://some.git/repo',
|
||||
type: 'git',
|
||||
},
|
||||
});
|
||||
|
||||
mockGit.clone.mockRejectedValue(new Error('Git clone failed'));
|
||||
mockGit.getRemotes.mockResolvedValue([{ name: 'origin' }]);
|
||||
|
||||
const dispatch = vi.fn();
|
||||
const extension = annotateActiveExtensions(
|
||||
[
|
||||
loadExtension({
|
||||
extensionDir,
|
||||
workspaceDir: tempWorkspaceDir,
|
||||
})!,
|
||||
],
|
||||
process.cwd(),
|
||||
new ExtensionEnablementManager(ExtensionStorage.getUserExtensionsDir()),
|
||||
)[0];
|
||||
await expect(
|
||||
updateExtension(
|
||||
extension,
|
||||
tempHomeDir,
|
||||
async (_) => true,
|
||||
ExtensionUpdateState.UPDATE_AVAILABLE,
|
||||
dispatch,
|
||||
),
|
||||
).rejects.toThrow();
|
||||
|
||||
expect(dispatch).toHaveBeenCalledWith({
|
||||
type: 'SET_STATE',
|
||||
payload: {
|
||||
name: extensionName,
|
||||
state: ExtensionUpdateState.UPDATING,
|
||||
},
|
||||
});
|
||||
expect(dispatch).toHaveBeenCalledWith({
|
||||
type: 'SET_STATE',
|
||||
payload: {
|
||||
name: extensionName,
|
||||
state: ExtensionUpdateState.ERROR,
|
||||
},
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('checkForAllExtensionUpdates', () => {
|
||||
it('should return UpdateAvailable for a git extension with updates', async () => {
|
||||
const extensionDir = createExtension({
|
||||
extensionsDir: userExtensionsDir,
|
||||
name: 'test-extension',
|
||||
version: '1.0.0',
|
||||
installMetadata: {
|
||||
source: 'https://some.git/repo',
|
||||
type: 'git',
|
||||
},
|
||||
});
|
||||
const extension = annotateActiveExtensions(
|
||||
[
|
||||
loadExtension({
|
||||
extensionDir,
|
||||
workspaceDir: tempWorkspaceDir,
|
||||
})!,
|
||||
],
|
||||
process.cwd(),
|
||||
new ExtensionEnablementManager(ExtensionStorage.getUserExtensionsDir()),
|
||||
)[0];
|
||||
|
||||
mockGit.getRemotes.mockResolvedValue([
|
||||
{ name: 'origin', refs: { fetch: 'https://some.git/repo' } },
|
||||
]);
|
||||
mockGit.listRemote.mockResolvedValue('remoteHash HEAD');
|
||||
mockGit.revparse.mockResolvedValue('localHash');
|
||||
|
||||
const dispatch = vi.fn();
|
||||
await checkForAllExtensionUpdates([extension], dispatch);
|
||||
expect(dispatch).toHaveBeenCalledWith({
|
||||
type: 'SET_STATE',
|
||||
payload: {
|
||||
name: 'test-extension',
|
||||
state: ExtensionUpdateState.UPDATE_AVAILABLE,
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
it('should return UpToDate for a git extension with no updates', async () => {
|
||||
const extensionDir = createExtension({
|
||||
extensionsDir: userExtensionsDir,
|
||||
name: 'test-extension',
|
||||
version: '1.0.0',
|
||||
installMetadata: {
|
||||
source: 'https://some.git/repo',
|
||||
type: 'git',
|
||||
},
|
||||
});
|
||||
const extension = annotateActiveExtensions(
|
||||
[
|
||||
loadExtension({
|
||||
extensionDir,
|
||||
workspaceDir: tempWorkspaceDir,
|
||||
})!,
|
||||
],
|
||||
process.cwd(),
|
||||
new ExtensionEnablementManager(ExtensionStorage.getUserExtensionsDir()),
|
||||
)[0];
|
||||
|
||||
mockGit.getRemotes.mockResolvedValue([
|
||||
{ name: 'origin', refs: { fetch: 'https://some.git/repo' } },
|
||||
]);
|
||||
mockGit.listRemote.mockResolvedValue('sameHash HEAD');
|
||||
mockGit.revparse.mockResolvedValue('sameHash');
|
||||
|
||||
const dispatch = vi.fn();
|
||||
await checkForAllExtensionUpdates([extension], dispatch);
|
||||
expect(dispatch).toHaveBeenCalledWith({
|
||||
type: 'SET_STATE',
|
||||
payload: {
|
||||
name: 'test-extension',
|
||||
state: ExtensionUpdateState.UP_TO_DATE,
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
it('should return UpToDate for a local extension with no updates', async () => {
|
||||
const localExtensionSourcePath = path.join(tempHomeDir, 'local-source');
|
||||
const sourceExtensionDir = createExtension({
|
||||
extensionsDir: localExtensionSourcePath,
|
||||
name: 'my-local-ext',
|
||||
version: '1.0.0',
|
||||
});
|
||||
|
||||
const installedExtensionDir = createExtension({
|
||||
extensionsDir: userExtensionsDir,
|
||||
name: 'local-extension',
|
||||
version: '1.0.0',
|
||||
installMetadata: { source: sourceExtensionDir, type: 'local' },
|
||||
});
|
||||
const extension = annotateActiveExtensions(
|
||||
[
|
||||
loadExtension({
|
||||
extensionDir: installedExtensionDir,
|
||||
workspaceDir: tempWorkspaceDir,
|
||||
})!,
|
||||
],
|
||||
process.cwd(),
|
||||
new ExtensionEnablementManager(ExtensionStorage.getUserExtensionsDir()),
|
||||
)[0];
|
||||
const dispatch = vi.fn();
|
||||
await checkForAllExtensionUpdates([extension], dispatch);
|
||||
expect(dispatch).toHaveBeenCalledWith({
|
||||
type: 'SET_STATE',
|
||||
payload: {
|
||||
name: 'local-extension',
|
||||
state: ExtensionUpdateState.UP_TO_DATE,
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
it('should return UpdateAvailable for a local extension with updates', async () => {
|
||||
const localExtensionSourcePath = path.join(tempHomeDir, 'local-source');
|
||||
const sourceExtensionDir = createExtension({
|
||||
extensionsDir: localExtensionSourcePath,
|
||||
name: 'my-local-ext',
|
||||
version: '1.1.0',
|
||||
});
|
||||
|
||||
const installedExtensionDir = createExtension({
|
||||
extensionsDir: userExtensionsDir,
|
||||
name: 'local-extension',
|
||||
version: '1.0.0',
|
||||
installMetadata: { source: sourceExtensionDir, type: 'local' },
|
||||
});
|
||||
const extension = annotateActiveExtensions(
|
||||
[
|
||||
loadExtension({
|
||||
extensionDir: installedExtensionDir,
|
||||
workspaceDir: tempWorkspaceDir,
|
||||
})!,
|
||||
],
|
||||
process.cwd(),
|
||||
new ExtensionEnablementManager(ExtensionStorage.getUserExtensionsDir()),
|
||||
)[0];
|
||||
const dispatch = vi.fn();
|
||||
await checkForAllExtensionUpdates([extension], dispatch);
|
||||
expect(dispatch).toHaveBeenCalledWith({
|
||||
type: 'SET_STATE',
|
||||
payload: {
|
||||
name: 'local-extension',
|
||||
state: ExtensionUpdateState.UPDATE_AVAILABLE,
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
it('should return Error when git check fails', async () => {
|
||||
const extensionDir = createExtension({
|
||||
extensionsDir: userExtensionsDir,
|
||||
name: 'error-extension',
|
||||
version: '1.0.0',
|
||||
installMetadata: {
|
||||
source: 'https://some.git/repo',
|
||||
type: 'git',
|
||||
},
|
||||
});
|
||||
const extension = annotateActiveExtensions(
|
||||
[
|
||||
loadExtension({
|
||||
extensionDir,
|
||||
workspaceDir: tempWorkspaceDir,
|
||||
})!,
|
||||
],
|
||||
process.cwd(),
|
||||
new ExtensionEnablementManager(ExtensionStorage.getUserExtensionsDir()),
|
||||
)[0];
|
||||
|
||||
mockGit.getRemotes.mockRejectedValue(new Error('Git error'));
|
||||
|
||||
const dispatch = vi.fn();
|
||||
await checkForAllExtensionUpdates([extension], dispatch);
|
||||
expect(dispatch).toHaveBeenCalledWith({
|
||||
type: 'SET_STATE',
|
||||
payload: {
|
||||
name: 'error-extension',
|
||||
state: ExtensionUpdateState.ERROR,
|
||||
},
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
@ -1,183 +0,0 @@
|
|||
/**
|
||||
* @license
|
||||
* Copyright 2025 Google LLC
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
import {
|
||||
type ExtensionUpdateAction,
|
||||
ExtensionUpdateState,
|
||||
type ExtensionUpdateStatus,
|
||||
} from '../../ui/state/extensions.js';
|
||||
import {
|
||||
copyExtension,
|
||||
installExtension,
|
||||
uninstallExtension,
|
||||
loadExtension,
|
||||
loadInstallMetadata,
|
||||
loadExtensionConfig,
|
||||
} from '../extension.js';
|
||||
|
||||
import { checkForExtensionUpdate } from './github.js';
|
||||
import type { GeminiCLIExtension } from '@qwen-code/qwen-code-core';
|
||||
import * as fs from 'node:fs';
|
||||
import { getErrorMessage } from '../../utils/errors.js';
|
||||
import { ExtensionStorage } from './storage.js';
|
||||
|
||||
export interface ExtensionUpdateInfo {
|
||||
name: string;
|
||||
originalVersion: string;
|
||||
updatedVersion: string;
|
||||
}
|
||||
|
||||
export async function updateExtension(
|
||||
extension: GeminiCLIExtension,
|
||||
cwd: string = process.cwd(),
|
||||
requestConsent: (consent: string) => Promise<boolean>,
|
||||
currentState: ExtensionUpdateState,
|
||||
dispatchExtensionStateUpdate: (action: ExtensionUpdateAction) => void,
|
||||
): Promise<ExtensionUpdateInfo | undefined> {
|
||||
if (currentState === ExtensionUpdateState.UPDATING) {
|
||||
return undefined;
|
||||
}
|
||||
dispatchExtensionStateUpdate({
|
||||
type: 'SET_STATE',
|
||||
payload: { name: extension.name, state: ExtensionUpdateState.UPDATING },
|
||||
});
|
||||
const installMetadata = loadInstallMetadata(extension.path);
|
||||
|
||||
if (!installMetadata?.type) {
|
||||
dispatchExtensionStateUpdate({
|
||||
type: 'SET_STATE',
|
||||
payload: { name: extension.name, state: ExtensionUpdateState.ERROR },
|
||||
});
|
||||
throw new Error(
|
||||
`Extension ${extension.name} cannot be updated, type is unknown.`,
|
||||
);
|
||||
}
|
||||
if (installMetadata?.type === 'link') {
|
||||
dispatchExtensionStateUpdate({
|
||||
type: 'SET_STATE',
|
||||
payload: { name: extension.name, state: 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 {
|
||||
await copyExtension(extension.path, tempDir);
|
||||
const previousExtensionConfig = await loadExtensionConfig({
|
||||
extensionDir: extension.path,
|
||||
workspaceDir: cwd,
|
||||
});
|
||||
await uninstallExtension(extension.name, cwd);
|
||||
await installExtension(
|
||||
installMetadata,
|
||||
requestConsent,
|
||||
cwd,
|
||||
previousExtensionConfig,
|
||||
);
|
||||
|
||||
const updatedExtensionStorage = new ExtensionStorage(extension.name);
|
||||
const updatedExtension = loadExtension({
|
||||
extensionDir: updatedExtensionStorage.getExtensionDir(),
|
||||
workspaceDir: cwd,
|
||||
});
|
||||
if (!updatedExtension) {
|
||||
dispatchExtensionStateUpdate({
|
||||
type: 'SET_STATE',
|
||||
payload: { name: extension.name, state: ExtensionUpdateState.ERROR },
|
||||
});
|
||||
throw new Error('Updated extension not found after installation.');
|
||||
}
|
||||
const updatedVersion = updatedExtension.config.version;
|
||||
dispatchExtensionStateUpdate({
|
||||
type: 'SET_STATE',
|
||||
payload: {
|
||||
name: extension.name,
|
||||
state: ExtensionUpdateState.UPDATED_NEEDS_RESTART,
|
||||
},
|
||||
});
|
||||
return {
|
||||
name: extension.name,
|
||||
originalVersion,
|
||||
updatedVersion,
|
||||
};
|
||||
} catch (e) {
|
||||
console.error(
|
||||
`Error updating extension, rolling back. ${getErrorMessage(e)}`,
|
||||
);
|
||||
dispatchExtensionStateUpdate({
|
||||
type: 'SET_STATE',
|
||||
payload: { name: extension.name, state: ExtensionUpdateState.ERROR },
|
||||
});
|
||||
await copyExtension(tempDir, extension.path);
|
||||
throw e;
|
||||
} finally {
|
||||
await fs.promises.rm(tempDir, { recursive: true, force: true });
|
||||
}
|
||||
}
|
||||
|
||||
export async function updateAllUpdatableExtensions(
|
||||
cwd: string = process.cwd(),
|
||||
requestConsent: (consent: string) => Promise<boolean>,
|
||||
extensions: GeminiCLIExtension[],
|
||||
extensionsState: Map<string, ExtensionUpdateStatus>,
|
||||
dispatch: (action: ExtensionUpdateAction) => void,
|
||||
): Promise<ExtensionUpdateInfo[]> {
|
||||
return (
|
||||
await Promise.all(
|
||||
extensions
|
||||
.filter(
|
||||
(extension) =>
|
||||
extensionsState.get(extension.name)?.status ===
|
||||
ExtensionUpdateState.UPDATE_AVAILABLE,
|
||||
)
|
||||
.map((extension) =>
|
||||
updateExtension(
|
||||
extension,
|
||||
cwd,
|
||||
requestConsent,
|
||||
extensionsState.get(extension.name)!.status,
|
||||
dispatch,
|
||||
),
|
||||
),
|
||||
)
|
||||
).filter((updateInfo) => !!updateInfo);
|
||||
}
|
||||
|
||||
export interface ExtensionUpdateCheckResult {
|
||||
state: ExtensionUpdateState;
|
||||
error?: string;
|
||||
}
|
||||
|
||||
export async function checkForAllExtensionUpdates(
|
||||
extensions: GeminiCLIExtension[],
|
||||
dispatch: (action: ExtensionUpdateAction) => void,
|
||||
): Promise<void> {
|
||||
dispatch({ type: 'BATCH_CHECK_START' });
|
||||
const promises: Array<Promise<void>> = [];
|
||||
for (const extension of extensions) {
|
||||
if (!extension.installMetadata) {
|
||||
dispatch({
|
||||
type: 'SET_STATE',
|
||||
payload: {
|
||||
name: extension.name,
|
||||
state: ExtensionUpdateState.NOT_UPDATABLE,
|
||||
},
|
||||
});
|
||||
continue;
|
||||
}
|
||||
promises.push(
|
||||
checkForExtensionUpdate(extension, (updatedState) => {
|
||||
dispatch({
|
||||
type: 'SET_STATE',
|
||||
payload: { name: extension.name, state: updatedState },
|
||||
});
|
||||
}),
|
||||
);
|
||||
}
|
||||
await Promise.all(promises);
|
||||
dispatch({ type: 'BATCH_CHECK_END' });
|
||||
}
|
||||
|
|
@ -1,39 +0,0 @@
|
|||
/**
|
||||
* @license
|
||||
* Copyright 2025 Google LLC
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
export interface VariableDefinition {
|
||||
type: 'string';
|
||||
description: string;
|
||||
default?: string;
|
||||
required?: boolean;
|
||||
}
|
||||
|
||||
export interface VariableSchema {
|
||||
[key: string]: VariableDefinition;
|
||||
}
|
||||
|
||||
export interface LoadExtensionContext {
|
||||
extensionDir: string;
|
||||
workspaceDir: string;
|
||||
}
|
||||
|
||||
const PATH_SEPARATOR_DEFINITION = {
|
||||
type: 'string',
|
||||
description: 'The path separator.',
|
||||
} as const;
|
||||
|
||||
export const VARIABLE_SCHEMA = {
|
||||
extensionPath: {
|
||||
type: 'string',
|
||||
description: 'The path of the extension in the filesystem.',
|
||||
},
|
||||
workspacePath: {
|
||||
type: 'string',
|
||||
description: 'The absolute path of the current workspace.',
|
||||
},
|
||||
'/': PATH_SEPARATOR_DEFINITION,
|
||||
pathSeparator: PATH_SEPARATOR_DEFINITION,
|
||||
} as const;
|
||||
|
|
@ -1,18 +0,0 @@
|
|||
/**
|
||||
* @license
|
||||
* Copyright 2025 Google LLC
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
import { expect, describe, it } from 'vitest';
|
||||
import { hydrateString } from './variables.js';
|
||||
|
||||
describe('hydrateString', () => {
|
||||
it('should replace a single variable', () => {
|
||||
const context = {
|
||||
extensionPath: 'path/my-extension',
|
||||
};
|
||||
const result = hydrateString('Hello, ${extensionPath}!', context);
|
||||
expect(result).toBe('Hello, path/my-extension!');
|
||||
});
|
||||
});
|
||||
|
|
@ -1,72 +0,0 @@
|
|||
/**
|
||||
* @license
|
||||
* Copyright 2025 Google LLC
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
import { type VariableSchema, VARIABLE_SCHEMA } from './variableSchema.js';
|
||||
import path from 'node:path';
|
||||
import { QWEN_DIR } from '@qwen-code/qwen-code-core';
|
||||
|
||||
export const EXTENSIONS_DIRECTORY_NAME = path.join(QWEN_DIR, 'extensions');
|
||||
export const EXTENSIONS_CONFIG_FILENAME = 'qwen-extension.json';
|
||||
export const INSTALL_METADATA_FILENAME = '.qwen-extension-install.json';
|
||||
export const EXTENSION_SETTINGS_FILENAME = '.env';
|
||||
|
||||
export type JsonObject = { [key: string]: JsonValue };
|
||||
export type JsonArray = JsonValue[];
|
||||
export type JsonValue =
|
||||
| string
|
||||
| number
|
||||
| boolean
|
||||
| null
|
||||
| JsonObject
|
||||
| JsonArray;
|
||||
|
||||
export type VariableContext = {
|
||||
[key in keyof typeof VARIABLE_SCHEMA]?: string;
|
||||
};
|
||||
|
||||
export function validateVariables(
|
||||
variables: VariableContext,
|
||||
schema: VariableSchema,
|
||||
) {
|
||||
for (const key in schema) {
|
||||
const definition = schema[key];
|
||||
if (definition.required && !variables[key as keyof VariableContext]) {
|
||||
throw new Error(`Missing required variable: ${key}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export function hydrateString(str: string, context: VariableContext): string {
|
||||
validateVariables(context, VARIABLE_SCHEMA);
|
||||
const regex = /\${(.*?)}/g;
|
||||
return str.replace(regex, (match, key) =>
|
||||
context[key as keyof VariableContext] == null
|
||||
? match
|
||||
: (context[key as keyof VariableContext] as string),
|
||||
);
|
||||
}
|
||||
|
||||
export function recursivelyHydrateStrings(
|
||||
obj: JsonValue,
|
||||
values: VariableContext,
|
||||
): JsonValue {
|
||||
if (typeof obj === 'string') {
|
||||
return hydrateString(obj, values);
|
||||
}
|
||||
if (Array.isArray(obj)) {
|
||||
return obj.map((item) => recursivelyHydrateStrings(item, values));
|
||||
}
|
||||
if (typeof obj === 'object' && obj !== null) {
|
||||
const newObj: JsonObject = {};
|
||||
for (const key in obj) {
|
||||
if (Object.prototype.hasOwnProperty.call(obj, key)) {
|
||||
newObj[key] = recursivelyHydrateStrings(obj[key], values);
|
||||
}
|
||||
}
|
||||
return newObj;
|
||||
}
|
||||
return obj;
|
||||
}
|
||||
|
|
@ -51,7 +51,6 @@ import {
|
|||
import * as fs from 'node:fs'; // fs will be mocked separately
|
||||
import stripJsonComments from 'strip-json-comments'; // Will be mocked separately
|
||||
import { isWorkspaceTrusted } from './trustedFolders.js';
|
||||
import { disableExtension } from './extension.js';
|
||||
|
||||
// These imports will get the versions from the vi.mock('./settings.js', ...) factory.
|
||||
import {
|
||||
|
|
@ -64,8 +63,6 @@ import {
|
|||
needsMigration,
|
||||
type Settings,
|
||||
loadEnvironment,
|
||||
migrateDeprecatedSettings,
|
||||
SettingScope,
|
||||
SETTINGS_VERSION,
|
||||
SETTINGS_VERSION_KEY,
|
||||
} from './settings.js';
|
||||
|
|
@ -2649,122 +2646,4 @@ describe('Settings Loading and Merging', () => {
|
|||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('migrateDeprecatedSettings', () => {
|
||||
let mockFsExistsSync: Mocked<typeof fs.existsSync>;
|
||||
let mockFsReadFileSync: Mocked<typeof fs.readFileSync>;
|
||||
let mockDisableExtension: Mocked<typeof disableExtension>;
|
||||
|
||||
beforeEach(() => {
|
||||
vi.resetAllMocks();
|
||||
|
||||
mockFsExistsSync = vi.mocked(fs.existsSync);
|
||||
mockFsReadFileSync = vi.mocked(fs.readFileSync);
|
||||
mockDisableExtension = vi.mocked(disableExtension);
|
||||
|
||||
(mockFsExistsSync as Mock).mockReturnValue(true);
|
||||
vi.mocked(isWorkspaceTrusted).mockReturnValue(true);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
|
||||
it('should migrate disabled extensions from user and workspace settings', () => {
|
||||
const userSettingsContent = {
|
||||
extensions: {
|
||||
disabled: ['user-ext-1', 'shared-ext'],
|
||||
},
|
||||
};
|
||||
const workspaceSettingsContent = {
|
||||
extensions: {
|
||||
disabled: ['workspace-ext-1', 'shared-ext'],
|
||||
},
|
||||
};
|
||||
|
||||
(mockFsReadFileSync as Mock).mockImplementation(
|
||||
(p: fs.PathOrFileDescriptor) => {
|
||||
if (p === USER_SETTINGS_PATH)
|
||||
return JSON.stringify(userSettingsContent);
|
||||
if (p === MOCK_WORKSPACE_SETTINGS_PATH)
|
||||
return JSON.stringify(workspaceSettingsContent);
|
||||
return '{}';
|
||||
},
|
||||
);
|
||||
|
||||
const loadedSettings = loadSettings(MOCK_WORKSPACE_DIR);
|
||||
const setValueSpy = vi.spyOn(loadedSettings, 'setValue');
|
||||
|
||||
migrateDeprecatedSettings(loadedSettings, MOCK_WORKSPACE_DIR);
|
||||
|
||||
// Check user settings migration
|
||||
expect(mockDisableExtension).toHaveBeenCalledWith(
|
||||
'user-ext-1',
|
||||
SettingScope.User,
|
||||
MOCK_WORKSPACE_DIR,
|
||||
);
|
||||
expect(mockDisableExtension).toHaveBeenCalledWith(
|
||||
'shared-ext',
|
||||
SettingScope.User,
|
||||
MOCK_WORKSPACE_DIR,
|
||||
);
|
||||
|
||||
// Check workspace settings migration
|
||||
expect(mockDisableExtension).toHaveBeenCalledWith(
|
||||
'workspace-ext-1',
|
||||
SettingScope.Workspace,
|
||||
MOCK_WORKSPACE_DIR,
|
||||
);
|
||||
expect(mockDisableExtension).toHaveBeenCalledWith(
|
||||
'shared-ext',
|
||||
SettingScope.Workspace,
|
||||
MOCK_WORKSPACE_DIR,
|
||||
);
|
||||
|
||||
// Check that setValue was called to remove the deprecated setting
|
||||
expect(setValueSpy).toHaveBeenCalledWith(
|
||||
SettingScope.User,
|
||||
'extensions',
|
||||
{
|
||||
disabled: undefined,
|
||||
},
|
||||
);
|
||||
expect(setValueSpy).toHaveBeenCalledWith(
|
||||
SettingScope.Workspace,
|
||||
'extensions',
|
||||
{
|
||||
disabled: undefined,
|
||||
},
|
||||
);
|
||||
});
|
||||
|
||||
it('should not do anything if there are no deprecated settings', () => {
|
||||
const userSettingsContent = {
|
||||
extensions: {
|
||||
enabled: ['user-ext-1'],
|
||||
},
|
||||
};
|
||||
const workspaceSettingsContent = {
|
||||
someOtherSetting: 'value',
|
||||
};
|
||||
|
||||
(mockFsReadFileSync as Mock).mockImplementation(
|
||||
(p: fs.PathOrFileDescriptor) => {
|
||||
if (p === USER_SETTINGS_PATH)
|
||||
return JSON.stringify(userSettingsContent);
|
||||
if (p === MOCK_WORKSPACE_SETTINGS_PATH)
|
||||
return JSON.stringify(workspaceSettingsContent);
|
||||
return '{}';
|
||||
},
|
||||
);
|
||||
|
||||
const loadedSettings = loadSettings(MOCK_WORKSPACE_DIR);
|
||||
const setValueSpy = vi.spyOn(loadedSettings, 'setValue');
|
||||
|
||||
migrateDeprecatedSettings(loadedSettings, MOCK_WORKSPACE_DIR);
|
||||
|
||||
expect(mockDisableExtension).not.toHaveBeenCalled();
|
||||
expect(setValueSpy).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -30,7 +30,6 @@ import {
|
|||
import { resolveEnvVarsInObject } from '../utils/envVarResolver.js';
|
||||
import { customDeepMerge, type MergeableObject } from '../utils/deepMerge.js';
|
||||
import { updateSettingsFilePreservingFormat } from '../utils/commentJson.js';
|
||||
import { disableExtension } from './extension.js';
|
||||
|
||||
function getMergeStrategyForPath(path: string[]): MergeStrategy | undefined {
|
||||
let current: SettingDefinition | undefined = undefined;
|
||||
|
|
@ -81,7 +80,6 @@ const MIGRATION_MAP: Record<string, string> = {
|
|||
excludeTools: 'tools.exclude',
|
||||
excludeMCPServers: 'mcp.excluded',
|
||||
excludedProjectEnvVars: 'advanced.excludedEnvVars',
|
||||
extensionManagement: 'experimental.extensionManagement',
|
||||
extensions: 'extensions',
|
||||
fileFiltering: 'context.fileFiltering',
|
||||
folderTrustFeature: 'security.folderTrust.featureEnabled',
|
||||
|
|
@ -812,31 +810,6 @@ export function loadSettings(
|
|||
);
|
||||
}
|
||||
|
||||
export function migrateDeprecatedSettings(
|
||||
loadedSettings: LoadedSettings,
|
||||
workspaceDir: string = process.cwd(),
|
||||
): void {
|
||||
const processScope = (scope: SettingScope) => {
|
||||
const settings = loadedSettings.forScope(scope).settings;
|
||||
if (settings.extensions?.disabled) {
|
||||
console.log(
|
||||
`Migrating deprecated extensions.disabled settings from ${scope} settings...`,
|
||||
);
|
||||
for (const extension of settings.extensions.disabled ?? []) {
|
||||
disableExtension(extension, scope, workspaceDir);
|
||||
}
|
||||
|
||||
const newExtensionsValue = { ...settings.extensions };
|
||||
newExtensionsValue.disabled = undefined;
|
||||
|
||||
loadedSettings.setValue(scope, 'extensions', newExtensionsValue);
|
||||
}
|
||||
};
|
||||
|
||||
processScope(SettingScope.User);
|
||||
processScope(SettingScope.Workspace);
|
||||
}
|
||||
|
||||
export function saveSettings(settingsFile: SettingsFile): void {
|
||||
try {
|
||||
// Ensure the directory exists
|
||||
|
|
|
|||
|
|
@ -1192,15 +1192,6 @@ const SETTINGS_SCHEMA = {
|
|||
description: 'Setting to enable experimental features',
|
||||
showInDialog: false,
|
||||
properties: {
|
||||
extensionManagement: {
|
||||
type: 'boolean',
|
||||
label: 'Extension Management',
|
||||
category: 'Experimental',
|
||||
requiresRestart: true,
|
||||
default: true,
|
||||
description: 'Enable extension management features.',
|
||||
showInDialog: false,
|
||||
},
|
||||
visionModelPreview: {
|
||||
type: 'boolean',
|
||||
label: 'Vision Model Preview',
|
||||
|
|
@ -1223,39 +1214,6 @@ const SETTINGS_SCHEMA = {
|
|||
},
|
||||
},
|
||||
},
|
||||
|
||||
extensions: {
|
||||
type: 'object',
|
||||
label: 'Extensions',
|
||||
category: 'Extensions',
|
||||
requiresRestart: true,
|
||||
default: {},
|
||||
description: 'Settings for extensions.',
|
||||
showInDialog: false,
|
||||
properties: {
|
||||
disabled: {
|
||||
type: 'array',
|
||||
label: 'Disabled Extensions',
|
||||
category: 'Extensions',
|
||||
requiresRestart: true,
|
||||
default: [] as string[],
|
||||
description: 'List of disabled extensions.',
|
||||
showInDialog: false,
|
||||
mergeStrategy: MergeStrategy.UNION,
|
||||
},
|
||||
workspacesWithMigrationNudge: {
|
||||
type: 'array',
|
||||
label: 'Workspaces with Migration Nudge',
|
||||
category: 'Extensions',
|
||||
requiresRestart: false,
|
||||
default: [] as string[],
|
||||
description:
|
||||
'List of workspaces for which the migration nudge has been shown.',
|
||||
showInDialog: false,
|
||||
mergeStrategy: MergeStrategy.UNION,
|
||||
},
|
||||
},
|
||||
},
|
||||
} as const satisfies SettingsSchema;
|
||||
|
||||
export type SettingsSchemaType = typeof SETTINGS_SCHEMA;
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue