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:
parent
1126b1ee38
commit
597c690dbc
4 changed files with 644 additions and 8 deletions
|
|
@ -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
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
});
|
||||
});
|
||||
});
|
||||
Loading…
Reference in a new issue