mirror of
https://github.com/QwenLM/qwen-code.git
synced 2026-01-12 11:59:18 +00:00
Compare commits
8 Commits
sdk-typesc
...
fix/vscode
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
ec8cccafd7 | ||
|
|
82c524f87d | ||
|
|
df75aa06b6 | ||
|
|
8ea9871d23 | ||
|
|
b7828ac765 | ||
|
|
95efe89ac0 | ||
|
|
052337861b | ||
|
|
361492247e |
3
.github/CODEOWNERS
vendored
3
.github/CODEOWNERS
vendored
@@ -1,3 +0,0 @@
|
||||
* @tanzhenxin @DennisYu07 @gwinthis @LaZzyMan @pomelo-nwu @Mingholy
|
||||
# SDK TypeScript package changes require review from Mingholy
|
||||
packages/sdk-typescript/** @Mingholy
|
||||
17
.github/workflows/release-sdk.yml
vendored
17
.github/workflows/release-sdk.yml
vendored
@@ -241,7 +241,7 @@ jobs:
|
||||
${{ steps.vars.outputs.is_dry_run == 'false' && steps.vars.outputs.is_nightly == 'false' && steps.vars.outputs.is_preview == 'false' }}
|
||||
id: 'pr'
|
||||
env:
|
||||
GITHUB_TOKEN: '${{ secrets.CI_BOT_PAT }}'
|
||||
GITHUB_TOKEN: '${{ secrets.GITHUB_TOKEN }}'
|
||||
RELEASE_BRANCH: '${{ steps.release_branch.outputs.BRANCH_NAME }}'
|
||||
RELEASE_TAG: '${{ steps.version.outputs.RELEASE_TAG }}'
|
||||
run: |-
|
||||
@@ -258,15 +258,26 @@ jobs:
|
||||
|
||||
echo "PR_URL=${pr_url}" >> "${GITHUB_OUTPUT}"
|
||||
|
||||
- name: 'Wait for CI checks to complete'
|
||||
if: |-
|
||||
${{ steps.vars.outputs.is_dry_run == 'false' && steps.vars.outputs.is_nightly == 'false' && steps.vars.outputs.is_preview == 'false' }}
|
||||
env:
|
||||
GITHUB_TOKEN: '${{ secrets.GITHUB_TOKEN }}'
|
||||
PR_URL: '${{ steps.pr.outputs.PR_URL }}'
|
||||
run: |-
|
||||
set -euo pipefail
|
||||
echo "Waiting for CI checks to complete..."
|
||||
gh pr checks "${PR_URL}" --watch --interval 30
|
||||
|
||||
- name: 'Enable auto-merge for release PR'
|
||||
if: |-
|
||||
${{ steps.vars.outputs.is_dry_run == 'false' && steps.vars.outputs.is_nightly == 'false' && steps.vars.outputs.is_preview == 'false' }}
|
||||
env:
|
||||
GITHUB_TOKEN: '${{ secrets.CI_BOT_PAT }}'
|
||||
GITHUB_TOKEN: '${{ secrets.GITHUB_TOKEN }}'
|
||||
PR_URL: '${{ steps.pr.outputs.PR_URL }}'
|
||||
run: |-
|
||||
set -euo pipefail
|
||||
gh pr merge "${PR_URL}" --merge --auto --delete-branch
|
||||
gh pr merge "${PR_URL}" --merge --auto
|
||||
|
||||
- name: 'Create Issue on Failure'
|
||||
if: |-
|
||||
|
||||
@@ -49,8 +49,6 @@ Cross-platform sandboxing with complete process isolation.
|
||||
|
||||
By default, Qwen Code uses a published sandbox image (configured in the CLI package) and will pull it as needed.
|
||||
|
||||
The container sandbox mounts your workspace and your `~/.qwen` directory into the container so auth and settings persist between runs.
|
||||
|
||||
**Best for**: Strong isolation on any OS, consistent tooling inside a known image.
|
||||
|
||||
### Choosing a method
|
||||
@@ -159,7 +157,7 @@ For a working allowlist-style proxy example, see: [Example Proxy Script](/develo
|
||||
|
||||
## Linux UID/GID handling
|
||||
|
||||
On Linux, Qwen Code defaults to enabling UID/GID mapping so the sandbox runs as your user (and reuses the mounted `~/.qwen`). Override with:
|
||||
The sandbox automatically handles user permissions on Linux. Override these permissions with:
|
||||
|
||||
```bash
|
||||
export SANDBOX_SET_UID_GID=true # Force host UID/GID
|
||||
|
||||
@@ -9,18 +9,11 @@ This guide provides solutions to common issues and debugging tips, including top
|
||||
|
||||
## Authentication or login errors
|
||||
|
||||
- **Error: `UNABLE_TO_GET_ISSUER_CERT_LOCALLY`, `UNABLE_TO_VERIFY_LEAF_SIGNATURE`, or `unable to get local issuer certificate`**
|
||||
- **Error: `UNABLE_TO_GET_ISSUER_CERT_LOCALLY` or `unable to get local issuer certificate`**
|
||||
- **Cause:** You may be on a corporate network with a firewall that intercepts and inspects SSL/TLS traffic. This often requires a custom root CA certificate to be trusted by Node.js.
|
||||
- **Solution:** Set the `NODE_EXTRA_CA_CERTS` environment variable to the absolute path of your corporate root CA certificate file.
|
||||
- Example: `export NODE_EXTRA_CA_CERTS=/path/to/your/corporate-ca.crt`
|
||||
|
||||
- **Error: `Device authorization flow failed: fetch failed`**
|
||||
- **Cause:** Node.js could not reach Qwen OAuth endpoints (often a proxy or SSL/TLS trust issue). When available, Qwen Code will also print the underlying error cause (for example: `UNABLE_TO_VERIFY_LEAF_SIGNATURE`).
|
||||
- **Solution:**
|
||||
- Confirm you can access `https://chat.qwen.ai` from the same machine/network.
|
||||
- If you are behind a proxy, set it via `qwen --proxy <url>` (or the `proxy` setting in `settings.json`).
|
||||
- If your network uses a corporate TLS inspection CA, set `NODE_EXTRA_CA_CERTS` as described above.
|
||||
|
||||
- **Issue: Unable to display UI after authentication failure**
|
||||
- **Cause:** If authentication fails after selecting an authentication type, the `security.auth.selectedType` setting may be persisted in `settings.json`. On restart, the CLI may get stuck trying to authenticate with the failed auth type and fail to display the UI.
|
||||
- **Solution:** Clear the `security.auth.selectedType` configuration item in your `settings.json` file:
|
||||
|
||||
2
package-lock.json
generated
2
package-lock.json
generated
@@ -18593,7 +18593,7 @@
|
||||
},
|
||||
"packages/sdk-typescript": {
|
||||
"name": "@qwen-code/sdk",
|
||||
"version": "0.1.2",
|
||||
"version": "0.1.0",
|
||||
"license": "Apache-2.0",
|
||||
"dependencies": {
|
||||
"@modelcontextprotocol/sdk": "^1.25.1",
|
||||
|
||||
@@ -170,7 +170,17 @@ function normalizeOutputFormat(
|
||||
}
|
||||
|
||||
export async function parseArguments(settings: Settings): Promise<CliArgs> {
|
||||
const rawArgv = hideBin(process.argv);
|
||||
let rawArgv = hideBin(process.argv);
|
||||
|
||||
// hack: if the first argument is the CLI entry point, remove it
|
||||
if (
|
||||
rawArgv.length > 0 &&
|
||||
(rawArgv[0].endsWith('/dist/qwen-cli/cli.js') ||
|
||||
rawArgv[0].endsWith('/dist/cli.js'))
|
||||
) {
|
||||
rawArgv = rawArgv.slice(1);
|
||||
}
|
||||
|
||||
const yargsInstance = yargs(rawArgv)
|
||||
.locale('en')
|
||||
.scriptName('qwen')
|
||||
|
||||
@@ -55,7 +55,6 @@ import { disableExtension } from './extension.js';
|
||||
|
||||
// These imports will get the versions from the vi.mock('./settings.js', ...) factory.
|
||||
import {
|
||||
getSettingsWarnings,
|
||||
loadSettings,
|
||||
USER_SETTINGS_PATH, // This IS the mocked path.
|
||||
getSystemSettingsPath,
|
||||
@@ -419,86 +418,6 @@ describe('Settings Loading and Merging', () => {
|
||||
});
|
||||
});
|
||||
|
||||
it('should warn about ignored legacy keys in a v2 settings file', () => {
|
||||
(mockFsExistsSync as Mock).mockImplementation(
|
||||
(p: fs.PathLike) => p === USER_SETTINGS_PATH,
|
||||
);
|
||||
const userSettingsContent = {
|
||||
[SETTINGS_VERSION_KEY]: SETTINGS_VERSION,
|
||||
usageStatisticsEnabled: false,
|
||||
};
|
||||
(fs.readFileSync as Mock).mockImplementation(
|
||||
(p: fs.PathOrFileDescriptor) => {
|
||||
if (p === USER_SETTINGS_PATH)
|
||||
return JSON.stringify(userSettingsContent);
|
||||
return '{}';
|
||||
},
|
||||
);
|
||||
|
||||
const settings = loadSettings(MOCK_WORKSPACE_DIR);
|
||||
|
||||
expect(getSettingsWarnings(settings)).toEqual(
|
||||
expect.arrayContaining([
|
||||
expect.stringContaining(
|
||||
"Legacy setting 'usageStatisticsEnabled' will be ignored",
|
||||
),
|
||||
]),
|
||||
);
|
||||
expect(getSettingsWarnings(settings)).toEqual(
|
||||
expect.arrayContaining([
|
||||
expect.stringContaining("'privacy.usageStatisticsEnabled'"),
|
||||
]),
|
||||
);
|
||||
});
|
||||
|
||||
it('should warn about unknown top-level keys in a v2 settings file', () => {
|
||||
(mockFsExistsSync as Mock).mockImplementation(
|
||||
(p: fs.PathLike) => p === USER_SETTINGS_PATH,
|
||||
);
|
||||
const userSettingsContent = {
|
||||
[SETTINGS_VERSION_KEY]: SETTINGS_VERSION,
|
||||
someUnknownKey: 'value',
|
||||
};
|
||||
(fs.readFileSync as Mock).mockImplementation(
|
||||
(p: fs.PathOrFileDescriptor) => {
|
||||
if (p === USER_SETTINGS_PATH)
|
||||
return JSON.stringify(userSettingsContent);
|
||||
return '{}';
|
||||
},
|
||||
);
|
||||
|
||||
const settings = loadSettings(MOCK_WORKSPACE_DIR);
|
||||
|
||||
expect(getSettingsWarnings(settings)).toEqual(
|
||||
expect.arrayContaining([
|
||||
expect.stringContaining(
|
||||
"Unknown setting 'someUnknownKey' will be ignored",
|
||||
),
|
||||
]),
|
||||
);
|
||||
});
|
||||
|
||||
it('should not warn for valid v2 container keys', () => {
|
||||
(mockFsExistsSync as Mock).mockImplementation(
|
||||
(p: fs.PathLike) => p === USER_SETTINGS_PATH,
|
||||
);
|
||||
const userSettingsContent = {
|
||||
[SETTINGS_VERSION_KEY]: SETTINGS_VERSION,
|
||||
model: { name: 'qwen-coder' },
|
||||
};
|
||||
(fs.readFileSync as Mock).mockImplementation(
|
||||
(p: fs.PathOrFileDescriptor) => {
|
||||
if (p === USER_SETTINGS_PATH)
|
||||
return JSON.stringify(userSettingsContent);
|
||||
return '{}';
|
||||
},
|
||||
);
|
||||
|
||||
const settings = loadSettings(MOCK_WORKSPACE_DIR);
|
||||
|
||||
expect(getSettingsWarnings(settings)).toEqual([]);
|
||||
});
|
||||
|
||||
it('should rewrite allowedTools to tools.allowed during migration', () => {
|
||||
(mockFsExistsSync as Mock).mockImplementation(
|
||||
(p: fs.PathLike) => p === USER_SETTINGS_PATH,
|
||||
|
||||
@@ -344,97 +344,6 @@ const KNOWN_V2_CONTAINERS = new Set(
|
||||
Object.values(MIGRATION_MAP).map((path) => path.split('.')[0]),
|
||||
);
|
||||
|
||||
function getSettingsFileKeyWarnings(
|
||||
settings: Record<string, unknown>,
|
||||
settingsFilePath: string,
|
||||
): string[] {
|
||||
const version = settings[SETTINGS_VERSION_KEY];
|
||||
if (typeof version !== 'number' || version < SETTINGS_VERSION) {
|
||||
return [];
|
||||
}
|
||||
|
||||
const warnings: string[] = [];
|
||||
const ignoredLegacyKeys = new Set<string>();
|
||||
|
||||
// Ignored legacy keys (V1 top-level keys that moved to a nested V2 path).
|
||||
for (const [oldKey, newPath] of Object.entries(MIGRATION_MAP)) {
|
||||
if (oldKey === newPath) {
|
||||
continue;
|
||||
}
|
||||
if (!(oldKey in settings)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const oldValue = settings[oldKey];
|
||||
|
||||
// If this key is a V2 container (like 'model') and it's already an object,
|
||||
// it's likely already in V2 format. Don't warn.
|
||||
if (
|
||||
KNOWN_V2_CONTAINERS.has(oldKey) &&
|
||||
typeof oldValue === 'object' &&
|
||||
oldValue !== null &&
|
||||
!Array.isArray(oldValue)
|
||||
) {
|
||||
continue;
|
||||
}
|
||||
|
||||
ignoredLegacyKeys.add(oldKey);
|
||||
warnings.push(
|
||||
`⚠️ Legacy setting '${oldKey}' will be ignored in ${settingsFilePath}. Please use '${newPath}' instead.`,
|
||||
);
|
||||
}
|
||||
|
||||
// Unknown top-level keys.
|
||||
const schemaKeys = new Set(Object.keys(getSettingsSchema()));
|
||||
for (const key of Object.keys(settings)) {
|
||||
if (key === SETTINGS_VERSION_KEY) {
|
||||
continue;
|
||||
}
|
||||
if (ignoredLegacyKeys.has(key)) {
|
||||
continue;
|
||||
}
|
||||
if (schemaKeys.has(key)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
warnings.push(
|
||||
`⚠️ Unknown setting '${key}' will be ignored in ${settingsFilePath}.`,
|
||||
);
|
||||
}
|
||||
|
||||
return warnings;
|
||||
}
|
||||
|
||||
/**
|
||||
* Collects warnings for ignored legacy and unknown settings keys.
|
||||
*
|
||||
* For `$version: 2` settings files, we do not apply implicit migrations.
|
||||
* Instead, we surface actionable, de-duplicated warnings in the terminal UI.
|
||||
*/
|
||||
export function getSettingsWarnings(loadedSettings: LoadedSettings): string[] {
|
||||
const warningSet = new Set<string>();
|
||||
|
||||
for (const scope of [SettingScope.User, SettingScope.Workspace]) {
|
||||
const settingsFile = loadedSettings.forScope(scope);
|
||||
if (settingsFile.rawJson === undefined) {
|
||||
continue; // File not present / not loaded.
|
||||
}
|
||||
const settingsObject = settingsFile.originalSettings as unknown as Record<
|
||||
string,
|
||||
unknown
|
||||
>;
|
||||
|
||||
for (const warning of getSettingsFileKeyWarnings(
|
||||
settingsObject,
|
||||
settingsFile.path,
|
||||
)) {
|
||||
warningSet.add(warning);
|
||||
}
|
||||
}
|
||||
|
||||
return [...warningSet];
|
||||
}
|
||||
|
||||
export function migrateSettingsToV1(
|
||||
v2Settings: Record<string, unknown>,
|
||||
): Record<string, unknown> {
|
||||
|
||||
@@ -17,11 +17,7 @@ import * as cliConfig from './config/config.js';
|
||||
import { loadCliConfig, parseArguments } from './config/config.js';
|
||||
import { ExtensionStorage, loadExtensions } from './config/extension.js';
|
||||
import type { DnsResolutionOrder, LoadedSettings } from './config/settings.js';
|
||||
import {
|
||||
getSettingsWarnings,
|
||||
loadSettings,
|
||||
migrateDeprecatedSettings,
|
||||
} from './config/settings.js';
|
||||
import { loadSettings, migrateDeprecatedSettings } from './config/settings.js';
|
||||
import {
|
||||
initializeApp,
|
||||
type InitializationResult,
|
||||
@@ -404,15 +400,12 @@ export async function main() {
|
||||
|
||||
let input = config.getQuestion();
|
||||
const startupWarnings = [
|
||||
...new Set([
|
||||
...(await getStartupWarnings()),
|
||||
...(await getUserStartupWarnings({
|
||||
workspaceRoot: process.cwd(),
|
||||
useRipgrep: settings.merged.tools?.useRipgrep ?? true,
|
||||
useBuiltinRipgrep: settings.merged.tools?.useBuiltinRipgrep ?? true,
|
||||
})),
|
||||
...getSettingsWarnings(settings),
|
||||
]),
|
||||
...(await getStartupWarnings()),
|
||||
...(await getUserStartupWarnings({
|
||||
workspaceRoot: process.cwd(),
|
||||
useRipgrep: settings.merged.tools?.useRipgrep ?? true,
|
||||
useBuiltinRipgrep: settings.merged.tools?.useBuiltinRipgrep ?? true,
|
||||
})),
|
||||
];
|
||||
|
||||
// Render UI, passing necessary config values. Check that there is no command line question.
|
||||
|
||||
@@ -8,6 +8,7 @@ import { exec, execSync, spawn, type ChildProcess } from 'node:child_process';
|
||||
import os from 'node:os';
|
||||
import path from 'node:path';
|
||||
import fs from 'node:fs';
|
||||
import { readFile } from 'node:fs/promises';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
import { quote, parse } from 'shell-quote';
|
||||
import {
|
||||
@@ -49,16 +50,16 @@ const BUILTIN_SEATBELT_PROFILES = [
|
||||
|
||||
/**
|
||||
* Determines whether the sandbox container should be run with the current user's UID and GID.
|
||||
* This is often necessary on Linux systems when using rootful Docker without userns-remap
|
||||
* configured, to avoid permission issues with
|
||||
* This is often necessary on Linux systems (especially Debian/Ubuntu based) when using
|
||||
* rootful Docker without userns-remap configured, to avoid permission issues with
|
||||
* mounted volumes.
|
||||
*
|
||||
* The behavior is controlled by the `SANDBOX_SET_UID_GID` environment variable:
|
||||
* - If `SANDBOX_SET_UID_GID` is "1" or "true", this function returns `true`.
|
||||
* - If `SANDBOX_SET_UID_GID` is "0" or "false", this function returns `false`.
|
||||
* - If `SANDBOX_SET_UID_GID` is not set:
|
||||
* - On Linux, it defaults to `true`.
|
||||
* - On other OSes, it defaults to `false`.
|
||||
* - On Debian/Ubuntu Linux, it defaults to `true`.
|
||||
* - On other OSes, or if OS detection fails, it defaults to `false`.
|
||||
*
|
||||
* For more context on running Docker containers as non-root, see:
|
||||
* https://medium.com/redbubble/running-a-docker-container-as-a-non-root-user-7d2e00f8ee15
|
||||
@@ -75,20 +76,31 @@ async function shouldUseCurrentUserInSandbox(): Promise<boolean> {
|
||||
return false;
|
||||
}
|
||||
|
||||
// If environment variable is not explicitly set, check for Debian/Ubuntu Linux
|
||||
if (os.platform() === 'linux') {
|
||||
const debugEnv = [process.env['DEBUG'], process.env['DEBUG_MODE']].some(
|
||||
(v) => v === 'true' || v === '1',
|
||||
);
|
||||
if (debugEnv) {
|
||||
// Use stderr so it doesn't clutter normal STDOUT output (e.g. in `--prompt` runs).
|
||||
console.error(
|
||||
'INFO: Using current user UID/GID in Linux sandbox. Set SANDBOX_SET_UID_GID=false to disable.',
|
||||
try {
|
||||
const osReleaseContent = await readFile('/etc/os-release', 'utf8');
|
||||
if (
|
||||
osReleaseContent.includes('ID=debian') ||
|
||||
osReleaseContent.includes('ID=ubuntu') ||
|
||||
osReleaseContent.match(/^ID_LIKE=.*debian.*/m) || // Covers derivatives
|
||||
osReleaseContent.match(/^ID_LIKE=.*ubuntu.*/m) // Covers derivatives
|
||||
) {
|
||||
// note here and below we use console.error for informational messages on stderr
|
||||
console.error(
|
||||
'INFO: Defaulting to use current user UID/GID for Debian/Ubuntu-based Linux.',
|
||||
);
|
||||
return true;
|
||||
}
|
||||
} catch (_err) {
|
||||
// Silently ignore if /etc/os-release is not found or unreadable.
|
||||
// The default (false) will be applied in this case.
|
||||
console.warn(
|
||||
'Warning: Could not read /etc/os-release to auto-detect Debian/Ubuntu for UID/GID default.',
|
||||
);
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
return false;
|
||||
return false; // Default to false if no other condition is met
|
||||
}
|
||||
|
||||
// docker does not allow container names to contain ':' or '/', so we
|
||||
|
||||
@@ -16,8 +16,6 @@ import {
|
||||
isDeviceTokenPending,
|
||||
isDeviceTokenSuccess,
|
||||
isErrorResponse,
|
||||
qwenOAuth2Events,
|
||||
QwenOAuth2Event,
|
||||
QwenOAuth2Client,
|
||||
type DeviceAuthorizationResponse,
|
||||
type DeviceTokenResponse,
|
||||
@@ -847,58 +845,6 @@ describe('getQwenOAuthClient', () => {
|
||||
|
||||
SharedTokenManager.getInstance = originalGetInstance;
|
||||
});
|
||||
|
||||
it('should include troubleshooting hints when device auth fetch fails', async () => {
|
||||
// Make SharedTokenManager fail so we hit the fallback device-flow path
|
||||
const mockTokenManager = {
|
||||
getValidCredentials: vi
|
||||
.fn()
|
||||
.mockRejectedValue(new Error('Token refresh failed')),
|
||||
};
|
||||
|
||||
const originalGetInstance = SharedTokenManager.getInstance;
|
||||
SharedTokenManager.getInstance = vi.fn().mockReturnValue(mockTokenManager);
|
||||
|
||||
const tlsCause = new Error('unable to verify the first certificate');
|
||||
(tlsCause as Error & { code?: string }).code =
|
||||
'UNABLE_TO_VERIFY_LEAF_SIGNATURE';
|
||||
|
||||
const fetchError = new TypeError('fetch failed') as TypeError & {
|
||||
cause?: unknown;
|
||||
};
|
||||
fetchError.cause = tlsCause;
|
||||
|
||||
vi.mocked(global.fetch).mockRejectedValue(fetchError);
|
||||
|
||||
const emitSpy = vi.spyOn(qwenOAuth2Events, 'emit');
|
||||
|
||||
let thrownError: unknown;
|
||||
try {
|
||||
const { getQwenOAuthClient } = await import('./qwenOAuth2.js');
|
||||
await getQwenOAuthClient(mockConfig);
|
||||
} catch (error: unknown) {
|
||||
thrownError = error;
|
||||
}
|
||||
|
||||
expect(thrownError).toBeInstanceOf(Error);
|
||||
expect((thrownError as Error).message).toContain(
|
||||
'Device authorization flow failed: fetch failed',
|
||||
);
|
||||
expect((thrownError as Error).message).toContain(
|
||||
'UNABLE_TO_VERIFY_LEAF_SIGNATURE',
|
||||
);
|
||||
expect((thrownError as Error).message).toContain('NODE_EXTRA_CA_CERTS');
|
||||
expect((thrownError as Error).message).toContain('--proxy');
|
||||
|
||||
expect(emitSpy).toHaveBeenCalledWith(
|
||||
QwenOAuth2Event.AuthProgress,
|
||||
'error',
|
||||
expect.stringContaining('NODE_EXTRA_CA_CERTS'),
|
||||
);
|
||||
|
||||
emitSpy.mockRestore();
|
||||
SharedTokenManager.getInstance = originalGetInstance;
|
||||
});
|
||||
});
|
||||
|
||||
describe('CredentialsClearRequiredError', () => {
|
||||
|
||||
@@ -13,7 +13,6 @@ import open from 'open';
|
||||
import { EventEmitter } from 'events';
|
||||
import type { Config } from '../config/config.js';
|
||||
import { randomUUID } from 'node:crypto';
|
||||
import { formatFetchErrorForUser } from '../utils/fetch.js';
|
||||
import {
|
||||
SharedTokenManager,
|
||||
TokenManagerError,
|
||||
@@ -848,12 +847,8 @@ async function authWithQwenDeviceFlow(
|
||||
console.error('\n' + timeoutMessage);
|
||||
return { success: false, reason: 'timeout', message: timeoutMessage };
|
||||
} catch (error: unknown) {
|
||||
const fullErrorMessage = formatFetchErrorForUser(error, {
|
||||
url: QWEN_OAUTH_BASE_URL,
|
||||
});
|
||||
const message = `Device authorization flow failed: ${fullErrorMessage}`;
|
||||
|
||||
qwenOAuth2Events.emit(QwenOAuth2Event.AuthProgress, 'error', message);
|
||||
const errorMessage = error instanceof Error ? error.message : String(error);
|
||||
const message = `Device authorization flow failed: ${errorMessage}`;
|
||||
console.error(message);
|
||||
return { success: false, reason: 'error', message };
|
||||
} finally {
|
||||
|
||||
@@ -818,7 +818,7 @@ describe('ShellExecutionService child_process fallback', () => {
|
||||
});
|
||||
|
||||
describe('Platform-Specific Behavior', () => {
|
||||
it('should use cmd.exe and hide window on Windows', async () => {
|
||||
it('should use cmd.exe on Windows', async () => {
|
||||
mockPlatform.mockReturnValue('win32');
|
||||
await simulateExecution('dir "foo bar"', (cp) =>
|
||||
cp.emit('exit', 0, null),
|
||||
@@ -829,8 +829,7 @@ describe('ShellExecutionService child_process fallback', () => {
|
||||
[],
|
||||
expect.objectContaining({
|
||||
shell: true,
|
||||
detached: false,
|
||||
windowsHide: true,
|
||||
detached: true,
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
@@ -229,8 +229,7 @@ export class ShellExecutionService {
|
||||
stdio: ['ignore', 'pipe', 'pipe'],
|
||||
windowsVerbatimArguments: true,
|
||||
shell: isWindows ? true : 'bash',
|
||||
detached: !isWindows,
|
||||
windowsHide: isWindows,
|
||||
detached: true,
|
||||
env: {
|
||||
...process.env,
|
||||
QWEN_CODE: '1',
|
||||
|
||||
@@ -1,52 +0,0 @@
|
||||
/**
|
||||
* @license
|
||||
* Copyright 2025 Google LLC
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import { FetchError, formatFetchErrorForUser } from './fetch.js';
|
||||
|
||||
describe('formatFetchErrorForUser', () => {
|
||||
it('includes troubleshooting hints for TLS errors', () => {
|
||||
const tlsCause = new Error('unable to verify the first certificate');
|
||||
(tlsCause as Error & { code?: string }).code =
|
||||
'UNABLE_TO_VERIFY_LEAF_SIGNATURE';
|
||||
|
||||
const fetchError = new TypeError('fetch failed') as TypeError & {
|
||||
cause?: unknown;
|
||||
};
|
||||
fetchError.cause = tlsCause;
|
||||
|
||||
const message = formatFetchErrorForUser(fetchError, {
|
||||
url: 'https://chat.qwen.ai',
|
||||
});
|
||||
|
||||
expect(message).toContain('fetch failed');
|
||||
expect(message).toContain('UNABLE_TO_VERIFY_LEAF_SIGNATURE');
|
||||
expect(message).toContain('Troubleshooting:');
|
||||
expect(message).toContain('Confirm you can reach https://chat.qwen.ai');
|
||||
expect(message).toContain('--proxy');
|
||||
expect(message).toContain('NODE_EXTRA_CA_CERTS');
|
||||
});
|
||||
|
||||
it('includes troubleshooting hints for network codes', () => {
|
||||
const fetchError = new FetchError(
|
||||
'Request timed out after 100ms',
|
||||
'ETIMEDOUT',
|
||||
);
|
||||
const message = formatFetchErrorForUser(fetchError, {
|
||||
url: 'https://example.com',
|
||||
});
|
||||
|
||||
expect(message).toContain('Request timed out after 100ms');
|
||||
expect(message).toContain('Troubleshooting:');
|
||||
expect(message).toContain('Confirm you can reach https://example.com');
|
||||
expect(message).toContain('--proxy');
|
||||
expect(message).not.toContain('NODE_EXTRA_CA_CERTS');
|
||||
});
|
||||
|
||||
it('does not include troubleshooting for non-fetch errors', () => {
|
||||
expect(formatFetchErrorForUser(new Error('boom'))).toBe('boom');
|
||||
});
|
||||
});
|
||||
@@ -17,26 +17,6 @@ const PRIVATE_IP_RANGES = [
|
||||
/^fe80:/,
|
||||
];
|
||||
|
||||
const TLS_ERROR_CODES = new Set([
|
||||
'UNABLE_TO_GET_ISSUER_CERT_LOCALLY',
|
||||
'UNABLE_TO_VERIFY_LEAF_SIGNATURE',
|
||||
'SELF_SIGNED_CERT_IN_CHAIN',
|
||||
'DEPTH_ZERO_SELF_SIGNED_CERT',
|
||||
'CERT_HAS_EXPIRED',
|
||||
'ERR_TLS_CERT_ALTNAME_INVALID',
|
||||
]);
|
||||
|
||||
const FETCH_TROUBLESHOOTING_ERROR_CODES = new Set([
|
||||
...TLS_ERROR_CODES,
|
||||
'ECONNRESET',
|
||||
'ETIMEDOUT',
|
||||
'ECONNREFUSED',
|
||||
'ENOTFOUND',
|
||||
'EAI_AGAIN',
|
||||
'EHOSTUNREACH',
|
||||
'ENETUNREACH',
|
||||
]);
|
||||
|
||||
export class FetchError extends Error {
|
||||
constructor(
|
||||
message: string,
|
||||
@@ -75,118 +55,3 @@ export async function fetchWithTimeout(
|
||||
clearTimeout(timeoutId);
|
||||
}
|
||||
}
|
||||
|
||||
function getErrorCode(error: unknown): string | undefined {
|
||||
if (!error || typeof error !== 'object') {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
if (
|
||||
'code' in error &&
|
||||
typeof (error as Record<string, unknown>)['code'] === 'string'
|
||||
) {
|
||||
return (error as Record<string, string>)['code'];
|
||||
}
|
||||
|
||||
return undefined;
|
||||
}
|
||||
|
||||
function formatUnknownErrorMessage(error: unknown): string | undefined {
|
||||
if (typeof error === 'string') {
|
||||
return error;
|
||||
}
|
||||
|
||||
if (
|
||||
typeof error === 'number' ||
|
||||
typeof error === 'boolean' ||
|
||||
typeof error === 'bigint'
|
||||
) {
|
||||
return String(error);
|
||||
}
|
||||
|
||||
if (error instanceof Error) {
|
||||
return error.message;
|
||||
}
|
||||
|
||||
if (!error || typeof error !== 'object') {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
const message = (error as Record<string, unknown>)['message'];
|
||||
if (typeof message === 'string') {
|
||||
return message;
|
||||
}
|
||||
|
||||
return undefined;
|
||||
}
|
||||
|
||||
function formatErrorCause(error: unknown): string | undefined {
|
||||
if (!(error instanceof Error)) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
const cause = (error as Error & { cause?: unknown }).cause;
|
||||
if (!cause) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
const causeCode = getErrorCode(cause);
|
||||
const causeMessage = formatUnknownErrorMessage(cause);
|
||||
|
||||
if (!causeCode && !causeMessage) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
if (causeCode && causeMessage && !causeMessage.includes(causeCode)) {
|
||||
return `${causeCode}: ${causeMessage}`;
|
||||
}
|
||||
|
||||
return causeMessage ?? causeCode;
|
||||
}
|
||||
|
||||
export function formatFetchErrorForUser(
|
||||
error: unknown,
|
||||
options: { url?: string } = {},
|
||||
): string {
|
||||
const errorMessage = getErrorMessage(error);
|
||||
|
||||
const code =
|
||||
error instanceof Error
|
||||
? (getErrorCode((error as Error & { cause?: unknown }).cause) ??
|
||||
getErrorCode(error))
|
||||
: getErrorCode(error);
|
||||
|
||||
const cause = formatErrorCause(error);
|
||||
const fullErrorMessage = [
|
||||
errorMessage,
|
||||
cause ? `(cause: ${cause})` : undefined,
|
||||
]
|
||||
.filter(Boolean)
|
||||
.join(' ');
|
||||
|
||||
const shouldShowFetchHints =
|
||||
errorMessage.toLowerCase().includes('fetch failed') ||
|
||||
(code != null && FETCH_TROUBLESHOOTING_ERROR_CODES.has(code));
|
||||
|
||||
const shouldShowTlsHint = code != null && TLS_ERROR_CODES.has(code);
|
||||
|
||||
if (!shouldShowFetchHints) {
|
||||
return fullErrorMessage;
|
||||
}
|
||||
|
||||
const hintLines = [
|
||||
'',
|
||||
'Troubleshooting:',
|
||||
...(options.url
|
||||
? [`- Confirm you can reach ${options.url} from this machine.`]
|
||||
: []),
|
||||
'- If you are behind a proxy, pass `--proxy <url>` (or set `proxy` in settings).',
|
||||
...(shouldShowTlsHint
|
||||
? [
|
||||
'- If your network uses a corporate TLS inspection CA, set `NODE_EXTRA_CA_CERTS` to your CA bundle.',
|
||||
]
|
||||
: []),
|
||||
];
|
||||
|
||||
return `${fullErrorMessage}${hintLines.join('\n')}`;
|
||||
}
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@qwen-code/sdk",
|
||||
"version": "0.1.2",
|
||||
"version": "0.1.0",
|
||||
"description": "TypeScript SDK for programmatic access to qwen-code CLI",
|
||||
"main": "./dist/index.cjs",
|
||||
"module": "./dist/index.mjs",
|
||||
|
||||
@@ -5,7 +5,7 @@
|
||||
import type { SDKUserMessage } from '../types/protocol.js';
|
||||
import { serializeJsonLine } from '../utils/jsonLines.js';
|
||||
import { ProcessTransport } from '../transport/ProcessTransport.js';
|
||||
import { prepareSpawnInfo, type SpawnInfo } from '../utils/cliPath.js';
|
||||
import { parseExecutableSpec } from '../utils/cliPath.js';
|
||||
import { Query } from './Query.js';
|
||||
import type { QueryOptions } from '../types/types.js';
|
||||
import { QueryOptionsSchema } from '../types/queryOptionsSchema.js';
|
||||
@@ -32,17 +32,17 @@ export function query({
|
||||
*/
|
||||
options?: QueryOptions;
|
||||
}): Query {
|
||||
const spawnInfo = validateOptions(options);
|
||||
const parsedExecutable = validateOptions(options);
|
||||
|
||||
const isSingleTurn = typeof prompt === 'string';
|
||||
|
||||
const pathToQwenExecutable = options.pathToQwenExecutable;
|
||||
const pathToQwenExecutable =
|
||||
options.pathToQwenExecutable ?? parsedExecutable.executablePath;
|
||||
|
||||
const abortController = options.abortController ?? new AbortController();
|
||||
|
||||
const transport = new ProcessTransport({
|
||||
pathToQwenExecutable,
|
||||
spawnInfo,
|
||||
cwd: options.cwd,
|
||||
model: options.model,
|
||||
permissionMode: options.permissionMode,
|
||||
@@ -97,7 +97,9 @@ export function query({
|
||||
return queryInstance;
|
||||
}
|
||||
|
||||
function validateOptions(options: QueryOptions): SpawnInfo | undefined {
|
||||
function validateOptions(
|
||||
options: QueryOptions,
|
||||
): ReturnType<typeof parseExecutableSpec> {
|
||||
const validationResult = QueryOptionsSchema.safeParse(options);
|
||||
if (!validationResult.success) {
|
||||
const errors = validationResult.error.errors
|
||||
@@ -106,10 +108,13 @@ function validateOptions(options: QueryOptions): SpawnInfo | undefined {
|
||||
throw new Error(`Invalid QueryOptions: ${errors}`);
|
||||
}
|
||||
|
||||
let parsedExecutable: ReturnType<typeof parseExecutableSpec>;
|
||||
try {
|
||||
return prepareSpawnInfo(options.pathToQwenExecutable);
|
||||
parsedExecutable = parseExecutableSpec(options.pathToQwenExecutable);
|
||||
} catch (error) {
|
||||
const errorMessage = error instanceof Error ? error.message : String(error);
|
||||
throw new Error(`Invalid pathToQwenExecutable: ${errorMessage}`);
|
||||
}
|
||||
|
||||
return parsedExecutable;
|
||||
}
|
||||
|
||||
@@ -44,9 +44,7 @@ export class ProcessTransport implements Transport {
|
||||
const cwd = this.options.cwd ?? process.cwd();
|
||||
const env = { ...process.env, ...this.options.env };
|
||||
|
||||
const spawnInfo =
|
||||
this.options.spawnInfo ??
|
||||
prepareSpawnInfo(this.options.pathToQwenExecutable);
|
||||
const spawnInfo = prepareSpawnInfo(this.options.pathToQwenExecutable);
|
||||
|
||||
const stderrMode =
|
||||
this.options.debug || this.options.stderr ? 'pipe' : 'ignore';
|
||||
@@ -142,7 +140,6 @@ export class ProcessTransport implements Transport {
|
||||
'--output-format',
|
||||
'stream-json',
|
||||
'--channel=SDK',
|
||||
'--experimental-skills',
|
||||
];
|
||||
|
||||
if (this.options.model) {
|
||||
|
||||
@@ -4,13 +4,11 @@ import type {
|
||||
SubagentConfig,
|
||||
SDKMcpServerConfig,
|
||||
} from './protocol.js';
|
||||
import type { SpawnInfo } from '../utils/cliPath.js';
|
||||
|
||||
export type { PermissionMode };
|
||||
|
||||
export type TransportOptions = {
|
||||
pathToQwenExecutable?: string;
|
||||
spawnInfo?: SpawnInfo;
|
||||
pathToQwenExecutable: string;
|
||||
cwd?: string;
|
||||
model?: string;
|
||||
permissionMode?: PermissionMode;
|
||||
@@ -179,25 +177,32 @@ export interface QueryOptions {
|
||||
model?: string;
|
||||
|
||||
/**
|
||||
* Path to the Qwen CLI executable.
|
||||
*
|
||||
* If not provided, the SDK automatically uses the bundled CLI included in the package.
|
||||
* Path to the Qwen CLI executable or runtime specification.
|
||||
*
|
||||
* Supports multiple formats:
|
||||
* - Command name (no path separators): `'qwen'` -> executes from PATH
|
||||
* - JavaScript file: `'/path/to/cli.js'` -> uses Node.js (or Bun if running under Bun)
|
||||
* - TypeScript file: `'/path/to/index.ts'` -> uses tsx if available (silent support for dev/debug)
|
||||
* - Native binary: `'/path/to/qwen'` -> executes directly
|
||||
* - 'qwen' -> native binary (auto-detected from PATH)
|
||||
* - '/path/to/qwen' -> native binary (explicit path)
|
||||
* - '/path/to/cli.js' -> Node.js bundle (default for .js files)
|
||||
* - '/path/to/index.ts' -> TypeScript source (requires tsx)
|
||||
* - 'bun:/path/to/cli.js' -> Force Bun runtime
|
||||
* - 'node:/path/to/cli.js' -> Force Node.js runtime
|
||||
* - 'tsx:/path/to/index.ts' -> Force tsx runtime
|
||||
* - 'deno:/path/to/cli.ts' -> Force Deno runtime
|
||||
*
|
||||
* Runtime detection:
|
||||
* - `.js/.mjs/.cjs` files: Node.js (or Bun if running under Bun)
|
||||
* - `.ts/.tsx` files: tsx if available, otherwise treated as native
|
||||
* - Command names: executed directly from PATH
|
||||
* - Other files: executed as native binaries
|
||||
* If not provided, the SDK will auto-detect the native binary in this order:
|
||||
* 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
|
||||
*
|
||||
* The .ts files are only supported for debugging purposes.
|
||||
*
|
||||
* @example '/path/to/cli.js'
|
||||
* @example 'qwen'
|
||||
* @example './packages/cli/index.ts'
|
||||
* @example '/usr/local/bin/qwen'
|
||||
* @example 'tsx:/path/to/packages/cli/src/index.ts'
|
||||
*/
|
||||
pathToQwenExecutable?: string;
|
||||
|
||||
|
||||
@@ -1,29 +1,28 @@
|
||||
/**
|
||||
* @license
|
||||
* Copyright 2025 Qwen Team
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
/**
|
||||
* CLI path resolution and subprocess spawning utilities
|
||||
* CLI path auto-detection and subprocess spawning utilities
|
||||
*
|
||||
* Supports multiple execution modes:
|
||||
* 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)
|
||||
*/
|
||||
|
||||
import * as fs from 'node:fs';
|
||||
import * as path from 'node:path';
|
||||
import { execSync } from 'node:child_process';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
import { createRequire } from 'node:module';
|
||||
|
||||
/**
|
||||
* Executable types supported by the SDK
|
||||
*/
|
||||
export type ExecutableType = 'node' | 'bun' | 'tsx' | 'native';
|
||||
export type ExecutableType = 'native' | 'node' | 'bun' | 'tsx' | 'deno';
|
||||
|
||||
/**
|
||||
* Spawn information for CLI process
|
||||
*/
|
||||
export type SpawnInfo = {
|
||||
/** Command to execute (e.g., 'node', 'bun', 'tsx', or native binary path) */
|
||||
/** Command to execute (e.g., 'qwen', 'node', 'bun', 'tsx') */
|
||||
command: string;
|
||||
/** Arguments to pass to command */
|
||||
args: string[];
|
||||
@@ -33,243 +32,49 @@ export type SpawnInfo = {
|
||||
originalInput: string;
|
||||
};
|
||||
|
||||
/**
|
||||
* Get the directory containing the current module (ESM or CJS)
|
||||
*/
|
||||
function getCurrentModuleDir(): string {
|
||||
let moduleDir: string | null = null;
|
||||
|
||||
try {
|
||||
if (typeof import.meta !== 'undefined' && import.meta.url) {
|
||||
moduleDir = path.dirname(fileURLToPath(import.meta.url));
|
||||
}
|
||||
} catch {
|
||||
// Fall through to CJS
|
||||
}
|
||||
|
||||
if (!moduleDir) {
|
||||
try {
|
||||
if (typeof __dirname !== 'undefined') {
|
||||
moduleDir = __dirname;
|
||||
}
|
||||
} catch {
|
||||
// Fall through
|
||||
}
|
||||
}
|
||||
|
||||
if (moduleDir) {
|
||||
return path.normalize(moduleDir);
|
||||
}
|
||||
throw new Error('Cannot find module directory.');
|
||||
}
|
||||
|
||||
/**
|
||||
* Find the SDK package root directory
|
||||
*/
|
||||
function findSdkPackageRoot(): string | null {
|
||||
try {
|
||||
const require = createRequire(import.meta.url);
|
||||
const packageJsonPath = require.resolve('@qwen-code/sdk/package.json');
|
||||
const packageRoot = path.dirname(packageJsonPath);
|
||||
const cliPath = path.join(packageRoot, 'dist', 'cli', 'cli.js');
|
||||
if (fs.existsSync(cliPath)) {
|
||||
return packageRoot;
|
||||
}
|
||||
} catch {
|
||||
// Continue to fallback strategy
|
||||
}
|
||||
|
||||
const currentDir = getCurrentModuleDir();
|
||||
let dir = currentDir;
|
||||
const root = path.parse(dir).root;
|
||||
let bestMatch: string | null = null;
|
||||
|
||||
while (dir !== root) {
|
||||
const packageJsonPath = path.join(dir, 'package.json');
|
||||
if (fs.existsSync(packageJsonPath)) {
|
||||
const cliPath = path.join(dir, 'dist', 'cli', 'cli.js');
|
||||
if (fs.existsSync(cliPath)) {
|
||||
try {
|
||||
const packageJson = JSON.parse(
|
||||
fs.readFileSync(packageJsonPath, 'utf-8'),
|
||||
);
|
||||
if (packageJson.name === '@qwen-code/sdk') {
|
||||
return dir;
|
||||
}
|
||||
if (!bestMatch) {
|
||||
bestMatch = dir;
|
||||
}
|
||||
} catch {
|
||||
if (!bestMatch) {
|
||||
bestMatch = dir;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
dir = path.dirname(dir);
|
||||
}
|
||||
|
||||
return bestMatch;
|
||||
}
|
||||
|
||||
/**
|
||||
* Normalize path separators for regex matching
|
||||
*/
|
||||
function normalizeForRegex(dirPath: string): string {
|
||||
return dirPath.replace(/\\/g, '/');
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve bundled CLI using import.meta.url relative path
|
||||
*/
|
||||
function tryResolveCliFromImportMeta(): string | null {
|
||||
try {
|
||||
if (typeof import.meta !== 'undefined' && import.meta.url) {
|
||||
const cliUrl = new URL('./cli/cli.js', import.meta.url);
|
||||
const cliPath = fileURLToPath(cliUrl);
|
||||
if (fs.existsSync(cliPath)) {
|
||||
return cliPath;
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
// Ignore errors
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get all candidate paths for the bundled CLI
|
||||
*/
|
||||
function getBundledCliCandidatePaths(): string[] {
|
||||
const candidates: string[] = [];
|
||||
|
||||
const importMetaResolved = tryResolveCliFromImportMeta();
|
||||
if (importMetaResolved) {
|
||||
candidates.push(importMetaResolved);
|
||||
}
|
||||
|
||||
try {
|
||||
const currentDir = getCurrentModuleDir();
|
||||
const normalizedDir = normalizeForRegex(currentDir);
|
||||
|
||||
candidates.push(path.join(currentDir, 'cli', 'cli.js'));
|
||||
|
||||
if (/\/src\/utils$/.test(normalizedDir)) {
|
||||
const packageRoot = path.dirname(path.dirname(currentDir));
|
||||
candidates.push(path.join(packageRoot, 'dist', 'cli', 'cli.js'));
|
||||
}
|
||||
|
||||
const packageRoot = findSdkPackageRoot();
|
||||
if (packageRoot) {
|
||||
candidates.push(path.join(packageRoot, 'dist', 'cli', 'cli.js'));
|
||||
}
|
||||
|
||||
const monorepoMatch = normalizedDir.match(
|
||||
/^(.+?)\/packages\/sdk-typescript/,
|
||||
);
|
||||
if (monorepoMatch && monorepoMatch[1]) {
|
||||
const monorepoRoot =
|
||||
process.platform === 'win32'
|
||||
? monorepoMatch[1].replace(/\//g, '\\')
|
||||
: monorepoMatch[1];
|
||||
candidates.push(path.join(monorepoRoot, 'dist', 'cli.js'));
|
||||
}
|
||||
} catch {
|
||||
const packageRoot = findSdkPackageRoot();
|
||||
if (packageRoot) {
|
||||
candidates.push(path.join(packageRoot, 'dist', 'cli', 'cli.js'));
|
||||
}
|
||||
}
|
||||
|
||||
return candidates;
|
||||
}
|
||||
|
||||
/**
|
||||
* Find the bundled CLI path
|
||||
*/
|
||||
function getBundledCliPath(): string | null {
|
||||
const candidates = getBundledCliCandidatePaths();
|
||||
try {
|
||||
const currentFile =
|
||||
typeof __filename !== 'undefined'
|
||||
? __filename
|
||||
: fileURLToPath(import.meta.url);
|
||||
|
||||
for (const candidate of candidates) {
|
||||
if (fs.existsSync(candidate)) {
|
||||
return candidate;
|
||||
const currentDir = path.dirname(currentFile);
|
||||
|
||||
const bundledCliPath = path.join(currentDir, 'cli', 'cli.js');
|
||||
|
||||
if (fs.existsSync(bundledCliPath)) {
|
||||
return bundledCliPath;
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
return null;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Find the bundled CLI path or throw error
|
||||
*/
|
||||
export function findBundledCliPath(): string {
|
||||
export function findNativeCliPath(): string {
|
||||
const bundledCli = getBundledCliPath();
|
||||
if (bundledCli) {
|
||||
return bundledCli;
|
||||
}
|
||||
|
||||
const candidates = getBundledCliCandidatePaths();
|
||||
throw new Error(
|
||||
'Bundled qwen CLI not found. The CLI should be included in the SDK package.\n' +
|
||||
'Searched locations:\n' +
|
||||
candidates.map((c) => ` - ${c}`).join('\n') +
|
||||
'\n\nIf you need to use a custom CLI, provide explicit path:\n' +
|
||||
' • query({ pathToQwenExecutable: "/path/to/cli.js" })',
|
||||
'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' +
|
||||
' • Force specific runtime: query({ pathToQwenExecutable: "bun:/path/to/cli.js" })',
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Validate file exists and is a file
|
||||
*/
|
||||
function validateFilePath(filePath: string): void {
|
||||
if (!fs.existsSync(filePath)) {
|
||||
throw new Error(
|
||||
`Executable file not found at '${filePath}'. ` +
|
||||
'Please check the file path and ensure the file exists.',
|
||||
);
|
||||
}
|
||||
|
||||
const stats = fs.statSync(filePath);
|
||||
if (!stats.isFile()) {
|
||||
throw new Error(
|
||||
`Path '${filePath}' exists but is not a file. ` +
|
||||
'Please provide a path to an executable file.',
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if path contains separators (file path vs command name)
|
||||
*/
|
||||
function isFilePath(spec: string): boolean {
|
||||
return spec.includes('/') || spec.includes('\\');
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if file is JavaScript
|
||||
*/
|
||||
function isJavaScriptFile(filePath: string): boolean {
|
||||
const ext = path.extname(filePath).toLowerCase();
|
||||
return ['.js', '.mjs', '.cjs'].includes(ext);
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if file is TypeScript
|
||||
*/
|
||||
function isTypeScriptFile(filePath: string): boolean {
|
||||
const ext = path.extname(filePath).toLowerCase();
|
||||
return ['.ts', '.tsx'].includes(ext);
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if command is available in PATH
|
||||
*/
|
||||
function isCommandAvailable(command: string): boolean {
|
||||
try {
|
||||
// Use 'which' on Unix-like systems, 'where' on Windows
|
||||
const whichCommand = process.platform === 'win32' ? 'where' : 'which';
|
||||
execSync(`${whichCommand} ${command}`, {
|
||||
stdio: 'ignore',
|
||||
timeout: 1000,
|
||||
timeout: 5000, // 5 second timeout
|
||||
});
|
||||
return true;
|
||||
} catch {
|
||||
@@ -277,87 +82,245 @@ function isCommandAvailable(command: string): boolean {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if tsx is available
|
||||
*/
|
||||
function isTsxAvailable(): boolean {
|
||||
return isCommandAvailable('tsx');
|
||||
function validateRuntimeAvailability(runtime: string): boolean {
|
||||
// Node.js is always available since we're running in Node.js
|
||||
if (runtime === 'node') {
|
||||
return true;
|
||||
}
|
||||
|
||||
// Check if the runtime command is available in PATH
|
||||
return isCommandAvailable(runtime);
|
||||
}
|
||||
|
||||
function validateFileExtensionForRuntime(
|
||||
filePath: string,
|
||||
runtime: string,
|
||||
): boolean {
|
||||
const ext = path.extname(filePath).toLowerCase();
|
||||
|
||||
switch (runtime) {
|
||||
case 'node':
|
||||
case 'bun':
|
||||
return ['.js', '.mjs', '.cjs'].includes(ext);
|
||||
case 'tsx':
|
||||
return ['.ts', '.tsx'].includes(ext);
|
||||
case 'deno':
|
||||
return ['.ts', '.tsx', '.js', '.mjs'].includes(ext);
|
||||
default:
|
||||
return true; // Unknown runtime, let it pass
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get JavaScript runtime type (bun if running under bun, otherwise node)
|
||||
* Parse executable specification into components with comprehensive validation
|
||||
*
|
||||
* Supports multiple formats:
|
||||
* - 'qwen' -> native binary (auto-detected)
|
||||
* - '/path/to/qwen' -> native binary (explicit path)
|
||||
* - '/path/to/cli.js' -> Node.js bundle (default for .js files)
|
||||
* - '/path/to/index.ts' -> TypeScript source (requires tsx)
|
||||
*
|
||||
* Advanced runtime specification (for overriding defaults):
|
||||
* - 'bun:/path/to/cli.js' -> Force Bun runtime
|
||||
* - 'node:/path/to/cli.js' -> Force Node.js runtime
|
||||
* - 'tsx:/path/to/index.ts' -> Force tsx runtime
|
||||
* - 'deno:/path/to/cli.ts' -> Force Deno runtime
|
||||
*
|
||||
* @param executableSpec - Executable specification
|
||||
* @returns Parsed executable information
|
||||
* @throws Error if specification is invalid or files don't exist
|
||||
*/
|
||||
function getJsRuntimeType(): 'bun' | 'node' {
|
||||
export function parseExecutableSpec(executableSpec?: string): {
|
||||
runtime?: string;
|
||||
executablePath: string;
|
||||
isExplicitRuntime: boolean;
|
||||
} {
|
||||
if (
|
||||
typeof process !== 'undefined' &&
|
||||
'versions' in process &&
|
||||
'bun' in process.versions
|
||||
executableSpec === '' ||
|
||||
(executableSpec && executableSpec.trim() === '')
|
||||
) {
|
||||
return 'bun';
|
||||
}
|
||||
return 'node';
|
||||
}
|
||||
|
||||
/**
|
||||
* Prepare spawn information for CLI process
|
||||
*/
|
||||
export function prepareSpawnInfo(executableSpec?: string): SpawnInfo {
|
||||
if (executableSpec !== undefined && executableSpec.trim() === '') {
|
||||
throw new Error('Executable path cannot be empty');
|
||||
throw new Error('Command name cannot be empty');
|
||||
}
|
||||
|
||||
if (executableSpec === undefined) {
|
||||
const bundledCliPath = findBundledCliPath();
|
||||
if (!executableSpec) {
|
||||
return {
|
||||
command: process.execPath,
|
||||
args: [bundledCliPath],
|
||||
type: getJsRuntimeType(),
|
||||
originalInput: '',
|
||||
executablePath: findNativeCliPath(),
|
||||
isExplicitRuntime: false,
|
||||
};
|
||||
}
|
||||
|
||||
if (!isFilePath(executableSpec)) {
|
||||
if (!/^[a-zA-Z0-9._-]+$/.test(executableSpec)) {
|
||||
throw new Error(
|
||||
`Invalid command name '${executableSpec}'. ` +
|
||||
'Command names should only contain letters, numbers, dots, hyphens, and underscores.',
|
||||
);
|
||||
}
|
||||
return {
|
||||
command: executableSpec,
|
||||
args: [],
|
||||
type: 'native',
|
||||
originalInput: executableSpec,
|
||||
};
|
||||
}
|
||||
// Check for runtime prefix (e.g., 'bun:/path/to/cli.js')
|
||||
// Use whitelist mechanism: only treat as runtime spec if prefix matches supported runtimes
|
||||
const supportedRuntimes = ['node', 'bun', 'tsx', 'deno'];
|
||||
const runtimeMatch = executableSpec.match(/^([^:]+):(.+)$/);
|
||||
|
||||
const resolvedPath = path.resolve(executableSpec);
|
||||
validateFilePath(resolvedPath);
|
||||
if (runtimeMatch) {
|
||||
const [, runtime, filePath] = runtimeMatch;
|
||||
|
||||
if (isJavaScriptFile(resolvedPath)) {
|
||||
return {
|
||||
command: process.execPath,
|
||||
args: [resolvedPath],
|
||||
type: getJsRuntimeType(),
|
||||
originalInput: executableSpec,
|
||||
};
|
||||
}
|
||||
// Only process as runtime specification if it matches a supported runtime
|
||||
if (runtime && supportedRuntimes.includes(runtime)) {
|
||||
if (!filePath) {
|
||||
throw new Error(`Invalid runtime specification: '${executableSpec}'`);
|
||||
}
|
||||
|
||||
if (!validateRuntimeAvailability(runtime)) {
|
||||
throw new Error(
|
||||
`Runtime '${runtime}' is not available on this system. Please install it first.`,
|
||||
);
|
||||
}
|
||||
|
||||
const resolvedPath = path.resolve(filePath);
|
||||
|
||||
if (!fs.existsSync(resolvedPath)) {
|
||||
throw new Error(
|
||||
`Executable file not found at '${resolvedPath}' for runtime '${runtime}'. ` +
|
||||
'Please check the file path and ensure the file exists.',
|
||||
);
|
||||
}
|
||||
|
||||
if (!validateFileExtensionForRuntime(resolvedPath, runtime)) {
|
||||
const ext = path.extname(resolvedPath);
|
||||
throw new Error(
|
||||
`File extension '${ext}' is not compatible with runtime '${runtime}'. ` +
|
||||
`Expected extensions for ${runtime}: ${getExpectedExtensions(runtime).join(', ')}`,
|
||||
);
|
||||
}
|
||||
|
||||
if (isTypeScriptFile(resolvedPath)) {
|
||||
if (isTsxAvailable()) {
|
||||
return {
|
||||
command: 'tsx',
|
||||
args: [resolvedPath],
|
||||
type: 'tsx',
|
||||
originalInput: executableSpec,
|
||||
runtime,
|
||||
executablePath: resolvedPath,
|
||||
isExplicitRuntime: true,
|
||||
};
|
||||
}
|
||||
// If not a supported runtime, fall through to treat as file path (e.g., Windows paths like 'D:\path\to\cli.js')
|
||||
}
|
||||
|
||||
// Check if it's a command name (no path separators) or a file path
|
||||
const isCommandName =
|
||||
!executableSpec.includes('/') && !executableSpec.includes('\\');
|
||||
|
||||
if (isCommandName) {
|
||||
// It's a command name like 'qwen' - validate it's a reasonable command name
|
||||
if (!executableSpec || executableSpec.trim() === '') {
|
||||
throw new Error('Command name cannot be empty');
|
||||
}
|
||||
|
||||
// Basic validation for command names
|
||||
if (!/^[a-zA-Z0-9._-]+$/.test(executableSpec)) {
|
||||
throw new Error(
|
||||
`Invalid command name '${executableSpec}'. Command names should only contain letters, numbers, dots, hyphens, and underscores.`,
|
||||
);
|
||||
}
|
||||
|
||||
return {
|
||||
executablePath: executableSpec,
|
||||
isExplicitRuntime: false,
|
||||
};
|
||||
}
|
||||
|
||||
// It's a file path - validate and resolve
|
||||
const resolvedPath = path.resolve(executableSpec);
|
||||
|
||||
if (!fs.existsSync(resolvedPath)) {
|
||||
throw new Error(
|
||||
`Executable file not found at '${resolvedPath}'. ` +
|
||||
'Please check the file path and ensure the file exists. ' +
|
||||
'You can also:\n' +
|
||||
' • Set QWEN_CODE_CLI_PATH environment variable\n' +
|
||||
' • Install qwen globally: npm install -g qwen\n' +
|
||||
' • For TypeScript files, ensure tsx is installed: npm install -g tsx\n' +
|
||||
' • Force specific runtime: bun:/path/to/cli.js or tsx:/path/to/index.ts',
|
||||
);
|
||||
}
|
||||
|
||||
// Additional validation for file paths
|
||||
const stats = fs.statSync(resolvedPath);
|
||||
if (!stats.isFile()) {
|
||||
throw new Error(
|
||||
`Path '${resolvedPath}' exists but is not a file. Please provide a path to an executable file.`,
|
||||
);
|
||||
}
|
||||
|
||||
return {
|
||||
command: resolvedPath,
|
||||
args: [],
|
||||
type: 'native',
|
||||
originalInput: executableSpec,
|
||||
executablePath: resolvedPath,
|
||||
isExplicitRuntime: false,
|
||||
};
|
||||
}
|
||||
|
||||
function getExpectedExtensions(runtime: string): string[] {
|
||||
switch (runtime) {
|
||||
case 'node':
|
||||
case 'bun':
|
||||
return ['.js', '.mjs', '.cjs'];
|
||||
case 'tsx':
|
||||
return ['.ts', '.tsx'];
|
||||
case 'deno':
|
||||
return ['.ts', '.tsx', '.js', '.mjs'];
|
||||
default:
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
function detectRuntimeFromExtension(filePath: string): string | undefined {
|
||||
const ext = path.extname(filePath).toLowerCase();
|
||||
|
||||
if (['.js', '.mjs', '.cjs'].includes(ext)) {
|
||||
// Default to Node.js for JavaScript files
|
||||
return 'node';
|
||||
}
|
||||
|
||||
if (['.ts', '.tsx'].includes(ext)) {
|
||||
// Check if tsx is available for TypeScript files
|
||||
if (isCommandAvailable('tsx')) {
|
||||
return 'tsx';
|
||||
}
|
||||
// If tsx is not available, suggest it in error message
|
||||
throw new Error(
|
||||
`TypeScript file '${filePath}' requires 'tsx' runtime, but it's not available. ` +
|
||||
'Please install tsx: npm install -g tsx, or use explicit runtime: tsx:/path/to/file.ts',
|
||||
);
|
||||
}
|
||||
|
||||
// Native executable or unknown extension
|
||||
return undefined;
|
||||
}
|
||||
|
||||
export function prepareSpawnInfo(executableSpec?: string): SpawnInfo {
|
||||
const parsed = parseExecutableSpec(executableSpec);
|
||||
const { runtime, executablePath, isExplicitRuntime } = parsed;
|
||||
|
||||
// If runtime is explicitly specified, use it
|
||||
if (isExplicitRuntime && runtime) {
|
||||
const runtimeCommand = runtime === 'node' ? process.execPath : runtime;
|
||||
|
||||
return {
|
||||
command: runtimeCommand,
|
||||
args: [executablePath],
|
||||
type: runtime as ExecutableType,
|
||||
originalInput: executableSpec || '',
|
||||
};
|
||||
}
|
||||
|
||||
// If no explicit runtime, try to detect from file extension
|
||||
const detectedRuntime = detectRuntimeFromExtension(executablePath);
|
||||
|
||||
if (detectedRuntime) {
|
||||
const runtimeCommand =
|
||||
detectedRuntime === 'node' ? process.execPath : detectedRuntime;
|
||||
|
||||
return {
|
||||
command: runtimeCommand,
|
||||
args: [executablePath],
|
||||
type: detectedRuntime as ExecutableType,
|
||||
originalInput: executableSpec || '',
|
||||
};
|
||||
}
|
||||
|
||||
// Native executable or command name - use it directly
|
||||
return {
|
||||
command: executablePath,
|
||||
args: [],
|
||||
type: 'native',
|
||||
originalInput: executableSpec || '',
|
||||
};
|
||||
}
|
||||
|
||||
@@ -1,21 +1,16 @@
|
||||
/**
|
||||
* @license
|
||||
* Copyright 2025 Qwen Team
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
/**
|
||||
* Unit tests for CLI path utilities
|
||||
* Tests executable detection, parsing, and spawn info preparation
|
||||
*/
|
||||
|
||||
import { describe, it, expect, beforeEach, vi } from 'vitest';
|
||||
import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest';
|
||||
import * as fs from 'node:fs';
|
||||
import * as path from 'node:path';
|
||||
import { execSync } from 'node:child_process';
|
||||
import {
|
||||
parseExecutableSpec,
|
||||
prepareSpawnInfo,
|
||||
findBundledCliPath,
|
||||
findNativeCliPath,
|
||||
} from '../../src/utils/cliPath.js';
|
||||
|
||||
// Mock fs module
|
||||
@@ -26,43 +21,36 @@ const mockFs = vi.mocked(fs);
|
||||
vi.mock('node:child_process');
|
||||
const mockExecSync = vi.mocked(execSync);
|
||||
|
||||
// Mock process.versions for bun detection
|
||||
const originalVersions = process.versions;
|
||||
|
||||
describe('CLI Path Utilities', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
// Reset process.versions
|
||||
Object.defineProperty(process, 'versions', {
|
||||
value: { ...originalVersions },
|
||||
writable: true,
|
||||
});
|
||||
// Default: tsx is available (can be overridden in specific tests)
|
||||
mockExecSync.mockReturnValue(Buffer.from(''));
|
||||
// Default: mock statSync to return a proper file stat object
|
||||
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);
|
||||
// Default: tsx is available (can be overridden in specific tests)
|
||||
mockExecSync.mockReturnValue(Buffer.from(''));
|
||||
});
|
||||
|
||||
describe('findBundledCliPath', () => {
|
||||
it('should find bundled CLI when it exists', () => {
|
||||
// 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 = findBundledCliPath();
|
||||
|
||||
expect(result).toContain('cli.js');
|
||||
});
|
||||
|
||||
it('should throw descriptive error when bundled CLI not found', () => {
|
||||
mockFs.existsSync.mockReturnValue(false);
|
||||
|
||||
expect(() => findBundledCliPath()).toThrow('Bundled qwen CLI not found');
|
||||
expect(() => findBundledCliPath()).toThrow('Searched locations:');
|
||||
afterEach(() => {
|
||||
// Restore original process.versions
|
||||
Object.defineProperty(process, 'versions', {
|
||||
value: originalVersions,
|
||||
writable: true,
|
||||
});
|
||||
});
|
||||
|
||||
describe('prepareSpawnInfo', () => {
|
||||
describe('parseExecutableSpec', () => {
|
||||
describe('auto-detection (no spec provided)', () => {
|
||||
it('should auto-detect bundled CLI when no spec provided', () => {
|
||||
// Mock existsSync to return true for bundled CLI
|
||||
@@ -73,23 +61,176 @@ describe('CLI Path Utilities', () => {
|
||||
);
|
||||
});
|
||||
|
||||
const result = prepareSpawnInfo();
|
||||
const result = parseExecutableSpec();
|
||||
|
||||
expect(result.command).toBe(process.execPath);
|
||||
expect(result.args[0]).toContain('cli.js');
|
||||
expect(result.type).toBe('node');
|
||||
expect(result.originalInput).toBe('');
|
||||
expect(result.executablePath).toContain('cli.js');
|
||||
expect(result.isExplicitRuntime).toBe(false);
|
||||
});
|
||||
|
||||
it('should throw when bundled CLI not found', () => {
|
||||
mockFs.existsSync.mockReturnValue(false);
|
||||
|
||||
expect(() => prepareSpawnInfo()).toThrow('Bundled qwen CLI not found');
|
||||
expect(() => parseExecutableSpec()).toThrow(
|
||||
'Bundled qwen CLI not found',
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe('runtime prefix parsing', () => {
|
||||
it('should parse node runtime prefix', () => {
|
||||
mockFs.existsSync.mockReturnValue(true);
|
||||
|
||||
const result = parseExecutableSpec('node:/path/to/cli.js');
|
||||
|
||||
expect(result).toEqual({
|
||||
runtime: 'node',
|
||||
executablePath: path.resolve('/path/to/cli.js'),
|
||||
isExplicitRuntime: true,
|
||||
});
|
||||
});
|
||||
|
||||
it('should parse bun runtime prefix', () => {
|
||||
mockFs.existsSync.mockReturnValue(true);
|
||||
|
||||
const result = parseExecutableSpec('bun:/path/to/cli.js');
|
||||
|
||||
expect(result).toEqual({
|
||||
runtime: 'bun',
|
||||
executablePath: path.resolve('/path/to/cli.js'),
|
||||
isExplicitRuntime: true,
|
||||
});
|
||||
});
|
||||
|
||||
it('should parse tsx runtime prefix', () => {
|
||||
mockFs.existsSync.mockReturnValue(true);
|
||||
|
||||
const result = parseExecutableSpec('tsx:/path/to/index.ts');
|
||||
|
||||
expect(result).toEqual({
|
||||
runtime: 'tsx',
|
||||
executablePath: path.resolve('/path/to/index.ts'),
|
||||
isExplicitRuntime: true,
|
||||
});
|
||||
});
|
||||
|
||||
it('should parse deno runtime prefix', () => {
|
||||
mockFs.existsSync.mockReturnValue(true);
|
||||
|
||||
const result = parseExecutableSpec('deno:/path/to/cli.ts');
|
||||
|
||||
expect(result).toEqual({
|
||||
runtime: 'deno',
|
||||
executablePath: path.resolve('/path/to/cli.ts'),
|
||||
isExplicitRuntime: true,
|
||||
});
|
||||
});
|
||||
|
||||
it('should treat non-whitelisted runtime prefixes as command names', () => {
|
||||
// With whitelist approach, 'invalid:format' is not recognized as a runtime spec
|
||||
// so it's treated as a command name, which fails validation due to the colon
|
||||
expect(() => parseExecutableSpec('invalid:format')).toThrow(
|
||||
'Invalid command name',
|
||||
);
|
||||
});
|
||||
|
||||
it('should treat Windows drive letters as file paths, not runtime specs', () => {
|
||||
mockFs.existsSync.mockReturnValue(true);
|
||||
|
||||
// Test various Windows drive letters
|
||||
const windowsPaths = [
|
||||
'C:\\path\\to\\cli.js',
|
||||
'D:\\path\\to\\cli.js',
|
||||
'E:\\Users\\dev\\qwen\\cli.js',
|
||||
];
|
||||
|
||||
for (const winPath of windowsPaths) {
|
||||
const result = parseExecutableSpec(winPath);
|
||||
|
||||
expect(result.isExplicitRuntime).toBe(false);
|
||||
expect(result.runtime).toBeUndefined();
|
||||
expect(result.executablePath).toBe(path.resolve(winPath));
|
||||
}
|
||||
});
|
||||
|
||||
it('should handle Windows paths with forward slashes', () => {
|
||||
mockFs.existsSync.mockReturnValue(true);
|
||||
|
||||
const result = parseExecutableSpec('C:/path/to/cli.js');
|
||||
|
||||
expect(result.isExplicitRuntime).toBe(false);
|
||||
expect(result.runtime).toBeUndefined();
|
||||
expect(result.executablePath).toBe(path.resolve('C:/path/to/cli.js'));
|
||||
});
|
||||
|
||||
it('should throw when runtime-prefixed file does not exist', () => {
|
||||
mockFs.existsSync.mockReturnValue(false);
|
||||
|
||||
expect(() => parseExecutableSpec('node:/nonexistent/cli.js')).toThrow(
|
||||
'Executable file not found at',
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe('command name detection', () => {
|
||||
it('should detect command names without path separators', () => {
|
||||
const result = parseExecutableSpec('qwen');
|
||||
|
||||
expect(result).toEqual({
|
||||
executablePath: 'qwen',
|
||||
isExplicitRuntime: false,
|
||||
});
|
||||
});
|
||||
|
||||
it('should detect command names on Windows', () => {
|
||||
const result = parseExecutableSpec('qwen.exe');
|
||||
|
||||
expect(result).toEqual({
|
||||
executablePath: 'qwen.exe',
|
||||
isExplicitRuntime: false,
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('file path resolution', () => {
|
||||
it('should resolve absolute file paths', () => {
|
||||
mockFs.existsSync.mockReturnValue(true);
|
||||
|
||||
const result = parseExecutableSpec('/absolute/path/to/qwen');
|
||||
|
||||
expect(result).toEqual({
|
||||
executablePath: path.resolve('/absolute/path/to/qwen'),
|
||||
isExplicitRuntime: false,
|
||||
});
|
||||
});
|
||||
|
||||
it('should resolve relative file paths', () => {
|
||||
mockFs.existsSync.mockReturnValue(true);
|
||||
|
||||
const result = parseExecutableSpec('./relative/path/to/qwen');
|
||||
|
||||
expect(result).toEqual({
|
||||
executablePath: path.resolve('./relative/path/to/qwen'),
|
||||
isExplicitRuntime: false,
|
||||
});
|
||||
});
|
||||
|
||||
it('should throw when file path does not exist', () => {
|
||||
mockFs.existsSync.mockReturnValue(false);
|
||||
|
||||
expect(() => parseExecutableSpec('/nonexistent/path')).toThrow(
|
||||
'Executable file not found at',
|
||||
);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('prepareSpawnInfo', () => {
|
||||
beforeEach(() => {
|
||||
mockFs.existsSync.mockReturnValue(true);
|
||||
});
|
||||
|
||||
describe('native executables', () => {
|
||||
it('should prepare spawn info for native binary command', () => {
|
||||
const result = prepareSpawnInfo('qwen');
|
||||
|
||||
expect(result).toEqual({
|
||||
@@ -100,38 +241,37 @@ describe('CLI Path Utilities', () => {
|
||||
});
|
||||
});
|
||||
|
||||
it('should detect command names on Windows', () => {
|
||||
const result = prepareSpawnInfo('qwen.exe');
|
||||
it('should prepare spawn info for native binary path', () => {
|
||||
const result = prepareSpawnInfo('/usr/local/bin/qwen');
|
||||
|
||||
expect(result).toEqual({
|
||||
command: 'qwen.exe',
|
||||
command: path.resolve('/usr/local/bin/qwen'),
|
||||
args: [],
|
||||
type: 'native',
|
||||
originalInput: 'qwen.exe',
|
||||
originalInput: '/usr/local/bin/qwen',
|
||||
});
|
||||
});
|
||||
|
||||
it('should reject invalid command name characters', () => {
|
||||
expect(() => prepareSpawnInfo('qwen@invalid')).toThrow(
|
||||
"Invalid command name 'qwen@invalid'. Command names should only contain letters, numbers, dots, hyphens, and underscores.",
|
||||
);
|
||||
});
|
||||
|
||||
it('should accept valid command names', () => {
|
||||
expect(() => prepareSpawnInfo('qwen')).not.toThrow();
|
||||
expect(() => prepareSpawnInfo('qwen-code')).not.toThrow();
|
||||
expect(() => prepareSpawnInfo('qwen_code')).not.toThrow();
|
||||
expect(() => prepareSpawnInfo('qwen.exe')).not.toThrow();
|
||||
expect(() => prepareSpawnInfo('qwen123')).not.toThrow();
|
||||
});
|
||||
});
|
||||
|
||||
describe('JavaScript files', () => {
|
||||
beforeEach(() => {
|
||||
mockFs.existsSync.mockReturnValue(true);
|
||||
it('should use node for .js files', () => {
|
||||
const result = prepareSpawnInfo('/path/to/cli.js');
|
||||
|
||||
expect(result).toEqual({
|
||||
command: process.execPath,
|
||||
args: [path.resolve('/path/to/cli.js')],
|
||||
type: 'node',
|
||||
originalInput: '/path/to/cli.js',
|
||||
});
|
||||
});
|
||||
|
||||
it('should use node for .js files', () => {
|
||||
it('should default to node for .js files (not auto-detect bun)', () => {
|
||||
// Even when running under bun, default to node for .js files
|
||||
Object.defineProperty(process, 'versions', {
|
||||
value: { ...originalVersions, bun: '1.0.0' },
|
||||
writable: true,
|
||||
});
|
||||
|
||||
const result = prepareSpawnInfo('/path/to/cli.js');
|
||||
|
||||
expect(result).toEqual({
|
||||
@@ -166,10 +306,6 @@ describe('CLI Path Utilities', () => {
|
||||
});
|
||||
|
||||
describe('TypeScript files', () => {
|
||||
beforeEach(() => {
|
||||
mockFs.existsSync.mockReturnValue(true);
|
||||
});
|
||||
|
||||
it('should use tsx for .ts files when tsx is available', () => {
|
||||
// tsx is available by default in beforeEach
|
||||
const result = prepareSpawnInfo('/path/to/index.ts');
|
||||
@@ -193,178 +329,107 @@ describe('CLI Path Utilities', () => {
|
||||
});
|
||||
});
|
||||
|
||||
it('should fallback to native when tsx is not available', () => {
|
||||
it('should throw helpful error when tsx is not available', () => {
|
||||
// Mock tsx not being available
|
||||
mockExecSync.mockImplementation(() => {
|
||||
throw new Error('Command not found');
|
||||
});
|
||||
|
||||
const result = prepareSpawnInfo('/path/to/index.ts');
|
||||
const resolvedPath = path.resolve('/path/to/index.ts');
|
||||
expect(() => prepareSpawnInfo('/path/to/index.ts')).toThrow(
|
||||
`TypeScript file '${resolvedPath}' requires 'tsx' runtime, but it's not available`,
|
||||
);
|
||||
expect(() => prepareSpawnInfo('/path/to/index.ts')).toThrow(
|
||||
'Please install tsx: npm install -g tsx',
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe('explicit runtime specifications', () => {
|
||||
it('should use explicit node runtime', () => {
|
||||
const result = prepareSpawnInfo('node:/path/to/cli.js');
|
||||
|
||||
expect(result).toEqual({
|
||||
command: path.resolve('/path/to/index.ts'),
|
||||
args: [],
|
||||
type: 'native',
|
||||
originalInput: '/path/to/index.ts',
|
||||
command: process.execPath,
|
||||
args: [path.resolve('/path/to/cli.js')],
|
||||
type: 'node',
|
||||
originalInput: 'node:/path/to/cli.js',
|
||||
});
|
||||
});
|
||||
|
||||
it('should use explicit bun runtime', () => {
|
||||
const result = prepareSpawnInfo('bun:/path/to/cli.js');
|
||||
|
||||
expect(result).toEqual({
|
||||
command: 'bun',
|
||||
args: [path.resolve('/path/to/cli.js')],
|
||||
type: 'bun',
|
||||
originalInput: 'bun:/path/to/cli.js',
|
||||
});
|
||||
});
|
||||
|
||||
it('should use explicit tsx runtime', () => {
|
||||
const result = prepareSpawnInfo('tsx:/path/to/index.ts');
|
||||
|
||||
expect(result).toEqual({
|
||||
command: 'tsx',
|
||||
args: [path.resolve('/path/to/index.ts')],
|
||||
type: 'tsx',
|
||||
originalInput: 'tsx:/path/to/index.ts',
|
||||
});
|
||||
});
|
||||
|
||||
it('should use explicit deno runtime', () => {
|
||||
const result = prepareSpawnInfo('deno:/path/to/cli.ts');
|
||||
|
||||
expect(result).toEqual({
|
||||
command: 'deno',
|
||||
args: [path.resolve('/path/to/cli.ts')],
|
||||
type: 'deno',
|
||||
originalInput: 'deno:/path/to/cli.ts',
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('native executables', () => {
|
||||
beforeEach(() => {
|
||||
mockFs.existsSync.mockReturnValue(true);
|
||||
});
|
||||
|
||||
it('should prepare spawn info for native binary path', () => {
|
||||
const result = prepareSpawnInfo('/usr/local/bin/qwen');
|
||||
|
||||
expect(result).toEqual({
|
||||
command: path.resolve('/usr/local/bin/qwen'),
|
||||
args: [],
|
||||
type: 'native',
|
||||
originalInput: '/usr/local/bin/qwen',
|
||||
describe('auto-detection fallback', () => {
|
||||
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')
|
||||
);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('file path resolution', () => {
|
||||
it('should resolve absolute file paths', () => {
|
||||
mockFs.existsSync.mockReturnValue(true);
|
||||
|
||||
const result = prepareSpawnInfo('/absolute/path/to/qwen');
|
||||
|
||||
expect(result.command).toBe(path.resolve('/absolute/path/to/qwen'));
|
||||
expect(result.type).toBe('native');
|
||||
});
|
||||
|
||||
it('should resolve relative file paths', () => {
|
||||
mockFs.existsSync.mockReturnValue(true);
|
||||
|
||||
const result = prepareSpawnInfo('./relative/path/to/cli.js');
|
||||
const result = prepareSpawnInfo();
|
||||
|
||||
expect(result.command).toBe(process.execPath);
|
||||
expect(result.args[0]).toBe(path.resolve('./relative/path/to/cli.js'));
|
||||
expect(result.args[0]).toContain('cli.js');
|
||||
expect(result.type).toBe('node');
|
||||
});
|
||||
|
||||
it('should throw when file path does not exist', () => {
|
||||
mockFs.existsSync.mockReturnValue(false);
|
||||
|
||||
expect(() => prepareSpawnInfo('/nonexistent/path')).toThrow(
|
||||
'Executable file not found at',
|
||||
);
|
||||
});
|
||||
|
||||
it('should throw when path is a directory', () => {
|
||||
mockFs.existsSync.mockReturnValue(true);
|
||||
mockFs.statSync.mockReturnValue({
|
||||
isFile: () => false,
|
||||
} as ReturnType<typeof import('fs').statSync>);
|
||||
|
||||
expect(() => prepareSpawnInfo('/path/to/directory')).toThrow(
|
||||
'exists but is not a file',
|
||||
);
|
||||
expect(result.originalInput).toBe('');
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('Windows path handling', () => {
|
||||
beforeEach(() => {
|
||||
mockFs.existsSync.mockReturnValue(true);
|
||||
});
|
||||
|
||||
it('should handle Windows paths with drive letters', () => {
|
||||
const windowsPath = 'D:\\path\\to\\cli.js';
|
||||
const result = prepareSpawnInfo(windowsPath);
|
||||
|
||||
expect(result).toEqual({
|
||||
command: process.execPath,
|
||||
args: [path.resolve(windowsPath)],
|
||||
type: 'node',
|
||||
originalInput: windowsPath,
|
||||
describe('findNativeCliPath', () => {
|
||||
it('should find bundled CLI', () => {
|
||||
// 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 = findNativeCliPath();
|
||||
|
||||
expect(result).toContain('cli.js');
|
||||
});
|
||||
|
||||
it('should handle Windows paths with forward slashes', () => {
|
||||
const windowsPath = 'C:/path/to/cli.js';
|
||||
const result = prepareSpawnInfo(windowsPath);
|
||||
|
||||
expect(result.command).toBe(process.execPath);
|
||||
expect(result.args[0]).toBe(path.resolve(windowsPath));
|
||||
expect(result.type).toBe('node');
|
||||
});
|
||||
|
||||
it('should not confuse Windows drive letters with invalid syntax', () => {
|
||||
const windowsPath = 'D:\\workspace\\project\\cli.js';
|
||||
const result = prepareSpawnInfo(windowsPath);
|
||||
|
||||
// Should use node runtime based on .js extension
|
||||
expect(result.type).toBe('node');
|
||||
expect(result.command).toBe(process.execPath);
|
||||
});
|
||||
|
||||
it('should handle Windows paths when file is missing', () => {
|
||||
it('should throw descriptive error when bundled CLI not found', () => {
|
||||
mockFs.existsSync.mockReturnValue(false);
|
||||
|
||||
expect(() => prepareSpawnInfo('D:\\missing\\cli.js')).toThrow(
|
||||
'Executable file not found at',
|
||||
);
|
||||
});
|
||||
|
||||
it('should handle mixed path separators', () => {
|
||||
// Users might paste paths with mixed separators
|
||||
const mixedPath = 'C:\\Users/project\\cli.js';
|
||||
const result = prepareSpawnInfo(mixedPath);
|
||||
|
||||
expect(result.command).toBe(process.execPath);
|
||||
expect(result.type).toBe('node');
|
||||
// path.resolve normalizes the separators
|
||||
expect(result.args[0]).toBe(path.resolve(mixedPath));
|
||||
});
|
||||
|
||||
it('should handle UNC paths', () => {
|
||||
// Windows network paths: \\server\share\path
|
||||
const uncPath = '\\\\server\\share\\path\\cli.js';
|
||||
const result = prepareSpawnInfo(uncPath);
|
||||
|
||||
expect(result.command).toBe(process.execPath);
|
||||
expect(result.type).toBe('node');
|
||||
expect(result.args[0]).toBe(path.resolve(uncPath));
|
||||
});
|
||||
|
||||
it('should handle Windows native executables', () => {
|
||||
const windowsPath = 'C:\\Program Files\\qwen\\qwen.exe';
|
||||
const result = prepareSpawnInfo(windowsPath);
|
||||
|
||||
// .exe files without .js extension should be treated as native
|
||||
expect(result.type).toBe('native');
|
||||
expect(result.command).toBe(path.resolve(windowsPath));
|
||||
expect(result.args).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
describe('error cases', () => {
|
||||
it('should throw for empty string', () => {
|
||||
expect(() => prepareSpawnInfo('')).toThrow(
|
||||
'Executable path cannot be empty',
|
||||
);
|
||||
});
|
||||
|
||||
it('should throw for whitespace-only string', () => {
|
||||
expect(() => prepareSpawnInfo(' ')).toThrow(
|
||||
'Executable path cannot be empty',
|
||||
);
|
||||
});
|
||||
|
||||
it('should provide helpful error for missing file', () => {
|
||||
mockFs.existsSync.mockReturnValue(false);
|
||||
|
||||
expect(() => prepareSpawnInfo('/missing/file')).toThrow(
|
||||
'Executable file not found at',
|
||||
);
|
||||
expect(() => prepareSpawnInfo('/missing/file')).toThrow(
|
||||
'Please check the file path and ensure the file exists',
|
||||
);
|
||||
expect(() => findNativeCliPath()).toThrow('Bundled qwen CLI not found');
|
||||
});
|
||||
});
|
||||
|
||||
@@ -373,6 +438,18 @@ describe('CLI Path Utilities', () => {
|
||||
mockFs.existsSync.mockReturnValue(true);
|
||||
});
|
||||
|
||||
it('should handle development with TypeScript source', () => {
|
||||
const devPath = '/Users/dev/qwen-code/packages/cli/index.ts';
|
||||
const result = prepareSpawnInfo(devPath);
|
||||
|
||||
expect(result).toEqual({
|
||||
command: 'tsx',
|
||||
args: [path.resolve(devPath)],
|
||||
type: 'tsx',
|
||||
originalInput: devPath,
|
||||
});
|
||||
});
|
||||
|
||||
it('should handle production bundle validation', () => {
|
||||
const bundlePath = '/path/to/bundled/cli.js';
|
||||
const result = prepareSpawnInfo(bundlePath);
|
||||
@@ -396,27 +473,235 @@ describe('CLI Path Utilities', () => {
|
||||
});
|
||||
});
|
||||
|
||||
it('should handle ESM bundle', () => {
|
||||
const bundlePath = '/path/to/cli.mjs';
|
||||
const result = prepareSpawnInfo(bundlePath);
|
||||
it('should handle bun runtime with bundle', () => {
|
||||
const bundlePath = '/path/to/cli.js';
|
||||
const result = prepareSpawnInfo(`bun:${bundlePath}`);
|
||||
|
||||
expect(result).toEqual({
|
||||
command: process.execPath,
|
||||
command: 'bun',
|
||||
args: [path.resolve(bundlePath)],
|
||||
type: 'node',
|
||||
originalInput: bundlePath,
|
||||
type: 'bun',
|
||||
originalInput: `bun:${bundlePath}`,
|
||||
});
|
||||
});
|
||||
|
||||
it('should handle CJS bundle', () => {
|
||||
const bundlePath = '/path/to/cli.cjs';
|
||||
const result = prepareSpawnInfo(bundlePath);
|
||||
it('should handle Windows paths with drive letters', () => {
|
||||
const windowsPath = 'D:\\path\\to\\cli.js';
|
||||
const result = prepareSpawnInfo(windowsPath);
|
||||
|
||||
expect(result).toEqual({
|
||||
command: process.execPath,
|
||||
args: [path.resolve(bundlePath)],
|
||||
args: [path.resolve(windowsPath)],
|
||||
type: 'node',
|
||||
originalInput: bundlePath,
|
||||
originalInput: windowsPath,
|
||||
});
|
||||
});
|
||||
|
||||
it('should handle Windows paths with TypeScript files', () => {
|
||||
const windowsPath = 'C:\\Users\\dev\\qwen\\index.ts';
|
||||
const result = prepareSpawnInfo(windowsPath);
|
||||
|
||||
expect(result).toEqual({
|
||||
command: 'tsx',
|
||||
args: [path.resolve(windowsPath)],
|
||||
type: 'tsx',
|
||||
originalInput: windowsPath,
|
||||
});
|
||||
});
|
||||
|
||||
it('should not confuse Windows drive letters with runtime prefixes', () => {
|
||||
// Ensure 'D:' is not treated as a runtime specification
|
||||
const windowsPath = 'D:\\workspace\\project\\cli.js';
|
||||
const result = prepareSpawnInfo(windowsPath);
|
||||
|
||||
// Should use node runtime based on .js extension, not treat 'D' as runtime
|
||||
expect(result.type).toBe('node');
|
||||
expect(result.command).toBe(process.execPath);
|
||||
expect(result.args).toEqual([path.resolve(windowsPath)]);
|
||||
});
|
||||
});
|
||||
|
||||
describe('error cases', () => {
|
||||
it('should provide helpful error for missing TypeScript file', () => {
|
||||
mockFs.existsSync.mockReturnValue(false);
|
||||
|
||||
expect(() => prepareSpawnInfo('/missing/index.ts')).toThrow(
|
||||
'Executable file not found at',
|
||||
);
|
||||
});
|
||||
|
||||
it('should provide helpful error for missing JavaScript file', () => {
|
||||
mockFs.existsSync.mockReturnValue(false);
|
||||
|
||||
expect(() => prepareSpawnInfo('/missing/cli.js')).toThrow(
|
||||
'Executable file not found at',
|
||||
);
|
||||
});
|
||||
|
||||
it('should treat non-whitelisted runtime prefixes as command names', () => {
|
||||
// With whitelist approach, 'invalid:spec' is not recognized as a runtime spec
|
||||
// so it's treated as a command name, which fails validation due to the colon
|
||||
expect(() => prepareSpawnInfo('invalid:spec')).toThrow(
|
||||
'Invalid command name',
|
||||
);
|
||||
});
|
||||
|
||||
it('should handle Windows paths correctly even when file is missing', () => {
|
||||
mockFs.existsSync.mockReturnValue(false);
|
||||
|
||||
expect(() => prepareSpawnInfo('D:\\missing\\cli.js')).toThrow(
|
||||
'Executable file not found at',
|
||||
);
|
||||
// Should not throw 'Invalid command name' error (which would happen if 'D:' was treated as invalid command)
|
||||
expect(() => prepareSpawnInfo('D:\\missing\\cli.js')).not.toThrow(
|
||||
'Invalid command name',
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe('comprehensive validation', () => {
|
||||
describe('runtime validation', () => {
|
||||
it('should treat unsupported runtime prefixes as file paths', () => {
|
||||
mockFs.existsSync.mockReturnValue(true);
|
||||
|
||||
// With whitelist approach, 'unsupported:' is not recognized as a runtime spec
|
||||
// so 'unsupported:/path/to/file.js' is treated as a file path
|
||||
const result = parseExecutableSpec('unsupported:/path/to/file.js');
|
||||
|
||||
// Should be treated as a file path, not a runtime specification
|
||||
expect(result.isExplicitRuntime).toBe(false);
|
||||
expect(result.runtime).toBeUndefined();
|
||||
});
|
||||
|
||||
it('should validate runtime availability for explicit runtime specs', () => {
|
||||
mockFs.existsSync.mockReturnValue(true);
|
||||
// Mock bun not being available
|
||||
mockExecSync.mockImplementation((command) => {
|
||||
if (command.includes('bun')) {
|
||||
throw new Error('Command not found');
|
||||
}
|
||||
return Buffer.from('');
|
||||
});
|
||||
|
||||
expect(() => parseExecutableSpec('bun:/path/to/cli.js')).toThrow(
|
||||
"Runtime 'bun' is not available on this system. Please install it first.",
|
||||
);
|
||||
});
|
||||
|
||||
it('should allow node runtime (always available)', () => {
|
||||
mockFs.existsSync.mockReturnValue(true);
|
||||
|
||||
expect(() => parseExecutableSpec('node:/path/to/cli.js')).not.toThrow();
|
||||
});
|
||||
|
||||
it('should validate file extension matches runtime', () => {
|
||||
mockFs.existsSync.mockReturnValue(true);
|
||||
|
||||
expect(() => parseExecutableSpec('tsx:/path/to/file.js')).toThrow(
|
||||
"File extension '.js' is not compatible with runtime 'tsx'",
|
||||
);
|
||||
});
|
||||
|
||||
it('should validate node runtime with JavaScript files', () => {
|
||||
mockFs.existsSync.mockReturnValue(true);
|
||||
|
||||
expect(() => parseExecutableSpec('node:/path/to/file.ts')).toThrow(
|
||||
"File extension '.ts' is not compatible with runtime 'node'",
|
||||
);
|
||||
});
|
||||
|
||||
it('should accept valid runtime-file combinations', () => {
|
||||
mockFs.existsSync.mockReturnValue(true);
|
||||
|
||||
expect(() => parseExecutableSpec('tsx:/path/to/file.ts')).not.toThrow();
|
||||
expect(() =>
|
||||
parseExecutableSpec('node:/path/to/file.js'),
|
||||
).not.toThrow();
|
||||
expect(() =>
|
||||
parseExecutableSpec('bun:/path/to/file.mjs'),
|
||||
).not.toThrow();
|
||||
});
|
||||
});
|
||||
|
||||
describe('command name validation', () => {
|
||||
it('should reject empty command names', () => {
|
||||
expect(() => parseExecutableSpec('')).toThrow(
|
||||
'Command name cannot be empty',
|
||||
);
|
||||
expect(() => parseExecutableSpec(' ')).toThrow(
|
||||
'Command name cannot be empty',
|
||||
);
|
||||
});
|
||||
|
||||
it('should reject invalid command name characters', () => {
|
||||
expect(() => parseExecutableSpec('qwen@invalid')).toThrow(
|
||||
"Invalid command name 'qwen@invalid'. Command names should only contain letters, numbers, dots, hyphens, and underscores.",
|
||||
);
|
||||
|
||||
expect(() => parseExecutableSpec('qwen/invalid')).not.toThrow(); // This is treated as a path
|
||||
});
|
||||
|
||||
it('should accept valid command names', () => {
|
||||
expect(() => parseExecutableSpec('qwen')).not.toThrow();
|
||||
expect(() => parseExecutableSpec('qwen-code')).not.toThrow();
|
||||
expect(() => parseExecutableSpec('qwen_code')).not.toThrow();
|
||||
expect(() => parseExecutableSpec('qwen.exe')).not.toThrow();
|
||||
expect(() => parseExecutableSpec('qwen123')).not.toThrow();
|
||||
});
|
||||
});
|
||||
|
||||
describe('file path validation', () => {
|
||||
it('should validate file exists', () => {
|
||||
mockFs.existsSync.mockReturnValue(false);
|
||||
|
||||
expect(() => parseExecutableSpec('/nonexistent/path')).toThrow(
|
||||
'Executable file not found at',
|
||||
);
|
||||
});
|
||||
|
||||
it('should validate path points to a file, not directory', () => {
|
||||
mockFs.existsSync.mockReturnValue(true);
|
||||
mockFs.statSync.mockReturnValue({
|
||||
isFile: () => false,
|
||||
} as ReturnType<typeof import('fs').statSync>);
|
||||
|
||||
expect(() => parseExecutableSpec('/path/to/directory')).toThrow(
|
||||
'exists but is not a file',
|
||||
);
|
||||
});
|
||||
|
||||
it('should accept valid file paths', () => {
|
||||
mockFs.existsSync.mockReturnValue(true);
|
||||
mockFs.statSync.mockReturnValue({
|
||||
isFile: () => true,
|
||||
} as ReturnType<typeof import('fs').statSync>);
|
||||
|
||||
expect(() => parseExecutableSpec('/path/to/qwen')).not.toThrow();
|
||||
expect(() => parseExecutableSpec('./relative/path')).not.toThrow();
|
||||
});
|
||||
});
|
||||
|
||||
describe('error message quality', () => {
|
||||
it('should provide helpful error for missing runtime-prefixed file', () => {
|
||||
mockFs.existsSync.mockReturnValue(false);
|
||||
|
||||
expect(() => parseExecutableSpec('tsx:/missing/file.ts')).toThrow(
|
||||
'Executable file not found at',
|
||||
);
|
||||
expect(() => parseExecutableSpec('tsx:/missing/file.ts')).toThrow(
|
||||
'Please check the file path and ensure the file exists',
|
||||
);
|
||||
});
|
||||
|
||||
it('should provide helpful error for missing regular file', () => {
|
||||
mockFs.existsSync.mockReturnValue(false);
|
||||
|
||||
expect(() => parseExecutableSpec('/missing/file')).toThrow(
|
||||
'Executable file not found at',
|
||||
);
|
||||
expect(() => parseExecutableSpec('/missing/file')).toThrow(
|
||||
'Please check the file path and ensure the file exists',
|
||||
);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -17,6 +17,7 @@ import {
|
||||
import { WebViewProvider } from './webview/WebViewProvider.js';
|
||||
import { registerNewCommands } from './commands/index.js';
|
||||
import { ReadonlyFileSystemProvider } from './services/readonlyFileSystemProvider.js';
|
||||
import { isWindows } from './utils/platform.js';
|
||||
|
||||
const CLI_IDE_COMPANION_IDENTIFIER = 'qwenlm.qwen-code-vscode-ide-companion';
|
||||
const INFO_MESSAGE_SHOWN_KEY = 'qwenCodeInfoMessageShown';
|
||||
@@ -312,13 +313,37 @@ export async function activate(context: vscode.ExtensionContext) {
|
||||
'qwen-cli',
|
||||
'cli.js',
|
||||
).fsPath;
|
||||
const quote = (s: string) => `"${s.replaceAll('"', '\\"')}"`;
|
||||
const qwenCmd = `${quote(process.execPath)} ${quote(cliEntry)}`;
|
||||
const terminal = vscode.window.createTerminal({
|
||||
const execPath = process.execPath;
|
||||
const lowerExecPath = execPath.toLowerCase();
|
||||
const needsElectronRunAsNode =
|
||||
lowerExecPath.includes('code') ||
|
||||
lowerExecPath.includes('electron');
|
||||
|
||||
let qwenCmd: string;
|
||||
const terminalOptions: vscode.TerminalOptions = {
|
||||
name: `Qwen Code (${selectedFolder.name})`,
|
||||
cwd: selectedFolder.uri.fsPath,
|
||||
location,
|
||||
});
|
||||
};
|
||||
|
||||
if (isWindows) {
|
||||
// Use system Node via cmd.exe; avoid PowerShell parsing issues
|
||||
const quoteCmd = (s: string) => `"${s.replace(/"/g, '""')}"`;
|
||||
const cliQuoted = quoteCmd(cliEntry);
|
||||
qwenCmd = `node ${cliQuoted}`;
|
||||
terminalOptions.shellPath = process.env.ComSpec;
|
||||
} else {
|
||||
const quotePosix = (s: string) => `"${s.replace(/"/g, '\\"')}"`;
|
||||
const baseCmd = `${quotePosix(execPath)} ${quotePosix(cliEntry)}`;
|
||||
if (needsElectronRunAsNode) {
|
||||
// macOS Electron helper needs ELECTRON_RUN_AS_NODE=1;
|
||||
qwenCmd = `ELECTRON_RUN_AS_NODE=1 ${baseCmd}`;
|
||||
} else {
|
||||
qwenCmd = baseCmd;
|
||||
}
|
||||
}
|
||||
|
||||
const terminal = vscode.window.createTerminal(terminalOptions);
|
||||
terminal.show();
|
||||
terminal.sendText(qwenCmd);
|
||||
}
|
||||
|
||||
@@ -26,6 +26,7 @@ import type {
|
||||
} from '../types/connectionTypes.js';
|
||||
import { AcpFileHandler } from '../services/acpFileHandler.js';
|
||||
import type { ChildProcess } from 'child_process';
|
||||
import { isWindows } from '../utils/platform.js';
|
||||
|
||||
/**
|
||||
* ACP Message Handler Class
|
||||
@@ -47,7 +48,7 @@ export class AcpMessageHandler {
|
||||
sendResponseMessage(child: ChildProcess | null, response: AcpResponse): void {
|
||||
if (child?.stdin) {
|
||||
const jsonString = JSON.stringify(response);
|
||||
const lineEnding = process.platform === 'win32' ? '\r\n' : '\n';
|
||||
const lineEnding = isWindows ? '\r\n' : '\n';
|
||||
child.stdin.write(jsonString + lineEnding);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -19,6 +19,7 @@ import type { ApprovalModeValue } from '../types/approvalModeValueTypes.js';
|
||||
import { AGENT_METHODS } from '../constants/acpSchema.js';
|
||||
import type { PendingRequest } from '../types/connectionTypes.js';
|
||||
import type { ChildProcess } from 'child_process';
|
||||
import { isWindows } from '../utils/platform.js';
|
||||
|
||||
/**
|
||||
* ACP Session Manager Class
|
||||
@@ -102,7 +103,7 @@ export class AcpSessionManager {
|
||||
): void {
|
||||
if (child?.stdin) {
|
||||
const jsonString = JSON.stringify(message);
|
||||
const lineEnding = process.platform === 'win32' ? '\r\n' : '\n';
|
||||
const lineEnding = isWindows ? '\r\n' : '\n';
|
||||
child.stdin.write(jsonString + lineEnding);
|
||||
}
|
||||
}
|
||||
|
||||
8
packages/vscode-ide-companion/src/utils/platform.ts
Normal file
8
packages/vscode-ide-companion/src/utils/platform.ts
Normal file
@@ -0,0 +1,8 @@
|
||||
/**
|
||||
* @license
|
||||
* Copyright 2025 Qwen Team
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
/** Whether the current platform is Windows */
|
||||
export const isWindows = process.platform === 'win32';
|
||||
Reference in New Issue
Block a user