fix: keep portable desktop CLI installs in the selected config dir

Desktop portable mode depends on CLI-driven installs using the same filesystem root as the app. The installed terminal launcher now carries CLAUDE_CONFIG_DIR into macOS/Linux shell invocations and uses a Windows cmd wrapper so plugin, skill, and MCP operations do not fall back to the native home config. Moved portable bundles also rebase cached plugin install paths when the cache exists under the current config root.

Constraint: Desktop portable bundles are file-system based and must survive zip/unzip relocation.
Rejected: Copy the sidecar binary directly into user bin | it cannot inject the selected portable config directory for future CLI installs.
Rejected: Rewrite plugin install paths unconditionally | missing cache directories should remain visible as broken state instead of being silently retargeted.
Confidence: high
Scope-risk: moderate
Directive: Keep desktop launcher wrappers as env-carrying sh/cmd files; do not revert to raw sidecar copies without portable install verification.
Tested: bun test src/server/__tests__/desktop-cli-launcher.test.ts src/utils/plugins/installedPluginsManager.test.ts
Tested: cargo test portable --manifest-path desktop/src-tauri/Cargo.toml
Tested: bun run check:server
Tested: bun run check:native
Tested: macOS launcher fixture plus Computer Use Finder verification of portable marker output.
Not-tested: Windows physical desktop execution; Windows wrapper behavior is covered by unit test only.
This commit is contained in:
程序员阿江(Relakkes) 2026-05-24 15:51:58 +08:00
parent 49481123f5
commit 562523aea6
6 changed files with 295 additions and 70 deletions

View File

@ -286,11 +286,22 @@ fn dir_has_portable_data(dir: &Path) -> bool {
if !dir.is_dir() {
return false;
}
[WINDOW_STATE_FILE, TERMINAL_CONFIG_FILE]
[
"settings.json",
".claude.json",
".mcp.json",
WINDOW_STATE_FILE,
TERMINAL_CONFIG_FILE,
]
.iter()
.any(|f| dir.join(f).is_file())
|| dir.join("Cache").is_dir()
|| dir.join("EBWebView").is_dir()
|| dir.join("projects").is_dir()
|| dir.join("skills").is_dir()
|| dir.join("plugins").is_dir()
|| dir.join("cowork_plugins").is_dir()
|| dir.join("cc-haha").is_dir()
}
/// Resolve the default portable config directory: exe_dir/CLAUDE_CONFIG_DIR.
@ -1321,9 +1332,13 @@ fn resolve_terminal_cwd(cwd: Option<String>) -> Result<PathBuf, String> {
}
}) {
Some(path) => path,
None => home_dir().unwrap_or(
std::env::current_dir().map_err(|err| format!("resolve current directory: {err}"))?,
),
None => std::env::var_os("CLAUDE_CONFIG_DIR")
.map(PathBuf::from)
.or_else(home_dir)
.unwrap_or(
std::env::current_dir()
.map_err(|err| format!("resolve current directory: {err}"))?,
),
};
if path.is_dir() {
@ -1838,8 +1853,9 @@ fn kill_windows_sidecars() {
mod tests {
use super::{
decode_terminal_output, default_utf8_locale, ensure_utf8_locale,
has_meaningful_intersection, is_persistable_window_state, normalize_terminal_bash_path,
parse_env_block, resolve_desktop_terminal_shell, run_notification_bridge,
dir_has_portable_data, has_meaningful_intersection, is_persistable_window_state,
normalize_terminal_bash_path, parse_env_block, resolve_desktop_terminal_shell,
resolve_terminal_cwd, run_notification_bridge,
select_h5_dist_dir, DesktopTerminalConfig, StoredWindowState, TerminalHostPlatform,
SERVER_BIND_HOST, SERVER_CONTROL_HOST,
};
@ -2017,6 +2033,42 @@ mod tests {
assert_eq!(env.get("LC_ALL").map(String::as_str), Some("C.UTF-8"));
}
#[test]
fn terminal_cwd_defaults_to_portable_config_dir_when_present() {
let original = std::env::var_os("CLAUDE_CONFIG_DIR");
let dir = std::env::temp_dir().join(format!(
"cchh-terminal-portable-cwd-{}",
std::process::id()
));
fs::create_dir_all(&dir).expect("create portable config dir");
std::env::set_var("CLAUDE_CONFIG_DIR", &dir);
let cwd = resolve_terminal_cwd(None).expect("portable cwd should resolve");
assert_eq!(cwd, dir);
if let Some(value) = original {
std::env::set_var("CLAUDE_CONFIG_DIR", value);
} else {
std::env::remove_var("CLAUDE_CONFIG_DIR");
}
fs::remove_dir_all(cwd).expect("remove portable config dir");
}
#[test]
fn portable_data_detection_includes_cli_state_dirs() {
let root = std::env::temp_dir().join(format!(
"cchh-portable-data-detect-{}",
std::process::id()
));
let skills = root.join("skills");
fs::create_dir_all(&skills).expect("create skills dir");
assert!(dir_has_portable_data(&root));
fs::remove_dir_all(root).expect("remove portable data fixture");
}
#[test]
fn desktop_terminal_shell_resolution_keeps_system_default_without_preference() {
assert_eq!(

View File

@ -71,6 +71,9 @@ fn determine_startup_portable_dir() -> Option<PathBuf> {
// 2. 优先检查便携目录本地的配置文件(保证移动便携版到新电脑依然生效,并能正确处理切回默认模式)
if let Some((mode, portable_dir)) = get_mode_from_config(&default_portable) {
if mode == "portable" {
if dir_has_portable_data(&default_portable) {
return Some(default_portable.clone());
}
return Some(portable_dir.unwrap_or(default_portable.clone()));
} else {
return None; // 明确设置了 default直接使用系统默认
@ -111,11 +114,22 @@ fn determine_startup_portable_dir() -> Option<PathBuf> {
if !dir.is_dir() {
return false;
}
["window-state.json", "terminal-config.json"]
[
"settings.json",
".claude.json",
".mcp.json",
"window-state.json",
"terminal-config.json",
]
.iter()
.any(|f| dir.join(f).is_file())
|| dir.join("Cache").is_dir()
|| dir.join("EBWebView").is_dir()
|| dir.join("projects").is_dir()
|| dir.join("skills").is_dir()
|| dir.join("plugins").is_dir()
|| dir.join("cowork_plugins").is_dir()
|| dir.join("cc-haha").is_dir()
}
if dir_has_portable_data(&default_portable) {

View File

@ -3,7 +3,11 @@ import { chmod, mkdtemp, readFile, rm, writeFile } from 'node:fs/promises'
import { join } from 'node:path'
import { tmpdir } from 'node:os'
import { ensureDesktopCliLauncherInstalled } from '../services/desktopCliLauncherService.js'
import {
buildWindowsLauncherWrapper,
ensureDesktopCliLauncherInstalled,
getDesktopCliCommandName,
} from '../services/desktopCliLauncherService.js'
const isWindows = process.platform === 'win32'
const unixOnly = isWindows ? it.skip : it
@ -13,6 +17,7 @@ const ORIGINAL_USERPROFILE = process.env.USERPROFILE
const ORIGINAL_SHELL = process.env.SHELL
const ORIGINAL_PATH = process.env.PATH
const ORIGINAL_CLAUDE_CLI_PATH = process.env.CLAUDE_CLI_PATH
const ORIGINAL_CLAUDE_CONFIG_DIR = process.env.CLAUDE_CONFIG_DIR
describe('ensureDesktopCliLauncherInstalled', () => {
let tempHome = ''
@ -25,6 +30,7 @@ describe('ensureDesktopCliLauncherInstalled', () => {
process.env.USERPROFILE = tempHome
process.env.SHELL = '/bin/zsh'
process.env.PATH = ''
delete process.env.CLAUDE_CONFIG_DIR
})
afterEach(async () => {
@ -58,6 +64,12 @@ describe('ensureDesktopCliLauncherInstalled', () => {
process.env.CLAUDE_CLI_PATH = ORIGINAL_CLAUDE_CLI_PATH
}
if (ORIGINAL_CLAUDE_CONFIG_DIR === undefined) {
delete process.env.CLAUDE_CONFIG_DIR
} else {
process.env.CLAUDE_CONFIG_DIR = ORIGINAL_CLAUDE_CONFIG_DIR
}
await rm(tempHome, { recursive: true, force: true })
await rm(tempSourceDir, { recursive: true, force: true })
})
@ -89,6 +101,32 @@ describe('ensureDesktopCliLauncherInstalled', () => {
)
})
unixOnly('pins portable config dir in the installed launcher wrapper', async () => {
const sourcePath = join(tempSourceDir, 'claude-sidecar')
const portableDir = join(tempHome, 'portable-config')
await writeFile(sourcePath, '#!/bin/sh\necho desktop-sidecar\n', 'utf8')
await chmod(sourcePath, 0o755)
process.env.CLAUDE_CLI_PATH = sourcePath
process.env.CLAUDE_CONFIG_DIR = portableDir
await ensureDesktopCliLauncherInstalled()
const launcher = await readFile(join(tempHome, '.local', 'bin', 'claude-haha'), 'utf8')
expect(launcher).toContain(`export CLAUDE_CONFIG_DIR='${portableDir}'`)
})
it('uses a Windows cmd launcher so portable env can be injected', () => {
expect(getDesktopCliCommandName('win32')).toBe('claude-haha.cmd')
process.env.CLAUDE_CONFIG_DIR = 'C:\\Portable\\ClaudeConfig'
const wrapper = buildWindowsLauncherWrapper('C:\\Apps\\cc-haha\\claude-sidecar.exe')
expect(wrapper).toContain('set "CLAUDE_CONFIG_DIR=C:\\Portable\\ClaudeConfig"')
expect(wrapper).toContain(
'"%SIDECAR%" cli --app-root "%APP_ROOT%" %*',
)
})
it('reports unsupported status when the current launcher is not a bundled sidecar', async () => {
const sourcePath = join(tempSourceDir, 'claude')
await writeFile(sourcePath, '#!/bin/sh\necho plain-cli\n', 'utf8')

View File

@ -1,8 +1,5 @@
import { createHash } from 'node:crypto'
import { createReadStream } from 'node:fs'
import {
chmod,
copyFile,
mkdir,
readFile,
rename,
@ -19,6 +16,7 @@ import { getShellConfigPaths } from '../../utils/shellConfig.js'
import { getUserBinDir } from '../../utils/xdg.js'
const DESKTOP_CLI_NAME = 'claude-haha'
const DESKTOP_CLI_WINDOWS_LEGACY_EXE = `${DESKTOP_CLI_NAME}.exe`
const PATH_BLOCK_START = '# >>> Claude Code Haha PATH >>>'
const PATH_BLOCK_END = '# <<< Claude Code Haha PATH <<<'
const WINDOWS_PATH_TARGET = 'Windows User PATH'
@ -43,7 +41,7 @@ let inFlightEnsure: Promise<DesktopCliLauncherStatus> | null = null
export function getDesktopCliCommandName(
platform: NodeJS.Platform = process.platform,
) {
return platform === 'win32' ? `${DESKTOP_CLI_NAME}.exe` : DESKTOP_CLI_NAME
return platform === 'win32' ? `${DESKTOP_CLI_NAME}.cmd` : DESKTOP_CLI_NAME
}
export function resolveHomeDir(env: NodeJS.ProcessEnv = process.env) {
@ -244,27 +242,8 @@ async function syncLauncher(sourcePath: string, targetPath: string) {
return
}
if (await filesMatch(sourcePath, targetPath)) {
return
}
const tempPath = `${targetPath}.tmp.${process.pid}.${Date.now()}`
await copyFile(sourcePath, tempPath)
if (process.platform !== 'win32') {
await chmod(tempPath, 0o755)
}
try {
if (process.platform === 'win32') {
await replaceWindowsBinary(tempPath, targetPath)
} else {
await rename(tempPath, targetPath)
await chmod(targetPath, 0o755)
}
} finally {
await unlink(tempPath).catch(() => undefined)
}
await syncWindowsLauncherWrapper(sourcePath, targetPath)
await removeLegacyWindowsBinaryLauncher(targetPath)
}
async function syncUnixLauncherWrapper(sourcePath: string, targetPath: string) {
@ -290,12 +269,17 @@ async function syncUnixLauncherWrapper(sourcePath: string, targetPath: string) {
function buildUnixLauncherWrapper(sourcePath: string) {
const quotedSource = shellSingleQuote(sourcePath)
const quotedAppRoot = shellSingleQuote(dirname(sourcePath))
const portableConfigDir = process.env.CLAUDE_CONFIG_DIR
const configExport = portableConfigDir
? `export CLAUDE_CONFIG_DIR=${shellSingleQuote(portableConfigDir)}\n`
: ''
return `#!/usr/bin/env bash
set -euo pipefail
SIDECAR=${quotedSource}
APP_ROOT=${quotedAppRoot}
${configExport}
if [[ ! -x "$SIDECAR" ]]; then
echo "claude-haha launcher could not find bundled sidecar: $SIDECAR" >&2
@ -314,11 +298,52 @@ exec "$SIDECAR" cli --app-root "$APP_ROOT" "$@"
`
}
async function syncWindowsLauncherWrapper(sourcePath: string, targetPath: string) {
const wrapper = buildWindowsLauncherWrapper(sourcePath)
const existing = await readFile(targetPath, 'utf8').catch(() => null)
if (existing === wrapper) {
return
}
const tempPath = `${targetPath}.tmp.${process.pid}.${Date.now()}`
await writeFile(tempPath, wrapper, { encoding: 'utf8' })
try {
await replaceFile(tempPath, targetPath)
} finally {
await unlink(tempPath).catch(() => undefined)
}
}
export function buildWindowsLauncherWrapper(sourcePath: string) {
const appRoot = dirname(sourcePath)
const portableConfigDir = process.env.CLAUDE_CONFIG_DIR
const configLine = portableConfigDir
? `set "CLAUDE_CONFIG_DIR=${portableConfigDir}"\r\n`
: ''
return [
'@echo off',
'setlocal',
`set "SIDECAR=${sourcePath}"`,
`set "APP_ROOT=${appRoot}"`,
configLine.trimEnd(),
'if not exist "%SIDECAR%" (',
' echo claude-haha launcher could not find bundled sidecar: %SIDECAR% 1>&2',
' exit /b 127',
')',
'"%SIDECAR%" cli --app-root "%APP_ROOT%" %*',
'exit /b %ERRORLEVEL%',
'',
].filter((line) => line.length > 0).join('\r\n')
}
function shellSingleQuote(value: string) {
return `'${value.replace(/'/g, `'\\''`)}'`
}
async function replaceWindowsBinary(tempPath: string, targetPath: string) {
async function replaceFile(tempPath: string, targetPath: string) {
try {
await unlink(targetPath)
} catch {
@ -343,39 +368,34 @@ async function replaceWindowsBinary(tempPath: string, targetPath: string) {
await unlink(backupPath).catch(() => undefined)
}
async function filesMatch(sourcePath: string, targetPath: string) {
async function removeLegacyWindowsBinaryLauncher(targetPath: string) {
const legacyPath = join(dirname(targetPath), DESKTOP_CLI_WINDOWS_LEGACY_EXE)
if (legacyPath === targetPath) return
try {
const [sourceStats, targetStats] = await Promise.all([
stat(sourcePath),
stat(targetPath),
])
if (!sourceStats.isFile() || !targetStats.isFile()) {
return false
}
if (sourceStats.size !== targetStats.size) {
return false
}
const [sourceHash, targetHash] = await Promise.all([
hashFile(sourcePath),
hashFile(targetPath),
])
return sourceHash === targetHash
const legacyStats = await stat(legacyPath)
if (!legacyStats.isFile()) return
} catch {
return false
return
}
}
async function hashFile(filePath: string) {
return await new Promise<string>((resolvePromise, reject) => {
const hash = createHash('sha256')
const stream = createReadStream(filePath)
stream.on('data', (chunk) => hash.update(chunk))
stream.on('error', reject)
stream.on('end', () => resolvePromise(hash.digest('hex')))
})
try {
await unlink(legacyPath)
return
} catch {
// Retry below by renaming the stale executable out of PATHEXT lookup.
}
const backupPath = `${legacyPath}.old.${Date.now()}`
try {
await rename(legacyPath, backupPath)
} catch (error) {
throw new Error(
`failed to remove legacy Windows launcher ${legacyPath}: ${
error instanceof Error ? error.message : String(error)
}`,
)
}
}
async function isUsableLauncher(filePath: string) {

View File

@ -2,7 +2,11 @@ import { afterEach, beforeEach, describe, expect, test } from 'bun:test'
import * as fs from 'node:fs/promises'
import * as os from 'node:os'
import * as path from 'node:path'
import { deletePluginCache } from './installedPluginsManager.js'
import {
clearInstalledPluginsCache,
deletePluginCache,
loadInstalledPluginsV2,
} from './installedPluginsManager.js'
describe('deletePluginCache', () => {
let tempDir: string
@ -29,6 +33,7 @@ describe('deletePluginCache', () => {
process.env.CLAUDE_CODE_PLUGIN_CACHE_DIR = originalPluginCacheDir
}
await fs.rm(tempDir, { recursive: true, force: true })
clearInstalledPluginsCache()
})
test('refuses to delete paths outside the managed plugin cache', async () => {
@ -63,4 +68,62 @@ describe('deletePluginCache', () => {
await expect(fs.stat(versionDir)).rejects.toThrow()
await expect(fs.stat(path.join(tempDir, '.claude'))).resolves.toBeDefined()
})
test('rebases installed plugin paths when a portable config directory moves', async () => {
const oldConfigDir = path.join(tempDir, 'old-config')
const newConfigDir = path.join(tempDir, 'new-config')
const pluginId = 'portable-proof-plugin@portable-proof-market'
const oldInstallPath = path.join(
oldConfigDir,
'plugins',
'cache',
'portable-proof-market',
'portable-proof-plugin',
'1.0.0',
)
const newInstallPath = path.join(
newConfigDir,
'plugins',
'cache',
'portable-proof-market',
'portable-proof-plugin',
'1.0.0',
)
const installedPluginsPath = path.join(
newConfigDir,
'plugins',
'installed_plugins.json',
)
await fs.mkdir(newInstallPath, { recursive: true })
await fs.writeFile(path.join(newInstallPath, 'sentinel.txt'), 'ok', 'utf-8')
await fs.mkdir(path.dirname(installedPluginsPath), { recursive: true })
await fs.writeFile(
installedPluginsPath,
JSON.stringify({
version: 2,
plugins: {
[pluginId]: [
{
scope: 'user',
installPath: oldInstallPath,
version: '1.0.0',
installedAt: '2026-05-24T00:00:00.000Z',
lastUpdated: '2026-05-24T00:00:00.000Z',
},
],
},
}, null, 2),
'utf-8',
)
process.env.CLAUDE_CONFIG_DIR = newConfigDir
clearInstalledPluginsCache()
const loaded = loadInstalledPluginsV2()
expect(loaded.plugins[pluginId]?.[0]?.installPath).toBe(newInstallPath)
const healed = JSON.parse(await fs.readFile(installedPluginsPath, 'utf-8'))
expect(healed.plugins[pluginId][0].installPath).toBe(newInstallPath)
})
})

View File

@ -304,6 +304,35 @@ function migrateV1ToV2(v1Data: InstalledPluginsFileV1): InstalledPluginsFileV2 {
return { version: 2, plugins: v2Plugins }
}
function normalizePortableInstallPaths(
data: InstalledPluginsFileV2,
): { data: InstalledPluginsFileV2; changed: boolean } {
const fs = getFsImplementation()
let changed = false
const plugins: InstalledPluginsMapV2 = {}
for (const [pluginId, entries] of Object.entries(data.plugins)) {
plugins[pluginId] = entries.map(entry => {
const expectedPath = getVersionedCachePath(pluginId, entry.version)
if (resolve(entry.installPath) === resolve(expectedPath)) {
return entry
}
if (!fs.existsSync(expectedPath)) {
return entry
}
changed = true
return {
...entry,
installPath: expectedPath,
}
})
}
return changed ? { data: { ...data, plugins }, changed } : { data, changed }
}
/**
* Load installed plugins in V2 format.
*
@ -327,16 +356,23 @@ export function loadInstalledPluginsV2(): InstalledPluginsFileV2 {
if (rawData.version === 2) {
// V2 format - validate and return
const validated = InstalledPluginsFileSchemaV2().parse(rawData.data)
installedPluginsCacheV2 = validated
const normalized = normalizePortableInstallPaths(validated)
if (normalized.changed) {
saveInstalledPluginsV2(normalized.data)
logForDebugging(
`Rebased installed plugin cache paths under ${getPluginsDirectory()}`,
)
}
installedPluginsCacheV2 = normalized.data
logForDebugging(
`Loaded ${Object.keys(validated.plugins).length} installed plugins from ${filePath}`,
`Loaded ${Object.keys(normalized.data.plugins).length} installed plugins from ${filePath}`,
)
return validated
return normalized.data
}
// V1 format - convert to V2
const v1Validated = InstalledPluginsFileSchemaV1().parse(rawData.data)
const v2Data = migrateV1ToV2(v1Validated)
const v2Data = normalizePortableInstallPaths(migrateV1ToV2(v1Validated)).data
installedPluginsCacheV2 = v2Data
logForDebugging(
`Loaded and converted ${Object.keys(v1Validated.plugins).length} plugins from V1 format`,
@ -506,11 +542,13 @@ export function loadInstalledPluginsFromDisk(): InstalledPluginsFileV2 {
if (rawData) {
if (rawData.version === 2) {
return InstalledPluginsFileSchemaV2().parse(rawData.data)
return normalizePortableInstallPaths(
InstalledPluginsFileSchemaV2().parse(rawData.data),
).data
}
// V1 format - convert to V2
const v1Data = InstalledPluginsFileSchemaV1().parse(rawData.data)
return migrateV1ToV2(v1Data)
return normalizePortableInstallPaths(migrateV1ToV2(v1Data)).data
}
return { version: 2, plugins: {} }