Skip to content

Commit 3f861c1

Browse files
authored
Fix Windows PATH shim Unicode handling (#1695)
1 parent 8117bf3 commit 3f861c1

2 files changed

Lines changed: 114 additions & 18 deletions

File tree

packages/desktop/src/main/cli-shim.ts

Lines changed: 39 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,4 @@
1+
import { Buffer } from 'node:buffer'
12
import { execFile } from 'node:child_process'
23
import {
34
appendFileSync,
@@ -18,6 +19,7 @@ const SHIM_MARKER = 'HERMES_STUDIO_CLI_SHIM'
1819
const MCP_SHIM_MARKER = 'HERMES_STUDIO_MCP_SHIM'
1920
const PATH_MARKER_START = '# >>> Hermes Studio CLI shim >>>'
2021
const PATH_MARKER_END = '# <<< Hermes Studio CLI shim <<<'
22+
const WINDOWS_USER_PATH_ENV_B64 = 'HERMES_STUDIO_WINDOWS_USER_PATH_B64'
2123

2224
type ShimInstallStatus = 'installed' | 'updated' | 'unchanged' | 'skipped'
2325

@@ -338,28 +340,48 @@ function shellPathSnippet(platform: NodeJS.Platform, profilePath: string): strin
338340
].join('\n')
339341
}
340342

341-
async function ensureWindowsUserPath(binDir: string): Promise<boolean> {
342-
let currentPath = ''
343-
try {
344-
const { stdout } = await execFileAsync('reg.exe', ['query', 'HKCU\\Environment', '/v', 'Path'], {
345-
encoding: 'utf-8',
346-
timeout: 1500,
347-
windowsHide: true,
348-
})
349-
const line = stdout.split(/\r?\n/).find(row => /^\s*Path\s+REG_/.test(row))
350-
if (line) currentPath = line.replace(/^\s*Path\s+REG_\w+\s+/, '').trim()
351-
} catch {
352-
currentPath = process.env.Path || process.env.PATH || ''
353-
}
343+
function powershellArgs(command: string): string[] {
344+
return ['-NoProfile', '-NonInteractive', '-ExecutionPolicy', 'Bypass', '-Command', command]
345+
}
354346

355-
if (pathContainsDir(currentPath, binDir, 'win32')) return false
347+
async function readWindowsUserPath(): Promise<string> {
348+
const command = [
349+
"$value = [Environment]::GetEnvironmentVariable('Path', 'User')",
350+
"if ($null -ne $value -and $value.Length -gt 0) { [Console]::Out.Write([Convert]::ToBase64String([System.Text.Encoding]::UTF8.GetBytes($value))) }",
351+
].join('; ')
352+
const { stdout } = await execFileAsync('powershell.exe', powershellArgs(command), {
353+
encoding: 'utf-8',
354+
timeout: 3000,
355+
windowsHide: true,
356+
})
357+
const encoded = stdout.trim()
358+
return encoded.length > 0 ? Buffer.from(encoded, 'base64').toString('utf-8') : ''
359+
}
356360

357-
const separator = currentPath ? ';' : ''
358-
await execFileAsync('reg.exe', ['add', 'HKCU\\Environment', '/v', 'Path', '/t', 'REG_EXPAND_SZ', '/d', `${binDir}${separator}${currentPath}`, '/f'], {
361+
async function writeWindowsUserPath(pathValue: string): Promise<void> {
362+
const command = [
363+
`$bytes = [Convert]::FromBase64String($env:${WINDOWS_USER_PATH_ENV_B64})`,
364+
'$value = [System.Text.Encoding]::UTF8.GetString($bytes)',
365+
"[Environment]::SetEnvironmentVariable('Path', $value, 'User')",
366+
].join('; ')
367+
await execFileAsync('powershell.exe', powershellArgs(command), {
359368
encoding: 'utf-8',
360-
timeout: 1500,
369+
env: {
370+
...process.env,
371+
[WINDOWS_USER_PATH_ENV_B64]: Buffer.from(pathValue, 'utf-8').toString('base64'),
372+
},
373+
timeout: 3000,
361374
windowsHide: true,
362375
})
376+
}
377+
378+
async function ensureWindowsUserPath(binDir: string): Promise<boolean> {
379+
const currentPath = await readWindowsUserPath()
380+
381+
if (pathContainsDir(currentPath, binDir, 'win32')) return false
382+
383+
const separator = currentPath ? ';' : ''
384+
await writeWindowsUserPath(`${binDir}${separator}${currentPath}`)
363385
return true
364386
}
365387

tests/desktop/cli-shim.test.ts

Lines changed: 75 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
import { mkdtempSync, readFileSync, rmSync } from 'node:fs'
22
import { tmpdir } from 'node:os'
33
import { join } from 'node:path'
4-
import { afterEach, describe, expect, it } from 'vitest'
4+
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
55
import {
66
createMcpShimContent,
77
createShimContent,
@@ -10,8 +10,18 @@ import {
1010
shimPathForPlatform,
1111
} from '../../packages/desktop/src/main/cli-shim'
1212

13+
const execFileMock = vi.hoisted(() => vi.fn())
14+
15+
vi.mock('node:child_process', () => ({
16+
execFile: execFileMock,
17+
}))
18+
1319
let tempDirs: string[] = []
1420

21+
beforeEach(() => {
22+
execFileMock.mockReset()
23+
})
24+
1525
afterEach(() => {
1626
for (const dir of tempDirs) {
1727
rmSync(dir, { recursive: true, force: true })
@@ -119,4 +129,68 @@ describe('Hermes Studio CLI shim', () => {
119129
expect(readFileSync(result.shimPath, 'utf-8')).toContain("WEBUI_SCRIPT='/resources/webui/bin/hermes-web-ui.mjs'")
120130
expect(readFileSync(join(homeDir, '.zprofile'), 'utf-8')).toContain('export PATH="$HOME/bin:$PATH"')
121131
})
132+
133+
it('updates Windows user PATH through PowerShell without corrupting Unicode entries', async () => {
134+
const existingPath = 'C:\\Users\\张三\\工具;C:\\Windows\\System32'
135+
let writtenPath = ''
136+
execFileMock.mockImplementation((command, args, options, callback) => {
137+
const script = Array.isArray(args) ? args.join(' ') : ''
138+
if (command !== 'powershell.exe') {
139+
callback(new Error(`unexpected command: ${command}`))
140+
return
141+
}
142+
if (script.includes('GetEnvironmentVariable')) {
143+
callback(null, { stdout: Buffer.from(existingPath, 'utf-8').toString('base64'), stderr: '' })
144+
return
145+
}
146+
if (script.includes('SetEnvironmentVariable')) {
147+
writtenPath = Buffer.from(options.env.HERMES_STUDIO_WINDOWS_USER_PATH_B64, 'base64').toString('utf-8')
148+
callback(null, { stdout: '', stderr: '' })
149+
return
150+
}
151+
callback(new Error(`unexpected PowerShell script: ${script}`))
152+
})
153+
154+
const homeDir = tempHome()
155+
const result = await installHermesStudioCliShim({
156+
homeDir,
157+
platform: 'win32',
158+
executablePath: 'C:\\Program Files\\Hermes Studio\\Hermes Studio.exe',
159+
nodePath: 'C:\\Program Files\\Hermes Studio\\node.exe',
160+
webUiScriptPath: 'C:\\Program Files\\Hermes Studio\\resources\\webui\\bin\\hermes-web-ui.mjs',
161+
env: { Path: existingPath },
162+
})
163+
164+
expect(result.status).toBe('installed')
165+
expect(result.pathUpdated).toBe(true)
166+
expect(execFileMock).toHaveBeenCalledTimes(2)
167+
expect(execFileMock).not.toHaveBeenCalledWith('reg.exe', expect.anything(), expect.anything(), expect.anything())
168+
expect(writtenPath).toBe(`${join(homeDir, 'bin')};${existingPath}`)
169+
})
170+
171+
it('does not rewrite Windows user PATH when the shim directory is already present', async () => {
172+
const homeDir = tempHome()
173+
const existingPath = `${join(homeDir, 'bin')};C:\\Users\\张三\\工具`
174+
execFileMock.mockImplementation((command, args, _options, callback) => {
175+
const script = Array.isArray(args) ? args.join(' ') : ''
176+
if (command === 'powershell.exe' && script.includes('GetEnvironmentVariable')) {
177+
callback(null, { stdout: Buffer.from(existingPath, 'utf-8').toString('base64'), stderr: '' })
178+
return
179+
}
180+
callback(new Error(`unexpected command: ${command}`))
181+
})
182+
183+
const result = await installHermesStudioCliShim({
184+
homeDir,
185+
platform: 'win32',
186+
executablePath: 'C:\\Program Files\\Hermes Studio\\Hermes Studio.exe',
187+
nodePath: 'C:\\Program Files\\Hermes Studio\\node.exe',
188+
webUiScriptPath: 'C:\\Program Files\\Hermes Studio\\resources\\webui\\bin\\hermes-web-ui.mjs',
189+
env: { Path: existingPath },
190+
})
191+
192+
expect(result.status).toBe('installed')
193+
expect(result.pathUpdated).toBe(false)
194+
expect(execFileMock).toHaveBeenCalledTimes(1)
195+
})
122196
})

0 commit comments

Comments
 (0)