721 lines
23 KiB
TypeScript
721 lines
23 KiB
TypeScript
import { execCli } from '@main/utils/childProcess';
|
|
import { getAppDataPath } from '@main/utils/pathDecoder';
|
|
import {
|
|
collectRuntimePathBinaryCandidates,
|
|
isAbsoluteExistingFile,
|
|
RUNTIME_PATH_SHELL_ENV_TIMEOUT_MS,
|
|
} from '@main/utils/runtimePathBinaryResolver';
|
|
import { safeSendToRenderer } from '@main/utils/safeWebContentsSend';
|
|
import { getShellPreferredHome, resolveInteractiveShellEnvBestEffort } from '@main/utils/shellEnv';
|
|
import { getErrorMessage } from '@shared/utils/errorHandling';
|
|
import { createLogger } from '@shared/utils/logger';
|
|
import { createHash, randomUUID } from 'crypto';
|
|
import { promises as fsp, readdirSync, readFileSync } from 'fs';
|
|
import path from 'path';
|
|
import { gunzipSync } from 'zlib';
|
|
|
|
import type { OpenCodeRuntimeInstallProgress, OpenCodeRuntimeStatus } from '@shared/types';
|
|
import type { BrowserWindow } from 'electron';
|
|
|
|
const logger = createLogger('OpenCodeRuntimeInstallerService');
|
|
|
|
const CHANNEL = 'openCodeRuntime:progress';
|
|
const ROOT_PACKAGE_NAME = 'opencode-ai';
|
|
const NPM_REGISTRY_BASE_URL = 'https://registry.npmjs.org';
|
|
const CURRENT_MANIFEST_SCHEMA_VERSION = 1;
|
|
const MAX_TARBALL_BYTES = 250 * 1024 * 1024;
|
|
const MAX_BINARY_BYTES = 350 * 1024 * 1024;
|
|
const FETCH_TIMEOUT_MS = 60_000;
|
|
const VERSION_TIMEOUT_MS = 10_000;
|
|
|
|
interface NpmPackageMetadata {
|
|
name?: string;
|
|
version?: string;
|
|
dist?: {
|
|
tarball?: string;
|
|
integrity?: string;
|
|
};
|
|
optionalDependencies?: Record<string, string>;
|
|
}
|
|
|
|
interface OpenCodeRuntimeManifest {
|
|
schemaVersion: 1;
|
|
version: string;
|
|
platformPackage: string;
|
|
binaryPath: string;
|
|
integrity: string;
|
|
installedAt: string;
|
|
}
|
|
|
|
export interface PlatformCandidate {
|
|
packageName: string;
|
|
reason: string;
|
|
}
|
|
|
|
function getRuntimeRootPath(): string {
|
|
return path.join(getAppDataPath(), 'runtimes', 'opencode');
|
|
}
|
|
|
|
function getCurrentManifestPath(): string {
|
|
return path.join(getRuntimeRootPath(), 'current.json');
|
|
}
|
|
|
|
function parseManifest(value: unknown): OpenCodeRuntimeManifest | null {
|
|
if (!value || typeof value !== 'object' || Array.isArray(value)) {
|
|
return null;
|
|
}
|
|
const manifest = value as Partial<OpenCodeRuntimeManifest>;
|
|
if (
|
|
manifest.schemaVersion !== CURRENT_MANIFEST_SCHEMA_VERSION ||
|
|
typeof manifest.version !== 'string' ||
|
|
typeof manifest.platformPackage !== 'string' ||
|
|
typeof manifest.binaryPath !== 'string' ||
|
|
typeof manifest.integrity !== 'string' ||
|
|
typeof manifest.installedAt !== 'string'
|
|
) {
|
|
return null;
|
|
}
|
|
return manifest as OpenCodeRuntimeManifest;
|
|
}
|
|
|
|
function readCurrentManifestSync(): OpenCodeRuntimeManifest | null {
|
|
try {
|
|
const raw = readFileSync(getCurrentManifestPath(), 'utf8');
|
|
return parseManifest(JSON.parse(raw));
|
|
} catch {
|
|
return null;
|
|
}
|
|
}
|
|
|
|
export function resolveAppManagedOpenCodeRuntimeBinaryPath(): string | null {
|
|
const manifest = readCurrentManifestSync();
|
|
return isAbsoluteExistingFile(manifest?.binaryPath) ? manifest.binaryPath : null;
|
|
}
|
|
|
|
export async function resolveVerifiedAppManagedOpenCodeRuntimeBinaryPath(): Promise<string | null> {
|
|
const binaryPath = resolveAppManagedOpenCodeRuntimeBinaryPath();
|
|
if (!binaryPath) {
|
|
return null;
|
|
}
|
|
try {
|
|
await execCli(binaryPath, ['--version'], {
|
|
timeout: VERSION_TIMEOUT_MS,
|
|
windowsHide: true,
|
|
});
|
|
return binaryPath;
|
|
} catch {
|
|
return null;
|
|
}
|
|
}
|
|
|
|
function getExecutableName(): string {
|
|
return process.platform === 'win32' ? 'opencode.exe' : 'opencode';
|
|
}
|
|
|
|
function getPathExecutableNames(): string[] {
|
|
return process.platform === 'win32'
|
|
? ['opencode.exe', 'opencode.cmd', 'opencode.bat', 'opencode']
|
|
: ['opencode'];
|
|
}
|
|
|
|
function collectPathOpenCodeBinaryCandidates(
|
|
additionalEnvSources: (NodeJS.ProcessEnv | null | undefined)[] = [],
|
|
options: { includeFallbackPathEntries?: boolean } = {}
|
|
): string[] {
|
|
return collectRuntimePathBinaryCandidates({
|
|
executableNames: getPathExecutableNames(),
|
|
additionalEnvSources,
|
|
includeFallbackPathEntries: options.includeFallbackPathEntries,
|
|
extraCandidates: collectNvmOpenCodeBinaryCandidates(),
|
|
});
|
|
}
|
|
|
|
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'], {
|
|
timeout: VERSION_TIMEOUT_MS,
|
|
windowsHide: true,
|
|
});
|
|
return { ok: true, version: stdout.trim() || null };
|
|
} catch (error) {
|
|
return { ok: false, error: getErrorMessage(error) };
|
|
}
|
|
}
|
|
|
|
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 };
|
|
}
|
|
|
|
interface OpenCodeRuntimeBinaryResolveOptions {
|
|
shellEnvTimeoutMs?: number;
|
|
includeShellEnv?: boolean;
|
|
}
|
|
|
|
async function probeFirstWorkingPathOpenCodeBinary(
|
|
options: OpenCodeRuntimeBinaryResolveOptions = {}
|
|
): 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;
|
|
|
|
if (options.includeShellEnv === false) {
|
|
return probeFirstWorkingOpenCodeBinaryCandidate(
|
|
collectPathOpenCodeBinaryCandidates([], {
|
|
includeFallbackPathEntries: true,
|
|
}),
|
|
seenCandidates,
|
|
firstFailure
|
|
);
|
|
}
|
|
|
|
const shellEnv = await resolveInteractiveShellEnvBestEffort({
|
|
timeoutMs: options.shellEnvTimeoutMs ?? RUNTIME_PATH_SHELL_ENV_TIMEOUT_MS,
|
|
fallbackEnv: process.env,
|
|
});
|
|
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: OpenCodeRuntimeBinaryResolveOptions = {}
|
|
): Promise<string | null> {
|
|
const result = await probeFirstWorkingPathOpenCodeBinary(options);
|
|
return result.ok ? result.binaryPath : null;
|
|
}
|
|
|
|
export async function resolveVerifiedOpenCodeRuntimeBinaryPath(
|
|
options: OpenCodeRuntimeBinaryResolveOptions = {}
|
|
): Promise<string | null> {
|
|
return (
|
|
(await resolveVerifiedAppManagedOpenCodeRuntimeBinaryPath()) ??
|
|
(await resolveVerifiedPathOpenCodeBinaryPath(options))
|
|
);
|
|
}
|
|
|
|
function isLinuxMuslRuntime(): boolean {
|
|
if (process.platform !== 'linux') {
|
|
return false;
|
|
}
|
|
const report =
|
|
typeof process.report?.getReport === 'function'
|
|
? (process.report.getReport() as { header?: { glibcVersionRuntime?: string } })
|
|
: null;
|
|
const header = report?.header;
|
|
return !header?.glibcVersionRuntime;
|
|
}
|
|
|
|
export function getOpenCodeRuntimePlatformCandidates(
|
|
platform: NodeJS.Platform = process.platform,
|
|
arch: string = process.arch,
|
|
musl: boolean = isLinuxMuslRuntime()
|
|
): PlatformCandidate[] {
|
|
if (platform === 'darwin') {
|
|
if (arch === 'arm64') return [{ packageName: 'opencode-darwin-arm64', reason: 'macOS arm64' }];
|
|
if (arch === 'x64') {
|
|
return [
|
|
{ packageName: 'opencode-darwin-x64', reason: 'macOS x64' },
|
|
{ packageName: 'opencode-darwin-x64-baseline', reason: 'macOS x64 baseline fallback' },
|
|
];
|
|
}
|
|
}
|
|
if (platform === 'linux') {
|
|
if (arch === 'arm64') {
|
|
return musl
|
|
? [
|
|
{ packageName: 'opencode-linux-arm64-musl', reason: 'Linux arm64 musl' },
|
|
{ packageName: 'opencode-linux-arm64', reason: 'Linux arm64 glibc fallback' },
|
|
]
|
|
: [
|
|
{ packageName: 'opencode-linux-arm64', reason: 'Linux arm64 glibc' },
|
|
{ packageName: 'opencode-linux-arm64-musl', reason: 'Linux arm64 musl fallback' },
|
|
];
|
|
}
|
|
if (arch === 'x64') {
|
|
return musl
|
|
? [
|
|
{ packageName: 'opencode-linux-x64-musl', reason: 'Linux x64 musl' },
|
|
{
|
|
packageName: 'opencode-linux-x64-baseline-musl',
|
|
reason: 'Linux x64 musl baseline fallback',
|
|
},
|
|
{ packageName: 'opencode-linux-x64', reason: 'Linux x64 glibc fallback' },
|
|
]
|
|
: [
|
|
{ packageName: 'opencode-linux-x64', reason: 'Linux x64 glibc' },
|
|
{ packageName: 'opencode-linux-x64-baseline', reason: 'Linux x64 baseline fallback' },
|
|
{ packageName: 'opencode-linux-x64-musl', reason: 'Linux x64 musl fallback' },
|
|
];
|
|
}
|
|
}
|
|
if (platform === 'win32') {
|
|
if (arch === 'arm64')
|
|
return [{ packageName: 'opencode-windows-arm64', reason: 'Windows arm64' }];
|
|
if (arch === 'x64') {
|
|
return [
|
|
{ packageName: 'opencode-windows-x64', reason: 'Windows x64' },
|
|
{ packageName: 'opencode-windows-x64-baseline', reason: 'Windows x64 baseline fallback' },
|
|
];
|
|
}
|
|
}
|
|
throw new Error(`OpenCode app install is not supported on ${platform}/${arch}`);
|
|
}
|
|
|
|
async function fetchText(url: string): Promise<string> {
|
|
const controller = new AbortController();
|
|
const timer = setTimeout(() => controller.abort(), FETCH_TIMEOUT_MS);
|
|
try {
|
|
const response = await fetch(url, { signal: controller.signal });
|
|
if (!response.ok) {
|
|
throw new Error(`HTTP ${response.status} from ${url}`);
|
|
}
|
|
return await response.text();
|
|
} finally {
|
|
clearTimeout(timer);
|
|
}
|
|
}
|
|
|
|
async function fetchPackageMetadata(
|
|
packageName: string,
|
|
version = 'latest'
|
|
): Promise<NpmPackageMetadata> {
|
|
const url = `${NPM_REGISTRY_BASE_URL}/${encodeURIComponent(packageName)}/${encodeURIComponent(version)}`;
|
|
const raw = await fetchText(url);
|
|
const parsed = JSON.parse(raw) as NpmPackageMetadata;
|
|
if (!parsed.version || !parsed.dist?.tarball || !parsed.dist.integrity) {
|
|
throw new Error(`Invalid npm metadata for ${packageName}@${version}`);
|
|
}
|
|
return parsed;
|
|
}
|
|
|
|
export function verifyOpenCodeRuntimePackageIntegrity(buffer: Buffer, integrity: string): void {
|
|
const match = /^sha512-([A-Za-z0-9+/=]+)$/.exec(integrity.trim());
|
|
if (!match) {
|
|
throw new Error('OpenCode package integrity is missing sha512 metadata');
|
|
}
|
|
const actual = createHash('sha512').update(buffer).digest('base64');
|
|
if (actual !== match[1]) {
|
|
throw new Error('OpenCode package integrity check failed');
|
|
}
|
|
}
|
|
|
|
async function downloadTarball(
|
|
url: string,
|
|
onProgress: (progress: OpenCodeRuntimeInstallProgress) => void
|
|
): Promise<Buffer> {
|
|
const controller = new AbortController();
|
|
const timer = setTimeout(() => controller.abort(), FETCH_TIMEOUT_MS);
|
|
try {
|
|
const response = await fetch(url, { signal: controller.signal });
|
|
if (!response.ok || !response.body) {
|
|
throw new Error(`Failed to download OpenCode package: HTTP ${response.status}`);
|
|
}
|
|
const totalHeader = response.headers.get('content-length');
|
|
const totalBytes = totalHeader ? Number.parseInt(totalHeader, 10) : undefined;
|
|
if (totalBytes && totalBytes > MAX_TARBALL_BYTES) {
|
|
throw new Error('OpenCode package is unexpectedly large');
|
|
}
|
|
|
|
const chunks: Buffer[] = [];
|
|
let downloadedBytes = 0;
|
|
const reader = response.body.getReader();
|
|
while (true) {
|
|
const { done, value } = await reader.read();
|
|
if (done) {
|
|
break;
|
|
}
|
|
const chunk = Buffer.from(value);
|
|
downloadedBytes += chunk.length;
|
|
if (downloadedBytes > MAX_TARBALL_BYTES) {
|
|
throw new Error('OpenCode package exceeded the maximum allowed download size');
|
|
}
|
|
chunks.push(chunk);
|
|
onProgress({
|
|
phase: 'downloading',
|
|
downloadedBytes,
|
|
totalBytes,
|
|
percent: totalBytes
|
|
? Math.min(100, Math.round((downloadedBytes / totalBytes) * 100))
|
|
: undefined,
|
|
detail: totalBytes
|
|
? `Downloading OpenCode ${Math.round((downloadedBytes / totalBytes) * 100)}%`
|
|
: 'Downloading OpenCode...',
|
|
});
|
|
}
|
|
return Buffer.concat(chunks, downloadedBytes);
|
|
} finally {
|
|
clearTimeout(timer);
|
|
}
|
|
}
|
|
|
|
function readTarString(buffer: Buffer, start: number, length: number): string {
|
|
const end = buffer.indexOf(0, start);
|
|
const safeEnd = end >= start && end < start + length ? end : start + length;
|
|
return buffer.toString('utf8', start, safeEnd).trim();
|
|
}
|
|
|
|
function readTarSize(buffer: Buffer, offset: number): number {
|
|
const raw = readTarString(buffer, offset + 124, 12)
|
|
.replace(/\0/g, '')
|
|
.trim();
|
|
const size = Number.parseInt(raw || '0', 8);
|
|
if (!Number.isFinite(size) || size < 0) {
|
|
throw new Error('Invalid OpenCode package tar entry size');
|
|
}
|
|
return size;
|
|
}
|
|
|
|
function assertSafeTarPath(name: string): void {
|
|
if (
|
|
!name ||
|
|
name.startsWith('/') ||
|
|
name.startsWith('\\') ||
|
|
name.includes('..') ||
|
|
name.includes('\\')
|
|
) {
|
|
throw new Error(`Unsafe OpenCode package tar entry: ${name}`);
|
|
}
|
|
}
|
|
|
|
export function extractOpenCodeRuntimeBinaryFromTarball(tarball: Buffer): Buffer {
|
|
const tar = gunzipSync(tarball, { maxOutputLength: MAX_BINARY_BYTES + 1024 * 1024 });
|
|
const targetName = `package/bin/${getExecutableName()}`;
|
|
let offset = 0;
|
|
while (offset + 512 <= tar.length) {
|
|
const name = readTarString(tar, offset, 100);
|
|
if (!name) {
|
|
break;
|
|
}
|
|
const prefix = readTarString(tar, offset + 345, 155);
|
|
const fullName = prefix ? `${prefix}/${name}` : name;
|
|
assertSafeTarPath(fullName);
|
|
const typeFlag = readTarString(tar, offset + 156, 1);
|
|
const size = readTarSize(tar, offset);
|
|
const dataStart = offset + 512;
|
|
const dataEnd = dataStart + size;
|
|
if (dataEnd > tar.length) {
|
|
throw new Error('OpenCode package tar entry exceeds archive bounds');
|
|
}
|
|
if ((typeFlag === '0' || typeFlag === '') && fullName === targetName) {
|
|
if (size <= 0 || size > MAX_BINARY_BYTES) {
|
|
throw new Error('OpenCode binary size is invalid');
|
|
}
|
|
return tar.subarray(dataStart, dataEnd);
|
|
}
|
|
offset = dataStart + Math.ceil(size / 512) * 512;
|
|
}
|
|
throw new Error(`OpenCode package did not contain ${targetName}`);
|
|
}
|
|
|
|
async function readCurrentManifest(): Promise<OpenCodeRuntimeManifest | null> {
|
|
try {
|
|
const raw = await fsp.readFile(getCurrentManifestPath(), 'utf8');
|
|
return parseManifest(JSON.parse(raw));
|
|
} catch {
|
|
return null;
|
|
}
|
|
}
|
|
|
|
export class OpenCodeRuntimeInstallerService {
|
|
private mainWindow: BrowserWindow | null = null;
|
|
private installPromise: Promise<OpenCodeRuntimeStatus> | null = null;
|
|
private latestStatus: OpenCodeRuntimeStatus | null = null;
|
|
|
|
setMainWindow(win: BrowserWindow | null): void {
|
|
this.mainWindow = win;
|
|
}
|
|
|
|
invalidateStatusCache(): void {
|
|
this.latestStatus = null;
|
|
}
|
|
|
|
async getStatus(): Promise<OpenCodeRuntimeStatus> {
|
|
if (this.installPromise && this.latestStatus) {
|
|
return this.latestStatus;
|
|
}
|
|
|
|
const appManagedStatus = await this.getAppManagedStatus();
|
|
if (appManagedStatus.installed) {
|
|
this.latestStatus = appManagedStatus;
|
|
return appManagedStatus;
|
|
}
|
|
|
|
const pathStatus = await this.getPathStatus();
|
|
const status =
|
|
pathStatus.installed ||
|
|
appManagedStatus.source !== 'app-managed' ||
|
|
appManagedStatus.state !== 'failed'
|
|
? pathStatus
|
|
: appManagedStatus;
|
|
this.latestStatus = status;
|
|
return status;
|
|
}
|
|
|
|
async install(): Promise<OpenCodeRuntimeStatus> {
|
|
if (this.installPromise) {
|
|
return this.installPromise;
|
|
}
|
|
this.installPromise = this.installInternal().finally(() => {
|
|
this.installPromise = null;
|
|
});
|
|
return this.installPromise;
|
|
}
|
|
|
|
private publish(status: OpenCodeRuntimeStatus): void {
|
|
this.latestStatus = status;
|
|
safeSendToRenderer(this.mainWindow, CHANNEL, status);
|
|
}
|
|
|
|
private publishProgress(progress: OpenCodeRuntimeInstallProgress): void {
|
|
this.publish({
|
|
installed: false,
|
|
source: 'missing',
|
|
state: progress.phase,
|
|
progress,
|
|
});
|
|
}
|
|
|
|
private async getAppManagedStatus(): Promise<OpenCodeRuntimeStatus> {
|
|
const manifest = await readCurrentManifest();
|
|
if (!isAbsoluteExistingFile(manifest?.binaryPath)) {
|
|
return { installed: false, source: 'missing', state: 'idle' };
|
|
}
|
|
try {
|
|
const { stdout } = await execCli(manifest.binaryPath, ['--version'], {
|
|
timeout: VERSION_TIMEOUT_MS,
|
|
windowsHide: true,
|
|
});
|
|
return {
|
|
installed: true,
|
|
binaryPath: manifest.binaryPath,
|
|
version: stdout.trim() || manifest.version,
|
|
source: 'app-managed',
|
|
state: 'ready',
|
|
};
|
|
} catch (error) {
|
|
return {
|
|
installed: false,
|
|
binaryPath: manifest.binaryPath,
|
|
version: manifest.version,
|
|
source: 'app-managed',
|
|
state: 'failed',
|
|
error: getErrorMessage(error),
|
|
};
|
|
}
|
|
}
|
|
|
|
private async getPathStatus(): Promise<OpenCodeRuntimeStatus> {
|
|
const result = await probeFirstWorkingPathOpenCodeBinary();
|
|
if (result.ok) {
|
|
return {
|
|
installed: true,
|
|
binaryPath: result.binaryPath,
|
|
version: result.version ?? undefined,
|
|
source: 'path',
|
|
state: 'ready',
|
|
};
|
|
}
|
|
if (!result.firstFailure) {
|
|
return { installed: false, source: 'missing', state: 'idle' };
|
|
}
|
|
return {
|
|
installed: false,
|
|
binaryPath: result.firstFailure.binaryPath,
|
|
source: 'path',
|
|
state: 'failed',
|
|
error: result.firstFailure.error,
|
|
};
|
|
}
|
|
|
|
private async installInternal(): Promise<OpenCodeRuntimeStatus> {
|
|
try {
|
|
this.publishProgress({ phase: 'checking', detail: 'Resolving latest OpenCode package...' });
|
|
const rootMetadata = await fetchPackageMetadata(ROOT_PACKAGE_NAME);
|
|
const candidates = getOpenCodeRuntimePlatformCandidates();
|
|
const optionalDependencies = rootMetadata.optionalDependencies ?? {};
|
|
const selected = candidates.find((candidate) => optionalDependencies[candidate.packageName]);
|
|
if (!selected) {
|
|
throw new Error(
|
|
`No OpenCode binary package is available for ${process.platform}/${process.arch}`
|
|
);
|
|
}
|
|
const platformVersion = optionalDependencies[selected.packageName] ?? rootMetadata.version!;
|
|
const normalizedVersion = platformVersion.replace(/^[~^]/, '');
|
|
const platformMetadata = await fetchPackageMetadata(selected.packageName, normalizedVersion);
|
|
|
|
this.publishProgress({
|
|
phase: 'downloading',
|
|
detail: `Downloading ${selected.packageName}@${platformMetadata.version}...`,
|
|
});
|
|
const tarball = await downloadTarball(platformMetadata.dist!.tarball!, (progress) => {
|
|
this.publishProgress(progress);
|
|
});
|
|
verifyOpenCodeRuntimePackageIntegrity(tarball, platformMetadata.dist!.integrity!);
|
|
|
|
this.publishProgress({ phase: 'installing', detail: 'Extracting OpenCode binary...' });
|
|
const binary = extractOpenCodeRuntimeBinaryFromTarball(tarball);
|
|
const runtimeRoot = getRuntimeRootPath();
|
|
const tempDir = path.join(runtimeRoot, `installing-${process.pid}-${randomUUID()}`);
|
|
const versionDir = path.join(
|
|
runtimeRoot,
|
|
'versions',
|
|
platformMetadata.version!,
|
|
selected.packageName
|
|
);
|
|
const binaryPath = path.join(versionDir, getExecutableName());
|
|
|
|
await fsp.rm(tempDir, { recursive: true, force: true });
|
|
await fsp.mkdir(tempDir, { recursive: true });
|
|
const tempBinaryPath = path.join(tempDir, getExecutableName());
|
|
await fsp.writeFile(tempBinaryPath, binary);
|
|
if (process.platform !== 'win32') {
|
|
// Required so the downloaded OpenCode platform binary can be spawned.
|
|
// eslint-disable-next-line sonarjs/file-permissions -- app-managed CLI binary must be executable
|
|
await fsp.chmod(tempBinaryPath, 0o755);
|
|
}
|
|
|
|
this.publishProgress({ phase: 'installing', detail: 'Verifying OpenCode binary...' });
|
|
const { stdout } = await execCli(tempBinaryPath, ['--version'], {
|
|
timeout: VERSION_TIMEOUT_MS,
|
|
windowsHide: true,
|
|
});
|
|
|
|
await fsp.rm(versionDir, { recursive: true, force: true });
|
|
await fsp.mkdir(path.dirname(versionDir), { recursive: true });
|
|
await fsp.rename(tempDir, versionDir);
|
|
const manifest: OpenCodeRuntimeManifest = {
|
|
schemaVersion: CURRENT_MANIFEST_SCHEMA_VERSION,
|
|
version: stdout.trim() || platformMetadata.version!,
|
|
platformPackage: selected.packageName,
|
|
binaryPath,
|
|
integrity: platformMetadata.dist!.integrity!,
|
|
installedAt: new Date().toISOString(),
|
|
};
|
|
await fsp.writeFile(
|
|
getCurrentManifestPath(),
|
|
`${JSON.stringify(manifest, null, 2)}\n`,
|
|
'utf8'
|
|
);
|
|
|
|
const status: OpenCodeRuntimeStatus = {
|
|
installed: true,
|
|
binaryPath,
|
|
version: manifest.version,
|
|
source: 'app-managed',
|
|
state: 'ready',
|
|
progress: {
|
|
phase: 'ready',
|
|
percent: 100,
|
|
detail: `Installed OpenCode ${manifest.version}`,
|
|
},
|
|
};
|
|
this.publish(status);
|
|
return status;
|
|
} catch (error) {
|
|
const status: OpenCodeRuntimeStatus = {
|
|
installed: false,
|
|
source: 'missing',
|
|
state: 'failed',
|
|
error: getErrorMessage(error),
|
|
progress: {
|
|
phase: 'failed',
|
|
detail: getErrorMessage(error),
|
|
},
|
|
};
|
|
logger.error('Failed to install OpenCode runtime:', status.error);
|
|
this.publish(status);
|
|
return status;
|
|
}
|
|
}
|
|
}
|