Skip to content
10 changes: 3 additions & 7 deletions integration-tests/globalSetup.ts
Original file line number Diff line number Diff line change
Expand Up @@ -19,12 +19,9 @@ import {
} from 'node:fs/promises';
import { join, dirname } from 'node:path';
import { fileURLToPath } from 'node:url';
import * as os from 'node:os';

import {
QWEN_CONFIG_DIR,
DEFAULT_CONTEXT_FILENAME,
} from '../packages/core/src/tools/memoryTool.js';
import { DEFAULT_CONTEXT_FILENAME } from '../packages/core/src/tools/memoryTool.js';
import { Storage } from '../packages/core/src/config/storage.js';

const __dirname = dirname(fileURLToPath(import.meta.url));
const rootDir = join(__dirname, '..');
Expand All @@ -33,8 +30,7 @@ let runDir = ''; // Make runDir accessible in teardown
let sdkE2eRunDir = ''; // SDK E2E test run directory

const memoryFilePath = join(
os.homedir(),
QWEN_CONFIG_DIR,
Storage.getGlobalQwenDir(),
DEFAULT_CONTEXT_FILENAME,
);
let originalMemoryContent: string | null = null;
Expand Down
7 changes: 6 additions & 1 deletion packages/channels/base/src/PairingStore.ts
Original file line number Diff line number Diff line change
Expand Up @@ -22,7 +22,12 @@ export class PairingStore {
private allowlistPath: string;

constructor(channelName: string) {
this.dir = path.join(os.homedir(), '.qwen', 'channels');
const configDir = process.env['QWEN_CONFIG_DIR']
? path.isAbsolute(process.env['QWEN_CONFIG_DIR'])
? process.env['QWEN_CONFIG_DIR']
: path.resolve(process.env['QWEN_CONFIG_DIR'])
: path.join(os.homedir(), '.qwen');
this.dir = path.join(configDir, 'channels');
this.pendingPath = path.join(this.dir, `${channelName}-pairing.json`);
this.allowlistPath = path.join(this.dir, `${channelName}-allowlist.json`);
}
Expand Down
12 changes: 10 additions & 2 deletions packages/channels/weixin/src/accounts.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,7 @@ import {
unlinkSync,
chmodSync,
} from 'node:fs';
import { join } from 'node:path';
import { join, isAbsolute, resolve } from 'node:path';
import { homedir } from 'node:os';

export const DEFAULT_BASE_URL = 'https://ilinkai.weixin.qq.com';
Expand All @@ -23,10 +23,18 @@ export interface AccountData {
savedAt: string;
}

function getGlobalQwenDir(): string {
const envDir = process.env['QWEN_CONFIG_DIR'];
if (envDir) {
return isAbsolute(envDir) ? envDir : resolve(envDir);
}
return join(homedir(), '.qwen');
}

export function getStateDir(): string {
const dir =
process.env['WEIXIN_STATE_DIR'] ||
join(homedir(), '.qwen', 'channels', 'weixin');
join(getGlobalQwenDir(), 'channels', 'weixin');
if (!existsSync(dir)) {
mkdirSync(dir, { recursive: true });
}
Expand Down
4 changes: 2 additions & 2 deletions packages/cli/src/commands/channel/pidfile.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@ import {
unlinkSync,
} from 'node:fs';
import * as path from 'node:path';
import * as os from 'node:os';
import { Storage } from '@qwen-code/qwen-code-core';

export interface ServiceInfo {
pid: number;
Expand All @@ -15,7 +15,7 @@ export interface ServiceInfo {
}

function pidFilePath(): string {
return path.join(os.homedir(), '.qwen', 'channels', 'service.pid');
return path.join(Storage.getGlobalQwenDir(), 'channels', 'service.pid');
}

/** Check if a process is alive. */
Expand Down
4 changes: 2 additions & 2 deletions packages/cli/src/commands/channel/start.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,4 @@
import * as path from 'node:path';
import * as os from 'node:os';
import type { CommandModule } from 'yargs';
import { loadSettings } from '../../config/settings.js';
import { writeStderrLine, writeStdoutLine } from '../../utils/stdioHelpers.js';
Expand All @@ -17,13 +16,14 @@ import {
removeServiceInfo,
} from './pidfile.js';
import { getExtensionManager } from '../extensions/utils.js';
import { Storage } from '@qwen-code/qwen-code-core';

const MAX_CRASH_RESTARTS = 3;
const CRASH_WINDOW_MS = 5 * 60 * 1000; // 5-minute window for counting crashes
const RESTART_DELAY_MS = 3000;

function sessionsPath(): string {
return path.join(os.homedir(), '.qwen', 'channels', 'sessions.json');
return path.join(Storage.getGlobalQwenDir(), 'channels', 'sessions.json');
}

function loadChannelsConfig(): Record<string, unknown> {
Expand Down
5 changes: 2 additions & 3 deletions packages/cli/src/commands/channel/status.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
import { existsSync, readFileSync } from 'node:fs';
import * as path from 'node:path';
import * as os from 'node:os';
import { Storage } from '@qwen-code/qwen-code-core';
import type { CommandModule } from 'yargs';
import { writeStdoutLine } from '../../utils/stdioHelpers.js';
import { readServiceInfo } from './pidfile.js';
Expand Down Expand Up @@ -42,8 +42,7 @@ export const statusCommand: CommandModule = {

// Read session data for per-channel counts
const sessionsPath = path.join(
os.homedir(),
'.qwen',
Storage.getGlobalQwenDir(),
'channels',
'sessions.json',
);
Expand Down
7 changes: 4 additions & 3 deletions packages/cli/src/config/settings.ts
Original file line number Diff line number Diff line change
Expand Up @@ -58,7 +58,7 @@ function getMergeStrategyForPath(path: string[]): MergeStrategy | undefined {

export type { Settings, MemoryImportFormat };

export const SETTINGS_DIRECTORY_NAME = '.qwen';
export const SETTINGS_DIRECTORY_NAME = QWEN_DIR;
export const USER_SETTINGS_PATH = Storage.getGlobalSettingsPath();
export const USER_SETTINGS_DIR = path.dirname(USER_SETTINGS_PATH);
export const DEFAULT_EXCLUDED_ENV_VARS = ['DEBUG', 'DEBUG_MODE'];
Expand Down Expand Up @@ -413,9 +413,10 @@ function findEnvFile(settings: Settings, startDir: string): string | null {
const isTrusted = isWorkspaceTrusted(settings).isTrusted;

// Pre-compute user-level .env paths for fast comparison
const globalQwenDir = Storage.getGlobalQwenDir();
const userLevelPaths = new Set([
path.normalize(path.join(homeDir, '.env')),
path.normalize(path.join(homeDir, QWEN_DIR, '.env')),
path.normalize(path.join(globalQwenDir, '.env')),
]);

// Determine if we can use this .env file based on trust settings
Expand All @@ -438,7 +439,7 @@ function findEnvFile(settings: Settings, startDir: string): string | null {
const parentDir = path.dirname(currentDir);
if (parentDir === currentDir || !parentDir) {
// At home directory - check fallback .env files
const homeGeminiEnvPath = path.join(homeDir, QWEN_DIR, '.env');
const homeGeminiEnvPath = path.join(globalQwenDir, '.env');
if (fs.existsSync(homeGeminiEnvPath)) {
return homeGeminiEnvPath;
}
Expand Down
2 changes: 1 addition & 1 deletion packages/cli/src/config/settingsSchema.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1324,7 +1324,7 @@ const SETTINGS_SCHEMA = {
default: undefined as string | undefined,
description:
'Custom directory for runtime output (temp files, debug logs, session data, todos, etc.). ' +
'Config files remain at ~/.qwen. Env var QWEN_RUNTIME_DIR takes priority.',
'Config files remain at ~/.qwen (or QWEN_CONFIG_DIR if set). Env var QWEN_RUNTIME_DIR takes priority.',
showInDialog: false,
},
tavilyApiKey: {
Expand Down
5 changes: 2 additions & 3 deletions packages/cli/src/config/trustedFolders.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,20 +6,19 @@

import * as fs from 'node:fs';
import * as path from 'node:path';
import { homedir } from 'node:os';
import {
FatalConfigError,
getErrorMessage,
isWithinRoot,
ideContextStore,
Storage,
} from '@qwen-code/qwen-code-core';
import type { Settings } from './settings.js';
import stripJsonComments from 'strip-json-comments';
import { writeStderrLine } from '../utils/stdioHelpers.js';

export const TRUSTED_FOLDERS_FILENAME = 'trustedFolders.json';
export const SETTINGS_DIRECTORY_NAME = '.qwen';
export const USER_SETTINGS_DIR = path.join(homedir(), SETTINGS_DIRECTORY_NAME);
export const USER_SETTINGS_DIR = Storage.getGlobalQwenDir();

export function getTrustedFoldersPath(): string {
if (process.env['QWEN_CODE_TRUSTED_FOLDERS_PATH']) {
Expand Down
4 changes: 2 additions & 2 deletions packages/cli/src/i18n/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,8 +7,8 @@
import * as fs from 'node:fs';
import * as path from 'node:path';
import { fileURLToPath, pathToFileURL } from 'node:url';
import { homedir } from 'node:os';
import { writeStderrLine } from '../utils/stdioHelpers.js';
import { Storage } from '@qwen-code/qwen-code-core';
import {
type SupportedLanguage,
SUPPORTED_LANGUAGES,
Expand All @@ -34,7 +34,7 @@ const getBuiltinLocalesDir = (): string => {
};

const getUserLocalesDir = (): string =>
path.join(homedir(), '.qwen', 'locales');
path.join(Storage.getGlobalQwenDir(), 'locales');

/**
* Get the path to the user's custom locales directory.
Expand Down
4 changes: 4 additions & 0 deletions packages/cli/src/ui/commands/memoryCommand.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,10 @@ vi.mock('@qwen-code/qwen-code-core', async (importOriginal) => {
await importOriginal<typeof import('@qwen-code/qwen-code-core')>();
return {
...original,
Storage: {
...original.Storage,
getGlobalQwenDir: vi.fn(() => '/home/user/.qwen'),
},
getErrorMessage: vi.fn((error: unknown) => {
if (error instanceof Error) return error.message;
return String(error);
Expand Down
5 changes: 2 additions & 3 deletions packages/cli/src/ui/commands/memoryCommand.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,10 +8,9 @@ import {
getErrorMessage,
getAllGeminiMdFilenames,
loadServerHierarchicalMemory,
QWEN_DIR,
Storage,
} from '@qwen-code/qwen-code-core';
import path from 'node:path';
import os from 'node:os';
import fs from 'node:fs/promises';
import { MessageType } from '../types.js';
import type { SlashCommand, SlashCommandActionReturn } from './types.js';
Expand Down Expand Up @@ -118,7 +117,7 @@ export const memoryCommand: SlashCommand = {
},
kind: CommandKind.BUILT_IN,
action: async (context) => {
const globalDir = path.join(os.homedir(), QWEN_DIR);
const globalDir = Storage.getGlobalQwenDir();
const results = await findAllExistingMemoryFiles(globalDir);

if (results.length > 0) {
Expand Down
37 changes: 0 additions & 37 deletions packages/cli/src/ui/components/SettingsDialog.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -873,43 +873,6 @@ describe('SettingsDialog', () => {

unmount();
});

it('should keep restart prompt when switching scopes', async () => {
const settings = createMockSettings();
const onSelect = vi.fn();

const { stdin, lastFrame, unmount } = render(
<KeypressProvider kittyProtocolEnabled={false}>
<SettingsDialog settings={settings} onSelect={onSelect} />
</KeypressProvider>,
);

// Trigger a restart-required setting change: navigate to "Language: UI" (2nd item) and toggle it.
stdin.write(TerminalKeys.DOWN_ARROW as string);
await wait();
stdin.write(TerminalKeys.ENTER as string);
await wait();

await waitFor(() => {
expect(lastFrame()).toContain(
'To see changes, Qwen Code must be restarted',
);
});

// Switch scopes; restart prompt should remain visible.
stdin.write(TerminalKeys.TAB as string);
await wait();
stdin.write('2');
await wait();

await waitFor(() => {
expect(lastFrame()).toContain(
'To see changes, Qwen Code must be restarted',
);
});

unmount();
});
});

describe('Settings Display Values', () => {
Expand Down
80 changes: 80 additions & 0 deletions packages/core/src/config/storage.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -313,6 +313,86 @@ describe('Storage – config paths remain at ~/.qwen regardless of runtime dir',
});
});

describe('Storage – QWEN_CONFIG_DIR env var', () => {
const originalEnv = process.env['QWEN_CONFIG_DIR'];

afterEach(() => {
if (originalEnv !== undefined) {
process.env['QWEN_CONFIG_DIR'] = originalEnv;
} else {
delete process.env['QWEN_CONFIG_DIR'];
}
});

it('defaults to ~/.qwen when QWEN_CONFIG_DIR is not set', () => {
delete process.env['QWEN_CONFIG_DIR'];
const expected = path.join(os.homedir(), '.qwen');
expect(Storage.getGlobalQwenDir()).toBe(expected);
});

it('uses QWEN_CONFIG_DIR when set to absolute path', () => {
const configDir = path.resolve('/tmp/custom-qwen');
process.env['QWEN_CONFIG_DIR'] = configDir;
expect(Storage.getGlobalQwenDir()).toBe(configDir);
});

it('resolves relative QWEN_CONFIG_DIR to absolute path', () => {
process.env['QWEN_CONFIG_DIR'] = 'relative/config';
const expected = path.resolve('relative/config');
expect(Storage.getGlobalQwenDir()).toBe(expected);
});

it('config paths follow QWEN_CONFIG_DIR', () => {
const configDir = path.resolve('/tmp/custom-qwen');
process.env['QWEN_CONFIG_DIR'] = configDir;
expect(Storage.getGlobalSettingsPath()).toBe(
path.join(configDir, 'settings.json'),
);
expect(Storage.getInstallationIdPath()).toBe(
path.join(configDir, 'installation_id'),
);
expect(Storage.getUserCommandsDir()).toBe(path.join(configDir, 'commands'));
expect(Storage.getMcpOAuthTokensPath()).toBe(
path.join(configDir, 'mcp-oauth-tokens.json'),
);
expect(Storage.getOAuthCredsPath()).toBe(
path.join(configDir, 'oauth_creds.json'),
);
expect(Storage.getGlobalBinDir()).toBe(path.join(configDir, 'bin'));
expect(Storage.getGlobalMemoryFilePath()).toBe(
path.join(configDir, 'memory.md'),
);
});

it('project-level paths are NOT affected by QWEN_CONFIG_DIR', () => {
const configDir = path.resolve('/tmp/custom-qwen');
const projectDir = path.resolve('/tmp/project');
process.env['QWEN_CONFIG_DIR'] = configDir;
const storage = new Storage(projectDir);
expect(storage.getWorkspaceSettingsPath()).toBe(
path.join(projectDir, '.qwen', 'settings.json'),
);
expect(storage.getProjectCommandsDir()).toBe(
path.join(projectDir, '.qwen', 'commands'),
);
});

it('QWEN_CONFIG_DIR and QWEN_RUNTIME_DIR are independent', () => {
const configDir = path.resolve('/tmp/config');
const runtimeDir = path.resolve('/tmp/runtime');
process.env['QWEN_CONFIG_DIR'] = configDir;
process.env['QWEN_RUNTIME_DIR'] = runtimeDir;
expect(Storage.getGlobalQwenDir()).toBe(configDir);
expect(Storage.getRuntimeBaseDir()).toBe(runtimeDir);
expect(Storage.getGlobalSettingsPath()).toBe(
path.join(configDir, 'settings.json'),
);
expect(Storage.getGlobalTempDir()).toBe(path.join(runtimeDir, 'tmp'));
expect(Storage.getGlobalDebugDir()).toBe(path.join(runtimeDir, 'debug'));
delete process.env['QWEN_RUNTIME_DIR'];
});
});

describe('Storage – runtime base dir async context isolation', () => {
const originalEnv = process.env['QWEN_RUNTIME_DIR'];

Expand Down
8 changes: 7 additions & 1 deletion packages/core/src/config/storage.ts
Original file line number Diff line number Diff line change
Expand Up @@ -118,6 +118,10 @@ export class Storage {
}

static getGlobalQwenDir(): string {
const envDir = process.env['QWEN_CONFIG_DIR'];
if (envDir) {
return path.isAbsolute(envDir) ? envDir : path.resolve(envDir);
}
const homeDir = os.homedir();
if (!homeDir) {
return path.join(os.tmpdir(), '.qwen');
Expand Down Expand Up @@ -235,7 +239,9 @@ export class Storage {
getUserSkillsDirs(): string[] {
const homeDir = os.homedir() || os.tmpdir();
return SKILL_PROVIDER_CONFIG_DIRS.map((dir) =>
path.join(homeDir, dir, 'skills'),
dir === QWEN_DIR
? path.join(Storage.getGlobalQwenDir(), 'skills')
: path.join(homeDir, dir, 'skills'),
);
}

Expand Down
Loading
Loading