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
This commit is contained in:
ComradeSwarog 2026-05-28 02:46:04 +03:00
parent c49d6c373e
commit cf98e74c11
4 changed files with 575 additions and 14 deletions

View file

@ -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,
@ -236,7 +242,7 @@ function buildOpenCodeProfileNodeModulesLinkDiagnostics(
const summary = 'OpenCode managed profile node_modules link was blocked.';
const likelyCause =
'Windows denied creating the managed OpenCode profile node_modules link. Newer Agent Teams runtimes fall back to a junction or local profile directory.';
'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,
@ -247,9 +253,9 @@ function buildOpenCodeProfileNodeModulesLinkDiagnostics(
stderrPreview: message,
stdoutPreview: null,
hints: [
'Update the Agent Teams runtime and refresh the OpenCode provider catalog.',
'If you must use an older runtime, enable Windows Developer Mode or run Agent Teams AI as Administrator.',
'If the error persists after updating, refresh again so the runtime can rebuild the managed OpenCode profile node_modules path.',
'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.',
],
};
}
@ -1085,13 +1091,36 @@ export class AgentTeamsRuntimeProviderManagementCliClient implements RuntimeProv
stderr
);
} catch (error) {
const response = extractJsonObjectFromError<RuntimeProviderManagementViewResponse>(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<RuntimeProviderManagementViewResponse>(
retryResult.stdout,
context,
retryResult.stderr
);
} catch {
// Retry also failed; fall through to return the original error.
}
}
}
const retryResponse = extractJsonObjectFromError<RuntimeProviderManagementViewResponse>(error);
if (retryResponse) {
return retryResponse;
}
return commandFailureResponse<RuntimeProviderManagementViewResponse>(
input.runtimeId,
normalizeCommandFailure(error, context)
failure
);
}
}
@ -1140,14 +1169,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<RuntimeProviderManagementDirectoryResponse>(
retryResult.stdout,
context,
retryResult.stderr
);
} catch {
// Retry also failed; fall through to return the original error.
}
}
}
const retryResponse =
extractJsonObjectFromError<RuntimeProviderManagementDirectoryResponse>(error);
if (response) {
return response;
if (retryResponse) {
return retryResponse;
}
return commandFailureResponse<RuntimeProviderManagementDirectoryResponse>(
input.runtimeId,
normalizeCommandFailure(error, context)
failure
);
}
}

View file

@ -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;
}
}

View file

@ -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();
@ -893,12 +908,198 @@ describe('AgentTeamsRuntimeProviderManagementCliClient', () => {
expect(response.error?.diagnostics?.stderrPreview).toBe(runtimeMessage);
expect(response.error?.diagnostics?.hints).toEqual(
expect.arrayContaining([
'Update the Agent Teams runtime and refresh the OpenCode provider catalog.',
'If you must use an older runtime, enable Windows Developer Mode or run Agent Teams AI as Administrator.',
'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<typeof vi.fn>).mockReturnValue(true);
(extractProfileIdFromSymlinkErrorMock as ReturnType<typeof vi.fn>).mockReturnValue('abc123');
(ensureOpenCodeProfileNodeModulesJunctionMock as ReturnType<typeof vi.fn>).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<typeof vi.fn>).mockReturnValue(true);
(extractProfileIdFromSymlinkErrorMock as ReturnType<typeof vi.fn>).mockReturnValue('abc123');
(ensureOpenCodeProfileNodeModulesJunctionMock as ReturnType<typeof vi.fn>).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<typeof vi.fn>).mockReturnValue(true);
(extractProfileIdFromSymlinkErrorMock as ReturnType<typeof vi.fn>).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<typeof vi.fn>).mockReturnValue(true);
(extractProfileIdFromSymlinkErrorMock as ReturnType<typeof vi.fn>).mockReturnValue('def456');
(ensureOpenCodeProfileNodeModulesJunctionMock as ReturnType<typeof vi.fn>).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,

View file

@ -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();
});
});
});