diff --git a/src/features/runtime-provider-management/main/infrastructure/AgentTeamsRuntimeProviderManagementCliClient.ts b/src/features/runtime-provider-management/main/infrastructure/AgentTeamsRuntimeProviderManagementCliClient.ts index 7fe152db..274947cb 100644 --- a/src/features/runtime-provider-management/main/infrastructure/AgentTeamsRuntimeProviderManagementCliClient.ts +++ b/src/features/runtime-provider-management/main/infrastructure/AgentTeamsRuntimeProviderManagementCliClient.ts @@ -1092,7 +1092,7 @@ export class AgentTeamsRuntimeProviderManagementCliClient implements RuntimeProv if (process.platform === 'win32' && isOpenCodeNodeModulesSymlinkError(failure.message)) { const profileId = extractProfileIdFromSymlinkError(failure.message); if (profileId) { - ensureOpenCodeProfileNodeModulesJunction(profileId); + ensureOpenCodeProfileNodeModulesJunction(profileId, failure.message); try { const retryResult = await execCli( binaryPath, @@ -1170,7 +1170,7 @@ export class AgentTeamsRuntimeProviderManagementCliClient implements RuntimeProv if (process.platform === 'win32' && isOpenCodeNodeModulesSymlinkError(failure.message)) { const profileId = extractProfileIdFromSymlinkError(failure.message); if (profileId) { - ensureOpenCodeProfileNodeModulesJunction(profileId); + ensureOpenCodeProfileNodeModulesJunction(profileId, failure.message); try { const retryResult = await execCli( binaryPath, diff --git a/src/features/runtime-provider-management/main/infrastructure/openCodeWindowsNodeModulesJunction.ts b/src/features/runtime-provider-management/main/infrastructure/openCodeWindowsNodeModulesJunction.ts index 38b37993..a41ffbc4 100644 --- a/src/features/runtime-provider-management/main/infrastructure/openCodeWindowsNodeModulesJunction.ts +++ b/src/features/runtime-provider-management/main/infrastructure/openCodeWindowsNodeModulesJunction.ts @@ -54,13 +54,45 @@ export function extractProfileIdFromSymlinkError(message: string): string | null return match ? match[1] : null; } -export function ensureOpenCodeProfileNodeModulesJunction(profileId: string): boolean { +const SYMLINK_SOURCE_PATTERN = /symlink\s+'([^']+)'/i; +const SYMLINK_TARGET_PATTERN = /->\s+'([^']+)'/i; + +export function extractSymlinkSourcePath(message: string): string | null { + const match = SYMLINK_SOURCE_PATTERN.exec(message); + return match ? match[1] : null; +} + +export function extractSymlinkTargetPath(message: string): string | null { + const match = SYMLINK_TARGET_PATTERN.exec(message); + return match ? match[1] : null; +} + +export function ensureOpenCodeProfileNodeModulesJunction( + profileId: string, + errorMessage?: string +): boolean { if (process.platform !== 'win32') { return false; } - const source = getSharedCacheNodeModulesPath(); - const target = getProfileNodeModulesPath(profileId); + let source: string; + let target: string; + + if (errorMessage) { + const extractedSource = extractSymlinkSourcePath(errorMessage); + const extractedTarget = extractSymlinkTargetPath(errorMessage); + + if (extractedTarget) { + target = extractedTarget; + source = extractedSource ?? getSharedCacheNodeModulesPath(); + } else { + target = getProfileNodeModulesPath(profileId); + source = getSharedCacheNodeModulesPath(); + } + } else { + target = getProfileNodeModulesPath(profileId); + source = getSharedCacheNodeModulesPath(); + } try { const existingStat = fs.statSync(target, { throwIfNoEntry: false }); diff --git a/test/main/features/runtime-provider-management/AgentTeamsRuntimeProviderManagementCliClient.test.ts b/test/main/features/runtime-provider-management/AgentTeamsRuntimeProviderManagementCliClient.test.ts index 9673f9f4..d9b2e1be 100644 --- a/test/main/features/runtime-provider-management/AgentTeamsRuntimeProviderManagementCliClient.test.ts +++ b/test/main/features/runtime-provider-management/AgentTeamsRuntimeProviderManagementCliClient.test.ts @@ -958,7 +958,7 @@ describe('AgentTeamsRuntimeProviderManagementCliClient', () => { const client = new AgentTeamsRuntimeProviderManagementCliClient(); const response = await client.loadView({ runtimeId: 'opencode' }); - expect(ensureOpenCodeProfileNodeModulesJunctionMock).toHaveBeenCalledWith('abc123'); + expect(ensureOpenCodeProfileNodeModulesJunctionMock).toHaveBeenCalledWith('abc123', runtimeMessage); expect(execCliMock).toHaveBeenCalledTimes(2); expect(response.error).toBeUndefined(); expect(response.view?.runtime?.state).toBe('ready'); @@ -998,7 +998,7 @@ describe('AgentTeamsRuntimeProviderManagementCliClient', () => { const client = new AgentTeamsRuntimeProviderManagementCliClient(); const response = await client.loadView({ runtimeId: 'opencode' }); - expect(ensureOpenCodeProfileNodeModulesJunctionMock).toHaveBeenCalledWith('abc123'); + expect(ensureOpenCodeProfileNodeModulesJunctionMock).toHaveBeenCalledWith('abc123', runtimeMessage); expect(execCliMock).toHaveBeenCalledTimes(2); expect(response.error?.message).toBe(runtimeMessage); } finally { @@ -1089,7 +1089,7 @@ describe('AgentTeamsRuntimeProviderManagementCliClient', () => { const client = new AgentTeamsRuntimeProviderManagementCliClient(); const response = await client.loadProviderDirectory({ runtimeId: 'opencode' }); - expect(ensureOpenCodeProfileNodeModulesJunctionMock).toHaveBeenCalledWith('def456'); + expect(ensureOpenCodeProfileNodeModulesJunctionMock).toHaveBeenCalledWith('def456', runtimeMessage); expect(execCliMock).toHaveBeenCalledTimes(2); expect(response.directory?.entries).toEqual([]); } finally { diff --git a/test/main/features/runtime-provider-management/openCodeWindowsNodeModulesJunction.test.ts b/test/main/features/runtime-provider-management/openCodeWindowsNodeModulesJunction.test.ts index 7a28f7cb..155856be 100644 --- a/test/main/features/runtime-provider-management/openCodeWindowsNodeModulesJunction.test.ts +++ b/test/main/features/runtime-provider-management/openCodeWindowsNodeModulesJunction.test.ts @@ -1,12 +1,13 @@ import fs from 'node:fs'; -import os from 'node:os'; import path from 'node:path'; -import { beforeEach, describe, expect, it, vi } from 'vitest'; +import { afterEach, describe, expect, it, vi } from 'vitest'; import { ensureOpenCodeProfileNodeModulesJunction, extractProfileIdFromSymlinkError, + extractSymlinkSourcePath, + extractSymlinkTargetPath, getProfileNodeModulesPath, getSharedCacheNodeModulesPath, isOpenCodeNodeModulesSymlinkError, @@ -75,6 +76,44 @@ describe('openCodeWindowsNodeModulesJunction', () => { }); }); + describe('extractSymlinkSourcePath', () => { + it('extracts the source path from a Windows error message', () => { + const message = + "EPERM: operation not permitted, symlink 'C:\\Users\\Swarog\\AppData\\Local\\claude-multimodel-nodejs\\Cache\\opencode\\shared-cache\\config-node_modules' -> 'C:\\Users\\Swarog\\AppData\\Local\\claude-multimodel-nodejs\\Data\\opencode\\profiles\\e8e2eadb00beea6c\\config\\opencode\\node_modules'"; + expect(extractSymlinkSourcePath(message)).toBe( + 'C:\\Users\\Swarog\\AppData\\Local\\claude-multimodel-nodejs\\Cache\\opencode\\shared-cache\\config-node_modules' + ); + }); + + it('extracts the source path from a single-quoted error', () => { + const message = + "EPERM: operation not permitted, symlink '/home/user/.cache/opencode/shared-cache/config-node_modules' -> '/home/user/.data/opencode/profiles/abc123/config/opencode/node_modules'"; + expect(extractSymlinkSourcePath(message)).toBe( + '/home/user/.cache/opencode/shared-cache/config-node_modules' + ); + }); + + it('returns null when no source path is found', () => { + const message = 'EPERM: some error without paths'; + expect(extractSymlinkSourcePath(message)).toBeNull(); + }); + }); + + describe('extractSymlinkTargetPath', () => { + it('extracts the target path from a Windows error message', () => { + const message = + "EPERM: operation not permitted, symlink 'C:\\Users\\Swarog\\AppData\\Local\\claude-multimodel-nodejs\\Cache\\opencode\\shared-cache\\config-node_modules' -> 'C:\\Users\\Swarog\\AppData\\Local\\claude-multimodel-nodejs\\Data\\opencode\\profiles\\e8e2eadb00beea6c\\config\\opencode\\node_modules'"; + expect(extractSymlinkTargetPath(message)).toBe( + 'C:\\Users\\Swarog\\AppData\\Local\\claude-multimodel-nodejs\\Data\\opencode\\profiles\\e8e2eadb00beea6c\\config\\opencode\\node_modules' + ); + }); + + it('returns null when no target path is found', () => { + const message = "EPERM: operation not permitted, symlink '/some/path'"; + expect(extractSymlinkTargetPath(message)).toBeNull(); + }); + }); + describe('getSharedCacheNodeModulesPath', () => { it('uses LOCALAPPDATA environment variable when set', () => { const originalEnv = process.env.LOCALAPPDATA; @@ -143,9 +182,11 @@ describe('openCodeWindowsNodeModulesJunction', () => { it('returns false on Windows when shared cache does not exist', () => { Object.defineProperty(process, 'platform', { value: 'win32' }); - const statSyncSpy = vi.spyOn(fs, 'statSync').mockImplementation(() => { - throw new Error('ENOENT'); - }); + const statSyncSpy = vi.spyOn(fs, 'statSync').mockImplementation( + (..._args: Parameters) => { + throw new Error('ENOENT'); + } + ); const result = ensureOpenCodeProfileNodeModulesJunction('abc123'); expect(result).toBe(false); statSyncSpy.mockRestore(); @@ -153,9 +194,11 @@ describe('openCodeWindowsNodeModulesJunction', () => { it('returns true on Windows when target node_modules already exists', () => { Object.defineProperty(process, 'platform', { value: 'win32' }); - const statSyncSpy = vi.spyOn(fs, 'statSync').mockImplementation(() => { - return {} as fs.Stats; - }); + const statSyncSpy = vi.spyOn(fs, 'statSync').mockImplementation( + (..._args: Parameters) => { + return {} as fs.Stats; + } + ); const result = ensureOpenCodeProfileNodeModulesJunction('abc123'); expect(result).toBe(true); statSyncSpy.mockRestore(); @@ -164,17 +207,17 @@ describe('openCodeWindowsNodeModulesJunction', () => { it('creates junction on Windows when shared cache exists and target is missing', () => { Object.defineProperty(process, 'platform', { value: 'win32' }); let callCount = 0; - const statSyncSpy = vi.spyOn(fs, 'statSync').mockImplementation(() => { - callCount++; - // First call: target does not exist (throw) - // Second call: source exists (return stats) - if (callCount === 1) { - const err = new Error('ENOENT') as NodeJS.ErrnoException; - err.code = 'ENOENT'; - throw err; + const statSyncSpy = vi.spyOn(fs, 'statSync').mockImplementation( + (..._args: Parameters) => { + callCount++; + if (callCount === 1) { + const err = new Error('ENOENT') as NodeJS.ErrnoException; + err.code = 'ENOENT'; + throw err; + } + return {} as fs.Stats; } - return {} as fs.Stats; - }); + ); const mkdirSyncSpy = vi.spyOn(fs, 'mkdirSync').mockImplementation(() => ''); const symlinkSyncSpy = vi.spyOn(fs, 'symlinkSync').mockImplementation(() => undefined); const result = ensureOpenCodeProfileNodeModulesJunction('abc123'); @@ -189,15 +232,17 @@ describe('openCodeWindowsNodeModulesJunction', () => { it('returns false when junction creation fails', () => { Object.defineProperty(process, 'platform', { value: 'win32' }); let callCount2 = 0; - const statSyncSpy = vi.spyOn(fs, 'statSync').mockImplementation(() => { - callCount2++; - if (callCount2 === 1) { - const err = new Error('ENOENT') as NodeJS.ErrnoException; - err.code = 'ENOENT'; - throw err; + const statSyncSpy = vi.spyOn(fs, 'statSync').mockImplementation( + (..._args: Parameters) => { + callCount2++; + if (callCount2 === 1) { + const err = new Error('ENOENT') as NodeJS.ErrnoException; + err.code = 'ENOENT'; + throw err; + } + return {} as fs.Stats; } - return {} as fs.Stats; - }); + ); const mkdirSyncSpy = vi.spyOn(fs, 'mkdirSync').mockImplementation(() => ''); const symlinkSyncSpy = vi.spyOn(fs, 'symlinkSync').mockImplementation(() => { throw new Error('EPERM');