Sync upstream Gemini-CLI v0.8.2 (#838)

This commit is contained in:
tanzhenxin
2025-10-23 09:27:04 +08:00
committed by GitHub
parent 096fabb5d6
commit eb95c131be
644 changed files with 70389 additions and 23709 deletions

View File

@@ -460,7 +460,7 @@ SOFTWARE.
============================================================
express@5.1.0
express@4.21.2
(No repository found)
(The MIT License)
@@ -490,7 +490,7 @@ SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
============================================================
accepts@2.0.0
accepts@1.3.8
(No repository found)
(The MIT License)
@@ -577,7 +577,7 @@ SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
============================================================
negotiator@1.0.0
negotiator@0.6.3
(No repository found)
(The MIT License)
@@ -607,7 +607,34 @@ SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
============================================================
body-parser@2.2.0
array-flatten@1.1.1
(git://github.com/blakeembrey/array-flatten.git)
The MIT License (MIT)
Copyright (c) 2014 Blake Embrey (hello@blakeembrey.com)
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in
all copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
THE SOFTWARE.
============================================================
body-parser@1.20.3
(No repository found)
(The MIT License)
@@ -717,6 +744,63 @@ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.
============================================================
depd@2.0.0
(No repository found)
(The MIT License)
Copyright (c) 2014-2018 Douglas Christopher Wilson
Permission is hereby granted, free of charge, to any person obtaining
a copy of this software and associated documentation files (the
'Software'), to deal in the Software without restriction, including
without limitation the rights to use, copy, modify, merge, publish,
distribute, sublicense, and/or sell copies of the Software, and to
permit persons to whom the Software is furnished to do so, subject to
the following conditions:
The above copyright notice and this permission notice shall be
included in all copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED 'AS IS', WITHOUT WARRANTY OF ANY KIND,
EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF
MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT.
IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY
CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT,
TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE
SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
============================================================
destroy@1.2.0
(No repository found)
The MIT License (MIT)
Copyright (c) 2014 Jonathan Ong me@jongleberry.com
Copyright (c) 2015-2022 Douglas Christopher Wilson doug@somethingdoug.com
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in
all copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
THE SOFTWARE.
============================================================
http-errors@2.0.0
(No repository found)
@@ -746,34 +830,6 @@ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
THE SOFTWARE.
============================================================
depd@2.0.0
(No repository found)
(The MIT License)
Copyright (c) 2014-2018 Douglas Christopher Wilson
Permission is hereby granted, free of charge, to any person obtaining
a copy of this software and associated documentation files (the
'Software'), to deal in the Software without restriction, including
without limitation the rights to use, copy, modify, merge, publish,
distribute, sublicense, and/or sell copies of the Software, and to
permit persons to whom the Software is furnished to do so, subject to
the following conditions:
The above copyright notice and this permission notice shall be
included in all copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED 'AS IS', WITHOUT WARRANTY OF ANY KIND,
EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF
MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT.
IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY
CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT,
TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE
SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
============================================================
inherits@2.0.4
(No repository found)
@@ -983,7 +1039,7 @@ THE SOFTWARE.
============================================================
qs@6.14.0
qs@6.13.0
(https://github.com/ljharb/qs.git)
BSD 3-Clause License
@@ -1559,7 +1615,7 @@ SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
============================================================
type-is@2.0.1
type-is@1.6.18
(No repository found)
(The MIT License)
@@ -1588,12 +1644,12 @@ SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
============================================================
media-typer@1.1.0
media-typer@0.3.0
(No repository found)
(The MIT License)
Copyright (c) 2014-2017 Douglas Christopher Wilson
Copyright (c) 2014 Douglas Christopher Wilson
Permission is hereby granted, free of charge, to any person obtaining
a copy of this software and associated documentation files (the
@@ -1616,7 +1672,7 @@ SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
============================================================
content-disposition@1.0.0
content-disposition@0.5.4
(No repository found)
(The MIT License)
@@ -1701,32 +1757,10 @@ SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
============================================================
cookie-signature@1.2.2
cookie-signature@1.0.6
(https://github.com/visionmedia/node-cookie-signature.git)
(The MIT License)
Copyright (c) 20122024 LearnBoost <tj@learnboost.com> and other contributors;
Permission is hereby granted, free of charge, to any person obtaining
a copy of this software and associated documentation files (the
'Software'), to deal in the Software without restriction, including
without limitation the rights to use, copy, modify, merge, publish,
distribute, sublicense, and/or sell copies of the Software, and to
permit persons to whom the Software is furnished to do so, subject to
the following conditions:
The above copyright notice and this permission notice shall be
included in all copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED 'AS IS', WITHOUT WARRANTY OF ANY KIND,
EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF
MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT.
IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY
CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT,
TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE
SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
License text not found.
============================================================
encodeurl@2.0.0
@@ -1815,7 +1849,7 @@ SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
============================================================
finalhandler@2.1.0
finalhandler@1.3.1
(No repository found)
(The MIT License)
@@ -1873,7 +1907,7 @@ SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
============================================================
fresh@2.0.0
fresh@0.5.2
(No repository found)
(The MIT License)
@@ -1902,62 +1936,89 @@ SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
============================================================
merge-descriptors@2.0.0
merge-descriptors@1.0.3
(No repository found)
MIT License
(The MIT License)
Copyright (c) Jonathan Ong <me@jongleberry.com>
Copyright (c) Douglas Christopher Wilson <doug@somethingdoug.com>
Copyright (c) Sindre Sorhus <sindresorhus@gmail.com> (https://sindresorhus.com)
Copyright (c) 2013 Jonathan Ong <me@jongleberry.com>
Copyright (c) 2015 Douglas Christopher Wilson <doug@somethingdoug.com>
Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions:
Permission is hereby granted, free of charge, to any person obtaining
a copy of this software and associated documentation files (the
'Software'), to deal in the Software without restriction, including
without limitation the rights to use, copy, modify, merge, publish,
distribute, sublicense, and/or sell copies of the Software, and to
permit persons to whom the Software is furnished to do so, subject to
the following conditions:
The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software.
The above copyright notice and this permission notice shall be
included in all copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
THE SOFTWARE IS PROVIDED 'AS IS', WITHOUT WARRANTY OF ANY KIND,
EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF
MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT.
IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY
CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT,
TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE
SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
============================================================
once@1.4.0
(git://github.com/isaacs/once)
methods@1.1.2
(No repository found)
The ISC License
(The MIT License)
Copyright (c) Isaac Z. Schlueter and Contributors
Copyright (c) 2013-2014 TJ Holowaychuk <tj@vision-media.ca>
Copyright (c) 2015-2016 Douglas Christopher Wilson <doug@somethingdoug.com>
Permission to use, copy, modify, and/or distribute this software for any
purpose with or without fee is hereby granted, provided that the above
copyright notice and this permission notice appear in all copies.
Permission is hereby granted, free of charge, to any person obtaining
a copy of this software and associated documentation files (the
'Software'), to deal in the Software without restriction, including
without limitation the rights to use, copy, modify, merge, publish,
distribute, sublicense, and/or sell copies of the Software, and to
permit persons to whom the Software is furnished to do so, subject to
the following conditions:
The above copyright notice and this permission notice shall be
included in all copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED 'AS IS', WITHOUT WARRANTY OF ANY KIND,
EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF
MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT.
IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY
CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT,
TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE
SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES
WITH REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF
MERCHANTABILITY AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR
ANY SPECIAL, DIRECT, INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES
WHATSOEVER RESULTING FROM LOSS OF USE, DATA OR PROFITS, WHETHER IN AN
ACTION OF CONTRACT, NEGLIGENCE OR OTHER TORTIOUS ACTION, ARISING OUT OF OR
IN CONNECTION WITH THE USE OR PERFORMANCE OF THIS SOFTWARE.
============================================================
wrappy@1.0.2
(https://github.com/npm/wrappy)
path-to-regexp@0.1.12
(https://github.com/pillarjs/path-to-regexp.git)
The ISC License
The MIT License (MIT)
Copyright (c) Isaac Z. Schlueter and Contributors
Copyright (c) 2014 Blake Embrey (hello@blakeembrey.com)
Permission to use, copy, modify, and/or distribute this software for any
purpose with or without fee is hereby granted, provided that the above
copyright notice and this permission notice appear in all copies.
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES
WITH REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF
MERCHANTABILITY AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR
ANY SPECIAL, DIRECT, INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES
WHATSOEVER RESULTING FROM LOSS OF USE, DATA OR PROFITS, WHETHER IN AN
ACTION OF CONTRACT, NEGLIGENCE OR OTHER TORTIOUS ACTION, ARISING OUT OF OR
IN CONNECTION WITH THE USE OR PERFORMANCE OF THIS SOFTWARE.
The above copyright notice and this permission notice shall be included in
all copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
THE SOFTWARE.
============================================================
@@ -2071,87 +2132,7 @@ SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
============================================================
router@2.2.0
(No repository found)
(The MIT License)
Copyright (c) 2013 Roman Shtylman
Copyright (c) 2014-2022 Douglas Christopher Wilson
Permission is hereby granted, free of charge, to any person obtaining
a copy of this software and associated documentation files (the
'Software'), to deal in the Software without restriction, including
without limitation the rights to use, copy, modify, merge, publish,
distribute, sublicense, and/or sell copies of the Software, and to
permit persons to whom the Software is furnished to do so, subject to
the following conditions:
The above copyright notice and this permission notice shall be
included in all copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED 'AS IS', WITHOUT WARRANTY OF ANY KIND,
EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF
MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT.
IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY
CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT,
TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE
SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
============================================================
is-promise@4.0.0
(https://github.com/then/is-promise.git)
Copyright (c) 2014 Forbes Lindesay
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in
all copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
THE SOFTWARE.
============================================================
path-to-regexp@8.2.0
(https://github.com/pillarjs/path-to-regexp.git)
The MIT License (MIT)
Copyright (c) 2014 Blake Embrey (hello@blakeembrey.com)
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in
all copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
THE SOFTWARE.
============================================================
send@1.2.0
send@0.19.0
(No repository found)
(The MIT License)
@@ -2180,7 +2161,7 @@ SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
============================================================
serve-static@2.2.0
serve-static@1.16.2
(No repository found)
(The MIT License)
@@ -2210,6 +2191,32 @@ TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE
SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
============================================================
utils-merge@1.0.1
(git://github.com/jaredhanson/utils-merge.git)
The MIT License (MIT)
Copyright (c) 2013-2017 Jared Hanson
Permission is hereby granted, free of charge, to any person obtaining a copy of
this software and associated documentation files (the "Software"), to deal in
the Software without restriction, including without limitation the rights to
use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of
the Software, and to permit persons to whom the Software is furnished to do so,
subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER
IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN
CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
============================================================
express-rate-limit@7.5.1
(git+https://github.com/express-rate-limit/express-rate-limit.git)

View File

@@ -1,30 +1,24 @@
# Local Development
# Local Development ⚙️
## Running the Extension
To run the extension locally for development:
To run the extension locally for development, we recommend using the automatic watch process for continuous compilation:
1. From the root of the repository, install dependencies:
1. **Install Dependencies** (from the root of the repository):
```bash
npm install
```
2. Open this directory (`packages/vscode-ide-companion`) in VS Code.
3. Compile the extension:
2. **Open in VS Code:** Open this directory (`packages/vscode-ide-companion`) in your VS Code editor.
3. **Start Watch Mode:** Run the watch script to compile the extension and monitor changes in both **esbuild** and **TypeScript**:
```bash
npm run compile
npm run watch
```
4. Press `F5` (fn+f5 on mac) to open a new Extension Development Host window with the extension running.
4. **Launch Host:** Press **`F5`** (or **`fn+F5`** on Mac) to open a new **Extension Development Host** window with the extension running.
To watch for changes and have the extension rebuild automatically, run:
### Manual Build
If you only need to compile the extension once without watching for changes:
```bash
npm run watch
```
## Running Tests
To run the automated tests, run:
```bash
npm run test
npm run build
```

View File

@@ -43,6 +43,12 @@ async function main() {
outfile: 'dist/extension.cjs',
external: ['vscode'],
logLevel: 'silent',
banner: {
js: `const import_meta = { url: require('url').pathToFileURL(__filename).href };`,
},
define: {
'import.meta.url': 'import_meta.url',
},
plugins: [
/* add to the end of plugins array */
esbuildProblemMatcherPlugin,

View File

@@ -96,18 +96,21 @@
"main": "./dist/extension.cjs",
"type": "module",
"scripts": {
"vscode:prepublish": "npm run generate:notices && npm run check-types && npm run lint && node esbuild.js --production",
"build": "npm run compile",
"compile": "npm run check-types && npm run lint && node esbuild.js",
"watch": "npm-run-all -p watch:*",
"prepackage": "npm run generate:notices && npm run check-types && npm run lint && npm run build:prod",
"build": "npm run build:dev",
"build:dev": "npm run check-types && npm run lint && node esbuild.js",
"build:prod": "node esbuild.js --production",
"generate:notices": "node ./scripts/generate-notices.js",
"prepare": "npm run generate:notices",
"check-types": "tsc --noEmit",
"lint": "eslint src",
"watch": "npm-run-all2 -p watch:*",
"watch:esbuild": "node esbuild.js --watch",
"watch:tsc": "tsc --noEmit --watch --project tsconfig.json",
"package": "vsce package --no-dependencies",
"generate:notices": "node ./scripts/generate-notices.js",
"check-types": "tsc --noEmit",
"lint": "eslint src",
"test": "vitest run",
"test:ci": "vitest run --coverage"
"test:ci": "vitest run --coverage",
"validate:notices": "node ./scripts/validate-notices.js"
},
"devDependencies": {
"@types/cors": "^2.8.19",
@@ -116,9 +119,10 @@
"@types/vscode": "^1.99.0",
"@typescript-eslint/eslint-plugin": "^8.31.1",
"@typescript-eslint/parser": "^8.31.1",
"@vscode/vsce": "^3.6.0",
"esbuild": "^0.25.3",
"eslint": "^9.25.1",
"npm-run-all": "^4.1.5",
"npm-run-all2": "^8.0.2",
"typescript": "^5.8.3",
"vitest": "^3.2.4"
},

View File

@@ -50,6 +50,8 @@ async function getDependencyLicense(depName, depVersion) {
'LICENSE.md',
'LICENSE.txt',
'LICENSE-MIT.txt',
'license.md',
'license',
].filter(Boolean);
let licenseFile;

View File

@@ -7,7 +7,7 @@
import {
IdeDiffAcceptedNotificationSchema,
IdeDiffClosedNotificationSchema,
} from '@qwen-code/qwen-code-core';
} from '@qwen-code/qwen-code-core/src/ide/types.js';
import { type JSONRPCNotification } from '@modelcontextprotocol/sdk/types.js';
import * as path from 'node:path';
import * as vscode from 'vscode';
@@ -132,7 +132,7 @@ export class DiffManager {
/**
* Closes an open diff view for a specific file.
*/
async closeDiff(filePath: string) {
async closeDiff(filePath: string, suppressNotification = false) {
let uriToClose: vscode.Uri | undefined;
for (const [uriString, diffInfo] of this.diffDocuments.entries()) {
if (diffInfo.originalFilePath === filePath) {
@@ -145,16 +145,18 @@ export class DiffManager {
const rightDoc = await vscode.workspace.openTextDocument(uriToClose);
const modifiedContent = rightDoc.getText();
await this.closeDiffEditor(uriToClose);
this.onDidChangeEmitter.fire(
IdeDiffClosedNotificationSchema.parse({
jsonrpc: '2.0',
method: 'ide/diffClosed',
params: {
filePath,
content: modifiedContent,
},
}),
);
if (!suppressNotification) {
this.onDidChangeEmitter.fire(
IdeDiffClosedNotificationSchema.parse({
jsonrpc: '2.0',
method: 'ide/diffClosed',
params: {
filePath,
content: modifiedContent,
},
}),
);
}
return modifiedContent;
}
return;

View File

@@ -7,6 +7,20 @@
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import * as vscode from 'vscode';
import { activate } from './extension.js';
import {
IDE_DEFINITIONS,
detectIdeFromEnv,
} from '@qwen-code/qwen-code-core/src/ide/detect-ide.js';
vi.mock('@qwen-code/qwen-code-core/src/ide/detect-ide.js', async () => {
const actual = await vi.importActual(
'@qwen-code/qwen-code-core/src/ide/detect-ide.js',
);
return {
...actual,
detectIdeFromEnv: vi.fn(() => IDE_DEFINITIONS.vscode),
};
});
vi.mock('vscode', () => ({
window: {
@@ -32,6 +46,7 @@ vi.mock('vscode', () => ({
onDidCloseTextDocument: vi.fn(),
registerTextDocumentContentProvider: vi.fn(),
onDidChangeWorkspaceFolders: vi.fn(),
onDidGrantWorkspaceTrust: vi.fn(),
},
commands: {
registerCommand: vi.fn(),
@@ -49,12 +64,18 @@ vi.mock('vscode', () => ({
fire: vi.fn(),
dispose: vi.fn(),
})),
extensions: {
getExtension: vi.fn(),
},
}));
describe('activate', () => {
let context: vscode.ExtensionContext;
beforeEach(() => {
vi.mocked(vscode.window.showInformationMessage).mockResolvedValue(
undefined,
);
context = {
subscriptions: [],
environmentVariableCollection: {
@@ -67,6 +88,11 @@ describe('activate', () => {
extensionUri: {
fsPath: '/path/to/extension',
},
extension: {
packageJSON: {
version: '1.1.0',
},
},
} as unknown as vscode.ExtensionContext;
});
@@ -79,6 +105,9 @@ describe('activate', () => {
.mocked(vscode.window.showInformationMessage)
.mockResolvedValue(undefined as never);
vi.mocked(context.globalState.get).mockReturnValue(undefined);
vi.mocked(vscode.extensions.getExtension).mockReturnValue({
packageJSON: { version: '1.1.0' },
} as vscode.Extension<unknown>);
await activate(context);
expect(showInformationMessageMock).toHaveBeenCalledWith(
'Qwen Code Companion extension successfully installed.',
@@ -87,22 +116,177 @@ describe('activate', () => {
it('should not show the info message on subsequent activations', async () => {
vi.mocked(context.globalState.get).mockReturnValue(true);
vi.mocked(vscode.extensions.getExtension).mockReturnValue({
packageJSON: { version: '1.1.0' },
} as vscode.Extension<unknown>);
await activate(context);
expect(vscode.window.showInformationMessage).not.toHaveBeenCalled();
});
it('should launch Qwen Code when the user clicks the button', async () => {
it('should register a handler for onDidGrantWorkspaceTrust', async () => {
await activate(context);
expect(vscode.workspace.onDidGrantWorkspaceTrust).toHaveBeenCalled();
});
it('should launch the Qwen Code when the user clicks the button', async () => {
const showInformationMessageMock = vi
.mocked(vscode.window.showInformationMessage)
.mockResolvedValue('Run Qwen Code' as never);
vi.mocked(context.globalState.get).mockReturnValue(undefined);
vi.mocked(vscode.extensions.getExtension).mockReturnValue({
packageJSON: { version: '1.1.0' },
} as vscode.Extension<unknown>);
await activate(context);
expect(showInformationMessageMock).toHaveBeenCalled();
await new Promise(process.nextTick); // Wait for the promise to resolve
const commandCallback = vi
.mocked(vscode.commands.registerCommand)
.mock.calls.find((call) => call[0] === 'qwen-code.runQwenCode')?.[1];
expect(showInformationMessageMock).toHaveBeenCalledWith(
'Qwen Code Companion extension successfully installed.',
);
});
expect(commandCallback).toBeDefined();
describe('update notification', () => {
beforeEach(() => {
// Prevent the "installed" message from showing
vi.mocked(context.globalState.get).mockReturnValue(true);
});
it('should show an update notification if a newer version is available', async () => {
vi.spyOn(global, 'fetch').mockResolvedValue({
ok: true,
json: async () => ({
results: [
{
extensions: [
{
versions: [{ version: '1.2.0' }],
},
],
},
],
}),
} as Response);
const showInformationMessageMock = vi.mocked(
vscode.window.showInformationMessage,
);
await activate(context);
expect(showInformationMessageMock).toHaveBeenCalledWith(
'A new version (1.2.0) of the Qwen Code Companion extension is available.',
'Update to latest version',
);
});
it('should not show an update notification if the version is the same', async () => {
vi.spyOn(global, 'fetch').mockResolvedValue({
ok: true,
json: async () => ({
results: [
{
extensions: [
{
versions: [{ version: '1.1.0' }],
},
],
},
],
}),
} as Response);
const showInformationMessageMock = vi.mocked(
vscode.window.showInformationMessage,
);
await activate(context);
expect(showInformationMessageMock).not.toHaveBeenCalled();
});
it.each([
{
ide: IDE_DEFINITIONS.cloudshell,
},
{ ide: IDE_DEFINITIONS.firebasestudio },
])('does not show the notification for $ide.name', async ({ ide }) => {
vi.mocked(detectIdeFromEnv).mockReturnValue(ide);
vi.mocked(context.globalState.get).mockReturnValue(undefined);
const showInformationMessageMock = vi.mocked(
vscode.window.showInformationMessage,
);
await activate(context);
expect(showInformationMessageMock).not.toHaveBeenCalled();
});
it('should not show an update notification if the version is older', async () => {
vi.spyOn(global, 'fetch').mockResolvedValue({
ok: true,
json: async () => ({
results: [
{
extensions: [
{
versions: [{ version: '1.0.0' }],
},
],
},
],
}),
} as Response);
const showInformationMessageMock = vi.mocked(
vscode.window.showInformationMessage,
);
await activate(context);
expect(showInformationMessageMock).not.toHaveBeenCalled();
});
it('should execute the install command when the user clicks "Update"', async () => {
vi.spyOn(global, 'fetch').mockResolvedValue({
ok: true,
json: async () => ({
results: [
{
extensions: [
{
versions: [{ version: '1.2.0' }],
},
],
},
],
}),
} as Response);
vi.mocked(vscode.window.showInformationMessage).mockResolvedValue(
'Update to latest version' as never,
);
const executeCommandMock = vi.mocked(vscode.commands.executeCommand);
await activate(context);
// Wait for the promise from showInformationMessage.then() to resolve
await new Promise(process.nextTick);
expect(executeCommandMock).toHaveBeenCalledWith(
'workbench.extensions.installExtension',
'qwenlm.qwen-code-vscode-ide-companion',
);
});
it('should handle fetch errors gracefully', async () => {
vi.spyOn(global, 'fetch').mockResolvedValue({
ok: false,
statusText: 'Internal Server Error',
} as Response);
const showInformationMessageMock = vi.mocked(
vscode.window.showInformationMessage,
);
await activate(context);
expect(showInformationMessageMock).not.toHaveBeenCalled();
});
});
});

View File

@@ -6,22 +6,107 @@
import * as vscode from 'vscode';
import { IDEServer } from './ide-server.js';
import semver from 'semver';
import { DiffContentProvider, DiffManager } from './diff-manager.js';
import { createLogger } from './utils/logger.js';
import {
detectIdeFromEnv,
IDE_DEFINITIONS,
type IdeInfo,
} from '@qwen-code/qwen-code-core/src/ide/detect-ide.js';
const CLI_IDE_COMPANION_IDENTIFIER = 'qwenlm.qwen-code-vscode-ide-companion';
const INFO_MESSAGE_SHOWN_KEY = 'qwenCodeInfoMessageShown';
export const DIFF_SCHEME = 'qwen-diff';
/**
* IDE environments where the installation greeting is hidden. In these
* environments we either are pre-installed and the installation message is
* confusing or we just want to be quiet.
*/
const HIDE_INSTALLATION_GREETING_IDES: ReadonlySet<IdeInfo['name']> = new Set([
IDE_DEFINITIONS.firebasestudio.name,
IDE_DEFINITIONS.cloudshell.name,
]);
let ideServer: IDEServer;
let logger: vscode.OutputChannel;
let log: (message: string) => void = () => {};
async function checkForUpdates(
context: vscode.ExtensionContext,
log: (message: string) => void,
) {
try {
const currentVersion = context.extension.packageJSON.version;
// Fetch extension details from the VSCode Marketplace.
const response = await fetch(
'https://marketplace.visualstudio.com/_apis/public/gallery/extensionquery',
{
method: 'POST',
headers: {
'Content-Type': 'application/json',
Accept: 'application/json;api-version=7.1-preview.1',
},
body: JSON.stringify({
filters: [
{
criteria: [
{
filterType: 7, // Corresponds to ExtensionName
value: CLI_IDE_COMPANION_IDENTIFIER,
},
],
},
],
// See: https://learn.microsoft.com/en-us/azure/devops/extend/gallery/apis/hyper-linking?view=azure-devops
// 946 = IncludeVersions | IncludeFiles | IncludeCategoryAndTags |
// IncludeShortDescription | IncludePublisher | IncludeStatistics
flags: 946,
}),
},
);
if (!response.ok) {
log(
`Failed to fetch latest version info from marketplace: ${response.statusText}`,
);
return;
}
const data = await response.json();
const extension = data?.results?.[0]?.extensions?.[0];
// The versions are sorted by date, so the first one is the latest.
const latestVersion = extension?.versions?.[0]?.version;
if (latestVersion && semver.gt(latestVersion, currentVersion)) {
const selection = await vscode.window.showInformationMessage(
`A new version (${latestVersion}) of the Qwen Code Companion extension is available.`,
'Update to latest version',
);
if (selection === 'Update to latest version') {
// The install command will update the extension if a newer version is found.
await vscode.commands.executeCommand(
'workbench.extensions.installExtension',
CLI_IDE_COMPANION_IDENTIFIER,
);
}
}
} catch (error) {
const message = error instanceof Error ? error.message : String(error);
log(`Error checking for extension updates: ${message}`);
}
}
export async function activate(context: vscode.ExtensionContext) {
logger = vscode.window.createOutputChannel('Qwen Code Companion');
log = createLogger(context, logger);
log('Extension activated');
checkForUpdates(context, log);
const diffContentProvider = new DiffContentProvider();
const diffManager = new DiffManager(log, diffContentProvider);
@@ -57,7 +142,11 @@ export async function activate(context: vscode.ExtensionContext) {
log(`Failed to start IDE server: ${message}`);
}
if (!context.globalState.get(INFO_MESSAGE_SHOWN_KEY)) {
const infoMessageEnabled = !HIDE_INSTALLATION_GREETING_IDES.has(
detectIdeFromEnv().name,
);
if (!context.globalState.get(INFO_MESSAGE_SHOWN_KEY) && infoMessageEnabled) {
void vscode.window.showInformationMessage(
'Qwen Code Companion extension successfully installed.',
);
@@ -66,7 +155,10 @@ export async function activate(context: vscode.ExtensionContext) {
context.subscriptions.push(
vscode.workspace.onDidChangeWorkspaceFolders(() => {
ideServer.updateWorkspacePath();
ideServer.syncEnvVars();
}),
vscode.workspace.onDidGrantWorkspaceTrust(() => {
ideServer.syncEnvVars();
}),
vscode.commands.registerCommand('qwen-code.runQwenCode', async () => {
const workspaceFolders = vscode.workspace.workspaceFolders;

View File

@@ -9,9 +9,14 @@ import type * as vscode from 'vscode';
import * as fs from 'node:fs/promises';
import type * as os from 'node:os';
import * as path from 'node:path';
import * as http from 'node:http';
import { IDEServer } from './ide-server.js';
import type { DiffManager } from './diff-manager.js';
vi.mock('node:crypto', () => ({
randomUUID: vi.fn(() => 'test-auth-token'),
}));
const mocks = vi.hoisted(() => ({
diffManager: {
onDidChange: vi.fn(() => ({ dispose: vi.fn() })),
@@ -21,6 +26,7 @@ const mocks = vi.hoisted(() => ({
vi.mock('node:fs/promises', () => ({
writeFile: vi.fn(() => Promise.resolve(undefined)),
unlink: vi.fn(() => Promise.resolve(undefined)),
chmod: vi.fn(() => Promise.resolve(undefined)),
}));
vi.mock('node:os', async (importOriginal) => {
@@ -45,6 +51,7 @@ const vscodeMock = vi.hoisted(() => ({
},
},
],
isTrusted: true,
},
}));
@@ -56,26 +63,26 @@ vi.mock('./open-files-manager', () => {
return { OpenFilesManager };
});
const getPortFromMock = (
replaceMock: ReturnType<
() => vscode.ExtensionContext['environmentVariableCollection']['replace']
>,
) => {
const port = vi
.mocked(replaceMock)
.mock.calls.find((call) => call[0] === 'QWEN_CODE_IDE_SERVER_PORT')?.[1];
if (port === undefined) {
expect.fail('Port was not set');
}
return port;
};
describe('IDEServer', () => {
let ideServer: IDEServer;
let mockContext: vscode.ExtensionContext;
let mockLog: (message: string) => void;
const getPortFromMock = (
replaceMock: ReturnType<
() => vscode.ExtensionContext['environmentVariableCollection']['replace']
>,
) => {
const port = vi
.mocked(replaceMock)
.mock.calls.find((call) => call[0] === 'QWEN_CODE_IDE_SERVER_PORT')?.[1];
if (port === undefined) {
expect.fail('Port was not set');
}
return port;
};
beforeEach(() => {
mockLog = vi.fn();
ideServer = new IDEServer(mockLog, mocks.diffManager);
@@ -123,15 +130,28 @@ describe('IDEServer', () => {
const port = getPortFromMock(replaceMock);
const expectedPortFile = path.join(
'/tmp',
`gemini-ide-server-${process.ppid}.json`,
`qwen-code-ide-server-${port}.json`,
);
const expectedPpidPortFile = path.join(
'/tmp',
`qwen-code-ide-server-${process.ppid}.json`,
);
const expectedContent = JSON.stringify({
port: parseInt(port, 10),
workspacePath: expectedWorkspacePaths,
ppid: process.ppid,
authToken: 'test-auth-token',
});
expect(fs.writeFile).toHaveBeenCalledWith(
expectedPortFile,
JSON.stringify({
port: parseInt(port, 10),
workspacePath: expectedWorkspacePaths,
}),
expectedContent,
);
expect(fs.writeFile).toHaveBeenCalledWith(
expectedPpidPortFile,
expectedContent,
);
expect(fs.chmod).toHaveBeenCalledWith(expectedPortFile, 0o600);
expect(fs.chmod).toHaveBeenCalledWith(expectedPpidPortFile, 0o600);
});
it('should set a single folder path', async () => {
@@ -148,15 +168,28 @@ describe('IDEServer', () => {
const port = getPortFromMock(replaceMock);
const expectedPortFile = path.join(
'/tmp',
`gemini-ide-server-${process.ppid}.json`,
`qwen-code-ide-server-${port}.json`,
);
const expectedPpidPortFile = path.join(
'/tmp',
`qwen-code-ide-server-${process.ppid}.json`,
);
const expectedContent = JSON.stringify({
port: parseInt(port, 10),
workspacePath: '/foo/bar',
ppid: process.ppid,
authToken: 'test-auth-token',
});
expect(fs.writeFile).toHaveBeenCalledWith(
expectedPortFile,
JSON.stringify({
port: parseInt(port, 10),
workspacePath: '/foo/bar',
}),
expectedContent,
);
expect(fs.writeFile).toHaveBeenCalledWith(
expectedPpidPortFile,
expectedContent,
);
expect(fs.chmod).toHaveBeenCalledWith(expectedPortFile, 0o600);
expect(fs.chmod).toHaveBeenCalledWith(expectedPpidPortFile, 0o600);
});
it('should set an empty string if no folders are open', async () => {
@@ -173,15 +206,28 @@ describe('IDEServer', () => {
const port = getPortFromMock(replaceMock);
const expectedPortFile = path.join(
'/tmp',
`gemini-ide-server-${process.ppid}.json`,
`qwen-code-ide-server-${port}.json`,
);
const expectedPpidPortFile = path.join(
'/tmp',
`qwen-code-ide-server-${process.ppid}.json`,
);
const expectedContent = JSON.stringify({
port: parseInt(port, 10),
workspacePath: '',
ppid: process.ppid,
authToken: 'test-auth-token',
});
expect(fs.writeFile).toHaveBeenCalledWith(
expectedPortFile,
JSON.stringify({
port: parseInt(port, 10),
workspacePath: '',
}),
expectedContent,
);
expect(fs.writeFile).toHaveBeenCalledWith(
expectedPpidPortFile,
expectedContent,
);
expect(fs.chmod).toHaveBeenCalledWith(expectedPortFile, 0o600);
expect(fs.chmod).toHaveBeenCalledWith(expectedPpidPortFile, 0o600);
});
it('should update the path when workspace folders change', async () => {
@@ -199,7 +245,7 @@ describe('IDEServer', () => {
{ uri: { fsPath: '/foo/bar' } },
{ uri: { fsPath: '/baz/qux' } },
];
await ideServer.updateWorkspacePath();
await ideServer.syncEnvVars();
const expectedWorkspacePaths = ['/foo/bar', '/baz/qux'].join(
path.delimiter,
@@ -212,45 +258,72 @@ describe('IDEServer', () => {
const port = getPortFromMock(replaceMock);
const expectedPortFile = path.join(
'/tmp',
`gemini-ide-server-${process.ppid}.json`,
`qwen-code-ide-server-${port}.json`,
);
const expectedPpidPortFile = path.join(
'/tmp',
`qwen-code-ide-server-${process.ppid}.json`,
);
const expectedContent = JSON.stringify({
port: parseInt(port, 10),
workspacePath: expectedWorkspacePaths,
ppid: process.ppid,
authToken: 'test-auth-token',
});
expect(fs.writeFile).toHaveBeenCalledWith(
expectedPortFile,
JSON.stringify({
port: parseInt(port, 10),
workspacePath: expectedWorkspacePaths,
}),
expectedContent,
);
expect(fs.writeFile).toHaveBeenCalledWith(
expectedPpidPortFile,
expectedContent,
);
expect(fs.chmod).toHaveBeenCalledWith(expectedPortFile, 0o600);
expect(fs.chmod).toHaveBeenCalledWith(expectedPpidPortFile, 0o600);
// Simulate removing a folder
vscodeMock.workspace.workspaceFolders = [{ uri: { fsPath: '/baz/qux' } }];
await ideServer.updateWorkspacePath();
await ideServer.syncEnvVars();
expect(replaceMock).toHaveBeenCalledWith(
'QWEN_CODE_IDE_WORKSPACE_PATH',
'/baz/qux',
);
const expectedContent2 = JSON.stringify({
port: parseInt(port, 10),
workspacePath: '/baz/qux',
ppid: process.ppid,
authToken: 'test-auth-token',
});
expect(fs.writeFile).toHaveBeenCalledWith(
expectedPortFile,
JSON.stringify({
port: parseInt(port, 10),
workspacePath: '/baz/qux',
}),
expectedContent2,
);
expect(fs.writeFile).toHaveBeenCalledWith(
expectedPpidPortFile,
expectedContent2,
);
expect(fs.chmod).toHaveBeenCalledWith(expectedPortFile, 0o600);
expect(fs.chmod).toHaveBeenCalledWith(expectedPpidPortFile, 0o600);
});
it('should clear env vars and delete port file on stop', async () => {
await ideServer.start(mockContext);
const portFile = path.join(
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',
`gemini-ide-server-${process.ppid}.json`,
`qwen-code-ide-server-${process.ppid}.json`,
);
expect(fs.writeFile).toHaveBeenCalledWith(portFile, expect.any(String));
expect(fs.writeFile).toHaveBeenCalledWith(ppidPortFile, expect.any(String));
await ideServer.stop();
expect(mockContext.environmentVariableCollection.clear).toHaveBeenCalled();
expect(fs.unlink).toHaveBeenCalledWith(portFile);
expect(fs.unlink).toHaveBeenCalledWith(ppidPortFile);
});
it.skipIf(process.platform !== 'win32')(
@@ -273,15 +346,216 @@ describe('IDEServer', () => {
const port = getPortFromMock(replaceMock);
const expectedPortFile = path.join(
'/tmp',
`gemini-ide-server-${process.ppid}.json`,
`qwen-code-ide-server-${port}.json`,
);
const expectedPpidPortFile = path.join(
'/tmp',
`qwen-code-ide-server-${process.ppid}.json`,
);
const expectedContent = JSON.stringify({
port: parseInt(port, 10),
workspacePath: expectedWorkspacePaths,
ppid: process.ppid,
authToken: 'test-auth-token',
});
expect(fs.writeFile).toHaveBeenCalledWith(
expectedPortFile,
JSON.stringify({
port: parseInt(port, 10),
workspacePath: expectedWorkspacePaths,
}),
expectedContent,
);
expect(fs.writeFile).toHaveBeenCalledWith(
expectedPpidPortFile,
expectedContent,
);
expect(fs.chmod).toHaveBeenCalledWith(expectedPortFile, 0o600);
expect(fs.chmod).toHaveBeenCalledWith(expectedPpidPortFile, 0o600);
},
);
describe('auth token', () => {
let port: number;
beforeEach(async () => {
await ideServer.start(mockContext);
port = (ideServer as unknown as { port: number }).port;
});
it('should allow request without auth token for backwards compatibility', async () => {
const response = await fetch(`http://localhost:${port}/mcp`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
jsonrpc: '2.0',
method: 'initialize',
params: {},
id: 1,
}),
});
expect(response.status).not.toBe(401);
});
it('should allow request with valid auth token', async () => {
const response = await fetch(`http://localhost:${port}/mcp`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
Authorization: `Bearer test-auth-token`,
},
body: JSON.stringify({
jsonrpc: '2.0',
method: 'initialize',
params: {},
id: 1,
}),
});
expect(response.status).not.toBe(401);
});
it('should reject request with invalid auth token', async () => {
const response = await fetch(`http://localhost:${port}/mcp`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
Authorization: 'Bearer invalid-token',
},
body: JSON.stringify({
jsonrpc: '2.0',
method: 'initialize',
params: {},
id: 1,
}),
});
expect(response.status).toBe(401);
const body = await response.text();
expect(body).toBe('Unauthorized');
});
it('should reject request with malformed auth token', async () => {
const malformedHeaders = [
'Bearer',
'invalid-token',
'Bearer token extra',
];
for (const header of malformedHeaders) {
const response = await fetch(`http://localhost:${port}/mcp`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
Authorization: header,
},
body: JSON.stringify({
jsonrpc: '2.0',
method: 'initialize',
params: {},
id: 1,
}),
});
expect(response.status, `Failed for header: ${header}`).toBe(401);
const body = await response.text();
expect(body, `Failed for header: ${header}`).toBe('Unauthorized');
}
});
});
});
const request = (
port: string,
options: http.RequestOptions,
body?: string,
): Promise<http.IncomingMessage> =>
new Promise((resolve, reject) => {
const req = http.request(
{
hostname: '127.0.0.1',
port,
...options,
},
(res) => {
res.resume(); // Consume response data to free up memory
resolve(res);
},
);
req.on('error', reject);
if (body) {
req.write(body);
}
req.end();
});
describe('IDEServer HTTP endpoints', () => {
let ideServer: IDEServer;
let mockContext: vscode.ExtensionContext;
let mockLog: (message: string) => void;
let port: string;
beforeEach(async () => {
mockLog = vi.fn();
ideServer = new IDEServer(mockLog, mocks.diffManager);
mockContext = {
subscriptions: [],
environmentVariableCollection: {
replace: vi.fn(),
clear: vi.fn(),
},
} as unknown as vscode.ExtensionContext;
await ideServer.start(mockContext);
const replaceMock = mockContext.environmentVariableCollection.replace;
port = getPortFromMock(replaceMock);
});
afterEach(async () => {
await ideServer.stop();
vi.restoreAllMocks();
});
it('should deny requests with an origin header', async () => {
const response = await request(
port,
{
path: '/mcp',
method: 'POST',
headers: {
Host: `localhost:${port}`,
Origin: 'https://evil.com',
'Content-Type': 'application/json',
},
},
JSON.stringify({ jsonrpc: '2.0', method: 'initialize' }),
);
expect(response.statusCode).toBe(403);
});
it('should deny requests with an invalid host header', async () => {
const response = await request(
port,
{
path: '/mcp',
method: 'POST',
headers: {
Host: 'evil.com',
'Content-Type': 'application/json',
},
},
JSON.stringify({ jsonrpc: '2.0', method: 'initialize' }),
);
expect(response.statusCode).toBe(403);
});
it('should allow requests with a valid host header', async () => {
const response = await request(
port,
{
path: '/mcp',
method: 'POST',
headers: {
Host: `localhost:${port}`,
'Content-Type': 'application/json',
},
},
JSON.stringify({ jsonrpc: '2.0', method: 'initialize' }),
);
// We expect a 400 here because we are not sending a valid MCP request,
// but it's not a host error, which is what we are testing.
expect(response.statusCode).toBe(400);
});
});

View File

@@ -5,30 +5,57 @@
*/
import * as vscode from 'vscode';
import { IdeContextNotificationSchema } from '@qwen-code/qwen-code-core';
import {
CloseDiffRequestSchema,
IdeContextNotificationSchema,
OpenDiffRequestSchema,
} from '@qwen-code/qwen-code-core/src/ide/types.js';
import { isInitializeRequest } from '@modelcontextprotocol/sdk/types.js';
import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js';
import { StreamableHTTPServerTransport } from '@modelcontextprotocol/sdk/server/streamableHttp.js';
import express, { type Request, type Response } from 'express';
import express, {
type Request,
type Response,
type NextFunction,
} from 'express';
import cors from 'cors';
import { randomUUID } from 'node:crypto';
import { type Server as HTTPServer } from 'node:http';
import * as path from 'node:path';
import * as fs from 'node:fs/promises';
import * as os from 'node:os';
import { z } from 'zod';
import type { z } from 'zod';
import type { DiffManager } from './diff-manager.js';
import { OpenFilesManager } from './open-files-manager.js';
class CORSError extends Error {
constructor(message: string) {
super(message);
this.name = 'CORSError';
}
}
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';
function writePortAndWorkspace(
context: vscode.ExtensionContext,
port: number,
portFile: string,
log: (message: string) => void,
): Promise<void> {
interface WritePortAndWorkspaceArgs {
context: vscode.ExtensionContext;
port: number;
portFile: string;
ppidPortFile: string;
authToken: string;
log: (message: string) => void;
}
async function writePortAndWorkspace({
context,
port,
portFile,
ppidPortFile,
authToken,
log,
}: WritePortAndWorkspaceArgs): Promise<void> {
const workspaceFolders = vscode.workspace.workspaceFolders;
const workspacePath =
workspaceFolders && workspaceFolders.length > 0
@@ -44,13 +71,27 @@ function writePortAndWorkspace(
workspacePath,
);
const content = JSON.stringify({
port,
workspacePath,
ppid: process.ppid,
authToken,
});
log(`Writing port file to: ${portFile}`);
return fs
.writeFile(portFile, JSON.stringify({ port, workspacePath }))
.catch((err) => {
const message = err instanceof Error ? err.message : String(err);
log(`Failed to write port to file: ${message}`);
});
log(`Writing ppid port file to: ${ppidPortFile}`);
try {
await Promise.all([
fs.writeFile(portFile, content).then(() => fs.chmod(portFile, 0o600)),
fs
.writeFile(ppidPortFile, content)
.then(() => fs.chmod(ppidPortFile, 0o600)),
]);
} catch (err) {
const message = err instanceof Error ? err.message : String(err);
log(`Failed to write port to file: ${message}`);
}
}
function sendIdeContextUpdateNotification(
@@ -80,44 +121,85 @@ export class IDEServer {
private server: HTTPServer | undefined;
private context: vscode.ExtensionContext | undefined;
private log: (message: string) => void;
private portFile: string;
private portFile: string | undefined;
private ppidPortFile: string | undefined;
private port: number | undefined;
private authToken: string | undefined;
private transports: { [sessionId: string]: StreamableHTTPServerTransport } =
{};
private openFilesManager: OpenFilesManager | undefined;
diffManager: DiffManager;
constructor(log: (message: string) => void, diffManager: DiffManager) {
this.log = log;
this.diffManager = diffManager;
this.portFile = path.join(
os.tmpdir(),
`gemini-ide-server-${process.ppid}.json`,
);
}
start(context: vscode.ExtensionContext): Promise<void> {
return new Promise((resolve) => {
this.context = context;
this.authToken = randomUUID();
const sessionsWithInitialNotification = new Set<string>();
const transports: { [sessionId: string]: StreamableHTTPServerTransport } =
{};
const app = express();
app.use(express.json());
app.use(express.json({ limit: '10mb' }));
app.use(
cors({
origin: (origin, callback) => {
// Only allow non-browser requests with no origin.
if (!origin) {
return callback(null, true);
}
return callback(
new CORSError('Request denied by CORS policy.'),
false,
);
},
}),
);
app.use((req, res, next) => {
const host = req.headers.host || '';
const allowedHosts = [
`localhost:${this.port}`,
`127.0.0.1:${this.port}`,
];
if (!allowedHosts.includes(host)) {
return res.status(403).json({ error: 'Invalid Host header' });
}
next();
});
app.use((req, res, next) => {
const authHeader = req.headers.authorization;
if (authHeader) {
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();
});
const mcpServer = createMcpServer(this.diffManager);
const openFilesManager = new OpenFilesManager(context);
const onDidChangeSubscription = openFilesManager.onDidChange(() => {
for (const transport of Object.values(transports)) {
sendIdeContextUpdateNotification(
transport,
this.log.bind(this),
openFilesManager,
);
}
this.openFilesManager = new OpenFilesManager(context);
const onDidChangeSubscription = this.openFilesManager.onDidChange(() => {
this.broadcastIdeContextUpdate();
});
context.subscriptions.push(onDidChangeSubscription);
const onDidChangeDiffSubscription = this.diffManager.onDidChange(
(notification) => {
for (const transport of Object.values(transports)) {
for (const transport of Object.values(this.transports)) {
transport.send(notification);
}
},
@@ -130,14 +212,14 @@ export class IDEServer {
| undefined;
let transport: StreamableHTTPServerTransport;
if (sessionId && transports[sessionId]) {
transport = transports[sessionId];
if (sessionId && this.transports[sessionId]) {
transport = this.transports[sessionId];
} else if (!sessionId && isInitializeRequest(req.body)) {
transport = new StreamableHTTPServerTransport({
sessionIdGenerator: () => randomUUID(),
onsessioninitialized: (newSessionId) => {
this.log(`New session initialized: ${newSessionId}`);
transports[newSessionId] = transport;
this.transports[newSessionId] = transport;
},
});
const keepAlive = setInterval(() => {
@@ -149,14 +231,14 @@ export class IDEServer {
);
clearInterval(keepAlive);
}
}, 60000); // 60 sec
}, 30000); // 30 sec
transport.onclose = () => {
clearInterval(keepAlive);
if (transport.sessionId) {
this.log(`Session closed: ${transport.sessionId}`);
sessionsWithInitialNotification.delete(transport.sessionId);
delete transports[transport.sessionId];
delete this.transports[transport.sessionId];
}
};
mcpServer.connect(transport);
@@ -199,13 +281,13 @@ export class IDEServer {
const sessionId = req.headers[MCP_SESSION_ID_HEADER] as
| string
| undefined;
if (!sessionId || !transports[sessionId]) {
if (!sessionId || !this.transports[sessionId]) {
this.log('Invalid or missing session ID');
res.status(400).send('Invalid or missing session ID');
return;
}
const transport = transports[sessionId];
const transport = this.transports[sessionId];
try {
await transport.handleRequest(req, res);
} catch (error) {
@@ -217,11 +299,14 @@ export class IDEServer {
}
}
if (!sessionsWithInitialNotification.has(sessionId)) {
if (
this.openFilesManager &&
!sessionsWithInitialNotification.has(sessionId)
) {
sendIdeContextUpdateNotification(
transport,
this.log.bind(this),
openFilesManager,
this.openFilesManager,
);
sessionsWithInitialNotification.add(sessionId);
}
@@ -229,34 +314,78 @@ export class IDEServer {
app.get('/mcp', handleSessionRequest);
this.server = app.listen(0, async () => {
app.use((err: Error, req: Request, res: Response, next: NextFunction) => {
if (err instanceof CORSError) {
res.status(403).json({ error: 'Request denied by CORS policy.' });
} else {
next(err);
}
});
this.server = app.listen(0, '127.0.0.1', async () => {
const address = (this.server as HTTPServer).address();
if (address && typeof address !== 'string') {
this.port = address.port;
this.log(`IDE server listening on port ${this.port}`);
await writePortAndWorkspace(
context,
this.port,
this.portFile,
this.log,
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`,
);
this.log(`IDE server listening on http://127.0.0.1:${this.port}`);
if (this.authToken) {
await writePortAndWorkspace({
context,
port: this.port,
portFile: this.portFile,
ppidPortFile: this.ppidPortFile,
authToken: this.authToken,
log: this.log,
});
}
}
resolve();
});
});
}
async updateWorkspacePath(): Promise<void> {
if (this.context && this.port) {
await writePortAndWorkspace(
this.context,
this.port,
this.portFile,
this.log,
broadcastIdeContextUpdate() {
if (!this.openFilesManager) {
return;
}
for (const transport of Object.values(this.transports)) {
sendIdeContextUpdateNotification(
transport,
this.log.bind(this),
this.openFilesManager,
);
}
}
async syncEnvVars(): Promise<void> {
if (
this.context &&
this.server &&
this.port &&
this.portFile &&
this.ppidPortFile &&
this.authToken
) {
await writePortAndWorkspace({
context: this.context,
port: this.port,
portFile: this.portFile,
ppidPortFile: this.ppidPortFile,
authToken: this.authToken,
log: this.log,
});
this.broadcastIdeContextUpdate();
}
}
async stop(): Promise<void> {
if (this.server) {
await new Promise<void>((resolve, reject) => {
@@ -275,10 +404,19 @@ export class IDEServer {
if (this.context) {
this.context.environmentVariableCollection.clear();
}
try {
await fs.unlink(this.portFile);
} catch (_err) {
// Ignore errors if the file doesn't exist.
if (this.portFile) {
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);
} catch (_err) {
// Ignore errors if the file doesn't exist.
}
}
}
}
@@ -296,40 +434,27 @@ const createMcpServer = (diffManager: DiffManager) => {
{
description:
'(IDE Tool) Open a diff view to create or modify a file. Returns a notification once the diff has been accepted or rejcted.',
inputSchema: z.object({
filePath: z.string(),
// TODO(chrstn): determine if this should be required or not.
newContent: z.string().optional(),
}).shape,
inputSchema: OpenDiffRequestSchema.shape,
},
async ({
filePath,
newContent,
}: {
filePath: string;
newContent?: string;
}) => {
await diffManager.showDiff(filePath, newContent ?? '');
return {
content: [
{
type: 'text',
text: `Showing diff for ${filePath}`,
},
],
};
async ({ filePath, newContent }: z.infer<typeof OpenDiffRequestSchema>) => {
await diffManager.showDiff(filePath, newContent);
return { content: [] };
},
);
server.registerTool(
'closeDiff',
{
description: '(IDE Tool) Close an open diff view for a specific file.',
inputSchema: z.object({
filePath: z.string(),
}).shape,
inputSchema: CloseDiffRequestSchema.shape,
},
async ({ filePath }: { filePath: string }) => {
const content = await diffManager.closeDiff(filePath);
async ({
filePath,
suppressNotification,
}: z.infer<typeof CloseDiffRequestSchema>) => {
const content = await diffManager.closeDiff(
filePath,
suppressNotification,
);
const response = { content: content ?? undefined };
return {
content: [

View File

@@ -5,7 +5,10 @@
*/
import * as vscode from 'vscode';
import type { File, IdeContext } from '@qwen-code/qwen-code-core';
import type {
File,
IdeContext,
} from '@qwen-code/qwen-code-core/src/ide/types.js';
export const MAX_FILES = 10;
const MAX_SELECTED_TEXT_LENGTH = 16384; // 16 KiB limit
@@ -172,6 +175,7 @@ export class OpenFilesManager {
return {
workspaceState: {
openFiles: [...this.openFiles],
isTrusted: vscode.workspace.isTrusted,
},
};
}

View File

@@ -5,13 +5,6 @@
"target": "ES2022",
"lib": ["ES2022", "dom"],
"sourceMap": true,
/*
* skipLibCheck is necessary because the a2a-server package depends on
* @google-cloud/storage which pulls in @types/request which depends on
* tough-cookie@4.x while jsdom requires tough-cookie@5.x. This causes a
* type checking error in ../../node_modules/@types/request/index.d.ts.
*/
"skipLibCheck": true,
"strict": true /* enable all strict type-checking options */
/* Additional Checks */
// "noImplicitReturns": true, /* Report error when not all code paths in function return a value. */