Compare commits

..

22 Commits

Author SHA1 Message Date
tanzhenxin
b6a482e090 release native binary: design spec and phase 1 implementation 2026-01-13 09:26:33 +08:00
pomelo
52d6d1ff13 Merge pull request #1472 from QwenLM/update-vscode-extension-docs
docs(vscode-ide-companion): update vscode extension readme
2026-01-13 09:11:04 +08:00
yiliang114
b93bb8bff6 docs(vscode-ide-companion): update vscode extension readme 2026-01-13 00:14:57 +08:00
qwen-code-ci-bot
09196c6e19 Merge pull request #1470 from QwenLM/release/sdk-typescript/v0.1.2
chore(release): sdk-typescript v0.1.2
2026-01-12 16:26:57 +08:00
github-actions[bot]
4bd01d592b chore(release): sdk-typescript v0.1.2 2026-01-12 08:25:25 +00:00
Mingholy
1aed5ce858 Merge pull request #1462 from QwenLM/mingholy/feat/sdk-skills
fix: SDK release workflow and stability improvements
2026-01-12 15:06:55 +08:00
Mingholy
bad5b0485d Merge pull request #1457 from liqiongyu/fix/1118-auth-fetch-failed-diagnostics
fix(core): improve OAuth fetch-failed diagnostics
2026-01-12 15:06:16 +08:00
tanzhenxin
5a6e5bb452 Merge pull request #1427 from liqiongyu/fix/1333-legacy-settings-alias
fix(cli): warn on deprecated/unknown settings keys
2026-01-12 14:43:27 +08:00
tanzhenxin
5f8e1ebc94 chore(settings): update legacy settings alias implementation and tests
Co-authored-by: Qwen-Coder <qwen-coder@alibabacloud.com>
2026-01-12 14:29:40 +08:00
mingholy.lmh
9670456a56 fix: simplify JavaScript runtime detection to fix powershell spawning process issue 2026-01-12 13:42:24 +08:00
liqoingyu
4c186e7c92 refactor(core): extract fetch error troubleshooting 2026-01-12 12:00:01 +08:00
tanzhenxin
2f6b0b233a Merge pull request #1464 from xuewenjie123/fix/windows-background-terminal-execute-x
fix(shell): prevent console window flash on Windows for foreground tasks
2026-01-12 11:48:10 +08:00
xuewenjie
9a8ce605c5 test: update shellExecutionService test for Windows spawn config changes 2026-01-12 11:22:54 +08:00
tanzhenxin
afc693a4ab Merge pull request #1453 from liqiongyu/fix/1359-sandbox-uidgid-default-linux
fix(cli): default sandbox UID/GID mapping on Linux
2026-01-12 11:08:47 +08:00
xuewenjie
7173cba844 fix(shell): prevent console window flash on Windows for foreground tasks 2026-01-12 11:04:05 +08:00
liqoingyu
8c56b612fb fix(cli): warn on deprecated/unknown settings keys 2026-01-12 10:49:37 +08:00
mingholy.lmh
7d40e1470c chore: add CODEOWNERS for SDK TypeScript package and remove legacy CLI path alias 2026-01-11 21:24:45 +08:00
liqoingyu
097482910e fix(core): improve OAuth fetch-failed diagnostics 2026-01-10 16:49:56 +08:00
liqoingyu
9b78c17638 fix(cli): default sandbox UID/GID mapping on Linux
Fixes #1359.

Default container sandboxing on Linux to use host UID/GID so qwen runs under a user that matches the mounted home directory and persists auth/settings in ~/.qwen.

Also gate the informational log behind DEBUG/DEBUG_MODE and clarify docs about Linux UID/GID mapping and ~/.qwen persistence.
2026-01-10 14:31:08 +08:00
mingholy.lmh
7f15256eba fix: improve release workflow 2026-01-09 18:00:01 +08:00
mingholy.lmh
587fc82fbc chore: update version to 0.1.1 in package.json 2026-01-09 17:54:59 +08:00
mingholy.lmh
8705f734d0 fix: improve bundled CLI path finding and support --experimental-skills 2026-01-09 16:32:55 +08:00
28 changed files with 1607 additions and 918 deletions

3
.github/CODEOWNERS vendored Normal file
View File

@@ -0,0 +1,3 @@
* @tanzhenxin @DennisYu07 @gwinthis @LaZzyMan @pomelo-nwu @Mingholy
# SDK TypeScript package changes require review from Mingholy
packages/sdk-typescript/** @Mingholy

View File

@@ -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.GITHUB_TOKEN }}'
GITHUB_TOKEN: '${{ secrets.CI_BOT_PAT }}'
RELEASE_BRANCH: '${{ steps.release_branch.outputs.BRANCH_NAME }}'
RELEASE_TAG: '${{ steps.version.outputs.RELEASE_TAG }}'
run: |-
@@ -258,26 +258,15 @@ 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.GITHUB_TOKEN }}'
GITHUB_TOKEN: '${{ secrets.CI_BOT_PAT }}'
PR_URL: '${{ steps.pr.outputs.PR_URL }}'
run: |-
set -euo pipefail
gh pr merge "${PR_URL}" --merge --auto
gh pr merge "${PR_URL}" --merge --auto --delete-branch
- name: 'Create Issue on Failure'
if: |-

View File

@@ -49,6 +49,8 @@ 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
@@ -157,7 +159,7 @@ For a working allowlist-style proxy example, see: [Example Proxy Script](/develo
## Linux UID/GID handling
The sandbox automatically handles user permissions on Linux. Override these permissions with:
On Linux, Qwen Code defaults to enabling UID/GID mapping so the sandbox runs as your user (and reuses the mounted `~/.qwen`). Override with:
```bash
export SANDBOX_SET_UID_GID=true # Force host UID/GID

View File

@@ -18,7 +18,7 @@
### Requirements
- VS Code 1.98.0 or higher
- VS Code 1.85.0 or higher
### Installation
@@ -34,7 +34,7 @@
### Extension not installing
- Ensure you have VS Code 1.98.0 or higher
- Ensure you have VS Code 1.85.0 or higher
- Check that VS Code has permission to install extensions
- Try installing directly from the Marketplace website

View File

@@ -9,11 +9,18 @@ This guide provides solutions to common issues and debugging tips, including top
## Authentication or login errors
- **Error: `UNABLE_TO_GET_ISSUER_CERT_LOCALLY` or `unable to get local issuer certificate`**
- **Error: `UNABLE_TO_GET_ISSUER_CERT_LOCALLY`, `UNABLE_TO_VERIFY_LEAF_SIGNATURE`, 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
View File

@@ -18593,7 +18593,7 @@
},
"packages/sdk-typescript": {
"name": "@qwen-code/sdk",
"version": "0.1.0",
"version": "0.1.2",
"license": "Apache-2.0",
"dependencies": {
"@modelcontextprotocol/sdk": "^1.25.1",

View File

@@ -23,6 +23,8 @@
"build-and-start": "npm run build && npm run start",
"build:vscode": "node scripts/build_vscode_companion.js",
"build:all": "npm run build && npm run build:sandbox && npm run build:vscode",
"build:native": "node scripts/build_native.js",
"build:native:all": "node scripts/build_native.js --all",
"build:packages": "npm run build --workspaces",
"build:sandbox": "node scripts/build_sandbox.js",
"bundle": "npm run generate && node esbuild.config.js && node scripts/copy_bundle_assets.js",

View File

@@ -55,6 +55,7 @@ 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,
@@ -418,6 +419,86 @@ 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,

View File

@@ -344,6 +344,97 @@ 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> {

View File

@@ -17,7 +17,11 @@ 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 { loadSettings, migrateDeprecatedSettings } from './config/settings.js';
import {
getSettingsWarnings,
loadSettings,
migrateDeprecatedSettings,
} from './config/settings.js';
import {
initializeApp,
type InitializationResult,
@@ -400,12 +404,15 @@ export async function main() {
let input = config.getQuestion();
const startupWarnings = [
...(await getStartupWarnings()),
...(await getUserStartupWarnings({
workspaceRoot: process.cwd(),
useRipgrep: settings.merged.tools?.useRipgrep ?? true,
useBuiltinRipgrep: settings.merged.tools?.useBuiltinRipgrep ?? true,
})),
...new Set([
...(await getStartupWarnings()),
...(await getUserStartupWarnings({
workspaceRoot: process.cwd(),
useRipgrep: settings.merged.tools?.useRipgrep ?? true,
useBuiltinRipgrep: settings.merged.tools?.useBuiltinRipgrep ?? true,
})),
...getSettingsWarnings(settings),
]),
];
// Render UI, passing necessary config values. Check that there is no command line question.

View File

@@ -1826,7 +1826,7 @@ describe('runNonInteractive', () => {
);
});
it('should print tool description and output to console in text mode (non-Task tools)', async () => {
it('should print tool output to console in text mode (non-Task tools)', async () => {
// Test that tool output is printed to stdout in text mode
const toolCallEvent: ServerGeminiStreamEvent = {
type: GeminiEventType.ToolCallRequest,
@@ -1839,21 +1839,6 @@ describe('runNonInteractive', () => {
},
};
// Mock the tool registry to return a tool with displayName and build method
const mockTool = {
displayName: 'Shell',
build: (args: Record<string, unknown>) => {
// @ts-expect-error - accessing indexed property for test mock
const command: string = args.command || '';
return {
getDescription: () => String(command),
};
},
};
vi.mocked(mockToolRegistry.getTool).mockReturnValue(
mockTool as unknown as ReturnType<typeof mockToolRegistry.getTool>,
);
// Mock tool execution with outputUpdateHandler being called
mockCoreExecuteToolCall.mockImplementation(
async (_config, _request, _signal, options) => {
@@ -1916,15 +1901,8 @@ describe('runNonInteractive', () => {
);
// Verify tool output was written to stdout
// First call should be tool description
expect(processStdoutSpy).toHaveBeenCalledWith('Shell: npm outdated');
expect(processStdoutSpy).toHaveBeenCalledWith('\n');
// Then the actual tool output
expect(processStdoutSpy).toHaveBeenCalledWith('Package outdated');
expect(processStdoutSpy).toHaveBeenCalledWith('npm@1.0.0 -> npm@2.0.0');
// Final newline after tool execution
expect(processStdoutSpy).toHaveBeenCalledWith('\n');
// And the model's response
expect(processStdoutSpy).toHaveBeenCalledWith('Package outdated\n');
expect(processStdoutSpy).toHaveBeenCalledWith('npm@1.0.0 -> npm@2.0.0\n');
expect(processStdoutSpy).toHaveBeenCalledWith('Dependencies checked');
});
});

View File

@@ -351,51 +351,19 @@ export async function runNonInteractive(
const taskToolProgressHandler = taskToolProgress?.handler;
// Create output handler for non-Task tools in text mode (for console output)
const toolOutputLines: string[] = [];
const nonTaskOutputHandler =
!isTaskTool && !adapter
? (callId: string, outputChunk: ToolResultDisplay) => {
const toolRegistry = config.getToolRegistry();
const tool = toolRegistry.getTool(finalRequestInfo.name);
if (tool) {
try {
const invocation = tool.build(finalRequestInfo.args);
const description = invocation.getDescription();
toolOutputLines.push(
`${tool.displayName}: ${description}`,
);
toolOutputLines.push('\n');
} catch {
// If we can't build invocation, just show tool name
toolOutputLines.push(`${tool.displayName}`);
toolOutputLines.push('\n');
}
}
// Print tool output to console in text mode
if (typeof outputChunk === 'string') {
// Indent output lines to show they're part of the tool execution
const lines = outputChunk.split('\n');
for (let i = 0; i < lines.length; i++) {
if (i === lines.length - 1 && lines[i] === '') {
// Skip trailing empty line
continue;
}
toolOutputLines.push(lines[i]);
}
process.stdout.write(outputChunk);
} else if (
outputChunk &&
typeof outputChunk === 'object' &&
'ansiOutput' in outputChunk
) {
// Handle ANSI output - indent it similarly
const ansiStr = String(outputChunk.ansiOutput);
const lines = ansiStr.split('\n');
for (let i = 0; i < lines.length; i++) {
if (i === lines.length - 1 && lines[i] === '') {
continue;
}
toolOutputLines.push(lines[i]);
}
// Handle ANSI output - just print as string for now
process.stdout.write(String(outputChunk.ansiOutput));
}
}
: undefined;
@@ -418,11 +386,6 @@ export async function runNonInteractive(
: undefined,
);
if (toolOutputLines.length > 0) {
toolOutputLines.forEach((line) => process.stdout.write(line));
process.stdout.write('\n');
}
// Note: In JSON mode, subagent messages are automatically added to the main
// adapter's messages array and will be output together on emitResult()

View File

@@ -8,7 +8,6 @@ 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 {
@@ -50,16 +49,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 (especially Debian/Ubuntu based) when using
* rootful Docker without userns-remap configured, to avoid permission issues with
* This is often necessary on Linux systems 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 Debian/Ubuntu Linux, it defaults to `true`.
* - On other OSes, or if OS detection fails, it defaults to `false`.
* - On Linux, it defaults to `true`.
* - On other OSes, 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
@@ -76,31 +75,20 @@ async function shouldUseCurrentUserInSandbox(): Promise<boolean> {
return false;
}
// If environment variable is not explicitly set, check for Debian/Ubuntu Linux
if (os.platform() === 'linux') {
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.',
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.',
);
}
return true;
}
return false; // Default to false if no other condition is met
return false;
}
// docker does not allow container names to contain ':' or '/', so we

View File

@@ -16,6 +16,8 @@ import {
isDeviceTokenPending,
isDeviceTokenSuccess,
isErrorResponse,
qwenOAuth2Events,
QwenOAuth2Event,
QwenOAuth2Client,
type DeviceAuthorizationResponse,
type DeviceTokenResponse,
@@ -845,6 +847,58 @@ 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', () => {

View File

@@ -13,6 +13,7 @@ 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,
@@ -847,8 +848,12 @@ async function authWithQwenDeviceFlow(
console.error('\n' + timeoutMessage);
return { success: false, reason: 'timeout', message: timeoutMessage };
} catch (error: unknown) {
const errorMessage = error instanceof Error ? error.message : String(error);
const message = `Device authorization flow failed: ${errorMessage}`;
const fullErrorMessage = formatFetchErrorForUser(error, {
url: QWEN_OAUTH_BASE_URL,
});
const message = `Device authorization flow failed: ${fullErrorMessage}`;
qwenOAuth2Events.emit(QwenOAuth2Event.AuthProgress, 'error', message);
console.error(message);
return { success: false, reason: 'error', message };
} finally {

View File

@@ -818,7 +818,7 @@ describe('ShellExecutionService child_process fallback', () => {
});
describe('Platform-Specific Behavior', () => {
it('should use cmd.exe on Windows', async () => {
it('should use cmd.exe and hide window on Windows', async () => {
mockPlatform.mockReturnValue('win32');
await simulateExecution('dir "foo bar"', (cp) =>
cp.emit('exit', 0, null),
@@ -829,7 +829,8 @@ describe('ShellExecutionService child_process fallback', () => {
[],
expect.objectContaining({
shell: true,
detached: true,
detached: false,
windowsHide: true,
}),
);
});

View File

@@ -229,7 +229,8 @@ export class ShellExecutionService {
stdio: ['ignore', 'pipe', 'pipe'],
windowsVerbatimArguments: true,
shell: isWindows ? true : 'bash',
detached: true,
detached: !isWindows,
windowsHide: isWindows,
env: {
...process.env,
QWEN_CODE: '1',

View File

@@ -0,0 +1,52 @@
/**
* @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');
});
});

View File

@@ -17,6 +17,26 @@ 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,
@@ -55,3 +75,118 @@ 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')}`;
}

View File

@@ -1,6 +1,6 @@
{
"name": "@qwen-code/sdk",
"version": "0.1.0",
"version": "0.1.2",
"description": "TypeScript SDK for programmatic access to qwen-code CLI",
"main": "./dist/index.cjs",
"module": "./dist/index.mjs",

View File

@@ -5,7 +5,7 @@
import type { SDKUserMessage } from '../types/protocol.js';
import { serializeJsonLine } from '../utils/jsonLines.js';
import { ProcessTransport } from '../transport/ProcessTransport.js';
import { parseExecutableSpec } from '../utils/cliPath.js';
import { prepareSpawnInfo, type SpawnInfo } 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 parsedExecutable = validateOptions(options);
const spawnInfo = validateOptions(options);
const isSingleTurn = typeof prompt === 'string';
const pathToQwenExecutable =
options.pathToQwenExecutable ?? parsedExecutable.executablePath;
const pathToQwenExecutable = options.pathToQwenExecutable;
const abortController = options.abortController ?? new AbortController();
const transport = new ProcessTransport({
pathToQwenExecutable,
spawnInfo,
cwd: options.cwd,
model: options.model,
permissionMode: options.permissionMode,
@@ -97,9 +97,7 @@ export function query({
return queryInstance;
}
function validateOptions(
options: QueryOptions,
): ReturnType<typeof parseExecutableSpec> {
function validateOptions(options: QueryOptions): SpawnInfo | undefined {
const validationResult = QueryOptionsSchema.safeParse(options);
if (!validationResult.success) {
const errors = validationResult.error.errors
@@ -108,13 +106,10 @@ function validateOptions(
throw new Error(`Invalid QueryOptions: ${errors}`);
}
let parsedExecutable: ReturnType<typeof parseExecutableSpec>;
try {
parsedExecutable = parseExecutableSpec(options.pathToQwenExecutable);
return prepareSpawnInfo(options.pathToQwenExecutable);
} catch (error) {
const errorMessage = error instanceof Error ? error.message : String(error);
throw new Error(`Invalid pathToQwenExecutable: ${errorMessage}`);
}
return parsedExecutable;
}

View File

@@ -44,7 +44,9 @@ export class ProcessTransport implements Transport {
const cwd = this.options.cwd ?? process.cwd();
const env = { ...process.env, ...this.options.env };
const spawnInfo = prepareSpawnInfo(this.options.pathToQwenExecutable);
const spawnInfo =
this.options.spawnInfo ??
prepareSpawnInfo(this.options.pathToQwenExecutable);
const stderrMode =
this.options.debug || this.options.stderr ? 'pipe' : 'ignore';
@@ -140,6 +142,7 @@ export class ProcessTransport implements Transport {
'--output-format',
'stream-json',
'--channel=SDK',
'--experimental-skills',
];
if (this.options.model) {

View File

@@ -4,11 +4,13 @@ import type {
SubagentConfig,
SDKMcpServerConfig,
} from './protocol.js';
import type { SpawnInfo } from '../utils/cliPath.js';
export type { PermissionMode };
export type TransportOptions = {
pathToQwenExecutable: string;
pathToQwenExecutable?: string;
spawnInfo?: SpawnInfo;
cwd?: string;
model?: string;
permissionMode?: PermissionMode;
@@ -177,32 +179,25 @@ export interface QueryOptions {
model?: string;
/**
* Path to the Qwen CLI executable or runtime specification.
* Path to the Qwen CLI executable.
*
* If not provided, the SDK automatically uses the bundled CLI included in the package.
*
* Supports multiple formats:
* - '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
* - 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
*
* 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.
* 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
*
* @example '/path/to/cli.js'
* @example 'qwen'
* @example '/usr/local/bin/qwen'
* @example 'tsx:/path/to/packages/cli/src/index.ts'
* @example './packages/cli/index.ts'
*/
pathToQwenExecutable?: string;

View File

@@ -1,28 +1,29 @@
/**
* 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)
* @license
* Copyright 2025 Qwen Team
* SPDX-License-Identifier: Apache-2.0
*/
/**
* CLI path resolution and subprocess spawning utilities
*/
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 = 'native' | 'node' | 'bun' | 'tsx' | 'deno';
export type ExecutableType = 'node' | 'bun' | 'tsx' | 'native';
/**
* Spawn information for CLI process
*/
export type SpawnInfo = {
/** Command to execute (e.g., 'qwen', 'node', 'bun', 'tsx') */
/** Command to execute (e.g., 'node', 'bun', 'tsx', or native binary path) */
command: string;
/** Arguments to pass to command */
args: string[];
@@ -32,49 +33,243 @@ export type SpawnInfo = {
originalInput: string;
};
function getBundledCliPath(): string | null {
/**
* Get the directory containing the current module (ESM or CJS)
*/
function getCurrentModuleDir(): string {
let moduleDir: string | null = null;
try {
const currentFile =
typeof __filename !== 'undefined'
? __filename
: fileURLToPath(import.meta.url);
const currentDir = path.dirname(currentFile);
const bundledCliPath = path.join(currentDir, 'cli', 'cli.js');
if (fs.existsSync(bundledCliPath)) {
return bundledCliPath;
if (typeof import.meta !== 'undefined' && import.meta.url) {
moduleDir = path.dirname(fileURLToPath(import.meta.url));
}
return null;
} catch {
return null;
// 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.');
}
export function findNativeCliPath(): string {
/**
* 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();
for (const candidate of candidates) {
if (fs.existsSync(candidate)) {
return candidate;
}
}
return null;
}
/**
* Find the bundled CLI path or throw error
*/
export function findBundledCliPath(): 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' +
'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" })',
'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" })',
);
}
/**
* 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: 5000, // 5 second timeout
timeout: 1000,
});
return true;
} catch {
@@ -82,245 +277,87 @@ function isCommandAvailable(command: string): boolean {
}
}
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
}
/**
* Check if tsx is available
*/
function isTsxAvailable(): boolean {
return isCommandAvailable('tsx');
}
/**
* 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
* Get JavaScript runtime type (bun if running under bun, otherwise node)
*/
export function parseExecutableSpec(executableSpec?: string): {
runtime?: string;
executablePath: string;
isExplicitRuntime: boolean;
} {
function getJsRuntimeType(): 'bun' | 'node' {
if (
executableSpec === '' ||
(executableSpec && executableSpec.trim() === '')
typeof process !== 'undefined' &&
'versions' in process &&
'bun' in process.versions
) {
throw new Error('Command name cannot be empty');
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');
}
if (!executableSpec) {
if (executableSpec === undefined) {
const bundledCliPath = findBundledCliPath();
return {
executablePath: findNativeCliPath(),
isExplicitRuntime: false,
command: process.execPath,
args: [bundledCliPath],
type: getJsRuntimeType(),
originalInput: '',
};
}
// 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(/^([^:]+):(.+)$/);
if (runtimeMatch) {
const [, runtime, filePath] = runtimeMatch;
// 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(', ')}`,
);
}
return {
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 (!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.`,
`Invalid command name '${executableSpec}'. ` +
'Command names should only contain letters, numbers, dots, hyphens, and underscores.',
);
}
return {
executablePath: executableSpec,
isExplicitRuntime: false,
command: executableSpec,
args: [],
type: 'native',
originalInput: executableSpec,
};
}
// It's a file path - validate and resolve
const resolvedPath = path.resolve(executableSpec);
validateFilePath(resolvedPath);
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',
);
if (isJavaScriptFile(resolvedPath)) {
return {
command: process.execPath,
args: [resolvedPath],
type: getJsRuntimeType(),
originalInput: executableSpec,
};
}
// 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 {
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 (isTypeScriptFile(resolvedPath)) {
if (isTsxAvailable()) {
return {
command: 'tsx',
args: [resolvedPath],
type: 'tsx',
originalInput: executableSpec,
};
}
// 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,
command: resolvedPath,
args: [],
type: 'native',
originalInput: executableSpec || '',
originalInput: executableSpec,
};
}

View File

@@ -1,16 +1,21 @@
/**
* @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, afterEach, vi } from 'vitest';
import { describe, it, expect, beforeEach, vi } from 'vitest';
import * as fs from 'node:fs';
import * as path from 'node:path';
import { execSync } from 'node:child_process';
import {
parseExecutableSpec,
prepareSpawnInfo,
findNativeCliPath,
findBundledCliPath,
} from '../../src/utils/cliPath.js';
// Mock fs module
@@ -21,36 +26,43 @@ 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(''));
});
afterEach(() => {
// Restore original process.versions
Object.defineProperty(process, 'versions', {
value: originalVersions,
writable: true,
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:');
});
});
describe('parseExecutableSpec', () => {
describe('prepareSpawnInfo', () => {
describe('auto-detection (no spec provided)', () => {
it('should auto-detect bundled CLI when no spec provided', () => {
// Mock existsSync to return true for bundled CLI
@@ -61,176 +73,23 @@ describe('CLI Path Utilities', () => {
);
});
const result = parseExecutableSpec();
const result = prepareSpawnInfo();
expect(result.executablePath).toContain('cli.js');
expect(result.isExplicitRuntime).toBe(false);
expect(result.command).toBe(process.execPath);
expect(result.args[0]).toContain('cli.js');
expect(result.type).toBe('node');
expect(result.originalInput).toBe('');
});
it('should throw when bundled CLI not found', () => {
mockFs.existsSync.mockReturnValue(false);
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',
);
expect(() => prepareSpawnInfo()).toThrow('Bundled qwen CLI not found');
});
});
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({
@@ -241,37 +100,38 @@ describe('CLI Path Utilities', () => {
});
});
it('should prepare spawn info for native binary path', () => {
const result = prepareSpawnInfo('/usr/local/bin/qwen');
it('should detect command names on Windows', () => {
const result = prepareSpawnInfo('qwen.exe');
expect(result).toEqual({
command: path.resolve('/usr/local/bin/qwen'),
command: 'qwen.exe',
args: [],
type: 'native',
originalInput: '/usr/local/bin/qwen',
originalInput: 'qwen.exe',
});
});
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', () => {
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',
});
beforeEach(() => {
mockFs.existsSync.mockReturnValue(true);
});
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,
});
it('should use node for .js files', () => {
const result = prepareSpawnInfo('/path/to/cli.js');
expect(result).toEqual({
@@ -306,6 +166,10 @@ 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');
@@ -329,107 +193,178 @@ describe('CLI Path Utilities', () => {
});
});
it('should throw helpful error when tsx is not available', () => {
it('should fallback to native when tsx is not available', () => {
// Mock tsx not being available
mockExecSync.mockImplementation(() => {
throw new Error('Command not found');
});
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');
const result = prepareSpawnInfo('/path/to/index.ts');
expect(result).toEqual({
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',
command: path.resolve('/path/to/index.ts'),
args: [],
type: 'native',
originalInput: '/path/to/index.ts',
});
});
});
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('native executables', () => {
beforeEach(() => {
mockFs.existsSync.mockReturnValue(true);
});
const result = prepareSpawnInfo();
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('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');
expect(result.command).toBe(process.execPath);
expect(result.args[0]).toContain('cli.js');
expect(result.args[0]).toBe(path.resolve('./relative/path/to/cli.js'));
expect(result.type).toBe('node');
expect(result.originalInput).toBe('');
});
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',
);
});
});
});
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');
describe('Windows path handling', () => {
beforeEach(() => {
mockFs.existsSync.mockReturnValue(true);
});
it('should throw descriptive error when bundled CLI not found', () => {
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,
});
});
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', () => {
mockFs.existsSync.mockReturnValue(false);
expect(() => findNativeCliPath()).toThrow('Bundled qwen CLI not found');
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',
);
});
});
@@ -438,18 +373,6 @@ 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);
@@ -473,235 +396,27 @@ describe('CLI Path Utilities', () => {
});
});
it('should handle bun runtime with bundle', () => {
const bundlePath = '/path/to/cli.js';
const result = prepareSpawnInfo(`bun:${bundlePath}`);
expect(result).toEqual({
command: 'bun',
args: [path.resolve(bundlePath)],
type: 'bun',
originalInput: `bun:${bundlePath}`,
});
});
it('should handle Windows paths with drive letters', () => {
const windowsPath = 'D:\\path\\to\\cli.js';
const result = prepareSpawnInfo(windowsPath);
it('should handle ESM bundle', () => {
const bundlePath = '/path/to/cli.mjs';
const result = prepareSpawnInfo(bundlePath);
expect(result).toEqual({
command: process.execPath,
args: [path.resolve(windowsPath)],
args: [path.resolve(bundlePath)],
type: 'node',
originalInput: windowsPath,
originalInput: bundlePath,
});
});
it('should handle Windows paths with TypeScript files', () => {
const windowsPath = 'C:\\Users\\dev\\qwen\\index.ts';
const result = prepareSpawnInfo(windowsPath);
it('should handle CJS bundle', () => {
const bundlePath = '/path/to/cli.cjs';
const result = prepareSpawnInfo(bundlePath);
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',
);
command: process.execPath,
args: [path.resolve(bundlePath)],
type: 'node',
originalInput: bundlePath,
});
});
});

View File

@@ -1,25 +1,36 @@
# Qwen Code Companion
The Qwen Code Companion extension seamlessly integrates [Qwen Code](https://github.com/QwenLM/qwen-code). This extension is compatible with both VS Code and VS Code forks.
Seamlessly integrate [Qwen Code](https://github.com/QwenLM/qwen-code) into Visual Studio Code with native IDE features and an intuitive interface. This extension bundles everything you need to get started immediately.
# Features
## Demo
- Open Editor File Context: Qwen Code gains awareness of the files you have open in your editor, providing it with a richer understanding of your project's structure and content.
<video src="https://cloud.video.taobao.com/vod/IKKwfM-kqNI3OJjM_U8uMCSMAoeEcJhs6VNCQmZxUfk.mp4" controls width="800">
Your browser does not support the video tag. You can open the video directly:
https://cloud.video.taobao.com/vod/IKKwfM-kqNI3OJjM_U8uMCSMAoeEcJhs6VNCQmZxUfk.mp4
</video>
- Selection Context: Qwen Code can easily access your cursor's position and selected text within the editor, giving it valuable context directly from your current work.
## Features
- Native Diffing: Seamlessly view, modify, and accept code changes suggested by Qwen Code directly within the editor.
- **Native IDE experience**: Dedicated Qwen Code sidebar panel accessed via the Qwen icon
- **Native diffing**: Review, edit, and accept changes in VS Code's diff view
- **Auto-accept edits mode**: Automatically apply Qwen's changes as they're made
- **File management**: @-mention files or attach files and images using the system file picker
- **Conversation history & multiple sessions**: Access past conversations and run multiple sessions simultaneously
- **Open file & selection context**: Share active files, cursor position, and selections for more precise help
- Launch Qwen Code: Quickly start a new Qwen Code session from the Command Palette (Cmd+Shift+P or Ctrl+Shift+P) by running the "Qwen Code: Run" command.
## Requirements
# Requirements
- Visual Studio Code 1.85.0 or newer
To use this extension, you'll need:
## Installation
- VS Code version 1.101.0 or newer
- Qwen Code (installed separately) running within the VS Code integrated terminal
1. Install from the VS Code Marketplace: https://marketplace.visualstudio.com/items?itemName=qwenlm.qwen-code-vscode-ide-companion
# Development and Debugging
2. Two ways to use
- Chat panel: Click the Qwen icon in the Activity Bar, or run `Qwen Code: Open` from the Command Palette (`Cmd+Shift+P` / `Ctrl+Shift+P`).
- Terminal session (classic): Run `Qwen Code: Run` to launch a session in the integrated terminal (bundled CLI).
## Development and Debugging
To debug and develop this extension locally:
@@ -76,6 +87,6 @@ npx vsce package
pnpm vsce package
```
# Terms of Service and Privacy Notice
## Terms of Service and Privacy Notice
By installing this extension, you agree to the [Terms of Service](https://github.com/QwenLM/qwen-code/blob/main/docs/tos-privacy.md).

323
scripts/build_native.js Normal file
View File

@@ -0,0 +1,323 @@
/**
* @license
* Copyright 2025 Qwen
* SPDX-License-Identifier: Apache-2.0
*/
import fs from 'node:fs';
import path from 'node:path';
import { fileURLToPath } from 'node:url';
import { spawnSync } from 'node:child_process';
const __filename = fileURLToPath(import.meta.url);
const __dirname = path.dirname(__filename);
const rootDir = path.resolve(__dirname, '..');
const distRoot = path.join(rootDir, 'dist', 'native');
const entryPoint = path.join(rootDir, 'packages', 'cli', 'index.ts');
const localesDir = path.join(
rootDir,
'packages',
'cli',
'src',
'i18n',
'locales',
);
const vendorDir = path.join(rootDir, 'packages', 'core', 'vendor');
const rootPackageJson = JSON.parse(
fs.readFileSync(path.join(rootDir, 'package.json'), 'utf-8'),
);
const cliName = Object.keys(rootPackageJson.bin || {})[0] || 'qwen';
const version = rootPackageJson.version;
const TARGETS = [
{
id: 'darwin-arm64',
os: 'darwin',
arch: 'arm64',
bunTarget: 'bun-darwin-arm64',
},
{
id: 'darwin-x64',
os: 'darwin',
arch: 'x64',
bunTarget: 'bun-darwin-x64',
},
{
id: 'linux-arm64',
os: 'linux',
arch: 'arm64',
bunTarget: 'bun-linux-arm64',
},
{
id: 'linux-x64',
os: 'linux',
arch: 'x64',
bunTarget: 'bun-linux-x64',
},
{
id: 'linux-arm64-musl',
os: 'linux',
arch: 'arm64',
libc: 'musl',
bunTarget: 'bun-linux-arm64-musl',
},
{
id: 'linux-x64-musl',
os: 'linux',
arch: 'x64',
libc: 'musl',
bunTarget: 'bun-linux-x64-musl',
},
{
id: 'windows-x64',
os: 'windows',
arch: 'x64',
bunTarget: 'bun-windows-x64',
},
];
function getHostTargetId() {
const platform = process.platform;
const arch = process.arch;
if (platform === 'darwin' && arch === 'arm64') return 'darwin-arm64';
if (platform === 'darwin' && arch === 'x64') return 'darwin-x64';
if (platform === 'win32' && arch === 'x64') return 'windows-x64';
if (platform === 'linux' && arch === 'x64') {
return isMusl() ? 'linux-x64-musl' : 'linux-x64';
}
if (platform === 'linux' && arch === 'arm64') {
return isMusl() ? 'linux-arm64-musl' : 'linux-arm64';
}
return null;
}
function isMusl() {
if (process.platform !== 'linux') return false;
const report = process.report?.getReport?.();
return !report?.header?.glibcVersionRuntime;
}
function parseArgs(argv) {
const args = {
all: false,
list: false,
targets: [],
};
for (let i = 0; i < argv.length; i += 1) {
const arg = argv[i];
if (arg === '--all') {
args.all = true;
} else if (arg === '--list-targets') {
args.list = true;
} else if (arg === '--target' && argv[i + 1]) {
args.targets.push(argv[i + 1]);
i += 1;
} else if (arg?.startsWith('--targets=')) {
const raw = arg.split('=')[1] || '';
args.targets.push(
...raw
.split(',')
.map((value) => value.trim())
.filter(Boolean),
);
}
}
return args;
}
function ensureBunAvailable() {
const result = spawnSync('bun', ['--version'], { stdio: 'pipe' });
if (result.error) {
console.error('Error: Bun is required to build native binaries.');
console.error('Install Bun from https://bun.sh and retry.');
process.exit(1);
}
}
function cleanNativeDist() {
fs.rmSync(distRoot, { recursive: true, force: true });
fs.mkdirSync(distRoot, { recursive: true });
}
function copyRecursiveSync(src, dest) {
if (!fs.existsSync(src)) {
return;
}
const stats = fs.statSync(src);
if (stats.isDirectory()) {
if (!fs.existsSync(dest)) {
fs.mkdirSync(dest, { recursive: true });
}
for (const entry of fs.readdirSync(src)) {
if (entry === '.DS_Store') continue;
copyRecursiveSync(path.join(src, entry), path.join(dest, entry));
}
} else {
fs.copyFileSync(src, dest);
if (stats.mode & 0o111) {
fs.chmodSync(dest, stats.mode);
}
}
}
function copyNativeAssets(targetDir, target) {
if (target.os === 'darwin') {
const sbFiles = findSandboxProfiles();
for (const file of sbFiles) {
fs.copyFileSync(file, path.join(targetDir, path.basename(file)));
}
}
copyVendorRipgrep(targetDir, target);
copyRecursiveSync(localesDir, path.join(targetDir, 'locales'));
}
function findSandboxProfiles() {
const matches = [];
const packagesDir = path.join(rootDir, 'packages');
const stack = [packagesDir];
while (stack.length) {
const current = stack.pop();
if (!current) break;
const entries = fs.readdirSync(current, { withFileTypes: true });
for (const entry of entries) {
const entryPath = path.join(current, entry.name);
if (entry.isDirectory()) {
stack.push(entryPath);
} else if (entry.isFile() && entry.name.endsWith('.sb')) {
matches.push(entryPath);
}
}
}
return matches;
}
function copyVendorRipgrep(targetDir, target) {
if (!fs.existsSync(vendorDir)) {
console.warn(`Warning: Vendor directory not found at ${vendorDir}`);
return;
}
const vendorRipgrepDir = path.join(vendorDir, 'ripgrep');
if (!fs.existsSync(vendorRipgrepDir)) {
console.warn(`Warning: ripgrep directory not found at ${vendorRipgrepDir}`);
return;
}
const platform = target.os === 'windows' ? 'win32' : target.os;
const ripgrepTargetDir = path.join(
vendorRipgrepDir,
`${target.arch}-${platform}`,
);
if (!fs.existsSync(ripgrepTargetDir)) {
console.warn(`Warning: ripgrep binaries not found at ${ripgrepTargetDir}`);
return;
}
const destVendorRoot = path.join(targetDir, 'vendor');
const destRipgrepDir = path.join(destVendorRoot, 'ripgrep');
fs.mkdirSync(destRipgrepDir, { recursive: true });
const copyingFile = path.join(vendorRipgrepDir, 'COPYING');
if (fs.existsSync(copyingFile)) {
fs.copyFileSync(copyingFile, path.join(destRipgrepDir, 'COPYING'));
}
copyRecursiveSync(
ripgrepTargetDir,
path.join(destRipgrepDir, path.basename(ripgrepTargetDir)),
);
}
function buildTarget(target) {
const outputName = `${cliName}-${target.id}`;
const targetDir = path.join(distRoot, outputName);
const binDir = path.join(targetDir, 'bin');
const binaryName = target.os === 'windows' ? `${cliName}.exe` : cliName;
fs.mkdirSync(binDir, { recursive: true });
const buildArgs = [
'build',
'--compile',
'--target',
target.bunTarget,
entryPoint,
'--outfile',
path.join(binDir, binaryName),
];
const result = spawnSync('bun', buildArgs, { stdio: 'inherit' });
if (result.status !== 0) {
throw new Error(`Bun build failed for ${target.id}`);
}
const packageJson = {
name: outputName,
version,
os: [target.os === 'windows' ? 'win32' : target.os],
cpu: [target.arch],
};
fs.writeFileSync(
path.join(targetDir, 'package.json'),
JSON.stringify(packageJson, null, 2) + '\n',
);
copyNativeAssets(targetDir, target);
}
function main() {
if (!fs.existsSync(entryPoint)) {
console.error(`Entry point not found at ${entryPoint}`);
process.exit(1);
}
const args = parseArgs(process.argv.slice(2));
if (args.list) {
console.log(TARGETS.map((target) => target.id).join('\n'));
return;
}
ensureBunAvailable();
cleanNativeDist();
let selectedTargets = [];
if (args.all) {
selectedTargets = TARGETS;
} else if (args.targets.length > 0) {
selectedTargets = TARGETS.filter((target) =>
args.targets.includes(target.id),
);
} else {
const hostTargetId = getHostTargetId();
if (!hostTargetId) {
console.error(
`Unsupported host platform/arch: ${process.platform}/${process.arch}`,
);
process.exit(1);
}
selectedTargets = TARGETS.filter((target) => target.id === hostTargetId);
}
if (selectedTargets.length === 0) {
console.error('No matching targets selected.');
process.exit(1);
}
for (const target of selectedTargets) {
console.log(`\nBuilding native binary for ${target.id}...`);
buildTarget(target);
}
console.log('\n✅ Native build complete.');
}
main();

251
standalone-release.md Normal file
View File

@@ -0,0 +1,251 @@
# Standalone Release Spec (Bun Native + npm Fallback)
This document describes the target release design for shipping Qwen Code as native
binaries built with Bun, while retaining the existing npm JS bundle as a fallback
distribution. It is written as a migration-ready spec that bridges the current
release pipeline to the future dual-release system.
## Goal
Provide a CLI that:
- Runs as a standalone binary on Linux/macOS/Windows without requiring Node or Bun.
- Retains npm installation (global/local) as a JS-only fallback.
- Supports a curl installer that pulls the correct binary from GitHub Releases.
- Ships multiple variants (x64/arm64, musl/glibc where needed).
- Uses one release flow to produce all artifacts with a single tag/version.
## Non-Goals
- Replacing npm as a dev-time dependency manager.
- Shipping a single universal binary for all platforms.
- Supporting every architecture or OS outside the defined target matrix.
- Removing the existing Node/esbuild bundle.
## Current State (Baseline)
The current release pipeline:
- Bundles the CLI into `dist/cli.js` via esbuild.
- Uses `scripts/prepare-package.js` to create `dist/package.json`,
plus `vendor/`, `locales/`, and `*.sb` assets.
- Publishes `dist/` to npm as the primary distribution.
- Creates a GitHub Release and attaches only `dist/cli.js`.
- Uses `release.yml` for nightly/preview schedules and manual stable releases.
This spec extends the above pipeline; it does not replace it until the migration
phases complete.
## Target Architecture
### 1) Build Outputs
There are two build outputs:
1. Native binaries (Bun compile) for a target matrix.
2. Node-compatible JS bundle for npm fallback (existing `dist/` output).
Native build output for each target:
- dist/<name>/bin/<cli> (or .exe on Windows)
- dist/<name>/package.json (minimal package metadata)
Name encodes target:
- <cli>-linux-x64
- <cli>-linux-x64-musl
- <cli>-linux-arm64
- <cli>-linux-arm64-musl
- <cli>-darwin-arm64
- <cli>-darwin-x64
- <cli>-windows-x64
### 2) npm Distribution (JS Fallback)
Keep npm as a pure JS/TS CLI package that runs under Node/Bun. Do not ship or
auto-install native binaries through npm.
Implications:
- npm install always uses the JS implementation.
- No optionalDependencies for platform binaries.
- No postinstall symlink logic.
- No node shim that searches for a native binary.
### 3) GitHub Release Distribution (Primary)
Native binaries are distributed only via GitHub Releases and the curl installer:
- Archive each platform binary into a tar.gz (Linux) or zip (macOS/Windows).
- Attach archives to the GitHub Release.
- Provide a shell installer that detects target and downloads the correct archive.
## Detailed Implementation
### A) Target Matrix
Define a target matrix that includes OS, arch, and libc variants.
Target list (fixed set):
- darwin arm64
- darwin x64
- linux arm64 (glibc)
- linux x64 (glibc)
- linux arm64 musl
- linux x64 musl
- win32 x64
### B) Build Scripts
1. Native build script (new, e.g. `scripts/build-native.ts`)
Responsibilities:
- Remove native build output directory (keep npm `dist/` intact).
- For each target:
- Compute a target name.
- Compile using `Bun.build({ compile: { target: ... } })`.
- Write the binary to `dist/<name>/bin/<cli>`.
- Write a minimal `package.json` into `dist/<name>/`.
2. npm fallback build (existing)
Responsibilities:
- `npm run bundle` produces `dist/cli.js`.
- `npm run prepare:package` creates `dist/package.json` and copies assets.
Key details:
- Use Bun.build with compile.target = <bun-target> (e.g. bun-linux-x64).
- Include any extra worker/runtime files in entrypoints.
- Use define or execArgv to inject version/channel metadata.
- Use "windows" in archive naming even though the OS is "win32" internally.
Build-time considerations:
- Preinstall platform-specific native deps for bundling (example: bun install --os="_" --cpu="_" for dependencies with native bindings).
- Include worker assets in the compile entrypoints and embed their paths via define constants.
- Use platform-specific bunfs root paths when resolving embedded worker files.
- Set runtime execArgv flags for user-agent/version and system CA usage.
Target name example:
<cli>-<os>-<arch>[-musl]
Minimal package.json example:
{
"name": "<cli>-linux-x64",
"version": "<version>",
"os": ["linux"],
"cpu": ["x64"]
}
### C) Publish Script (new, optional)
Responsibilities:
1. Run the native build script.
2. Smoke test a local binary (`dist/<host>/bin/<cli> --version`).
3. Create GitHub Release archives.
4. Optionally build and push Docker image.
5. Publish npm package (JS-only fallback) as a separate step or pipeline.
Note: npm publishing is now independent of native binary publishing. It should not reference platform binaries.
### D) GitHub Release Installer (install)
A bash installer that:
1. Detects OS and arch.
2. Handles Rosetta (macOS) and musl detection (Alpine, ldd).
3. Builds target name and downloads from GitHub Releases.
4. Extracts to ~/.<cli>/bin.
5. Adds PATH unless --no-modify-path.
Supports:
- --version <version>
- --binary <path>
- --no-modify-path
Installer details to include:
- Require tar for Linux and unzip for macOS/Windows archives.
- Use "windows" in asset naming, not "win32".
- Prefer arm64 when macOS is running under Rosetta.
## CI/CD Flow (Dual Pipeline)
Release pipeline (native binaries):
1. Bump version.
2. Build binaries for the full target matrix.
3. Smoke test the host binary.
4. Create GitHub release assets.
5. Mark release as final (if draft).
Release pipeline (npm fallback):
1. Bump version (same tag).
2. Publish the JS-only npm package.
Release orchestration details to consider:
- Update all package.json version fields in the repo.
- Update any extension metadata or download URLs that embed version strings.
- Tag the release and create a GitHub Release draft that includes the binary assets.
### Workflow Mapping to Current Code
The existing `release.yml` workflow remains the orchestrator:
- Use `scripts/get-release-version.js` for version/tag selection.
- Keep tests and integration checks as-is.
- Add a native build matrix job that produces archives and uploads them to
the GitHub Release.
- Keep the npm publish step from `dist/` as the fallback.
- Ensure the same `RELEASE_TAG` is used for both native and npm outputs.
## Edge Cases and Pitfalls
- musl: Alpine requires musl binaries.
- Rosetta: macOS under Rosetta should prefer arm64 when available.
- npm fallback: ensure JS implementation is functional without native helpers.
- Path precedence: binary install should appear earlier in PATH than npm global bin if you want native to win by default.
- Archive prerequisites: users need tar/unzip depending on OS.
## Testing Plan
- Build all targets in CI.
- Run dist/<host>/bin/<cli> --version.
- npm install locally and verify CLI invocation.
- Run installer script on each OS or VM.
- Validate musl builds on Alpine.
## Migration Plan
Phase 1: Add native builds without changing npm
- [ ] Define target matrix with musl variants.
- [ ] Add native build script for Bun compile per target.
- [ ] Generate per-target package.json.
- [ ] Produce per-target archives and upload to GitHub Releases.
- [ ] Keep existing npm bundle publish unchanged.
Phase 2: Installer and docs
- [ ] Add curl installer for GitHub Releases.
- [ ] Document recommended install paths (native first).
- [ ] Add smoke tests for installer output.
Phase 3: Default install guidance and cleanup
- [ ] Update docs to recommend native install where possible.
- [ ] Decide whether npm stays equal or fallback-only in user docs.
## Implementation Checklist
- [ ] Keep `npm run bundle` + `npm run prepare:package` for JS fallback.
- [ ] Add `scripts/build-native.ts` for Bun compile targets.
- [ ] Add archive creation and asset upload in `release.yml`.
- [ ] Add an installer script with OS/arch/musl detection.
- [ ] Ensure tag/version parity across native and npm releases.