mirror of
https://github.com/QwenLM/qwen-code.git
synced 2025-12-22 17:57:46 +00:00
Merge branch 'main' into feat/gemini-3-integration
This commit is contained in:
12
.github/workflows/release-sdk.yml
vendored
12
.github/workflows/release-sdk.yml
vendored
@@ -121,6 +121,11 @@ jobs:
|
||||
IS_PREVIEW: '${{ steps.vars.outputs.is_preview }}'
|
||||
MANUAL_VERSION: '${{ inputs.version }}'
|
||||
|
||||
- name: 'Build CLI Bundle'
|
||||
run: |
|
||||
npm run build
|
||||
npm run bundle
|
||||
|
||||
- name: 'Run Tests'
|
||||
if: |-
|
||||
${{ github.event.inputs.force_skip_tests != 'true' }}
|
||||
@@ -132,13 +137,6 @@ jobs:
|
||||
OPENAI_BASE_URL: '${{ secrets.OPENAI_BASE_URL }}'
|
||||
OPENAI_MODEL: '${{ secrets.OPENAI_MODEL }}'
|
||||
|
||||
- name: 'Build CLI for Integration Tests'
|
||||
if: |-
|
||||
${{ github.event.inputs.force_skip_tests != 'true' }}
|
||||
run: |
|
||||
npm run build
|
||||
npm run bundle
|
||||
|
||||
- name: 'Run SDK Integration Tests'
|
||||
if: |-
|
||||
${{ github.event.inputs.force_skip_tests != 'true' }}
|
||||
|
||||
4
.github/workflows/release.yml
vendored
4
.github/workflows/release.yml
vendored
@@ -133,8 +133,8 @@ jobs:
|
||||
${{ github.event.inputs.force_skip_tests != 'true' }}
|
||||
run: |
|
||||
npm run preflight
|
||||
npm run test:integration:sandbox:none
|
||||
npm run test:integration:sandbox:docker
|
||||
npm run test:integration:cli:sandbox:none
|
||||
npm run test:integration:cli:sandbox:docker
|
||||
env:
|
||||
OPENAI_API_KEY: '${{ secrets.OPENAI_API_KEY }}'
|
||||
OPENAI_BASE_URL: '${{ secrets.OPENAI_BASE_URL }}'
|
||||
|
||||
@@ -5,6 +5,7 @@ Welcome to the Qwen Code documentation. Qwen Code is an agentic coding tool that
|
||||
## Documentation Sections
|
||||
|
||||
### [User Guide](./users/overview)
|
||||
|
||||
Learn how to use Qwen Code as an end user. This section covers:
|
||||
|
||||
- Basic installation and setup
|
||||
|
||||
@@ -358,7 +358,7 @@ Arguments passed directly when running the CLI can override other configurations
|
||||
### Command-Line Arguments Table
|
||||
|
||||
| Argument | Alias | Description | Possible Values | Notes |
|
||||
| ---------------------------- | ----- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
||||
| ---------------------------- | ----- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
||||
| `--model` | `-m` | Specifies the Qwen model to use for this session. | Model name | Example: `npm start -- --model qwen3-coder-plus` |
|
||||
| `--prompt` | `-p` | Used to pass a prompt directly to the command. This invokes Qwen Code in a non-interactive mode. | Your prompt text | For scripting examples, use the `--output-format json` flag to get structured output. |
|
||||
| `--prompt-interactive` | `-i` | Starts an interactive session with the provided prompt as the initial input. | Your prompt text | The prompt is processed within the interactive session, not before it. Cannot be used when piping input from stdin. Example: `qwen -i "explain this code"` |
|
||||
|
||||
@@ -16,16 +16,15 @@ The plugin **MUST** run a local HTTP server that implements the **Model Context
|
||||
- **Endpoint:** The server should expose a single endpoint (e.g., `/mcp`) for all MCP communication.
|
||||
- **Port:** The server **MUST** listen on a dynamically assigned port (i.e., listen on port `0`).
|
||||
|
||||
### 2. Discovery Mechanism: The Port File
|
||||
### 2. Discovery Mechanism: The Lock File
|
||||
|
||||
For Qwen Code to connect, it needs to discover which IDE instance it's running in and what port your server is using. The plugin **MUST** facilitate this by creating a "discovery file."
|
||||
For Qwen Code to connect, it needs to discover what port your server is using. The plugin **MUST** facilitate this by creating a "lock file" and setting the port environment variable.
|
||||
|
||||
- **How the CLI Finds the File:** The CLI determines the Process ID (PID) of the IDE it's running in by traversing the process tree. It then looks for a discovery file that contains this PID in its name.
|
||||
- **File Location:** The file must be created in a specific directory: `os.tmpdir()/qwen/ide/`. Your plugin must create this directory if it doesn't exist.
|
||||
- **How the CLI Finds the File:** The CLI reads the port from `QWEN_CODE_IDE_SERVER_PORT`, then reads `~/.qwen/ide/<PORT>.lock`. (Legacy fallbacks exist for older extensions; see note below.)
|
||||
- **File Location:** The file must be created in a specific directory: `~/.qwen/ide/`. Your plugin must create this directory if it doesn't exist.
|
||||
- **File Naming Convention:** The filename is critical and **MUST** follow the pattern:
|
||||
`qwen-code-ide-server-${PID}-${PORT}.json`
|
||||
- `${PID}`: The process ID of the parent IDE process. Your plugin must determine this PID and include it in the filename.
|
||||
- `${PORT}`: The port your MCP server is listening on.
|
||||
`<PORT>.lock`
|
||||
- `<PORT>`: The port your MCP server is listening on.
|
||||
- **File Content & Workspace Validation:** The file **MUST** contain a JSON object with the following structure:
|
||||
|
||||
```json
|
||||
@@ -33,21 +32,20 @@ For Qwen Code to connect, it needs to discover which IDE instance it's running i
|
||||
"port": 12345,
|
||||
"workspacePath": "/path/to/project1:/path/to/project2",
|
||||
"authToken": "a-very-secret-token",
|
||||
"ideInfo": {
|
||||
"name": "vscode",
|
||||
"displayName": "VS Code"
|
||||
}
|
||||
"ppid": 1234,
|
||||
"ideName": "VS Code"
|
||||
}
|
||||
```
|
||||
- `port` (number, required): The port of the MCP server.
|
||||
- `workspacePath` (string, required): A list of all open workspace root paths, delimited by the OS-specific path separator (`:` for Linux/macOS, `;` for Windows). The CLI uses this path to ensure it's running in the same project folder that's open in the IDE. If the CLI's current working directory is not a sub-directory of `workspacePath`, the connection will be rejected. Your plugin **MUST** provide the correct, absolute path(s) to the root of the open workspace(s).
|
||||
- `authToken` (string, required): A secret token for securing the connection. The CLI will include this token in an `Authorization: Bearer <token>` header on all requests.
|
||||
- `ideInfo` (object, required): Information about the IDE.
|
||||
- `name` (string, required): A short, lowercase identifier for the IDE (e.g., `vscode`, `jetbrains`).
|
||||
- `displayName` (string, required): A user-friendly name for the IDE (e.g., `VS Code`, `JetBrains IDE`).
|
||||
- `ppid` (number, required): The parent process ID of the IDE process.
|
||||
- `ideName` (string, required): A user-friendly name for the IDE (e.g., `VS Code`, `JetBrains IDE`).
|
||||
|
||||
- **Authentication:** To secure the connection, the plugin **MUST** generate a unique, secret token and include it in the discovery file. The CLI will then include this token in the `Authorization` header for all requests to the MCP server (e.g., `Authorization: Bearer a-very-secret-token`). Your server **MUST** validate this token on every request and reject any that are unauthorized.
|
||||
- **Tie-Breaking with Environment Variables (Recommended):** For the most reliable experience, your plugin **SHOULD** both create the discovery file and set the `QWEN_CODE_IDE_SERVER_PORT` environment variable in the integrated terminal. The file serves as the primary discovery mechanism, but the environment variable is crucial for tie-breaking. If a user has multiple IDE windows open for the same workspace, the CLI uses the `QWEN_CODE_IDE_SERVER_PORT` variable to identify and connect to the correct window's server.
|
||||
- **Environment Variables (Required):** Your plugin **MUST** set `QWEN_CODE_IDE_SERVER_PORT` in the integrated terminal so the CLI can locate the correct `<PORT>.lock` file.
|
||||
|
||||
**Legacy note:** For extensions older than v0.5.1, Qwen Code may fall back to reading JSON files in the system temp directory named `qwen-code-ide-server-<PID>.json` or `qwen-code-ide-server-<PORT>.json`. New integrations should not rely on these legacy files.
|
||||
|
||||
## II. The Context Interface
|
||||
|
||||
|
||||
42
package-lock.json
generated
42
package-lock.json
generated
@@ -1,12 +1,12 @@
|
||||
{
|
||||
"name": "@qwen-code/qwen-code",
|
||||
"version": "0.5.1",
|
||||
"version": "0.6.0",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "@qwen-code/qwen-code",
|
||||
"version": "0.5.1",
|
||||
"version": "0.6.0",
|
||||
"workspaces": [
|
||||
"packages/*"
|
||||
],
|
||||
@@ -16085,6 +16085,7 @@
|
||||
"version": "7.15.0",
|
||||
"resolved": "https://registry.npmjs.org/undici/-/undici-7.15.0.tgz",
|
||||
"integrity": "sha512-7oZJCPvvMvTd0OlqWsIxTuItTpJBpU1tcbVl24FMn3xt3+VSunwUasmfPJRE57oNO1KsZ4PgA1xTdAX4hq8NyQ==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=20.18.1"
|
||||
@@ -17193,7 +17194,7 @@
|
||||
},
|
||||
"packages/cli": {
|
||||
"name": "@qwen-code/qwen-code",
|
||||
"version": "0.5.1",
|
||||
"version": "0.6.0",
|
||||
"dependencies": {
|
||||
"@google/genai": "1.30.0",
|
||||
"@iarna/toml": "^2.2.5",
|
||||
@@ -17224,7 +17225,7 @@
|
||||
"strip-ansi": "^7.1.0",
|
||||
"strip-json-comments": "^3.1.1",
|
||||
"tar": "^7.5.2",
|
||||
"undici": "^7.10.0",
|
||||
"undici": "^6.22.0",
|
||||
"update-notifier": "^7.3.1",
|
||||
"wrap-ansi": "9.0.2",
|
||||
"yargs": "^17.7.2",
|
||||
@@ -17819,9 +17820,18 @@
|
||||
"node": ">= 0.6"
|
||||
}
|
||||
},
|
||||
"packages/cli/node_modules/undici": {
|
||||
"version": "6.22.0",
|
||||
"resolved": "https://registry.npmjs.org/undici/-/undici-6.22.0.tgz",
|
||||
"integrity": "sha512-hU/10obOIu62MGYjdskASR3CUAiYaFTtC9Pa6vHyf//mAipSvSQg6od2CnJswq7fvzNS3zJhxoRkgNVaHurWKw==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=18.17"
|
||||
}
|
||||
},
|
||||
"packages/core": {
|
||||
"name": "@qwen-code/qwen-code-core",
|
||||
"version": "0.5.1",
|
||||
"version": "0.6.0",
|
||||
"hasInstallScript": true,
|
||||
"dependencies": {
|
||||
"@google/genai": "1.30.0",
|
||||
@@ -17863,7 +17873,7 @@
|
||||
"simple-git": "^3.28.0",
|
||||
"strip-ansi": "^7.1.0",
|
||||
"tiktoken": "^1.0.21",
|
||||
"undici": "^7.10.0",
|
||||
"undici": "^6.22.0",
|
||||
"uuid": "^9.0.1",
|
||||
"ws": "^8.18.0"
|
||||
},
|
||||
@@ -18440,6 +18450,15 @@
|
||||
"node": ">= 0.6"
|
||||
}
|
||||
},
|
||||
"packages/core/node_modules/undici": {
|
||||
"version": "6.22.0",
|
||||
"resolved": "https://registry.npmjs.org/undici/-/undici-6.22.0.tgz",
|
||||
"integrity": "sha512-hU/10obOIu62MGYjdskASR3CUAiYaFTtC9Pa6vHyf//mAipSvSQg6od2CnJswq7fvzNS3zJhxoRkgNVaHurWKw==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=18.17"
|
||||
}
|
||||
},
|
||||
"packages/core/node_modules/zod-to-json-schema": {
|
||||
"version": "3.25.0",
|
||||
"resolved": "https://registry.npmjs.org/zod-to-json-schema/-/zod-to-json-schema-3.25.0.tgz",
|
||||
@@ -18451,10 +18470,11 @@
|
||||
},
|
||||
"packages/sdk-typescript": {
|
||||
"name": "@qwen-code/sdk",
|
||||
"version": "0.5.1",
|
||||
"version": "0.6.0",
|
||||
"license": "Apache-2.0",
|
||||
"dependencies": {
|
||||
"@modelcontextprotocol/sdk": "^1.25.1",
|
||||
"tiktoken": "^1.0.21",
|
||||
"zod": "^3.25.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
@@ -21270,7 +21290,7 @@
|
||||
},
|
||||
"packages/test-utils": {
|
||||
"name": "@qwen-code/qwen-code-test-utils",
|
||||
"version": "0.5.1",
|
||||
"version": "0.6.0",
|
||||
"dev": true,
|
||||
"license": "Apache-2.0",
|
||||
"devDependencies": {
|
||||
@@ -21282,7 +21302,7 @@
|
||||
},
|
||||
"packages/vscode-ide-companion": {
|
||||
"name": "qwen-code-vscode-ide-companion",
|
||||
"version": "0.5.1",
|
||||
"version": "0.6.0",
|
||||
"license": "LICENSE",
|
||||
"dependencies": {
|
||||
"@modelcontextprotocol/sdk": "^1.25.1",
|
||||
@@ -21302,7 +21322,7 @@
|
||||
"@types/react": "^19.1.8",
|
||||
"@types/react-dom": "^19.1.6",
|
||||
"@types/semver": "^7.7.1",
|
||||
"@types/vscode": "^1.99.0",
|
||||
"@types/vscode": "^1.85.0",
|
||||
"@typescript-eslint/eslint-plugin": "^8.31.1",
|
||||
"@typescript-eslint/parser": "^8.31.1",
|
||||
"@vscode/vsce": "^3.6.0",
|
||||
@@ -21317,7 +21337,7 @@
|
||||
"vitest": "^3.2.4"
|
||||
},
|
||||
"engines": {
|
||||
"vscode": "^1.99.0"
|
||||
"vscode": "^1.85.0"
|
||||
}
|
||||
},
|
||||
"packages/vscode-ide-companion/node_modules/@modelcontextprotocol/sdk": {
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@qwen-code/qwen-code",
|
||||
"version": "0.5.1",
|
||||
"version": "0.6.0",
|
||||
"engines": {
|
||||
"node": ">=20.0.0"
|
||||
},
|
||||
@@ -13,7 +13,7 @@
|
||||
"url": "git+https://github.com/QwenLM/qwen-code.git"
|
||||
},
|
||||
"config": {
|
||||
"sandboxImageUri": "ghcr.io/qwenlm/qwen-code:0.5.1"
|
||||
"sandboxImageUri": "ghcr.io/qwenlm/qwen-code:0.6.0"
|
||||
},
|
||||
"scripts": {
|
||||
"start": "cross-env node scripts/start.js",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@qwen-code/qwen-code",
|
||||
"version": "0.5.1",
|
||||
"version": "0.6.0",
|
||||
"description": "Qwen Code",
|
||||
"repository": {
|
||||
"type": "git",
|
||||
@@ -33,7 +33,7 @@
|
||||
"dist"
|
||||
],
|
||||
"config": {
|
||||
"sandboxImageUri": "ghcr.io/qwenlm/qwen-code:0.5.1"
|
||||
"sandboxImageUri": "ghcr.io/qwenlm/qwen-code:0.6.0"
|
||||
},
|
||||
"dependencies": {
|
||||
"@google/genai": "1.30.0",
|
||||
@@ -64,7 +64,7 @@
|
||||
"strip-ansi": "^7.1.0",
|
||||
"strip-json-comments": "^3.1.1",
|
||||
"tar": "^7.5.2",
|
||||
"undici": "^7.10.0",
|
||||
"undici": "^6.22.0",
|
||||
"extract-zip": "^2.0.1",
|
||||
"update-notifier": "^7.3.1",
|
||||
"wrap-ansi": "9.0.2",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@qwen-code/qwen-code-core",
|
||||
"version": "0.5.1",
|
||||
"version": "0.6.0",
|
||||
"description": "Qwen Code Core",
|
||||
"repository": {
|
||||
"type": "git",
|
||||
@@ -62,7 +62,7 @@
|
||||
"simple-git": "^3.28.0",
|
||||
"strip-ansi": "^7.1.0",
|
||||
"tiktoken": "^1.0.21",
|
||||
"undici": "^7.10.0",
|
||||
"undici": "^6.22.0",
|
||||
"uuid": "^9.0.1",
|
||||
"ws": "^8.18.0"
|
||||
},
|
||||
|
||||
@@ -15,6 +15,7 @@ export const OAUTH_FILE = 'oauth_creds.json';
|
||||
const TMP_DIR_NAME = 'tmp';
|
||||
const BIN_DIR_NAME = 'bin';
|
||||
const PROJECT_DIR_NAME = 'projects';
|
||||
const IDE_DIR_NAME = 'ide';
|
||||
|
||||
export class Storage {
|
||||
private readonly targetDir: string;
|
||||
@@ -59,6 +60,10 @@ export class Storage {
|
||||
return path.join(Storage.getGlobalQwenDir(), TMP_DIR_NAME);
|
||||
}
|
||||
|
||||
static getGlobalIdeDir(): string {
|
||||
return path.join(Storage.getGlobalQwenDir(), IDE_DIR_NAME);
|
||||
}
|
||||
|
||||
static getGlobalBinDir(): string {
|
||||
return path.join(Storage.getGlobalQwenDir(), BIN_DIR_NAME);
|
||||
}
|
||||
|
||||
@@ -32,6 +32,7 @@ vi.mock('node:fs', async (importOriginal) => {
|
||||
...actual.promises,
|
||||
readFile: vi.fn(),
|
||||
readdir: vi.fn(),
|
||||
stat: vi.fn(),
|
||||
},
|
||||
realpathSync: (p: string) => p,
|
||||
existsSync: () => false,
|
||||
@@ -68,6 +69,7 @@ describe('IdeClient', () => {
|
||||
command: 'test-ide',
|
||||
});
|
||||
vi.mocked(os.tmpdir).mockReturnValue('/tmp');
|
||||
vi.mocked(os.homedir).mockReturnValue('/home/test');
|
||||
|
||||
// Mock MCP client and transports
|
||||
mockClient = {
|
||||
@@ -97,19 +99,15 @@ describe('IdeClient', () => {
|
||||
|
||||
describe('connect', () => {
|
||||
it('should connect using HTTP when port is provided in config file', async () => {
|
||||
process.env['QWEN_CODE_IDE_SERVER_PORT'] = '8080';
|
||||
const config = { port: '8080' };
|
||||
vi.mocked(fs.promises.readFile).mockResolvedValue(JSON.stringify(config));
|
||||
(
|
||||
vi.mocked(fs.promises.readdir) as Mock<
|
||||
(path: fs.PathLike) => Promise<string[]>
|
||||
>
|
||||
).mockResolvedValue([]);
|
||||
|
||||
const ideClient = await IdeClient.getInstance();
|
||||
await ideClient.connect();
|
||||
|
||||
expect(fs.promises.readFile).toHaveBeenCalledWith(
|
||||
path.join('/tmp', 'qwen-code-ide-server-12345.json'),
|
||||
path.join('/home/test', '.qwen', 'ide', '8080.lock'),
|
||||
'utf8',
|
||||
);
|
||||
expect(StreamableHTTPClientTransport).toHaveBeenCalledWith(
|
||||
@@ -120,16 +118,13 @@ describe('IdeClient', () => {
|
||||
expect(ideClient.getConnectionStatus().status).toBe(
|
||||
IDEConnectionStatus.Connected,
|
||||
);
|
||||
delete process.env['QWEN_CODE_IDE_SERVER_PORT'];
|
||||
});
|
||||
|
||||
it('should connect using stdio when stdio config is provided in file', async () => {
|
||||
process.env['QWEN_CODE_IDE_SERVER_PORT'] = '8080';
|
||||
const config = { stdio: { command: 'test-cmd', args: ['--foo'] } };
|
||||
vi.mocked(fs.promises.readFile).mockResolvedValue(JSON.stringify(config));
|
||||
(
|
||||
vi.mocked(fs.promises.readdir) as Mock<
|
||||
(path: fs.PathLike) => Promise<string[]>
|
||||
>
|
||||
).mockResolvedValue([]);
|
||||
|
||||
const ideClient = await IdeClient.getInstance();
|
||||
await ideClient.connect();
|
||||
@@ -142,19 +137,16 @@ describe('IdeClient', () => {
|
||||
expect(ideClient.getConnectionStatus().status).toBe(
|
||||
IDEConnectionStatus.Connected,
|
||||
);
|
||||
delete process.env['QWEN_CODE_IDE_SERVER_PORT'];
|
||||
});
|
||||
|
||||
it('should prioritize port over stdio when both are in config file', async () => {
|
||||
process.env['QWEN_CODE_IDE_SERVER_PORT'] = '8080';
|
||||
const config = {
|
||||
port: '8080',
|
||||
stdio: { command: 'test-cmd', args: ['--foo'] },
|
||||
};
|
||||
vi.mocked(fs.promises.readFile).mockResolvedValue(JSON.stringify(config));
|
||||
(
|
||||
vi.mocked(fs.promises.readdir) as Mock<
|
||||
(path: fs.PathLike) => Promise<string[]>
|
||||
>
|
||||
).mockResolvedValue([]);
|
||||
|
||||
const ideClient = await IdeClient.getInstance();
|
||||
await ideClient.connect();
|
||||
@@ -164,6 +156,7 @@ describe('IdeClient', () => {
|
||||
expect(ideClient.getConnectionStatus().status).toBe(
|
||||
IDEConnectionStatus.Connected,
|
||||
);
|
||||
delete process.env['QWEN_CODE_IDE_SERVER_PORT'];
|
||||
});
|
||||
|
||||
it('should connect using HTTP when port is provided in environment variables', async () => {
|
||||
@@ -263,7 +256,8 @@ describe('IdeClient', () => {
|
||||
});
|
||||
|
||||
describe('getConnectionConfigFromFile', () => {
|
||||
it('should return config from the specific pid file if it exists', async () => {
|
||||
it('should return config from the env port lock file if it exists', async () => {
|
||||
process.env['QWEN_CODE_IDE_SERVER_PORT'] = '1234';
|
||||
const config = { port: '1234', workspacePath: '/test/workspace' };
|
||||
vi.mocked(fs.promises.readFile).mockResolvedValue(JSON.stringify(config));
|
||||
|
||||
@@ -277,18 +271,14 @@ describe('IdeClient', () => {
|
||||
|
||||
expect(result).toEqual(config);
|
||||
expect(fs.promises.readFile).toHaveBeenCalledWith(
|
||||
path.join('/tmp', 'qwen-code-ide-server-12345.json'),
|
||||
path.join('/home/test', '.qwen', 'ide', '1234.lock'),
|
||||
'utf8',
|
||||
);
|
||||
delete process.env['QWEN_CODE_IDE_SERVER_PORT'];
|
||||
});
|
||||
|
||||
it('should return undefined if no config files are found', async () => {
|
||||
vi.mocked(fs.promises.readFile).mockRejectedValue(new Error('not found'));
|
||||
(
|
||||
vi.mocked(fs.promises.readdir) as Mock<
|
||||
(path: fs.PathLike) => Promise<string[]>
|
||||
>
|
||||
).mockResolvedValue([]);
|
||||
|
||||
const ideClient = await IdeClient.getInstance();
|
||||
const result = await (
|
||||
@@ -300,20 +290,15 @@ describe('IdeClient', () => {
|
||||
expect(result).toBeUndefined();
|
||||
});
|
||||
|
||||
it('should find and parse a single config file with the new naming scheme', async () => {
|
||||
const config = { port: '5678', workspacePath: '/test/workspace' };
|
||||
vi.mocked(fs.promises.readFile).mockRejectedValueOnce(
|
||||
new Error('not found'),
|
||||
); // For old path
|
||||
(
|
||||
vi.mocked(fs.promises.readdir) as Mock<
|
||||
(path: fs.PathLike) => Promise<string[]>
|
||||
>
|
||||
).mockResolvedValue(['qwen-code-ide-server-12345-123.json']);
|
||||
vi.mocked(fs.promises.readFile).mockResolvedValue(JSON.stringify(config));
|
||||
vi.spyOn(IdeClient, 'validateWorkspacePath').mockReturnValue({
|
||||
isValid: true,
|
||||
});
|
||||
it('should read legacy pid config when available', async () => {
|
||||
const config = {
|
||||
port: '5678',
|
||||
workspacePath: '/test/workspace',
|
||||
ppid: 12345,
|
||||
};
|
||||
vi.mocked(fs.promises.readFile).mockResolvedValueOnce(
|
||||
JSON.stringify(config),
|
||||
);
|
||||
|
||||
const ideClient = await IdeClient.getInstance();
|
||||
const result = await (
|
||||
@@ -324,110 +309,18 @@ describe('IdeClient', () => {
|
||||
|
||||
expect(result).toEqual(config);
|
||||
expect(fs.promises.readFile).toHaveBeenCalledWith(
|
||||
path.join('/tmp/gemini/ide', 'qwen-code-ide-server-12345-123.json'),
|
||||
path.join('/tmp', 'qwen-code-ide-server-12345.json'),
|
||||
'utf8',
|
||||
);
|
||||
});
|
||||
|
||||
it('should filter out configs with invalid workspace paths', async () => {
|
||||
const validConfig = {
|
||||
port: '5678',
|
||||
workspacePath: '/test/workspace',
|
||||
};
|
||||
const invalidConfig = {
|
||||
port: '1111',
|
||||
workspacePath: '/invalid/workspace',
|
||||
};
|
||||
vi.mocked(fs.promises.readFile).mockRejectedValueOnce(
|
||||
new Error('not found'),
|
||||
);
|
||||
(
|
||||
vi.mocked(fs.promises.readdir) as Mock<
|
||||
(path: fs.PathLike) => Promise<string[]>
|
||||
>
|
||||
).mockResolvedValue([
|
||||
'qwen-code-ide-server-12345-111.json',
|
||||
'qwen-code-ide-server-12345-222.json',
|
||||
]);
|
||||
vi.mocked(fs.promises.readFile)
|
||||
.mockResolvedValueOnce(JSON.stringify(invalidConfig))
|
||||
.mockResolvedValueOnce(JSON.stringify(validConfig));
|
||||
|
||||
const validateSpy = vi
|
||||
.spyOn(IdeClient, 'validateWorkspacePath')
|
||||
.mockReturnValueOnce({ isValid: false })
|
||||
.mockReturnValueOnce({ isValid: true });
|
||||
|
||||
const ideClient = await IdeClient.getInstance();
|
||||
const result = await (
|
||||
ideClient as unknown as {
|
||||
getConnectionConfigFromFile: () => Promise<unknown>;
|
||||
}
|
||||
).getConnectionConfigFromFile();
|
||||
|
||||
expect(result).toEqual(validConfig);
|
||||
expect(validateSpy).toHaveBeenCalledWith(
|
||||
'/invalid/workspace',
|
||||
'/test/workspace/sub-dir',
|
||||
);
|
||||
expect(validateSpy).toHaveBeenCalledWith(
|
||||
'/test/workspace',
|
||||
'/test/workspace/sub-dir',
|
||||
);
|
||||
});
|
||||
|
||||
it('should return the first valid config when multiple workspaces are valid', async () => {
|
||||
const config1 = { port: '1111', workspacePath: '/test/workspace' };
|
||||
const config2 = { port: '2222', workspacePath: '/test/workspace2' };
|
||||
vi.mocked(fs.promises.readFile).mockRejectedValueOnce(
|
||||
new Error('not found'),
|
||||
);
|
||||
(
|
||||
vi.mocked(fs.promises.readdir) as Mock<
|
||||
(path: fs.PathLike) => Promise<string[]>
|
||||
>
|
||||
).mockResolvedValue([
|
||||
'qwen-code-ide-server-12345-111.json',
|
||||
'qwen-code-ide-server-12345-222.json',
|
||||
]);
|
||||
vi.mocked(fs.promises.readFile)
|
||||
.mockResolvedValueOnce(JSON.stringify(config1))
|
||||
.mockResolvedValueOnce(JSON.stringify(config2));
|
||||
vi.spyOn(IdeClient, 'validateWorkspacePath').mockReturnValue({
|
||||
isValid: true,
|
||||
});
|
||||
|
||||
const ideClient = await IdeClient.getInstance();
|
||||
const result = await (
|
||||
ideClient as unknown as {
|
||||
getConnectionConfigFromFile: () => Promise<unknown>;
|
||||
}
|
||||
).getConnectionConfigFromFile();
|
||||
|
||||
expect(result).toEqual(config1);
|
||||
});
|
||||
|
||||
it('should prioritize the config matching the port from the environment variable', async () => {
|
||||
it('should fall back to legacy port file when pid file is missing', async () => {
|
||||
process.env['QWEN_CODE_IDE_SERVER_PORT'] = '2222';
|
||||
const config1 = { port: '1111', workspacePath: '/test/workspace' };
|
||||
const config2 = { port: '2222', workspacePath: '/test/workspace2' };
|
||||
vi.mocked(fs.promises.readFile).mockRejectedValueOnce(
|
||||
new Error('not found'),
|
||||
);
|
||||
(
|
||||
vi.mocked(fs.promises.readdir) as Mock<
|
||||
(path: fs.PathLike) => Promise<string[]>
|
||||
>
|
||||
).mockResolvedValue([
|
||||
'qwen-code-ide-server-12345-111.json',
|
||||
'qwen-code-ide-server-12345-222.json',
|
||||
]);
|
||||
vi.mocked(fs.promises.readFile)
|
||||
.mockResolvedValueOnce(JSON.stringify(config1))
|
||||
.mockRejectedValueOnce(new Error('not found')) // ~/.qwen/ide/<port>.lock
|
||||
.mockRejectedValueOnce(new Error('not found')) // legacy pid file
|
||||
.mockResolvedValueOnce(JSON.stringify(config2));
|
||||
vi.spyOn(IdeClient, 'validateWorkspacePath').mockReturnValue({
|
||||
isValid: true,
|
||||
});
|
||||
|
||||
const ideClient = await IdeClient.getInstance();
|
||||
const result = await (
|
||||
@@ -437,28 +330,23 @@ describe('IdeClient', () => {
|
||||
).getConnectionConfigFromFile();
|
||||
|
||||
expect(result).toEqual(config2);
|
||||
expect(fs.promises.readFile).toHaveBeenCalledWith(
|
||||
path.join('/tmp', 'qwen-code-ide-server-12345.json'),
|
||||
'utf8',
|
||||
);
|
||||
expect(fs.promises.readFile).toHaveBeenCalledWith(
|
||||
path.join('/tmp', 'qwen-code-ide-server-2222.json'),
|
||||
'utf8',
|
||||
);
|
||||
delete process.env['QWEN_CODE_IDE_SERVER_PORT'];
|
||||
});
|
||||
|
||||
it('should handle invalid JSON in one of the config files', async () => {
|
||||
const validConfig = { port: '2222', workspacePath: '/test/workspace' };
|
||||
vi.mocked(fs.promises.readFile).mockRejectedValueOnce(
|
||||
new Error('not found'),
|
||||
);
|
||||
(
|
||||
vi.mocked(fs.promises.readdir) as Mock<
|
||||
(path: fs.PathLike) => Promise<string[]>
|
||||
>
|
||||
).mockResolvedValue([
|
||||
'qwen-code-ide-server-12345-111.json',
|
||||
'qwen-code-ide-server-12345-222.json',
|
||||
]);
|
||||
it('should fall back to legacy config when env lock file has invalid JSON', async () => {
|
||||
process.env['QWEN_CODE_IDE_SERVER_PORT'] = '3333';
|
||||
const config = { port: '1111', workspacePath: '/test/workspace' };
|
||||
vi.mocked(fs.promises.readFile)
|
||||
.mockResolvedValueOnce('invalid json')
|
||||
.mockResolvedValueOnce(JSON.stringify(validConfig));
|
||||
vi.spyOn(IdeClient, 'validateWorkspacePath').mockReturnValue({
|
||||
isValid: true,
|
||||
});
|
||||
.mockResolvedValueOnce(JSON.stringify(config));
|
||||
|
||||
const ideClient = await IdeClient.getInstance();
|
||||
const result = await (
|
||||
@@ -467,96 +355,7 @@ describe('IdeClient', () => {
|
||||
}
|
||||
).getConnectionConfigFromFile();
|
||||
|
||||
expect(result).toEqual(validConfig);
|
||||
});
|
||||
|
||||
it('should return undefined if readdir throws an error', async () => {
|
||||
vi.mocked(fs.promises.readFile).mockRejectedValueOnce(
|
||||
new Error('not found'),
|
||||
);
|
||||
vi.mocked(fs.promises.readdir).mockRejectedValue(
|
||||
new Error('readdir failed'),
|
||||
);
|
||||
|
||||
const ideClient = await IdeClient.getInstance();
|
||||
const result = await (
|
||||
ideClient as unknown as {
|
||||
getConnectionConfigFromFile: () => Promise<unknown>;
|
||||
}
|
||||
).getConnectionConfigFromFile();
|
||||
|
||||
expect(result).toBeUndefined();
|
||||
});
|
||||
|
||||
it('should ignore files with invalid names', async () => {
|
||||
const validConfig = { port: '3333', workspacePath: '/test/workspace' };
|
||||
vi.mocked(fs.promises.readFile).mockRejectedValueOnce(
|
||||
new Error('not found'),
|
||||
);
|
||||
(
|
||||
vi.mocked(fs.promises.readdir) as Mock<
|
||||
(path: fs.PathLike) => Promise<string[]>
|
||||
>
|
||||
).mockResolvedValue([
|
||||
'qwen-code-ide-server-12345-111.json', // valid
|
||||
'not-a-config-file.txt', // invalid
|
||||
'qwen-code-ide-server-asdf.json', // invalid
|
||||
]);
|
||||
vi.mocked(fs.promises.readFile).mockResolvedValueOnce(
|
||||
JSON.stringify(validConfig),
|
||||
);
|
||||
vi.spyOn(IdeClient, 'validateWorkspacePath').mockReturnValue({
|
||||
isValid: true,
|
||||
});
|
||||
|
||||
const ideClient = await IdeClient.getInstance();
|
||||
const result = await (
|
||||
ideClient as unknown as {
|
||||
getConnectionConfigFromFile: () => Promise<unknown>;
|
||||
}
|
||||
).getConnectionConfigFromFile();
|
||||
|
||||
expect(result).toEqual(validConfig);
|
||||
expect(fs.promises.readFile).toHaveBeenCalledWith(
|
||||
path.join('/tmp/gemini/ide', 'qwen-code-ide-server-12345-111.json'),
|
||||
'utf8',
|
||||
);
|
||||
expect(fs.promises.readFile).not.toHaveBeenCalledWith(
|
||||
path.join('/tmp/gemini/ide', 'not-a-config-file.txt'),
|
||||
'utf8',
|
||||
);
|
||||
});
|
||||
|
||||
it('should match env port string to a number port in the config', async () => {
|
||||
process.env['QWEN_CODE_IDE_SERVER_PORT'] = '3333';
|
||||
const config1 = { port: 1111, workspacePath: '/test/workspace' };
|
||||
const config2 = { port: 3333, workspacePath: '/test/workspace2' };
|
||||
vi.mocked(fs.promises.readFile).mockRejectedValueOnce(
|
||||
new Error('not found'),
|
||||
);
|
||||
(
|
||||
vi.mocked(fs.promises.readdir) as Mock<
|
||||
(path: fs.PathLike) => Promise<string[]>
|
||||
>
|
||||
).mockResolvedValue([
|
||||
'qwen-code-ide-server-12345-111.json',
|
||||
'qwen-code-ide-server-12345-222.json',
|
||||
]);
|
||||
vi.mocked(fs.promises.readFile)
|
||||
.mockResolvedValueOnce(JSON.stringify(config1))
|
||||
.mockResolvedValueOnce(JSON.stringify(config2));
|
||||
vi.spyOn(IdeClient, 'validateWorkspacePath').mockReturnValue({
|
||||
isValid: true,
|
||||
});
|
||||
|
||||
const ideClient = await IdeClient.getInstance();
|
||||
const result = await (
|
||||
ideClient as unknown as {
|
||||
getConnectionConfigFromFile: () => Promise<unknown>;
|
||||
}
|
||||
).getConnectionConfigFromFile();
|
||||
|
||||
expect(result).toEqual(config2);
|
||||
expect(result).toEqual(config);
|
||||
delete process.env['QWEN_CODE_IDE_SERVER_PORT'];
|
||||
});
|
||||
});
|
||||
|
||||
@@ -8,6 +8,7 @@ import * as fs from 'node:fs';
|
||||
import { isSubpath } from '../utils/paths.js';
|
||||
import { detectIde, type IdeInfo } from '../ide/detect-ide.js';
|
||||
import { ideContextStore } from './ideContext.js';
|
||||
import { Storage } from '../config/storage.js';
|
||||
import {
|
||||
IdeContextNotificationSchema,
|
||||
IdeDiffAcceptedNotificationSchema,
|
||||
@@ -572,11 +573,30 @@ export class IdeClient {
|
||||
| (ConnectionConfig & { workspacePath?: string; ideInfo?: IdeInfo })
|
||||
| undefined
|
||||
> {
|
||||
if (!this.ideProcessInfo) {
|
||||
return undefined;
|
||||
const portFromEnv = this.getPortFromEnv();
|
||||
if (portFromEnv) {
|
||||
try {
|
||||
const ideDir = Storage.getGlobalIdeDir();
|
||||
const lockFile = path.join(ideDir, `${portFromEnv}.lock`);
|
||||
const lockFileContents = await fs.promises.readFile(lockFile, 'utf8');
|
||||
return JSON.parse(lockFileContents);
|
||||
} catch (_) {
|
||||
// Fall through to legacy discovery.
|
||||
}
|
||||
}
|
||||
|
||||
// For backwards compatability
|
||||
// Legacy discovery for VSCode extension < v0.5.1.
|
||||
return this.getLegacyConnectionConfig(portFromEnv);
|
||||
}
|
||||
|
||||
// Legacy connection files were written in the global temp directory.
|
||||
private async getLegacyConnectionConfig(
|
||||
portFromEnv?: string,
|
||||
): Promise<
|
||||
| (ConnectionConfig & { workspacePath?: string; ideInfo?: IdeInfo })
|
||||
| undefined
|
||||
> {
|
||||
if (this.ideProcessInfo) {
|
||||
try {
|
||||
const portFile = path.join(
|
||||
os.tmpdir(),
|
||||
@@ -585,85 +605,71 @@ export class IdeClient {
|
||||
const portFileContents = await fs.promises.readFile(portFile, 'utf8');
|
||||
return JSON.parse(portFileContents);
|
||||
} catch (_) {
|
||||
// For newer extension versions, the file name matches the pattern
|
||||
// For older/newer extension versions, the file name matches the pattern
|
||||
// /^qwen-code-ide-server-${pid}-\d+\.json$/. If multiple IDE
|
||||
// windows are open, multiple files matching the pattern are expected to
|
||||
// exist.
|
||||
}
|
||||
}
|
||||
|
||||
const portFileDir = path.join(os.tmpdir(), 'gemini', 'ide');
|
||||
let portFiles;
|
||||
if (portFromEnv) {
|
||||
try {
|
||||
portFiles = await fs.promises.readdir(portFileDir);
|
||||
const portFile = path.join(
|
||||
os.tmpdir(),
|
||||
`qwen-code-ide-server-${portFromEnv}.json`,
|
||||
);
|
||||
const portFileContents = await fs.promises.readFile(portFile, 'utf8');
|
||||
return JSON.parse(portFileContents);
|
||||
} catch (_) {
|
||||
// Ignore and fall through.
|
||||
}
|
||||
}
|
||||
|
||||
return undefined;
|
||||
}
|
||||
|
||||
protected async getAllConnectionConfigs(
|
||||
ideDir: string,
|
||||
): Promise<
|
||||
ConnectionConfig & Array<{ workspacePath?: string; ideInfo?: IdeInfo }>
|
||||
> {
|
||||
const fileRegex = new RegExp('^\\d+\\.lock$');
|
||||
let lockFiles: string[];
|
||||
try {
|
||||
lockFiles = (await fs.promises.readdir(ideDir)).filter((file) =>
|
||||
fileRegex.test(file),
|
||||
);
|
||||
} catch (e) {
|
||||
logger.debug('Failed to read IDE connection directory:', e);
|
||||
return undefined;
|
||||
return [];
|
||||
}
|
||||
|
||||
if (!portFiles) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
const fileRegex = new RegExp(
|
||||
`^qwen-code-ide-server-${this.ideProcessInfo.pid}-\\d+\\.json$`,
|
||||
);
|
||||
const matchingFiles = portFiles
|
||||
.filter((file) => fileRegex.test(file))
|
||||
.sort();
|
||||
if (matchingFiles.length === 0) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
let fileContents: string[];
|
||||
const fileContents = await Promise.all(
|
||||
lockFiles.map(async (file) => {
|
||||
const fullPath = path.join(ideDir, file);
|
||||
try {
|
||||
fileContents = await Promise.all(
|
||||
matchingFiles.map((file) =>
|
||||
fs.promises.readFile(path.join(portFileDir, file), 'utf8'),
|
||||
),
|
||||
);
|
||||
} catch (e) {
|
||||
logger.debug('Failed to read IDE connection config file(s):', e);
|
||||
return undefined;
|
||||
}
|
||||
const parsedContents = fileContents.map((content) => {
|
||||
const stat = await fs.promises.stat(fullPath);
|
||||
const content = await fs.promises.readFile(fullPath, 'utf8');
|
||||
try {
|
||||
return JSON.parse(content);
|
||||
const parsed = JSON.parse(content);
|
||||
return { file, mtimeMs: stat.mtimeMs, parsed };
|
||||
} catch (e) {
|
||||
logger.debug('Failed to parse JSON from config file: ', e);
|
||||
return undefined;
|
||||
logger.debug('Failed to parse JSON from lock file: ', e);
|
||||
return { file, mtimeMs: stat.mtimeMs, parsed: undefined };
|
||||
}
|
||||
});
|
||||
|
||||
const validWorkspaces = parsedContents.filter((content) => {
|
||||
if (!content) {
|
||||
return false;
|
||||
} catch (e) {
|
||||
// If we can't stat/read the file, treat it as very old so it doesn't
|
||||
// win ties, and skip parsing by returning undefined content.
|
||||
logger.debug('Failed to read/stat IDE lock file:', e);
|
||||
return { file, mtimeMs: -Infinity, parsed: undefined };
|
||||
}
|
||||
const { isValid } = IdeClient.validateWorkspacePath(
|
||||
content.workspacePath,
|
||||
process.cwd(),
|
||||
}),
|
||||
);
|
||||
return isValid;
|
||||
});
|
||||
|
||||
if (validWorkspaces.length === 0) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
if (validWorkspaces.length === 1) {
|
||||
return validWorkspaces[0];
|
||||
}
|
||||
|
||||
const portFromEnv = this.getPortFromEnv();
|
||||
if (portFromEnv) {
|
||||
const matchingPort = validWorkspaces.find(
|
||||
(content) => String(content.port) === portFromEnv,
|
||||
);
|
||||
if (matchingPort) {
|
||||
return matchingPort;
|
||||
}
|
||||
}
|
||||
|
||||
return validWorkspaces[0];
|
||||
return fileContents
|
||||
.filter(({ parsed }) => parsed !== undefined)
|
||||
.sort((a, b) => b.mtimeMs - a.mtimeMs)
|
||||
.map(({ parsed }) => parsed);
|
||||
}
|
||||
|
||||
private createProxyAwareFetch() {
|
||||
|
||||
@@ -50,7 +50,7 @@ describe('getIdeProcessInfo', () => {
|
||||
expect(result).toEqual({ pid: 700, command: '/usr/lib/vscode/code' });
|
||||
});
|
||||
|
||||
it('should return parent process info if grandparent lookup fails', async () => {
|
||||
it('should return shell process info if grandparent lookup fails', async () => {
|
||||
(os.platform as Mock).mockReturnValue('linux');
|
||||
mockedExec
|
||||
.mockResolvedValueOnce({ stdout: '800 /bin/bash' }) // pid 1000 -> ppid 800 (shell)
|
||||
@@ -63,134 +63,96 @@ describe('getIdeProcessInfo', () => {
|
||||
});
|
||||
|
||||
describe('on Windows', () => {
|
||||
it('should traverse up and find the great-grandchild of the root process', async () => {
|
||||
it('should return great-grandparent process using heuristic', async () => {
|
||||
(os.platform as Mock).mockReturnValue('win32');
|
||||
const processInfoMap = new Map([
|
||||
[
|
||||
1000,
|
||||
|
||||
const processes = [
|
||||
{
|
||||
stdout:
|
||||
'{"Name":"node.exe","ParentProcessId":900,"CommandLine":"node.exe"}',
|
||||
ProcessId: 1000,
|
||||
ParentProcessId: 900,
|
||||
Name: 'node.exe',
|
||||
CommandLine: 'node.exe',
|
||||
},
|
||||
],
|
||||
[
|
||||
900,
|
||||
{
|
||||
stdout:
|
||||
'{"Name":"powershell.exe","ParentProcessId":800,"CommandLine":"powershell.exe"}',
|
||||
ProcessId: 900,
|
||||
ParentProcessId: 800,
|
||||
Name: 'powershell.exe',
|
||||
CommandLine: 'powershell.exe',
|
||||
},
|
||||
],
|
||||
[
|
||||
800,
|
||||
{
|
||||
stdout:
|
||||
'{"Name":"code.exe","ParentProcessId":700,"CommandLine":"code.exe"}',
|
||||
ProcessId: 800,
|
||||
ParentProcessId: 700,
|
||||
Name: 'code.exe',
|
||||
CommandLine: 'code.exe',
|
||||
},
|
||||
],
|
||||
[
|
||||
700,
|
||||
{
|
||||
stdout:
|
||||
'{"Name":"wininit.exe","ParentProcessId":0,"CommandLine":"wininit.exe"}',
|
||||
ProcessId: 700,
|
||||
ParentProcessId: 0,
|
||||
Name: 'wininit.exe',
|
||||
CommandLine: 'wininit.exe',
|
||||
},
|
||||
],
|
||||
]);
|
||||
mockedExec.mockImplementation((command: string) => {
|
||||
const pidMatch = command.match(/ProcessId=(\d+)/);
|
||||
if (pidMatch) {
|
||||
const pid = parseInt(pidMatch[1], 10);
|
||||
return Promise.resolve(processInfoMap.get(pid));
|
||||
];
|
||||
|
||||
mockedExec.mockImplementation((file: string, _args: string[]) => {
|
||||
if (file === 'powershell') {
|
||||
return Promise.resolve({ stdout: JSON.stringify(processes) });
|
||||
}
|
||||
return Promise.reject(new Error('Invalid command for mock'));
|
||||
return Promise.resolve({ stdout: '' });
|
||||
});
|
||||
|
||||
const result = await getIdeProcessInfo();
|
||||
// Process chain: 1000 (node.exe) -> 900 (powershell.exe) -> 800 (code.exe) -> 700 (wininit.exe)
|
||||
// ancestors = [1000, 900, 800, 700], length = 4
|
||||
// Heuristic: return ancestors[length-3] = ancestors[1] = 900 (powershell.exe)
|
||||
expect(result).toEqual({ pid: 900, command: 'powershell.exe' });
|
||||
});
|
||||
|
||||
it('should handle non-existent process gracefully', async () => {
|
||||
it('should handle empty process list gracefully', async () => {
|
||||
(os.platform as Mock).mockReturnValue('win32');
|
||||
mockedExec
|
||||
.mockResolvedValueOnce({ stdout: '' }) // Non-existent PID returns empty due to -ErrorAction SilentlyContinue
|
||||
.mockResolvedValueOnce({
|
||||
stdout:
|
||||
'{"Name":"fallback.exe","ParentProcessId":0,"CommandLine":"fallback.exe"}',
|
||||
}); // Fallback call
|
||||
mockedExec.mockResolvedValue({ stdout: '[]' });
|
||||
|
||||
const result = await getIdeProcessInfo();
|
||||
expect(result).toEqual({ pid: 1000, command: 'fallback.exe' });
|
||||
// Should return current pid and empty command because process not found in map
|
||||
expect(result).toEqual({ pid: 1000, command: '' });
|
||||
});
|
||||
|
||||
it('should handle malformed JSON output gracefully', async () => {
|
||||
(os.platform as Mock).mockReturnValue('win32');
|
||||
mockedExec
|
||||
.mockResolvedValueOnce({ stdout: '{"invalid":json}' }) // Malformed JSON
|
||||
.mockResolvedValueOnce({
|
||||
stdout:
|
||||
'{"Name":"fallback.exe","ParentProcessId":0,"CommandLine":"fallback.exe"}',
|
||||
}); // Fallback call
|
||||
mockedExec.mockResolvedValue({ stdout: '{"invalid":json}' });
|
||||
|
||||
const result = await getIdeProcessInfo();
|
||||
expect(result).toEqual({ pid: 1000, command: 'fallback.exe' });
|
||||
expect(result).toEqual({ pid: 1000, command: '' });
|
||||
});
|
||||
|
||||
it('should handle PowerShell errors without crashing the process chain', async () => {
|
||||
it('should return last ancestor if chain is too short', async () => {
|
||||
(os.platform as Mock).mockReturnValue('win32');
|
||||
const processInfoMap = new Map([
|
||||
[1000, { stdout: '' }], // First process doesn't exist (empty due to -ErrorAction)
|
||||
[
|
||||
1001,
|
||||
{
|
||||
stdout:
|
||||
'{"Name":"parent.exe","ParentProcessId":800,"CommandLine":"parent.exe"}',
|
||||
},
|
||||
],
|
||||
[
|
||||
800,
|
||||
{
|
||||
stdout:
|
||||
'{"Name":"ide.exe","ParentProcessId":0,"CommandLine":"ide.exe"}',
|
||||
},
|
||||
],
|
||||
]);
|
||||
|
||||
// Mock the process.pid to test traversal with missing processes
|
||||
Object.defineProperty(process, 'pid', {
|
||||
value: 1001,
|
||||
configurable: true,
|
||||
});
|
||||
const processes = [
|
||||
{
|
||||
ProcessId: 1000,
|
||||
ParentProcessId: 900,
|
||||
Name: 'node.exe',
|
||||
CommandLine: 'node.exe',
|
||||
},
|
||||
{
|
||||
ProcessId: 900,
|
||||
ParentProcessId: 0,
|
||||
Name: 'explorer.exe',
|
||||
CommandLine: 'explorer.exe',
|
||||
},
|
||||
];
|
||||
|
||||
mockedExec.mockImplementation((command: string) => {
|
||||
const pidMatch = command.match(/ProcessId=(\d+)/);
|
||||
if (pidMatch) {
|
||||
const pid = parseInt(pidMatch[1], 10);
|
||||
return Promise.resolve(processInfoMap.get(pid) || { stdout: '' });
|
||||
mockedExec.mockImplementation((file: string, _args: string[]) => {
|
||||
if (file === 'powershell') {
|
||||
return Promise.resolve({ stdout: JSON.stringify(processes) });
|
||||
}
|
||||
return Promise.reject(new Error('Invalid command for mock'));
|
||||
return Promise.resolve({ stdout: '' });
|
||||
});
|
||||
|
||||
const result = await getIdeProcessInfo();
|
||||
// Should return the current process command since traversal continues despite missing processes
|
||||
expect(result).toEqual({ pid: 1001, command: 'parent.exe' });
|
||||
|
||||
// Reset process.pid
|
||||
Object.defineProperty(process, 'pid', {
|
||||
value: 1000,
|
||||
configurable: true,
|
||||
});
|
||||
});
|
||||
|
||||
it('should handle partial JSON data with defaults', async () => {
|
||||
(os.platform as Mock).mockReturnValue('win32');
|
||||
mockedExec
|
||||
.mockResolvedValueOnce({ stdout: '{"Name":"partial.exe"}' }) // Missing ParentProcessId, defaults to 0
|
||||
.mockResolvedValueOnce({
|
||||
stdout:
|
||||
'{"Name":"root.exe","ParentProcessId":0,"CommandLine":"root.exe"}',
|
||||
}); // Get grandparent info
|
||||
|
||||
const result = await getIdeProcessInfo();
|
||||
expect(result).toEqual({ pid: 1000, command: 'root.exe' });
|
||||
// ancestors = [1000, 900], length = 2 (< 3)
|
||||
// Heuristic: return ancestors[length-1] = ancestors[1] = 900 (explorer.exe)
|
||||
expect(result).toEqual({ pid: 900, command: 'explorer.exe' });
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -4,74 +4,28 @@
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
import { exec } from 'node:child_process';
|
||||
import { exec, execFile } from 'node:child_process';
|
||||
import { promisify } from 'node:util';
|
||||
import os from 'node:os';
|
||||
import path from 'node:path';
|
||||
|
||||
const execAsync = promisify(exec);
|
||||
const execFileAsync = promisify(execFile);
|
||||
|
||||
const MAX_TRAVERSAL_DEPTH = 32;
|
||||
|
||||
/**
|
||||
* Fetches the parent process ID, name, and command for a given process ID.
|
||||
*
|
||||
* @param pid The process ID to inspect.
|
||||
* @returns A promise that resolves to the parent's PID, name, and command.
|
||||
*/
|
||||
async function getProcessInfo(pid: number): Promise<{
|
||||
parentPid: number;
|
||||
name: string;
|
||||
command: string;
|
||||
}> {
|
||||
try {
|
||||
const platform = os.platform();
|
||||
if (platform === 'win32') {
|
||||
const powershellCommand = [
|
||||
'$p = Get-CimInstance Win32_Process',
|
||||
`-Filter 'ProcessId=${pid}'`,
|
||||
'-ErrorAction SilentlyContinue;',
|
||||
'if ($p) {',
|
||||
'@{Name=$p.Name;ParentProcessId=$p.ParentProcessId;CommandLine=$p.CommandLine}',
|
||||
'| ConvertTo-Json',
|
||||
'}',
|
||||
].join(' ');
|
||||
const { stdout } = await execAsync(`powershell "${powershellCommand}"`);
|
||||
const output = stdout.trim();
|
||||
if (!output) return { parentPid: 0, name: '', command: '' };
|
||||
const {
|
||||
Name = '',
|
||||
ParentProcessId = 0,
|
||||
CommandLine = '',
|
||||
} = JSON.parse(output);
|
||||
return {
|
||||
parentPid: ParentProcessId,
|
||||
name: Name,
|
||||
command: CommandLine ?? '',
|
||||
};
|
||||
} else {
|
||||
const command = `ps -o ppid=,command= -p ${pid}`;
|
||||
const { stdout } = await execAsync(command);
|
||||
const trimmedStdout = stdout.trim();
|
||||
if (!trimmedStdout) {
|
||||
return { parentPid: 0, name: '', command: '' };
|
||||
}
|
||||
const ppidString = trimmedStdout.split(/\s+/)[0];
|
||||
const parentPid = parseInt(ppidString, 10);
|
||||
const fullCommand = trimmedStdout.substring(ppidString.length).trim();
|
||||
const processName = path.basename(fullCommand.split(' ')[0]);
|
||||
return {
|
||||
parentPid: isNaN(parentPid) ? 1 : parentPid,
|
||||
name: processName,
|
||||
command: fullCommand,
|
||||
};
|
||||
}
|
||||
} catch (_e) {
|
||||
console.debug(`Failed to get process info for pid ${pid}:`, _e);
|
||||
return { parentPid: 0, name: '', command: '' };
|
||||
}
|
||||
// Only used for Unix systems (macOS and Linux)
|
||||
const { stdout } = await execAsync(`ps -p ${pid} -o ppid=,comm=`);
|
||||
const [ppidStr, ...commandParts] = stdout.trim().split(/\s+/);
|
||||
const parentPid = parseInt(ppidStr, 10);
|
||||
const command = commandParts.join(' ');
|
||||
return { parentPid, name: path.basename(command), command };
|
||||
}
|
||||
|
||||
/**
|
||||
* Finds the IDE process info on Unix-like systems.
|
||||
*
|
||||
@@ -106,15 +60,15 @@ async function getIdeProcessInfoForUnix(): Promise<{
|
||||
} catch {
|
||||
// Ignore if getting grandparent fails, we'll just use the parent pid.
|
||||
}
|
||||
const { command } = await getProcessInfo(idePid);
|
||||
return { pid: idePid, command };
|
||||
const { command: ideCommand } = await getProcessInfo(idePid);
|
||||
return { pid: idePid, command: ideCommand };
|
||||
}
|
||||
|
||||
if (parentPid <= 1) {
|
||||
break; // Reached the root
|
||||
}
|
||||
currentPid = parentPid;
|
||||
} catch {
|
||||
} catch (_e) {
|
||||
// Process in chain died
|
||||
break;
|
||||
}
|
||||
@@ -124,50 +78,104 @@ async function getIdeProcessInfoForUnix(): Promise<{
|
||||
return { pid: currentPid, command };
|
||||
}
|
||||
|
||||
interface ProcessInfo {
|
||||
pid: number;
|
||||
parentPid: number;
|
||||
name: string;
|
||||
command: string;
|
||||
}
|
||||
|
||||
interface RawProcessInfo {
|
||||
ProcessId?: number;
|
||||
ParentProcessId?: number;
|
||||
Name?: string;
|
||||
CommandLine?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Finds the IDE process info on Windows.
|
||||
*
|
||||
* The strategy is to find the great-grandchild of the root process.
|
||||
*
|
||||
* @returns A promise that resolves to the PID and command of the IDE process.
|
||||
* Fetches the entire process table on Windows.
|
||||
*/
|
||||
async function getProcessTableWindows(): Promise<Map<number, ProcessInfo>> {
|
||||
const processMap = new Map<number, ProcessInfo>();
|
||||
try {
|
||||
const powershellCommand =
|
||||
'Get-CimInstance Win32_Process | Select-Object ProcessId,ParentProcessId,Name,CommandLine | ConvertTo-Json -Compress';
|
||||
const { stdout } = await execFileAsync(
|
||||
'powershell',
|
||||
['-NoProfile', '-NonInteractive', '-Command', powershellCommand],
|
||||
{ maxBuffer: 10 * 1024 * 1024 },
|
||||
);
|
||||
|
||||
if (!stdout.trim()) {
|
||||
return processMap;
|
||||
}
|
||||
|
||||
let processes: RawProcessInfo | RawProcessInfo[];
|
||||
try {
|
||||
processes = JSON.parse(stdout);
|
||||
} catch (_e) {
|
||||
return processMap;
|
||||
}
|
||||
|
||||
if (!Array.isArray(processes)) {
|
||||
processes = [processes];
|
||||
}
|
||||
|
||||
for (const p of processes) {
|
||||
if (p && typeof p.ProcessId === 'number') {
|
||||
processMap.set(p.ProcessId, {
|
||||
pid: p.ProcessId,
|
||||
parentPid: p.ParentProcessId || 0,
|
||||
name: p.Name || '',
|
||||
command: p.CommandLine || '',
|
||||
});
|
||||
}
|
||||
}
|
||||
} catch (_e) {
|
||||
// Fallback or error handling if PowerShell fails
|
||||
}
|
||||
return processMap;
|
||||
}
|
||||
|
||||
async function getIdeProcessInfoForWindows(): Promise<{
|
||||
pid: number;
|
||||
command: string;
|
||||
}> {
|
||||
let currentPid = process.pid;
|
||||
let previousPid = process.pid;
|
||||
// Fetch the entire process table in one go.
|
||||
const processMap = await getProcessTableWindows();
|
||||
|
||||
for (let i = 0; i < MAX_TRAVERSAL_DEPTH; i++) {
|
||||
try {
|
||||
const { parentPid } = await getProcessInfo(currentPid);
|
||||
const myPid = process.pid;
|
||||
const myProc = processMap.get(myPid);
|
||||
|
||||
if (parentPid > 0) {
|
||||
try {
|
||||
const { parentPid: grandParentPid } = await getProcessInfo(parentPid);
|
||||
if (grandParentPid === 0) {
|
||||
// We've found the grandchild of the root (`currentPid`). The IDE
|
||||
// process is its child, which we've stored in `previousPid`.
|
||||
const { command } = await getProcessInfo(previousPid);
|
||||
return { pid: previousPid, command };
|
||||
}
|
||||
} catch {
|
||||
// getting grandparent failed, proceed
|
||||
}
|
||||
if (!myProc) {
|
||||
// Fallback: return current process info if snapshot fails
|
||||
return { pid: myPid, command: '' };
|
||||
}
|
||||
|
||||
if (parentPid <= 0) {
|
||||
break; // Reached the root
|
||||
}
|
||||
previousPid = currentPid;
|
||||
currentPid = parentPid;
|
||||
} catch {
|
||||
// Process in chain died
|
||||
// Perform tree traversal in memory
|
||||
const ancestors: ProcessInfo[] = [];
|
||||
let curr: ProcessInfo | undefined = myProc;
|
||||
|
||||
for (let i = 0; i < MAX_TRAVERSAL_DEPTH && curr; i++) {
|
||||
ancestors.push(curr);
|
||||
|
||||
if (curr.parentPid === 0 || !processMap.has(curr.parentPid)) {
|
||||
// Parent process not in map, stop traversal
|
||||
break;
|
||||
}
|
||||
curr = processMap.get(curr.parentPid);
|
||||
}
|
||||
const { command } = await getProcessInfo(currentPid);
|
||||
return { pid: currentPid, command };
|
||||
|
||||
// Use heuristic: return the great-grandparent (ancestors[length-3])
|
||||
if (ancestors.length >= 3) {
|
||||
const target = ancestors[ancestors.length - 3];
|
||||
return { pid: target.pid, command: target.command };
|
||||
} else if (ancestors.length > 0) {
|
||||
const target = ancestors[ancestors.length - 1];
|
||||
return { pid: target.pid, command: target.command };
|
||||
}
|
||||
|
||||
return { pid: myPid, command: myProc.command };
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -13,9 +13,8 @@ npm install @qwen-code/sdk
|
||||
## Requirements
|
||||
|
||||
- Node.js >= 20.0.0
|
||||
- [Qwen Code](https://github.com/QwenLM/qwen-code) >= 0.4.0 (stable) installed and accessible in PATH
|
||||
|
||||
> **Note for nvm users**: If you use nvm to manage Node.js versions, the SDK may not be able to auto-detect the Qwen Code executable. You should explicitly set the `pathToQwenExecutable` option to the full path of the `qwen` binary.
|
||||
> From v0.1.1, the CLI is bundled with the SDK. So no standalone CLI installation is needed.
|
||||
|
||||
## Quick Start
|
||||
|
||||
@@ -372,6 +371,23 @@ try {
|
||||
}
|
||||
```
|
||||
|
||||
## FAQ / Troubleshooting
|
||||
|
||||
### Version 0.1.0 Requirements
|
||||
|
||||
If you're using SDK version **0.1.0**, please note the following requirements:
|
||||
|
||||
#### Qwen Code Installation Required
|
||||
|
||||
Version 0.1.0 requires [Qwen Code](https://github.com/QwenLM/qwen-code) **>= 0.4.0** to be installed separately and accessible in your PATH.
|
||||
|
||||
```bash
|
||||
# Install Qwen Code globally
|
||||
npm install -g qwen-code@^0.4.0
|
||||
```
|
||||
|
||||
**Note**: From version **0.1.1** onwards, the CLI is bundled with the SDK, so no separate Qwen Code installation is needed.
|
||||
|
||||
## License
|
||||
|
||||
Apache-2.0 - see [LICENSE](./LICENSE) for details.
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@qwen-code/sdk",
|
||||
"version": "0.5.1",
|
||||
"version": "0.6.0",
|
||||
"description": "TypeScript SDK for programmatic access to qwen-code CLI",
|
||||
"main": "./dist/index.cjs",
|
||||
"module": "./dist/index.mjs",
|
||||
@@ -46,7 +46,8 @@
|
||||
},
|
||||
"dependencies": {
|
||||
"@modelcontextprotocol/sdk": "^1.25.1",
|
||||
"zod": "^3.25.0"
|
||||
"zod": "^3.25.0",
|
||||
"tiktoken": "^1.0.21"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/node": "^20.14.0",
|
||||
|
||||
@@ -91,3 +91,35 @@ if (existsSync(licenseSource)) {
|
||||
console.warn('Could not copy LICENSE:', error.message);
|
||||
}
|
||||
}
|
||||
|
||||
console.log('Bundling CLI into SDK package...');
|
||||
const repoRoot = join(rootDir, '..', '..');
|
||||
const rootDistDir = join(repoRoot, 'dist');
|
||||
|
||||
if (!existsSync(rootDistDir) || !existsSync(join(rootDistDir, 'cli.js'))) {
|
||||
console.log('Building CLI bundle...');
|
||||
try {
|
||||
execSync('npm run bundle', { stdio: 'inherit', cwd: repoRoot });
|
||||
} catch (error) {
|
||||
console.error('Failed to build CLI bundle:', error.message);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
const cliDistDir = join(rootDir, 'dist', 'cli');
|
||||
mkdirSync(cliDistDir, { recursive: true });
|
||||
|
||||
console.log('Copying CLI bundle...');
|
||||
cpSync(join(rootDistDir, 'cli.js'), join(cliDistDir, 'cli.js'));
|
||||
|
||||
const vendorSource = join(rootDistDir, 'vendor');
|
||||
if (existsSync(vendorSource)) {
|
||||
cpSync(vendorSource, join(cliDistDir, 'vendor'), { recursive: true });
|
||||
}
|
||||
|
||||
const localesSource = join(rootDistDir, 'locales');
|
||||
if (existsSync(localesSource)) {
|
||||
cpSync(localesSource, join(cliDistDir, 'locales'), { recursive: true });
|
||||
}
|
||||
|
||||
console.log('CLI bundle copied successfully to SDK package');
|
||||
|
||||
@@ -2,24 +2,16 @@
|
||||
* CLI path auto-detection and subprocess spawning utilities
|
||||
*
|
||||
* Supports multiple execution modes:
|
||||
* 1. Native binary: 'qwen' (production)
|
||||
* 2. Node.js bundle: 'node /path/to/cli.js' (production validation)
|
||||
* 1. Bundled CLI: Node.js bundle included in the SDK package (default)
|
||||
* 2. Node.js bundle: 'node /path/to/cli.js' (custom path)
|
||||
* 3. Bun bundle: 'bun /path/to/cli.js' (alternative runtime)
|
||||
* 4. TypeScript source: 'tsx /path/to/index.ts' (development)
|
||||
*
|
||||
* Auto-detection locations for native binary:
|
||||
* 1. QWEN_CODE_CLI_PATH environment variable
|
||||
* 2. ~/.volta/bin/qwen
|
||||
* 3. ~/.npm-global/bin/qwen
|
||||
* 4. /usr/local/bin/qwen
|
||||
* 5. ~/.local/bin/qwen
|
||||
* 6. ~/node_modules/.bin/qwen
|
||||
* 7. ~/.yarn/bin/qwen
|
||||
*/
|
||||
|
||||
import * as fs from 'node:fs';
|
||||
import * as path from 'node:path';
|
||||
import { execSync } from 'node:child_process';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
|
||||
/**
|
||||
* Executable types supported by the SDK
|
||||
@@ -40,49 +32,38 @@ export type SpawnInfo = {
|
||||
originalInput: string;
|
||||
};
|
||||
|
||||
function getBundledCliPath(): string | null {
|
||||
try {
|
||||
const currentFile =
|
||||
typeof __filename !== 'undefined'
|
||||
? __filename
|
||||
: fileURLToPath(import.meta.url);
|
||||
|
||||
const currentDir = path.dirname(currentFile);
|
||||
|
||||
const bundledCliPath = path.join(currentDir, 'cli', 'cli.js');
|
||||
|
||||
if (fs.existsSync(bundledCliPath)) {
|
||||
return bundledCliPath;
|
||||
}
|
||||
|
||||
return null;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
export function findNativeCliPath(): string {
|
||||
const homeDir = process.env['HOME'] || process.env['USERPROFILE'] || '';
|
||||
|
||||
const candidates: Array<string | undefined> = [
|
||||
// 1. Environment variable (highest priority)
|
||||
process.env['QWEN_CODE_CLI_PATH'],
|
||||
|
||||
// 2. Volta bin
|
||||
path.join(homeDir, '.volta', 'bin', 'qwen'),
|
||||
|
||||
// 3. Global npm installations
|
||||
path.join(homeDir, '.npm-global', 'bin', 'qwen'),
|
||||
|
||||
// 4. Common Unix binary locations
|
||||
'/usr/local/bin/qwen',
|
||||
|
||||
// 5. User local bin
|
||||
path.join(homeDir, '.local', 'bin', 'qwen'),
|
||||
|
||||
// 6. Node modules bin in home directory
|
||||
path.join(homeDir, 'node_modules', '.bin', 'qwen'),
|
||||
|
||||
// 7. Yarn global bin
|
||||
path.join(homeDir, '.yarn', 'bin', 'qwen'),
|
||||
];
|
||||
|
||||
// Find first existing candidate
|
||||
for (const candidate of candidates) {
|
||||
if (candidate && fs.existsSync(candidate)) {
|
||||
return path.resolve(candidate);
|
||||
}
|
||||
const bundledCli = getBundledCliPath();
|
||||
if (bundledCli) {
|
||||
return bundledCli;
|
||||
}
|
||||
|
||||
// Not found - throw helpful error
|
||||
throw new Error(
|
||||
'qwen CLI not found. Please:\n' +
|
||||
' 1. Install qwen globally: npm install -g qwen\n' +
|
||||
' 2. Or provide explicit executable: query({ pathToQwenExecutable: "/path/to/qwen" })\n' +
|
||||
' 3. Or set environment variable: QWEN_CODE_CLI_PATH="/path/to/qwen"\n' +
|
||||
'\n' +
|
||||
'For development/testing, you can also use:\n' +
|
||||
'Bundled qwen CLI not found. The CLI should be included in the SDK package.\n' +
|
||||
'If you need to use a custom CLI, provide explicit executable:\n' +
|
||||
' • query({ pathToQwenExecutable: "/path/to/cli.js" })\n' +
|
||||
' • TypeScript source: query({ pathToQwenExecutable: "/path/to/index.ts" })\n' +
|
||||
' • Node.js bundle: query({ pathToQwenExecutable: "/path/to/cli.js" })\n' +
|
||||
' • Force specific runtime: query({ pathToQwenExecutable: "bun:/path/to/cli.js" })',
|
||||
);
|
||||
}
|
||||
|
||||
@@ -38,6 +38,8 @@ describe('CLI Path Utilities', () => {
|
||||
mockFs.statSync.mockReturnValue({
|
||||
isFile: () => true,
|
||||
} as ReturnType<typeof import('fs').statSync>);
|
||||
// Default: return true for existsSync (can be overridden in specific tests)
|
||||
mockFs.existsSync.mockReturnValue(true);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
@@ -50,28 +52,26 @@ describe('CLI Path Utilities', () => {
|
||||
|
||||
describe('parseExecutableSpec', () => {
|
||||
describe('auto-detection (no spec provided)', () => {
|
||||
it('should auto-detect native CLI when no spec provided', () => {
|
||||
// Mock environment variable
|
||||
const originalEnv = process.env['QWEN_CODE_CLI_PATH'];
|
||||
process.env['QWEN_CODE_CLI_PATH'] = '/usr/local/bin/qwen';
|
||||
mockFs.existsSync.mockReturnValue(true);
|
||||
it('should auto-detect bundled CLI when no spec provided', () => {
|
||||
// Mock existsSync to return true for bundled CLI
|
||||
mockFs.existsSync.mockImplementation((p) => {
|
||||
const pathStr = p.toString();
|
||||
return (
|
||||
pathStr.includes('cli/cli.js') || pathStr.includes('cli\\cli.js')
|
||||
);
|
||||
});
|
||||
|
||||
const result = parseExecutableSpec();
|
||||
|
||||
expect(result).toEqual({
|
||||
executablePath: path.resolve('/usr/local/bin/qwen'),
|
||||
isExplicitRuntime: false,
|
||||
expect(result.executablePath).toContain('cli.js');
|
||||
expect(result.isExplicitRuntime).toBe(false);
|
||||
});
|
||||
|
||||
// Restore env
|
||||
process.env['QWEN_CODE_CLI_PATH'] = originalEnv;
|
||||
});
|
||||
|
||||
it('should throw when auto-detection fails', () => {
|
||||
it('should throw when bundled CLI not found', () => {
|
||||
mockFs.existsSync.mockReturnValue(false);
|
||||
|
||||
expect(() => parseExecutableSpec()).toThrow(
|
||||
'qwen CLI not found. Please:',
|
||||
'Bundled qwen CLI not found',
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -361,65 +361,44 @@ describe('CLI Path Utilities', () => {
|
||||
});
|
||||
|
||||
describe('auto-detection fallback', () => {
|
||||
it('should auto-detect when no spec provided', () => {
|
||||
// Mock environment variable
|
||||
const originalEnv = process.env['QWEN_CODE_CLI_PATH'];
|
||||
process.env['QWEN_CODE_CLI_PATH'] = '/usr/local/bin/qwen';
|
||||
it('should auto-detect bundled CLI when no spec provided', () => {
|
||||
// Mock existsSync to return true for bundled CLI
|
||||
mockFs.existsSync.mockImplementation((p) => {
|
||||
const pathStr = p.toString();
|
||||
return (
|
||||
pathStr.includes('cli/cli.js') || pathStr.includes('cli\\cli.js')
|
||||
);
|
||||
});
|
||||
|
||||
const result = prepareSpawnInfo();
|
||||
|
||||
expect(result).toEqual({
|
||||
command: path.resolve('/usr/local/bin/qwen'),
|
||||
args: [],
|
||||
type: 'native',
|
||||
originalInput: '',
|
||||
});
|
||||
|
||||
// Restore env
|
||||
process.env['QWEN_CODE_CLI_PATH'] = originalEnv;
|
||||
expect(result.command).toBe(process.execPath);
|
||||
expect(result.args[0]).toContain('cli.js');
|
||||
expect(result.type).toBe('node');
|
||||
expect(result.originalInput).toBe('');
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('findNativeCliPath', () => {
|
||||
it('should find CLI from environment variable', () => {
|
||||
const originalEnv = process.env['QWEN_CODE_CLI_PATH'];
|
||||
process.env['QWEN_CODE_CLI_PATH'] = '/custom/path/to/qwen';
|
||||
mockFs.existsSync.mockReturnValue(true);
|
||||
|
||||
const result = findNativeCliPath();
|
||||
|
||||
expect(result).toBe(path.resolve('/custom/path/to/qwen'));
|
||||
|
||||
process.env['QWEN_CODE_CLI_PATH'] = originalEnv;
|
||||
});
|
||||
|
||||
it('should search common installation locations', () => {
|
||||
const originalEnv = process.env['QWEN_CODE_CLI_PATH'];
|
||||
delete process.env['QWEN_CODE_CLI_PATH'];
|
||||
|
||||
// Mock fs.existsSync to return true for volta bin
|
||||
// Use path.join to match platform-specific path separators
|
||||
const voltaBinPath = path.join('.volta', 'bin', 'qwen');
|
||||
it('should find bundled CLI', () => {
|
||||
// Mock existsSync to return true for bundled CLI
|
||||
mockFs.existsSync.mockImplementation((p) => {
|
||||
return p.toString().includes(voltaBinPath);
|
||||
const pathStr = p.toString();
|
||||
return (
|
||||
pathStr.includes('cli/cli.js') || pathStr.includes('cli\\cli.js')
|
||||
);
|
||||
});
|
||||
|
||||
const result = findNativeCliPath();
|
||||
|
||||
expect(result).toContain(voltaBinPath);
|
||||
|
||||
process.env['QWEN_CODE_CLI_PATH'] = originalEnv;
|
||||
expect(result).toContain('cli.js');
|
||||
});
|
||||
|
||||
it('should throw descriptive error when CLI not found', () => {
|
||||
const originalEnv = process.env['QWEN_CODE_CLI_PATH'];
|
||||
delete process.env['QWEN_CODE_CLI_PATH'];
|
||||
it('should throw descriptive error when bundled CLI not found', () => {
|
||||
mockFs.existsSync.mockReturnValue(false);
|
||||
|
||||
expect(() => findNativeCliPath()).toThrow('qwen CLI not found. Please:');
|
||||
|
||||
process.env['QWEN_CODE_CLI_PATH'] = originalEnv;
|
||||
expect(() => findNativeCliPath()).toThrow('Bundled qwen CLI not found');
|
||||
});
|
||||
});
|
||||
|
||||
@@ -634,13 +613,10 @@ describe('CLI Path Utilities', () => {
|
||||
mockFs.existsSync.mockReturnValue(false);
|
||||
|
||||
expect(() => parseExecutableSpec('/missing/file')).toThrow(
|
||||
'Set QWEN_CODE_CLI_PATH environment variable',
|
||||
'Executable file not found at',
|
||||
);
|
||||
expect(() => parseExecutableSpec('/missing/file')).toThrow(
|
||||
'Install qwen globally: npm install -g qwen',
|
||||
);
|
||||
expect(() => parseExecutableSpec('/missing/file')).toThrow(
|
||||
'Force specific runtime: bun:/path/to/cli.js or tsx:/path/to/index.ts',
|
||||
'Please check the file path and ensure the file exists',
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@qwen-code/qwen-code-test-utils",
|
||||
"version": "0.5.1",
|
||||
"version": "0.6.0",
|
||||
"private": true,
|
||||
"main": "src/index.ts",
|
||||
"license": "Apache-2.0",
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
"name": "qwen-code-vscode-ide-companion",
|
||||
"displayName": "Qwen Code Companion",
|
||||
"description": "Enable Qwen Code with direct access to your VS Code workspace.",
|
||||
"version": "0.5.1",
|
||||
"version": "0.6.0",
|
||||
"publisher": "qwenlm",
|
||||
"icon": "assets/icon.png",
|
||||
"repository": {
|
||||
@@ -11,7 +11,7 @@
|
||||
"directory": "packages/vscode-ide-companion"
|
||||
},
|
||||
"engines": {
|
||||
"vscode": "^1.99.0"
|
||||
"vscode": "^1.85.0"
|
||||
},
|
||||
"license": "LICENSE",
|
||||
"preview": true,
|
||||
@@ -137,7 +137,7 @@
|
||||
"@types/react": "^19.1.8",
|
||||
"@types/react-dom": "^19.1.6",
|
||||
"@types/semver": "^7.7.1",
|
||||
"@types/vscode": "^1.99.0",
|
||||
"@types/vscode": "^1.85.0",
|
||||
"@typescript-eslint/eslint-plugin": "^8.31.1",
|
||||
"@typescript-eslint/parser": "^8.31.1",
|
||||
"@vscode/vsce": "^3.6.0",
|
||||
|
||||
@@ -27,13 +27,14 @@ vi.mock('node:fs/promises', () => ({
|
||||
writeFile: vi.fn(() => Promise.resolve(undefined)),
|
||||
unlink: vi.fn(() => Promise.resolve(undefined)),
|
||||
chmod: vi.fn(() => Promise.resolve(undefined)),
|
||||
mkdir: vi.fn(() => Promise.resolve(undefined)),
|
||||
}));
|
||||
|
||||
vi.mock('node:os', async (importOriginal) => {
|
||||
const actual = await importOriginal<typeof os>();
|
||||
return {
|
||||
...actual,
|
||||
tmpdir: vi.fn(() => '/tmp'),
|
||||
homedir: vi.fn(() => '/home/test'),
|
||||
};
|
||||
});
|
||||
|
||||
@@ -128,30 +129,24 @@ describe('IDEServer', () => {
|
||||
);
|
||||
|
||||
const port = getPortFromMock(replaceMock);
|
||||
const expectedPortFile = path.join(
|
||||
'/tmp',
|
||||
`qwen-code-ide-server-${port}.json`,
|
||||
);
|
||||
const expectedPpidPortFile = path.join(
|
||||
'/tmp',
|
||||
`qwen-code-ide-server-${process.ppid}.json`,
|
||||
const expectedLockFile = path.join(
|
||||
'/home/test',
|
||||
'.qwen',
|
||||
'ide',
|
||||
`${port}.lock`,
|
||||
);
|
||||
const expectedContent = JSON.stringify({
|
||||
port: parseInt(port, 10),
|
||||
workspacePath: expectedWorkspacePaths,
|
||||
ppid: process.ppid,
|
||||
authToken: 'test-auth-token',
|
||||
ideName: 'VS Code',
|
||||
});
|
||||
expect(fs.writeFile).toHaveBeenCalledWith(
|
||||
expectedPortFile,
|
||||
expectedLockFile,
|
||||
expectedContent,
|
||||
);
|
||||
expect(fs.writeFile).toHaveBeenCalledWith(
|
||||
expectedPpidPortFile,
|
||||
expectedContent,
|
||||
);
|
||||
expect(fs.chmod).toHaveBeenCalledWith(expectedPortFile, 0o600);
|
||||
expect(fs.chmod).toHaveBeenCalledWith(expectedPpidPortFile, 0o600);
|
||||
expect(fs.chmod).toHaveBeenCalledWith(expectedLockFile, 0o600);
|
||||
});
|
||||
|
||||
it('should set a single folder path', async () => {
|
||||
@@ -166,30 +161,24 @@ describe('IDEServer', () => {
|
||||
);
|
||||
|
||||
const port = getPortFromMock(replaceMock);
|
||||
const expectedPortFile = path.join(
|
||||
'/tmp',
|
||||
`qwen-code-ide-server-${port}.json`,
|
||||
);
|
||||
const expectedPpidPortFile = path.join(
|
||||
'/tmp',
|
||||
`qwen-code-ide-server-${process.ppid}.json`,
|
||||
const expectedLockFile = path.join(
|
||||
'/home/test',
|
||||
'.qwen',
|
||||
'ide',
|
||||
`${port}.lock`,
|
||||
);
|
||||
const expectedContent = JSON.stringify({
|
||||
port: parseInt(port, 10),
|
||||
workspacePath: '/foo/bar',
|
||||
ppid: process.ppid,
|
||||
authToken: 'test-auth-token',
|
||||
ideName: 'VS Code',
|
||||
});
|
||||
expect(fs.writeFile).toHaveBeenCalledWith(
|
||||
expectedPortFile,
|
||||
expectedLockFile,
|
||||
expectedContent,
|
||||
);
|
||||
expect(fs.writeFile).toHaveBeenCalledWith(
|
||||
expectedPpidPortFile,
|
||||
expectedContent,
|
||||
);
|
||||
expect(fs.chmod).toHaveBeenCalledWith(expectedPortFile, 0o600);
|
||||
expect(fs.chmod).toHaveBeenCalledWith(expectedPpidPortFile, 0o600);
|
||||
expect(fs.chmod).toHaveBeenCalledWith(expectedLockFile, 0o600);
|
||||
});
|
||||
|
||||
it('should set an empty string if no folders are open', async () => {
|
||||
@@ -204,30 +193,24 @@ describe('IDEServer', () => {
|
||||
);
|
||||
|
||||
const port = getPortFromMock(replaceMock);
|
||||
const expectedPortFile = path.join(
|
||||
'/tmp',
|
||||
`qwen-code-ide-server-${port}.json`,
|
||||
);
|
||||
const expectedPpidPortFile = path.join(
|
||||
'/tmp',
|
||||
`qwen-code-ide-server-${process.ppid}.json`,
|
||||
const expectedLockFile = path.join(
|
||||
'/home/test',
|
||||
'.qwen',
|
||||
'ide',
|
||||
`${port}.lock`,
|
||||
);
|
||||
const expectedContent = JSON.stringify({
|
||||
port: parseInt(port, 10),
|
||||
workspacePath: '',
|
||||
ppid: process.ppid,
|
||||
authToken: 'test-auth-token',
|
||||
ideName: 'VS Code',
|
||||
});
|
||||
expect(fs.writeFile).toHaveBeenCalledWith(
|
||||
expectedPortFile,
|
||||
expectedLockFile,
|
||||
expectedContent,
|
||||
);
|
||||
expect(fs.writeFile).toHaveBeenCalledWith(
|
||||
expectedPpidPortFile,
|
||||
expectedContent,
|
||||
);
|
||||
expect(fs.chmod).toHaveBeenCalledWith(expectedPortFile, 0o600);
|
||||
expect(fs.chmod).toHaveBeenCalledWith(expectedPpidPortFile, 0o600);
|
||||
expect(fs.chmod).toHaveBeenCalledWith(expectedLockFile, 0o600);
|
||||
});
|
||||
|
||||
it('should update the path when workspace folders change', async () => {
|
||||
@@ -256,30 +239,24 @@ describe('IDEServer', () => {
|
||||
);
|
||||
|
||||
const port = getPortFromMock(replaceMock);
|
||||
const expectedPortFile = path.join(
|
||||
'/tmp',
|
||||
`qwen-code-ide-server-${port}.json`,
|
||||
);
|
||||
const expectedPpidPortFile = path.join(
|
||||
'/tmp',
|
||||
`qwen-code-ide-server-${process.ppid}.json`,
|
||||
const expectedLockFile = path.join(
|
||||
'/home/test',
|
||||
'.qwen',
|
||||
'ide',
|
||||
`${port}.lock`,
|
||||
);
|
||||
const expectedContent = JSON.stringify({
|
||||
port: parseInt(port, 10),
|
||||
workspacePath: expectedWorkspacePaths,
|
||||
ppid: process.ppid,
|
||||
authToken: 'test-auth-token',
|
||||
ideName: 'VS Code',
|
||||
});
|
||||
expect(fs.writeFile).toHaveBeenCalledWith(
|
||||
expectedPortFile,
|
||||
expectedLockFile,
|
||||
expectedContent,
|
||||
);
|
||||
expect(fs.writeFile).toHaveBeenCalledWith(
|
||||
expectedPpidPortFile,
|
||||
expectedContent,
|
||||
);
|
||||
expect(fs.chmod).toHaveBeenCalledWith(expectedPortFile, 0o600);
|
||||
expect(fs.chmod).toHaveBeenCalledWith(expectedPpidPortFile, 0o600);
|
||||
expect(fs.chmod).toHaveBeenCalledWith(expectedLockFile, 0o600);
|
||||
|
||||
// Simulate removing a folder
|
||||
vscodeMock.workspace.workspaceFolders = [{ uri: { fsPath: '/baz/qux' } }];
|
||||
@@ -294,36 +271,26 @@ describe('IDEServer', () => {
|
||||
workspacePath: '/baz/qux',
|
||||
ppid: process.ppid,
|
||||
authToken: 'test-auth-token',
|
||||
ideName: 'VS Code',
|
||||
});
|
||||
expect(fs.writeFile).toHaveBeenCalledWith(
|
||||
expectedPortFile,
|
||||
expectedLockFile,
|
||||
expectedContent2,
|
||||
);
|
||||
expect(fs.writeFile).toHaveBeenCalledWith(
|
||||
expectedPpidPortFile,
|
||||
expectedContent2,
|
||||
);
|
||||
expect(fs.chmod).toHaveBeenCalledWith(expectedPortFile, 0o600);
|
||||
expect(fs.chmod).toHaveBeenCalledWith(expectedPpidPortFile, 0o600);
|
||||
expect(fs.chmod).toHaveBeenCalledWith(expectedLockFile, 0o600);
|
||||
});
|
||||
|
||||
it('should clear env vars and delete port file on stop', async () => {
|
||||
it('should clear env vars and delete lock file on stop', async () => {
|
||||
await ideServer.start(mockContext);
|
||||
const replaceMock = mockContext.environmentVariableCollection.replace;
|
||||
const port = getPortFromMock(replaceMock);
|
||||
const portFile = path.join('/tmp', `qwen-code-ide-server-${port}.json`);
|
||||
const ppidPortFile = path.join(
|
||||
'/tmp',
|
||||
`qwen-code-ide-server-${process.ppid}.json`,
|
||||
);
|
||||
expect(fs.writeFile).toHaveBeenCalledWith(portFile, expect.any(String));
|
||||
expect(fs.writeFile).toHaveBeenCalledWith(ppidPortFile, expect.any(String));
|
||||
const lockFile = path.join('/home/test', '.qwen', 'ide', `${port}.lock`);
|
||||
expect(fs.writeFile).toHaveBeenCalledWith(lockFile, expect.any(String));
|
||||
|
||||
await ideServer.stop();
|
||||
|
||||
expect(mockContext.environmentVariableCollection.clear).toHaveBeenCalled();
|
||||
expect(fs.unlink).toHaveBeenCalledWith(portFile);
|
||||
expect(fs.unlink).toHaveBeenCalledWith(ppidPortFile);
|
||||
expect(fs.unlink).toHaveBeenCalledWith(lockFile);
|
||||
});
|
||||
|
||||
it.skipIf(process.platform !== 'win32')(
|
||||
@@ -344,30 +311,24 @@ describe('IDEServer', () => {
|
||||
);
|
||||
|
||||
const port = getPortFromMock(replaceMock);
|
||||
const expectedPortFile = path.join(
|
||||
'/tmp',
|
||||
`qwen-code-ide-server-${port}.json`,
|
||||
);
|
||||
const expectedPpidPortFile = path.join(
|
||||
'/tmp',
|
||||
`qwen-code-ide-server-${process.ppid}.json`,
|
||||
const expectedLockFile = path.join(
|
||||
'/home/test',
|
||||
'.qwen',
|
||||
'ide',
|
||||
`${port}.lock`,
|
||||
);
|
||||
const expectedContent = JSON.stringify({
|
||||
port: parseInt(port, 10),
|
||||
workspacePath: expectedWorkspacePaths,
|
||||
ppid: process.ppid,
|
||||
authToken: 'test-auth-token',
|
||||
ideName: 'VS Code',
|
||||
});
|
||||
expect(fs.writeFile).toHaveBeenCalledWith(
|
||||
expectedPortFile,
|
||||
expectedLockFile,
|
||||
expectedContent,
|
||||
);
|
||||
expect(fs.writeFile).toHaveBeenCalledWith(
|
||||
expectedPpidPortFile,
|
||||
expectedContent,
|
||||
);
|
||||
expect(fs.chmod).toHaveBeenCalledWith(expectedPortFile, 0o600);
|
||||
expect(fs.chmod).toHaveBeenCalledWith(expectedPpidPortFile, 0o600);
|
||||
expect(fs.chmod).toHaveBeenCalledWith(expectedLockFile, 0o600);
|
||||
},
|
||||
);
|
||||
|
||||
@@ -379,7 +340,7 @@ describe('IDEServer', () => {
|
||||
port = (ideServer as unknown as { port: number }).port;
|
||||
});
|
||||
|
||||
it('should allow request without auth token for backwards compatibility', async () => {
|
||||
it('should reject request without auth token', async () => {
|
||||
const response = await fetch(`http://localhost:${port}/mcp`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
@@ -390,7 +351,9 @@ describe('IDEServer', () => {
|
||||
id: 1,
|
||||
}),
|
||||
});
|
||||
expect(response.status).not.toBe(401);
|
||||
expect(response.status).toBe(401);
|
||||
const body = await response.text();
|
||||
expect(body).toBe('Unauthorized');
|
||||
});
|
||||
|
||||
it('should allow request with valid auth token', async () => {
|
||||
@@ -550,6 +513,7 @@ describe('IDEServer HTTP endpoints', () => {
|
||||
headers: {
|
||||
Host: `localhost:${port}`,
|
||||
'Content-Type': 'application/json',
|
||||
Authorization: 'Bearer test-auth-token',
|
||||
},
|
||||
},
|
||||
JSON.stringify({ jsonrpc: '2.0', method: 'initialize' }),
|
||||
|
||||
@@ -10,6 +10,7 @@ import {
|
||||
IdeContextNotificationSchema,
|
||||
OpenDiffRequestSchema,
|
||||
} from '@qwen-code/qwen-code-core/src/ide/types.js';
|
||||
import { detectIdeFromEnv } from '@qwen-code/qwen-code-core/src/ide/detect-ide.js';
|
||||
import { isInitializeRequest } from '@modelcontextprotocol/sdk/types.js';
|
||||
import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js';
|
||||
import { StreamableHTTPServerTransport } from '@modelcontextprotocol/sdk/server/streamableHttp.js';
|
||||
@@ -38,12 +39,24 @@ class CORSError extends Error {
|
||||
const MCP_SESSION_ID_HEADER = 'mcp-session-id';
|
||||
const IDE_SERVER_PORT_ENV_VAR = 'QWEN_CODE_IDE_SERVER_PORT';
|
||||
const IDE_WORKSPACE_PATH_ENV_VAR = 'QWEN_CODE_IDE_WORKSPACE_PATH';
|
||||
const QWEN_DIR = '.qwen';
|
||||
const IDE_DIR = 'ide';
|
||||
|
||||
async function getGlobalIdeDir(): Promise<string> {
|
||||
const homeDir = os.homedir();
|
||||
// Prefer home dir, but fall back to tmpdir if unavailable (matches core Storage behavior).
|
||||
const baseDir = homeDir
|
||||
? path.join(homeDir, QWEN_DIR)
|
||||
: path.join(os.tmpdir(), QWEN_DIR);
|
||||
const ideDir = path.join(baseDir, IDE_DIR);
|
||||
await fs.mkdir(ideDir, { recursive: true });
|
||||
return ideDir;
|
||||
}
|
||||
|
||||
interface WritePortAndWorkspaceArgs {
|
||||
context: vscode.ExtensionContext;
|
||||
port: number;
|
||||
portFile: string;
|
||||
ppidPortFile: string;
|
||||
lockFile: string;
|
||||
authToken: string;
|
||||
log: (message: string) => void;
|
||||
}
|
||||
@@ -51,8 +64,7 @@ interface WritePortAndWorkspaceArgs {
|
||||
async function writePortAndWorkspace({
|
||||
context,
|
||||
port,
|
||||
portFile,
|
||||
ppidPortFile,
|
||||
lockFile,
|
||||
authToken,
|
||||
log,
|
||||
}: WritePortAndWorkspaceArgs): Promise<void> {
|
||||
@@ -71,26 +83,24 @@ async function writePortAndWorkspace({
|
||||
workspacePath,
|
||||
);
|
||||
|
||||
const ideInfo = detectIdeFromEnv();
|
||||
const content = JSON.stringify({
|
||||
port,
|
||||
workspacePath,
|
||||
ppid: process.ppid,
|
||||
authToken,
|
||||
ideName: ideInfo.displayName,
|
||||
});
|
||||
|
||||
log(`Writing port file to: ${portFile}`);
|
||||
log(`Writing ppid port file to: ${ppidPortFile}`);
|
||||
log(`Writing IDE lock file to: ${lockFile}`);
|
||||
|
||||
try {
|
||||
await Promise.all([
|
||||
fs.writeFile(portFile, content).then(() => fs.chmod(portFile, 0o600)),
|
||||
fs
|
||||
.writeFile(ppidPortFile, content)
|
||||
.then(() => fs.chmod(ppidPortFile, 0o600)),
|
||||
]);
|
||||
await fs.mkdir(path.dirname(lockFile), { recursive: true });
|
||||
await fs.writeFile(lockFile, content);
|
||||
await fs.chmod(lockFile, 0o600);
|
||||
} catch (err) {
|
||||
const message = err instanceof Error ? err.message : String(err);
|
||||
log(`Failed to write port to file: ${message}`);
|
||||
log(`Failed to write IDE lock file: ${message}`);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -121,8 +131,7 @@ export class IDEServer {
|
||||
private server: HTTPServer | undefined;
|
||||
private context: vscode.ExtensionContext | undefined;
|
||||
private log: (message: string) => void;
|
||||
private portFile: string | undefined;
|
||||
private ppidPortFile: string | undefined;
|
||||
private lockFile: string | undefined;
|
||||
private port: number | undefined;
|
||||
private authToken: string | undefined;
|
||||
private transports: { [sessionId: string]: StreamableHTTPServerTransport } =
|
||||
@@ -174,20 +183,25 @@ export class IDEServer {
|
||||
|
||||
app.use((req, res, next) => {
|
||||
const authHeader = req.headers.authorization;
|
||||
if (authHeader) {
|
||||
if (!authHeader) {
|
||||
this.log('Missing Authorization header. Rejecting request.');
|
||||
res.status(401).send('Unauthorized');
|
||||
return;
|
||||
}
|
||||
|
||||
const parts = authHeader.split(' ');
|
||||
if (parts.length !== 2 || parts[0] !== 'Bearer') {
|
||||
this.log('Malformed Authorization header. Rejecting request.');
|
||||
res.status(401).send('Unauthorized');
|
||||
return;
|
||||
}
|
||||
|
||||
const token = parts[1];
|
||||
if (token !== this.authToken) {
|
||||
this.log('Invalid auth token provided. Rejecting request.');
|
||||
res.status(401).send('Unauthorized');
|
||||
return;
|
||||
}
|
||||
}
|
||||
next();
|
||||
});
|
||||
|
||||
@@ -327,22 +341,21 @@ export class IDEServer {
|
||||
const address = (this.server as HTTPServer).address();
|
||||
if (address && typeof address !== 'string') {
|
||||
this.port = address.port;
|
||||
this.portFile = path.join(
|
||||
os.tmpdir(),
|
||||
`qwen-code-ide-server-${this.port}.json`,
|
||||
);
|
||||
this.ppidPortFile = path.join(
|
||||
os.tmpdir(),
|
||||
`qwen-code-ide-server-${process.ppid}.json`,
|
||||
);
|
||||
try {
|
||||
const ideDir = await getGlobalIdeDir();
|
||||
// Name the lock file by port to support multiple server instances.
|
||||
this.lockFile = path.join(ideDir, `${this.port}.lock`);
|
||||
} catch (err) {
|
||||
const message = err instanceof Error ? err.message : String(err);
|
||||
this.log(`Failed to determine IDE lock directory: ${message}`);
|
||||
}
|
||||
this.log(`IDE server listening on http://127.0.0.1:${this.port}`);
|
||||
|
||||
if (this.authToken) {
|
||||
if (this.authToken && this.lockFile) {
|
||||
await writePortAndWorkspace({
|
||||
context,
|
||||
port: this.port,
|
||||
portFile: this.portFile,
|
||||
ppidPortFile: this.ppidPortFile,
|
||||
lockFile: this.lockFile,
|
||||
authToken: this.authToken,
|
||||
log: this.log,
|
||||
});
|
||||
@@ -371,15 +384,13 @@ export class IDEServer {
|
||||
this.context &&
|
||||
this.server &&
|
||||
this.port &&
|
||||
this.portFile &&
|
||||
this.ppidPortFile &&
|
||||
this.lockFile &&
|
||||
this.authToken
|
||||
) {
|
||||
await writePortAndWorkspace({
|
||||
context: this.context,
|
||||
port: this.port,
|
||||
portFile: this.portFile,
|
||||
ppidPortFile: this.ppidPortFile,
|
||||
lockFile: this.lockFile,
|
||||
authToken: this.authToken,
|
||||
log: this.log,
|
||||
});
|
||||
@@ -405,16 +416,9 @@ export class IDEServer {
|
||||
if (this.context) {
|
||||
this.context.environmentVariableCollection.clear();
|
||||
}
|
||||
if (this.portFile) {
|
||||
if (this.lockFile) {
|
||||
try {
|
||||
await fs.unlink(this.portFile);
|
||||
} catch (_err) {
|
||||
// Ignore errors if the file doesn't exist.
|
||||
}
|
||||
}
|
||||
if (this.ppidPortFile) {
|
||||
try {
|
||||
await fs.unlink(this.ppidPortFile);
|
||||
await fs.unlink(this.lockFile);
|
||||
} catch (_err) {
|
||||
// Ignore errors if the file doesn't exist.
|
||||
}
|
||||
|
||||
@@ -48,5 +48,5 @@
|
||||
}
|
||||
|
||||
.assistant-message-container.assistant-message-loading::after {
|
||||
display: none
|
||||
display: none;
|
||||
}
|
||||
|
||||
@@ -172,7 +172,8 @@
|
||||
|
||||
/* Loading animation for toolcall header */
|
||||
@keyframes toolcallHeaderPulse {
|
||||
0%, 100% {
|
||||
0%,
|
||||
100% {
|
||||
opacity: 1;
|
||||
}
|
||||
50% {
|
||||
|
||||
@@ -51,7 +51,8 @@
|
||||
.composer-form:focus-within {
|
||||
/* match existing highlight behavior */
|
||||
border-color: var(--app-input-highlight);
|
||||
box-shadow: 0 1px 2px color-mix(in srgb, var(--app-input-highlight), transparent 80%);
|
||||
box-shadow: 0 1px 2px
|
||||
color-mix(in srgb, var(--app-input-highlight), transparent 80%);
|
||||
}
|
||||
|
||||
/* Composer: input editable area */
|
||||
@@ -66,7 +67,7 @@
|
||||
The data attribute is needed because some browsers insert a <br> in
|
||||
contentEditable, which breaks :empty matching. */
|
||||
.composer-input:empty:before,
|
||||
.composer-input[data-empty="true"]::before {
|
||||
.composer-input[data-empty='true']::before {
|
||||
content: attr(data-placeholder);
|
||||
color: var(--app-input-placeholder-foreground);
|
||||
pointer-events: none;
|
||||
@@ -80,7 +81,7 @@
|
||||
outline: none;
|
||||
}
|
||||
.composer-input:disabled,
|
||||
.composer-input[contenteditable="false"] {
|
||||
.composer-input[contenteditable='false'] {
|
||||
color: #999;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user