feat(workspaces): implement ssh tunneling and workspace connect command

This commit is contained in:
mkorwel
2026-03-19 08:50:42 -07:00
parent bd523c8d48
commit 22ff923100
7 changed files with 209 additions and 3 deletions
+2
View File
@@ -9,6 +9,7 @@ import type { CommandModule, Argv } from 'yargs';
import { listCommand } from './workspace/list.js';
import { createCommand } from './workspace/create.js';
import { deleteCommand } from './workspace/delete.js';
import { connectCommand } from './workspace/connect.js';
import { defer } from '../deferred.js';
export const remoteWorkspaceCommand: CommandModule = {
@@ -22,6 +23,7 @@ export const remoteWorkspaceCommand: CommandModule = {
.command(defer(listCommand, 'wsr'))
.command(defer(createCommand, 'wsr'))
.command(defer(deleteCommand, 'wsr'))
.command(defer(connectCommand, 'wsr'))
.demandCommand(1, 'You need at least one command before continuing.')
.version(false),
@@ -0,0 +1,83 @@
/**
* @license
* Copyright 2026 Google LLC
* SPDX-License-Identifier: Apache-2.0
*/
import type { CommandModule, ArgumentsCamelCase } from 'yargs';
import { WorkspaceHubClient, SSHService, type Config, type WorkspaceHubInfo } from '@google-gemini-cli-core';
import { exitCli } from '../utils.js';
import chalk from 'chalk';
interface ConnectArgs {
config?: Config;
id: string;
}
export async function connectToWorkspace(args: ArgumentsCamelCase<ConnectArgs>): Promise<void> {
if (!args.config) {
// eslint-disable-next-line no-console
console.error(chalk.red('Internal error: Config not loaded.'));
return;
}
const hubUrl = 'http://localhost:8080';
const client = new WorkspaceHubClient(hubUrl);
try {
// eslint-disable-next-line no-console
console.log(chalk.yellow(`Fetching workspace details for "${args.id}"...`));
// We need to fetch the workspace info to get the instance name and zone
const workspaces = await client.listWorkspaces() as WorkspaceHubInfo[];
const ws = workspaces.find(w => w.id === args.id || w.name === args.id);
if (!ws) {
// eslint-disable-next-line no-console
console.error(chalk.red(`Error: Workspace "${args.id}" not found.`));
return;
}
const { status, instance_name: instanceName, zone } = ws;
if (status !== 'READY' && status !== 'PROVISIONING') {
// eslint-disable-next-line no-console
console.warn(chalk.yellow(`Warning: Workspace is in status ${status}. Connection might fail.`));
}
const ssh = new SSHService();
const project = 'dev-project';
// eslint-disable-next-line no-console
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,
zone,
project,
command: remoteCommand,
});
} catch (error: unknown) {
const message = error instanceof Error ? error.message : String(error);
// eslint-disable-next-line no-console
console.error(chalk.red('Connection failed:'), message);
}
}
export const connectCommand: CommandModule<object, ConnectArgs> = {
command: 'connect <id>',
describe: 'Connect to a remote workspace',
builder: (yargs) => yargs.positional('id', {
type: 'string',
describe: 'ID or Name of the workspace to connect to',
demandOption: true,
}),
handler: async (argv) => {
await connectToWorkspace(argv);
await exitCli();
},
};
@@ -150,12 +150,36 @@ const deleteCommand: SlashCommand = {
},
};
const connectCommand: SlashCommand = {
name: 'connect',
description: 'Connect to a remote workspace',
kind: CommandKind.BUILT_IN,
autoExecute: true,
action: async (
_context: CommandContext,
args: string,
): Promise<MessageActionReturn> => {
const id = args.trim();
if (!id) {
return {
type: 'message',
messageType: 'error',
content: 'Workspace ID is required. Usage: /workspace connect <id>',
};
}
return {
type: 'submit_prompt',
content: `I want to connect to remote workspace "${id}". Please run the connect command.`,
};
},
};
export const workspaceSlashCommand: SlashCommand = {
name: 'workspace',
altNames: ['wsr'],
description: 'Manage remote workspaces',
kind: CommandKind.BUILT_IN,
autoExecute: false,
subCommands: [listCommand, createCommand, deleteCommand],
subCommands: [listCommand, createCommand, deleteCommand, connectCommand],
action: async (context: CommandContext) => listAction(context),
};