mirror of
https://github.com/google-gemini/gemini-cli.git
synced 2026-05-18 07:43:00 -07:00
fix(workspaces): clean up distractors and fix ESM runtime errors
This commit is contained in:
@@ -1,12 +1,12 @@
|
||||
/**
|
||||
* Workspace Attach Utility (Local)
|
||||
*
|
||||
* Re-attaches to a running tmux session inside the container on the worker.
|
||||
* @license
|
||||
* Copyright 2026 Google LLC
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
import path from 'path';
|
||||
import fs from 'fs';
|
||||
import { spawnSync } from 'child_process';
|
||||
import { fileURLToPath } from 'url';
|
||||
import path from 'node:path';
|
||||
import fs from 'node:fs';
|
||||
import { spawnSync } from 'node:child_process';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
import { ProviderFactory } from './providers/ProviderFactory.ts';
|
||||
|
||||
const __dirname = path.dirname(fileURLToPath(import.meta.url));
|
||||
@@ -14,19 +14,22 @@ const REPO_ROOT = path.resolve(__dirname, '../../../..');
|
||||
|
||||
const q = (str: string) => `'${str.replace(/'/g, "'\\''")}'`;
|
||||
|
||||
export async function runAttach(args: string[], env: NodeJS.ProcessEnv = process.env) {
|
||||
export async function runAttach(
|
||||
args: string[],
|
||||
env: NodeJS.ProcessEnv = process.env,
|
||||
) {
|
||||
const prNumber = args[0];
|
||||
const action = args[1] || 'review';
|
||||
const isLocal = args.includes('--local');
|
||||
|
||||
|
||||
if (!prNumber) {
|
||||
console.error('Usage: npm run workspace:attach <PR_NUMBER> [action] [--local]');
|
||||
console.error('Usage: workspace attach <PR_NUMBER> [action] [--local]');
|
||||
return 1;
|
||||
}
|
||||
|
||||
const settingsPath = path.join(REPO_ROOT, '.gemini/workspaces/settings.json');
|
||||
if (!fs.existsSync(settingsPath)) {
|
||||
console.error('❌ Settings not found. Run "npm run workspace:setup" first.');
|
||||
console.error('❌ Settings not found. Run "workspace setup" first.');
|
||||
return 1;
|
||||
}
|
||||
const settings = JSON.parse(fs.readFileSync(settingsPath, 'utf8'));
|
||||
@@ -38,18 +41,30 @@ export async function runAttach(args: string[], env: NodeJS.ProcessEnv = process
|
||||
|
||||
const { projectId, zone } = config;
|
||||
const targetVM = `gcli-workspace-${env.USER || 'mattkorwel'}`;
|
||||
const provider = ProviderFactory.getProvider({ projectId, zone, instanceName: targetVM });
|
||||
const provider = ProviderFactory.getProvider({
|
||||
projectId,
|
||||
zone,
|
||||
instanceName: targetVM,
|
||||
});
|
||||
|
||||
const sessionName = `workspace-${prNumber}-${action}`;
|
||||
const containerAttach = `sudo docker exec -it maintainer-worker sh -c ${q(`tmux attach-session -t ${sessionName}`)}`;
|
||||
const finalSSH = provider.getRunCommand(containerAttach, { interactive: true });
|
||||
const finalSSH = provider.getRunCommand(containerAttach, {
|
||||
interactive: true,
|
||||
});
|
||||
|
||||
console.log(`🔗 Attaching to session: ${sessionName}...`);
|
||||
|
||||
const isWithinGemini = !!env.GEMINI_CLI || !!env.GEMINI_SESSION_ID || !!env.GCLI_SESSION_ID;
|
||||
const isWithinGemini =
|
||||
!!env.GEMINI_CLI || !!env.GEMINI_SESSION_ID || !!env.GCLI_SESSION_ID;
|
||||
if (isWithinGemini && !isLocal) {
|
||||
const tempCmdPath = path.join(process.env.TMPDIR || '/tmp', `workspace-attach-${prNumber}.sh`);
|
||||
fs.writeFileSync(tempCmdPath, `#!/bin/bash\n${finalSSH}\nrm "$0"`, { mode: 0o755 });
|
||||
const tempCmdPath = path.join(
|
||||
process.env.TMPDIR || '/tmp',
|
||||
`workspace-attach-${prNumber}.sh`,
|
||||
);
|
||||
fs.writeFileSync(tempCmdPath, `#!/bin/bash\n${finalSSH}\nrm "$0"`, {
|
||||
mode: 0o755,
|
||||
});
|
||||
|
||||
const appleScript = `
|
||||
on run argv
|
||||
|
||||
@@ -1,13 +1,21 @@
|
||||
import { spawnSync } from 'child_process';
|
||||
import path from 'path';
|
||||
import fs from 'fs';
|
||||
import { fileURLToPath } from 'url';
|
||||
/**
|
||||
* @license
|
||||
* Copyright 2026 Google LLC
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
import { spawnSync } from 'node:child_process';
|
||||
import path from 'node:path';
|
||||
import fs from 'node:fs';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
import { ProviderFactory } from './providers/ProviderFactory.ts';
|
||||
|
||||
const __dirname = path.dirname(fileURLToPath(import.meta.url));
|
||||
const REPO_ROOT = path.resolve(__dirname, '../../../..');
|
||||
|
||||
export async function runChecker(args: string[], env: NodeJS.ProcessEnv = process.env) {
|
||||
export async function runChecker(
|
||||
args: string[],
|
||||
env: NodeJS.ProcessEnv = process.env,
|
||||
) {
|
||||
const prNumber = args[0];
|
||||
if (!prNumber) {
|
||||
console.error('Usage: npm run review:check <PR_NUMBER>');
|
||||
@@ -16,7 +24,7 @@ export async function runChecker(args: string[], env: NodeJS.ProcessEnv = proces
|
||||
|
||||
const settingsPath = path.join(REPO_ROOT, '.gemini/workspaces/settings.json');
|
||||
if (!fs.existsSync(settingsPath)) {
|
||||
console.error('❌ Settings not found. Run "npm run workspace:setup" first.');
|
||||
console.error('❌ Settings not found. Run "workspace setup" first.');
|
||||
return 1;
|
||||
}
|
||||
const settings = JSON.parse(fs.readFileSync(settingsPath, 'utf8'));
|
||||
@@ -27,11 +35,21 @@ export async function runChecker(args: string[], env: NodeJS.ProcessEnv = proces
|
||||
}
|
||||
const { projectId, zone, remoteWorkDir } = config;
|
||||
const targetVM = `gcli-workspace-${env.USER || 'mattkorwel'}`;
|
||||
const provider = ProviderFactory.getProvider({ projectId, zone, instanceName: targetVM });
|
||||
const provider = ProviderFactory.getProvider({
|
||||
projectId,
|
||||
zone,
|
||||
instanceName: targetVM,
|
||||
});
|
||||
|
||||
console.log(`🔍 Checking remote status for PR #${prNumber} on ${targetVM}...`);
|
||||
console.log(
|
||||
`🔍 Checking remote status for PR #${prNumber} on ${targetVM}...`,
|
||||
);
|
||||
|
||||
const branchView = spawnSync('gh', ['pr', 'view', prNumber, '--json', 'headRefName', '-q', '.headRefName'], { shell: true });
|
||||
const branchView = spawnSync(
|
||||
'gh',
|
||||
['pr', 'view', prNumber, '--json', 'headRefName', '-q', '.headRefName'],
|
||||
{ shell: true },
|
||||
);
|
||||
const branchName = branchView.stdout.toString().trim();
|
||||
const logDir = `${remoteWorkDir}/${branchName}/.gemini/logs/review-${prNumber}`;
|
||||
|
||||
@@ -41,13 +59,20 @@ export async function runChecker(args: string[], env: NodeJS.ProcessEnv = proces
|
||||
console.log('\n--- Task Status ---');
|
||||
for (const task of tasks) {
|
||||
const exitFile = `${logDir}/${task}.exit`;
|
||||
const checkExit = await provider.getExecOutput(`[ -f ${exitFile} ] && cat ${exitFile}`, { wrapContainer: 'maintainer-worker' });
|
||||
|
||||
const checkExit = await provider.getExecOutput(
|
||||
`[ -f ${exitFile} ] && cat ${exitFile}`,
|
||||
{ wrapContainer: 'maintainer-worker' },
|
||||
);
|
||||
|
||||
if (checkExit.status === 0 && checkExit.stdout.trim()) {
|
||||
const code = checkExit.stdout.trim();
|
||||
console.log(` ${code === '0' ? '✅' : '❌'} ${task.padEnd(10)}: ${code === '0' ? 'SUCCESS' : `FAILED (exit ${code})`}`);
|
||||
console.log(
|
||||
` ${code === '0' ? '✅' : '❌'} ${task.padEnd(10)}: ${code === '0' ? 'SUCCESS' : `FAILED (exit ${code})`}`,
|
||||
);
|
||||
} else {
|
||||
const checkRunning = await provider.exec(`[ -f ${logDir}/${task}.log ]`, { wrapContainer: 'maintainer-worker' });
|
||||
const checkRunning = await provider.exec(`[ -f ${logDir}/${task}.log ]`, {
|
||||
wrapContainer: 'maintainer-worker',
|
||||
});
|
||||
if (checkRunning === 0) {
|
||||
console.log(` ⏳ ${task.padEnd(10)}: RUNNING`);
|
||||
} else {
|
||||
@@ -58,9 +83,13 @@ export async function runChecker(args: string[], env: NodeJS.ProcessEnv = proces
|
||||
}
|
||||
|
||||
if (allDone) {
|
||||
console.log('\n✨ All remote tasks complete. You can now synthesize the results.');
|
||||
console.log(
|
||||
'\n✨ All remote tasks complete. You can now synthesize the results.',
|
||||
);
|
||||
} else {
|
||||
console.log('\n⏳ Some tasks are still in progress. Check again in a few minutes.');
|
||||
console.log(
|
||||
'\n⏳ Some tasks are still in progress. Check again in a few minutes.',
|
||||
);
|
||||
}
|
||||
return 0;
|
||||
}
|
||||
|
||||
@@ -1,20 +1,22 @@
|
||||
/**
|
||||
* Universal Workspace Cleanup (Local)
|
||||
*
|
||||
* Surgical or full cleanup of sessions and worktrees on the GCE worker.
|
||||
* Refactored to use WorkerProvider for container compatibility.
|
||||
* @license
|
||||
* Copyright 2026 Google LLC
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
import path from 'path';
|
||||
import fs from 'fs';
|
||||
import { fileURLToPath } from 'url';
|
||||
import readline from 'readline';
|
||||
import path from 'node:path';
|
||||
import fs from 'node:fs';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
import readline from 'node:readline';
|
||||
import { ProviderFactory } from './providers/ProviderFactory.ts';
|
||||
|
||||
const __dirname = path.dirname(fileURLToPath(import.meta.url));
|
||||
const REPO_ROOT = path.resolve(__dirname, '../../../..');
|
||||
|
||||
async function confirm(question: string): Promise<boolean> {
|
||||
const rl = readline.createInterface({ input: process.stdin, output: process.stdout });
|
||||
const rl = readline.createInterface({
|
||||
input: process.stdin,
|
||||
output: process.stdout,
|
||||
});
|
||||
return new Promise((resolve) => {
|
||||
rl.question(`${question} (y/n): `, (answer) => {
|
||||
rl.close();
|
||||
@@ -23,13 +25,16 @@ async function confirm(question: string): Promise<boolean> {
|
||||
});
|
||||
}
|
||||
|
||||
export async function runCleanup(args: string[], env: NodeJS.ProcessEnv = process.env) {
|
||||
export async function runCleanup(
|
||||
args: string[],
|
||||
env: NodeJS.ProcessEnv = process.env,
|
||||
) {
|
||||
const prNumber = args[0];
|
||||
const action = args[1];
|
||||
|
||||
const settingsPath = path.join(REPO_ROOT, '.gemini/workspaces/settings.json');
|
||||
if (!fs.existsSync(settingsPath)) {
|
||||
console.error('❌ Settings not found. Run "npm run workspace:setup" first.');
|
||||
console.error('❌ Settings not found. Run "workspace setup" first.');
|
||||
return 1;
|
||||
}
|
||||
|
||||
@@ -42,54 +47,81 @@ export async function runCleanup(args: string[], env: NodeJS.ProcessEnv = proces
|
||||
|
||||
const { projectId, zone } = config;
|
||||
const targetVM = `gcli-workspace-${env.USER || 'mattkorwel'}`;
|
||||
const provider = ProviderFactory.getProvider({ projectId, zone, instanceName: targetVM });
|
||||
const provider = ProviderFactory.getProvider({
|
||||
projectId,
|
||||
zone,
|
||||
instanceName: targetVM,
|
||||
});
|
||||
|
||||
if (prNumber && action) {
|
||||
const sessionName = `workspace-${prNumber}-${action}`;
|
||||
const worktreePath = `/home/node/.workspaces/worktrees/${sessionName}`;
|
||||
|
||||
console.log(`🧹 Surgically removing session and worktree for ${prNumber}-${action}...`);
|
||||
|
||||
|
||||
console.log(
|
||||
`🧹 Surgically removing session and worktree for ${prNumber}-${action}...`,
|
||||
);
|
||||
|
||||
// Kill specific tmux session inside container
|
||||
await provider.exec(`tmux kill-session -t ${sessionName} 2>/dev/null`, { wrapContainer: 'maintainer-worker' });
|
||||
|
||||
await provider.exec(`tmux kill-session -t ${sessionName} 2>/dev/null`, {
|
||||
wrapContainer: 'maintainer-worker',
|
||||
});
|
||||
|
||||
// Remove specific worktree inside container
|
||||
await provider.exec(`cd /home/node/.workspaces/main && git worktree remove -f ${worktreePath} 2>/dev/null && git worktree prune`, { wrapContainer: 'maintainer-worker' });
|
||||
|
||||
await provider.exec(
|
||||
`cd /home/node/.workspaces/main && git worktree remove -f ${worktreePath} 2>/dev/null && git worktree prune`,
|
||||
{ wrapContainer: 'maintainer-worker' },
|
||||
);
|
||||
|
||||
console.log(`✅ Cleaned up ${prNumber}-${action}.`);
|
||||
return 0;
|
||||
}
|
||||
|
||||
// --- Bulk Cleanup ---
|
||||
console.log(`⚠️ DANGER: You are about to perform a BULK cleanup on ${targetVM}.`);
|
||||
const confirmed = await confirm(' Are you sure you want to kill ALL sessions and worktrees?');
|
||||
console.log(
|
||||
`⚠️ DANGER: You are about to perform a BULK cleanup on ${targetVM}.`,
|
||||
);
|
||||
const confirmed = await confirm(
|
||||
' Are you sure you want to kill ALL sessions and worktrees?',
|
||||
);
|
||||
if (!confirmed) {
|
||||
console.log('❌ Cleanup cancelled.');
|
||||
return 0;
|
||||
console.log('❌ Cleanup cancelled.');
|
||||
return 0;
|
||||
}
|
||||
|
||||
console.log(`🧹 Starting BULK cleanup...`);
|
||||
|
||||
// 1. Standard Cleanup
|
||||
console.log(' - Killing ALL remote tmux sessions...');
|
||||
await provider.exec(`tmux kill-server`, { wrapContainer: 'maintainer-worker' });
|
||||
await provider.exec(`tmux kill-server`, {
|
||||
wrapContainer: 'maintainer-worker',
|
||||
});
|
||||
|
||||
console.log(' - Cleaning up Docker resources...');
|
||||
await provider.exec(`sudo docker rm -f maintainer-worker || true`);
|
||||
await provider.exec(`sudo docker system prune -af --volumes`);
|
||||
|
||||
console.log(' - Cleaning up ALL Git Worktrees...');
|
||||
await provider.exec(`cd /home/node/.workspaces/main && git worktree prune && rm -rf /home/node/.workspaces/worktrees/*`, { wrapContainer: 'maintainer-worker' });
|
||||
await provider.exec(
|
||||
`cd /home/node/.workspaces/main && git worktree prune && rm -rf /home/node/.workspaces/worktrees/*`,
|
||||
{ wrapContainer: 'maintainer-worker' },
|
||||
);
|
||||
|
||||
console.log('✅ Remote environment cleared.');
|
||||
|
||||
// 2. Full Wipe Option
|
||||
const shouldWipe = await confirm('\nWould you like to COMPLETELY wipe the remote workspace (main clone)?');
|
||||
|
||||
const shouldWipe = await confirm(
|
||||
'\nWould you like to COMPLETELY wipe the remote workspace (main clone)?',
|
||||
);
|
||||
|
||||
if (shouldWipe) {
|
||||
console.log(`🔥 Wiping /home/node/.workspaces/main...`);
|
||||
await provider.exec(`rm -rf /home/node/.workspaces/main && mkdir -p /home/node/.workspaces/main`, { wrapContainer: 'maintainer-worker' });
|
||||
console.log('✅ Remote hub wiped. You will need to run npm run workspace:setup again.');
|
||||
await provider.exec(
|
||||
`rm -rf /home/node/.workspaces/main && mkdir -p /home/node/.workspaces/main`,
|
||||
{ wrapContainer: 'maintainer-worker' },
|
||||
);
|
||||
console.log(
|
||||
'✅ Remote hub wiped. You will need to run workspace setup again.',
|
||||
);
|
||||
}
|
||||
return 0;
|
||||
}
|
||||
|
||||
@@ -1,12 +1,12 @@
|
||||
/**
|
||||
* Workspace Fleet Manager
|
||||
*
|
||||
* Manages dynamic GCP workers for workspaces tasks.
|
||||
* @license
|
||||
* Copyright 2026 Google LLC
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
import { spawnSync } from 'child_process';
|
||||
import path from 'path';
|
||||
import fs from 'fs';
|
||||
import { fileURLToPath } from 'url';
|
||||
import { spawnSync } from 'node:child_process';
|
||||
import path from 'node:path';
|
||||
import fs from 'node:fs';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
import { ProviderFactory } from './providers/ProviderFactory.ts';
|
||||
|
||||
const __dirname = path.dirname(fileURLToPath(import.meta.url));
|
||||
@@ -22,7 +22,9 @@ function getProjectId(): string {
|
||||
try {
|
||||
const settings = JSON.parse(fs.readFileSync(settingsPath, 'utf8'));
|
||||
return settings.workspace?.projectId;
|
||||
} catch (e) {}
|
||||
} catch {
|
||||
// Ignore
|
||||
}
|
||||
}
|
||||
return process.env.GOOGLE_CLOUD_PROJECT || '';
|
||||
}
|
||||
@@ -30,36 +32,47 @@ function getProjectId(): string {
|
||||
async function listWorkers() {
|
||||
const projectId = getProjectId();
|
||||
if (!projectId) {
|
||||
console.error('❌ Project ID not found. Run "npm run workspace:setup" first.');
|
||||
console.error('❌ Project ID not found. Run "workspace setup" first.');
|
||||
return;
|
||||
}
|
||||
|
||||
|
||||
console.log(`🔍 Listing Workspace Workers for ${USER} in ${projectId}...`);
|
||||
|
||||
spawnSync('gcloud', [
|
||||
'compute', 'instances', 'list',
|
||||
'--project', projectId,
|
||||
'--filter', `name~^${INSTANCE_PREFIX}`,
|
||||
'--format', 'table(name,zone,status,networkInterfaces[0].networkIP:label=INTERNAL_IP,creationTimestamp)'
|
||||
], { stdio: 'inherit' });
|
||||
|
||||
spawnSync(
|
||||
'gcloud',
|
||||
[
|
||||
'compute',
|
||||
'instances',
|
||||
'list',
|
||||
'--project',
|
||||
projectId,
|
||||
'--filter',
|
||||
`name~^${INSTANCE_PREFIX}`,
|
||||
'--format',
|
||||
'table(name,zone,status,networkInterfaces[0].networkIP:label=INTERNAL_IP,creationTimestamp)',
|
||||
],
|
||||
{ stdio: 'inherit' },
|
||||
);
|
||||
}
|
||||
|
||||
async function provisionWorker() {
|
||||
const projectId = getProjectId();
|
||||
if (!projectId) {
|
||||
console.error('❌ Project ID not found. Run "npm run workspace:setup" first.');
|
||||
console.error('❌ Project ID not found. Run "workspace setup" first.');
|
||||
return;
|
||||
}
|
||||
|
||||
const provider = ProviderFactory.getProvider({
|
||||
projectId: projectId,
|
||||
zone: DEFAULT_ZONE,
|
||||
instanceName: INSTANCE_PREFIX
|
||||
const provider = ProviderFactory.getProvider({
|
||||
projectId: projectId,
|
||||
zone: DEFAULT_ZONE,
|
||||
instanceName: INSTANCE_PREFIX,
|
||||
});
|
||||
|
||||
const status = await provider.getStatus();
|
||||
if (status.status !== 'UNKNOWN' && status.status !== 'ERROR') {
|
||||
console.log(`✅ Worker ${INSTANCE_PREFIX} already exists and is ${status.status}.`);
|
||||
console.log(
|
||||
`✅ Worker ${INSTANCE_PREFIX} already exists and is ${status.status}.`,
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -68,12 +81,12 @@ async function provisionWorker() {
|
||||
|
||||
async function stopWorker() {
|
||||
const projectId = getProjectId();
|
||||
const provider = ProviderFactory.getProvider({
|
||||
projectId: projectId,
|
||||
zone: DEFAULT_ZONE,
|
||||
instanceName: INSTANCE_PREFIX
|
||||
const provider = ProviderFactory.getProvider({
|
||||
projectId: projectId,
|
||||
zone: DEFAULT_ZONE,
|
||||
instanceName: INSTANCE_PREFIX,
|
||||
});
|
||||
|
||||
|
||||
console.log(`🛑 Stopping workspace worker: ${INSTANCE_PREFIX}...`);
|
||||
await provider.stop();
|
||||
}
|
||||
@@ -81,14 +94,28 @@ async function stopWorker() {
|
||||
async function rebuildWorker() {
|
||||
const projectId = getProjectId();
|
||||
console.log(`🔥 Rebuilding worker ${INSTANCE_PREFIX}...`);
|
||||
|
||||
|
||||
const knownHostsPath = path.join(REPO_ROOT, '.gemini/workspaces_known_hosts');
|
||||
if (fs.existsSync(knownHostsPath)) {
|
||||
console.log(` - Clearing isolated known_hosts...`);
|
||||
fs.unlinkSync(knownHostsPath);
|
||||
console.log(` - Clearing isolated known_hosts...`);
|
||||
fs.unlinkSync(knownHostsPath);
|
||||
}
|
||||
|
||||
spawnSync('gcloud', ['compute', 'instances', 'delete', INSTANCE_PREFIX, '--project', projectId, '--zone', DEFAULT_ZONE, '--quiet'], { stdio: 'inherit' });
|
||||
spawnSync(
|
||||
'gcloud',
|
||||
[
|
||||
'compute',
|
||||
'instances',
|
||||
'delete',
|
||||
INSTANCE_PREFIX,
|
||||
'--project',
|
||||
projectId,
|
||||
'--zone',
|
||||
DEFAULT_ZONE,
|
||||
'--quiet',
|
||||
],
|
||||
{ stdio: 'inherit' },
|
||||
);
|
||||
await provisionWorker();
|
||||
}
|
||||
|
||||
|
||||
@@ -1,12 +1,12 @@
|
||||
/**
|
||||
* Workspace Log Tailer (Local)
|
||||
*
|
||||
* Tails the latest remote logs for a specific job.
|
||||
* @license
|
||||
* Copyright 2026 Google LLC
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
import { spawnSync } from 'child_process';
|
||||
import path from 'path';
|
||||
import fs from 'fs';
|
||||
import { fileURLToPath } from 'url';
|
||||
import { spawnSync } from 'node:child_process';
|
||||
import path from 'node:path';
|
||||
import fs from 'node:fs';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
|
||||
const __dirname = path.dirname(fileURLToPath(import.meta.url));
|
||||
const REPO_ROOT = path.resolve(__dirname, '../../../..');
|
||||
@@ -14,9 +14,9 @@ const REPO_ROOT = path.resolve(__dirname, '../../../..');
|
||||
export async function runLogs(args: string[]) {
|
||||
const prNumber = args[0];
|
||||
const action = args[1] || 'review';
|
||||
|
||||
|
||||
if (!prNumber) {
|
||||
console.error('Usage: npm run workspace:logs <PR_NUMBER> [action]');
|
||||
console.error('Usage: workspace logs <PR_NUMBER> [action]');
|
||||
return 1;
|
||||
}
|
||||
|
||||
@@ -42,7 +42,10 @@ export async function runLogs(args: string[]) {
|
||||
tail -f "$latest_log"
|
||||
`;
|
||||
|
||||
spawnSync(`ssh -F ${sshConfigPath} ${remoteHost} ${JSON.stringify(tailCmd)}`, { stdio: 'inherit', shell: true });
|
||||
spawnSync(
|
||||
`ssh -F ${sshConfigPath} ${remoteHost} ${JSON.stringify(tailCmd)}`,
|
||||
{ stdio: 'inherit', shell: true },
|
||||
);
|
||||
return 0;
|
||||
}
|
||||
|
||||
|
||||
@@ -1,63 +1,71 @@
|
||||
/**
|
||||
* Workspace Orchestrator (Local)
|
||||
*
|
||||
* Central coordination of remote tasks.
|
||||
* Wakes workers, prepares worktrees, and launches tmux sessions.
|
||||
* @license
|
||||
* Copyright 2026 Google LLC
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
import { spawnSync } from 'child_process';
|
||||
import path from 'path';
|
||||
import fs from 'fs';
|
||||
import { fileURLToPath } from 'url';
|
||||
import { spawnSync } from 'node:child_process';
|
||||
import path from 'node:path';
|
||||
import fs from 'node:fs';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
import { ProviderFactory } from './providers/ProviderFactory.ts';
|
||||
|
||||
const __dirname = path.dirname(fileURLToPath(import.meta.url));
|
||||
const REPO_ROOT = path.resolve(__dirname, '../../../..');
|
||||
|
||||
function q(str: string) {
|
||||
return `'${str.replace(/'/g, "'\\''")}'`;
|
||||
return `'${str.replace(/'/g, "'\\''")}'`;
|
||||
}
|
||||
|
||||
export async function runOrchestrator(args: string[], env: NodeJS.ProcessEnv = process.env) {
|
||||
export async function runOrchestrator(
|
||||
args: string[],
|
||||
env: NodeJS.ProcessEnv = process.env,
|
||||
) {
|
||||
let prNumber = args[0];
|
||||
let action = args[1] || 'review';
|
||||
|
||||
// Handle "shell" mode: npm run workspace:shell [identifier]
|
||||
// Handle "shell" mode: workspace shell [identifier]
|
||||
const isShellMode = prNumber === 'shell';
|
||||
if (isShellMode) {
|
||||
prNumber = args[1] || `adhoc-${Math.floor(Math.random() * 10000)}`;
|
||||
action = 'shell';
|
||||
prNumber = args[1] || `adhoc-${Math.floor(Math.random() * 10000)}`;
|
||||
action = 'shell';
|
||||
}
|
||||
|
||||
if (!prNumber) {
|
||||
console.error('❌ Usage: npm run workspace <PR_NUMBER> [action] OR npm run workspace:shell [identifier]');
|
||||
console.error(
|
||||
'❌ Usage: workspace <PR_NUMBER> [action] OR workspace shell [identifier]',
|
||||
);
|
||||
return 1;
|
||||
}
|
||||
|
||||
// 1. Load Settings
|
||||
const settingsPath = path.join(REPO_ROOT, '.gemini/workspaces/settings.json');
|
||||
if (!fs.existsSync(settingsPath)) {
|
||||
console.error('❌ Workspace settings not found. Run "npm run workspace:setup" first.');
|
||||
console.error(
|
||||
'❌ Workspace settings not found. Run "workspace setup" first.',
|
||||
);
|
||||
return 1;
|
||||
}
|
||||
const settings = JSON.parse(fs.readFileSync(settingsPath, 'utf8'));
|
||||
const config = settings.workspace;
|
||||
|
||||
const targetVM = `gcli-workspace-${env.USER || 'mattkorwel'}`;
|
||||
const provider = ProviderFactory.getProvider({ projectId: config.projectId, zone: config.zone, instanceName: targetVM });
|
||||
const provider = ProviderFactory.getProvider({
|
||||
projectId: config.projectId,
|
||||
zone: config.zone,
|
||||
instanceName: targetVM,
|
||||
});
|
||||
|
||||
// 2. Wake Worker & Verify Container
|
||||
await provider.ensureReady();
|
||||
|
||||
// Retrieve the remote user to ensure we run git commands correctly
|
||||
const whoamiRes = await provider.getExecOutput('whoami');
|
||||
const remoteUser = whoamiRes.stdout.trim();
|
||||
await provider.getExecOutput('whoami');
|
||||
|
||||
// Paths - Unified across host and container
|
||||
const hostWorkspaceRoot = `/home/node/.workspaces`;
|
||||
const hostWorkDir = `${hostWorkspaceRoot}/main`;
|
||||
const containerHome = '/home/node';
|
||||
const containerWorkspaceRoot = `/home/node/.workspaces`;
|
||||
|
||||
|
||||
const remotePolicyPath = `${containerWorkspaceRoot}/policies/workspace-policy.toml`;
|
||||
const persistentScripts = `${containerWorkspaceRoot}/scripts`;
|
||||
const sessionName = `workspace-${prNumber}-${action}`;
|
||||
@@ -65,40 +73,42 @@ export async function runOrchestrator(args: string[], env: NodeJS.ProcessEnv = p
|
||||
const hostWorktreeDir = `${hostWorkspaceRoot}/worktrees/${sessionName}`;
|
||||
|
||||
// 3. Remote Context Setup (Executed on HOST for permission simplicity)
|
||||
console.log(`🚀 Preparing remote environment for ${action} on ${isShellMode ? 'branch/id' : '#'}${prNumber}...`);
|
||||
|
||||
console.log(
|
||||
`🚀 Preparing remote environment for ${action} on ${isShellMode ? 'branch/id' : '#'}${prNumber}...`,
|
||||
);
|
||||
|
||||
// FIX: Use the host path to check for existence
|
||||
const check = await provider.getExecOutput(`ls -d ${hostWorktreeDir}/.git`);
|
||||
|
||||
|
||||
// FIX: Ensure container user (node) owns the workspaces directories
|
||||
console.log(' - Synchronizing container permissions...');
|
||||
await provider.exec(`sudo chown -R 1000:1000 /home/node/.workspaces`);
|
||||
if (check.status !== 0) {
|
||||
console.log(` - Provisioning isolated git worktree for ${prNumber}...`);
|
||||
if (check.status !== 0) {
|
||||
console.log(` - Provisioning isolated git worktree for ${prNumber}...`);
|
||||
|
||||
// We run these on the host. Since setup might have left the repo root-owned, we use sudo.
|
||||
// We use environment variables to bypass safe.directory checks on a read-only filesystem.
|
||||
const gitEnv = `GIT_CONFIG_COUNT=1 GIT_CONFIG_KEY_0=safe.directory GIT_CONFIG_VALUE_0=${hostWorkDir}`;
|
||||
// We run these on the host. Since setup might have left the repo root-owned, we use sudo.
|
||||
// We use environment variables to bypass safe.directory checks on a read-only filesystem.
|
||||
const gitEnv = `GIT_CONFIG_COUNT=1 GIT_CONFIG_KEY_0=safe.directory GIT_CONFIG_VALUE_0=${hostWorkDir}`;
|
||||
|
||||
const gitFetch = isShellMode
|
||||
const gitFetch = isShellMode
|
||||
? `sudo ${gitEnv} git -C ${hostWorkDir} fetch --quiet origin`
|
||||
: `sudo ${gitEnv} git -C ${hostWorkDir} fetch --quiet upstream pull/${prNumber}/head`;
|
||||
|
||||
const gitTarget = isShellMode ? 'FETCH_HEAD' : 'FETCH_HEAD';
|
||||
const gitTarget = isShellMode ? 'FETCH_HEAD' : 'FETCH_HEAD';
|
||||
|
||||
const setupCmd = `
|
||||
sudo mkdir -p ${hostWorkspaceRoot}/worktrees && \
|
||||
sudo chown chronos:chronos ${hostWorkspaceRoot}/worktrees && \
|
||||
${gitFetch} && \
|
||||
sudo ${gitEnv} git -C ${hostWorkDir} worktree add --quiet -f ${hostWorktreeDir} ${gitTarget} 2>&1 && \
|
||||
sudo chown -R 1000:1000 ${hostWorkspaceRoot}
|
||||
`;
|
||||
const setupCmd = `
|
||||
sudo mkdir -p ${hostWorkspaceRoot}/worktrees && \
|
||||
sudo chown chronos:chronos ${hostWorkspaceRoot}/worktrees && \
|
||||
${gitFetch} && \
|
||||
sudo ${gitEnv} git -C ${hostWorkDir} worktree add --quiet -f ${hostWorktreeDir} ${gitTarget} 2>&1 && \
|
||||
sudo chown -R 1000:1000 ${hostWorkspaceRoot}
|
||||
`;
|
||||
const setupRes = await provider.getExecOutput(setupCmd);
|
||||
if (setupRes.status !== 0) {
|
||||
console.error(' ❌ Failed to provision remote worktree.');
|
||||
console.error(' STDOUT:', setupRes.stdout);
|
||||
console.error(' STDERR:', setupRes.stderr);
|
||||
return 1;
|
||||
console.error(' ❌ Failed to provision remote worktree.');
|
||||
console.error(' STDOUT:', setupRes.stdout);
|
||||
console.error(' STDERR:', setupRes.stderr);
|
||||
return 1;
|
||||
}
|
||||
console.log(' ✅ Worktree provisioned successfully.');
|
||||
} else {
|
||||
@@ -107,13 +117,19 @@ if (check.status !== 0) {
|
||||
|
||||
// AUTH: Dynamically retrieve credentials from host-side config/disk
|
||||
const remoteConfigPath = `${hostWorkspaceRoot}/gemini-cli-config/.gemini/settings.json`;
|
||||
const remoteSettingsRes = await provider.getExecOutput(`cat ${remoteConfigPath}`);
|
||||
const remoteSettingsRes = await provider.getExecOutput(
|
||||
`cat ${remoteConfigPath}`,
|
||||
);
|
||||
const remoteSettingsJson = remoteSettingsRes.stdout.trim();
|
||||
|
||||
const apiKeyRes = await provider.getExecOutput(`cat ${remoteConfigPath} | grep apiKey | cut -d '\"' -f 4`);
|
||||
|
||||
const apiKeyRes = await provider.getExecOutput(
|
||||
`cat ${remoteConfigPath} | grep apiKey | cut -d '"' -f 4`,
|
||||
);
|
||||
const remoteApiKey = apiKeyRes.stdout.trim();
|
||||
|
||||
const ghTokenRes = await provider.getExecOutput(`cat ${hostWorkspaceRoot}/.gh_token`);
|
||||
|
||||
const ghTokenRes = await provider.getExecOutput(
|
||||
`cat ${hostWorkspaceRoot}/.gh_token`,
|
||||
);
|
||||
const remoteGhToken = ghTokenRes.stdout.trim();
|
||||
|
||||
// AUTH: Inject credentials and settings directly into the worktree
|
||||
@@ -126,19 +142,23 @@ GEMINI_AUTO_UPDATE=0
|
||||
GEMINI_SANDBOX=workspace
|
||||
GEMINI_HOST=${targetVM}
|
||||
`.trim();
|
||||
await provider.exec(`sudo docker exec maintainer-worker sh -c ${q(`echo ${q(dotEnvContent)} > ${remoteWorktreeDir}/.env`)}`);
|
||||
|
||||
await provider.exec(
|
||||
`sudo docker exec maintainer-worker sh -c ${q(`echo ${q(dotEnvContent)} > ${remoteWorktreeDir}/.env`)}`,
|
||||
);
|
||||
|
||||
// Also inject the settings.json into the worktree's .gemini folder for maximum reliability
|
||||
await provider.exec(`sudo docker exec maintainer-worker sh -c ${q(`mkdir -p ${remoteWorktreeDir}/.gemini && echo ${q(remoteSettingsJson)} > ${remoteWorktreeDir}/.gemini/settings.json`)}`);
|
||||
await provider.exec(
|
||||
`sudo docker exec maintainer-worker sh -c ${q(`mkdir -p ${remoteWorktreeDir}/.gemini && echo ${q(remoteSettingsJson)} > ${remoteWorktreeDir}/.gemini/settings.json`)}`,
|
||||
);
|
||||
|
||||
// 4. Execution Logic
|
||||
// In shell mode, we just start gemini. In action mode, we run the entrypoint.
|
||||
const remoteWorker = isShellMode
|
||||
const remoteWorker = isShellMode
|
||||
? `gemini`
|
||||
: `tsx ${persistentScripts}/entrypoint.ts ${prNumber} . ${remotePolicyPath} ${action}`;
|
||||
|
||||
const authEnv = `-e GEMINI_AUTO_UPDATE=0 ${remoteApiKey ? `-e GEMINI_API_KEY=${remoteApiKey} ` : ''}${remoteGhToken ? `-e GITHUB_TOKEN=${remoteGhToken} -e GH_TOKEN=${remoteGhToken} ` : ''}`;
|
||||
|
||||
|
||||
// PERSISTENCE: Wrap the entire execution in a tmux session inside the container
|
||||
// We HIDE the tmux status bar to reduce visual noise
|
||||
const tmuxStyle = `
|
||||
@@ -147,25 +167,37 @@ GEMINI_HOST=${targetVM}
|
||||
|
||||
const tmuxCmd = `tmux new-session -A -s ${sessionName} ${q(`${tmuxStyle} cd ${remoteWorktreeDir} && ${remoteWorker}; exec $SHELL`)}`;
|
||||
const containerWrap = `sudo docker exec -it -e COLORTERM=truecolor -e TERM=xterm-256color ${authEnv}maintainer-worker sh -c ${q(tmuxCmd)}`;
|
||||
|
||||
|
||||
const finalSSH = provider.getRunCommand(containerWrap, { interactive: true });
|
||||
|
||||
const isWithinGemini = !!env.GEMINI_CLI || !!env.GEMINI_SESSION_ID || !!env.GCLI_SESSION_ID;
|
||||
|
||||
const isWithinGemini =
|
||||
!!env.GEMINI_CLI || !!env.GEMINI_SESSION_ID || !!env.GCLI_SESSION_ID;
|
||||
|
||||
// 1.5 Handle --open override
|
||||
const openIdx = args.indexOf('--open');
|
||||
let terminalTarget = config.terminalTarget || 'tab';
|
||||
if (openIdx !== -1 && args[openIdx + 1]) {
|
||||
terminalTarget = args[openIdx + 1];
|
||||
terminalTarget = args[openIdx + 1];
|
||||
}
|
||||
|
||||
const forceMainTerminal = terminalTarget === 'foreground';
|
||||
|
||||
if (!forceMainTerminal && isWithinGemini && env.TERM_PROGRAM === 'iTerm.app') {
|
||||
const tempCmdPath = path.join(process.env.TMPDIR || '/tmp', `workspace-ssh-${prNumber}.sh`);
|
||||
fs.writeFileSync(tempCmdPath, `#!/bin/bash\n${finalSSH}\nrm "$0"`, { mode: 0o755 });
|
||||
if (
|
||||
!forceMainTerminal &&
|
||||
isWithinGemini &&
|
||||
env.TERM_PROGRAM === 'iTerm.app'
|
||||
) {
|
||||
const tempCmdPath = path.join(
|
||||
process.env.TMPDIR || '/tmp',
|
||||
`workspace-ssh-${prNumber}.sh`,
|
||||
);
|
||||
fs.writeFileSync(tempCmdPath, `#!/bin/bash\n${finalSSH}\nrm "$0"`, {
|
||||
mode: 0o755,
|
||||
});
|
||||
|
||||
const appleScript = terminalTarget === 'window' ? `
|
||||
const appleScript =
|
||||
terminalTarget === 'window'
|
||||
? `
|
||||
on run argv
|
||||
tell application "iTerm"
|
||||
set newWindow to (create window with default profile)
|
||||
@@ -175,7 +207,8 @@ GEMINI_HOST=${targetVM}
|
||||
activate
|
||||
end tell
|
||||
end run
|
||||
` : `
|
||||
`
|
||||
: `
|
||||
on run argv
|
||||
tell application "iTerm"
|
||||
tell current window
|
||||
|
||||
@@ -5,10 +5,10 @@
|
||||
*/
|
||||
|
||||
/**
|
||||
* WorkspaceProvider interface defines the contract for different remote
|
||||
* WorkerProvider interface defines the contract for different remote
|
||||
* execution environments (GCE, Workstations, etc.).
|
||||
*/
|
||||
export interface WorkspaceProvider {
|
||||
export interface WorkerProvider {
|
||||
/**
|
||||
* Provisions the underlying infrastructure.
|
||||
*/
|
||||
@@ -37,12 +37,19 @@ export interface WorkspaceProvider {
|
||||
/**
|
||||
* Executes a command on the workspace and returns the output.
|
||||
*/
|
||||
getExecOutput(command: string, options?: ExecOptions): Promise<{ status: number; stdout: string; stderr: string }>;
|
||||
getExecOutput(
|
||||
command: string,
|
||||
options?: ExecOptions,
|
||||
): Promise<{ status: number; stdout: string; stderr: string }>;
|
||||
|
||||
/**
|
||||
* Synchronizes local files to the workspace.
|
||||
*/
|
||||
sync(localPath: string, remotePath: string, options?: SyncOptions): Promise<number>;
|
||||
sync(
|
||||
localPath: string,
|
||||
remotePath: string,
|
||||
options?: SyncOptions,
|
||||
): Promise<number>;
|
||||
|
||||
/**
|
||||
* Returns the status of the workspace.
|
||||
|
||||
@@ -4,14 +4,20 @@
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
import { spawnSync } from 'child_process';
|
||||
import path from 'path';
|
||||
import fs from 'fs';
|
||||
import os from 'os';
|
||||
import { WorkspaceProvider, SetupOptions, ExecOptions, SyncOptions, WorkspaceStatus } from './BaseProvider.ts';
|
||||
import { spawnSync } from 'node:child_process';
|
||||
import path from 'node:path';
|
||||
import fs from 'node:fs';
|
||||
import os from 'node:os';
|
||||
import {
|
||||
type WorkerProvider,
|
||||
type SetupOptions,
|
||||
type ExecOptions,
|
||||
type SyncOptions,
|
||||
type WorkspaceStatus,
|
||||
} from './BaseProvider.ts';
|
||||
import { GceConnectionManager } from './GceConnectionManager.ts';
|
||||
|
||||
export class GceCosProvider implements WorkspaceProvider {
|
||||
export class GceCosProvider implements WorkerProvider {
|
||||
private projectId: string;
|
||||
private zone: string;
|
||||
private instanceName: string;
|
||||
@@ -20,46 +26,143 @@ export class GceCosProvider implements WorkspaceProvider {
|
||||
private sshAlias = 'gcli-worker';
|
||||
private conn: GceConnectionManager;
|
||||
|
||||
constructor(projectId: string, zone: string, instanceName: string, repoRoot: string) {
|
||||
constructor(
|
||||
projectId: string,
|
||||
zone: string,
|
||||
instanceName: string,
|
||||
repoRoot: string,
|
||||
) {
|
||||
this.projectId = projectId;
|
||||
this.zone = zone;
|
||||
this.instanceName = instanceName;
|
||||
const workspacesDir = path.join(repoRoot, '.gemini/workspaces');
|
||||
if (!fs.existsSync(workspacesDir)) fs.mkdirSync(workspacesDir, { recursive: true });
|
||||
if (!fs.existsSync(workspacesDir))
|
||||
fs.mkdirSync(workspacesDir, { recursive: true });
|
||||
this.sshConfigPath = path.join(workspacesDir, 'ssh_config');
|
||||
this.knownHostsPath = path.join(workspacesDir, 'known_hosts');
|
||||
this.conn = new GceConnectionManager(projectId, zone, instanceName);
|
||||
}
|
||||
|
||||
async provision(): Promise<number> {
|
||||
const imageUri = 'us-docker.pkg.dev/gemini-code-dev/gemini-cli/maintainer:latest';
|
||||
const imageUri =
|
||||
'us-docker.pkg.dev/gemini-code-dev/gemini-cli/maintainer:latest';
|
||||
const region = this.zone.split('-').slice(0, 2).join('-');
|
||||
const vpcName = 'iap-vpc';
|
||||
const subnetName = 'iap-subnet';
|
||||
|
||||
console.log(`🏗️ Ensuring "Magic" Network Infrastructure in ${this.projectId}...`);
|
||||
console.log(
|
||||
`🏗️ Ensuring "Magic" Network Infrastructure in ${this.projectId}...`,
|
||||
);
|
||||
|
||||
const vpcCheck = spawnSync('gcloud', ['compute', 'networks', 'describe', vpcName, '--project', this.projectId], { stdio: 'pipe' });
|
||||
const vpcCheck = spawnSync(
|
||||
'gcloud',
|
||||
['compute', 'networks', 'describe', vpcName, '--project', this.projectId],
|
||||
{ stdio: 'pipe' },
|
||||
);
|
||||
if (vpcCheck.status !== 0) {
|
||||
spawnSync('gcloud', ['compute', 'networks', 'create', vpcName, '--project', this.projectId, '--subnet-mode=custom'], { stdio: 'inherit' });
|
||||
spawnSync(
|
||||
'gcloud',
|
||||
[
|
||||
'compute',
|
||||
'networks',
|
||||
'create',
|
||||
vpcName,
|
||||
'--project',
|
||||
this.projectId,
|
||||
'--subnet-mode=custom',
|
||||
],
|
||||
{ stdio: 'inherit' },
|
||||
);
|
||||
}
|
||||
|
||||
const subnetCheck = spawnSync('gcloud', ['compute', 'networks', 'subnets', 'describe', subnetName, '--project', this.projectId, '--region', region], { stdio: 'pipe' });
|
||||
const subnetCheck = spawnSync(
|
||||
'gcloud',
|
||||
[
|
||||
'compute',
|
||||
'networks',
|
||||
'subnets',
|
||||
'describe',
|
||||
subnetName,
|
||||
'--project',
|
||||
this.projectId,
|
||||
'--region',
|
||||
region,
|
||||
],
|
||||
{ stdio: 'pipe' },
|
||||
);
|
||||
if (subnetCheck.status !== 0) {
|
||||
spawnSync('gcloud', ['compute', 'networks', 'subnets', 'create', subnetName,
|
||||
'--project', this.projectId, '--network', vpcName, '--region', region,
|
||||
'--range=10.0.0.0/24', '--enable-private-ip-google-access'], { stdio: 'inherit' });
|
||||
spawnSync(
|
||||
'gcloud',
|
||||
[
|
||||
'compute',
|
||||
'networks',
|
||||
'subnets',
|
||||
'create',
|
||||
subnetName,
|
||||
'--project',
|
||||
this.projectId,
|
||||
'--network',
|
||||
vpcName,
|
||||
'--region',
|
||||
region,
|
||||
'--range=10.0.0.0/24',
|
||||
'--enable-private-ip-google-access',
|
||||
],
|
||||
{ stdio: 'inherit' },
|
||||
);
|
||||
} else {
|
||||
spawnSync('gcloud', ['compute', 'networks', 'subnets', 'update', subnetName, '--project', this.projectId, '--region', region, '--enable-private-ip-google-access'], { stdio: 'pipe' });
|
||||
spawnSync(
|
||||
'gcloud',
|
||||
[
|
||||
'compute',
|
||||
'networks',
|
||||
'subnets',
|
||||
'update',
|
||||
subnetName,
|
||||
'--project',
|
||||
this.projectId,
|
||||
'--region',
|
||||
region,
|
||||
'--enable-private-ip-google-access',
|
||||
],
|
||||
{ stdio: 'pipe' },
|
||||
);
|
||||
}
|
||||
|
||||
const fwCheck = spawnSync('gcloud', ['compute', 'firewall-rules', 'describe', 'allow-corporate-ssh', '--project', this.projectId], { stdio: 'pipe' });
|
||||
const fwCheck = spawnSync(
|
||||
'gcloud',
|
||||
[
|
||||
'compute',
|
||||
'firewall-rules',
|
||||
'describe',
|
||||
'allow-corporate-ssh',
|
||||
'--project',
|
||||
this.projectId,
|
||||
],
|
||||
{ stdio: 'pipe' },
|
||||
);
|
||||
if (fwCheck.status !== 0) {
|
||||
spawnSync('gcloud', ['compute', 'firewall-rules', 'create', 'allow-corporate-ssh',
|
||||
'--project', this.projectId, '--network', vpcName, '--allow=tcp:22', '--source-ranges=0.0.0.0/0'], { stdio: 'inherit' });
|
||||
spawnSync(
|
||||
'gcloud',
|
||||
[
|
||||
'compute',
|
||||
'firewall-rules',
|
||||
'create',
|
||||
'allow-corporate-ssh',
|
||||
'--project',
|
||||
this.projectId,
|
||||
'--network',
|
||||
vpcName,
|
||||
'--allow=tcp:22',
|
||||
'--source-ranges=0.0.0.0/0',
|
||||
],
|
||||
{ stdio: 'inherit' },
|
||||
);
|
||||
}
|
||||
|
||||
console.log(`🚀 Provisioning GCE COS worker: ${this.instanceName} (Unified Workspace Setup)...`);
|
||||
console.log(
|
||||
`🚀 Provisioning GCE COS worker: ${this.instanceName} (Unified Workspace Setup)...`,
|
||||
);
|
||||
|
||||
const startupScriptContent = `#!/bin/bash
|
||||
set -e
|
||||
@@ -112,32 +215,55 @@ export class GceCosProvider implements WorkspaceProvider {
|
||||
echo "✅ Unified Workspace is active."
|
||||
`;
|
||||
|
||||
const tmpScriptPath = path.join(os.tmpdir(), `gcli-startup-${Date.now()}.sh`);
|
||||
const tmpScriptPath = path.join(
|
||||
os.tmpdir(),
|
||||
`gcli-startup-${Date.now()}.sh`,
|
||||
);
|
||||
fs.writeFileSync(tmpScriptPath, startupScriptContent);
|
||||
|
||||
const result = spawnSync('gcloud', [
|
||||
'compute', 'instances', 'create', this.instanceName,
|
||||
'--project', this.projectId,
|
||||
'--zone', this.zone,
|
||||
'--machine-type', 'n2-standard-8',
|
||||
'--image-family', 'cos-stable',
|
||||
'--image-project', 'cos-cloud',
|
||||
'--boot-disk-size', '10GB',
|
||||
'--boot-disk-type', 'pd-balanced',
|
||||
'--create-disk', `name=${this.instanceName}-data,size=200,type=pd-balanced,device-name=data,auto-delete=yes`,
|
||||
'--metadata-from-file', `startup-script=${tmpScriptPath}`,
|
||||
'--metadata', 'enable-oslogin=TRUE',
|
||||
'--network-interface', `network=${vpcName},subnet=${subnetName},no-address`,
|
||||
'--scopes', 'https://www.googleapis.com/auth/cloud-platform',
|
||||
'--quiet'
|
||||
], { stdio: 'inherit' });
|
||||
const result = spawnSync(
|
||||
'gcloud',
|
||||
[
|
||||
'compute',
|
||||
'instances',
|
||||
'create',
|
||||
this.instanceName,
|
||||
'--project',
|
||||
this.projectId,
|
||||
'--zone',
|
||||
this.zone,
|
||||
'--machine-type',
|
||||
'n2-standard-8',
|
||||
'--image-family',
|
||||
'cos-stable',
|
||||
'--image-project',
|
||||
'cos-cloud',
|
||||
'--boot-disk-size',
|
||||
'10GB',
|
||||
'--boot-disk-type',
|
||||
'pd-balanced',
|
||||
'--create-disk',
|
||||
`name=${this.instanceName}-data,size=200,type=pd-balanced,device-name=data,auto-delete=yes`,
|
||||
'--metadata-from-file',
|
||||
`startup-script=${tmpScriptPath}`,
|
||||
'--metadata',
|
||||
'enable-oslogin=TRUE',
|
||||
'--network-interface',
|
||||
`network=${vpcName},subnet=${subnetName},no-address`,
|
||||
'--scopes',
|
||||
'https://www.googleapis.com/auth/cloud-platform',
|
||||
'--quiet',
|
||||
],
|
||||
{ stdio: 'inherit' },
|
||||
);
|
||||
|
||||
fs.unlinkSync(tmpScriptPath);
|
||||
|
||||
|
||||
if (result.status === 0) {
|
||||
console.log('⏳ Waiting for OS Login and SSH to initialize (this takes ~45s)...');
|
||||
await new Promise(r => setTimeout(r, 45000));
|
||||
console.log(
|
||||
'⏳ Waiting for OS Login and SSH to initialize (this takes ~45s)...',
|
||||
);
|
||||
await new Promise((r) => setTimeout(r, 45000));
|
||||
}
|
||||
|
||||
return result.status ?? 1;
|
||||
@@ -146,47 +272,68 @@ export class GceCosProvider implements WorkspaceProvider {
|
||||
async ensureReady(): Promise<number> {
|
||||
const status = await this.getStatus();
|
||||
if (status.status !== 'RUNNING') {
|
||||
console.log(`⚠️ Worker ${this.instanceName} is ${status.status}. Waking it up...`);
|
||||
const res = spawnSync('gcloud', [
|
||||
'compute', 'instances', 'start', this.instanceName,
|
||||
'--project', this.projectId,
|
||||
'--zone', this.zone
|
||||
], { stdio: 'inherit' });
|
||||
console.log(
|
||||
`⚠️ Worker ${this.instanceName} is ${status.status}. Waking it up...`,
|
||||
);
|
||||
const res = spawnSync(
|
||||
'gcloud',
|
||||
[
|
||||
'compute',
|
||||
'instances',
|
||||
'start',
|
||||
this.instanceName,
|
||||
'--project',
|
||||
this.projectId,
|
||||
'--zone',
|
||||
this.zone,
|
||||
],
|
||||
{ stdio: 'inherit' },
|
||||
);
|
||||
if (res.status !== 0) return res.status ?? 1;
|
||||
|
||||
|
||||
console.log('⏳ Waiting for boot...');
|
||||
await new Promise(r => setTimeout(r, 20000));
|
||||
await new Promise((r) => setTimeout(r, 20000));
|
||||
}
|
||||
|
||||
// NEW: Verify the container is actually running AND up to date
|
||||
console.log(' - Verifying remote container health and image version...');
|
||||
const containerCheck = await this.getExecOutput('sudo docker ps -q --filter "name=maintainer-worker"');
|
||||
|
||||
const containerCheck = await this.getExecOutput(
|
||||
'sudo docker ps -q --filter "name=maintainer-worker"',
|
||||
);
|
||||
|
||||
let needsUpdate = false;
|
||||
if (containerCheck.status === 0 && containerCheck.stdout.trim()) {
|
||||
// Check if the volume mounts are correct by checking for files inside .workspaces/main
|
||||
const mountCheck = await this.getExecOutput('sudo docker exec maintainer-worker ls -A /home/node/.workspaces/main');
|
||||
if (mountCheck.status !== 0 || !mountCheck.stdout.trim()) {
|
||||
console.log(' ⚠️ Remote container has incorrect or empty mounts. Triggering refresh...');
|
||||
needsUpdate = true;
|
||||
} else {
|
||||
// Check if the running image is stale
|
||||
const imageUri = 'us-docker.pkg.dev/gemini-code-dev/gemini-cli/maintainer:latest';
|
||||
const tmuxCheck = await this.getExecOutput('sudo docker exec maintainer-worker which tmux');
|
||||
if (tmuxCheck.status !== 0) {
|
||||
console.log(' ⚠️ Remote container is stale (missing tmux). Triggering update...');
|
||||
needsUpdate = true;
|
||||
}
|
||||
}
|
||||
} else {
|
||||
// Check if the volume mounts are correct by checking for files inside .workspaces/main
|
||||
const mountCheck = await this.getExecOutput(
|
||||
'sudo docker exec maintainer-worker ls -A /home/node/.workspaces/main',
|
||||
);
|
||||
if (mountCheck.status !== 0 || !mountCheck.stdout.trim()) {
|
||||
console.log(
|
||||
' ⚠️ Remote container has incorrect or empty mounts. Triggering refresh...',
|
||||
);
|
||||
needsUpdate = true;
|
||||
} else {
|
||||
// Check if the running image is stale
|
||||
const tmuxCheck = await this.getExecOutput(
|
||||
'sudo docker exec maintainer-worker which tmux',
|
||||
);
|
||||
if (tmuxCheck.status !== 0) {
|
||||
console.log(
|
||||
' ⚠️ Remote container is stale (missing tmux). Triggering update...',
|
||||
);
|
||||
needsUpdate = true;
|
||||
}
|
||||
}
|
||||
} else {
|
||||
needsUpdate = true;
|
||||
}
|
||||
|
||||
if (needsUpdate) {
|
||||
console.log(' ⚠️ Container missing or stale. Attempting refresh...');
|
||||
const imageUri = 'us-docker.pkg.dev/gemini-code-dev/gemini-cli/maintainer:latest';
|
||||
// Ensure data mount is available before running
|
||||
const recoverCmd = `
|
||||
console.log(' ⚠️ Container missing or stale. Attempting refresh...');
|
||||
const imageUri =
|
||||
'us-docker.pkg.dev/gemini-code-dev/gemini-cli/maintainer:latest';
|
||||
// Ensure data mount is available before running
|
||||
const recoverCmd = `
|
||||
(mountpoint -q /mnt/disks/data || sudo mount /dev/disk/by-id/google-data /mnt/disks/data) && \
|
||||
sudo docker pull ${imageUri} && \
|
||||
(sudo docker rm -f maintainer-worker || true) && \
|
||||
@@ -196,12 +343,14 @@ export class GceCosProvider implements WorkspaceProvider {
|
||||
-v ~/.config/gh:/home/node/.config/gh:rw \
|
||||
${imageUri} /bin/bash -c "while true; do sleep 1000; done"
|
||||
`;
|
||||
const recoverRes = await this.exec(recoverCmd);
|
||||
if (recoverRes !== 0) {
|
||||
console.error(' ❌ Critical: Failed to refresh maintainer container.');
|
||||
return 1;
|
||||
}
|
||||
console.log(' ✅ Container refreshed.');
|
||||
const recoverRes = await this.exec(recoverCmd);
|
||||
if (recoverRes !== 0) {
|
||||
console.error(
|
||||
' ❌ Critical: Failed to refresh maintainer container.',
|
||||
);
|
||||
return 1;
|
||||
}
|
||||
console.log(' ✅ Container refreshed.');
|
||||
}
|
||||
|
||||
return 0;
|
||||
@@ -227,23 +376,31 @@ Host ${this.sshAlias}
|
||||
fs.writeFileSync(this.sshConfigPath, sshEntry);
|
||||
console.log(` ✅ Created project SSH config: ${this.sshConfigPath}`);
|
||||
|
||||
console.log(' - Verifying direct connection (may trigger corporate SSO prompt)...');
|
||||
console.log(
|
||||
' - Verifying direct connection (may trigger corporate SSO prompt)...',
|
||||
);
|
||||
const res = this.conn.run('echo 1');
|
||||
if (res.status !== 0) {
|
||||
console.error('\n❌ All connection attempts failed. Please ensure you have "gcert" and IAP permissions.');
|
||||
return 1;
|
||||
console.error(
|
||||
'\n❌ All connection attempts failed. Please ensure you have "gcert" and IAP permissions.',
|
||||
);
|
||||
return 1;
|
||||
}
|
||||
console.log(' ✅ Connection verified. Waiting 10s for remote disk initialization...');
|
||||
await new Promise(r => setTimeout(r, 10000));
|
||||
console.log(
|
||||
' ✅ Connection verified. Waiting 10s for remote disk initialization...',
|
||||
);
|
||||
await new Promise((r) => setTimeout(r, 10000));
|
||||
return 0;
|
||||
}
|
||||
|
||||
getRunCommand(command: string, options: ExecOptions = {}): string {
|
||||
let finalCmd = command;
|
||||
if (options.wrapContainer) {
|
||||
finalCmd = `sudo docker exec ${options.interactive ? '-it' : ''} ${options.cwd ? `-w ${options.cwd}` : ''} ${options.wrapContainer} sh -c ${this.quote(command)}`;
|
||||
finalCmd = `sudo docker exec ${options.interactive ? '-it' : ''} ${options.cwd ? `-w ${options.cwd}` : ''} ${options.wrapContainer} sh -c ${this.quote(command)}`;
|
||||
}
|
||||
return this.conn.getRunCommand(finalCmd, { interactive: options.interactive });
|
||||
return this.conn.getRunCommand(finalCmd, {
|
||||
interactive: options.interactive,
|
||||
});
|
||||
}
|
||||
|
||||
async exec(command: string, options: ExecOptions = {}): Promise<number> {
|
||||
@@ -251,27 +408,47 @@ Host ${this.sshAlias}
|
||||
return res.status;
|
||||
}
|
||||
|
||||
async getExecOutput(command: string, options: ExecOptions = {}): Promise<{ status: number; stdout: string; stderr: string }> {
|
||||
async getExecOutput(
|
||||
command: string,
|
||||
options: ExecOptions = {},
|
||||
): Promise<{ status: number; stdout: string; stderr: string }> {
|
||||
let finalCmd = command;
|
||||
if (options.wrapContainer) {
|
||||
finalCmd = `sudo docker exec ${options.interactive ? '-it' : ''} ${options.cwd ? `-w ${options.cwd}` : ''} ${options.wrapContainer} sh -c ${this.quote(command)}`;
|
||||
finalCmd = `sudo docker exec ${options.interactive ? '-it' : ''} ${options.cwd ? `-w ${options.cwd}` : ''} ${options.wrapContainer} sh -c ${this.quote(command)}`;
|
||||
}
|
||||
|
||||
return this.conn.run(finalCmd, { interactive: options.interactive, stdio: options.interactive ? 'inherit' : 'pipe' });
|
||||
return this.conn.run(finalCmd, {
|
||||
interactive: options.interactive,
|
||||
stdio: options.interactive ? 'inherit' : 'pipe',
|
||||
});
|
||||
}
|
||||
|
||||
async sync(localPath: string, remotePath: string, options: SyncOptions = {}): Promise<number> {
|
||||
async sync(
|
||||
localPath: string,
|
||||
remotePath: string,
|
||||
options: SyncOptions = {},
|
||||
): Promise<number> {
|
||||
console.log(`📦 Syncing ${localPath} to remote:${remotePath}...`);
|
||||
return this.conn.sync(localPath, remotePath, options);
|
||||
}
|
||||
|
||||
async getStatus(): Promise<WorkspaceStatus> {
|
||||
const res = spawnSync('gcloud', [
|
||||
'compute', 'instances', 'describe', this.instanceName,
|
||||
'--project', this.projectId,
|
||||
'--zone', this.zone,
|
||||
'--format', 'json(name,status,networkInterfaces[0].networkIP)'
|
||||
], { stdio: 'pipe' });
|
||||
const res = spawnSync(
|
||||
'gcloud',
|
||||
[
|
||||
'compute',
|
||||
'instances',
|
||||
'describe',
|
||||
this.instanceName,
|
||||
'--project',
|
||||
this.projectId,
|
||||
'--zone',
|
||||
this.zone,
|
||||
'--format',
|
||||
'json(name,status,networkInterfaces[0].networkIP)',
|
||||
],
|
||||
{ stdio: 'pipe' },
|
||||
);
|
||||
|
||||
if (res.status !== 0) {
|
||||
return { name: this.instanceName, status: 'UNKNOWN' };
|
||||
@@ -282,19 +459,28 @@ Host ${this.sshAlias}
|
||||
return {
|
||||
name: data.name,
|
||||
status: data.status,
|
||||
internalIp: data.networkInterfaces?.[0]?.networkIP
|
||||
internalIp: data.networkInterfaces?.[0]?.networkIP,
|
||||
};
|
||||
} catch (e) {
|
||||
return { name: this.instanceName, status: 'ERROR' };
|
||||
} catch {
|
||||
return { name: this.instanceName, status: 'UNKNOWN' };
|
||||
}
|
||||
}
|
||||
|
||||
async stop(): Promise<number> {
|
||||
const res = spawnSync('gcloud', [
|
||||
'compute', 'instances', 'stop', this.instanceName,
|
||||
'--project', this.projectId,
|
||||
'--zone', this.zone
|
||||
], { stdio: 'inherit' });
|
||||
const res = spawnSync(
|
||||
'gcloud',
|
||||
[
|
||||
'compute',
|
||||
'instances',
|
||||
'stop',
|
||||
this.instanceName,
|
||||
'--project',
|
||||
this.projectId,
|
||||
'--zone',
|
||||
this.zone,
|
||||
],
|
||||
{ stdio: 'inherit' },
|
||||
);
|
||||
return res.status ?? 1;
|
||||
}
|
||||
|
||||
|
||||
@@ -5,16 +5,25 @@
|
||||
*/
|
||||
|
||||
import { GceCosProvider } from './GceCosProvider.ts';
|
||||
import { WorkspaceProvider } from './BaseProvider.ts';
|
||||
import path from 'path';
|
||||
import { fileURLToPath } from 'url';
|
||||
import type { WorkerProvider } from './BaseProvider.ts';
|
||||
import path from 'node:path';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
|
||||
const __dirname = path.dirname(fileURLToPath(import.meta.url));
|
||||
const REPO_ROOT = path.resolve(__dirname, '../../../../..');
|
||||
|
||||
export class ProviderFactory {
|
||||
static getProvider(config: { projectId: string; zone: string; instanceName: string }): WorkspaceProvider {
|
||||
static getProvider(config: {
|
||||
projectId: string;
|
||||
zone: string;
|
||||
instanceName: string;
|
||||
}): WorkerProvider {
|
||||
// Currently we only have GceCosProvider, but this is where we'd branch
|
||||
return new GceCosProvider(config.projectId, config.zone, config.instanceName, REPO_ROOT);
|
||||
return new GceCosProvider(
|
||||
config.projectId,
|
||||
config.zone,
|
||||
config.instanceName,
|
||||
REPO_ROOT,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,11 +1,11 @@
|
||||
/**
|
||||
* Workspace Status Inspector (Local)
|
||||
*
|
||||
* Orchestrates remote status retrieval via the WorkerProvider.
|
||||
* @license
|
||||
* Copyright 2026 Google LLC
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
import path from 'path';
|
||||
import fs from 'fs';
|
||||
import { fileURLToPath } from 'url';
|
||||
import path from 'node:path';
|
||||
import fs from 'node:fs';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
import { ProviderFactory } from './providers/ProviderFactory.ts';
|
||||
|
||||
const __dirname = path.dirname(fileURLToPath(import.meta.url));
|
||||
@@ -14,7 +14,7 @@ const REPO_ROOT = path.resolve(__dirname, '../../../..');
|
||||
async function runStatus(env: NodeJS.ProcessEnv = process.env) {
|
||||
const settingsPath = path.join(REPO_ROOT, '.gemini/workspaces/settings.json');
|
||||
if (!fs.existsSync(settingsPath)) {
|
||||
console.error('❌ Settings not found. Run "npm run workspace:setup" first.');
|
||||
console.error('❌ Settings not found. Run "workspace setup" first.');
|
||||
return 1;
|
||||
}
|
||||
const settings = JSON.parse(fs.readFileSync(settingsPath, 'utf8'));
|
||||
@@ -26,11 +26,17 @@ async function runStatus(env: NodeJS.ProcessEnv = process.env) {
|
||||
|
||||
const { projectId, zone } = config;
|
||||
const targetVM = `gcli-workspace-${env.USER || 'mattkorwel'}`;
|
||||
const provider = ProviderFactory.getProvider({ projectId, zone, instanceName: targetVM });
|
||||
const provider = ProviderFactory.getProvider({
|
||||
projectId,
|
||||
zone,
|
||||
instanceName: targetVM,
|
||||
});
|
||||
|
||||
console.log(`\n🛰️ Workspace Mission Control: ${targetVM}`);
|
||||
console.log(`--------------------------------------------------------------------------------`);
|
||||
|
||||
console.log(
|
||||
`--------------------------------------------------------------------------------`,
|
||||
);
|
||||
|
||||
const status = await provider.getStatus();
|
||||
console.log(` - VM State: ${status.status}`);
|
||||
console.log(` - Internal IP: ${status.internalIp || 'N/A'}`);
|
||||
@@ -38,11 +44,14 @@ async function runStatus(env: NodeJS.ProcessEnv = process.env) {
|
||||
if (status.status === 'RUNNING') {
|
||||
console.log(`\n🧵 Active Sessions (tmux):`);
|
||||
// We fetch the list of sessions from INSIDE the container
|
||||
const tmuxRes = await provider.getExecOutput('tmux list-sessions -F "#S" 2>/dev/null', { wrapContainer: 'maintainer-worker' });
|
||||
|
||||
const tmuxRes = await provider.getExecOutput(
|
||||
'tmux list-sessions -F "#S" 2>/dev/null',
|
||||
{ wrapContainer: 'maintainer-worker' },
|
||||
);
|
||||
|
||||
if (tmuxRes.status === 0 && tmuxRes.stdout.trim()) {
|
||||
const sessions = tmuxRes.stdout.trim().split('\n');
|
||||
sessions.forEach(s => {
|
||||
sessions.forEach((s) => {
|
||||
if (s.startsWith('workspace-')) {
|
||||
console.log(` ✅ ${s}`);
|
||||
} else {
|
||||
@@ -54,7 +63,9 @@ async function runStatus(env: NodeJS.ProcessEnv = process.env) {
|
||||
}
|
||||
}
|
||||
|
||||
console.log(`--------------------------------------------------------------------------------\n`);
|
||||
console.log(
|
||||
`--------------------------------------------------------------------------------\n`,
|
||||
);
|
||||
return 0;
|
||||
}
|
||||
|
||||
|
||||
@@ -20,7 +20,7 @@ This skill enables the agent to utilize **Gemini Workspaces**—a high-performan
|
||||
- **Environment Isolation**: When you need a clean, high-performance environment to verify a fix without polluting the user's local machine.
|
||||
|
||||
### How to use Workspaces
|
||||
1. **Setup**: If the user hasn't initialized their environment, instruct them to run the setup script:
|
||||
1. **Setup**: If the user hasn't initialized their environment, you MUST run the setup script using npx tsx and the absolute path. Do NOT use npm scripts.
|
||||
```bash
|
||||
npx tsx ${extensionPath}/extensions/workspaces/scripts/setup.ts
|
||||
```
|
||||
@@ -50,9 +50,13 @@ This skill enables the agent to utilize **Gemini Workspaces**—a high-performan
|
||||
```bash
|
||||
npx tsx ${extensionPath}/extensions/workspaces/scripts/fleet.ts [stop|provision|list]
|
||||
```
|
||||
6. **Attach/Logs**:
|
||||
```bash
|
||||
npx tsx ${extensionPath}/extensions/workspaces/scripts/attach.ts <PR_NUMBER>
|
||||
npx tsx ${extensionPath}/extensions/workspaces/scripts/logs.ts <PR_NUMBER>
|
||||
```
|
||||
|
||||
## ⚠️ Important Constraints
|
||||
- **NO NPM**: Do NOT attempt to use `npm run` or `npm workspace`. Those commands are deprecated in favor of running the extension scripts directly.
|
||||
- **npx tsx**: Always use `npx tsx` followed by the absolute path provided in `${extensionPath}`.
|
||||
- **Absolute Paths**: Always use absolute paths (e.g., `/mnt/disks/data/...`) when orchestrating remote commands.
|
||||
- **npx tsx**: When running scripts manually from the skill directory, always prefix with `npx tsx` to ensure dependencies are available.
|
||||
- **Be Behavioral**: Prioritize results from live execution (behavioral proofs) over static reading.
|
||||
- **Multi-tasking**: Remind the user they can continue chatting in the main window while the heavy workspace task runs in the separate terminal window.
|
||||
|
||||
@@ -64,16 +64,6 @@
|
||||
"telemetry": "node scripts/telemetry.js",
|
||||
"check:lockfile": "node scripts/check-lockfile.js",
|
||||
"clean": "node scripts/clean.js",
|
||||
"workspace": "tsx ./scripts/workspaces.ts",
|
||||
"workspace:setup": "tsx ./scripts/workspaces.ts setup",
|
||||
"workspace:shell": "tsx ./scripts/workspaces.ts shell",
|
||||
"workspace:check": "tsx ./scripts/workspaces.ts check",
|
||||
"workspace:clean-all": "tsx ./scripts/workspaces.ts clean-all",
|
||||
"workspace:kill": "tsx ./scripts/workspaces.ts kill",
|
||||
"workspace:fleet": "tsx ./scripts/workspaces.ts fleet",
|
||||
"workspace:status": "tsx ./scripts/workspaces.ts status",
|
||||
"workspace:attach": "tsx ./scripts/workspaces.ts attach",
|
||||
"workspace:logs": "tsx ./scripts/workspaces.ts logs",
|
||||
"pre-commit": "node scripts/pre-commit.js"
|
||||
},
|
||||
"overrides": {
|
||||
|
||||
@@ -1,78 +0,0 @@
|
||||
#!/usr/bin/env npx tsx
|
||||
/**
|
||||
* @license
|
||||
* Copyright 2026 Google LLC
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
import { spawnSync } from 'node:child_process';
|
||||
import path from 'node:path';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
|
||||
const __dirname = path.dirname(fileURLToPath(import.meta.url));
|
||||
const REPO_ROOT = path.resolve(__dirname, '..');
|
||||
|
||||
const commands: Record<string, string> = {
|
||||
setup: '.gemini/skills/workspaces/scripts/setup.ts',
|
||||
shell: '.gemini/skills/workspaces/scripts/orchestrator.ts shell',
|
||||
check: '.gemini/skills/workspaces/scripts/check.ts',
|
||||
'clean-all': '.gemini/skills/workspaces/scripts/clean.ts',
|
||||
kill: '.gemini/skills/workspaces/scripts/clean.ts',
|
||||
fleet: '.gemini/skills/workspaces/scripts/fleet.ts',
|
||||
status: '.gemini/skills/workspaces/scripts/status.ts',
|
||||
attach: '.gemini/skills/workspaces/scripts/attach.ts',
|
||||
};
|
||||
|
||||
function printUsage() {
|
||||
console.log('Gemini Workspace Management CLI');
|
||||
console.log(
|
||||
'\nUsage: scripts/workspace.ts <command> [args] [--open foreground|tab|window]',
|
||||
);
|
||||
console.log('\nCommands:');
|
||||
console.log(
|
||||
' setup Initialize or reconfigure your remote worker',
|
||||
);
|
||||
console.log(' <pr-number> [action] Launch a PR task (review, fix, ready)');
|
||||
console.log(' shell [id] Open an ad-hoc interactive session');
|
||||
console.log(' status See worker and session overview');
|
||||
console.log(' check <pr-number> Deep-dive into PR logs');
|
||||
console.log(' kill <pr-number> <act> Surgical removal of a task');
|
||||
console.log(' clean-all Full remote cleanup');
|
||||
console.log(' fleet <action> Manage VM life cycle (stop, provision)');
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
async function main() {
|
||||
const args = process.argv.slice(2);
|
||||
const cmd = args[0];
|
||||
|
||||
if (!cmd || cmd === '--help' || cmd === '-h') {
|
||||
printUsage();
|
||||
}
|
||||
|
||||
let scriptPath = commands[cmd];
|
||||
let finalArgs = args.slice(1);
|
||||
|
||||
// Default: If it's a number, it's a PR orchestrator task
|
||||
if (!scriptPath && /^\d+$/.test(cmd)) {
|
||||
scriptPath = '.gemini/skills/workspaces/scripts/orchestrator.ts';
|
||||
finalArgs = args; // Pass the PR number as the first arg
|
||||
}
|
||||
|
||||
if (!scriptPath) {
|
||||
console.error(`❌ Unknown command: ${cmd}`);
|
||||
printUsage();
|
||||
}
|
||||
|
||||
const [realScript, ...internalArgs] = scriptPath.split(' ');
|
||||
const fullScriptPath = path.join(REPO_ROOT, realScript);
|
||||
|
||||
const result = spawnSync(
|
||||
'npx',
|
||||
['tsx', fullScriptPath, ...internalArgs, ...finalArgs],
|
||||
{ stdio: 'inherit' },
|
||||
);
|
||||
|
||||
process.exit(result.status ?? 0);
|
||||
}
|
||||
|
||||
main().catch(console.error);
|
||||
@@ -1,77 +0,0 @@
|
||||
#!/usr/bin/env npx tsx
|
||||
/**
|
||||
* Unified Workspaces Entry Point (Local)
|
||||
*
|
||||
* Central CLI for managing Gemini Workspaces.
|
||||
* Usage: scripts/workspaces.ts <command> [args]
|
||||
*/
|
||||
import { spawnSync } from 'child_process';
|
||||
import path from 'path';
|
||||
import { fileURLToPath } from 'url';
|
||||
|
||||
const __dirname = path.dirname(fileURLToPath(import.meta.url));
|
||||
const REPO_ROOT = path.resolve(__dirname, '..');
|
||||
|
||||
const commands: Record<string, string> = {
|
||||
'setup': 'extensions/workspaces/scripts/setup.ts',
|
||||
'shell': 'extensions/workspaces/scripts/orchestrator.ts shell',
|
||||
'check': 'extensions/workspaces/scripts/check.ts',
|
||||
'clean-all': 'extensions/workspaces/scripts/clean.ts',
|
||||
'kill': 'extensions/workspaces/scripts/clean.ts',
|
||||
'fleet': 'extensions/workspaces/scripts/fleet.ts',
|
||||
'status': 'extensions/workspaces/scripts/status.ts',
|
||||
'attach': 'extensions/workspaces/scripts/attach.ts',
|
||||
'logs': 'extensions/workspaces/scripts/logs.ts',
|
||||
};
|
||||
|
||||
function printUsage() {
|
||||
console.log('Gemini Workspaces Management CLI');
|
||||
console.log('\nUsage: scripts/workspaces.ts <command> [args] [--open foreground|tab|window]');
|
||||
console.log('\nCommands:');
|
||||
console.log(' setup Initialize or reconfigure your remote worker');
|
||||
console.log(' <pr-number> [action] Launch a PR task (review, fix, ready)');
|
||||
console.log(' shell [id] Open an ad-hoc interactive session');
|
||||
console.log(' status See worker and session overview');
|
||||
console.log(' check <pr-number> Deep-dive into PR logs');
|
||||
console.log(' kill <pr-number> <act> Surgical removal of a task');
|
||||
console.log(' clean-all Full remote cleanup');
|
||||
console.log(' fleet <action> Manage VM life cycle (stop, provision)');
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
async function main() {
|
||||
const args = process.argv.slice(2);
|
||||
const cmd = args[0];
|
||||
|
||||
if (!cmd || cmd === '--help' || cmd === '-h') {
|
||||
printUsage();
|
||||
}
|
||||
|
||||
let scriptPath = commands[cmd];
|
||||
let finalArgs = args.slice(1);
|
||||
|
||||
// Default: If it's a number, it's a PR orchestrator task
|
||||
if (!scriptPath && /^\d+$/.test(cmd)) {
|
||||
scriptPath = 'extensions/workspaces/scripts/orchestrator.ts';
|
||||
finalArgs = args; // Pass the PR number as the first arg
|
||||
}
|
||||
|
||||
if (!scriptPath) {
|
||||
console.error(`❌ Unknown command: ${cmd}`);
|
||||
printUsage();
|
||||
}
|
||||
|
||||
const [realScript, ...internalArgs] = scriptPath.split(' ');
|
||||
const fullScriptPath = path.join(REPO_ROOT, realScript);
|
||||
|
||||
const result = spawnSync('npx', [
|
||||
'tsx',
|
||||
fullScriptPath,
|
||||
...internalArgs,
|
||||
...finalArgs
|
||||
], { stdio: 'inherit' });
|
||||
|
||||
process.exit(result.status ?? 0);
|
||||
}
|
||||
|
||||
main().catch(console.error);
|
||||
Reference in New Issue
Block a user