diff --git a/package-lock.json b/package-lock.json index 330b90e08..9ff9cc019 100644 --- a/package-lock.json +++ b/package-lock.json @@ -3875,6 +3875,17 @@ "dev": true, "license": "MIT" }, + "node_modules/@types/prompts": { + "version": "2.4.9", + "resolved": "https://registry.npmjs.org/@types/prompts/-/prompts-2.4.9.tgz", + "integrity": "sha512-qTxFi6Buiu8+50/+3DGIWLHM6QuWsEKugJnnP6iv2Mc4ncxE4A/OJkjuVOA+5X0X1S/nq5VJRa8Lu+nwcvbrKA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/node": "*", + "kleur": "^3.0.3" + } + }, "node_modules/@types/prop-types": { "version": "15.7.15", "resolved": "https://registry.npmjs.org/@types/prop-types/-/prop-types-15.7.15.tgz", @@ -10984,6 +10995,15 @@ "json-buffer": "3.0.1" } }, + "node_modules/kleur": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/kleur/-/kleur-3.0.3.tgz", + "integrity": "sha512-eTIzlVOSUR+JxdDFepEYcBMtZ9Qqdef+rnzWdRZuMbOywu5tO2w2N7rqjoANZ5k9vywhL6Br1VRjUIgTQx4E8w==", + "license": "MIT", + "engines": { + "node": ">=6" + } + }, "node_modules/ky": { "version": "1.8.1", "resolved": "https://registry.npmjs.org/ky/-/ky-1.8.1.tgz", @@ -13393,6 +13413,19 @@ "dev": true, "license": "MIT" }, + "node_modules/prompts": { + "version": "2.4.2", + "resolved": "https://registry.npmjs.org/prompts/-/prompts-2.4.2.tgz", + "integrity": "sha512-NxNv/kLguCA7p3jE8oL2aEBsrJWgAakBpgmgK6lpPWV+WuOmY6r2/zbAVnP+T8bQlA0nzHXSJSJW0Hq7ylaD2Q==", + "license": "MIT", + "dependencies": { + "kleur": "^3.0.3", + "sisteransi": "^1.0.5" + }, + "engines": { + "node": ">= 6" + } + }, "node_modules/prop-types": { "version": "15.8.1", "resolved": "https://registry.npmjs.org/prop-types/-/prop-types-15.8.1.tgz", @@ -14753,6 +14786,12 @@ "url": "https://github.com/steveukx/git-js?sponsor=1" } }, + "node_modules/sisteransi": { + "version": "1.0.5", + "resolved": "https://registry.npmjs.org/sisteransi/-/sisteransi-1.0.5.tgz", + "integrity": "sha512-bLGGlR1QxBcynn2d5YmDX4MGjlZvy2MRBDRNHLJ8VI6l6+9FUiyTFNJ0IveOSP0bcXgVDPRcfGqA0pjaqUpfVg==", + "license": "MIT" + }, "node_modules/slash": { "version": "5.1.0", "resolved": "https://registry.npmjs.org/slash/-/slash-5.1.0.tgz", @@ -17338,6 +17377,7 @@ "ink-spinner": "^5.0.0", "lowlight": "^3.3.0", "open": "^10.1.2", + "prompts": "^2.4.2", "qrcode-terminal": "^0.12.0", "react": "^19.1.0", "read-package-up": "^11.0.0", @@ -17366,6 +17406,7 @@ "@types/diff": "^7.0.2", "@types/dotenv": "^6.1.1", "@types/node": "^20.11.24", + "@types/prompts": "^2.4.9", "@types/react": "^19.1.8", "@types/react-dom": "^19.1.6", "@types/semver": "^7.7.0", diff --git a/packages/cli/package.json b/packages/cli/package.json index f2083fe19..daffd1659 100644 --- a/packages/cli/package.json +++ b/packages/cli/package.json @@ -46,6 +46,7 @@ "comment-json": "^4.2.5", "diff": "^7.0.0", "dotenv": "^17.1.0", + "prompts": "^2.4.2", "fzf": "^0.5.2", "glob": "^10.5.0", "highlight.js": "^11.11.1", @@ -79,6 +80,7 @@ "@types/command-exists": "^1.2.3", "@types/diff": "^7.0.2", "@types/dotenv": "^6.1.1", + "@types/prompts": "^2.4.9", "@types/node": "^20.11.24", "@types/react": "^19.1.8", "@types/react-dom": "^19.1.6", diff --git a/packages/cli/src/acp-integration/acpAgent.ts b/packages/cli/src/acp-integration/acpAgent.ts index d56d196db..e5f27636b 100644 --- a/packages/cli/src/acp-integration/acpAgent.ts +++ b/packages/cli/src/acp-integration/acpAgent.ts @@ -27,7 +27,8 @@ import { Readable, Writable } from 'node:stream'; import type { LoadedSettings } from '../config/settings.js'; import { SettingScope } from '../config/settings.js'; import { z } from 'zod'; -import { ExtensionStorage, type Extension } from '../config/extension.js'; +import { ExtensionStorage } from '../config/extensions/storage.js'; +import type { Extension } from '../config/extension.js'; import type { CliArgs } from '../config/config.js'; import { loadCliConfig } from '../config/config.js'; import { ExtensionEnablementManager } from '../config/extensions/extensionEnablement.js'; diff --git a/packages/cli/src/commands/extensions/update.ts b/packages/cli/src/commands/extensions/update.ts index bb200e58f..e882fa1b3 100644 --- a/packages/cli/src/commands/extensions/update.ts +++ b/packages/cli/src/commands/extensions/update.ts @@ -8,9 +8,10 @@ import type { CommandModule } from 'yargs'; import { loadExtensions, annotateActiveExtensions, - ExtensionStorage, requestConsentNonInteractive, } from '../../config/extension.js'; +import { ExtensionStorage } from '../../config/extensions/storage.js'; + import { updateAllUpdatableExtensions, type ExtensionUpdateInfo, diff --git a/packages/cli/src/commands/mcp/list.test.ts b/packages/cli/src/commands/mcp/list.test.ts index 4d9bb083f..bf186c729 100644 --- a/packages/cli/src/commands/mcp/list.test.ts +++ b/packages/cli/src/commands/mcp/list.test.ts @@ -7,7 +7,8 @@ import { vi, describe, it, expect, beforeEach, afterEach } from 'vitest'; import { listMcpServers } from './list.js'; import { loadSettings } from '../../config/settings.js'; -import { ExtensionStorage, loadExtensions } from '../../config/extension.js'; +import { loadExtensions } from '../../config/extension.js'; +import { ExtensionStorage } from '../../config/extensions/storage.js'; import { createTransport } from '@qwen-code/qwen-code-core'; import { Client } from '@modelcontextprotocol/sdk/client/index.js'; diff --git a/packages/cli/src/commands/mcp/list.ts b/packages/cli/src/commands/mcp/list.ts index 09d9bf639..46b880632 100644 --- a/packages/cli/src/commands/mcp/list.ts +++ b/packages/cli/src/commands/mcp/list.ts @@ -10,7 +10,8 @@ import { loadSettings } from '../../config/settings.js'; import type { MCPServerConfig } from '@qwen-code/qwen-code-core'; import { MCPServerStatus, createTransport } from '@qwen-code/qwen-code-core'; import { Client } from '@modelcontextprotocol/sdk/client/index.js'; -import { ExtensionStorage, loadExtensions } from '../../config/extension.js'; +import { loadExtensions } from '../../config/extension.js'; +import { ExtensionStorage } from '../../config/extensions/storage.js'; import { ExtensionEnablementManager } from '../../config/extensions/extensionEnablement.js'; const COLOR_GREEN = '\u001b[32m'; diff --git a/packages/cli/src/config/config.test.ts b/packages/cli/src/config/config.test.ts index 0b95f7857..9927438bf 100644 --- a/packages/cli/src/config/config.test.ts +++ b/packages/cli/src/config/config.test.ts @@ -16,7 +16,8 @@ import { } from '@qwen-code/qwen-code-core'; import { loadCliConfig, parseArguments, type CliArgs } from './config.js'; import type { Settings } from './settings.js'; -import { ExtensionStorage, type Extension } from './extension.js'; +import type { Extension } from './extension.js'; +import { ExtensionStorage } from './extensions/storage.js'; import * as ServerConfig from '@qwen-code/qwen-code-core'; import { isWorkspaceTrusted } from './trustedFolders.js'; import { ExtensionEnablementManager } from './extensions/extensionEnablement.js'; diff --git a/packages/cli/src/config/extension.test.ts b/packages/cli/src/config/extension.test.ts index 2bae9c1ec..a3a85ee40 100644 --- a/packages/cli/src/config/extension.test.ts +++ b/packages/cli/src/config/extension.test.ts @@ -9,9 +9,6 @@ import * as fs from 'node:fs'; import * as os from 'node:os'; import * as path from 'node:path'; import { - EXTENSIONS_CONFIG_FILENAME, - ExtensionStorage, - INSTALL_METADATA_FILENAME, annotateActiveExtensions, disableExtension, enableExtension, @@ -24,6 +21,11 @@ import { uninstallExtension, type Extension, } from './extension.js'; +import { + INSTALL_METADATA_FILENAME, + EXTENSIONS_CONFIG_FILENAME, +} from './extensions/variables.js'; +import { ExtensionStorage } from './extensions/storage.js'; import { QWEN_DIR, type GeminiCLIExtension, diff --git a/packages/cli/src/config/extension.ts b/packages/cli/src/config/extension.ts index 8942e2e9b..32f4a6557 100644 --- a/packages/cli/src/config/extension.ts +++ b/packages/cli/src/config/extension.ts @@ -10,7 +10,6 @@ import type { ExtensionInstallMetadata, } from '@qwen-code/qwen-code-core'; import { - QWEN_DIR, Storage, Config, ExtensionInstallEvent, @@ -27,12 +26,17 @@ 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 { recursivelyHydrateStrings } from './extensions/variables.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'; @@ -48,11 +52,13 @@ import { convertGeminiExtensionPackage, } from './extensions/gemini-converter.js'; import { glob } from 'glob'; - -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'; +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; @@ -90,34 +96,6 @@ export interface ExtensionUpdateInfo { updatedVersion: string; } -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); - } - - static getUserExtensionsDir(): string { - const storage = new Storage(os.homedir()); - return storage.getExtensionsDir(); - } - - static async createTmpDir(): Promise { - return await fs.promises.mkdtemp(path.join(os.tmpdir(), 'qwen-extension')); - } -} - 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())) { @@ -585,8 +563,15 @@ export async function installExtension( 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' || @@ -648,78 +633,6 @@ export async function installExtension( } } -/** - * Builds a consent string for installing an extension based on it's - * extensionConfig. - */ -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. - */ -async function maybeRequestConsentOrFail( - extensionConfig: ExtensionConfig, - requestConsent: (consent: string) => Promise, - 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}".`); - } -} - async function loadCommandsFromDir( dir: string, extensionName: string, @@ -801,6 +714,7 @@ export function loadExtensionConfig( '/': 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"'}`, @@ -941,3 +855,31 @@ export function enableExtension( 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'); +} diff --git a/packages/cli/src/config/extensions/consent.ts b/packages/cli/src/config/extensions/consent.ts new file mode 100644 index 000000000..a686188ea --- /dev/null +++ b/packages/cli/src/config/extensions/consent.ts @@ -0,0 +1,157 @@ +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 { + 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 { + 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 { + 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 { + return new Promise((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, + 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}".`); + } +} diff --git a/packages/cli/src/config/extensions/converter.test.ts b/packages/cli/src/config/extensions/converter.test.ts deleted file mode 100644 index 67c3cc2d1..000000000 --- a/packages/cli/src/config/extensions/converter.test.ts +++ /dev/null @@ -1,228 +0,0 @@ -/** - * @license - * Copyright 2025 Google LLC - * SPDX-License-Identifier: Apache-2.0 - */ - -import { describe, it, expect } from 'vitest'; -import { - isClaudePluginConfig, - convertClaudeToQwenConfig, - mergeClaudeConfigs, - type ClaudePluginConfig, - type ClaudeMarketplacePluginConfig, -} from './claude-converter.js'; -import { - isGeminiExtensionConfig, - convertGeminiToQwenConfig, - type GeminiExtensionConfig, -} from './gemini-converter.js'; -import { parseMarketplaceSource } from './marketplace.js'; - -describe('Claude Converter', () => { - describe('isClaudePluginConfig', () => { - it('should detect Claude plugin config', () => { - const config = { - name: 'test-plugin', - version: '1.0.0', - agents: 'agents/', - hooks: 'hooks.js', - }; - expect(isClaudePluginConfig(config)).toBe(true); - }); - - it('should return false for non-Claude config', () => { - const config = { - name: 'test-plugin', - version: '1.0.0', - commands: 'commands/', - }; - expect(isClaudePluginConfig(config)).toBe(false); - }); - - it('should return false for invalid config', () => { - expect(isClaudePluginConfig(null)).toBe(false); - expect(isClaudePluginConfig('string')).toBe(false); - expect(isClaudePluginConfig({})).toBe(false); - }); - }); - - describe('convertClaudeToQwenConfig', () => { - it('should convert basic Claude config', () => { - const claudeConfig: ClaudePluginConfig = { - name: 'claude-plugin', - version: '1.0.0', - commands: 'commands/', - agents: 'agents/', - }; - - const qwenConfig = convertClaudeToQwenConfig(claudeConfig); - - expect(qwenConfig.name).toBe('claude-plugin'); - expect(qwenConfig.version).toBe('1.0.0'); - expect(qwenConfig.commands).toBe('commands/'); - expect(qwenConfig.agents).toBe('agents/'); - }); - - it('should throw error for invalid config', () => { - expect(() => convertClaudeToQwenConfig({} as ClaudePluginConfig)).toThrow( - 'Claude plugin config must have name and version fields', - ); - }); - }); - - describe('mergeClaudeConfigs', () => { - it('should merge marketplace and plugin configs', () => { - const marketplaceConfig: ClaudeMarketplacePluginConfig = { - name: 'plugin', - version: '2.0.0', - source: 'https://github.com/example/plugin', - description: 'Updated description', - }; - - const pluginConfig: ClaudePluginConfig = { - name: 'plugin', - version: '1.0.0', - description: 'Original description', - commands: 'commands/', - }; - - const merged = mergeClaudeConfigs(marketplaceConfig, pluginConfig); - - expect(merged.name).toBe('plugin'); - expect(merged.version).toBe('2.0.0'); - expect(merged.description).toBe('Updated description'); - expect(merged.commands).toBe('commands/'); - }); - - it('should throw error in strict mode without plugin config', () => { - const marketplaceConfig: ClaudeMarketplacePluginConfig = { - name: 'plugin', - version: '1.0.0', - source: 'https://github.com/example/plugin', - strict: true, - }; - - expect(() => mergeClaudeConfigs(marketplaceConfig)).toThrow( - 'Plugin plugin requires plugin.json (strict mode)', - ); - }); - }); -}); - -describe('Gemini Converter', () => { - describe('isGeminiExtensionConfig', () => { - it('should detect Gemini extension config with settings', () => { - const config = { - name: 'test-extension', - version: '1.0.0', - settings: [ - { - name: 'API Key', - description: 'Your API key', - envVar: 'MY_API_KEY', - }, - ], - }; - expect(isGeminiExtensionConfig(config)).toBe(true); - }); - - it('should return true for basic Gemini config without settings', () => { - const config = { - name: 'test-extension', - version: '1.0.0', - contextFileName: 'QWEN.md', - }; - expect(isGeminiExtensionConfig(config)).toBe(true); - }); - - it('should return false for invalid config', () => { - expect(isGeminiExtensionConfig(null)).toBe(false); - expect(isGeminiExtensionConfig('string')).toBe(false); - expect(isGeminiExtensionConfig({})).toBe(false); - }); - }); - - describe('convertGeminiToQwenConfig', () => { - it('should convert basic Gemini config', () => { - const geminiConfig: GeminiExtensionConfig = { - name: 'gemini-extension', - version: '1.0.0', - commands: 'commands/', - excludeTools: ['tool1', 'tool2'], - }; - - const qwenConfig = convertGeminiToQwenConfig(geminiConfig); - - expect(qwenConfig.name).toBe('gemini-extension'); - expect(qwenConfig.version).toBe('1.0.0'); - expect(qwenConfig.commands).toBe('commands/'); - expect(qwenConfig.excludeTools).toEqual(['tool1', 'tool2']); - }); - - it('should convert config with settings', () => { - const geminiConfig: GeminiExtensionConfig = { - name: 'gemini-extension', - version: '1.0.0', - settings: [ - { - name: 'API Key', - description: 'Your API key', - envVar: 'MY_API_KEY', - sensitive: true, - }, - ], - }; - - const qwenConfig = convertGeminiToQwenConfig(geminiConfig); - - expect(qwenConfig.settings).toEqual(geminiConfig.settings); - }); - - it('should throw error for invalid config', () => { - expect(() => - convertGeminiToQwenConfig({} as GeminiExtensionConfig), - ).toThrow('Gemini extension config must have name and version fields'); - }); - }); -}); - -describe('Marketplace Parser', () => { - describe('parseMarketplaceSource', () => { - it('should parse valid marketplace source', () => { - const result = parseMarketplaceSource( - 'https://github.com/example/marketplace:my-plugin', - ); - expect(result).toEqual({ - marketplaceUrl: 'https://github.com/example/marketplace', - pluginName: 'my-plugin', - }); - }); - - it('should parse HTTP marketplace source', () => { - const result = parseMarketplaceSource( - 'http://example.com/marketplace:plugin-name', - ); - expect(result).toEqual({ - marketplaceUrl: 'http://example.com/marketplace', - pluginName: 'plugin-name', - }); - }); - - it('should handle multiple colons in URL', () => { - const result = parseMarketplaceSource( - 'https://github.com:8080/repo:plugin', - ); - expect(result).toEqual({ - marketplaceUrl: 'https://github.com:8080/repo', - pluginName: 'plugin', - }); - }); - - it('should return null for invalid sources', () => { - expect(parseMarketplaceSource('not-a-url:plugin')).toBeNull(); - expect(parseMarketplaceSource('https://github.com/repo')).toBeNull(); - expect(parseMarketplaceSource('https://github.com/repo:')).toBeNull(); - }); - }); -}); diff --git a/packages/cli/src/config/extensions/extensionSettings.test.ts b/packages/cli/src/config/extensions/extensionSettings.test.ts new file mode 100644 index 000000000..01fc63135 --- /dev/null +++ b/packages/cli/src/config/extensions/extensionSettings.test.ts @@ -0,0 +1,725 @@ +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(); + return { + ...mockedOs, + homedir: vi.fn(), + }; +}); + +vi.mock('@qwen-code/qwen-code-core', async (importOriginal) => { + const actual = + await importOriginal(); + return { + ...actual, + KeychainTokenStorage: vi.fn(), + }; +}); + +describe('extensionSettings', () => { + let tempHomeDir: string; + let tempWorkspaceDir: string; + let extensionDir: string; + let mockKeychainData: Record>; + + 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 + }); + }); +}); diff --git a/packages/cli/src/config/extensions/extensionSettings.ts b/packages/cli/src/config/extensions/extensionSettings.ts new file mode 100644 index 000000000..13561180d --- /dev/null +++ b/packages/cli/src/config/extensions/extensionSettings.ts @@ -0,0 +1,298 @@ +/** + * @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, + previousExtensionConfig?: ExtensionConfig, + previousSettings?: Record, +): Promise { + 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 = { ...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 = {}; + 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 { + 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 { + 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> { + const { name: extensionName } = extensionConfig; + const keychain = new KeychainTokenStorage( + getKeychainStorageName(extensionName, extensionId, scope), + ); + const envFilePath = getEnvFilePath(extensionName, scope); + let customEnv: Record = {}; + 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> { + 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, + scope: ExtensionSettingScope, +): Promise { + 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 = {}; + 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; +} diff --git a/packages/cli/src/config/extensions/gemini-converter.ts b/packages/cli/src/config/extensions/gemini-converter.ts index 2aa0b7a74..ccba248f3 100644 --- a/packages/cli/src/config/extensions/gemini-converter.ts +++ b/packages/cli/src/config/extensions/gemini-converter.ts @@ -12,7 +12,7 @@ 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 '../extension.js'; +import { ExtensionStorage } from '../extensions/storage.js'; import { convertTomlToMarkdown } from '../../services/toml-to-markdown-converter.js'; export interface GeminiExtensionConfig { diff --git a/packages/cli/src/config/extensions/github.ts b/packages/cli/src/config/extensions/github.ts index 9bdcb6486..4db75c18a 100644 --- a/packages/cli/src/config/extensions/github.ts +++ b/packages/cli/src/config/extensions/github.ts @@ -15,7 +15,8 @@ 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 { EXTENSIONS_CONFIG_FILENAME, loadExtension } from '../extension.js'; +import { loadExtension } from '../extension.js'; +import { EXTENSIONS_CONFIG_FILENAME } from './variables.js'; import * as tar from 'tar'; import extract from 'extract-zip'; diff --git a/packages/cli/src/config/extensions/storage.test.ts b/packages/cli/src/config/extensions/storage.test.ts new file mode 100644 index 000000000..4f392f94e --- /dev/null +++ b/packages/cli/src/config/extensions/storage.test.ts @@ -0,0 +1,101 @@ +/** + * @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(); + 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); + }); +}); diff --git a/packages/cli/src/config/extensions/storage.ts b/packages/cli/src/config/extensions/storage.ts new file mode 100644 index 000000000..fec7b38d4 --- /dev/null +++ b/packages/cli/src/config/extensions/storage.ts @@ -0,0 +1,40 @@ +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 { + return await fs.promises.mkdtemp(path.join(os.tmpdir(), 'qwen-extension')); + } +} diff --git a/packages/cli/src/config/extensions/update.test.ts b/packages/cli/src/config/extensions/update.test.ts index 849857e4d..f7eea8d39 100644 --- a/packages/cli/src/config/extensions/update.test.ts +++ b/packages/cli/src/config/extensions/update.test.ts @@ -8,13 +8,12 @@ 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, - ExtensionStorage, INSTALL_METADATA_FILENAME, - annotateActiveExtensions, - loadExtension, -} from '../extension.js'; +} 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'; diff --git a/packages/cli/src/config/extensions/update.ts b/packages/cli/src/config/extensions/update.ts index d74be540f..16f298a02 100644 --- a/packages/cli/src/config/extensions/update.ts +++ b/packages/cli/src/config/extensions/update.ts @@ -15,13 +15,14 @@ import { uninstallExtension, loadExtension, loadInstallMetadata, - ExtensionStorage, 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; diff --git a/packages/cli/src/config/extensions/variables.ts b/packages/cli/src/config/extensions/variables.ts index 7c6ef8469..f905eea60 100644 --- a/packages/cli/src/config/extensions/variables.ts +++ b/packages/cli/src/config/extensions/variables.ts @@ -5,6 +5,13 @@ */ 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[]; diff --git a/packages/cli/src/gemini.test.tsx b/packages/cli/src/gemini.test.tsx index 9fa0b8261..ad74bd279 100644 --- a/packages/cli/src/gemini.test.tsx +++ b/packages/cli/src/gemini.test.tsx @@ -263,6 +263,7 @@ describe('gemini.tsx main function', () => { const { loadSettings } = await import('./config/settings.js'); const cleanupModule = await import('./utils/cleanup.js'); const extensionModule = await import('./config/extension.js'); + const { ExtensionStorage } = await import('./config/extensions/storage.js'); const validatorModule = await import('./validateNonInterActiveAuth.js'); const streamJsonModule = await import('./nonInteractive/session.js'); const initializerModule = await import('./core/initializer.js'); @@ -276,10 +277,9 @@ describe('gemini.tsx main function', () => { const runExitCleanupMock = vi.mocked(cleanupModule.runExitCleanup); runExitCleanupMock.mockResolvedValue(undefined); vi.spyOn(extensionModule, 'loadExtensions').mockReturnValue([]); - vi.spyOn( - extensionModule.ExtensionStorage, - 'getUserExtensionsDir', - ).mockReturnValue('/tmp/extensions'); + vi.spyOn(ExtensionStorage, 'getUserExtensionsDir').mockReturnValue( + '/tmp/extensions', + ); vi.spyOn(initializerModule, 'initializeApp').mockResolvedValue({ authError: null, themeError: null, diff --git a/packages/cli/src/gemini.tsx b/packages/cli/src/gemini.tsx index b05f12453..b9dfb09a9 100644 --- a/packages/cli/src/gemini.tsx +++ b/packages/cli/src/gemini.tsx @@ -15,7 +15,8 @@ import React from 'react'; import { validateAuthMethod } from './config/auth.js'; import * as cliConfig from './config/config.js'; import { loadCliConfig, parseArguments } from './config/config.js'; -import { ExtensionStorage, loadExtensions } from './config/extension.js'; +import { loadExtensions } from './config/extension.js'; +import { ExtensionStorage } from './config/extensions/storage.js'; import type { DnsResolutionOrder, LoadedSettings } from './config/settings.js'; import { loadSettings, migrateDeprecatedSettings } from './config/settings.js'; import { diff --git a/packages/cli/src/services/FileCommandLoader.ts b/packages/cli/src/services/FileCommandLoader.ts index 38ae44ab1..8095a72e3 100644 --- a/packages/cli/src/services/FileCommandLoader.ts +++ b/packages/cli/src/services/FileCommandLoader.ts @@ -22,7 +22,7 @@ import { type CommandDefinition, } from './command-factory.js'; import type { SlashCommand } from '../ui/commands/types.js'; -import { EXTENSIONS_CONFIG_FILENAME } from '../config/extension.js'; +import { EXTENSIONS_CONFIG_FILENAME } from '../config/extensions/variables.js'; interface CommandDirectory { path: string; diff --git a/packages/cli/src/test-utils/createExtension.ts b/packages/cli/src/test-utils/createExtension.ts index cf20c4889..7366c3cf6 100644 --- a/packages/cli/src/test-utils/createExtension.ts +++ b/packages/cli/src/test-utils/createExtension.ts @@ -6,14 +6,14 @@ import * as fs from 'node:fs'; import * as path from 'node:path'; -import { - EXTENSIONS_CONFIG_FILENAME, - INSTALL_METADATA_FILENAME, -} from '../config/extension.js'; import { type MCPServerConfig, type ExtensionInstallMetadata, } from '@qwen-code/qwen-code-core'; +import { + EXTENSIONS_CONFIG_FILENAME, + INSTALL_METADATA_FILENAME, +} from '../config/extensions/variables.js'; export function createExtension({ extensionsDir = 'extensions-dir', diff --git a/packages/cli/src/ui/hooks/useExtensionUpdates.test.ts b/packages/cli/src/ui/hooks/useExtensionUpdates.test.ts index 29428e8d8..80640fa0d 100644 --- a/packages/cli/src/ui/hooks/useExtensionUpdates.test.ts +++ b/packages/cli/src/ui/hooks/useExtensionUpdates.test.ts @@ -9,10 +9,10 @@ import * as fs from 'node:fs'; import * as os from 'node:os'; import * as path from 'node:path'; import { - ExtensionStorage, annotateActiveExtensions, loadExtension, } from '../../config/extension.js'; +import { ExtensionStorage } from '../../config/extensions/storage.js'; import { createExtension } from '../../test-utils/createExtension.js'; import { useExtensionUpdates } from './useExtensionUpdates.js'; import { QWEN_DIR, type GeminiCLIExtension } from '@qwen-code/qwen-code-core'; diff --git a/packages/core/src/index.ts b/packages/core/src/index.ts index 56680403b..36656b9b2 100644 --- a/packages/core/src/index.ts +++ b/packages/core/src/index.ts @@ -115,6 +115,7 @@ export type { OAuthCredentials, } from './mcp/token-storage/types.js'; export { MCPOAuthTokenStorage } from './mcp/oauth-token-storage.js'; +export { KeychainTokenStorage } from './mcp/token-storage/keychain-token-storage.js'; export type { MCPOAuthConfig } from './mcp/oauth-provider.js'; export type { OAuthAuthorizationServerMetadata, diff --git a/packages/core/src/mcp/token-storage/keychain-token-storage.ts b/packages/core/src/mcp/token-storage/keychain-token-storage.ts index 70eccbadf..4f7397967 100644 --- a/packages/core/src/mcp/token-storage/keychain-token-storage.ts +++ b/packages/core/src/mcp/token-storage/keychain-token-storage.ts @@ -22,6 +22,7 @@ interface Keytar { } const KEYCHAIN_TEST_PREFIX = '__keychain_test__'; +const SECRET_PREFIX = '__secret__'; export class KeychainTokenStorage extends BaseTokenStorage { private keychainAvailable: boolean | null = null; @@ -137,6 +138,7 @@ export class KeychainTokenStorage extends BaseTokenStorage { const credentials = await keytar.findCredentials(this.serviceName); return credentials .filter((cred) => !cred.account.startsWith(KEYCHAIN_TEST_PREFIX)) + .filter((cred) => !cred.account.startsWith(SECRET_PREFIX)) .map((cred: { account: string }) => cred.account); } catch (error) { console.error('Failed to list servers from keychain:', error); @@ -156,9 +158,9 @@ export class KeychainTokenStorage extends BaseTokenStorage { const result = new Map(); try { - const credentials = ( - await keytar.findCredentials(this.serviceName) - ).filter((c) => !c.account.startsWith(KEYCHAIN_TEST_PREFIX)); + const credentials = (await keytar.findCredentials(this.serviceName)) + .filter((c) => !c.account.startsWith(KEYCHAIN_TEST_PREFIX)) + .filter((c) => !c.account.startsWith(SECRET_PREFIX)); for (const cred of credentials) { try { @@ -248,4 +250,62 @@ export class KeychainTokenStorage extends BaseTokenStorage { async isAvailable(): Promise { return this.checkKeychainAvailability(); } + + async setSecret(key: string, value: string): Promise { + if (!(await this.checkKeychainAvailability())) { + throw new Error('Keychain is not available'); + } + const keytar = await this.getKeytar(); + if (!keytar) { + throw new Error('Keytar module not available'); + } + await keytar.setPassword(this.serviceName, `${SECRET_PREFIX}${key}`, value); + } + + async getSecret(key: string): Promise { + if (!(await this.checkKeychainAvailability())) { + throw new Error('Keychain is not available'); + } + const keytar = await this.getKeytar(); + if (!keytar) { + throw new Error('Keytar module not available'); + } + return keytar.getPassword(this.serviceName, `${SECRET_PREFIX}${key}`); + } + + async deleteSecret(key: string): Promise { + if (!(await this.checkKeychainAvailability())) { + throw new Error('Keychain is not available'); + } + const keytar = await this.getKeytar(); + if (!keytar) { + throw new Error('Keytar module not available'); + } + const deleted = await keytar.deletePassword( + this.serviceName, + `${SECRET_PREFIX}${key}`, + ); + if (!deleted) { + throw new Error(`No secret found for key: ${key}`); + } + } + + async listSecrets(): Promise { + if (!(await this.checkKeychainAvailability())) { + throw new Error('Keychain is not available'); + } + const keytar = await this.getKeytar(); + if (!keytar) { + throw new Error('Keytar module not available'); + } + try { + const credentials = await keytar.findCredentials(this.serviceName); + return credentials + .filter((cred) => cred.account.startsWith(SECRET_PREFIX)) + .map((cred) => cred.account.substring(SECRET_PREFIX.length)); + } catch (error) { + console.error('Failed to list secrets from keychain:', error); + return []; + } + } } diff --git a/packages/core/src/skills/skill-manager.ts b/packages/core/src/skills/skill-manager.ts index 7a693add3..27810fb40 100644 --- a/packages/core/src/skills/skill-manager.ts +++ b/packages/core/src/skills/skill-manager.ts @@ -320,38 +320,38 @@ export class SkillManager { * @param dir - provided directory * @returns Array of skill configurations */ - async parseSkillsFromDir(dir: string): Promise { - const discoveredSkills: SkillConfig[] = []; + // async parseSkillsFromDir(dir: string): Promise { + // const discoveredSkills: SkillConfig[] = []; - try { - const absoluteSearchPath = path.resolve(dir); - const stats = await fs.stat(absoluteSearchPath).catch(() => null); - if (!stats || !stats.isDirectory()) { - return []; - } + // try { + // const absoluteSearchPath = path.resolve(dir); + // const stats = await fs.stat(absoluteSearchPath).catch(() => null); + // if (!stats || !stats.isDirectory()) { + // return []; + // } - const skillFiles = await glob('*/SKILL.md', { - cwd: absoluteSearchPath, - absolute: true, - nodir: true, - }); + // const skillFiles = await glob('*/SKILL.md', { + // cwd: absoluteSearchPath, + // absolute: true, + // nodir: true, + // }); - for (const skillFile of skillFiles) { - const metadata = await this.parseSkillFile(skillFile, 'extension'); - if (metadata) { - discoveredSkills.push(metadata); - } - } - } catch (error) { - coreEvents.emitFeedback( - 'warning', - `Error discovering skills in ${dir}:`, - error, - ); - } + // for (const skillFile of skillFiles) { + // const metadata = await this.parseSkillFile(skillFile, 'extension'); + // if (metadata) { + // discoveredSkills.push(metadata); + // } + // } + // } catch (error) { + // coreEvents.emitFeedback( + // 'warning', + // `Error discovering skills in ${dir}:`, + // error, + // ); + // } - return discoveredSkills; - } + // return discoveredSkills; + // } /** * Parses a SKILL.md file and returns the configuration.