fix(opencode): detect nvm runtime installs
This commit is contained in:
parent
8511d6af6e
commit
ec7b1dbd18
5 changed files with 469 additions and 39 deletions
|
|
@ -2,11 +2,15 @@ import { execCli } from '@main/utils/childProcess';
|
|||
import { buildMergedCliPath } from '@main/utils/cliPathMerge';
|
||||
import { getAppDataPath } from '@main/utils/pathDecoder';
|
||||
import { safeSendToRenderer } from '@main/utils/safeWebContentsSend';
|
||||
import { getCachedShellEnv, resolveInteractiveShellEnvBestEffort } from '@main/utils/shellEnv';
|
||||
import {
|
||||
getCachedShellEnv,
|
||||
getShellPreferredHome,
|
||||
resolveInteractiveShellEnvBestEffort,
|
||||
} from '@main/utils/shellEnv';
|
||||
import { getErrorMessage } from '@shared/utils/errorHandling';
|
||||
import { createLogger } from '@shared/utils/logger';
|
||||
import { createHash, randomUUID } from 'crypto';
|
||||
import { existsSync, promises as fsp, readFileSync, statSync } from 'fs';
|
||||
import { existsSync, promises as fsp, readdirSync, readFileSync, statSync } from 'fs';
|
||||
import path from 'path';
|
||||
import { gunzipSync } from 'zlib';
|
||||
|
||||
|
|
@ -136,17 +140,32 @@ function splitPathEnv(pathValue: string | undefined): string[] {
|
|||
.filter(Boolean);
|
||||
}
|
||||
|
||||
function resolvePathOpenCodeBinary(
|
||||
additionalEnvSources: (NodeJS.ProcessEnv | null | undefined)[] = []
|
||||
): string | null {
|
||||
function collectPathOpenCodeBinaryCandidates(
|
||||
additionalEnvSources: (NodeJS.ProcessEnv | null | undefined)[] = [],
|
||||
options: { includeFallbackPathEntries?: boolean } = {}
|
||||
): string[] {
|
||||
const shellEnv = getCachedShellEnv() ?? {};
|
||||
const pathEntries = [
|
||||
const directPathEntries = [
|
||||
...additionalEnvSources.flatMap((env) => splitPathEnv(env?.PATH)),
|
||||
...splitPathEnv(shellEnv.PATH),
|
||||
...splitPathEnv(buildMergedCliPath()),
|
||||
...splitPathEnv(process.env.PATH),
|
||||
];
|
||||
const fallbackPathEntries =
|
||||
options.includeFallbackPathEntries === false
|
||||
? []
|
||||
: [...splitPathEnv(buildMergedCliPath()), ...splitPathEnv(process.env.PATH)];
|
||||
const seen = new Set<string>();
|
||||
return [
|
||||
...collectOpenCodeBinariesFromPathEntries(directPathEntries, seen),
|
||||
...collectNvmOpenCodeBinaryCandidates().filter(isAbsoluteExistingFile),
|
||||
...collectOpenCodeBinariesFromPathEntries(fallbackPathEntries, seen),
|
||||
];
|
||||
}
|
||||
|
||||
function collectOpenCodeBinariesFromPathEntries(
|
||||
pathEntries: string[],
|
||||
seen: Set<string>
|
||||
): string[] {
|
||||
const results: string[] = [];
|
||||
for (const entry of pathEntries) {
|
||||
const normalizedEntry = path.resolve(entry);
|
||||
if (seen.has(normalizedEntry)) {
|
||||
|
|
@ -156,17 +175,56 @@ function resolvePathOpenCodeBinary(
|
|||
for (const executableName of getPathExecutableNames()) {
|
||||
const candidate = path.join(normalizedEntry, executableName);
|
||||
if (isAbsoluteExistingFile(candidate)) {
|
||||
return candidate;
|
||||
results.push(candidate);
|
||||
}
|
||||
}
|
||||
}
|
||||
return null;
|
||||
return results;
|
||||
}
|
||||
|
||||
function collectNvmOpenCodeBinaryCandidates(): string[] {
|
||||
if (process.platform === 'win32') {
|
||||
const appdata = process.env.APPDATA;
|
||||
if (!appdata) {
|
||||
return [];
|
||||
}
|
||||
return collectVersionedOpenCodeBinaryCandidates(path.join(appdata, 'nvm'));
|
||||
}
|
||||
|
||||
return collectVersionedOpenCodeBinaryCandidates(
|
||||
path.join(getShellPreferredHome(), '.nvm', 'versions', 'node'),
|
||||
'bin'
|
||||
);
|
||||
}
|
||||
|
||||
function collectVersionedOpenCodeBinaryCandidates(rootPath: string, binSegment = ''): string[] {
|
||||
let versions: string[];
|
||||
try {
|
||||
versions = readdirSync(rootPath);
|
||||
} catch {
|
||||
return [];
|
||||
}
|
||||
|
||||
return versions
|
||||
.toSorted((left, right) => right.localeCompare(left, undefined, { numeric: true }))
|
||||
.flatMap((version) => {
|
||||
const versionPath = binSegment
|
||||
? path.join(rootPath, version, binSegment)
|
||||
: path.join(rootPath, version);
|
||||
return getPathExecutableNames().map((executableName) =>
|
||||
path.join(versionPath, executableName)
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
type OpenCodeBinaryVersionProbe =
|
||||
| { ok: true; version: string | null }
|
||||
| { ok: false; error: string };
|
||||
|
||||
type VerifiedOpenCodeBinaryProbe =
|
||||
| { ok: true; binaryPath: string; version: string | null }
|
||||
| { ok: false; firstFailure: { binaryPath: string; error: string } | null };
|
||||
|
||||
async function probeOpenCodeBinaryVersion(binaryPath: string): Promise<OpenCodeBinaryVersionProbe> {
|
||||
try {
|
||||
const { stdout } = await execCli(binaryPath, ['--version'], {
|
||||
|
|
@ -179,30 +237,79 @@ async function probeOpenCodeBinaryVersion(binaryPath: string): Promise<OpenCodeB
|
|||
}
|
||||
}
|
||||
|
||||
async function resolvePathOpenCodeBinaryWithBestEffortEnv(
|
||||
options: { shellEnvTimeoutMs?: number } = {}
|
||||
): Promise<string | null> {
|
||||
const cachedCandidate = resolvePathOpenCodeBinary();
|
||||
if (cachedCandidate) {
|
||||
return cachedCandidate;
|
||||
function normalizeBinaryCandidateForCompare(binaryPath: string): string {
|
||||
const normalized = path.resolve(binaryPath);
|
||||
return process.platform === 'win32' ? normalized.toLowerCase() : normalized;
|
||||
}
|
||||
|
||||
async function probeFirstWorkingOpenCodeBinaryCandidate(
|
||||
candidates: string[],
|
||||
seen: Set<string>,
|
||||
firstFailure: { binaryPath: string; error: string } | null
|
||||
): Promise<VerifiedOpenCodeBinaryProbe> {
|
||||
let nextFirstFailure = firstFailure;
|
||||
for (const binaryPath of candidates) {
|
||||
const normalized = normalizeBinaryCandidateForCompare(binaryPath);
|
||||
if (seen.has(normalized)) {
|
||||
continue;
|
||||
}
|
||||
seen.add(normalized);
|
||||
const version = await probeOpenCodeBinaryVersion(binaryPath);
|
||||
if (version.ok) {
|
||||
return { ok: true, binaryPath, version: version.version };
|
||||
}
|
||||
nextFirstFailure ??= { binaryPath, error: version.error };
|
||||
}
|
||||
|
||||
return { ok: false, firstFailure: nextFirstFailure };
|
||||
}
|
||||
|
||||
async function probeFirstWorkingPathOpenCodeBinary(
|
||||
options: { shellEnvTimeoutMs?: number } = {}
|
||||
): Promise<VerifiedOpenCodeBinaryProbe> {
|
||||
const seenCandidates = new Set<string>();
|
||||
let firstFailure: { binaryPath: string; error: string } | null = null;
|
||||
|
||||
const cachedProbe = await probeFirstWorkingOpenCodeBinaryCandidate(
|
||||
collectPathOpenCodeBinaryCandidates([], {
|
||||
includeFallbackPathEntries: false,
|
||||
}),
|
||||
seenCandidates,
|
||||
firstFailure
|
||||
);
|
||||
if (cachedProbe.ok) {
|
||||
return cachedProbe;
|
||||
}
|
||||
firstFailure = cachedProbe.firstFailure;
|
||||
|
||||
const shellEnv = await resolveInteractiveShellEnvBestEffort({
|
||||
timeoutMs: options.shellEnvTimeoutMs ?? PATH_SHELL_ENV_TIMEOUT_MS,
|
||||
fallbackEnv: process.env,
|
||||
});
|
||||
return resolvePathOpenCodeBinary([shellEnv]);
|
||||
const shellProbe = await probeFirstWorkingOpenCodeBinaryCandidate(
|
||||
collectPathOpenCodeBinaryCandidates([shellEnv], {
|
||||
includeFallbackPathEntries: false,
|
||||
}),
|
||||
seenCandidates,
|
||||
firstFailure
|
||||
);
|
||||
if (shellProbe.ok) {
|
||||
return shellProbe;
|
||||
}
|
||||
firstFailure = shellProbe.firstFailure;
|
||||
|
||||
return probeFirstWorkingOpenCodeBinaryCandidate(
|
||||
collectPathOpenCodeBinaryCandidates([shellEnv]),
|
||||
seenCandidates,
|
||||
firstFailure
|
||||
);
|
||||
}
|
||||
|
||||
async function resolveVerifiedPathOpenCodeBinaryPath(
|
||||
options: { shellEnvTimeoutMs?: number } = {}
|
||||
): Promise<string | null> {
|
||||
const binaryPath = await resolvePathOpenCodeBinaryWithBestEffortEnv(options);
|
||||
if (!binaryPath) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (await probeOpenCodeBinaryVersion(binaryPath)).ok ? binaryPath : null;
|
||||
const result = await probeFirstWorkingPathOpenCodeBinary(options);
|
||||
return result.ok ? result.binaryPath : null;
|
||||
}
|
||||
|
||||
export async function resolveVerifiedOpenCodeRuntimeBinaryPath(
|
||||
|
|
@ -526,26 +633,25 @@ export class OpenCodeRuntimeInstallerService {
|
|||
}
|
||||
|
||||
private async getPathStatus(): Promise<OpenCodeRuntimeStatus> {
|
||||
const binaryPath = await resolvePathOpenCodeBinaryWithBestEffortEnv();
|
||||
if (!binaryPath) {
|
||||
return { installed: false, source: 'missing', state: 'idle' };
|
||||
}
|
||||
const version = await probeOpenCodeBinaryVersion(binaryPath);
|
||||
if (!version.ok) {
|
||||
const result = await probeFirstWorkingPathOpenCodeBinary();
|
||||
if (result.ok) {
|
||||
return {
|
||||
installed: false,
|
||||
binaryPath,
|
||||
installed: true,
|
||||
binaryPath: result.binaryPath,
|
||||
version: result.version ?? undefined,
|
||||
source: 'path',
|
||||
state: 'failed',
|
||||
error: version.error,
|
||||
state: 'ready',
|
||||
};
|
||||
}
|
||||
if (!result.firstFailure) {
|
||||
return { installed: false, source: 'missing', state: 'idle' };
|
||||
}
|
||||
return {
|
||||
installed: true,
|
||||
binaryPath,
|
||||
version: version.version ?? undefined,
|
||||
installed: false,
|
||||
binaryPath: result.firstFailure.binaryPath,
|
||||
source: 'path',
|
||||
state: 'ready',
|
||||
state: 'failed',
|
||||
error: result.firstFailure.error,
|
||||
};
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -134,6 +134,10 @@ export class OpenCodeBehaviorSourceScanner {
|
|||
kind: 'global_config',
|
||||
targetPath: path.join(this.homePath, '.config/opencode/opencode.json'),
|
||||
},
|
||||
{
|
||||
kind: 'global_config',
|
||||
targetPath: path.join(this.homePath, '.config/opencode/opencode.jsonc'),
|
||||
},
|
||||
{ kind: 'project_config', targetPath: path.join(projectPath, 'opencode.json') },
|
||||
{ kind: 'project_config', targetPath: path.join(projectPath, 'opencode.jsonc') },
|
||||
{
|
||||
|
|
@ -150,6 +154,7 @@ export class OpenCodeBehaviorSourceScanner {
|
|||
async readDeclaredMcpNames(projectPath: string): Promise<Set<string>> {
|
||||
const configPaths = [
|
||||
path.join(this.homePath, '.config/opencode/opencode.json'),
|
||||
path.join(this.homePath, '.config/opencode/opencode.jsonc'),
|
||||
path.join(projectPath, 'opencode.json'),
|
||||
path.join(projectPath, 'opencode.jsonc'),
|
||||
path.join(projectPath, '.opencode/opencode.json'),
|
||||
|
|
|
|||
|
|
@ -8,6 +8,7 @@ import { gzipSync } from 'zlib';
|
|||
const execCliMock = vi.hoisted(() => vi.fn());
|
||||
const buildMergedCliPathMock = vi.hoisted(() => vi.fn());
|
||||
const getCachedShellEnvMock = vi.hoisted(() => vi.fn());
|
||||
const getShellPreferredHomeMock = vi.hoisted(() => vi.fn());
|
||||
const resolveInteractiveShellEnvBestEffortMock = vi.hoisted(() => vi.fn());
|
||||
|
||||
vi.mock('@main/utils/childProcess', () => ({
|
||||
|
|
@ -20,6 +21,7 @@ vi.mock('@main/utils/cliPathMerge', () => ({
|
|||
|
||||
vi.mock('@main/utils/shellEnv', () => ({
|
||||
getCachedShellEnv: () => getCachedShellEnvMock(),
|
||||
getShellPreferredHome: () => getShellPreferredHomeMock(),
|
||||
resolveInteractiveShellEnvBestEffort: (
|
||||
...args: Parameters<typeof resolveInteractiveShellEnvBestEffortMock>
|
||||
) => resolveInteractiveShellEnvBestEffortMock(...args),
|
||||
|
|
@ -88,6 +90,8 @@ describe('OpenCodeRuntimeInstallerService resolver', () => {
|
|||
buildMergedCliPathMock.mockReturnValue('');
|
||||
getCachedShellEnvMock.mockReset();
|
||||
getCachedShellEnvMock.mockReturnValue(null);
|
||||
getShellPreferredHomeMock.mockReset();
|
||||
getShellPreferredHomeMock.mockReturnValue(os.homedir());
|
||||
resolveInteractiveShellEnvBestEffortMock.mockReset();
|
||||
resolveInteractiveShellEnvBestEffortMock.mockResolvedValue(process.env);
|
||||
});
|
||||
|
|
@ -218,12 +222,53 @@ describe('OpenCodeRuntimeInstallerService resolver', () => {
|
|||
});
|
||||
});
|
||||
|
||||
it('returns a verified OpenCode binary from the merged CLI PATH without interactive shell env resolution', async () => {
|
||||
it('returns a verified OpenCode binary from the merged CLI PATH after zero-wait shell fallback', async () => {
|
||||
const binaryPath = path.join(tempRoot!, 'merged-cli-path', 'bin', 'opencode');
|
||||
await mkdir(path.dirname(binaryPath), { recursive: true });
|
||||
await writeFile(binaryPath, 'binary', { mode: 0o755 });
|
||||
buildMergedCliPathMock.mockReturnValue(path.dirname(binaryPath));
|
||||
|
||||
await expect(resolveVerifiedOpenCodeRuntimeBinaryPath({ shellEnvTimeoutMs: 0 })).resolves.toBe(
|
||||
binaryPath
|
||||
);
|
||||
expect(resolveInteractiveShellEnvBestEffortMock).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
timeoutMs: 0,
|
||||
fallbackEnv: process.env,
|
||||
})
|
||||
);
|
||||
expect(execCliMock).toHaveBeenCalledWith(binaryPath, ['--version'], {
|
||||
timeout: 10_000,
|
||||
windowsHide: true,
|
||||
});
|
||||
});
|
||||
|
||||
it('returns a verified OpenCode binary from nvm when desktop PATH misses npm globals', async () => {
|
||||
const olderBinaryPath = path.join(
|
||||
tempRoot!,
|
||||
'.nvm',
|
||||
'versions',
|
||||
'node',
|
||||
'v20.10.0',
|
||||
'bin',
|
||||
'opencode'
|
||||
);
|
||||
const binaryPath = path.join(
|
||||
tempRoot!,
|
||||
'.nvm',
|
||||
'versions',
|
||||
'node',
|
||||
'v22.22.1',
|
||||
'bin',
|
||||
'opencode'
|
||||
);
|
||||
await mkdir(path.dirname(olderBinaryPath), { recursive: true });
|
||||
await mkdir(path.dirname(binaryPath), { recursive: true });
|
||||
await writeFile(olderBinaryPath, 'older binary', { mode: 0o755 });
|
||||
await writeFile(binaryPath, 'binary', { mode: 0o755 });
|
||||
getCachedShellEnvMock.mockReturnValue({ HOME: tempRoot! });
|
||||
getShellPreferredHomeMock.mockReturnValue(tempRoot!);
|
||||
|
||||
await expect(resolveVerifiedOpenCodeRuntimeBinaryPath({ shellEnvTimeoutMs: 0 })).resolves.toBe(
|
||||
binaryPath
|
||||
);
|
||||
|
|
@ -234,6 +279,130 @@ describe('OpenCodeRuntimeInstallerService resolver', () => {
|
|||
});
|
||||
});
|
||||
|
||||
it('returns a verified OpenCode cmd shim from nvm-windows when desktop PATH misses npm globals', async () => {
|
||||
const originalPlatform = process.platform;
|
||||
const originalAppData = process.env.APPDATA;
|
||||
|
||||
try {
|
||||
Object.defineProperty(process, 'platform', {
|
||||
value: 'win32',
|
||||
configurable: true,
|
||||
writable: true,
|
||||
});
|
||||
process.env.APPDATA = tempRoot!;
|
||||
|
||||
const olderBinaryPath = path.join(tempRoot!, 'nvm', 'v20.10.0', 'opencode.cmd');
|
||||
const binaryPath = path.join(tempRoot!, 'nvm', 'v22.22.1', 'opencode.cmd');
|
||||
await mkdir(path.dirname(olderBinaryPath), { recursive: true });
|
||||
await mkdir(path.dirname(binaryPath), { recursive: true });
|
||||
await writeFile(olderBinaryPath, 'older binary', { mode: 0o755 });
|
||||
await writeFile(binaryPath, 'binary', { mode: 0o755 });
|
||||
|
||||
await expect(
|
||||
resolveVerifiedOpenCodeRuntimeBinaryPath({ shellEnvTimeoutMs: 0 })
|
||||
).resolves.toBe(binaryPath);
|
||||
expect(resolveInteractiveShellEnvBestEffortMock).not.toHaveBeenCalled();
|
||||
expect(execCliMock).toHaveBeenCalledWith(binaryPath, ['--version'], {
|
||||
timeout: 10_000,
|
||||
windowsHide: true,
|
||||
});
|
||||
} finally {
|
||||
Object.defineProperty(process, 'platform', {
|
||||
value: originalPlatform,
|
||||
configurable: true,
|
||||
writable: true,
|
||||
});
|
||||
if (originalAppData === undefined) {
|
||||
delete process.env.APPDATA;
|
||||
} else {
|
||||
process.env.APPDATA = originalAppData;
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
it('skips a broken newer nvm OpenCode binary and reports the next working install', async () => {
|
||||
const brokenBinaryPath = path.join(
|
||||
tempRoot!,
|
||||
'.nvm',
|
||||
'versions',
|
||||
'node',
|
||||
'v23.0.0',
|
||||
'bin',
|
||||
'opencode'
|
||||
);
|
||||
const workingBinaryPath = path.join(
|
||||
tempRoot!,
|
||||
'.nvm',
|
||||
'versions',
|
||||
'node',
|
||||
'v22.22.1',
|
||||
'bin',
|
||||
'opencode'
|
||||
);
|
||||
await mkdir(path.dirname(brokenBinaryPath), { recursive: true });
|
||||
await mkdir(path.dirname(workingBinaryPath), { recursive: true });
|
||||
await writeFile(brokenBinaryPath, 'broken binary', { mode: 0o755 });
|
||||
await writeFile(workingBinaryPath, 'working binary', { mode: 0o755 });
|
||||
getCachedShellEnvMock.mockReturnValue({ HOME: tempRoot! });
|
||||
getShellPreferredHomeMock.mockReturnValue(tempRoot!);
|
||||
execCliMock.mockImplementation(async (binaryPath: string) => {
|
||||
if (binaryPath === brokenBinaryPath) {
|
||||
throw new Error('broken nvm runtime');
|
||||
}
|
||||
return { stdout: 'opencode 1.15.6\n', stderr: '' };
|
||||
});
|
||||
|
||||
await expect(resolveVerifiedOpenCodeRuntimeBinaryPath({ shellEnvTimeoutMs: 0 })).resolves.toBe(
|
||||
workingBinaryPath
|
||||
);
|
||||
await expect(new OpenCodeRuntimeInstallerService().getStatus()).resolves.toMatchObject({
|
||||
installed: true,
|
||||
source: 'path',
|
||||
state: 'ready',
|
||||
binaryPath: workingBinaryPath,
|
||||
version: 'opencode 1.15.6',
|
||||
});
|
||||
});
|
||||
|
||||
it('falls through to shell PATH when all fast nvm candidates are broken', async () => {
|
||||
const brokenBinaryPath = path.join(
|
||||
tempRoot!,
|
||||
'.nvm',
|
||||
'versions',
|
||||
'node',
|
||||
'v23.0.0',
|
||||
'bin',
|
||||
'opencode'
|
||||
);
|
||||
const shellBinaryPath = path.join(tempRoot!, 'custom-npm-prefix', 'bin', 'opencode');
|
||||
await mkdir(path.dirname(brokenBinaryPath), { recursive: true });
|
||||
await mkdir(path.dirname(shellBinaryPath), { recursive: true });
|
||||
await writeFile(brokenBinaryPath, 'broken binary', { mode: 0o755 });
|
||||
await writeFile(shellBinaryPath, 'working binary', { mode: 0o755 });
|
||||
getCachedShellEnvMock.mockReturnValue({ HOME: tempRoot! });
|
||||
getShellPreferredHomeMock.mockReturnValue(tempRoot!);
|
||||
resolveInteractiveShellEnvBestEffortMock.mockResolvedValue({
|
||||
PATH: path.dirname(shellBinaryPath),
|
||||
HOME: tempRoot!,
|
||||
});
|
||||
execCliMock.mockImplementation(async (binaryPath: string) => {
|
||||
if (binaryPath === brokenBinaryPath) {
|
||||
throw new Error('broken nvm runtime');
|
||||
}
|
||||
return { stdout: 'opencode 1.15.6\n', stderr: '' };
|
||||
});
|
||||
|
||||
await expect(resolveVerifiedOpenCodeRuntimeBinaryPath({ shellEnvTimeoutMs: 0 })).resolves.toBe(
|
||||
shellBinaryPath
|
||||
);
|
||||
expect(resolveInteractiveShellEnvBestEffortMock).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
timeoutMs: 0,
|
||||
fallbackEnv: process.env,
|
||||
})
|
||||
);
|
||||
});
|
||||
|
||||
it('reports PATH-installed OpenCode as installed after best-effort shell env resolution', async () => {
|
||||
const binaryPath = path.join(tempRoot!, 'homebrew', 'bin', 'opencode');
|
||||
await mkdir(path.dirname(binaryPath), { recursive: true });
|
||||
|
|
|
|||
|
|
@ -0,0 +1,122 @@
|
|||
// @vitest-environment node
|
||||
/* eslint-disable security/detect-non-literal-fs-filename */
|
||||
import { chmod, mkdir, mkdtemp, rm, writeFile } from 'node:fs/promises';
|
||||
import os from 'node:os';
|
||||
import path from 'node:path';
|
||||
|
||||
import { afterEach, beforeEach, describe, expect, it } from 'vitest';
|
||||
|
||||
import {
|
||||
OpenCodeRuntimeInstallerService,
|
||||
resolveVerifiedOpenCodeRuntimeBinaryPath,
|
||||
} from '../../../../src/main/services/infrastructure/OpenCodeRuntimeInstallerService';
|
||||
import { ensureOpenCodeBridgeRuntimeBinaryEnv } from '../../../../src/main/services/runtime/openCodeBridgeRuntimeEnv';
|
||||
import { execCli } from '../../../../src/main/utils/childProcess';
|
||||
import { setAppDataBasePath } from '../../../../src/main/utils/pathDecoder';
|
||||
import { clearShellEnvCache } from '../../../../src/main/utils/shellEnv';
|
||||
|
||||
const describePosix = process.platform === 'win32' ? describe.skip : describe;
|
||||
|
||||
describePosix('OpenCode nvm runtime resolution safe e2e', () => {
|
||||
let tempDir: string | null = null;
|
||||
let originalHome: string | undefined;
|
||||
let originalPath: string | undefined;
|
||||
let originalShell: string | undefined;
|
||||
|
||||
beforeEach(async () => {
|
||||
tempDir = await mkdtemp(path.join(os.tmpdir(), 'opencode-nvm-resolution-e2e-'));
|
||||
setAppDataBasePath(path.join(tempDir, 'app-data'));
|
||||
clearShellEnvCache();
|
||||
|
||||
originalHome = process.env.HOME;
|
||||
originalPath = process.env.PATH;
|
||||
originalShell = process.env.SHELL;
|
||||
process.env.HOME = tempDir;
|
||||
process.env.PATH = '';
|
||||
process.env.SHELL = path.join(tempDir, 'missing-shell');
|
||||
});
|
||||
|
||||
afterEach(async () => {
|
||||
clearShellEnvCache();
|
||||
setAppDataBasePath(null);
|
||||
|
||||
restoreEnvValue('HOME', originalHome);
|
||||
restoreEnvValue('PATH', originalPath);
|
||||
restoreEnvValue('SHELL', originalShell);
|
||||
|
||||
if (tempDir) {
|
||||
await rm(tempDir, { recursive: true, force: true });
|
||||
tempDir = null;
|
||||
}
|
||||
});
|
||||
|
||||
it('reports and launches an npm global OpenCode binary installed under nvm when GUI PATH is empty', async () => {
|
||||
await createFakeNvmOpenCodeBinary('v23.0.0', { broken: true });
|
||||
const binaryPath = await createFakeNvmOpenCodeBinary('v22.22.1');
|
||||
const binDir = path.dirname(binaryPath);
|
||||
|
||||
await expect(resolveVerifiedOpenCodeRuntimeBinaryPath({ shellEnvTimeoutMs: 0 })).resolves.toBe(
|
||||
binaryPath
|
||||
);
|
||||
|
||||
await expect(new OpenCodeRuntimeInstallerService().getStatus()).resolves.toMatchObject({
|
||||
installed: true,
|
||||
source: 'path',
|
||||
state: 'ready',
|
||||
binaryPath,
|
||||
version: 'opencode 1.15.6',
|
||||
});
|
||||
|
||||
const bridgeEnv: NodeJS.ProcessEnv = { PATH: '' };
|
||||
await ensureOpenCodeBridgeRuntimeBinaryEnv({
|
||||
targetEnv: bridgeEnv,
|
||||
bridgeEnv,
|
||||
resolveVerifiedOpenCodeRuntimeBinaryPath,
|
||||
});
|
||||
|
||||
expect(bridgeEnv.CLAUDE_MULTIMODEL_OPENCODE_BIN_PATH).toBe(binaryPath);
|
||||
expect(bridgeEnv.OPENCODE_BIN_PATH).toBe(binaryPath);
|
||||
expect(bridgeEnv.PATH?.split(path.delimiter)[0]).toBe(binDir);
|
||||
|
||||
const version = await execCli('opencode', ['--version'], {
|
||||
env: bridgeEnv,
|
||||
timeout: 2_000,
|
||||
windowsHide: true,
|
||||
});
|
||||
expect(version.stdout.trim()).toBe('opencode 1.15.6');
|
||||
});
|
||||
|
||||
async function createFakeNvmOpenCodeBinary(
|
||||
version: string,
|
||||
options: { broken?: boolean } = {}
|
||||
): Promise<string> {
|
||||
const binDir = path.join(tempDir!, '.nvm', 'versions', 'node', version, 'bin');
|
||||
const binaryPath = path.join(binDir, 'opencode');
|
||||
await mkdir(binDir, { recursive: true });
|
||||
await writeFile(
|
||||
binaryPath,
|
||||
options.broken
|
||||
? ['#!/bin/sh', 'echo "broken opencode" >&2', 'exit 2'].join('\n')
|
||||
: [
|
||||
'#!/bin/sh',
|
||||
'if [ "$1" = "--version" ]; then',
|
||||
' echo "opencode 1.15.6"',
|
||||
' exit 0',
|
||||
'fi',
|
||||
'echo "unexpected opencode args: $*" >&2',
|
||||
'exit 2',
|
||||
].join('\n'),
|
||||
'utf8'
|
||||
);
|
||||
await chmod(binaryPath, 0o755);
|
||||
return binaryPath;
|
||||
}
|
||||
});
|
||||
|
||||
function restoreEnvValue(name: string, value: string | undefined): void {
|
||||
if (value === undefined) {
|
||||
delete process.env[name];
|
||||
} else {
|
||||
process.env[name] = value;
|
||||
}
|
||||
}
|
||||
|
|
@ -1,7 +1,6 @@
|
|||
import { promises as fs } from 'fs';
|
||||
import * as os from 'os';
|
||||
import * as path from 'path';
|
||||
|
||||
import { afterEach, beforeEach, describe, expect, it } from 'vitest';
|
||||
|
||||
import {
|
||||
|
|
@ -154,6 +153,35 @@ describe('OpenCodeManagedOverlay', () => {
|
|||
);
|
||||
});
|
||||
|
||||
it('reads JSONC global config as a user-owned behavior source', async () => {
|
||||
await fs.mkdir(path.join(homePath, '.config/opencode'), { recursive: true });
|
||||
await fs.writeFile(
|
||||
path.join(homePath, '.config/opencode/opencode.jsonc'),
|
||||
`{
|
||||
// global user-owned MCP must be observed, not overwritten
|
||||
"mcp": {
|
||||
"agent-teams": { "type": "local", "command": "custom", "enabled": true }
|
||||
}
|
||||
}`,
|
||||
'utf8'
|
||||
);
|
||||
const scanner = new OpenCodeBehaviorSourceScanner({ homePath });
|
||||
|
||||
await expect(scanner.readDeclaredMcpNames(projectPath)).resolves.toEqual(
|
||||
new Set(['agent-teams'])
|
||||
);
|
||||
const sources = await scanner.scan(projectPath);
|
||||
|
||||
expect(sources).toContainEqual(
|
||||
expect.objectContaining({
|
||||
kind: 'global_config',
|
||||
exists: true,
|
||||
fileCount: 1,
|
||||
fingerprint: expect.stringMatching(/^[a-f0-9]{64}$/),
|
||||
})
|
||||
);
|
||||
});
|
||||
|
||||
it('rejects managed overlays that would shadow user behavior keys', () => {
|
||||
expect(() =>
|
||||
assertManagedOverlayDoesNotShadowUserConfig({
|
||||
|
|
|
|||
Loading…
Reference in a new issue