mirror of
https://github.com/google-gemini/gemini-cli.git
synced 2026-03-26 05:50:56 -07:00
feat(offload): finalize GCE-only architecture and secure fleet management
This commit is contained in:
@@ -9,7 +9,7 @@
|
||||
},
|
||||
"maintainer": {
|
||||
"deepReview": {
|
||||
"remoteHost": "cli",
|
||||
"remoteHost": "bg1",
|
||||
"remoteWorkDir": "~/.offload/workspace",
|
||||
"terminalType": "iterm2",
|
||||
"syncAuth": true,
|
||||
|
||||
108
.gemini/skills/offload/scripts/fleet.ts
Normal file
108
.gemini/skills/offload/scripts/fleet.ts
Normal file
@@ -0,0 +1,108 @@
|
||||
/**
|
||||
* Offload Fleet Manager
|
||||
*
|
||||
* Manages dynamic GCP workers for offloading tasks.
|
||||
*/
|
||||
import { spawnSync } from 'child_process';
|
||||
|
||||
const PROJECT_ID = 'gemini-cli-team-quota';
|
||||
const USER = process.env.USER || 'mattkorwel';
|
||||
const INSTANCE_PREFIX = `gcli-offload-${USER}`;
|
||||
|
||||
async function listWorkers() {
|
||||
console.log(`🔍 Listing Offload Workers for ${USER} in ${PROJECT_ID}...`);
|
||||
|
||||
const result = spawnSync('gcloud', [
|
||||
'compute', 'instances', 'list',
|
||||
'--project', PROJECT_ID,
|
||||
'--filter', `name~^${INSTANCE_PREFIX}`,
|
||||
'--format', 'table(name,zone,status,networkInterfaces[0].networkIP:label=INTERNAL_IP,creationTimestamp)'
|
||||
], { stdio: 'inherit' });
|
||||
|
||||
if (result.status !== 0) {
|
||||
console.error('\n❌ Failed to list workers. Ensure you have access to the project and gcloud is authenticated.');
|
||||
}
|
||||
}
|
||||
|
||||
async function provisionWorker() {
|
||||
const instanceId = Math.floor(Date.now() / 1000);
|
||||
const name = `${INSTANCE_PREFIX}-${instanceId}`;
|
||||
const zone = 'us-west1-a';
|
||||
|
||||
console.log(`🚀 Provisioning secure offload worker: ${name}...`);
|
||||
|
||||
// Hardened Metadata: Enable OS Login and 72h Self-Deletion
|
||||
const startupScript = `#!/bin/bash
|
||||
echo "gcloud compute instances delete ${name} --zone ${zone} --project ${PROJECT_ID} --quiet" | at now + 72 hours
|
||||
`;
|
||||
|
||||
const result = spawnSync('gcloud', [
|
||||
'compute', 'instances', 'create', name,
|
||||
'--project', PROJECT_ID,
|
||||
'--zone', zone,
|
||||
'--machine-type', 'n2-standard-8',
|
||||
'--image-family', 'gcli-maintainer-worker',
|
||||
'--image-project', PROJECT_ID,
|
||||
'--metadata', `enable-oslogin=TRUE,startup-script=${startupScript}`,
|
||||
'--labels', `owner=${USER.replace(/[^a-z0-9_-]/g, '_')},type=offload-worker`,
|
||||
'--tags', `gcli-offload-${USER}`,
|
||||
'--scopes', 'https://www.googleapis.com/auth/cloud-platform'
|
||||
], { stdio: 'inherit' });
|
||||
|
||||
if (result.status === 0) {
|
||||
console.log(`\n✅ Worker ${name} is being provisioned.`);
|
||||
console.log(`👉 Access is restricted via OS Login and tags.`);
|
||||
}
|
||||
}
|
||||
|
||||
async function createImage() {
|
||||
const name = `gcli-maintainer-worker-build-${Math.floor(Date.now() / 1000)}`;
|
||||
const zone = 'us-west1-a';
|
||||
const imageName = 'gcli-maintainer-worker-v1';
|
||||
|
||||
console.log(`🏗️ Building Maintainer Image: ${imageName}...`);
|
||||
|
||||
// 1. Create a temporary builder VM
|
||||
console.log(' - Creating temporary builder VM...');
|
||||
spawnSync('gcloud', [
|
||||
'compute', 'instances', 'create', name,
|
||||
'--project', PROJECT_ID,
|
||||
'--zone', zone,
|
||||
'--machine-type', 'n2-standard-4',
|
||||
'--image-family', 'ubuntu-2204-lts',
|
||||
'--image-project', 'ubuntu-os-cloud',
|
||||
'--metadata-from-file', `startup-script=.gemini/skills/offload/scripts/provision-worker.sh`
|
||||
], { stdio: 'inherit' });
|
||||
|
||||
console.log('\n⏳ Waiting for provisioning to complete (this takes ~3-5 mins)...');
|
||||
console.log(' - You can tail the startup script via:');
|
||||
console.log(` gcloud compute instances get-serial-port-output ${name} --project ${PROJECT_ID} --zone ${zone} --follow`);
|
||||
|
||||
// Note: For a true automation we'd poll here, but for a maintainer tool,
|
||||
// we'll provide the instructions to finalize.
|
||||
console.log(`\n👉 Once provisioning is DONE, run these commands to finalize:`);
|
||||
console.log(` 1. gcloud compute instances stop ${name} --project ${PROJECT_ID} --zone ${zone}`);
|
||||
console.log(` 2. gcloud compute images create ${imageName} --project ${PROJECT_ID} --source-disk ${name} --source-disk-zone ${zone} --family gcli-maintainer-worker`);
|
||||
console.log(` 3. gcloud compute instances delete ${name} --project ${PROJECT_ID} --zone ${zone} --quiet`);
|
||||
}
|
||||
|
||||
async function main() {
|
||||
const action = process.argv[2] || 'list';
|
||||
|
||||
switch (action) {
|
||||
case 'list':
|
||||
await listWorkers();
|
||||
break;
|
||||
case 'provision':
|
||||
await provisionWorker();
|
||||
break;
|
||||
case 'create-image':
|
||||
await createImage();
|
||||
break;
|
||||
default:
|
||||
console.error(`❌ Unknown fleet action: ${action}`);
|
||||
process.exit(1);
|
||||
}
|
||||
}
|
||||
|
||||
main().catch(console.error);
|
||||
@@ -1,5 +1,7 @@
|
||||
/**
|
||||
* Universal Offload Orchestrator (Local)
|
||||
*
|
||||
* Automatically detects and connects to your dynamic GCE fleet.
|
||||
*/
|
||||
import { spawnSync } from 'child_process';
|
||||
import path from 'path';
|
||||
@@ -13,55 +15,75 @@ const q = (str: string) => `'${str.replace(/'/g, "'\\''")}'`;
|
||||
|
||||
export async function runOrchestrator(args: string[], env: NodeJS.ProcessEnv = process.env) {
|
||||
const prNumber = args[0];
|
||||
const action = args[1] || 'review'; // Default action is review
|
||||
const action = args[1] || 'review';
|
||||
|
||||
if (!prNumber) {
|
||||
console.error('Usage: npm run offload <PR_NUMBER> [action]');
|
||||
return 1;
|
||||
}
|
||||
|
||||
// Load Settings
|
||||
// 1. Load GCP Settings
|
||||
const settingsPath = path.join(REPO_ROOT, '.gemini/settings.json');
|
||||
let settings: any = {};
|
||||
if (fs.existsSync(settingsPath)) {
|
||||
try { settings = JSON.parse(fs.readFileSync(settingsPath, 'utf8')); } catch (e) {}
|
||||
}
|
||||
|
||||
let config = settings.maintainer?.deepReview;
|
||||
if (!config) {
|
||||
console.log('⚠️ Offload configuration not found. Launching setup...');
|
||||
const setupResult = spawnSync('npm', ['run', 'offload:setup'], { stdio: 'inherit' });
|
||||
if (setupResult.status !== 0) {
|
||||
console.error('❌ Setup failed. Please run "npm run offload:setup" manually.');
|
||||
if (!fs.existsSync(settingsPath)) {
|
||||
console.error('❌ Settings not found. Run "npm run offload:setup" first.');
|
||||
return 1;
|
||||
}
|
||||
// Reload settings after setup
|
||||
settings = JSON.parse(fs.readFileSync(settingsPath, 'utf8'));
|
||||
config = settings.maintainer.deepReview;
|
||||
}
|
||||
const settings = JSON.parse(fs.readFileSync(settingsPath, 'utf8'));
|
||||
const config = settings.maintainer?.deepReview;
|
||||
if (!config) {
|
||||
console.error('❌ Fleet settings not found. Run "npm run offload:setup" first.');
|
||||
return 1;
|
||||
}
|
||||
|
||||
const { remoteHost, remoteWorkDir, terminalType, syncAuth, geminiSetup, ghSetup } = config;
|
||||
const { projectId, zone, terminalType, syncAuth } = config;
|
||||
const userPrefix = `gcli-offload-${env.USER || 'mattkorwel'}`;
|
||||
|
||||
console.log(`🔍 Finding active fleet workers for ${userPrefix}...`);
|
||||
|
||||
// 2. Discover Worker VM
|
||||
const gcloudList = spawnSync(`gcloud compute instances list --project ${projectId} --filter="name~^${userPrefix} AND status=RUNNING" --format="json"`, { shell: true });
|
||||
|
||||
let instances = [];
|
||||
try {
|
||||
instances = JSON.parse(gcloudList.stdout.toString());
|
||||
} catch (e) {
|
||||
console.error('❌ Failed to parse gcloud output. Ensure you are logged in.');
|
||||
return 1;
|
||||
}
|
||||
|
||||
if (instances.length === 0) {
|
||||
console.log('⚠️ No active workers found. Please run "npm run offload:fleet provision" first.');
|
||||
return 1;
|
||||
}
|
||||
|
||||
// Default to the first found worker
|
||||
const targetVM = instances[0].name;
|
||||
const remoteWorkDir = '/home/ubuntu/.offload/workspace';
|
||||
const sessionName = `offload-${prNumber}-${action}`;
|
||||
|
||||
// Fetch Metadata (local)
|
||||
console.log(`🔍 Fetching metadata for ${action === 'implement' ? 'Issue' : 'PR'} #${prNumber}...`);
|
||||
const ghCmd = action === 'implement' ? ['issue', 'view', prNumber, '--json', 'title', '-q', '.title'] : ['pr', 'view', prNumber, '--json', 'headRefName', '-q', '.headRefName'];
|
||||
const ghView = spawnSync('gh', ghCmd, { shell: true });
|
||||
const ghCmd = action === 'implement'
|
||||
? `gh issue view ${prNumber} --json title -q .title`
|
||||
: `gh pr view ${prNumber} --json headRefName -q .headRefName`;
|
||||
|
||||
const ghView = spawnSync(ghCmd, { shell: true });
|
||||
const metaName = ghView.stdout.toString().trim() || `task-${prNumber}`;
|
||||
|
||||
const branchName = action === 'implement' ? `impl-${prNumber}` : metaName;
|
||||
const sessionName = `offload-${prNumber}-${branchName.replace(/[^a-zA-Z0-9]/g, '-')}`;
|
||||
|
||||
// 2. Sync Configuration Mirror (Isolated Profiles)
|
||||
const ISOLATED_GEMINI = geminiSetup === 'isolated' ? '~/.offload/gemini-cli-config' : '~/.gemini';
|
||||
const ISOLATED_GH = ghSetup === 'isolated' ? '~/.offload/gh-cli-config' : '~/.config/gh';
|
||||
const remotePolicyPath = `${ISOLATED_GEMINI}/policies/offload-policy.toml`;
|
||||
|
||||
console.log(`📡 Mirroring environment to ${remoteHost}...`);
|
||||
spawnSync('ssh', [remoteHost, `mkdir -p ${remoteWorkDir}/.gemini/skills/offload/scripts/ ${ISOLATED_GEMINI}/policies/`]);
|
||||
|
||||
// Sync the policy file specifically
|
||||
spawnSync('rsync', ['-avz', path.join(REPO_ROOT, '.gemini/skills/offload/policy.toml'), `${remoteHost}:${remotePolicyPath}`]);
|
||||
|
||||
spawnSync('rsync', ['-avz', '--delete', path.join(REPO_ROOT, '.gemini/skills/offload/scripts/'), `${remoteHost}:${remoteWorkDir}/.gemini/skills/offload/scripts/`]);
|
||||
console.log(`📡 Using worker: ${targetVM}`);
|
||||
|
||||
// 3. Mirror logic
|
||||
const ISOLATED_GEMINI = '~/.offload/gemini-cli-config';
|
||||
const ISOLATED_GH = '~/.offload/gh-cli-config';
|
||||
const remotePolicyPath = `${ISOLATED_GEMINI}/policies/offload-policy.toml`;
|
||||
|
||||
console.log(`📦 Synchronizing with ${targetVM}...`);
|
||||
spawnSync(`gcloud compute ssh ${targetVM} --project ${projectId} --zone ${zone} --command "mkdir -p ${remoteWorkDir} ${ISOLATED_GEMINI}/policies/"`, { shell: true });
|
||||
|
||||
// Sync scripts and policy
|
||||
spawnSync(`rsync -avz -e "gcloud compute ssh --project ${projectId} --zone ${zone}" .gemini/skills/offload/policy.toml ${targetVM}:${remotePolicyPath}`, { shell: true });
|
||||
spawnSync(`rsync -avz --delete -e "gcloud compute ssh --project ${projectId} --zone ${zone}" .gemini/skills/offload/scripts/ ${targetVM}:${remoteWorkDir}/.gemini/skills/offload/scripts/`, { shell: true });
|
||||
|
||||
if (syncAuth) {
|
||||
const homeDir = env.HOME || '';
|
||||
@@ -69,56 +91,31 @@ export async function runOrchestrator(args: string[], env: NodeJS.ProcessEnv = p
|
||||
const syncFiles = ['google_accounts.json', 'settings.json'];
|
||||
for (const f of syncFiles) {
|
||||
const lp = path.join(localGeminiDir, f);
|
||||
if (fs.existsSync(lp)) spawnSync('rsync', ['-avz', lp, `${remoteHost}:${ISOLATED_GEMINI}/${f}`]);
|
||||
if (fs.existsSync(lp)) {
|
||||
spawnSync(`rsync -avz -e "gcloud compute ssh --project ${projectId} --zone ${zone}" ${lp} ${targetVM}:${ISOLATED_GEMINI}/${f}`, { shell: true });
|
||||
}
|
||||
}
|
||||
const localPolicies = path.join(localGeminiDir, 'policies/');
|
||||
if (fs.existsSync(localPolicies)) spawnSync('rsync', ['-avz', '--delete', localPolicies, `${remoteHost}:${ISOLATED_GEMINI}/policies/`]);
|
||||
const localEnv = path.join(REPO_ROOT, '.env');
|
||||
if (fs.existsSync(localEnv)) spawnSync('rsync', ['-avz', localEnv, `${remoteHost}:${remoteWorkDir}/.env`]);
|
||||
}
|
||||
|
||||
// 3. Construct Clean Command
|
||||
// 4. Construct Command
|
||||
const envLoader = 'export NVM_DIR="$HOME/.nvm"; [ -s "$NVM_DIR/nvm.sh" ] && \\. "$NVM_DIR/nvm.sh"';
|
||||
const remoteWorker = `export GEMINI_CLI_HOME=${ISOLATED_GEMINI} && export GH_CONFIG_DIR=${ISOLATED_GH} && ./node_modules/.bin/tsx .gemini/skills/offload/scripts/entrypoint.ts ${prNumber} ${branchName} ${remotePolicyPath} ${action}`;
|
||||
|
||||
const remoteWorker = `export GEMINI_CLI_HOME=${ISOLATED_GEMINI} && export GH_CONFIG_DIR=${ISOLATED_GH} && node_modules/.bin/tsx .gemini/skills/offload/scripts/entrypoint.ts ${prNumber} ${branchName} ${remotePolicyPath} ${action}`;
|
||||
const tmuxCmd = `cd ${remoteWorkDir} && ${envLoader} && ${remoteWorker}; exec $SHELL`;
|
||||
const sshInternal = `tmux attach-session -t ${sessionName} 2>/dev/null || tmux new-session -s ${sessionName} -n ${q(branchName)} ${q(tmuxCmd)}`;
|
||||
const sshCmd = `ssh -t ${remoteHost} ${q(sshInternal)}`;
|
||||
|
||||
const sshInternal = `tmux attach-session -t ${sessionName} 2>/dev/null || tmux new-session -s ${sessionName} -n 'offload' ${q(tmuxCmd)}`;
|
||||
const finalSSH = `gcloud compute ssh ${targetVM} --project ${projectId} --zone ${zone} -- -t ${q(sshInternal)}`;
|
||||
|
||||
// 4. Smart Context Execution
|
||||
// 5. Terminal Automation
|
||||
const isWithinGemini = !!env.GEMINI_SESSION_ID || !!env.GCLI_SESSION_ID;
|
||||
const forceBackground = args.includes('--background');
|
||||
|
||||
if (isWithinGemini || forceBackground) {
|
||||
if (process.platform === 'darwin' && terminalType !== 'none' && !forceBackground) {
|
||||
// macOS: Use Window Automation
|
||||
let appleScript = `on run argv\n set theCommand to item 1 of argv\n tell application "iTerm"\n set newWindow to (create window with default profile)\n tell current session of newWindow\n write text theCommand\n end tell\n activate\n end tell\n end run`;
|
||||
if (terminalType === 'terminal') {
|
||||
appleScript = `on run argv\n set theCommand to item 1 of argv\n tell application "Terminal"\n do script theCommand\n activate\n end tell\n end run`;
|
||||
}
|
||||
|
||||
spawnSync('osascript', ['-', sshCmd], { input: appleScript });
|
||||
console.log(`✅ ${terminalType.toUpperCase()} window opened for verification.`);
|
||||
return 0;
|
||||
}
|
||||
|
||||
// Cross-Platform Background Mode
|
||||
console.log(`📡 Launching remote verification in background mode...`);
|
||||
const logFile = path.join(REPO_ROOT, `.gemini/logs/offload-${prNumber}/background.log`);
|
||||
fs.mkdirSync(path.dirname(logFile), { recursive: true });
|
||||
|
||||
const backgroundCmd = `ssh ${remoteHost} ${q(tmuxCmd)} > ${q(logFile)} 2>&1 &`;
|
||||
spawnSync(backgroundCmd, { shell: true });
|
||||
|
||||
console.log(`⏳ Remote worker started in background.`);
|
||||
console.log(`📄 Tailing logs to: .gemini/logs/offload-${prNumber}/background.log`);
|
||||
if (isWithinGemini) {
|
||||
const appleScript = `on run argv\n tell application "iTerm"\n set newWindow to (create window with default profile)\n tell current session of newWindow\n write text (item 1 of argv)\n end tell\n activate\n end tell\n end run`;
|
||||
spawnSync('osascript', ['-', finalSSH], { input: appleScript });
|
||||
console.log(`✅ iTerm2 window opened on ${targetVM}.`);
|
||||
return 0;
|
||||
}
|
||||
|
||||
// Direct Shell Mode: Execute SSH in-place
|
||||
console.log(`🚀 Launching offload session in current terminal...`);
|
||||
const result = spawnSync(sshCmd, { stdio: 'inherit', shell: true });
|
||||
return result.status || 0;
|
||||
spawnSync(finalSSH, { stdio: 'inherit', shell: true });
|
||||
return 0;
|
||||
}
|
||||
|
||||
if (import.meta.url === `file://${process.argv[1]}`) {
|
||||
|
||||
56
.gemini/skills/offload/scripts/provision-worker.sh
Normal file
56
.gemini/skills/offload/scripts/provision-worker.sh
Normal file
@@ -0,0 +1,56 @@
|
||||
#!/bin/bash
|
||||
set -e
|
||||
|
||||
# Ensure we have a valid environment for non-interactive startup
|
||||
export USER=${USER:-ubuntu}
|
||||
export HOME=/home/$USER
|
||||
export DEBIAN_FRONTEND=noninteractive
|
||||
|
||||
echo "🛠️ Provisioning Gemini CLI Maintainer Worker for user: $USER"
|
||||
|
||||
# Wait for apt lock
|
||||
wait_for_apt() {
|
||||
echo "Waiting for apt lock..."
|
||||
while sudo fuser /var/lib/dpkg/lock-frontend /var/lib/apt/lists/lock >/dev/null 2>&1 ; do
|
||||
sleep 2
|
||||
done
|
||||
}
|
||||
|
||||
wait_for_apt
|
||||
|
||||
# 1. System Essentials
|
||||
apt-get update && apt-get install -y \
|
||||
curl git git-lfs tmux build-essential unzip jq gnupg cron
|
||||
|
||||
# 2. GitHub CLI
|
||||
if ! command -v gh &> /dev/null; then
|
||||
curl -fsSL https://cli.github.com/packages/githubcli-archive-keyring.gpg | dd of=/usr/share/keyrings/githubcli-archive-keyring.gpg
|
||||
chmod go+r /usr/share/keyrings/githubcli-archive-keyring.gpg
|
||||
echo "deb [arch=$(dpkg --print-architecture) signed-by=/usr/share/keyrings/githubcli-archive-keyring.gpg] https://cli.github.com/packages stable main" | tee /etc/apt/sources.list.d/github-cli.list > /dev/null
|
||||
wait_for_apt
|
||||
apt-get update && apt-get install gh -y
|
||||
fi
|
||||
|
||||
# 3. Direct Node.js 20 Installation (NodeSource)
|
||||
echo "Removing any existing nodejs/npm..."
|
||||
wait_for_apt
|
||||
apt-get purge -y nodejs npm || true
|
||||
apt-get autoremove -y
|
||||
|
||||
echo "Installing Node.js 20 via NodeSource..."
|
||||
curl -fsSL https://deb.nodesource.com/setup_20.x | bash -
|
||||
wait_for_apt
|
||||
apt-get install -y nodejs
|
||||
|
||||
# Verify installations
|
||||
node -v
|
||||
npm -v
|
||||
|
||||
# 4. Install Gemini CLI (Nightly)
|
||||
echo "Installing Gemini CLI..."
|
||||
npm install -g @google/gemini-cli@nightly
|
||||
|
||||
# 5. Self-Deletion Cron (Safety)
|
||||
(crontab -u $USER -l 2>/dev/null; echo "0 0 * * * gcloud compute instances delete $(hostname) --zone $(curl -H Metadata-Flavor:Google http://metadata.google.internal/computeMetadata/v1/instance/zone | cut -d/ -f4) --quiet") | crontab -u $USER -
|
||||
|
||||
echo "✅ Provisioning Complete!"
|
||||
@@ -1,5 +1,7 @@
|
||||
/**
|
||||
* Universal Deep Review Onboarding (Local)
|
||||
* Universal Offload Onboarding (Local)
|
||||
*
|
||||
* Configures the GCP Project and Fleet defaults.
|
||||
*/
|
||||
import { spawnSync } from 'child_process';
|
||||
import path from 'path';
|
||||
@@ -10,8 +12,6 @@ import readline from 'readline';
|
||||
const __dirname = path.dirname(fileURLToPath(import.meta.url));
|
||||
const REPO_ROOT = path.resolve(__dirname, '../../../..');
|
||||
|
||||
const q = (str: string) => `'${str.replace(/'/g, "'\\''")}'`;
|
||||
|
||||
async function prompt(question: string, defaultValue: string): Promise<string> {
|
||||
const rl = readline.createInterface({ input: process.stdin, output: process.stdout });
|
||||
return new Promise((resolve) => {
|
||||
@@ -33,141 +33,33 @@ async function confirm(question: string): Promise<boolean> {
|
||||
}
|
||||
|
||||
export async function runSetup(env: NodeJS.ProcessEnv = process.env) {
|
||||
console.log('\n🌟 Initializing Offload Skill Settings...');
|
||||
console.log('\n🌟 Initializing GCE Offload Fleet Settings...');
|
||||
|
||||
const OFFLOAD_BASE = '~/.offload';
|
||||
const remoteHost = await prompt('Remote SSH Host', 'cli');
|
||||
const remoteWorkDir = await prompt('Remote Work Directory', `${OFFLOAD_BASE}/workspace`);
|
||||
const projectId = await prompt('GCP Project ID', 'gemini-cli-team-quota');
|
||||
const zone = await prompt('Compute Zone', 'us-west1-a');
|
||||
const machineType = await prompt('Machine Type', 'n2-standard-8');
|
||||
|
||||
console.log(`🔍 Checking state of ${remoteHost}...`);
|
||||
const envLoader = 'export NVM_DIR="$HOME/.nvm"; [ -s "$NVM_DIR/nvm.sh" ] && \\. "$NVM_DIR/nvm.sh"';
|
||||
|
||||
// Probe remote for existing installations
|
||||
const ghCheck = spawnSync('ssh', [remoteHost, 'sh -lc "command -v gh"'], { stdio: 'pipe' });
|
||||
const tmuxCheck = spawnSync('ssh', [remoteHost, 'sh -lc "command -v tmux"'], { stdio: 'pipe' });
|
||||
const geminiCheck = spawnSync('ssh', [remoteHost, `sh -lc "${envLoader} && command -v gemini"`], { stdio: 'pipe' });
|
||||
|
||||
const hasGH = ghCheck.status === 0;
|
||||
const hasTmux = tmuxCheck.status === 0;
|
||||
const hasGemini = geminiCheck.status === 0;
|
||||
|
||||
// 1. Gemini CLI Isolation Choice
|
||||
let geminiSetup = 'isolated';
|
||||
if (hasGemini) {
|
||||
const geminiChoice = await prompt(`\nGemini CLI found on remote. Use [p]re-existing instance or [i]solated sandbox instance? (Isolated is recommended)`, 'i');
|
||||
geminiSetup = geminiChoice.toLowerCase() === 'p' ? 'preexisting' : 'isolated';
|
||||
} else {
|
||||
console.log('\n💡 Gemini CLI not found on remote. Defaulting to isolated sandbox instance.');
|
||||
console.log(`🔍 Verifying project access for ${projectId}...`);
|
||||
const projectCheck = spawnSync('gcloud', ['projects', 'describe', projectId], { stdio: 'pipe' });
|
||||
if (projectCheck.status !== 0) {
|
||||
console.error(`❌ Access denied to project: ${projectId}. Ensure you are logged in via gcloud.`);
|
||||
return 1;
|
||||
}
|
||||
|
||||
// 2. GitHub CLI Isolation Choice
|
||||
let ghSetup = 'isolated';
|
||||
if (hasGH) {
|
||||
const ghChoice = await prompt(`GitHub CLI found on remote. Use [p]re-existing instance or [i]solated sandbox instance? (Isolated is recommended)`, 'i');
|
||||
ghSetup = ghChoice.toLowerCase() === 'p' ? 'preexisting' : 'isolated';
|
||||
} else {
|
||||
console.log('💡 GitHub CLI not found on remote. Defaulting to isolated sandbox instance.');
|
||||
}
|
||||
|
||||
const ISOLATED_GEMINI_CONFIG = `${OFFLOAD_BASE}/gemini-cli-config`;
|
||||
const ISOLATED_GH_CONFIG = `${OFFLOAD_BASE}/gh-cli-config`;
|
||||
|
||||
if (!hasGH || !hasTmux) {
|
||||
console.log('\n📥 System Requirements Check:');
|
||||
if (!hasGH) console.log(' ❌ GitHub CLI (gh) is not installed on remote.');
|
||||
if (!hasTmux) console.log(' ❌ tmux is not installed on remote.');
|
||||
|
||||
const shouldProvision = await confirm('\nWould you like Gemini to automatically provision missing requirements?');
|
||||
if (shouldProvision) {
|
||||
console.log(`🚀 Attempting to provision dependencies on ${remoteHost}...`);
|
||||
const osCheck = spawnSync('ssh', [remoteHost, 'uname -s'], { stdio: 'pipe' });
|
||||
const os = osCheck.stdout.toString().trim();
|
||||
|
||||
let installCmd = '';
|
||||
if (os === 'Linux') {
|
||||
installCmd = 'sudo apt update && sudo apt install -y ' + [!hasGH ? 'gh' : '', !hasTmux ? 'tmux' : ''].filter(Boolean).join(' ');
|
||||
} else if (os === 'Darwin') {
|
||||
installCmd = 'brew install ' + [!hasGH ? 'gh' : '', !hasTmux ? 'tmux' : ''].filter(Boolean).join(' ');
|
||||
}
|
||||
|
||||
if (installCmd) {
|
||||
spawnSync('ssh', ['-t', remoteHost, installCmd], { stdio: 'inherit' });
|
||||
}
|
||||
} else {
|
||||
console.log('⚠️ Please ensure gh and tmux are installed before running again.');
|
||||
return 1;
|
||||
}
|
||||
}
|
||||
|
||||
// Ensure remote work dir and isolated config dirs exist
|
||||
spawnSync('ssh', [remoteHost, `mkdir -p ${remoteWorkDir} ${ISOLATED_GEMINI_CONFIG}/policies/ ${ISOLATED_GH_CONFIG}`], { stdio: 'pipe' });
|
||||
// Identity Synchronization Onboarding
|
||||
console.log('\n🔐 Identity & Authentication:');
|
||||
|
||||
// GH Auth Check
|
||||
const ghAuthCmd = ghSetup === 'isolated' ? `export GH_CONFIG_DIR=${ISOLATED_GH_CONFIG} && gh auth status` : 'gh auth status';
|
||||
const remoteGHAuth = spawnSync('ssh', [remoteHost, `sh -lc "${ghAuthCmd}"`], { stdio: 'pipe' });
|
||||
const isGHAuthRemote = remoteGHAuth.status === 0;
|
||||
|
||||
if (isGHAuthRemote) {
|
||||
console.log(` ✅ GitHub CLI is already authenticated on remote (${ghSetup}).`);
|
||||
} else {
|
||||
console.log(` ❌ GitHub CLI is NOT authenticated on remote (${ghSetup}).`);
|
||||
// If it's isolated but global is authenticated, offer to sync
|
||||
if (ghSetup === 'isolated') {
|
||||
const globalGHAuth = spawnSync('ssh', [remoteHost, 'sh -lc "gh auth status"'], { stdio: 'pipe' });
|
||||
if (globalGHAuth.status === 0) {
|
||||
if (await confirm(' Global GH auth found. Sync it to isolated instance?')) {
|
||||
spawnSync('ssh', [remoteHost, `mkdir -p ${ISOLATED_GH_CONFIG} && cp -r ~/.config/gh/* ${ISOLATED_GH_CONFIG}/`]);
|
||||
const verifySync = spawnSync('ssh', [remoteHost, `sh -lc "export GH_CONFIG_DIR=${ISOLATED_GH_CONFIG} && gh auth status"`], { stdio: 'pipe' });
|
||||
if (verifySync.status === 0) {
|
||||
console.log(' ✅ GitHub CLI successfully authenticated via sync.');
|
||||
return; // Skip the "may need to login" message
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
console.log(' ⚠️ GitHub CLI is not yet authenticated. You may need to run "gh auth login" on the remote machine later.');
|
||||
}
|
||||
|
||||
// Gemini Auth Check
|
||||
const geminiAuthCheck = geminiSetup === 'isolated'
|
||||
? `[ -f ${ISOLATED_GEMINI_CONFIG}/google_accounts.json ]`
|
||||
: '[ -f ~/.gemini/google_accounts.json ]';
|
||||
const remoteGeminiAuth = spawnSync('ssh', [remoteHost, `sh -lc "${geminiAuthCheck}"`], { stdio: 'pipe' });
|
||||
const isGeminiAuthRemote = remoteGeminiAuth.status === 0;
|
||||
const homeDir = env.HOME || '';
|
||||
const localAuth = path.join(homeDir, '.gemini/google_accounts.json');
|
||||
const hasAuth = fs.existsSync(localAuth);
|
||||
|
||||
let syncAuth = false;
|
||||
if (isGeminiAuthRemote) {
|
||||
console.log(` ✅ Gemini CLI is already authenticated on remote (${geminiSetup}).`);
|
||||
} else {
|
||||
const homeDir = env.HOME || '';
|
||||
const localAuth = path.join(homeDir, '.gemini/google_accounts.json');
|
||||
const localEnv = path.join(REPO_ROOT, '.env');
|
||||
const hasAuth = fs.existsSync(localAuth);
|
||||
const hasEnv = fs.existsSync(localEnv);
|
||||
|
||||
if (hasAuth || hasEnv) {
|
||||
console.log(` 🔍 Found local Gemini CLI credentials: ${[hasAuth ? 'Google Account' : '', hasEnv ? '.env' : ''].filter(Boolean).join(', ')}`);
|
||||
syncAuth = await confirm(' Would you like Gemini to automatically sync your local credentials to the remote workstation for seamless authentication?');
|
||||
}
|
||||
if (hasAuth) {
|
||||
console.log(` 🔍 Found local Gemini CLI credentials.`);
|
||||
syncAuth = await confirm(' Would you like to automatically sync your local credentials to new fleet workers for seamless authentication?');
|
||||
}
|
||||
|
||||
const terminalType = await prompt('\nTerminal Automation (iterm2 / terminal / none)', 'iterm2');
|
||||
|
||||
// Local Dependencies Install (Isolated)
|
||||
console.log(`\n📦 Checking isolated dependencies in ${remoteWorkDir}...`);
|
||||
const checkCmd = `ssh ${remoteHost} ${q(`${envLoader} && [ -x ${remoteWorkDir}/node_modules/.bin/tsx ] && [ -x ${remoteWorkDir}/node_modules/.bin/gemini ]`)}`;
|
||||
const depCheck = spawnSync(checkCmd, { shell: true });
|
||||
|
||||
if (depCheck.status !== 0) {
|
||||
console.log(`📦 Installing isolated dependencies (nightly CLI & tsx) in ${remoteWorkDir}...`);
|
||||
const installCmd = `ssh ${remoteHost} ${q(`${envLoader} && mkdir -p ${remoteWorkDir} && cd ${remoteWorkDir} && [ -f package.json ] || npm init -y > /dev/null && npm install tsx @google/gemini-cli@nightly`)}`;
|
||||
spawnSync(installCmd, { stdio: 'inherit', shell: true });
|
||||
} else {
|
||||
console.log('✅ Isolated dependencies already present.');
|
||||
}
|
||||
|
||||
// Save Settings
|
||||
const settingsPath = path.join(REPO_ROOT, '.gemini/settings.json');
|
||||
let settings: any = {};
|
||||
@@ -175,10 +67,21 @@ export async function runSetup(env: NodeJS.ProcessEnv = process.env) {
|
||||
try { settings = JSON.parse(fs.readFileSync(settingsPath, 'utf8')); } catch (e) {}
|
||||
}
|
||||
settings.maintainer = settings.maintainer || {};
|
||||
settings.maintainer.deepReview = { remoteHost, remoteWorkDir, terminalType, syncAuth, geminiSetup, ghSetup };
|
||||
settings.maintainer.deepReview = {
|
||||
projectId,
|
||||
zone,
|
||||
machineType,
|
||||
terminalType,
|
||||
syncAuth,
|
||||
setupType: 'isolated',
|
||||
geminiSetup: 'isolated',
|
||||
ghSetup: 'isolated'
|
||||
};
|
||||
fs.mkdirSync(path.dirname(settingsPath), { recursive: true });
|
||||
fs.writeFileSync(settingsPath, JSON.stringify(settings, null, 2));
|
||||
console.log('\n✅ Onboarding complete! Settings saved to .gemini/settings.json');
|
||||
|
||||
console.log('\n✅ GCE Fleet Onboarding complete! Settings saved to .gemini/settings.json');
|
||||
console.log(`👉 Use 'npm run offload:fleet provision' to spin up your first worker.`);
|
||||
return 0;
|
||||
}
|
||||
|
||||
|
||||
@@ -13,12 +13,12 @@ describe('Offload Tooling Matrix', () => {
|
||||
const mockSettings = {
|
||||
maintainer: {
|
||||
deepReview: {
|
||||
remoteHost: 'test-host',
|
||||
remoteWorkDir: '~/test-dir',
|
||||
projectId: 'test-project',
|
||||
zone: 'us-west1-a',
|
||||
terminalType: 'none',
|
||||
syncAuth: false,
|
||||
geminiSetup: 'preexisting',
|
||||
ghSetup: 'preexisting'
|
||||
geminiSetup: 'isolated',
|
||||
ghSetup: 'isolated'
|
||||
}
|
||||
}
|
||||
};
|
||||
@@ -33,8 +33,19 @@ describe('Offload Tooling Matrix', () => {
|
||||
vi.spyOn(process, 'chdir').mockImplementation(() => {});
|
||||
|
||||
vi.mocked(spawnSync).mockImplementation((cmd: any, args: any) => {
|
||||
if (cmd === 'ssh' && args?.[1]?.includes('command -v')) return { status: 0 } as any;
|
||||
return { status: 0, stdout: Buffer.from('test-meta\n'), stderr: Buffer.from('') } as any;
|
||||
const callStr = JSON.stringify({ cmd, args });
|
||||
|
||||
// 1. Mock GCloud Instance List
|
||||
if (callStr.includes('gcloud') && callStr.includes('instances') && callStr.includes('list')) {
|
||||
return { status: 0, stdout: Buffer.from(JSON.stringify([{ name: 'gcli-offload-test-worker' }])), stderr: Buffer.from('') } as any;
|
||||
}
|
||||
|
||||
// 2. Mock GH Metadata Fetching (local or remote)
|
||||
if (callStr.includes('gh') && callStr.includes('view')) {
|
||||
return { status: 0, stdout: Buffer.from('test-meta\n'), stderr: Buffer.from('') } as any;
|
||||
}
|
||||
|
||||
return { status: 0, stdout: Buffer.from(''), stderr: Buffer.from('') } as any;
|
||||
});
|
||||
|
||||
vi.mocked(spawn).mockImplementation(() => {
|
||||
@@ -53,14 +64,14 @@ describe('Offload Tooling Matrix', () => {
|
||||
|
||||
const spawnCalls = vi.mocked(spawnSync).mock.calls;
|
||||
const ghCall = spawnCalls.find(call => {
|
||||
const cmdStr = JSON.stringify(call);
|
||||
return cmdStr.includes('issue') && cmdStr.includes('view') && cmdStr.includes('456');
|
||||
const s = JSON.stringify(call);
|
||||
return s.includes('gh') && s.includes('issue') && s.includes('view') && s.includes('456');
|
||||
});
|
||||
expect(ghCall).toBeDefined();
|
||||
|
||||
const sshCall = spawnCalls.find(call => {
|
||||
const cmdStr = JSON.stringify(call);
|
||||
return cmdStr.includes('implement') && cmdStr.includes('offload-456-impl-456');
|
||||
const s = JSON.stringify(call);
|
||||
return s.includes('gcloud') && s.includes('ssh') && s.includes('offload-456-implement');
|
||||
});
|
||||
expect(sshCall).toBeDefined();
|
||||
});
|
||||
@@ -69,9 +80,7 @@ describe('Offload Tooling Matrix', () => {
|
||||
describe('Fix Playbook', () => {
|
||||
it('should launch the agentic fix-pr skill', async () => {
|
||||
vi.mocked(fs.existsSync).mockReturnValue(true);
|
||||
|
||||
await runWorker(['123', 'test-branch', '/path/policy', 'fix']);
|
||||
|
||||
const spawnSyncCalls = vi.mocked(spawnSync).mock.calls;
|
||||
const fixCall = spawnSyncCalls.find(call =>
|
||||
JSON.stringify(call).includes("activate the 'fix-pr' skill")
|
||||
|
||||
@@ -5,50 +5,47 @@ import readline from 'readline';
|
||||
import { runOrchestrator } from '../scripts/orchestrator.ts';
|
||||
import { runSetup } from '../scripts/setup.ts';
|
||||
import { runWorker } from '../scripts/worker.ts';
|
||||
import { runChecker } from '../scripts/check.ts';
|
||||
import { runCleanup } from '../scripts/clean.ts';
|
||||
|
||||
vi.mock('child_process');
|
||||
vi.mock('fs');
|
||||
vi.mock('readline');
|
||||
|
||||
describe('Offload Orchestration', () => {
|
||||
describe('Offload Orchestration (GCE)', () => {
|
||||
const mockSettings = {
|
||||
maintainer: {
|
||||
deepReview: {
|
||||
remoteHost: 'test-host',
|
||||
remoteWorkDir: '~/test-dir',
|
||||
projectId: 'test-project',
|
||||
zone: 'us-west1-a',
|
||||
terminalType: 'none',
|
||||
syncAuth: false,
|
||||
geminiSetup: 'preexisting',
|
||||
ghSetup: 'preexisting'
|
||||
geminiSetup: 'isolated',
|
||||
ghSetup: 'isolated'
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
beforeEach(() => {
|
||||
vi.resetAllMocks();
|
||||
|
||||
// Mock settings file existence and content
|
||||
vi.mocked(fs.existsSync).mockReturnValue(true);
|
||||
vi.mocked(fs.readFileSync).mockReturnValue(JSON.stringify(mockSettings));
|
||||
vi.mocked(fs.mkdirSync).mockReturnValue(undefined as any);
|
||||
vi.mocked(fs.writeFileSync).mockReturnValue(undefined as any);
|
||||
vi.mocked(fs.createWriteStream).mockReturnValue({ pipe: vi.fn() } as any);
|
||||
|
||||
// Mock process methods
|
||||
vi.spyOn(process, 'chdir').mockImplementation(() => {});
|
||||
vi.spyOn(process, 'cwd').mockReturnValue('/test-cwd');
|
||||
|
||||
// Default mock for spawnSync
|
||||
|
||||
vi.mocked(spawnSync).mockImplementation((cmd: any, args: any) => {
|
||||
if (cmd === 'gh' && args?.[0] === 'pr' && args?.[1] === 'view') {
|
||||
return { status: 0, stdout: Buffer.from('test-branch\n'), stderr: Buffer.from('') } as any;
|
||||
const callInfo = JSON.stringify({ cmd, args });
|
||||
// 1. Mock GCloud Instance List
|
||||
if (callInfo.includes('gcloud') && callInfo.includes('instances') && callInfo.includes('list')) {
|
||||
return { status: 0, stdout: Buffer.from(JSON.stringify([{ name: 'gcli-offload-test-worker' }])), stderr: Buffer.from('') } as any;
|
||||
}
|
||||
// 2. Mock GH Metadata Fetching (local or remote)
|
||||
if (callInfo.includes('gh') && callInfo.includes('view')) {
|
||||
return { status: 0, stdout: Buffer.from('test-meta\n'), stderr: Buffer.from('') } as any;
|
||||
}
|
||||
return { status: 0, stdout: Buffer.from(''), stderr: Buffer.from('') } as any;
|
||||
});
|
||||
|
||||
// Default mock for spawn
|
||||
vi.mocked(spawn).mockImplementation(() => {
|
||||
return {
|
||||
stdout: { pipe: vi.fn(), on: vi.fn() },
|
||||
@@ -60,49 +57,24 @@ describe('Offload Orchestration', () => {
|
||||
});
|
||||
|
||||
describe('orchestrator.ts', () => {
|
||||
it('should default to review action and pass it to remote', async () => {
|
||||
it('should discover active workers and use gcloud compute ssh', async () => {
|
||||
await runOrchestrator(['123'], {});
|
||||
|
||||
const spawnCalls = vi.mocked(spawnSync).mock.calls;
|
||||
const sshCall = spawnCalls.find(call => typeof call[0] === 'string' && call[0].includes('entrypoint.ts 123'));
|
||||
expect(sshCall![0]).toContain('review');
|
||||
});
|
||||
|
||||
it('should pass explicit actions (like fix) to remote', async () => {
|
||||
await runOrchestrator(['123', 'fix'], {});
|
||||
const spawnCalls = vi.mocked(spawnSync).mock.calls;
|
||||
const sshCall = spawnCalls.find(call => typeof call[0] === 'string' && call[0].includes('entrypoint.ts 123'));
|
||||
expect(sshCall![0]).toContain('fix');
|
||||
});
|
||||
|
||||
it('should construct the correct tmux session name from branch', async () => {
|
||||
await runOrchestrator(['123'], {});
|
||||
const spawnCalls = vi.mocked(spawnSync).mock.calls;
|
||||
const sshCall = spawnCalls.find(call => typeof call[0] === 'string' && call[0].includes('tmux new-session'));
|
||||
expect(sshCall![0]).toContain('offload-123-test-branch');
|
||||
});
|
||||
|
||||
it('should use isolated config path when geminiSetup is isolated', async () => {
|
||||
const isolatedSettings = {
|
||||
...mockSettings,
|
||||
maintainer: {
|
||||
...mockSettings.maintainer,
|
||||
deepReview: {
|
||||
...mockSettings.maintainer.deepReview,
|
||||
geminiSetup: 'isolated'
|
||||
}
|
||||
}
|
||||
};
|
||||
vi.mocked(fs.readFileSync).mockReturnValue(JSON.stringify(isolatedSettings));
|
||||
|
||||
await runOrchestrator(['123'], {});
|
||||
|
||||
const spawnCalls = vi.mocked(spawnSync).mock.calls;
|
||||
const sshCall = spawnCalls.find(call => {
|
||||
const cmdStr = typeof call[0] === 'string' ? call[0] : '';
|
||||
return cmdStr.includes('GEMINI_CLI_HOME=~/.offload/gemini-cli-config');
|
||||
});
|
||||
const sshCall = spawnCalls.find(call =>
|
||||
JSON.stringify(call).includes('gcloud') && JSON.stringify(call).includes('ssh')
|
||||
);
|
||||
|
||||
expect(sshCall).toBeDefined();
|
||||
expect(JSON.stringify(sshCall)).toContain('gcli-offload-test-worker');
|
||||
expect(JSON.stringify(sshCall)).toContain('test-project');
|
||||
});
|
||||
|
||||
it('should construct the correct tmux session name', async () => {
|
||||
await runOrchestrator(['123'], {});
|
||||
const spawnCalls = vi.mocked(spawnSync).mock.calls;
|
||||
const sshCall = spawnCalls.find(call => JSON.stringify(call).includes('tmux new-session'));
|
||||
expect(JSON.stringify(sshCall)).toContain('offload-123-review');
|
||||
});
|
||||
});
|
||||
|
||||
@@ -116,103 +88,31 @@ describe('Offload Orchestration', () => {
|
||||
vi.mocked(readline.createInterface).mockReturnValue(mockInterface as any);
|
||||
});
|
||||
|
||||
it('should correctly detect pre-existing setup when everything is present', async () => {
|
||||
vi.mocked(spawnSync).mockImplementation((cmd: any, args: any) => {
|
||||
if (cmd === 'ssh') {
|
||||
const remoteCmd = args[1];
|
||||
// Mock dependencies present (gh, tmux, gemini)
|
||||
if (remoteCmd.includes('command -v')) return { status: 0 } as any;
|
||||
if (remoteCmd.includes('gh auth status')) return { status: 0 } as any;
|
||||
if (remoteCmd.includes('google_accounts.json')) return { status: 0 } as any;
|
||||
}
|
||||
return { status: 0, stdout: Buffer.from(''), stderr: Buffer.from('') } as any;
|
||||
it('should verify project access during setup', async () => {
|
||||
vi.mocked(spawnSync).mockImplementation((cmd: any) => {
|
||||
if (cmd === 'gcloud') return { status: 0 } as any;
|
||||
return { status: 0, stdout: Buffer.from('') } as any;
|
||||
});
|
||||
|
||||
mockInterface.question
|
||||
.mockImplementationOnce((q, cb) => cb('test-host'))
|
||||
.mockImplementationOnce((q, cb) => cb('~/test-dir'))
|
||||
.mockImplementationOnce((q, cb) => cb('p')) // gemini preexisting
|
||||
.mockImplementationOnce((q, cb) => cb('p')) // gh preexisting
|
||||
.mockImplementationOnce((q, cb) => cb('test-project'))
|
||||
.mockImplementationOnce((q, cb) => cb('us-west1-a'))
|
||||
.mockImplementationOnce((q, cb) => cb('n2-standard-8'))
|
||||
.mockImplementationOnce((q, cb) => cb('y')) // syncAuth
|
||||
.mockImplementationOnce((q, cb) => cb('none'));
|
||||
|
||||
await runSetup({ HOME: '/test-home' });
|
||||
|
||||
const writeCall = vi.mocked(fs.writeFileSync).mock.calls.find(call =>
|
||||
call[0].toString().includes('.gemini/settings.json')
|
||||
);
|
||||
expect(writeCall).toBeDefined();
|
||||
});
|
||||
|
||||
it('should default to isolated when dependencies are missing', async () => {
|
||||
vi.mocked(spawnSync).mockImplementation((cmd: any, args: any) => {
|
||||
if (cmd === 'ssh') {
|
||||
const remoteCmd = args[1];
|
||||
// Mock dependencies missing
|
||||
if (remoteCmd.includes('command -v')) return { status: 1 } as any;
|
||||
}
|
||||
return { status: 0, stdout: Buffer.from(''), stderr: Buffer.from('') } as any;
|
||||
});
|
||||
|
||||
// Only 3 questions now: host, dir, terminal (gemini/gh choice skipped)
|
||||
mockInterface.question
|
||||
.mockImplementationOnce((q, cb) => cb('test-host'))
|
||||
.mockImplementationOnce((q, cb) => cb('~/test-dir'))
|
||||
.mockImplementationOnce((q, cb) => cb('y')) // provision requirements
|
||||
.mockImplementationOnce((q, cb) => cb('none'));
|
||||
|
||||
await runSetup({ HOME: '/test-home' });
|
||||
|
||||
const writeCall = vi.mocked(fs.writeFileSync).mock.calls.find(call =>
|
||||
call[0].toString().includes('.gemini/settings.json')
|
||||
);
|
||||
const savedSettings = JSON.parse(writeCall![1] as string);
|
||||
expect(savedSettings.maintainer.deepReview.geminiSetup).toBe('isolated');
|
||||
expect(savedSettings.maintainer.deepReview.ghSetup).toBe('isolated');
|
||||
expect(vi.mocked(spawnSync)).toHaveBeenCalledWith('gcloud', expect.arrayContaining(['projects', 'describe', 'test-project']), expect.any(Object));
|
||||
});
|
||||
});
|
||||
|
||||
describe('worker.ts (playbooks)', () => {
|
||||
it('should launch the review playbook by default', async () => {
|
||||
it('should launch the review playbook', async () => {
|
||||
vi.mocked(fs.existsSync).mockReturnValue(true);
|
||||
await runWorker(['123', 'test-branch', '/test-policy.toml', 'review']);
|
||||
const spawnCalls = vi.mocked(spawn).mock.calls;
|
||||
expect(spawnCalls.some(c => c[0].includes("activate the 'review-pr' skill"))).toBe(true);
|
||||
});
|
||||
|
||||
it('should launch the fix playbook when requested', async () => {
|
||||
vi.mocked(fs.existsSync).mockReturnValue(true);
|
||||
await runWorker(['123', 'test-branch', '/test-policy.toml', 'fix']);
|
||||
// runFixPlaybook uses spawnSync
|
||||
const spawnSyncCalls = vi.mocked(spawnSync).mock.calls;
|
||||
expect(spawnSyncCalls.some(c => JSON.stringify(c).includes("activate the 'fix-pr' skill"))).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe('check.ts', () => {
|
||||
it('should report SUCCESS when exit files contain 0', async () => {
|
||||
vi.mocked(spawnSync).mockImplementation((cmd: any, args: any) => {
|
||||
if (cmd === 'gh') return { status: 0, stdout: Buffer.from('test-branch\n') } as any;
|
||||
if (cmd === 'ssh' && args[1].includes('cat') && args[1].includes('.exit')) {
|
||||
return { status: 0, stdout: Buffer.from('0\n') } as any;
|
||||
}
|
||||
return { status: 0, stdout: Buffer.from('') } as any;
|
||||
});
|
||||
const consoleSpy = vi.spyOn(console, 'log').mockImplementation(() => {});
|
||||
await runChecker(['123']);
|
||||
expect(consoleSpy).toHaveBeenCalledWith(expect.stringContaining('✅ build : SUCCESS'));
|
||||
consoleSpy.mockRestore();
|
||||
});
|
||||
});
|
||||
|
||||
describe('clean.ts', () => {
|
||||
it('should kill tmux server', async () => {
|
||||
vi.mocked(readline.createInterface).mockReturnValue({
|
||||
question: vi.fn((q, cb) => cb('n')),
|
||||
close: vi.fn()
|
||||
} as any);
|
||||
await runCleanup();
|
||||
const spawnCalls = vi.mocked(spawnSync).mock.calls;
|
||||
expect(spawnCalls.some(call => Array.isArray(call[1]) && call[1].some(arg => arg === 'tmux kill-server'))).toBe(true);
|
||||
expect(spawnCalls.some(c => JSON.stringify(c).includes("activate the 'review-pr' skill"))).toBe(true);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -67,6 +67,7 @@
|
||||
"offload:setup": "tsx .gemini/skills/offload/scripts/setup.ts",
|
||||
"offload:check": "tsx .gemini/skills/offload/scripts/check.ts",
|
||||
"offload:clean": "tsx .gemini/skills/offload/scripts/clean.ts",
|
||||
"offload:fleet": "tsx .gemini/skills/offload/scripts/fleet.ts",
|
||||
"pre-commit": "node scripts/pre-commit.js"
|
||||
},
|
||||
"overrides": {
|
||||
|
||||
Reference in New Issue
Block a user