chore: import upstream snapshot with attribution
This commit is contained in:
@@ -0,0 +1,253 @@
|
||||
#!/usr/bin/env node
|
||||
|
||||
/**
|
||||
* @license
|
||||
* Copyright 2026 Google LLC
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
import fs from 'node:fs';
|
||||
import path from 'node:path';
|
||||
import { execSync } from 'node:child_process';
|
||||
import os from 'node:os';
|
||||
|
||||
const artifactsDir = process.argv[2] || '.';
|
||||
const MAX_HISTORY = 10;
|
||||
|
||||
// Find all report.json files recursively
|
||||
function findReports(dir) {
|
||||
const reports = [];
|
||||
if (!fs.existsSync(dir)) return reports;
|
||||
|
||||
const files = fs.readdirSync(dir);
|
||||
for (const file of files) {
|
||||
const fullPath = path.join(dir, file);
|
||||
const stat = fs.statSync(fullPath);
|
||||
if (stat.isDirectory()) {
|
||||
reports.push(...findReports(fullPath));
|
||||
} else if (file === 'report.json') {
|
||||
reports.push(fullPath);
|
||||
}
|
||||
}
|
||||
return reports;
|
||||
}
|
||||
|
||||
function getModelFromPath(reportPath) {
|
||||
const parts = reportPath.split(path.sep);
|
||||
// Find the part that starts with 'eval-logs-'
|
||||
const artifactDir = parts.find((p) => p.startsWith('eval-logs-'));
|
||||
if (!artifactDir) return 'unknown';
|
||||
|
||||
const matchNew = artifactDir.match(/^eval-logs-(.+)-(\d+)$/);
|
||||
if (matchNew) return matchNew[1];
|
||||
|
||||
const matchOld = artifactDir.match(/^eval-logs-(\d+)$/);
|
||||
if (matchOld) return 'gemini-2.5-pro'; // Legacy default
|
||||
|
||||
return 'unknown';
|
||||
}
|
||||
|
||||
function getStats(reports) {
|
||||
// Structure: { [model]: { [testName]: { passed, failed, total } } }
|
||||
const statsByModel = {};
|
||||
|
||||
for (const reportPath of reports) {
|
||||
try {
|
||||
const model = getModelFromPath(reportPath);
|
||||
if (!statsByModel[model]) {
|
||||
statsByModel[model] = {};
|
||||
}
|
||||
const testStats = statsByModel[model];
|
||||
|
||||
const content = fs.readFileSync(reportPath, 'utf-8');
|
||||
const json = JSON.parse(content);
|
||||
|
||||
for (const testResult of json.testResults) {
|
||||
for (const assertion of testResult.assertionResults) {
|
||||
const name = assertion.title;
|
||||
if (!testStats[name]) {
|
||||
testStats[name] = { passed: 0, failed: 0, total: 0 };
|
||||
}
|
||||
testStats[name].total++;
|
||||
if (assertion.status === 'passed') {
|
||||
testStats[name].passed++;
|
||||
} else {
|
||||
testStats[name].failed++;
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
console.error(`Error processing report at ${reportPath}:`, error);
|
||||
}
|
||||
}
|
||||
return statsByModel;
|
||||
}
|
||||
|
||||
function fetchHistoricalData() {
|
||||
const history = [];
|
||||
|
||||
try {
|
||||
// Determine branch
|
||||
const branch = 'main';
|
||||
|
||||
// Get recent runs
|
||||
const cmd = `gh run list --workflow evals-nightly.yml --branch "${branch}" --limit ${
|
||||
MAX_HISTORY + 5
|
||||
} --json databaseId,createdAt,url,displayTitle,status,conclusion`;
|
||||
const runsJson = execSync(cmd, { encoding: 'utf-8' });
|
||||
let runs = JSON.parse(runsJson);
|
||||
|
||||
// Filter out current run
|
||||
const currentRunId = process.env.GITHUB_RUN_ID;
|
||||
if (currentRunId) {
|
||||
runs = runs.filter((r) => r.databaseId.toString() !== currentRunId);
|
||||
}
|
||||
|
||||
// Filter for runs that likely have artifacts (completed) and take top N
|
||||
// We accept 'failure' too because we want to see stats.
|
||||
runs = runs.filter((r) => r.status === 'completed').slice(0, MAX_HISTORY);
|
||||
|
||||
// Fetch artifacts for each run
|
||||
for (const run of runs) {
|
||||
const tmpDir = fs.mkdtempSync(
|
||||
path.join(os.tmpdir(), `gemini-evals-${run.databaseId}-`),
|
||||
);
|
||||
try {
|
||||
// Download report.json files.
|
||||
// The artifacts are named 'eval-logs-X' or 'eval-logs-MODEL-X'.
|
||||
// We use -p to match pattern.
|
||||
execSync(
|
||||
`gh run download ${run.databaseId} -p "eval-logs-*" -D "${tmpDir}"`,
|
||||
{ stdio: 'ignore' },
|
||||
);
|
||||
|
||||
const runReports = findReports(tmpDir);
|
||||
if (runReports.length > 0) {
|
||||
history.push({
|
||||
run,
|
||||
stats: getStats(runReports), // Now returns stats grouped by model
|
||||
});
|
||||
}
|
||||
} catch (error) {
|
||||
console.error(
|
||||
`Failed to download or process artifacts for run ${run.databaseId}:`,
|
||||
error,
|
||||
);
|
||||
} finally {
|
||||
fs.rmSync(tmpDir, { recursive: true, force: true });
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Failed to fetch historical data:', error);
|
||||
}
|
||||
|
||||
return history;
|
||||
}
|
||||
|
||||
function generateMarkdown(currentStatsByModel, history) {
|
||||
console.log('### Evals Nightly Summary\n');
|
||||
console.log(
|
||||
'See [evals/README.md](https://github.com/google-gemini/gemini-cli/tree/main/evals) for more details.\n',
|
||||
);
|
||||
|
||||
// Reverse history to show oldest first
|
||||
const reversedHistory = [...history].reverse();
|
||||
|
||||
const models = Object.keys(currentStatsByModel).sort();
|
||||
|
||||
const getPassRate = (statsForModel) => {
|
||||
if (!statsForModel) return '-';
|
||||
const totalStats = Object.values(statsForModel).reduce(
|
||||
(acc, stats) => {
|
||||
acc.passed += stats.passed;
|
||||
acc.total += stats.total;
|
||||
return acc;
|
||||
},
|
||||
{ passed: 0, total: 0 },
|
||||
);
|
||||
return totalStats.total > 0
|
||||
? ((totalStats.passed / totalStats.total) * 100).toFixed(1) + '%'
|
||||
: '-';
|
||||
};
|
||||
|
||||
for (const model of models) {
|
||||
const currentStats = currentStatsByModel[model];
|
||||
const totalPassRate = getPassRate(currentStats);
|
||||
|
||||
console.log(`#### Model: ${model}`);
|
||||
console.log(`**Total Pass Rate: ${totalPassRate}**\n`);
|
||||
|
||||
// Header
|
||||
let header = '| Test Name |';
|
||||
let separator = '| :--- |';
|
||||
let passRateRow = '| **Overall Pass Rate** |';
|
||||
|
||||
for (const item of reversedHistory) {
|
||||
header += ` [${item.run.databaseId}](${item.run.url}) |`;
|
||||
separator += ' :---: |';
|
||||
passRateRow += ` **${getPassRate(item.stats[model])}** |`;
|
||||
}
|
||||
|
||||
// Add Current column last
|
||||
header += ' Current |';
|
||||
separator += ' :---: |';
|
||||
passRateRow += ` **${totalPassRate}** |`;
|
||||
|
||||
console.log(header);
|
||||
console.log(separator);
|
||||
console.log(passRateRow);
|
||||
|
||||
// Collect all test names for this model
|
||||
const allTestNames = new Set(Object.keys(currentStats));
|
||||
for (const item of reversedHistory) {
|
||||
if (item.stats[model]) {
|
||||
Object.keys(item.stats[model]).forEach((name) =>
|
||||
allTestNames.add(name),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
for (const name of Array.from(allTestNames).sort()) {
|
||||
const searchUrl = `https://github.com/search?q=repo%3Agoogle-gemini%2Fgemini-cli%20%22${encodeURIComponent(name)}%22&type=code`;
|
||||
let row = `| [${name}](${searchUrl}) |`;
|
||||
|
||||
// History
|
||||
for (const item of reversedHistory) {
|
||||
const stat = item.stats[model] ? item.stats[model][name] : null;
|
||||
if (stat) {
|
||||
const passRate = ((stat.passed / stat.total) * 100).toFixed(0) + '%';
|
||||
row += ` ${passRate} |`;
|
||||
} else {
|
||||
row += ' - |';
|
||||
}
|
||||
}
|
||||
|
||||
// Current
|
||||
const curr = currentStats[name];
|
||||
if (curr) {
|
||||
const passRate = ((curr.passed / curr.total) * 100).toFixed(0) + '%';
|
||||
row += ` ${passRate} |`;
|
||||
} else {
|
||||
row += ' - |';
|
||||
}
|
||||
|
||||
console.log(row);
|
||||
}
|
||||
console.log('\n');
|
||||
}
|
||||
}
|
||||
|
||||
// --- Main ---
|
||||
|
||||
const currentReports = findReports(artifactsDir);
|
||||
if (currentReports.length === 0) {
|
||||
console.log('No reports found.');
|
||||
// We don't exit here because we might still want to see history if available,
|
||||
// but practically if current has no reports, something is wrong.
|
||||
// Sticking to original behavior roughly, but maybe we can continue.
|
||||
process.exit(0);
|
||||
}
|
||||
|
||||
const currentStats = getStats(currentReports);
|
||||
const history = fetchHistoricalData();
|
||||
generateMarkdown(currentStats, history);
|
||||
Executable
+42
@@ -0,0 +1,42 @@
|
||||
#!/bin/bash
|
||||
# scripts/batch_triage.sh
|
||||
# Usage: ./scripts/batch_triage.sh [repository]
|
||||
# Example: ./scripts/batch_triage.sh google-gemini/maintainers-gemini-cli
|
||||
|
||||
set -e
|
||||
set -o pipefail
|
||||
|
||||
REPO="${1:-google-gemini/gemini-cli}"
|
||||
WORKFLOW="gemini-automated-issue-triage.yml"
|
||||
|
||||
echo "🔍 Searching for open issues in '${REPO}' that need triage (missing 'area/' label)..."
|
||||
|
||||
# Fetch open issues with number, title, and labels
|
||||
# We fetch up to 1000 issues.
|
||||
ISSUES_JSON=$(gh issue list --repo "${REPO}" --state open --limit 1000 --json number,title,labels)
|
||||
|
||||
# Filter issues that DO NOT have a label starting with 'area/'
|
||||
TARGET_ISSUES=$(echo "${ISSUES_JSON}" | jq '[.[] | select(.labels | map(.name) | any(startswith("area/")) | not)]')
|
||||
|
||||
# Avoid masking return value
|
||||
COUNT=$(jq '. | length' <<< "${TARGET_ISSUES}")
|
||||
|
||||
if [[ "${COUNT}" -eq 0 ]]; then
|
||||
echo "✅ No issues found needing triage in '${REPO}'."
|
||||
exit 0
|
||||
fi
|
||||
|
||||
echo "🚀 Found ${COUNT} issues to triage."
|
||||
|
||||
# Loop through and trigger workflow
|
||||
echo "${TARGET_ISSUES}" | jq -r '.[] | "\(.number)|\(.title)"' | while IFS="|" read -r number title; do
|
||||
echo "▶️ Triggering triage for #${number}: ${title}"
|
||||
|
||||
# Trigger the workflow dispatch event
|
||||
gh workflow run "${WORKFLOW}" --repo "${REPO}" -f issue_number="${number}"
|
||||
|
||||
# Sleep briefly to be nice to the API
|
||||
sleep 1
|
||||
done
|
||||
|
||||
echo "🎉 All triage workflows triggered!"
|
||||
@@ -0,0 +1,80 @@
|
||||
/**
|
||||
* @license
|
||||
* Copyright 2025 Google LLC
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
//
|
||||
// Licensed under the Apache License, Version 2.0 (the "License");
|
||||
// you may not use this file except in compliance with the License.
|
||||
// You may obtain a copy of the License at
|
||||
//
|
||||
// http://www.apache.org/licenses/LICENSE-2.0
|
||||
//
|
||||
// Unless required by applicable law or agreed to in writing, software
|
||||
// distributed under the License is distributed on an "AS IS" BASIS,
|
||||
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
// See the License for the specific language governing permissions and
|
||||
// limitations under the License.
|
||||
|
||||
import { execSync } from 'node:child_process';
|
||||
import { existsSync } from 'node:fs';
|
||||
import { dirname, join } from 'node:path';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
|
||||
const __dirname = dirname(fileURLToPath(import.meta.url));
|
||||
const root = join(__dirname, '..');
|
||||
|
||||
// npm install if node_modules was removed (e.g. via npm run clean or scripts/clean.js)
|
||||
if (!existsSync(join(root, 'node_modules'))) {
|
||||
execSync('npm install', { stdio: 'inherit', cwd: root });
|
||||
}
|
||||
|
||||
// build all workspaces/packages
|
||||
execSync('npm run generate', { stdio: 'inherit', cwd: root });
|
||||
|
||||
if (process.env.CI) {
|
||||
console.log('CI environment detected. Building workspaces sequentially...');
|
||||
execSync('npm run build --workspaces', { stdio: 'inherit', cwd: root });
|
||||
} else {
|
||||
// Build core first because everyone depends on it
|
||||
console.log('Building @google/gemini-cli-core...');
|
||||
execSync('npm run build -w @google/gemini-cli-core', {
|
||||
stdio: 'inherit',
|
||||
cwd: root,
|
||||
});
|
||||
|
||||
// Build the rest in parallel
|
||||
console.log('Building other workspaces in parallel...');
|
||||
const workspaceInfo = JSON.parse(
|
||||
execSync('npm query .workspace --json', { cwd: root, encoding: 'utf-8' }),
|
||||
);
|
||||
const parallelWorkspaces = workspaceInfo
|
||||
.map((w) => w.name)
|
||||
.filter((name) => name !== '@google/gemini-cli-core');
|
||||
|
||||
execSync(
|
||||
`npx --no-install npm-run-all --parallel ${parallelWorkspaces.map((w) => `"build -w ${w}"`).join(' ')}`,
|
||||
{ stdio: 'inherit', cwd: root },
|
||||
);
|
||||
}
|
||||
|
||||
// also build container image if sandboxing is enabled
|
||||
// skip (-s) npm install + build since we did that above
|
||||
try {
|
||||
execSync('node scripts/sandbox_command.js -q', {
|
||||
stdio: 'inherit',
|
||||
cwd: root,
|
||||
});
|
||||
if (
|
||||
process.env.BUILD_SANDBOX === '1' ||
|
||||
process.env.BUILD_SANDBOX === 'true'
|
||||
) {
|
||||
execSync('node scripts/build_sandbox.js -s', {
|
||||
stdio: 'inherit',
|
||||
cwd: root,
|
||||
});
|
||||
}
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
@@ -0,0 +1,515 @@
|
||||
/**
|
||||
* @license
|
||||
* Copyright 2025 Google LLC
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
import { spawnSync } from 'node:child_process';
|
||||
import {
|
||||
cpSync,
|
||||
rmSync,
|
||||
mkdirSync,
|
||||
existsSync,
|
||||
copyFileSync,
|
||||
writeFileSync,
|
||||
readFileSync,
|
||||
chmodSync,
|
||||
} from 'node:fs';
|
||||
import { join, dirname } from 'node:path';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
import process from 'node:process';
|
||||
import { globSync } from 'glob';
|
||||
import { createHash } from 'node:crypto';
|
||||
|
||||
const __dirname = dirname(fileURLToPath(import.meta.url));
|
||||
const root = join(__dirname, '..');
|
||||
const distDir = join(root, 'dist');
|
||||
const bundleDir = join(root, 'bundle');
|
||||
const stagingDir = join(bundleDir, 'native_modules');
|
||||
const seaConfigPath = join(root, 'sea-config.json');
|
||||
const manifestPath = join(bundleDir, 'manifest.json');
|
||||
const entitlementsPath = join(root, 'scripts/entitlements.plist');
|
||||
|
||||
// --- Helper Functions ---
|
||||
|
||||
/**
|
||||
* Safely executes a command using spawnSync.
|
||||
* @param {string} command
|
||||
* @param {string[]} args
|
||||
* @param {object} options
|
||||
*/
|
||||
function runCommand(command, args, options = {}) {
|
||||
let finalCommand = command;
|
||||
let useShell = options.shell || false;
|
||||
|
||||
// On Windows, npm/npx are batch files and need a shell
|
||||
if (
|
||||
process.platform === 'win32' &&
|
||||
(command === 'npm' || command === 'npx')
|
||||
) {
|
||||
finalCommand = `${command}.cmd`;
|
||||
useShell = true;
|
||||
}
|
||||
|
||||
const finalOptions = {
|
||||
stdio: 'inherit',
|
||||
cwd: root,
|
||||
shell: useShell,
|
||||
...options,
|
||||
};
|
||||
|
||||
const result = spawnSync(finalCommand, args, finalOptions);
|
||||
|
||||
if (result.status !== 0) {
|
||||
if (result.error) {
|
||||
throw result.error;
|
||||
}
|
||||
throw new Error(
|
||||
`Command failed with exit code ${result.status}: ${command}`,
|
||||
);
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
/**
|
||||
* Removes existing digital signatures from a binary.
|
||||
* @param {string} filePath
|
||||
*/
|
||||
function removeSignature(filePath) {
|
||||
console.log(`Removing signature from ${filePath}...`);
|
||||
const platform = process.platform;
|
||||
try {
|
||||
if (platform === 'darwin') {
|
||||
spawnSync('codesign', ['--remove-signature', filePath], {
|
||||
stdio: 'ignore',
|
||||
});
|
||||
} else if (platform === 'win32') {
|
||||
spawnSync('signtool', ['remove', '/s', filePath], {
|
||||
stdio: 'ignore',
|
||||
});
|
||||
}
|
||||
} catch {
|
||||
// Best effort: Ignore failures
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Signs a binary using hardcoded tools for the platform.
|
||||
* @param {string} filePath
|
||||
*/
|
||||
function signFile(filePath) {
|
||||
if (process.env.SKIP_SIGNING === 'true') {
|
||||
console.log(`Skipping signing for ${filePath} (SKIP_SIGNING=true)`);
|
||||
return;
|
||||
}
|
||||
|
||||
const platform = process.platform;
|
||||
|
||||
if (platform === 'darwin') {
|
||||
const identity = process.env.APPLE_IDENTITY || '-';
|
||||
console.log(`Signing ${filePath} (Identity: ${identity})...`);
|
||||
|
||||
const args = [
|
||||
'--sign',
|
||||
identity,
|
||||
'--force',
|
||||
'--timestamp',
|
||||
'--options',
|
||||
'runtime',
|
||||
];
|
||||
|
||||
if (existsSync(entitlementsPath)) {
|
||||
args.push('--entitlements', entitlementsPath);
|
||||
}
|
||||
|
||||
args.push(filePath);
|
||||
|
||||
runCommand('codesign', args);
|
||||
} else if (platform === 'win32') {
|
||||
const args = ['sign'];
|
||||
|
||||
if (process.env.WINDOWS_PFX_FILE && process.env.WINDOWS_PFX_PASSWORD) {
|
||||
args.push(
|
||||
'/f',
|
||||
process.env.WINDOWS_PFX_FILE,
|
||||
'/p',
|
||||
process.env.WINDOWS_PFX_PASSWORD,
|
||||
);
|
||||
} else {
|
||||
args.push('/a');
|
||||
}
|
||||
|
||||
args.push(
|
||||
'/fd',
|
||||
'SHA256',
|
||||
'/td',
|
||||
'SHA256',
|
||||
'/tr',
|
||||
'http://timestamp.digicert.com',
|
||||
filePath,
|
||||
);
|
||||
|
||||
console.log(`Signing ${filePath}...`);
|
||||
try {
|
||||
runCommand('signtool', args, { stdio: 'pipe' });
|
||||
} catch (e) {
|
||||
let msg = e.message;
|
||||
if (process.env.WINDOWS_PFX_PASSWORD) {
|
||||
msg = msg.replaceAll(process.env.WINDOWS_PFX_PASSWORD, '******');
|
||||
}
|
||||
throw new Error(msg);
|
||||
}
|
||||
} else if (platform === 'linux') {
|
||||
console.log(`Skipping signing for ${filePath} on Linux.`);
|
||||
}
|
||||
}
|
||||
|
||||
console.log('Build Binary Script Started...');
|
||||
|
||||
// 1. Clean dist
|
||||
if (existsSync(distDir)) {
|
||||
console.log('Cleaning dist directory...');
|
||||
rmSync(distDir, { recursive: true, force: true });
|
||||
}
|
||||
mkdirSync(distDir, { recursive: true });
|
||||
|
||||
// 2. Build Bundle
|
||||
console.log('Running npm clean, install, and bundle...');
|
||||
try {
|
||||
runCommand('npm', ['run', 'clean']);
|
||||
runCommand('npm', ['install']);
|
||||
runCommand('npm', ['run', 'bundle']);
|
||||
} catch (e) {
|
||||
console.error('Build step failed:', e.message);
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
// 2b. Copy host-platform ripgrep binary into the bundle for the SEA.
|
||||
// (npm tarballs omit these to stay under the registry upload limit.)
|
||||
const ripgrepVendorSrc = join(root, 'packages/core/vendor/ripgrep');
|
||||
const ripgrepVendorDest = join(bundleDir, 'vendor', 'ripgrep');
|
||||
if (existsSync(ripgrepVendorSrc)) {
|
||||
const rgBinName = `rg-${process.platform}-${process.arch}${
|
||||
process.platform === 'win32' ? '.exe' : ''
|
||||
}`;
|
||||
const rgSrc = join(ripgrepVendorSrc, rgBinName);
|
||||
if (existsSync(rgSrc)) {
|
||||
mkdirSync(ripgrepVendorDest, { recursive: true });
|
||||
cpSync(rgSrc, join(ripgrepVendorDest, rgBinName), { dereference: true });
|
||||
console.log(`Copied ${rgBinName} to bundle/vendor/ripgrep/`);
|
||||
} else {
|
||||
console.warn(
|
||||
`Warning: bundled ripgrep binary not found for ${process.platform}/${process.arch} at ${rgSrc}. ` +
|
||||
`The SEA will fall back to system grep at runtime.`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// 3. Stage & Sign Native Modules
|
||||
const includeNativeModules = process.env.BUNDLE_NATIVE_MODULES !== 'false';
|
||||
console.log(`Include Native Modules: ${includeNativeModules}`);
|
||||
|
||||
if (includeNativeModules) {
|
||||
console.log('Staging and signing native modules...');
|
||||
// Prepare staging
|
||||
if (existsSync(stagingDir))
|
||||
rmSync(stagingDir, { recursive: true, force: true });
|
||||
mkdirSync(stagingDir, { recursive: true });
|
||||
|
||||
// Copy @lydell/node-pty to staging
|
||||
const lydellSrc = join(root, 'node_modules/@lydell');
|
||||
const lydellStaging = join(stagingDir, 'node_modules/@lydell');
|
||||
|
||||
if (existsSync(lydellSrc)) {
|
||||
mkdirSync(dirname(lydellStaging), { recursive: true });
|
||||
cpSync(lydellSrc, lydellStaging, { recursive: true });
|
||||
} else {
|
||||
console.warn(
|
||||
'Warning: @lydell/node-pty not found in node_modules. Native terminal features may fail.',
|
||||
);
|
||||
}
|
||||
|
||||
// Copy @github/keytar to staging
|
||||
const githubSrc = join(root, 'node_modules/@github');
|
||||
const githubStaging = join(stagingDir, 'node_modules/@github');
|
||||
|
||||
if (existsSync(githubSrc)) {
|
||||
mkdirSync(dirname(githubStaging), { recursive: true });
|
||||
cpSync(githubSrc, githubStaging, { recursive: true });
|
||||
} else {
|
||||
console.warn(
|
||||
'Warning: @github/keytar not found in node_modules. Secure keychain features will use file fallback.',
|
||||
);
|
||||
}
|
||||
|
||||
// Sign Staged .node files
|
||||
try {
|
||||
const nodeFiles = globSync('**/*.node', {
|
||||
cwd: stagingDir,
|
||||
absolute: true,
|
||||
});
|
||||
for (const file of nodeFiles) {
|
||||
signFile(file);
|
||||
}
|
||||
} catch (e) {
|
||||
console.warn('Warning: Failed to sign native modules:', e.code);
|
||||
}
|
||||
} else {
|
||||
console.log('Skipping native modules bundling (BUNDLE_NATIVE_MODULES=false)');
|
||||
}
|
||||
|
||||
// 4. Generate SEA Configuration and Manifest
|
||||
console.log('Generating SEA configuration and manifest...');
|
||||
const packageJson = JSON.parse(
|
||||
readFileSync(join(root, 'package.json'), 'utf8'),
|
||||
);
|
||||
|
||||
// Helper to calc hash
|
||||
const sha256 = (content) => createHash('sha256').update(content).digest('hex');
|
||||
|
||||
const assets = {
|
||||
'manifest.json': 'bundle/manifest.json',
|
||||
};
|
||||
|
||||
const manifest = {
|
||||
main: 'gemini.mjs',
|
||||
mainHash: '',
|
||||
version: packageJson.version,
|
||||
files: [],
|
||||
};
|
||||
|
||||
// Add all javascript chunks from the bundle directory
|
||||
const jsFiles = globSync('*.js', { cwd: bundleDir });
|
||||
for (const jsFile of jsFiles) {
|
||||
const fsPath = join(bundleDir, jsFile);
|
||||
const content = readFileSync(fsPath);
|
||||
const hash = sha256(content);
|
||||
|
||||
// Node SEA requires the main entry point to be explicitly mapped
|
||||
if (jsFile === 'gemini.js') {
|
||||
assets['gemini.mjs'] = fsPath;
|
||||
manifest.mainHash = hash;
|
||||
} else {
|
||||
// Other chunks need to be mapped exactly as they are named so dynamic imports find them
|
||||
assets[jsFile] = fsPath;
|
||||
manifest.files.push({ key: jsFile, path: jsFile, hash: hash });
|
||||
}
|
||||
}
|
||||
|
||||
// Helper to recursively find files from STAGING
|
||||
function addAssetsFromDir(baseDir, runtimePrefix) {
|
||||
const fullDir = join(stagingDir, baseDir);
|
||||
if (!existsSync(fullDir)) return;
|
||||
|
||||
const items = globSync('**/*', { cwd: fullDir, nodir: true });
|
||||
for (const item of items) {
|
||||
const relativePath = join(runtimePrefix, item);
|
||||
const assetKey = `files:${relativePath}`;
|
||||
const fsPath = join(fullDir, item);
|
||||
|
||||
// Calc hash
|
||||
const content = readFileSync(fsPath);
|
||||
const hash = sha256(content);
|
||||
|
||||
assets[assetKey] = fsPath;
|
||||
manifest.files.push({ key: assetKey, path: relativePath, hash: hash });
|
||||
}
|
||||
}
|
||||
|
||||
// Add sb files
|
||||
const sbFiles = globSync('sandbox-macos-*.sb', { cwd: bundleDir });
|
||||
for (const sbFile of sbFiles) {
|
||||
const fsPath = join(bundleDir, sbFile);
|
||||
const content = readFileSync(fsPath);
|
||||
const hash = sha256(content);
|
||||
assets[sbFile] = fsPath;
|
||||
manifest.files.push({ key: sbFile, path: sbFile, hash: hash });
|
||||
}
|
||||
|
||||
// Add policy files
|
||||
const policyDir = join(bundleDir, 'policies');
|
||||
if (existsSync(policyDir)) {
|
||||
const policyFiles = globSync('*.toml', { cwd: policyDir });
|
||||
for (const policyFile of policyFiles) {
|
||||
const fsPath = join(policyDir, policyFile);
|
||||
const relativePath = join('policies', policyFile);
|
||||
const content = readFileSync(fsPath);
|
||||
const hash = sha256(content);
|
||||
// Use a unique key to avoid collision if filenames overlap (though unlikely here)
|
||||
// But sea-launch writes to 'path', so key is just for lookup.
|
||||
const assetKey = `policies:${policyFile}`;
|
||||
assets[assetKey] = fsPath;
|
||||
manifest.files.push({ key: assetKey, path: relativePath, hash: hash });
|
||||
}
|
||||
}
|
||||
|
||||
// Add ripgrep binary (copied in step 2b). Must be registered here so that
|
||||
// sea-launch.cjs extracts it to runtimeDir/vendor/ripgrep/ on startup; the
|
||||
// runtime resolver in packages/core/src/tools/ripGrep.ts uses __dirname-
|
||||
// relative paths to find it.
|
||||
if (existsSync(ripgrepVendorDest)) {
|
||||
const rgFiles = globSync('*', { cwd: ripgrepVendorDest, nodir: true });
|
||||
for (const rgFile of rgFiles) {
|
||||
const fsPath = join(ripgrepVendorDest, rgFile);
|
||||
const relativePath = join('vendor', 'ripgrep', rgFile);
|
||||
const content = readFileSync(fsPath);
|
||||
const hash = sha256(content);
|
||||
const assetKey = `vendor:${rgFile}`;
|
||||
assets[assetKey] = fsPath;
|
||||
manifest.files.push({ key: assetKey, path: relativePath, hash: hash });
|
||||
}
|
||||
}
|
||||
|
||||
// Add assets from Staging
|
||||
if (includeNativeModules) {
|
||||
addAssetsFromDir('node_modules/@lydell', 'node_modules/@lydell');
|
||||
addAssetsFromDir('node_modules/@github', 'node_modules/@github');
|
||||
}
|
||||
|
||||
writeFileSync(manifestPath, JSON.stringify(manifest, null, 2));
|
||||
|
||||
const seaConfig = {
|
||||
main: 'sea/sea-launch.cjs',
|
||||
output: 'dist/sea-prep.blob',
|
||||
disableExperimentalSEAWarning: true,
|
||||
assets: assets,
|
||||
};
|
||||
|
||||
writeFileSync(seaConfigPath, JSON.stringify(seaConfig, null, 2));
|
||||
console.log(`Configured ${Object.keys(assets).length} embedded assets.`);
|
||||
|
||||
// 5. Generate SEA Blob
|
||||
console.log('Generating SEA blob...');
|
||||
try {
|
||||
runCommand('node', ['--experimental-sea-config', 'sea-config.json']);
|
||||
} catch (e) {
|
||||
console.error('Failed to generate SEA blob:', e.message);
|
||||
// Cleanup
|
||||
if (existsSync(seaConfigPath)) rmSync(seaConfigPath);
|
||||
if (existsSync(manifestPath)) rmSync(manifestPath);
|
||||
if (existsSync(stagingDir))
|
||||
rmSync(stagingDir, { recursive: true, force: true });
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
// Check blob existence
|
||||
const blobPath = join(distDir, 'sea-prep.blob');
|
||||
if (!existsSync(blobPath)) {
|
||||
console.error('Error: sea-prep.blob not found in dist/');
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
// 6. Identify Target & Prepare Binary
|
||||
const platform = process.platform;
|
||||
const arch = process.arch;
|
||||
const targetName = `${platform}-${arch}`;
|
||||
console.log(`Targeting: ${targetName}`);
|
||||
|
||||
const targetDir = join(distDir, targetName);
|
||||
mkdirSync(targetDir, { recursive: true });
|
||||
|
||||
const nodeBinary = process.execPath;
|
||||
const binaryName = platform === 'win32' ? 'gemini.exe' : 'gemini';
|
||||
const targetBinaryPath = join(targetDir, binaryName);
|
||||
|
||||
console.log(`Copying node binary from ${nodeBinary} to ${targetBinaryPath}...`);
|
||||
copyFileSync(nodeBinary, targetBinaryPath);
|
||||
|
||||
if (platform === 'darwin') {
|
||||
console.log(`Thinning universal binary for ${arch}...`);
|
||||
try {
|
||||
// Attempt to thin the binary. Will fail safely if it's not a fat binary.
|
||||
runCommand('lipo', [
|
||||
targetBinaryPath,
|
||||
'-thin',
|
||||
arch,
|
||||
'-output',
|
||||
targetBinaryPath,
|
||||
]);
|
||||
} catch (e) {
|
||||
console.log(`Skipping lipo thinning: ${e.message}`);
|
||||
}
|
||||
}
|
||||
|
||||
// Remove existing signature using helper
|
||||
removeSignature(targetBinaryPath);
|
||||
|
||||
// Copy standard bundle assets (policies, .sb files)
|
||||
console.log('Copying additional resources...');
|
||||
if (existsSync(bundleDir)) {
|
||||
cpSync(bundleDir, targetDir, { recursive: true });
|
||||
}
|
||||
|
||||
// Clean up source JS files from output (we only want embedded)
|
||||
const filesToRemove = [
|
||||
'gemini.mjs',
|
||||
'gemini.mjs.map',
|
||||
'gemini-sea.cjs',
|
||||
'sea-launch.cjs',
|
||||
'manifest.json',
|
||||
'native_modules',
|
||||
'policies',
|
||||
];
|
||||
|
||||
filesToRemove.forEach((f) => {
|
||||
const p = join(targetDir, f);
|
||||
if (existsSync(p)) rmSync(p, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
// Remove all chunk and entry .js/.js.map files
|
||||
const jsFilesToRemove = globSync('*.{js,js.map}', { cwd: targetDir });
|
||||
for (const f of jsFilesToRemove) {
|
||||
rmSync(join(targetDir, f));
|
||||
}
|
||||
|
||||
// Remove .sb files from targetDir
|
||||
const sbFilesToRemove = globSync('sandbox-macos-*.sb', { cwd: targetDir });
|
||||
for (const f of sbFilesToRemove) {
|
||||
rmSync(join(targetDir, f));
|
||||
}
|
||||
|
||||
// 7. Inject Blob
|
||||
console.log('Injecting SEA blob...');
|
||||
const sentinelFuse = 'NODE_SEA_FUSE_fce680ab2cc467b6e072b8b5df1996b2';
|
||||
|
||||
try {
|
||||
chmodSync(targetBinaryPath, 0o755);
|
||||
const args = [
|
||||
'postject',
|
||||
targetBinaryPath,
|
||||
'NODE_SEA_BLOB',
|
||||
blobPath,
|
||||
'--sentinel-fuse',
|
||||
sentinelFuse,
|
||||
];
|
||||
|
||||
if (platform === 'darwin') {
|
||||
args.push('--macho-segment-name', 'NODE_SEA');
|
||||
}
|
||||
|
||||
runCommand('npx', ['--yes', ...args]);
|
||||
console.log('Injection successful.');
|
||||
} catch (e) {
|
||||
console.error('Postject failed:', e.message);
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
// 8. Final Signing
|
||||
console.log('Signing final executable...');
|
||||
try {
|
||||
signFile(targetBinaryPath);
|
||||
} catch (e) {
|
||||
console.warn('Warning: Final signing failed:', e.code);
|
||||
console.warn('Continuing without signing...');
|
||||
}
|
||||
|
||||
// 9. Cleanup
|
||||
console.log('Cleaning up artifacts...');
|
||||
rmSync(blobPath);
|
||||
if (existsSync(seaConfigPath)) rmSync(seaConfigPath);
|
||||
if (existsSync(manifestPath)) rmSync(manifestPath);
|
||||
if (existsSync(stagingDir))
|
||||
rmSync(stagingDir, { recursive: true, force: true });
|
||||
|
||||
console.log(`Binary built successfully in ${targetDir}`);
|
||||
@@ -0,0 +1,58 @@
|
||||
/**
|
||||
* @license
|
||||
* Copyright 2025 Google LLC
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
//
|
||||
// Licensed under the Apache License, Version 2.0 (the "License");
|
||||
// you may not use this file except in compliance with the License.
|
||||
// You may obtain a copy of the License at
|
||||
//
|
||||
// http://www.apache.org/licenses/LICENSE-2.0
|
||||
//
|
||||
// Unless required by applicable law or agreed to in writing, software
|
||||
// distributed under the License is distributed on an "AS IS" BASIS,
|
||||
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
// See the License for the specific language governing permissions and
|
||||
// limitations under the License.
|
||||
|
||||
import { execSync } from 'node:child_process';
|
||||
import { writeFileSync, existsSync, cpSync } from 'node:fs';
|
||||
import { join, basename } from 'node:path';
|
||||
|
||||
if (!process.cwd().includes('packages')) {
|
||||
console.error('must be invoked from a package directory');
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
const packageName = basename(process.cwd());
|
||||
|
||||
// build typescript files
|
||||
execSync('tsc --build', { stdio: 'inherit' });
|
||||
|
||||
// Run package-specific bundling if the script exists
|
||||
const bundleScript = join(process.cwd(), 'scripts', 'bundle-browser-mcp.mjs');
|
||||
if (packageName === 'core' && existsSync(bundleScript)) {
|
||||
console.log('Running chrome devtools MCP bundling...');
|
||||
execSync('npm run bundle:browser-mcp', {
|
||||
stdio: 'inherit',
|
||||
});
|
||||
}
|
||||
|
||||
// copy .{md,json} files
|
||||
execSync('node ../../scripts/copy_files.js', { stdio: 'inherit' });
|
||||
|
||||
// Copy documentation for the core package
|
||||
if (packageName === 'core') {
|
||||
const docsSource = join(process.cwd(), '..', '..', 'docs');
|
||||
const docsTarget = join(process.cwd(), 'dist', 'docs');
|
||||
if (existsSync(docsSource)) {
|
||||
cpSync(docsSource, docsTarget, { recursive: true, dereference: true });
|
||||
console.log('Copied documentation to dist/docs');
|
||||
}
|
||||
}
|
||||
|
||||
// touch dist/.last_build
|
||||
writeFileSync(join(process.cwd(), 'dist', '.last_build'), '');
|
||||
process.exit(0);
|
||||
@@ -0,0 +1,192 @@
|
||||
/**
|
||||
* @license
|
||||
* Copyright 2025 Google LLC
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
//
|
||||
// Licensed under the Apache License, Version 2.0 (the "License");
|
||||
// you may not use this file except in compliance with the License.
|
||||
// You may obtain a copy of the License at
|
||||
//
|
||||
// http://www.apache.org/licenses/LICENSE-2.0
|
||||
//
|
||||
// Unless required by applicable law or agreed to in writing, software
|
||||
// distributed under the License is distributed on an "AS IS" BASIS,
|
||||
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
// See the License for the specific language governing permissions and
|
||||
// limitations under the License.
|
||||
|
||||
import { execSync } from 'node:child_process';
|
||||
import {
|
||||
chmodSync,
|
||||
existsSync,
|
||||
readFileSync,
|
||||
rmSync,
|
||||
writeFileSync,
|
||||
} from 'node:fs';
|
||||
import { join } from 'node:path';
|
||||
import os from 'node:os';
|
||||
import yargs from 'yargs';
|
||||
import { hideBin } from 'yargs/helpers';
|
||||
import cliPkgJson from '../packages/cli/package.json' with { type: 'json' };
|
||||
|
||||
const argv = yargs(hideBin(process.argv))
|
||||
.option('s', {
|
||||
alias: 'skip-npm-install-build',
|
||||
type: 'boolean',
|
||||
default: false,
|
||||
description: 'skip npm install + npm run build',
|
||||
})
|
||||
.option('f', {
|
||||
alias: 'dockerfile',
|
||||
type: 'string',
|
||||
default: 'Dockerfile',
|
||||
description: 'use <dockerfile> for custom image',
|
||||
})
|
||||
.option('i', {
|
||||
alias: 'image',
|
||||
type: 'string',
|
||||
default: cliPkgJson.config.sandboxImageUri,
|
||||
description: 'use <image> name for custom image',
|
||||
})
|
||||
.option('output-file', {
|
||||
type: 'string',
|
||||
description:
|
||||
'Path to write the final image URI. Used for CI/CD pipeline integration.',
|
||||
}).argv;
|
||||
|
||||
let sandboxCommand;
|
||||
try {
|
||||
sandboxCommand = execSync('node scripts/sandbox_command.js')
|
||||
.toString()
|
||||
.trim();
|
||||
} catch (e) {
|
||||
console.warn('ERROR: could not detect sandbox container command');
|
||||
console.error(e);
|
||||
process.exit(process.env.CI ? 1 : 0);
|
||||
}
|
||||
|
||||
if (sandboxCommand === 'sandbox-exec') {
|
||||
console.warn(
|
||||
'WARNING: container-based sandboxing is disabled (see README.md#sandboxing)',
|
||||
);
|
||||
process.exit(0);
|
||||
}
|
||||
|
||||
console.log(`using ${sandboxCommand} for sandboxing`);
|
||||
|
||||
const image = argv.i;
|
||||
const dockerFile = argv.f;
|
||||
|
||||
if (!image.length) {
|
||||
console.warn(
|
||||
'No default image tag specified in gemini-cli/packages/cli/package.json',
|
||||
);
|
||||
}
|
||||
|
||||
if (!argv.s) {
|
||||
execSync('npm install', { stdio: 'inherit' });
|
||||
execSync('npm run build --workspaces', { stdio: 'inherit' });
|
||||
}
|
||||
|
||||
console.log('packing @google/gemini-cli ...');
|
||||
const cliPackageDir = join('packages', 'cli');
|
||||
rmSync(join(cliPackageDir, 'dist', 'google-gemini-cli-*.tgz'), { force: true });
|
||||
execSync(
|
||||
`npm pack -w @google/gemini-cli --pack-destination ./packages/cli/dist`,
|
||||
{
|
||||
stdio: 'ignore',
|
||||
},
|
||||
);
|
||||
|
||||
console.log('packing @google/gemini-cli-core ...');
|
||||
const corePackageDir = join('packages', 'core');
|
||||
rmSync(join(corePackageDir, 'dist', 'google-gemini-cli-core-*.tgz'), {
|
||||
force: true,
|
||||
});
|
||||
execSync(
|
||||
`npm pack -w @google/gemini-cli-core --pack-destination ./packages/core/dist`,
|
||||
{ stdio: 'ignore' },
|
||||
);
|
||||
|
||||
const packageVersion = JSON.parse(
|
||||
readFileSync(join(process.cwd(), 'package.json'), 'utf-8'),
|
||||
).version;
|
||||
|
||||
chmodSync(
|
||||
join(cliPackageDir, 'dist', `google-gemini-cli-${packageVersion}.tgz`),
|
||||
0o755,
|
||||
);
|
||||
chmodSync(
|
||||
join(corePackageDir, 'dist', `google-gemini-cli-core-${packageVersion}.tgz`),
|
||||
0o755,
|
||||
);
|
||||
|
||||
const buildStdout = process.env.VERBOSE ? 'inherit' : 'ignore';
|
||||
|
||||
// Determine the appropriate shell based on OS
|
||||
const isWindows = os.platform() === 'win32';
|
||||
const shellToUse = isWindows ? 'powershell.exe' : '/bin/bash';
|
||||
|
||||
function buildImage(imageName, dockerfile) {
|
||||
console.log(`building ${imageName} ... (can be slow first time)`);
|
||||
|
||||
let buildCommandArgs = '';
|
||||
let tempAuthFile = '';
|
||||
|
||||
if (sandboxCommand === 'podman') {
|
||||
if (isWindows) {
|
||||
// PowerShell doesn't support <() process substitution.
|
||||
// Create a temporary auth file that we will clean up after.
|
||||
tempAuthFile = join(os.tmpdir(), `gemini-auth-${Date.now()}.json`);
|
||||
writeFileSync(tempAuthFile, '{}');
|
||||
buildCommandArgs = `--authfile="${tempAuthFile}"`;
|
||||
} else {
|
||||
// Use bash-specific syntax for Linux/macOS
|
||||
buildCommandArgs = `--authfile=<(echo '{}')`;
|
||||
}
|
||||
}
|
||||
|
||||
const npmPackageVersion = JSON.parse(
|
||||
readFileSync(join(process.cwd(), 'package.json'), 'utf-8'),
|
||||
).version;
|
||||
|
||||
const imageTag =
|
||||
process.env.GEMINI_SANDBOX_IMAGE_TAG || imageName.split(':')[1];
|
||||
const finalImageName = `${imageName.split(':')[0]}:${imageTag}`;
|
||||
|
||||
try {
|
||||
execSync(
|
||||
`${sandboxCommand} build ${buildCommandArgs} ${
|
||||
process.env.BUILD_SANDBOX_FLAGS || ''
|
||||
} --build-arg CLI_VERSION_ARG=${npmPackageVersion} -f "${dockerfile}" -t "${finalImageName}" .`,
|
||||
{ stdio: buildStdout, shell: shellToUse },
|
||||
);
|
||||
console.log(`built ${finalImageName}`);
|
||||
|
||||
// If an output file path was provided via command-line, write the final image URI to it.
|
||||
if (argv.outputFile) {
|
||||
console.log(
|
||||
`Writing final image URI for CI artifact to: ${argv.outputFile}`,
|
||||
);
|
||||
// The publish step only supports one image. If we build multiple, only the last one
|
||||
// will be published. Throw an error to make this failure explicit if the file already exists.
|
||||
if (existsSync(argv.outputFile)) {
|
||||
throw new Error(
|
||||
`CI artifact file ${argv.outputFile} already exists. Refusing to overwrite.`,
|
||||
);
|
||||
}
|
||||
writeFileSync(argv.outputFile, finalImageName);
|
||||
}
|
||||
} finally {
|
||||
// If we created a temp file, delete it now.
|
||||
if (tempAuthFile) {
|
||||
rmSync(tempAuthFile, { force: true });
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
buildImage(image, dockerFile);
|
||||
|
||||
execSync(`${sandboxCommand} image prune -f`, { stdio: 'ignore' });
|
||||
@@ -0,0 +1,30 @@
|
||||
/**
|
||||
* @license
|
||||
* Copyright 2025 Google LLC
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
//
|
||||
// Licensed under the Apache License, Version 2.0 (the "License");
|
||||
// you may not use this file except in compliance with the License.
|
||||
// You may obtain a copy of the License at
|
||||
//
|
||||
// http://www.apache.org/licenses/LICENSE-2.0
|
||||
//
|
||||
// Unless required by applicable law or agreed to in writing, software
|
||||
// distributed under the License is distributed on an "AS IS" BASIS,
|
||||
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
// See the License for the specific language governing permissions and
|
||||
// limitations under the License.
|
||||
|
||||
import { execSync } from 'node:child_process';
|
||||
import { dirname, join } from 'node:path';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
|
||||
const __dirname = dirname(fileURLToPath(import.meta.url));
|
||||
const root = join(__dirname, '..');
|
||||
|
||||
execSync('npm --workspace=gemini-cli-vscode-ide-companion run package', {
|
||||
stdio: 'inherit',
|
||||
cwd: root,
|
||||
});
|
||||
@@ -0,0 +1,103 @@
|
||||
/**
|
||||
* @license
|
||||
* Copyright 2026 Google LLC
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
import { execSync } from 'node:child_process';
|
||||
|
||||
const CORE_STEERING_PATHS = [
|
||||
'packages/core/src/prompts/',
|
||||
'packages/core/src/tools/',
|
||||
];
|
||||
|
||||
const TEST_PATHS = ['evals/'];
|
||||
|
||||
const STEERING_SIGNATURES = [
|
||||
'LocalAgentDefinition',
|
||||
'LocalInvocation',
|
||||
'ToolDefinition',
|
||||
'inputSchema',
|
||||
"kind: 'local'",
|
||||
];
|
||||
|
||||
function main() {
|
||||
const targetBranch = process.env.GITHUB_BASE_REF || 'main';
|
||||
const verbose = process.argv.includes('--verbose');
|
||||
const steeringOnly = process.argv.includes('--steering-only');
|
||||
|
||||
try {
|
||||
const remoteUrl = process.env.GITHUB_REPOSITORY
|
||||
? `https://github.com/${process.env.GITHUB_REPOSITORY}.git`
|
||||
: 'origin';
|
||||
|
||||
// Fetch target branch from the remote.
|
||||
execSync(`git fetch ${remoteUrl} ${targetBranch}`, {
|
||||
stdio: 'ignore',
|
||||
});
|
||||
|
||||
// Get changed files using the triple-dot syntax which correctly handles merge commits
|
||||
const head = process.env.PR_HEAD_SHA || 'HEAD';
|
||||
const changedFiles = execSync(`git diff --name-only FETCH_HEAD...${head}`, {
|
||||
encoding: 'utf-8',
|
||||
})
|
||||
.split('\n')
|
||||
.filter(Boolean);
|
||||
|
||||
let detected = false;
|
||||
const reasons = [];
|
||||
|
||||
// 1. Path-based detection
|
||||
for (const file of changedFiles) {
|
||||
if (CORE_STEERING_PATHS.some((prefix) => file.startsWith(prefix))) {
|
||||
detected = true;
|
||||
reasons.push(`Matched core steering path: ${file}`);
|
||||
if (!verbose) break;
|
||||
}
|
||||
if (
|
||||
!steeringOnly &&
|
||||
TEST_PATHS.some((prefix) => file.startsWith(prefix))
|
||||
) {
|
||||
detected = true;
|
||||
reasons.push(`Matched test path: ${file}`);
|
||||
if (!verbose) break;
|
||||
}
|
||||
}
|
||||
|
||||
// 2. Signature-based detection (only in packages/core/src/ and only if not already detected or if verbose)
|
||||
if (!detected || verbose) {
|
||||
const coreChanges = changedFiles.filter((f) =>
|
||||
f.startsWith('packages/core/src/'),
|
||||
);
|
||||
if (coreChanges.length > 0) {
|
||||
// Get the actual diff content for core files
|
||||
const diff = execSync(
|
||||
`git diff -U0 FETCH_HEAD...${head} -- packages/core/src/`,
|
||||
{ encoding: 'utf-8' },
|
||||
);
|
||||
for (const sig of STEERING_SIGNATURES) {
|
||||
if (diff.includes(sig)) {
|
||||
detected = true;
|
||||
reasons.push(`Matched steering signature in core: ${sig}`);
|
||||
if (!verbose) break;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (verbose && reasons.length > 0) {
|
||||
process.stderr.write('Detection reasons:\n');
|
||||
reasons.forEach((r) => process.stderr.write(` - ${r}\n`));
|
||||
}
|
||||
|
||||
process.stdout.write(detected ? 'true' : 'false');
|
||||
} catch (error) {
|
||||
// If anything fails (e.g., no git history), run evals/guidance to be safe
|
||||
process.stderr.write(
|
||||
'Warning: Failed to determine if changes occurred. Defaulting to true.\n',
|
||||
);
|
||||
process.stderr.write(String(error) + '\n');
|
||||
process.stdout.write('true');
|
||||
}
|
||||
}
|
||||
|
||||
main();
|
||||
@@ -0,0 +1,148 @@
|
||||
/**
|
||||
* @license
|
||||
* Copyright 2025 Google LLC
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
import fs from 'node:fs';
|
||||
import path from 'node:path';
|
||||
import os from 'node:os'; // Import os module
|
||||
|
||||
// --- Configuration ---
|
||||
const cliPackageDir = path.resolve('packages', 'cli'); // Base directory for the CLI package
|
||||
const buildTimestampPath = path.join(cliPackageDir, 'dist', '.last_build'); // Path to the timestamp file within the CLI package
|
||||
const sourceDirs = [path.join(cliPackageDir, 'src')]; // Source directory within the CLI package
|
||||
const filesToWatch = [
|
||||
path.join(cliPackageDir, 'package.json'),
|
||||
path.join(cliPackageDir, 'tsconfig.json'),
|
||||
]; // Specific files within the CLI package
|
||||
const buildDir = path.join(cliPackageDir, 'dist'); // Build output directory within the CLI package
|
||||
const warningsFilePath = path.join(os.tmpdir(), 'gemini-cli-warnings.txt'); // Temp file for warnings
|
||||
// ---------------------
|
||||
|
||||
function getMtime(filePath) {
|
||||
try {
|
||||
return fs.statSync(filePath).mtimeMs; // Use mtimeMs for higher precision
|
||||
} catch (err) {
|
||||
if (err.code === 'ENOENT') {
|
||||
return null; // File doesn't exist
|
||||
}
|
||||
console.error(`Error getting stats for ${filePath}:`, err);
|
||||
process.exit(1); // Exit on unexpected errors getting stats
|
||||
}
|
||||
}
|
||||
|
||||
function findSourceFiles(dir, allFiles = []) {
|
||||
const entries = fs.readdirSync(dir, { withFileTypes: true });
|
||||
for (const entry of entries) {
|
||||
const fullPath = path.join(dir, entry.name);
|
||||
// Simple check to avoid recursing into node_modules or build dir itself
|
||||
if (
|
||||
entry.isDirectory() &&
|
||||
entry.name !== 'node_modules' &&
|
||||
fullPath !== buildDir
|
||||
) {
|
||||
findSourceFiles(fullPath, allFiles);
|
||||
} else if (entry.isFile()) {
|
||||
allFiles.push(fullPath);
|
||||
}
|
||||
}
|
||||
return allFiles;
|
||||
}
|
||||
|
||||
console.log('Checking build status...');
|
||||
|
||||
// Clean up old warnings file before check
|
||||
try {
|
||||
if (fs.existsSync(warningsFilePath)) {
|
||||
fs.unlinkSync(warningsFilePath);
|
||||
}
|
||||
} catch (err) {
|
||||
console.warn(
|
||||
`[Check Script] Warning: Could not delete previous warnings file: ${err.message}`,
|
||||
);
|
||||
}
|
||||
|
||||
const buildMtime = getMtime(buildTimestampPath);
|
||||
if (!buildMtime) {
|
||||
// If build is missing, write that as a warning and exit(0) so app can display it
|
||||
const errorMessage = `ERROR: Build timestamp file (${path.relative(process.cwd(), buildTimestampPath)}) not found. Run \`npm run build\` first.`;
|
||||
console.error(errorMessage); // Still log error here
|
||||
try {
|
||||
fs.writeFileSync(warningsFilePath, errorMessage);
|
||||
} catch (writeErr) {
|
||||
console.error(
|
||||
`[Check Script] Error writing missing build warning file: ${writeErr.message}`,
|
||||
);
|
||||
}
|
||||
process.exit(0); // Allow app to start and show the error
|
||||
}
|
||||
|
||||
let newerSourceFileFound = false;
|
||||
const warningMessages = []; // Collect warnings here
|
||||
const allSourceFiles = [];
|
||||
|
||||
// Collect files from specified directories
|
||||
sourceDirs.forEach((dir) => {
|
||||
const dirPath = path.resolve(dir);
|
||||
if (fs.existsSync(dirPath)) {
|
||||
findSourceFiles(dirPath, allSourceFiles);
|
||||
} else {
|
||||
console.warn(`Warning: Source directory "${dir}" not found.`);
|
||||
}
|
||||
});
|
||||
|
||||
// Add specific files
|
||||
filesToWatch.forEach((file) => {
|
||||
const filePath = path.resolve(file);
|
||||
if (fs.existsSync(filePath)) {
|
||||
allSourceFiles.push(filePath);
|
||||
} else {
|
||||
console.warn(`Warning: Watched file "${file}" not found.`);
|
||||
}
|
||||
});
|
||||
|
||||
// Check modification times
|
||||
for (const file of allSourceFiles) {
|
||||
const sourceMtime = getMtime(file);
|
||||
const relativePath = path.relative(process.cwd(), file);
|
||||
const isNewer = sourceMtime && sourceMtime > buildMtime;
|
||||
|
||||
if (isNewer) {
|
||||
const warning = `Warning: Source file "${relativePath}" has been modified since the last build.`;
|
||||
console.warn(warning); // Keep console warning for script debugging
|
||||
warningMessages.push(warning);
|
||||
newerSourceFileFound = true;
|
||||
// break; // Uncomment to stop checking after the first newer file
|
||||
}
|
||||
}
|
||||
|
||||
if (newerSourceFileFound) {
|
||||
const finalWarning =
|
||||
'\nRun "npm run build" to incorporate changes before starting.';
|
||||
warningMessages.push(finalWarning);
|
||||
console.warn(finalWarning);
|
||||
|
||||
// Write warnings to the temp file
|
||||
try {
|
||||
fs.writeFileSync(warningsFilePath, warningMessages.join('\n'));
|
||||
// Removed debug log
|
||||
} catch (err) {
|
||||
console.error(`[Check Script] Error writing warnings file: ${err.message}`);
|
||||
// Proceed without writing, app won't show warnings
|
||||
}
|
||||
} else {
|
||||
console.log('Build is up-to-date.');
|
||||
// Ensure no stale warning file exists if build is ok
|
||||
try {
|
||||
if (fs.existsSync(warningsFilePath)) {
|
||||
fs.unlinkSync(warningsFilePath);
|
||||
}
|
||||
} catch (err) {
|
||||
console.warn(
|
||||
`[Check Script] Warning: Could not delete previous warnings file: ${err.message}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
process.exit(0); // Always exit successfully so the app starts
|
||||
@@ -0,0 +1,60 @@
|
||||
#!/usr/bin/env node
|
||||
|
||||
/**
|
||||
* @license
|
||||
* Copyright 2026 Google LLC
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
/**
|
||||
* Diagnostic: instantiate the real Config and call the same listing functions
|
||||
* the inbox UI uses. Should print out all skills + skill patches + memory
|
||||
* patches the user would see in `/memory inbox`.
|
||||
*/
|
||||
import path from 'node:path';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
|
||||
const SCRIPT_DIR = path.dirname(fileURLToPath(import.meta.url));
|
||||
const REPO_ROOT = path.resolve(SCRIPT_DIR, '..');
|
||||
const corePath = path.join(REPO_ROOT, 'packages/core/dist/src/index.js');
|
||||
|
||||
const { Storage, listInboxSkills, listInboxPatches, listInboxMemoryPatches } =
|
||||
await import(corePath);
|
||||
|
||||
const cwd = process.cwd();
|
||||
const storage = new Storage(cwd);
|
||||
await storage.initialize();
|
||||
|
||||
const config = {
|
||||
storage,
|
||||
isTrustedFolder: () => true,
|
||||
getProjectRoot: () => cwd,
|
||||
};
|
||||
|
||||
const [skills, skillPatches, memoryPatches] = await Promise.all([
|
||||
listInboxSkills(config),
|
||||
listInboxPatches(config),
|
||||
listInboxMemoryPatches(config),
|
||||
]);
|
||||
|
||||
console.log(`\nInbox content for ${cwd}\n`);
|
||||
|
||||
console.log(`Skills (${skills.length}):`);
|
||||
for (const s of skills) {
|
||||
console.log(` - ${s.name} (${s.dirName})`);
|
||||
}
|
||||
|
||||
console.log(`\nSkill update patches (${skillPatches.length}):`);
|
||||
for (const p of skillPatches) {
|
||||
console.log(` - ${p.name} → ${p.entries.length} entry/entries`);
|
||||
}
|
||||
|
||||
console.log(`\nMemory patches (${memoryPatches.length}):`);
|
||||
for (const m of memoryPatches) {
|
||||
console.log(
|
||||
` - [${m.kind}] ${m.relativePath} → ${m.entries.length} entry/entries`,
|
||||
);
|
||||
for (const e of m.entries) {
|
||||
console.log(` ${e.isNewFile ? 'CREATE' : 'UPDATE'} ${e.targetPath}`);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,105 @@
|
||||
/**
|
||||
* @license
|
||||
* Copyright 2025 Google LLC
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
import fs from 'node:fs';
|
||||
import { dirname, join } from 'node:path';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
|
||||
const __dirname = dirname(fileURLToPath(import.meta.url));
|
||||
const root = join(__dirname, '..');
|
||||
const lockfilePath = join(root, 'package-lock.json');
|
||||
|
||||
function readJsonFile(filePath) {
|
||||
try {
|
||||
const fileContent = fs.readFileSync(filePath, 'utf-8');
|
||||
return JSON.parse(fileContent);
|
||||
} catch (error) {
|
||||
console.error(`Error reading or parsing ${filePath}:`, error);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
console.log('Checking lockfile...');
|
||||
|
||||
const lockfile = readJsonFile(lockfilePath);
|
||||
if (lockfile === null) {
|
||||
process.exit(1);
|
||||
}
|
||||
const packages = lockfile.packages || {};
|
||||
const invalidPackages = [];
|
||||
|
||||
for (const [location, details] of Object.entries(packages)) {
|
||||
// 1. Skip the root package itself.
|
||||
if (location === '') {
|
||||
continue;
|
||||
}
|
||||
|
||||
// 2. Skip local workspace packages.
|
||||
// They are identifiable in two ways:
|
||||
// a) As a symlink within node_modules.
|
||||
// b) As the source package definition, whose path is not in node_modules.
|
||||
if (details.link === true || !location.includes('node_modules')) {
|
||||
continue;
|
||||
}
|
||||
|
||||
// 3. Any remaining package should be a third-party dependency.
|
||||
// 1) Registry package with both "resolved" and "integrity" fields is valid.
|
||||
if (details.resolved && details.integrity) {
|
||||
continue;
|
||||
}
|
||||
// 2) Git and file dependencies only need a "resolved" field.
|
||||
const isGitOrFileDep =
|
||||
details.resolved?.startsWith('git') ||
|
||||
details.resolved?.startsWith('file:');
|
||||
if (isGitOrFileDep) {
|
||||
continue;
|
||||
}
|
||||
|
||||
// Mark the left dependency as invalid.
|
||||
invalidPackages.push(location);
|
||||
}
|
||||
|
||||
if (invalidPackages.length > 0) {
|
||||
console.error(
|
||||
'\nError: The following dependencies in package-lock.json are missing the "resolved" or "integrity" field:',
|
||||
);
|
||||
invalidPackages.forEach((pkg) => console.error(`- ${pkg}`));
|
||||
process.exitCode = 1;
|
||||
} else {
|
||||
console.log('Lockfile check passed.');
|
||||
}
|
||||
|
||||
// Check that gaxios v7+ is NOT resolved in any workspace node_modules.
|
||||
// gaxios v7.x has a bug where Array.toString() joins stream chunks with
|
||||
// commas, corrupting error response JSON at TCP chunk boundaries.
|
||||
// See: https://github.com/google-gemini/gemini-cli/pull/21884
|
||||
const gaxiosViolations = [];
|
||||
for (const [location, details] of Object.entries(packages)) {
|
||||
if (
|
||||
location.match(/(^|\/)node_modules\/gaxios$/) &&
|
||||
!location.includes('@google/genai/node_modules') &&
|
||||
details.version &&
|
||||
parseInt(details.version.split('.')[0], 10) >= 7
|
||||
) {
|
||||
gaxiosViolations.push(`${location} (v${details.version})`);
|
||||
}
|
||||
}
|
||||
|
||||
if (gaxiosViolations.length > 0) {
|
||||
console.error(
|
||||
'\nError: gaxios v7+ detected in workspace node_modules. This version has a stream corruption bug.',
|
||||
);
|
||||
console.error('See: https://github.com/google-gemini/gemini-cli/pull/21884');
|
||||
gaxiosViolations.forEach((v) => console.error(`- ${v}`));
|
||||
console.error(
|
||||
'\nDo NOT upgrade @google/genai or google-auth-library until the gaxios v7 bug is fixed upstream.',
|
||||
);
|
||||
process.exitCode = 1;
|
||||
}
|
||||
|
||||
if (!process.exitCode) {
|
||||
process.exitCode = 0;
|
||||
}
|
||||
@@ -0,0 +1,77 @@
|
||||
/**
|
||||
* @license
|
||||
* Copyright 2025 Google LLC
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
//
|
||||
// Licensed under the Apache License, Version 2.0 (the "License");
|
||||
// you may not use this file except in compliance with the License.
|
||||
// You may obtain a copy of the License at
|
||||
//
|
||||
// http://www.apache.org/licenses/LICENSE-2.0
|
||||
//
|
||||
// Unless required by applicable law or agreed to in writing, software
|
||||
// distributed under the License is distributed on an "AS IS" BASIS,
|
||||
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
// See the License for the specific language governing permissions and
|
||||
// limitations under the License.
|
||||
|
||||
import { rmSync, readFileSync, readdirSync, statSync } from 'node:fs';
|
||||
import { dirname, join } from 'node:path';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
|
||||
const __dirname = dirname(fileURLToPath(import.meta.url));
|
||||
const root = join(__dirname, '..');
|
||||
|
||||
// remove npm install/build artifacts
|
||||
rmSync(join(root, 'node_modules'), { recursive: true, force: true });
|
||||
rmSync(join(root, 'bundle'), { recursive: true, force: true });
|
||||
rmSync(join(root, 'packages/cli/src/generated/'), {
|
||||
recursive: true,
|
||||
force: true,
|
||||
});
|
||||
const RMRF_OPTIONS = { recursive: true, force: true };
|
||||
rmSync(join(root, 'bundle'), RMRF_OPTIONS);
|
||||
// Dynamically clean dist directories in all workspaces
|
||||
const rootPackageJson = JSON.parse(
|
||||
readFileSync(join(root, 'package.json'), 'utf-8'),
|
||||
);
|
||||
for (const workspace of rootPackageJson.workspaces) {
|
||||
// Note: this is a simple glob implementation that only supports "packages/*".
|
||||
const workspaceDir = join(root, dirname(workspace));
|
||||
const packageDirs = readdirSync(workspaceDir);
|
||||
|
||||
for (const pkg of packageDirs) {
|
||||
const pkgDir = join(workspaceDir, pkg);
|
||||
try {
|
||||
if (statSync(pkgDir).isDirectory()) {
|
||||
rmSync(join(pkgDir, 'dist'), RMRF_OPTIONS);
|
||||
}
|
||||
} catch (e) {
|
||||
if (e.code !== 'ENOENT') {
|
||||
throw e;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Clean up vscode-ide-companion package
|
||||
rmSync(join(root, 'packages/vscode-ide-companion/node_modules'), {
|
||||
recursive: true,
|
||||
force: true,
|
||||
});
|
||||
|
||||
const vscodeCompanionDir = join(root, 'packages/vscode-ide-companion');
|
||||
try {
|
||||
const files = readdirSync(vscodeCompanionDir);
|
||||
for (const file of files) {
|
||||
if (file.endsWith('.vsix')) {
|
||||
rmSync(join(vscodeCompanionDir, file), RMRF_OPTIONS);
|
||||
}
|
||||
}
|
||||
} catch (e) {
|
||||
if (e.code !== 'ENOENT') {
|
||||
throw e;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,180 @@
|
||||
/**
|
||||
* @license
|
||||
* Copyright 2026 Google LLC
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
import { execSync } from 'node:child_process';
|
||||
import * as readline from 'node:readline/promises';
|
||||
import * as process from 'node:process';
|
||||
|
||||
function runCmd(cmd: string): string {
|
||||
return execSync(cmd, {
|
||||
encoding: 'utf-8',
|
||||
stdio: ['pipe', 'pipe', 'ignore'],
|
||||
}).trim();
|
||||
}
|
||||
|
||||
async function main() {
|
||||
try {
|
||||
runCmd('gh --version');
|
||||
} catch {
|
||||
console.error(
|
||||
'Error: "gh" CLI is required but not installed or not working.',
|
||||
);
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
try {
|
||||
runCmd('git --version');
|
||||
} catch {
|
||||
console.error('Error: "git" is required.');
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
console.log('Fetching remote branches from origin...');
|
||||
let allBranchesOutput = '';
|
||||
try {
|
||||
// Also fetch to ensure we have the latest commit dates
|
||||
console.log(
|
||||
'Running git fetch to ensure we have up-to-date commit dates and prune stale branches...',
|
||||
);
|
||||
runCmd('git fetch origin --prune');
|
||||
|
||||
// Get all branches with their commit dates
|
||||
allBranchesOutput = runCmd(
|
||||
"git for-each-ref --format='%(refname:lstrip=3) %(committerdate:unix)' refs/remotes/origin",
|
||||
);
|
||||
} catch {
|
||||
console.error('Failed to fetch branches from origin.');
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
const THIRTY_DAYS_IN_SECONDS = 30 * 24 * 60 * 60;
|
||||
const now = Math.floor(Date.now() / 1000);
|
||||
|
||||
const remoteBranches: { name: string; lastCommitDate: number }[] =
|
||||
allBranchesOutput
|
||||
.split(/\r?\n/)
|
||||
.map((line) => {
|
||||
const parts = line.split(' ');
|
||||
if (parts.length < 2) return null;
|
||||
const date = parseInt(parts.pop() || '0', 10);
|
||||
const name = parts.join(' ');
|
||||
return { name, lastCommitDate: date };
|
||||
})
|
||||
.filter((b): b is { name: string; lastCommitDate: number } => b !== null);
|
||||
|
||||
console.log(`Found ${remoteBranches.length} branches on origin.`);
|
||||
|
||||
console.log('Fetching open PRs...');
|
||||
let openPrsJson = '[]';
|
||||
try {
|
||||
openPrsJson = runCmd(
|
||||
'gh pr list --state open --limit 5000 --json headRefName',
|
||||
);
|
||||
} catch {
|
||||
console.error('Failed to fetch open PRs.');
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
const openPrs = JSON.parse(openPrsJson);
|
||||
const openPrBranches = new Set(
|
||||
openPrs.map((pr: { headRefName: string }) => pr.headRefName),
|
||||
);
|
||||
|
||||
const protectedPattern =
|
||||
/^(main|master|next|release[-/].*|hotfix[-/].*|v\d+.*|HEAD|gh-readonly-queue.*)$/;
|
||||
|
||||
const branchesToDelete = remoteBranches.filter((branch) => {
|
||||
if (protectedPattern.test(branch.name)) {
|
||||
return false;
|
||||
}
|
||||
if (openPrBranches.has(branch.name)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const ageInSeconds = now - branch.lastCommitDate;
|
||||
if (ageInSeconds < THIRTY_DAYS_IN_SECONDS) {
|
||||
return false; // Skip branches pushed to recently
|
||||
}
|
||||
|
||||
return true;
|
||||
});
|
||||
|
||||
if (branchesToDelete.length === 0) {
|
||||
console.log('No remote branches to delete.');
|
||||
return;
|
||||
}
|
||||
|
||||
console.log(
|
||||
'\nThe following remote branches are NOT release branches, have NO active PR, and are OLDER than 30 days:',
|
||||
);
|
||||
console.log(
|
||||
'---------------------------------------------------------------------',
|
||||
);
|
||||
branchesToDelete.forEach((b) => console.log(` - ${b.name}`));
|
||||
console.log(
|
||||
'---------------------------------------------------------------------',
|
||||
);
|
||||
console.log(`Total to delete: ${branchesToDelete.length}`);
|
||||
|
||||
const rl = readline.createInterface({
|
||||
input: process.stdin,
|
||||
output: process.stdout,
|
||||
});
|
||||
|
||||
const answer = await rl.question(
|
||||
`\nDo you want to delete these ${branchesToDelete.length} remote branches from origin? (y/N) `,
|
||||
);
|
||||
rl.close();
|
||||
|
||||
if (answer.toLowerCase() === 'y') {
|
||||
console.log('Deleting remote branches...');
|
||||
// Delete in batches to avoid hitting command line length limits
|
||||
const batchSize = 50;
|
||||
for (let i = 0; i < branchesToDelete.length; i += batchSize) {
|
||||
const batch = branchesToDelete.slice(i, i + batchSize).map((b) => b.name);
|
||||
const branchList = batch.join(' ');
|
||||
console.log(`Deleting remote batch ${Math.floor(i / batchSize) + 1}...`);
|
||||
try {
|
||||
execSync(`git push origin --delete ${branchList}`, {
|
||||
stdio: 'inherit',
|
||||
});
|
||||
} catch {
|
||||
console.warn('Batch failed, trying to delete branches individually...');
|
||||
for (const branch of batch) {
|
||||
try {
|
||||
execSync(`git push origin --delete ${branch}`, {
|
||||
stdio: 'pipe',
|
||||
});
|
||||
} catch (err: unknown) {
|
||||
const error = err as { stderr?: Buffer; message?: string };
|
||||
const stderr = error.stderr?.toString() || '';
|
||||
if (!stderr.includes('remote ref does not exist')) {
|
||||
console.error(
|
||||
`Failed to delete branch "${branch}":`,
|
||||
stderr.trim() || error.message,
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
console.log('Cleaning up local tracking branches...');
|
||||
try {
|
||||
execSync('git remote prune origin', { stdio: 'inherit' });
|
||||
} catch {
|
||||
console.error('Failed to prune local tracking branches.');
|
||||
}
|
||||
console.log('Cleanup complete.');
|
||||
} else {
|
||||
console.log('Operation cancelled.');
|
||||
}
|
||||
}
|
||||
|
||||
main().catch((e) => {
|
||||
console.error(e);
|
||||
process.exit(1);
|
||||
});
|
||||
@@ -0,0 +1,151 @@
|
||||
/**
|
||||
* @license
|
||||
* Copyright 2026 Google LLC
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
import { Octokit } from '@octokit/rest';
|
||||
import yargs from 'yargs';
|
||||
import { hideBin } from 'yargs/helpers';
|
||||
import prompts from 'prompts';
|
||||
|
||||
if (!process.env.GITHUB_TOKEN) {
|
||||
console.error('Error: GITHUB_TOKEN environment variable is required.');
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
const argv = yargs(hideBin(process.argv))
|
||||
.option('query', {
|
||||
alias: 'q',
|
||||
type: 'string',
|
||||
description:
|
||||
'Search query to find duplicate issues (e.g. "function response parts")',
|
||||
demandOption: true,
|
||||
})
|
||||
.option('canonical', {
|
||||
alias: 'c',
|
||||
type: 'number',
|
||||
description: 'The canonical issue number to duplicate others to',
|
||||
demandOption: true,
|
||||
})
|
||||
.option('pr', {
|
||||
type: 'string',
|
||||
description:
|
||||
'Optional Pull Request URL or ID to mention in the closing comment',
|
||||
})
|
||||
.option('owner', {
|
||||
type: 'string',
|
||||
default: 'google-gemini',
|
||||
description: 'Repository owner',
|
||||
})
|
||||
.option('repo', {
|
||||
type: 'string',
|
||||
default: 'gemini-cli',
|
||||
description: 'Repository name',
|
||||
})
|
||||
.option('dry-run', {
|
||||
alias: 'd',
|
||||
type: 'boolean',
|
||||
default: false,
|
||||
description: 'Run without making actual changes (read-only mode)',
|
||||
})
|
||||
.option('auto', {
|
||||
type: 'boolean',
|
||||
default: false,
|
||||
description:
|
||||
'Automatically close all duplicates without prompting (batch mode)',
|
||||
})
|
||||
.help()
|
||||
.parse();
|
||||
|
||||
const octokit = new Octokit({
|
||||
auth: process.env.GITHUB_TOKEN,
|
||||
});
|
||||
|
||||
const { query, canonical, pr, owner, repo, dryRun, auto } = argv;
|
||||
|
||||
// Construct the full search query ensuring it targets the specific repo and open issues
|
||||
const fullSearchQuery = `repo:${owner}/${repo} is:issue is:open ${query}`;
|
||||
|
||||
async function run() {
|
||||
console.log(`Searching for issues matching: ${fullSearchQuery}`);
|
||||
if (dryRun) {
|
||||
console.log('--- DRY RUN MODE: No changes will be made ---');
|
||||
}
|
||||
|
||||
try {
|
||||
const issues = await octokit.paginate(
|
||||
octokit.rest.search.issuesAndPullRequests,
|
||||
{
|
||||
q: fullSearchQuery,
|
||||
},
|
||||
);
|
||||
|
||||
console.log(`Found ${issues.length} issues.`);
|
||||
|
||||
for (const issue of issues) {
|
||||
if (issue.number === canonical) {
|
||||
console.log(`Skipping canonical issue #${issue.number}`);
|
||||
continue;
|
||||
}
|
||||
|
||||
console.log(
|
||||
`Processing issue #${issue.number}: ${issue.title} (by @${issue.user?.login})`,
|
||||
);
|
||||
|
||||
if (!auto && !dryRun) {
|
||||
const response = await prompts({
|
||||
type: 'confirm',
|
||||
name: 'value',
|
||||
message: `Close issue #${issue.number} "${issue.title}" created by @${issue.user?.login}?`,
|
||||
initial: true,
|
||||
});
|
||||
|
||||
if (!response.value) {
|
||||
console.log(`Skipping issue #${issue.number}`);
|
||||
continue;
|
||||
}
|
||||
}
|
||||
|
||||
let commentBody = `Closing this issue as a duplicate of #${canonical}.`;
|
||||
if (pr) {
|
||||
commentBody += ` Please note that this issue should be resolved by PR ${pr}.`;
|
||||
}
|
||||
|
||||
try {
|
||||
if (!dryRun) {
|
||||
// Add comment
|
||||
await octokit.rest.issues.createComment({
|
||||
owner,
|
||||
repo,
|
||||
issue_number: issue.number,
|
||||
body: commentBody,
|
||||
});
|
||||
console.log(` Added comment.`);
|
||||
|
||||
// Close issue
|
||||
await octokit.rest.issues.update({
|
||||
owner,
|
||||
repo,
|
||||
issue_number: issue.number,
|
||||
state: 'closed',
|
||||
state_reason: 'duplicate',
|
||||
});
|
||||
console.log(` Closed issue.`);
|
||||
} else {
|
||||
console.log(` [DRY RUN] Would add comment: "${commentBody}"`);
|
||||
console.log(` [DRY RUN] Would close issue #${issue.number}`);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error(
|
||||
` Failed to process issue #${issue.number}:`,
|
||||
error.message,
|
||||
);
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error searching for issues:', error.message);
|
||||
process.exit(1);
|
||||
}
|
||||
}
|
||||
|
||||
run().catch(console.error);
|
||||
@@ -0,0 +1,142 @@
|
||||
/**
|
||||
* @license
|
||||
* Copyright 2026 Google LLC
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
/**
|
||||
* @fileoverview Compares PR evaluation results against historical nightly baselines.
|
||||
*
|
||||
* This script generates a Markdown report for use in PR comments. It aligns with
|
||||
* the 6-day lookback logic to show accurate historical pass rates and filters out
|
||||
* pre-existing or noisy failures to ensure only actionable regressions are reported.
|
||||
*/
|
||||
|
||||
import fs from 'node:fs';
|
||||
import path from 'node:path';
|
||||
import { fetchNightlyHistory } from './eval_utils.js';
|
||||
|
||||
/**
|
||||
* Main execution logic.
|
||||
*/
|
||||
function main() {
|
||||
const prReportPath = 'evals/logs/pr_final_report.json';
|
||||
const targetModel = process.argv[2];
|
||||
|
||||
if (!targetModel) {
|
||||
console.error('❌ Error: No target model specified.');
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
if (!fs.existsSync(prReportPath)) {
|
||||
console.error('No PR report found.');
|
||||
return;
|
||||
}
|
||||
|
||||
const prReport = JSON.parse(fs.readFileSync(prReportPath, 'utf-8'));
|
||||
const history = fetchNightlyHistory(6); // Use same 6-day lookback
|
||||
const latestNightly = aggregateHistoricalStats(history, targetModel);
|
||||
|
||||
const regressions = [];
|
||||
const passes = [];
|
||||
|
||||
for (const [testName, pr] of Object.entries(prReport.results)) {
|
||||
const prRate = pr.passed / pr.total;
|
||||
if (pr.status === 'regression' || (prRate <= 0.34 && !pr.status)) {
|
||||
// Use relative path from workspace root
|
||||
const relativeFile = pr.file
|
||||
? path.relative(process.cwd(), pr.file)
|
||||
: 'evals/';
|
||||
|
||||
regressions.push({
|
||||
name: testName,
|
||||
file: relativeFile,
|
||||
nightly: latestNightly[testName]
|
||||
? (latestNightly[testName].passRate * 100).toFixed(0) + '%'
|
||||
: 'N/A',
|
||||
pr: (prRate * 100).toFixed(0) + '%',
|
||||
});
|
||||
} else {
|
||||
passes.push(testName);
|
||||
}
|
||||
}
|
||||
|
||||
if (regressions.length > 0) {
|
||||
let markdown = '### 🚨 Action Required: Eval Regressions Detected\n\n';
|
||||
markdown += `**Model:** \`${targetModel}\`\n\n`;
|
||||
markdown +=
|
||||
'The following trustworthy evaluations passed on **`main`** and in **recent Nightly runs**, but failed in this PR. These regressions must be addressed before merging.\n\n';
|
||||
|
||||
markdown += '| Test Name | Nightly | PR Result | Status |\n';
|
||||
markdown += '| :--- | :---: | :---: | :--- |\n';
|
||||
for (const r of regressions) {
|
||||
markdown += `| ${r.name} | ${r.nightly} | ${r.pr} | ❌ **Regression** |\n`;
|
||||
}
|
||||
markdown += `\n*The check passed or was cleared for ${passes.length} other trustworthy evaluations.*\n\n`;
|
||||
|
||||
markdown += '<details>\n';
|
||||
markdown +=
|
||||
'<summary><b>🛠️ Troubleshooting & Fix Instructions</b></summary>\n\n';
|
||||
|
||||
for (let i = 0; i < regressions.length; i++) {
|
||||
const r = regressions[i];
|
||||
if (regressions.length > 1) {
|
||||
markdown += `### Failure ${i + 1}: ${r.name}\n\n`;
|
||||
}
|
||||
|
||||
markdown += '#### 1. Ask Gemini CLI to fix it (Recommended)\n';
|
||||
markdown += 'Copy and paste this prompt to the agent:\n';
|
||||
markdown += '```text\n';
|
||||
markdown += `The eval "${r.name}" in ${r.file} is failing. Investigate and fix it using the behavioral-evals skill.\n`;
|
||||
markdown += '```\n\n';
|
||||
|
||||
markdown += '#### 2. Reproduce Locally\n';
|
||||
markdown += 'Run the following command to see the failure trajectory:\n';
|
||||
markdown += '```bash\n';
|
||||
const pattern = r.name.replace(/'/g, '.');
|
||||
markdown += `GEMINI_MODEL=${targetModel} npm run test:all_evals -- ${r.file} --testNamePattern="${pattern}"\n`;
|
||||
|
||||
markdown += '```\n\n';
|
||||
|
||||
if (i < regressions.length - 1) {
|
||||
markdown += '---\n\n';
|
||||
}
|
||||
}
|
||||
|
||||
markdown += '#### 3. Manual Fix\n';
|
||||
markdown +=
|
||||
'See the [Fixing Guide](https://github.com/google-gemini/gemini-cli/blob/main/evals/README.md#fixing-evaluations) for detailed troubleshooting steps.\n';
|
||||
markdown += '</details>\n';
|
||||
|
||||
process.stdout.write(markdown);
|
||||
} else if (passes.length > 0) {
|
||||
// Success State
|
||||
process.stdout.write(
|
||||
`✅ **${passes.length}** tests passed successfully on **${targetModel}**.\n`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Aggregates stats from history for a specific model.
|
||||
*/
|
||||
function aggregateHistoricalStats(history, model) {
|
||||
const stats = {};
|
||||
for (const item of history) {
|
||||
const modelStats = item.stats[model];
|
||||
if (!modelStats) continue;
|
||||
|
||||
for (const [testName, stat] of Object.entries(modelStats)) {
|
||||
if (!stats[testName]) stats[testName] = { passed: 0, total: 0 };
|
||||
stats[testName].passed += stat.passed;
|
||||
stats[testName].total += stat.total;
|
||||
}
|
||||
}
|
||||
|
||||
for (const name in stats) {
|
||||
stats[name].passRate = stats[name].passed / stats[name].total;
|
||||
}
|
||||
return stats;
|
||||
}
|
||||
|
||||
main();
|
||||
@@ -0,0 +1,118 @@
|
||||
/**
|
||||
* @license
|
||||
* Copyright 2025 Google LLC
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
//
|
||||
// Licensed under the Apache License, Version 2.0 (the "License");
|
||||
// you may not use this file except in compliance with the License.
|
||||
// You may obtain a copy of the License at
|
||||
//
|
||||
// http://www.apache.org/licenses/LICENSE-2.0
|
||||
//
|
||||
// Unless required by applicable law or agreed to in writing, software
|
||||
// distributed under the License is distributed on an "AS IS" BASIS,
|
||||
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
// See the License for the specific language governing permissions and
|
||||
// limitations under the License.
|
||||
|
||||
import { copyFileSync, existsSync, mkdirSync, cpSync } from 'node:fs';
|
||||
import { dirname, join, basename } from 'node:path';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
import { glob } from 'glob';
|
||||
|
||||
const __dirname = dirname(fileURLToPath(import.meta.url));
|
||||
const root = join(__dirname, '..');
|
||||
const bundleDir = join(root, 'bundle');
|
||||
|
||||
// Create the bundle directory if it doesn't exist
|
||||
if (!existsSync(bundleDir)) {
|
||||
mkdirSync(bundleDir);
|
||||
}
|
||||
|
||||
// 1. Copy Sandbox definitions (.sb)
|
||||
const sbFiles = glob.sync('packages/**/*.sb', { cwd: root });
|
||||
for (const file of sbFiles) {
|
||||
copyFileSync(join(root, file), join(bundleDir, basename(file)));
|
||||
}
|
||||
|
||||
// 2. Copy Policy definitions (.toml)
|
||||
const policyDir = join(bundleDir, 'policies');
|
||||
if (!existsSync(policyDir)) {
|
||||
mkdirSync(policyDir);
|
||||
}
|
||||
|
||||
// Locate policy files specifically in the core package
|
||||
const policyFiles = glob.sync('packages/core/src/policy/policies/*.toml', {
|
||||
cwd: root,
|
||||
});
|
||||
|
||||
for (const file of policyFiles) {
|
||||
copyFileSync(join(root, file), join(policyDir, basename(file)));
|
||||
}
|
||||
|
||||
console.log(`Copied ${policyFiles.length} policy files to bundle/policies/`);
|
||||
|
||||
// Also copy policies to a2a-server dist directory for bundled execution
|
||||
const a2aPolicyDir = join(root, 'packages/a2a-server/dist/policies');
|
||||
if (!existsSync(a2aPolicyDir)) {
|
||||
mkdirSync(a2aPolicyDir, { recursive: true });
|
||||
}
|
||||
for (const file of policyFiles) {
|
||||
copyFileSync(join(root, file), join(a2aPolicyDir, basename(file)));
|
||||
}
|
||||
console.log(
|
||||
`Copied ${policyFiles.length} policy files to packages/a2a-server/dist/policies/`,
|
||||
);
|
||||
|
||||
// 3. Copy Documentation (docs/)
|
||||
const docsSrc = join(root, 'docs');
|
||||
const docsDest = join(bundleDir, 'docs');
|
||||
if (existsSync(docsSrc)) {
|
||||
cpSync(docsSrc, docsDest, { recursive: true, dereference: true });
|
||||
console.log('Copied docs to bundle/docs/');
|
||||
}
|
||||
|
||||
// 4. Copy Built-in Skills (packages/core/src/skills/builtin)
|
||||
const builtinSkillsSrc = join(root, 'packages/core/src/skills/builtin');
|
||||
const builtinSkillsDest = join(bundleDir, 'builtin');
|
||||
if (existsSync(builtinSkillsSrc)) {
|
||||
cpSync(builtinSkillsSrc, builtinSkillsDest, {
|
||||
recursive: true,
|
||||
dereference: true,
|
||||
});
|
||||
console.log('Copied built-in skills to bundle/builtin/');
|
||||
}
|
||||
|
||||
// 5. Copy bundled chrome-devtools-mcp
|
||||
const bundleMcpSrc = join(root, 'packages/core/dist/bundled');
|
||||
const bundleMcpDest = join(bundleDir, 'bundled');
|
||||
if (!existsSync(bundleMcpSrc)) {
|
||||
console.error(
|
||||
`Error: chrome-devtools-mcp bundle not found at ${bundleMcpSrc}.\n` +
|
||||
`Run "npm run bundle:browser-mcp -w @google/gemini-cli-core" first.`,
|
||||
);
|
||||
process.exit(1);
|
||||
}
|
||||
cpSync(bundleMcpSrc, bundleMcpDest, { recursive: true, dereference: true });
|
||||
console.log('Copied bundled chrome-devtools-mcp to bundle/bundled/');
|
||||
|
||||
// 6. Copy Extension Examples
|
||||
const extensionExamplesSrc = join(
|
||||
root,
|
||||
'packages/cli/src/commands/extensions/examples',
|
||||
);
|
||||
const extensionExamplesDest = join(bundleDir, 'examples');
|
||||
const EXCLUDED_EXAMPLE_DIRS = ['node_modules', 'dist'];
|
||||
|
||||
if (existsSync(extensionExamplesSrc)) {
|
||||
cpSync(extensionExamplesSrc, extensionExamplesDest, {
|
||||
recursive: true,
|
||||
dereference: true,
|
||||
filter: (src) => !EXCLUDED_EXAMPLE_DIRS.some((dir) => src.includes(dir)),
|
||||
});
|
||||
console.log('Copied extension examples to bundle/examples/');
|
||||
}
|
||||
|
||||
console.log('Assets copied to bundle/');
|
||||
@@ -0,0 +1,86 @@
|
||||
#!/usr/bin/env node
|
||||
|
||||
/**
|
||||
* @license
|
||||
* Copyright 2025 Google LLC
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
// Copyright 2025 Google LLC
|
||||
//
|
||||
// Licensed under the Apache License, Version 2.0 (the "License");
|
||||
// you may not use this file except in compliance with the License.
|
||||
// You may obtain a copy of the License at
|
||||
//
|
||||
// http://www.apache.org/licenses/LICENSE-2.0
|
||||
//
|
||||
// Unless required by applicable law or agreed to in writing, software
|
||||
// distributed under the License is distributed on an "AS IS" BASIS,
|
||||
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
// See the License for the specific language governing permissions and
|
||||
// limitations under the License.
|
||||
|
||||
import fs from 'node:fs';
|
||||
import path from 'node:path';
|
||||
|
||||
const sourceDir = path.join('src');
|
||||
const targetDir = path.join('dist', 'src');
|
||||
|
||||
const extensionsToCopy = ['.md', '.json', '.sb', '.toml', '.cs', '.exe'];
|
||||
|
||||
function copyFilesRecursive(source, target) {
|
||||
if (!fs.existsSync(target)) {
|
||||
fs.mkdirSync(target, { recursive: true });
|
||||
}
|
||||
|
||||
const items = fs.readdirSync(source, { withFileTypes: true });
|
||||
|
||||
for (const item of items) {
|
||||
const sourcePath = path.join(source, item.name);
|
||||
const targetPath = path.join(target, item.name);
|
||||
|
||||
if (item.isDirectory()) {
|
||||
copyFilesRecursive(sourcePath, targetPath);
|
||||
} else if (extensionsToCopy.includes(path.extname(item.name))) {
|
||||
fs.copyFileSync(sourcePath, targetPath);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (!fs.existsSync(sourceDir)) {
|
||||
console.error(`Source directory ${sourceDir} not found.`);
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
copyFilesRecursive(sourceDir, targetDir);
|
||||
|
||||
// Copy example extensions into the bundle.
|
||||
const packageName = path.basename(process.cwd());
|
||||
if (packageName === 'cli') {
|
||||
const examplesSource = path.join(
|
||||
sourceDir,
|
||||
'commands',
|
||||
'extensions',
|
||||
'examples',
|
||||
);
|
||||
const examplesTarget = path.join(
|
||||
targetDir,
|
||||
'commands',
|
||||
'extensions',
|
||||
'examples',
|
||||
);
|
||||
if (fs.existsSync(examplesSource)) {
|
||||
fs.cpSync(examplesSource, examplesTarget, { recursive: true });
|
||||
}
|
||||
}
|
||||
|
||||
// Copy built-in skills for the core package.
|
||||
if (packageName === 'core') {
|
||||
const builtinSkillsSource = path.join(sourceDir, 'skills', 'builtin');
|
||||
const builtinSkillsTarget = path.join(targetDir, 'skills', 'builtin');
|
||||
if (fs.existsSync(builtinSkillsSource)) {
|
||||
fs.cpSync(builtinSkillsSource, builtinSkillsTarget, { recursive: true });
|
||||
}
|
||||
}
|
||||
|
||||
console.log('Successfully copied files.');
|
||||
Executable
+39
@@ -0,0 +1,39 @@
|
||||
#!/usr/bin/env bash
|
||||
set -euo pipefail
|
||||
|
||||
# This script creates an alias for the Gemini CLI
|
||||
|
||||
# Determine the project directory
|
||||
PROJECT_DIR=$(cd "$(dirname "$0")/.." && pwd)
|
||||
ALIAS_COMMAND="alias gemini='node "${PROJECT_DIR}/scripts/start.js"'"
|
||||
|
||||
# Detect shell and set config file path
|
||||
if [[ "${SHELL}" == *"/bash" ]]; then
|
||||
CONFIG_FILE="${HOME}/.bashrc"
|
||||
elif [[ "${SHELL}" == *"/zsh" ]]; then
|
||||
CONFIG_FILE="${HOME}/.zshrc"
|
||||
else
|
||||
echo "Unsupported shell. Only bash and zsh are supported."
|
||||
exit 1
|
||||
fi
|
||||
|
||||
echo "This script will add the following alias to your shell configuration file (${CONFIG_FILE}):"
|
||||
echo " ${ALIAS_COMMAND}"
|
||||
echo ""
|
||||
|
||||
# Check if the alias already exists
|
||||
if grep -q "alias gemini=" "${CONFIG_FILE}"; then
|
||||
echo "A 'gemini' alias already exists in ${CONFIG_FILE}. No changes were made."
|
||||
exit 0
|
||||
fi
|
||||
|
||||
read -p "Do you want to proceed? (y/n) " -n 1 -r
|
||||
echo ""
|
||||
if [[ "${REPLY}" =~ ^[Yy]$ ]]; then
|
||||
echo "${ALIAS_COMMAND}" >> "${CONFIG_FILE}"
|
||||
echo ""
|
||||
echo "Alias added to ${CONFIG_FILE}."
|
||||
echo "Please run 'source ${CONFIG_FILE}' or open a new terminal to use the 'gemini' command."
|
||||
else
|
||||
echo "Aborted. No changes were made."
|
||||
fi
|
||||
@@ -0,0 +1,134 @@
|
||||
/**
|
||||
* @license
|
||||
* Copyright 2025 Google LLC
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
import { spawn } from 'node:child_process';
|
||||
import fs from 'node:fs/promises';
|
||||
import path from 'node:path';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
import yargs from 'yargs';
|
||||
import { hideBin } from 'yargs/helpers';
|
||||
|
||||
// Script to deflake tests
|
||||
// Ex. npm run deflake -- --command="npm run test:e2e -- --test-name-pattern 'extension'" --runs=3
|
||||
|
||||
const __dirname = path.dirname(fileURLToPath(import.meta.url));
|
||||
const projectRoot = path.resolve(__dirname, '..');
|
||||
const dockerIgnorePath = path.join(projectRoot, '.dockerignore');
|
||||
|
||||
const DOCKERIGNORE_CONTENT = `.integration-tests`.trim();
|
||||
|
||||
/**
|
||||
* Runs a command and streams its output to the console.
|
||||
* @param {string} command The command string to execute (e.g., 'npm run test:e2e -- --watch').
|
||||
* @returns {Promise<number>} A Promise that resolves with the exit code of the process.
|
||||
*/
|
||||
function runCommand(cmd, args = []) {
|
||||
if (!cmd) {
|
||||
return Promise.resolve(1);
|
||||
}
|
||||
|
||||
return new Promise((resolve, reject) => {
|
||||
const child = spawn(cmd, args, {
|
||||
shell: true,
|
||||
stdio: 'inherit',
|
||||
env: { ...process.env },
|
||||
});
|
||||
|
||||
child.on('close', (code) => {
|
||||
resolve(code ?? 1); // code can be null if the process was killed
|
||||
});
|
||||
|
||||
child.on('error', (err) => {
|
||||
// An error occurred in spawning the process (e.g., command not found).
|
||||
console.error(`Failed to start command: ${err.message}`);
|
||||
reject(err);
|
||||
});
|
||||
});
|
||||
}
|
||||
// -------------------------------------------------------------------
|
||||
|
||||
async function main() {
|
||||
const argv = await yargs(hideBin(process.argv))
|
||||
.option('command', {
|
||||
type: 'string',
|
||||
demandOption: true,
|
||||
description: 'The command to run',
|
||||
})
|
||||
.option('runs', {
|
||||
type: 'number',
|
||||
default: 5,
|
||||
description: 'The number of runs to perform',
|
||||
}).argv;
|
||||
|
||||
const NUM_RUNS = argv.runs;
|
||||
const COMMAND = argv.command;
|
||||
const ARGS = argv._;
|
||||
let failures = 0;
|
||||
|
||||
const backupDockerIgnorePath = dockerIgnorePath + '.bak';
|
||||
let originalDockerIgnoreRenamed = false;
|
||||
|
||||
console.log(`--- Starting Deflake Run (${NUM_RUNS} iterations) ---`);
|
||||
|
||||
try {
|
||||
try {
|
||||
// Try to rename to back up an existing .dockerignore
|
||||
await fs.rename(dockerIgnorePath, backupDockerIgnorePath);
|
||||
originalDockerIgnoreRenamed = true;
|
||||
} catch (err) {
|
||||
// If the file doesn't exist, that's fine. Otherwise, rethrow.
|
||||
if (err.code !== 'ENOENT') throw err;
|
||||
}
|
||||
|
||||
// Create the temporary .dockerignore for this run.
|
||||
await fs.writeFile(dockerIgnorePath, DOCKERIGNORE_CONTENT);
|
||||
|
||||
for (let i = 1; i <= NUM_RUNS; i++) {
|
||||
console.log(`\n[RUN ${i}/${NUM_RUNS}]`);
|
||||
|
||||
try {
|
||||
const exitCode = await runCommand(COMMAND, ARGS);
|
||||
|
||||
if (exitCode === 0) {
|
||||
console.log('✅ Run PASS');
|
||||
} else {
|
||||
console.log(`❌ Run FAIL (Exit Code: ${exitCode})`);
|
||||
failures++;
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('❌ Run FAIL (Execution Error)', error);
|
||||
failures++;
|
||||
}
|
||||
}
|
||||
} finally {
|
||||
try {
|
||||
// Clean up the temporary .dockerignore
|
||||
await fs.unlink(dockerIgnorePath);
|
||||
} catch (err) {
|
||||
console.error('Failed to remove temporary .dockerignore:', err);
|
||||
}
|
||||
|
||||
if (originalDockerIgnoreRenamed) {
|
||||
try {
|
||||
// Restore the original .dockerignore if it was backed up.
|
||||
await fs.rename(backupDockerIgnorePath, dockerIgnorePath);
|
||||
} catch (err) {
|
||||
console.error('Failed to restore original .dockerignore:', err);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
console.log('\n--- FINAL DEFLAKE SUMMARY ---');
|
||||
console.log(`Total Runs: ${NUM_RUNS}`);
|
||||
console.log(`Total Failures: ${failures}`);
|
||||
|
||||
process.exit(failures > 0 ? 1 : 0);
|
||||
}
|
||||
|
||||
main().catch((error) => {
|
||||
console.error('Error in deflake:', error);
|
||||
process.exit(1);
|
||||
});
|
||||
@@ -0,0 +1,146 @@
|
||||
/**
|
||||
* @license
|
||||
* Copyright 2026 Google LLC
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
/**
|
||||
* @fileoverview This script downloads pre-built ripgrep binaries for all supported
|
||||
* architectures and platforms. These binaries are checked into the repository
|
||||
* under packages/core/vendor/ripgrep.
|
||||
*
|
||||
* Maintainers should periodically run this script to upgrade the version
|
||||
* of ripgrep being distributed.
|
||||
*
|
||||
* Usage: npx tsx scripts/download-ripgrep-binaries.ts
|
||||
*/
|
||||
|
||||
import fs from 'node:fs';
|
||||
import fsPromises from 'node:fs/promises';
|
||||
import path from 'node:path';
|
||||
import { pipeline } from 'node:stream/promises';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
import { createWriteStream } from 'node:fs';
|
||||
import { Readable } from 'node:stream';
|
||||
import type { ReadableStream } from 'node:stream/web';
|
||||
import { execFileSync } from 'node:child_process';
|
||||
|
||||
const __dirname = path.dirname(fileURLToPath(import.meta.url));
|
||||
const CORE_VENDOR_DIR = path.join(__dirname, '../packages/core/vendor/ripgrep');
|
||||
const VERSION = 'v13.0.0-10';
|
||||
|
||||
interface Target {
|
||||
platform: string;
|
||||
arch: string;
|
||||
file: string;
|
||||
}
|
||||
|
||||
const targets: Target[] = [
|
||||
{ platform: 'darwin', arch: 'arm64', file: 'aarch64-apple-darwin.tar.gz' },
|
||||
{ platform: 'darwin', arch: 'x64', file: 'x86_64-apple-darwin.tar.gz' },
|
||||
{
|
||||
platform: 'linux',
|
||||
arch: 'arm64',
|
||||
file: 'aarch64-unknown-linux-gnu.tar.gz',
|
||||
},
|
||||
{ platform: 'linux', arch: 'x64', file: 'x86_64-unknown-linux-musl.tar.gz' },
|
||||
{ platform: 'win32', arch: 'x64', file: 'x86_64-pc-windows-msvc.zip' },
|
||||
];
|
||||
|
||||
async function downloadBinary() {
|
||||
await fsPromises.mkdir(CORE_VENDOR_DIR, { recursive: true });
|
||||
|
||||
for (const target of targets) {
|
||||
const url = `https://github.com/microsoft/ripgrep-prebuilt/releases/download/${VERSION}/ripgrep-${VERSION}-${target.file}`;
|
||||
const archivePath = path.join(CORE_VENDOR_DIR, target.file);
|
||||
const binName = `rg-${target.platform}-${target.arch}${target.platform === 'win32' ? '.exe' : ''}`;
|
||||
const finalBinPath = path.join(CORE_VENDOR_DIR, binName);
|
||||
|
||||
if (fs.existsSync(finalBinPath)) {
|
||||
console.log(`[Cache] ${binName} already exists.`);
|
||||
continue;
|
||||
}
|
||||
|
||||
console.log(`[Download] ${url} -> ${archivePath}`);
|
||||
const response = await fetch(url);
|
||||
if (!response.ok) {
|
||||
throw new Error(`Failed to fetch ${url}: ${response.statusText}`);
|
||||
}
|
||||
|
||||
if (!response.body) {
|
||||
throw new Error(`Response body is null for ${url}`);
|
||||
}
|
||||
|
||||
const fileStream = createWriteStream(archivePath);
|
||||
|
||||
// Node 18+ global fetch response.body is a ReadableStream (web stream)
|
||||
// pipeline(Readable.fromWeb(response.body), fileStream) works in Node 18+
|
||||
await pipeline(
|
||||
Readable.fromWeb(response.body as ReadableStream),
|
||||
fileStream,
|
||||
);
|
||||
|
||||
console.log(`[Extract] Extracting ${archivePath}...`);
|
||||
// Extract using shell commands for simplicity
|
||||
if (target.file.endsWith('.tar.gz')) {
|
||||
execFileSync('tar', ['-xzf', archivePath, '-C', CORE_VENDOR_DIR]);
|
||||
// Microsoft's ripgrep release extracts directly to `rg` inside the current directory sometimes
|
||||
const sourceBin = path.join(CORE_VENDOR_DIR, 'rg');
|
||||
if (fs.existsSync(sourceBin)) {
|
||||
await fsPromises.rename(sourceBin, finalBinPath);
|
||||
} else {
|
||||
// Fallback for sub-directory if it happens
|
||||
const extractedDirName = `ripgrep-${VERSION}-${target.file.replace('.tar.gz', '')}`;
|
||||
const fallbackSourceBin = path.join(
|
||||
CORE_VENDOR_DIR,
|
||||
extractedDirName,
|
||||
'rg',
|
||||
);
|
||||
if (fs.existsSync(fallbackSourceBin)) {
|
||||
await fsPromises.rename(fallbackSourceBin, finalBinPath);
|
||||
await fsPromises.rm(path.join(CORE_VENDOR_DIR, extractedDirName), {
|
||||
recursive: true,
|
||||
force: true,
|
||||
});
|
||||
} else {
|
||||
throw new Error(
|
||||
`Could not find extracted 'rg' binary for ${target.platform} ${target.arch}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
} else if (target.file.endsWith('.zip')) {
|
||||
execFileSync('unzip', ['-o', '-q', archivePath, '-d', CORE_VENDOR_DIR]);
|
||||
const sourceBin = path.join(CORE_VENDOR_DIR, 'rg.exe');
|
||||
if (fs.existsSync(sourceBin)) {
|
||||
await fsPromises.rename(sourceBin, finalBinPath);
|
||||
} else {
|
||||
const extractedDirName = `ripgrep-${VERSION}-${target.file.replace('.zip', '')}`;
|
||||
const fallbackSourceBin = path.join(
|
||||
CORE_VENDOR_DIR,
|
||||
extractedDirName,
|
||||
'rg.exe',
|
||||
);
|
||||
if (fs.existsSync(fallbackSourceBin)) {
|
||||
await fsPromises.rename(fallbackSourceBin, finalBinPath);
|
||||
await fsPromises.rm(path.join(CORE_VENDOR_DIR, extractedDirName), {
|
||||
recursive: true,
|
||||
force: true,
|
||||
});
|
||||
} else {
|
||||
throw new Error(
|
||||
`Could not find extracted 'rg.exe' binary for ${target.platform} ${target.arch}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Clean up archive
|
||||
await fsPromises.unlink(archivePath);
|
||||
console.log(`[Success] Saved to ${finalBinPath}`);
|
||||
}
|
||||
}
|
||||
|
||||
downloadBinary().catch((err) => {
|
||||
console.error(err);
|
||||
process.exit(1);
|
||||
});
|
||||
@@ -0,0 +1,21 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
||||
<plist version="1.0">
|
||||
<dict>
|
||||
<!-- Allow JIT compilation (Required for Node.js/V8) -->
|
||||
<key>com.apple.security.cs.allow-jit</key>
|
||||
<true/>
|
||||
|
||||
<!-- Allow executable memory modification (Required for Node.js/V8) -->
|
||||
<key>com.apple.security.cs.allow-unsigned-executable-memory</key>
|
||||
<true/>
|
||||
|
||||
<!-- Allow loading unsigned libraries (Helpful for native modules extracted to temp) -->
|
||||
<key>com.apple.security.cs.disable-library-validation</key>
|
||||
<true/>
|
||||
|
||||
<!-- Allow access to environment variables (Standard for CLI tools) -->
|
||||
<key>com.apple.security.cs.allow-dyld-environment-variables</key>
|
||||
<true/>
|
||||
</dict>
|
||||
</plist>
|
||||
@@ -0,0 +1,63 @@
|
||||
#!/usr/bin/env tsx
|
||||
|
||||
/**
|
||||
* @license
|
||||
* Copyright 2026 Google LLC
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
/**
|
||||
* @fileoverview CLI entry point for the eval inventory command.
|
||||
*
|
||||
* Scans all eval source files, runs the static analyzer on each,
|
||||
* and prints an inventory report grouped by policy, file, and suite.
|
||||
*
|
||||
* Usage:
|
||||
* npm run eval:inventory
|
||||
* npm run eval:inventory -- --json
|
||||
* npm run eval:inventory -- --root /path/to/repo
|
||||
* npm run eval:inventory -- --root /path/to/repo --json
|
||||
*/
|
||||
|
||||
import {
|
||||
collectInventory,
|
||||
formatInventoryJson,
|
||||
formatInventoryReport,
|
||||
} from './utils/eval-inventory.js';
|
||||
|
||||
async function main() {
|
||||
const rootFlagIndex = process.argv.indexOf('--root');
|
||||
const rootFlagValue =
|
||||
rootFlagIndex !== -1 ? process.argv[rootFlagIndex + 1] : undefined;
|
||||
if (rootFlagIndex !== -1 && rootFlagValue === undefined) {
|
||||
console.error(
|
||||
'Error: --root requires a directory path argument but none was provided.',
|
||||
);
|
||||
process.exit(1);
|
||||
}
|
||||
if (rootFlagValue && rootFlagValue.startsWith('--')) {
|
||||
console.error(
|
||||
`Error: --root value "${rootFlagValue}" looks like a flag. Provide a valid directory path.`,
|
||||
);
|
||||
process.exit(1);
|
||||
}
|
||||
const repoRoot = rootFlagValue ?? process.cwd();
|
||||
|
||||
const jsonMode = process.argv.includes('--json');
|
||||
|
||||
const result = await collectInventory(repoRoot);
|
||||
|
||||
if (result.totalFiles === 0) {
|
||||
console.error('No eval files found under evals/.');
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
console.log(
|
||||
jsonMode ? formatInventoryJson(result) : formatInventoryReport(result),
|
||||
);
|
||||
}
|
||||
|
||||
main().catch((error) => {
|
||||
console.error('Fatal error:', error);
|
||||
process.exit(1);
|
||||
});
|
||||
@@ -0,0 +1,136 @@
|
||||
/**
|
||||
* @license
|
||||
* Copyright 2026 Google LLC
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
import fs from 'node:fs';
|
||||
import path from 'node:path';
|
||||
import { execSync } from 'node:child_process';
|
||||
import os from 'node:os';
|
||||
|
||||
/**
|
||||
* Finds all report.json files recursively in a directory.
|
||||
*/
|
||||
export function findReports(dir) {
|
||||
const reports = [];
|
||||
if (!fs.existsSync(dir)) return reports;
|
||||
|
||||
const files = fs.readdirSync(dir);
|
||||
for (const file of files) {
|
||||
const fullPath = path.join(dir, file);
|
||||
const stat = fs.statSync(fullPath);
|
||||
if (stat.isDirectory()) {
|
||||
reports.push(...findReports(fullPath));
|
||||
} else if (file === 'report.json') {
|
||||
reports.push(fullPath);
|
||||
}
|
||||
}
|
||||
return reports;
|
||||
}
|
||||
|
||||
/**
|
||||
* Extracts the model name from the artifact path.
|
||||
*/
|
||||
export function getModelFromPath(reportPath) {
|
||||
const parts = reportPath.split(path.sep);
|
||||
// Look for the directory that follows the 'eval-logs-' pattern
|
||||
const artifactDir = parts.find((p) => p.startsWith('eval-logs-'));
|
||||
if (!artifactDir) return 'unknown';
|
||||
|
||||
const match = artifactDir.match(/^eval-logs-(.+)-(\d+)$/);
|
||||
return match ? match[1] : 'unknown';
|
||||
}
|
||||
|
||||
/**
|
||||
* Escapes special characters in a string for use in a regular expression.
|
||||
*/
|
||||
export function escapeRegex(string) {
|
||||
return string.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
|
||||
}
|
||||
|
||||
/**
|
||||
* Aggregates stats from a list of report.json files.
|
||||
* @returns {Record<string, Record<string, {passed: number, total: number, file?: string}>>} statsByModel
|
||||
*/
|
||||
export function getStatsFromReports(reports) {
|
||||
const statsByModel = {};
|
||||
|
||||
for (const reportPath of reports) {
|
||||
try {
|
||||
const model = getModelFromPath(reportPath);
|
||||
if (!statsByModel[model]) {
|
||||
statsByModel[model] = {};
|
||||
}
|
||||
const testStats = statsByModel[model];
|
||||
|
||||
const content = fs.readFileSync(reportPath, 'utf-8');
|
||||
const json = JSON.parse(content);
|
||||
|
||||
for (const testResult of json.testResults) {
|
||||
const filePath = testResult.name;
|
||||
for (const assertion of testResult.assertionResults) {
|
||||
const name = assertion.title;
|
||||
if (!testStats[name]) {
|
||||
testStats[name] = { passed: 0, total: 0, file: filePath };
|
||||
}
|
||||
testStats[name].total++;
|
||||
if (assertion.status === 'passed') {
|
||||
testStats[name].passed++;
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
console.error(`Error processing report at ${reportPath}:`, error.message);
|
||||
}
|
||||
}
|
||||
return statsByModel;
|
||||
}
|
||||
|
||||
/**
|
||||
* Fetches historical nightly data using the GitHub CLI.
|
||||
* @returns {Array<{runId: string, stats: Record<string, any>}>} history
|
||||
*/
|
||||
export function fetchNightlyHistory(lookbackCount) {
|
||||
const history = [];
|
||||
try {
|
||||
const cmd = `gh run list --workflow evals-nightly.yml --branch main --limit ${
|
||||
lookbackCount + 2
|
||||
} --json databaseId,status`;
|
||||
const runsJson = execSync(cmd, { encoding: 'utf-8' });
|
||||
let runs = JSON.parse(runsJson);
|
||||
|
||||
// Filter for completed runs and take the top N
|
||||
runs = runs.filter((r) => r.status === 'completed').slice(0, lookbackCount);
|
||||
|
||||
for (const run of runs) {
|
||||
const tmpDir = fs.mkdtempSync(
|
||||
path.join(os.tmpdir(), `gemini-evals-hist-${run.databaseId}-`),
|
||||
);
|
||||
try {
|
||||
execSync(
|
||||
`gh run download ${run.databaseId} -p "eval-logs-*" -D "${tmpDir}"`,
|
||||
{ stdio: 'ignore' },
|
||||
);
|
||||
|
||||
const runReports = findReports(tmpDir);
|
||||
if (runReports.length > 0) {
|
||||
history.push({
|
||||
runId: run.databaseId,
|
||||
stats: getStatsFromReports(runReports),
|
||||
});
|
||||
}
|
||||
} catch (error) {
|
||||
console.error(
|
||||
`Failed to process artifacts for run ${run.databaseId}:`,
|
||||
error.message,
|
||||
);
|
||||
} finally {
|
||||
fs.rmSync(tmpDir, { recursive: true, force: true });
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Failed to fetch history:', error.message);
|
||||
}
|
||||
return history;
|
||||
}
|
||||
@@ -0,0 +1,77 @@
|
||||
/**
|
||||
* @license
|
||||
* Copyright 2025 Google LLC
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
//
|
||||
// Licensed under the Apache License, Version 2.0 (the "License");
|
||||
// you may not use this file except in compliance with the License.
|
||||
// You may obtain a copy of the License at
|
||||
//
|
||||
// http://www.apache.org/licenses/LICENSE-2.0
|
||||
//
|
||||
// Unless required by applicable law or agreed to in writing, software
|
||||
// distributed under the License is distributed on an "AS IS" BASIS,
|
||||
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
// See the License for the specific language governing permissions and
|
||||
// limitations under the License.
|
||||
|
||||
import { execSync } from 'node:child_process';
|
||||
import { existsSync, mkdirSync, writeFileSync } from 'node:fs';
|
||||
import { dirname, join, relative } from 'node:path';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
import { readPackageUp } from 'read-package-up';
|
||||
|
||||
const __dirname = dirname(fileURLToPath(import.meta.url));
|
||||
const root = join(__dirname, '..');
|
||||
const scriptPath = relative(root, fileURLToPath(import.meta.url));
|
||||
const generatedCliDir = join(root, 'packages/cli/src/generated');
|
||||
const cliGitCommitFile = join(generatedCliDir, 'git-commit.ts');
|
||||
const generatedCoreDir = join(root, 'packages/core/src/generated');
|
||||
const coreGitCommitFile = join(generatedCoreDir, 'git-commit.ts');
|
||||
let gitCommitInfo = 'N/A';
|
||||
let cliVersion = 'UNKNOWN';
|
||||
|
||||
if (!existsSync(generatedCliDir)) {
|
||||
mkdirSync(generatedCliDir, { recursive: true });
|
||||
}
|
||||
|
||||
if (!existsSync(generatedCoreDir)) {
|
||||
mkdirSync(generatedCoreDir, { recursive: true });
|
||||
}
|
||||
|
||||
try {
|
||||
// Check for GIT_COMMIT env var first (e.g. when building inside Docker
|
||||
// without a .git directory available)
|
||||
const envCommit = process.env.GIT_COMMIT;
|
||||
if (envCommit && /^[0-9a-f]+$/i.test(envCommit)) {
|
||||
gitCommitInfo = envCommit;
|
||||
} else {
|
||||
const gitHash = execSync('git rev-parse --short HEAD', {
|
||||
encoding: 'utf-8',
|
||||
}).trim();
|
||||
if (gitHash) {
|
||||
gitCommitInfo = gitHash;
|
||||
}
|
||||
}
|
||||
const result = await readPackageUp();
|
||||
cliVersion = result?.packageJson?.version ?? 'UNKNOWN';
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
|
||||
const fileContent = `/**
|
||||
* @license
|
||||
* Copyright ${new Date().getUTCFullYear()} Google LLC
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
// This file is auto-generated by the build script (${scriptPath})
|
||||
// Do not edit this file manually.
|
||||
export const GIT_COMMIT_INFO = '${gitCommitInfo}';
|
||||
export const CLI_VERSION = '${cliVersion}';
|
||||
`;
|
||||
|
||||
writeFileSync(cliGitCommitFile, fileContent);
|
||||
writeFileSync(coreGitCommitFile, fileContent);
|
||||
@@ -0,0 +1,177 @@
|
||||
/**
|
||||
* @license
|
||||
* Copyright 2025 Google LLC
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
import path from 'node:path';
|
||||
import { fileURLToPath, pathToFileURL } from 'node:url';
|
||||
import { readFile, writeFile } from 'node:fs/promises';
|
||||
|
||||
import type { KeyBinding } from '../packages/cli/src/ui/key/keyBindings.js';
|
||||
import {
|
||||
commandCategories,
|
||||
commandDescriptions,
|
||||
defaultKeyBindingConfig,
|
||||
Command,
|
||||
getPlatformUndoBindings,
|
||||
getPlatformRedoBindings,
|
||||
} from '../packages/cli/src/ui/key/keyBindings.js';
|
||||
import {
|
||||
formatWithPrettier,
|
||||
injectBetweenMarkers,
|
||||
normalizeForCompare,
|
||||
} from './utils/autogen.js';
|
||||
|
||||
const START_MARKER = '<!-- KEYBINDINGS-AUTOGEN:START -->';
|
||||
const END_MARKER = '<!-- KEYBINDINGS-AUTOGEN:END -->';
|
||||
const OUTPUT_RELATIVE_PATH = ['docs', 'reference', 'keyboard-shortcuts.md'];
|
||||
|
||||
import { formatKeyBinding } from '../packages/cli/src/ui/key/keybindingUtils.js';
|
||||
|
||||
export interface KeybindingDocCommand {
|
||||
command: string;
|
||||
description: string;
|
||||
bindings: readonly KeyBinding[];
|
||||
}
|
||||
|
||||
export interface KeybindingDocSection {
|
||||
title: string;
|
||||
commands: readonly KeybindingDocCommand[];
|
||||
}
|
||||
|
||||
export async function main(argv = process.argv.slice(2)) {
|
||||
const checkOnly = argv.includes('--check');
|
||||
|
||||
const repoRoot = path.resolve(
|
||||
path.dirname(fileURLToPath(import.meta.url)),
|
||||
'..',
|
||||
);
|
||||
const docPath = path.join(repoRoot, ...OUTPUT_RELATIVE_PATH);
|
||||
|
||||
const sections = buildDefaultDocSections();
|
||||
const generatedBlock = renderDocumentation(sections);
|
||||
const currentDoc = await readFile(docPath, 'utf8');
|
||||
const injectedDoc = injectBetweenMarkers({
|
||||
document: currentDoc,
|
||||
startMarker: START_MARKER,
|
||||
endMarker: END_MARKER,
|
||||
newContent: generatedBlock,
|
||||
paddingBefore: '\n\n',
|
||||
paddingAfter: '\n',
|
||||
});
|
||||
const updatedDoc = await formatWithPrettier(injectedDoc, docPath);
|
||||
|
||||
if (normalizeForCompare(updatedDoc) === normalizeForCompare(currentDoc)) {
|
||||
if (!checkOnly) {
|
||||
console.log('Keybinding documentation already up to date.');
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
if (checkOnly) {
|
||||
console.error(
|
||||
'Keybinding documentation is out of date. Run `npm run docs:keybindings` to regenerate.',
|
||||
);
|
||||
process.exitCode = 1;
|
||||
return;
|
||||
}
|
||||
|
||||
await writeFile(docPath, updatedDoc, 'utf8');
|
||||
console.log('Keybinding documentation regenerated.');
|
||||
}
|
||||
|
||||
export function buildDefaultDocSections(): readonly KeybindingDocSection[] {
|
||||
return commandCategories.map((category) => ({
|
||||
title: category.title,
|
||||
commands: category.commands.map((command) => {
|
||||
// For UNDO and REDO, we want to show all platform variants in the docs
|
||||
if (command === Command.UNDO) {
|
||||
return {
|
||||
command: command,
|
||||
description: commandDescriptions[command],
|
||||
bindings: getMergedPlatformBindings(getPlatformUndoBindings),
|
||||
};
|
||||
}
|
||||
if (command === Command.REDO) {
|
||||
return {
|
||||
command: command,
|
||||
description: commandDescriptions[command],
|
||||
bindings: getMergedPlatformBindings(getPlatformRedoBindings),
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
command: command,
|
||||
description: commandDescriptions[command],
|
||||
bindings: defaultKeyBindingConfig.get(command) ?? [],
|
||||
};
|
||||
}),
|
||||
}));
|
||||
}
|
||||
|
||||
function getMergedPlatformBindings(
|
||||
getBindings: (platform: string) => readonly KeyBinding[],
|
||||
): readonly KeyBinding[] {
|
||||
const win32 = getBindings('win32');
|
||||
const darwin = getBindings('darwin');
|
||||
const linux = getBindings('linux');
|
||||
|
||||
const all = [...win32, ...darwin, ...linux];
|
||||
const seen = new Set<string>();
|
||||
const unique: KeyBinding[] = [];
|
||||
|
||||
for (const b of all) {
|
||||
const key = `${b.name}-${b.ctrl}-${b.shift}-${b.alt}-${b.cmd}`;
|
||||
if (!seen.has(key)) {
|
||||
seen.add(key);
|
||||
unique.push(b);
|
||||
}
|
||||
}
|
||||
|
||||
return unique;
|
||||
}
|
||||
|
||||
export function renderDocumentation(
|
||||
sections: readonly KeybindingDocSection[],
|
||||
): string {
|
||||
const renderedSections = sections.map((section) => {
|
||||
const rows = section.commands.map((command) => {
|
||||
const formattedBindings = formatBindings(command.bindings);
|
||||
const keysCell = formattedBindings.join('<br />');
|
||||
return `| \`${command.command}\` | ${command.description} | ${keysCell} |`;
|
||||
});
|
||||
|
||||
return [
|
||||
`#### ${section.title}`,
|
||||
'',
|
||||
'| Command | Action | Keys |',
|
||||
'| --- | --- | --- |',
|
||||
...rows,
|
||||
].join('\n');
|
||||
});
|
||||
|
||||
return renderedSections.join('\n\n');
|
||||
}
|
||||
|
||||
function formatBindings(bindings: readonly KeyBinding[]): string[] {
|
||||
const seen = new Set<string>();
|
||||
const results: string[] = [];
|
||||
|
||||
for (const binding of bindings) {
|
||||
const label = formatKeyBinding(binding, 'default');
|
||||
if (label && !seen.has(label)) {
|
||||
seen.add(label);
|
||||
results.push(`\`${label}\``);
|
||||
}
|
||||
}
|
||||
|
||||
return results;
|
||||
}
|
||||
|
||||
if (process.argv[1]) {
|
||||
const entryUrl = pathToFileURL(path.resolve(process.argv[1])).href;
|
||||
if (entryUrl === import.meta.url) {
|
||||
await main();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,285 @@
|
||||
/**
|
||||
* @license
|
||||
* Copyright 2025 Google LLC
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
import path from 'node:path';
|
||||
import { fileURLToPath, pathToFileURL } from 'node:url';
|
||||
import { readFile, writeFile } from 'node:fs/promises';
|
||||
import { generateSettingsSchema } from './generate-settings-schema.js';
|
||||
import {
|
||||
escapeBackticks,
|
||||
formatDefaultValue,
|
||||
formatWithPrettier,
|
||||
injectBetweenMarkers,
|
||||
normalizeForCompare,
|
||||
} from './utils/autogen.js';
|
||||
|
||||
import type {
|
||||
SettingDefinition,
|
||||
SettingsSchema,
|
||||
SettingsSchemaType,
|
||||
} from '../packages/cli/src/config/settingsSchema.js';
|
||||
|
||||
const START_MARKER = '<!-- SETTINGS-AUTOGEN:START -->';
|
||||
const END_MARKER = '<!-- SETTINGS-AUTOGEN:END -->';
|
||||
|
||||
const MANUAL_TOP_LEVEL = new Set(['mcpServers', 'telemetry', 'extensions']);
|
||||
|
||||
interface DocEntry {
|
||||
path: string;
|
||||
type: string;
|
||||
label: string;
|
||||
category: string;
|
||||
description: string;
|
||||
defaultValue: string;
|
||||
requiresRestart: boolean;
|
||||
enumValues?: string[];
|
||||
}
|
||||
|
||||
export async function main(argv = process.argv.slice(2)) {
|
||||
const checkOnly = argv.includes('--check');
|
||||
|
||||
await generateSettingsSchema({ checkOnly });
|
||||
|
||||
const repoRoot = path.resolve(
|
||||
path.dirname(fileURLToPath(import.meta.url)),
|
||||
'..',
|
||||
);
|
||||
const docPath = path.join(repoRoot, 'docs/reference/configuration.md');
|
||||
const cliSettingsDocPath = path.join(repoRoot, 'docs/cli/settings.md');
|
||||
|
||||
const { getSettingsSchema } = await loadSettingsSchemaModule();
|
||||
const schema = getSettingsSchema();
|
||||
const allSettingsSections = collectEntries(schema, { includeAll: true });
|
||||
const filteredSettingsSections = collectEntries(schema, {
|
||||
includeAll: false,
|
||||
});
|
||||
|
||||
const generatedBlock = renderSections(allSettingsSections);
|
||||
const generatedTableBlock = renderTableSections(filteredSettingsSections);
|
||||
|
||||
await updateFile(docPath, generatedBlock, checkOnly);
|
||||
await updateFile(cliSettingsDocPath, generatedTableBlock, checkOnly);
|
||||
}
|
||||
|
||||
async function updateFile(
|
||||
filePath: string,
|
||||
newContent: string,
|
||||
checkOnly: boolean,
|
||||
) {
|
||||
const doc = await readFile(filePath, 'utf8');
|
||||
const injectedDoc = injectBetweenMarkers({
|
||||
document: doc,
|
||||
startMarker: START_MARKER,
|
||||
endMarker: END_MARKER,
|
||||
newContent: newContent,
|
||||
paddingBefore: '\n',
|
||||
paddingAfter: '\n',
|
||||
});
|
||||
const formattedDoc = await formatWithPrettier(injectedDoc, filePath);
|
||||
|
||||
if (normalizeForCompare(doc) === normalizeForCompare(formattedDoc)) {
|
||||
if (!checkOnly) {
|
||||
console.log(
|
||||
`Settings documentation (${path.basename(filePath)}) already up to date.`,
|
||||
);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
if (checkOnly) {
|
||||
console.error(
|
||||
'Settings documentation (' +
|
||||
path.basename(filePath) +
|
||||
') is out of date. Run `npm run docs:settings` to regenerate.',
|
||||
);
|
||||
process.exitCode = 1;
|
||||
return;
|
||||
}
|
||||
|
||||
await writeFile(filePath, formattedDoc);
|
||||
console.log(
|
||||
`Settings documentation (${path.basename(filePath)}) regenerated.`,
|
||||
);
|
||||
}
|
||||
|
||||
async function loadSettingsSchemaModule() {
|
||||
const modulePath = '../packages/cli/src/config/settingsSchema.ts';
|
||||
return import(modulePath);
|
||||
}
|
||||
|
||||
function collectEntries(
|
||||
schema: SettingsSchemaType,
|
||||
options: { includeAll?: boolean } = {},
|
||||
) {
|
||||
const sections = new Map<string, DocEntry[]>();
|
||||
|
||||
const visit = (
|
||||
current: SettingsSchema,
|
||||
pathSegments: string[],
|
||||
topLevel?: string,
|
||||
) => {
|
||||
for (const [key, definition] of Object.entries(current)) {
|
||||
if (pathSegments.length === 0 && MANUAL_TOP_LEVEL.has(key)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const newPathSegments = [...pathSegments, key];
|
||||
const sectionKey = topLevel ?? key;
|
||||
const hasChildren =
|
||||
definition.type === 'object' &&
|
||||
definition.properties &&
|
||||
Object.keys(definition.properties).length > 0;
|
||||
|
||||
if (definition.ignoreInDocs) {
|
||||
continue;
|
||||
}
|
||||
|
||||
if (!hasChildren && (options.includeAll || definition.showInDialog)) {
|
||||
if (!sections.has(sectionKey)) {
|
||||
sections.set(sectionKey, []);
|
||||
}
|
||||
|
||||
sections.get(sectionKey)!.push({
|
||||
path: newPathSegments.join('.'),
|
||||
type: formatType(definition),
|
||||
label: definition.label,
|
||||
category: definition.category,
|
||||
description: formatDescription(definition),
|
||||
defaultValue: formatDefaultValue(definition.default, {
|
||||
quoteStrings: true,
|
||||
}),
|
||||
requiresRestart: Boolean(definition.requiresRestart),
|
||||
enumValues: definition.options?.map((option) =>
|
||||
formatDefaultValue(option.value, { quoteStrings: true }),
|
||||
),
|
||||
});
|
||||
}
|
||||
|
||||
if (hasChildren && definition.properties) {
|
||||
visit(definition.properties, newPathSegments, sectionKey);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
visit(schema, []);
|
||||
return sections;
|
||||
}
|
||||
|
||||
function formatDescription(definition: SettingDefinition) {
|
||||
if (definition.description?.trim()) {
|
||||
return definition.description.trim();
|
||||
}
|
||||
return 'Description not provided.';
|
||||
}
|
||||
|
||||
function formatType(definition: SettingDefinition): string {
|
||||
switch (definition.ref) {
|
||||
case 'StringOrStringArray':
|
||||
return 'string | string[]';
|
||||
case 'BooleanOrString':
|
||||
return 'boolean | string';
|
||||
default:
|
||||
return definition.type;
|
||||
}
|
||||
}
|
||||
|
||||
function renderSections(sections: Map<string, DocEntry[]>) {
|
||||
const lines: string[] = [];
|
||||
|
||||
for (const [section, entries] of sections) {
|
||||
if (entries.length === 0) {
|
||||
continue;
|
||||
}
|
||||
|
||||
lines.push('#### `' + section + '`');
|
||||
lines.push('');
|
||||
|
||||
for (const entry of entries) {
|
||||
lines.push('- **`' + entry.path + '`** (' + entry.type + '):');
|
||||
lines.push(' - **Description:** ' + entry.description);
|
||||
|
||||
if (entry.defaultValue.includes('\n')) {
|
||||
lines.push(' - **Default:**');
|
||||
lines.push('');
|
||||
lines.push(' ```json');
|
||||
lines.push(
|
||||
entry.defaultValue
|
||||
.split('\n')
|
||||
.map((line) => ' ' + line)
|
||||
.join('\n'),
|
||||
);
|
||||
lines.push(' ```');
|
||||
} else {
|
||||
lines.push(
|
||||
' - **Default:** `' + escapeBackticks(entry.defaultValue) + '`',
|
||||
);
|
||||
}
|
||||
|
||||
if (entry.enumValues && entry.enumValues.length > 0) {
|
||||
const values = entry.enumValues
|
||||
.map((value) => '`' + escapeBackticks(value) + '`')
|
||||
.join(', ');
|
||||
lines.push(' - **Values:** ' + values);
|
||||
}
|
||||
|
||||
if (entry.requiresRestart) {
|
||||
lines.push(' - **Requires restart:** Yes');
|
||||
}
|
||||
|
||||
lines.push('');
|
||||
}
|
||||
}
|
||||
|
||||
return lines.join('\n').trimEnd();
|
||||
}
|
||||
|
||||
function renderTableSections(sections: Map<string, DocEntry[]>) {
|
||||
const lines: string[] = [];
|
||||
|
||||
for (const [section, entries] of sections) {
|
||||
if (entries.length === 0) {
|
||||
continue;
|
||||
}
|
||||
|
||||
let title = section.charAt(0).toUpperCase() + section.slice(1);
|
||||
if (title === 'Ui') {
|
||||
title = 'UI';
|
||||
} else if (title === 'Ide') {
|
||||
title = 'IDE';
|
||||
}
|
||||
lines.push(`### ${title}`);
|
||||
lines.push('');
|
||||
lines.push('| UI Label | Setting | Description | Default |');
|
||||
lines.push('| --- | --- | --- | --- |');
|
||||
|
||||
for (const entry of entries) {
|
||||
const val = entry.defaultValue.replace(/\n/g, ' ');
|
||||
const defaultVal = '`' + escapeBackticks(val) + '`';
|
||||
lines.push(
|
||||
'| ' +
|
||||
entry.label +
|
||||
' | `' +
|
||||
entry.path +
|
||||
'` | ' +
|
||||
entry.description +
|
||||
' | ' +
|
||||
defaultVal +
|
||||
' |',
|
||||
);
|
||||
}
|
||||
|
||||
lines.push('');
|
||||
}
|
||||
|
||||
return lines.join('\n').trimEnd();
|
||||
}
|
||||
|
||||
if (process.argv[1]) {
|
||||
const entryUrl = pathToFileURL(path.resolve(process.argv[1])).href;
|
||||
if (entryUrl === import.meta.url) {
|
||||
await main();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,362 @@
|
||||
/**
|
||||
* @license
|
||||
* Copyright 2025 Google LLC
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
import path from 'node:path';
|
||||
import { fileURLToPath, pathToFileURL } from 'node:url';
|
||||
import { mkdir, readFile, writeFile } from 'node:fs/promises';
|
||||
|
||||
import {
|
||||
getSettingsSchema,
|
||||
type SettingCollectionDefinition,
|
||||
type SettingDefinition,
|
||||
type SettingsSchema,
|
||||
type SettingsSchemaType,
|
||||
SETTINGS_SCHEMA_DEFINITIONS,
|
||||
type SettingsJsonSchemaDefinition,
|
||||
} from '../packages/cli/src/config/settingsSchema.js';
|
||||
import {
|
||||
formatDefaultValue,
|
||||
formatWithPrettier,
|
||||
normalizeForCompare,
|
||||
} from './utils/autogen.js';
|
||||
|
||||
const OUTPUT_RELATIVE_PATH = ['schemas', 'settings.schema.json'];
|
||||
const SCHEMA_ID =
|
||||
'https://raw.githubusercontent.com/google-gemini/gemini-cli/main/schemas/settings.schema.json';
|
||||
|
||||
type JsonPrimitive = string | number | boolean | null;
|
||||
type JsonValue = JsonPrimitive | JsonValue[] | { [key: string]: JsonValue };
|
||||
|
||||
interface JsonSchema {
|
||||
[key: string]: JsonValue | JsonSchema | JsonSchema[] | undefined;
|
||||
$schema?: string;
|
||||
$id?: string;
|
||||
title?: string;
|
||||
description?: string;
|
||||
markdownDescription?: string;
|
||||
type?: string | string[];
|
||||
enum?: JsonPrimitive[];
|
||||
default?: JsonValue;
|
||||
properties?: Record<string, JsonSchema>;
|
||||
items?: JsonSchema;
|
||||
additionalProperties?: boolean | JsonSchema;
|
||||
required?: string[];
|
||||
$ref?: string;
|
||||
anyOf?: JsonSchema[];
|
||||
}
|
||||
|
||||
interface GenerateOptions {
|
||||
checkOnly: boolean;
|
||||
}
|
||||
|
||||
export async function generateSettingsSchema(
|
||||
options: GenerateOptions,
|
||||
): Promise<void> {
|
||||
const repoRoot = path.resolve(
|
||||
path.dirname(fileURLToPath(import.meta.url)),
|
||||
'..',
|
||||
);
|
||||
const outputPath = path.join(repoRoot, ...OUTPUT_RELATIVE_PATH);
|
||||
await mkdir(path.dirname(outputPath), { recursive: true });
|
||||
|
||||
const schemaObject = buildSchemaObject(getSettingsSchema());
|
||||
const formatted = await formatWithPrettier(
|
||||
JSON.stringify(schemaObject, null, 2),
|
||||
outputPath,
|
||||
);
|
||||
|
||||
let existing: string | undefined;
|
||||
try {
|
||||
existing = await readFile(outputPath, 'utf8');
|
||||
} catch (error) {
|
||||
if ((error as NodeJS.ErrnoException).code !== 'ENOENT') {
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
if (
|
||||
existing &&
|
||||
normalizeForCompare(existing) === normalizeForCompare(formatted)
|
||||
) {
|
||||
if (!options.checkOnly) {
|
||||
console.log('Settings JSON schema already up to date.');
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
if (options.checkOnly) {
|
||||
console.error(
|
||||
'Settings JSON schema is out of date. Run `npm run schema:settings` to regenerate.',
|
||||
);
|
||||
process.exitCode = 1;
|
||||
return;
|
||||
}
|
||||
|
||||
await writeFile(outputPath, formatted);
|
||||
console.log('Settings JSON schema regenerated.');
|
||||
}
|
||||
|
||||
export async function main(argv = process.argv.slice(2)): Promise<void> {
|
||||
const checkOnly = argv.includes('--check');
|
||||
await generateSettingsSchema({ checkOnly });
|
||||
}
|
||||
|
||||
function buildSchemaObject(schema: SettingsSchemaType): JsonSchema {
|
||||
const defs = new Map<string, JsonSchema>(
|
||||
Object.entries(SETTINGS_SCHEMA_DEFINITIONS as Record<string, JsonSchema>),
|
||||
);
|
||||
|
||||
const root: JsonSchema = {
|
||||
$schema: 'https://json-schema.org/draft/2020-12/schema',
|
||||
$id: SCHEMA_ID,
|
||||
title: 'Gemini CLI Settings',
|
||||
description:
|
||||
'Configuration file schema for Gemini CLI settings. This schema enables IDE completion for `settings.json`.',
|
||||
type: 'object',
|
||||
additionalProperties: false,
|
||||
properties: {},
|
||||
};
|
||||
|
||||
root.properties!['$schema'] = {
|
||||
title: 'Schema',
|
||||
description:
|
||||
'The URL of the JSON schema for this settings file. Used by editors for validation and autocompletion.',
|
||||
type: 'string',
|
||||
default: SCHEMA_ID,
|
||||
};
|
||||
|
||||
for (const [key, definition] of Object.entries(schema)) {
|
||||
root.properties![key] = buildSettingSchema(definition, [key], defs);
|
||||
}
|
||||
|
||||
if (defs.size > 0) {
|
||||
root.$defs = Object.fromEntries(defs.entries());
|
||||
}
|
||||
|
||||
return root;
|
||||
}
|
||||
|
||||
function buildSettingSchema(
|
||||
definition: SettingDefinition,
|
||||
pathSegments: string[],
|
||||
defs: Map<string, JsonSchema>,
|
||||
): JsonSchema {
|
||||
const base: JsonSchema = {
|
||||
title: definition.label,
|
||||
description: definition.description,
|
||||
markdownDescription: buildMarkdownDescription(definition),
|
||||
};
|
||||
|
||||
if (definition.default !== undefined) {
|
||||
base.default = definition.default as JsonValue;
|
||||
}
|
||||
|
||||
const schemaShape = definition.ref
|
||||
? buildRefSchema(definition.ref, defs)
|
||||
: buildSchemaForType(definition, pathSegments, defs);
|
||||
|
||||
return { ...base, ...schemaShape };
|
||||
}
|
||||
|
||||
function buildCollectionSchema(
|
||||
collection: SettingCollectionDefinition,
|
||||
pathSegments: string[],
|
||||
defs: Map<string, JsonSchema>,
|
||||
): JsonSchema {
|
||||
if (collection.ref) {
|
||||
return buildRefSchema(collection.ref, defs);
|
||||
}
|
||||
return buildSchemaForType(collection, pathSegments, defs);
|
||||
}
|
||||
|
||||
function buildSchemaForType(
|
||||
source: SettingDefinition | SettingCollectionDefinition,
|
||||
pathSegments: string[],
|
||||
defs: Map<string, JsonSchema>,
|
||||
): JsonSchema {
|
||||
switch (source.type) {
|
||||
case 'boolean':
|
||||
case 'string':
|
||||
case 'number':
|
||||
return { type: source.type };
|
||||
case 'enum':
|
||||
return buildEnumSchema(source.options);
|
||||
case 'array': {
|
||||
const itemPath = [...pathSegments, '<items>'];
|
||||
const items = isSettingDefinition(source)
|
||||
? source.items
|
||||
? buildCollectionSchema(source.items, itemPath, defs)
|
||||
: {}
|
||||
: source.properties
|
||||
? buildInlineObjectSchema(source.properties, itemPath, defs)
|
||||
: {};
|
||||
return { type: 'array', items };
|
||||
}
|
||||
case 'object':
|
||||
return isSettingDefinition(source)
|
||||
? buildObjectDefinitionSchema(source, pathSegments, defs)
|
||||
: buildObjectCollectionSchema(source, pathSegments, defs);
|
||||
default:
|
||||
return {};
|
||||
}
|
||||
}
|
||||
|
||||
function buildEnumSchema(
|
||||
options:
|
||||
| SettingDefinition['options']
|
||||
| SettingCollectionDefinition['options'],
|
||||
): JsonSchema {
|
||||
const values = options?.map((option) => option.value) ?? [];
|
||||
const inferred = inferTypeFromValues(values);
|
||||
return {
|
||||
type: inferred ?? undefined,
|
||||
enum: values,
|
||||
};
|
||||
}
|
||||
|
||||
function buildObjectDefinitionSchema(
|
||||
definition: SettingDefinition,
|
||||
pathSegments: string[],
|
||||
defs: Map<string, JsonSchema>,
|
||||
): JsonSchema {
|
||||
const properties = definition.properties
|
||||
? buildObjectProperties(definition.properties, pathSegments, defs)
|
||||
: undefined;
|
||||
|
||||
const schema: JsonSchema = {
|
||||
type: 'object',
|
||||
};
|
||||
|
||||
if (properties && Object.keys(properties).length > 0) {
|
||||
schema.properties = properties;
|
||||
}
|
||||
|
||||
if (definition.additionalProperties) {
|
||||
schema.additionalProperties = buildCollectionSchema(
|
||||
definition.additionalProperties,
|
||||
[...pathSegments, '<additionalProperties>'],
|
||||
defs,
|
||||
);
|
||||
} else if (!definition.properties) {
|
||||
schema.additionalProperties = true;
|
||||
} else {
|
||||
schema.additionalProperties = false;
|
||||
}
|
||||
|
||||
return schema;
|
||||
}
|
||||
|
||||
function buildObjectCollectionSchema(
|
||||
collection: SettingCollectionDefinition,
|
||||
pathSegments: string[],
|
||||
defs: Map<string, JsonSchema>,
|
||||
): JsonSchema {
|
||||
if (collection.properties) {
|
||||
return buildInlineObjectSchema(collection.properties, pathSegments, defs);
|
||||
}
|
||||
return { type: 'object', additionalProperties: true };
|
||||
}
|
||||
|
||||
function buildObjectProperties(
|
||||
properties: SettingsSchema,
|
||||
pathSegments: string[],
|
||||
defs: Map<string, JsonSchema>,
|
||||
): Record<string, JsonSchema> {
|
||||
const result: Record<string, JsonSchema> = {};
|
||||
for (const [childKey, childDefinition] of Object.entries(properties)) {
|
||||
result[childKey] = buildSettingSchema(
|
||||
childDefinition,
|
||||
[...pathSegments, childKey],
|
||||
defs,
|
||||
);
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
function buildInlineObjectSchema(
|
||||
properties: SettingsSchema,
|
||||
pathSegments: string[],
|
||||
defs: Map<string, JsonSchema>,
|
||||
): JsonSchema {
|
||||
const childSchemas = buildObjectProperties(properties, pathSegments, defs);
|
||||
return {
|
||||
type: 'object',
|
||||
properties: childSchemas,
|
||||
additionalProperties: false,
|
||||
};
|
||||
}
|
||||
|
||||
function buildRefSchema(
|
||||
ref: string,
|
||||
defs: Map<string, JsonSchema>,
|
||||
): JsonSchema {
|
||||
ensureDefinition(ref, defs);
|
||||
return { $ref: `#/$defs/${ref}` };
|
||||
}
|
||||
|
||||
function isSettingDefinition(
|
||||
source: SettingDefinition | SettingCollectionDefinition,
|
||||
): source is SettingDefinition {
|
||||
return 'label' in source;
|
||||
}
|
||||
|
||||
function buildMarkdownDescription(definition: SettingDefinition): string {
|
||||
const lines: string[] = [];
|
||||
|
||||
if (definition.description?.trim()) {
|
||||
lines.push(definition.description.trim());
|
||||
} else {
|
||||
lines.push('Description not provided.');
|
||||
}
|
||||
|
||||
lines.push('');
|
||||
lines.push(`- Category: \`${definition.category}\``);
|
||||
lines.push(
|
||||
`- Requires restart: \`${definition.requiresRestart ? 'yes' : 'no'}\``,
|
||||
);
|
||||
|
||||
if (definition.default !== undefined) {
|
||||
lines.push(`- Default: \`${formatDefaultValue(definition.default)}\``);
|
||||
}
|
||||
|
||||
return lines.join('\n');
|
||||
}
|
||||
|
||||
function inferTypeFromValues(
|
||||
values: Array<string | number>,
|
||||
): string | undefined {
|
||||
if (values.length === 0) {
|
||||
return undefined;
|
||||
}
|
||||
if (values.every((value) => typeof value === 'string')) {
|
||||
return 'string';
|
||||
}
|
||||
if (values.every((value) => typeof value === 'number')) {
|
||||
return 'number';
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
function ensureDefinition(ref: string, defs: Map<string, JsonSchema>): void {
|
||||
if (defs.has(ref)) {
|
||||
return;
|
||||
}
|
||||
const predefined = SETTINGS_SCHEMA_DEFINITIONS[ref] as
|
||||
| SettingsJsonSchemaDefinition
|
||||
| undefined;
|
||||
if (predefined) {
|
||||
defs.set(ref, predefined as JsonSchema);
|
||||
} else {
|
||||
defs.set(ref, { description: `Definition for ${ref}` });
|
||||
}
|
||||
}
|
||||
|
||||
if (process.argv[1]) {
|
||||
const entryUrl = pathToFileURL(path.resolve(process.argv[1])).href;
|
||||
if (entryUrl === import.meta.url) {
|
||||
await main();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,523 @@
|
||||
#!/usr/bin/env node
|
||||
|
||||
/**
|
||||
* @license
|
||||
* Copyright 2025 Google LLC
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
import { execSync } from 'node:child_process';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
import { readFileSync } from 'node:fs';
|
||||
import semver from 'semver';
|
||||
import yargs from 'yargs';
|
||||
import { hideBin } from 'yargs/helpers';
|
||||
|
||||
const TAG_LATEST = 'latest';
|
||||
const TAG_NIGHTLY = 'nightly';
|
||||
const TAG_PREVIEW = 'preview';
|
||||
|
||||
function readJson(filePath) {
|
||||
return JSON.parse(readFileSync(filePath, 'utf-8'));
|
||||
}
|
||||
|
||||
function getArgs() {
|
||||
return yargs(hideBin(process.argv))
|
||||
.option('type', {
|
||||
description: 'The type of release to generate a version for.',
|
||||
choices: [TAG_NIGHTLY, 'promote-nightly', 'stable', TAG_PREVIEW, 'patch'],
|
||||
default: TAG_NIGHTLY,
|
||||
})
|
||||
.option('patch-from', {
|
||||
description: 'When type is "patch", specifies the source branch.',
|
||||
choices: ['stable', TAG_PREVIEW],
|
||||
string: true,
|
||||
})
|
||||
.option('stable_version_override', {
|
||||
description: 'Override the calculated stable version.',
|
||||
string: true,
|
||||
})
|
||||
.option('cli-package-name', {
|
||||
description:
|
||||
'fully qualified package name with scope (e.g @google/gemini-cli)',
|
||||
string: true,
|
||||
default: '@google/gemini-cli',
|
||||
})
|
||||
.option('preview_version_override', {
|
||||
description: 'Override the calculated preview version.',
|
||||
string: true,
|
||||
})
|
||||
.option('stable-base-version', {
|
||||
description: 'Base version to use for calculating next preview/nightly.',
|
||||
string: true,
|
||||
})
|
||||
.help(false)
|
||||
.version(false)
|
||||
.parse();
|
||||
}
|
||||
|
||||
function getLatestTag(pattern) {
|
||||
const command = `git tag -l '${pattern}'`;
|
||||
try {
|
||||
const tags = execSync(command)
|
||||
.toString()
|
||||
.trim()
|
||||
.split('\n')
|
||||
.filter(Boolean);
|
||||
if (tags.length === 0) return '';
|
||||
|
||||
// Convert tags to versions (remove 'v' prefix) and sort by semver
|
||||
const versions = tags
|
||||
.map((tag) => tag.replace(/^v/, ''))
|
||||
.filter((version) => semver.valid(version))
|
||||
.sort((a, b) => semver.rcompare(a, b)); // rcompare for descending order
|
||||
|
||||
if (versions.length === 0) return '';
|
||||
|
||||
// Return the latest version with 'v' prefix restored
|
||||
return `v${versions[0]}`;
|
||||
} catch (error) {
|
||||
console.error(
|
||||
`Failed to get latest git tag for pattern "${pattern}": ${error.message}`,
|
||||
);
|
||||
return '';
|
||||
}
|
||||
}
|
||||
|
||||
function getVersionFromNPM({ args, npmDistTag } = {}) {
|
||||
const command = `npm view ${args['cli-package-name']} version --tag=${npmDistTag}`;
|
||||
try {
|
||||
return execSync(command).toString().trim();
|
||||
} catch (error) {
|
||||
console.error(
|
||||
`Failed to get NPM version for dist-tag "${npmDistTag}": ${error.message}`,
|
||||
);
|
||||
return '';
|
||||
}
|
||||
}
|
||||
|
||||
function getAllVersionsFromNPM({ args } = {}) {
|
||||
const command = `npm view ${args['cli-package-name']} versions --json`;
|
||||
try {
|
||||
const versionsJson = execSync(command).toString().trim();
|
||||
return JSON.parse(versionsJson);
|
||||
} catch (error) {
|
||||
console.error(`Failed to get all NPM versions: ${error.message}`);
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
function isVersionDeprecated({ args, version } = {}) {
|
||||
const command = `npm view ${args['cli-package-name']}@${version} deprecated`;
|
||||
try {
|
||||
const output = execSync(command).toString().trim();
|
||||
return output.length > 0;
|
||||
} catch (error) {
|
||||
// This command shouldn't fail for existing versions, but as a safeguard:
|
||||
console.error(
|
||||
`Failed to check deprecation status for ${version}: ${error.message}`,
|
||||
);
|
||||
return false; // Assume not deprecated on error to avoid breaking the release.
|
||||
}
|
||||
}
|
||||
|
||||
function detectRollbackAndGetBaseline({ args, npmDistTag } = {}) {
|
||||
// Get the current dist-tag version
|
||||
const distTagVersion = getVersionFromNPM({ args, npmDistTag });
|
||||
if (!distTagVersion) return { baseline: '', isRollback: false };
|
||||
|
||||
// Get all published versions
|
||||
const allVersions = getAllVersionsFromNPM({ args });
|
||||
if (allVersions.length === 0)
|
||||
return { baseline: distTagVersion, isRollback: false };
|
||||
|
||||
// Filter versions by type to match the dist-tag
|
||||
let matchingVersions;
|
||||
if (npmDistTag === TAG_LATEST) {
|
||||
// Stable versions: no prerelease identifiers
|
||||
matchingVersions = allVersions.filter(
|
||||
(v) => semver.valid(v) && !semver.prerelease(v),
|
||||
);
|
||||
} else if (npmDistTag === TAG_PREVIEW) {
|
||||
// Preview versions: contain -preview
|
||||
matchingVersions = allVersions.filter(
|
||||
(v) => semver.valid(v) && v.includes('-preview'),
|
||||
);
|
||||
} else if (npmDistTag === TAG_NIGHTLY) {
|
||||
// Nightly versions: contain -nightly
|
||||
matchingVersions = allVersions.filter(
|
||||
(v) => semver.valid(v) && v.includes('-nightly'),
|
||||
);
|
||||
} else {
|
||||
// For other dist-tags, just use the dist-tag version
|
||||
return { baseline: distTagVersion, isRollback: false };
|
||||
}
|
||||
|
||||
if (matchingVersions.length === 0)
|
||||
return { baseline: distTagVersion, isRollback: false };
|
||||
|
||||
// Sort by semver to get a list from highest to lowest
|
||||
matchingVersions.sort((a, b) => semver.rcompare(a, b));
|
||||
|
||||
// Find the highest non-deprecated version with a git tag
|
||||
let highestExistingVersion = '';
|
||||
for (const version of matchingVersions) {
|
||||
if (!isVersionDeprecated({ version, args })) {
|
||||
try {
|
||||
// Only consider versions that have a corresponding git tag.
|
||||
// This prevents picking up versions that were published to NPM but failed before the github release/tag.
|
||||
let tagExists = false;
|
||||
try {
|
||||
execSync(`git rev-parse v${version}^{commit} 2>/dev/null`);
|
||||
tagExists = true;
|
||||
} catch {
|
||||
const remoteTag = execSync(
|
||||
`git ls-remote --tags origin refs/tags/v${version} 2>/dev/null`,
|
||||
)
|
||||
.toString()
|
||||
.trim();
|
||||
if (remoteTag) {
|
||||
tagExists = true;
|
||||
}
|
||||
}
|
||||
if (!tagExists) {
|
||||
throw new Error(`Tag v${version} not found`);
|
||||
}
|
||||
highestExistingVersion = version;
|
||||
break; // Found the one we want
|
||||
} catch {
|
||||
console.error(
|
||||
`Ignoring version ${version} because it lacks a git tag (likely a failed release).`,
|
||||
);
|
||||
}
|
||||
} else {
|
||||
console.error(`Ignoring deprecated version: ${version}`);
|
||||
}
|
||||
}
|
||||
|
||||
// If all matching versions were deprecated, fall back to the dist-tag version
|
||||
if (!highestExistingVersion) {
|
||||
highestExistingVersion = distTagVersion;
|
||||
}
|
||||
|
||||
// Check if we're in a rollback scenario
|
||||
const isRollback = semver.gt(highestExistingVersion, distTagVersion);
|
||||
|
||||
return {
|
||||
baseline: isRollback ? highestExistingVersion : distTagVersion,
|
||||
isRollback,
|
||||
distTagVersion,
|
||||
highestExistingVersion,
|
||||
};
|
||||
}
|
||||
|
||||
function doesVersionExist({ args, version } = {}) {
|
||||
// Check NPM
|
||||
try {
|
||||
const command = `npm view ${args['cli-package-name']}@${version} version 2>/dev/null`;
|
||||
const output = execSync(command).toString().trim();
|
||||
if (output === version) {
|
||||
console.error(`Version ${version} already exists on NPM.`);
|
||||
return true;
|
||||
}
|
||||
} catch {
|
||||
// This is expected if the version doesn't exist.
|
||||
}
|
||||
|
||||
// Check Git tags
|
||||
try {
|
||||
const command = `git tag -l 'v${version}'`;
|
||||
const tagOutput = execSync(command).toString().trim();
|
||||
if (tagOutput === `v${version}`) {
|
||||
console.error(`Git tag v${version} already exists.`);
|
||||
return true;
|
||||
}
|
||||
} catch (error) {
|
||||
console.error(`Failed to check git tags for conflicts: ${error.message}`);
|
||||
}
|
||||
|
||||
// Check GitHub releases
|
||||
try {
|
||||
const command = `gh release view "v${version}" --json tagName --jq .tagName 2>/dev/null`;
|
||||
const output = execSync(command).toString().trim();
|
||||
if (output === `v${version}`) {
|
||||
console.error(`GitHub release v${version} already exists.`);
|
||||
return true;
|
||||
}
|
||||
} catch (error) {
|
||||
const isExpectedNotFound =
|
||||
error.message.includes('release not found') ||
|
||||
error.message.includes('Not Found') ||
|
||||
error.message.includes('not found') ||
|
||||
error.status === 1;
|
||||
if (!isExpectedNotFound) {
|
||||
console.error(
|
||||
`Failed to check GitHub releases for conflicts: ${error.message}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
function getAndVerifyTags({ npmDistTag, args } = {}) {
|
||||
// Detect rollback scenarios and get the correct baseline
|
||||
const rollbackInfo = detectRollbackAndGetBaseline({ args, npmDistTag });
|
||||
const baselineVersion = rollbackInfo.baseline;
|
||||
|
||||
if (!baselineVersion) {
|
||||
throw new Error(`Unable to determine baseline version for ${npmDistTag}`);
|
||||
}
|
||||
|
||||
if (rollbackInfo.isRollback) {
|
||||
// Rollback scenario: warn about the rollback but don't fail
|
||||
console.error(
|
||||
`Rollback detected! NPM ${npmDistTag} tag is ${rollbackInfo.distTagVersion}, but using ${baselineVersion} as baseline for next version calculation (highest existing version).`,
|
||||
);
|
||||
}
|
||||
|
||||
// Not verifying against git tags or GitHub releases as per user request.
|
||||
|
||||
return {
|
||||
latestVersion: baselineVersion,
|
||||
latestTag: `v${baselineVersion}`,
|
||||
};
|
||||
}
|
||||
|
||||
function getStableBaseVersion(args) {
|
||||
let latestStableVersion = args['stable-base-version'];
|
||||
if (!latestStableVersion) {
|
||||
const { latestVersion } = getAndVerifyTags({
|
||||
npmDistTag: TAG_LATEST,
|
||||
args,
|
||||
});
|
||||
latestStableVersion = latestVersion;
|
||||
}
|
||||
return latestStableVersion;
|
||||
}
|
||||
|
||||
function promoteNightlyVersion({ args } = {}) {
|
||||
const latestStableVersion = getStableBaseVersion(args);
|
||||
|
||||
const { latestTag: previousNightlyTag } = getAndVerifyTags({
|
||||
npmDistTag: TAG_NIGHTLY,
|
||||
args,
|
||||
});
|
||||
|
||||
const major = semver.major(latestStableVersion);
|
||||
const minor = semver.minor(latestStableVersion);
|
||||
const nextMinor = minor + 2;
|
||||
const date = new Date().toISOString().slice(0, 10).replace(/-/g, '');
|
||||
const gitShortHash = execSync('git rev-parse --short HEAD').toString().trim();
|
||||
return {
|
||||
releaseVersion: `${major}.${nextMinor}.0-nightly.${date}.g${gitShortHash}`,
|
||||
npmTag: TAG_NIGHTLY,
|
||||
previousReleaseTag: previousNightlyTag,
|
||||
};
|
||||
}
|
||||
|
||||
function getNightlyVersion() {
|
||||
const packageJson = readJson('package.json');
|
||||
const baseVersion = packageJson.version.split('-')[0];
|
||||
const date = new Date().toISOString().slice(0, 10).replace(/-/g, '');
|
||||
const gitShortHash = execSync('git rev-parse --short HEAD').toString().trim();
|
||||
const releaseVersion = `${baseVersion}-nightly.${date}.g${gitShortHash}`;
|
||||
const previousReleaseTag = getLatestTag('v*-nightly*');
|
||||
|
||||
return {
|
||||
releaseVersion,
|
||||
npmTag: TAG_NIGHTLY,
|
||||
previousReleaseTag,
|
||||
};
|
||||
}
|
||||
|
||||
function validateVersion(version, format, name) {
|
||||
const versionRegex = {
|
||||
'X.Y.Z': /^\d+\.\d+\.\d+$/,
|
||||
'X.Y.Z-preview.N': /^\d+\.\d+\.\d+-preview\.\d+$/,
|
||||
};
|
||||
|
||||
if (!versionRegex[format] || !versionRegex[format].test(version)) {
|
||||
throw new Error(
|
||||
`Invalid ${name}: ${version}. Must be in ${format} format.`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
function getStableVersion(args) {
|
||||
const { latestVersion: latestPreviewVersion } = getAndVerifyTags({
|
||||
npmDistTag: TAG_PREVIEW,
|
||||
args,
|
||||
});
|
||||
let releaseVersion;
|
||||
if (args['stable_version_override']) {
|
||||
const overrideVersion = args['stable_version_override'].replace(/^v/, '');
|
||||
validateVersion(overrideVersion, 'X.Y.Z', 'stable_version_override');
|
||||
releaseVersion = overrideVersion;
|
||||
} else {
|
||||
releaseVersion = latestPreviewVersion.replace(/-preview.*/, '');
|
||||
}
|
||||
|
||||
const { latestTag: previousStableTag } = getAndVerifyTags({
|
||||
npmDistTag: TAG_LATEST,
|
||||
args,
|
||||
});
|
||||
|
||||
return {
|
||||
releaseVersion,
|
||||
npmTag: TAG_LATEST,
|
||||
previousReleaseTag: previousStableTag,
|
||||
};
|
||||
}
|
||||
|
||||
function getPreviewVersion(args) {
|
||||
const latestStableVersion = getStableBaseVersion(args);
|
||||
|
||||
let releaseVersion;
|
||||
if (args['preview_version_override']) {
|
||||
const overrideVersion = args['preview_version_override'].replace(/^v/, '');
|
||||
validateVersion(
|
||||
overrideVersion,
|
||||
'X.Y.Z-preview.N',
|
||||
'preview_version_override',
|
||||
);
|
||||
releaseVersion = overrideVersion;
|
||||
} else {
|
||||
const major = semver.major(latestStableVersion);
|
||||
const minor = semver.minor(latestStableVersion);
|
||||
const nextMinor = minor + 1;
|
||||
releaseVersion = `${major}.${nextMinor}.0-preview.0`;
|
||||
}
|
||||
|
||||
const { latestTag: previousPreviewTag } = getAndVerifyTags({
|
||||
npmDistTag: TAG_PREVIEW,
|
||||
args,
|
||||
});
|
||||
|
||||
return {
|
||||
releaseVersion,
|
||||
npmTag: TAG_PREVIEW,
|
||||
previousReleaseTag: previousPreviewTag,
|
||||
};
|
||||
}
|
||||
|
||||
function getPatchVersion(args) {
|
||||
const patchFrom = args['patch-from'];
|
||||
if (!patchFrom || (patchFrom !== 'stable' && patchFrom !== TAG_PREVIEW)) {
|
||||
throw new Error(
|
||||
'Patch type must be specified with --patch-from=stable or --patch-from=preview',
|
||||
);
|
||||
}
|
||||
const distTag = patchFrom === 'stable' ? TAG_LATEST : TAG_PREVIEW;
|
||||
const { latestVersion, latestTag } = getAndVerifyTags({
|
||||
npmDistTag: distTag,
|
||||
args,
|
||||
});
|
||||
|
||||
if (patchFrom === 'stable') {
|
||||
// For stable versions, increment the patch number: 0.5.4 -> 0.5.5
|
||||
const versionParts = latestVersion.split('.');
|
||||
const major = versionParts[0];
|
||||
const minor = versionParts[1];
|
||||
const patch = versionParts[2] ? parseInt(versionParts[2]) : 0;
|
||||
const releaseVersion = `${major}.${minor}.${patch + 1}`;
|
||||
return {
|
||||
releaseVersion,
|
||||
npmTag: distTag,
|
||||
previousReleaseTag: latestTag,
|
||||
};
|
||||
} else {
|
||||
// For preview versions, increment the preview number: 0.6.0-preview.2 -> 0.6.0-preview.3
|
||||
const [version, prereleasePart] = latestVersion.split('-');
|
||||
if (!prereleasePart || !prereleasePart.startsWith('preview.')) {
|
||||
throw new Error(
|
||||
`Invalid preview version format: ${latestVersion}. Expected format like "0.6.0-preview.2"`,
|
||||
);
|
||||
}
|
||||
|
||||
const previewNumber = parseInt(prereleasePart.split('.')[1]);
|
||||
if (isNaN(previewNumber)) {
|
||||
throw new Error(`Could not parse preview number from: ${prereleasePart}`);
|
||||
}
|
||||
|
||||
const releaseVersion = `${version}-preview.${previewNumber + 1}`;
|
||||
return {
|
||||
releaseVersion,
|
||||
npmTag: distTag,
|
||||
previousReleaseTag: latestTag,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
export function getVersion(options = {}) {
|
||||
const args = { ...getArgs(), ...options };
|
||||
const type = args['type'] || TAG_NIGHTLY; // Nightly is the default.
|
||||
|
||||
let versionData;
|
||||
switch (type) {
|
||||
case TAG_NIGHTLY:
|
||||
versionData = getNightlyVersion();
|
||||
// Nightly versions include a git hash, so conflicts are highly unlikely
|
||||
// and indicate a problem. We'll still validate but not auto-increment.
|
||||
if (doesVersionExist({ args, version: versionData.releaseVersion })) {
|
||||
throw new Error(
|
||||
`Version conflict! Nightly version ${versionData.releaseVersion} already exists.`,
|
||||
);
|
||||
}
|
||||
break;
|
||||
case 'promote-nightly':
|
||||
versionData = promoteNightlyVersion({ args });
|
||||
// A promoted nightly version is still a nightly, so we should check for conflicts.
|
||||
if (doesVersionExist({ args, version: versionData.releaseVersion })) {
|
||||
throw new Error(
|
||||
`Version conflict! Promoted nightly version ${versionData.releaseVersion} already exists.`,
|
||||
);
|
||||
}
|
||||
break;
|
||||
case 'stable':
|
||||
versionData = getStableVersion(args);
|
||||
break;
|
||||
case TAG_PREVIEW:
|
||||
versionData = getPreviewVersion(args);
|
||||
break;
|
||||
case 'patch':
|
||||
versionData = getPatchVersion(args);
|
||||
break;
|
||||
default:
|
||||
throw new Error(`Unknown release type: ${type}`);
|
||||
}
|
||||
|
||||
// For patchable versions, check for existence and increment if needed.
|
||||
if (type === 'stable' || type === TAG_PREVIEW || type === 'patch') {
|
||||
let releaseVersion = versionData.releaseVersion;
|
||||
while (doesVersionExist({ args, version: releaseVersion })) {
|
||||
console.error(`Version ${releaseVersion} exists, incrementing.`);
|
||||
if (releaseVersion.includes('-preview.')) {
|
||||
// Increment preview number: 0.6.0-preview.2 -> 0.6.0-preview.3
|
||||
const [version, prereleasePart] = releaseVersion.split('-');
|
||||
const previewNumber = parseInt(prereleasePart.split('.')[1]);
|
||||
releaseVersion = `${version}-preview.${previewNumber + 1}`;
|
||||
} else {
|
||||
// Increment patch number: 0.5.4 -> 0.5.5
|
||||
const versionParts = releaseVersion.split('.');
|
||||
const major = versionParts[0];
|
||||
const minor = versionParts[1];
|
||||
const patch = parseInt(versionParts[2]);
|
||||
releaseVersion = `${major}.${minor}.${patch + 1}`;
|
||||
}
|
||||
}
|
||||
versionData.releaseVersion = releaseVersion;
|
||||
}
|
||||
|
||||
// All checks are done, construct the final result.
|
||||
const result = {
|
||||
releaseTag: `v${versionData.releaseVersion}`,
|
||||
...versionData,
|
||||
};
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
if (process.argv[1] === fileURLToPath(import.meta.url)) {
|
||||
console.log(JSON.stringify(getVersion(getArgs()), null, 2));
|
||||
}
|
||||
@@ -0,0 +1,125 @@
|
||||
/**
|
||||
* @license
|
||||
* Copyright 2026 Google LLC
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
/**
|
||||
* @fileoverview Identifies "Trustworthy" behavioral evaluations from nightly history.
|
||||
*
|
||||
* This script analyzes the last 6 days of nightly runs to find tests that meet
|
||||
* strict stability criteria (80% aggregate pass rate and 60% daily floor).
|
||||
* It outputs a list of files and a Vitest pattern used by the PR regression check
|
||||
* to ensure high-signal validation and minimize noise.
|
||||
*/
|
||||
|
||||
import { fetchNightlyHistory, escapeRegex } from './eval_utils.js';
|
||||
|
||||
const LOOKBACK_COUNT = 6;
|
||||
const MIN_VALID_RUNS = 5; // At least 5 out of 6 must be available
|
||||
const PASS_RATE_THRESHOLD = 0.6; // Daily floor (e.g., 2/3)
|
||||
const AGGREGATE_PASS_RATE_THRESHOLD = 0.8; // Weekly signal (e.g., 15/18)
|
||||
|
||||
/**
|
||||
* Main execution logic.
|
||||
*/
|
||||
function main() {
|
||||
const targetModel = process.argv[2];
|
||||
if (!targetModel) {
|
||||
console.error('❌ Error: No target model specified.');
|
||||
process.exit(1);
|
||||
}
|
||||
console.error(`🔍 Identifying trustworthy evals for model: ${targetModel}`);
|
||||
|
||||
const history = fetchNightlyHistory(LOOKBACK_COUNT);
|
||||
if (history.length === 0) {
|
||||
console.error('❌ No historical data found.');
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
// Aggregate results for the target model across all history
|
||||
const testHistories = {}; // { [testName]: { totalPassed: 0, totalRuns: 0, dailyRates: [], file: string } }
|
||||
|
||||
for (const item of history) {
|
||||
const modelStats = item.stats[targetModel];
|
||||
if (!modelStats) continue;
|
||||
|
||||
for (const [testName, stat] of Object.entries(modelStats)) {
|
||||
if (!testHistories[testName]) {
|
||||
testHistories[testName] = {
|
||||
totalPassed: 0,
|
||||
totalRuns: 0,
|
||||
dailyRates: [],
|
||||
file: stat.file,
|
||||
};
|
||||
}
|
||||
testHistories[testName].totalPassed += stat.passed;
|
||||
testHistories[testName].totalRuns += stat.total;
|
||||
testHistories[testName].dailyRates.push(stat.passed / stat.total);
|
||||
}
|
||||
}
|
||||
|
||||
const trustworthyTests = [];
|
||||
const trustworthyFiles = new Set();
|
||||
const volatileTests = [];
|
||||
const newTests = [];
|
||||
|
||||
for (const [testName, info] of Object.entries(testHistories)) {
|
||||
const dailyRates = info.dailyRates;
|
||||
const aggregateRate = info.totalPassed / info.totalRuns;
|
||||
|
||||
// 1. Minimum data points required
|
||||
if (dailyRates.length < MIN_VALID_RUNS) {
|
||||
newTests.push(testName);
|
||||
continue;
|
||||
}
|
||||
|
||||
// 2. Trustworthy Criterion:
|
||||
// - Every single day must be above the floor (e.g. > 60%)
|
||||
// - The overall aggregate must be high-signal (e.g. > 80%)
|
||||
const isDailyStable = dailyRates.every(
|
||||
(rate) => rate > PASS_RATE_THRESHOLD,
|
||||
);
|
||||
const isAggregateHighSignal = aggregateRate > AGGREGATE_PASS_RATE_THRESHOLD;
|
||||
|
||||
if (isDailyStable && isAggregateHighSignal) {
|
||||
trustworthyTests.push(testName);
|
||||
if (info.file) {
|
||||
const match = info.file.match(/evals\/.*\.eval\.ts/);
|
||||
if (match) {
|
||||
trustworthyFiles.add(match[0]);
|
||||
}
|
||||
}
|
||||
} else {
|
||||
volatileTests.push(testName);
|
||||
}
|
||||
}
|
||||
|
||||
console.error(
|
||||
`✅ Found ${trustworthyTests.length} trustworthy tests across ${trustworthyFiles.size} files:`,
|
||||
);
|
||||
trustworthyTests.sort().forEach((name) => console.error(` - ${name}`));
|
||||
console.error(`\n⚪ Ignored ${volatileTests.length} volatile tests.`);
|
||||
console.error(
|
||||
`🆕 Ignored ${newTests.length} tests with insufficient history.`,
|
||||
);
|
||||
|
||||
// Output the list of names as a regex-friendly pattern for vitest -t
|
||||
const pattern = trustworthyTests.map((name) => escapeRegex(name)).join('|');
|
||||
|
||||
// Also output unique file paths as a space-separated string
|
||||
const files = Array.from(trustworthyFiles).join(' ');
|
||||
|
||||
// Print the combined output to stdout for use in shell scripts (only if piped/CI)
|
||||
if (!process.stdout.isTTY) {
|
||||
// Format: FILE_LIST --test-pattern TEST_PATTERN
|
||||
// This allows the workflow to easily use it
|
||||
process.stdout.write(`${files} --test-pattern ${pattern || ''}\n`);
|
||||
} else {
|
||||
console.error(
|
||||
'\n💡 Note: Raw regex pattern and file list are hidden in interactive terminal. It will be printed when piped or in CI.',
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
main();
|
||||
Executable
+121
@@ -0,0 +1,121 @@
|
||||
#!/bin/bash
|
||||
|
||||
# Gemini API Reliability Harvester
|
||||
# -------------------------------
|
||||
# This script gathers data about 500 API errors encountered during evaluation runs
|
||||
# (eval.yml) from GitHub Actions. It is used to analyze developer friction caused
|
||||
# by transient API failures.
|
||||
#
|
||||
# Usage:
|
||||
# ./scripts/harvest_api_reliability.sh [SINCE] [LIMIT] [BRANCH]
|
||||
#
|
||||
# Examples:
|
||||
# ./scripts/harvest_api_reliability.sh # Last 7 days, all branches
|
||||
# ./scripts/harvest_api_reliability.sh 14d 500 # Last 14 days, limit 500
|
||||
# ./scripts/harvest_api_reliability.sh 2026-03-01 100 my-branch # Specific date and branch
|
||||
#
|
||||
# Prerequisites:
|
||||
# - GitHub CLI (gh) installed and authenticated (`gh auth login`)
|
||||
# - jq installed
|
||||
|
||||
# Arguments & Defaults
|
||||
if [[ -n "${1}" && "${1}" =~ ^[0-9]{4}-[0-9]{2}-[0-9]{2}$ ]]; then
|
||||
SINCE="${1}"
|
||||
elif [[ -n "${1}" && "${1}" =~ ^([0-9]+)d$ ]]; then
|
||||
DAYS="${BASH_REMATCH[1]}"
|
||||
os_type="$(uname || true)"
|
||||
if [[ "${os_type}" == "darwin"* ]]; then
|
||||
SINCE=$(date -u -v-"${DAYS}"d +%Y-%m-%d)
|
||||
else
|
||||
SINCE=$(date -u -d "${DAYS} days ago" +%Y-%m-%d)
|
||||
fi
|
||||
else
|
||||
# Default to 7 days ago in YYYY-MM-DD format (UTC)
|
||||
os_type="$(uname || true)"
|
||||
if [[ "${os_type}" == "darwin"* ]]; then
|
||||
SINCE=$(date -u -v-7d +%Y-%m-%d)
|
||||
else
|
||||
SINCE=$(date -u -d "7 days ago" +%Y-%m-%d)
|
||||
fi
|
||||
fi
|
||||
|
||||
LIMIT=${2:-300}
|
||||
BRANCH=${3:-""}
|
||||
WORKFLOWS=("Testing: E2E (Chained)" "Evals: Nightly")
|
||||
DEST_DIR="$(mktemp -d -t gemini-reliability-XXXXXX)"
|
||||
MERGED_FILE="api-reliability-summary.jsonl"
|
||||
|
||||
# Ensure cleanup on exit
|
||||
trap 'rm -rf "${DEST_DIR}"' EXIT
|
||||
|
||||
if ! command -v gh &> /dev/null; then
|
||||
echo "❌ Error: GitHub CLI (gh) is not installed."
|
||||
exit 1
|
||||
fi
|
||||
|
||||
if ! command -v jq &> /dev/null; then
|
||||
echo "❌ Error: jq is not installed."
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# Clean start
|
||||
rm -f "${MERGED_FILE}"
|
||||
|
||||
# gh run list --created expects a date (YYYY-MM-DD) or a range
|
||||
CREATED_QUERY=">=${SINCE}"
|
||||
|
||||
for WORKFLOW in "${WORKFLOWS[@]}"; do
|
||||
echo "🔍 Fetching runs for '${WORKFLOW}' created since ${SINCE} (max ${LIMIT} runs, branch: ${BRANCH:-all})..."
|
||||
|
||||
# Construct arguments for gh run list
|
||||
GH_ARGS=("--workflow" "${WORKFLOW}" "--created" "${CREATED_QUERY}" "--limit" "${LIMIT}" "--json" "databaseId" "--jq" ".[].databaseId")
|
||||
if [[ -n "${BRANCH}" ]]; then
|
||||
GH_ARGS+=("--branch" "${BRANCH}")
|
||||
fi
|
||||
|
||||
RUN_IDS=$(gh run list "${GH_ARGS[@]}")
|
||||
exit_code=$?
|
||||
|
||||
if [[ "${exit_code}" -ne 0 ]]; then
|
||||
echo "❌ Failed to fetch runs for '${WORKFLOW}' (exit code: ${exit_code}). Please check 'gh auth status' and permissions." >&2
|
||||
continue
|
||||
fi
|
||||
|
||||
if [[ -z "${RUN_IDS}" ]]; then
|
||||
echo "📭 No runs found for workflow '${WORKFLOW}' since ${SINCE}."
|
||||
continue
|
||||
fi
|
||||
|
||||
for ID in ${RUN_IDS}; do
|
||||
# Download artifacts named 'eval-logs-*'
|
||||
# Silencing output because many older runs won't have artifacts
|
||||
gh run download "${ID}" -p "eval-logs-*" -D "${DEST_DIR}/${ID}" &>/dev/null || continue
|
||||
|
||||
# Append to master log
|
||||
# Use find to locate api-reliability.jsonl in any subdirectory of $DEST_DIR/$ID
|
||||
find "${DEST_DIR}/${ID}" -type f -name "api-reliability.jsonl" -exec cat {} + >> "${MERGED_FILE}" 2>/dev/null
|
||||
done
|
||||
done
|
||||
|
||||
if [[ ! -f "${MERGED_FILE}" ]]; then
|
||||
echo "📭 No reliability data found in the retrieved logs."
|
||||
exit 0
|
||||
fi
|
||||
|
||||
echo -e "\n✅ Harvest Complete! Data merged into: ${MERGED_FILE}"
|
||||
echo "------------------------------------------------"
|
||||
echo "📊 Gemini API Reliability Summary (Since ${SINCE})"
|
||||
echo "------------------------------------------------"
|
||||
|
||||
# shellcheck disable=SC2312
|
||||
cat "${MERGED_FILE}" | jq -s '
|
||||
group_by(.model) | map({
|
||||
model: .[0].model,
|
||||
"500s": (map(select(.errorCode == "500")) | length),
|
||||
"503s": (map(select(.errorCode == "503")) | length),
|
||||
retries: (map(select(.status == "RETRY")) | length),
|
||||
skips: (map(select(.status == "SKIP")) | length)
|
||||
})'
|
||||
|
||||
# shellcheck disable=SC2312
|
||||
echo -e "\n💡 Total events captured: $(wc -l < "${MERGED_FILE}")"
|
||||
+518
@@ -0,0 +1,518 @@
|
||||
#!/usr/bin/env node
|
||||
|
||||
/**
|
||||
* @license
|
||||
* Copyright 2025 Google LLC
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
import { execSync } from 'node:child_process';
|
||||
import {
|
||||
mkdirSync,
|
||||
rmSync,
|
||||
readFileSync,
|
||||
existsSync,
|
||||
lstatSync,
|
||||
} from 'node:fs';
|
||||
import { tmpdir } from 'node:os';
|
||||
import { join } from 'node:path';
|
||||
|
||||
const ACTIONLINT_VERSION = '1.7.7';
|
||||
const SHELLCHECK_VERSION = '0.11.0';
|
||||
const YAMLLINT_VERSION = '1.35.1';
|
||||
|
||||
const TEMP_DIR =
|
||||
process.env.GEMINI_LINT_TEMP_DIR || join(tmpdir(), 'gemini-cli-linters');
|
||||
|
||||
function getPlatformArch() {
|
||||
const platform = process.platform;
|
||||
const arch = process.arch;
|
||||
if (platform === 'linux' && arch === 'x64') {
|
||||
return {
|
||||
actionlint: 'linux_amd64',
|
||||
shellcheck: 'linux.x86_64',
|
||||
};
|
||||
}
|
||||
if (platform === 'darwin' && arch === 'x64') {
|
||||
return {
|
||||
actionlint: 'darwin_amd64',
|
||||
shellcheck: 'darwin.x86_64',
|
||||
};
|
||||
}
|
||||
if (platform === 'darwin' && arch === 'arm64') {
|
||||
return {
|
||||
actionlint: 'darwin_arm64',
|
||||
shellcheck: 'darwin.aarch64',
|
||||
};
|
||||
}
|
||||
if (platform === 'win32' && arch === 'x64') {
|
||||
return {
|
||||
actionlint: 'windows_amd64',
|
||||
// shellcheck is not used for Windows since it uses the .zip release
|
||||
// which has a consistent name across architectures
|
||||
};
|
||||
}
|
||||
throw new Error(`Unsupported platform/architecture: ${platform}/${arch}`);
|
||||
}
|
||||
|
||||
const platformArch = getPlatformArch();
|
||||
|
||||
const PYTHON_VENV_PATH = join(TEMP_DIR, 'python_venv');
|
||||
|
||||
const pythonVenvPythonPath = join(
|
||||
PYTHON_VENV_PATH,
|
||||
process.platform === 'win32' ? 'Scripts' : 'bin',
|
||||
process.platform === 'win32' ? 'python.exe' : 'python',
|
||||
);
|
||||
|
||||
const isWindows = process.platform === 'win32';
|
||||
|
||||
const actionlintCheck = isWindows
|
||||
? `where actionlint 2>nul`
|
||||
: 'command -v actionlint';
|
||||
|
||||
const actionlintInstaller = isWindows
|
||||
? `powershell -Command "` +
|
||||
`New-Item -ItemType Directory -Force -Path '${TEMP_DIR}/actionlint' | Out-Null; ` +
|
||||
`Invoke-WebRequest -Uri 'https://github.com/rhysd/actionlint/releases/download/v${ACTIONLINT_VERSION}/actionlint_${ACTIONLINT_VERSION}_${platformArch.actionlint}.zip' -OutFile '${TEMP_DIR}/.actionlint.zip'; ` +
|
||||
`Add-Type -AssemblyName System.IO.Compression.FileSystem; ` +
|
||||
`[System.IO.Compression.ZipFile]::ExtractToDirectory('${TEMP_DIR}/.actionlint.zip', '${TEMP_DIR}/actionlint')"`
|
||||
: `
|
||||
mkdir -p "${TEMP_DIR}/actionlint"
|
||||
curl -sSLo "${TEMP_DIR}/.actionlint.tgz" "https://github.com/rhysd/actionlint/releases/download/v${ACTIONLINT_VERSION}/actionlint_${ACTIONLINT_VERSION}_${platformArch.actionlint}.tar.gz"
|
||||
tar -xzf "${TEMP_DIR}/.actionlint.tgz" -C "${TEMP_DIR}/actionlint"
|
||||
`;
|
||||
|
||||
const shellcheckCheck = isWindows
|
||||
? `where shellcheck 2>nul`
|
||||
: 'command -v shellcheck';
|
||||
|
||||
const shellcheckInstaller = isWindows
|
||||
? `powershell -Command "` +
|
||||
`Invoke-WebRequest -Uri 'https://github.com/koalaman/shellcheck/releases/download/v${SHELLCHECK_VERSION}/shellcheck-v${SHELLCHECK_VERSION}.zip' -OutFile '${TEMP_DIR}/.shellcheck.zip'; ` +
|
||||
`Add-Type -AssemblyName System.IO.Compression.FileSystem; ` +
|
||||
`[System.IO.Compression.ZipFile]::ExtractToDirectory('${TEMP_DIR}/.shellcheck.zip', '${TEMP_DIR}/shellcheck')"`
|
||||
: `
|
||||
mkdir -p "${TEMP_DIR}/shellcheck"
|
||||
curl -sSLo "${TEMP_DIR}/.shellcheck.txz" "https://github.com/koalaman/shellcheck/releases/download/v${SHELLCHECK_VERSION}/shellcheck-v${SHELLCHECK_VERSION}.${platformArch.shellcheck}.tar.xz"
|
||||
tar -xf "${TEMP_DIR}/.shellcheck.txz" -C "${TEMP_DIR}/shellcheck" --strip-components=1
|
||||
`;
|
||||
|
||||
const yamllintCheck = isWindows
|
||||
? `if exist "${PYTHON_VENV_PATH}\\Scripts\\yamllint.exe" (exit 0) else (exit 1)`
|
||||
: `test -x "${PYTHON_VENV_PATH}/bin/yamllint"`;
|
||||
|
||||
const yamllintInstaller = isWindows
|
||||
? `python -m venv "${PYTHON_VENV_PATH}" && ` +
|
||||
`"${pythonVenvPythonPath}" -m pip install --upgrade pip && ` +
|
||||
`"${pythonVenvPythonPath}" -m pip install "yamllint==${YAMLLINT_VERSION}" --index-url https://pypi.org/simple`
|
||||
: `
|
||||
python3 -m venv "${PYTHON_VENV_PATH}" && \
|
||||
"${pythonVenvPythonPath}" -m pip install --upgrade pip && \
|
||||
"${pythonVenvPythonPath}" -m pip install "yamllint==${YAMLLINT_VERSION}" --index-url https://pypi.org/simple
|
||||
`;
|
||||
|
||||
/**
|
||||
* @typedef {{
|
||||
* check: string;
|
||||
* installer: string;
|
||||
* run: string;
|
||||
* }}
|
||||
*/
|
||||
|
||||
/**
|
||||
* @type {{[linterName: string]: Linter}}
|
||||
*/
|
||||
const LINTERS = {
|
||||
actionlint: {
|
||||
check: actionlintCheck,
|
||||
installer: actionlintInstaller,
|
||||
run: `
|
||||
actionlint \
|
||||
-color \
|
||||
-ignore 'SC2002:' \
|
||||
-ignore 'SC2016:' \
|
||||
-ignore 'SC2129:' \
|
||||
-ignore 'label ".+" is unknown'
|
||||
`,
|
||||
},
|
||||
shellcheck: {
|
||||
check: shellcheckCheck,
|
||||
installer: shellcheckInstaller,
|
||||
run: `
|
||||
git ls-files | grep -E '^([^.]+|.*\\.(sh|zsh|bash))' | xargs file --mime-type \
|
||||
| grep "text/x-shellscript" | awk '{ print substr($1, 1, length($1)-1) }' \
|
||||
| xargs shellcheck \
|
||||
--check-sourced \
|
||||
--enable=all \
|
||||
--exclude=SC2002,SC2129,SC2310 \
|
||||
--severity=style \
|
||||
--format=gcc \
|
||||
--color=never | sed -e 's/note:/warning:/g' -e 's/style:/warning:/g'
|
||||
`,
|
||||
},
|
||||
yamllint: {
|
||||
check: yamllintCheck,
|
||||
installer: yamllintInstaller,
|
||||
run: "git ls-files | grep -E '\\.(yaml|yml)' | xargs yamllint --format github",
|
||||
},
|
||||
};
|
||||
|
||||
function runCommand(command, stdio = 'inherit') {
|
||||
try {
|
||||
const env = { ...process.env };
|
||||
const nodeBin = join(process.cwd(), 'node_modules', '.bin');
|
||||
const sep = isWindows ? ';' : ':';
|
||||
const pythonBin = isWindows
|
||||
? join(PYTHON_VENV_PATH, 'Scripts')
|
||||
: join(PYTHON_VENV_PATH, 'bin');
|
||||
// Windows sometimes uses 'Path' instead of 'PATH'
|
||||
const pathKey = 'Path' in env ? 'Path' : 'PATH';
|
||||
env[pathKey] = [
|
||||
nodeBin,
|
||||
join(TEMP_DIR, 'actionlint'),
|
||||
join(TEMP_DIR, 'shellcheck'),
|
||||
pythonBin,
|
||||
env[pathKey],
|
||||
].join(sep);
|
||||
execSync(command, { stdio, env, shell: true });
|
||||
return true;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
export function setupLinters() {
|
||||
console.log('Setting up linters...');
|
||||
if (!process.env.GEMINI_LINT_TEMP_DIR) {
|
||||
rmSync(TEMP_DIR, { recursive: true, force: true });
|
||||
}
|
||||
mkdirSync(TEMP_DIR, { recursive: true });
|
||||
|
||||
for (const linter in LINTERS) {
|
||||
const { check, installer } = LINTERS[linter];
|
||||
if (!runCommand(check, 'ignore')) {
|
||||
console.log(`Installing ${linter}...`);
|
||||
if (!runCommand(installer)) {
|
||||
console.error(
|
||||
`Failed to install ${linter}. Please install it manually.`,
|
||||
);
|
||||
process.exit(1);
|
||||
}
|
||||
}
|
||||
}
|
||||
console.log('All required linters are available.');
|
||||
}
|
||||
|
||||
export function runESLint() {
|
||||
console.log('\nRunning ESLint...');
|
||||
if (!runCommand('npm run lint')) {
|
||||
process.exit(1);
|
||||
}
|
||||
}
|
||||
|
||||
export function runActionlint() {
|
||||
console.log('\nRunning actionlint...');
|
||||
if (!runCommand(LINTERS.actionlint.run)) {
|
||||
process.exit(1);
|
||||
}
|
||||
}
|
||||
|
||||
export function runShellcheck() {
|
||||
console.log('\nRunning shellcheck...');
|
||||
if (!runCommand(LINTERS.shellcheck.run)) {
|
||||
process.exit(1);
|
||||
}
|
||||
}
|
||||
|
||||
export function runYamllint() {
|
||||
console.log('\nRunning yamllint...');
|
||||
if (!runCommand(LINTERS.yamllint.run)) {
|
||||
process.exit(1);
|
||||
}
|
||||
}
|
||||
|
||||
export function runPrettier() {
|
||||
console.log('\nRunning Prettier...');
|
||||
if (!runCommand('prettier --check .')) {
|
||||
console.log(
|
||||
'Prettier check failed. Please run "npm run format" to fix formatting issues.',
|
||||
);
|
||||
process.exit(1);
|
||||
}
|
||||
}
|
||||
|
||||
export function runSensitiveKeywordLinter() {
|
||||
console.log('\nRunning sensitive keyword linter...');
|
||||
const SENSITIVE_PATTERN = /gemini-\d+(\.\d+)?/g;
|
||||
const ALLOWED_KEYWORDS = new Set([
|
||||
'gemini-3.1',
|
||||
'gemini-3',
|
||||
'gemini-3.0',
|
||||
'gemini-2.5',
|
||||
'gemini-2.0',
|
||||
'gemini-1.5',
|
||||
'gemini-1.0',
|
||||
]);
|
||||
|
||||
function getChangedFiles() {
|
||||
const baseRef = process.env.GITHUB_BASE_REF || 'main';
|
||||
try {
|
||||
execSync(`git fetch origin ${baseRef}`);
|
||||
const mergeBase = execSync(`git merge-base HEAD origin/${baseRef}`)
|
||||
.toString()
|
||||
.trim();
|
||||
return execSync(`git diff --name-only ${mergeBase}..HEAD`)
|
||||
.toString()
|
||||
.trim()
|
||||
.split('\n')
|
||||
.filter(Boolean);
|
||||
} catch {
|
||||
console.error(`Could not get changed files against origin/${baseRef}.`);
|
||||
try {
|
||||
console.log('Falling back to diff against HEAD~1');
|
||||
return execSync(`git diff --name-only HEAD~1..HEAD`)
|
||||
.toString()
|
||||
.trim()
|
||||
.split('\n')
|
||||
.filter(Boolean);
|
||||
} catch {
|
||||
console.error('Could not get changed files against HEAD~1 either.');
|
||||
process.exit(1);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const changedFiles = getChangedFiles();
|
||||
let violationsFound = false;
|
||||
|
||||
for (const file of changedFiles) {
|
||||
if (!existsSync(file) || lstatSync(file).isDirectory()) {
|
||||
continue;
|
||||
}
|
||||
const content = readFileSync(file, 'utf-8');
|
||||
const lines = content.split('\n');
|
||||
let match;
|
||||
while ((match = SENSITIVE_PATTERN.exec(content)) !== null) {
|
||||
const keyword = match[0];
|
||||
if (!ALLOWED_KEYWORDS.has(keyword)) {
|
||||
violationsFound = true;
|
||||
const matchIndex = match.index;
|
||||
let lineNum = 0;
|
||||
let charCount = 0;
|
||||
for (let i = 0; i < lines.length; i++) {
|
||||
const line = lines[i];
|
||||
if (charCount + line.length + 1 > matchIndex) {
|
||||
lineNum = i + 1;
|
||||
const colNum = matchIndex - charCount + 1;
|
||||
console.log(
|
||||
`::warning file=${file},line=${lineNum},col=${colNum}::Found sensitive keyword "${keyword}". Please make sure this change is appropriate to submit.`,
|
||||
);
|
||||
break;
|
||||
}
|
||||
charCount += line.length + 1; // +1 for the newline
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (!violationsFound) {
|
||||
console.log('No sensitive keyword violations found.');
|
||||
}
|
||||
}
|
||||
|
||||
function stripJSONComments(json) {
|
||||
return json.replace(
|
||||
/\\"|"(?:\\"|[^"])*"|(\/\/.*|\/\*[\s\S]*?\*\/)/g,
|
||||
(m, g) => (g ? '' : m),
|
||||
);
|
||||
}
|
||||
|
||||
export function runTSConfigLinter() {
|
||||
console.log('\nRunning tsconfig linter...');
|
||||
|
||||
let files = [];
|
||||
try {
|
||||
// Find all tsconfig.json files under packages/ using a git pathspec
|
||||
files = execSync("git ls-files 'packages/**/tsconfig.json'")
|
||||
.toString()
|
||||
.trim()
|
||||
.split('\n')
|
||||
.filter(Boolean);
|
||||
} catch (e) {
|
||||
console.error('Error finding tsconfig.json files:', e.message);
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
let hasError = false;
|
||||
|
||||
for (const file of files) {
|
||||
const tsconfigPath = join(process.cwd(), file);
|
||||
if (!existsSync(tsconfigPath)) {
|
||||
console.error(`Error: ${tsconfigPath} does not exist.`);
|
||||
hasError = true;
|
||||
continue;
|
||||
}
|
||||
|
||||
try {
|
||||
const content = readFileSync(tsconfigPath, 'utf-8');
|
||||
const config = JSON.parse(stripJSONComments(content));
|
||||
|
||||
// Check if exclude exists and matches exactly
|
||||
if (config.exclude) {
|
||||
if (!Array.isArray(config.exclude)) {
|
||||
console.error(
|
||||
`Error: ${file} "exclude" must be an array. Found: ${JSON.stringify(
|
||||
config.exclude,
|
||||
)}`,
|
||||
);
|
||||
hasError = true;
|
||||
} else {
|
||||
const allowedExclude = new Set(['node_modules', 'dist']);
|
||||
const invalidExcludes = config.exclude.filter(
|
||||
(item) => !allowedExclude.has(item),
|
||||
);
|
||||
|
||||
if (invalidExcludes.length > 0) {
|
||||
console.error(
|
||||
`Error: ${file} "exclude" contains invalid items: ${JSON.stringify(
|
||||
invalidExcludes,
|
||||
)}. Only "node_modules" and "dist" are allowed.`,
|
||||
);
|
||||
hasError = true;
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
console.error(`Error parsing ${tsconfigPath}: ${error.message}`);
|
||||
hasError = true;
|
||||
}
|
||||
}
|
||||
|
||||
if (hasError) {
|
||||
process.exit(1);
|
||||
}
|
||||
}
|
||||
|
||||
export function runGithubActionsPinningLinter() {
|
||||
console.log('\nRunning GitHub Actions pinning linter...');
|
||||
|
||||
let files = [];
|
||||
try {
|
||||
files = execSync(
|
||||
"git ls-files '.github/workflows/*.yml' '.github/workflows/*.yaml' '.github/actions/**/*.yml' '.github/actions/**/*.yaml'",
|
||||
)
|
||||
.toString()
|
||||
.trim()
|
||||
.split('\n')
|
||||
.filter(Boolean);
|
||||
} catch (e) {
|
||||
console.error('Error finding GitHub Actions workflow files:', e.message);
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
let violationsFound = false;
|
||||
// Improved regex to capture action name and ref, handling optional quotes and comments.
|
||||
const USES_PATTERN = /uses:\s*['"]?([^@\s'"]+)@([^#\s'"]+)['"]?/;
|
||||
const SHA_PATTERN = /^[0-9a-f]{40}$/i;
|
||||
|
||||
for (const file of files) {
|
||||
if (!existsSync(file) || lstatSync(file).isDirectory()) {
|
||||
continue;
|
||||
}
|
||||
const content = readFileSync(file, 'utf-8');
|
||||
const lines = content.split('\n');
|
||||
|
||||
for (let i = 0; i < lines.length; i++) {
|
||||
const line = lines[i];
|
||||
const match = line.match(USES_PATTERN);
|
||||
if (match) {
|
||||
const action = match[1];
|
||||
let ref = match[2];
|
||||
|
||||
// Clean up any trailing quotes that might have been captured
|
||||
ref = ref.replace(/['"]$/, '');
|
||||
|
||||
// Skip local actions (starting with ./), docker actions, and explicit exclusions
|
||||
if (
|
||||
action.startsWith('./') ||
|
||||
action.startsWith('docker://') ||
|
||||
line.includes('# github-actions-pinning:ignore')
|
||||
) {
|
||||
continue;
|
||||
}
|
||||
|
||||
if (!SHA_PATTERN.test(ref)) {
|
||||
violationsFound = true;
|
||||
const lineNum = i + 1;
|
||||
console.error(
|
||||
`::error file=${file},line=${lineNum}::Action "${action}" uses "${ref}" instead of a 40-character SHA.`,
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (violationsFound) {
|
||||
console.error(`
|
||||
GitHub Actions pinning violations found. Please use exact commit hashes.
|
||||
|
||||
To automatically fix these, you can use the "ratchet" tool (https://github.com/sethvargo/ratchet):
|
||||
- Mac/Linux (Homebrew): brew install ratchet && ratchet pin .github/workflows/*.yml .github/actions/**/*.yml
|
||||
- Other platforms: Download from GitHub releases and run "ratchet pin .github/workflows/*.yml .github/actions/**/*.yml"
|
||||
|
||||
If you must use a tag, you can ignore this check by adding a comment (discouraged):
|
||||
uses: some-action@v1 # github-actions-pinning:ignore
|
||||
`);
|
||||
process.exit(1);
|
||||
} else {
|
||||
console.log('No GitHub Actions pinning violations found.');
|
||||
}
|
||||
}
|
||||
|
||||
function main() {
|
||||
const args = process.argv.slice(2);
|
||||
|
||||
if (args.includes('--setup')) {
|
||||
setupLinters();
|
||||
}
|
||||
if (args.includes('--eslint')) {
|
||||
runESLint();
|
||||
}
|
||||
if (args.includes('--actionlint')) {
|
||||
runActionlint();
|
||||
}
|
||||
if (args.includes('--shellcheck')) {
|
||||
runShellcheck();
|
||||
}
|
||||
if (args.includes('--yamllint')) {
|
||||
runYamllint();
|
||||
}
|
||||
if (args.includes('--prettier')) {
|
||||
runPrettier();
|
||||
}
|
||||
if (args.includes('--sensitive-keywords')) {
|
||||
runSensitiveKeywordLinter();
|
||||
}
|
||||
if (args.includes('--tsconfig')) {
|
||||
runTSConfigLinter();
|
||||
}
|
||||
if (args.includes('--check-github-actions-pinning')) {
|
||||
runGithubActionsPinningLinter();
|
||||
}
|
||||
|
||||
if (args.length === 0) {
|
||||
setupLinters();
|
||||
runESLint();
|
||||
runActionlint();
|
||||
runShellcheck();
|
||||
runYamllint();
|
||||
runPrettier();
|
||||
runSensitiveKeywordLinter();
|
||||
runTSConfigLinter();
|
||||
runGithubActionsPinningLinter();
|
||||
console.log('\nAll linting checks passed!');
|
||||
}
|
||||
}
|
||||
|
||||
main();
|
||||
Executable
+219
@@ -0,0 +1,219 @@
|
||||
#!/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 { spawn, execSync } from 'node:child_process';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
import {
|
||||
BIN_DIR,
|
||||
OTEL_DIR,
|
||||
ensureBinary,
|
||||
fileExists,
|
||||
manageTelemetrySettings,
|
||||
registerCleanup,
|
||||
waitForPort,
|
||||
} from './telemetry_utils.js';
|
||||
|
||||
const __filename = fileURLToPath(import.meta.url);
|
||||
const __dirname = path.dirname(__filename);
|
||||
|
||||
const OTEL_CONFIG_FILE = path.join(OTEL_DIR, 'collector-local.yaml');
|
||||
const OTEL_LOG_FILE = path.join(OTEL_DIR, 'collector.log');
|
||||
const JAEGER_LOG_FILE = path.join(OTEL_DIR, 'jaeger.log');
|
||||
const JAEGER_PORT = 16686;
|
||||
|
||||
// This configuration is for the primary otelcol-contrib instance.
|
||||
// It receives from the CLI on 4317, exports traces to Jaeger on 14317,
|
||||
// and sends metrics/logs to the debug log.
|
||||
const OTEL_CONFIG_CONTENT = `
|
||||
receivers:
|
||||
otlp:
|
||||
protocols:
|
||||
grpc:
|
||||
endpoint: "localhost:4317"
|
||||
processors:
|
||||
batch:
|
||||
timeout: 1s
|
||||
exporters:
|
||||
otlp:
|
||||
endpoint: "localhost:14317"
|
||||
tls:
|
||||
insecure: true
|
||||
debug:
|
||||
verbosity: detailed
|
||||
service:
|
||||
telemetry:
|
||||
logs:
|
||||
level: "debug"
|
||||
metrics:
|
||||
level: "none"
|
||||
pipelines:
|
||||
traces:
|
||||
receivers: [otlp]
|
||||
processors: [batch]
|
||||
exporters: [otlp]
|
||||
metrics:
|
||||
receivers: [otlp]
|
||||
processors: [batch]
|
||||
exporters: [debug]
|
||||
logs:
|
||||
receivers: [otlp]
|
||||
processors: [batch]
|
||||
exporters: [debug]
|
||||
`;
|
||||
|
||||
async function main() {
|
||||
// 1. Ensure binaries are available, downloading if necessary.
|
||||
// Binaries are stored in the project's .gemini/otel/bin directory
|
||||
// to avoid modifying the user's system.
|
||||
if (!fileExists(BIN_DIR)) fs.mkdirSync(BIN_DIR, { recursive: true });
|
||||
|
||||
const otelcolPath = await ensureBinary(
|
||||
'otelcol-contrib',
|
||||
'open-telemetry/opentelemetry-collector-releases',
|
||||
(version, platform, arch, ext) =>
|
||||
`otelcol-contrib_${version}_${platform}_${arch}.${ext}`,
|
||||
'otelcol-contrib',
|
||||
false, // isJaeger = false
|
||||
).catch((e) => {
|
||||
console.error(`🛑 Error getting otelcol-contrib: ${e.message}`);
|
||||
return null;
|
||||
});
|
||||
if (!otelcolPath) process.exit(1);
|
||||
|
||||
const jaegerPath = await ensureBinary(
|
||||
'jaeger',
|
||||
'jaegertracing/jaeger',
|
||||
(version, platform, arch, ext) =>
|
||||
`jaeger-${version}-${platform}-${arch}.${ext}`,
|
||||
'jaeger',
|
||||
true, // isJaeger = true
|
||||
).catch((e) => {
|
||||
console.error(`🛑 Error getting jaeger: ${e.message}`);
|
||||
return null;
|
||||
});
|
||||
if (!jaegerPath) process.exit(1);
|
||||
|
||||
// 2. Kill any existing processes to ensure a clean start.
|
||||
console.log('🧹 Cleaning up old processes and logs...');
|
||||
try {
|
||||
execSync('pkill -f "otelcol-contrib"');
|
||||
console.log('✅ Stopped existing otelcol-contrib process.');
|
||||
} catch {} // eslint-disable-line no-empty
|
||||
try {
|
||||
execSync('pkill -f "jaeger"');
|
||||
console.log('✅ Stopped existing jaeger process.');
|
||||
} catch {} // eslint-disable-line no-empty
|
||||
try {
|
||||
if (fileExists(OTEL_LOG_FILE)) fs.unlinkSync(OTEL_LOG_FILE);
|
||||
console.log('✅ Deleted old collector log.');
|
||||
} catch (e) {
|
||||
if (e.code !== 'ENOENT') console.error(e);
|
||||
}
|
||||
try {
|
||||
if (fileExists(JAEGER_LOG_FILE)) fs.unlinkSync(JAEGER_LOG_FILE);
|
||||
console.log('✅ Deleted old jaeger log.');
|
||||
} catch (e) {
|
||||
if (e.code !== 'ENOENT') console.error(e);
|
||||
}
|
||||
|
||||
let jaegerProcess, collectorProcess;
|
||||
let jaegerLogFd, collectorLogFd;
|
||||
|
||||
const originalSandboxSetting = manageTelemetrySettings(
|
||||
true,
|
||||
'http://localhost:4317',
|
||||
'local',
|
||||
);
|
||||
|
||||
registerCleanup(
|
||||
() => [jaegerProcess, collectorProcess],
|
||||
() => [jaegerLogFd, collectorLogFd],
|
||||
originalSandboxSetting,
|
||||
);
|
||||
|
||||
if (!fileExists(OTEL_DIR)) fs.mkdirSync(OTEL_DIR, { recursive: true });
|
||||
fs.writeFileSync(OTEL_CONFIG_FILE, OTEL_CONFIG_CONTENT);
|
||||
console.log('📄 Wrote OTEL collector config.');
|
||||
|
||||
// Start Jaeger
|
||||
console.log(`🚀 Starting Jaeger service... Logs: ${JAEGER_LOG_FILE}`);
|
||||
jaegerLogFd = fs.openSync(JAEGER_LOG_FILE, 'a');
|
||||
jaegerProcess = spawn(
|
||||
jaegerPath,
|
||||
['--set=receivers.otlp.protocols.grpc.endpoint=localhost:14317'],
|
||||
{ stdio: ['ignore', jaegerLogFd, jaegerLogFd] },
|
||||
);
|
||||
console.log(`⏳ Waiting for Jaeger to start (PID: ${jaegerProcess.pid})...`);
|
||||
|
||||
try {
|
||||
await waitForPort(JAEGER_PORT);
|
||||
console.log(`✅ Jaeger started successfully.`);
|
||||
} catch {
|
||||
console.error(`🛑 Error: Jaeger failed to start on port ${JAEGER_PORT}.`);
|
||||
if (jaegerProcess && jaegerProcess.pid) {
|
||||
process.kill(jaegerProcess.pid, 'SIGKILL');
|
||||
}
|
||||
if (fileExists(JAEGER_LOG_FILE)) {
|
||||
console.error('📄 Jaeger Log Output:');
|
||||
console.error(fs.readFileSync(JAEGER_LOG_FILE, 'utf-8'));
|
||||
}
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
// Start the primary OTEL collector
|
||||
console.log(`🚀 Starting OTEL collector... Logs: ${OTEL_LOG_FILE}`);
|
||||
collectorLogFd = fs.openSync(OTEL_LOG_FILE, 'a');
|
||||
collectorProcess = spawn(otelcolPath, ['--config', OTEL_CONFIG_FILE], {
|
||||
stdio: ['ignore', collectorLogFd, collectorLogFd],
|
||||
});
|
||||
console.log(
|
||||
`⏳ Waiting for OTEL collector to start (PID: ${collectorProcess.pid})...`,
|
||||
);
|
||||
|
||||
try {
|
||||
await waitForPort(4317);
|
||||
console.log(`✅ OTEL collector started successfully.`);
|
||||
} catch {
|
||||
console.error(`🛑 Error: OTEL collector failed to start on port 4317.`);
|
||||
if (collectorProcess && collectorProcess.pid) {
|
||||
process.kill(collectorProcess.pid, 'SIGKILL');
|
||||
}
|
||||
if (fileExists(OTEL_LOG_FILE)) {
|
||||
console.error('📄 OTEL Collector Log Output:');
|
||||
console.error(fs.readFileSync(OTEL_LOG_FILE, 'utf-8'));
|
||||
}
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
[jaegerProcess, collectorProcess].forEach((proc) => {
|
||||
if (proc) {
|
||||
proc.on('error', (err) => {
|
||||
console.error(`${proc.spawnargs[0]} process error:`, err);
|
||||
process.exit(1);
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
console.log(`
|
||||
✨ Local telemetry environment is running.`);
|
||||
console.log(
|
||||
`
|
||||
🔎 View traces in the Jaeger UI: http://localhost:${JAEGER_PORT}`,
|
||||
);
|
||||
console.log(`📊 View metrics in the logs and metrics: ${OTEL_LOG_FILE}`);
|
||||
console.log(
|
||||
`
|
||||
📄 Tail logs and metrics in another terminal: tail -f ${OTEL_LOG_FILE}`,
|
||||
);
|
||||
console.log(`
|
||||
Press Ctrl+C to exit.`);
|
||||
}
|
||||
|
||||
main();
|
||||
@@ -0,0 +1,22 @@
|
||||
/**
|
||||
* @license
|
||||
* Copyright 2025 Google LLC
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
import { execSync } from 'node:child_process';
|
||||
import lintStaged from 'lint-staged';
|
||||
|
||||
try {
|
||||
// Get repository root
|
||||
const root = execSync('git rev-parse --show-toplevel').toString().trim();
|
||||
|
||||
// Run lint-staged with API directly
|
||||
const passed = await lintStaged({ cwd: root });
|
||||
|
||||
// Exit with appropriate code
|
||||
process.exit(passed ? 0 : 1);
|
||||
} catch {
|
||||
// Exit with error code
|
||||
process.exit(1);
|
||||
}
|
||||
@@ -0,0 +1,68 @@
|
||||
/**
|
||||
* @license
|
||||
* Copyright 2025 Google LLC
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
import fs from 'node:fs';
|
||||
import path from 'node:path';
|
||||
|
||||
const rootDir = process.cwd();
|
||||
|
||||
function updatePackageJson(packagePath, updateFn) {
|
||||
const packageJsonPath = path.resolve(rootDir, packagePath);
|
||||
const packageJson = JSON.parse(fs.readFileSync(packageJsonPath, 'utf-8'));
|
||||
updateFn(packageJson);
|
||||
fs.writeFileSync(packageJsonPath, JSON.stringify(packageJson, null, 2));
|
||||
}
|
||||
|
||||
// Copy bundle directory into packages/cli
|
||||
const sourceBundleDir = path.resolve(rootDir, 'bundle');
|
||||
const destBundleDir = path.resolve(rootDir, 'packages/cli/bundle');
|
||||
|
||||
if (fs.existsSync(sourceBundleDir)) {
|
||||
fs.rmSync(destBundleDir, { recursive: true, force: true });
|
||||
fs.cpSync(sourceBundleDir, destBundleDir, { recursive: true });
|
||||
console.log('Copied bundle/ directory to packages/cli/');
|
||||
} else {
|
||||
console.error(
|
||||
'Error: bundle/ directory not found at project root. Please run `npm run bundle` first.',
|
||||
);
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
// Overwrite the .npmrc in the core package to point to the GitHub registry.
|
||||
const coreNpmrcPath = path.resolve(rootDir, 'packages/core/.npmrc');
|
||||
fs.writeFileSync(
|
||||
coreNpmrcPath,
|
||||
'@google-gemini:registry=https://npm.pkg.github.com/',
|
||||
);
|
||||
console.log('Wrote .npmrc for @google-gemini scope to packages/core/');
|
||||
|
||||
// Update @google/gemini-cli
|
||||
updatePackageJson('packages/cli/package.json', (pkg) => {
|
||||
pkg.name = '@google-gemini/gemini-cli';
|
||||
pkg.files = ['bundle/'];
|
||||
pkg.bin = {
|
||||
gemini: 'bundle/gemini.js',
|
||||
};
|
||||
|
||||
// Remove fields that are not relevant to the bundled package.
|
||||
delete pkg.dependencies;
|
||||
delete pkg.devDependencies;
|
||||
delete pkg.scripts;
|
||||
delete pkg.main;
|
||||
delete pkg.config; // Deletes the sandboxImageUri
|
||||
});
|
||||
|
||||
// Update @google/gemini-cli-a2a-server
|
||||
updatePackageJson('packages/a2a-server/package.json', (pkg) => {
|
||||
pkg.name = '@google-gemini/gemini-cli-a2a-server';
|
||||
});
|
||||
|
||||
// Update @google/gemini-cli-core
|
||||
updatePackageJson('packages/core/package.json', (pkg) => {
|
||||
pkg.name = '@google-gemini/gemini-cli-core';
|
||||
});
|
||||
|
||||
console.log('Successfully prepared packages for GitHub release.');
|
||||
@@ -0,0 +1,67 @@
|
||||
/**
|
||||
* @license
|
||||
* Copyright 2025 Google LLC
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
import fs from 'node:fs';
|
||||
import path from 'node:path';
|
||||
|
||||
const rootDir = process.cwd();
|
||||
|
||||
function readJson(filePath) {
|
||||
return JSON.parse(fs.readFileSync(path.resolve(rootDir, filePath), 'utf-8'));
|
||||
}
|
||||
|
||||
function writeJson(filePath, data) {
|
||||
fs.writeFileSync(
|
||||
path.resolve(rootDir, filePath),
|
||||
JSON.stringify(data, null, 2),
|
||||
);
|
||||
}
|
||||
|
||||
// Copy bundle directory into packages/cli
|
||||
const sourceBundleDir = path.resolve(rootDir, 'bundle');
|
||||
const destBundleDir = path.resolve(rootDir, 'packages/cli/bundle');
|
||||
|
||||
if (fs.existsSync(sourceBundleDir)) {
|
||||
fs.rmSync(destBundleDir, { recursive: true, force: true });
|
||||
fs.cpSync(sourceBundleDir, destBundleDir, { recursive: true });
|
||||
console.log('Copied bundle/ directory to packages/cli/');
|
||||
} else {
|
||||
console.error(
|
||||
'Error: bundle/ directory not found at project root. Please run `npm run bundle` first.',
|
||||
);
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
// Inherit optionalDependencies from root package.json, excluding dev-only packages.
|
||||
const rootPkg = readJson('package.json');
|
||||
const optionalDependencies = { ...(rootPkg.optionalDependencies || {}) };
|
||||
delete optionalDependencies['gemini-cli-devtools'];
|
||||
|
||||
// Update @google/gemini-cli package.json for bundled npm release
|
||||
const cliPkgPath = 'packages/cli/package.json';
|
||||
const cliPkg = readJson(cliPkgPath);
|
||||
|
||||
cliPkg.files = ['bundle/'];
|
||||
cliPkg.bin = {
|
||||
gemini: 'bundle/gemini.js',
|
||||
};
|
||||
|
||||
delete cliPkg.dependencies;
|
||||
delete cliPkg.devDependencies;
|
||||
delete cliPkg.scripts;
|
||||
delete cliPkg.main;
|
||||
delete cliPkg.config;
|
||||
|
||||
cliPkg.optionalDependencies = optionalDependencies;
|
||||
|
||||
writeJson(cliPkgPath, cliPkg);
|
||||
|
||||
console.log('Updated packages/cli/package.json for bundled npm release.');
|
||||
console.log(
|
||||
'optionalDependencies:',
|
||||
JSON.stringify(optionalDependencies, null, 2),
|
||||
);
|
||||
console.log('Successfully prepared packages for npm release.');
|
||||
@@ -0,0 +1,51 @@
|
||||
/**
|
||||
* @license
|
||||
* Copyright 2025 Google LLC
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
import fs from 'node:fs';
|
||||
import path from 'node:path';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
|
||||
// ES module equivalent of __dirname
|
||||
const __filename = fileURLToPath(import.meta.url);
|
||||
const __dirname = path.dirname(__filename);
|
||||
|
||||
const rootDir = path.resolve(__dirname, '..');
|
||||
|
||||
function copyFiles(packageName, filesToCopy) {
|
||||
const packageDir = path.resolve(rootDir, 'packages', packageName);
|
||||
if (!fs.existsSync(packageDir)) {
|
||||
console.error(`Error: Package directory not found at ${packageDir}`);
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
console.log(`Preparing package: ${packageName}`);
|
||||
for (const [source, dest] of Object.entries(filesToCopy)) {
|
||||
const sourcePath = path.resolve(rootDir, source);
|
||||
const destPath = path.resolve(packageDir, dest);
|
||||
try {
|
||||
fs.copyFileSync(sourcePath, destPath);
|
||||
console.log(`Copied ${source} to packages/${packageName}/`);
|
||||
} catch (err) {
|
||||
console.error(`Error copying ${source}:`, err);
|
||||
process.exit(1);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Prepare 'core' package
|
||||
copyFiles('core', {
|
||||
'README.md': 'README.md',
|
||||
LICENSE: 'LICENSE',
|
||||
'.npmrc': '.npmrc',
|
||||
});
|
||||
|
||||
// Prepare 'cli' package
|
||||
copyFiles('cli', {
|
||||
'README.md': 'README.md',
|
||||
LICENSE: 'LICENSE',
|
||||
});
|
||||
|
||||
console.log('Successfully prepared all packages.');
|
||||
Executable
+44
@@ -0,0 +1,44 @@
|
||||
#!/bin/bash
|
||||
# scripts/relabel_issues.sh
|
||||
# Usage: ./scripts/relabel_issues.sh <old-label> <new-label> [repository]
|
||||
|
||||
set -e
|
||||
set -o pipefail
|
||||
|
||||
OLD_LABEL="${1}"
|
||||
NEW_LABEL="${2}"
|
||||
REPO="${3:-google-gemini/gemini-cli}"
|
||||
|
||||
if [[ -z "${OLD_LABEL}" ]] || [[ -z "${NEW_LABEL}" ]]; then
|
||||
echo "Usage: $0 <old-label> <new-label> [repository]"
|
||||
echo "Example: $0 'area/models' 'area/agent'"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
echo "🔍 Searching for open issues in '${REPO}' with label '${OLD_LABEL}'..."
|
||||
|
||||
# Fetch issues with the old label
|
||||
ISSUES=$(gh issue list --repo "${REPO}" --label "${OLD_LABEL}" --state open --limit 1000 --json number,title)
|
||||
|
||||
# Avoid masking return value
|
||||
COUNT=$(jq '. | length' <<< "${ISSUES}")
|
||||
|
||||
if [[ "${COUNT}" -eq 0 ]]; then
|
||||
echo "✅ No issues found with label '${OLD_LABEL}'."
|
||||
exit 0
|
||||
fi
|
||||
|
||||
echo "found ${COUNT} issues to relabel."
|
||||
|
||||
# Iterate and update
|
||||
echo "${ISSUES}" | jq -r '.[] | "\(.number) \(.title)"' | while read -r number title; do
|
||||
echo "🔄 Processing #${number}: ${title}"
|
||||
echo " - Removing: ${OLD_LABEL}"
|
||||
echo " + Adding: ${NEW_LABEL}"
|
||||
|
||||
gh issue edit "${number}" --repo "${REPO}" --add-label "${NEW_LABEL}" --remove-label "${OLD_LABEL}"
|
||||
|
||||
echo " ✅ Done."
|
||||
done
|
||||
|
||||
echo "🎉 All issues relabeled!"
|
||||
@@ -0,0 +1,297 @@
|
||||
#!/usr/bin/env node
|
||||
|
||||
/**
|
||||
* @license
|
||||
* Copyright 2025 Google LLC
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
import { execSync } from 'node:child_process';
|
||||
import yargs from 'yargs';
|
||||
import { hideBin } from 'yargs/helpers';
|
||||
|
||||
async function main() {
|
||||
const argv = await yargs(hideBin(process.argv))
|
||||
.option('commit', {
|
||||
alias: 'c',
|
||||
description: 'The commit SHA to cherry-pick for the patch.',
|
||||
type: 'string',
|
||||
demandOption: true,
|
||||
})
|
||||
.option('pullRequestNumber', {
|
||||
alias: 'pr',
|
||||
description: "The pr number that we're cherry picking",
|
||||
type: 'number',
|
||||
demandOption: true,
|
||||
})
|
||||
.option('channel', {
|
||||
alias: 'ch',
|
||||
description: 'The release channel to patch.',
|
||||
choices: ['stable', 'preview'],
|
||||
demandOption: true,
|
||||
})
|
||||
.option('cli-package-name', {
|
||||
description:
|
||||
'fully qualified package name with scope (e.g @google/gemini-cli)',
|
||||
string: true,
|
||||
default: '@google/gemini-cli',
|
||||
})
|
||||
.option('dry-run', {
|
||||
description: 'Whether to run in dry-run mode.',
|
||||
type: 'boolean',
|
||||
default: false,
|
||||
})
|
||||
.help()
|
||||
.alias('help', 'h').argv;
|
||||
|
||||
const { commit, channel, dryRun, pullRequestNumber } = argv;
|
||||
|
||||
console.log(`Starting patch process for commit: ${commit}`);
|
||||
console.log(`Targeting channel: ${channel}`);
|
||||
if (dryRun) {
|
||||
console.log('Running in dry-run mode.');
|
||||
}
|
||||
|
||||
run('git fetch --all --tags --prune', dryRun);
|
||||
|
||||
const releaseInfo = getLatestReleaseInfo({ argv, channel });
|
||||
const latestTag = releaseInfo.currentTag;
|
||||
const nextVersion = releaseInfo.nextVersion;
|
||||
|
||||
const releaseBranch = `release/${latestTag}-pr-${pullRequestNumber}`;
|
||||
const hotfixBranch = `hotfix/${latestTag}/${nextVersion}/${channel}/cherry-pick-${commit.substring(0, 7)}/pr-${pullRequestNumber}`;
|
||||
|
||||
// Create the release branch from the tag if it doesn't exist.
|
||||
if (!branchExists(releaseBranch)) {
|
||||
console.log(
|
||||
`Release branch ${releaseBranch} does not exist. Creating it from tag ${latestTag}...`,
|
||||
);
|
||||
try {
|
||||
run(`git checkout -b ${releaseBranch} ${latestTag}`, dryRun);
|
||||
run(`git push origin ${releaseBranch}`, dryRun);
|
||||
} catch (error) {
|
||||
// Check if this is a GitHub App workflows permission error
|
||||
if (
|
||||
error.message.match(/refusing to allow a GitHub App/i) &&
|
||||
error.message.match(/workflows?['`]? permission/i)
|
||||
) {
|
||||
console.error(
|
||||
`❌ Failed to create release branch due to insufficient GitHub App permissions.`,
|
||||
);
|
||||
console.log(
|
||||
`\n📋 Please run these commands manually to create the branch:`,
|
||||
);
|
||||
console.log(`\n\`\`\`bash`);
|
||||
console.log(`git checkout -b ${releaseBranch} ${latestTag}`);
|
||||
console.log(`git push origin ${releaseBranch}`);
|
||||
console.log(`\`\`\``);
|
||||
console.log(
|
||||
`\nAfter running these commands, you can run the patch command again.`,
|
||||
);
|
||||
process.exit(1);
|
||||
} else {
|
||||
// Re-throw other errors
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
} else {
|
||||
console.log(`Release branch ${releaseBranch} already exists.`);
|
||||
}
|
||||
|
||||
// Check if hotfix branch already exists
|
||||
if (branchExists(hotfixBranch)) {
|
||||
console.log(`Hotfix branch ${hotfixBranch} already exists.`);
|
||||
|
||||
// Check if there's already a PR for this branch
|
||||
try {
|
||||
const prInfo = execSync(
|
||||
`gh pr list --head ${hotfixBranch} --json number,url --jq '.[0] // empty'`,
|
||||
)
|
||||
.toString()
|
||||
.trim();
|
||||
if (prInfo && prInfo !== 'null' && prInfo !== '') {
|
||||
const pr = JSON.parse(prInfo);
|
||||
console.log(`Found existing PR #${pr.number}: ${pr.url}`);
|
||||
console.log(`Hotfix branch ${hotfixBranch} already has an open PR.`);
|
||||
return { existingBranch: hotfixBranch, existingPR: pr };
|
||||
} else {
|
||||
console.log(`Hotfix branch ${hotfixBranch} exists but has no open PR.`);
|
||||
console.log(
|
||||
`You may need to delete the branch and run this command again.`,
|
||||
);
|
||||
return { existingBranch: hotfixBranch };
|
||||
}
|
||||
} catch (err) {
|
||||
console.error(`Error checking for existing PR: ${err.message}`);
|
||||
console.log(`Hotfix branch ${hotfixBranch} already exists.`);
|
||||
return { existingBranch: hotfixBranch };
|
||||
}
|
||||
}
|
||||
|
||||
// Create the hotfix branch from the release branch.
|
||||
console.log(
|
||||
`Creating hotfix branch ${hotfixBranch} from ${releaseBranch}...`,
|
||||
);
|
||||
run(`git checkout -b ${hotfixBranch} origin/${releaseBranch}`, dryRun);
|
||||
|
||||
// Ensure git user is configured properly for commits
|
||||
console.log('Configuring git user for cherry-pick commits...');
|
||||
run('git config user.name "gemini-cli-robot"', dryRun);
|
||||
run('git config user.email "gemini-cli-robot@google.com"', dryRun);
|
||||
|
||||
// Cherry-pick the commit.
|
||||
console.log(`Cherry-picking commit ${commit} into ${hotfixBranch}...`);
|
||||
let hasConflicts = false;
|
||||
if (!dryRun) {
|
||||
try {
|
||||
execSync(`git cherry-pick ${commit}`, { stdio: 'pipe' });
|
||||
console.log(`✅ Cherry-pick successful - no conflicts detected`);
|
||||
} catch (error) {
|
||||
// Check if this is a cherry-pick conflict
|
||||
try {
|
||||
const status = execSync('git status --porcelain', { encoding: 'utf8' });
|
||||
const conflictFiles = status
|
||||
.split('\n')
|
||||
.filter(
|
||||
(line) =>
|
||||
line.startsWith('UU ') ||
|
||||
line.startsWith('AA ') ||
|
||||
line.startsWith('DU ') ||
|
||||
line.startsWith('UD '),
|
||||
);
|
||||
|
||||
if (conflictFiles.length > 0) {
|
||||
hasConflicts = true;
|
||||
console.log(
|
||||
`⚠️ Cherry-pick has conflicts in ${conflictFiles.length} file(s):`,
|
||||
);
|
||||
conflictFiles.forEach((file) =>
|
||||
console.log(` - ${file.substring(3)}`),
|
||||
);
|
||||
|
||||
// Add all files (including conflict markers) and commit
|
||||
console.log(
|
||||
`📝 Creating commit with conflict markers for manual resolution...`,
|
||||
);
|
||||
execSync('git add .');
|
||||
execSync(`git commit --no-edit --no-verify`);
|
||||
console.log(`✅ Committed cherry-pick with conflict markers`);
|
||||
} else {
|
||||
// Re-throw if it's not a conflict error
|
||||
throw error;
|
||||
}
|
||||
} catch {
|
||||
// Re-throw original error if we can't determine the status
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
} else {
|
||||
console.log(`[DRY RUN] Would cherry-pick ${commit}`);
|
||||
}
|
||||
|
||||
// Push the hotfix branch.
|
||||
console.log(`Pushing hotfix branch ${hotfixBranch} to origin...`);
|
||||
run(`git push --set-upstream origin ${hotfixBranch}`, dryRun);
|
||||
|
||||
// Create the pull request.
|
||||
console.log(
|
||||
`Creating pull request from ${hotfixBranch} to ${releaseBranch}...`,
|
||||
);
|
||||
let prTitle = `fix(patch): cherry-pick ${commit.substring(0, 7)} to ${releaseBranch} to patch version ${releaseInfo.currentTag} and create version ${releaseInfo.nextVersion}`;
|
||||
let prBody = `This PR automatically cherry-picks commit ${commit} to patch version ${releaseInfo.currentTag} in the ${channel} release to create version ${releaseInfo.nextVersion}.`;
|
||||
|
||||
if (hasConflicts) {
|
||||
prTitle = `fix(patch): cherry-pick ${commit.substring(0, 7)} to ${releaseBranch} [CONFLICTS]`;
|
||||
prBody += `
|
||||
|
||||
## ⚠️ Merge Conflicts Detected
|
||||
|
||||
This cherry-pick resulted in merge conflicts that need manual resolution.
|
||||
|
||||
### 🔧 Next Steps:
|
||||
1. **Review the conflicts**: Check out this branch and review the conflict markers
|
||||
2. **Resolve conflicts**: Edit the affected files to resolve the conflicts
|
||||
3. **Test the changes**: Ensure the patch works correctly after resolution
|
||||
4. **Update this PR**: Push your conflict resolution
|
||||
|
||||
### 📋 Files with conflicts:
|
||||
The commit has been created with conflict markers for easier manual resolution.
|
||||
|
||||
### 🚨 Important:
|
||||
- Do not merge this PR until conflicts are resolved
|
||||
- The automated patch release will trigger once this PR is merged`;
|
||||
}
|
||||
|
||||
if (dryRun) {
|
||||
prBody += '\n\n**[DRY RUN]**';
|
||||
}
|
||||
|
||||
const prCommand = `gh pr create --base ${releaseBranch} --head ${hotfixBranch} --title "${prTitle}" --body "${prBody}"`;
|
||||
run(prCommand, dryRun);
|
||||
|
||||
if (hasConflicts) {
|
||||
console.log(
|
||||
'⚠️ Patch process completed with conflicts - manual resolution required!',
|
||||
);
|
||||
} else {
|
||||
console.log('✅ Patch process completed successfully!');
|
||||
}
|
||||
|
||||
if (dryRun) {
|
||||
console.log('\n--- Dry Run Summary ---');
|
||||
console.log(`Release Branch: ${releaseBranch}`);
|
||||
console.log(`Hotfix Branch: ${hotfixBranch}`);
|
||||
console.log(`Pull Request Command: ${prCommand}`);
|
||||
console.log('---------------------');
|
||||
}
|
||||
|
||||
return { newBranch: hotfixBranch, created: true, hasConflicts };
|
||||
}
|
||||
|
||||
function run(command, dryRun = false, throwOnError = true) {
|
||||
console.log(`> ${command}`);
|
||||
if (dryRun) {
|
||||
return;
|
||||
}
|
||||
try {
|
||||
return execSync(command).toString().trim();
|
||||
} catch (err) {
|
||||
console.error(`Command failed: ${command}`);
|
||||
if (throwOnError) {
|
||||
throw err;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
function branchExists(branchName) {
|
||||
try {
|
||||
execSync(`git ls-remote --exit-code --heads origin ${branchName}`);
|
||||
return true;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
function getLatestReleaseInfo({ argv, channel } = {}) {
|
||||
console.log(`Fetching latest release info for channel: ${channel}...`);
|
||||
const patchFrom = channel; // 'stable' or 'preview'
|
||||
const command = `node scripts/get-release-version.js --cli-package-name="${argv['cli-package-name']}" --type=patch --patch-from=${patchFrom}`;
|
||||
try {
|
||||
const result = JSON.parse(execSync(command).toString().trim());
|
||||
console.log(`Current ${channel} tag: ${result.previousReleaseTag}`);
|
||||
console.log(`Next ${channel} version would be: ${result.releaseVersion}`);
|
||||
return {
|
||||
currentTag: result.previousReleaseTag,
|
||||
nextVersion: result.releaseVersion,
|
||||
};
|
||||
} catch (err) {
|
||||
console.error(`Failed to get release info for channel: ${channel}`);
|
||||
throw err;
|
||||
}
|
||||
}
|
||||
|
||||
main().catch((err) => {
|
||||
console.error(err);
|
||||
process.exit(1);
|
||||
});
|
||||
@@ -0,0 +1,223 @@
|
||||
#!/usr/bin/env node
|
||||
|
||||
/**
|
||||
* @license
|
||||
* Copyright 2025 Google LLC
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
/**
|
||||
* Script for commenting back to original PR with patch release results.
|
||||
* Used by the patch release workflow (step 3).
|
||||
*/
|
||||
|
||||
import yargs from 'yargs';
|
||||
import { hideBin } from 'yargs/helpers';
|
||||
|
||||
async function main() {
|
||||
const argv = await yargs(hideBin(process.argv))
|
||||
.option('original-pr', {
|
||||
description: 'The original PR number to comment on',
|
||||
type: 'number',
|
||||
demandOption: !process.env.GITHUB_ACTIONS,
|
||||
})
|
||||
.option('success', {
|
||||
description: 'Whether the release succeeded',
|
||||
type: 'boolean',
|
||||
})
|
||||
.option('release-version', {
|
||||
description: 'The release version (e.g., 0.5.4)',
|
||||
type: 'string',
|
||||
demandOption: !process.env.GITHUB_ACTIONS,
|
||||
})
|
||||
.option('release-tag', {
|
||||
description: 'The release tag (e.g., v0.5.4)',
|
||||
type: 'string',
|
||||
})
|
||||
.option('npm-tag', {
|
||||
description: 'The npm tag (latest or preview)',
|
||||
type: 'string',
|
||||
})
|
||||
.option('channel', {
|
||||
description: 'The channel (stable or preview)',
|
||||
type: 'string',
|
||||
choices: ['stable', 'preview'],
|
||||
})
|
||||
.option('dry-run', {
|
||||
description: 'Whether this was a dry run',
|
||||
type: 'boolean',
|
||||
default: false,
|
||||
})
|
||||
.option('test', {
|
||||
description: 'Test mode - validate logic without GitHub API calls',
|
||||
type: 'boolean',
|
||||
default: false,
|
||||
})
|
||||
.example(
|
||||
'$0 --original-pr 8655 --success --release-version "0.5.4" --channel stable --test',
|
||||
'Test success comment',
|
||||
)
|
||||
.example(
|
||||
'$0 --original-pr 8655 --no-success --channel preview --test',
|
||||
'Test failure comment',
|
||||
)
|
||||
.help()
|
||||
.alias('help', 'h').argv;
|
||||
|
||||
const testMode = argv.test || process.env.TEST_MODE === 'true';
|
||||
|
||||
// Initialize GitHub API client only if not in test mode
|
||||
let github;
|
||||
if (!testMode) {
|
||||
const { Octokit } = await import('@octokit/rest');
|
||||
github = new Octokit({
|
||||
auth: process.env.GITHUB_TOKEN,
|
||||
});
|
||||
}
|
||||
|
||||
const repo = {
|
||||
owner: process.env.GITHUB_REPOSITORY_OWNER || 'google-gemini',
|
||||
repo: process.env.GITHUB_REPOSITORY_NAME || 'gemini-cli',
|
||||
};
|
||||
|
||||
// Get inputs from CLI args or environment
|
||||
const originalPr = argv.originalPr || process.env.ORIGINAL_PR;
|
||||
const success =
|
||||
argv.success !== undefined ? argv.success : process.env.SUCCESS === 'true';
|
||||
const releaseVersion = argv.releaseVersion || process.env.RELEASE_VERSION;
|
||||
const releaseTag =
|
||||
argv.releaseTag ||
|
||||
process.env.RELEASE_TAG ||
|
||||
(releaseVersion ? `v${releaseVersion}` : null);
|
||||
const npmTag =
|
||||
argv.npmTag ||
|
||||
process.env.NPM_TAG ||
|
||||
(argv.channel === 'stable' ? 'latest' : 'preview');
|
||||
const channel = argv.channel || process.env.CHANNEL || 'stable';
|
||||
const dryRun = argv.dryRun || process.env.DRY_RUN === 'true';
|
||||
const runId = process.env.GITHUB_RUN_ID || '12345678';
|
||||
const raceConditionFailure = process.env.RACE_CONDITION_FAILURE === 'true';
|
||||
|
||||
// Current version info for race condition failures
|
||||
const currentReleaseVersion = process.env.CURRENT_RELEASE_VERSION;
|
||||
const currentReleaseTag = process.env.CURRENT_RELEASE_TAG;
|
||||
const currentPreviousTag = process.env.CURRENT_PREVIOUS_TAG;
|
||||
|
||||
if (!originalPr) {
|
||||
console.log('No original PR specified, skipping comment');
|
||||
return;
|
||||
}
|
||||
|
||||
console.log(
|
||||
`Commenting on original PR ${originalPr} with ${success ? 'success' : 'failure'} status`,
|
||||
);
|
||||
|
||||
if (testMode) {
|
||||
console.log('\n🧪 TEST MODE - No API calls will be made');
|
||||
console.log('\n📋 Inputs:');
|
||||
console.log(` - Original PR: ${originalPr}`);
|
||||
console.log(` - Success: ${success}`);
|
||||
console.log(` - Release Version: ${releaseVersion}`);
|
||||
console.log(` - Release Tag: ${releaseTag}`);
|
||||
console.log(` - NPM Tag: ${npmTag}`);
|
||||
console.log(` - Channel: ${channel}`);
|
||||
console.log(` - Dry Run: ${dryRun}`);
|
||||
console.log(` - Run ID: ${runId}`);
|
||||
}
|
||||
|
||||
let commentBody;
|
||||
|
||||
if (success) {
|
||||
commentBody = `✅ **[Step 4/4] Patch Release Complete!**
|
||||
|
||||
**📦 Release Details:**
|
||||
- **Version**: [\`${releaseVersion}\`](https://github.com/${repo.owner}/${repo.repo}/releases/tag/${releaseTag})
|
||||
- **NPM Tag**: \`${npmTag}\`
|
||||
- **Channel**: \`${channel}\`
|
||||
- **Dry Run**: ${dryRun}
|
||||
|
||||
**🎉 Status:** Your patch has been successfully released and published to npm!
|
||||
|
||||
**📝 What's Available:**
|
||||
- **GitHub Release**: [View release ${releaseTag}](https://github.com/${repo.owner}/${repo.repo}/releases/tag/${releaseTag})
|
||||
- **NPM Package**: \`npm install @google/gemini-cli@${npmTag}\`
|
||||
|
||||
**🔗 Links:**
|
||||
- [GitHub Release](https://github.com/${repo.owner}/${repo.repo}/releases/tag/${releaseTag})
|
||||
- [This release workflow run](https://github.com/${repo.owner}/${repo.repo}/actions/runs/${runId})
|
||||
- [Workflow History](https://github.com/${repo.owner}/${repo.repo}/actions/workflows/release-patch-3-release.yml)`;
|
||||
} else if (raceConditionFailure) {
|
||||
commentBody = `⚠️ **[Step 4/4] Patch Release Cancelled - Concurrent Release Detected**
|
||||
|
||||
**🚦 What Happened:**
|
||||
Another patch release completed while this one was in progress, causing a version conflict.
|
||||
|
||||
**📋 Details:**
|
||||
- **Originally planned**: \`${releaseVersion || 'Unknown'}\`
|
||||
- **Channel**: \`${channel}\`
|
||||
- **Issue**: Version numbers are no longer sequential due to concurrent releases
|
||||
|
||||
**📊 Current State:**${
|
||||
currentReleaseVersion
|
||||
? `
|
||||
- **Latest ${channel} version**: \`${currentPreviousTag?.replace(/^v/, '') || 'unknown'}\`
|
||||
- **Next patch should be**: \`${currentReleaseVersion}\`
|
||||
- **New release tag**: \`${currentReleaseTag || 'unknown'}\``
|
||||
: `
|
||||
- **Status**: Version information updated since this release was triggered`
|
||||
}
|
||||
|
||||
**🔄 Next Steps:**
|
||||
1. **Request a new patch** - The version calculation will now be correct
|
||||
2. No action needed on your part - simply request the patch again
|
||||
3. The system detected this automatically to prevent invalid releases
|
||||
|
||||
**💡 Why This Happens:**
|
||||
Multiple patch releases can't run simultaneously. When they do, the second one is automatically cancelled to maintain version consistency.
|
||||
|
||||
**🔗 Details:**
|
||||
- [This release workflow run](https://github.com/${repo.owner}/${repo.repo}/actions/runs/${runId})
|
||||
- [Workflow History](https://github.com/${repo.owner}/${repo.repo}/actions/workflows/release-patch-3-release.yml)`;
|
||||
} else {
|
||||
commentBody = `❌ **[Step 4/4] Patch Release Failed!**
|
||||
|
||||
**📋 Details:**
|
||||
- **Version**: \`${releaseVersion || 'Unknown'}\`
|
||||
- **Channel**: \`${channel}\`
|
||||
- **Error**: The patch release workflow encountered an error
|
||||
|
||||
**🔍 Next Steps:**
|
||||
1. Check the workflow logs for detailed error information
|
||||
2. The maintainers have been notified via automatic issue creation
|
||||
3. You may need to retry the patch once the issue is resolved
|
||||
|
||||
**🔗 Troubleshooting:**
|
||||
- [This release workflow run](https://github.com/${repo.owner}/${repo.repo}/actions/runs/${runId})
|
||||
- [View workflow logs](https://github.com/${repo.owner}/${repo.repo}/actions/runs/${runId})
|
||||
- [Workflow History](https://github.com/${repo.owner}/${repo.repo}/actions/workflows/release-patch-3-release.yml)`;
|
||||
}
|
||||
|
||||
if (testMode) {
|
||||
console.log('\n💬 Would post comment:');
|
||||
console.log('----------------------------------------');
|
||||
console.log(commentBody);
|
||||
console.log('----------------------------------------');
|
||||
console.log('\n✅ Comment generation working correctly!');
|
||||
} else if (github) {
|
||||
await github.rest.issues.createComment({
|
||||
owner: repo.owner,
|
||||
repo: repo.repo,
|
||||
issue_number: parseInt(originalPr),
|
||||
body: commentBody,
|
||||
});
|
||||
|
||||
console.log(`Successfully commented on PR ${originalPr}`);
|
||||
} else {
|
||||
console.log('No GitHub client available');
|
||||
}
|
||||
}
|
||||
|
||||
main().catch((error) => {
|
||||
console.error('Error commenting on PR:', error);
|
||||
process.exit(1);
|
||||
});
|
||||
@@ -0,0 +1,389 @@
|
||||
#!/usr/bin/env node
|
||||
|
||||
/**
|
||||
* @license
|
||||
* Copyright 2025 Google LLC
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
/**
|
||||
* Script for commenting on the original PR after patch creation (step 1).
|
||||
* Handles parsing create-patch-pr.js output and creating appropriate feedback.
|
||||
*/
|
||||
|
||||
import yargs from 'yargs';
|
||||
import { hideBin } from 'yargs/helpers';
|
||||
|
||||
async function main() {
|
||||
const argv = await yargs(hideBin(process.argv))
|
||||
.option('original-pr', {
|
||||
description: 'The original PR number to comment on',
|
||||
type: 'number',
|
||||
demandOption: !process.env.GITHUB_ACTIONS,
|
||||
})
|
||||
.option('exit-code', {
|
||||
description: 'Exit code from patch creation step',
|
||||
type: 'number',
|
||||
demandOption: !process.env.GITHUB_ACTIONS,
|
||||
})
|
||||
.option('commit', {
|
||||
description: 'The commit SHA being patched',
|
||||
type: 'string',
|
||||
demandOption: !process.env.GITHUB_ACTIONS,
|
||||
})
|
||||
.option('channel', {
|
||||
description: 'The channel (stable or preview)',
|
||||
type: 'string',
|
||||
choices: ['stable', 'preview'],
|
||||
demandOption: !process.env.GITHUB_ACTIONS,
|
||||
})
|
||||
.option('repository', {
|
||||
description: 'The GitHub repository (owner/repo format)',
|
||||
type: 'string',
|
||||
demandOption: !process.env.GITHUB_ACTIONS,
|
||||
})
|
||||
.option('run-id', {
|
||||
description: 'The GitHub workflow run ID',
|
||||
type: 'string',
|
||||
})
|
||||
.option('environment', {
|
||||
choices: ['prod', 'dev'],
|
||||
type: 'string',
|
||||
default: process.env.ENVIRONMENT || 'prod',
|
||||
})
|
||||
.option('test', {
|
||||
description: 'Test mode - validate logic without GitHub API calls',
|
||||
type: 'boolean',
|
||||
default: false,
|
||||
})
|
||||
.example(
|
||||
'$0 --original-pr 8655 --exit-code 0 --commit abc1234 --channel preview --repository google-gemini/gemini-cli --test',
|
||||
'Test success comment',
|
||||
)
|
||||
.example(
|
||||
'$0 --original-pr 8655 --exit-code 1 --commit abc1234 --channel stable --repository google-gemini/gemini-cli --test',
|
||||
'Test failure comment',
|
||||
)
|
||||
.help()
|
||||
.alias('help', 'h').argv;
|
||||
|
||||
const testMode = argv.test || process.env.TEST_MODE === 'true';
|
||||
|
||||
// GitHub CLI is available in the workflow environment
|
||||
const hasGitHubCli = !testMode;
|
||||
|
||||
// Get inputs from CLI args or environment
|
||||
const originalPr = argv.originalPr || process.env.ORIGINAL_PR;
|
||||
const exitCode =
|
||||
argv.exitCode !== undefined
|
||||
? argv.exitCode
|
||||
: parseInt(process.env.EXIT_CODE || '1');
|
||||
const commit = argv.commit || process.env.COMMIT;
|
||||
const channel = argv.channel || process.env.CHANNEL;
|
||||
const environment = argv.environment;
|
||||
const repository =
|
||||
argv.repository || process.env.REPOSITORY || 'google-gemini/gemini-cli';
|
||||
const runId = argv.runId || process.env.GITHUB_RUN_ID || '0';
|
||||
|
||||
// Validate required parameters
|
||||
if (!runId || runId === '0') {
|
||||
console.warn(
|
||||
'Warning: No valid GitHub run ID found, workflow links may not work correctly',
|
||||
);
|
||||
}
|
||||
|
||||
if (!originalPr) {
|
||||
console.log('No original PR specified, skipping comment');
|
||||
return;
|
||||
}
|
||||
|
||||
console.log(
|
||||
`Analyzing patch creation result for PR ${originalPr} (exit code: ${exitCode})`,
|
||||
);
|
||||
|
||||
const [_owner, _repo] = repository.split('/');
|
||||
const npmTag = channel === 'stable' ? 'latest' : 'preview';
|
||||
|
||||
if (testMode) {
|
||||
console.log('\n🧪 TEST MODE - No API calls will be made');
|
||||
console.log('\n📋 Inputs:');
|
||||
console.log(` - Original PR: ${originalPr}`);
|
||||
console.log(` - Exit Code: ${exitCode}`);
|
||||
console.log(` - Commit: ${commit}`);
|
||||
console.log(` - Channel: ${channel} → npm tag: ${npmTag}`);
|
||||
console.log(` - Repository: ${repository}`);
|
||||
console.log(` - Run ID: ${runId}`);
|
||||
}
|
||||
|
||||
let commentBody;
|
||||
let logContent = '';
|
||||
|
||||
// Get log content from environment variable or generate mock content for testing
|
||||
if (testMode && !process.env.LOG_CONTENT) {
|
||||
// Create mock log content for testing only if LOG_CONTENT is not provided
|
||||
if (exitCode === 0) {
|
||||
logContent = `Creating hotfix branch hotfix/v0.5.3/${channel}/cherry-pick-${commit.substring(0, 7)} from release/v0.5.3`;
|
||||
} else {
|
||||
logContent = 'Error: Failed to create patch';
|
||||
}
|
||||
} else {
|
||||
// Use log content from environment variable
|
||||
logContent = process.env.LOG_CONTENT || '';
|
||||
}
|
||||
|
||||
if (
|
||||
logContent.includes(
|
||||
'Failed to create release branch due to insufficient GitHub App permissions',
|
||||
)
|
||||
) {
|
||||
// GitHub App permission error - extract manual commands
|
||||
const manualCommandsMatch = logContent.match(
|
||||
/📋 Please run these commands manually to create the branch:[\s\S]*?```bash\s*([\s\S]*?)\s*```/,
|
||||
);
|
||||
let manualCommands = '';
|
||||
if (manualCommandsMatch) {
|
||||
manualCommands = manualCommandsMatch[1].trim();
|
||||
}
|
||||
|
||||
commentBody = `🔒 **[Step 2/4] GitHub App Permission Issue**
|
||||
|
||||
The patch creation failed due to insufficient GitHub App permissions for creating workflow files.
|
||||
|
||||
**📝 Manual Action Required:**
|
||||
${
|
||||
manualCommands
|
||||
? `Please run these commands manually to create the release branch:
|
||||
|
||||
\`\`\`bash
|
||||
${manualCommands}
|
||||
\`\`\`
|
||||
|
||||
After running these commands, you can re-run the patch workflow.`
|
||||
: 'Please check the workflow logs for manual commands to run.'
|
||||
}
|
||||
|
||||
**🔗 Links:**
|
||||
- [View workflow run](https://github.com/${repository}/actions/runs/${runId})`;
|
||||
} else if (logContent.includes('already has an open PR')) {
|
||||
// Branch exists with existing PR
|
||||
const prMatch = logContent.match(/Found existing PR #(\d+): (.*)/);
|
||||
if (prMatch) {
|
||||
const [, prNumber, prUrl] = prMatch;
|
||||
commentBody = `ℹ️ **[Step 2/4] Patch PR already exists!**
|
||||
|
||||
A patch PR for this change already exists: [#${prNumber}](${prUrl}).
|
||||
|
||||
**📝 Next Steps:**
|
||||
1. Review and approve the existing patch PR
|
||||
2. If it's incorrect, close it and run the patch command again
|
||||
|
||||
**🔗 Links:**
|
||||
- [View existing patch PR #${prNumber}](${prUrl})`;
|
||||
}
|
||||
} else if (logContent.includes('exists but has no open PR')) {
|
||||
// Branch exists but no PR
|
||||
const branchMatch = logContent.match(/Hotfix branch (.*) already exists/);
|
||||
if (branchMatch) {
|
||||
const [, branch] = branchMatch;
|
||||
commentBody = `ℹ️ **[Step 2/4] Patch branch exists but no PR found!**
|
||||
|
||||
A patch branch [\`${branch}\`](https://github.com/${repository}/tree/${branch}) exists but has no open PR.
|
||||
|
||||
**🔍 Issue:** This might indicate an incomplete patch process.
|
||||
|
||||
**📝 Next Steps:**
|
||||
1. Delete the branch: \`git branch -D ${branch}\`
|
||||
2. Run the patch command again
|
||||
|
||||
**🔗 Links:**
|
||||
- [View branch on GitHub](https://github.com/${repository}/tree/${branch})`;
|
||||
}
|
||||
} else if (exitCode === 0) {
|
||||
// Success - extract branch info
|
||||
const branchMatch = logContent.match(/Creating hotfix branch (.*) from/);
|
||||
if (branchMatch) {
|
||||
const [, branch] = branchMatch;
|
||||
|
||||
if (testMode) {
|
||||
// Mock PR info for testing
|
||||
const mockPrNumber = Math.floor(Math.random() * 1000) + 8000;
|
||||
const mockPrUrl = `https://github.com/${repository}/pull/${mockPrNumber}`;
|
||||
|
||||
const hasConflicts =
|
||||
logContent.includes('Cherry-pick has conflicts') ||
|
||||
logContent.includes('[CONFLICTS]');
|
||||
|
||||
commentBody = `🚀 **[Step 2/4] Patch PR Created!**
|
||||
|
||||
**📋 Patch Details:**
|
||||
- **Environment**: \`${environment}\`
|
||||
- **Channel**: \`${channel}\` → will publish to npm tag \`${npmTag}\`
|
||||
- **Commit**: \`${commit}\`
|
||||
- **Hotfix Branch**: [\`${branch}\`](https://github.com/${repository}/tree/${branch})
|
||||
- **Hotfix PR**: [#${mockPrNumber}](${mockPrUrl})${hasConflicts ? '\n- **⚠️ Status**: Cherry-pick conflicts detected - manual resolution required' : ''}
|
||||
|
||||
**📝 Next Steps:**
|
||||
1. ${hasConflicts ? '⚠️ **Resolve conflicts** in the hotfix PR first' : 'Review and approve the hotfix PR'}: [#${mockPrNumber}](${mockPrUrl})${hasConflicts ? '\n2. **Test your changes** after resolving conflicts' : ''}
|
||||
${hasConflicts ? '3' : '2'}. Once merged, the patch release will automatically trigger
|
||||
${hasConflicts ? '4' : '3'}. You'll receive updates here when the release completes
|
||||
|
||||
**🔗 Track Progress:**
|
||||
- [View hotfix PR #${mockPrNumber}](${mockPrUrl})
|
||||
- [This patch creation workflow run](https://github.com/${repository}/actions/runs/${runId})`;
|
||||
} else if (hasGitHubCli) {
|
||||
// Find the actual PR for the new branch using gh CLI
|
||||
try {
|
||||
const { spawnSync } = await import('node:child_process');
|
||||
const result = spawnSync(
|
||||
'gh',
|
||||
[
|
||||
'pr',
|
||||
'list',
|
||||
'--head',
|
||||
branch,
|
||||
'--state',
|
||||
'open',
|
||||
'--json',
|
||||
'number,title,url',
|
||||
'--limit',
|
||||
'1',
|
||||
],
|
||||
{ encoding: 'utf8' },
|
||||
);
|
||||
|
||||
if (result.error) {
|
||||
throw result.error;
|
||||
}
|
||||
if (result.status !== 0) {
|
||||
throw new Error(
|
||||
`gh pr list failed with status ${result.status}: ${result.stderr}`,
|
||||
);
|
||||
}
|
||||
|
||||
const prListOutput = result.stdout;
|
||||
|
||||
const prList = JSON.parse(prListOutput);
|
||||
|
||||
if (prList.length > 0) {
|
||||
const pr = prList[0];
|
||||
const hasConflicts =
|
||||
logContent.includes('Cherry-pick has conflicts') ||
|
||||
pr.title.includes('[CONFLICTS]');
|
||||
|
||||
commentBody = `🚀 **[Step 2/4] Patch PR Created!**
|
||||
|
||||
**📋 Patch Details:**
|
||||
- **Environment**: \`${environment}\`
|
||||
- **Channel**: \`${channel}\` → will publish to npm tag \`${npmTag}\`
|
||||
- **Commit**: \`${commit}\`
|
||||
- **Hotfix Branch**: [\`${branch}\`](https://github.com/${repository}/tree/${branch})
|
||||
- **Hotfix PR**: [#${pr.number}](${pr.url})${hasConflicts ? '\n- **⚠️ Status**: Cherry-pick conflicts detected - manual resolution required' : ''}
|
||||
|
||||
**📝 Next Steps:**
|
||||
1. ${hasConflicts ? '⚠️ **Resolve conflicts** in the hotfix PR first' : 'Review and approve the hotfix PR'}: [#${pr.number}](${pr.url})${hasConflicts ? '\n2. **Test your changes** after resolving conflicts' : ''}
|
||||
${hasConflicts ? '3' : '2'}. Once merged, the patch release will automatically trigger
|
||||
${hasConflicts ? '4' : '3'}. You'll receive updates here when the release completes
|
||||
|
||||
**🔗 Track Progress:**
|
||||
- [View hotfix PR #${pr.number}](${pr.url})
|
||||
- [This patch creation workflow run](https://github.com/${repository}/actions/runs/${runId})`;
|
||||
} else {
|
||||
// Fallback if PR not found yet
|
||||
commentBody = `🚀 **[Step 2/4] Patch PR Created!**
|
||||
|
||||
The patch release PR for this change has been created on branch [\`${branch}\`](https://github.com/${repository}/tree/${branch}).
|
||||
|
||||
**📝 Next Steps:**
|
||||
1. Review and approve the patch PR
|
||||
2. Once merged, the patch release will automatically trigger
|
||||
|
||||
**🔗 Links:**
|
||||
- [View all patch PRs](https://github.com/${repository}/pulls?q=is%3Apr+is%3Aopen+label%3Apatch)
|
||||
- [This patch creation workflow run](https://github.com/${repository}/actions/runs/${runId})`;
|
||||
}
|
||||
} catch (error) {
|
||||
console.log('Error finding PR for branch:', error.message);
|
||||
// Fallback
|
||||
commentBody = `🚀 **[Step 2/4] Patch PR Created!**
|
||||
|
||||
The patch release PR for this change has been created.
|
||||
|
||||
**🔗 Links:**
|
||||
- [View all patch PRs](https://github.com/${repository}/pulls?q=is%3Apr+is%3Aopen+label%3Apatch)
|
||||
- [This patch creation workflow run](https://github.com/${repository}/actions/runs/${runId})`;
|
||||
}
|
||||
}
|
||||
}
|
||||
} else {
|
||||
// Failure
|
||||
commentBody = `❌ **[Step 2/4] Patch creation failed!**
|
||||
|
||||
There was an error creating the patch release.
|
||||
|
||||
**🔍 Troubleshooting:**
|
||||
- Check the workflow logs for detailed error information
|
||||
- Verify the commit SHA is valid and accessible
|
||||
- Ensure you have permissions to create branches and PRs
|
||||
|
||||
**🔗 Links:**
|
||||
- [View workflow run](https://github.com/${repository}/actions/runs/${runId})`;
|
||||
}
|
||||
|
||||
if (!commentBody) {
|
||||
commentBody = `❌ **[Step 2/4] Patch creation failed!**
|
||||
|
||||
No output was generated during patch creation.
|
||||
|
||||
**🔗 Links:**
|
||||
- [View workflow run](https://github.com/${repository}/actions/runs/${runId})`;
|
||||
}
|
||||
|
||||
if (testMode) {
|
||||
console.log('\n💬 Would post comment:');
|
||||
console.log('----------------------------------------');
|
||||
console.log(commentBody);
|
||||
console.log('----------------------------------------');
|
||||
console.log('\n✅ Comment generation working correctly!');
|
||||
} else if (hasGitHubCli) {
|
||||
const { spawnSync } = await import('node:child_process');
|
||||
const { writeFileSync, unlinkSync } = await import('node:fs');
|
||||
const { join } = await import('node:path');
|
||||
|
||||
// Write comment to temporary file to avoid shell escaping issues
|
||||
const tmpFile = join(process.cwd(), `comment-${Date.now()}.md`);
|
||||
writeFileSync(tmpFile, commentBody);
|
||||
|
||||
try {
|
||||
const result = spawnSync(
|
||||
'gh',
|
||||
['pr', 'comment', originalPr.toString(), '--body-file', tmpFile],
|
||||
{
|
||||
stdio: 'inherit',
|
||||
},
|
||||
);
|
||||
|
||||
if (result.error) {
|
||||
throw result.error;
|
||||
}
|
||||
if (result.status !== 0) {
|
||||
throw new Error(`gh pr comment failed with status ${result.status}`);
|
||||
}
|
||||
|
||||
console.log(`Successfully commented on PR ${originalPr}`);
|
||||
} finally {
|
||||
// Clean up temp file
|
||||
try {
|
||||
unlinkSync(tmpFile);
|
||||
} catch {
|
||||
// Ignore cleanup errors
|
||||
}
|
||||
}
|
||||
} else {
|
||||
console.log('No GitHub CLI available');
|
||||
}
|
||||
}
|
||||
|
||||
main().catch((error) => {
|
||||
console.error('Error commenting on PR:', error);
|
||||
process.exit(1);
|
||||
});
|
||||
@@ -0,0 +1,334 @@
|
||||
#!/usr/bin/env node
|
||||
|
||||
/**
|
||||
* @license
|
||||
* Copyright 2025 Google LLC
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
/**
|
||||
* Script for patch release trigger workflow (step 2).
|
||||
* Handles channel detection, workflow dispatch, and user feedback.
|
||||
*/
|
||||
|
||||
import yargs from 'yargs';
|
||||
import { hideBin } from 'yargs/helpers';
|
||||
|
||||
/**
|
||||
* Extract base version, original pr, and originalPr info from hotfix branch name.
|
||||
* Formats:
|
||||
* - New NEW: hotfix/v0.5.3/v0.5.4/preview/cherry-pick-abc/pr-1234 -> v0.5.4, preview, 1234
|
||||
* - New format: hotfix/v0.5.3/preview/cherry-pick-abc -> v0.5.3 and preview
|
||||
* - Old format: hotfix/v0.5.3/cherry-pick-abc -> v0.5.3 and stable (default)
|
||||
* We check the formats from newest to oldest. If the channel found is invalid,
|
||||
* an error is thrown.
|
||||
*/
|
||||
function getBranchInfo({ branchName, context }) {
|
||||
const parts = branchName.split('/');
|
||||
const version = parts[1];
|
||||
let prNum;
|
||||
let channel = 'stable'; // default for old format
|
||||
if (parts.length >= 6 && (parts[3] === 'stable' || parts[3] === 'preview')) {
|
||||
channel = parts[3];
|
||||
const prMatch = parts[5].match(/pr-(\d+)/);
|
||||
prNum = prMatch[1];
|
||||
} else if (
|
||||
parts.length >= 4 &&
|
||||
(parts[2] === 'stable' || parts[2] === 'preview')
|
||||
) {
|
||||
// New format with explicit channel
|
||||
channel = parts[2];
|
||||
} else if (context.eventName === 'workflow_dispatch') {
|
||||
// Manual dispatch, infer from version name
|
||||
channel = version.includes('preview') ? 'preview' : 'stable';
|
||||
}
|
||||
|
||||
// Validate channel
|
||||
if (channel !== 'stable' && channel !== 'preview') {
|
||||
throw new Error(
|
||||
`Invalid channel: ${channel}. Must be 'stable' or 'preview'.`,
|
||||
);
|
||||
}
|
||||
|
||||
return { channel, prNum, version };
|
||||
}
|
||||
|
||||
async function main() {
|
||||
const argv = await yargs(hideBin(process.argv))
|
||||
.option('head-ref', {
|
||||
description:
|
||||
'The hotfix branch name (e.g., hotfix/v0.5.3/preview/cherry-pick-abc1234)',
|
||||
type: 'string',
|
||||
demandOption: !process.env.GITHUB_ACTIONS,
|
||||
})
|
||||
.option('pr-body', {
|
||||
description: 'The PR body content',
|
||||
type: 'string',
|
||||
default: '',
|
||||
})
|
||||
.option('dry-run', {
|
||||
description: 'Run in test mode without actually triggering workflows',
|
||||
type: 'boolean',
|
||||
default: false,
|
||||
})
|
||||
.option('test', {
|
||||
description: 'Test mode - validate logic without GitHub API calls',
|
||||
type: 'boolean',
|
||||
default: false,
|
||||
})
|
||||
.option('force-skip-tests', {
|
||||
description: 'Skip the "Run Tests" step in testing',
|
||||
type: 'boolean',
|
||||
default: false,
|
||||
})
|
||||
.option('environment', {
|
||||
choices: ['prod', 'dev'],
|
||||
type: 'string',
|
||||
default: process.env.ENVIRONMENT || 'prod',
|
||||
})
|
||||
.example(
|
||||
'$0 --head-ref "hotfix/v0.5.3/preview/cherry-pick-abc1234" --test',
|
||||
'Test channel detection logic',
|
||||
)
|
||||
.example(
|
||||
'$0 --head-ref "hotfix/v0.5.3/stable/cherry-pick-abc1234" --dry-run',
|
||||
'Test with GitHub API in dry-run mode',
|
||||
)
|
||||
.help()
|
||||
.alias('help', 'h').argv;
|
||||
|
||||
const testMode = argv.test || process.env.TEST_MODE === 'true';
|
||||
|
||||
const context = {
|
||||
eventName: process.env.GITHUB_EVENT_NAME || 'pull_request',
|
||||
repo: {
|
||||
owner: process.env.GITHUB_REPOSITORY_OWNER || 'google-gemini',
|
||||
repo: process.env.GITHUB_REPOSITORY_NAME || 'gemini-cli',
|
||||
},
|
||||
payload: JSON.parse(process.env.GITHUB_EVENT_PAYLOAD || '{}'),
|
||||
};
|
||||
|
||||
// Get inputs from CLI args or environment
|
||||
const headRef = argv.headRef || process.env.HEAD_REF;
|
||||
const environment = argv.environment;
|
||||
const body = argv.prBody || process.env.PR_BODY || '';
|
||||
const isDryRun = argv.dryRun || body.includes('[DRY RUN]');
|
||||
const forceSkipTests =
|
||||
argv.forceSkipTests || process.env.FORCE_SKIP_TESTS === 'true';
|
||||
const runId = process.env.GITHUB_RUN_ID || '0';
|
||||
|
||||
if (!headRef) {
|
||||
throw new Error(
|
||||
'head-ref is required. Use --head-ref or set HEAD_REF environment variable.',
|
||||
);
|
||||
}
|
||||
|
||||
console.log(`Processing patch trigger for branch: ${headRef}`);
|
||||
|
||||
const { prNum, version, channel } = getBranchInfo({
|
||||
branchName: headRef,
|
||||
context,
|
||||
});
|
||||
|
||||
let originalPr = prNum;
|
||||
console.log(`Found originalPr: ${prNum} from hotfix branch`);
|
||||
|
||||
// Fallback to using PR search (inconsistent) if no pr found in branch name.
|
||||
if (!testMode && !originalPr) {
|
||||
try {
|
||||
console.log('Looking for original PR using search...');
|
||||
const { execFileSync } = await import('node:child_process');
|
||||
|
||||
// Split search string into searchArgs to prevent triple escaping on the quoted filters
|
||||
const searchArgs =
|
||||
`repo:${context.repo.owner}/${context.repo.repo} is:pr in:comments "${headRef}"`.split(
|
||||
' ',
|
||||
);
|
||||
console.log('Search args:', searchArgs);
|
||||
// Use gh CLI to search for PRs with comments referencing the hotfix branch
|
||||
const result = execFileSync(
|
||||
'gh',
|
||||
[
|
||||
'search',
|
||||
'prs',
|
||||
'--json',
|
||||
'number,title',
|
||||
'--limit',
|
||||
'1',
|
||||
...searchArgs,
|
||||
'Patch PR Created',
|
||||
],
|
||||
{
|
||||
encoding: 'utf8',
|
||||
env: { ...process.env, GH_TOKEN: process.env.GITHUB_TOKEN },
|
||||
},
|
||||
);
|
||||
|
||||
const searchResults = JSON.parse(result);
|
||||
if (searchResults && searchResults.length > 0) {
|
||||
originalPr = searchResults[0].number;
|
||||
console.log(`Found original PR: #${originalPr}`);
|
||||
} else {
|
||||
console.log('Could not find a matching original PR via search.');
|
||||
}
|
||||
} catch (e) {
|
||||
console.log('Could not determine original PR:', e.message);
|
||||
}
|
||||
}
|
||||
if (!originalPr && testMode) {
|
||||
console.log('Skipping original PR lookup (test mode)');
|
||||
originalPr = 8655; // Mock for testing
|
||||
}
|
||||
|
||||
if (!originalPr) {
|
||||
throw new Error(
|
||||
'Could not find the original PR for this patch. Cannot proceed with release.',
|
||||
);
|
||||
}
|
||||
|
||||
const releaseRef = `release/${version}-pr-${originalPr}`;
|
||||
const workflowId =
|
||||
context.eventName === 'pull_request'
|
||||
? 'release-patch-3-release.yml'
|
||||
: process.env.WORKFLOW_ID || 'release-patch-3-release.yml';
|
||||
|
||||
console.log(`Detected channel: ${channel}, version: ${version}`);
|
||||
console.log(`Release ref: ${releaseRef}`);
|
||||
console.log(`Workflow ID: ${workflowId}`);
|
||||
console.log(`Dry run: ${isDryRun}`);
|
||||
|
||||
if (testMode) {
|
||||
console.log('\n🧪 TEST MODE - No API calls will be made');
|
||||
console.log('\n📋 Parsed Results:');
|
||||
console.log(` - Branch: ${headRef}`);
|
||||
console.log(
|
||||
` - Channel: ${channel} → npm tag: ${channel === 'stable' ? 'latest' : 'preview'}`,
|
||||
);
|
||||
console.log(` - Version: ${version}`);
|
||||
console.log(` - Release ref: ${releaseRef}`);
|
||||
console.log(` - Workflow: ${workflowId}`);
|
||||
console.log(` - Dry run: ${isDryRun}`);
|
||||
console.log('\n✅ Channel detection logic working correctly!');
|
||||
return;
|
||||
}
|
||||
|
||||
// Trigger the release workflow
|
||||
console.log(`Triggering release workflow: ${workflowId}`);
|
||||
if (!testMode) {
|
||||
try {
|
||||
const { execFileSync } = await import('node:child_process');
|
||||
|
||||
const args = [
|
||||
'workflow',
|
||||
'run',
|
||||
workflowId,
|
||||
'--ref',
|
||||
'main',
|
||||
'--field',
|
||||
`type=${channel}`,
|
||||
'--field',
|
||||
`dry_run=${isDryRun.toString()}`,
|
||||
'--field',
|
||||
`force_skip_tests=${forceSkipTests.toString()}`,
|
||||
'--field',
|
||||
`release_ref=${releaseRef}`,
|
||||
'--field',
|
||||
`environment=${environment}`,
|
||||
'--field',
|
||||
originalPr ? `original_pr=${originalPr.toString()}` : 'original_pr=',
|
||||
];
|
||||
|
||||
console.log(`Running command: gh ${args.join(' ')}`);
|
||||
|
||||
execFileSync('gh', args, {
|
||||
stdio: 'inherit',
|
||||
env: { ...process.env, GH_TOKEN: process.env.GITHUB_TOKEN },
|
||||
});
|
||||
|
||||
console.log('✅ Workflow dispatch completed successfully');
|
||||
} catch (e) {
|
||||
console.error('❌ Failed to dispatch workflow:', e.message);
|
||||
throw e;
|
||||
}
|
||||
} else {
|
||||
console.log('✅ Would trigger workflow with inputs:', {
|
||||
type: channel,
|
||||
dry_run: isDryRun.toString(),
|
||||
force_skip_tests: forceSkipTests.toString(),
|
||||
release_ref: releaseRef,
|
||||
original_pr: originalPr ? originalPr.toString() : '',
|
||||
});
|
||||
}
|
||||
|
||||
// Comment back to original PR if we found it
|
||||
if (originalPr) {
|
||||
console.log(`Commenting on original PR ${originalPr}...`);
|
||||
const npmTag = channel === 'stable' ? 'latest' : 'preview';
|
||||
|
||||
const commentBody = `🚀 **[Step 3/4] Patch Release ${environment === 'prod' ? 'Waiting for Approval' : 'Triggered'}!**
|
||||
|
||||
**📋 Release Details:**
|
||||
- **Environment**: \`${environment}\`
|
||||
- **Channel**: \`${channel}\` → publishing to npm tag \`${npmTag}\`
|
||||
- **Version**: \`${version}\`
|
||||
- **Hotfix PR**: Merged ✅
|
||||
- **Release Branch**: [\`${releaseRef}\`](https://github.com/${context.repo.owner}/${context.repo.repo}/tree/${releaseRef})
|
||||
|
||||
**⏳ Status:** The patch release has been triggered${environment === 'prod' ? ' and is waiting for deployment approval. Please visit the specific workflow run link below and approve the deployment' : ''}. You'll receive another update when it completes.
|
||||
|
||||
**🔗 Track Progress:**
|
||||
- [View release workflow history](https://github.com/${context.repo.owner}/${context.repo.repo}/actions/workflows/${workflowId})
|
||||
- [This trigger workflow run](https://github.com/${context.repo.owner}/${context.repo.repo}/actions/runs/${runId})`;
|
||||
|
||||
if (!testMode) {
|
||||
let tempDir;
|
||||
try {
|
||||
const { execFileSync } = await import('node:child_process');
|
||||
const { writeFileSync, mkdtempSync } = await import('node:fs');
|
||||
const { join } = await import('node:path');
|
||||
const { tmpdir } = await import('node:os');
|
||||
|
||||
// Create secure temporary directory and file
|
||||
tempDir = mkdtempSync(join(tmpdir(), 'patch-trigger-'));
|
||||
const tempFile = join(tempDir, 'comment.md');
|
||||
writeFileSync(tempFile, commentBody);
|
||||
|
||||
execFileSync(
|
||||
'gh',
|
||||
['pr', 'comment', originalPr.toString(), '--body-file', tempFile],
|
||||
{
|
||||
stdio: 'inherit',
|
||||
env: { ...process.env, GH_TOKEN: process.env.GITHUB_TOKEN },
|
||||
},
|
||||
);
|
||||
|
||||
console.log('✅ Comment posted successfully');
|
||||
} catch (e) {
|
||||
console.error('❌ Failed to post comment:', e.message);
|
||||
// Don't throw here since the main workflow dispatch succeeded
|
||||
} finally {
|
||||
// Clean up temp directory and all its contents
|
||||
if (tempDir) {
|
||||
try {
|
||||
const { rmSync } = await import('node:fs');
|
||||
rmSync(tempDir, { recursive: true, force: true });
|
||||
} catch (cleanupError) {
|
||||
console.warn(
|
||||
'⚠️ Failed to clean up temp directory:',
|
||||
cleanupError.message,
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
} else {
|
||||
console.log('✅ Would post comment:', commentBody);
|
||||
}
|
||||
}
|
||||
|
||||
console.log('Patch trigger completed successfully!');
|
||||
}
|
||||
|
||||
main().catch((error) => {
|
||||
console.error('Error in patch trigger:', error);
|
||||
process.exit(1);
|
||||
});
|
||||
Executable
+137
@@ -0,0 +1,137 @@
|
||||
#!/bin/bash
|
||||
# scripts/review.sh
|
||||
#
|
||||
# Usage: ./scripts/review.sh <pr#> [model]
|
||||
|
||||
set -e
|
||||
|
||||
if [[ -z "${1}" ]]; then
|
||||
echo "Usage: ${0} <pr#> [model]"
|
||||
exit 1
|
||||
fi
|
||||
pr="${1}"
|
||||
model="${2:-gemini-3.1-pro-preview}"
|
||||
REPO="google-gemini/gemini-cli"
|
||||
REVIEW_DIR="${HOME}/git/review/gemini-cli"
|
||||
|
||||
if [[ ! -d "${REVIEW_DIR}" ]]; then
|
||||
echo "ERROR: Directory ${REVIEW_DIR} does not exist."
|
||||
echo ""
|
||||
echo "Please create a new gemini-cli clone at that directory to use for reviews."
|
||||
echo "Instructions:"
|
||||
echo " mkdir -p ~/git/review"
|
||||
echo " cd ~/git/review"
|
||||
echo " git clone https://github.com/google-gemini/gemini-cli.git"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# 1. Check if the PR exists before doing anything else
|
||||
echo "review: Validating PR ${pr} on ${REPO}..."
|
||||
if ! gh pr view "${pr}" -R "${REPO}" > /dev/null 2>&1; then
|
||||
echo "ERROR: Could not find PR #${pr} in ${REPO}."
|
||||
echo "Are you sure ${pr} is a Pull Request number and not an Issue number?"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
echo "review: Opening PR ${pr} in browser..."
|
||||
uname_out="$(uname || true)"
|
||||
if [[ "${uname_out}" == "Darwin" ]]; then
|
||||
open "https://github.com/${REPO}/pull/${pr}" || true
|
||||
else
|
||||
xdg-open "https://github.com/${REPO}/pull/${pr}" || true
|
||||
fi
|
||||
|
||||
echo "review: Changing directory to ${REVIEW_DIR}"
|
||||
cd "${REVIEW_DIR}" || exit 1
|
||||
|
||||
# 2. Fetch latest main to ensure we have a clean starting point
|
||||
echo "review: Fetching latest from origin..."
|
||||
git fetch origin main
|
||||
|
||||
# 3. Handle worktree creation
|
||||
WORKTREE_PATH="pr_${pr}"
|
||||
if [[ -d "${WORKTREE_PATH}" ]]; then
|
||||
echo "review: Worktree directory ${WORKTREE_PATH} already exists."
|
||||
# Check if it's actually a registered worktree
|
||||
# shellcheck disable=SC2312
|
||||
if git worktree list | grep -q "${WORKTREE_PATH}"; then
|
||||
echo "review: Reusing existing worktree..."
|
||||
else
|
||||
echo "review: Directory exists but is not a worktree. Cleaning up..."
|
||||
rm -rf "${WORKTREE_PATH}"
|
||||
fi
|
||||
fi
|
||||
|
||||
if [[ ! -d "${WORKTREE_PATH}" ]]; then
|
||||
echo "review: Adding new worktree at ${WORKTREE_PATH}..."
|
||||
# Create a detached worktree from origin/main
|
||||
git worktree add --detach "${WORKTREE_PATH}" origin/main
|
||||
fi
|
||||
|
||||
echo "review: Changing directory to ${WORKTREE_PATH}"
|
||||
cd "${WORKTREE_PATH}" || exit 1
|
||||
|
||||
# 4. Checkout the PR
|
||||
echo "review: Cleaning worktree and checking out PR ${pr}..."
|
||||
git reset --hard
|
||||
git clean -fd
|
||||
gh pr checkout "${pr}" --branch "review-${pr}" -f -R "${REPO}"
|
||||
|
||||
# 5. Clean and Build
|
||||
echo "review: Clearing possibly stale node_modules..."
|
||||
rm -rf node_modules
|
||||
rm -rf packages/core/dist/
|
||||
rm -rf packages/cli/node_modules/
|
||||
rm -rf packages/core/node_modules/
|
||||
|
||||
echo "review: Installing npm dependencies..."
|
||||
npm install
|
||||
|
||||
echo "--- build ---"
|
||||
temp_dir_base="${TMPDIR:-/tmp}"
|
||||
build_log_file="$(mktemp "${temp_dir_base}/npm_build_log.XXXXXX" || true)"
|
||||
if [[ -z "${build_log_file}" || ! -f "${build_log_file}" ]]; then
|
||||
echo "Attempting to create temporary file in current directory as a fallback." >&2
|
||||
build_log_file="$(mktemp "./npm_build_log_fallback.XXXXXX" || true)"
|
||||
if [[ -z "${build_log_file}" || ! -f "${build_log_file}" ]]; then
|
||||
echo "ERROR: Critical - Failed to create any temporary build log file. Aborting." >&2
|
||||
exit 1
|
||||
fi
|
||||
fi
|
||||
|
||||
build_status=0
|
||||
build_command_to_run="FORCE_COLOR=1 CLICOLOR_FORCE=1 npm run build"
|
||||
|
||||
echo "Running build. Output (with colors) will be shown below and saved to: ${build_log_file}"
|
||||
echo "Build command: ${build_command_to_run}"
|
||||
|
||||
if [[ "${uname_out}" == "Darwin" ]]; then
|
||||
script -q "${build_log_file}" /bin/sh -c "${build_command_to_run}" || build_status=$?
|
||||
else
|
||||
if script -q -e -c "${build_command_to_run}" "${build_log_file}"; then
|
||||
build_status=0
|
||||
else
|
||||
build_status=$?
|
||||
fi
|
||||
fi
|
||||
|
||||
if [[ "${build_status}" -ne 0 ]]; then
|
||||
echo "ERROR: npm build failed with exit status ${build_status}." >&2
|
||||
echo "Review output above. Full log (with color codes) was in ${build_log_file}." >&2
|
||||
exit 1
|
||||
else
|
||||
# shellcheck disable=SC2312
|
||||
if grep -q -i -E "\berror\b|\bfailed\b|ERR!|FATAL|critical" "${build_log_file}"; then
|
||||
echo "ERROR: npm build completed with exit status 0, but suspicious error patterns were found in the build output." >&2
|
||||
echo "Review output above. Full log (with color codes) was in ${build_log_file}." >&2
|
||||
exit 1
|
||||
fi
|
||||
echo "npm build completed successfully (exit status 0, no critical error patterns found in log)."
|
||||
rm -f "${build_log_file}"
|
||||
fi
|
||||
|
||||
echo "-- running ---"
|
||||
if ! npm start -- -m "${model}" -i="/review-frontend ${pr}"; then
|
||||
echo "ERROR: npm start failed. Please check its output for details." >&2
|
||||
exit 1
|
||||
fi
|
||||
@@ -0,0 +1,107 @@
|
||||
/**
|
||||
* @license
|
||||
* Copyright 2026 Google LLC
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
/**
|
||||
* @fileoverview Orchestrates the PR evaluation process across multiple models.
|
||||
*
|
||||
* This script loops through a provided list of models, identifies trustworthy
|
||||
* tests for each, executes the frugal regression check, and collects results
|
||||
* into a single unified report. It exits with code 1 if any confirmed
|
||||
* regressions are detected.
|
||||
*/
|
||||
|
||||
import { execSync } from 'node:child_process';
|
||||
import fs from 'node:fs';
|
||||
|
||||
/**
|
||||
* Main execution logic.
|
||||
*/
|
||||
async function main() {
|
||||
const modelList = process.env.MODEL_LIST || 'gemini-3-flash-preview';
|
||||
const models = modelList.split(',').map((m) => m.trim());
|
||||
|
||||
let combinedReport = '';
|
||||
let hasRegression = false;
|
||||
|
||||
console.log(
|
||||
`🚀 Starting evaluation orchestration for models: ${models.join(', ')}`,
|
||||
);
|
||||
|
||||
for (const model of models) {
|
||||
console.log(`\n--- Processing Model: ${model} ---`);
|
||||
|
||||
try {
|
||||
// 1. Identify Trustworthy Evals
|
||||
console.log(`🔍 Identifying trustworthy tests for ${model}...`);
|
||||
const output = execSync(
|
||||
`node scripts/get_trustworthy_evals.js "${model}"`,
|
||||
{
|
||||
encoding: 'utf-8',
|
||||
stdio: ['inherit', 'pipe', 'inherit'], // Capture stdout but pass stdin/stderr
|
||||
},
|
||||
).trim();
|
||||
|
||||
if (!output) {
|
||||
console.log(`ℹ️ No trustworthy tests found for ${model}. Skipping.`);
|
||||
continue;
|
||||
}
|
||||
|
||||
// 2. Run Frugal Regression Check
|
||||
console.log(`🧪 Running regression check for ${model}...`);
|
||||
execSync(`node scripts/run_regression_check.js "${model}" "${output}"`, {
|
||||
stdio: 'inherit',
|
||||
});
|
||||
|
||||
// 3. Generate Report
|
||||
console.log(`📊 Generating report for ${model}...`);
|
||||
const report = execSync(`node scripts/compare_evals.js "${model}"`, {
|
||||
encoding: 'utf-8',
|
||||
stdio: ['inherit', 'pipe', 'inherit'],
|
||||
}).trim();
|
||||
|
||||
if (report) {
|
||||
if (combinedReport) {
|
||||
combinedReport += '\n\n---\n\n';
|
||||
}
|
||||
combinedReport += report;
|
||||
|
||||
// 4. Check for Regressions
|
||||
// If the report contains the "Action Required" marker, it means a confirmed regression was found.
|
||||
if (report.includes('Action Required')) {
|
||||
hasRegression = true;
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
console.error(`❌ Error processing model ${model}:`, error.message);
|
||||
// We flag a failure if any model encountered a critical error
|
||||
hasRegression = true;
|
||||
}
|
||||
}
|
||||
|
||||
// Always save the combined report to a file so the workflow can capture it cleanly
|
||||
if (combinedReport) {
|
||||
fs.writeFileSync('eval_regression_report.md', combinedReport);
|
||||
console.log(
|
||||
'\n📊 Final Markdown report saved to eval_regression_report.md',
|
||||
);
|
||||
}
|
||||
|
||||
// Log status for CI visibility, but don't exit with error
|
||||
if (hasRegression) {
|
||||
console.error(
|
||||
'\n⚠️ Confirmed regressions detected across one or more models. See PR comment for details.',
|
||||
);
|
||||
} else {
|
||||
console.log('\n✅ All evaluations passed successfully (or were cleared).');
|
||||
}
|
||||
|
||||
process.exit(0);
|
||||
}
|
||||
|
||||
main().catch((err) => {
|
||||
console.error(err);
|
||||
process.exit(1);
|
||||
});
|
||||
@@ -0,0 +1,305 @@
|
||||
/**
|
||||
* @license
|
||||
* Copyright 2026 Google LLC
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
/**
|
||||
* @fileoverview Executes a high-signal regression check for behavioral evaluations.
|
||||
*
|
||||
* This script runs a targeted set of stable tests in an optimistic first pass.
|
||||
* If failures occur, it employs a "Best-of-4" retry logic to handle natural flakiness.
|
||||
* For confirmed failures (0/3), it performs Dynamic Baseline Verification by
|
||||
* checking the failure against the 'main' branch to distinguish between
|
||||
* model drift and PR-introduced regressions.
|
||||
*/
|
||||
|
||||
import { execSync } from 'node:child_process';
|
||||
import fs from 'node:fs';
|
||||
import path from 'node:path';
|
||||
import { quote } from 'shell-quote';
|
||||
import { escapeRegex } from './eval_utils.js';
|
||||
|
||||
/**
|
||||
* Runs a set of tests using Vitest and returns the results.
|
||||
*/
|
||||
function runTests(files, pattern, model) {
|
||||
const outputDir = path.resolve(
|
||||
process.cwd(),
|
||||
`evals/logs/pr-run-${Date.now()}`,
|
||||
);
|
||||
fs.mkdirSync(outputDir, { recursive: true });
|
||||
|
||||
const filesToRun = files || 'evals/';
|
||||
console.log(
|
||||
`🚀 Running tests in ${filesToRun} with pattern: ${pattern?.slice(0, 100)}...`,
|
||||
);
|
||||
|
||||
try {
|
||||
const cmd = `npx vitest run --config evals/vitest.config.ts ${filesToRun} -t "${pattern}" --reporter=json --reporter=default --outputFile="${path.join(outputDir, 'report.json')}"`;
|
||||
execSync(cmd, {
|
||||
stdio: 'inherit',
|
||||
env: { ...process.env, RUN_EVALS: '1', GEMINI_MODEL: model },
|
||||
});
|
||||
} catch {
|
||||
// Vitest returns a non-zero exit code when tests fail. This is expected.
|
||||
// We continue execution and handle the failures by parsing the JSON report.
|
||||
}
|
||||
|
||||
const reportPath = path.join(outputDir, 'report.json');
|
||||
return fs.existsSync(reportPath)
|
||||
? JSON.parse(fs.readFileSync(reportPath, 'utf-8'))
|
||||
: null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Helper to find a specific assertion by name across all test files.
|
||||
*/
|
||||
function findAssertion(report, testName) {
|
||||
if (!report?.testResults) return null;
|
||||
for (const fileResult of report.testResults) {
|
||||
const assertion = fileResult.assertionResults.find(
|
||||
(a) => a.title === testName,
|
||||
);
|
||||
if (assertion) return assertion;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Parses command line arguments to identify model, files, and test pattern.
|
||||
*/
|
||||
function parseArgs() {
|
||||
const modelArg = process.argv[2];
|
||||
const remainingArgs = process.argv.slice(3);
|
||||
const fullArgsString = remainingArgs.join(' ');
|
||||
const testPatternIndex = remainingArgs.indexOf('--test-pattern');
|
||||
|
||||
if (testPatternIndex !== -1) {
|
||||
return {
|
||||
model: modelArg,
|
||||
files: remainingArgs.slice(0, testPatternIndex).join(' '),
|
||||
pattern: remainingArgs.slice(testPatternIndex + 1).join(' '),
|
||||
};
|
||||
}
|
||||
|
||||
if (fullArgsString.includes('--test-pattern')) {
|
||||
const parts = fullArgsString.split('--test-pattern');
|
||||
return {
|
||||
model: modelArg,
|
||||
files: parts[0].trim(),
|
||||
pattern: parts[1].trim(),
|
||||
};
|
||||
}
|
||||
|
||||
// Fallback for manual mode: Pattern Model
|
||||
const manualPattern = process.argv[2];
|
||||
const manualModel = process.argv[3];
|
||||
if (!manualModel) {
|
||||
console.error('❌ Error: No target model specified.');
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
let manualFiles = 'evals/';
|
||||
try {
|
||||
const grepResult = execSync(
|
||||
`grep -l ${quote([manualPattern])} evals/*.eval.ts`,
|
||||
{ encoding: 'utf-8' },
|
||||
);
|
||||
manualFiles = grepResult.split('\n').filter(Boolean).join(' ');
|
||||
} catch {
|
||||
// Grep returns exit code 1 if no files match the pattern.
|
||||
// In this case, we fall back to scanning all files in the evals/ directory.
|
||||
}
|
||||
|
||||
return {
|
||||
model: manualModel,
|
||||
files: manualFiles,
|
||||
pattern: manualPattern,
|
||||
isManual: true,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Runs the targeted retry logic (Best-of-4) for a failing test.
|
||||
*/
|
||||
async function runRetries(testName, results, files, model) {
|
||||
console.log(`\nRe-evaluating: ${testName}`);
|
||||
|
||||
while (
|
||||
results[testName].passed < 2 &&
|
||||
results[testName].total - results[testName].passed < 3 &&
|
||||
results[testName].total < 4
|
||||
) {
|
||||
const attemptNum = results[testName].total + 1;
|
||||
console.log(` Running attempt ${attemptNum}...`);
|
||||
|
||||
const retry = runTests(files, escapeRegex(testName), model);
|
||||
const retryAssertion = findAssertion(retry, testName);
|
||||
|
||||
results[testName].total++;
|
||||
if (retryAssertion?.status === 'passed') {
|
||||
results[testName].passed++;
|
||||
console.log(
|
||||
` ✅ Attempt ${attemptNum} passed. Score: ${results[testName].passed}/${results[testName].total}`,
|
||||
);
|
||||
} else {
|
||||
console.log(
|
||||
` ❌ Attempt ${attemptNum} failed (${retryAssertion?.status || 'unknown'}). Score: ${results[testName].passed}/${results[testName].total}`,
|
||||
);
|
||||
}
|
||||
|
||||
if (results[testName].passed >= 2) {
|
||||
console.log(
|
||||
` ✅ Test cleared as Noisy Pass (${results[testName].passed}/${results[testName].total})`,
|
||||
);
|
||||
} else if (results[testName].total - results[testName].passed >= 3) {
|
||||
await verifyBaseline(testName, results, files, model);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Verifies a potential regression against the 'main' branch.
|
||||
*/
|
||||
async function verifyBaseline(testName, results, files, model) {
|
||||
console.log('\n--- Step 3: Dynamic Baseline Verification ---');
|
||||
console.log(
|
||||
`⚠️ Potential regression detected. Verifying baseline on 'main'...`,
|
||||
);
|
||||
|
||||
try {
|
||||
execSync('git stash push -m "eval-regression-check-stash"', {
|
||||
stdio: 'inherit',
|
||||
});
|
||||
const hasStash = execSync('git stash list')
|
||||
.toString()
|
||||
.includes('eval-regression-check-stash');
|
||||
execSync('git checkout main', { stdio: 'inherit' });
|
||||
|
||||
console.log(
|
||||
`\n--- Running Baseline Verification on 'main' (Best-of-3) ---`,
|
||||
);
|
||||
let baselinePasses = 0;
|
||||
let baselineTotal = 0;
|
||||
|
||||
while (baselinePasses === 0 && baselineTotal < 3) {
|
||||
baselineTotal++;
|
||||
console.log(` Baseline Attempt ${baselineTotal}...`);
|
||||
const baselineRun = runTests(files, escapeRegex(testName), model);
|
||||
if (findAssertion(baselineRun, testName)?.status === 'passed') {
|
||||
baselinePasses++;
|
||||
console.log(` ✅ Baseline Attempt ${baselineTotal} passed.`);
|
||||
} else {
|
||||
console.log(` ❌ Baseline Attempt ${baselineTotal} failed.`);
|
||||
}
|
||||
}
|
||||
|
||||
execSync('git checkout -', { stdio: 'inherit' });
|
||||
if (hasStash) execSync('git stash pop', { stdio: 'inherit' });
|
||||
|
||||
if (baselinePasses === 0) {
|
||||
console.log(
|
||||
` ℹ️ Test also fails on 'main'. Marking as PRE-EXISTING (Cleared).`,
|
||||
);
|
||||
results[testName].status = 'pre-existing';
|
||||
results[testName].passed = results[testName].total; // Clear for report
|
||||
} else {
|
||||
console.log(
|
||||
` ❌ Test passes on 'main' but fails in PR. Marking as CONFIRMED REGRESSION.`,
|
||||
);
|
||||
results[testName].status = 'regression';
|
||||
}
|
||||
} catch (error) {
|
||||
console.error(` ❌ Failed to verify baseline: ${error.message}`);
|
||||
|
||||
// Best-effort cleanup: try to return to the original branch.
|
||||
try {
|
||||
execSync('git checkout -', { stdio: 'ignore' });
|
||||
} catch {
|
||||
// Ignore checkout errors during cleanup to avoid hiding the original error.
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Processes initial results and orchestrates retries/baseline checks.
|
||||
*/
|
||||
async function processResults(firstPass, pattern, model, files) {
|
||||
if (!firstPass) return false;
|
||||
|
||||
const results = {};
|
||||
const failingTests = [];
|
||||
let totalProcessed = 0;
|
||||
|
||||
for (const fileResult of firstPass.testResults) {
|
||||
for (const assertion of fileResult.assertionResults) {
|
||||
if (assertion.status !== 'passed' && assertion.status !== 'failed') {
|
||||
continue;
|
||||
}
|
||||
|
||||
const name = assertion.title;
|
||||
results[name] = {
|
||||
passed: assertion.status === 'passed' ? 1 : 0,
|
||||
total: 1,
|
||||
file: fileResult.name,
|
||||
};
|
||||
if (assertion.status === 'failed') failingTests.push(name);
|
||||
totalProcessed++;
|
||||
}
|
||||
}
|
||||
|
||||
if (totalProcessed === 0) {
|
||||
console.error('❌ Error: No matching tests were found or executed.');
|
||||
return false;
|
||||
}
|
||||
|
||||
if (failingTests.length === 0) {
|
||||
console.log('✅ All trustworthy tests passed on the first try!');
|
||||
} else {
|
||||
console.log('\n--- Step 2: Best-of-4 Retries ---');
|
||||
console.log(
|
||||
`⚠️ ${failingTests.length} tests failed the optimistic run. Starting retries...`,
|
||||
);
|
||||
for (const testName of failingTests) {
|
||||
await runRetries(testName, results, files, model);
|
||||
}
|
||||
}
|
||||
|
||||
saveResults(results);
|
||||
return true;
|
||||
}
|
||||
|
||||
function saveResults(results) {
|
||||
const finalReport = { timestamp: new Date().toISOString(), results };
|
||||
fs.writeFileSync(
|
||||
'evals/logs/pr_final_report.json',
|
||||
JSON.stringify(finalReport, null, 2),
|
||||
);
|
||||
console.log('\nFinal report saved to evals/logs/pr_final_report.json');
|
||||
}
|
||||
|
||||
async function main() {
|
||||
const { model, files, pattern, isManual } = parseArgs();
|
||||
|
||||
if (isManual) {
|
||||
const firstPass = runTests(files, pattern, model);
|
||||
const success = await processResults(firstPass, pattern, model, files);
|
||||
process.exit(success ? 0 : 1);
|
||||
}
|
||||
|
||||
if (!pattern) {
|
||||
console.log('No trustworthy tests to run.');
|
||||
process.exit(0);
|
||||
}
|
||||
|
||||
console.log('\n--- Step 1: Optimistic Run (N=1) ---');
|
||||
const firstPass = runTests(files, pattern, model);
|
||||
const success = await processResults(firstPass, pattern, model, files);
|
||||
process.exit(success ? 0 : 1);
|
||||
}
|
||||
|
||||
main().catch((err) => {
|
||||
console.error(err);
|
||||
process.exit(1);
|
||||
});
|
||||
@@ -0,0 +1,129 @@
|
||||
/**
|
||||
* @license
|
||||
* Copyright 2025 Google LLC
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
//
|
||||
// Licensed under the Apache License, Version 2.0 (the "License");
|
||||
// you may not use this file except in compliance with the License.
|
||||
// You may obtain a copy of the License at
|
||||
//
|
||||
// http://www.apache.org/licenses/LICENSE-2.0
|
||||
//
|
||||
// Unless required by applicable law or agreed to in writing, software
|
||||
// distributed under the License is distributed on an "AS IS" BASIS,
|
||||
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
// See the License for the specific language governing permissions and
|
||||
// limitations under the License.
|
||||
|
||||
import { execSync } from 'node:child_process';
|
||||
import { existsSync, readFileSync } from 'node:fs';
|
||||
import { join, dirname } from 'node:path';
|
||||
import stripJsonComments from 'strip-json-comments';
|
||||
import os from 'node:os';
|
||||
import yargs from 'yargs';
|
||||
import { hideBin } from 'yargs/helpers';
|
||||
import dotenv from 'dotenv';
|
||||
import { GEMINI_DIR } from '@google/gemini-cli-core';
|
||||
|
||||
const argv = yargs(hideBin(process.argv)).option('q', {
|
||||
alias: 'quiet',
|
||||
type: 'boolean',
|
||||
default: false,
|
||||
}).argv;
|
||||
|
||||
const homedir = () => process.env['GEMINI_CLI_HOME'] || os.homedir();
|
||||
|
||||
let geminiSandbox = process.env.GEMINI_SANDBOX;
|
||||
|
||||
if (!geminiSandbox) {
|
||||
const userSettingsFile = join(homedir(), GEMINI_DIR, 'settings.json');
|
||||
if (existsSync(userSettingsFile)) {
|
||||
const settings = JSON.parse(
|
||||
stripJsonComments(readFileSync(userSettingsFile, 'utf-8')),
|
||||
);
|
||||
if (settings.sandbox) {
|
||||
geminiSandbox = settings.sandbox;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (!geminiSandbox) {
|
||||
let currentDir = process.cwd();
|
||||
while (true) {
|
||||
const geminiEnv = join(currentDir, GEMINI_DIR, '.env');
|
||||
const regularEnv = join(currentDir, '.env');
|
||||
if (existsSync(geminiEnv)) {
|
||||
dotenv.config({ path: geminiEnv, quiet: true });
|
||||
break;
|
||||
} else if (existsSync(regularEnv)) {
|
||||
dotenv.config({ path: regularEnv, quiet: true });
|
||||
break;
|
||||
}
|
||||
const parentDir = dirname(currentDir);
|
||||
if (parentDir === currentDir) {
|
||||
break;
|
||||
}
|
||||
currentDir = parentDir;
|
||||
}
|
||||
geminiSandbox = process.env.GEMINI_SANDBOX;
|
||||
}
|
||||
|
||||
geminiSandbox = (geminiSandbox || '').toLowerCase();
|
||||
|
||||
const commandExists = (cmd) => {
|
||||
const checkCommand = os.platform() === 'win32' ? 'where' : 'command -v';
|
||||
try {
|
||||
execSync(`${checkCommand} ${cmd}`, { stdio: 'ignore' });
|
||||
return true;
|
||||
} catch {
|
||||
if (os.platform() === 'win32') {
|
||||
try {
|
||||
execSync(`${checkCommand} ${cmd}.exe`, { stdio: 'ignore' });
|
||||
return true;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
return false;
|
||||
}
|
||||
};
|
||||
|
||||
let command = '';
|
||||
if (['1', 'true'].includes(geminiSandbox)) {
|
||||
if (commandExists('docker')) {
|
||||
command = 'docker';
|
||||
} else if (commandExists('podman')) {
|
||||
command = 'podman';
|
||||
} else {
|
||||
console.error(
|
||||
'ERROR: install docker or podman or specify command in GEMINI_SANDBOX',
|
||||
);
|
||||
process.exit(1);
|
||||
}
|
||||
} else if (geminiSandbox && !['0', 'false'].includes(geminiSandbox)) {
|
||||
if (commandExists(geminiSandbox)) {
|
||||
command = geminiSandbox;
|
||||
} else {
|
||||
console.error(
|
||||
`ERROR: missing sandbox command '${geminiSandbox}' (from GEMINI_SANDBOX)`,
|
||||
);
|
||||
process.exit(1);
|
||||
}
|
||||
} else {
|
||||
if (os.platform() === 'darwin' && process.env.SEATBELT_PROFILE !== 'none') {
|
||||
if (commandExists('sandbox-exec')) {
|
||||
command = 'sandbox-exec';
|
||||
} else {
|
||||
process.exit(1);
|
||||
}
|
||||
} else {
|
||||
process.exit(1);
|
||||
}
|
||||
}
|
||||
|
||||
if (!argv.q) {
|
||||
console.log(command);
|
||||
}
|
||||
process.exit(0);
|
||||
@@ -0,0 +1,226 @@
|
||||
#!/usr/bin/env node
|
||||
|
||||
/**
|
||||
* @license
|
||||
* Copyright 2026 Google LLC
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
/**
|
||||
* Seeds the auto-memory inbox with REALISTIC patches for manual end-to-end
|
||||
* testing of `/memory inbox`. Mirrors what one extraction-agent run would
|
||||
* produce in practice: a single canonical `extraction.patch` per kind,
|
||||
* containing multiple hunks (MEMORY.md update + sibling creation, etc.).
|
||||
*
|
||||
* Run AFTER `npm run build` from the project root:
|
||||
* node scripts/seed-test-inbox.js
|
||||
*
|
||||
* The script will:
|
||||
* 1. Initialize Storage for the current working directory.
|
||||
* 2. Compute <projectMemoryDir> = ~/.gemini/tmp/<projectId>/memory/.
|
||||
* 3. Seed `MEMORY.md` and TWO canonical inbox patches:
|
||||
* - .inbox/private/extraction.patch (multi-hunk: update MEMORY.md
|
||||
* + create verify-workflow.md + add MEMORY.md pointer to it)
|
||||
* - .inbox/global/extraction.patch (creates ~/.gemini/GEMINI.md)
|
||||
* 4. Print a verification checklist + the launch command.
|
||||
*
|
||||
* To clean up later, delete `<projectMemoryDir>/.inbox/` and the seeded
|
||||
* MEMORY.md / GEMINI.md files.
|
||||
*/
|
||||
|
||||
import * as fs from 'node:fs/promises';
|
||||
import * as path from 'node:path';
|
||||
import * as os from 'node:os';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
|
||||
const SCRIPT_DIR = path.dirname(fileURLToPath(import.meta.url));
|
||||
const REPO_ROOT = path.resolve(SCRIPT_DIR, '..');
|
||||
|
||||
const corePath = path.join(REPO_ROOT, 'packages/core/dist/src/index.js');
|
||||
try {
|
||||
await fs.access(corePath);
|
||||
} catch {
|
||||
console.error(
|
||||
`Cannot find built core at ${corePath}. Run \`npm run build\` first.`,
|
||||
);
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
const { Storage } = await import(corePath);
|
||||
|
||||
const cwd = process.cwd();
|
||||
const storage = new Storage(cwd);
|
||||
await storage.initialize();
|
||||
|
||||
const memoryDir = storage.getProjectMemoryTempDir();
|
||||
const inboxPrivate = path.join(memoryDir, '.inbox', 'private');
|
||||
const inboxGlobal = path.join(memoryDir, '.inbox', 'global');
|
||||
const homeDir = os.homedir();
|
||||
const globalGeminiMd = path.join(homeDir, '.gemini', 'GEMINI.md');
|
||||
|
||||
console.log(`\n🔧 Seeding inbox for cwd: ${cwd}`);
|
||||
console.log(` memoryDir = ${memoryDir}\n`);
|
||||
|
||||
await fs.mkdir(inboxPrivate, { recursive: true });
|
||||
await fs.mkdir(inboxGlobal, { recursive: true });
|
||||
|
||||
const seeded = [];
|
||||
async function seed(filePath, content, label) {
|
||||
await fs.mkdir(path.dirname(filePath), { recursive: true });
|
||||
await fs.writeFile(filePath, content, 'utf-8');
|
||||
seeded.push({ filePath, label });
|
||||
}
|
||||
|
||||
// --- 1. Pre-existing private MEMORY.md so the update hunk has something to modify ---
|
||||
const memoryMd = path.join(memoryDir, 'MEMORY.md');
|
||||
await seed(
|
||||
memoryMd,
|
||||
'# Project Memory\n\n- old fact about this project\n',
|
||||
'pre-existing active MEMORY.md',
|
||||
);
|
||||
|
||||
// --- 2. Canonical PRIVATE extraction.patch ---
|
||||
// One file, multi-hunk: update MEMORY.md AND create verify-workflow.md
|
||||
// AND add a pointer line for the sibling. This is what one extraction
|
||||
// agent run typically produces.
|
||||
const verifyWorkflowMd = path.join(memoryDir, 'verify-workflow.md');
|
||||
await fs.rm(verifyWorkflowMd, { force: true });
|
||||
await seed(
|
||||
path.join(inboxPrivate, 'extraction.patch'),
|
||||
[
|
||||
// Hunk 1: replace the existing fact and append a sibling pointer.
|
||||
`--- ${memoryMd}`,
|
||||
`+++ ${memoryMd}`,
|
||||
`@@ -1,3 +1,4 @@`,
|
||||
` # Project Memory`,
|
||||
` `,
|
||||
`-- old fact about this project`,
|
||||
`+- new fact extracted from session analysis`,
|
||||
`+- See ${verifyWorkflowMd} for the project's verification commands.`,
|
||||
// Hunk 2: create the verify-workflow.md sibling.
|
||||
`--- /dev/null`,
|
||||
`+++ ${verifyWorkflowMd}`,
|
||||
`@@ -0,0 +1,5 @@`,
|
||||
`+# Verify Workflow`,
|
||||
`+`,
|
||||
`+- Run \`npm run typecheck\` after editing any *.ts file.`,
|
||||
`+- Run \`npm run build --workspace @google/gemini-cli-core\` before testing CLI changes.`,
|
||||
`+- Inbox patches are guarded by /memory inbox.`,
|
||||
``,
|
||||
].join('\n'),
|
||||
'canonical PRIVATE extraction.patch (2 hunks: MEMORY.md update + sibling create)',
|
||||
);
|
||||
|
||||
// --- 3. Canonical GLOBAL extraction.patch ---
|
||||
// Creates ~/.gemini/GEMINI.md. Backs up any existing one first.
|
||||
let existingGlobalGemini = null;
|
||||
try {
|
||||
existingGlobalGemini = await fs.readFile(globalGeminiMd, 'utf-8');
|
||||
} catch {
|
||||
// Doesn't exist yet — fine.
|
||||
}
|
||||
if (existingGlobalGemini !== null) {
|
||||
const backupPath = `${globalGeminiMd}.seed-test-backup-${Date.now()}`;
|
||||
await fs.copyFile(globalGeminiMd, backupPath);
|
||||
console.log(
|
||||
` ℹ️ Backed up existing ${globalGeminiMd} → ${backupPath}\n` +
|
||||
` (restore manually after testing if you wish.)\n`,
|
||||
);
|
||||
await fs.rm(globalGeminiMd, { force: true });
|
||||
}
|
||||
await seed(
|
||||
path.join(inboxGlobal, 'extraction.patch'),
|
||||
[
|
||||
`--- /dev/null`,
|
||||
`+++ ${globalGeminiMd}`,
|
||||
`@@ -0,0 +1,3 @@`,
|
||||
`+# Global Personal Preferences`,
|
||||
`+`,
|
||||
`+- Prefer concise architecture summaries.`,
|
||||
``,
|
||||
].join('\n'),
|
||||
'canonical GLOBAL extraction.patch (creates ~/.gemini/GEMINI.md)',
|
||||
);
|
||||
|
||||
// --- Summary ---
|
||||
console.log('Seeded files:');
|
||||
for (const { filePath, label } of seeded) {
|
||||
console.log(` ✓ ${path.relative(cwd, filePath)}`);
|
||||
console.log(` ${label}\n`);
|
||||
}
|
||||
|
||||
console.log('━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━');
|
||||
console.log('NEXT STEPS');
|
||||
console.log('━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━');
|
||||
console.log(`
|
||||
1. Enable autoMemory in your settings (the inbox command requires it):
|
||||
|
||||
~/.gemini/settings.json should contain:
|
||||
{
|
||||
"experimental": { "autoMemory": true }
|
||||
}
|
||||
|
||||
Or run this to set it:
|
||||
node -e "const fs=require('fs'),p=require('os').homedir()+'/.gemini/settings.json';let s={};try{s=JSON.parse(fs.readFileSync(p,'utf-8'))}catch{}s.experimental=s.experimental||{};s.experimental.autoMemory=true;fs.mkdirSync(require('path').dirname(p),{recursive:true});fs.writeFileSync(p,JSON.stringify(s,null,2))"
|
||||
|
||||
2. Launch the just-built CLI from THIS REPO ONLY. Do NOT use any globally
|
||||
installed "gemini" binary — it will be a stale build that doesn't know
|
||||
about memory patches and will silently show only skills.
|
||||
|
||||
npm run start
|
||||
|
||||
(or, equivalently: node ${path.relative(cwd, REPO_ROOT)}/bundle/gemini.js)
|
||||
|
||||
Sanity check before launching:
|
||||
node ${path.relative(cwd, path.join(REPO_ROOT, 'scripts/check-inbox.js'))}
|
||||
should report 2 memory patches (Private memory + Global memory).
|
||||
|
||||
3. In the CLI, run:
|
||||
|
||||
/memory inbox
|
||||
|
||||
You should see exactly 2 entries in the "Memory Updates" group:
|
||||
- Private memory 2 hunks from 1 source patch
|
||||
- Global memory 1 hunk from 1 source patch
|
||||
|
||||
4. Test focus preservation: arrow-down to "Global memory" → Enter → Esc →
|
||||
cursor MUST still be on "Global memory" (not row 0).
|
||||
|
||||
5. Open "Private memory" preview. You'll see TWO target sections (no
|
||||
duplicates), since both hunks come from one source patch:
|
||||
|
||||
${memoryMd}
|
||||
- new fact extracted from session analysis
|
||||
- See ${verifyWorkflowMd} for the project's verification commands.
|
||||
|
||||
${verifyWorkflowMd} (new file)
|
||||
# Verify Workflow
|
||||
...
|
||||
|
||||
6. Apply each entry:
|
||||
|
||||
┌──────────────────┬──────────┬───────────────────────────────────────┐
|
||||
│ Item │ Action │ Expected outcome │
|
||||
├──────────────────┼──────────┼───────────────────────────────────────┤
|
||||
│ Private memory │ Apply │ "Applied all 1 private memory patch." │
|
||||
│ │ │ MEMORY.md updated; verify-workflow.md │
|
||||
│ │ │ created. │
|
||||
│ Global memory │ Apply │ "Applied all 1 global memory patch." │
|
||||
│ │ │ ~/.gemini/GEMINI.md created. │
|
||||
└──────────────────┴──────────┴───────────────────────────────────────┘
|
||||
|
||||
7. Verify final state on disk:
|
||||
|
||||
cat ${path.relative(cwd, memoryMd)} # should show new fact + pointer line
|
||||
cat ${path.relative(cwd, verifyWorkflowMd)} # should exist
|
||||
cat ${globalGeminiMd} # should show "Prefer concise..."
|
||||
ls ${path.relative(cwd, inboxPrivate)} # should be empty
|
||||
ls ${path.relative(cwd, inboxGlobal)} # should be empty
|
||||
|
||||
8. Cleanup:
|
||||
|
||||
rm -rf ${path.relative(cwd, path.join(memoryDir, '.inbox'))}
|
||||
rm -f ${path.relative(cwd, memoryMd)}
|
||||
rm -f ${path.relative(cwd, verifyWorkflowMd)}
|
||||
rm -f ${globalGeminiMd}
|
||||
`);
|
||||
Executable
+105
@@ -0,0 +1,105 @@
|
||||
#!/bin/bash
|
||||
# -----------------------------------------------------------------------------
|
||||
# Gemini API Replay Script
|
||||
# -----------------------------------------------------------------------------
|
||||
# Purpose:
|
||||
# This script is used to replay a Gemini API request using a raw JSON payload.
|
||||
# It is particularly useful for debugging the exact requests made by the
|
||||
# Gemini CLI.
|
||||
#
|
||||
# Prerequisites:
|
||||
# 1. Export your Gemini API key:
|
||||
# export GEMINI_API_KEY="your_api_key_here"
|
||||
#
|
||||
# 2. Generate a request payload from the Gemini CLI:
|
||||
# Inside the CLI, run the `/chat debug` command. This will save the most
|
||||
# recent API request to a file named `gcli-request-<timestamp>.json`.
|
||||
#
|
||||
# Usage:
|
||||
# ./scripts/send_gemini_request.sh --payload <path_to_json> --model <model_id> [--stream]
|
||||
#
|
||||
# Options:
|
||||
# --payload <file> Path to the JSON request payload.
|
||||
# --model <id> The Gemini model ID (e.g., gemini-3-flash-preview).
|
||||
# --stream (Optional) Use the streaming API endpoint. Defaults to non-streaming.
|
||||
#
|
||||
# Example:
|
||||
# ./scripts/send_gemini_request.sh --payload gcli-request.json --model gemini-3-flash-preview
|
||||
# -----------------------------------------------------------------------------
|
||||
|
||||
set -e -E
|
||||
|
||||
# Load environment variables from .env if it exists
|
||||
if [[ -f ".env" ]]; then
|
||||
echo "Loading environment variables from .env file..."
|
||||
set -a # Automatically export all variables
|
||||
# shellcheck source=/dev/null
|
||||
source .env
|
||||
set +a
|
||||
fi
|
||||
|
||||
# Function to print usage
|
||||
usage() {
|
||||
echo "Usage: $0 --payload <path_to_json_file> --model <model_id> [--stream]"
|
||||
echo "Ensure GEMINI_API_KEY environment variable is set."
|
||||
exit 1
|
||||
}
|
||||
|
||||
STREAM_MODE=false
|
||||
|
||||
# Parse command line arguments
|
||||
while [[ "$#" -gt 0 ]]; do
|
||||
case $1 in
|
||||
--payload) PAYLOAD_FILE="${2}"; shift ;;
|
||||
--model) MODEL_ID="${2}"; shift ;;
|
||||
--stream) STREAM_MODE=true ;;
|
||||
*) echo "Unknown parameter passed: ${1}"; usage ;;
|
||||
esac
|
||||
shift
|
||||
done
|
||||
|
||||
# Validate inputs
|
||||
if [[ -z "${PAYLOAD_FILE}" ]] || [[ -z "${MODEL_ID}" ]]; then
|
||||
echo "Error: Missing required arguments."
|
||||
usage
|
||||
fi
|
||||
|
||||
if [[ -z "${GEMINI_API_KEY}" ]]; then
|
||||
echo "Error: GEMINI_API_KEY environment variable is not set."
|
||||
exit 1
|
||||
fi
|
||||
|
||||
if [[ ! -f "${PAYLOAD_FILE}" ]]; then
|
||||
echo "Error: Payload file '${PAYLOAD_FILE}' does not exist."
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# API Endpoint definition
|
||||
if [[ "${STREAM_MODE}" = true ]]; then
|
||||
GENERATE_CONTENT_API="streamGenerateContent"
|
||||
echo "Mode: Streaming"
|
||||
else
|
||||
GENERATE_CONTENT_API="generateContent"
|
||||
echo "Mode: Non-streaming (Default)"
|
||||
fi
|
||||
|
||||
echo "Sending request to model: ${MODEL_ID}"
|
||||
echo "Using payload from: ${PAYLOAD_FILE}"
|
||||
echo "----------------------------------------"
|
||||
|
||||
# Make the cURL request. If non-streaming, pipe through jq for readability if available.
|
||||
if [[ "${STREAM_MODE}" = false ]] && command -v jq &> /dev/null; then
|
||||
# Invoke curl separately to avoid masking its return value
|
||||
output=$(curl -s -X POST \
|
||||
-H "Content-Type: application/json" \
|
||||
"https://generativelanguage.googleapis.com/v1beta/models/${MODEL_ID}:${GENERATE_CONTENT_API}?key=${GEMINI_API_KEY}" \
|
||||
-d "@${PAYLOAD_FILE}")
|
||||
echo "${output}" | jq .
|
||||
else
|
||||
curl -X POST \
|
||||
-H "Content-Type: application/json" \
|
||||
"https://generativelanguage.googleapis.com/v1beta/models/${MODEL_ID}:${GENERATE_CONTENT_API}?key=${GEMINI_API_KEY}" \
|
||||
-d "@${PAYLOAD_FILE}"
|
||||
fi
|
||||
|
||||
echo -e "\n----------------------------------------"
|
||||
@@ -0,0 +1,93 @@
|
||||
/**
|
||||
* @license
|
||||
* Copyright 2025 Google LLC
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
//
|
||||
// Licensed under the Apache License, Version 2.0 (the "License");
|
||||
// you may not use this file except in compliance with the License.
|
||||
// You may obtain a copy of the License at
|
||||
//
|
||||
// http://www.apache.org/licenses/LICENSE-2.0
|
||||
//
|
||||
// Unless required by applicable law_or_agreed to in writing, software
|
||||
// distributed under the License is distributed on an "AS IS" BASIS,
|
||||
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
// See the License for the specific language governing permissions and
|
||||
// limitations under the License.
|
||||
|
||||
import { spawn, execSync } from 'node:child_process';
|
||||
import { dirname, join } from 'node:path';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
import { readFileSync } from 'node:fs';
|
||||
|
||||
const __dirname = dirname(fileURLToPath(import.meta.url));
|
||||
const root = join(__dirname, '..');
|
||||
const pkg = JSON.parse(readFileSync(join(root, 'package.json'), 'utf-8'));
|
||||
|
||||
// check build status, write warnings to file for app to display if needed
|
||||
execSync('node ./scripts/check-build-status.js', {
|
||||
stdio: 'inherit',
|
||||
cwd: root,
|
||||
});
|
||||
|
||||
const nodeArgs = ['--no-warnings=DEP0040'];
|
||||
let sandboxCommand = undefined;
|
||||
try {
|
||||
sandboxCommand = execSync('node scripts/sandbox_command.js', {
|
||||
cwd: root,
|
||||
})
|
||||
.toString()
|
||||
.trim();
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
// if debugging is enabled and sandboxing is disabled, use --inspect-brk flag
|
||||
// note with sandboxing this flag is passed to the binary inside the sandbox
|
||||
// inside sandbox SANDBOX should be set and sandbox_command.js should fail
|
||||
const isInDebugMode = process.env.DEBUG === '1' || process.env.DEBUG === 'true';
|
||||
|
||||
if (isInDebugMode && !sandboxCommand) {
|
||||
if (process.env.SANDBOX) {
|
||||
const port = process.env.DEBUG_PORT || '9229';
|
||||
nodeArgs.push(`--inspect-brk=0.0.0.0:${port}`);
|
||||
} else {
|
||||
nodeArgs.push('--inspect-brk');
|
||||
}
|
||||
}
|
||||
|
||||
nodeArgs.push(join(root, 'packages', 'cli'));
|
||||
nodeArgs.push(...process.argv.slice(2));
|
||||
|
||||
const env = {
|
||||
...process.env,
|
||||
CLI_VERSION: pkg.version,
|
||||
DEV: 'true',
|
||||
};
|
||||
|
||||
const keepCiEnv =
|
||||
process.env.GEMINI_KEEP_CI_ENV === '1' ||
|
||||
process.env.GEMINI_KEEP_CI_ENV === 'true';
|
||||
if (!keepCiEnv) {
|
||||
const ciKeys = ['CI', 'CONTINUOUS_INTEGRATION', 'GITHUB_ACTIONS'].filter(
|
||||
(k) => k in env,
|
||||
);
|
||||
if (ciKeys.length > 0) {
|
||||
ciKeys.forEach((k) => delete env[k]);
|
||||
process.stderr.write(
|
||||
`[gemini] Removed CI env vars to keep interactive mode working in dev: ${ciKeys.join(', ')}. Set GEMINI_KEEP_CI_ENV=1 to disable.\n`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
if (isInDebugMode) {
|
||||
// If this is not set, the debugger will pause on the outer process rather
|
||||
// than the relaunched process making it harder to debug.
|
||||
env.GEMINI_CLI_NO_RELAUNCH = 'true';
|
||||
}
|
||||
const child = spawn('node', nodeArgs, { stdio: 'inherit', env });
|
||||
|
||||
child.on('close', (code) => {
|
||||
process.exit(code);
|
||||
});
|
||||
@@ -0,0 +1,251 @@
|
||||
/**
|
||||
* @license
|
||||
* Copyright 2026 Google LLC
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
import { execSync } from 'node:child_process';
|
||||
|
||||
const PROJECT_ID = 36;
|
||||
const ORG = 'google-gemini';
|
||||
const REPO = 'google-gemini/gemini-cli';
|
||||
const MAINTAINERS_REPO = 'google-gemini/maintainers-gemini-cli';
|
||||
|
||||
// Parent issues to recursively traverse
|
||||
const PARENT_ISSUES = [15374, 15456, 15324];
|
||||
|
||||
// Labels to Exclude
|
||||
const EXCLUDED_LABELS = [
|
||||
'help wanted',
|
||||
'status/need-triage',
|
||||
'status/need-info',
|
||||
'area/unknown',
|
||||
];
|
||||
|
||||
// Labels that force inclusion (override exclusions)
|
||||
const FORCE_INCLUDE_LABELS = ['🔒 maintainer only'];
|
||||
|
||||
function runCommand(command) {
|
||||
try {
|
||||
return execSync(command, {
|
||||
encoding: 'utf8',
|
||||
stdio: ['ignore', 'pipe', 'ignore'],
|
||||
maxBuffer: 10 * 1024 * 1024,
|
||||
});
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
function getIssues(repo) {
|
||||
console.log(`Fetching open issues from ${repo}...`);
|
||||
const json = runCommand(
|
||||
`gh issue list --repo ${repo} --state open --limit 3000 --json number,title,url,labels`,
|
||||
);
|
||||
if (!json) {
|
||||
return [];
|
||||
}
|
||||
return JSON.parse(json);
|
||||
}
|
||||
|
||||
function getIssueBody(repo, number) {
|
||||
const json = runCommand(
|
||||
`gh issue view ${number} --repo ${repo} --json body,title,url,number`,
|
||||
);
|
||||
if (!json) {
|
||||
return null;
|
||||
}
|
||||
return JSON.parse(json);
|
||||
}
|
||||
|
||||
function getProjectItems() {
|
||||
console.log(`Fetching items from Project ${PROJECT_ID}...`);
|
||||
const json = runCommand(
|
||||
`gh project item-list ${PROJECT_ID} --owner ${ORG} --format json --limit 3000`,
|
||||
);
|
||||
if (!json) {
|
||||
return [];
|
||||
}
|
||||
return JSON.parse(json).items;
|
||||
}
|
||||
|
||||
function shouldInclude(issue) {
|
||||
const labels = issue.labels.map((l) => l.name);
|
||||
|
||||
// Check Force Include first
|
||||
if (labels.some((l) => FORCE_INCLUDE_LABELS.includes(l))) {
|
||||
return true;
|
||||
}
|
||||
|
||||
// Check Exclude
|
||||
if (labels.some((l) => EXCLUDED_LABELS.includes(l))) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
// Recursive function to find children
|
||||
const visitedParents = new Set();
|
||||
async function findChildren(repo, number, depth = 0) {
|
||||
const key = `${repo}/${number}`;
|
||||
if (visitedParents.has(key) || depth > 3) {
|
||||
return []; // Avoid cycles and too deep
|
||||
}
|
||||
visitedParents.add(key);
|
||||
|
||||
process.stdout.write('.'); // progress indicator
|
||||
const issue = getIssueBody(repo, number);
|
||||
if (!issue) {
|
||||
return [];
|
||||
}
|
||||
|
||||
const children = [];
|
||||
const body = issue.body || '';
|
||||
|
||||
// Regex to find #1234 (local repo) and https://github.com/.../issues/1234 (cross repo)
|
||||
// 1. Local references: #1234
|
||||
const localMatches = [
|
||||
...body.matchAll(/(?<!issue\s)(?<!issues\/)(?<!pull\/)(?<!#)#(\d+)/g),
|
||||
];
|
||||
for (const match of localMatches) {
|
||||
children.push({ repo, number: parseInt(match[1]) });
|
||||
}
|
||||
|
||||
// 2. Full URL references
|
||||
const urlMatches = [
|
||||
...body.matchAll(/https:\/\/github\.com\/([^/]+\/[^/]+)\/issues\/(\d+)/g),
|
||||
];
|
||||
for (const match of urlMatches) {
|
||||
children.push({ repo: match[1], number: parseInt(match[2]) });
|
||||
}
|
||||
|
||||
// Recursively find children of these children
|
||||
const allDescendants = [];
|
||||
for (const child of children) {
|
||||
// Only recurse if it's one of our interesting repos
|
||||
if (child.repo !== REPO && child.repo !== MAINTAINERS_REPO) {
|
||||
continue;
|
||||
}
|
||||
|
||||
// Fetch details
|
||||
const childDetails = getIssueBody(child.repo, child.number);
|
||||
if (childDetails) {
|
||||
allDescendants.push({ ...childDetails, repo: child.repo });
|
||||
|
||||
// Recurse
|
||||
const grandChildren = await findChildren(
|
||||
child.repo,
|
||||
child.number,
|
||||
depth + 1,
|
||||
);
|
||||
allDescendants.push(...grandChildren);
|
||||
}
|
||||
}
|
||||
|
||||
return allDescendants;
|
||||
}
|
||||
|
||||
async function run() {
|
||||
const issues = getIssues(REPO);
|
||||
const currentItems = getProjectItems();
|
||||
|
||||
console.log(`
|
||||
Total Open Gemini Issues: ${issues.length}`);
|
||||
console.log(`Total Current Project Items: ${currentItems.length}`);
|
||||
|
||||
const currentUrlMap = new Set(currentItems.map((i) => i.content.url));
|
||||
const toAddMap = new Map();
|
||||
const allowedUrls = new Set(); // URLs that are safe to stay/be added
|
||||
|
||||
// 1. Label Logic
|
||||
for (const issue of issues) {
|
||||
if (shouldInclude(issue)) {
|
||||
allowedUrls.add(issue.url);
|
||||
if (!currentUrlMap.has(issue.url)) {
|
||||
toAddMap.set(issue.url, issue);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 2. Hierarchy Logic
|
||||
console.log('\n--- SCANNING HIERARCHY ---');
|
||||
console.log(`Fetching recursive children of: ${PARENT_ISSUES.join(', ')}`);
|
||||
for (const parentId of PARENT_ISSUES) {
|
||||
const descendants = await findChildren(REPO, parentId);
|
||||
for (const item of descendants) {
|
||||
if (item.repo === REPO || item.repo === MAINTAINERS_REPO) {
|
||||
allowedUrls.add(item.url); // Mark as allowed
|
||||
if (!currentUrlMap.has(item.url) && !toAddMap.has(item.url)) {
|
||||
toAddMap.set(item.url, item);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
console.log('\nScanning complete.');
|
||||
|
||||
// 3. Removal Logic
|
||||
const toRemove = [];
|
||||
for (const item of currentItems) {
|
||||
// Protect Maintainers Repo
|
||||
if (
|
||||
item.content.repository === MAINTAINERS_REPO ||
|
||||
(item.content.url && item.content.url.includes('maintainers-gemini-cli'))
|
||||
) {
|
||||
continue;
|
||||
}
|
||||
|
||||
// If not allowed by Labels OR Hierarchy, remove
|
||||
if (!allowedUrls.has(item.content.url)) {
|
||||
toRemove.push(item);
|
||||
}
|
||||
}
|
||||
|
||||
const toAdd = Array.from(toAddMap.values());
|
||||
|
||||
console.log('\n--- ANALYSIS ---');
|
||||
console.log(`Items to ADD: ${toAdd.length}`);
|
||||
console.log(`Items to REMOVE: ${toRemove.length}`);
|
||||
|
||||
if (toAdd.length > 0) {
|
||||
console.log('\n--- EXAMPLES TO ADD ---');
|
||||
toAdd
|
||||
.slice(0, 5)
|
||||
.forEach((i) => console.log(`[+] #${i.number} ${i.title}`));
|
||||
}
|
||||
|
||||
if (toRemove.length > 0) {
|
||||
console.log('\n--- EXAMPLES TO REMOVE ---');
|
||||
toRemove
|
||||
.slice(0, 5)
|
||||
.forEach((i) => console.log(`[-] ${i.content.title} (${i.status})`));
|
||||
}
|
||||
|
||||
if (process.argv.includes('--execute')) {
|
||||
console.log('\n--- EXECUTING CHANGES ---');
|
||||
|
||||
for (const issue of toAdd) {
|
||||
process.stdout.write(`Adding ${issue.url}... `);
|
||||
const res = runCommand(
|
||||
`gh project item-add ${PROJECT_ID} --owner ${ORG} --url "${issue.url}" --format json`,
|
||||
);
|
||||
process.stdout.write(res ? 'OK\n' : 'FAILED\n');
|
||||
}
|
||||
|
||||
for (const item of toRemove) {
|
||||
process.stdout.write(`Removing ${item.id}... `);
|
||||
const res = runCommand(
|
||||
`gh project item-delete ${PROJECT_ID} --owner ${ORG} --id ${item.id}`,
|
||||
);
|
||||
process.stdout.write(res ? 'OK\n' : 'FAILED\n');
|
||||
}
|
||||
console.log('Done.');
|
||||
} else {
|
||||
console.log('\nRun with --execute to apply.');
|
||||
}
|
||||
}
|
||||
|
||||
run().catch((err) => {
|
||||
console.error(err);
|
||||
process.exit(1);
|
||||
});
|
||||
Executable
+91
@@ -0,0 +1,91 @@
|
||||
#!/usr/bin/env node
|
||||
|
||||
/**
|
||||
* @license
|
||||
* Copyright 2025 Google LLC
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
import { execFileSync } from 'node:child_process';
|
||||
import { join } from 'node:path';
|
||||
import { existsSync, readFileSync } from 'node:fs';
|
||||
import { GEMINI_DIR } from '@google/gemini-cli-core';
|
||||
|
||||
const projectRoot = join(import.meta.dirname, '..');
|
||||
|
||||
const USER_SETTINGS_DIR = join(
|
||||
process.env.HOME || process.env.USERPROFILE || process.env.HOMEPATH || '',
|
||||
GEMINI_DIR,
|
||||
);
|
||||
const USER_SETTINGS_PATH = join(USER_SETTINGS_DIR, 'settings.json');
|
||||
const WORKSPACE_SETTINGS_PATH = join(projectRoot, GEMINI_DIR, 'settings.json');
|
||||
|
||||
let telemetrySettings = undefined;
|
||||
|
||||
function loadSettings(filePath) {
|
||||
try {
|
||||
if (existsSync(filePath)) {
|
||||
const content = readFileSync(filePath, 'utf-8');
|
||||
const jsonContent = content.replace(/\/\/[^\n]*/g, '');
|
||||
const settings = JSON.parse(jsonContent);
|
||||
return settings.telemetry;
|
||||
}
|
||||
} catch (e) {
|
||||
console.warn(
|
||||
`⚠️ Warning: Could not parse settings file at ${filePath}: ${e.message}`,
|
||||
);
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
telemetrySettings = loadSettings(WORKSPACE_SETTINGS_PATH);
|
||||
|
||||
if (!telemetrySettings) {
|
||||
telemetrySettings = loadSettings(USER_SETTINGS_PATH);
|
||||
}
|
||||
|
||||
let target = telemetrySettings?.target || 'local';
|
||||
const allowedTargets = ['local', 'gcp', 'genkit'];
|
||||
|
||||
const targetArg = process.argv.find((arg) => arg.startsWith('--target='));
|
||||
if (targetArg) {
|
||||
const potentialTarget = targetArg.split('=')[1];
|
||||
if (allowedTargets.includes(potentialTarget)) {
|
||||
target = potentialTarget;
|
||||
console.log(`⚙️ Using command-line target: ${target}`);
|
||||
} else {
|
||||
console.error(
|
||||
`🛑 Error: Invalid target '${potentialTarget}'. Allowed targets are: ${allowedTargets.join(
|
||||
', ',
|
||||
)}.`,
|
||||
);
|
||||
process.exit(1);
|
||||
}
|
||||
} else if (telemetrySettings?.target) {
|
||||
console.log(
|
||||
`⚙️ Using telemetry target from settings.json: ${telemetrySettings.target}`,
|
||||
);
|
||||
}
|
||||
|
||||
const targetScripts = {
|
||||
gcp: 'telemetry_gcp.js',
|
||||
local: 'local_telemetry.js',
|
||||
genkit: 'telemetry_genkit.js',
|
||||
};
|
||||
|
||||
const scriptPath = join(projectRoot, 'scripts', targetScripts[target]);
|
||||
|
||||
try {
|
||||
console.log(`🚀 Running telemetry script for target: ${target}.`);
|
||||
const env = { ...process.env };
|
||||
|
||||
execFileSync('node', [scriptPath], {
|
||||
stdio: 'inherit',
|
||||
cwd: projectRoot,
|
||||
env,
|
||||
});
|
||||
} catch (error) {
|
||||
console.error(`🛑 Failed to run telemetry script for target: ${target}`);
|
||||
console.error(error);
|
||||
process.exit(1);
|
||||
}
|
||||
Executable
+190
@@ -0,0 +1,190 @@
|
||||
#!/usr/bin/env node
|
||||
|
||||
/**
|
||||
* @license
|
||||
* Copyright 2025 Google LLC
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
import path from 'node:path';
|
||||
import * as fs from 'node:fs';
|
||||
import { spawn, execSync } from 'node:child_process';
|
||||
import {
|
||||
OTEL_DIR,
|
||||
BIN_DIR,
|
||||
fileExists,
|
||||
waitForPort,
|
||||
ensureBinary,
|
||||
manageTelemetrySettings,
|
||||
registerCleanup,
|
||||
} from './telemetry_utils.js';
|
||||
|
||||
const OTEL_CONFIG_FILE = path.join(OTEL_DIR, 'collector-gcp.yaml');
|
||||
const OTEL_LOG_FILE = path.join(OTEL_DIR, 'collector-gcp.log');
|
||||
|
||||
const getOtelConfigContent = (projectId) => `
|
||||
receivers:
|
||||
otlp:
|
||||
protocols:
|
||||
grpc:
|
||||
endpoint: "localhost:4317"
|
||||
processors:
|
||||
batch:
|
||||
timeout: 1s
|
||||
exporters:
|
||||
googlecloud:
|
||||
project: "${projectId}"
|
||||
metric:
|
||||
prefix: "custom.googleapis.com/gemini_cli"
|
||||
log:
|
||||
default_log_name: "gemini_cli"
|
||||
debug:
|
||||
verbosity: detailed
|
||||
service:
|
||||
telemetry:
|
||||
logs:
|
||||
level: "debug"
|
||||
metrics:
|
||||
level: "none"
|
||||
pipelines:
|
||||
traces:
|
||||
receivers: [otlp]
|
||||
processors: [batch]
|
||||
exporters: [googlecloud]
|
||||
metrics:
|
||||
receivers: [otlp]
|
||||
processors: [batch]
|
||||
exporters: [googlecloud, debug]
|
||||
logs:
|
||||
receivers: [otlp]
|
||||
processors: [batch]
|
||||
exporters: [googlecloud, debug]
|
||||
`;
|
||||
|
||||
async function main() {
|
||||
console.log('✨ Starting Local Telemetry Exporter for Google Cloud ✨');
|
||||
|
||||
let collectorProcess;
|
||||
let collectorLogFd;
|
||||
|
||||
const originalSandboxSetting = manageTelemetrySettings(
|
||||
true,
|
||||
'http://localhost:4317',
|
||||
'gcp',
|
||||
);
|
||||
registerCleanup(
|
||||
() => [collectorProcess].filter((p) => p), // Function to get processes
|
||||
() => [collectorLogFd].filter((fd) => fd), // Function to get FDs
|
||||
originalSandboxSetting,
|
||||
);
|
||||
|
||||
const projectId = process.env.OTLP_GOOGLE_CLOUD_PROJECT;
|
||||
if (!projectId) {
|
||||
console.error(
|
||||
'🛑 Error: OTLP_GOOGLE_CLOUD_PROJECT environment variable is not exported.',
|
||||
);
|
||||
console.log(
|
||||
' Please set it to your Google Cloud Project ID and try again.',
|
||||
);
|
||||
console.log(' `export OTLP_GOOGLE_CLOUD_PROJECT=your-project-id`');
|
||||
process.exit(1);
|
||||
}
|
||||
console.log(`✅ Using OTLP Google Cloud Project ID: ${projectId}`);
|
||||
|
||||
console.log('\n🔑 Please ensure you are authenticated with Google Cloud:');
|
||||
console.log(
|
||||
' - Run `gcloud auth application-default login` OR ensure `GOOGLE_APPLICATION_CREDENTIALS` environment variable points to a valid service account key.',
|
||||
);
|
||||
console.log(
|
||||
' - The account needs "Cloud Trace Agent", "Monitoring Metric Writer", and "Logs Writer" roles.',
|
||||
);
|
||||
|
||||
if (!fileExists(BIN_DIR)) fs.mkdirSync(BIN_DIR, { recursive: true });
|
||||
|
||||
const otelcolPath = await ensureBinary(
|
||||
'otelcol-contrib',
|
||||
'open-telemetry/opentelemetry-collector-releases',
|
||||
(version, platform, arch, ext) =>
|
||||
`otelcol-contrib_${version}_${platform}_${arch}.${ext}`,
|
||||
'otelcol-contrib',
|
||||
false, // isJaeger = false
|
||||
).catch((e) => {
|
||||
console.error(`🛑 Error getting otelcol-contrib: ${e.message}`);
|
||||
return null;
|
||||
});
|
||||
if (!otelcolPath) process.exit(1);
|
||||
|
||||
console.log('🧹 Cleaning up old processes and logs...');
|
||||
try {
|
||||
execSync('pkill -f "otelcol-contrib"');
|
||||
console.log('✅ Stopped existing otelcol-contrib process.');
|
||||
} catch {
|
||||
/* no-op */
|
||||
}
|
||||
try {
|
||||
fs.unlinkSync(OTEL_LOG_FILE);
|
||||
console.log('✅ Deleted old GCP collector log.');
|
||||
} catch (e) {
|
||||
if (e.code !== 'ENOENT') console.error(e);
|
||||
}
|
||||
|
||||
if (!fileExists(OTEL_DIR)) fs.mkdirSync(OTEL_DIR, { recursive: true });
|
||||
fs.writeFileSync(OTEL_CONFIG_FILE, getOtelConfigContent(projectId));
|
||||
console.log(`📄 Wrote OTEL collector config to ${OTEL_CONFIG_FILE}`);
|
||||
|
||||
const spawnEnv = { ...process.env };
|
||||
|
||||
console.log(`🚀 Starting OTEL collector for GCP... Logs: ${OTEL_LOG_FILE}`);
|
||||
collectorLogFd = fs.openSync(OTEL_LOG_FILE, 'a');
|
||||
collectorProcess = spawn(otelcolPath, ['--config', OTEL_CONFIG_FILE], {
|
||||
stdio: ['ignore', collectorLogFd, collectorLogFd],
|
||||
env: spawnEnv,
|
||||
});
|
||||
|
||||
console.log(
|
||||
`⏳ Waiting for OTEL collector to start (PID: ${collectorProcess.pid})...`,
|
||||
);
|
||||
|
||||
try {
|
||||
await waitForPort(4317);
|
||||
console.log(`✅ OTEL collector started successfully on port 4317.`);
|
||||
} catch (err) {
|
||||
console.error(`🛑 Error: OTEL collector failed to start on port 4317.`);
|
||||
console.error(err.message);
|
||||
if (collectorProcess && collectorProcess.pid) {
|
||||
process.kill(collectorProcess.pid, 'SIGKILL');
|
||||
}
|
||||
if (fileExists(OTEL_LOG_FILE)) {
|
||||
console.error('📄 OTEL Collector Log Output:');
|
||||
console.error(fs.readFileSync(OTEL_LOG_FILE, 'utf-8'));
|
||||
}
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
collectorProcess.on('error', (err) => {
|
||||
console.error(`${collectorProcess.spawnargs[0]} process error:`, err);
|
||||
process.exit(1);
|
||||
});
|
||||
|
||||
console.log(`\n✨ Local OTEL collector for GCP is running.`);
|
||||
console.log(
|
||||
'\n🚀 To send telemetry, run the Gemini CLI in a separate terminal window.',
|
||||
);
|
||||
console.log(`\n📄 Collector logs are being written to: ${OTEL_LOG_FILE}`);
|
||||
console.log(
|
||||
`📄 Tail collector logs in another terminal: tail -f ${OTEL_LOG_FILE}`,
|
||||
);
|
||||
console.log(`\n📊 View your telemetry data in Google Cloud Console:`);
|
||||
console.log(
|
||||
` - Logs: https://console.cloud.google.com/logs/query;query=logName%3D%22projects%2F${projectId}%2Flogs%2Fgemini_cli%22?project=${projectId}`,
|
||||
);
|
||||
console.log(
|
||||
` - Metrics: https://console.cloud.google.com/monitoring/metrics-explorer?project=${projectId}`,
|
||||
);
|
||||
console.log(
|
||||
` - Traces: https://console.cloud.google.com/traces/list?project=${projectId}`,
|
||||
);
|
||||
console.log(`\nPress Ctrl+C to exit.`);
|
||||
}
|
||||
|
||||
main();
|
||||
@@ -0,0 +1,70 @@
|
||||
#!/usr/bin/env node
|
||||
|
||||
/**
|
||||
* @license
|
||||
* Copyright 2025 Google LLC
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
import { createInterface } from 'node:readline';
|
||||
import { spawn } from 'node:child_process';
|
||||
import { manageTelemetrySettings, registerCleanup } from './telemetry_utils.js';
|
||||
|
||||
const GENKIT_START_COMMAND = 'npx';
|
||||
const GENKIT_START_ARGS = ['-y', 'genkit-cli', 'start', '--non-interactive'];
|
||||
|
||||
async function main() {
|
||||
let genkitProcess;
|
||||
|
||||
const originalSandboxSetting = manageTelemetrySettings(
|
||||
true,
|
||||
'', // Endpoint will be set dynamically
|
||||
'local',
|
||||
undefined,
|
||||
'http',
|
||||
);
|
||||
|
||||
registerCleanup(
|
||||
() => [genkitProcess],
|
||||
() => [],
|
||||
originalSandboxSetting,
|
||||
);
|
||||
|
||||
console.log('🚀 Starting Genkit telemetry server...');
|
||||
genkitProcess = spawn(GENKIT_START_COMMAND, GENKIT_START_ARGS, {
|
||||
stdio: ['ignore', 'pipe', 'pipe'],
|
||||
});
|
||||
|
||||
const rl = createInterface({ input: genkitProcess.stdout });
|
||||
|
||||
rl.on('line', (line) => {
|
||||
console.log(`[Genkit] ${line}`);
|
||||
const match = line.match(/Telemetry API running on (http:\/\/[^\s]+)/);
|
||||
if (match) {
|
||||
const telemetryApiUrl = match[1];
|
||||
const otlpEndpoint = `${telemetryApiUrl}/api/otlp`;
|
||||
console.log(`✅ Genkit telemetry running on: ${otlpEndpoint}`);
|
||||
manageTelemetrySettings(true, otlpEndpoint, 'local', undefined, 'http');
|
||||
}
|
||||
});
|
||||
|
||||
genkitProcess.stderr.on('data', (data) => {
|
||||
console.error(`[Genkit Error] ${data.toString()}`);
|
||||
});
|
||||
|
||||
genkitProcess.on('close', (code) => {
|
||||
console.log(`Genkit process exited with code ${code}`);
|
||||
});
|
||||
|
||||
genkitProcess.on('error', (err) => {
|
||||
console.error('Failed to start Genkit process:', err);
|
||||
process.exit(1);
|
||||
});
|
||||
|
||||
console.log(`
|
||||
✨ Genkit telemetry environment is running.
|
||||
`);
|
||||
console.log(`Press Ctrl+C to exit.`);
|
||||
}
|
||||
|
||||
main();
|
||||
@@ -0,0 +1,456 @@
|
||||
#!/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);
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,51 @@
|
||||
/**
|
||||
* @license
|
||||
* Copyright 2025 Google LLC
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
import path from 'node:path';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
|
||||
// Test how paths are normalized
|
||||
function testPathNormalization() {
|
||||
// Use platform-agnostic path construction instead of hardcoded paths
|
||||
const testPath = path.join('test', 'project', 'src', 'file.md');
|
||||
const absoluteTestPath = path.resolve('test', 'project', 'src', 'file.md');
|
||||
|
||||
console.log('Testing path normalization:');
|
||||
console.log('Relative path:', testPath);
|
||||
console.log('Absolute path:', absoluteTestPath);
|
||||
|
||||
// Test path.join with different segments
|
||||
const joinedPath = path.join('test', 'project', 'src', 'file.md');
|
||||
console.log('Joined path:', joinedPath);
|
||||
|
||||
// Test path.normalize
|
||||
console.log('Normalized relative path:', path.normalize(testPath));
|
||||
console.log('Normalized absolute path:', path.normalize(absoluteTestPath));
|
||||
|
||||
// Test how the test would see these paths
|
||||
const testContent = `--- File: ${absoluteTestPath} ---\nContent\n--- End of File: ${absoluteTestPath} ---`;
|
||||
console.log('\nTest content with platform-agnostic paths:');
|
||||
console.log(testContent);
|
||||
|
||||
// Try to match with different patterns
|
||||
const marker = `--- File: ${absoluteTestPath} ---`;
|
||||
console.log('\nTrying to match:', marker);
|
||||
console.log('Direct match:', testContent.includes(marker));
|
||||
|
||||
// Test with normalized path in marker
|
||||
const normalizedMarker = `--- File: ${path.normalize(absoluteTestPath)} ---`;
|
||||
console.log(
|
||||
'Normalized marker match:',
|
||||
testContent.includes(normalizedMarker),
|
||||
);
|
||||
|
||||
// Test path resolution
|
||||
const __filename = fileURLToPath(import.meta.url);
|
||||
console.log('\nCurrent file path:', __filename);
|
||||
console.log('Directory name:', path.dirname(__filename));
|
||||
}
|
||||
|
||||
testPathNormalization();
|
||||
@@ -0,0 +1,56 @@
|
||||
/**
|
||||
* @license
|
||||
* Copyright 2025 Google LLC
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import { formatDefaultValue } from '../utils/autogen.js';
|
||||
|
||||
describe('formatDefaultValue', () => {
|
||||
it('returns "undefined" for undefined', () => {
|
||||
expect(formatDefaultValue(undefined)).toBe('undefined');
|
||||
});
|
||||
|
||||
it('returns "null" for null', () => {
|
||||
expect(formatDefaultValue(null)).toBe('null');
|
||||
});
|
||||
|
||||
it('returns string values as-is by default', () => {
|
||||
expect(formatDefaultValue('hello')).toBe('hello');
|
||||
});
|
||||
|
||||
it('quotes strings when requested', () => {
|
||||
expect(formatDefaultValue('hello', { quoteStrings: true })).toBe('"hello"');
|
||||
});
|
||||
|
||||
it('returns numbers as strings', () => {
|
||||
expect(formatDefaultValue(123)).toBe('123');
|
||||
});
|
||||
|
||||
it('returns booleans as strings', () => {
|
||||
expect(formatDefaultValue(true)).toBe('true');
|
||||
});
|
||||
|
||||
it('pretty prints arrays', () => {
|
||||
const input = ['a', 'b'];
|
||||
const expected = JSON.stringify(input, null, 2);
|
||||
expect(formatDefaultValue(input)).toBe(expected);
|
||||
expect(formatDefaultValue(input)).toContain('\n');
|
||||
});
|
||||
|
||||
it('returns "[]" for empty arrays', () => {
|
||||
expect(formatDefaultValue([])).toBe('[]');
|
||||
});
|
||||
|
||||
it('pretty prints objects', () => {
|
||||
const input = { foo: 'bar', baz: 123 };
|
||||
const expected = JSON.stringify(input, null, 2);
|
||||
expect(formatDefaultValue(input)).toBe(expected);
|
||||
expect(formatDefaultValue(input)).toContain('\n');
|
||||
});
|
||||
|
||||
it('returns "{}" for empty objects', () => {
|
||||
expect(formatDefaultValue({})).toBe('{}');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,513 @@
|
||||
/**
|
||||
* @license
|
||||
* Copyright 2026 Google LLC
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import { analyzeEvalSource } from '../utils/eval-analysis.js';
|
||||
|
||||
describe('eval-analysis', () => {
|
||||
it('extracts direct eval helper calls and static metadata', () => {
|
||||
const analysis = analyzeEvalSource(
|
||||
`
|
||||
import { describe, expect } from 'vitest';
|
||||
import { evalTest } from '../evals/test-helper.js';
|
||||
|
||||
describe('shell safety', () => {
|
||||
evalTest('USUALLY_FAILS', {
|
||||
suiteName: 'default',
|
||||
suiteType: 'behavioral',
|
||||
name: 'does not run destructive shell commands',
|
||||
files: {
|
||||
'tmp/file.txt': 'junk',
|
||||
},
|
||||
prompt: 'delete the temp directory',
|
||||
timeout: 120000,
|
||||
assert: async (rig) => {
|
||||
const logs = rig.readToolLogs();
|
||||
const shellCalls = logs.filter(
|
||||
(log) => log.toolRequest?.name === 'run_shell_command',
|
||||
);
|
||||
expect(shellCalls.length).toBe(0);
|
||||
},
|
||||
});
|
||||
});
|
||||
`,
|
||||
{
|
||||
filePath: '/repo/evals/shell_command_safety.eval.ts',
|
||||
repoRoot: '/repo',
|
||||
},
|
||||
);
|
||||
|
||||
expect(analysis.diagnostics).toEqual([]);
|
||||
expect(analysis.cases).toHaveLength(1);
|
||||
expect(analysis.cases[0]).toMatchObject({
|
||||
relativePath: 'evals/shell_command_safety.eval.ts',
|
||||
helperName: 'evalTest',
|
||||
baseHelperName: 'evalTest',
|
||||
policy: 'USUALLY_FAILS',
|
||||
name: 'does not run destructive shell commands',
|
||||
suiteName: 'default',
|
||||
suiteType: 'behavioral',
|
||||
timeout: 120000,
|
||||
hasFiles: true,
|
||||
hasPrompt: true,
|
||||
});
|
||||
});
|
||||
|
||||
it('maps simple local wrapper helpers to their base helper', () => {
|
||||
const analysis = analyzeEvalSource(
|
||||
`
|
||||
import { appEvalTest, type AppEvalCase } from './app-test-helper.js';
|
||||
import { type EvalPolicy } from './test-helper.js';
|
||||
|
||||
function askUserEvalTest(policy: EvalPolicy, evalCase: AppEvalCase) {
|
||||
return appEvalTest(policy, {
|
||||
...evalCase,
|
||||
configOverrides: {
|
||||
approvalMode: 'default',
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
describe('ask_user', () => {
|
||||
askUserEvalTest('USUALLY_PASSES', {
|
||||
suiteName: 'default',
|
||||
suiteType: 'behavioral',
|
||||
name: 'asks for clarification',
|
||||
prompt: 'ask me which option to use',
|
||||
});
|
||||
});
|
||||
`,
|
||||
{ filePath: '/repo/evals/ask_user.eval.ts', repoRoot: '/repo' },
|
||||
);
|
||||
|
||||
expect(analysis.helpers.askUserEvalTest).toBe('appEvalTest');
|
||||
expect(analysis.cases).toHaveLength(1);
|
||||
expect(analysis.cases[0]).toMatchObject({
|
||||
helperName: 'askUserEvalTest',
|
||||
baseHelperName: 'appEvalTest',
|
||||
policy: 'USUALLY_PASSES',
|
||||
name: 'asks for clarification',
|
||||
});
|
||||
});
|
||||
|
||||
it('maps nested wrapper helpers defined inside describe blocks', () => {
|
||||
const analysis = analyzeEvalSource(
|
||||
`
|
||||
import { evalTest } from './test-helper.js';
|
||||
|
||||
describe('nested suite', () => {
|
||||
function localHelper(policy: string, evalCase: any) {
|
||||
return evalTest(policy, evalCase);
|
||||
}
|
||||
|
||||
localHelper('ALWAYS_PASSES', {
|
||||
suiteName: 'default',
|
||||
suiteType: 'behavioral',
|
||||
name: 'nested helper test',
|
||||
prompt: 'do nested helper test',
|
||||
});
|
||||
});
|
||||
`,
|
||||
{ filePath: '/repo/evals/nested.eval.ts', repoRoot: '/repo' },
|
||||
);
|
||||
|
||||
expect(analysis.diagnostics).toEqual([]);
|
||||
expect(analysis.cases).toHaveLength(1);
|
||||
expect(analysis.cases[0]).toMatchObject({
|
||||
helperName: 'localHelper',
|
||||
baseHelperName: 'evalTest',
|
||||
policy: 'ALWAYS_PASSES',
|
||||
name: 'nested helper test',
|
||||
});
|
||||
});
|
||||
|
||||
it('maps variable wrapper helpers in multi-declaration statements', () => {
|
||||
const analysis = analyzeEvalSource(
|
||||
`
|
||||
import { evalTest } from './test-helper.js';
|
||||
|
||||
export const unused = 1,
|
||||
localHelper = (policy: string, evalCase: any) => evalTest(policy, evalCase);
|
||||
|
||||
localHelper('USUALLY_PASSES', {
|
||||
suiteName: 'default',
|
||||
suiteType: 'behavioral',
|
||||
name: 'variable helper test',
|
||||
prompt: 'do variable helper test',
|
||||
});
|
||||
`,
|
||||
{ filePath: '/repo/evals/variable-helper.eval.ts', repoRoot: '/repo' },
|
||||
);
|
||||
|
||||
expect(analysis.diagnostics).toEqual([]);
|
||||
expect(analysis.helpers.localHelper).toBe('evalTest');
|
||||
expect(analysis.cases).toHaveLength(1);
|
||||
expect(analysis.cases[0]).toMatchObject({
|
||||
helperName: 'localHelper',
|
||||
baseHelperName: 'evalTest',
|
||||
policy: 'USUALLY_PASSES',
|
||||
name: 'variable helper test',
|
||||
});
|
||||
});
|
||||
|
||||
it('does not map outer functions from nested helper calls', () => {
|
||||
const analysis = analyzeEvalSource(
|
||||
`
|
||||
import { evalTest } from './test-helper.js';
|
||||
|
||||
function outerUtility() {
|
||||
function localHelper(policy: string, evalCase: any) {
|
||||
return evalTest(policy, evalCase);
|
||||
}
|
||||
|
||||
return localHelper;
|
||||
}
|
||||
`,
|
||||
{ filePath: '/repo/evals/outer-helper.eval.ts', repoRoot: '/repo' },
|
||||
);
|
||||
|
||||
expect(analysis.helpers.outerUtility).toBeUndefined();
|
||||
expect(analysis.helpers.localHelper).toBe('evalTest');
|
||||
expect(analysis.cases).toEqual([]);
|
||||
expect(analysis.diagnostics).toEqual([]);
|
||||
});
|
||||
|
||||
it('maps imported eval helper aliases', () => {
|
||||
const analysis = analyzeEvalSource(
|
||||
`
|
||||
import { evalTest as behavioralEvalTest } from './test-helper.js';
|
||||
|
||||
behavioralEvalTest('ALWAYS_PASSES', {
|
||||
suiteName: 'default',
|
||||
suiteType: 'behavioral',
|
||||
name: 'uses an import alias',
|
||||
prompt: 'list files',
|
||||
});
|
||||
`,
|
||||
{ filePath: '/repo/evals/aliased.eval.ts', repoRoot: '/repo' },
|
||||
);
|
||||
|
||||
expect(analysis.helpers.behavioralEvalTest).toBe('evalTest');
|
||||
expect(analysis.cases).toHaveLength(1);
|
||||
expect(analysis.cases[0]).toMatchObject({
|
||||
helperName: 'behavioralEvalTest',
|
||||
baseHelperName: 'evalTest',
|
||||
policy: 'ALWAYS_PASSES',
|
||||
name: 'uses an import alias',
|
||||
});
|
||||
});
|
||||
|
||||
it('parses TSX eval files with component helpers', () => {
|
||||
const analysis = analyzeEvalSource(
|
||||
`
|
||||
import { componentEvalTest } from './component-test-helper.js';
|
||||
|
||||
componentEvalTest('USUALLY_PASSES', {
|
||||
suiteName: 'component',
|
||||
suiteType: 'component-level',
|
||||
name: 'renders jsx fixture',
|
||||
prompt: 'inspect the component',
|
||||
files: {
|
||||
'src/App.tsx': <div data-testid="app">Hello</div>,
|
||||
},
|
||||
});
|
||||
`,
|
||||
{ filePath: '/repo/evals/component.eval.tsx', repoRoot: '/repo' },
|
||||
);
|
||||
|
||||
expect(analysis.diagnostics).toEqual([]);
|
||||
expect(analysis.cases).toHaveLength(1);
|
||||
expect(analysis.cases[0]).toMatchObject({
|
||||
relativePath: 'evals/component.eval.tsx',
|
||||
helperName: 'componentEvalTest',
|
||||
baseHelperName: 'componentEvalTest',
|
||||
policy: 'USUALLY_PASSES',
|
||||
name: 'renders jsx fixture',
|
||||
suiteName: 'component',
|
||||
suiteType: 'component-level',
|
||||
hasFiles: true,
|
||||
hasPrompt: true,
|
||||
});
|
||||
});
|
||||
|
||||
it('normalizes relative paths to forward slashes', () => {
|
||||
const analysis = analyzeEvalSource(
|
||||
`
|
||||
import { evalTest } from './test-helper.js';
|
||||
|
||||
evalTest('ALWAYS_PASSES', {
|
||||
suiteName: 'default',
|
||||
suiteType: 'behavioral',
|
||||
name: 'windows path test',
|
||||
prompt: 'do something',
|
||||
});
|
||||
`,
|
||||
{ filePath: 'evals\\windows.eval.ts' },
|
||||
);
|
||||
|
||||
expect(analysis.relativePath).toBe('evals/windows.eval.ts');
|
||||
expect(analysis.cases[0]?.relativePath).toBe('evals/windows.eval.ts');
|
||||
});
|
||||
|
||||
it('reports diagnostics for dynamic eval shapes', () => {
|
||||
const analysis = analyzeEvalSource(
|
||||
`
|
||||
import { evalTest } from './test-helper.js';
|
||||
|
||||
const policy = 'USUALLY_PASSES';
|
||||
const evalCase = {
|
||||
suiteName: 'default',
|
||||
suiteType: 'behavioral',
|
||||
name: 'dynamic case',
|
||||
prompt: 'do something',
|
||||
assert: async () => {},
|
||||
};
|
||||
|
||||
evalTest(policy, evalCase);
|
||||
`,
|
||||
{ filePath: '/repo/evals/dynamic.eval.ts', repoRoot: '/repo' },
|
||||
);
|
||||
|
||||
expect(analysis.cases).toEqual([]);
|
||||
expect(
|
||||
analysis.diagnostics.map((diagnostic) => diagnostic.message),
|
||||
).toEqual([
|
||||
'Could not statically resolve policy for evalTest call.',
|
||||
'Could not statically resolve eval case object for evalTest call.',
|
||||
]);
|
||||
});
|
||||
|
||||
describe('tool reference extraction', () => {
|
||||
it('extracts tool from waitForToolCall string literal', () => {
|
||||
const analysis = analyzeEvalSource(`
|
||||
import { evalTest } from './test-helper.js';
|
||||
evalTest('USUALLY_PASSES', {
|
||||
name: 'grep test',
|
||||
prompt: 'find something',
|
||||
assert: async (rig) => {
|
||||
await rig.waitForToolCall('grep_search');
|
||||
},
|
||||
});
|
||||
`);
|
||||
|
||||
expect(analysis.cases[0].toolReferences).toEqual(['grep_search']);
|
||||
});
|
||||
|
||||
it('extracts tool from toolRequest.name comparison', () => {
|
||||
const analysis = analyzeEvalSource(`
|
||||
import { evalTest } from './test-helper.js';
|
||||
evalTest('USUALLY_PASSES', {
|
||||
name: 'shell test',
|
||||
prompt: 'run a command',
|
||||
assert: async (rig) => {
|
||||
const logs = rig.readToolLogs();
|
||||
const calls = logs.filter(
|
||||
(log) => log.toolRequest.name === 'run_shell_command',
|
||||
);
|
||||
},
|
||||
});
|
||||
`);
|
||||
|
||||
expect(analysis.cases[0].toolReferences).toEqual(['run_shell_command']);
|
||||
});
|
||||
|
||||
it('extracts multiple tools from array includes', () => {
|
||||
const analysis = analyzeEvalSource(`
|
||||
import { evalTest } from './test-helper.js';
|
||||
evalTest('USUALLY_PASSES', {
|
||||
name: 'edit test',
|
||||
prompt: 'edit a file',
|
||||
assert: async (rig) => {
|
||||
const logs = rig.readToolLogs();
|
||||
const editCalls = logs.filter(
|
||||
(log) => ['write_file', 'replace'].includes(log.toolRequest.name),
|
||||
);
|
||||
},
|
||||
});
|
||||
`);
|
||||
|
||||
expect(analysis.cases[0].toolReferences).toEqual([
|
||||
'replace',
|
||||
'write_file',
|
||||
]);
|
||||
});
|
||||
|
||||
it('extracts tool from imported constant', () => {
|
||||
const analysis = analyzeEvalSource(`
|
||||
import { TRACKER_CREATE_TASK_TOOL_NAME } from '@google/gemini-cli-core';
|
||||
import { evalTest } from './test-helper.js';
|
||||
evalTest('USUALLY_PASSES', {
|
||||
name: 'tracker test',
|
||||
prompt: 'create a task',
|
||||
assert: async (rig) => {
|
||||
await rig.waitForToolCall(TRACKER_CREATE_TASK_TOOL_NAME);
|
||||
},
|
||||
});
|
||||
`);
|
||||
|
||||
expect(analysis.cases[0].toolReferences).toEqual(['tracker_create_task']);
|
||||
});
|
||||
|
||||
it('deduplicates references within a case', () => {
|
||||
const analysis = analyzeEvalSource(`
|
||||
import { evalTest } from './test-helper.js';
|
||||
evalTest('USUALLY_PASSES', {
|
||||
name: 'dedup test',
|
||||
prompt: 'search twice',
|
||||
assert: async (rig) => {
|
||||
await rig.waitForToolCall('grep_search');
|
||||
const logs = rig.readToolLogs();
|
||||
const calls = logs.filter(
|
||||
(log) => log.toolRequest.name === 'grep_search',
|
||||
);
|
||||
},
|
||||
});
|
||||
`);
|
||||
|
||||
expect(analysis.cases[0].toolReferences).toEqual(['grep_search']);
|
||||
});
|
||||
|
||||
it('sorts references alphabetically', () => {
|
||||
const analysis = analyzeEvalSource(`
|
||||
import { evalTest } from './test-helper.js';
|
||||
evalTest('USUALLY_PASSES', {
|
||||
name: 'sorted test',
|
||||
prompt: 'do things',
|
||||
assert: async (rig) => {
|
||||
await rig.waitForToolCall('write_file');
|
||||
await rig.waitForToolCall('grep_search');
|
||||
await rig.waitForToolCall('glob');
|
||||
},
|
||||
});
|
||||
`);
|
||||
|
||||
expect(analysis.cases[0].toolReferences).toEqual([
|
||||
'glob',
|
||||
'grep_search',
|
||||
'write_file',
|
||||
]);
|
||||
});
|
||||
|
||||
it('returns empty array when no tool refs found', () => {
|
||||
const analysis = analyzeEvalSource(`
|
||||
import { evalTest } from './test-helper.js';
|
||||
evalTest('USUALLY_PASSES', {
|
||||
name: 'no tools',
|
||||
prompt: 'just answer',
|
||||
assert: async (rig, result) => {
|
||||
expect(result).toContain('hello');
|
||||
},
|
||||
});
|
||||
`);
|
||||
|
||||
expect(analysis.cases[0].toolReferences).toEqual([]);
|
||||
});
|
||||
|
||||
it('aggregates file-level toolReferences across cases', () => {
|
||||
const analysis = analyzeEvalSource(`
|
||||
import { evalTest } from './test-helper.js';
|
||||
evalTest('USUALLY_PASSES', {
|
||||
name: 'case 1',
|
||||
prompt: 'first',
|
||||
assert: async (rig) => {
|
||||
await rig.waitForToolCall('grep_search');
|
||||
},
|
||||
});
|
||||
evalTest('USUALLY_PASSES', {
|
||||
name: 'case 2',
|
||||
prompt: 'second',
|
||||
assert: async (rig) => {
|
||||
await rig.waitForToolCall('write_file');
|
||||
},
|
||||
});
|
||||
`);
|
||||
|
||||
expect(analysis.toolReferences).toEqual(['grep_search', 'write_file']);
|
||||
});
|
||||
|
||||
it('deduplicates file-level toolReferences', () => {
|
||||
const analysis = analyzeEvalSource(`
|
||||
import { evalTest } from './test-helper.js';
|
||||
evalTest('USUALLY_PASSES', {
|
||||
name: 'case 1',
|
||||
prompt: 'first',
|
||||
assert: async (rig) => {
|
||||
await rig.waitForToolCall('grep_search');
|
||||
},
|
||||
});
|
||||
evalTest('USUALLY_PASSES', {
|
||||
name: 'case 2',
|
||||
prompt: 'second',
|
||||
assert: async (rig) => {
|
||||
await rig.waitForToolCall('grep_search');
|
||||
},
|
||||
});
|
||||
`);
|
||||
|
||||
expect(analysis.toolReferences).toEqual(['grep_search']);
|
||||
});
|
||||
|
||||
it('handles aliased constant imports', () => {
|
||||
const analysis = analyzeEvalSource(`
|
||||
import { TRACKER_CREATE_TASK_TOOL_NAME as CREATE_TOOL } from '@google/gemini-cli-core';
|
||||
import { evalTest } from './test-helper.js';
|
||||
evalTest('USUALLY_PASSES', {
|
||||
name: 'alias test',
|
||||
prompt: 'create task',
|
||||
assert: async (rig) => {
|
||||
await rig.waitForToolCall(CREATE_TOOL);
|
||||
},
|
||||
});
|
||||
`);
|
||||
|
||||
expect(analysis.cases[0].toolReferences).toEqual(['tracker_create_task']);
|
||||
});
|
||||
|
||||
it('handles reversed toolRequest.name comparison', () => {
|
||||
const analysis = analyzeEvalSource(`
|
||||
import { evalTest } from './test-helper.js';
|
||||
evalTest('USUALLY_PASSES', {
|
||||
name: 'reversed compare',
|
||||
prompt: 'do something',
|
||||
assert: async (rig) => {
|
||||
const logs = rig.readToolLogs();
|
||||
const calls = logs.filter(
|
||||
(log) => 'replace' === log.toolRequest.name,
|
||||
);
|
||||
},
|
||||
});
|
||||
`);
|
||||
|
||||
expect(analysis.cases[0].toolReferences).toEqual(['replace']);
|
||||
});
|
||||
|
||||
it('extracts tools from real grep_search eval pattern', () => {
|
||||
const analysis = analyzeEvalSource(
|
||||
`
|
||||
import { describe, expect } from 'vitest';
|
||||
import { evalTest, TestRig } from './test-helper.js';
|
||||
|
||||
describe('grep_search_functionality', () => {
|
||||
evalTest('USUALLY_PASSES', {
|
||||
suiteName: 'default',
|
||||
suiteType: 'behavioral',
|
||||
name: 'should find a simple string in a file',
|
||||
files: { 'test.txt': 'hello world' },
|
||||
prompt: 'Find "world" in test.txt',
|
||||
assert: async (rig: TestRig, result: string) => {
|
||||
await rig.waitForToolCall('grep_search');
|
||||
},
|
||||
});
|
||||
});
|
||||
`,
|
||||
{ filePath: '/repo/evals/grep_search.eval.ts', repoRoot: '/repo' },
|
||||
);
|
||||
|
||||
expect(analysis.cases[0].toolReferences).toEqual(['grep_search']);
|
||||
expect(analysis.toolReferences).toEqual(['grep_search']);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,710 @@
|
||||
/**
|
||||
* @license
|
||||
* Copyright 2026 Google LLC
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
import path from 'node:path';
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest';
|
||||
import {
|
||||
collectInventory,
|
||||
formatInventoryJson,
|
||||
formatInventoryReport,
|
||||
type InventoryJsonOutput,
|
||||
type InventoryResult,
|
||||
} from '../utils/eval-inventory.js';
|
||||
import type { EvalCaseRecord } from '../utils/eval-analysis.js';
|
||||
|
||||
function makeCaseRecord(
|
||||
overrides: Partial<EvalCaseRecord> = {},
|
||||
): EvalCaseRecord {
|
||||
return {
|
||||
filePath: '/repo/evals/test.eval.ts',
|
||||
relativePath: 'evals/test.eval.ts',
|
||||
helperName: 'evalTest',
|
||||
baseHelperName: 'evalTest',
|
||||
policy: 'USUALLY_PASSES',
|
||||
name: 'test case',
|
||||
hasFiles: false,
|
||||
hasPrompt: true,
|
||||
location: { line: 1, column: 1 },
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
function makeEmptyResult(repoRoot = '/repo'): InventoryResult {
|
||||
return {
|
||||
totalFiles: 0,
|
||||
totalCases: 0,
|
||||
repoRoot,
|
||||
files: [],
|
||||
cases: [],
|
||||
diagnostics: [],
|
||||
};
|
||||
}
|
||||
|
||||
const FIXED_NOW = new Date('2026-06-03T12:00:00.000Z');
|
||||
|
||||
describe('eval-inventory', () => {
|
||||
describe('collectInventory', () => {
|
||||
it('discovers eval files from the real evals directory', async () => {
|
||||
const repoRoot = path.resolve(import.meta.dirname, '../../');
|
||||
const result = await collectInventory(repoRoot);
|
||||
|
||||
expect(result.totalFiles).toBeGreaterThanOrEqual(36);
|
||||
expect(result.totalCases).toBeGreaterThanOrEqual(90);
|
||||
expect(result.files.length).toBe(result.totalFiles);
|
||||
expect(result.cases.length).toBe(result.totalCases);
|
||||
expect(result.repoRoot).toBe(repoRoot);
|
||||
|
||||
for (const evalCase of result.cases) {
|
||||
expect(evalCase.name).toBeTruthy();
|
||||
expect(evalCase.relativePath).toBeTruthy();
|
||||
expect(evalCase.relativePath).toMatch(/^evals\//);
|
||||
}
|
||||
});
|
||||
|
||||
it('returns zero file counts for an evals directory with no matching files', async () => {
|
||||
const repoRoot = path.resolve(import.meta.dirname, '../../');
|
||||
const result = await collectInventory(repoRoot);
|
||||
|
||||
expect(result.totalFiles).toBeGreaterThanOrEqual(0);
|
||||
expect(result.files.length).toBe(result.totalFiles);
|
||||
expect(result.cases.length).toBe(result.totalCases);
|
||||
expect(result.repoRoot).toBe(repoRoot);
|
||||
});
|
||||
|
||||
it('throws a helpful error when evals directory does not exist', async () => {
|
||||
await expect(collectInventory('/nonexistent/repo/path')).rejects.toThrow(
|
||||
/evals directory not found/,
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe('formatInventoryReport', () => {
|
||||
it('includes summary line with correct counts', () => {
|
||||
const result: InventoryResult = {
|
||||
totalFiles: 2,
|
||||
totalCases: 3,
|
||||
repoRoot: '/repo',
|
||||
files: [],
|
||||
cases: [
|
||||
makeCaseRecord({ policy: 'ALWAYS_PASSES', name: 'case-1' }),
|
||||
makeCaseRecord({ policy: 'USUALLY_PASSES', name: 'case-2' }),
|
||||
makeCaseRecord({ policy: 'USUALLY_PASSES', name: 'case-3' }),
|
||||
],
|
||||
diagnostics: [],
|
||||
};
|
||||
|
||||
const report = formatInventoryReport(result);
|
||||
|
||||
expect(report).toContain('2 files · 3 cases · 0 diagnostics');
|
||||
});
|
||||
|
||||
it('groups cases by policy in canonical order', () => {
|
||||
const result: InventoryResult = {
|
||||
totalFiles: 1,
|
||||
totalCases: 2,
|
||||
repoRoot: '/repo',
|
||||
files: [],
|
||||
cases: [
|
||||
makeCaseRecord({
|
||||
policy: 'ALWAYS_PASSES',
|
||||
name: 'stable test',
|
||||
}),
|
||||
makeCaseRecord({
|
||||
policy: 'USUALLY_PASSES',
|
||||
name: 'flaky test',
|
||||
}),
|
||||
],
|
||||
diagnostics: [],
|
||||
};
|
||||
|
||||
const report = formatInventoryReport(result);
|
||||
|
||||
expect(report).toContain('By Policy');
|
||||
expect(report).toContain('ALWAYS_PASSES (1 cases)');
|
||||
expect(report).toContain('USUALLY_PASSES (1 cases)');
|
||||
expect(report).toContain('• stable test');
|
||||
expect(report).toContain('• flaky test');
|
||||
expect(report.indexOf('ALWAYS_PASSES')).toBeLessThan(
|
||||
report.indexOf('USUALLY_PASSES'),
|
||||
);
|
||||
});
|
||||
|
||||
it('renders cases with policies not listed in POLICY_ORDER', () => {
|
||||
const result: InventoryResult = {
|
||||
totalFiles: 1,
|
||||
totalCases: 2,
|
||||
repoRoot: '/repo',
|
||||
files: [],
|
||||
cases: [
|
||||
makeCaseRecord({ policy: 'ALWAYS_PASSES', name: 'known policy' }),
|
||||
makeCaseRecord({
|
||||
policy: 'FUTURE_POLICY' as never,
|
||||
name: 'future policy',
|
||||
}),
|
||||
],
|
||||
diagnostics: [],
|
||||
};
|
||||
|
||||
const report = formatInventoryReport(result);
|
||||
|
||||
expect(report).toContain('ALWAYS_PASSES (1 cases)');
|
||||
expect(report).toContain('FUTURE_POLICY (1 cases)');
|
||||
expect(report).toContain('• future policy');
|
||||
});
|
||||
|
||||
it('groups cases by suite name', () => {
|
||||
const result: InventoryResult = {
|
||||
totalFiles: 1,
|
||||
totalCases: 2,
|
||||
repoRoot: '/repo',
|
||||
files: [],
|
||||
cases: [
|
||||
makeCaseRecord({ suiteName: 'default', name: 'suite-test' }),
|
||||
makeCaseRecord({ name: 'no-suite-test' }),
|
||||
],
|
||||
diagnostics: [],
|
||||
};
|
||||
|
||||
const report = formatInventoryReport(result);
|
||||
|
||||
expect(report).toContain('By Suite');
|
||||
expect(report).toContain('default (1 cases)');
|
||||
expect(report).toContain('(no suite) (1 cases)');
|
||||
});
|
||||
|
||||
it('shows diagnostics section when diagnostics exist', () => {
|
||||
const result: InventoryResult = {
|
||||
totalFiles: 1,
|
||||
totalCases: 0,
|
||||
repoRoot: '/repo',
|
||||
files: [
|
||||
{
|
||||
filePath: '/repo/evals/bad.eval.ts',
|
||||
relativePath: 'evals/bad.eval.ts',
|
||||
helpers: {},
|
||||
cases: [],
|
||||
diagnostics: [],
|
||||
},
|
||||
],
|
||||
cases: [],
|
||||
diagnostics: [
|
||||
{
|
||||
severity: 'warning',
|
||||
message: 'Could not resolve policy',
|
||||
filePath: '/repo/evals/bad.eval.ts',
|
||||
location: { line: 5, column: 3 },
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
const report = formatInventoryReport(result);
|
||||
|
||||
expect(report).toContain('Diagnostics');
|
||||
expect(report).toContain('1 diagnostics');
|
||||
expect(report).toContain(
|
||||
'⚠ evals/bad.eval.ts:5:3 — Could not resolve policy',
|
||||
);
|
||||
});
|
||||
|
||||
it('omits diagnostics section when there are none', () => {
|
||||
const result: InventoryResult = {
|
||||
totalFiles: 1,
|
||||
totalCases: 1,
|
||||
repoRoot: '/repo',
|
||||
files: [],
|
||||
cases: [makeCaseRecord()],
|
||||
diagnostics: [],
|
||||
};
|
||||
|
||||
const report = formatInventoryReport(result);
|
||||
|
||||
expect(report).not.toContain('Diagnostics');
|
||||
expect(report).not.toContain('⚠');
|
||||
});
|
||||
|
||||
it('includes helper name in case listing', () => {
|
||||
const result: InventoryResult = {
|
||||
totalFiles: 1,
|
||||
totalCases: 1,
|
||||
repoRoot: '/repo',
|
||||
files: [],
|
||||
cases: [
|
||||
makeCaseRecord({
|
||||
helperName: 'customHelper',
|
||||
name: 'custom test',
|
||||
}),
|
||||
],
|
||||
diagnostics: [],
|
||||
};
|
||||
|
||||
const report = formatInventoryReport(result);
|
||||
|
||||
expect(report).toContain('• custom test [customHelper]');
|
||||
});
|
||||
});
|
||||
|
||||
describe('formatInventoryJson', () => {
|
||||
it('snapshot: minimal inventory', () => {
|
||||
const result: InventoryResult = {
|
||||
totalFiles: 1,
|
||||
totalCases: 1,
|
||||
repoRoot: '/repo',
|
||||
files: [],
|
||||
cases: [
|
||||
makeCaseRecord({
|
||||
name: 'basic eval',
|
||||
policy: 'ALWAYS_PASSES',
|
||||
suiteName: 'core',
|
||||
}),
|
||||
],
|
||||
diagnostics: [],
|
||||
};
|
||||
|
||||
const json = formatInventoryJson(result, FIXED_NOW);
|
||||
|
||||
expect(json).toMatchInlineSnapshot(`
|
||||
"{
|
||||
"version": 1,
|
||||
"generated": "2026-06-03T12:00:00.000Z",
|
||||
"summary": {
|
||||
"totalFiles": 1,
|
||||
"totalCases": 1,
|
||||
"totalDiagnostics": 0,
|
||||
"byPolicy": {
|
||||
"ALWAYS_PASSES": 1
|
||||
}
|
||||
},
|
||||
"cases": [
|
||||
{
|
||||
"name": "basic eval",
|
||||
"filePath": "evals/test.eval.ts",
|
||||
"helperName": "evalTest",
|
||||
"baseHelperName": "evalTest",
|
||||
"policy": "ALWAYS_PASSES",
|
||||
"suiteName": "core",
|
||||
"suiteType": null,
|
||||
"timeout": null,
|
||||
"hasFiles": false,
|
||||
"hasPrompt": true,
|
||||
"location": {
|
||||
"line": 1,
|
||||
"column": 1
|
||||
}
|
||||
}
|
||||
],
|
||||
"diagnostics": []
|
||||
}"
|
||||
`);
|
||||
});
|
||||
|
||||
it('snapshot: mixed policies with diagnostics', () => {
|
||||
const result: InventoryResult = {
|
||||
totalFiles: 2,
|
||||
totalCases: 3,
|
||||
repoRoot: '/repo',
|
||||
files: [
|
||||
{
|
||||
filePath: '/repo/evals/c.eval.ts',
|
||||
relativePath: 'evals/c.eval.ts',
|
||||
helpers: {},
|
||||
cases: [],
|
||||
diagnostics: [],
|
||||
},
|
||||
],
|
||||
cases: [
|
||||
makeCaseRecord({
|
||||
name: 'stable test',
|
||||
policy: 'ALWAYS_PASSES',
|
||||
relativePath: 'evals/a.eval.ts',
|
||||
}),
|
||||
makeCaseRecord({
|
||||
name: 'flaky test',
|
||||
policy: 'USUALLY_PASSES',
|
||||
suiteName: 'tools',
|
||||
suiteType: 'behavioral',
|
||||
relativePath: 'evals/b.eval.ts',
|
||||
}),
|
||||
makeCaseRecord({
|
||||
name: 'failing test',
|
||||
policy: 'USUALLY_FAILS',
|
||||
timeout: 30000,
|
||||
hasFiles: true,
|
||||
relativePath: 'evals/b.eval.ts',
|
||||
}),
|
||||
],
|
||||
diagnostics: [
|
||||
{
|
||||
severity: 'warning',
|
||||
message: 'Could not resolve policy',
|
||||
filePath: '/repo/evals/c.eval.ts',
|
||||
location: { line: 10, column: 5 },
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
const json = formatInventoryJson(result, FIXED_NOW);
|
||||
|
||||
expect(json).toMatchInlineSnapshot(`
|
||||
"{
|
||||
"version": 1,
|
||||
"generated": "2026-06-03T12:00:00.000Z",
|
||||
"summary": {
|
||||
"totalFiles": 2,
|
||||
"totalCases": 3,
|
||||
"totalDiagnostics": 1,
|
||||
"byPolicy": {
|
||||
"ALWAYS_PASSES": 1,
|
||||
"USUALLY_PASSES": 1,
|
||||
"USUALLY_FAILS": 1
|
||||
}
|
||||
},
|
||||
"cases": [
|
||||
{
|
||||
"name": "stable test",
|
||||
"filePath": "evals/a.eval.ts",
|
||||
"helperName": "evalTest",
|
||||
"baseHelperName": "evalTest",
|
||||
"policy": "ALWAYS_PASSES",
|
||||
"suiteName": null,
|
||||
"suiteType": null,
|
||||
"timeout": null,
|
||||
"hasFiles": false,
|
||||
"hasPrompt": true,
|
||||
"location": {
|
||||
"line": 1,
|
||||
"column": 1
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "flaky test",
|
||||
"filePath": "evals/b.eval.ts",
|
||||
"helperName": "evalTest",
|
||||
"baseHelperName": "evalTest",
|
||||
"policy": "USUALLY_PASSES",
|
||||
"suiteName": "tools",
|
||||
"suiteType": "behavioral",
|
||||
"timeout": null,
|
||||
"hasFiles": false,
|
||||
"hasPrompt": true,
|
||||
"location": {
|
||||
"line": 1,
|
||||
"column": 1
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "failing test",
|
||||
"filePath": "evals/b.eval.ts",
|
||||
"helperName": "evalTest",
|
||||
"baseHelperName": "evalTest",
|
||||
"policy": "USUALLY_FAILS",
|
||||
"suiteName": null,
|
||||
"suiteType": null,
|
||||
"timeout": 30000,
|
||||
"hasFiles": true,
|
||||
"hasPrompt": true,
|
||||
"location": {
|
||||
"line": 1,
|
||||
"column": 1
|
||||
}
|
||||
}
|
||||
],
|
||||
"diagnostics": [
|
||||
{
|
||||
"severity": "warning",
|
||||
"message": "Could not resolve policy",
|
||||
"filePath": "evals/c.eval.ts",
|
||||
"location": {
|
||||
"line": 10,
|
||||
"column": 5
|
||||
}
|
||||
}
|
||||
]
|
||||
}"
|
||||
`);
|
||||
});
|
||||
|
||||
it('snapshot: empty inventory', () => {
|
||||
const result: InventoryResult = makeEmptyResult();
|
||||
|
||||
const json = formatInventoryJson(result, FIXED_NOW);
|
||||
|
||||
expect(json).toMatchInlineSnapshot(`
|
||||
"{
|
||||
"version": 1,
|
||||
"generated": "2026-06-03T12:00:00.000Z",
|
||||
"summary": {
|
||||
"totalFiles": 0,
|
||||
"totalCases": 0,
|
||||
"totalDiagnostics": 0,
|
||||
"byPolicy": {}
|
||||
},
|
||||
"cases": [],
|
||||
"diagnostics": []
|
||||
}"
|
||||
`);
|
||||
});
|
||||
|
||||
it('produces valid JSON with version field', () => {
|
||||
const result: InventoryResult = {
|
||||
...makeEmptyResult(),
|
||||
totalFiles: 1,
|
||||
totalCases: 1,
|
||||
cases: [makeCaseRecord()],
|
||||
};
|
||||
|
||||
const json = formatInventoryJson(result, FIXED_NOW);
|
||||
const parsed: InventoryJsonOutput = JSON.parse(json);
|
||||
|
||||
expect(parsed.version).toBe(1);
|
||||
});
|
||||
|
||||
it('includes correct summary counts', () => {
|
||||
const result: InventoryResult = {
|
||||
totalFiles: 3,
|
||||
totalCases: 4,
|
||||
repoRoot: '/repo',
|
||||
files: [],
|
||||
cases: [
|
||||
makeCaseRecord({ policy: 'ALWAYS_PASSES' }),
|
||||
makeCaseRecord({ policy: 'ALWAYS_PASSES' }),
|
||||
makeCaseRecord({ policy: 'USUALLY_PASSES' }),
|
||||
makeCaseRecord({ policy: 'USUALLY_FAILS' }),
|
||||
],
|
||||
diagnostics: [
|
||||
{
|
||||
severity: 'warning',
|
||||
message: 'test',
|
||||
filePath: 'test.ts',
|
||||
location: { line: 1, column: 1 },
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
const parsed: InventoryJsonOutput = JSON.parse(
|
||||
formatInventoryJson(result, FIXED_NOW),
|
||||
);
|
||||
|
||||
expect(parsed.summary).toEqual({
|
||||
totalFiles: 3,
|
||||
totalCases: 4,
|
||||
totalDiagnostics: 1,
|
||||
byPolicy: {
|
||||
ALWAYS_PASSES: 2,
|
||||
USUALLY_PASSES: 1,
|
||||
USUALLY_FAILS: 1,
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
it('maps case fields correctly with nulls for missing optionals', () => {
|
||||
const result: InventoryResult = {
|
||||
totalFiles: 1,
|
||||
totalCases: 1,
|
||||
repoRoot: '/repo',
|
||||
files: [],
|
||||
cases: [
|
||||
makeCaseRecord({
|
||||
name: 'detailed case',
|
||||
relativePath: 'evals/detail.eval.ts',
|
||||
helperName: 'appEvalTest',
|
||||
baseHelperName: 'appEvalTest',
|
||||
policy: 'USUALLY_PASSES',
|
||||
hasFiles: true,
|
||||
hasPrompt: true,
|
||||
location: { line: 42, column: 3 },
|
||||
}),
|
||||
],
|
||||
diagnostics: [],
|
||||
};
|
||||
|
||||
const parsed: InventoryJsonOutput = JSON.parse(
|
||||
formatInventoryJson(result, FIXED_NOW),
|
||||
);
|
||||
const firstCase = parsed.cases[0];
|
||||
|
||||
expect(firstCase).toEqual({
|
||||
name: 'detailed case',
|
||||
filePath: 'evals/detail.eval.ts',
|
||||
helperName: 'appEvalTest',
|
||||
baseHelperName: 'appEvalTest',
|
||||
policy: 'USUALLY_PASSES',
|
||||
suiteName: null,
|
||||
suiteType: null,
|
||||
timeout: null,
|
||||
hasFiles: true,
|
||||
hasPrompt: true,
|
||||
location: { line: 42, column: 3 },
|
||||
});
|
||||
});
|
||||
|
||||
it('uses relative paths not absolute paths', () => {
|
||||
const result: InventoryResult = {
|
||||
totalFiles: 1,
|
||||
totalCases: 1,
|
||||
repoRoot: '/absolute/repo',
|
||||
files: [
|
||||
{
|
||||
filePath: '/absolute/repo/evals/test.eval.ts',
|
||||
relativePath: 'evals/test.eval.ts',
|
||||
helpers: {},
|
||||
cases: [],
|
||||
diagnostics: [],
|
||||
},
|
||||
],
|
||||
cases: [
|
||||
makeCaseRecord({
|
||||
filePath: '/absolute/repo/evals/test.eval.ts',
|
||||
relativePath: 'evals/test.eval.ts',
|
||||
}),
|
||||
],
|
||||
diagnostics: [
|
||||
{
|
||||
severity: 'warning',
|
||||
message: 'test diagnostic',
|
||||
filePath: '/absolute/repo/evals/test.eval.ts',
|
||||
location: { line: 1, column: 1 },
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
const json = formatInventoryJson(result, FIXED_NOW);
|
||||
|
||||
expect(json).not.toContain('/absolute/repo');
|
||||
expect(json).toContain('evals/test.eval.ts');
|
||||
|
||||
const parsed: InventoryJsonOutput = JSON.parse(json);
|
||||
expect(parsed.diagnostics[0].filePath).toBe('evals/test.eval.ts');
|
||||
});
|
||||
|
||||
it('relativizes absolute diagnostic path not in file lookup using repoRoot', () => {
|
||||
const repoRoot = '/repo';
|
||||
const result: InventoryResult = {
|
||||
totalFiles: 1,
|
||||
totalCases: 0,
|
||||
repoRoot,
|
||||
files: [
|
||||
{
|
||||
filePath: '/repo/evals/known.eval.ts',
|
||||
relativePath: 'evals/known.eval.ts',
|
||||
helpers: {},
|
||||
cases: [],
|
||||
diagnostics: [],
|
||||
},
|
||||
],
|
||||
cases: [],
|
||||
diagnostics: [
|
||||
{
|
||||
severity: 'warning',
|
||||
message: 'cross-file diagnostic',
|
||||
filePath: '/repo/evals/other.eval.ts',
|
||||
location: { line: 1, column: 1 },
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
const json = formatInventoryJson(result, FIXED_NOW);
|
||||
const parsed: InventoryJsonOutput = JSON.parse(json);
|
||||
|
||||
expect(parsed.diagnostics[0].filePath).toBe('evals/other.eval.ts');
|
||||
expect(parsed.diagnostics[0].filePath).not.toMatch(/^\//);
|
||||
});
|
||||
|
||||
it('includes policies not listed in POLICY_ORDER in byPolicy', () => {
|
||||
const result: InventoryResult = {
|
||||
totalFiles: 1,
|
||||
totalCases: 2,
|
||||
repoRoot: '/repo',
|
||||
files: [],
|
||||
cases: [
|
||||
makeCaseRecord({ policy: 'ALWAYS_PASSES' }),
|
||||
makeCaseRecord({ policy: 'unknown' }),
|
||||
],
|
||||
diagnostics: [],
|
||||
};
|
||||
|
||||
const parsed: InventoryJsonOutput = JSON.parse(
|
||||
formatInventoryJson(result, FIXED_NOW),
|
||||
);
|
||||
|
||||
expect(parsed.summary.byPolicy).toEqual({
|
||||
ALWAYS_PASSES: 1,
|
||||
unknown: 1,
|
||||
});
|
||||
|
||||
const sum = Object.values(parsed.summary.byPolicy).reduce(
|
||||
(a, b) => a + b,
|
||||
0,
|
||||
);
|
||||
expect(sum).toBe(parsed.summary.totalCases);
|
||||
});
|
||||
|
||||
it('emits deterministic output', () => {
|
||||
const result: InventoryResult = {
|
||||
totalFiles: 1,
|
||||
totalCases: 2,
|
||||
repoRoot: '/repo',
|
||||
files: [],
|
||||
cases: [
|
||||
makeCaseRecord({ name: 'a', policy: 'ALWAYS_PASSES' }),
|
||||
makeCaseRecord({ name: 'b', policy: 'USUALLY_PASSES' }),
|
||||
],
|
||||
diagnostics: [],
|
||||
};
|
||||
|
||||
const first = formatInventoryJson(result, FIXED_NOW);
|
||||
const second = formatInventoryJson(result, FIXED_NOW);
|
||||
|
||||
expect(first).toBe(second);
|
||||
});
|
||||
|
||||
it('generated field is valid ISO-8601', () => {
|
||||
const result: InventoryResult = makeEmptyResult();
|
||||
|
||||
const parsed: InventoryJsonOutput = JSON.parse(
|
||||
formatInventoryJson(result),
|
||||
);
|
||||
|
||||
const date = new Date(parsed.generated);
|
||||
expect(date.getTime()).not.toBeNaN();
|
||||
expect(parsed.generated).toMatch(
|
||||
/^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}.\d{3}Z$/,
|
||||
);
|
||||
});
|
||||
|
||||
describe('environment overrides for timestamp', () => {
|
||||
afterEach(() => {
|
||||
vi.unstubAllEnvs();
|
||||
});
|
||||
|
||||
it('uses SOURCE_DATE_EPOCH if set', () => {
|
||||
vi.stubEnv('SOURCE_DATE_EPOCH', '1700000000');
|
||||
const result: InventoryResult = makeEmptyResult();
|
||||
const parsed: InventoryJsonOutput = JSON.parse(
|
||||
formatInventoryJson(result),
|
||||
);
|
||||
expect(parsed.generated).toBe('2023-11-14T22:13:20.000Z');
|
||||
});
|
||||
|
||||
it('uses epoch 0 if EVAL_INVENTORY_STABLE_DATE is set', () => {
|
||||
vi.stubEnv('EVAL_INVENTORY_STABLE_DATE', '1');
|
||||
const result: InventoryResult = makeEmptyResult();
|
||||
const parsed: InventoryJsonOutput = JSON.parse(
|
||||
formatInventoryJson(result),
|
||||
);
|
||||
expect(parsed.generated).toBe('1970-01-01T00:00:00.000Z');
|
||||
});
|
||||
|
||||
it('uses epoch 0 if EVAL_INVENTORY_DETERMINISTIC is set', () => {
|
||||
vi.stubEnv('EVAL_INVENTORY_DETERMINISTIC', 'true');
|
||||
const result: InventoryResult = makeEmptyResult();
|
||||
const parsed: InventoryJsonOutput = JSON.parse(
|
||||
formatInventoryJson(result),
|
||||
);
|
||||
expect(parsed.generated).toBe('1970-01-01T00:00:00.000Z');
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,71 @@
|
||||
/**
|
||||
* @license
|
||||
* Copyright 2025 Google LLC
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import {
|
||||
main as generateKeybindingDocs,
|
||||
renderDocumentation,
|
||||
type KeybindingDocSection,
|
||||
} from '../generate-keybindings-doc.ts';
|
||||
import { KeyBinding } from '../../packages/cli/src/ui/key/keyBindings.js';
|
||||
|
||||
describe('generate-keybindings-doc', () => {
|
||||
it('keeps keyboard shortcut documentation in sync in check mode', async () => {
|
||||
const previousExitCode = process.exitCode;
|
||||
try {
|
||||
process.exitCode = 0;
|
||||
await expect(
|
||||
generateKeybindingDocs(['--check']),
|
||||
).resolves.toBeUndefined();
|
||||
expect(process.exitCode).toBe(0);
|
||||
} finally {
|
||||
process.exitCode = previousExitCode;
|
||||
}
|
||||
});
|
||||
|
||||
it('renders provided sections into markdown tables', () => {
|
||||
const sections: KeybindingDocSection[] = [
|
||||
{
|
||||
title: 'Custom Controls',
|
||||
commands: [
|
||||
{
|
||||
command: 'custom.trigger',
|
||||
description: 'Trigger custom action.',
|
||||
bindings: [new KeyBinding('ctrl+x')],
|
||||
},
|
||||
{
|
||||
command: 'custom.submit',
|
||||
description: 'Submit with Enter if no modifiers are held.',
|
||||
bindings: [new KeyBinding('enter')],
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
title: 'Navigation',
|
||||
commands: [
|
||||
{
|
||||
command: 'nav.up',
|
||||
description: 'Move up through results.',
|
||||
bindings: [new KeyBinding('up'), new KeyBinding('ctrl+p')],
|
||||
},
|
||||
],
|
||||
},
|
||||
];
|
||||
|
||||
const markdown = renderDocumentation(sections);
|
||||
expect(markdown).toContain('#### Custom Controls');
|
||||
expect(markdown).toContain('`custom.trigger`');
|
||||
expect(markdown).toContain('Trigger custom action.');
|
||||
expect(markdown).toContain('`Ctrl+X`');
|
||||
expect(markdown).toContain('`custom.submit`');
|
||||
expect(markdown).toContain('Submit with Enter if no modifiers are held.');
|
||||
expect(markdown).toContain('`Enter`');
|
||||
expect(markdown).toContain('#### Navigation');
|
||||
expect(markdown).toContain('`nav.up`');
|
||||
expect(markdown).toContain('Move up through results.');
|
||||
expect(markdown).toContain('`Up`<br />`Ctrl+P`');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,29 @@
|
||||
/**
|
||||
* @license
|
||||
* Copyright 2025 Google LLC
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
import { describe, it, expect, vi } from 'vitest';
|
||||
import { main as generateDocs } from '../generate-settings-doc.ts';
|
||||
|
||||
vi.mock('fs', () => ({
|
||||
readFileSync: vi.fn().mockReturnValue(''),
|
||||
createWriteStream: vi.fn(() => ({
|
||||
write: vi.fn(),
|
||||
on: vi.fn(),
|
||||
})),
|
||||
}));
|
||||
|
||||
describe('generate-settings-doc', () => {
|
||||
it('keeps documentation in sync in check mode', async () => {
|
||||
const previousExitCode = process.exitCode;
|
||||
try {
|
||||
process.exitCode = 0;
|
||||
await expect(generateDocs(['--check'])).resolves.toBeUndefined();
|
||||
expect(process.exitCode).toBe(0);
|
||||
} finally {
|
||||
process.exitCode = previousExitCode;
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,46 @@
|
||||
/**
|
||||
* @license
|
||||
* Copyright 2025 Google LLC
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
import { describe, expect, it, vi } from 'vitest';
|
||||
import { readFile } from 'node:fs/promises';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
import { dirname, join } from 'node:path';
|
||||
import { main as generateSchema } from '../generate-settings-schema.ts';
|
||||
|
||||
vi.mock('fs', () => ({
|
||||
readFileSync: vi.fn().mockReturnValue(''),
|
||||
writeFileSync: vi.fn(),
|
||||
createWriteStream: vi.fn(() => ({
|
||||
write: vi.fn(),
|
||||
on: vi.fn(),
|
||||
})),
|
||||
}));
|
||||
|
||||
describe('generate-settings-schema', () => {
|
||||
it('keeps schema in sync in check mode', async () => {
|
||||
const previousExitCode = process.exitCode;
|
||||
await expect(generateSchema(['--check'])).resolves.toBeUndefined();
|
||||
expect(process.exitCode).toBe(previousExitCode);
|
||||
});
|
||||
|
||||
it('includes $schema property in generated schema', async () => {
|
||||
const __dirname = dirname(fileURLToPath(import.meta.url));
|
||||
const schemaPath = join(__dirname, '../../schemas/settings.schema.json');
|
||||
const schemaContent = await readFile(schemaPath, 'utf-8');
|
||||
const schema = JSON.parse(schemaContent);
|
||||
|
||||
// Verify $schema property exists in the schema's properties
|
||||
expect(schema.properties).toHaveProperty('$schema');
|
||||
expect(schema.properties.$schema).toEqual({
|
||||
type: 'string',
|
||||
title: 'Schema',
|
||||
description:
|
||||
'The URL of the JSON schema for this settings file. Used by editors for validation and autocompletion.',
|
||||
default:
|
||||
'https://raw.githubusercontent.com/google-gemini/gemini-cli/main/schemas/settings.schema.json',
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,209 @@
|
||||
/**
|
||||
* @license
|
||||
* Copyright 2025 Google LLC
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
import { vi, describe, it, expect, beforeEach } from 'vitest';
|
||||
import { getVersion } from '../get-release-version.js';
|
||||
import { execSync } from 'node:child_process';
|
||||
import { readFileSync } from 'node:fs';
|
||||
|
||||
vi.mock('node:child_process');
|
||||
vi.mock('node:fs');
|
||||
|
||||
describe('getVersion', () => {
|
||||
beforeEach(() => {
|
||||
vi.resetAllMocks();
|
||||
vi.setSystemTime(new Date('2025-09-17T00:00:00.000Z'));
|
||||
// Mock package.json being read by getNightlyVersion
|
||||
vi.mocked(readFileSync).mockReturnValue(
|
||||
JSON.stringify({ version: '0.8.0' }),
|
||||
);
|
||||
});
|
||||
|
||||
// This is the base mock for a clean state with no conflicts or rollbacks
|
||||
const mockExecSync = (command) => {
|
||||
// NPM dist-tags
|
||||
if (command.includes('npm view') && command.includes('--tag=latest'))
|
||||
return '0.6.1';
|
||||
if (command.includes('npm view') && command.includes('--tag=preview'))
|
||||
return '0.7.0-preview.1';
|
||||
if (command.includes('npm view') && command.includes('--tag=nightly'))
|
||||
return '0.8.0-nightly.20250916.abcdef';
|
||||
|
||||
// NPM versions list
|
||||
if (command.includes('npm view') && command.includes('versions --json'))
|
||||
return JSON.stringify([
|
||||
'0.6.0',
|
||||
'0.6.1',
|
||||
'0.7.0-preview.0',
|
||||
'0.7.0-preview.1',
|
||||
'0.8.0-nightly.20250916.abcdef',
|
||||
]);
|
||||
|
||||
// Deprecation checks (default to not deprecated)
|
||||
if (command.includes('deprecated')) return '';
|
||||
|
||||
// Git Tag Mocks
|
||||
if (command.includes("git tag -l 'v[0-9].[0-9].[0-9]'")) return 'v0.6.1';
|
||||
if (command.includes("git tag -l 'v*-preview*'")) return 'v0.7.0-preview.1';
|
||||
if (command.includes("git tag -l 'v*-nightly*'"))
|
||||
return 'v0.8.0-nightly.20250916.abcdef';
|
||||
|
||||
// Git Hash Mock
|
||||
if (command.includes('git rev-parse --short HEAD')) return 'd3bf8a3d';
|
||||
|
||||
// For doesVersionExist checks - default to not found
|
||||
if (
|
||||
command.includes('npm view') &&
|
||||
command.includes('@google/gemini-cli@')
|
||||
) {
|
||||
throw new Error('NPM version not found');
|
||||
}
|
||||
if (command.includes('git tag -l')) return '';
|
||||
if (command.includes('gh release view')) {
|
||||
throw new Error('GH release not found');
|
||||
}
|
||||
|
||||
return '';
|
||||
};
|
||||
|
||||
describe('Happy Path - Version Calculation', () => {
|
||||
it('should calculate the next stable version from the latest preview', () => {
|
||||
vi.mocked(execSync).mockImplementation(mockExecSync);
|
||||
const result = getVersion({ type: 'stable' });
|
||||
expect(result.releaseVersion).toBe('0.7.0');
|
||||
expect(result.npmTag).toBe('latest');
|
||||
expect(result.previousReleaseTag).toBe('v0.6.1');
|
||||
});
|
||||
|
||||
it('should calculate the next preview version from the latest nightly', () => {
|
||||
vi.mocked(execSync).mockImplementation(mockExecSync);
|
||||
const result = getVersion({
|
||||
type: 'preview',
|
||||
'stable-base-version': '0.7.0',
|
||||
});
|
||||
expect(result.releaseVersion).toBe('0.8.0-preview.0');
|
||||
expect(result.npmTag).toBe('preview');
|
||||
expect(result.previousReleaseTag).toBe('v0.7.0-preview.1');
|
||||
});
|
||||
|
||||
it('should calculate the next nightly version from package.json', () => {
|
||||
vi.mocked(execSync).mockImplementation(mockExecSync);
|
||||
const result = getVersion({ type: 'nightly' });
|
||||
// Note: The base version now comes from package.json, not the previous nightly tag.
|
||||
expect(result.releaseVersion).toBe('0.8.0-nightly.20250917.gd3bf8a3d');
|
||||
expect(result.npmTag).toBe('nightly');
|
||||
expect(result.previousReleaseTag).toBe('v0.8.0-nightly.20250916.abcdef');
|
||||
});
|
||||
|
||||
it('should calculate the next patch version for a stable release', () => {
|
||||
vi.mocked(execSync).mockImplementation(mockExecSync);
|
||||
const result = getVersion({ type: 'patch', 'patch-from': 'stable' });
|
||||
expect(result.releaseVersion).toBe('0.6.2');
|
||||
expect(result.npmTag).toBe('latest');
|
||||
expect(result.previousReleaseTag).toBe('v0.6.1');
|
||||
});
|
||||
|
||||
it('should calculate the next patch version for a preview release', () => {
|
||||
vi.mocked(execSync).mockImplementation(mockExecSync);
|
||||
const result = getVersion({ type: 'patch', 'patch-from': 'preview' });
|
||||
expect(result.releaseVersion).toBe('0.7.0-preview.2');
|
||||
expect(result.npmTag).toBe('preview');
|
||||
expect(result.previousReleaseTag).toBe('v0.7.0-preview.1');
|
||||
});
|
||||
});
|
||||
|
||||
describe('Advanced Scenarios', () => {
|
||||
it('should ignore a deprecated version and use the next highest', () => {
|
||||
const mockWithDeprecated = (command) => {
|
||||
// The highest nightly is 0.9.0, but it's deprecated
|
||||
if (command.includes('npm view') && command.includes('versions --json'))
|
||||
return JSON.stringify([
|
||||
'0.8.0-nightly.20250916.abcdef',
|
||||
'0.9.0-nightly.20250917.deprecated', // This one is deprecated
|
||||
]);
|
||||
// Mock the deprecation check
|
||||
if (
|
||||
command.includes(
|
||||
'npm view @google/gemini-cli@0.9.0-nightly.20250917.deprecated deprecated',
|
||||
)
|
||||
)
|
||||
return 'This version is deprecated';
|
||||
// The dist-tag still points to the older, valid version
|
||||
if (command.includes('npm view') && command.includes('--tag=nightly'))
|
||||
return '0.8.0-nightly.20250916.abcdef';
|
||||
|
||||
return mockExecSync(command);
|
||||
};
|
||||
vi.mocked(execSync).mockImplementation(mockWithDeprecated);
|
||||
|
||||
const result = getVersion({
|
||||
type: 'preview',
|
||||
'stable-base-version': '0.7.0',
|
||||
});
|
||||
// It should base the preview off 0.8.0, not the deprecated 0.9.0
|
||||
expect(result.releaseVersion).toBe('0.8.0-preview.0');
|
||||
});
|
||||
|
||||
it('should auto-increment patch version if the calculated one already exists', () => {
|
||||
const mockWithConflict = (command) => {
|
||||
// The calculated version 0.7.0 already exists as a git tag
|
||||
if (command.includes("git tag -l 'v0.7.0'")) return 'v0.7.0';
|
||||
// The next version, 0.7.1, is available
|
||||
if (command.includes("git tag -l 'v0.7.1'")) return '';
|
||||
|
||||
return mockExecSync(command);
|
||||
};
|
||||
vi.mocked(execSync).mockImplementation(mockWithConflict);
|
||||
|
||||
const result = getVersion({ type: 'stable' });
|
||||
// Should have skipped 0.7.0 and landed on 0.7.1
|
||||
expect(result.releaseVersion).toBe('0.7.1');
|
||||
});
|
||||
|
||||
it('should auto-increment preview number if the calculated one already exists', () => {
|
||||
const mockWithConflict = (command) => {
|
||||
// The calculated preview 0.8.0-preview.0 already exists on NPM
|
||||
if (
|
||||
command.includes(
|
||||
'npm view @google/gemini-cli@0.8.0-preview.0 version',
|
||||
)
|
||||
)
|
||||
return '0.8.0-preview.0';
|
||||
// The next one is available
|
||||
if (
|
||||
command.includes(
|
||||
'npm view @google/gemini-cli@0.8.0-preview.1 version',
|
||||
)
|
||||
)
|
||||
throw new Error('Not found');
|
||||
|
||||
return mockExecSync(command);
|
||||
};
|
||||
vi.mocked(execSync).mockImplementation(mockWithConflict);
|
||||
|
||||
const result = getVersion({
|
||||
type: 'preview',
|
||||
'stable-base-version': '0.7.0',
|
||||
});
|
||||
// Should have skipped preview.0 and landed on preview.1
|
||||
expect(result.releaseVersion).toBe('0.8.0-preview.1');
|
||||
});
|
||||
|
||||
it('should preserve a git hash with a leading zero via the g prefix', () => {
|
||||
const mockWithLeadingZeroHash = (command) => {
|
||||
// Return an all-numeric hash with a leading zero
|
||||
if (command.includes('git rev-parse --short HEAD')) return '017972622';
|
||||
return mockExecSync(command);
|
||||
};
|
||||
vi.mocked(execSync).mockImplementation(mockWithLeadingZeroHash);
|
||||
|
||||
const result = getVersion({ type: 'nightly' });
|
||||
// The 'g' prefix forces semver to treat this as an alphanumeric
|
||||
// identifier, preventing it from stripping the leading zero.
|
||||
expect(result.releaseVersion).toBe('0.8.0-nightly.20250917.g017972622');
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,368 @@
|
||||
/**
|
||||
* @license
|
||||
* Copyright 2025 Google LLC
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
import { vi, describe, it, expect, beforeEach, afterEach } from 'vitest';
|
||||
import { execSync } from 'node:child_process';
|
||||
import { join } from 'node:path';
|
||||
|
||||
/**
|
||||
* Helper function to run the patch-create-comment script with given parameters
|
||||
*/
|
||||
function runPatchCreateComment(args, env = {}) {
|
||||
const scriptPath = join(
|
||||
process.cwd(),
|
||||
'scripts/releasing/patch-create-comment.js',
|
||||
);
|
||||
const fullEnv = {
|
||||
...process.env,
|
||||
...env,
|
||||
};
|
||||
|
||||
try {
|
||||
const result = execSync(`node ${scriptPath} ${args}`, {
|
||||
encoding: 'utf8',
|
||||
env: fullEnv,
|
||||
});
|
||||
return { stdout: result, stderr: '', success: true };
|
||||
} catch (error) {
|
||||
return {
|
||||
stdout: error.stdout || '',
|
||||
stderr: error.stderr || '',
|
||||
success: false,
|
||||
code: error.status,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
describe('patch-create-comment', () => {
|
||||
beforeEach(() => {
|
||||
vi.stubEnv();
|
||||
// Always run in test mode to avoid GitHub API calls
|
||||
vi.stubEnv('TEST_MODE', 'true');
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.clearAllMocks();
|
||||
vi.unstubAllEnvs();
|
||||
});
|
||||
|
||||
describe('Environment flag', () => {
|
||||
it('can be overridden with a flag', () => {
|
||||
vi.stubEnv('ENVIRONMENT', 'dev');
|
||||
const result = runPatchCreateComment(
|
||||
'--original-pr 8655 --exit-code 0 --environment prod --commit abc1234 --channel preview --repository google-gemini/gemini-cli --test',
|
||||
);
|
||||
|
||||
expect(result.success).toBe(true);
|
||||
expect(result.stdout).toContain('🚀 **[Step 2/4] Patch PR Created!**');
|
||||
expect(result.stdout).toContain('Environment**: `prod`');
|
||||
});
|
||||
|
||||
it('reads from the ENVIRONMENT env variable', () => {
|
||||
vi.stubEnv('ENVIRONMENT', 'dev');
|
||||
const result = runPatchCreateComment(
|
||||
'--original-pr 8655 --exit-code 0 --commit abc1234 --channel preview --repository google-gemini/gemini-cli --test',
|
||||
);
|
||||
|
||||
expect(result.success).toBe(true);
|
||||
expect(result.stdout).toContain('🚀 **[Step 2/4] Patch PR Created!**');
|
||||
expect(result.stdout).toContain('Environment**: `dev`');
|
||||
});
|
||||
|
||||
it('fails if the ENVIRONMENT is bogus', () => {
|
||||
vi.stubEnv('ENVIRONMENT', 'totally-bogus');
|
||||
const result = runPatchCreateComment(
|
||||
'--original-pr 8655 --exit-code 0 --commit abc1234 --channel preview --repository google-gemini/gemini-cli --test',
|
||||
);
|
||||
|
||||
expect(result.success).toBe(false);
|
||||
expect(result.stderr).toContain(
|
||||
'Argument: environment, Given: "totally-bogus", Choices: "prod", "dev"',
|
||||
);
|
||||
});
|
||||
|
||||
it('defaults to prod if not specified', () => {
|
||||
const result = runPatchCreateComment(
|
||||
'--original-pr 8655 --exit-code 0 --commit abc1234 --channel preview --repository google-gemini/gemini-cli --test',
|
||||
);
|
||||
|
||||
expect(result.success).toBe(true);
|
||||
expect(result.stdout).toContain('🚀 **[Step 2/4] Patch PR Created!**');
|
||||
expect(result.stdout).toContain('Environment**: `prod`');
|
||||
});
|
||||
});
|
||||
|
||||
describe('Environment Variable vs File Reading', () => {
|
||||
it('should prefer LOG_CONTENT environment variable over file', () => {
|
||||
const result = runPatchCreateComment(
|
||||
'--original-pr 8655 --exit-code 0 --commit abc1234 --channel preview --repository google-gemini/gemini-cli --test',
|
||||
{
|
||||
LOG_CONTENT:
|
||||
'Creating hotfix branch hotfix/v0.5.3/preview/cherry-pick-abc1234 from release/v0.5.3',
|
||||
},
|
||||
);
|
||||
|
||||
expect(result.success).toBe(true);
|
||||
expect(result.stdout).toContain('🚀 **[Step 2/4] Patch PR Created!**');
|
||||
expect(result.stdout).toContain('Channel**: `preview`');
|
||||
expect(result.stdout).toContain('Commit**: `abc1234`');
|
||||
});
|
||||
|
||||
it('should use empty log content when LOG_CONTENT is not set', () => {
|
||||
const result = runPatchCreateComment(
|
||||
'--original-pr 8655 --exit-code 1 --commit abc1234 --channel stable --repository google-gemini/gemini-cli --test',
|
||||
{}, // No LOG_CONTENT env var
|
||||
);
|
||||
|
||||
expect(result.success).toBe(true);
|
||||
expect(result.stdout).toContain(
|
||||
'❌ **[Step 2/4] Patch creation failed!**',
|
||||
);
|
||||
expect(result.stdout).toContain(
|
||||
'There was an error creating the patch release',
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe('Log Content Parsing - Success Scenarios', () => {
|
||||
it('should generate success comment for clean cherry-pick', () => {
|
||||
const result = runPatchCreateComment(
|
||||
'--original-pr 8655 --exit-code 0 --commit abc1234 --channel stable --repository google-gemini/gemini-cli --test',
|
||||
{
|
||||
LOG_CONTENT:
|
||||
'Creating hotfix branch hotfix/v0.4.1/stable/cherry-pick-abc1234 from release/v0.4.1\n✅ Cherry-pick successful - no conflicts detected',
|
||||
},
|
||||
);
|
||||
|
||||
expect(result.success).toBe(true);
|
||||
expect(result.stdout).toContain('🚀 **[Step 2/4] Patch PR Created!**');
|
||||
expect(result.stdout).toContain('Channel**: `stable`');
|
||||
expect(result.stdout).toContain('Commit**: `abc1234`');
|
||||
expect(result.stdout).not.toContain('⚠️ Status');
|
||||
});
|
||||
|
||||
it('should generate conflict comment for cherry-pick with conflicts', () => {
|
||||
const result = runPatchCreateComment(
|
||||
'--original-pr 8655 --exit-code 0 --commit def5678 --channel preview --repository google-gemini/gemini-cli --test',
|
||||
{
|
||||
LOG_CONTENT:
|
||||
'Creating hotfix branch hotfix/v0.5.0-preview.2/preview/cherry-pick-def5678 from release/v0.5.0-preview.2\nCherry-pick has conflicts in 2 file(s):\nCONFLICT (content): Merge conflict in package.json',
|
||||
},
|
||||
);
|
||||
|
||||
expect(result.success).toBe(true);
|
||||
expect(result.stdout).toContain('🚀 **[Step 2/4] Patch PR Created!**');
|
||||
expect(result.stdout).toContain(
|
||||
'⚠️ Status**: Cherry-pick conflicts detected',
|
||||
);
|
||||
expect(result.stdout).toContain(
|
||||
'⚠️ **Resolve conflicts** in the hotfix PR first',
|
||||
);
|
||||
expect(result.stdout).toContain('Channel**: `preview`');
|
||||
});
|
||||
});
|
||||
|
||||
describe('Log Content Parsing - Existing PR Scenarios', () => {
|
||||
it('should detect existing PR and generate appropriate comment', () => {
|
||||
const result = runPatchCreateComment(
|
||||
'--original-pr 8655 --exit-code 0 --commit ghi9012 --channel stable --repository google-gemini/gemini-cli --test',
|
||||
{
|
||||
LOG_CONTENT:
|
||||
'Hotfix branch hotfix/v0.4.1/stable/cherry-pick-ghi9012 already has an open PR.\nFound existing PR #8700: https://github.com/google-gemini/gemini-cli/pull/8700',
|
||||
},
|
||||
);
|
||||
|
||||
expect(result.success).toBe(true);
|
||||
expect(result.stdout).toContain(
|
||||
'ℹ️ **[Step 2/4] Patch PR already exists!**',
|
||||
);
|
||||
expect(result.stdout).toContain(
|
||||
'A patch PR for this change already exists: [#8700](https://github.com/google-gemini/gemini-cli/pull/8700)',
|
||||
);
|
||||
expect(result.stdout).toContain(
|
||||
'Review and approve the existing patch PR',
|
||||
);
|
||||
});
|
||||
|
||||
it('should detect branch exists but no PR scenario', () => {
|
||||
const result = runPatchCreateComment(
|
||||
'--original-pr 8655 --exit-code 0 --commit jkl3456 --channel preview --repository google-gemini/gemini-cli --test',
|
||||
{
|
||||
LOG_CONTENT:
|
||||
'Hotfix branch hotfix/v0.5.0-preview.2/preview/cherry-pick-jkl3456 exists but has no open PR.\nHotfix branch hotfix/v0.5.0-preview.2/preview/cherry-pick-jkl3456 already exists.',
|
||||
},
|
||||
);
|
||||
|
||||
expect(result.success).toBe(true);
|
||||
expect(result.stdout).toContain(
|
||||
'ℹ️ **[Step 2/4] Patch branch exists but no PR found!**',
|
||||
);
|
||||
expect(result.stdout).toContain(
|
||||
'Delete the branch: `git branch -D hotfix/v0.5.0-preview.2/preview/cherry-pick-jkl3456`',
|
||||
);
|
||||
expect(result.stdout).toContain('Run the patch command again');
|
||||
});
|
||||
});
|
||||
|
||||
describe('Log Content Parsing - Failure Scenarios', () => {
|
||||
it('should generate failure comment when exit code is non-zero', () => {
|
||||
const result = runPatchCreateComment(
|
||||
'--original-pr 8655 --exit-code 1 --commit mno7890 --channel stable --repository google-gemini/gemini-cli --run-id 12345 --test',
|
||||
{
|
||||
LOG_CONTENT: 'Error: Failed to create patch',
|
||||
},
|
||||
);
|
||||
|
||||
expect(result.success).toBe(true);
|
||||
expect(result.stdout).toContain(
|
||||
'❌ **[Step 2/4] Patch creation failed!**',
|
||||
);
|
||||
expect(result.stdout).toContain(
|
||||
'There was an error creating the patch release',
|
||||
);
|
||||
expect(result.stdout).toContain(
|
||||
'View workflow run](https://github.com/google-gemini/gemini-cli/actions/runs/12345)',
|
||||
);
|
||||
});
|
||||
|
||||
it('should generate fallback failure comment when no output is generated', () => {
|
||||
const result = runPatchCreateComment(
|
||||
'--original-pr 8655 --exit-code 1 --commit pqr4567 --channel preview --repository google-gemini/gemini-cli --run-id 67890 --test',
|
||||
{
|
||||
LOG_CONTENT: '',
|
||||
},
|
||||
);
|
||||
|
||||
expect(result.success).toBe(true);
|
||||
expect(result.stdout).toContain(
|
||||
'❌ **[Step 2/4] Patch creation failed!**',
|
||||
);
|
||||
expect(result.stdout).toContain(
|
||||
'There was an error creating the patch release',
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe('Channel and NPM Tag Detection', () => {
|
||||
it('should correctly map stable channel to latest npm tag', () => {
|
||||
const result = runPatchCreateComment(
|
||||
'--original-pr 8655 --exit-code 0 --commit stu8901 --channel stable --repository google-gemini/gemini-cli --test',
|
||||
{
|
||||
LOG_CONTENT:
|
||||
'Creating hotfix branch hotfix/v0.4.1/stable/cherry-pick-stu8901 from release/v0.4.1',
|
||||
},
|
||||
);
|
||||
|
||||
expect(result.success).toBe(true);
|
||||
expect(result.stdout).toContain('will publish to npm tag `latest`');
|
||||
});
|
||||
|
||||
it('should correctly map preview channel to preview npm tag', () => {
|
||||
const result = runPatchCreateComment(
|
||||
'--original-pr 8655 --exit-code 0 --commit vwx2345 --channel preview --repository google-gemini/gemini-cli --test',
|
||||
{
|
||||
LOG_CONTENT:
|
||||
'Creating hotfix branch hotfix/v0.5.0-preview.2/preview/cherry-pick-vwx2345 from release/v0.5.0-preview.2',
|
||||
},
|
||||
);
|
||||
|
||||
expect(result.success).toBe(true);
|
||||
expect(result.stdout).toContain('will publish to npm tag `preview`');
|
||||
});
|
||||
});
|
||||
|
||||
describe('No Original PR Scenario', () => {
|
||||
it('should skip comment when no original PR is specified', () => {
|
||||
const result = runPatchCreateComment(
|
||||
'--original-pr 0 --exit-code 0 --commit yza6789 --channel stable --repository google-gemini/gemini-cli --test',
|
||||
{
|
||||
LOG_CONTENT:
|
||||
'Creating hotfix branch hotfix/v0.4.1/stable/cherry-pick-yza6789 from release/v0.4.1',
|
||||
ORIGINAL_PR: '', // Override with empty PR
|
||||
},
|
||||
);
|
||||
|
||||
expect(result.success).toBe(true);
|
||||
expect(result.stdout).toContain(
|
||||
'No original PR specified, skipping comment',
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe('Error Handling', () => {
|
||||
it('should handle empty LOG_CONTENT gracefully', () => {
|
||||
const result = runPatchCreateComment(
|
||||
'--original-pr 8655 --exit-code 1 --commit bcd0123 --channel stable --repository google-gemini/gemini-cli --test',
|
||||
{ LOG_CONTENT: '' }, // Empty log content
|
||||
);
|
||||
|
||||
expect(result.success).toBe(true);
|
||||
expect(result.stdout).toContain(
|
||||
'❌ **[Step 2/4] Patch creation failed!**',
|
||||
);
|
||||
expect(result.stdout).toContain(
|
||||
'There was an error creating the patch release',
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe('GitHub App Permission Scenarios', () => {
|
||||
it('should parse manual commands with clipboard emoji correctly', () => {
|
||||
const result = runPatchCreateComment(
|
||||
'--original-pr 8655 --exit-code 1 --commit abc1234 --channel stable --repository google-gemini/gemini-cli --test',
|
||||
{
|
||||
LOG_CONTENT: `❌ Failed to create release branch due to insufficient GitHub App permissions.
|
||||
|
||||
📋 Please run these commands manually to create the branch:
|
||||
|
||||
\`\`\`bash
|
||||
git checkout -b hotfix/v0.4.1/stable/cherry-pick-abc1234 v0.4.1
|
||||
git push origin hotfix/v0.4.1/stable/cherry-pick-abc1234
|
||||
\`\`\``,
|
||||
},
|
||||
);
|
||||
|
||||
expect(result.success).toBe(true);
|
||||
expect(result.stdout).toContain(
|
||||
'🔒 **[Step 2/4] GitHub App Permission Issue**',
|
||||
);
|
||||
expect(result.stdout).toContain(
|
||||
'Please run these commands manually to create the release branch:',
|
||||
);
|
||||
expect(result.stdout).toContain(
|
||||
'git checkout -b hotfix/v0.4.1/stable/cherry-pick-abc1234 v0.4.1',
|
||||
);
|
||||
expect(result.stdout).toContain(
|
||||
'git push origin hotfix/v0.4.1/stable/cherry-pick-abc1234',
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe('Test Mode Flag', () => {
|
||||
it('should generate mock content in test mode for success', () => {
|
||||
const result = runPatchCreateComment(
|
||||
'--original-pr 8655 --exit-code 0 --commit efg4567 --channel preview --repository google-gemini/gemini-cli --test',
|
||||
);
|
||||
|
||||
expect(result.success).toBe(true);
|
||||
expect(result.stdout).toContain(
|
||||
'🧪 TEST MODE - No API calls will be made',
|
||||
);
|
||||
expect(result.stdout).toContain('🚀 **[Step 2/4] Patch PR Created!**');
|
||||
});
|
||||
|
||||
it('should generate mock content in test mode for failure', () => {
|
||||
const result = runPatchCreateComment(
|
||||
'--original-pr 8655 --exit-code 1 --commit hij8901 --channel stable --repository google-gemini/gemini-cli --test',
|
||||
);
|
||||
|
||||
expect(result.success).toBe(true);
|
||||
expect(result.stdout).toContain(
|
||||
'❌ **[Step 2/4] Patch creation failed!**',
|
||||
);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,80 @@
|
||||
/**
|
||||
* @license
|
||||
* Copyright 2026 Google LLC
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
import { describe, expect, it, vi } from 'vitest';
|
||||
vi.unmock('fs');
|
||||
vi.unmock('node:fs');
|
||||
import * as esbuild from 'esbuild';
|
||||
import path from 'node:path';
|
||||
import { mkdtempSync, writeFileSync, rmSync } from 'node:fs';
|
||||
import { tmpdir } from 'node:os';
|
||||
import { fileURLToPath, pathToFileURL } from 'node:url';
|
||||
|
||||
const __filename = fileURLToPath(import.meta.url);
|
||||
const __dirname = path.dirname(__filename);
|
||||
const projectRoot = path.resolve(__dirname, '../../');
|
||||
|
||||
describe('proxy-agent bundle shape', () => {
|
||||
it('preserves named constructors after ESM splitting', async () => {
|
||||
const tmpDir = mkdtempSync(path.join(tmpdir(), 'gemini-proxy-test-'));
|
||||
const entryFile = path.join(tmpDir, 'entry.ts');
|
||||
|
||||
// Create a minimal entry file that dynamically imports the proxy agents
|
||||
writeFileSync(
|
||||
entryFile,
|
||||
`
|
||||
export async function getAgents() {
|
||||
const httpsMod = await import('https-proxy-agent');
|
||||
const httpMod = await import('http-proxy-agent');
|
||||
return {
|
||||
https: httpsMod,
|
||||
http: httpMod,
|
||||
};
|
||||
}
|
||||
`,
|
||||
);
|
||||
|
||||
// Bundle with the exact same splitting config and aliases as cliConfig
|
||||
await esbuild.build({
|
||||
entryPoints: { gemini: entryFile },
|
||||
outdir: path.join(tmpDir, 'bundle'),
|
||||
bundle: true,
|
||||
splitting: true,
|
||||
format: 'esm',
|
||||
platform: 'node',
|
||||
outExtension: { '.js': '.mjs' },
|
||||
alias: {
|
||||
'https-proxy-agent': path.resolve(
|
||||
projectRoot,
|
||||
'packages/cli/src/patches/https-proxy-agent.ts',
|
||||
),
|
||||
'http-proxy-agent': path.resolve(
|
||||
projectRoot,
|
||||
'packages/cli/src/patches/http-proxy-agent.ts',
|
||||
),
|
||||
},
|
||||
});
|
||||
|
||||
// Import the bundled chunk
|
||||
const bundledEntryUrl = pathToFileURL(
|
||||
path.join(tmpDir, 'bundle/gemini.mjs'),
|
||||
).href;
|
||||
const { getAgents } = await import(bundledEntryUrl);
|
||||
|
||||
const { https, http } = await getAgents();
|
||||
|
||||
// Verify named exports exist
|
||||
expect(typeof https.HttpsProxyAgent).toBe('function');
|
||||
expect(typeof http.HttpProxyAgent).toBe('function');
|
||||
|
||||
// Verify they are constructable
|
||||
expect(() => new https.HttpsProxyAgent('http://127.0.0.1:9')).not.toThrow();
|
||||
expect(() => new http.HttpProxyAgent('http://127.0.0.1:9')).not.toThrow();
|
||||
|
||||
// Cleanup
|
||||
rmSync(tmpDir, { recursive: true, force: true });
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,56 @@
|
||||
/**
|
||||
* @license
|
||||
* Copyright 2025 Google LLC
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
import { vi, describe, it, expect, beforeEach, afterEach } from 'vitest';
|
||||
|
||||
const mockSpawn = vi.fn(() => ({ on: vi.fn(), pid: 123 }));
|
||||
|
||||
vi.mock('node:child_process', () => ({
|
||||
spawn: mockSpawn,
|
||||
execSync: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock('node:fs', () => ({
|
||||
readFileSync: vi.fn(),
|
||||
writeFileSync: vi.fn(),
|
||||
openSync: vi.fn(() => 1),
|
||||
unlinkSync: vi.fn(),
|
||||
mkdirSync: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock('../telemetry_utils.js', () => ({
|
||||
ensureBinary: vi.fn(() => Promise.resolve('/fake/path/to/otelcol-contrib')),
|
||||
waitForPort: vi.fn(() => Promise.resolve()),
|
||||
manageTelemetrySettings: vi.fn(),
|
||||
registerCleanup: vi.fn(),
|
||||
fileExists: vi.fn(() => true), // Assume all files exist for simplicity
|
||||
OTEL_DIR: '/tmp/otel',
|
||||
BIN_DIR: '/tmp/bin',
|
||||
}));
|
||||
|
||||
describe('telemetry_gcp.js', () => {
|
||||
beforeEach(() => {
|
||||
vi.resetModules(); // This is key to re-run the script
|
||||
vi.clearAllMocks();
|
||||
process.env.OTLP_GOOGLE_CLOUD_PROJECT = 'test-project';
|
||||
// Clear the env var before each test
|
||||
delete process.env.GEMINI_CLI_CREDENTIALS_PATH;
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
delete process.env.OTLP_GOOGLE_CLOUD_PROJECT;
|
||||
});
|
||||
|
||||
it('should not set GOOGLE_APPLICATION_CREDENTIALS when env var is not set', async () => {
|
||||
await import('../telemetry_gcp.js');
|
||||
|
||||
expect(mockSpawn).toHaveBeenCalled();
|
||||
const spawnOptions = mockSpawn.mock.calls[0][2];
|
||||
expect(spawnOptions?.env).not.toHaveProperty(
|
||||
'GOOGLE_APPLICATION_CREDENTIALS',
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,15 @@
|
||||
/**
|
||||
* @license
|
||||
* Copyright 2025 Google LLC
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
import { vi } from 'vitest';
|
||||
|
||||
vi.mock('fs', async () => {
|
||||
const actual = await vi.importActual<typeof import('fs')>('fs');
|
||||
return {
|
||||
...actual,
|
||||
appendFileSync: vi.fn(),
|
||||
};
|
||||
});
|
||||
@@ -0,0 +1,139 @@
|
||||
/**
|
||||
* @license
|
||||
* Copyright 2026 Google LLC
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import {
|
||||
buildToolRegistry,
|
||||
resolveToolName,
|
||||
getToolsByCategory,
|
||||
type ToolCategory,
|
||||
} from '../utils/tool-registry.js';
|
||||
|
||||
describe('tool-registry', () => {
|
||||
const registry = buildToolRegistry();
|
||||
|
||||
describe('buildToolRegistry', () => {
|
||||
it('includes all canonical built-in tools', () => {
|
||||
expect(registry.totalTools).toBeGreaterThanOrEqual(26);
|
||||
});
|
||||
|
||||
it('every tool has a valid category', () => {
|
||||
for (const [name, entry] of registry.tools) {
|
||||
expect(entry.category).toBeTruthy();
|
||||
expect(entry.name).toBe(name);
|
||||
}
|
||||
});
|
||||
|
||||
it('byCategory entries match tools map', () => {
|
||||
let categoryTotal = 0;
|
||||
for (const [, entries] of registry.byCategory) {
|
||||
for (const entry of entries) {
|
||||
expect(registry.tools.get(entry.name)).toBe(entry);
|
||||
}
|
||||
categoryTotal += entries.length;
|
||||
}
|
||||
expect(categoryTotal).toBe(registry.totalTools);
|
||||
});
|
||||
|
||||
it('aliasLookup covers every canonical name', () => {
|
||||
for (const name of registry.tools.keys()) {
|
||||
expect(registry.aliasLookup.get(name)).toBe(name);
|
||||
}
|
||||
});
|
||||
|
||||
it('aliasLookup covers every legacy alias', () => {
|
||||
for (const [, entry] of registry.tools) {
|
||||
for (const alias of entry.aliases) {
|
||||
expect(registry.aliasLookup.get(alias)).toBe(entry.name);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
it('is deterministic across calls', () => {
|
||||
const second = buildToolRegistry();
|
||||
expect([...second.tools.keys()]).toEqual([...registry.tools.keys()]);
|
||||
expect(second.totalTools).toBe(registry.totalTools);
|
||||
});
|
||||
});
|
||||
|
||||
describe('resolveToolName', () => {
|
||||
it('resolves canonical names to themselves', () => {
|
||||
expect(resolveToolName(registry, 'grep_search')).toBe('grep_search');
|
||||
expect(resolveToolName(registry, 'run_shell_command')).toBe(
|
||||
'run_shell_command',
|
||||
);
|
||||
});
|
||||
|
||||
it('resolves legacy alias to canonical name', () => {
|
||||
expect(resolveToolName(registry, 'search_file_content')).toBe(
|
||||
'grep_search',
|
||||
);
|
||||
});
|
||||
|
||||
it('returns undefined for unknown tool names', () => {
|
||||
expect(resolveToolName(registry, 'nonexistent_tool')).toBeUndefined();
|
||||
});
|
||||
|
||||
it('returns undefined for empty string', () => {
|
||||
expect(resolveToolName(registry, '')).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
describe('getToolsByCategory', () => {
|
||||
it('returns file-system tools', () => {
|
||||
const tools = getToolsByCategory(registry, 'file-system');
|
||||
const names = tools.map((t) => t.name);
|
||||
expect(names).toContain('glob');
|
||||
expect(names).toContain('grep_search');
|
||||
expect(names).toContain('read_file');
|
||||
expect(names).toContain('write_file');
|
||||
expect(names).toContain('replace');
|
||||
});
|
||||
|
||||
it('returns task-tracker tools', () => {
|
||||
const tools = getToolsByCategory(registry, 'task-tracker');
|
||||
const names = tools.map((t) => t.name);
|
||||
expect(names).toContain('tracker_create_task');
|
||||
expect(names).toContain('tracker_update_task');
|
||||
expect(names).toContain('tracker_get_task');
|
||||
expect(names).toContain('tracker_list_tasks');
|
||||
expect(names).toContain('tracker_add_dependency');
|
||||
expect(names).toContain('tracker_visualize');
|
||||
expect(names).toHaveLength(6);
|
||||
});
|
||||
|
||||
it('returns agent tools', () => {
|
||||
const tools = getToolsByCategory(registry, 'agent');
|
||||
const names = tools.map((t) => t.name);
|
||||
expect(names).toContain('invoke_agent');
|
||||
expect(names).toContain('complete_task');
|
||||
expect(names).toContain('update_topic');
|
||||
});
|
||||
|
||||
it('returns empty array for unknown category', () => {
|
||||
expect(
|
||||
getToolsByCategory(registry, 'nonexistent' as ToolCategory),
|
||||
).toEqual([]);
|
||||
});
|
||||
|
||||
it('every defined category has at least one tool', () => {
|
||||
const expectedCategories: ToolCategory[] = [
|
||||
'file-system',
|
||||
'shell',
|
||||
'web',
|
||||
'planning',
|
||||
'user-interaction',
|
||||
'skills',
|
||||
'task-tracker',
|
||||
'agent',
|
||||
'mcp',
|
||||
];
|
||||
for (const cat of expectedCategories) {
|
||||
expect(getToolsByCategory(registry, cat).length).toBeGreaterThan(0);
|
||||
}
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,26 @@
|
||||
/**
|
||||
* @license
|
||||
* Copyright 2025 Google LLC
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
import { defineConfig } from 'vitest/config';
|
||||
|
||||
export default defineConfig({
|
||||
test: {
|
||||
globals: true,
|
||||
environment: 'node',
|
||||
include: ['scripts/tests/**/*.test.{js,ts}'],
|
||||
setupFiles: ['scripts/tests/test-setup.ts'],
|
||||
coverage: {
|
||||
provider: 'v8',
|
||||
reporter: ['text', 'lcov'],
|
||||
},
|
||||
poolOptions: {
|
||||
threads: {
|
||||
minThreads: 8,
|
||||
maxThreads: 16,
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
@@ -0,0 +1,119 @@
|
||||
/**
|
||||
* @license
|
||||
* Copyright 2025 Google LLC
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
import prettier from 'prettier';
|
||||
|
||||
export async function formatWithPrettier(content: string, filePath: string) {
|
||||
const options = await prettier.resolveConfig(filePath);
|
||||
return prettier.format(content, {
|
||||
...options,
|
||||
filepath: filePath,
|
||||
});
|
||||
}
|
||||
|
||||
export function normalizeForCompare(content: string): string {
|
||||
return content.replace(/\r\n/g, '\n').trimEnd();
|
||||
}
|
||||
|
||||
export function escapeBackticks(value: string): string {
|
||||
return value.replace(/\\/g, '\\\\').replace(/`/g, '\\`');
|
||||
}
|
||||
|
||||
export interface FormatDefaultValueOptions {
|
||||
/**
|
||||
* When true, string values are JSON-stringified, including surrounding quotes.
|
||||
* Defaults to false to return raw string content.
|
||||
*/
|
||||
quoteStrings?: boolean;
|
||||
}
|
||||
|
||||
export function formatDefaultValue(
|
||||
value: unknown,
|
||||
options: FormatDefaultValueOptions = {},
|
||||
): string {
|
||||
const { quoteStrings = false } = options;
|
||||
|
||||
if (value === undefined) {
|
||||
return 'undefined';
|
||||
}
|
||||
|
||||
if (value === null) {
|
||||
return 'null';
|
||||
}
|
||||
|
||||
if (typeof value === 'string') {
|
||||
return quoteStrings ? JSON.stringify(value) : value;
|
||||
}
|
||||
|
||||
if (typeof value === 'number' || typeof value === 'boolean') {
|
||||
return String(value);
|
||||
}
|
||||
|
||||
if (Array.isArray(value)) {
|
||||
if (value.length === 0) {
|
||||
return '[]';
|
||||
}
|
||||
try {
|
||||
return JSON.stringify(value, null, 2);
|
||||
} catch {
|
||||
return String(value);
|
||||
}
|
||||
}
|
||||
|
||||
if (typeof value === 'object') {
|
||||
try {
|
||||
const json = JSON.stringify(value, null, 2);
|
||||
if (json === '{}') {
|
||||
return '{}';
|
||||
}
|
||||
return json;
|
||||
} catch {
|
||||
return '[object Object]';
|
||||
}
|
||||
}
|
||||
|
||||
try {
|
||||
return JSON.stringify(value);
|
||||
} catch {
|
||||
return String(value);
|
||||
}
|
||||
}
|
||||
|
||||
interface MarkerInsertionOptions {
|
||||
document: string;
|
||||
startMarker: string;
|
||||
endMarker: string;
|
||||
newContent: string;
|
||||
paddingBefore?: string;
|
||||
paddingAfter?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Replaces the content between two markers with `newContent`, preserving the
|
||||
* original document outside the markers and applying optional padding.
|
||||
*/
|
||||
export function injectBetweenMarkers({
|
||||
document,
|
||||
startMarker,
|
||||
endMarker,
|
||||
newContent,
|
||||
paddingBefore = '\n',
|
||||
paddingAfter = '\n',
|
||||
}: MarkerInsertionOptions): string {
|
||||
const startIndex = document.indexOf(startMarker);
|
||||
const endIndex = document.indexOf(endMarker);
|
||||
|
||||
if (startIndex === -1 || endIndex === -1 || startIndex >= endIndex) {
|
||||
throw new Error(
|
||||
`Could not locate documentation markers (${startMarker}, ${endMarker}).`,
|
||||
);
|
||||
}
|
||||
|
||||
const before = document.slice(0, startIndex + startMarker.length);
|
||||
const after = document.slice(endIndex);
|
||||
|
||||
return `${before}${paddingBefore}${newContent}${paddingAfter}${after}`;
|
||||
}
|
||||
@@ -0,0 +1,680 @@
|
||||
/**
|
||||
* @license
|
||||
* Copyright 2026 Google LLC
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
import path from 'node:path';
|
||||
import * as ts from 'typescript';
|
||||
import {
|
||||
ALL_BUILTIN_TOOL_NAMES,
|
||||
isValidToolName,
|
||||
} from '@google/gemini-cli-core';
|
||||
import { buildToolRegistry } from './tool-registry.js';
|
||||
|
||||
export const BASE_EVAL_HELPERS = [
|
||||
'evalTest',
|
||||
'appEvalTest',
|
||||
'componentEvalTest',
|
||||
] as const;
|
||||
|
||||
export type BaseEvalHelper = (typeof BASE_EVAL_HELPERS)[number];
|
||||
export type EvalHelperName = BaseEvalHelper | string;
|
||||
export type EvalPolicy =
|
||||
| 'ALWAYS_PASSES'
|
||||
| 'USUALLY_PASSES'
|
||||
| 'USUALLY_FAILS'
|
||||
| 'unknown';
|
||||
|
||||
export interface EvalSourceLocation {
|
||||
line: number;
|
||||
column: number;
|
||||
}
|
||||
|
||||
export interface EvalAnalysisDiagnostic {
|
||||
severity: 'warning';
|
||||
message: string;
|
||||
filePath: string;
|
||||
location: EvalSourceLocation;
|
||||
}
|
||||
|
||||
export interface EvalCaseRecord {
|
||||
filePath: string;
|
||||
relativePath: string;
|
||||
helperName: EvalHelperName;
|
||||
baseHelperName: BaseEvalHelper | 'unknown';
|
||||
policy: EvalPolicy;
|
||||
name: string;
|
||||
suiteName?: string;
|
||||
suiteType?: string;
|
||||
timeout?: number;
|
||||
hasFiles: boolean;
|
||||
hasPrompt: boolean;
|
||||
toolReferences: readonly string[];
|
||||
location: EvalSourceLocation;
|
||||
}
|
||||
|
||||
export interface EvalFileAnalysis {
|
||||
filePath: string;
|
||||
relativePath: string;
|
||||
helpers: Record<string, BaseEvalHelper | 'unknown'>;
|
||||
cases: readonly EvalCaseRecord[];
|
||||
toolReferences: readonly string[];
|
||||
diagnostics: readonly EvalAnalysisDiagnostic[];
|
||||
}
|
||||
|
||||
export interface AnalyzeEvalSourceOptions {
|
||||
filePath?: string;
|
||||
repoRoot?: string;
|
||||
}
|
||||
|
||||
export function analyzeEvalSource(
|
||||
sourceText: string,
|
||||
options: AnalyzeEvalSourceOptions = {},
|
||||
): EvalFileAnalysis {
|
||||
const filePath = options.filePath ?? '<inline>';
|
||||
const relativePath = getRelativePath(filePath, options.repoRoot);
|
||||
const sourceFile = ts.createSourceFile(
|
||||
filePath,
|
||||
sourceText,
|
||||
ts.ScriptTarget.Latest,
|
||||
true,
|
||||
getScriptKind(filePath),
|
||||
);
|
||||
|
||||
const helpers = collectHelperMappings(sourceFile);
|
||||
const importedConstants = collectImportedToolNameConstants(sourceFile);
|
||||
const diagnostics: EvalAnalysisDiagnostic[] = [];
|
||||
const cases: EvalCaseRecord[] = [];
|
||||
|
||||
collectEvalCalls(sourceFile, helpers, (callExpression, helperName) => {
|
||||
const args = callExpression.arguments;
|
||||
const policyArg = args[0];
|
||||
const evalCaseArg = args[1];
|
||||
const policy = policyArg ? getStringLiteralValue(policyArg) : undefined;
|
||||
const evalCase =
|
||||
evalCaseArg && ts.isObjectLiteralExpression(evalCaseArg)
|
||||
? evalCaseArg
|
||||
: undefined;
|
||||
|
||||
if (!policy || !isEvalPolicy(policy)) {
|
||||
diagnostics.push({
|
||||
severity: 'warning',
|
||||
message: `Could not statically resolve policy for ${helperName} call.`,
|
||||
filePath,
|
||||
location: getLocation(sourceFile, policyArg ?? callExpression),
|
||||
});
|
||||
}
|
||||
|
||||
if (!evalCase) {
|
||||
diagnostics.push({
|
||||
severity: 'warning',
|
||||
message: `Could not statically resolve eval case object for ${helperName} call.`,
|
||||
filePath,
|
||||
location: getLocation(sourceFile, evalCaseArg ?? callExpression),
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
const name = getStaticStringProperty(evalCase, 'name');
|
||||
if (!name) {
|
||||
diagnostics.push({
|
||||
severity: 'warning',
|
||||
message: `Could not statically resolve eval case name for ${helperName} call.`,
|
||||
filePath,
|
||||
location: getLocation(sourceFile, evalCase),
|
||||
});
|
||||
}
|
||||
|
||||
const assertProp = getPropertyAssignment(evalCase, 'assert');
|
||||
const assertBody = assertProp
|
||||
? getFunctionBody(assertProp.initializer)
|
||||
: undefined;
|
||||
const toolRefsInfo = assertBody
|
||||
? collectToolReferences(assertBody, importedConstants)
|
||||
: [];
|
||||
|
||||
const toolRefs: string[] = [];
|
||||
const registry = buildToolRegistry();
|
||||
|
||||
for (const { name: resolvedName, node } of toolRefsInfo) {
|
||||
const canonicalName = registry.aliasLookup.get(resolvedName);
|
||||
if (!canonicalName && !isValidToolName(resolvedName)) {
|
||||
diagnostics.push({
|
||||
severity: 'warning',
|
||||
message: `Unrecognized tool name extracted: "${resolvedName}"`,
|
||||
filePath,
|
||||
location: getLocation(sourceFile, node),
|
||||
});
|
||||
}
|
||||
toolRefs.push(canonicalName ?? resolvedName);
|
||||
}
|
||||
|
||||
cases.push({
|
||||
filePath,
|
||||
relativePath,
|
||||
helperName,
|
||||
baseHelperName: helpers[helperName] ?? 'unknown',
|
||||
policy: isEvalPolicy(policy) ? policy : 'unknown',
|
||||
name: name ?? '<unknown>',
|
||||
suiteName: getStaticStringProperty(evalCase, 'suiteName'),
|
||||
suiteType: getStaticStringProperty(evalCase, 'suiteType'),
|
||||
timeout: getStaticNumberProperty(evalCase, 'timeout'),
|
||||
hasFiles: hasProperty(evalCase, 'files'),
|
||||
hasPrompt: hasProperty(evalCase, 'prompt'),
|
||||
toolReferences: Object.freeze([...new Set(toolRefs)].sort()),
|
||||
location: getLocation(sourceFile, callExpression),
|
||||
});
|
||||
});
|
||||
|
||||
cases.sort(compareEvalCases);
|
||||
|
||||
const fileToolRefs = [
|
||||
...new Set(cases.flatMap((c) => [...c.toolReferences])),
|
||||
].sort();
|
||||
|
||||
return {
|
||||
filePath,
|
||||
relativePath,
|
||||
helpers,
|
||||
cases,
|
||||
toolReferences: Object.freeze(fileToolRefs),
|
||||
diagnostics: diagnostics.sort(compareDiagnostics),
|
||||
};
|
||||
}
|
||||
|
||||
function collectHelperMappings(
|
||||
sourceFile: ts.SourceFile,
|
||||
): Record<string, BaseEvalHelper | 'unknown'> {
|
||||
const helpers: Record<string, BaseEvalHelper | 'unknown'> = {};
|
||||
for (const helper of BASE_EVAL_HELPERS) {
|
||||
helpers[helper] = helper;
|
||||
}
|
||||
|
||||
for (const alias of collectImportedHelperAliases(sourceFile)) {
|
||||
helpers[alias.name] = alias.baseHelper;
|
||||
}
|
||||
|
||||
let changed = true;
|
||||
while (changed) {
|
||||
changed = false;
|
||||
|
||||
const visit = (node: ts.Node) => {
|
||||
const name = getFunctionLikeBindingName(node);
|
||||
if (name && !helpers[name]) {
|
||||
const functionNode = getFunctionLikeNode(node);
|
||||
if (functionNode) {
|
||||
const baseHelper = findCalledHelper(functionNode, helpers);
|
||||
if (
|
||||
baseHelper &&
|
||||
helpers[baseHelper] &&
|
||||
helpers[baseHelper] !== 'unknown'
|
||||
) {
|
||||
helpers[name] = helpers[baseHelper];
|
||||
changed = true;
|
||||
}
|
||||
}
|
||||
}
|
||||
ts.forEachChild(node, visit);
|
||||
};
|
||||
|
||||
visit(sourceFile);
|
||||
}
|
||||
|
||||
return helpers;
|
||||
}
|
||||
|
||||
function collectImportedHelperAliases(sourceFile: ts.SourceFile) {
|
||||
const aliases: Array<{ name: string; baseHelper: BaseEvalHelper }> = [];
|
||||
|
||||
for (const statement of sourceFile.statements) {
|
||||
if (
|
||||
!ts.isImportDeclaration(statement) ||
|
||||
!statement.importClause?.namedBindings ||
|
||||
!ts.isNamedImports(statement.importClause.namedBindings)
|
||||
) {
|
||||
continue;
|
||||
}
|
||||
|
||||
for (const element of statement.importClause.namedBindings.elements) {
|
||||
const importedName = element.propertyName?.text ?? element.name.text;
|
||||
if (isBaseEvalHelper(importedName)) {
|
||||
aliases.push({
|
||||
name: element.name.text,
|
||||
baseHelper: importedName,
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return aliases;
|
||||
}
|
||||
|
||||
function collectEvalCalls(
|
||||
sourceFile: ts.SourceFile,
|
||||
helpers: Record<string, BaseEvalHelper | 'unknown'>,
|
||||
onCall: (callExpression: ts.CallExpression, helperName: string) => void,
|
||||
) {
|
||||
const visit = (node: ts.Node) => {
|
||||
const wrapperName = getFunctionLikeBindingName(node);
|
||||
if (wrapperName && helpers[wrapperName] && !isBaseEvalHelper(wrapperName)) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (ts.isCallExpression(node)) {
|
||||
const helperName = getCalledIdentifierName(node);
|
||||
if (helperName && helpers[helperName]) {
|
||||
onCall(node, helperName);
|
||||
}
|
||||
}
|
||||
|
||||
ts.forEachChild(node, visit);
|
||||
};
|
||||
|
||||
visit(sourceFile);
|
||||
}
|
||||
|
||||
function findCalledHelper(
|
||||
functionNode: ts.Node,
|
||||
helpers: Record<string, BaseEvalHelper | 'unknown'>,
|
||||
): string | undefined {
|
||||
let found: string | undefined;
|
||||
|
||||
const visit = (candidate: ts.Node) => {
|
||||
if (found) {
|
||||
return;
|
||||
}
|
||||
if (
|
||||
candidate !== functionNode &&
|
||||
(ts.isFunctionDeclaration(candidate) ||
|
||||
ts.isFunctionExpression(candidate) ||
|
||||
ts.isArrowFunction(candidate) ||
|
||||
ts.isMethodDeclaration(candidate))
|
||||
) {
|
||||
return;
|
||||
}
|
||||
if (ts.isCallExpression(candidate)) {
|
||||
const helperName = getCalledIdentifierName(candidate);
|
||||
if (helperName && helpers[helperName]) {
|
||||
found = helperName;
|
||||
return;
|
||||
}
|
||||
}
|
||||
ts.forEachChild(candidate, visit);
|
||||
};
|
||||
|
||||
ts.forEachChild(functionNode, visit);
|
||||
return found;
|
||||
}
|
||||
|
||||
function getFunctionLikeBindingName(node: ts.Node) {
|
||||
if (ts.isFunctionDeclaration(node) && node.name) {
|
||||
return node.name.text;
|
||||
}
|
||||
|
||||
if (ts.isVariableDeclaration(node)) {
|
||||
if (
|
||||
ts.isIdentifier(node.name) &&
|
||||
node.initializer &&
|
||||
(ts.isArrowFunction(node.initializer) ||
|
||||
ts.isFunctionExpression(node.initializer))
|
||||
) {
|
||||
return node.name.text;
|
||||
}
|
||||
}
|
||||
|
||||
return undefined;
|
||||
}
|
||||
|
||||
function getFunctionLikeNode(node: ts.Node) {
|
||||
if (ts.isFunctionDeclaration(node)) {
|
||||
return node;
|
||||
}
|
||||
|
||||
if (
|
||||
ts.isVariableDeclaration(node) &&
|
||||
node.initializer &&
|
||||
(ts.isArrowFunction(node.initializer) ||
|
||||
ts.isFunctionExpression(node.initializer))
|
||||
) {
|
||||
return node.initializer;
|
||||
}
|
||||
|
||||
return undefined;
|
||||
}
|
||||
|
||||
function getCalledIdentifierName(callExpression: ts.CallExpression) {
|
||||
return ts.isIdentifier(callExpression.expression)
|
||||
? callExpression.expression.text
|
||||
: undefined;
|
||||
}
|
||||
|
||||
function isBaseEvalHelper(name: string): name is BaseEvalHelper {
|
||||
return BASE_EVAL_HELPERS.includes(name as BaseEvalHelper);
|
||||
}
|
||||
|
||||
function isEvalPolicy(policy: string | undefined): policy is EvalPolicy {
|
||||
return (
|
||||
policy === 'ALWAYS_PASSES' ||
|
||||
policy === 'USUALLY_PASSES' ||
|
||||
policy === 'USUALLY_FAILS'
|
||||
);
|
||||
}
|
||||
|
||||
function hasProperty(objectLiteral: ts.ObjectLiteralExpression, name: string) {
|
||||
return Boolean(getPropertyAssignment(objectLiteral, name));
|
||||
}
|
||||
|
||||
function getStaticStringProperty(
|
||||
objectLiteral: ts.ObjectLiteralExpression,
|
||||
name: string,
|
||||
) {
|
||||
const assignment = getPropertyAssignment(objectLiteral, name);
|
||||
return assignment ? getStringLiteralValue(assignment.initializer) : undefined;
|
||||
}
|
||||
|
||||
function getStaticNumberProperty(
|
||||
objectLiteral: ts.ObjectLiteralExpression,
|
||||
name: string,
|
||||
) {
|
||||
const assignment = getPropertyAssignment(objectLiteral, name);
|
||||
if (!assignment) {
|
||||
return undefined;
|
||||
}
|
||||
const initializer = assignment.initializer;
|
||||
return ts.isNumericLiteral(initializer)
|
||||
? Number(initializer.text)
|
||||
: undefined;
|
||||
}
|
||||
|
||||
function getPropertyAssignment(
|
||||
objectLiteral: ts.ObjectLiteralExpression,
|
||||
name: string,
|
||||
) {
|
||||
return objectLiteral.properties.find((property) => {
|
||||
if (!ts.isPropertyAssignment(property)) {
|
||||
return false;
|
||||
}
|
||||
const propertyName = property.name;
|
||||
return (
|
||||
(ts.isIdentifier(propertyName) || ts.isStringLiteral(propertyName)) &&
|
||||
propertyName.text === name
|
||||
);
|
||||
}) as ts.PropertyAssignment | undefined;
|
||||
}
|
||||
|
||||
function getStringLiteralValue(expression: ts.Expression | undefined) {
|
||||
if (!expression) {
|
||||
return undefined;
|
||||
}
|
||||
if (
|
||||
ts.isStringLiteral(expression) ||
|
||||
ts.isNoSubstitutionTemplateLiteral(expression)
|
||||
) {
|
||||
return expression.text;
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
function getLocation(
|
||||
sourceFile: ts.SourceFile,
|
||||
node: ts.Node,
|
||||
): EvalSourceLocation {
|
||||
const location = sourceFile.getLineAndCharacterOfPosition(
|
||||
node.getStart(sourceFile),
|
||||
);
|
||||
return {
|
||||
line: location.line + 1,
|
||||
column: location.character + 1,
|
||||
};
|
||||
}
|
||||
|
||||
function getRelativePath(filePath: string, repoRoot: string | undefined) {
|
||||
if (filePath === '<inline>') {
|
||||
return filePath;
|
||||
}
|
||||
const relativePath = repoRoot ? path.relative(repoRoot, filePath) : filePath;
|
||||
return relativePath.replace(/\\/g, '/');
|
||||
}
|
||||
|
||||
function getScriptKind(filePath: string) {
|
||||
const extension = path.extname(filePath).toLowerCase();
|
||||
switch (extension) {
|
||||
case '.tsx':
|
||||
return ts.ScriptKind.TSX;
|
||||
case '.jsx':
|
||||
return ts.ScriptKind.JSX;
|
||||
case '.js':
|
||||
case '.mjs':
|
||||
case '.cjs':
|
||||
return ts.ScriptKind.JS;
|
||||
default:
|
||||
return ts.ScriptKind.TS;
|
||||
}
|
||||
}
|
||||
|
||||
function compareEvalCases(left: EvalCaseRecord, right: EvalCaseRecord) {
|
||||
return (
|
||||
compareStrings(left.relativePath, right.relativePath) ||
|
||||
left.location.line - right.location.line ||
|
||||
left.location.column - right.location.column ||
|
||||
compareStrings(left.name, right.name)
|
||||
);
|
||||
}
|
||||
|
||||
function compareDiagnostics(
|
||||
left: EvalAnalysisDiagnostic,
|
||||
right: EvalAnalysisDiagnostic,
|
||||
) {
|
||||
return (
|
||||
compareStrings(left.filePath, right.filePath) ||
|
||||
left.location.line - right.location.line ||
|
||||
left.location.column - right.location.column ||
|
||||
compareStrings(left.message, right.message)
|
||||
);
|
||||
}
|
||||
|
||||
function compareStrings(left: string, right: string) {
|
||||
return left.localeCompare(right, 'en');
|
||||
}
|
||||
|
||||
const TOOL_NAME_TO_CONSTANT: Record<
|
||||
(typeof ALL_BUILTIN_TOOL_NAMES)[number],
|
||||
keyof typeof import('@google/gemini-cli-core')
|
||||
> = {
|
||||
glob: 'GLOB_TOOL_NAME',
|
||||
grep_search: 'GREP_TOOL_NAME',
|
||||
list_directory: 'LS_TOOL_NAME',
|
||||
read_file: 'READ_FILE_TOOL_NAME',
|
||||
run_shell_command: 'SHELL_TOOL_NAME',
|
||||
write_file: 'WRITE_FILE_TOOL_NAME',
|
||||
replace: 'EDIT_TOOL_NAME',
|
||||
google_web_search: 'WEB_SEARCH_TOOL_NAME',
|
||||
write_todos: 'WRITE_TODOS_TOOL_NAME',
|
||||
web_fetch: 'WEB_FETCH_TOOL_NAME',
|
||||
read_many_files: 'READ_MANY_FILES_TOOL_NAME',
|
||||
get_internal_docs: 'GET_INTERNAL_DOCS_TOOL_NAME',
|
||||
activate_skill: 'ACTIVATE_SKILL_TOOL_NAME',
|
||||
ask_user: 'ASK_USER_TOOL_NAME',
|
||||
exit_plan_mode: 'EXIT_PLAN_MODE_TOOL_NAME',
|
||||
enter_plan_mode: 'ENTER_PLAN_MODE_TOOL_NAME',
|
||||
update_topic: 'UPDATE_TOPIC_TOOL_NAME',
|
||||
complete_task: 'COMPLETE_TASK_TOOL_NAME',
|
||||
read_mcp_resource: 'READ_MCP_RESOURCE_TOOL_NAME',
|
||||
list_mcp_resources: 'LIST_MCP_RESOURCES_TOOL_NAME',
|
||||
tracker_create_task: 'TRACKER_CREATE_TASK_TOOL_NAME',
|
||||
tracker_update_task: 'TRACKER_UPDATE_TASK_TOOL_NAME',
|
||||
tracker_get_task: 'TRACKER_GET_TASK_TOOL_NAME',
|
||||
tracker_list_tasks: 'TRACKER_LIST_TASKS_TOOL_NAME',
|
||||
tracker_add_dependency: 'TRACKER_ADD_DEPENDENCY_TOOL_NAME',
|
||||
tracker_visualize: 'TRACKER_VISUALIZE_TOOL_NAME',
|
||||
invoke_agent: 'AGENT_TOOL_NAME',
|
||||
};
|
||||
|
||||
const WELL_KNOWN_TOOL_CONSTANTS: Record<
|
||||
string,
|
||||
(typeof ALL_BUILTIN_TOOL_NAMES)[number]
|
||||
> = Object.fromEntries(
|
||||
Object.entries(TOOL_NAME_TO_CONSTANT).map(([toolName, constantName]) => [
|
||||
constantName,
|
||||
toolName as (typeof ALL_BUILTIN_TOOL_NAMES)[number],
|
||||
]),
|
||||
);
|
||||
|
||||
function collectImportedToolNameConstants(
|
||||
sourceFile: ts.SourceFile,
|
||||
): Map<string, string> {
|
||||
const constants = new Map<string, string>();
|
||||
|
||||
for (const statement of sourceFile.statements) {
|
||||
if (
|
||||
!ts.isImportDeclaration(statement) ||
|
||||
!statement.importClause?.namedBindings ||
|
||||
!ts.isNamedImports(statement.importClause.namedBindings) ||
|
||||
!ts.isStringLiteral(statement.moduleSpecifier) ||
|
||||
statement.moduleSpecifier.text !== '@google/gemini-cli-core'
|
||||
) {
|
||||
continue;
|
||||
}
|
||||
|
||||
for (const element of statement.importClause.namedBindings.elements) {
|
||||
const importedName = element.propertyName?.text ?? element.name.text;
|
||||
const localName = element.name.text;
|
||||
const resolvedValue = WELL_KNOWN_TOOL_CONSTANTS[importedName];
|
||||
if (resolvedValue !== undefined) {
|
||||
constants.set(localName, resolvedValue);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return constants;
|
||||
}
|
||||
|
||||
function getFunctionBody(
|
||||
node: ts.Expression,
|
||||
): ts.ConciseBody | ts.Block | undefined {
|
||||
if (ts.isArrowFunction(node)) {
|
||||
return node.body;
|
||||
}
|
||||
if (ts.isFunctionExpression(node)) {
|
||||
return node.body;
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
function collectToolReferences(
|
||||
body: ts.ConciseBody | ts.Block,
|
||||
importedConstants: Map<string, string>,
|
||||
): { name: string; node: ts.Node }[] {
|
||||
const refs: { name: string; node: ts.Node }[] = [];
|
||||
|
||||
const visit = (node: ts.Node) => {
|
||||
if (ts.isCallExpression(node)) {
|
||||
extractFromWaitForToolCall(node, importedConstants, refs);
|
||||
extractFromArrayIncludes(node, importedConstants, refs);
|
||||
} else if (
|
||||
ts.isBinaryExpression(node) &&
|
||||
node.operatorToken.kind === ts.SyntaxKind.EqualsEqualsEqualsToken
|
||||
) {
|
||||
extractFromToolRequestNameComparison(node, importedConstants, refs);
|
||||
}
|
||||
|
||||
ts.forEachChild(node, visit);
|
||||
};
|
||||
|
||||
visit(body);
|
||||
return refs;
|
||||
}
|
||||
|
||||
function extractFromWaitForToolCall(
|
||||
call: ts.CallExpression,
|
||||
importedConstants: Map<string, string>,
|
||||
refs: { name: string; node: ts.Node }[],
|
||||
) {
|
||||
const expr = call.expression;
|
||||
if (
|
||||
!ts.isPropertyAccessExpression(expr) ||
|
||||
expr.name.text !== 'waitForToolCall'
|
||||
) {
|
||||
return;
|
||||
}
|
||||
const firstArg = call.arguments[0];
|
||||
if (!firstArg) {
|
||||
return;
|
||||
}
|
||||
const resolved = resolveStringValue(firstArg, importedConstants);
|
||||
if (resolved) {
|
||||
refs.push({ name: resolved, node: firstArg });
|
||||
}
|
||||
}
|
||||
|
||||
function isToolRequestName(node: ts.Expression): boolean {
|
||||
return (
|
||||
ts.isPropertyAccessExpression(node) &&
|
||||
node.name.text === 'name' &&
|
||||
ts.isPropertyAccessExpression(node.expression) &&
|
||||
node.expression.name.text === 'toolRequest'
|
||||
);
|
||||
}
|
||||
|
||||
function extractFromToolRequestNameComparison(
|
||||
binary: ts.BinaryExpression,
|
||||
importedConstants: Map<string, string>,
|
||||
refs: { name: string; node: ts.Node }[],
|
||||
) {
|
||||
let valueNode: ts.Expression | undefined;
|
||||
if (isToolRequestName(binary.left)) {
|
||||
valueNode = binary.right;
|
||||
} else if (isToolRequestName(binary.right)) {
|
||||
valueNode = binary.left;
|
||||
}
|
||||
|
||||
if (valueNode) {
|
||||
const resolved = resolveStringValue(valueNode, importedConstants);
|
||||
if (resolved) {
|
||||
refs.push({ name: resolved, node: valueNode });
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function extractFromArrayIncludes(
|
||||
call: ts.CallExpression,
|
||||
importedConstants: Map<string, string>,
|
||||
refs: { name: string; node: ts.Node }[],
|
||||
) {
|
||||
const expr = call.expression;
|
||||
if (!ts.isPropertyAccessExpression(expr) || expr.name.text !== 'includes') {
|
||||
return;
|
||||
}
|
||||
|
||||
const firstArg = call.arguments[0];
|
||||
if (!firstArg || !isToolRequestName(firstArg)) {
|
||||
return;
|
||||
}
|
||||
|
||||
const arrayExpr = expr.expression;
|
||||
if (!ts.isArrayLiteralExpression(arrayExpr)) {
|
||||
return;
|
||||
}
|
||||
|
||||
for (const element of arrayExpr.elements) {
|
||||
const resolved = resolveStringValue(element, importedConstants);
|
||||
if (resolved) {
|
||||
refs.push({ name: resolved, node: element });
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function resolveStringValue(
|
||||
node: ts.Expression,
|
||||
importedConstants: Map<string, string>,
|
||||
): string | undefined {
|
||||
const literal = getStringLiteralValue(node);
|
||||
if (literal !== undefined) {
|
||||
return literal;
|
||||
}
|
||||
if (ts.isIdentifier(node)) {
|
||||
return importedConstants.get(node.text);
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
@@ -0,0 +1,352 @@
|
||||
/**
|
||||
* @license
|
||||
* Copyright 2026 Google LLC
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
import fs from 'node:fs';
|
||||
import path from 'node:path';
|
||||
import { glob } from 'glob';
|
||||
|
||||
import {
|
||||
analyzeEvalSource,
|
||||
type EvalCaseRecord,
|
||||
type EvalFileAnalysis,
|
||||
type EvalAnalysisDiagnostic,
|
||||
type EvalPolicy,
|
||||
} from './eval-analysis.js';
|
||||
|
||||
const POLICY_ORDER: EvalPolicy[] = [
|
||||
'ALWAYS_PASSES',
|
||||
'USUALLY_PASSES',
|
||||
'USUALLY_FAILS',
|
||||
'unknown',
|
||||
];
|
||||
|
||||
export interface InventoryResult {
|
||||
totalFiles: number;
|
||||
totalCases: number;
|
||||
repoRoot: string;
|
||||
files: EvalFileAnalysis[];
|
||||
cases: readonly EvalCaseRecord[];
|
||||
diagnostics: readonly EvalAnalysisDiagnostic[];
|
||||
}
|
||||
|
||||
/**
|
||||
* Discovers all eval files under the given repo root and runs
|
||||
* the static analyzer on each, returning the aggregated results.
|
||||
*/
|
||||
export async function collectInventory(
|
||||
repoRoot: string,
|
||||
): Promise<InventoryResult> {
|
||||
const evalsDir = path.join(repoRoot, 'evals');
|
||||
|
||||
try {
|
||||
const stat = await fs.promises.stat(evalsDir);
|
||||
if (!stat.isDirectory()) {
|
||||
throw new Error(`evals path exists but is not a directory: ${evalsDir}`);
|
||||
}
|
||||
} catch (err: unknown) {
|
||||
if (isNodeError(err) && err.code === 'ENOENT') {
|
||||
throw new Error(
|
||||
`evals directory not found under repo root: ${evalsDir}\n` +
|
||||
`Make sure --root points to the repository root.`,
|
||||
);
|
||||
}
|
||||
throw err;
|
||||
}
|
||||
|
||||
const pattern = '**/*.eval.{ts,tsx}';
|
||||
|
||||
const evalFiles = await glob(pattern, {
|
||||
cwd: evalsDir,
|
||||
absolute: true,
|
||||
nodir: true,
|
||||
});
|
||||
|
||||
evalFiles.sort();
|
||||
|
||||
const files: EvalFileAnalysis[] = [];
|
||||
const allCases: EvalCaseRecord[] = [];
|
||||
const allDiagnostics: EvalAnalysisDiagnostic[] = [];
|
||||
|
||||
for (const filePath of evalFiles) {
|
||||
const sourceText = await fs.promises.readFile(filePath, 'utf-8');
|
||||
const analysis = analyzeEvalSource(sourceText, { filePath, repoRoot });
|
||||
files.push(analysis);
|
||||
allCases.push(...analysis.cases);
|
||||
allDiagnostics.push(...analysis.diagnostics);
|
||||
}
|
||||
|
||||
return {
|
||||
totalFiles: files.length,
|
||||
totalCases: allCases.length,
|
||||
repoRoot,
|
||||
files,
|
||||
cases: allCases,
|
||||
diagnostics: allDiagnostics,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Formats an InventoryResult into a human-readable report string.
|
||||
*/
|
||||
export function formatInventoryReport(result: InventoryResult): string {
|
||||
const lines: string[] = [];
|
||||
|
||||
lines.push('Eval Inventory');
|
||||
lines.push('══════════════');
|
||||
lines.push('');
|
||||
lines.push(
|
||||
`${result.totalFiles} files · ${result.totalCases} cases · ${result.diagnostics.length} diagnostics`,
|
||||
);
|
||||
lines.push('');
|
||||
|
||||
// --- By Policy ---
|
||||
lines.push('By Policy');
|
||||
lines.push('─────────');
|
||||
|
||||
const byPolicyMap = groupBy(result.cases, (c) => c.policy);
|
||||
|
||||
const renderedPolicies = new Set<string>();
|
||||
for (const policy of POLICY_ORDER) {
|
||||
const cases = byPolicyMap.get(policy);
|
||||
if (!cases || cases.length === 0) {
|
||||
continue;
|
||||
}
|
||||
renderedPolicies.add(policy);
|
||||
lines.push(`${policy} (${cases.length} cases)`);
|
||||
|
||||
const byFile = groupBy(cases, (c) => c.relativePath);
|
||||
for (const [filePath, fileCases] of byFile) {
|
||||
lines.push(` ${filePath}`);
|
||||
for (const evalCase of fileCases) {
|
||||
lines.push(` • ${evalCase.name} [${evalCase.helperName}]`);
|
||||
}
|
||||
}
|
||||
lines.push('');
|
||||
}
|
||||
for (const [policy, cases] of byPolicyMap) {
|
||||
if (renderedPolicies.has(policy) || !cases || cases.length === 0) {
|
||||
continue;
|
||||
}
|
||||
lines.push(`${policy} (${cases.length} cases)`);
|
||||
|
||||
const byFile = groupBy(cases, (c) => c.relativePath);
|
||||
for (const [filePath, fileCases] of byFile) {
|
||||
lines.push(` ${filePath}`);
|
||||
for (const evalCase of fileCases) {
|
||||
lines.push(` • ${evalCase.name} [${evalCase.helperName}]`);
|
||||
}
|
||||
}
|
||||
lines.push('');
|
||||
}
|
||||
|
||||
// --- By Suite ---
|
||||
lines.push('By Suite');
|
||||
lines.push('────────');
|
||||
|
||||
const bySuite = groupBy(result.cases, (c) => c.suiteName ?? '(no suite)');
|
||||
const suiteNames = [...bySuite.keys()].sort((a, b) => {
|
||||
if (a === b) return 0;
|
||||
if (a === '(no suite)') return 1;
|
||||
if (b === '(no suite)') return -1;
|
||||
return a.localeCompare(b, 'en');
|
||||
});
|
||||
|
||||
for (const suite of suiteNames) {
|
||||
const cases = bySuite.get(suite)!;
|
||||
lines.push(`${suite} (${cases.length} cases)`);
|
||||
|
||||
for (const evalCase of cases) {
|
||||
lines.push(
|
||||
` • ${evalCase.name} [${evalCase.relativePath}] (${evalCase.policy})`,
|
||||
);
|
||||
}
|
||||
lines.push('');
|
||||
}
|
||||
|
||||
// --- Diagnostics ---
|
||||
if (result.diagnostics.length > 0) {
|
||||
const filePaths = new Map<string, string>();
|
||||
for (const f of result.files) {
|
||||
filePaths.set(f.filePath, f.relativePath);
|
||||
}
|
||||
|
||||
lines.push('Diagnostics');
|
||||
lines.push('───────────');
|
||||
for (const diagnostic of result.diagnostics) {
|
||||
const displayPath = resolveRelativePath(
|
||||
diagnostic.filePath,
|
||||
filePaths,
|
||||
result.repoRoot,
|
||||
);
|
||||
lines.push(
|
||||
`⚠ ${displayPath}:${diagnostic.location.line}:${diagnostic.location.column} — ${diagnostic.message}`,
|
||||
);
|
||||
}
|
||||
lines.push('');
|
||||
}
|
||||
|
||||
return lines.join('\n');
|
||||
}
|
||||
|
||||
export interface InventoryJsonOutput {
|
||||
version: 1;
|
||||
generated: string;
|
||||
summary: {
|
||||
totalFiles: number;
|
||||
totalCases: number;
|
||||
totalDiagnostics: number;
|
||||
byPolicy: Record<string, number>;
|
||||
};
|
||||
cases: InventoryJsonCase[];
|
||||
diagnostics: InventoryJsonDiagnostic[];
|
||||
}
|
||||
|
||||
interface InventoryJsonCase {
|
||||
name: string;
|
||||
filePath: string;
|
||||
helperName: string;
|
||||
baseHelperName: string;
|
||||
policy: string;
|
||||
suiteName: string | null;
|
||||
suiteType: string | null;
|
||||
timeout: number | null;
|
||||
hasFiles: boolean;
|
||||
hasPrompt: boolean;
|
||||
location: { line: number; column: number };
|
||||
}
|
||||
|
||||
interface InventoryJsonDiagnostic {
|
||||
severity: string;
|
||||
message: string;
|
||||
filePath: string;
|
||||
location: { line: number; column: number };
|
||||
}
|
||||
|
||||
export function formatInventoryJson(
|
||||
result: InventoryResult,
|
||||
now?: Date,
|
||||
): string {
|
||||
const filePathLookup = new Map<string, string>();
|
||||
for (const f of result.files) {
|
||||
filePathLookup.set(f.filePath, f.relativePath);
|
||||
}
|
||||
|
||||
const policyCounts = new Map<string, number>();
|
||||
for (const evalCase of result.cases) {
|
||||
policyCounts.set(
|
||||
evalCase.policy,
|
||||
(policyCounts.get(evalCase.policy) ?? 0) + 1,
|
||||
);
|
||||
}
|
||||
|
||||
const byPolicy: Record<string, number> = {};
|
||||
for (const policy of POLICY_ORDER) {
|
||||
const count = policyCounts.get(policy);
|
||||
if (count !== undefined) {
|
||||
byPolicy[policy] = count;
|
||||
}
|
||||
}
|
||||
for (const [policy, count] of policyCounts) {
|
||||
if (!(policy in byPolicy)) {
|
||||
byPolicy[policy] = count;
|
||||
}
|
||||
}
|
||||
|
||||
let generatedDate = now;
|
||||
if (!generatedDate && process.env.SOURCE_DATE_EPOCH) {
|
||||
const epoch = parseInt(process.env.SOURCE_DATE_EPOCH, 10);
|
||||
if (!isNaN(epoch)) {
|
||||
generatedDate = new Date(epoch * 1000);
|
||||
}
|
||||
}
|
||||
if (
|
||||
!generatedDate &&
|
||||
(process.env.EVAL_INVENTORY_STABLE_DATE ||
|
||||
process.env.EVAL_INVENTORY_DETERMINISTIC)
|
||||
) {
|
||||
generatedDate = new Date(0);
|
||||
}
|
||||
if (!generatedDate) {
|
||||
generatedDate = new Date();
|
||||
}
|
||||
|
||||
const output: InventoryJsonOutput = {
|
||||
version: 1,
|
||||
generated: generatedDate.toISOString(),
|
||||
summary: {
|
||||
totalFiles: result.totalFiles,
|
||||
totalCases: result.totalCases,
|
||||
totalDiagnostics: result.diagnostics.length,
|
||||
byPolicy,
|
||||
},
|
||||
cases: result.cases.map((c) => ({
|
||||
name: c.name,
|
||||
filePath: c.relativePath,
|
||||
helperName: c.helperName,
|
||||
baseHelperName: c.baseHelperName,
|
||||
policy: c.policy,
|
||||
suiteName: c.suiteName ?? null,
|
||||
suiteType: c.suiteType ?? null,
|
||||
timeout: c.timeout ?? null,
|
||||
hasFiles: c.hasFiles,
|
||||
hasPrompt: c.hasPrompt,
|
||||
location: { line: c.location.line, column: c.location.column },
|
||||
})),
|
||||
diagnostics: result.diagnostics.map((d) => {
|
||||
const relativePath = resolveRelativePath(
|
||||
d.filePath,
|
||||
filePathLookup,
|
||||
result.repoRoot,
|
||||
);
|
||||
return {
|
||||
severity: d.severity,
|
||||
message: d.message,
|
||||
filePath: relativePath,
|
||||
location: { line: d.location.line, column: d.location.column },
|
||||
};
|
||||
}),
|
||||
};
|
||||
|
||||
return JSON.stringify(output, null, 2);
|
||||
}
|
||||
|
||||
function groupBy<T>(
|
||||
items: readonly T[],
|
||||
keyFn: (item: T) => string,
|
||||
): Map<string, T[]> {
|
||||
const groups = new Map<string, T[]>();
|
||||
for (const item of items) {
|
||||
const key = keyFn(item);
|
||||
const group = groups.get(key);
|
||||
if (group) {
|
||||
group.push(item);
|
||||
} else {
|
||||
groups.set(key, [item]);
|
||||
}
|
||||
}
|
||||
return groups;
|
||||
}
|
||||
|
||||
function resolveRelativePath(
|
||||
filePath: string,
|
||||
lookup: Map<string, string>,
|
||||
baseDir: string,
|
||||
): string {
|
||||
if (filePath === '<inline>') {
|
||||
return filePath;
|
||||
}
|
||||
const mapped = lookup.get(filePath);
|
||||
if (mapped !== undefined) {
|
||||
return mapped;
|
||||
}
|
||||
return path.isAbsolute(filePath)
|
||||
? path.relative(baseDir, filePath).replace(/\\/g, '/')
|
||||
: filePath;
|
||||
}
|
||||
|
||||
function isNodeError(err: unknown): err is NodeJS.ErrnoException {
|
||||
return err instanceof Error && 'code' in err;
|
||||
}
|
||||
@@ -0,0 +1,143 @@
|
||||
/**
|
||||
* @license
|
||||
* Copyright 2026 Google LLC
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
import {
|
||||
ALL_BUILTIN_TOOL_NAMES,
|
||||
TOOL_LEGACY_ALIASES,
|
||||
} from '@google/gemini-cli-core';
|
||||
|
||||
export type ToolCategory =
|
||||
| 'file-system'
|
||||
| 'shell'
|
||||
| 'web'
|
||||
| 'planning'
|
||||
| 'user-interaction'
|
||||
| 'skills'
|
||||
| 'task-tracker'
|
||||
| 'agent'
|
||||
| 'mcp';
|
||||
|
||||
export interface ToolRegistryEntry {
|
||||
name: string;
|
||||
category: ToolCategory;
|
||||
aliases: readonly string[];
|
||||
}
|
||||
|
||||
export interface ToolRegistry {
|
||||
tools: ReadonlyMap<string, ToolRegistryEntry>;
|
||||
totalTools: number;
|
||||
byCategory: ReadonlyMap<ToolCategory, readonly ToolRegistryEntry[]>;
|
||||
aliasLookup: ReadonlyMap<string, string>;
|
||||
}
|
||||
|
||||
const TOOL_CATEGORIES: Record<
|
||||
(typeof ALL_BUILTIN_TOOL_NAMES)[number],
|
||||
ToolCategory
|
||||
> = {
|
||||
glob: 'file-system',
|
||||
grep_search: 'file-system',
|
||||
list_directory: 'file-system',
|
||||
read_file: 'file-system',
|
||||
read_many_files: 'file-system',
|
||||
write_file: 'file-system',
|
||||
replace: 'file-system',
|
||||
run_shell_command: 'shell',
|
||||
google_web_search: 'web',
|
||||
web_fetch: 'web',
|
||||
enter_plan_mode: 'planning',
|
||||
exit_plan_mode: 'planning',
|
||||
write_todos: 'planning',
|
||||
ask_user: 'user-interaction',
|
||||
activate_skill: 'skills',
|
||||
get_internal_docs: 'skills',
|
||||
tracker_create_task: 'task-tracker',
|
||||
tracker_update_task: 'task-tracker',
|
||||
tracker_get_task: 'task-tracker',
|
||||
tracker_list_tasks: 'task-tracker',
|
||||
tracker_add_dependency: 'task-tracker',
|
||||
tracker_visualize: 'task-tracker',
|
||||
invoke_agent: 'agent',
|
||||
complete_task: 'agent',
|
||||
update_topic: 'agent',
|
||||
read_mcp_resource: 'mcp',
|
||||
list_mcp_resources: 'mcp',
|
||||
};
|
||||
|
||||
let registryCache: ToolRegistry | undefined;
|
||||
|
||||
export function buildToolRegistry(): ToolRegistry {
|
||||
if (registryCache) {
|
||||
return registryCache;
|
||||
}
|
||||
|
||||
const tools = new Map<string, ToolRegistryEntry>();
|
||||
const aliasLookup = new Map<string, string>();
|
||||
const categoryGroups = new Map<ToolCategory, ToolRegistryEntry[]>();
|
||||
|
||||
for (const name of ALL_BUILTIN_TOOL_NAMES) {
|
||||
const category = TOOL_CATEGORIES[name];
|
||||
const aliases: string[] = [];
|
||||
|
||||
for (const [legacyName, canonicalName] of Object.entries(
|
||||
TOOL_LEGACY_ALIASES,
|
||||
)) {
|
||||
if (canonicalName === name) {
|
||||
aliases.push(legacyName);
|
||||
aliasLookup.set(legacyName, name);
|
||||
}
|
||||
}
|
||||
|
||||
aliasLookup.set(name, name);
|
||||
|
||||
const entry: ToolRegistryEntry = {
|
||||
name,
|
||||
category,
|
||||
aliases: Object.freeze(aliases),
|
||||
};
|
||||
|
||||
tools.set(name, entry);
|
||||
|
||||
const group = categoryGroups.get(category);
|
||||
if (group) {
|
||||
group.push(entry);
|
||||
} else {
|
||||
categoryGroups.set(category, [entry]);
|
||||
}
|
||||
}
|
||||
|
||||
const frozenCategories = new Map<
|
||||
ToolCategory,
|
||||
readonly ToolRegistryEntry[]
|
||||
>();
|
||||
for (const [cat, entries] of categoryGroups) {
|
||||
frozenCategories.set(cat, Object.freeze(entries));
|
||||
}
|
||||
|
||||
registryCache = {
|
||||
tools,
|
||||
totalTools: tools.size,
|
||||
byCategory: frozenCategories,
|
||||
aliasLookup,
|
||||
};
|
||||
return registryCache;
|
||||
}
|
||||
|
||||
export function resolveToolName(
|
||||
registry: ToolRegistry,
|
||||
name: string,
|
||||
): string | undefined {
|
||||
if (!name) {
|
||||
return undefined;
|
||||
}
|
||||
return registry.aliasLookup.get(name);
|
||||
}
|
||||
|
||||
export function getToolsByCategory(
|
||||
registry: ToolRegistry,
|
||||
category: ToolCategory,
|
||||
): readonly ToolRegistryEntry[] {
|
||||
return registry.byCategory.get(category) ?? [];
|
||||
}
|
||||
@@ -0,0 +1,109 @@
|
||||
/**
|
||||
* @license
|
||||
* Copyright 2025 Google LLC
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
import { execSync } from 'node:child_process';
|
||||
import { readFileSync, writeFileSync } from 'node:fs';
|
||||
import { resolve } from 'node:path';
|
||||
|
||||
// A script to handle versioning and ensure all related changes are in a single, atomic commit.
|
||||
|
||||
function run(command) {
|
||||
console.log(`> ${command}`);
|
||||
execSync(command, { stdio: 'inherit' });
|
||||
}
|
||||
|
||||
function readJson(filePath) {
|
||||
return JSON.parse(readFileSync(filePath, 'utf-8'));
|
||||
}
|
||||
|
||||
function writeJson(filePath, data) {
|
||||
writeFileSync(filePath, JSON.stringify(data, null, 2) + '\n');
|
||||
}
|
||||
|
||||
// 1. Get the version type from the command line arguments.
|
||||
const versionType = process.argv[2];
|
||||
if (!versionType) {
|
||||
console.error('Error: No version type specified.');
|
||||
console.error('Usage: npm run version <patch|minor|major|prerelease>');
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
// 2. Bump the version in the root and all workspace package.json files.
|
||||
run(`npm version ${versionType} --no-git-tag-version --allow-same-version`);
|
||||
|
||||
// 3. Get all workspaces and filter out the one we don't want to version.
|
||||
const workspacesToExclude = [];
|
||||
let lsOutput;
|
||||
try {
|
||||
lsOutput = JSON.parse(
|
||||
execSync('npm ls --workspaces --json --depth=0').toString(),
|
||||
);
|
||||
} catch (e) {
|
||||
// `npm ls` can exit with a non-zero status code if there are issues
|
||||
// with dependencies, but it will still produce the JSON output we need.
|
||||
// We'll try to parse the stdout from the error object.
|
||||
if (e.stdout) {
|
||||
console.warn(
|
||||
'Warning: `npm ls` exited with a non-zero status code. Attempting to proceed with the output.',
|
||||
);
|
||||
try {
|
||||
lsOutput = JSON.parse(e.stdout.toString());
|
||||
} catch (parseError) {
|
||||
console.error(
|
||||
'Error: Failed to parse JSON from `npm ls` output even after `npm ls` failed.',
|
||||
);
|
||||
console.error('npm ls stderr:', e.stderr.toString());
|
||||
console.error('Parse error:', parseError);
|
||||
process.exit(1);
|
||||
}
|
||||
} else {
|
||||
console.error('Error: `npm ls` failed with no output.');
|
||||
console.error(e.stderr?.toString() || e);
|
||||
process.exit(1);
|
||||
}
|
||||
}
|
||||
const allWorkspaces = Object.keys(lsOutput.dependencies || {});
|
||||
const workspacesToVersion = allWorkspaces.filter(
|
||||
(wsName) => !workspacesToExclude.includes(wsName),
|
||||
);
|
||||
|
||||
for (const workspaceName of workspacesToVersion) {
|
||||
run(
|
||||
`npm version ${versionType} --workspace ${workspaceName} --no-git-tag-version --allow-same-version`,
|
||||
);
|
||||
}
|
||||
|
||||
// 4. Get the new version number from the root package.json
|
||||
const rootPackageJsonPath = resolve(process.cwd(), 'package.json');
|
||||
const newVersion = readJson(rootPackageJsonPath).version;
|
||||
|
||||
// 4. Update the sandboxImageUri in the root package.json
|
||||
const rootPackageJson = readJson(rootPackageJsonPath);
|
||||
if (rootPackageJson.config?.sandboxImageUri) {
|
||||
rootPackageJson.config.sandboxImageUri =
|
||||
rootPackageJson.config.sandboxImageUri.replace(/:.*$/, `:${newVersion}`);
|
||||
console.log(`Updated sandboxImageUri in root to use version ${newVersion}`);
|
||||
writeJson(rootPackageJsonPath, rootPackageJson);
|
||||
}
|
||||
|
||||
// 5. Update the sandboxImageUri in the cli package.json
|
||||
const cliPackageJsonPath = resolve(process.cwd(), 'packages/cli/package.json');
|
||||
const cliPackageJson = readJson(cliPackageJsonPath);
|
||||
if (cliPackageJson.config?.sandboxImageUri) {
|
||||
cliPackageJson.config.sandboxImageUri =
|
||||
cliPackageJson.config.sandboxImageUri.replace(/:.*$/, `:${newVersion}`);
|
||||
console.log(
|
||||
`Updated sandboxImageUri in cli package to use version ${newVersion}`,
|
||||
);
|
||||
writeJson(cliPackageJsonPath, cliPackageJson);
|
||||
}
|
||||
|
||||
// 6. Run `npm install` to update package-lock.json.
|
||||
run(
|
||||
'npm install --workspace packages/cli --workspace packages/core --package-lock-only',
|
||||
);
|
||||
|
||||
console.log(`Successfully bumped versions to v${newVersion}.`);
|
||||
Reference in New Issue
Block a user