mirror of
https://github.com/google-gemini/gemini-cli.git
synced 2026-03-26 14:01:14 -07:00
457 lines
13 KiB
JavaScript
457 lines
13 KiB
JavaScript
#!/usr/bin/env node
|
||
|
||
/**
|
||
* @license
|
||
* Copyright 2025 Google LLC
|
||
* SPDX-License-Identifier: Apache-2.0
|
||
*/
|
||
|
||
import path from 'node:path';
|
||
import fs from 'node:fs';
|
||
import net from 'node:net';
|
||
import os from 'node:os';
|
||
import { spawnSync } from 'node:child_process';
|
||
import { fileURLToPath } from 'node:url';
|
||
import crypto from 'node:crypto';
|
||
import { GEMINI_DIR } from '@google/gemini-cli-core';
|
||
|
||
const __filename = fileURLToPath(import.meta.url);
|
||
const __dirname = path.dirname(__filename);
|
||
|
||
const projectRoot = path.resolve(__dirname, '..');
|
||
const projectHash = crypto
|
||
.createHash('sha256')
|
||
.update(projectRoot)
|
||
.digest('hex');
|
||
|
||
// Returns the home directory, respecting GEMINI_CLI_HOME
|
||
const homedir = () => process.env['GEMINI_CLI_HOME'] || os.homedir();
|
||
|
||
// User-level .gemini directory in home
|
||
const USER_GEMINI_DIR = path.join(homedir(), GEMINI_DIR);
|
||
// Project-level .gemini directory in the workspace
|
||
const WORKSPACE_GEMINI_DIR = path.join(projectRoot, GEMINI_DIR);
|
||
|
||
// Telemetry artifacts are stored in a hashed directory under the user's ~/.gemini/tmp
|
||
export const OTEL_DIR = path.join(USER_GEMINI_DIR, 'tmp', projectHash, 'otel');
|
||
export const BIN_DIR = path.join(OTEL_DIR, 'bin');
|
||
|
||
// Workspace settings remain in the project's .gemini directory
|
||
export const WORKSPACE_SETTINGS_FILE = path.join(
|
||
WORKSPACE_GEMINI_DIR,
|
||
'settings.json',
|
||
);
|
||
|
||
export function getJson(url) {
|
||
const tmpFile = path.join(
|
||
os.tmpdir(),
|
||
`gemini-cli-releases-${Date.now()}.json`,
|
||
);
|
||
try {
|
||
const result = spawnSync(
|
||
'curl',
|
||
['-sL', '-H', 'User-Agent: gemini-cli-dev-script', '-o', tmpFile, url],
|
||
{ stdio: 'pipe', encoding: 'utf-8' },
|
||
);
|
||
if (result.status !== 0) {
|
||
throw new Error(result.stderr);
|
||
}
|
||
const content = fs.readFileSync(tmpFile, 'utf-8');
|
||
return JSON.parse(content);
|
||
} catch (e) {
|
||
console.error(`Failed to fetch or parse JSON from ${url}`);
|
||
throw e;
|
||
} finally {
|
||
if (fs.existsSync(tmpFile)) {
|
||
fs.unlinkSync(tmpFile);
|
||
}
|
||
}
|
||
}
|
||
|
||
export function downloadFile(url, dest) {
|
||
try {
|
||
const result = spawnSync('curl', ['-fL', '-sS', '-o', dest, url], {
|
||
stdio: 'pipe',
|
||
encoding: 'utf-8',
|
||
});
|
||
if (result.status !== 0) {
|
||
throw new Error(result.stderr);
|
||
}
|
||
return dest;
|
||
} catch (e) {
|
||
console.error(`Failed to download file from ${url}`);
|
||
throw e;
|
||
}
|
||
}
|
||
|
||
export function findFile(startPath, filter) {
|
||
if (!fs.existsSync(startPath)) {
|
||
return null;
|
||
}
|
||
const files = fs.readdirSync(startPath);
|
||
for (const file of files) {
|
||
const filename = path.join(startPath, file);
|
||
const stat = fs.lstatSync(filename);
|
||
if (stat.isDirectory()) {
|
||
const result = findFile(filename, filter);
|
||
if (result) return result;
|
||
} else if (filter(file)) {
|
||
return filename;
|
||
}
|
||
}
|
||
return null;
|
||
}
|
||
|
||
export function fileExists(filePath) {
|
||
return fs.existsSync(filePath);
|
||
}
|
||
|
||
export function readJsonFile(filePath) {
|
||
if (!fileExists(filePath)) {
|
||
return {};
|
||
}
|
||
const content = fs.readFileSync(filePath, 'utf-8');
|
||
try {
|
||
return JSON.parse(content);
|
||
} catch (e) {
|
||
console.error(`Error parsing JSON from ${filePath}: ${e.message}`);
|
||
return {};
|
||
}
|
||
}
|
||
|
||
export function writeJsonFile(filePath, data) {
|
||
fs.writeFileSync(filePath, JSON.stringify(data, null, 2));
|
||
}
|
||
|
||
export function moveBinary(source, destination) {
|
||
try {
|
||
fs.renameSync(source, destination);
|
||
} catch (error) {
|
||
if (error.code !== 'EXDEV') {
|
||
throw error;
|
||
}
|
||
// Handle a cross-device error: copy-to-temp-then-rename.
|
||
const destDir = path.dirname(destination);
|
||
const destFile = path.basename(destination);
|
||
const tempDest = path.join(destDir, `${destFile}.tmp`);
|
||
|
||
try {
|
||
fs.copyFileSync(source, tempDest);
|
||
fs.renameSync(tempDest, destination);
|
||
} catch (moveError) {
|
||
// If copy or rename fails, clean up the intermediate temp file.
|
||
if (fs.existsSync(tempDest)) {
|
||
fs.unlinkSync(tempDest);
|
||
}
|
||
throw moveError;
|
||
}
|
||
fs.unlinkSync(source);
|
||
}
|
||
}
|
||
|
||
export function waitForPort(port, timeout = 10000) {
|
||
return new Promise((resolve, reject) => {
|
||
const startTime = Date.now();
|
||
const tryConnect = () => {
|
||
const socket = new net.Socket();
|
||
socket.once('connect', () => {
|
||
socket.end();
|
||
resolve();
|
||
});
|
||
socket.once('error', (_) => {
|
||
if (Date.now() - startTime > timeout) {
|
||
reject(new Error(`Timeout waiting for port ${port} to open.`));
|
||
} else {
|
||
setTimeout(tryConnect, 500);
|
||
}
|
||
});
|
||
socket.connect(port, 'localhost');
|
||
};
|
||
tryConnect();
|
||
});
|
||
}
|
||
|
||
export async function ensureBinary(
|
||
executableName,
|
||
repo,
|
||
assetNameCallback,
|
||
binaryNameInArchive,
|
||
isJaeger = false,
|
||
) {
|
||
const executablePath = path.join(BIN_DIR, executableName);
|
||
if (fileExists(executablePath)) {
|
||
console.log(`✅ ${executableName} already exists at ${executablePath}`);
|
||
return executablePath;
|
||
}
|
||
|
||
console.log(`🔍 ${executableName} not found. Downloading from ${repo}...`);
|
||
|
||
const platform = process.platform === 'win32' ? 'windows' : process.platform;
|
||
const arch = process.arch === 'x64' ? 'amd64' : process.arch;
|
||
const ext = platform === 'windows' ? 'zip' : 'tar.gz';
|
||
|
||
if (isJaeger && platform === 'windows' && arch === 'arm64') {
|
||
console.warn(
|
||
`⚠️ Jaeger does not have a release for Windows on ARM64. Skipping.`,
|
||
);
|
||
return null;
|
||
}
|
||
|
||
let release;
|
||
let asset;
|
||
|
||
if (isJaeger) {
|
||
console.log(`🔍 Finding latest Jaeger v2+ asset...`);
|
||
const releases = getJson(`https://api.github.com/repos/${repo}/releases`);
|
||
const sortedReleases = releases
|
||
.filter((r) => !r.prerelease && r.tag_name.startsWith('v'))
|
||
.sort((a, b) => {
|
||
const aVersion = a.tag_name.substring(1).split('.').map(Number);
|
||
const bVersion = b.tag_name.substring(1).split('.').map(Number);
|
||
for (let i = 0; i < Math.max(aVersion.length, bVersion.length); i++) {
|
||
if ((aVersion[i] || 0) > (bVersion[i] || 0)) return -1;
|
||
if ((aVersion[i] || 0) < (bVersion[i] || 0)) return 1;
|
||
}
|
||
return 0;
|
||
});
|
||
|
||
for (const r of sortedReleases) {
|
||
const expectedSuffix =
|
||
platform === 'windows'
|
||
? `-${platform}-${arch}.zip`
|
||
: `-${platform}-${arch}.tar.gz`;
|
||
const foundAsset = r.assets.find(
|
||
(a) =>
|
||
a.name.startsWith('jaeger-2.') && a.name.endsWith(expectedSuffix),
|
||
);
|
||
|
||
if (foundAsset) {
|
||
release = r;
|
||
asset = foundAsset;
|
||
console.log(
|
||
`⬇️ Found ${asset.name} in release ${r.tag_name}, downloading...`,
|
||
);
|
||
break;
|
||
}
|
||
}
|
||
if (!asset) {
|
||
throw new Error(
|
||
`Could not find a suitable Jaeger v2 asset for platform ${platform}/${arch}.`,
|
||
);
|
||
}
|
||
} else {
|
||
release = getJson(`https://api.github.com/repos/${repo}/releases/latest`);
|
||
const version = release.tag_name.startsWith('v')
|
||
? release.tag_name.substring(1)
|
||
: release.tag_name;
|
||
const assetName = assetNameCallback(version, platform, arch, ext);
|
||
asset = release.assets.find((a) => a.name === assetName);
|
||
if (!asset) {
|
||
throw new Error(
|
||
`Could not find a suitable asset for ${repo} (version ${version}) on platform ${platform}/${arch}. Searched for: ${assetName}`,
|
||
);
|
||
}
|
||
}
|
||
|
||
const downloadUrl = asset.browser_download_url;
|
||
const tmpDir = fs.mkdtempSync(
|
||
path.join(os.tmpdir(), 'gemini-cli-telemetry-'),
|
||
);
|
||
const archivePath = path.join(tmpDir, asset.name);
|
||
|
||
try {
|
||
console.log(`⬇️ Downloading ${asset.name}...`);
|
||
downloadFile(downloadUrl, archivePath);
|
||
console.log(`📦 Extracting ${asset.name}...`);
|
||
|
||
const actualExt = asset.name.endsWith('.zip') ? 'zip' : 'tar.gz';
|
||
|
||
let result;
|
||
if (actualExt === 'zip') {
|
||
result = spawnSync('unzip', ['-o', archivePath, '-d', tmpDir], {
|
||
stdio: 'pipe',
|
||
encoding: 'utf-8',
|
||
});
|
||
} else {
|
||
result = spawnSync('tar', ['-xzf', archivePath, '-C', tmpDir], {
|
||
stdio: 'pipe',
|
||
encoding: 'utf-8',
|
||
});
|
||
}
|
||
if (result.status !== 0) {
|
||
throw new Error(result.stderr);
|
||
}
|
||
|
||
const nameToFind = binaryNameInArchive || executableName;
|
||
const foundBinaryPath = findFile(tmpDir, (file) => {
|
||
if (platform === 'windows') {
|
||
return file === `${nameToFind}.exe`;
|
||
}
|
||
return file === nameToFind;
|
||
});
|
||
|
||
if (!foundBinaryPath) {
|
||
throw new Error(
|
||
`Could not find binary "${nameToFind}" in extracted archive at ${tmpDir}. Contents: ${fs.readdirSync(tmpDir).join(', ')}`,
|
||
);
|
||
}
|
||
|
||
moveBinary(foundBinaryPath, executablePath);
|
||
|
||
if (platform !== 'windows') {
|
||
fs.chmodSync(executablePath, '755');
|
||
}
|
||
|
||
console.log(`✅ ${executableName} installed at ${executablePath}`);
|
||
return executablePath;
|
||
} finally {
|
||
fs.rmSync(tmpDir, { recursive: true, force: true });
|
||
if (fs.existsSync(archivePath)) {
|
||
fs.unlinkSync(archivePath);
|
||
}
|
||
}
|
||
}
|
||
|
||
export function manageTelemetrySettings(
|
||
enable,
|
||
oTelEndpoint = 'http://localhost:4317',
|
||
target = 'local',
|
||
originalSandboxSettingToRestore,
|
||
otlpProtocol = 'grpc',
|
||
) {
|
||
const workspaceSettings = readJsonFile(WORKSPACE_SETTINGS_FILE);
|
||
const currentSandboxSetting = workspaceSettings.sandbox;
|
||
let settingsModified = false;
|
||
|
||
if (typeof workspaceSettings.telemetry !== 'object') {
|
||
workspaceSettings.telemetry = {};
|
||
}
|
||
|
||
if (enable) {
|
||
if (workspaceSettings.telemetry.enabled !== true) {
|
||
workspaceSettings.telemetry.enabled = true;
|
||
settingsModified = true;
|
||
console.log('⚙️ Enabled telemetry in workspace settings.');
|
||
}
|
||
if (workspaceSettings.sandbox !== false) {
|
||
workspaceSettings.sandbox = false;
|
||
settingsModified = true;
|
||
console.log('✅ Disabled sandbox mode for telemetry.');
|
||
}
|
||
if (workspaceSettings.telemetry.otlpEndpoint !== oTelEndpoint) {
|
||
workspaceSettings.telemetry.otlpEndpoint = oTelEndpoint;
|
||
settingsModified = true;
|
||
console.log(`🔧 Set telemetry OTLP endpoint to ${oTelEndpoint}.`);
|
||
}
|
||
if (workspaceSettings.telemetry.target !== target) {
|
||
workspaceSettings.telemetry.target = target;
|
||
settingsModified = true;
|
||
console.log(`🎯 Set telemetry target to ${target}.`);
|
||
}
|
||
if (workspaceSettings.telemetry.otlpProtocol !== otlpProtocol) {
|
||
workspaceSettings.telemetry.otlpProtocol = otlpProtocol;
|
||
settingsModified = true;
|
||
console.log(`🔧 Set telemetry OTLP protocol to ${otlpProtocol}.`);
|
||
}
|
||
} else {
|
||
if (workspaceSettings.telemetry.enabled === true) {
|
||
delete workspaceSettings.telemetry.enabled;
|
||
settingsModified = true;
|
||
console.log('⚙️ Disabled telemetry in workspace settings.');
|
||
}
|
||
if (workspaceSettings.telemetry.otlpEndpoint) {
|
||
delete workspaceSettings.telemetry.otlpEndpoint;
|
||
settingsModified = true;
|
||
console.log('🔧 Cleared telemetry OTLP endpoint.');
|
||
}
|
||
if (workspaceSettings.telemetry.target) {
|
||
delete workspaceSettings.telemetry.target;
|
||
settingsModified = true;
|
||
console.log('🎯 Cleared telemetry target.');
|
||
}
|
||
if (workspaceSettings.telemetry.otlpProtocol) {
|
||
delete workspaceSettings.telemetry.otlpProtocol;
|
||
settingsModified = true;
|
||
console.log('🔧 Cleared telemetry OTLP protocol.');
|
||
}
|
||
if (Object.keys(workspaceSettings.telemetry).length === 0) {
|
||
delete workspaceSettings.telemetry;
|
||
}
|
||
|
||
if (
|
||
originalSandboxSettingToRestore !== undefined &&
|
||
workspaceSettings.sandbox !== originalSandboxSettingToRestore
|
||
) {
|
||
workspaceSettings.sandbox = originalSandboxSettingToRestore;
|
||
settingsModified = true;
|
||
console.log('✅ Restored original sandbox setting.');
|
||
}
|
||
}
|
||
|
||
if (settingsModified) {
|
||
writeJsonFile(WORKSPACE_SETTINGS_FILE, workspaceSettings);
|
||
console.log('✅ Workspace settings updated.');
|
||
} else {
|
||
console.log(
|
||
enable
|
||
? '✅ Workspace settings are already configured for telemetry.'
|
||
: '✅ Workspace settings already reflect telemetry disabled.',
|
||
);
|
||
}
|
||
return currentSandboxSetting;
|
||
}
|
||
|
||
export function registerCleanup(
|
||
getProcesses,
|
||
getLogFileDescriptors,
|
||
originalSandboxSetting,
|
||
) {
|
||
let cleanedUp = false;
|
||
const cleanup = () => {
|
||
if (cleanedUp) return;
|
||
cleanedUp = true;
|
||
|
||
console.log('\n👋 Shutting down...');
|
||
|
||
manageTelemetrySettings(false, null, null, originalSandboxSetting);
|
||
|
||
const processes = getProcesses ? getProcesses() : [];
|
||
processes.forEach((proc) => {
|
||
if (proc && proc.pid) {
|
||
const name = path.basename(proc.spawnfile);
|
||
try {
|
||
console.log(`🛑 Stopping ${name} (PID: ${proc.pid})...`);
|
||
process.kill(proc.pid, 'SIGTERM');
|
||
console.log(`✅ ${name} stopped.`);
|
||
} catch (e) {
|
||
if (e.code !== 'ESRCH') {
|
||
console.error(`Error stopping ${name}: ${e.message}`);
|
||
}
|
||
}
|
||
}
|
||
});
|
||
|
||
const logFileDescriptors = getLogFileDescriptors
|
||
? getLogFileDescriptors()
|
||
: [];
|
||
logFileDescriptors.forEach((fd) => {
|
||
if (fd) {
|
||
try {
|
||
fs.closeSync(fd);
|
||
} catch (_) {
|
||
/* no-op */
|
||
}
|
||
}
|
||
});
|
||
};
|
||
|
||
process.on('exit', cleanup);
|
||
process.on('SIGINT', () => process.exit(0));
|
||
process.on('SIGTERM', () => process.exit(0));
|
||
process.on('uncaughtException', (err) => {
|
||
console.error('Uncaught Exception:', err);
|
||
cleanup();
|
||
process.exit(1);
|
||
});
|
||
}
|