mirror of
https://github.com/NanmiCoder/claude-code-haha.git
synced 2026-10-10 20:03:13 +08:00
44137ccb91
The NSIS installer spawned by quitAndInstall() inherits the app process environment, including the app-managed CLAUDE_CONFIG_DIR / CC_HAHA_APP_PORTABLE_DIR pair that applyStartupPortableMode() derives from app-mode.json. The installer's recovery helper then re-validated that snapshot against the persisted mode it reads via its own APPDATA and blocked the whole upgrade on any disagreement (mode switched without a restart, APPDATA differing from the app's known-folder view), even though an active data directory outside every install directory cannot be touched by removing the old version. Manually launched setups never saw the variables, which is why they kept working. - clear the app-managed portable env before handing off to the spawned installer (shared with the existing app.relaunch() cleanup), so built-in updates present the same clean environment as a manual setup - downgrade the recovery helper's managed-mode consistency check from fail-closed to treating the directory as externally managed; the install-contained legacy data guard below it still refuses unsafe removals, and matching persisted modes behave exactly as before
292 lines
11 KiB
TypeScript
292 lines
11 KiB
TypeScript
import fs from 'node:fs'
|
|
import os from 'node:os'
|
|
import path from 'node:path'
|
|
import { afterEach, describe, expect, it, vi } from 'vitest'
|
|
import {
|
|
applyStartupPortableMode,
|
|
clearAppManagedPortableEnv,
|
|
determineStartupPortableDir,
|
|
getAppMode,
|
|
setAppMode,
|
|
systemClaudeConfigDir,
|
|
type AppModeAppLike,
|
|
} from './appMode'
|
|
|
|
const tempDirs: string[] = []
|
|
|
|
function tempDir() {
|
|
const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'cc-haha-app-mode-'))
|
|
tempDirs.push(dir)
|
|
return dir
|
|
}
|
|
|
|
function app(root = tempDir()): AppModeAppLike & { root: string } {
|
|
const exe = path.join(root, 'install', 'Claude Code Haha')
|
|
const home = path.join(root, 'home')
|
|
const userData = path.join(root, 'user-data')
|
|
fs.mkdirSync(path.dirname(exe), { recursive: true })
|
|
fs.writeFileSync(exe, '')
|
|
return {
|
|
root,
|
|
getPath(name) {
|
|
if (name === 'exe') return exe
|
|
if (name === 'home') return home
|
|
return userData
|
|
},
|
|
}
|
|
}
|
|
|
|
function writeMode(fakeApp: AppModeAppLike, value: unknown) {
|
|
const userData = fakeApp.getPath('userData')
|
|
fs.mkdirSync(userData, { recursive: true })
|
|
fs.writeFileSync(path.join(userData, 'app-mode.json'), JSON.stringify(value))
|
|
}
|
|
|
|
afterEach(() => {
|
|
vi.restoreAllMocks()
|
|
for (const dir of tempDirs.splice(0)) {
|
|
fs.rmSync(dir, { recursive: true, force: true })
|
|
}
|
|
})
|
|
|
|
describe('Electron app mode service', () => {
|
|
it('always uses ~/.claude in system mode and ignores app-adjacent legacy data at runtime', () => {
|
|
const fakeApp = app()
|
|
const legacyDir = path.join(path.dirname(fakeApp.getPath('exe')), 'CLAUDE_CONFIG_DIR')
|
|
fs.mkdirSync(legacyDir, { recursive: true })
|
|
fs.writeFileSync(path.join(legacyDir, 'settings.json'), '{"legacy":true}')
|
|
|
|
expect(systemClaudeConfigDir(fakeApp)).toBe(path.join(fakeApp.root, 'home', '.claude'))
|
|
expect(determineStartupPortableDir(fakeApp, {})).toBeNull()
|
|
expect(applyStartupPortableMode(fakeApp, {})).toBeNull()
|
|
expect(getAppMode(fakeApp, {})).toEqual({
|
|
mode: 'default',
|
|
portableDir: null,
|
|
activeConfigDir: path.join(fakeApp.root, 'home', '.claude'),
|
|
configDirSource: 'system',
|
|
})
|
|
})
|
|
|
|
it('activates only an explicit absolute custom directory persisted in userData', () => {
|
|
const fakeApp = app()
|
|
const customDir = path.join(fakeApp.root, 'custom-data')
|
|
writeMode(fakeApp, { mode: 'portable', portable_dir: customDir })
|
|
const env: NodeJS.ProcessEnv = {}
|
|
|
|
expect(determineStartupPortableDir(fakeApp, env)).toBe(customDir)
|
|
expect(applyStartupPortableMode(fakeApp, env)).toBe(customDir)
|
|
expect(env).toMatchObject({
|
|
CLAUDE_CONFIG_DIR: customDir,
|
|
CC_HAHA_APP_PORTABLE_DIR: '1',
|
|
WEBVIEW2_USER_DATA_FOLDER: path.join(customDir, 'EBWebView'),
|
|
})
|
|
expect(getAppMode(fakeApp, env)).toEqual({
|
|
mode: 'portable',
|
|
portableDir: customDir,
|
|
activeConfigDir: customDir,
|
|
configDirSource: 'portable',
|
|
})
|
|
})
|
|
|
|
it('treats an externally supplied CLAUDE_CONFIG_DIR as a read-only override', () => {
|
|
const fakeApp = app()
|
|
const externalDir = path.join(fakeApp.root, 'external-data')
|
|
const env: NodeJS.ProcessEnv = { CLAUDE_CONFIG_DIR: externalDir }
|
|
|
|
expect(determineStartupPortableDir(fakeApp, env)).toBeNull()
|
|
expect(applyStartupPortableMode(fakeApp, env)).toBeNull()
|
|
expect(env).toEqual({ CLAUDE_CONFIG_DIR: externalDir })
|
|
expect(getAppMode(fakeApp, env)).toEqual({
|
|
mode: 'portable',
|
|
portableDir: externalDir,
|
|
activeConfigDir: externalDir,
|
|
configDirSource: 'environment',
|
|
})
|
|
expect(() => setAppMode(fakeApp, { mode: 'default', portableDir: null }, env))
|
|
.toThrow('CLAUDE_CONFIG_DIR is controlled by the launch environment')
|
|
})
|
|
|
|
it('rejects relative or install-contained external custom directories', () => {
|
|
const fakeApp = app()
|
|
const installData = path.join(path.dirname(fakeApp.getPath('exe')), 'external-data')
|
|
|
|
expect(() => applyStartupPortableMode(fakeApp, {
|
|
CLAUDE_CONFIG_DIR: 'relative-data',
|
|
})).toThrow('absolute path')
|
|
expect(() => getAppMode(fakeApp, {
|
|
CLAUDE_CONFIG_DIR: 'relative-data',
|
|
})).toThrow('absolute path')
|
|
expect(() => applyStartupPortableMode(fakeApp, {
|
|
CLAUDE_CONFIG_DIR: installData,
|
|
})).toThrow('outside the application install directory')
|
|
})
|
|
|
|
it('clears only an app-managed portable selection from a handed-off environment', () => {
|
|
const customDir = path.join(tempDir(), 'custom-data')
|
|
const managedEnv: NodeJS.ProcessEnv = {
|
|
CLAUDE_CONFIG_DIR: customDir,
|
|
CC_HAHA_APP_PORTABLE_DIR: '1',
|
|
WEBVIEW2_USER_DATA_FOLDER: path.join(customDir, 'EBWebView'),
|
|
APPDATA: 'C:\\Users\\someone\\AppData\\Roaming',
|
|
}
|
|
clearAppManagedPortableEnv(managedEnv)
|
|
expect(managedEnv).toEqual({ APPDATA: 'C:\\Users\\someone\\AppData\\Roaming' })
|
|
|
|
const externalEnv: NodeJS.ProcessEnv = { CLAUDE_CONFIG_DIR: customDir }
|
|
clearAppManagedPortableEnv(externalEnv)
|
|
expect(externalEnv).toEqual({ CLAUDE_CONFIG_DIR: customDir })
|
|
})
|
|
|
|
it('drops inherited app-managed env so switching back to ~/.claude survives relaunch', () => {
|
|
const fakeApp = app()
|
|
writeMode(fakeApp, { mode: 'default', portable_dir: null })
|
|
const oldCustomDir = path.join(fakeApp.root, 'old-custom')
|
|
const env: NodeJS.ProcessEnv = {
|
|
CLAUDE_CONFIG_DIR: oldCustomDir,
|
|
CC_HAHA_APP_PORTABLE_DIR: '1',
|
|
WEBVIEW2_USER_DATA_FOLDER: path.join(oldCustomDir, 'EBWebView'),
|
|
}
|
|
|
|
expect(applyStartupPortableMode(fakeApp, env)).toBeNull()
|
|
expect(env.CLAUDE_CONFIG_DIR).toBeUndefined()
|
|
expect(env.CC_HAHA_APP_PORTABLE_DIR).toBeUndefined()
|
|
expect(env.WEBVIEW2_USER_DATA_FOLDER).toBeUndefined()
|
|
expect(getAppMode(fakeApp, env)).toMatchObject({
|
|
mode: 'default',
|
|
activeConfigDir: systemClaudeConfigDir(fakeApp),
|
|
})
|
|
})
|
|
|
|
it('replaces an inherited app-managed env with the newly persisted custom directory', () => {
|
|
const fakeApp = app()
|
|
const newCustomDir = path.join(fakeApp.root, 'new-custom')
|
|
writeMode(fakeApp, { mode: 'portable', portable_dir: newCustomDir })
|
|
const env: NodeJS.ProcessEnv = {
|
|
CLAUDE_CONFIG_DIR: path.join(fakeApp.root, 'old-custom'),
|
|
CC_HAHA_APP_PORTABLE_DIR: '1',
|
|
WEBVIEW2_USER_DATA_FOLDER: path.join(fakeApp.root, 'old-custom', 'EBWebView'),
|
|
}
|
|
|
|
expect(applyStartupPortableMode(fakeApp, env)).toBe(newCustomDir)
|
|
expect(env.CLAUDE_CONFIG_DIR).toBe(newCustomDir)
|
|
expect(env.WEBVIEW2_USER_DATA_FOLDER).toBe(path.join(newCustomDir, 'EBWebView'))
|
|
})
|
|
|
|
it.each([
|
|
{ mode: 'portable', portable_dir: null },
|
|
{ mode: 'portable', portable_dir: '' },
|
|
{ mode: 'portable', portable_dir: 'relative-data' },
|
|
{ mode: 'unknown', portable_dir: '/tmp/custom' },
|
|
])('falls back to system mode for invalid custom metadata: %o', value => {
|
|
const fakeApp = app()
|
|
writeMode(fakeApp, value)
|
|
|
|
expect(determineStartupPortableDir(fakeApp, {})).toBeNull()
|
|
expect(getAppMode(fakeApp, {})).toMatchObject({
|
|
mode: 'default',
|
|
portableDir: null,
|
|
activeConfigDir: systemClaudeConfigDir(fakeApp),
|
|
configDirSource: 'system',
|
|
})
|
|
})
|
|
|
|
it('persists one atomic system-owned mode record for a custom directory', () => {
|
|
const fakeApp = app()
|
|
const customDir = path.join(fakeApp.root, 'custom-data')
|
|
const previousActive = path.join(fakeApp.root, 'previous-custom')
|
|
|
|
setAppMode(fakeApp, { mode: 'portable', portableDir: customDir }, {
|
|
CLAUDE_CONFIG_DIR: previousActive,
|
|
CC_HAHA_APP_PORTABLE_DIR: '1',
|
|
})
|
|
|
|
expect(JSON.parse(fs.readFileSync(path.join(fakeApp.getPath('userData'), 'app-mode.json'), 'utf8'))).toEqual({
|
|
mode: 'portable',
|
|
portable_dir: customDir,
|
|
})
|
|
expect(fs.existsSync(path.join(customDir, 'app-mode.json'))).toBe(false)
|
|
expect(fs.existsSync(path.join(previousActive, 'app-mode.json'))).toBe(false)
|
|
expect(fs.readdirSync(fakeApp.getPath('userData'))).toEqual(['app-mode.json'])
|
|
})
|
|
|
|
it('switches back to system mode without touching the custom directory', () => {
|
|
const fakeApp = app()
|
|
const customDir = path.join(fakeApp.root, 'custom-data')
|
|
fs.mkdirSync(customDir, { recursive: true })
|
|
fs.writeFileSync(path.join(customDir, 'settings.json'), '{"keep":true}')
|
|
writeMode(fakeApp, { mode: 'portable', portable_dir: customDir })
|
|
|
|
setAppMode(fakeApp, { mode: 'default', portableDir: null }, {
|
|
CLAUDE_CONFIG_DIR: customDir,
|
|
CC_HAHA_APP_PORTABLE_DIR: '1',
|
|
})
|
|
|
|
expect(JSON.parse(fs.readFileSync(path.join(fakeApp.getPath('userData'), 'app-mode.json'), 'utf8'))).toEqual({
|
|
mode: 'default',
|
|
portable_dir: null,
|
|
})
|
|
expect(fs.readFileSync(path.join(customDir, 'settings.json'), 'utf8')).toBe('{"keep":true}')
|
|
})
|
|
|
|
it.each([
|
|
{ label: 'missing', value: null },
|
|
{ label: 'empty', value: ' ' },
|
|
{ label: 'relative', value: 'relative-data' },
|
|
])('rejects a $label custom directory', ({ value }) => {
|
|
const fakeApp = app()
|
|
|
|
expect(() => setAppMode(fakeApp, { mode: 'portable', portableDir: value }, {})).toThrow()
|
|
expect(fs.existsSync(path.join(fakeApp.getPath('userData'), 'app-mode.json'))).toBe(false)
|
|
})
|
|
|
|
it('rejects custom directories inside the application install tree, including symlink aliases', () => {
|
|
const fakeApp = app()
|
|
const installDir = path.dirname(fakeApp.getPath('exe'))
|
|
const aliasedInstallDir = path.join(fakeApp.root, 'install-alias')
|
|
fs.symlinkSync(installDir, aliasedInstallDir, 'dir')
|
|
|
|
expect(() => setAppMode(fakeApp, {
|
|
mode: 'portable',
|
|
portableDir: path.join(installDir, 'data'),
|
|
}, {})).toThrow('outside the application install directory')
|
|
expect(() => setAppMode(fakeApp, {
|
|
mode: 'portable',
|
|
portableDir: path.join(aliasedInstallDir, 'data'),
|
|
}, {})).toThrow('outside the application install directory')
|
|
})
|
|
|
|
it('does not partially mutate process.env when custom startup preparation fails', () => {
|
|
const fakeApp = app()
|
|
const customDir = path.join(fakeApp.root, 'custom-data')
|
|
writeMode(fakeApp, { mode: 'portable', portable_dir: customDir })
|
|
const env: NodeJS.ProcessEnv = {}
|
|
vi.spyOn(fs, 'mkdirSync').mockImplementation(() => {
|
|
throw new Error('mkdir failed')
|
|
})
|
|
|
|
expect(() => applyStartupPortableMode(fakeApp, env)).toThrow('mkdir failed')
|
|
expect(env.CLAUDE_CONFIG_DIR).toBeUndefined()
|
|
expect(env.CC_HAHA_APP_PORTABLE_DIR).toBeUndefined()
|
|
expect(env.WEBVIEW2_USER_DATA_FOLDER).toBeUndefined()
|
|
})
|
|
|
|
it('keeps the previous mode record if the atomic replacement fails', () => {
|
|
const fakeApp = app()
|
|
const modeFile = path.join(fakeApp.getPath('userData'), 'app-mode.json')
|
|
writeMode(fakeApp, { mode: 'default', portable_dir: null })
|
|
vi.spyOn(fs, 'renameSync').mockImplementation(() => {
|
|
throw new Error('rename failed')
|
|
})
|
|
|
|
expect(() => setAppMode(fakeApp, {
|
|
mode: 'portable',
|
|
portableDir: path.join(fakeApp.root, 'custom-data'),
|
|
}, {})).toThrow('rename failed')
|
|
expect(JSON.parse(fs.readFileSync(modeFile, 'utf8'))).toEqual({
|
|
mode: 'default',
|
|
portable_dir: null,
|
|
})
|
|
expect(fs.readdirSync(fakeApp.getPath('userData'))).toEqual(['app-mode.json'])
|
|
})
|
|
})
|