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 { buildMergedCliPath } from '@main/utils/cliPathMerge';
|
||||||
import { getAppDataPath } from '@main/utils/pathDecoder';
|
import { getAppDataPath } from '@main/utils/pathDecoder';
|
||||||
import { safeSendToRenderer } from '@main/utils/safeWebContentsSend';
|
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 { getErrorMessage } from '@shared/utils/errorHandling';
|
||||||
import { createLogger } from '@shared/utils/logger';
|
import { createLogger } from '@shared/utils/logger';
|
||||||
import { createHash, randomUUID } from 'crypto';
|
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 path from 'path';
|
||||||
import { gunzipSync } from 'zlib';
|
import { gunzipSync } from 'zlib';
|
||||||
|
|
||||||
|
|
@ -136,17 +140,32 @@ function splitPathEnv(pathValue: string | undefined): string[] {
|
||||||
.filter(Boolean);
|
.filter(Boolean);
|
||||||
}
|
}
|
||||||
|
|
||||||
function resolvePathOpenCodeBinary(
|
function collectPathOpenCodeBinaryCandidates(
|
||||||
additionalEnvSources: (NodeJS.ProcessEnv | null | undefined)[] = []
|
additionalEnvSources: (NodeJS.ProcessEnv | null | undefined)[] = [],
|
||||||
): string | null {
|
options: { includeFallbackPathEntries?: boolean } = {}
|
||||||
|
): string[] {
|
||||||
const shellEnv = getCachedShellEnv() ?? {};
|
const shellEnv = getCachedShellEnv() ?? {};
|
||||||
const pathEntries = [
|
const directPathEntries = [
|
||||||
...additionalEnvSources.flatMap((env) => splitPathEnv(env?.PATH)),
|
...additionalEnvSources.flatMap((env) => splitPathEnv(env?.PATH)),
|
||||||
...splitPathEnv(shellEnv.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>();
|
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) {
|
for (const entry of pathEntries) {
|
||||||
const normalizedEntry = path.resolve(entry);
|
const normalizedEntry = path.resolve(entry);
|
||||||
if (seen.has(normalizedEntry)) {
|
if (seen.has(normalizedEntry)) {
|
||||||
|
|
@ -156,17 +175,56 @@ function resolvePathOpenCodeBinary(
|
||||||
for (const executableName of getPathExecutableNames()) {
|
for (const executableName of getPathExecutableNames()) {
|
||||||
const candidate = path.join(normalizedEntry, executableName);
|
const candidate = path.join(normalizedEntry, executableName);
|
||||||
if (isAbsoluteExistingFile(candidate)) {
|
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 =
|
type OpenCodeBinaryVersionProbe =
|
||||||
| { ok: true; version: string | null }
|
| { ok: true; version: string | null }
|
||||||
| { ok: false; error: string };
|
| { 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> {
|
async function probeOpenCodeBinaryVersion(binaryPath: string): Promise<OpenCodeBinaryVersionProbe> {
|
||||||
try {
|
try {
|
||||||
const { stdout } = await execCli(binaryPath, ['--version'], {
|
const { stdout } = await execCli(binaryPath, ['--version'], {
|
||||||
|
|
@ -179,30 +237,79 @@ async function probeOpenCodeBinaryVersion(binaryPath: string): Promise<OpenCodeB
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async function resolvePathOpenCodeBinaryWithBestEffortEnv(
|
function normalizeBinaryCandidateForCompare(binaryPath: string): string {
|
||||||
options: { shellEnvTimeoutMs?: number } = {}
|
const normalized = path.resolve(binaryPath);
|
||||||
): Promise<string | null> {
|
return process.platform === 'win32' ? normalized.toLowerCase() : normalized;
|
||||||
const cachedCandidate = resolvePathOpenCodeBinary();
|
}
|
||||||
if (cachedCandidate) {
|
|
||||||
return cachedCandidate;
|
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({
|
const shellEnv = await resolveInteractiveShellEnvBestEffort({
|
||||||
timeoutMs: options.shellEnvTimeoutMs ?? PATH_SHELL_ENV_TIMEOUT_MS,
|
timeoutMs: options.shellEnvTimeoutMs ?? PATH_SHELL_ENV_TIMEOUT_MS,
|
||||||
fallbackEnv: process.env,
|
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(
|
async function resolveVerifiedPathOpenCodeBinaryPath(
|
||||||
options: { shellEnvTimeoutMs?: number } = {}
|
options: { shellEnvTimeoutMs?: number } = {}
|
||||||
): Promise<string | null> {
|
): Promise<string | null> {
|
||||||
const binaryPath = await resolvePathOpenCodeBinaryWithBestEffortEnv(options);
|
const result = await probeFirstWorkingPathOpenCodeBinary(options);
|
||||||
if (!binaryPath) {
|
return result.ok ? result.binaryPath : null;
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
return (await probeOpenCodeBinaryVersion(binaryPath)).ok ? binaryPath : null;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function resolveVerifiedOpenCodeRuntimeBinaryPath(
|
export async function resolveVerifiedOpenCodeRuntimeBinaryPath(
|
||||||
|
|
@ -526,26 +633,25 @@ export class OpenCodeRuntimeInstallerService {
|
||||||
}
|
}
|
||||||
|
|
||||||
private async getPathStatus(): Promise<OpenCodeRuntimeStatus> {
|
private async getPathStatus(): Promise<OpenCodeRuntimeStatus> {
|
||||||
const binaryPath = await resolvePathOpenCodeBinaryWithBestEffortEnv();
|
const result = await probeFirstWorkingPathOpenCodeBinary();
|
||||||
if (!binaryPath) {
|
if (result.ok) {
|
||||||
return { installed: false, source: 'missing', state: 'idle' };
|
|
||||||
}
|
|
||||||
const version = await probeOpenCodeBinaryVersion(binaryPath);
|
|
||||||
if (!version.ok) {
|
|
||||||
return {
|
return {
|
||||||
installed: false,
|
installed: true,
|
||||||
binaryPath,
|
binaryPath: result.binaryPath,
|
||||||
|
version: result.version ?? undefined,
|
||||||
source: 'path',
|
source: 'path',
|
||||||
state: 'failed',
|
state: 'ready',
|
||||||
error: version.error,
|
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
if (!result.firstFailure) {
|
||||||
|
return { installed: false, source: 'missing', state: 'idle' };
|
||||||
|
}
|
||||||
return {
|
return {
|
||||||
installed: true,
|
installed: false,
|
||||||
binaryPath,
|
binaryPath: result.firstFailure.binaryPath,
|
||||||
version: version.version ?? undefined,
|
|
||||||
source: 'path',
|
source: 'path',
|
||||||
state: 'ready',
|
state: 'failed',
|
||||||
|
error: result.firstFailure.error,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -134,6 +134,10 @@ export class OpenCodeBehaviorSourceScanner {
|
||||||
kind: 'global_config',
|
kind: 'global_config',
|
||||||
targetPath: path.join(this.homePath, '.config/opencode/opencode.json'),
|
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.json') },
|
||||||
{ kind: 'project_config', targetPath: path.join(projectPath, 'opencode.jsonc') },
|
{ kind: 'project_config', targetPath: path.join(projectPath, 'opencode.jsonc') },
|
||||||
{
|
{
|
||||||
|
|
@ -150,6 +154,7 @@ export class OpenCodeBehaviorSourceScanner {
|
||||||
async readDeclaredMcpNames(projectPath: string): Promise<Set<string>> {
|
async readDeclaredMcpNames(projectPath: string): Promise<Set<string>> {
|
||||||
const configPaths = [
|
const configPaths = [
|
||||||
path.join(this.homePath, '.config/opencode/opencode.json'),
|
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.json'),
|
||||||
path.join(projectPath, 'opencode.jsonc'),
|
path.join(projectPath, 'opencode.jsonc'),
|
||||||
path.join(projectPath, '.opencode/opencode.json'),
|
path.join(projectPath, '.opencode/opencode.json'),
|
||||||
|
|
|
||||||
|
|
@ -8,6 +8,7 @@ import { gzipSync } from 'zlib';
|
||||||
const execCliMock = vi.hoisted(() => vi.fn());
|
const execCliMock = vi.hoisted(() => vi.fn());
|
||||||
const buildMergedCliPathMock = vi.hoisted(() => vi.fn());
|
const buildMergedCliPathMock = vi.hoisted(() => vi.fn());
|
||||||
const getCachedShellEnvMock = vi.hoisted(() => vi.fn());
|
const getCachedShellEnvMock = vi.hoisted(() => vi.fn());
|
||||||
|
const getShellPreferredHomeMock = vi.hoisted(() => vi.fn());
|
||||||
const resolveInteractiveShellEnvBestEffortMock = vi.hoisted(() => vi.fn());
|
const resolveInteractiveShellEnvBestEffortMock = vi.hoisted(() => vi.fn());
|
||||||
|
|
||||||
vi.mock('@main/utils/childProcess', () => ({
|
vi.mock('@main/utils/childProcess', () => ({
|
||||||
|
|
@ -20,6 +21,7 @@ vi.mock('@main/utils/cliPathMerge', () => ({
|
||||||
|
|
||||||
vi.mock('@main/utils/shellEnv', () => ({
|
vi.mock('@main/utils/shellEnv', () => ({
|
||||||
getCachedShellEnv: () => getCachedShellEnvMock(),
|
getCachedShellEnv: () => getCachedShellEnvMock(),
|
||||||
|
getShellPreferredHome: () => getShellPreferredHomeMock(),
|
||||||
resolveInteractiveShellEnvBestEffort: (
|
resolveInteractiveShellEnvBestEffort: (
|
||||||
...args: Parameters<typeof resolveInteractiveShellEnvBestEffortMock>
|
...args: Parameters<typeof resolveInteractiveShellEnvBestEffortMock>
|
||||||
) => resolveInteractiveShellEnvBestEffortMock(...args),
|
) => resolveInteractiveShellEnvBestEffortMock(...args),
|
||||||
|
|
@ -88,6 +90,8 @@ describe('OpenCodeRuntimeInstallerService resolver', () => {
|
||||||
buildMergedCliPathMock.mockReturnValue('');
|
buildMergedCliPathMock.mockReturnValue('');
|
||||||
getCachedShellEnvMock.mockReset();
|
getCachedShellEnvMock.mockReset();
|
||||||
getCachedShellEnvMock.mockReturnValue(null);
|
getCachedShellEnvMock.mockReturnValue(null);
|
||||||
|
getShellPreferredHomeMock.mockReset();
|
||||||
|
getShellPreferredHomeMock.mockReturnValue(os.homedir());
|
||||||
resolveInteractiveShellEnvBestEffortMock.mockReset();
|
resolveInteractiveShellEnvBestEffortMock.mockReset();
|
||||||
resolveInteractiveShellEnvBestEffortMock.mockResolvedValue(process.env);
|
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');
|
const binaryPath = path.join(tempRoot!, 'merged-cli-path', 'bin', 'opencode');
|
||||||
await mkdir(path.dirname(binaryPath), { recursive: true });
|
await mkdir(path.dirname(binaryPath), { recursive: true });
|
||||||
await writeFile(binaryPath, 'binary', { mode: 0o755 });
|
await writeFile(binaryPath, 'binary', { mode: 0o755 });
|
||||||
buildMergedCliPathMock.mockReturnValue(path.dirname(binaryPath));
|
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(
|
await expect(resolveVerifiedOpenCodeRuntimeBinaryPath({ shellEnvTimeoutMs: 0 })).resolves.toBe(
|
||||||
binaryPath
|
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 () => {
|
it('reports PATH-installed OpenCode as installed after best-effort shell env resolution', async () => {
|
||||||
const binaryPath = path.join(tempRoot!, 'homebrew', 'bin', 'opencode');
|
const binaryPath = path.join(tempRoot!, 'homebrew', 'bin', 'opencode');
|
||||||
await mkdir(path.dirname(binaryPath), { recursive: true });
|
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 { promises as fs } from 'fs';
|
||||||
import * as os from 'os';
|
import * as os from 'os';
|
||||||
import * as path from 'path';
|
import * as path from 'path';
|
||||||
|
|
||||||
import { afterEach, beforeEach, describe, expect, it } from 'vitest';
|
import { afterEach, beforeEach, describe, expect, it } from 'vitest';
|
||||||
|
|
||||||
import {
|
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', () => {
|
it('rejects managed overlays that would shadow user behavior keys', () => {
|
||||||
expect(() =>
|
expect(() =>
|
||||||
assertManagedOverlayDoesNotShadowUserConfig({
|
assertManagedOverlayDoesNotShadowUserConfig({
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue