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 committed by 777genius
parent 1126b1ee38
commit 597c690dbc
4 changed files with 644 additions and 8 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,
@ -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<T>(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<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
);
}
}
@ -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<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();
@ -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<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();
});
});
});