From 597c690dbca5ff3445e5da28b5573bb7bf609823 Mon Sep 17 00:00:00 2001 From: ComradeSwarog Date: Thu, 28 May 2026 02:46:04 +0300 Subject: [PATCH] fix(opencode): add Windows junction fallback for node_modules EPERM symlink error (#187) On Windows 10 without Developer Mode, the OpenCode runtime fails to create a symlink from shared-cache/config-node_modules to the profile's node_modules directory. The EPERM error blocks the entire OpenCode provider catalog, leaving it unavailable. Changes: - New openCodeWindowsNodeModulesJunction module that pre-creates a Windows directory junction (no Developer Mode required) before the runtime call when an EPERM symlink error is detected - On Windows, loadView and loadProviderDirectory now detect EPERM symlink errors, extract the profile ID, create the junction, and retry the runtime command once before falling back to the error response - Updated diagnostic hints to accurately reflect that the runtime does not yet include junction fallback, and that the next runtime update will include it - Added unit tests for the junction module and retry behavior --- ...TeamsRuntimeProviderManagementCliClient.ts | 101 +++++++- .../openCodeWindowsNodeModulesJunction.ts | 96 +++++++ ...RuntimeProviderManagementCliClient.test.ts | 243 ++++++++++++++++++ ...openCodeWindowsNodeModulesJunction.test.ts | 212 +++++++++++++++ 4 files changed, 644 insertions(+), 8 deletions(-) create mode 100644 src/features/runtime-provider-management/main/infrastructure/openCodeWindowsNodeModulesJunction.ts create mode 100644 test/main/features/runtime-provider-management/openCodeWindowsNodeModulesJunction.test.ts diff --git a/src/features/runtime-provider-management/main/infrastructure/AgentTeamsRuntimeProviderManagementCliClient.ts b/src/features/runtime-provider-management/main/infrastructure/AgentTeamsRuntimeProviderManagementCliClient.ts index 493956c5..7fe152db 100644 --- a/src/features/runtime-provider-management/main/infrastructure/AgentTeamsRuntimeProviderManagementCliClient.ts +++ b/src/features/runtime-provider-management/main/infrastructure/AgentTeamsRuntimeProviderManagementCliClient.ts @@ -6,6 +6,12 @@ import { ClaudeBinaryResolver } from '@main/services/team/ClaudeBinaryResolver'; import { execCli, killProcessTree, spawnCli } from '@main/utils/childProcess'; import { resolveInteractiveShellEnvBestEffort } from '@main/utils/shellEnv'; +import { + ensureOpenCodeProfileNodeModulesJunction, + extractProfileIdFromSymlinkError, + isOpenCodeNodeModulesSymlinkError, +} from './openCodeWindowsNodeModulesJunction'; + import type { RuntimeProviderManagementApi, RuntimeProviderManagementConnectApiKeyInput, @@ -217,6 +223,39 @@ function sanitizeNullableRuntimeProviderText(value: unknown): string | null { return typeof value === 'string' ? sanitizeRuntimeProviderText(value) : null; } +function buildOpenCodeProfileNodeModulesLinkDiagnostics( + message: string +): RuntimeProviderManagementErrorDto['diagnostics'] { + const normalized = message.toLowerCase(); + const isAccessDeniedLinkFailure = + (normalized.includes('eperm') || normalized.includes('eacces')) && + normalized.includes('symlink') && + normalized.includes('opencode') && + normalized.includes('node_modules'); + if (!isAccessDeniedLinkFailure) { + return null; + } + + const summary = 'OpenCode managed profile node_modules link was blocked.'; + const likelyCause = + 'Windows denied creating the managed OpenCode profile node_modules link. The runtime does not yet fall back to a junction or local profile directory on Windows — this is a known limitation.'; + return { + summary, + likelyCause, + binaryPath: null, + command: null, + projectPath: null, + exitCode: null, + stderrPreview: message, + stdoutPreview: null, + hints: [ + 'The next runtime update will include automatic junction fallback for Windows.', + 'As a temporary workaround, enable Windows Developer Mode or run Agent Teams AI as Administrator.', + 'After enabling Developer Mode, refresh the OpenCode provider catalog.', + ], + }; +} + function extractJsonObject(raw: string): T { const start = raw.indexOf('{'); if (start < 0) { @@ -1048,13 +1087,36 @@ export class AgentTeamsRuntimeProviderManagementCliClient implements RuntimeProv stderr ); } catch (error) { - const response = extractJsonObjectFromError(error); - if (response) { - return response; + const failure = normalizeCommandFailure(error, context); + + if (process.platform === 'win32' && isOpenCodeNodeModulesSymlinkError(failure.message)) { + const profileId = extractProfileIdFromSymlinkError(failure.message); + if (profileId) { + ensureOpenCodeProfileNodeModulesJunction(profileId); + try { + const retryResult = await execCli( + binaryPath, + args, + runtimeProviderCommandOptions({ env, timeout: COMMAND_TIMEOUT_MS }, projectPath) + ); + return extractJsonObjectWithContext( + retryResult.stdout, + context, + retryResult.stderr + ); + } catch { + // Retry also failed; fall through to return the original error. + } + } + } + + const retryResponse = extractJsonObjectFromError(error); + if (retryResponse) { + return retryResponse; } return commandFailureResponse( input.runtimeId, - normalizeCommandFailure(error, context) + failure ); } } @@ -1103,14 +1165,37 @@ export class AgentTeamsRuntimeProviderManagementCliClient implements RuntimeProv stderr ); } catch (error) { - const response = + const failure = normalizeCommandFailure(error, context); + + if (process.platform === 'win32' && isOpenCodeNodeModulesSymlinkError(failure.message)) { + const profileId = extractProfileIdFromSymlinkError(failure.message); + if (profileId) { + ensureOpenCodeProfileNodeModulesJunction(profileId); + try { + const retryResult = await execCli( + binaryPath, + args, + runtimeProviderCommandOptions({ env, timeout: COMMAND_TIMEOUT_MS }, projectPath) + ); + return extractJsonObjectWithContext( + retryResult.stdout, + context, + retryResult.stderr + ); + } catch { + // Retry also failed; fall through to return the original error. + } + } + } + + const retryResponse = extractJsonObjectFromError(error); - if (response) { - return response; + if (retryResponse) { + return retryResponse; } return commandFailureResponse( input.runtimeId, - normalizeCommandFailure(error, context) + failure ); } } diff --git a/src/features/runtime-provider-management/main/infrastructure/openCodeWindowsNodeModulesJunction.ts b/src/features/runtime-provider-management/main/infrastructure/openCodeWindowsNodeModulesJunction.ts new file mode 100644 index 00000000..38b37993 --- /dev/null +++ b/src/features/runtime-provider-management/main/infrastructure/openCodeWindowsNodeModulesJunction.ts @@ -0,0 +1,96 @@ +import fs from 'node:fs'; +import os from 'node:os'; +import path from 'node:path'; + +const OPENCODE_SHARED_CACHE_NODE_MODULES_RELATIVE = path.join( + 'Cache', + 'opencode', + 'shared-cache', + 'config-node_modules' +); +const OPENCODE_PROFILES_BASE_RELATIVE = path.join( + 'Data', + 'opencode', + 'profiles' +); + +function getLocalAppDataPath(): string { + return process.env.LOCALAPPDATA ?? path.join(os.homedir(), 'AppData', 'Local'); +} + +function getBaseDir(): string { + return path.join(getLocalAppDataPath(), 'claude-multimodel-nodejs'); +} + +export function getSharedCacheNodeModulesPath(): string { + return path.join(getBaseDir(), OPENCODE_SHARED_CACHE_NODE_MODULES_RELATIVE); +} + +export function getProfileNodeModulesPath(profileId: string): string { + return path.join( + getBaseDir(), + OPENCODE_PROFILES_BASE_RELATIVE, + profileId, + 'config', + 'opencode', + 'node_modules' + ); +} + +export function isOpenCodeNodeModulesSymlinkError(message: string): boolean { + const normalized = message.toLowerCase(); + return ( + (normalized.includes('eperm') || normalized.includes('eacces')) && + normalized.includes('symlink') && + normalized.includes('opencode') && + normalized.includes('node_modules') + ); +} + +export function extractProfileIdFromSymlinkError(message: string): string | null { + const profilePathPattern = + /profiles[\\/]([0-9a-f]+)[\\/]config[\\/]opencode[\\/]node_modules/i; + const match = profilePathPattern.exec(message); + return match ? match[1] : null; +} + +export function ensureOpenCodeProfileNodeModulesJunction(profileId: string): boolean { + if (process.platform !== 'win32') { + return false; + } + + const source = getSharedCacheNodeModulesPath(); + const target = getProfileNodeModulesPath(profileId); + + try { + const existingStat = fs.statSync(target, { throwIfNoEntry: false }); + if (existingStat !== undefined) { + return true; + } + } catch { + // Target does not exist, proceed to create junction. + } + + try { + const sourceStat = fs.statSync(source, { throwIfNoEntry: false }); + if (sourceStat === undefined) { + return false; + } + } catch { + return false; + } + + const parentDir = path.dirname(target); + try { + fs.mkdirSync(parentDir, { recursive: true }); + } catch { + return false; + } + + try { + fs.symlinkSync(source, target, 'junction'); + return true; + } catch { + return false; + } +} \ No newline at end of file diff --git a/test/main/features/runtime-provider-management/AgentTeamsRuntimeProviderManagementCliClient.test.ts b/test/main/features/runtime-provider-management/AgentTeamsRuntimeProviderManagementCliClient.test.ts index a916ddde..9673f9f4 100644 --- a/test/main/features/runtime-provider-management/AgentTeamsRuntimeProviderManagementCliClient.test.ts +++ b/test/main/features/runtime-provider-management/AgentTeamsRuntimeProviderManagementCliClient.test.ts @@ -73,8 +73,23 @@ vi.mock('@main/utils/shellEnv', () => ({ resolveInteractiveShellEnvBestEffort: () => resolveInteractiveShellEnvMock(), })); +vi.mock( + '../../../../src/features/runtime-provider-management/main/infrastructure/openCodeWindowsNodeModulesJunction', + () => ({ + isOpenCodeNodeModulesSymlinkError: vi.fn(), + extractProfileIdFromSymlinkError: vi.fn(), + ensureOpenCodeProfileNodeModulesJunction: vi.fn(), + }) +); + import { AgentTeamsRuntimeProviderManagementCliClient } from '../../../../src/features/runtime-provider-management/main/infrastructure/AgentTeamsRuntimeProviderManagementCliClient'; +import { + isOpenCodeNodeModulesSymlinkError as isOpenCodeNodeModulesSymlinkErrorMock, + extractProfileIdFromSymlinkError as extractProfileIdFromSymlinkErrorMock, + ensureOpenCodeProfileNodeModulesJunction as ensureOpenCodeProfileNodeModulesJunctionMock, +} from '../../../../src/features/runtime-provider-management/main/infrastructure/openCodeWindowsNodeModulesJunction'; + describe('AgentTeamsRuntimeProviderManagementCliClient', () => { beforeEach(() => { vi.clearAllMocks(); @@ -857,6 +872,234 @@ describe('AgentTeamsRuntimeProviderManagementCliClient', () => { expect(JSON.stringify(response)).not.toContain('sk-secret-value-123456'); }); + it('adds actionable diagnostics for OpenCode managed profile node_modules symlink failures', async () => { + const runtimeMessage = [ + 'Runtime provider management command failed unexpectedly:', + "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\\abc123\\config\\opencode\\node_modules'", + ].join(' '); + const error = new Error('Command failed: /repo/cli-dev runtime providers view'); + Object.assign(error, { + stdout: JSON.stringify({ + schemaVersion: 1, + runtimeId: 'opencode', + error: { + code: 'runtime-unhealthy', + message: runtimeMessage, + recoverable: true, + }, + }), + stderr: '', + }); + execCliMock.mockRejectedValue(error); + + const client = new AgentTeamsRuntimeProviderManagementCliClient(); + const response = await client.loadView({ + runtimeId: 'opencode', + }); + + expect(response.error?.message).toBe(runtimeMessage); + expect(response.error?.diagnostics?.summary).toBe( + 'OpenCode managed profile node_modules link was blocked.' + ); + expect(response.error?.diagnostics?.likelyCause).toContain( + 'Windows denied creating the managed OpenCode profile node_modules link' + ); + expect(response.error?.diagnostics?.stderrPreview).toBe(runtimeMessage); + expect(response.error?.diagnostics?.hints).toEqual( + expect.arrayContaining([ + 'The next runtime update will include automatic junction fallback for Windows.', + 'As a temporary workaround, enable Windows Developer Mode or run Agent Teams AI as Administrator.', + ]) + ); + }); + + it('attempts junction pre-seed and retry on Windows when EPERM symlink error is detected in loadView', async () => { + const runtimeMessage = [ + 'Runtime provider management command failed unexpectedly:', + "EPERM: operation not permitted, symlink 'C:\\Users\\test\\AppData\\Local\\claude-multimodel-nodejs\\Cache\\opencode\\shared-cache\\config-node_modules'", + "-> 'C:\\Users\\test\\AppData\\Local\\claude-multimodel-nodejs\\Data\\opencode\\profiles\\abc123\\config\\opencode\\node_modules'", + ].join(' '); + const firstError = new Error('Command failed: /repo/cli-dev runtime providers view'); + Object.assign(firstError, { + stdout: JSON.stringify({ + schemaVersion: 1, + runtimeId: 'opencode', + error: { code: 'runtime-unhealthy', message: runtimeMessage, recoverable: true }, + }), + stderr: '', + }); + + const successResponse = { + schemaVersion: 1, + runtimeId: 'opencode', + view: { + runtimeId: 'opencode', + title: 'OpenCode', + runtime: { state: 'ready', cliPath: '/repo/cli-dev', version: '1.15.6', managedProfile: 'active', localAuth: 'synced' }, + providers: [], + defaultModel: null, + fallbackModel: null, + diagnostics: [], + }, + }; + + execCliMock + .mockRejectedValueOnce(firstError) + .mockResolvedValueOnce({ stdout: JSON.stringify(successResponse), stderr: '' }); + + const originalPlatform = process.platform; + Object.defineProperty(process, 'platform', { value: 'win32' }); + (isOpenCodeNodeModulesSymlinkErrorMock as ReturnType).mockReturnValue(true); + (extractProfileIdFromSymlinkErrorMock as ReturnType).mockReturnValue('abc123'); + (ensureOpenCodeProfileNodeModulesJunctionMock as ReturnType).mockReturnValue(true); + + try { + const client = new AgentTeamsRuntimeProviderManagementCliClient(); + const response = await client.loadView({ runtimeId: 'opencode' }); + + expect(ensureOpenCodeProfileNodeModulesJunctionMock).toHaveBeenCalledWith('abc123'); + expect(execCliMock).toHaveBeenCalledTimes(2); + expect(response.error).toBeUndefined(); + expect(response.view?.runtime?.state).toBe('ready'); + } finally { + Object.defineProperty(process, 'platform', { value: originalPlatform }); + vi.mocked(isOpenCodeNodeModulesSymlinkErrorMock).mockRestore(); + vi.mocked(extractProfileIdFromSymlinkErrorMock).mockRestore(); + vi.mocked(ensureOpenCodeProfileNodeModulesJunctionMock).mockRestore(); + } + }); + + it('falls back to error response when junction pre-seed succeeds but retry also fails in loadView', async () => { + const runtimeMessage = [ + 'Runtime provider management command failed unexpectedly:', + "EPERM: operation not permitted, symlink 'C:\\Users\\test\\AppData\\Local\\claude-multimodel-nodejs\\Cache\\opencode\\shared-cache\\config-node_modules'", + "-> 'C:\\Users\\test\\AppData\\Local\\claude-multimodel-nodejs\\Data\\opencode\\profiles\\abc123\\config\\opencode\\node_modules'", + ].join(' '); + const error = new Error('Command failed: /repo/cli-dev runtime providers view'); + Object.assign(error, { + stdout: JSON.stringify({ + schemaVersion: 1, + runtimeId: 'opencode', + error: { code: 'runtime-unhealthy', message: runtimeMessage, recoverable: true }, + }), + stderr: '', + }); + + execCliMock.mockRejectedValue(error); + + const originalPlatform = process.platform; + Object.defineProperty(process, 'platform', { value: 'win32' }); + (isOpenCodeNodeModulesSymlinkErrorMock as ReturnType).mockReturnValue(true); + (extractProfileIdFromSymlinkErrorMock as ReturnType).mockReturnValue('abc123'); + (ensureOpenCodeProfileNodeModulesJunctionMock as ReturnType).mockReturnValue(true); + + try { + const client = new AgentTeamsRuntimeProviderManagementCliClient(); + const response = await client.loadView({ runtimeId: 'opencode' }); + + expect(ensureOpenCodeProfileNodeModulesJunctionMock).toHaveBeenCalledWith('abc123'); + expect(execCliMock).toHaveBeenCalledTimes(2); + expect(response.error?.message).toBe(runtimeMessage); + } finally { + Object.defineProperty(process, 'platform', { value: originalPlatform }); + vi.mocked(isOpenCodeNodeModulesSymlinkErrorMock).mockRestore(); + vi.mocked(extractProfileIdFromSymlinkErrorMock).mockRestore(); + vi.mocked(ensureOpenCodeProfileNodeModulesJunctionMock).mockRestore(); + } + }); + + it('does not attempt junction retry on non-Windows platforms in loadView', async () => { + const runtimeMessage = [ + 'Runtime provider management command failed unexpectedly:', + "EPERM: operation not permitted, symlink 'opencode' -> 'node_modules'", + ].join(' '); + const error = new Error('Command failed: /repo/cli-dev runtime providers view'); + Object.assign(error, { + stdout: JSON.stringify({ + schemaVersion: 1, + runtimeId: 'opencode', + error: { code: 'runtime-unhealthy', message: runtimeMessage, recoverable: true }, + }), + stderr: '', + }); + + execCliMock.mockRejectedValue(error); + + const originalPlatform = process.platform; + Object.defineProperty(process, 'platform', { value: 'darwin' }); + (isOpenCodeNodeModulesSymlinkErrorMock as ReturnType).mockReturnValue(true); + (extractProfileIdFromSymlinkErrorMock as ReturnType).mockReturnValue('abc123'); + + try { + const client = new AgentTeamsRuntimeProviderManagementCliClient(); + const response = await client.loadView({ runtimeId: 'opencode' }); + + expect(ensureOpenCodeProfileNodeModulesJunctionMock).not.toHaveBeenCalled(); + expect(execCliMock).toHaveBeenCalledTimes(1); + expect(response.error?.message).toBe(runtimeMessage); + } finally { + Object.defineProperty(process, 'platform', { value: originalPlatform }); + vi.mocked(isOpenCodeNodeModulesSymlinkErrorMock).mockRestore(); + vi.mocked(extractProfileIdFromSymlinkErrorMock).mockRestore(); + } + }); + + it('attempts junction pre-seed and retry on Windows for loadProviderDirectory', async () => { + const runtimeMessage = [ + 'Runtime provider management command failed unexpectedly:', + "EPERM: operation not permitted, symlink 'C:\\Users\\test\\AppData\\Local\\claude-multimodel-nodejs\\Cache\\opencode\\shared-cache\\config-node_modules'", + "-> 'C:\\Users\\test\\AppData\\Local\\claude-multimodel-nodejs\\Data\\opencode\\profiles\\def456\\config\\opencode\\node_modules'", + ].join(' '); + const firstError = new Error('Command failed: /repo/cli-dev runtime providers directory'); + Object.assign(firstError, { + stdout: '', + stderr: runtimeMessage, + }); + + const successResponse = { + schemaVersion: 1, + runtimeId: 'opencode', + directory: { + runtimeId: 'opencode', + totalCount: 0, + returnedCount: 0, + query: null, + filter: 'all', + limit: 50, + cursor: null, + nextCursor: null, + entries: [], + diagnostics: [], + fetchedAt: new Date().toISOString(), + }, + }; + + execCliMock + .mockRejectedValueOnce(firstError) + .mockResolvedValueOnce({ stdout: JSON.stringify(successResponse), stderr: '' }); + + const originalPlatform = process.platform; + Object.defineProperty(process, 'platform', { value: 'win32' }); + (isOpenCodeNodeModulesSymlinkErrorMock as ReturnType).mockReturnValue(true); + (extractProfileIdFromSymlinkErrorMock as ReturnType).mockReturnValue('def456'); + (ensureOpenCodeProfileNodeModulesJunctionMock as ReturnType).mockReturnValue(true); + + try { + const client = new AgentTeamsRuntimeProviderManagementCliClient(); + const response = await client.loadProviderDirectory({ runtimeId: 'opencode' }); + + expect(ensureOpenCodeProfileNodeModulesJunctionMock).toHaveBeenCalledWith('def456'); + expect(execCliMock).toHaveBeenCalledTimes(2); + expect(response.directory?.entries).toEqual([]); + } finally { + Object.defineProperty(process, 'platform', { value: originalPlatform }); + vi.mocked(isOpenCodeNodeModulesSymlinkErrorMock).mockRestore(); + vi.mocked(extractProfileIdFromSymlinkErrorMock).mockRestore(); + vi.mocked(ensureOpenCodeProfileNodeModulesJunctionMock).mockRestore(); + } + }); + it('does not let non-object error logs shadow a later valid runtime response', async () => { const validResponse = { schemaVersion: 1, diff --git a/test/main/features/runtime-provider-management/openCodeWindowsNodeModulesJunction.test.ts b/test/main/features/runtime-provider-management/openCodeWindowsNodeModulesJunction.test.ts new file mode 100644 index 00000000..7a28f7cb --- /dev/null +++ b/test/main/features/runtime-provider-management/openCodeWindowsNodeModulesJunction.test.ts @@ -0,0 +1,212 @@ +import fs from 'node:fs'; +import os from 'node:os'; +import path from 'node:path'; + +import { beforeEach, describe, expect, it, vi } from 'vitest'; + +import { + ensureOpenCodeProfileNodeModulesJunction, + extractProfileIdFromSymlinkError, + getProfileNodeModulesPath, + getSharedCacheNodeModulesPath, + isOpenCodeNodeModulesSymlinkError, +} from '../../../../src/features/runtime-provider-management/main/infrastructure/openCodeWindowsNodeModulesJunction'; + +describe('openCodeWindowsNodeModulesJunction', () => { + describe('isOpenCodeNodeModulesSymlinkError', () => { + it('matches EPERM symlink errors containing opencode and node_modules', () => { + const message = [ + 'Runtime provider management command failed unexpectedly:', + "EPERM: operation not permitted, symlink 'C:\\Users\\test\\AppData\\Local\\claude-multimodel-nodejs\\Cache\\opencode\\shared-cache\\config-node_modules'", + "-> 'C:\\Users\\test\\AppData\\Local\\claude-multimodel-nodejs\\Data\\opencode\\profiles\\abc123\\config\\opencode\\node_modules'", + ].join(' '); + expect(isOpenCodeNodeModulesSymlinkError(message)).toBe(true); + }); + + it('matches EACCES symlink errors containing opencode and node_modules', () => { + const message = + "EACCES: access denied, symlink 'opencode' -> 'node_modules'"; + expect(isOpenCodeNodeModulesSymlinkError(message)).toBe(true); + }); + + it('is case-insensitive', () => { + const message = + "eperm: operation not permitted, SYMLINK 'OpenCode' -> 'NODE_MODULES'"; + expect(isOpenCodeNodeModulesSymlinkError(message)).toBe(true); + }); + + it('does not match errors missing symlink keyword', () => { + const message = + "EPERM: operation not permitted, open 'opencode' -> 'node_modules'"; + expect(isOpenCodeNodeModulesSymlinkError(message)).toBe(false); + }); + + it('does not match errors missing opencode keyword', () => { + const message = + "EPERM: operation not permitted, symlink '/some/path' -> 'node_modules'"; + expect(isOpenCodeNodeModulesSymlinkError(message)).toBe(false); + }); + + it('does not match errors missing node_modules keyword', () => { + const message = + "EPERM: operation not permitted, symlink 'opencode' -> '/some/path'"; + expect(isOpenCodeNodeModulesSymlinkError(message)).toBe(false); + }); + }); + + describe('extractProfileIdFromSymlinkError', () => { + it('extracts the profile hash from a Windows path', () => { + 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'", + ].join(' '); + expect(extractProfileIdFromSymlinkError(message)).toBe('e8e2eadb00beea6c'); + }); + + it('extracts the profile hash from a Unix-style path', () => { + const message = + "EPERM: symlink '/home/user/.cache/opencode/shared-cache/config-node_modules' -> '/home/user/.data/opencode/profiles/abc123def456/config/opencode/node_modules'"; + expect(extractProfileIdFromSymlinkError(message)).toBe('abc123def456'); + }); + + it('returns null when no profile path pattern is found', () => { + const message = 'EPERM: some other error without a profile path'; + expect(extractProfileIdFromSymlinkError(message)).toBeNull(); + }); + }); + + describe('getSharedCacheNodeModulesPath', () => { + it('uses LOCALAPPDATA environment variable when set', () => { + const originalEnv = process.env.LOCALAPPDATA; + process.env.LOCALAPPDATA = 'X:\\custom\\local'; + try { + const result = getSharedCacheNodeModulesPath(); + expect(result).toBe( + path.join('X:\\custom\\local', 'claude-multimodel-nodejs', 'Cache', 'opencode', 'shared-cache', 'config-node_modules') + ); + } finally { + process.env.LOCALAPPDATA = originalEnv; + } + }); + + it('falls back to homedir AppData Local when LOCALAPPDATA is unset', () => { + const originalEnv = process.env.LOCALAPPDATA; + delete process.env.LOCALAPPDATA; + try { + const result = getSharedCacheNodeModulesPath(); + expect(result).toContain('AppData'); + expect(result).toContain('Local'); + expect(result).toContain('claude-multimodel-nodejs'); + } finally { + process.env.LOCALAPPDATA = originalEnv; + } + }); + }); + + describe('getProfileNodeModulesPath', () => { + it('constructs the correct profile node_modules path', () => { + const originalEnv = process.env.LOCALAPPDATA; + process.env.LOCALAPPDATA = 'C:\\Users\\test\\AppData\\Local'; + try { + const result = getProfileNodeModulesPath('abc123'); + expect(result).toBe( + path.join( + 'C:\\Users\\test\\AppData\\Local', + 'claude-multimodel-nodejs', + 'Data', + 'opencode', + 'profiles', + 'abc123', + 'config', + 'opencode', + 'node_modules' + ) + ); + } finally { + process.env.LOCALAPPDATA = originalEnv; + } + }); + }); + + describe('ensureOpenCodeProfileNodeModulesJunction', () => { + const originalPlatform = process.platform; + + afterEach(() => { + Object.defineProperty(process, 'platform', { value: originalPlatform }); + }); + + it('returns false on non-Windows platforms', () => { + Object.defineProperty(process, 'platform', { value: 'darwin' }); + const result = ensureOpenCodeProfileNodeModulesJunction('abc123'); + expect(result).toBe(false); + }); + + 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 result = ensureOpenCodeProfileNodeModulesJunction('abc123'); + expect(result).toBe(false); + statSyncSpy.mockRestore(); + }); + + 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 result = ensureOpenCodeProfileNodeModulesJunction('abc123'); + expect(result).toBe(true); + statSyncSpy.mockRestore(); + }); + + 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; + } + return {} as fs.Stats; + }); + const mkdirSyncSpy = vi.spyOn(fs, 'mkdirSync').mockImplementation(() => ''); + const symlinkSyncSpy = vi.spyOn(fs, 'symlinkSync').mockImplementation(() => undefined); + const result = ensureOpenCodeProfileNodeModulesJunction('abc123'); + expect(result).toBe(true); + expect(symlinkSyncSpy).toHaveBeenCalledTimes(1); + expect(symlinkSyncSpy.mock.calls[0][2]).toBe('junction'); + statSyncSpy.mockRestore(); + mkdirSyncSpy.mockRestore(); + symlinkSyncSpy.mockRestore(); + }); + + 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; + } + return {} as fs.Stats; + }); + const mkdirSyncSpy = vi.spyOn(fs, 'mkdirSync').mockImplementation(() => ''); + const symlinkSyncSpy = vi.spyOn(fs, 'symlinkSync').mockImplementation(() => { + throw new Error('EPERM'); + }); + const result = ensureOpenCodeProfileNodeModulesJunction('abc123'); + expect(result).toBe(false); + statSyncSpy.mockRestore(); + mkdirSyncSpy.mockRestore(); + symlinkSyncSpy.mockRestore(); + }); + }); +}); \ No newline at end of file