mirror of
https://github.com/QwenLM/qwen-code.git
synced 2026-01-22 16:56:19 +00:00
Compare commits
12 Commits
feat/exten
...
vscode-ide
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
bfe451bb4a | ||
|
|
c143c68656 | ||
|
|
011f3d2320 | ||
|
|
674bb6386e | ||
|
|
2aa681f610 | ||
|
|
3b6849be94 | ||
|
|
a7e55ccf43 | ||
|
|
c0d78a8f3c | ||
|
|
64eea4889d | ||
|
|
aa80e738fb | ||
|
|
06b64b07e7 | ||
|
|
605e8709fb |
207
.github/workflows/release-vscode-companion.yml
vendored
Normal file
207
.github/workflows/release-vscode-companion.yml
vendored
Normal file
@@ -0,0 +1,207 @@
|
||||
name: 'Release VSCode IDE Companion'
|
||||
|
||||
on:
|
||||
workflow_dispatch:
|
||||
inputs:
|
||||
version:
|
||||
description: 'The version to release (e.g., v0.1.11). Required for manual patch releases.'
|
||||
required: false
|
||||
type: 'string'
|
||||
ref:
|
||||
description: 'The branch or ref (full git sha) to release from.'
|
||||
required: true
|
||||
type: 'string'
|
||||
default: 'main'
|
||||
dry_run:
|
||||
description: 'Run a dry-run of the release process; no branches, vsix packages or GitHub releases will be created.'
|
||||
required: true
|
||||
type: 'boolean'
|
||||
default: true
|
||||
create_preview_release:
|
||||
description: 'Auto apply the preview release tag, input version is ignored.'
|
||||
required: false
|
||||
type: 'boolean'
|
||||
default: false
|
||||
force_skip_tests:
|
||||
description: 'Select to skip the "Run Tests" step in testing. Prod releases should run tests'
|
||||
required: false
|
||||
type: 'boolean'
|
||||
default: false
|
||||
|
||||
concurrency:
|
||||
group: '${{ github.workflow }}'
|
||||
cancel-in-progress: false
|
||||
|
||||
jobs:
|
||||
release-vscode-companion:
|
||||
runs-on: 'ubuntu-latest'
|
||||
environment:
|
||||
name: 'production-release'
|
||||
url: '${{ github.server_url }}/${{ github.repository }}/releases/tag/vscode-companion-${{ steps.version.outputs.RELEASE_TAG }}'
|
||||
if: |-
|
||||
${{ github.repository == 'QwenLM/qwen-code' }}
|
||||
permissions:
|
||||
contents: 'read'
|
||||
issues: 'write'
|
||||
|
||||
steps:
|
||||
- name: 'Checkout'
|
||||
uses: 'actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8' # ratchet:actions/checkout@v5
|
||||
with:
|
||||
ref: '${{ github.event.inputs.ref || github.sha }}'
|
||||
fetch-depth: 0
|
||||
|
||||
- name: 'Set booleans for simplified logic'
|
||||
env:
|
||||
CREATE_PREVIEW_RELEASE: '${{ github.event.inputs.create_preview_release }}'
|
||||
DRY_RUN_INPUT: '${{ github.event.inputs.dry_run }}'
|
||||
id: 'vars'
|
||||
run: |-
|
||||
is_preview="false"
|
||||
if [[ "${CREATE_PREVIEW_RELEASE}" == "true" ]]; then
|
||||
is_preview="true"
|
||||
fi
|
||||
echo "is_preview=${is_preview}" >> "${GITHUB_OUTPUT}"
|
||||
|
||||
is_dry_run="false"
|
||||
if [[ "${DRY_RUN_INPUT}" == "true" ]]; then
|
||||
is_dry_run="true"
|
||||
fi
|
||||
echo "is_dry_run=${is_dry_run}" >> "${GITHUB_OUTPUT}"
|
||||
|
||||
- name: 'Setup Node.js'
|
||||
uses: 'actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020' # ratchet:actions/setup-node@v4
|
||||
with:
|
||||
node-version-file: '.nvmrc'
|
||||
cache: 'npm'
|
||||
cache-dependency-path: 'package-lock.json'
|
||||
|
||||
- name: 'Install Dependencies'
|
||||
env:
|
||||
NPM_CONFIG_PREFER_OFFLINE: 'true'
|
||||
run: |-
|
||||
npm ci
|
||||
|
||||
- name: 'Install VSCE and OVSX'
|
||||
run: |-
|
||||
npm install -g @vscode/vsce
|
||||
npm install -g ovsx
|
||||
|
||||
- name: 'Get the version'
|
||||
id: 'version'
|
||||
working-directory: 'packages/vscode-ide-companion'
|
||||
run: |
|
||||
# Get the base version from package.json regardless of scenario
|
||||
BASE_VERSION=$(node -p "require('./package.json').version")
|
||||
|
||||
if [[ "${IS_PREVIEW}" == "true" ]]; then
|
||||
# Generate preview version with timestamp based on actual package version
|
||||
TIMESTAMP=$(date +%Y%m%d%H%M%S)
|
||||
PREVIEW_VERSION="${BASE_VERSION}-preview.${TIMESTAMP}"
|
||||
RELEASE_TAG="preview.${TIMESTAMP}"
|
||||
|
||||
echo "RELEASE_TAG=${RELEASE_TAG}" >> "$GITHUB_OUTPUT"
|
||||
echo "RELEASE_VERSION=${PREVIEW_VERSION}" >> "$GITHUB_OUTPUT"
|
||||
echo "VSCODE_TAG=preview" >> "$GITHUB_OUTPUT"
|
||||
else
|
||||
# Use specified version or get from package.json
|
||||
if [[ -n "${MANUAL_VERSION}" ]]; then
|
||||
RELEASE_VERSION="${MANUAL_VERSION#v}" # Remove 'v' prefix if present
|
||||
RELEASE_TAG="${MANUAL_VERSION#v}" # Remove 'v' prefix if present
|
||||
else
|
||||
RELEASE_VERSION="${BASE_VERSION}"
|
||||
RELEASE_TAG="${BASE_VERSION}"
|
||||
fi
|
||||
|
||||
echo "RELEASE_TAG=${RELEASE_TAG}" >> "$GITHUB_OUTPUT"
|
||||
echo "RELEASE_VERSION=${RELEASE_VERSION}" >> "$GITHUB_OUTPUT"
|
||||
echo "VSCODE_TAG=latest" >> "$GITHUB_OUTPUT"
|
||||
fi
|
||||
env:
|
||||
IS_PREVIEW: '${{ steps.vars.outputs.is_preview }}'
|
||||
MANUAL_VERSION: '${{ inputs.version }}'
|
||||
|
||||
- name: 'Update package version (for preview releases)'
|
||||
if: '${{ steps.vars.outputs.is_preview == ''true'' }}'
|
||||
working-directory: 'packages/vscode-ide-companion'
|
||||
env:
|
||||
RELEASE_VERSION: '${{ steps.version.outputs.RELEASE_VERSION }}'
|
||||
run: |-
|
||||
# Update package.json with preview version
|
||||
npm version "${RELEASE_VERSION}" --no-git-tag-version --allow-same-version
|
||||
|
||||
- name: 'Run Tests'
|
||||
if: |-
|
||||
${{ github.event.inputs.force_skip_tests != 'true' }}
|
||||
working-directory: 'packages/vscode-ide-companion'
|
||||
run: |
|
||||
npm run test:ci
|
||||
env:
|
||||
OPENAI_API_KEY: '${{ secrets.OPENAI_API_KEY }}'
|
||||
OPENAI_BASE_URL: '${{ secrets.OPENAI_BASE_URL }}'
|
||||
OPENAI_MODEL: '${{ secrets.OPENAI_MODEL }}'
|
||||
|
||||
- name: 'Prepare VSCode Extension'
|
||||
run: |
|
||||
# Build and stage the extension + bundled CLI once.
|
||||
npm --workspace=qwen-code-vscode-ide-companion run prepackage
|
||||
|
||||
- name: 'Package VSIX (dry run)'
|
||||
if: '${{ steps.vars.outputs.is_dry_run == ''true'' }}'
|
||||
working-directory: 'packages/vscode-ide-companion'
|
||||
run: |-
|
||||
if [[ "${{ steps.vars.outputs.is_preview }}" == "true" ]]; then
|
||||
vsce package --pre-release --out ../qwen-code-vscode-companion-${{ steps.version.outputs.RELEASE_VERSION }}.vsix
|
||||
else
|
||||
vsce package --out ../qwen-code-vscode-companion-${{ steps.version.outputs.RELEASE_VERSION }}.vsix
|
||||
fi
|
||||
|
||||
- name: 'Upload VSIX Artifact (dry run)'
|
||||
if: '${{ steps.vars.outputs.is_dry_run == ''true'' }}'
|
||||
uses: 'actions/upload-artifact@v4'
|
||||
with:
|
||||
name: 'qwen-code-vscode-companion-${{ steps.version.outputs.RELEASE_VERSION }}.vsix'
|
||||
path: 'packages/qwen-code-vscode-companion-${{ steps.version.outputs.RELEASE_VERSION }}.vsix'
|
||||
if-no-files-found: 'error'
|
||||
|
||||
- name: 'Publish to Microsoft Marketplace'
|
||||
if: '${{ steps.vars.outputs.is_dry_run == ''false'' }}'
|
||||
working-directory: 'packages/vscode-ide-companion'
|
||||
env:
|
||||
VSCE_PAT: '${{ secrets.VSCE_PAT }}'
|
||||
VSCODE_TAG: '${{ steps.version.outputs.VSCODE_TAG }}'
|
||||
run: |-
|
||||
if [[ "${{ steps.vars.outputs.is_preview }}" == "true" ]]; then
|
||||
echo "Skipping Microsoft Marketplace for preview release"
|
||||
else
|
||||
vsce publish --pat "${VSCE_PAT}" --tag "${VSCODE_TAG}"
|
||||
fi
|
||||
|
||||
- name: 'Publish to OpenVSX'
|
||||
if: '${{ steps.vars.outputs.is_dry_run == ''false'' }}'
|
||||
working-directory: 'packages/vscode-ide-companion'
|
||||
env:
|
||||
OVSX_TOKEN: '${{ secrets.OVSX_TOKEN }}'
|
||||
VSCODE_TAG: '${{ steps.version.outputs.VSCODE_TAG }}'
|
||||
run: |-
|
||||
if [[ "${{ steps.vars.outputs.is_preview }}" == "true" ]]; then
|
||||
# For preview releases, publish with preview tag
|
||||
# First package the extension for preview
|
||||
vsce package --pre-release --out ../qwen-code-vscode-companion-${{ steps.version.outputs.RELEASE_VERSION }}.vsix
|
||||
ovsx publish ../qwen-code-vscode-companion-${{ steps.version.outputs.RELEASE_VERSION }}.vsix --pat "${OVSX_TOKEN}" --pre-release
|
||||
else
|
||||
# Package and publish normally
|
||||
vsce package --out ../qwen-code-vscode-companion-${{ steps.version.outputs.RELEASE_VERSION }}.vsix
|
||||
ovsx publish ../qwen-code-vscode-companion-${{ steps.version.outputs.RELEASE_VERSION }}.vsix --pat "${OVSX_TOKEN}" --tag "${VSCODE_TAG}"
|
||||
fi
|
||||
|
||||
- name: 'Create Issue on Failure'
|
||||
if: |-
|
||||
${{ failure() }}
|
||||
env:
|
||||
GITHUB_TOKEN: '${{ secrets.GITHUB_TOKEN }}'
|
||||
DETAILS_URL: '${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }}'
|
||||
run: |-
|
||||
gh issue create \
|
||||
--title "VSCode IDE Companion Release Failed for ${{ steps.version.outputs.RELEASE_VERSION }} on $(date +'%Y-%m-%d')" \
|
||||
--body "The VSCode IDE Companion release workflow failed. See the full run for details: ${DETAILS_URL}"
|
||||
11
package-lock.json
generated
11
package-lock.json
generated
@@ -17355,7 +17355,6 @@
|
||||
"comment-json": "^4.2.5",
|
||||
"diff": "^7.0.0",
|
||||
"dotenv": "^17.1.0",
|
||||
"extract-zip": "^2.0.1",
|
||||
"fzf": "^0.5.2",
|
||||
"glob": "^10.5.0",
|
||||
"highlight.js": "^11.11.1",
|
||||
@@ -17365,7 +17364,6 @@
|
||||
"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",
|
||||
@@ -17374,7 +17372,6 @@
|
||||
"string-width": "^7.1.0",
|
||||
"strip-ansi": "^7.1.0",
|
||||
"strip-json-comments": "^3.1.1",
|
||||
"tar": "^7.5.2",
|
||||
"undici": "^6.22.0",
|
||||
"update-notifier": "^7.3.1",
|
||||
"wrap-ansi": "9.0.2",
|
||||
@@ -17394,12 +17391,10 @@
|
||||
"@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",
|
||||
"@types/shell-quote": "^1.7.5",
|
||||
"@types/tar": "^6.1.13",
|
||||
"@types/yargs": "^17.0.32",
|
||||
"archiver": "^7.0.1",
|
||||
"ink-testing-library": "^4.0.0",
|
||||
@@ -17987,6 +17982,7 @@
|
||||
"dependencies": {
|
||||
"@anthropic-ai/sdk": "^0.36.1",
|
||||
"@google/genai": "1.30.0",
|
||||
"@iarna/toml": "^2.2.5",
|
||||
"@modelcontextprotocol/sdk": "^1.25.1",
|
||||
"@opentelemetry/api": "^1.9.0",
|
||||
"@opentelemetry/exporter-logs-otlp-grpc": "^0.203.0",
|
||||
@@ -18006,6 +18002,7 @@
|
||||
"chokidar": "^4.0.3",
|
||||
"diff": "^7.0.0",
|
||||
"dotenv": "^17.1.0",
|
||||
"extract-zip": "^2.0.1",
|
||||
"fast-levenshtein": "^2.0.6",
|
||||
"fast-uri": "^3.0.6",
|
||||
"fdir": "^6.4.6",
|
||||
@@ -18022,9 +18019,11 @@
|
||||
"open": "^10.1.2",
|
||||
"openai": "5.11.0",
|
||||
"picomatch": "^4.0.1",
|
||||
"prompts": "^2.4.2",
|
||||
"shell-quote": "^1.8.3",
|
||||
"simple-git": "^3.28.0",
|
||||
"strip-ansi": "^7.1.0",
|
||||
"tar": "^7.5.2",
|
||||
"undici": "^6.22.0",
|
||||
"uuid": "^9.0.1",
|
||||
"ws": "^8.18.0"
|
||||
@@ -18036,6 +18035,8 @@
|
||||
"@types/fast-levenshtein": "^0.0.4",
|
||||
"@types/minimatch": "^5.1.2",
|
||||
"@types/picomatch": "^4.0.1",
|
||||
"@types/prompts": "^2.4.9",
|
||||
"@types/tar": "^6.1.13",
|
||||
"@types/ws": "^8.5.10",
|
||||
"msw": "^2.3.4",
|
||||
"typescript": "^5.3.3",
|
||||
|
||||
@@ -46,8 +46,6 @@
|
||||
"comment-json": "^4.2.5",
|
||||
"diff": "^7.0.0",
|
||||
"dotenv": "^17.1.0",
|
||||
"prompts": "^2.4.2",
|
||||
"extract-zip": "^2.0.1",
|
||||
"fzf": "^0.5.2",
|
||||
"glob": "^10.5.0",
|
||||
"highlight.js": "^11.11.1",
|
||||
@@ -65,7 +63,6 @@
|
||||
"string-width": "^7.1.0",
|
||||
"strip-ansi": "^7.1.0",
|
||||
"strip-json-comments": "^3.1.1",
|
||||
"tar": "^7.5.2",
|
||||
"undici": "^6.22.0",
|
||||
"update-notifier": "^7.3.1",
|
||||
"wrap-ansi": "9.0.2",
|
||||
@@ -81,13 +78,11 @@
|
||||
"@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",
|
||||
"@types/semver": "^7.7.0",
|
||||
"@types/shell-quote": "^1.7.5",
|
||||
"@types/tar": "^6.1.13",
|
||||
"@types/yargs": "^17.0.32",
|
||||
"archiver": "^7.0.1",
|
||||
"ink-testing-library": "^4.0.0",
|
||||
|
||||
@@ -23,6 +23,7 @@ vi.mock('./utils.js', () => ({
|
||||
getLoadedExtensions: mockGetLoadedExtensions,
|
||||
toOutputString: mockToOutputString,
|
||||
}),
|
||||
extensionToOutputString: mockToOutputString,
|
||||
}));
|
||||
|
||||
vi.mock('../../utils/errors.js', () => ({
|
||||
|
||||
@@ -6,7 +6,7 @@
|
||||
|
||||
import type { CommandModule } from 'yargs';
|
||||
import { getErrorMessage } from '../../utils/errors.js';
|
||||
import { getExtensionManager } from './utils.js';
|
||||
import { extensionToOutputString, getExtensionManager } from './utils.js';
|
||||
import { t } from '../../i18n/index.js';
|
||||
|
||||
export async function handleList() {
|
||||
@@ -21,7 +21,7 @@ export async function handleList() {
|
||||
console.log(
|
||||
extensions
|
||||
.map((extension, _): string =>
|
||||
extensionManager.toOutputString(extension, process.cwd()),
|
||||
extensionToOutputString(extension, extensionManager, process.cwd()),
|
||||
)
|
||||
.join('\n\n'),
|
||||
);
|
||||
|
||||
@@ -5,7 +5,8 @@
|
||||
*/
|
||||
|
||||
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
||||
import { getExtensionManager } from './utils.js';
|
||||
import { getExtensionManager, extensionToOutputString } from './utils.js';
|
||||
import type { Extension, ExtensionManager } from '@qwen-code/qwen-code-core';
|
||||
|
||||
const mockRefreshCache = vi.fn();
|
||||
const mockExtensionManagerInstance = {
|
||||
@@ -64,3 +65,70 @@ describe('getExtensionManager', () => {
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe('extensionToOutputString', () => {
|
||||
const mockIsEnabled = vi.fn();
|
||||
const mockExtensionManager = {
|
||||
isEnabled: mockIsEnabled,
|
||||
} as unknown as ExtensionManager;
|
||||
|
||||
const createMockExtension = (overrides = {}): Extension => ({
|
||||
id: 'test-ext-id',
|
||||
name: 'test-extension',
|
||||
version: '1.0.0',
|
||||
isActive: true,
|
||||
path: '/path/to/extension',
|
||||
contextFiles: [],
|
||||
config: { name: 'test-extension', version: '1.0.0' },
|
||||
...overrides,
|
||||
});
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
mockIsEnabled.mockReturnValue(true);
|
||||
});
|
||||
|
||||
it('should include status icon when inline is false', () => {
|
||||
const extension = createMockExtension();
|
||||
const result = extensionToOutputString(
|
||||
extension,
|
||||
mockExtensionManager,
|
||||
'/workspace',
|
||||
false,
|
||||
);
|
||||
|
||||
// Should contain either ✓ or ✗ (with ANSI color codes)
|
||||
expect(result).toMatch(/test-extension/);
|
||||
expect(result).toContain('(1.0.0)');
|
||||
});
|
||||
|
||||
it('should exclude status icon when inline is true', () => {
|
||||
const extension = createMockExtension();
|
||||
const result = extensionToOutputString(
|
||||
extension,
|
||||
mockExtensionManager,
|
||||
'/workspace',
|
||||
true,
|
||||
);
|
||||
|
||||
// Should start with extension name (after stripping potential whitespace)
|
||||
expect(result.trim()).toMatch(/^test-extension/);
|
||||
});
|
||||
|
||||
it('should default inline to false', () => {
|
||||
const extension = createMockExtension();
|
||||
const resultWithoutInline = extensionToOutputString(
|
||||
extension,
|
||||
mockExtensionManager,
|
||||
'/workspace',
|
||||
);
|
||||
const resultWithInlineFalse = extensionToOutputString(
|
||||
extension,
|
||||
mockExtensionManager,
|
||||
'/workspace',
|
||||
false,
|
||||
);
|
||||
|
||||
expect(resultWithoutInline).toEqual(resultWithInlineFalse);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -4,13 +4,15 @@
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
import { ExtensionManager } from '@qwen-code/qwen-code-core';
|
||||
import { ExtensionManager, type Extension } from '@qwen-code/qwen-code-core';
|
||||
import { loadSettings } from '../../config/settings.js';
|
||||
import {
|
||||
requestConsentOrFail,
|
||||
requestConsentNonInteractive,
|
||||
} from './consent.js';
|
||||
import { isWorkspaceTrusted } from '../../config/trustedFolders.js';
|
||||
import * as os from 'node:os';
|
||||
import chalk from 'chalk';
|
||||
|
||||
export async function getExtensionManager(): Promise<ExtensionManager> {
|
||||
const workspaceDir = process.cwd();
|
||||
@@ -25,3 +27,54 @@ export async function getExtensionManager(): Promise<ExtensionManager> {
|
||||
await extensionManager.refreshCache();
|
||||
return extensionManager;
|
||||
}
|
||||
|
||||
export function extensionToOutputString(
|
||||
extension: Extension,
|
||||
extensionManager: ExtensionManager,
|
||||
workspaceDir: string,
|
||||
inline = false,
|
||||
): string {
|
||||
const cwd = workspaceDir;
|
||||
const userEnabled = extensionManager.isEnabled(
|
||||
extension.config.name,
|
||||
os.homedir(),
|
||||
);
|
||||
const workspaceEnabled = extensionManager.isEnabled(
|
||||
extension.config.name,
|
||||
cwd,
|
||||
);
|
||||
|
||||
const status = workspaceEnabled ? chalk.green('✓') : chalk.red('✗');
|
||||
let output = `${inline ? '' : 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.commands && extension.commands.length > 0) {
|
||||
output += `\n Commands:`;
|
||||
extension.commands.forEach((command) => {
|
||||
output += `\n /${command}`;
|
||||
});
|
||||
}
|
||||
if (extension.config.mcpServers) {
|
||||
output += `\n MCP servers:`;
|
||||
Object.keys(extension.config.mcpServers).forEach((key) => {
|
||||
output += `\n ${key}`;
|
||||
});
|
||||
}
|
||||
return output;
|
||||
}
|
||||
|
||||
@@ -777,4 +777,87 @@ describe('extensionsCommand', () => {
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe('detail', () => {
|
||||
const detailAction = extensionsCommand.subCommands?.find(
|
||||
(cmd) => cmd.name === 'detail',
|
||||
)?.action;
|
||||
|
||||
if (!detailAction) {
|
||||
throw new Error('Detail action not found');
|
||||
}
|
||||
|
||||
let realMockExtensionManager: ExtensionManager;
|
||||
|
||||
beforeEach(() => {
|
||||
vi.resetAllMocks();
|
||||
realMockExtensionManager = Object.create(ExtensionManager.prototype);
|
||||
realMockExtensionManager.getLoadedExtensions = mockGetLoadedExtensions;
|
||||
|
||||
mockContext = createMockCommandContext({
|
||||
invocation: {
|
||||
raw: '/extensions detail',
|
||||
name: 'detail',
|
||||
args: '',
|
||||
},
|
||||
services: {
|
||||
config: {
|
||||
getExtensions: mockGetExtensions,
|
||||
getWorkingDir: () => '/test/dir',
|
||||
getExtensionManager: () => realMockExtensionManager,
|
||||
},
|
||||
},
|
||||
ui: {
|
||||
dispatchExtensionStateUpdate: vi.fn(),
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
it('should show usage if no name is provided', async () => {
|
||||
await detailAction(mockContext, '');
|
||||
expect(mockContext.ui.addItem).toHaveBeenCalledWith(
|
||||
{
|
||||
type: MessageType.ERROR,
|
||||
text: 'Usage: /extensions detail <extension-name>',
|
||||
},
|
||||
expect.any(Number),
|
||||
);
|
||||
});
|
||||
|
||||
it('should show error if extension not found', async () => {
|
||||
mockGetExtensions.mockReturnValue([]);
|
||||
await detailAction(mockContext, 'nonexistent-extension');
|
||||
expect(mockContext.ui.addItem).toHaveBeenCalledWith(
|
||||
{
|
||||
type: MessageType.ERROR,
|
||||
text: 'Extension "nonexistent-extension" not found.',
|
||||
},
|
||||
expect.any(Number),
|
||||
);
|
||||
});
|
||||
|
||||
it('should show extension details when found', async () => {
|
||||
const extension: Extension = {
|
||||
id: 'test-ext',
|
||||
name: 'test-ext',
|
||||
version: '1.0.0',
|
||||
isActive: true,
|
||||
path: '/test/dir/test-ext',
|
||||
contextFiles: [],
|
||||
config: { name: 'test-ext', version: '1.0.0' },
|
||||
};
|
||||
mockGetExtensions.mockReturnValue([extension]);
|
||||
realMockExtensionManager.isEnabled = vi.fn().mockReturnValue(true);
|
||||
|
||||
await detailAction(mockContext, 'test-ext');
|
||||
|
||||
expect(mockContext.ui.addItem).toHaveBeenCalledWith(
|
||||
{
|
||||
type: MessageType.INFO,
|
||||
text: expect.stringContaining('test-ext'),
|
||||
},
|
||||
expect.any(Number),
|
||||
);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -20,6 +20,7 @@ import {
|
||||
} from '@qwen-code/qwen-code-core';
|
||||
import { SettingScope } from '../../config/settings.js';
|
||||
import open from 'open';
|
||||
import { extensionToOutputString } from '../../commands/extensions/utils.js';
|
||||
|
||||
const EXTENSION_EXPLORE_URL = {
|
||||
Gemini: 'https://geminicli.com/extensions/',
|
||||
@@ -475,6 +476,53 @@ async function enableAction(context: CommandContext, args: string) {
|
||||
}
|
||||
}
|
||||
|
||||
async function detailAction(context: CommandContext, args: string) {
|
||||
const extensionManager = context.services.config?.getExtensionManager();
|
||||
if (!(extensionManager instanceof ExtensionManager)) {
|
||||
console.error(
|
||||
`Cannot ${context.invocation?.name} extensions in this environment`,
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
const name = args.trim();
|
||||
if (!name) {
|
||||
context.ui.addItem(
|
||||
{
|
||||
type: MessageType.ERROR,
|
||||
text: t('Usage: /extensions detail <extension-name>'),
|
||||
},
|
||||
Date.now(),
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
const extensions = context.services.config!.getExtensions();
|
||||
const extension = extensions.find((extension) => extension.name === name);
|
||||
if (!extension) {
|
||||
context.ui.addItem(
|
||||
{
|
||||
type: MessageType.ERROR,
|
||||
text: t('Extension "{{name}}" not found.', { name }),
|
||||
},
|
||||
Date.now(),
|
||||
);
|
||||
return;
|
||||
}
|
||||
context.ui.addItem(
|
||||
{
|
||||
type: MessageType.INFO,
|
||||
text: extensionToOutputString(
|
||||
extension,
|
||||
extensionManager,
|
||||
process.cwd(),
|
||||
true,
|
||||
),
|
||||
},
|
||||
Date.now(),
|
||||
);
|
||||
}
|
||||
|
||||
export async function completeExtensions(
|
||||
context: CommandContext,
|
||||
partialArg: string,
|
||||
@@ -495,7 +543,10 @@ export async function completeExtensions(
|
||||
name.startsWith(partialArg),
|
||||
);
|
||||
|
||||
if (context.invocation?.name !== 'uninstall') {
|
||||
if (
|
||||
context.invocation?.name !== 'uninstall' &&
|
||||
context.invocation?.name !== 'detail'
|
||||
) {
|
||||
if ('--all'.startsWith(partialArg) || 'all'.startsWith(partialArg)) {
|
||||
suggestions.unshift('--all');
|
||||
}
|
||||
@@ -594,6 +645,16 @@ const uninstallCommand: SlashCommand = {
|
||||
completion: completeExtensions,
|
||||
};
|
||||
|
||||
const detailCommand: SlashCommand = {
|
||||
name: 'detail',
|
||||
get description() {
|
||||
return t('Get detail of an extension');
|
||||
},
|
||||
kind: CommandKind.BUILT_IN,
|
||||
action: detailAction,
|
||||
completion: completeExtensions,
|
||||
};
|
||||
|
||||
export const extensionsCommand: SlashCommand = {
|
||||
name: 'extensions',
|
||||
get description() {
|
||||
@@ -608,6 +669,7 @@ export const extensionsCommand: SlashCommand = {
|
||||
installCommand,
|
||||
uninstallCommand,
|
||||
exploreExtensionsCommand,
|
||||
detailCommand,
|
||||
],
|
||||
action: (context, args) =>
|
||||
// Default to list if no subcommand is provided
|
||||
|
||||
@@ -37,6 +37,7 @@
|
||||
"@opentelemetry/sdk-node": "^0.203.0",
|
||||
"@types/html-to-text": "^9.0.4",
|
||||
"@xterm/headless": "5.5.0",
|
||||
"@iarna/toml": "^2.2.5",
|
||||
"ajv": "^8.17.1",
|
||||
"ajv-formats": "^3.0.0",
|
||||
"async-mutex": "^0.5.0",
|
||||
@@ -59,10 +60,13 @@
|
||||
"mnemonist": "^0.40.3",
|
||||
"open": "^10.1.2",
|
||||
"openai": "5.11.0",
|
||||
"prompts": "^2.4.2",
|
||||
"picomatch": "^4.0.1",
|
||||
"shell-quote": "^1.8.3",
|
||||
"simple-git": "^3.28.0",
|
||||
"strip-ansi": "^7.1.0",
|
||||
"tar": "^7.5.2",
|
||||
"extract-zip": "^2.0.1",
|
||||
"undici": "^6.22.0",
|
||||
"uuid": "^9.0.1",
|
||||
"ws": "^8.18.0"
|
||||
@@ -84,6 +88,8 @@
|
||||
"@types/minimatch": "^5.1.2",
|
||||
"@types/picomatch": "^4.0.1",
|
||||
"@types/ws": "^8.5.10",
|
||||
"@types/tar": "^6.1.13",
|
||||
"@types/prompts": "^2.4.9",
|
||||
"msw": "^2.3.4",
|
||||
"typescript": "^5.3.3",
|
||||
"vitest": "^3.1.1"
|
||||
|
||||
@@ -218,6 +218,30 @@ describe('extension tests', () => {
|
||||
]);
|
||||
});
|
||||
|
||||
it('should use default QWEN.md when contextFileName is empty array', async () => {
|
||||
const extDir = path.join(userExtensionsDir, 'ext-empty-context');
|
||||
fs.mkdirSync(extDir, { recursive: true });
|
||||
fs.writeFileSync(
|
||||
path.join(extDir, EXTENSIONS_CONFIG_FILENAME),
|
||||
JSON.stringify({
|
||||
name: 'ext-empty-context',
|
||||
version: '1.0.0',
|
||||
contextFileName: [],
|
||||
}),
|
||||
);
|
||||
fs.writeFileSync(path.join(extDir, 'QWEN.md'), 'context content');
|
||||
|
||||
const manager = createExtensionManager();
|
||||
await manager.refreshCache();
|
||||
const extensions = manager.getLoadedExtensions();
|
||||
|
||||
expect(extensions).toHaveLength(1);
|
||||
const ext = extensions.find((e) => e.config.name === 'ext-empty-context');
|
||||
expect(ext?.contextFiles).toEqual([
|
||||
path.join(userExtensionsDir, 'ext-empty-context', 'QWEN.md'),
|
||||
]);
|
||||
});
|
||||
|
||||
it('should skip extensions with invalid JSON and log a warning', async () => {
|
||||
const consoleSpy = vi
|
||||
.spyOn(console, 'error')
|
||||
@@ -694,13 +718,14 @@ describe('extension tests', () => {
|
||||
expect(() => validateName('UPPERCASE')).not.toThrow();
|
||||
});
|
||||
|
||||
it('should accept names with underscores and dots', () => {
|
||||
expect(() => validateName('my_extension')).not.toThrow();
|
||||
expect(() => validateName('my.extension')).not.toThrow();
|
||||
expect(() => validateName('my_ext.v1')).not.toThrow();
|
||||
expect(() => validateName('ext_1.2.3')).not.toThrow();
|
||||
});
|
||||
|
||||
it('should reject names with invalid characters', () => {
|
||||
expect(() => validateName('my_extension')).toThrow(
|
||||
'Invalid extension name',
|
||||
);
|
||||
expect(() => validateName('my.extension')).toThrow(
|
||||
'Invalid extension name',
|
||||
);
|
||||
expect(() => validateName('my extension')).toThrow(
|
||||
'Invalid extension name',
|
||||
);
|
||||
|
||||
@@ -36,7 +36,6 @@ import {
|
||||
} from './github.js';
|
||||
import type { LoadExtensionContext } from './variableSchema.js';
|
||||
import { Override, type AllExtensionsEnablementConfig } from './override.js';
|
||||
import chalk from 'chalk';
|
||||
import { parseMarketplaceSource } from './marketplace.js';
|
||||
import {
|
||||
isGeminiExtensionConfig,
|
||||
@@ -191,7 +190,7 @@ function filterMcpConfig(original: MCPServerConfig): MCPServerConfig {
|
||||
}
|
||||
|
||||
function getContextFileNames(config: ExtensionConfig): string[] {
|
||||
if (!config.contextFileName) {
|
||||
if (!config.contextFileName || config.contextFileName.length === 0) {
|
||||
return ['QWEN.md'];
|
||||
} else if (!Array.isArray(config.contextFileName)) {
|
||||
return [config.contextFileName];
|
||||
@@ -1056,49 +1055,6 @@ export class ExtensionManager {
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Converts an extension to output string.
|
||||
*/
|
||||
toOutputString(extension: Extension, workspaceDir?: string): string {
|
||||
const cwd = workspaceDir ?? this.workspaceDir;
|
||||
const userEnabled = this.isEnabled(extension.config.name, os.homedir());
|
||||
const workspaceEnabled = this.isEnabled(extension.config.name, cwd);
|
||||
|
||||
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.commands && extension.commands.length > 0) {
|
||||
output += `\n Commands:`;
|
||||
extension.commands.forEach((command) => {
|
||||
output += `\n /${command}`;
|
||||
});
|
||||
}
|
||||
if (extension.config.mcpServers) {
|
||||
output += `\n MCP servers:`;
|
||||
Object.keys(extension.config.mcpServers).forEach((key) => {
|
||||
output += `\n ${key}`;
|
||||
});
|
||||
}
|
||||
return output;
|
||||
}
|
||||
|
||||
async performWorkspaceExtensionMigration(
|
||||
extensions: Extension[],
|
||||
requestConsent: (options?: ExtensionRequestOptions) => Promise<void>,
|
||||
@@ -1288,9 +1244,9 @@ export function hashValue(value: string): string {
|
||||
}
|
||||
|
||||
export function validateName(name: string) {
|
||||
if (!/^[a-zA-Z0-9-]+$/.test(name)) {
|
||||
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.`,
|
||||
`Invalid extension name: "${name}". Only letters (a-z, A-Z), numbers (0-9), underscores (_), dots (.), and dashes (-) are allowed.`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -117,6 +117,51 @@ describe('git extension helpers', () => {
|
||||
'Failed to clone Git repository from http://my-repo.com',
|
||||
);
|
||||
});
|
||||
|
||||
it('should use marketplace source for marketplace type extensions', async () => {
|
||||
const installMetadata = {
|
||||
source: 'marketplace:my-plugin',
|
||||
type: 'marketplace' as const,
|
||||
marketplace: {
|
||||
pluginName: 'my-plugin',
|
||||
marketplaceSource: 'https://github.com/marketplace/my-plugin',
|
||||
},
|
||||
};
|
||||
const destination = '/dest';
|
||||
mockGit.getRemotes.mockResolvedValue([
|
||||
{
|
||||
name: 'origin',
|
||||
refs: { fetch: 'https://github.com/marketplace/my-plugin' },
|
||||
},
|
||||
]);
|
||||
|
||||
await cloneFromGit(installMetadata, destination);
|
||||
|
||||
expect(mockGit.clone).toHaveBeenCalledWith(
|
||||
'https://github.com/marketplace/my-plugin',
|
||||
'./',
|
||||
['--depth', '1'],
|
||||
);
|
||||
});
|
||||
|
||||
it('should use source for marketplace type without marketplace metadata', async () => {
|
||||
const installMetadata = {
|
||||
source: 'http://fallback-repo.com',
|
||||
type: 'marketplace' as const,
|
||||
};
|
||||
const destination = '/dest';
|
||||
mockGit.getRemotes.mockResolvedValue([
|
||||
{ name: 'origin', refs: { fetch: 'http://fallback-repo.com' } },
|
||||
]);
|
||||
|
||||
await cloneFromGit(installMetadata, destination);
|
||||
|
||||
expect(mockGit.clone).toHaveBeenCalledWith(
|
||||
'http://fallback-repo.com',
|
||||
'./',
|
||||
['--depth', '1'],
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe('checkForExtensionUpdate', () => {
|
||||
|
||||
@@ -53,7 +53,10 @@ export async function cloneFromGit(
|
||||
): Promise<void> {
|
||||
try {
|
||||
const git = simpleGit(destination);
|
||||
let sourceUrl = installMetadata.source;
|
||||
let sourceUrl =
|
||||
installMetadata.type === 'marketplace' && installMetadata.marketplace
|
||||
? installMetadata.marketplace.marketplaceSource
|
||||
: installMetadata.source;
|
||||
const token = getGitHubToken();
|
||||
if (token) {
|
||||
try {
|
||||
|
||||
Reference in New Issue
Block a user