feat(workspaces): implement user settings sync (~/.gemini/) for remote workspaces

This commit is contained in:
mkorwel
2026-03-19 09:04:47 -07:00
parent d7422c1142
commit f5300cbb13
6 changed files with 209 additions and 10 deletions
+48 -8
View File
@@ -5,7 +5,13 @@
*/
import type { CommandModule, ArgumentsCamelCase } from 'yargs';
import { WorkspaceHubClient, SSHService, type Config, type WorkspaceHubInfo } from '@google-gemini-cli-core';
import {
WorkspaceHubClient,
SSHService,
SyncService,
type Config,
type WorkspaceHubInfo
} from '@google-gemini-cli-core';
import { exitCli } from '../utils.js';
import chalk from 'chalk';
@@ -14,6 +20,7 @@ interface ConnectArgs {
id: string;
forwardAgent?: boolean;
wait?: boolean;
sync?: boolean;
}
async function waitForReady(client: WorkspaceHubClient, id: string): Promise<WorkspaceHubInfo> {
@@ -42,6 +49,7 @@ export async function connectToWorkspace(args: ArgumentsCamelCase<ConnectArgs>):
return;
}
// eslint-disable-next-line @typescript-eslint/no-unsafe-assignment
const hubUrl = process.env['GEMINI_WORKSPACE_HUB_URL'] || 'http://localhost:8080';
const client = new WorkspaceHubClient(hubUrl);
@@ -49,7 +57,11 @@ export async function connectToWorkspace(args: ArgumentsCamelCase<ConnectArgs>):
// eslint-disable-next-line no-console
console.log(chalk.yellow(`Fetching workspace details for "${args.id}"...`));
let ws = await client.getWorkspace(args.id);
// We need to fetch the workspace info to get the instance name and zone
// eslint-disable-next-line @typescript-eslint/no-unsafe-assignment
const workspaces: WorkspaceHubInfo[] = await client.listWorkspaces();
// eslint-disable-next-line @typescript-eslint/no-unsafe-assignment
const ws: WorkspaceHubInfo | undefined = workspaces.find(w => w.id === args.id || w.name === args.id);
if (!ws) {
// eslint-disable-next-line no-console
@@ -57,9 +69,10 @@ export async function connectToWorkspace(args: ArgumentsCamelCase<ConnectArgs>):
return;
}
let readyWs = ws;
if (ws.status !== 'READY') {
if (args.wait) {
ws = await waitForReady(client, args.id);
readyWs = await waitForReady(client, args.id);
} else {
// eslint-disable-next-line no-console
console.warn(chalk.yellow(`Warning: Workspace is in status ${ws.status}.`));
@@ -70,19 +83,41 @@ export async function connectToWorkspace(args: ArgumentsCamelCase<ConnectArgs>):
}
}
const ssh = new SSHService();
// TODO: Get project from config once GCP settings are integrated into core Config
const { instance_name: instanceName, zone } = readyWs;
// eslint-disable-next-line @typescript-eslint/no-unsafe-assignment
const project = process.env['GOOGLE_CLOUD_PROJECT'] || 'dev-project';
// 1. Sync settings if enabled
if (args.sync !== false) {
// eslint-disable-next-line no-console
console.log(chalk.yellow(`Syncing local settings (~/.gemini) to remote...`));
const sync = new SyncService();
try {
await sync.pushSettings({
instanceName,
zone,
project,
});
// eslint-disable-next-line no-console
console.log(chalk.green(`✓ Settings synced.`));
} catch (err) {
// eslint-disable-next-line no-console
console.warn(chalk.red(`Warning: Settings sync failed, continuing connection...`), (err as Error).message);
}
}
// 2. Connect via SSH
const ssh = new SSHService();
// eslint-disable-next-line no-console
console.log(chalk.green(`🚀 Teleporting to ${ws.instance_name} (${ws.zone})...`));
console.log(chalk.green(`🚀 Teleporting to ${instanceName} (${zone})...`));
// Command to run on the remote VM: attach to the shpool session
const remoteCommand = 'shpool attach main || shpool attach';
await ssh.connect({
instanceName: ws.instance_name,
zone: ws.zone,
instanceName,
zone,
project,
command: remoteCommand,
forwardAgent: args.forwardAgent,
@@ -115,6 +150,11 @@ export const connectCommand: CommandModule<object, ConnectArgs> = {
type: 'boolean',
describe: 'Wait for the workspace to become READY if it is provisioning',
default: false,
})
.option('sync', {
type: 'boolean',
describe: 'Synchronize local ~/.gemini settings to the remote workspace',
default: true,
}),
handler: async (argv) => {
await connectToWorkspace(argv);
+1
View File
@@ -135,6 +135,7 @@ export * from './services/keychainService.js';
export * from './services/keychainTypes.js';
export * from './services/workspaceHubClient.js';
export * from './services/sshService.js';
export * from './services/syncService.js';
export * from './skills/skillManager.js';
export * from './skills/skillLoader.js';
@@ -0,0 +1,59 @@
/**
* @license
* Copyright 2026 Google LLC
* SPDX-License-Identifier: Apache-2.0
*/
import { describe, it, expect, vi, beforeEach } from 'vitest';
import { SyncService } from './syncService.js';
import { spawn } from 'node:child_process';
import { EventEmitter } from 'node:events';
vi.mock('node:child_process', () => ({
spawn: vi.fn(),
}));
vi.mock('../config/storage.js', () => ({
Storage: {
getGlobalGeminiDir: vi.fn().mockReturnValue('/mock/local/dir'),
},
}));
describe('SyncService', () => {
let service: SyncService;
beforeEach(() => {
vi.clearAllMocks();
service = new SyncService();
});
it('should construct correct gcloud scp command', async () => {
const mockChild = new EventEmitter() as any;
vi.mocked(spawn).mockReturnValue(mockChild);
const promise = service.pushSettings({
instanceName: 'test-inst',
zone: 'us-west1-a',
project: 'test-project',
});
setTimeout(() => mockChild.emit('exit', 0), 10);
await promise;
expect(spawn).toHaveBeenCalledWith(
'gcloud',
[
'compute',
'scp',
'--recurse',
'/mock/local/dir',
'test-inst:.gemini',
'--zone=us-west1-a',
'--project=test-project',
'--tunnel-through-iap',
],
expect.any(Object)
);
});
});
+66
View File
@@ -0,0 +1,66 @@
/**
* @license
* Copyright 2026 Google LLC
* SPDX-License-Identifier: Apache-2.0
*/
import { spawn } from 'node:child_process';
import { debugLogger } from '../utils/debugLogger.js';
import { Storage } from '../config/storage.js';
export interface SyncOptions {
instanceName: string;
zone: string;
project: string;
}
export class SyncService {
/**
* Push local ~/.gemini directory to the remote workspace.
* Currently uses gcloud compute scp.
*/
async pushSettings(options: SyncOptions): Promise<void> {
const { instanceName, zone, project } = options;
const localDir = Storage.getGlobalGeminiDir();
// We want to sync the contents of ~/.gemini to ~/.gemini on the remote.
// gcloud compute scp local-dir remote-instance:remote-dir
const remotePath = `${instanceName}:.gemini`;
// Note: gcloud scp doesn't have a native "exclude" flag like rsync,
// so we might need to be selective or use a tarball approach if it's too slow.
// For v1, we just push the whole thing but excluding the 'tmp' and 'logs' folder if possible
// via a manual scp of subdirectories, or just the whole thing for simplicity now.
const args = [
'compute',
'scp',
'--recurse',
localDir,
remotePath,
`--zone=${zone}`,
`--project=${project}`,
'--tunnel-through-iap',
];
debugLogger.log(`[SyncService] Syncing settings: gcloud ${args.join(' ')}`);
return new Promise((resolve, reject) => {
const child = spawn('gcloud', args, {
stdio: 'inherit',
});
child.on('exit', (code) => {
if (code === 0) {
resolve();
} else {
reject(new Error(`gcloud scp exited with code ${code}`));
}
});
child.on('error', (err) => {
reject(err);
});
});
}
}