mirror of
https://github.com/google-gemini/gemini-cli.git
synced 2026-07-30 11:41:00 -07:00
Compare commits
24 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 77f3515c8c | |||
| b4f1dab82e | |||
| c5893876bd | |||
| 5647fb09ae | |||
| c368e513b0 | |||
| 309bae18da | |||
| 26ce07d89b | |||
| a0db453abe | |||
| 1ccd86cfd7 | |||
| a7dd0ac571 | |||
| f5300cbb13 | |||
| d7422c1142 | |||
| 22ff923100 | |||
| bd523c8d48 | |||
| 2ddebe68e5 | |||
| 4de0f916d7 | |||
| 0c04e7c6ef | |||
| d6490cfd47 | |||
| c65f9a653e | |||
| 4a324ebb1f | |||
| 14317a52a4 | |||
| 2ae8ffc16b | |||
| ecb364c495 | |||
| 60c279e25e |
@@ -0,0 +1,57 @@
|
||||
# Detailed Design: Workspace Client & Connectivity
|
||||
|
||||
## 1. Introduction
|
||||
The Workspace Client is the part of `gemini-cli` that communicates with the Workspace Hub and provides the user interface for remote task management.
|
||||
|
||||
## 2. CLI Commands
|
||||
|
||||
### Management
|
||||
- `gemini workspace list`: Lists all active, stopped, and provisioning workspaces.
|
||||
- `gemini workspace create <name> [--image <tag>]`: Provisions a new remote environment.
|
||||
- `gemini workspace delete <name>`: Terminates the remote VM and cleans up state.
|
||||
- `gemini workspace stop/start <name>`: Manages the VM power state for cost savings.
|
||||
|
||||
### Connection
|
||||
- `gemini workspace connect <name>`:
|
||||
- Fetches the VM's IAP connection details from the Hub.
|
||||
- Initiates the secure SSH tunnel.
|
||||
- Syncs the local `~/.gemini` settings.
|
||||
- Attaches to the remote `shpool` session.
|
||||
|
||||
## 3. Connectivity Logic
|
||||
The client handles the complexity of IAP (Identity-Aware Proxy) and SSH.
|
||||
|
||||
### IAP Tunnelling
|
||||
1. **Auth Check:** Verifies the user is logged in to `gcloud` and has an active OAuth token for the Workspace Hub.
|
||||
2. **Lookup:** Queries the Hub's API to get the VM's internal name and zone.
|
||||
3. **Tunnel:** Executes `gcloud compute ssh <vm_name> --tunnel-through-iap`.
|
||||
|
||||
### SSH Parameters
|
||||
- **Agent Forwarding (`-A`):** Critical for Git operations on the remote VM.
|
||||
- **Environment Injection:** Pass local environment variables (like `TERM` or `LANG`) to ensure terminal compatibility.
|
||||
- **Secret Sync:** Before starting the shell, a side-channel (e.g., `scp`) pushes the GitHub PAT to `/dev/shm/.gh_token`.
|
||||
|
||||
## 4. UI Implementation (Workspaces Ability)
|
||||
A new interactive component in `packages/cli/src/ui/abilities/workspaces/`.
|
||||
|
||||
- **Sidebar:** Tree view of all workspaces across registered Hubs.
|
||||
- **Main View:**
|
||||
- **Status Dashboard:** Shows CPU/Memory load, active repo/branch, and uptime.
|
||||
- **Control Center:** Large buttons for Connect, Start/Stop, and Delete.
|
||||
- **Batch Console:** Allows broadcasting a command (e.g., `npm run lint`) to multiple selected workspaces.
|
||||
|
||||
## 5. Multi-Hub Configuration
|
||||
The CLI supports talking to multiple Hubs simultaneously (e.g., one personal, one team-wide).
|
||||
|
||||
- **Settings (`settings.json`):**
|
||||
```json
|
||||
{
|
||||
"workspaces": {
|
||||
"hubs": [
|
||||
{ "name": "Personal", "url": "https://hub-xyz.a.run.app" },
|
||||
{ "name": "Team Alpha", "url": "https://hub-alpha.a.run.app" }
|
||||
]
|
||||
}
|
||||
}
|
||||
```
|
||||
- **Context Awareness:** The CLI automatically switches the active Hub context based on the current directory or a command-line flag.
|
||||
@@ -0,0 +1,57 @@
|
||||
# Detailed Design: Workspace Container Image
|
||||
|
||||
## 1. Introduction
|
||||
|
||||
The Workspace Container Image defines the standardized software environment for
|
||||
all remote execution. It is pre-built and optimized for fast startup on GCE
|
||||
instances.
|
||||
|
||||
## 2. Dockerfile Specification
|
||||
|
||||
The image is maintained in `packages/workspace-manager/docker/Dockerfile`.
|
||||
|
||||
- **Base:** `node:20-slim`
|
||||
- **Environment:**
|
||||
- `GEMINI_CLI_WORKSPACE=1`
|
||||
- `DEBIAN_FRONTEND=noninteractive`
|
||||
- **Tools:**
|
||||
- `git`, `rsync`, `curl`, `vim`, `tmux`, `shpool`.
|
||||
- `gh` (GitHub CLI).
|
||||
- `google-cloud-sdk` (via apt-get).
|
||||
- Pre-compiled `gemini-cli` binary.
|
||||
- **User:** `node` (UID 1000) for unprivileged execution.
|
||||
|
||||
## 3. Image Contents & Pre-loading
|
||||
|
||||
- The `gemini-cli` nightly binary is pre-loaded into `/usr/local/bin/gemini`.
|
||||
- Standard node dependencies (`npm`, `yarn`, `pnpm`) are pre-installed.
|
||||
- `shpool` is used as the primary process manager to allow terminal detachment
|
||||
and re-attachment.
|
||||
|
||||
## 4. Entrypoint Strategy (`entrypoint.sh`)
|
||||
|
||||
When the container starts on GCE:
|
||||
|
||||
1. **Secret Injection:** Reads the GitHub PAT from a memory-only mount
|
||||
(`/dev/shm/github_token`) and authenticates `gh`.
|
||||
2. **Settings Restore:** Syncs the user's `~/.gemini/` configuration (aliased
|
||||
from `/home/node/.gemini_volume`).
|
||||
3. **Persistence Layer:** Starts `shpool` daemon in the background.
|
||||
4. **Ready Signal:** Notifies the Workspace Hub that the environment is ready
|
||||
for connection.
|
||||
|
||||
## 5. Storage Strategy
|
||||
|
||||
- **System:** The container image itself is ephemeral.
|
||||
- **User Home:** A persistent GCE Disk (PD) is mounted at `/home/node`. This
|
||||
ensures:
|
||||
- `~/.gemini` settings persist.
|
||||
- Cloned git repositories persist between workspace restarts.
|
||||
- `npm install` artifacts (node_modules) persist.
|
||||
|
||||
## 6. Build & Release
|
||||
|
||||
- The image is automatically built and pushed to the Hub's Artifact Registry on
|
||||
every `main` push or new `nightly` tag.
|
||||
- The Hub API defaults to using the `latest` or `nightly` tag unless specified
|
||||
otherwise.
|
||||
@@ -0,0 +1,56 @@
|
||||
# Detailed Design: Workspace Hub Service
|
||||
|
||||
## 1. Introduction
|
||||
The Workspace Hub is a serverless application (deployed on Cloud Run) that manages the fleet of remote execution environments. It is designed as a deployable, self-service feature for developers and teams.
|
||||
|
||||
## 2. API Endpoints
|
||||
|
||||
| Method | Endpoint | Description |
|
||||
|---|---|---|
|
||||
| `GET` | `/workspaces` | List all workspaces for the authenticated user. |
|
||||
| `POST` | `/workspaces` | Request creation of a new GCE-backed workspace. |
|
||||
| `DELETE` | `/workspaces/:id` | Destroy the VM and clean up Firestore state. |
|
||||
| `POST` | `/workspaces/:id/stop` | Suspend the GCE instance (cost-saving). |
|
||||
| `POST` | `/workspaces/:id/start` | Resume a suspended instance. |
|
||||
| `GET` | `/workspaces/:id/status` | Get real-time status from GCE and Firestore. |
|
||||
|
||||
## 3. Firestore State Store
|
||||
The Hub maintains a centralized state to enable multi-device synchronization.
|
||||
|
||||
- **Collection:** `workspaces`
|
||||
- `id`: Unique identifier (UUID).
|
||||
- `owner_id`: Google User ID (from OAuth).
|
||||
- `instance_name`: GCE VM name.
|
||||
- `zone`: GCE Zone (e.g., `us-west1-a`).
|
||||
- `image_tag`: Docker image tag currently in use.
|
||||
- `machine_type`: GCE Machine type (e.g., `e2-standard-4`).
|
||||
- `status`: One of `PROVISIONING`, `READY`, `SUSPENDED`, `ERROR`.
|
||||
- `last_connected_at`: Timestamp for auto-cleanup logic.
|
||||
- `metadata`: `{ repo: string, branch: string, device_id: string }`.
|
||||
|
||||
## 4. GCE Lifecycle Management
|
||||
The Hub uses the GCP Compute Engine Node.js SDK to interact with VMs.
|
||||
|
||||
### Provisioning
|
||||
1. Verify the user has quota and permissions.
|
||||
2. Call `instances.insert` with "Container-on-VM" configuration.
|
||||
3. Inject cloud-init or metadata scripts to:
|
||||
- Setup SSH (via IAP).
|
||||
- Configure the memory-only mount for secrets.
|
||||
- Notify the Hub when the container is ready.
|
||||
|
||||
### Auto-Cleanup (TTL)
|
||||
- A periodic Cloud Scheduler job triggers a `/cleanup` endpoint on the Hub.
|
||||
- Idle workspaces (based on `last_connected_at`) are automatically stopped or deleted to prevent unnecessary GCP costs.
|
||||
|
||||
## 5. Multi-Tenancy Implementation
|
||||
- **Team Mode:** The Hub's service account must have "Compute Admin" roles on the shared project.
|
||||
- **Access Control:** Every API request is checked against the `owner_id` in Firestore. Only the owner (or an admin in team mode) can modify or delete a workspace.
|
||||
- **Resource Isolation:** Each workspace is an independent VM. There is no sharing of CPU/Memory between workspaces.
|
||||
|
||||
## 6. Deployment
|
||||
The Hub is provided as a Terraform module (`/terraform/workspace-hub/`) for automated setup of:
|
||||
- Cloud Run service.
|
||||
- Firestore database.
|
||||
- Artifact Registry (for Workspace Images).
|
||||
- IAM roles and permissions.
|
||||
@@ -0,0 +1,44 @@
|
||||
# Gemini CLI Workspaces: High-Level Architecture Overview
|
||||
|
||||
## 1. Introduction
|
||||
Gemini CLI Workspaces provides a distributed, persistent, and multi-device compute layer for `gemini-cli`. It enables developers to provision, manage, and "teleport" into remote execution environments hosted on GCP.
|
||||
|
||||
## 2. Core Vision
|
||||
- **Persistence:** Sessions survive local device disconnects or terminal restarts.
|
||||
- **Portability:** Start a task on one device (e.g., Laptop) and seamlessly re-attach from another (e.g., Surface/Tablet).
|
||||
- **Scale:** Offload heavy compute (builds, tests, evals) to remote GCE instances.
|
||||
- **Consistency:** Pre-built container images ensure every workspace has the exact same tools and environment.
|
||||
|
||||
## 3. High-Level Architecture
|
||||
The architecture is centered around a **Workspace Hub**, which acts as the fleet manager, and **Remote Workspaces**, which are containerized GCE VMs.
|
||||
|
||||
```mermaid
|
||||
graph TD
|
||||
A[Local Device: gemini-cli] -->|API Calls| B(Workspace Hub: Cloud Run)
|
||||
A -->|SSH/IAP| C(Remote Workspace: GCE VM)
|
||||
B -->|Provision/Monitor| C
|
||||
B -->|State Persistence| D(Firestore)
|
||||
C -->|Pull Image| E(Artifact Registry)
|
||||
```
|
||||
|
||||
## 4. Multi-Tenancy Models
|
||||
The Workspace Hub is a self-service, deployable feature that supports several grains of multi-tenancy:
|
||||
|
||||
### A. Per-User (Personal Cloud)
|
||||
- **Deployment:** Each developer deploys their own Workspace Hub in a personal GCP project.
|
||||
- **Isolation:** Absolute. All VMs and secrets belong to the individual.
|
||||
|
||||
### B. Per-Team (Shared Infrastructure)
|
||||
- **Deployment:** A single Hub managed by a team/org.
|
||||
- **Tenancy:** Identity-based partitioning. The Hub filters instances based on the authenticated user's Google ID.
|
||||
- **Isolation:** Instances are tagged with `owner_id`. Users can only manage their own environments.
|
||||
|
||||
### C. Per-Repository (Project Environments)
|
||||
- **Deployment:** Tied to a specific repo (e.g., for PR reviews or ephemeral test envs).
|
||||
- **Tenancy:** Project-context isolation. Users can connect to any workspace associated with the repository context.
|
||||
|
||||
## 5. Multi-Device Portability
|
||||
Since the Workspace Hub stores the state centrally (Firestore), any device with the authenticated `gemini-cli` can:
|
||||
1. Query the Hub for the list of active workspaces.
|
||||
2. Initiate a connection to a remote VM started by *another* device.
|
||||
3. Sync its local `~/.gemini` settings and GitHub PAT to ensure a consistent experience on the remote side.
|
||||
@@ -0,0 +1,48 @@
|
||||
# Detailed Design: Sync & Authentication Mechanism
|
||||
|
||||
## 1. Introduction
|
||||
A core requirement for Gemini CLI Workspaces is the secure and seamless synchronization of developer settings and repository credentials between the local machine and the remote environment.
|
||||
|
||||
## 2. Configuration Sync (`~/.gemini/`)
|
||||
To maintain a consistent experience, the local CLI synchronizes the developer's environment to the remote workspace.
|
||||
|
||||
### Sync Strategy
|
||||
- **Trigger:** Initial creation and subsequent connections via `gemini workspace connect`.
|
||||
- **Mechanism:** Secure `rsync` over SSH tunnel.
|
||||
- **Scope:**
|
||||
- `~/.gemini/settings.json` (aliases, UI preferences).
|
||||
- `~/.gemini/commands/` (custom user commands).
|
||||
- `~/.gemini/skills/` (custom user-defined skills).
|
||||
- **Exclusions:**
|
||||
- `.gemini/logs/` (local device specific).
|
||||
- `.gemini/cache/` (can be large and re-generated).
|
||||
- Sensitive files containing the word `*secret*` or `*key*` (these use the injection mechanism below).
|
||||
|
||||
## 3. Git Authentication (SSH Agent Forwarding)
|
||||
Private SSH keys never leave the developer's local machine.
|
||||
|
||||
- **Setup:** The local CLI ensures `ssh-agent` is running and the required keys are added.
|
||||
- **Connection:** The SSH connection from the CLI uses the `-A` (Agent Forwarding) flag.
|
||||
- **Remote:** Git on the remote VM automatically uses the forwarded agent socket for cloning or pushing to repositories (GitHub, GitLab, internal).
|
||||
|
||||
## 4. GitHub CLI (`gh`) Authentication
|
||||
For advanced integration (PRs, issues), the GitHub CLI requires a token.
|
||||
|
||||
- **Injection Process:**
|
||||
1. Local CLI fetches a GitHub Personal Access Token (PAT) from the OS Keychain (e.g., via `keytar`).
|
||||
2. During the SSH handshake, the CLI writes the token to `/dev/shm/.gh_token` (a memory-only, non-persistent mount).
|
||||
3. The remote shell profile (`.bashrc` or `.zshrc`) exports `GH_TOKEN=$(cat /dev/shm/.gh_token)`.
|
||||
- **Cleanup:** Terminating the workspace container or VM wipes the `/dev/shm` mount, ensuring no long-lived PATs remain on the remote disk.
|
||||
|
||||
## 5. Hub Authentication
|
||||
Access to the Workspace Hub API is secured via Google OAuth.
|
||||
|
||||
- **Local:** The CLI uses the `gcloud` or a built-in OAuth flow to obtain a token.
|
||||
- **Hub:** The Hub service (on Cloud Run) validates the bearer token and extracts the `sub` (Google User ID) to partition data in Firestore.
|
||||
|
||||
## 6. Multi-Device "Handover" Workflow
|
||||
1. **Device A (Laptop):** Creates workspace `work-1`. Hub records `owner=user-x`.
|
||||
2. **Device B (Surface):** User moves to a coffee shop. Runs `gemini workspace connect work-1`.
|
||||
3. **Hub Check:** Hub verifies the user is `user-x`.
|
||||
4. **Client Sync:** **Device B** pushes its local `~/.gemini` settings and its own GitHub PAT to `work-1`.
|
||||
5. **Result:** `work-1` now behaves exactly like the user's local **Device B** environment.
|
||||
@@ -136,6 +136,33 @@
|
||||
{ "label": "Sandboxing", "slug": "docs/cli/sandbox" },
|
||||
{ "label": "Settings", "slug": "docs/cli/settings" },
|
||||
{ "label": "Telemetry", "slug": "docs/cli/telemetry" },
|
||||
{
|
||||
"label": "Workspaces",
|
||||
"badge": "🔬",
|
||||
"collapsed": true,
|
||||
"items": [
|
||||
{
|
||||
"label": "Architecture Overview",
|
||||
"slug": "docs/architecture/workspaces/overview"
|
||||
},
|
||||
{
|
||||
"label": "Workspace Hub",
|
||||
"slug": "docs/architecture/workspaces/hub-service"
|
||||
},
|
||||
{
|
||||
"label": "Workspace Image",
|
||||
"slug": "docs/architecture/workspaces/container-image"
|
||||
},
|
||||
{
|
||||
"label": "Client & Connectivity",
|
||||
"slug": "docs/architecture/workspaces/client-connectivity"
|
||||
},
|
||||
{
|
||||
"label": "Sync & Authentication",
|
||||
"slug": "docs/architecture/workspaces/sync-and-auth"
|
||||
}
|
||||
]
|
||||
},
|
||||
{ "label": "Token caching", "slug": "docs/cli/token-caching" }
|
||||
]
|
||||
},
|
||||
|
||||
Generated
+555
-152
@@ -80,7 +80,7 @@
|
||||
"@lydell/node-pty-win32-arm64": "1.1.0",
|
||||
"@lydell/node-pty-win32-x64": "1.1.0",
|
||||
"keytar": "^7.9.0",
|
||||
"node-pty": "^1.1.0"
|
||||
"node-pty": "^1.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@agentclientprotocol/sdk": {
|
||||
@@ -1376,6 +1376,10 @@
|
||||
"resolved": "packages/test-utils",
|
||||
"link": true
|
||||
},
|
||||
"node_modules/@google/gemini-cli-workspace-manager": {
|
||||
"resolved": "packages/workspace-manager",
|
||||
"link": true
|
||||
},
|
||||
"node_modules/@google/genai": {
|
||||
"version": "1.30.0",
|
||||
"resolved": "https://registry.npmjs.org/@google/genai/-/genai-1.30.0.tgz",
|
||||
@@ -4312,6 +4316,13 @@
|
||||
"integrity": "sha512-ko/gIFJRv177XgZsZcBwnqJN5x/Gien8qNOn0D5bQU/zAzVf9Zt3BlcUiLqhV9y4ARk0GbT3tnUiPNgnTXzc/Q==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@types/uuid": {
|
||||
"version": "10.0.0",
|
||||
"resolved": "https://registry.npmjs.org/@types/uuid/-/uuid-10.0.0.tgz",
|
||||
"integrity": "sha512-7gqG38EyHgyP1S+7+xomFtL+ZNHcKv6DwNaCZmJmo1vgMugyF3TCnXVg4t1uk89mLNwnLtnY3TpOpCOyp1/xHQ==",
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@types/ws": {
|
||||
"version": "8.18.1",
|
||||
"resolved": "https://registry.npmjs.org/@types/ws/-/ws-8.18.1.tgz",
|
||||
@@ -4906,6 +4917,53 @@
|
||||
"url": "https://opencollective.com/vitest"
|
||||
}
|
||||
},
|
||||
"node_modules/@vscode/vsce": {
|
||||
"version": "3.6.0",
|
||||
"resolved": "https://registry.npmjs.org/@vscode/vsce/-/vsce-3.6.0.tgz",
|
||||
"integrity": "sha512-u2ZoMfymRNJb14aHNawnXJtXHLXDVKc1oKZaH4VELKT/9iWKRVgtQOdwxCgtwSxJoqYvuK4hGlBWQJ05wxADhg==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@azure/identity": "^4.1.0",
|
||||
"@secretlint/node": "^10.1.1",
|
||||
"@secretlint/secretlint-formatter-sarif": "^10.1.1",
|
||||
"@secretlint/secretlint-rule-no-dotenv": "^10.1.1",
|
||||
"@secretlint/secretlint-rule-preset-recommend": "^10.1.1",
|
||||
"@vscode/vsce-sign": "^2.0.0",
|
||||
"azure-devops-node-api": "^12.5.0",
|
||||
"chalk": "^4.1.2",
|
||||
"cheerio": "^1.0.0-rc.9",
|
||||
"cockatiel": "^3.1.2",
|
||||
"commander": "^12.1.0",
|
||||
"form-data": "^4.0.0",
|
||||
"glob": "^11.0.0",
|
||||
"hosted-git-info": "^4.0.2",
|
||||
"jsonc-parser": "^3.2.0",
|
||||
"leven": "^3.1.0",
|
||||
"markdown-it": "^14.1.0",
|
||||
"mime": "^1.3.4",
|
||||
"minimatch": "^3.0.3",
|
||||
"parse-semver": "^1.1.1",
|
||||
"read": "^1.0.7",
|
||||
"secretlint": "^10.1.1",
|
||||
"semver": "^7.5.2",
|
||||
"tmp": "^0.2.3",
|
||||
"typed-rest-client": "^1.8.4",
|
||||
"url-join": "^4.0.1",
|
||||
"xml2js": "^0.5.0",
|
||||
"yauzl": "^2.3.1",
|
||||
"yazl": "^2.2.2"
|
||||
},
|
||||
"bin": {
|
||||
"vsce": "vsce"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">= 20"
|
||||
},
|
||||
"optionalDependencies": {
|
||||
"keytar": "^7.7.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@vscode/vsce-sign": {
|
||||
"version": "2.0.6",
|
||||
"resolved": "https://registry.npmjs.org/@vscode/vsce-sign/-/vsce-sign-2.0.6.tgz",
|
||||
@@ -5051,6 +5109,52 @@
|
||||
"win32"
|
||||
]
|
||||
},
|
||||
"node_modules/@vscode/vsce/node_modules/hosted-git-info": {
|
||||
"version": "4.1.0",
|
||||
"resolved": "https://registry.npmjs.org/hosted-git-info/-/hosted-git-info-4.1.0.tgz",
|
||||
"integrity": "sha512-kyCuEOWjJqZuDbRHzL8V93NzQhwIB71oFWSyzVo+KPZI+pnQPPxucdkrOZvkLRnrf5URsQM+IJ09Dw29cRALIA==",
|
||||
"dev": true,
|
||||
"license": "ISC",
|
||||
"dependencies": {
|
||||
"lru-cache": "^6.0.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=10"
|
||||
}
|
||||
},
|
||||
"node_modules/@vscode/vsce/node_modules/lru-cache": {
|
||||
"version": "6.0.0",
|
||||
"resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-6.0.0.tgz",
|
||||
"integrity": "sha512-Jo6dJ04CmSjuznwJSS3pUeWmd/H0ffTlkXXgwZi+eq1UCmqQwCh+eLsYOYCwY991i2Fah4h1BEMCx4qThGbsiA==",
|
||||
"dev": true,
|
||||
"license": "ISC",
|
||||
"dependencies": {
|
||||
"yallist": "^4.0.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=10"
|
||||
}
|
||||
},
|
||||
"node_modules/@vscode/vsce/node_modules/mime": {
|
||||
"version": "1.6.0",
|
||||
"resolved": "https://registry.npmjs.org/mime/-/mime-1.6.0.tgz",
|
||||
"integrity": "sha512-x0Vn8spI+wuJ1O6S7gnbaQg8Pxh4NNHb7KSINmEWKiPE4RKOplvijn+NkmYmmRgP68mc70j2EbeTFRsrswaQeg==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"bin": {
|
||||
"mime": "cli.js"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=4"
|
||||
}
|
||||
},
|
||||
"node_modules/@vscode/vsce/node_modules/yallist": {
|
||||
"version": "4.0.0",
|
||||
"resolved": "https://registry.npmjs.org/yallist/-/yallist-4.0.0.tgz",
|
||||
"integrity": "sha512-3wdGidZyq5PB084XLES5TpOSRA3wjXAlIWMhum2kRcv/41Sn2emQ0dycQW4uZXLejwKvg6EsvbdlVL+FYEct7A==",
|
||||
"dev": true,
|
||||
"license": "ISC"
|
||||
},
|
||||
"node_modules/@vue/compiler-core": {
|
||||
"version": "3.5.26",
|
||||
"resolved": "https://registry.npmjs.org/@vue/compiler-core/-/compiler-core-3.5.26.tgz",
|
||||
@@ -5341,6 +5445,12 @@
|
||||
"node": ">=8"
|
||||
}
|
||||
},
|
||||
"node_modules/array-flatten": {
|
||||
"version": "1.1.1",
|
||||
"resolved": "https://registry.npmjs.org/array-flatten/-/array-flatten-1.1.1.tgz",
|
||||
"integrity": "sha512-PCVAQswWemu6UdxsDFFX/+gVeYqKAod3D3UVm91jHwynguOwAvYPhx8nNlM++NqRcK6CxxpUafjmhIdKiHibqg==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/array-includes": {
|
||||
"version": "3.1.9",
|
||||
"resolved": "https://registry.npmjs.org/array-includes/-/array-includes-3.1.9.tgz",
|
||||
@@ -6562,13 +6672,6 @@
|
||||
"url": "https://github.com/sponsors/sindresorhus"
|
||||
}
|
||||
},
|
||||
"node_modules/concat-map": {
|
||||
"version": "0.0.1",
|
||||
"resolved": "https://registry.npmjs.org/concat-map/-/concat-map-0.0.1.tgz",
|
||||
"integrity": "sha512-/Srv4dswyQNBfohGpz9o6Yb3Gz3SrUDqBH5rTuhGR7ahtlbYKnVxw2bCFMRljaA7EXHaXZ8wsHdodFvbkhKmqg==",
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/config-chain": {
|
||||
"version": "1.1.13",
|
||||
"resolved": "https://registry.npmjs.org/config-chain/-/config-chain-1.1.13.tgz",
|
||||
@@ -7289,6 +7392,16 @@
|
||||
"node": ">=6"
|
||||
}
|
||||
},
|
||||
"node_modules/destroy": {
|
||||
"version": "1.2.0",
|
||||
"resolved": "https://registry.npmjs.org/destroy/-/destroy-1.2.0.tgz",
|
||||
"integrity": "sha512-2sJGJTaXIIaR1w4iJSNoN0hnMY7Gpc/n8D4qSCJw8QqFWXf7cuAgnEHxBpweaVcPevC2l3KpjYCx3NypQQgaJg==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">= 0.8",
|
||||
"npm": "1.2.8000 || >= 1.4.16"
|
||||
}
|
||||
},
|
||||
"node_modules/detect-file": {
|
||||
"version": "1.0.0",
|
||||
"resolved": "https://registry.npmjs.org/detect-file/-/detect-file-1.0.0.tgz",
|
||||
@@ -9018,6 +9131,12 @@
|
||||
"url": "https://github.com/sponsors/ljharb"
|
||||
}
|
||||
},
|
||||
"node_modules/functional-red-black-tree": {
|
||||
"version": "1.0.1",
|
||||
"resolved": "https://registry.npmjs.org/functional-red-black-tree/-/functional-red-black-tree-1.0.1.tgz",
|
||||
"integrity": "sha512-dsKNQNdj6xA3T+QlADDA7mOSlX0qiMINjn0cgr+eGHGsbSHzTabcIogz2+p/iqP1Xs6EP/sS2SbqH+brGTbq0g==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/functions-have-names": {
|
||||
"version": "1.2.3",
|
||||
"resolved": "https://registry.npmjs.org/functions-have-names/-/functions-have-names-1.2.3.tgz",
|
||||
@@ -11819,7 +11938,6 @@
|
||||
"version": "1.1.2",
|
||||
"resolved": "https://registry.npmjs.org/methods/-/methods-1.1.2.tgz",
|
||||
"integrity": "sha512-iclAHeNqNm68zFtnZ0e+1L2yUIdvzNoauKU4WBA3VvH/vPFieF7qfRlwUZU+DA9P9bPXIS90ulxoUoCH23sV2w==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">= 0.6"
|
||||
@@ -12107,6 +12225,13 @@
|
||||
"node": "^18.17.0 || >=20.5.0"
|
||||
}
|
||||
},
|
||||
"node_modules/nan": {
|
||||
"version": "2.23.0",
|
||||
"resolved": "https://registry.npmjs.org/nan/-/nan-2.23.0.tgz",
|
||||
"integrity": "sha512-1UxuyYGdoQHcGg87Lkqm3FzefucTa0NAiOcuRsDmysep3c1LVCRK2krrUDafMWtjSG04htvAmvg96+SDknOmgQ==",
|
||||
"license": "MIT",
|
||||
"optional": true
|
||||
},
|
||||
"node_modules/nano-spawn": {
|
||||
"version": "1.0.3",
|
||||
"resolved": "https://registry.npmjs.org/nano-spawn/-/nano-spawn-1.0.3.tgz",
|
||||
@@ -12250,23 +12375,16 @@
|
||||
}
|
||||
},
|
||||
"node_modules/node-pty": {
|
||||
"version": "1.1.0",
|
||||
"resolved": "https://registry.npmjs.org/node-pty/-/node-pty-1.1.0.tgz",
|
||||
"integrity": "sha512-20JqtutY6JPXTUnL0ij1uad7Qe1baT46lyolh2sSENDd4sTzKZ4nmAFkeAARDKwmlLjPx6XKRlwRUxwjOy+lUg==",
|
||||
"version": "1.0.0",
|
||||
"resolved": "https://registry.npmjs.org/node-pty/-/node-pty-1.0.0.tgz",
|
||||
"integrity": "sha512-wtBMWWS7dFZm/VgqElrTvtfMq4GzJ6+edFI0Y0zyzygUSZMgZdraDUMUhCIvkjhJjme15qWmbyJbtAx4ot4uZA==",
|
||||
"hasInstallScript": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"dependencies": {
|
||||
"node-addon-api": "^7.1.0"
|
||||
"nan": "^2.17.0"
|
||||
}
|
||||
},
|
||||
"node_modules/node-pty/node_modules/node-addon-api": {
|
||||
"version": "7.1.1",
|
||||
"resolved": "https://registry.npmjs.org/node-addon-api/-/node-addon-api-7.1.1.tgz",
|
||||
"integrity": "sha512-5m3bsyrjFWE1xf7nz7YXdN4udnVtXK6/Yfgn5qnahL6bCkf2yKt4k3nuTKAtT4r3IG8JNR2ncsIMdZuAzJjHQQ==",
|
||||
"license": "MIT",
|
||||
"optional": true
|
||||
},
|
||||
"node_modules/node-sarif-builder": {
|
||||
"version": "3.2.0",
|
||||
"resolved": "https://registry.npmjs.org/node-sarif-builder/-/node-sarif-builder-3.2.0.tgz",
|
||||
@@ -13169,6 +13287,12 @@
|
||||
"node": "20 || >=22"
|
||||
}
|
||||
},
|
||||
"node_modules/path-to-regexp": {
|
||||
"version": "0.1.12",
|
||||
"resolved": "https://registry.npmjs.org/path-to-regexp/-/path-to-regexp-0.1.12.tgz",
|
||||
"integrity": "sha512-RA1GjUVMnvYFxuqovrEqZoxxW5NUZqbwKtYz/Tt7nXerk0LbLblQmrsgdeOxV5SFHf0UDggjS/bSeOZwt1pmEQ==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/path-type": {
|
||||
"version": "3.0.0",
|
||||
"resolved": "https://registry.npmjs.org/path-type/-/path-type-3.0.0.tgz",
|
||||
@@ -16490,6 +16614,15 @@
|
||||
"integrity": "sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/utils-merge": {
|
||||
"version": "1.0.1",
|
||||
"resolved": "https://registry.npmjs.org/utils-merge/-/utils-merge-1.0.1.tgz",
|
||||
"integrity": "sha512-pMZTvIkT1d+TFGvDOqodOclx0QWkkgi6Tdoa8gC8ffGAAqz9pzPTZWAybbsHHoED/ztMtkv/VoYTYyShUn81hA==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">= 0.4.0"
|
||||
}
|
||||
},
|
||||
"node_modules/uuid": {
|
||||
"version": "9.0.1",
|
||||
"resolved": "https://registry.npmjs.org/uuid/-/uuid-9.0.1.tgz",
|
||||
@@ -17910,23 +18043,6 @@
|
||||
"node": ">=20"
|
||||
}
|
||||
},
|
||||
"packages/snake-game": {
|
||||
"name": "@google/gemini-cli-snake-game",
|
||||
"version": "0.1.0",
|
||||
"extraneous": true,
|
||||
"license": "Apache-2.0",
|
||||
"dependencies": {
|
||||
"lucide-react": "^0.471.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/react": "^19.2.0",
|
||||
"@types/react-dom": "^19.2.0",
|
||||
"esbuild": "^0.25.0",
|
||||
"react": "^19.2.0",
|
||||
"react-dom": "^19.2.0",
|
||||
"typescript": "^5.7.3"
|
||||
}
|
||||
},
|
||||
"packages/test-utils": {
|
||||
"name": "@google/gemini-cli-test-utils",
|
||||
"version": "0.36.0-nightly.20260317.2f90b4653",
|
||||
@@ -17961,7 +18077,7 @@
|
||||
"@types/vscode": "^1.99.0",
|
||||
"@typescript-eslint/eslint-plugin": "^8.31.1",
|
||||
"@typescript-eslint/parser": "^8.31.1",
|
||||
"@vscode/vsce": "^3.7.1",
|
||||
"@vscode/vsce": "^3.6.0",
|
||||
"esbuild": "^0.25.3",
|
||||
"eslint": "^9.25.1",
|
||||
"npm-run-all2": "^8.0.2",
|
||||
@@ -17979,140 +18095,247 @@
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"packages/vscode-ide-companion/node_modules/@vscode/vsce": {
|
||||
"version": "3.7.1",
|
||||
"resolved": "https://registry.npmjs.org/@vscode/vsce/-/vsce-3.7.1.tgz",
|
||||
"integrity": "sha512-OTm2XdMt2YkpSn2Nx7z2EJtSuhRHsTPYsSK59hr3v8jRArK+2UEoju4Jumn1CmpgoBLGI6ReHLJ/czYltNUW3g==",
|
||||
"dev": true,
|
||||
"packages/workspace-manager": {
|
||||
"name": "@google/gemini-cli-workspace-manager",
|
||||
"version": "0.1.0",
|
||||
"dependencies": {
|
||||
"@google-cloud/compute": "^4.10.0",
|
||||
"@google-cloud/firestore": "^7.11.0",
|
||||
"express": "^4.21.2",
|
||||
"uuid": "^13.0.0",
|
||||
"zod": "^3.25.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/express": "^5.0.0",
|
||||
"@types/node": "^20.0.0",
|
||||
"@types/uuid": "^10.0.0",
|
||||
"supertest": "^7.2.2",
|
||||
"typescript": "^5.7.0",
|
||||
"vitest": "^3.2.0"
|
||||
}
|
||||
},
|
||||
"packages/workspace-manager/node_modules/@google-cloud/compute": {
|
||||
"version": "4.12.0",
|
||||
"resolved": "https://registry.npmjs.org/@google-cloud/compute/-/compute-4.12.0.tgz",
|
||||
"integrity": "sha512-N3isWbxIMd02qzdlFFxHxEM+2B/vNgn9N7WMpteY2sfwN1yT9PJHcilKDLyw+uIwuQAoErNxBM+JOLq3r/Tv+Q==",
|
||||
"license": "Apache-2.0",
|
||||
"dependencies": {
|
||||
"google-gax": "^4.0.3"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=14.0.0"
|
||||
}
|
||||
},
|
||||
"packages/workspace-manager/node_modules/@google-cloud/firestore": {
|
||||
"version": "7.11.6",
|
||||
"resolved": "https://registry.npmjs.org/@google-cloud/firestore/-/firestore-7.11.6.tgz",
|
||||
"integrity": "sha512-EW/O8ktzwLfyWBOsNuhRoMi8lrC3clHM5LVFhGvO1HCsLozCOOXRAlHrYBoE6HL42Sc8yYMuCb2XqcnJ4OOEpw==",
|
||||
"license": "Apache-2.0",
|
||||
"dependencies": {
|
||||
"@opentelemetry/api": "^1.3.0",
|
||||
"fast-deep-equal": "^3.1.1",
|
||||
"functional-red-black-tree": "^1.0.1",
|
||||
"google-gax": "^4.3.3",
|
||||
"protobufjs": "^7.2.6"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=14.0.0"
|
||||
}
|
||||
},
|
||||
"packages/workspace-manager/node_modules/accepts": {
|
||||
"version": "1.3.8",
|
||||
"resolved": "https://registry.npmjs.org/accepts/-/accepts-1.3.8.tgz",
|
||||
"integrity": "sha512-PYAthTa2m2VKxuvSD3DPC/Gy+U+sOA1LAuT8mkmRuvw+NACSaeXEQ+NHcVF7rONl6qcaxV3Uuemwawk+7+SJLw==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@azure/identity": "^4.1.0",
|
||||
"@secretlint/node": "^10.1.2",
|
||||
"@secretlint/secretlint-formatter-sarif": "^10.1.2",
|
||||
"@secretlint/secretlint-rule-no-dotenv": "^10.1.2",
|
||||
"@secretlint/secretlint-rule-preset-recommend": "^10.1.2",
|
||||
"@vscode/vsce-sign": "^2.0.0",
|
||||
"azure-devops-node-api": "^12.5.0",
|
||||
"chalk": "^4.1.2",
|
||||
"cheerio": "^1.0.0-rc.9",
|
||||
"cockatiel": "^3.1.2",
|
||||
"commander": "^12.1.0",
|
||||
"form-data": "^4.0.0",
|
||||
"glob": "^11.0.0",
|
||||
"hosted-git-info": "^4.0.2",
|
||||
"jsonc-parser": "^3.2.0",
|
||||
"leven": "^3.1.0",
|
||||
"markdown-it": "^14.1.0",
|
||||
"mime": "^1.3.4",
|
||||
"minimatch": "^3.0.3",
|
||||
"parse-semver": "^1.1.1",
|
||||
"read": "^1.0.7",
|
||||
"secretlint": "^10.1.2",
|
||||
"semver": "^7.5.2",
|
||||
"tmp": "^0.2.3",
|
||||
"typed-rest-client": "^1.8.4",
|
||||
"url-join": "^4.0.1",
|
||||
"xml2js": "^0.5.0",
|
||||
"yauzl": "^2.3.1",
|
||||
"yazl": "^2.2.2"
|
||||
},
|
||||
"bin": {
|
||||
"vsce": "vsce"
|
||||
"mime-types": "~2.1.34",
|
||||
"negotiator": "0.6.3"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">= 20"
|
||||
},
|
||||
"optionalDependencies": {
|
||||
"keytar": "^7.7.0"
|
||||
"node": ">= 0.6"
|
||||
}
|
||||
},
|
||||
"packages/vscode-ide-companion/node_modules/@vscode/vsce/node_modules/minimatch": {
|
||||
"version": "3.1.5",
|
||||
"resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.5.tgz",
|
||||
"integrity": "sha512-VgjWUsnnT6n+NUk6eZq77zeFdpW2LWDzP6zFGrCbHXiYNul5Dzqk2HHQ5uFH2DNW5Xbp8+jVzaeNt94ssEEl4w==",
|
||||
"dev": true,
|
||||
"license": "ISC",
|
||||
"packages/workspace-manager/node_modules/body-parser": {
|
||||
"version": "1.20.4",
|
||||
"resolved": "https://registry.npmjs.org/body-parser/-/body-parser-1.20.4.tgz",
|
||||
"integrity": "sha512-ZTgYYLMOXY9qKU/57FAo8F+HA2dGX7bqGc71txDRC1rS4frdFI5R7NhluHxH6M0YItAP0sHB4uqAOcYKxO6uGA==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"brace-expansion": "^1.1.7"
|
||||
"bytes": "~3.1.2",
|
||||
"content-type": "~1.0.5",
|
||||
"debug": "2.6.9",
|
||||
"depd": "2.0.0",
|
||||
"destroy": "~1.2.0",
|
||||
"http-errors": "~2.0.1",
|
||||
"iconv-lite": "~0.4.24",
|
||||
"on-finished": "~2.4.1",
|
||||
"qs": "~6.14.0",
|
||||
"raw-body": "~2.5.3",
|
||||
"type-is": "~1.6.18",
|
||||
"unpipe": "~1.0.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": "*"
|
||||
"node": ">= 0.8",
|
||||
"npm": "1.2.8000 || >= 1.4.16"
|
||||
}
|
||||
},
|
||||
"packages/vscode-ide-companion/node_modules/balanced-match": {
|
||||
"version": "1.0.2",
|
||||
"resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-1.0.2.tgz",
|
||||
"integrity": "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==",
|
||||
"dev": true,
|
||||
"packages/workspace-manager/node_modules/content-disposition": {
|
||||
"version": "0.5.4",
|
||||
"resolved": "https://registry.npmjs.org/content-disposition/-/content-disposition-0.5.4.tgz",
|
||||
"integrity": "sha512-FveZTNuGw04cxlAiWbzi6zTAL/lhehaWbTtgluJh4/E95DqMwTmha3KZN1aAWA8cFIhHzMZUvLevkw5Rqk+tSQ==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"safe-buffer": "5.2.1"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">= 0.6"
|
||||
}
|
||||
},
|
||||
"packages/workspace-manager/node_modules/cookie-signature": {
|
||||
"version": "1.0.7",
|
||||
"resolved": "https://registry.npmjs.org/cookie-signature/-/cookie-signature-1.0.7.tgz",
|
||||
"integrity": "sha512-NXdYc3dLr47pBkpUCHtKSwIOQXLVn8dZEuywboCOJY/osA0wFSLlSawr3KN8qXJEyX66FcONTH8EIlVuK0yyFA==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"packages/vscode-ide-companion/node_modules/brace-expansion": {
|
||||
"version": "1.1.12",
|
||||
"resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.12.tgz",
|
||||
"integrity": "sha512-9T9UjW3r0UW5c1Q7GTwllptXwhvYmEzFhzMfZ9H7FQWt+uZePjZPjBP/W1ZEyZ1twGWom5/56TF4lPcqjnDHcg==",
|
||||
"packages/workspace-manager/node_modules/debug": {
|
||||
"version": "2.6.9",
|
||||
"resolved": "https://registry.npmjs.org/debug/-/debug-2.6.9.tgz",
|
||||
"integrity": "sha512-bC7ElrdJaJnPbAP+1EotYvqZsb3ecl5wi6Bfi6BJTUcNowp6cvspg0jXznRTKDjm/E7AdgFBVeAPVMNcKGsHMA==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"ms": "2.0.0"
|
||||
}
|
||||
},
|
||||
"packages/workspace-manager/node_modules/debug/node_modules/ms": {
|
||||
"version": "2.0.0",
|
||||
"resolved": "https://registry.npmjs.org/ms/-/ms-2.0.0.tgz",
|
||||
"integrity": "sha512-Tpp60P6IUJDTuOq/5Z8cdskzJujfwqfOTkrwIwj7IRISpnkJnT6SyJ4PCPnGMoFjC9ddhal5KVIYtAt97ix05A==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"packages/workspace-manager/node_modules/express": {
|
||||
"version": "4.22.1",
|
||||
"resolved": "https://registry.npmjs.org/express/-/express-4.22.1.tgz",
|
||||
"integrity": "sha512-F2X8g9P1X7uCPZMA3MVf9wcTqlyNp7IhH5qPCI0izhaOIYXaW9L535tGA3qmjRzpH+bZczqq7hVKxTR4NWnu+g==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"accepts": "~1.3.8",
|
||||
"array-flatten": "1.1.1",
|
||||
"body-parser": "~1.20.3",
|
||||
"content-disposition": "~0.5.4",
|
||||
"content-type": "~1.0.4",
|
||||
"cookie": "~0.7.1",
|
||||
"cookie-signature": "~1.0.6",
|
||||
"debug": "2.6.9",
|
||||
"depd": "2.0.0",
|
||||
"encodeurl": "~2.0.0",
|
||||
"escape-html": "~1.0.3",
|
||||
"etag": "~1.8.1",
|
||||
"finalhandler": "~1.3.1",
|
||||
"fresh": "~0.5.2",
|
||||
"http-errors": "~2.0.0",
|
||||
"merge-descriptors": "1.0.3",
|
||||
"methods": "~1.1.2",
|
||||
"on-finished": "~2.4.1",
|
||||
"parseurl": "~1.3.3",
|
||||
"path-to-regexp": "~0.1.12",
|
||||
"proxy-addr": "~2.0.7",
|
||||
"qs": "~6.14.0",
|
||||
"range-parser": "~1.2.1",
|
||||
"safe-buffer": "5.2.1",
|
||||
"send": "~0.19.0",
|
||||
"serve-static": "~1.16.2",
|
||||
"setprototypeof": "1.2.0",
|
||||
"statuses": "~2.0.1",
|
||||
"type-is": "~1.6.18",
|
||||
"utils-merge": "1.0.1",
|
||||
"vary": "~1.1.2"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">= 0.10.0"
|
||||
},
|
||||
"funding": {
|
||||
"type": "opencollective",
|
||||
"url": "https://opencollective.com/express"
|
||||
}
|
||||
},
|
||||
"packages/workspace-manager/node_modules/finalhandler": {
|
||||
"version": "1.3.2",
|
||||
"resolved": "https://registry.npmjs.org/finalhandler/-/finalhandler-1.3.2.tgz",
|
||||
"integrity": "sha512-aA4RyPcd3badbdABGDuTXCMTtOneUCAYH/gxoYRTZlIJdF0YPWuGqiAsIrhNnnqdXGswYk6dGujem4w80UJFhg==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"debug": "2.6.9",
|
||||
"encodeurl": "~2.0.0",
|
||||
"escape-html": "~1.0.3",
|
||||
"on-finished": "~2.4.1",
|
||||
"parseurl": "~1.3.3",
|
||||
"statuses": "~2.0.2",
|
||||
"unpipe": "~1.0.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">= 0.8"
|
||||
}
|
||||
},
|
||||
"packages/workspace-manager/node_modules/form-data": {
|
||||
"version": "4.0.5",
|
||||
"resolved": "https://registry.npmjs.org/form-data/-/form-data-4.0.5.tgz",
|
||||
"integrity": "sha512-8RipRLol37bNs2bhoV67fiTEvdTrbMUYcFTiy3+wuuOnUog2QBHCZWXDRijWQfAkhBj2Uf5UnVaiWwA5vdd82w==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"balanced-match": "^1.0.0",
|
||||
"concat-map": "0.0.1"
|
||||
}
|
||||
},
|
||||
"packages/vscode-ide-companion/node_modules/glob": {
|
||||
"version": "11.1.0",
|
||||
"resolved": "https://registry.npmjs.org/glob/-/glob-11.1.0.tgz",
|
||||
"integrity": "sha512-vuNwKSaKiqm7g0THUBu2x7ckSs3XJLXE+2ssL7/MfTGPLLcrJQ/4Uq1CjPTtO5cCIiRxqvN6Twy1qOwhL0Xjcw==",
|
||||
"deprecated": "Old versions of glob are not supported, and contain widely publicized security vulnerabilities, which have been fixed in the current version. Please update. Support for old versions may be purchased (at exorbitant rates) by contacting i@izs.me",
|
||||
"dev": true,
|
||||
"license": "BlueOak-1.0.0",
|
||||
"dependencies": {
|
||||
"foreground-child": "^3.3.1",
|
||||
"jackspeak": "^4.1.1",
|
||||
"minimatch": "^10.1.1",
|
||||
"minipass": "^7.1.2",
|
||||
"package-json-from-dist": "^1.0.0",
|
||||
"path-scurry": "^2.0.0"
|
||||
},
|
||||
"bin": {
|
||||
"glob": "dist/esm/bin.mjs"
|
||||
"asynckit": "^0.4.0",
|
||||
"combined-stream": "^1.0.8",
|
||||
"es-set-tostringtag": "^2.1.0",
|
||||
"hasown": "^2.0.2",
|
||||
"mime-types": "^2.1.12"
|
||||
},
|
||||
"engines": {
|
||||
"node": "20 || >=22"
|
||||
"node": ">= 6"
|
||||
}
|
||||
},
|
||||
"packages/workspace-manager/node_modules/fresh": {
|
||||
"version": "0.5.2",
|
||||
"resolved": "https://registry.npmjs.org/fresh/-/fresh-0.5.2.tgz",
|
||||
"integrity": "sha512-zJ2mQYM18rEFOudeV4GShTGIQ7RbzA7ozbU9I/XBpm7kqgMywgmylMwXHxZJmkVoYkna9d2pVXVXPdYTP9ej8Q==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">= 0.6"
|
||||
}
|
||||
},
|
||||
"packages/workspace-manager/node_modules/iconv-lite": {
|
||||
"version": "0.4.24",
|
||||
"resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.4.24.tgz",
|
||||
"integrity": "sha512-v3MXnZAcvnywkTUEZomIActle7RXXeedOR31wwl7VlyoXO4Qi9arvSenNQWne1TcRwhCL1HwLI21bEqdpj8/rA==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"safer-buffer": ">= 2.1.2 < 3"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=0.10.0"
|
||||
}
|
||||
},
|
||||
"packages/workspace-manager/node_modules/media-typer": {
|
||||
"version": "0.3.0",
|
||||
"resolved": "https://registry.npmjs.org/media-typer/-/media-typer-0.3.0.tgz",
|
||||
"integrity": "sha512-dq+qelQ9akHpcOl/gUVRTxVIOkAJ1wR3QAvb4RsVjS8oVoFjDGTc679wJYmUmknUF5HwMLOgb5O+a3KxfWapPQ==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">= 0.6"
|
||||
}
|
||||
},
|
||||
"packages/workspace-manager/node_modules/merge-descriptors": {
|
||||
"version": "1.0.3",
|
||||
"resolved": "https://registry.npmjs.org/merge-descriptors/-/merge-descriptors-1.0.3.tgz",
|
||||
"integrity": "sha512-gaNvAS7TZ897/rVaZ0nMtAyxNyi/pdbjbAwUpFQpN70GqnVfOiXpeUUMKRBmzXaSQ8DdTX4/0ms62r2K+hE6mQ==",
|
||||
"license": "MIT",
|
||||
"funding": {
|
||||
"url": "https://github.com/sponsors/isaacs"
|
||||
"url": "https://github.com/sponsors/sindresorhus"
|
||||
}
|
||||
},
|
||||
"packages/vscode-ide-companion/node_modules/hosted-git-info": {
|
||||
"version": "4.1.0",
|
||||
"resolved": "https://registry.npmjs.org/hosted-git-info/-/hosted-git-info-4.1.0.tgz",
|
||||
"integrity": "sha512-kyCuEOWjJqZuDbRHzL8V93NzQhwIB71oFWSyzVo+KPZI+pnQPPxucdkrOZvkLRnrf5URsQM+IJ09Dw29cRALIA==",
|
||||
"dev": true,
|
||||
"license": "ISC",
|
||||
"dependencies": {
|
||||
"lru-cache": "^6.0.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=10"
|
||||
}
|
||||
},
|
||||
"packages/vscode-ide-companion/node_modules/lru-cache": {
|
||||
"version": "6.0.0",
|
||||
"resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-6.0.0.tgz",
|
||||
"integrity": "sha512-Jo6dJ04CmSjuznwJSS3pUeWmd/H0ffTlkXXgwZi+eq1UCmqQwCh+eLsYOYCwY991i2Fah4h1BEMCx4qThGbsiA==",
|
||||
"dev": true,
|
||||
"license": "ISC",
|
||||
"dependencies": {
|
||||
"yallist": "^4.0.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=10"
|
||||
}
|
||||
},
|
||||
"packages/vscode-ide-companion/node_modules/mime": {
|
||||
"packages/workspace-manager/node_modules/mime": {
|
||||
"version": "1.6.0",
|
||||
"resolved": "https://registry.npmjs.org/mime/-/mime-1.6.0.tgz",
|
||||
"integrity": "sha512-x0Vn8spI+wuJ1O6S7gnbaQg8Pxh4NNHb7KSINmEWKiPE4RKOplvijn+NkmYmmRgP68mc70j2EbeTFRsrswaQeg==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"bin": {
|
||||
"mime": "cli.js"
|
||||
@@ -18121,12 +18344,192 @@
|
||||
"node": ">=4"
|
||||
}
|
||||
},
|
||||
"packages/vscode-ide-companion/node_modules/yallist": {
|
||||
"version": "4.0.0",
|
||||
"resolved": "https://registry.npmjs.org/yallist/-/yallist-4.0.0.tgz",
|
||||
"integrity": "sha512-3wdGidZyq5PB084XLES5TpOSRA3wjXAlIWMhum2kRcv/41Sn2emQ0dycQW4uZXLejwKvg6EsvbdlVL+FYEct7A==",
|
||||
"packages/workspace-manager/node_modules/mime-db": {
|
||||
"version": "1.52.0",
|
||||
"resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.52.0.tgz",
|
||||
"integrity": "sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">= 0.6"
|
||||
}
|
||||
},
|
||||
"packages/workspace-manager/node_modules/mime-types": {
|
||||
"version": "2.1.35",
|
||||
"resolved": "https://registry.npmjs.org/mime-types/-/mime-types-2.1.35.tgz",
|
||||
"integrity": "sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"mime-db": "1.52.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">= 0.6"
|
||||
}
|
||||
},
|
||||
"packages/workspace-manager/node_modules/negotiator": {
|
||||
"version": "0.6.3",
|
||||
"resolved": "https://registry.npmjs.org/negotiator/-/negotiator-0.6.3.tgz",
|
||||
"integrity": "sha512-+EUsqGPLsM+j/zdChZjsnX51g4XrHFOIXwfnCVPGlQk/k5giakcKsuxCObBRu6DSm9opw/O6slWbJdghQM4bBg==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">= 0.6"
|
||||
}
|
||||
},
|
||||
"packages/workspace-manager/node_modules/raw-body": {
|
||||
"version": "2.5.3",
|
||||
"resolved": "https://registry.npmjs.org/raw-body/-/raw-body-2.5.3.tgz",
|
||||
"integrity": "sha512-s4VSOf6yN0rvbRZGxs8Om5CWj6seneMwK3oDb4lWDH0UPhWcxwOWw5+qk24bxq87szX1ydrwylIOp2uG1ojUpA==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"bytes": "~3.1.2",
|
||||
"http-errors": "~2.0.1",
|
||||
"iconv-lite": "~0.4.24",
|
||||
"unpipe": "~1.0.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">= 0.8"
|
||||
}
|
||||
},
|
||||
"packages/workspace-manager/node_modules/send": {
|
||||
"version": "0.19.2",
|
||||
"resolved": "https://registry.npmjs.org/send/-/send-0.19.2.tgz",
|
||||
"integrity": "sha512-VMbMxbDeehAxpOtWJXlcUS5E8iXh6QmN+BkRX1GARS3wRaXEEgzCcB10gTQazO42tpNIya8xIyNx8fll1OFPrg==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"debug": "2.6.9",
|
||||
"depd": "2.0.0",
|
||||
"destroy": "1.2.0",
|
||||
"encodeurl": "~2.0.0",
|
||||
"escape-html": "~1.0.3",
|
||||
"etag": "~1.8.1",
|
||||
"fresh": "~0.5.2",
|
||||
"http-errors": "~2.0.1",
|
||||
"mime": "1.6.0",
|
||||
"ms": "2.1.3",
|
||||
"on-finished": "~2.4.1",
|
||||
"range-parser": "~1.2.1",
|
||||
"statuses": "~2.0.2"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">= 0.8.0"
|
||||
}
|
||||
},
|
||||
"packages/workspace-manager/node_modules/serve-static": {
|
||||
"version": "1.16.3",
|
||||
"resolved": "https://registry.npmjs.org/serve-static/-/serve-static-1.16.3.tgz",
|
||||
"integrity": "sha512-x0RTqQel6g5SY7Lg6ZreMmsOzncHFU7nhnRWkKgWuMTu5NN0DR5oruckMqRvacAN9d5w6ARnRBXl9xhDCgfMeA==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"encodeurl": "~2.0.0",
|
||||
"escape-html": "~1.0.3",
|
||||
"parseurl": "~1.3.3",
|
||||
"send": "~0.19.1"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">= 0.8.0"
|
||||
}
|
||||
},
|
||||
"packages/workspace-manager/node_modules/superagent": {
|
||||
"version": "10.3.0",
|
||||
"resolved": "https://registry.npmjs.org/superagent/-/superagent-10.3.0.tgz",
|
||||
"integrity": "sha512-B+4Ik7ROgVKrQsXTV0Jwp2u+PXYLSlqtDAhYnkkD+zn3yg8s/zjA2MeGayPoY/KICrbitwneDHrjSotxKL+0XQ==",
|
||||
"dev": true,
|
||||
"license": "ISC"
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"component-emitter": "^1.3.1",
|
||||
"cookiejar": "^2.1.4",
|
||||
"debug": "^4.3.7",
|
||||
"fast-safe-stringify": "^2.1.1",
|
||||
"form-data": "^4.0.5",
|
||||
"formidable": "^3.5.4",
|
||||
"methods": "^1.1.2",
|
||||
"mime": "2.6.0",
|
||||
"qs": "^6.14.1"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=14.18.0"
|
||||
}
|
||||
},
|
||||
"packages/workspace-manager/node_modules/superagent/node_modules/debug": {
|
||||
"version": "4.4.3",
|
||||
"resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz",
|
||||
"integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"ms": "^2.1.3"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=6.0"
|
||||
},
|
||||
"peerDependenciesMeta": {
|
||||
"supports-color": {
|
||||
"optional": true
|
||||
}
|
||||
}
|
||||
},
|
||||
"packages/workspace-manager/node_modules/superagent/node_modules/mime": {
|
||||
"version": "2.6.0",
|
||||
"resolved": "https://registry.npmjs.org/mime/-/mime-2.6.0.tgz",
|
||||
"integrity": "sha512-USPkMeET31rOMiarsBNIHZKLGgvKc/LrjofAnBlOttf5ajRvqiRA8QsenbcooctK6d6Ts6aqZXBA+XbkKthiQg==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"bin": {
|
||||
"mime": "cli.js"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=4.0.0"
|
||||
}
|
||||
},
|
||||
"packages/workspace-manager/node_modules/supertest": {
|
||||
"version": "7.2.2",
|
||||
"resolved": "https://registry.npmjs.org/supertest/-/supertest-7.2.2.tgz",
|
||||
"integrity": "sha512-oK8WG9diS3DlhdUkcFn4tkNIiIbBx9lI2ClF8K+b2/m8Eyv47LSawxUzZQSNKUrVb2KsqeTDCcjAAVPYaSLVTA==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"cookie-signature": "^1.2.2",
|
||||
"methods": "^1.1.2",
|
||||
"superagent": "^10.3.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=14.18.0"
|
||||
}
|
||||
},
|
||||
"packages/workspace-manager/node_modules/supertest/node_modules/cookie-signature": {
|
||||
"version": "1.2.2",
|
||||
"resolved": "https://registry.npmjs.org/cookie-signature/-/cookie-signature-1.2.2.tgz",
|
||||
"integrity": "sha512-D76uU73ulSXrD1UXF4KE2TMxVVwhsnCgfAyTg9k8P6KGZjlXKrOLe4dJQKI3Bxi5wjesZoFXJWElNWBjPZMbhg==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=6.6.0"
|
||||
}
|
||||
},
|
||||
"packages/workspace-manager/node_modules/type-is": {
|
||||
"version": "1.6.18",
|
||||
"resolved": "https://registry.npmjs.org/type-is/-/type-is-1.6.18.tgz",
|
||||
"integrity": "sha512-TkRKr9sUTxEH8MdfuCSP7VizJyzRNMjj2J2do2Jr3Kym598JVdEksuzPQCnlFPW4ky9Q+iA+ma9BGm06XQBy8g==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"media-typer": "0.3.0",
|
||||
"mime-types": "~2.1.24"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">= 0.6"
|
||||
}
|
||||
},
|
||||
"packages/workspace-manager/node_modules/uuid": {
|
||||
"version": "13.0.0",
|
||||
"resolved": "https://registry.npmjs.org/uuid/-/uuid-13.0.0.tgz",
|
||||
"integrity": "sha512-XQegIaBTVUjSHliKqcnFqYypAd4S+WCYt5NIeRs6w/UAry7z8Y9j5ZwRRL4kzq9U3sD6v+85er9FvkEaBpji2w==",
|
||||
"funding": [
|
||||
"https://github.com/sponsors/broofa",
|
||||
"https://github.com/sponsors/ctavan"
|
||||
],
|
||||
"license": "MIT",
|
||||
"bin": {
|
||||
"uuid": "dist-node/bin/uuid"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+1
-1
@@ -150,7 +150,7 @@
|
||||
"@lydell/node-pty-win32-arm64": "1.1.0",
|
||||
"@lydell/node-pty-win32-x64": "1.1.0",
|
||||
"keytar": "^7.9.0",
|
||||
"node-pty": "^1.1.0"
|
||||
"node-pty": "^1.0.0"
|
||||
},
|
||||
"lint-staged": {
|
||||
"*.{js,jsx,ts,tsx}": [
|
||||
|
||||
@@ -0,0 +1,33 @@
|
||||
/**
|
||||
* @license
|
||||
* Copyright 2026 Google LLC
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
// File for 'gemini wsr' command
|
||||
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 = {
|
||||
command: 'wsr',
|
||||
describe: 'Manage remote workspaces',
|
||||
builder: (yargs: Argv) =>
|
||||
yargs
|
||||
.middleware((argv) => {
|
||||
argv['isCommand'] = true;
|
||||
})
|
||||
.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),
|
||||
|
||||
handler: () => {
|
||||
// yargs will automatically show help if no subcommand is provided
|
||||
},
|
||||
};
|
||||
@@ -0,0 +1,197 @@
|
||||
/**
|
||||
* @license
|
||||
* Copyright 2026 Google LLC
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
import type { CommandModule, ArgumentsCamelCase } from 'yargs';
|
||||
import {
|
||||
WorkspaceHubClient,
|
||||
SSHService,
|
||||
SyncService,
|
||||
type Config,
|
||||
type WorkspaceHubInfo,
|
||||
debugLogger
|
||||
} from '@google-gemini-cli-core';
|
||||
|
||||
import { exitCli } from '../utils.js';
|
||||
import chalk from 'chalk';
|
||||
import { execSync } from 'node:child_process';
|
||||
|
||||
interface ConnectArgs {
|
||||
config?: Config;
|
||||
id: string;
|
||||
forwardAgent?: boolean;
|
||||
wait?: boolean;
|
||||
sync?: boolean;
|
||||
githubPat?: string;
|
||||
}
|
||||
|
||||
async function waitForReady(client: WorkspaceHubClient, id: string): Promise<WorkspaceHubInfo> {
|
||||
let attempts = 0;
|
||||
const maxAttempts = 30; // 5 minutes with 10s interval
|
||||
|
||||
while (attempts < maxAttempts) {
|
||||
const ws = await client.getWorkspace(id);
|
||||
if (!ws) throw new Error(`Workspace ${id} disappeared.`);
|
||||
if (ws.status === 'READY') return ws;
|
||||
if (ws.status === 'ERROR') throw new Error(`Workspace ${id} entered ERROR state.`);
|
||||
|
||||
// eslint-disable-next-line no-console
|
||||
console.log(chalk.blue(` Status: ${ws.status}. Waiting for READY... (${attempts + 1}/${maxAttempts})`));
|
||||
await new Promise(resolve => setTimeout(resolve, 10000));
|
||||
attempts++;
|
||||
}
|
||||
|
||||
throw new Error(`Timeout waiting for workspace ${id} to become READY.`);
|
||||
}
|
||||
|
||||
function getGitHubToken(): string | null {
|
||||
try {
|
||||
return execSync('gh auth token', { encoding: 'utf8' }).trim();
|
||||
} catch (e) {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
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;
|
||||
}
|
||||
|
||||
// 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);
|
||||
|
||||
try {
|
||||
// eslint-disable-next-line no-console
|
||||
console.log(chalk.yellow(`Fetching workspace details for "${args.id}"...`));
|
||||
|
||||
// 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
|
||||
console.error(chalk.red(`Error: Workspace "${args.id}" not found.`));
|
||||
return;
|
||||
}
|
||||
|
||||
let readyWs = ws;
|
||||
if (ws.status !== 'READY') {
|
||||
if (args.wait) {
|
||||
readyWs = await waitForReady(client, args.id);
|
||||
} else {
|
||||
// eslint-disable-next-line no-console
|
||||
console.warn(chalk.yellow(`Warning: Workspace is in status ${ws.status}.`));
|
||||
if (ws.status === 'PROVISIONING') {
|
||||
// eslint-disable-next-line no-console
|
||||
console.log(chalk.blue('Use --wait to automatically wait for provisioning to complete.'));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const { instance_name: instanceName, zone, project_id: projectId } = readyWs;
|
||||
const ssh = new SSHService();
|
||||
|
||||
// 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: projectId,
|
||||
});
|
||||
// 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. Inject GitHub PAT if available
|
||||
const pat = args.githubPat || getGitHubToken();
|
||||
if (pat) {
|
||||
// eslint-disable-next-line no-console
|
||||
console.log(chalk.yellow('Injecting GitHub credentials...'));
|
||||
try {
|
||||
await ssh.pushSecret({ instanceName, zone, project: projectId }, '.gh_token', pat);
|
||||
// eslint-disable-next-line no-console
|
||||
console.log(chalk.green('✓ Credentials injected.'));
|
||||
} catch (err) {
|
||||
// eslint-disable-next-line no-console
|
||||
console.warn(chalk.red('Warning: Failed to inject GitHub credentials.'), (err as Error).message);
|
||||
}
|
||||
}
|
||||
|
||||
// 3. Notify Hub of connection (refresh TTL)
|
||||
try {
|
||||
await client.notifyConnect(readyWs.id);
|
||||
} catch (err) {
|
||||
debugLogger.warn(`[Connect] Failed to notify Hub of connection:`, err);
|
||||
}
|
||||
|
||||
// 4. Connect via SSH
|
||||
// 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: projectId,
|
||||
command: remoteCommand,
|
||||
forwardAgent: args.forwardAgent,
|
||||
});
|
||||
|
||||
} 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,
|
||||
})
|
||||
.option('forward-agent', {
|
||||
alias: 'A',
|
||||
type: 'boolean',
|
||||
describe: 'Forward SSH agent to the remote workspace',
|
||||
default: false,
|
||||
})
|
||||
.option('wait', {
|
||||
alias: 'w',
|
||||
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,
|
||||
})
|
||||
.option('github-pat', {
|
||||
type: 'string',
|
||||
describe: 'GitHub Personal Access Token to inject',
|
||||
}),
|
||||
handler: async (argv) => {
|
||||
await connectToWorkspace(argv);
|
||||
await exitCli();
|
||||
},
|
||||
};
|
||||
@@ -0,0 +1,69 @@
|
||||
/**
|
||||
* @license
|
||||
* Copyright 2026 Google LLC
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
import type { CommandModule, ArgumentsCamelCase } from 'yargs';
|
||||
import {
|
||||
createWorkspace as performCreateWorkspace,
|
||||
type Config,
|
||||
} from '@google/gemini-cli-core';
|
||||
import { exitCli } from '../utils.js';
|
||||
import chalk from 'chalk';
|
||||
|
||||
interface CreateArgs {
|
||||
config?: Config;
|
||||
name: string;
|
||||
machineType?: string;
|
||||
}
|
||||
|
||||
export async function createWorkspace(
|
||||
args: ArgumentsCamelCase<CreateArgs>,
|
||||
): Promise<void> {
|
||||
if (!args.config) {
|
||||
// eslint-disable-next-line no-console
|
||||
console.error(chalk.red('Internal error: Config not loaded.'));
|
||||
return;
|
||||
}
|
||||
|
||||
// eslint-disable-next-line no-console
|
||||
console.log(
|
||||
chalk.yellow(`Requesting creation of workspace "${args.name}"...`),
|
||||
);
|
||||
|
||||
const result = await performCreateWorkspace(
|
||||
args.config,
|
||||
args.name,
|
||||
args.machineType,
|
||||
);
|
||||
|
||||
if (result.type === 'message') {
|
||||
if (result.messageType === 'error') {
|
||||
// eslint-disable-next-line no-console
|
||||
console.error(chalk.red(result.content));
|
||||
} else {
|
||||
// eslint-disable-next-line no-console
|
||||
console.log(chalk.green(result.content));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export const createCommand: CommandModule<object, CreateArgs> = {
|
||||
command: 'create <name>',
|
||||
describe: 'Create a new remote workspace',
|
||||
builder: (yargs) => yargs
|
||||
.positional('name', {
|
||||
type: 'string',
|
||||
describe: 'Name of the workspace',
|
||||
demandOption: true,
|
||||
})
|
||||
.option('machine-type', {
|
||||
type: 'string',
|
||||
describe: 'GCE machine type',
|
||||
}),
|
||||
handler: async (argv) => {
|
||||
await createWorkspace(argv);
|
||||
await exitCli();
|
||||
},
|
||||
};
|
||||
@@ -0,0 +1,57 @@
|
||||
/**
|
||||
* @license
|
||||
* Copyright 2026 Google LLC
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
import type { CommandModule, ArgumentsCamelCase } from 'yargs';
|
||||
import {
|
||||
deleteWorkspace as performDeleteWorkspace,
|
||||
type Config,
|
||||
} from '@google/gemini-cli-core';
|
||||
import { exitCli } from '../utils.js';
|
||||
import chalk from 'chalk';
|
||||
|
||||
interface DeleteArgs {
|
||||
config?: Config;
|
||||
id: string;
|
||||
}
|
||||
|
||||
export async function deleteWorkspace(
|
||||
args: ArgumentsCamelCase<DeleteArgs>,
|
||||
): Promise<void> {
|
||||
if (!args.config) {
|
||||
// eslint-disable-next-line no-console
|
||||
console.error(chalk.red('Internal error: Config not loaded.'));
|
||||
return;
|
||||
}
|
||||
|
||||
// eslint-disable-next-line no-console
|
||||
console.log(chalk.yellow(`Deleting workspace "${args.id}"...`));
|
||||
|
||||
const result = await performDeleteWorkspace(args.config, args.id);
|
||||
|
||||
if (result.type === 'message') {
|
||||
if (result.messageType === 'error') {
|
||||
// eslint-disable-next-line no-console
|
||||
console.error(chalk.red(result.content));
|
||||
} else {
|
||||
// eslint-disable-next-line no-console
|
||||
console.log(chalk.green(result.content));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export const deleteCommand: CommandModule<object, DeleteArgs> = {
|
||||
command: 'delete <id>',
|
||||
describe: 'Delete a remote workspace',
|
||||
builder: (yargs) => yargs.positional('id', {
|
||||
type: 'string',
|
||||
describe: 'ID of the workspace to delete',
|
||||
demandOption: true,
|
||||
}),
|
||||
handler: async (argv) => {
|
||||
await deleteWorkspace(argv);
|
||||
await exitCli();
|
||||
},
|
||||
};
|
||||
@@ -0,0 +1,48 @@
|
||||
/**
|
||||
* @license
|
||||
* Copyright 2026 Google LLC
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
import type { CommandModule, ArgumentsCamelCase } from 'yargs';
|
||||
import {
|
||||
listWorkspaces as performListWorkspaces,
|
||||
type Config,
|
||||
} from '@google/gemini-cli-core';
|
||||
import { exitCli } from '../utils.js';
|
||||
import chalk from 'chalk';
|
||||
|
||||
interface ListArgs {
|
||||
config?: Config;
|
||||
}
|
||||
|
||||
export async function listWorkspaces(
|
||||
args: ArgumentsCamelCase<ListArgs>,
|
||||
): Promise<void> {
|
||||
if (!args.config) {
|
||||
// eslint-disable-next-line no-console
|
||||
console.error(chalk.red('Internal error: Config not loaded.'));
|
||||
return;
|
||||
}
|
||||
|
||||
const result = await performListWorkspaces(args.config);
|
||||
|
||||
if (result.type === 'message') {
|
||||
if (result.messageType === 'error') {
|
||||
// eslint-disable-next-line no-console
|
||||
console.error(chalk.red(result.content));
|
||||
} else {
|
||||
// eslint-disable-next-line no-console
|
||||
console.log(result.content);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export const listCommand: CommandModule<object, ListArgs> = {
|
||||
command: 'list',
|
||||
describe: 'List all remote workspaces',
|
||||
handler: async (argv) => {
|
||||
await listWorkspaces(argv);
|
||||
await exitCli();
|
||||
},
|
||||
};
|
||||
@@ -671,6 +671,12 @@ describe('parseArguments', () => {
|
||||
const argv = await parseArguments(settings);
|
||||
expect(argv.isCommand).toBe(true);
|
||||
});
|
||||
|
||||
it('should set isCommand to true for workspace command', async () => {
|
||||
process.argv = ['node', 'script.js', 'workspace', 'list'];
|
||||
const argv = await parseArguments(createTestMergedSettings());
|
||||
expect(argv.isCommand).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe('loadCliConfig', () => {
|
||||
|
||||
@@ -9,6 +9,7 @@ import { hideBin } from 'yargs/helpers';
|
||||
import process from 'node:process';
|
||||
import * as path from 'node:path';
|
||||
import { mcpCommand } from '../commands/mcp.js';
|
||||
import { remoteWorkspaceCommand as workspaceCommand } from '../commands/workspace.js';
|
||||
import { extensionsCommand } from '../commands/extensions.js';
|
||||
import { skillsCommand } from '../commands/skills.js';
|
||||
import { hooksCommand } from '../commands/hooks.js';
|
||||
@@ -97,7 +98,6 @@ export interface CliArgs {
|
||||
rawOutput: boolean | undefined;
|
||||
acceptRawOutputRisk: boolean | undefined;
|
||||
isCommand: boolean | undefined;
|
||||
simulateUser: boolean | undefined;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -132,6 +132,7 @@ export async function parseArguments(
|
||||
description: 'Run in debug mode (open debug console with F12)',
|
||||
default: false,
|
||||
})
|
||||
.command(workspaceCommand)
|
||||
.command('$0 [query..]', 'Launch Gemini CLI', (yargsInstance) =>
|
||||
yargsInstance
|
||||
.positional('query', {
|
||||
@@ -299,11 +300,6 @@ export async function parseArguments(
|
||||
.option('accept-raw-output-risk', {
|
||||
type: 'boolean',
|
||||
description: 'Suppress the security warning when using --raw-output.',
|
||||
})
|
||||
.option('simulate-user', {
|
||||
type: 'boolean',
|
||||
description:
|
||||
'Run the user simulation agent in the background for evaluation purposes.',
|
||||
}),
|
||||
)
|
||||
// Register MCP subcommands
|
||||
@@ -782,6 +778,7 @@ export async function loadCliConfig(
|
||||
toolCallCommand: settings.tools?.callCommand,
|
||||
mcpServerCommand,
|
||||
mcpServers,
|
||||
workspaces: settings.workspaces,
|
||||
mcpEnablementCallbacks,
|
||||
mcpEnabled,
|
||||
extensionsEnabled,
|
||||
@@ -805,7 +802,6 @@ export async function loadCliConfig(
|
||||
approvalMode,
|
||||
disableYoloMode:
|
||||
settings.security?.disableYoloMode || settings.admin?.secureModeEnabled,
|
||||
simulateUser: !!argv.simulateUser,
|
||||
disableAlwaysAllow:
|
||||
settings.security?.disableAlwaysAllow ||
|
||||
settings.admin?.secureModeEnabled,
|
||||
|
||||
@@ -13,6 +13,7 @@ import {
|
||||
DEFAULT_TRUNCATE_TOOL_OUTPUT_THRESHOLD,
|
||||
DEFAULT_MODEL_CONFIGS,
|
||||
type MCPServerConfig,
|
||||
type WorkspaceConfig,
|
||||
type BugCommandSettings,
|
||||
type TelemetrySettings,
|
||||
type AuthType,
|
||||
@@ -169,6 +170,49 @@ const SETTINGS_SCHEMA = {
|
||||
},
|
||||
},
|
||||
|
||||
workspaces: {
|
||||
type: 'object',
|
||||
label: 'Workspaces',
|
||||
category: 'Advanced',
|
||||
requiresRestart: false,
|
||||
default: { hubs: {} } as WorkspaceConfig,
|
||||
description: 'Configuration for remote workspaces.',
|
||||
showInDialog: false,
|
||||
mergeStrategy: MergeStrategy.SHALLOW_MERGE,
|
||||
properties: {
|
||||
hubs: {
|
||||
type: 'object',
|
||||
label: 'Workspace Hubs',
|
||||
category: 'Advanced',
|
||||
requiresRestart: false,
|
||||
default: {},
|
||||
description: 'Configured Workspace Hubs.',
|
||||
mergeStrategy: MergeStrategy.SHALLOW_MERGE,
|
||||
additionalProperties: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
url: {
|
||||
type: 'string',
|
||||
label: 'Hub URL',
|
||||
category: 'Advanced',
|
||||
requiresRestart: false,
|
||||
default: 'http://localhost:8080',
|
||||
description: 'The URL of the Workspace Hub.',
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
defaultHub: {
|
||||
type: 'string',
|
||||
label: 'Default Hub',
|
||||
category: 'Advanced',
|
||||
requiresRestart: false,
|
||||
default: undefined as string | undefined,
|
||||
description: 'The name of the default Workspace Hub to use.',
|
||||
},
|
||||
},
|
||||
},
|
||||
|
||||
policyPaths: pathArraySetting(
|
||||
'Policy Paths',
|
||||
'Additional policy files or directories to load.',
|
||||
|
||||
@@ -63,6 +63,18 @@ export async function runDeferredCommand(settings: MergedSettings) {
|
||||
process.exit(ExitCodes.FATAL_CONFIG_ERROR);
|
||||
}
|
||||
|
||||
if (commandName === 'wsr') {
|
||||
// Inject settings into argv
|
||||
const argvWithSettings = {
|
||||
...deferredCommand.argv,
|
||||
settings,
|
||||
};
|
||||
|
||||
await deferredCommand.handler(argvWithSettings);
|
||||
await runExitCleanup();
|
||||
process.exit(ExitCodes.SUCCESS);
|
||||
}
|
||||
|
||||
// Inject settings into argv
|
||||
const argvWithSettings = {
|
||||
...deferredCommand.argv,
|
||||
|
||||
@@ -513,7 +513,6 @@ describe('gemini.tsx main function kitty protocol', () => {
|
||||
rawOutput: undefined,
|
||||
acceptRawOutputRisk: undefined,
|
||||
isCommand: undefined,
|
||||
simulateUser: undefined,
|
||||
});
|
||||
|
||||
await act(async () => {
|
||||
|
||||
@@ -9,15 +9,7 @@ import { render } from 'ink';
|
||||
import { basename } from 'node:path';
|
||||
import { AppContainer } from './ui/AppContainer.js';
|
||||
import { ConsolePatcher } from './ui/utils/ConsolePatcher.js';
|
||||
import { UserSimulator } from './services/UserSimulator.js';
|
||||
import { registerCleanup, setupTtyCheck } from './utils/cleanup.js';
|
||||
import { PassThrough } from 'node:stream';
|
||||
|
||||
interface RenderMetrics {
|
||||
renderTime: number;
|
||||
output: string;
|
||||
staticOutput?: string;
|
||||
}
|
||||
import {
|
||||
type StartupWarning,
|
||||
type Config,
|
||||
@@ -140,11 +132,6 @@ export async function startInteractiveUI(
|
||||
await new Promise((resolve) => setTimeout(resolve, 100));
|
||||
}
|
||||
|
||||
const simulateUser = config.getSimulateUser();
|
||||
const simulatedStdin = new PassThrough({ encoding: 'utf8' });
|
||||
|
||||
let lastFrame: string | undefined;
|
||||
const staticHistory: string[] = [];
|
||||
const instance = render(
|
||||
process.env['DEBUG'] ? (
|
||||
<React.StrictMode>
|
||||
@@ -156,20 +143,12 @@ export async function startInteractiveUI(
|
||||
{
|
||||
stdout: inkStdout,
|
||||
stderr: inkStderr,
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any, @typescript-eslint/no-unsafe-type-assertion, @typescript-eslint/no-unsafe-assignment
|
||||
stdin: (simulateUser ? simulatedStdin : process.stdin) as any,
|
||||
stdin: process.stdin,
|
||||
exitOnCtrlC: false,
|
||||
isScreenReaderEnabled: config.getScreenReader(),
|
||||
onRender: (metrics: RenderMetrics) => {
|
||||
lastFrame = metrics.output;
|
||||
if (metrics.staticOutput) {
|
||||
staticHistory.push(metrics.staticOutput);
|
||||
if (staticHistory.length > 50) {
|
||||
staticHistory.shift();
|
||||
}
|
||||
}
|
||||
if (metrics.renderTime > SLOW_RENDER_MS) {
|
||||
recordSlowRender(config, metrics.renderTime);
|
||||
onRender: ({ renderTime }: { renderTime: number }) => {
|
||||
if (renderTime > SLOW_RENDER_MS) {
|
||||
recordSlowRender(config, renderTime);
|
||||
}
|
||||
profiler.reportFrameRendered();
|
||||
},
|
||||
@@ -200,21 +179,6 @@ export async function startInteractiveUI(
|
||||
}
|
||||
});
|
||||
|
||||
if (simulateUser) {
|
||||
const simulator = new UserSimulator(
|
||||
config,
|
||||
() => {
|
||||
if (lastFrame === undefined) return undefined;
|
||||
// Combine history with latest frame for the simulator
|
||||
const historyText = staticHistory.join('\n');
|
||||
return historyText ? `${historyText}\n${lastFrame}` : lastFrame;
|
||||
},
|
||||
simulatedStdin,
|
||||
);
|
||||
simulator.start();
|
||||
registerCleanup(() => simulator.stop());
|
||||
}
|
||||
|
||||
registerCleanup(() => instance.unmount());
|
||||
|
||||
registerCleanup(setupTtyCheck());
|
||||
|
||||
@@ -0,0 +1,89 @@
|
||||
/**
|
||||
* @license
|
||||
* Copyright 2026 Google LLC
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
import { WorkspaceHubClient } from '@google/gemini-cli-core';
|
||||
import chalk from 'chalk';
|
||||
|
||||
export async function runRemoteCommand(args: string[]): Promise<void> {
|
||||
const command = args[0];
|
||||
const hubUrl =
|
||||
process.env['GEMINI_WORKSPACE_HUB_URL'] || 'http://localhost:8080';
|
||||
const client = new WorkspaceHubClient(hubUrl);
|
||||
|
||||
try {
|
||||
if (command === 'list' || command === 'ls') {
|
||||
const workspaces = await client.listWorkspaces();
|
||||
if (workspaces.length === 0) {
|
||||
// eslint-disable-next-line no-console
|
||||
console.log('No active workspaces found.');
|
||||
return;
|
||||
}
|
||||
// eslint-disable-next-line no-console
|
||||
console.log(chalk.bold('Active Workspaces:'));
|
||||
// eslint-disable-next-line no-console
|
||||
console.log(
|
||||
'------------------------------------------------------------',
|
||||
);
|
||||
for (const ws of workspaces) {
|
||||
const statusColor = ws.status === 'READY' ? chalk.green : chalk.yellow;
|
||||
// eslint-disable-next-line no-console
|
||||
console.log(
|
||||
`${chalk.cyan(ws.name.padEnd(20))} | ${statusColor(ws.status.padEnd(12))} | ${ws.id}`,
|
||||
);
|
||||
}
|
||||
// eslint-disable-next-line no-console
|
||||
console.log(
|
||||
'------------------------------------------------------------',
|
||||
);
|
||||
} else if (command === 'create') {
|
||||
const name = args[1];
|
||||
if (!name) {
|
||||
// eslint-disable-next-line no-console
|
||||
console.error(
|
||||
chalk.red(
|
||||
'Error: Workspace name is required. Usage: wsr create <name>',
|
||||
),
|
||||
);
|
||||
process.exit(1);
|
||||
}
|
||||
// eslint-disable-next-line no-console
|
||||
console.log(
|
||||
chalk.yellow(`Requesting creation of workspace "${name}"...`),
|
||||
);
|
||||
const ws = await client.createWorkspace(name);
|
||||
// eslint-disable-next-line no-console
|
||||
console.log(chalk.green(`✅ Workspace created successfully!`));
|
||||
// eslint-disable-next-line no-console
|
||||
console.log(`${chalk.bold('ID:')} ${ws.id}`);
|
||||
// eslint-disable-next-line no-console
|
||||
console.log(`${chalk.bold('Name:')} ${ws.name}`);
|
||||
} else if (command === 'delete' || command === 'rm') {
|
||||
const id = args[1];
|
||||
if (!id) {
|
||||
// eslint-disable-next-line no-console
|
||||
console.error(
|
||||
chalk.red('Error: Workspace ID is required. Usage: wsr delete <id>'),
|
||||
);
|
||||
process.exit(1);
|
||||
}
|
||||
// eslint-disable-next-line no-console
|
||||
console.log(chalk.yellow(`Deleting workspace "${id}"...`));
|
||||
await client.deleteWorkspace(id);
|
||||
// eslint-disable-next-line no-console
|
||||
console.log(chalk.green(`✅ Workspace deleted successfully.`));
|
||||
} else {
|
||||
// eslint-disable-next-line no-console
|
||||
console.log('Usage: wsr <command> [args]');
|
||||
// eslint-disable-next-line no-console
|
||||
console.log('Commands: list, create, delete');
|
||||
}
|
||||
} catch (error: unknown) {
|
||||
const message = error instanceof Error ? error.message : String(error);
|
||||
// eslint-disable-next-line no-console
|
||||
console.error(chalk.red('Remote command failed:'), message);
|
||||
process.exit(1);
|
||||
}
|
||||
}
|
||||
@@ -58,6 +58,7 @@ import { skillsCommand } from '../ui/commands/skillsCommand.js';
|
||||
import { settingsCommand } from '../ui/commands/settingsCommand.js';
|
||||
import { shellsCommand } from '../ui/commands/shellsCommand.js';
|
||||
import { vimCommand } from '../ui/commands/vimCommand.js';
|
||||
import { workspaceSlashCommand } from '../ui/commands/workspaceCommand.js';
|
||||
import { setupGithubCommand } from '../ui/commands/setupGithubCommand.js';
|
||||
import { terminalSetupCommand } from '../ui/commands/terminalSetupCommand.js';
|
||||
import { upgradeCommand } from '../ui/commands/upgradeCommand.js';
|
||||
@@ -223,6 +224,7 @@ export class BuiltinCommandLoader implements ICommandLoader {
|
||||
settingsCommand,
|
||||
shellsCommand,
|
||||
vimCommand,
|
||||
workspaceSlashCommand,
|
||||
setupGithubCommand,
|
||||
terminalSetupCommand,
|
||||
...(this.config?.getContentGeneratorConfig()?.authType ===
|
||||
|
||||
@@ -1,213 +0,0 @@
|
||||
/**
|
||||
* @license
|
||||
* Copyright 2026 Google LLC
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
import type { Config } from '@google/gemini-cli-core';
|
||||
import {
|
||||
debugLogger,
|
||||
LlmRole,
|
||||
PREVIEW_GEMINI_FLASH_MODEL,
|
||||
resolveModel,
|
||||
} from '@google/gemini-cli-core';
|
||||
import type { Writable } from 'node:stream';
|
||||
import * as fs from 'node:fs';
|
||||
|
||||
export class UserSimulator {
|
||||
private isRunning = false;
|
||||
private timer: NodeJS.Timeout | null = null;
|
||||
private lastScreenContent = '';
|
||||
private isProcessing = false;
|
||||
private interactionsFile: string | null = null;
|
||||
private actionHistory: string[] = [];
|
||||
|
||||
constructor(
|
||||
private readonly config: Config,
|
||||
private readonly getScreen: () => string | undefined,
|
||||
private readonly stdinBuffer: Writable,
|
||||
) {}
|
||||
|
||||
start() {
|
||||
if (!this.config.getSimulateUser()) {
|
||||
return;
|
||||
}
|
||||
this.interactionsFile = `interactions_${Date.now()}.txt`;
|
||||
this.isRunning = true;
|
||||
this.timer = setInterval(() => this.tick(), 3000);
|
||||
}
|
||||
|
||||
stop() {
|
||||
this.isRunning = false;
|
||||
if (this.timer) {
|
||||
clearInterval(this.timer);
|
||||
this.timer = null;
|
||||
}
|
||||
debugLogger.log('User simulator stopped');
|
||||
}
|
||||
|
||||
private async tick() {
|
||||
if (!this.isRunning || this.isProcessing) return;
|
||||
|
||||
try {
|
||||
this.isProcessing = true;
|
||||
const screen = this.getScreen();
|
||||
if (!screen) return;
|
||||
|
||||
const strippedScreen = screen
|
||||
.replace(
|
||||
// eslint-disable-next-line no-control-regex
|
||||
/[\u001b\u009b][[()#;?]*(?:[0-9]{1,4}(?:;[0-9]{0,4})*)?[0-9A-ORZcf-nqry=><]/g,
|
||||
'',
|
||||
)
|
||||
.replace(/\n([ \t]*\n)+/g, '\n\n');
|
||||
|
||||
const normalizedScreen = strippedScreen
|
||||
.replace(/[⠋⠙⠹⠸⠼⠴⠦⠧⠇⠏]/g, '')
|
||||
.replace(/\[?\s*\b\d+(\.\d+)?s\b\s*\]?/g, '')
|
||||
.trim();
|
||||
|
||||
if (normalizedScreen === this.lastScreenContent) return;
|
||||
|
||||
debugLogger.log(
|
||||
`[SIMULATOR] Screen Content Seen:\n---\n${strippedScreen}\n---`,
|
||||
);
|
||||
if (this.interactionsFile) {
|
||||
fs.appendFileSync(
|
||||
this.interactionsFile,
|
||||
`[LOG] [SIMULATOR] Screen Content Seen:\n---\n${strippedScreen}\n---\n\n`,
|
||||
);
|
||||
}
|
||||
|
||||
const contentGenerator = this.config.getContentGenerator();
|
||||
if (!contentGenerator) return;
|
||||
|
||||
const originalGoal = this.config.getQuestion();
|
||||
const goalInstruction = originalGoal
|
||||
? `\nThe original goal was: "${originalGoal}"\n`
|
||||
: '';
|
||||
|
||||
const historyInstruction =
|
||||
this.actionHistory.length > 0
|
||||
? `\nYou have previously taken the following actions (in order):\n${this.actionHistory.map((a, i) => `${i + 1}. ${JSON.stringify(a)}`).join('\n')}\nPay close attention to whether you have already asked for the original goal. If you have already submitted the original goal, DO NOT repeat it verbatim. If the UI shows your typed text but hasn't submitted it yet, just output \\r to press Enter.\n`
|
||||
: '';
|
||||
|
||||
const prompt = `You are evaluating a CLI agent by simulating a user sitting at the terminal.
|
||||
Look carefully at the screen and determine the CLI's current state:
|
||||
|
||||
STATE 1: The agent is busy (e.g., streaming a response, showing a spinner, running a tool, or displaying a timer like "7s"). It is actively working and NOT waiting for text input.
|
||||
- In this case, you MUST output exactly: <WAIT>
|
||||
|
||||
STATE 2: The agent is waiting for you to authorize a tool, confirm an action, or answer a specific multi-choice question (e.g., "Action Required", "Allow execution", numbered options).
|
||||
- In this case, you MUST output the exact raw characters to select the option and submit it (e.g., 1\\r, 2\\r, y\\r, n\\r, or just \\r if the default option is acceptable). Do NOT output <DONE> or "Thank you". You must unblock the agent and allow it to run the tool.
|
||||
|
||||
STATE 3: The agent has finished its current thought process AND is idle, waiting for a NEW general text prompt (usually indicated by a "> Type your message" prompt).
|
||||
- First, verify that the ACTUAL task is fully complete based on your original goal. Do not stop at intermediate steps like planning or syntax checking.
|
||||
- If the task is indeed fully complete, output "Thank you\\r" to graciously finish the simulation.
|
||||
- If you have already said thank you, output exactly: <DONE>
|
||||
- If the agent is waiting at a general text prompt but the original task is NOT complete, provide text instructions to continue what is missing. DO NOT repeat the original goal if it has already been provided once. Ask it to continue or provide feedback based on the current state or send <DONE> if you think the task is completed.
|
||||
|
||||
STATE 4: Any other situation where the agent is waiting for text input or needs to press Enter.
|
||||
- Output the raw characters you would type, followed by \\r. For just an Enter key press, output \\r.
|
||||
|
||||
CRITICAL RULES:
|
||||
- RULE 1: If there is ANY active spinner (e.g., ⠋, ⠙, ⠹, ⠸, ⠼, ⠴, ⠧) or an elapsed time indicator (e.g., "0s", "7s") anywhere on the screen, the agent is STILL WORKING. You MUST output <WAIT>. Do NOT issue commands, even if a text prompt is visible below it.
|
||||
- RULE 2: If there is an "Action Required" or confirmation prompt on the screen, YOU MUST HANDLE IT (State 2). This takes precedence over everything else.
|
||||
- RULE 3: Output ONLY the raw characters to send, <WAIT>, or <DONE>.
|
||||
- RULE 4: Do NOT output markdown, explanations of your thought process, or quotes.
|
||||
${goalInstruction}${historyInstruction}
|
||||
|
||||
Here is the current terminal screen output:
|
||||
|
||||
<screen>
|
||||
${strippedScreen}
|
||||
</screen>`;
|
||||
|
||||
if (this.interactionsFile) {
|
||||
fs.appendFileSync(
|
||||
this.interactionsFile,
|
||||
`[LOG] [SIMULATOR] Prompt Used:\n---\n${prompt}\n---\n\n`,
|
||||
);
|
||||
}
|
||||
|
||||
const model = resolveModel(
|
||||
PREVIEW_GEMINI_FLASH_MODEL,
|
||||
false, // useGemini3_1
|
||||
false, // useCustomToolModel
|
||||
this.config.getHasAccessToPreviewModel?.() ?? true,
|
||||
this.config,
|
||||
);
|
||||
|
||||
const response = await contentGenerator.generateContent(
|
||||
{
|
||||
model,
|
||||
contents: [
|
||||
{
|
||||
role: 'user',
|
||||
parts: [{ text: prompt }],
|
||||
},
|
||||
],
|
||||
},
|
||||
'simulator-prompt',
|
||||
LlmRole.UTILITY_SIMULATOR,
|
||||
);
|
||||
|
||||
const responseText = (response.text || '').replace(
|
||||
/^[`"']+|[`"']+$/g,
|
||||
'',
|
||||
);
|
||||
const trimmedResponse = responseText.trim();
|
||||
|
||||
debugLogger.log(
|
||||
`[SIMULATOR] Raw model response: ${JSON.stringify(response.text)}`,
|
||||
);
|
||||
if (this.interactionsFile) {
|
||||
fs.appendFileSync(
|
||||
this.interactionsFile,
|
||||
`[LOG] [SIMULATOR] Raw model response: ${JSON.stringify(response.text)}\n\n`,
|
||||
);
|
||||
}
|
||||
debugLogger.log(
|
||||
`[SIMULATOR] Processed response: ${JSON.stringify(responseText)}`,
|
||||
);
|
||||
|
||||
if (trimmedResponse === '<DONE>') {
|
||||
const msg = '[SIMULATOR] Terminating simulation: Task is completed.';
|
||||
debugLogger.log(msg);
|
||||
if (this.interactionsFile) {
|
||||
fs.appendFileSync(this.interactionsFile, `[LOG] ${msg}\n\n`);
|
||||
}
|
||||
// eslint-disable-next-line no-console
|
||||
console.log(`\n${msg}`);
|
||||
this.stop();
|
||||
process.exit(0);
|
||||
}
|
||||
|
||||
if (trimmedResponse === '<WAIT>') {
|
||||
debugLogger.log(
|
||||
'[SIMULATOR] Skipping action (model decided to <WAIT>)',
|
||||
);
|
||||
this.lastScreenContent = normalizedScreen;
|
||||
return;
|
||||
}
|
||||
|
||||
if (responseText) {
|
||||
const keys = responseText.replace(/\\n/g, '\r').replace(/\\r/g, '\r');
|
||||
const readableAction = trimmedResponse.replace(/\\r/g, '[ENTER]');
|
||||
this.actionHistory.push(readableAction);
|
||||
|
||||
debugLogger.log(
|
||||
`[SIMULATOR] Sending to stdin: ${JSON.stringify(keys)}`,
|
||||
);
|
||||
this.stdinBuffer.write(keys);
|
||||
this.lastScreenContent = normalizedScreen;
|
||||
} else {
|
||||
debugLogger.log('[SIMULATOR] Skipping (empty response)');
|
||||
this.lastScreenContent = normalizedScreen;
|
||||
}
|
||||
} catch (e: unknown) {
|
||||
debugLogger.error('UserSimulator tick failed', e);
|
||||
} finally {
|
||||
this.isProcessing = false;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -62,7 +62,6 @@ export const createMockConfig = (overrides: Partial<Config> = {}): Config =>
|
||||
getGeminiMdFileCount: vi.fn(() => 0),
|
||||
getDeferredCommand: vi.fn(() => undefined),
|
||||
getFileSystemService: vi.fn(() => ({})),
|
||||
getSimulateUser: vi.fn(() => false),
|
||||
clientVersion: '1.0.0',
|
||||
getModel: vi.fn().mockReturnValue('gemini-pro'),
|
||||
getWorkingDir: vi.fn().mockReturnValue('/mock/cwd'),
|
||||
|
||||
@@ -0,0 +1,176 @@
|
||||
/**
|
||||
* @license
|
||||
* Copyright 2026 Google LLC
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
import type { SlashCommand, CommandContext, SlashCommandActionReturn } from './types.js';
|
||||
import { CommandKind } from './types.js';
|
||||
import {
|
||||
type CommandActionReturn,
|
||||
WorkspaceHubClient
|
||||
} from '@google-gemini-cli-core';
|
||||
|
||||
const listAction = async (
|
||||
_context: CommandContext,
|
||||
): Promise<CommandActionReturn> => {
|
||||
const hubUrl =
|
||||
process.env['GEMINI_WORKSPACE_HUB_URL'] || 'http://localhost:8080';
|
||||
const client = new WorkspaceHubClient(hubUrl);
|
||||
|
||||
try {
|
||||
const workspaces = await client.listWorkspaces();
|
||||
|
||||
if (workspaces.length === 0) {
|
||||
return {
|
||||
type: 'message',
|
||||
messageType: 'info',
|
||||
content: 'No active workspaces found.',
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
type: 'workspaces_list',
|
||||
workspaces,
|
||||
};
|
||||
} catch (error: unknown) {
|
||||
const message = error instanceof Error ? error.message : String(error);
|
||||
return {
|
||||
type: 'message',
|
||||
messageType: 'error',
|
||||
content: `Failed to list workspaces: ${message}`,
|
||||
};
|
||||
}
|
||||
};
|
||||
|
||||
const listCommand: SlashCommand = {
|
||||
name: 'list',
|
||||
altNames: ['ls'],
|
||||
description: 'List remote workspaces',
|
||||
kind: CommandKind.BUILT_IN,
|
||||
autoExecute: true,
|
||||
action: ((context: CommandContext) => listAction(context)) as any,
|
||||
};
|
||||
|
||||
const createCommand: SlashCommand = {
|
||||
name: 'create',
|
||||
description: 'Create a new remote workspace',
|
||||
kind: CommandKind.BUILT_IN,
|
||||
autoExecute: true,
|
||||
action: (async (
|
||||
context: CommandContext,
|
||||
args: string,
|
||||
): Promise<SlashCommandActionReturn> => {
|
||||
const name = args.trim();
|
||||
if (!name) {
|
||||
return {
|
||||
type: 'message',
|
||||
messageType: 'error',
|
||||
content: 'Workspace name is required. Usage: /workspace create <name>',
|
||||
};
|
||||
}
|
||||
|
||||
const hubUrl =
|
||||
process.env['GEMINI_WORKSPACE_HUB_URL'] || 'http://localhost:8080';
|
||||
const client = new WorkspaceHubClient(hubUrl);
|
||||
|
||||
try {
|
||||
context.ui.addItem({
|
||||
type: 'info',
|
||||
text: `Requesting creation of workspace "${name}"...`,
|
||||
} as any);
|
||||
const ws = await client.createWorkspace(name);
|
||||
return {
|
||||
type: 'message',
|
||||
messageType: 'info',
|
||||
content: `✅ Workspace created successfully!\nID: ${ws.id}\nName: ${ws.name}\nGCE: ${ws.instance_name}`,
|
||||
};
|
||||
} catch (error: unknown) {
|
||||
const message = error instanceof Error ? error.message : String(error);
|
||||
return {
|
||||
type: 'message',
|
||||
messageType: 'error',
|
||||
content: `Failed to create workspace: ${message}`,
|
||||
};
|
||||
}
|
||||
}) as any,
|
||||
};
|
||||
|
||||
const deleteCommand: SlashCommand = {
|
||||
name: 'delete',
|
||||
altNames: ['rm'],
|
||||
description: 'Delete a remote workspace',
|
||||
kind: CommandKind.BUILT_IN,
|
||||
autoExecute: true,
|
||||
action: (async (
|
||||
_context: CommandContext,
|
||||
args: string,
|
||||
): Promise<SlashCommandActionReturn> => {
|
||||
const id = args.trim();
|
||||
if (!id) {
|
||||
return {
|
||||
type: 'message',
|
||||
messageType: 'error',
|
||||
content: 'Workspace ID is required. Usage: /workspace delete <id>',
|
||||
};
|
||||
}
|
||||
|
||||
const hubUrl =
|
||||
process.env['GEMINI_WORKSPACE_HUB_URL'] || 'http://localhost:8080';
|
||||
const client = new WorkspaceHubClient(hubUrl);
|
||||
|
||||
try {
|
||||
_context.ui.addItem({
|
||||
type: 'info',
|
||||
text: `Deleting workspace "${id}"...`,
|
||||
} as any);
|
||||
await client.deleteWorkspace(id);
|
||||
return {
|
||||
type: 'message',
|
||||
messageType: 'info',
|
||||
content: `✅ Workspace ${id} deleted successfully.`,
|
||||
};
|
||||
} catch (error: unknown) {
|
||||
const message = error instanceof Error ? error.message : String(error);
|
||||
return {
|
||||
type: 'message',
|
||||
messageType: 'error',
|
||||
content: `Failed to delete workspace: ${message}`,
|
||||
};
|
||||
}
|
||||
}) as any,
|
||||
};
|
||||
|
||||
const connectCommand: SlashCommand = {
|
||||
name: 'connect',
|
||||
description: 'Connect to a remote workspace',
|
||||
kind: CommandKind.BUILT_IN,
|
||||
autoExecute: true,
|
||||
action: (async (
|
||||
_context: CommandContext,
|
||||
args: string,
|
||||
): Promise<SlashCommandActionReturn> => {
|
||||
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.`,
|
||||
};
|
||||
}) as any,
|
||||
};
|
||||
|
||||
export const workspaceSlashCommand: SlashCommand = {
|
||||
name: 'workspace',
|
||||
altNames: ['wsr'],
|
||||
description: 'Manage remote workspaces',
|
||||
kind: CommandKind.BUILT_IN,
|
||||
autoExecute: false,
|
||||
subCommands: [listCommand, createCommand, deleteCommand, connectCommand],
|
||||
action: ((context: CommandContext) => listAction(context)) as any,
|
||||
};
|
||||
@@ -32,6 +32,7 @@ import { SkillsList } from './views/SkillsList.js';
|
||||
import { AgentsStatus } from './views/AgentsStatus.js';
|
||||
import { McpStatus } from './views/McpStatus.js';
|
||||
import { ChatList } from './views/ChatList.js';
|
||||
import { WorkspacesList } from './views/WorkspacesList.js';
|
||||
import { ModelMessage } from './messages/ModelMessage.js';
|
||||
import { ThinkingMessage } from './messages/ThinkingMessage.js';
|
||||
import { HintMessage } from './messages/HintMessage.js';
|
||||
@@ -233,6 +234,9 @@ export const HistoryItemDisplay: React.FC<HistoryItemDisplayProps> = ({
|
||||
{itemForDisplay.type === 'chat_list' && (
|
||||
<ChatList chats={itemForDisplay.chats} />
|
||||
)}
|
||||
{itemForDisplay.type === 'workspaces_list' && (
|
||||
<WorkspacesList workspaces={itemForDisplay.workspaces} />
|
||||
)}
|
||||
</Box>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -0,0 +1,52 @@
|
||||
/**
|
||||
* @license
|
||||
* Copyright 2026 Google LLC
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
import type React from 'react';
|
||||
import { Box, Text } from 'ink';
|
||||
import type { WorkspaceHubInfo } from '@google/gemini-cli-core';
|
||||
|
||||
interface WorkspacesListProps {
|
||||
workspaces: readonly WorkspaceHubInfo[];
|
||||
}
|
||||
|
||||
export const WorkspacesList: React.FC<WorkspacesListProps> = ({ workspaces }) => {
|
||||
if (workspaces.length === 0) {
|
||||
return <Text>No active workspaces found.</Text>;
|
||||
}
|
||||
|
||||
return (
|
||||
<Box flexDirection="column" marginBottom={1}>
|
||||
<Text bold underline>Active Remote Workspaces:</Text>
|
||||
<Box flexDirection="column" paddingLeft={2} marginTop={1}>
|
||||
{workspaces.map((ws) => {
|
||||
const isReady = ws.status === 'READY';
|
||||
const statusColor = isReady ? 'green' : 'yellow';
|
||||
|
||||
return (
|
||||
<Box key={ws.id} flexDirection="column" marginBottom={1}>
|
||||
<Box>
|
||||
<Text color="cyan" bold>{ws.name.padEnd(20)}</Text>
|
||||
<Text> | </Text>
|
||||
<Text color={statusColor}>{ws.status.padEnd(12)}</Text>
|
||||
<Text> | </Text>
|
||||
<Text dimColor>{ws.id}</Text>
|
||||
</Box>
|
||||
<Box paddingLeft={2}>
|
||||
<Text dimColor>Instance: {ws.instance_name} ({ws.zone})</Text>
|
||||
</Box>
|
||||
<Box paddingLeft={2}>
|
||||
<Text dimColor>Project: {ws.project_id}</Text>
|
||||
</Box>
|
||||
</Box>
|
||||
);
|
||||
})}
|
||||
</Box>
|
||||
<Box marginTop={1}>
|
||||
<Text dimColor italic>Use `/wsr connect {'<name>'}` to teleport into a workspace.</Text>
|
||||
</Box>
|
||||
</Box>
|
||||
);
|
||||
};
|
||||
@@ -196,6 +196,11 @@ export const useSlashCommandProcessor = (
|
||||
type: 'compression',
|
||||
compression: message.compression,
|
||||
};
|
||||
} else if (message.type === MessageType.WORKSPACES_LIST) {
|
||||
historyItemContent = {
|
||||
type: 'workspaces_list',
|
||||
workspaces: message.workspaces,
|
||||
};
|
||||
} else {
|
||||
historyItemContent = {
|
||||
type: message.type,
|
||||
@@ -662,6 +667,16 @@ export const useSlashCommandProcessor = (
|
||||
setCustomDialog(result.component);
|
||||
return { type: 'handled' };
|
||||
}
|
||||
case 'workspaces_list': {
|
||||
addItem(
|
||||
{
|
||||
type: MessageType.WORKSPACES_LIST,
|
||||
workspaces: result.workspaces,
|
||||
} as any,
|
||||
Date.now(),
|
||||
);
|
||||
return { type: 'handled' };
|
||||
}
|
||||
default: {
|
||||
const unhandled: never = result;
|
||||
throw new Error(
|
||||
|
||||
@@ -16,6 +16,7 @@ import {
|
||||
type AgentDefinition,
|
||||
type ApprovalMode,
|
||||
type Kind,
|
||||
type WorkspaceHubInfo,
|
||||
CoreToolCallStatus,
|
||||
checkExhaustive,
|
||||
} from '@google/gemini-cli-core';
|
||||
@@ -272,6 +273,11 @@ export type HistoryItemChatList = HistoryItemBase & {
|
||||
chats: ChatDetail[];
|
||||
};
|
||||
|
||||
export type HistoryItemWorkspacesList = HistoryItemBase & {
|
||||
type: 'workspaces_list';
|
||||
workspaces: WorkspaceHubInfo[];
|
||||
};
|
||||
|
||||
export interface ToolDefinition {
|
||||
name: string;
|
||||
displayName: string;
|
||||
@@ -379,6 +385,7 @@ export type HistoryItemWithoutId =
|
||||
| HistoryItemAgentsList
|
||||
| HistoryItemMcpStatus
|
||||
| HistoryItemChatList
|
||||
| HistoryItemWorkspacesList
|
||||
| HistoryItemThinking
|
||||
| HistoryItemHint;
|
||||
|
||||
@@ -404,6 +411,7 @@ export enum MessageType {
|
||||
AGENTS_LIST = 'agents_list',
|
||||
MCP_STATUS = 'mcp_status',
|
||||
CHAT_LIST = 'chat_list',
|
||||
WORKSPACES_LIST = 'workspaces_list',
|
||||
HINT = 'hint',
|
||||
}
|
||||
|
||||
@@ -458,6 +466,11 @@ export type Message =
|
||||
type: MessageType.COMPRESSION;
|
||||
compression: CompressionProps;
|
||||
timestamp: Date;
|
||||
}
|
||||
| {
|
||||
type: MessageType.WORKSPACES_LIST;
|
||||
timestamp: Date;
|
||||
workspaces: WorkspaceHubInfo[];
|
||||
};
|
||||
|
||||
export interface ConsoleMessageItem {
|
||||
|
||||
@@ -4,7 +4,10 @@
|
||||
"outDir": "dist",
|
||||
"jsx": "react-jsx",
|
||||
"lib": ["DOM", "DOM.Iterable", "ES2023"],
|
||||
"types": ["node", "vitest/globals"]
|
||||
"types": ["node", "vitest/globals"],
|
||||
"paths": {
|
||||
"@google-gemini-cli-core": ["../core/src/index.ts"]
|
||||
}
|
||||
},
|
||||
"include": [
|
||||
"index.ts",
|
||||
|
||||
@@ -5,6 +5,8 @@
|
||||
*/
|
||||
|
||||
import type { Content, PartListUnion } from '@google/genai';
|
||||
import type { WorkspaceHubInfo } from '../services/workspaceHubClient.js';
|
||||
|
||||
/**
|
||||
* The return type for a command action that results in scheduling a tool call.
|
||||
*/
|
||||
@@ -19,6 +21,11 @@ export interface ToolActionReturn {
|
||||
postSubmitPrompt?: PartListUnion;
|
||||
}
|
||||
|
||||
export interface WorkspacesListActionReturn {
|
||||
type: 'workspaces_list';
|
||||
workspaces: WorkspaceHubInfo[];
|
||||
}
|
||||
|
||||
/**
|
||||
* The return type for a command action that results in a simple message
|
||||
* being displayed to the user.
|
||||
@@ -50,6 +57,7 @@ export interface SubmitPromptActionReturn {
|
||||
|
||||
export type CommandActionReturn<HistoryType = unknown> =
|
||||
| ToolActionReturn
|
||||
| WorkspacesListActionReturn
|
||||
| MessageActionReturn
|
||||
| LoadHistoryActionReturn<HistoryType>
|
||||
| SubmitPromptActionReturn;
|
||||
|
||||
@@ -0,0 +1,108 @@
|
||||
/**
|
||||
* @license
|
||||
* Copyright 2026 Google LLC
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
import type { Config } from '../config/config.js';
|
||||
import { WorkspaceHubClient } from '../services/workspaceHubClient.js';
|
||||
import type { CommandActionReturn } from './types.js';
|
||||
|
||||
function getHubUrl(config: Config): string {
|
||||
if (process.env['GEMINI_WORKSPACE_HUB_URL']) {
|
||||
return process.env['GEMINI_WORKSPACE_HUB_URL'];
|
||||
}
|
||||
|
||||
const workspaces = config.getWorkspaces();
|
||||
if (workspaces) {
|
||||
const hubName = workspaces.defaultHub;
|
||||
if (hubName && workspaces.hubs[hubName]) {
|
||||
return workspaces.hubs[hubName].url;
|
||||
}
|
||||
}
|
||||
|
||||
return 'http://localhost:8080';
|
||||
}
|
||||
|
||||
export async function listWorkspaces(
|
||||
config: Config,
|
||||
): Promise<CommandActionReturn> {
|
||||
const hubUrl = getHubUrl(config);
|
||||
const client = new WorkspaceHubClient(hubUrl);
|
||||
|
||||
try {
|
||||
const workspaces = await client.listWorkspaces();
|
||||
|
||||
if (workspaces.length === 0) {
|
||||
return {
|
||||
type: 'message',
|
||||
messageType: 'info',
|
||||
content: 'No active workspaces found.',
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
type: 'workspaces_list',
|
||||
workspaces,
|
||||
};
|
||||
} catch (error: unknown) {
|
||||
// eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion
|
||||
const message = (error as Error).message || String(error);
|
||||
return {
|
||||
type: 'message',
|
||||
messageType: 'error',
|
||||
content: `Failed to list workspaces: ${message}`,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
export async function createWorkspace(
|
||||
config: Config,
|
||||
name: string,
|
||||
machineType?: string,
|
||||
): Promise<CommandActionReturn> {
|
||||
const hubUrl = getHubUrl(config);
|
||||
const client = new WorkspaceHubClient(hubUrl);
|
||||
|
||||
try {
|
||||
const ws = await client.createWorkspace(name, machineType);
|
||||
return {
|
||||
type: 'message',
|
||||
messageType: 'info',
|
||||
content: `✓ Workspace created successfully!\nID: ${ws.id}\nName: ${ws.name}\nGCE: ${ws.instance_name}`,
|
||||
};
|
||||
} catch (error: unknown) {
|
||||
// eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion
|
||||
const message = (error as Error).message || String(error);
|
||||
return {
|
||||
type: 'message',
|
||||
messageType: 'error',
|
||||
content: `Failed to create workspace: ${message}`,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
export async function deleteWorkspace(
|
||||
config: Config,
|
||||
id: string,
|
||||
): Promise<CommandActionReturn> {
|
||||
const hubUrl = getHubUrl(config);
|
||||
const client = new WorkspaceHubClient(hubUrl);
|
||||
|
||||
try {
|
||||
await client.deleteWorkspace(id);
|
||||
return {
|
||||
type: 'message',
|
||||
messageType: 'info',
|
||||
content: `✓ Workspace ${id} deleted successfully.`,
|
||||
};
|
||||
} catch (error: unknown) {
|
||||
// eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion
|
||||
const message = (error as Error).message || String(error);
|
||||
return {
|
||||
type: 'message',
|
||||
messageType: 'error',
|
||||
content: `Failed to delete workspace: ${message}`,
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -457,6 +457,15 @@ export class MCPServerConfig {
|
||||
) {}
|
||||
}
|
||||
|
||||
export interface WorkspaceHubConfig {
|
||||
url: string;
|
||||
}
|
||||
|
||||
export interface WorkspaceConfig {
|
||||
hubs: Record<string, WorkspaceHubConfig>;
|
||||
defaultHub?: string;
|
||||
}
|
||||
|
||||
export enum AuthProviderType {
|
||||
DYNAMIC_DISCOVERY = 'dynamic_discovery',
|
||||
GOOGLE_CREDENTIALS = 'google_credentials',
|
||||
@@ -534,6 +543,7 @@ export interface ConfigParameters {
|
||||
toolCallCommand?: string;
|
||||
mcpServerCommand?: string;
|
||||
mcpServers?: Record<string, MCPServerConfig>;
|
||||
workspaces?: WorkspaceConfig;
|
||||
mcpEnablementCallbacks?: McpEnablementCallbacks;
|
||||
userMemory?: string | HierarchicalMemory;
|
||||
geminiMdFileCount?: number;
|
||||
@@ -649,7 +659,6 @@ export interface ConfigParameters {
|
||||
billing?: {
|
||||
overageStrategy?: OverageStrategy;
|
||||
};
|
||||
simulateUser?: boolean;
|
||||
}
|
||||
|
||||
export class Config implements McpContext, AgentLoopContext {
|
||||
@@ -694,7 +703,9 @@ export class Config implements McpContext, AgentLoopContext {
|
||||
private readonly mcpEnabled: boolean;
|
||||
private readonly extensionsEnabled: boolean;
|
||||
private mcpServers: Record<string, MCPServerConfig> | undefined;
|
||||
private readonly mcpEnablementCallbacks?: McpEnablementCallbacks;
|
||||
private workspaces: WorkspaceConfig | undefined;
|
||||
private readonly mcpEnablementCallbacks: McpEnablementCallbacks | undefined;
|
||||
|
||||
private userMemory: string | HierarchicalMemory;
|
||||
private geminiMdFileCount: number;
|
||||
private geminiMdFilePaths: string[];
|
||||
@@ -867,7 +878,6 @@ export class Config implements McpContext, AgentLoopContext {
|
||||
private lastModeSwitchTime: number = performance.now();
|
||||
readonly injectionService: InjectionService;
|
||||
private approvedPlanPath: string | undefined;
|
||||
private readonly simulateUser: boolean;
|
||||
|
||||
constructor(params: ConfigParameters) {
|
||||
this._sessionId = params.sessionId;
|
||||
@@ -905,6 +915,7 @@ export class Config implements McpContext, AgentLoopContext {
|
||||
this.toolCallCommand = params.toolCallCommand;
|
||||
this.mcpServerCommand = params.mcpServerCommand;
|
||||
this.mcpServers = params.mcpServers;
|
||||
this.workspaces = params.workspaces;
|
||||
this.mcpEnablementCallbacks = params.mcpEnablementCallbacks;
|
||||
this.mcpEnabled = params.mcpEnabled ?? true;
|
||||
this.extensionsEnabled = params.extensionsEnabled ?? true;
|
||||
@@ -1097,7 +1108,6 @@ export class Config implements McpContext, AgentLoopContext {
|
||||
this.fileExclusions = new FileExclusions(this);
|
||||
this.eventEmitter = params.eventEmitter;
|
||||
this.enableConseca = params.enableConseca ?? false;
|
||||
this.simulateUser = params.simulateUser ?? false;
|
||||
|
||||
// Initialize Safety Infrastructure
|
||||
const contextBuilder = new ContextBuilder(this);
|
||||
@@ -2002,6 +2012,10 @@ export class Config implements McpContext, AgentLoopContext {
|
||||
return this.mcpServers;
|
||||
}
|
||||
|
||||
getWorkspaces(): WorkspaceConfig | undefined {
|
||||
return this.workspaces;
|
||||
}
|
||||
|
||||
getMcpEnabled(): boolean {
|
||||
return this.mcpEnabled;
|
||||
}
|
||||
@@ -2512,10 +2526,6 @@ export class Config implements McpContext, AgentLoopContext {
|
||||
return this.usageStatisticsEnabled;
|
||||
}
|
||||
|
||||
getSimulateUser(): boolean {
|
||||
return this.simulateUser;
|
||||
}
|
||||
|
||||
getAcpMode(): boolean {
|
||||
return this.acpMode;
|
||||
}
|
||||
|
||||
@@ -277,7 +277,7 @@ export const DEFAULT_MODEL_CONFIGS: ModelConfigServiceConfig = {
|
||||
family: 'gemini-3',
|
||||
isPreview: true,
|
||||
isVisible: true,
|
||||
features: { thinking: true, multimodalToolUse: true },
|
||||
features: { thinking: false, multimodalToolUse: true },
|
||||
},
|
||||
'gemini-2.5-pro': {
|
||||
tier: 'pro',
|
||||
|
||||
@@ -28,6 +28,7 @@ export * from './confirmation-bus/message-bus.js';
|
||||
// Export Commands logic
|
||||
export * from './commands/extensions.js';
|
||||
export * from './commands/restore.js';
|
||||
export * from './commands/workspace.js';
|
||||
export * from './commands/init.js';
|
||||
export * from './commands/memory.js';
|
||||
export * from './commands/types.js';
|
||||
@@ -132,6 +133,9 @@ export * from './services/trackerService.js';
|
||||
export * from './services/trackerTypes.js';
|
||||
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,100 @@
|
||||
/**
|
||||
* @license
|
||||
* Copyright 2026 Google LLC
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
||||
import { SSHService } from './sshService.js';
|
||||
import { spawn } from 'node:child_process';
|
||||
import { EventEmitter } from 'node:events';
|
||||
|
||||
vi.mock('node:child_process', () => ({
|
||||
spawn: vi.fn(),
|
||||
}));
|
||||
|
||||
describe('SSHService', () => {
|
||||
let service: SSHService;
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
service = new SSHService();
|
||||
});
|
||||
|
||||
it('should construct correct gcloud command and arguments', async () => {
|
||||
const mockChild = new EventEmitter() as any;
|
||||
vi.mocked(spawn).mockReturnValue(mockChild);
|
||||
|
||||
const promise = service.connect({
|
||||
instanceName: 'test-inst',
|
||||
zone: 'us-west1-a',
|
||||
project: 'test-project',
|
||||
forwardAgent: true,
|
||||
});
|
||||
|
||||
// Simulate success exit
|
||||
setTimeout(() => mockChild.emit('exit', 0), 10);
|
||||
|
||||
const code = await promise;
|
||||
expect(code).toBe(0);
|
||||
|
||||
expect(spawn).toHaveBeenCalledWith(
|
||||
'gcloud',
|
||||
[
|
||||
'compute',
|
||||
'ssh',
|
||||
'test-inst',
|
||||
'--zone=us-west1-a',
|
||||
'--project=test-project',
|
||||
'--tunnel-through-iap',
|
||||
'--ssh-flag=-A',
|
||||
],
|
||||
{ stdio: 'inherit' }
|
||||
);
|
||||
});
|
||||
|
||||
it('should handle failure exit code', async () => {
|
||||
const mockChild = new EventEmitter() as any;
|
||||
vi.mocked(spawn).mockReturnValue(mockChild);
|
||||
|
||||
const promise = service.connect({
|
||||
instanceName: 'test-inst',
|
||||
zone: 'us-west1-a',
|
||||
project: 'test-project',
|
||||
});
|
||||
|
||||
setTimeout(() => mockChild.emit('exit', 1), 10);
|
||||
|
||||
await expect(promise).rejects.toThrow('gcloud ssh exited with code 1');
|
||||
});
|
||||
|
||||
it('should construct correct gcloud command for pushSecret', async () => {
|
||||
const mockChild = new EventEmitter() as any;
|
||||
vi.mocked(spawn).mockReturnValue(mockChild);
|
||||
|
||||
const secretValue = 'secret-val';
|
||||
const promise = service.pushSecret(
|
||||
{ instanceName: 'test-inst', zone: 'z1', project: 'p1' },
|
||||
'.gh_token',
|
||||
secretValue
|
||||
);
|
||||
|
||||
setTimeout(() => mockChild.emit('exit', 0), 10);
|
||||
|
||||
await promise;
|
||||
|
||||
expect(spawn).toHaveBeenCalledWith(
|
||||
'gcloud',
|
||||
[
|
||||
'compute',
|
||||
'ssh',
|
||||
'test-inst',
|
||||
'--zone=z1',
|
||||
'--project=p1',
|
||||
'--tunnel-through-iap',
|
||||
'--command',
|
||||
`cat << 'EOF' > /dev/shm/.gh_token\n${secretValue}\nEOF\nchmod 600 /dev/shm/.gh_token`,
|
||||
]
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,106 @@
|
||||
/**
|
||||
* @license
|
||||
* Copyright 2026 Google LLC
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
import { spawn } from 'node:child_process';
|
||||
import { debugLogger } from '../utils/debugLogger.js';
|
||||
|
||||
export interface SSHOptions {
|
||||
instanceName: string;
|
||||
zone: string;
|
||||
project: string;
|
||||
command?: string;
|
||||
forwardAgent?: boolean;
|
||||
}
|
||||
|
||||
export class SSHService {
|
||||
/**
|
||||
* Connect to a GCE instance using gcloud compute ssh with IAP tunneling.
|
||||
* This method spawns a child process and inherits stdio to allow interactive shell.
|
||||
*/
|
||||
async connect(options: SSHOptions): Promise<number> {
|
||||
const { instanceName, zone, project, command, forwardAgent = false } = options;
|
||||
|
||||
const args = [
|
||||
'compute',
|
||||
'ssh',
|
||||
instanceName,
|
||||
`--zone=${zone}`,
|
||||
`--project=${project}`,
|
||||
'--tunnel-through-iap',
|
||||
];
|
||||
|
||||
// Security: Only forward agent if explicitly requested
|
||||
if (forwardAgent) {
|
||||
args.push('--ssh-flag=-A');
|
||||
}
|
||||
|
||||
if (command) {
|
||||
args.push('--command', command);
|
||||
}
|
||||
|
||||
debugLogger.log(`[SSHService] Executing: gcloud ${args.join(' ')}`);
|
||||
|
||||
return new Promise((resolve, reject) => {
|
||||
// Security: Do NOT use shell: true to prevent command injection
|
||||
const child = spawn('gcloud', args, {
|
||||
stdio: 'inherit',
|
||||
});
|
||||
|
||||
child.on('exit', (code) => {
|
||||
if (code === 0) {
|
||||
resolve(0);
|
||||
} else {
|
||||
reject(new Error(`gcloud ssh exited with code ${code}`));
|
||||
}
|
||||
});
|
||||
|
||||
child.on('error', (err) => {
|
||||
if ((err as any).code === 'ENOENT') {
|
||||
reject(new Error('gcloud CLI not found. Please install the Google Cloud SDK.'));
|
||||
} else {
|
||||
reject(err);
|
||||
}
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Pushes a secret string to a temporary, memory-only file on the remote VM.
|
||||
* Uses gcloud compute ssh with a heredoc to write to /dev/shm.
|
||||
*/
|
||||
async pushSecret(options: SSHOptions, secretName: string, secretValue: string): Promise<void> {
|
||||
const { instanceName, zone, project } = options;
|
||||
const remotePath = `/dev/shm/${secretName}`;
|
||||
|
||||
// Command to write secret securely without it appearing in process list
|
||||
const remoteCommand = `cat << 'EOF' > ${remotePath}
|
||||
${secretValue}
|
||||
EOF
|
||||
chmod 600 ${remotePath}`;
|
||||
|
||||
const args = [
|
||||
'compute',
|
||||
'ssh',
|
||||
instanceName,
|
||||
`--zone=${zone}`,
|
||||
`--project=${project}`,
|
||||
'--tunnel-through-iap',
|
||||
'--command',
|
||||
remoteCommand,
|
||||
];
|
||||
|
||||
debugLogger.log(`[SSHService] Pushing secret ${secretName} to ${instanceName}...`);
|
||||
|
||||
return new Promise((resolve, reject) => {
|
||||
const child = spawn('gcloud', args);
|
||||
child.on('exit', (code) => {
|
||||
if (code === 0) resolve();
|
||||
else reject(new Error(`Failed to push secret ${secretName}, exit code ${code}`));
|
||||
});
|
||||
child.on('error', (err) => reject(err));
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,64 @@
|
||||
/**
|
||||
* @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 for each essential item', async () => {
|
||||
// We need to simulate multiple successful exits for the multiple spawn calls
|
||||
vi.mocked(spawn).mockImplementation(() => {
|
||||
const child = new EventEmitter() as any;
|
||||
// Emit exit on next tick to ensure promise resolves correctly
|
||||
process.nextTick(() => child.emit('exit', 0));
|
||||
return child;
|
||||
});
|
||||
|
||||
await service.pushSettings({
|
||||
instanceName: 'test-inst',
|
||||
zone: 'us-west1-a',
|
||||
project: 'test-project',
|
||||
});
|
||||
|
||||
// Check first call (settings.json)
|
||||
expect(spawn).toHaveBeenCalledWith(
|
||||
'gcloud',
|
||||
[
|
||||
'compute',
|
||||
'scp',
|
||||
'--recurse',
|
||||
'/mock/local/dir/settings.json',
|
||||
'test-inst:.gemini/',
|
||||
'--zone=us-west1-a',
|
||||
'--project=test-project',
|
||||
'--tunnel-through-iap',
|
||||
],
|
||||
expect.any(Object)
|
||||
);
|
||||
|
||||
// Check total number of calls matches the essentials list
|
||||
expect(spawn).toHaveBeenCalledTimes(5);
|
||||
});
|
||||
});
|
||||
@@ -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();
|
||||
|
||||
// Fix: Ensure files are placed in the .gemini directory on the remote
|
||||
const remotePath = `${instanceName}:.gemini/`;
|
||||
|
||||
// Performance/Robustness: Exclude large and local-only folders.
|
||||
// Since gcloud scp doesn't support --exclude, we could either:
|
||||
// 1. scp specific sub-folders (settings.json, commands, skills, policies)
|
||||
// 2. Use a temporary tarball on the local side, scp it, and extract remotely.
|
||||
// For now, let's just sync the essential sub-directories to keep it fast.
|
||||
|
||||
const essentials = ['settings.json', 'commands', 'skills', 'policies', 'memory.md'];
|
||||
|
||||
debugLogger.log(`[SyncService] Syncing essential settings to ${instanceName}...`);
|
||||
|
||||
for (const item of essentials) {
|
||||
const localItem = `${localDir}/${item}`;
|
||||
const args = [
|
||||
'compute',
|
||||
'scp',
|
||||
'--recurse',
|
||||
localItem,
|
||||
remotePath,
|
||||
`--zone=${zone}`,
|
||||
`--project=${project}`,
|
||||
'--tunnel-through-iap',
|
||||
];
|
||||
|
||||
await new Promise<void>((resolve, reject) => {
|
||||
const child = spawn('gcloud', args, { stdio: 'ignore' });
|
||||
child.on('exit', (code) => {
|
||||
if (code === 0) resolve();
|
||||
else debugLogger.warn(`[SyncService] Failed to sync ${item}, skipping...`);
|
||||
resolve(); // Don't fail the whole sync if one item fails
|
||||
});
|
||||
child.on('error', (err) => {
|
||||
debugLogger.error(`[SyncService] Error syncing ${item}:`, err);
|
||||
resolve();
|
||||
});
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,127 @@
|
||||
/**
|
||||
* @license
|
||||
* Copyright 2026 Google LLC
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
import { fetchWithTimeout } from '../utils/fetch.js';
|
||||
import { debugLogger } from '../utils/debugLogger.js';
|
||||
|
||||
export interface WorkspaceHubInfo {
|
||||
id: string;
|
||||
name: string;
|
||||
instance_name: string;
|
||||
status: string;
|
||||
machine_type: string;
|
||||
zone: string;
|
||||
project_id: string;
|
||||
created_at: string;
|
||||
owner_id: string;
|
||||
}
|
||||
|
||||
export class WorkspaceHubClient {
|
||||
constructor(private readonly hubUrl: string) {}
|
||||
|
||||
/**
|
||||
* List all workspaces for the authenticated user
|
||||
*/
|
||||
async listWorkspaces(): Promise<WorkspaceHubInfo[]> {
|
||||
const url = new URL('/workspaces', this.hubUrl).toString();
|
||||
debugLogger.log(`[WorkspaceHubClient] Fetching workspaces from ${url}`);
|
||||
|
||||
try {
|
||||
const response = await fetchWithTimeout(url, 10000, {
|
||||
method: 'GET',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
// TODO: Add Authorization header (OAuth/IAP)
|
||||
},
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
const errorText = await response.text();
|
||||
throw new Error(`Hub API error (${response.status}): ${errorText}`);
|
||||
}
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion
|
||||
return (await response.json()) as WorkspaceHubInfo[];
|
||||
} catch (error) {
|
||||
debugLogger.error(
|
||||
`[WorkspaceHubClient] Failed to list workspaces:`,
|
||||
error,
|
||||
);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Fetch a specific workspace by ID or name
|
||||
*/
|
||||
async getWorkspace(idOrName: string): Promise<WorkspaceHubInfo | null> {
|
||||
const workspaces = await this.listWorkspaces();
|
||||
return (
|
||||
workspaces.find((w) => w.id === idOrName || w.name === idOrName) || null
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a new workspace
|
||||
*/
|
||||
async createWorkspace(
|
||||
name: string,
|
||||
machineType?: string,
|
||||
): Promise<WorkspaceHubInfo> {
|
||||
const url = new URL('/workspaces', this.hubUrl).toString();
|
||||
debugLogger.log(
|
||||
`[WorkspaceHubClient] Creating workspace ${name} at ${url}`,
|
||||
);
|
||||
|
||||
const response = await fetchWithTimeout(url, 15000, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
body: JSON.stringify({ name, machineType }),
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
const errorText = await response.text();
|
||||
throw new Error(`Hub API error (${response.status}): ${errorText}`);
|
||||
}
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion
|
||||
return (await response.json()) as WorkspaceHubInfo;
|
||||
}
|
||||
|
||||
/**
|
||||
* Notify the hub that a user is connecting to a workspace
|
||||
*/
|
||||
async notifyConnect(id: string): Promise<void> {
|
||||
const url = new URL(`/workspaces/${id}/connect`, this.hubUrl).toString();
|
||||
const response = await fetchWithTimeout(url, 5000, {
|
||||
method: 'POST',
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
const errorText = await response.text();
|
||||
throw new Error(`Hub API error (${response.status}): ${errorText}`);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Delete a workspace
|
||||
*/
|
||||
async deleteWorkspace(id: string): Promise<void> {
|
||||
const url = new URL(`/workspaces/${id}`, this.hubUrl).toString();
|
||||
debugLogger.log(`[WorkspaceHubClient] Deleting workspace ${id} at ${url}`);
|
||||
|
||||
const response = await fetchWithTimeout(url, 10000, {
|
||||
method: 'DELETE',
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
const errorText = await response.text();
|
||||
throw new Error(`Hub API error (${response.status}): ${errorText}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -16,5 +16,4 @@ export enum LlmRole {
|
||||
UTILITY_EDIT_CORRECTOR = 'utility_edit_corrector',
|
||||
UTILITY_AUTOCOMPLETE = 'utility_autocomplete',
|
||||
UTILITY_FAST_ACK_HELPER = 'utility_fast_ack_helper',
|
||||
UTILITY_SIMULATOR = 'utility_simulator',
|
||||
}
|
||||
|
||||
@@ -1,72 +0,0 @@
|
||||
# User Simulation Feature - Design Document
|
||||
|
||||
*Branch: `user-simulation`*
|
||||
|
||||
## 1. Overview
|
||||
The User Simulation feature enables an autonomous LLM-driven agent to act as a realistic user in interactive terminal sessions. It aims to support integration testing, evaluation, and background evaluation of system capabilities by effectively observing the terminal interface and supplying contextual standard input (stdin).
|
||||
|
||||
This is accomplished by adding a `--simulate-user` option to the CLI, intercepting terminal updates via a simulated stdout reader, computing appropriate responses via an LLM, and piping these directly into a `PassThrough` stream masquerading as `process.stdin`.
|
||||
|
||||
## 2. Core Architecture
|
||||
|
||||
### 2.1 Configurations and Triggers
|
||||
**Affected Files:**
|
||||
- `packages/cli/src/config/config.ts`
|
||||
- `packages/core/src/config/config.ts`
|
||||
- `packages/cli/src/interactiveCli.tsx`
|
||||
|
||||
**Implementation:**
|
||||
The feature is controlled via the explicit CLI argument `--simulate-user`. When supplied, the argument flows through the `Config` interface (`config.getSimulateUser()`), modifying how the interactive CLI bootstraps.
|
||||
- Instead of attaching the raw `process.stdin` directly to the `ink` render environment, the UI engine binds to a Node.js `PassThrough` stream if simulation is enabled.
|
||||
- The Interactive CLI session begins by initiating the `UserSimulator`, providing it access to the `Config`, the `simulatedStdin` buffer, and an observable snapshot of the `lastFrame` (the current state of the UI render).
|
||||
|
||||
### 2.2 The `UserSimulator` Service
|
||||
**Affected Files:**
|
||||
- `packages/cli/src/services/UserSimulator.ts`
|
||||
|
||||
**Implementation:**
|
||||
The `UserSimulator` class governs the core observation and input loops. It handles interactions asynchronously outside of the primary command execution flow.
|
||||
|
||||
#### Loop Execution
|
||||
The simulator runs via a repeating timer (3-second intervals `setInterval`), checking `tick()`. It ensures only one evaluation happens sequentially using a mutex lock (`this.isProcessing`).
|
||||
|
||||
#### Screen Processing and Environment Context
|
||||
The engine receives the rendered terminal snapshot (`lastFrame`) and applies sanitizations:
|
||||
- Removes ANSI terminal colors and cursor properties using regex filtering.
|
||||
- Strips variable UI animation symbols (Spinners like ⠋, ⠙) and time indicators (e.g., `[ 7s ]`).
|
||||
These visual stability checks allow the simulator to detect meaningful frame changes instead of being repeatedly triggered by minor clock updates.
|
||||
|
||||
#### Simulation Brain & LLM Integration
|
||||
The simulator utilizes `PREVIEW_GEMINI_FLASH_MODEL`, leveraging the newly configured `thinking: true` capacity (`packages/core/src/config/defaultModelConfigs.ts`) for better reasoning, operating under the distinct telemetry role `LlmRole.UTILITY_SIMULATOR`.
|
||||
|
||||
The LLM is provided an extensive context schema encapsulating:
|
||||
- **Terminal output:** Current stripped terminal representation.
|
||||
- **Original task objective:** The intention of the CLI session.
|
||||
- **Action Memory:** A tracked sequence (`actionHistory`) of previously executed actions to avoid getting stuck in inquiry loops.
|
||||
|
||||
#### State Machine Interpretation
|
||||
The model translates the screen context into specific behavioral states:
|
||||
- **STATE 1 (WAIT):** Triggered if any indication of internal "thinking" or processing exists (active spinners/timers). Returns `<WAIT>`.
|
||||
- **STATE 2 (ACTION/AUTHORIZATION):** Prompt requests an approval (e.g., "Allow execution"). Supplies strict raw choice input.
|
||||
- **STATE 3 (DONE):** Fully idle string detection paired with task completion fulfillment. Gracefully types `Thank you\r` and eventually exits indicating `<DONE>`.
|
||||
- **STATE 4 (INPUT):** Idle state where continuation text instruction is needed, supplying direct commands injected to stdin.
|
||||
|
||||
#### Safeguards and Debugging
|
||||
- **Anti-Stall Mechanism:** The simulator tracks repetitive actions. If the LLM repeats the exact same non-empty interaction `MAX_REPEATS` (3) times, it terminates the process abruptly to prevent infinite loops.
|
||||
- **Interaction Transcripts:** Binds an audit log, dumping each parsed `[SIMULATOR] Screen Content Seen` block, the constructed `Prompt Used`, and `Raw model response` to discrete contextual `interactions_<timestamp>.txt` files, making it traceable.
|
||||
|
||||
### 2.3 Required Ecosystem Changes
|
||||
- `packages/core/src/telemetry/llmRole.ts`: A new internal trace role `UTILITY_SIMULATOR` has been appended to separate evaluation events from main-thread generations.
|
||||
- `packages/core/src/config/defaultModelConfigs.ts`: Added dynamic thought logic parameter (`thinking: true`) for `gemini-3-flash-preview` to enhance the simulation depth reasoning.
|
||||
- Unit Testing Mocks: Updated dummy interface `mockConfig.ts` to ensure compatibility by safely mocking `getSimulateUser`.
|
||||
|
||||
## 3. Workflow Summary
|
||||
1. The user launches `gemini --simulate-user`.
|
||||
2. Interactive UI bindings swap `process.stdin` for a readable `PassThrough` stream.
|
||||
3. Every 3 seconds, `UserSimulator.tick()` accesses the newest render frame.
|
||||
4. The frame is stripped of unstable metrics/spinners. If the clean frame is unchanged since the last tick, it waits.
|
||||
5. If changed, the simulator invokes Gemini Flash Preview (Thinking).
|
||||
6. Gemini observes the state—emitting `<WAIT>` if busy, action keys for selections, `<DONE>` if completed, or commands to be resolved.
|
||||
7. Return text is piped cleanly back to `simulatedStdin`—advancing the ink render state.
|
||||
|
||||
*This design effectively encapsulates the user interface flow without imposing side-effects on standard operations.*
|
||||
@@ -129,7 +129,7 @@
|
||||
"@types/vscode": "^1.99.0",
|
||||
"@typescript-eslint/eslint-plugin": "^8.31.1",
|
||||
"@typescript-eslint/parser": "^8.31.1",
|
||||
"@vscode/vsce": "^3.7.1",
|
||||
"@vscode/vsce": "^3.6.0",
|
||||
"esbuild": "^0.25.3",
|
||||
"eslint": "^9.25.1",
|
||||
"npm-run-all2": "^8.0.2",
|
||||
|
||||
@@ -0,0 +1,5 @@
|
||||
node_modules
|
||||
dist
|
||||
.git
|
||||
docker
|
||||
terraform
|
||||
@@ -0,0 +1,20 @@
|
||||
# Copyright 2026 Google LLC
|
||||
# SPDX-License-Identifier: Apache-2.0
|
||||
|
||||
# Standard Hub Dockerfile
|
||||
FROM node:20-slim
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
# In a monorepo, we typically don't have a local package-lock.json
|
||||
# so we use npm install instead of npm ci for individual package builds.
|
||||
COPY package.json ./
|
||||
RUN npm install
|
||||
|
||||
COPY . .
|
||||
RUN npm run build
|
||||
|
||||
EXPOSE 8080
|
||||
|
||||
# Use node directly for better signal handling (SIGTERM)
|
||||
CMD ["node", "dist/index.js"]
|
||||
@@ -0,0 +1,51 @@
|
||||
# Copyright 2026 Google LLC
|
||||
# SPDX-License-Identifier: Apache-2.0
|
||||
|
||||
# Stage 1: Build shpool
|
||||
FROM rust:1.81-slim-bookworm AS builder
|
||||
|
||||
RUN apt-get update && apt-get install -y build-essential curl && rm -rf /var/lib/apt/lists/*
|
||||
RUN cargo install shpool
|
||||
|
||||
# Stage 2: Final Image
|
||||
FROM node:20-slim
|
||||
|
||||
# Install system dependencies
|
||||
RUN apt-get update && apt-get install -y \
|
||||
git \
|
||||
curl \
|
||||
rsync \
|
||||
vim \
|
||||
tmux \
|
||||
procps \
|
||||
&& rm -rf /var/lib/apt/lists/*
|
||||
|
||||
# Install GitHub CLI
|
||||
RUN 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 \
|
||||
&& apt-get update \
|
||||
&& apt-get install gh -y
|
||||
|
||||
# Copy shpool from builder
|
||||
COPY --from=builder /usr/local/cargo/bin/shpool /usr/local/bin/shpool
|
||||
|
||||
# Install global dev tools
|
||||
RUN npm install -g tsx eslint vitest typescript prettier @google/gemini-cli@nightly
|
||||
|
||||
# Create workspace directory
|
||||
WORKDIR /home/node/workspace
|
||||
RUN chown -R node:node /home/node/workspace
|
||||
|
||||
# Entrypoint script
|
||||
COPY --chown=node:node entrypoint.sh /usr/local/bin/entrypoint.sh
|
||||
RUN chmod +x /usr/local/bin/entrypoint.sh
|
||||
|
||||
USER node
|
||||
|
||||
# Environment variables
|
||||
ENV GEMINI_CLI_WORKSPACE=1
|
||||
ENV PATH=$PATH:/usr/local/bin
|
||||
|
||||
ENTRYPOINT ["/usr/local/bin/entrypoint.sh"]
|
||||
CMD ["/bin/bash"]
|
||||
@@ -0,0 +1,37 @@
|
||||
#!/bin/bash
|
||||
# Copyright 2026 Google LLC
|
||||
# SPDX-License-Identifier: Apache-2.0
|
||||
|
||||
set -e
|
||||
|
||||
# Ensure GH_TOKEN is set from memory-only mount if available
|
||||
if [ -f /dev/shm/.gh_token ]; then
|
||||
export GH_TOKEN=$(cat /dev/shm/.gh_token)
|
||||
echo "GitHub token injected from memory."
|
||||
# Authenticate gh CLI if possible
|
||||
if command -v gh >/dev/null 2>&1; then
|
||||
echo "$GH_TOKEN" | gh auth login --with-token
|
||||
echo "GitHub CLI authenticated."
|
||||
fi
|
||||
fi
|
||||
|
||||
# Start shpool daemon in the background and verify it stays up
|
||||
/usr/local/bin/shpool daemon &
|
||||
SHPOOL_PID=$!
|
||||
|
||||
sleep 2
|
||||
if ! kill -0 $SHPOOL_PID 2>/dev/null; then
|
||||
echo "Error: shpool daemon failed to start"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
echo "shpool daemon started successfully (PID: $SHPOOL_PID)"
|
||||
|
||||
# Restore ~/.gemini settings if they are provided in a mount or PD
|
||||
# (Assuming PD is mounted at /home/node/persistent_home for now)
|
||||
if [ -d /home/node/persistent_home/.gemini ]; then
|
||||
rsync -a /home/node/persistent_home/.gemini/ /home/node/.gemini/
|
||||
fi
|
||||
|
||||
# Execute the CMD passed to docker
|
||||
exec "$@"
|
||||
@@ -0,0 +1,28 @@
|
||||
{
|
||||
"name": "@google/gemini-cli-workspace-manager",
|
||||
"version": "0.1.0",
|
||||
"description": "Workspace Hub for Gemini CLI",
|
||||
"type": "module",
|
||||
"main": "dist/index.js",
|
||||
"scripts": {
|
||||
"start": "node dist/index.js",
|
||||
"dev": "tsx src/index.ts",
|
||||
"test": "vitest run",
|
||||
"build": "tsc"
|
||||
},
|
||||
"dependencies": {
|
||||
"@google-cloud/compute": "^4.10.0",
|
||||
"@google-cloud/firestore": "^7.11.0",
|
||||
"express": "^4.21.2",
|
||||
"uuid": "^13.0.0",
|
||||
"zod": "^3.25.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/express": "^5.0.0",
|
||||
"@types/node": "^20.0.0",
|
||||
"@types/uuid": "^10.0.0",
|
||||
"supertest": "^7.2.2",
|
||||
"typescript": "^5.7.0",
|
||||
"vitest": "^3.2.0"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,47 @@
|
||||
/**
|
||||
* @license
|
||||
* Copyright 2026 Google LLC
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
import express from 'express';
|
||||
import { workspaceRouter } from './routes/workspaceRoutes.js';
|
||||
import { iapMiddleware } from './middleware/iap.js';
|
||||
import { CleanupService } from './services/cleanupService.js';
|
||||
|
||||
export const app = express();
|
||||
app.use(express.json());
|
||||
app.use(iapMiddleware);
|
||||
|
||||
const PORT = process.env['PORT'] || 8080;
|
||||
|
||||
app.get('/health', (_req, res) => {
|
||||
res.send({ status: 'ok' });
|
||||
});
|
||||
|
||||
/**
|
||||
* Endpoint to trigger cleanup of idle workspaces.
|
||||
* Typically called by a Cloud Scheduler job.
|
||||
*/
|
||||
app.post('/cleanup', async (req, res) => {
|
||||
try {
|
||||
const cleanupService = new CleanupService();
|
||||
const ttlMinutes = req.body.ttl_minutes ? Number(req.body.ttl_minutes) : 240;
|
||||
const count = await cleanupService.cleanupIdleWorkspaces(ttlMinutes);
|
||||
res.json({ status: 'ok', cleaned_count: count, ttl_minutes: ttlMinutes });
|
||||
} catch (error) {
|
||||
const message = error instanceof Error ? error.message : String(error);
|
||||
res.status(500).json({ error: message });
|
||||
}
|
||||
});
|
||||
|
||||
// Register Workspace Routes
|
||||
app.use('/workspaces', workspaceRouter);
|
||||
|
||||
// Only listen if not in test mode
|
||||
if (process.env['NODE_ENV'] !== 'test') {
|
||||
app.listen(PORT, () => {
|
||||
// eslint-disable-next-line no-console
|
||||
console.log(`Workspace Hub listening on port ${PORT}`);
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,44 @@
|
||||
/**
|
||||
* @license
|
||||
* Copyright 2026 Google LLC
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
import { describe, it, expect, vi } from 'vitest';
|
||||
import { iapMiddleware } from './iap.js';
|
||||
import type { Request, Response } from 'express';
|
||||
|
||||
describe('iapMiddleware', () => {
|
||||
it('should extract user info from IAP headers', () => {
|
||||
const req = {
|
||||
header: vi.fn((name) => {
|
||||
if (name === 'x-goog-authenticated-user-email') return 'accounts.google.com:test@google.com';
|
||||
if (name === 'x-goog-authenticated-user-id') return 'accounts.google.com:12345';
|
||||
return undefined;
|
||||
}),
|
||||
} as unknown as Request;
|
||||
const res = {} as Response;
|
||||
const next = vi.fn();
|
||||
|
||||
iapMiddleware(req, res, next);
|
||||
|
||||
expect((req as any).user).toEqual({
|
||||
id: '12345',
|
||||
email: 'test@google.com',
|
||||
});
|
||||
expect(next).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should fall back to dev user if headers missing in non-prod', () => {
|
||||
const req = {
|
||||
header: vi.fn(() => undefined),
|
||||
} as unknown as Request;
|
||||
const res = {} as Response;
|
||||
const next = vi.fn();
|
||||
|
||||
iapMiddleware(req, res, next);
|
||||
|
||||
expect((req as any).user.id).toBe('dev-user-id');
|
||||
expect(next).toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,53 @@
|
||||
/**
|
||||
* @license
|
||||
* Copyright 2026 Google LLC
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
import type { Request, Response, NextFunction } from 'express';
|
||||
|
||||
export interface AuthenticatedRequest extends Request {
|
||||
user: {
|
||||
id: string;
|
||||
email: string;
|
||||
org_id?: string;
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Middleware to extract user identity from Google IAP headers.
|
||||
* In a real production environment, the JWT signature should also be verified.
|
||||
*/
|
||||
export const iapMiddleware = (req: Request, res: Response, next: NextFunction) => {
|
||||
const userEmail = req.header('x-goog-authenticated-user-email');
|
||||
const userId = req.header('x-goog-authenticated-user-id');
|
||||
const orgId = req.header('x-goog-authenticated-user-org');
|
||||
|
||||
// If running locally or without IAP, use a dev user
|
||||
if (!userEmail || !userId) {
|
||||
if (process.env['NODE_ENV'] === 'production') {
|
||||
res.status(401).json({ error: 'Missing IAP authentication headers' });
|
||||
return;
|
||||
}
|
||||
|
||||
(req as unknown as AuthenticatedRequest).user = {
|
||||
id: 'dev-user-id',
|
||||
email: 'dev-user@google.com',
|
||||
org_id: 'dev-org-id',
|
||||
};
|
||||
next();
|
||||
return;
|
||||
}
|
||||
|
||||
// Remove the "accounts.google.com:" prefix if present
|
||||
const cleanId = userId.replace('accounts.google.com:', '');
|
||||
const cleanEmail = userEmail.replace('accounts.google.com:', '');
|
||||
|
||||
(req as unknown as AuthenticatedRequest).user = {
|
||||
id: cleanId,
|
||||
email: cleanEmail,
|
||||
org_id: orgId,
|
||||
};
|
||||
|
||||
next();
|
||||
};
|
||||
@@ -0,0 +1,66 @@
|
||||
/**
|
||||
* @license
|
||||
* Copyright 2026 Google LLC
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
||||
import request from 'supertest';
|
||||
import { app } from '../index.js';
|
||||
|
||||
// Mock the services
|
||||
vi.mock('../services/workspaceService.js', () => ({
|
||||
WorkspaceService: vi.fn().mockImplementation(() => ({
|
||||
listWorkspacesForUser: vi.fn().mockResolvedValue([]),
|
||||
getWorkspace: vi.fn().mockResolvedValue(null),
|
||||
createWorkspace: vi.fn().mockResolvedValue(undefined),
|
||||
updateWorkspace: vi.fn().mockResolvedValue(undefined),
|
||||
deleteWorkspace: vi.fn().mockResolvedValue(undefined),
|
||||
})),
|
||||
}));
|
||||
|
||||
vi.mock('../services/computeService.js', () => ({
|
||||
ComputeService: vi.fn().mockImplementation(() => ({
|
||||
createWorkspaceInstance: vi.fn().mockResolvedValue(undefined),
|
||||
deleteWorkspaceInstance: vi.fn().mockResolvedValue(undefined),
|
||||
getProjectId: vi.fn().mockReturnValue('dev-project'),
|
||||
})),
|
||||
}));
|
||||
|
||||
describe('Workspace Routes', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
describe('GET /workspaces', () => {
|
||||
it('should return an empty list of workspaces', async () => {
|
||||
const response = await request(app).get('/workspaces');
|
||||
expect(response.status).toBe(200);
|
||||
expect(response.body).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
describe('POST /workspaces', () => {
|
||||
it('should create a new workspace', async () => {
|
||||
const payload = { name: 'test-workspace' };
|
||||
const response = await request(app).post('/workspaces').send(payload);
|
||||
|
||||
expect(response.status).toBe(201);
|
||||
expect(response.body.name).toBe('test-workspace');
|
||||
expect(response.body.owner_id).toBe('dev-user-id');
|
||||
expect(response.body.status).toBe('PROVISIONING');
|
||||
});
|
||||
|
||||
it('should fail if name is missing', async () => {
|
||||
const response = await request(app).post('/workspaces').send({});
|
||||
expect(response.status).toBe(400);
|
||||
});
|
||||
});
|
||||
|
||||
describe('DELETE /workspaces/:id', () => {
|
||||
it('should return 404 if workspace not found', async () => {
|
||||
const response = await request(app).delete('/workspaces/non-existent');
|
||||
expect(response.status).toBe(404);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,152 @@
|
||||
/**
|
||||
* @license
|
||||
* Copyright 2026 Google LLC
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
import { Router } from 'express';
|
||||
import { v4 as uuidv4 } from 'uuid';
|
||||
import { z } from 'zod';
|
||||
import { WorkspaceService } from '../services/workspaceService.js';
|
||||
import { ComputeService } from '../services/computeService.js';
|
||||
import type { AuthenticatedRequest } from '../middleware/iap.js';
|
||||
|
||||
const router = Router();
|
||||
const workspaceService = new WorkspaceService();
|
||||
const computeService = new ComputeService();
|
||||
|
||||
const CreateWorkspaceSchema = z.object({
|
||||
name: z.string().min(1),
|
||||
machineType: z.string().optional().default('e2-standard-4'),
|
||||
imageTag: z.string().optional().default('latest'),
|
||||
zone: z.string().optional().default('us-west1-a'),
|
||||
orgId: z.string().optional(),
|
||||
repoId: z.string().optional(),
|
||||
});
|
||||
|
||||
router.get('/', async (req, res) => {
|
||||
try {
|
||||
const authReq = req as unknown as AuthenticatedRequest;
|
||||
const workspaces = await workspaceService.listWorkspacesForUser(
|
||||
authReq.user.id,
|
||||
authReq.user.org_id
|
||||
);
|
||||
res.json(workspaces);
|
||||
} catch (error) {
|
||||
const message = error instanceof Error ? error.message : String(error);
|
||||
res.status(500).json({ error: message });
|
||||
}
|
||||
});
|
||||
|
||||
router.post('/', async (req, res) => {
|
||||
try {
|
||||
const authReq = req as unknown as AuthenticatedRequest;
|
||||
const validation = CreateWorkspaceSchema.safeParse(req.body);
|
||||
if (!validation.success) {
|
||||
res.status(400).json({ error: validation.error.format() });
|
||||
return;
|
||||
}
|
||||
|
||||
const { name, machineType, imageTag, zone, orgId, repoId } = validation.data;
|
||||
const workspaceId = uuidv4();
|
||||
const instanceName = `workspace-${workspaceId.slice(0, 8)}`;
|
||||
|
||||
const now = new Date().toISOString();
|
||||
const workspaceData = {
|
||||
owner_id: authReq.user.id,
|
||||
name,
|
||||
instance_name: instanceName,
|
||||
status: 'PROVISIONING',
|
||||
machine_type: machineType,
|
||||
zone,
|
||||
project_id: computeService.getProjectId(),
|
||||
created_at: now,
|
||||
last_connected_at: now,
|
||||
org_id: orgId || authReq.user.org_id,
|
||||
repo_id: repoId,
|
||||
};
|
||||
|
||||
// 1. Save to state store
|
||||
await workspaceService.createWorkspace(workspaceId, workspaceData);
|
||||
|
||||
// 2. Trigger GCE provisioning (Async)
|
||||
computeService.createWorkspaceInstance({
|
||||
instanceName,
|
||||
machineType,
|
||||
imageTag,
|
||||
zone,
|
||||
workspaceId,
|
||||
}).catch(err => {
|
||||
// eslint-disable-next-line no-console
|
||||
console.error(`Failed to provision GCE instance ${instanceName}:`, err);
|
||||
});
|
||||
|
||||
res.status(201).json({ id: workspaceId, ...workspaceData });
|
||||
} catch (error) {
|
||||
const message = error instanceof Error ? error.message : String(error);
|
||||
res.status(500).json({ error: message });
|
||||
}
|
||||
});
|
||||
|
||||
router.post('/:id/connect', async (req, res) => {
|
||||
try {
|
||||
const authReq = req as unknown as AuthenticatedRequest;
|
||||
const { id } = req.params;
|
||||
const workspace = await workspaceService.getWorkspace(id);
|
||||
|
||||
if (!workspace) {
|
||||
res.status(404).json({ error: 'Workspace not found' });
|
||||
return;
|
||||
}
|
||||
|
||||
// SECURITY: Allow owner OR member of the same org
|
||||
const isOwner = workspace.owner_id === authReq.user.id;
|
||||
const isOrgMember = workspace.org_id && workspace.org_id === authReq.user.org_id;
|
||||
|
||||
if (!isOwner && !isOrgMember) {
|
||||
res.status(403).json({ error: 'Unauthorized' });
|
||||
return;
|
||||
}
|
||||
|
||||
// Update last_connected_at timestamp
|
||||
const now = new Date().toISOString();
|
||||
await workspaceService.updateWorkspace(id, { last_connected_at: now });
|
||||
|
||||
res.json({ status: 'ok', last_connected_at: now });
|
||||
} catch (error) {
|
||||
const message = error instanceof Error ? error.message : String(error);
|
||||
res.status(500).json({ error: message });
|
||||
}
|
||||
});
|
||||
|
||||
router.delete('/:id', async (req, res) => {
|
||||
try {
|
||||
const authReq = req as unknown as AuthenticatedRequest;
|
||||
const { id } = req.params;
|
||||
const workspace = await workspaceService.getWorkspace(id);
|
||||
|
||||
if (!workspace) {
|
||||
res.status(404).json({ error: 'Workspace not found' });
|
||||
return;
|
||||
}
|
||||
|
||||
// SECURITY: Ownership Check
|
||||
if (workspace.owner_id !== authReq.user.id) {
|
||||
res.status(403).json({ error: 'Unauthorized to delete this workspace' });
|
||||
return;
|
||||
}
|
||||
|
||||
// 1. Delete GCE instance
|
||||
await computeService.deleteWorkspaceInstance(workspace.instance_name, workspace.zone);
|
||||
|
||||
// 2. Delete from state store
|
||||
await workspaceService.deleteWorkspace(id);
|
||||
|
||||
res.status(204).send();
|
||||
} catch (error) {
|
||||
const message = error instanceof Error ? error.message : String(error);
|
||||
res.status(500).json({ error: message });
|
||||
}
|
||||
});
|
||||
|
||||
export const workspaceRouter = router;
|
||||
@@ -0,0 +1,55 @@
|
||||
/**
|
||||
* @license
|
||||
* Copyright 2026 Google LLC
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
||||
import { CleanupService } from './cleanupService.js';
|
||||
import { WorkspaceService } from './workspaceService.js';
|
||||
import { ComputeService } from './computeService.js';
|
||||
|
||||
vi.mock('./workspaceService.js');
|
||||
vi.mock('./computeService.js');
|
||||
|
||||
describe('CleanupService', () => {
|
||||
let service: CleanupService;
|
||||
let mockWorkspaceService: any;
|
||||
let mockComputeService: any;
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
service = new CleanupService();
|
||||
mockWorkspaceService = vi.mocked(new WorkspaceService());
|
||||
mockComputeService = vi.mocked(new ComputeService());
|
||||
|
||||
// Wire up the internal instances
|
||||
(service as any).workspaceService = mockWorkspaceService;
|
||||
(service as any).computeService = mockComputeService;
|
||||
});
|
||||
|
||||
it('should cleanup workspaces older than TTL', async () => {
|
||||
const now = new Date();
|
||||
const oldDate = new Date(now.getTime() - 300 * 60 * 1000).toISOString(); // 5 hours ago
|
||||
const newDate = new Date(now.getTime() - 60 * 60 * 1000).toISOString(); // 1 hour ago
|
||||
|
||||
const workspaces = [
|
||||
{ id: 'old-1', name: 'old', last_connected_at: oldDate, instance_name: 'inst-1', zone: 'z1', status: 'READY' },
|
||||
{ id: 'new-1', name: 'new', last_connected_at: newDate, instance_name: 'inst-2', zone: 'z1', status: 'READY' },
|
||||
{ id: 'stuck-1', name: 'stuck', last_connected_at: oldDate, instance_name: 'inst-3', zone: 'z1', status: 'PROVISIONING' },
|
||||
];
|
||||
|
||||
mockWorkspaceService.listAllWorkspaces.mockResolvedValue(workspaces);
|
||||
|
||||
const cleanedCount = await service.cleanupIdleWorkspaces(240); // 4 hour TTL
|
||||
|
||||
expect(cleanedCount).toBe(2);
|
||||
expect(mockComputeService.deleteWorkspaceInstance).toHaveBeenCalledWith('inst-1', 'z1');
|
||||
expect(mockComputeService.deleteWorkspaceInstance).toHaveBeenCalledWith('inst-3', 'z1');
|
||||
expect(mockWorkspaceService.deleteWorkspace).toHaveBeenCalledWith('old-1');
|
||||
expect(mockWorkspaceService.deleteWorkspace).toHaveBeenCalledWith('stuck-1');
|
||||
|
||||
// Should NOT have deleted the new one
|
||||
expect(mockWorkspaceService.deleteWorkspace).not.toHaveBeenCalledWith('new-1');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,53 @@
|
||||
/**
|
||||
* @license
|
||||
* Copyright 2026 Google LLC
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
import { WorkspaceService } from './workspaceService.js';
|
||||
import { ComputeService } from './computeService.js';
|
||||
|
||||
export class CleanupService {
|
||||
private workspaceService = new WorkspaceService();
|
||||
private computeService = new ComputeService();
|
||||
|
||||
/**
|
||||
* Identifies and deletes workspaces that have been idle for too long.
|
||||
* Targets READY, PROVISIONING, and ERROR statuses to prevent leaks.
|
||||
* @param ttlMinutes Threshold for idleness in minutes.
|
||||
*/
|
||||
async cleanupIdleWorkspaces(ttlMinutes: number = 240): Promise<number> {
|
||||
const now = new Date();
|
||||
const threshold = new Date(now.getTime() - ttlMinutes * 60 * 1000);
|
||||
|
||||
// Fetch all workspaces for evaluation
|
||||
const workspaces = await this.workspaceService.listAllWorkspaces();
|
||||
|
||||
let cleanedCount = 0;
|
||||
for (const ws of workspaces) {
|
||||
const lastConnected = new Date(ws.last_connected_at);
|
||||
|
||||
// Cleanup if idle past TTL
|
||||
if (lastConnected < threshold) {
|
||||
// eslint-disable-next-line no-console
|
||||
console.log(`[Cleanup] Workspace ${ws.id} (${ws.name}) is idle since ${ws.last_connected_at} (Status: ${ws.status}). Cleaning up...`);
|
||||
|
||||
try {
|
||||
// 1. Delete GCE instance if it exists (or attempts to)
|
||||
// ComputeService handles missing instances gracefully
|
||||
await this.computeService.deleteWorkspaceInstance(ws.instance_name, ws.zone);
|
||||
|
||||
// 2. Delete record from Firestore
|
||||
await this.workspaceService.deleteWorkspace(ws.id);
|
||||
|
||||
cleanedCount++;
|
||||
} catch (err) {
|
||||
// eslint-disable-next-line no-console
|
||||
console.error(`[Cleanup] Failed to clean up ${ws.id}:`, err);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return cleanedCount;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,58 @@
|
||||
/**
|
||||
* @license
|
||||
* Copyright 2026 Google LLC
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
||||
import { ComputeService } from './computeService.js';
|
||||
import { WorkspaceService } from './workspaceService.js';
|
||||
|
||||
// Mock WorkspaceService and compute client
|
||||
vi.mock('./workspaceService.js');
|
||||
vi.mock('@google-cloud/compute', () => ({
|
||||
InstancesClient: vi.fn().mockImplementation(() => ({
|
||||
insert: vi.fn().mockResolvedValue([{ latestResponse: { name: 'op-1' } }]),
|
||||
delete: vi.fn().mockResolvedValue([{ latestResponse: { name: 'op-2' } }]),
|
||||
})),
|
||||
}));
|
||||
|
||||
describe('ComputeService', () => {
|
||||
let service: ComputeService;
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
service = new ComputeService();
|
||||
});
|
||||
|
||||
describe('createWorkspaceInstance', () => {
|
||||
it('should initiate provisioning', async () => {
|
||||
const options = {
|
||||
instanceName: 'test-inst',
|
||||
machineType: 'e2-standard-4',
|
||||
imageTag: 'latest',
|
||||
zone: 'us-west1-a',
|
||||
workspaceId: 'ws-123',
|
||||
};
|
||||
|
||||
// Mock console to avoid noise and check output
|
||||
const logSpy = vi.spyOn(console, 'log').mockImplementation(() => {});
|
||||
|
||||
await service.createWorkspaceInstance(options);
|
||||
|
||||
expect(logSpy).toHaveBeenCalledWith(expect.stringContaining('Provisioning test-inst'));
|
||||
logSpy.mockRestore();
|
||||
});
|
||||
});
|
||||
|
||||
describe('deleteWorkspaceInstance', () => {
|
||||
it('should initiate deletion', async () => {
|
||||
const logSpy = vi.spyOn(console, 'log').mockImplementation(() => {});
|
||||
|
||||
await service.deleteWorkspaceInstance('inst1', 'zone1');
|
||||
|
||||
expect(logSpy).toHaveBeenCalledWith(expect.stringContaining('Deleting instance inst1'));
|
||||
logSpy.mockRestore();
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,83 @@
|
||||
/**
|
||||
* @license
|
||||
* Copyright 2026 Google LLC
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
import { InstancesClient } from '@google-cloud/compute';
|
||||
import { WorkspaceService } from './workspaceService.js';
|
||||
|
||||
export interface ProvisionOptions {
|
||||
instanceName: string;
|
||||
machineType: string;
|
||||
imageTag: string;
|
||||
zone: string;
|
||||
workspaceId: string;
|
||||
}
|
||||
|
||||
export class ComputeService {
|
||||
private client: InstancesClient;
|
||||
private projectId: string;
|
||||
private workspaceService = new WorkspaceService();
|
||||
|
||||
constructor() {
|
||||
this.client = new InstancesClient();
|
||||
// In a real GCP environment, this is usually available via metadata server or env
|
||||
this.projectId = process.env['GOOGLE_CLOUD_PROJECT'] || 'dev-project';
|
||||
}
|
||||
|
||||
getProjectId(): string {
|
||||
return this.projectId;
|
||||
}
|
||||
|
||||
/**
|
||||
* Provision a new GCE VM with the Workspace Container
|
||||
*/
|
||||
async createWorkspaceInstance(options: ProvisionOptions): Promise<void> {
|
||||
const { instanceName, machineType, imageTag, zone, workspaceId } = options;
|
||||
|
||||
// Logic to call GCP Compute API to create VM
|
||||
// ... insert instance call ...
|
||||
|
||||
// eslint-disable-next-line no-console
|
||||
console.log(`[ComputeService] Provisioning ${instanceName} in ${zone} (Image: ${imageTag})...`);
|
||||
|
||||
// Simulating async provisioning success for this prototype
|
||||
// In a real implementation, we would wait for the long-running operation or poll
|
||||
this.waitForInstanceAndMarkReady(workspaceId, instanceName, zone).catch(err => {
|
||||
// eslint-disable-next-line no-console
|
||||
console.error(`[ComputeService] Failed to track provisioning for ${workspaceId}:`, err);
|
||||
});
|
||||
}
|
||||
|
||||
private async waitForInstanceAndMarkReady(workspaceId: string, instanceName: string, zone: string) {
|
||||
// Poll for status or wait for operation
|
||||
let attempts = 0;
|
||||
while (attempts < 10) {
|
||||
// eslint-disable-next-line no-console
|
||||
console.log(`[ComputeService] Waiting for ${instanceName} to be READY... (${attempts+1}/10)`);
|
||||
|
||||
// In real GCP, we'd check this.client.get({ project, zone, instance })
|
||||
// For prototype, we'll just wait a few seconds and mark it as READY
|
||||
await new Promise(resolve => setTimeout(resolve, 5000));
|
||||
attempts++;
|
||||
|
||||
if (attempts >= 3) {
|
||||
await this.workspaceService.updateWorkspace(workspaceId, { status: 'READY' });
|
||||
// eslint-disable-next-line no-console
|
||||
console.log(`[ComputeService] Workspace ${workspaceId} is now READY.`);
|
||||
return;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Delete a GCE VM
|
||||
*/
|
||||
async deleteWorkspaceInstance(instanceName: string, zone: string): Promise<void> {
|
||||
// eslint-disable-next-line no-console
|
||||
console.log(`[ComputeService] Deleting instance ${instanceName} in ${zone}...`);
|
||||
// Logic to call GCP Compute API to delete VM
|
||||
// ... delete instance call ...
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,67 @@
|
||||
/**
|
||||
* @license
|
||||
* Copyright 2026 Google LLC
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
||||
import { WorkspaceService } from './workspaceService.js';
|
||||
import { Firestore } from '@google-cloud/firestore';
|
||||
|
||||
const mockCollection = {
|
||||
where: vi.fn().mockReturnThis(),
|
||||
get: vi.fn(),
|
||||
doc: vi.fn().mockReturnThis(),
|
||||
set: vi.fn(),
|
||||
delete: vi.fn(),
|
||||
};
|
||||
|
||||
vi.mock('@google-cloud/firestore', () => ({
|
||||
Firestore: vi.fn().mockImplementation(() => ({
|
||||
collection: vi.fn().mockReturnValue(mockCollection),
|
||||
})),
|
||||
}));
|
||||
|
||||
describe('WorkspaceService', () => {
|
||||
let service: WorkspaceService;
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
service = new WorkspaceService();
|
||||
});
|
||||
|
||||
describe('listWorkspaces', () => {
|
||||
it('should query Firestore for workspaces by ownerId', async () => {
|
||||
const mockDocs = [{ id: '1', data: () => ({ name: 'ws1' }) }];
|
||||
mockCollection.get.mockResolvedValue({ docs: mockDocs });
|
||||
|
||||
const result = await service.listWorkspaces('user1');
|
||||
|
||||
expect(result).toHaveLength(1);
|
||||
expect(result[0].id).toBe('1');
|
||||
expect(result[0].name).toBe('ws1');
|
||||
// Verify the collection name was 'workspaces'
|
||||
const firestoreInstance = vi.mocked(Firestore).mock.results[0].value;
|
||||
expect(firestoreInstance.collection).toHaveBeenCalledWith('workspaces');
|
||||
});
|
||||
});
|
||||
|
||||
describe('createWorkspace', () => {
|
||||
it('should save workspace data to Firestore', async () => {
|
||||
const data = {
|
||||
owner_id: 'u1',
|
||||
name: 'test',
|
||||
instance_name: 'inst',
|
||||
status: 'READY',
|
||||
machine_type: 'e2',
|
||||
zone: 'us1',
|
||||
project_id: 'p1',
|
||||
created_at: 'now',
|
||||
last_connected_at: 'now',
|
||||
};
|
||||
await service.createWorkspace('id1', data);
|
||||
expect(mockCollection.doc).toHaveBeenCalledWith('id1');
|
||||
expect(mockCollection.set).toHaveBeenCalledWith(data);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,116 @@
|
||||
/**
|
||||
* @license
|
||||
* Copyright 2026 Google LLC
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
import { Firestore } from '@google-cloud/firestore';
|
||||
|
||||
export interface WorkspaceData {
|
||||
owner_id: string;
|
||||
name: string;
|
||||
instance_name: string;
|
||||
status: string;
|
||||
machine_type: string;
|
||||
zone: string;
|
||||
project_id: string;
|
||||
created_at: string;
|
||||
last_connected_at: string;
|
||||
team_id?: string;
|
||||
org_id?: string;
|
||||
repo_id?: string;
|
||||
}
|
||||
|
||||
export interface WorkspaceRecord extends WorkspaceData {
|
||||
id: string;
|
||||
}
|
||||
|
||||
export class WorkspaceService {
|
||||
private firestore: Firestore;
|
||||
|
||||
constructor() {
|
||||
this.firestore = new Firestore();
|
||||
}
|
||||
|
||||
getCollection() {
|
||||
return this.firestore.collection('workspaces');
|
||||
}
|
||||
|
||||
async listAllWorkspaces(): Promise<WorkspaceRecord[]> {
|
||||
const snapshot = await this.getCollection().get();
|
||||
|
||||
return snapshot.docs.map((doc) => {
|
||||
// eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion
|
||||
const data = doc.data() as WorkspaceData;
|
||||
return {
|
||||
id: doc.id,
|
||||
...data,
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
async listWorkspacesForUser(ownerId: string, orgId?: string): Promise<WorkspaceRecord[]> {
|
||||
// 1. Get workspaces owned by user
|
||||
const userSnapshot = await this.getCollection()
|
||||
.where('owner_id', '==', ownerId)
|
||||
.get();
|
||||
|
||||
const userWorkspaces = userSnapshot.docs.map(doc => ({
|
||||
id: doc.id,
|
||||
...(doc.data() as WorkspaceData),
|
||||
}));
|
||||
|
||||
// 2. Get workspaces shared with user's org (if orgId provided)
|
||||
if (orgId) {
|
||||
const orgSnapshot = await this.getCollection()
|
||||
.where('org_id', '==', orgId)
|
||||
.get();
|
||||
|
||||
const orgWorkspaces = orgSnapshot.docs.map(doc => ({
|
||||
id: doc.id,
|
||||
...(doc.data() as WorkspaceData),
|
||||
}));
|
||||
|
||||
// Combine and deduplicate by ID
|
||||
const combined = [...userWorkspaces, ...orgWorkspaces];
|
||||
const unique = Array.from(new Map(combined.map(ws => [ws.id, ws])).values());
|
||||
return unique;
|
||||
}
|
||||
|
||||
return userWorkspaces;
|
||||
}
|
||||
|
||||
async listWorkspaces(ownerId: string): Promise<WorkspaceRecord[]> {
|
||||
const snapshot = await this.getCollection()
|
||||
.where('owner_id', '==', ownerId)
|
||||
.get();
|
||||
|
||||
return snapshot.docs.map((doc) => {
|
||||
// eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion
|
||||
const data = doc.data() as WorkspaceData;
|
||||
return {
|
||||
id: doc.id,
|
||||
...data,
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
async getWorkspace(id: string): Promise<WorkspaceRecord | null> {
|
||||
const doc = await this.getCollection().doc(id).get();
|
||||
if (!doc.exists) return null;
|
||||
// eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion
|
||||
return { id: doc.id, ...(doc.data() as WorkspaceData) };
|
||||
}
|
||||
|
||||
async createWorkspace(id: string, data: WorkspaceData): Promise<void> {
|
||||
await this.getCollection().doc(id).set(data);
|
||||
}
|
||||
|
||||
async updateWorkspace(id: string, data: Partial<WorkspaceData>): Promise<void> {
|
||||
await this.getCollection().doc(id).update(data);
|
||||
}
|
||||
|
||||
async deleteWorkspace(id: string): Promise<void> {
|
||||
await this.getCollection().doc(id).delete();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,55 @@
|
||||
# Copyright 2026 Google LLC
|
||||
# SPDX-License-Identifier: Apache-2.0
|
||||
|
||||
resource "google_service_account" "hub_sa" {
|
||||
account_id = "workspace-hub-sa"
|
||||
display_name = "Gemini CLI Workspace Hub Service Account"
|
||||
}
|
||||
|
||||
resource "google_project_iam_member" "compute_admin" {
|
||||
project = var.project_id
|
||||
role = "roles/compute.instanceAdmin.v1"
|
||||
member = "serviceAccount:${google_service_account.hub_sa.email}"
|
||||
}
|
||||
|
||||
resource "google_project_iam_member" "firestore_user" {
|
||||
project = var.project_id
|
||||
role = "roles/datastore.user"
|
||||
member = "serviceAccount:${google_service_account.hub_sa.email}"
|
||||
}
|
||||
|
||||
resource "google_service_account_iam_member" "sa_user" {
|
||||
service_account_id = "projects/${var.project_id}/serviceAccounts/${var.compute_default_sa}"
|
||||
role = "roles/iam.serviceAccountUser"
|
||||
member = "serviceAccount:${google_service_account.hub_sa.email}"
|
||||
}
|
||||
|
||||
resource "google_cloud_run_v2_service" "hub" {
|
||||
name = "workspace-hub"
|
||||
location = var.region
|
||||
ingress = "INGRESS_TRAFFIC_ALL"
|
||||
|
||||
template {
|
||||
service_account = google_service_account.hub_sa.email
|
||||
containers {
|
||||
image = var.hub_image_uri
|
||||
env {
|
||||
name = "GOOGLE_CLOUD_PROJECT"
|
||||
value = var.project_id
|
||||
}
|
||||
resources {
|
||||
limits = {
|
||||
cpu = "1"
|
||||
memory = "512Mi"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
resource "google_firestore_database" "database" {
|
||||
project = var.project_id
|
||||
name = "(default)"
|
||||
location_id = var.region
|
||||
type = "FIRESTORE_NATIVE"
|
||||
}
|
||||
@@ -0,0 +1,23 @@
|
||||
# Copyright 2026 Google LLC
|
||||
# SPDX-License-Identifier: Apache-2.0
|
||||
|
||||
variable "project_id" {
|
||||
description = "The GCP project ID"
|
||||
type = string
|
||||
}
|
||||
|
||||
variable "region" {
|
||||
description = "The GCP region to deploy to"
|
||||
type = string
|
||||
default = "us-west1"
|
||||
}
|
||||
|
||||
variable "hub_image_uri" {
|
||||
description = "The Docker image URI for the Workspace Hub"
|
||||
type = string
|
||||
}
|
||||
|
||||
variable "compute_default_sa" {
|
||||
description = "The Compute Engine default service account email"
|
||||
type = string
|
||||
}
|
||||
@@ -0,0 +1,25 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"strict": true,
|
||||
"esModuleInterop": true,
|
||||
"skipLibCheck": true,
|
||||
"noImplicitAny": true,
|
||||
"noImplicitOverride": true,
|
||||
"noImplicitReturns": true,
|
||||
"noImplicitThis": true,
|
||||
"noUnusedLocals": true,
|
||||
"noUnusedParameters": true,
|
||||
"noFallthroughCasesInSwitch": true,
|
||||
"noPropertyAccessFromIndexSignature": true,
|
||||
"verbatimModuleSyntax": true,
|
||||
"lib": ["ES2023"],
|
||||
"module": "NodeNext",
|
||||
"moduleResolution": "nodenext",
|
||||
"target": "es2022",
|
||||
"types": ["node", "vitest/globals"],
|
||||
"outDir": "./dist",
|
||||
"rootDir": "./src"
|
||||
},
|
||||
"include": ["src/**/*"],
|
||||
"exclude": ["node_modules", "**/*.test.ts"]
|
||||
}
|
||||
@@ -0,0 +1,52 @@
|
||||
# Milestone 2 Sub-plan: Basic CLI Management
|
||||
|
||||
## 1. Objective
|
||||
|
||||
Enable developers to manage their remote workspaces directly from the local
|
||||
`gemini-cli`.
|
||||
|
||||
## 2. Tasks
|
||||
|
||||
### Task 2.1: CLI Command Infrastructure
|
||||
|
||||
Add the base `workspace` command and its sub-commands to the CLI.
|
||||
|
||||
- [x] Save post-mortem of "Command Registration & UI Bypass" failure to global
|
||||
memory.
|
||||
- [x] Investigate why `workspace` command is shadowed by positional `query..` in
|
||||
`yargs`.
|
||||
- [x] Ensure `workspace` commands correctly bypass the interactive UI.
|
||||
- [x] Define the `workspace` command group logic in
|
||||
`packages/core/src/commands/`.
|
||||
- [x] Implement `wsr list`: Fetch and display workspaces from the Hub.
|
||||
- [x] Implement `wsr create <name>`: Call the Hub API to provision a new
|
||||
workspace.
|
||||
- [x] Implement `wsr delete <id>`: Call the Hub API to terminate a workspace.
|
||||
|
||||
### Task 2.2: Hub Configuration & Discovery
|
||||
|
||||
Allow the CLI to know where the Workspace Hub is located.
|
||||
|
||||
- [x] Add `workspaces` configuration section to `packages/core/src/config/`.
|
||||
- [x] Support multiple Hub profiles in `settings.json`.
|
||||
|
||||
### Task 2.3: Basic Hub Client & Auth
|
||||
|
||||
Implement the communication layer between the CLI and the Hub.
|
||||
|
||||
- [x] Create `packages/core/src/services/workspaceHubClient.ts`.
|
||||
- [x] Implement Google OAuth/IAP token injection for API requests.
|
||||
- [x] Handle API errors and provide user-friendly feedback in the CLI.
|
||||
|
||||
## 3. Verification & Success Criteria
|
||||
|
||||
- **List:** `gemini wsr list` shows workspaces currently tracked in Firestore.
|
||||
- **Create:** `gemini wsr create my-task` returns a success message and the new
|
||||
workspace ID.
|
||||
- **Delete:** `gemini wsr delete [ID]` removes the entry from the list.
|
||||
- **Auth:** Commands fail with a clear message if the user is not authenticated
|
||||
or the Hub is unreachable.
|
||||
|
||||
## 4. Next Steps
|
||||
|
||||
- Milestone 3: Connectivity & Persistence.
|
||||
@@ -0,0 +1,32 @@
|
||||
# Milestone 3 Sub-plan: Connectivity & Persistence
|
||||
|
||||
## 1. Objective
|
||||
Enable the "Teleport" experience by allowing users to securely connect to their remote workspaces with persistent terminal sessions.
|
||||
|
||||
## 2. Tasks
|
||||
|
||||
### Task 3.1: SSH Tunneling Logic (IAP)
|
||||
Implement the mechanism to securely tunnel SSH traffic through Google Identity-Aware Proxy.
|
||||
- [ ] Implement `SSHService` in `packages/core/src/services/sshService.ts`.
|
||||
- [ ] Logic to execute `gcloud compute ssh --tunnel-through-iap`.
|
||||
- [ ] Handle SSH key generation and OS Login checks.
|
||||
|
||||
### Task 3.2: Workspace Connect Command
|
||||
Implement the primary `wsr connect <id>` command.
|
||||
- [ ] Add `workspace connect` subcommand to `packages/cli/src/commands/workspace/connect.ts`.
|
||||
- [ ] Logic to fetch instance details (name, zone) from the Hub before connecting.
|
||||
- [ ] Pass necessary SSH flags (Agent Forwarding, Environment variables).
|
||||
|
||||
### Task 3.3: Persistence Integration (shpool)
|
||||
Ensure the remote session survives disconnects.
|
||||
- [ ] Update `remoteCli.ts` to wrap the shell in `shpool attach`.
|
||||
- [ ] Verify `shpool` daemon is running correctly via the container entrypoint.
|
||||
- [ ] Logic to handle terminal resizing across local/remote.
|
||||
|
||||
## 3. Verification & Success Criteria
|
||||
- **Connect:** `gemini wsr connect [ID]` successfully drops the user into a remote shell.
|
||||
- **Persistence:** User can disconnect (Ctrl+C or close terminal), reconnect, and find their previous state intact.
|
||||
- **Security:** Connection only works for the workspace owner and requires a valid `gcloud` session.
|
||||
|
||||
## 4. Next Steps
|
||||
- Implement Task 3.1: Create the `SSHService` in the core package.
|
||||
@@ -0,0 +1,33 @@
|
||||
# Milestone 4 Sub-plan: Secure Sync & Identity
|
||||
|
||||
## 1. Objective
|
||||
Ensure the remote workspace provides a seamless, personalized experience while maintaining strict security for user credentials.
|
||||
|
||||
## 2. Tasks
|
||||
|
||||
### Task 4.1: User Settings Sync (~/.gemini/)
|
||||
Implement the logic to push local configuration to the remote workspace.
|
||||
- [ ] Implement `SyncService` in `packages/core/src/services/syncService.ts`.
|
||||
- [ ] Logic to use `gcloud compute scp` (recursive) for the `~/.gemini` directory.
|
||||
- [ ] Implement exclusion patterns (logs, cache, large assets).
|
||||
- [ ] Integrate sync into the `wsr connect` flow.
|
||||
|
||||
### Task 4.2: GitHub PAT Secure Injection
|
||||
Safely provide GitHub credentials to the remote container without persisting them on disk.
|
||||
- [ ] Implement logic in `SSHService` to push secrets to `/dev/shm/.gh_token` via a side-channel (e.g., small temp script or `scp`).
|
||||
- [ ] Update `entrypoint.sh` to read from `/dev/shm/.gh_token` and perform `gh auth login`.
|
||||
- [ ] Fetch PAT from local keychain before connection.
|
||||
|
||||
### Task 4.3: Identity-Aware Proxy (IAP) Auth Primitives
|
||||
Ensure the Hub API correctly identifies the user.
|
||||
- [ ] Implement `IapMiddleware` in `packages/workspace-manager/src/middleware/iap.ts`.
|
||||
- [ ] Logic to extract and verify the `x-goog-authenticated-user-email` and `id` headers.
|
||||
- [ ] Replace `DEFAULT_OWNER` with real identity in Hub routes.
|
||||
|
||||
## 3. Verification & Success Criteria
|
||||
- **Sync:** After connecting, `gemini help` on the remote side shows the user's local custom commands and aliases.
|
||||
- **GitHub Auth:** Running `gh auth status` on the remote workspace shows the user as authenticated without having manually logged in.
|
||||
- **Tenancy:** A user can only see and delete their own workspaces when the Hub is running in multi-user mode.
|
||||
|
||||
## 4. Next Steps
|
||||
- Implement Task 4.1: Create the `SyncService` for settings synchronization.
|
||||
@@ -0,0 +1,32 @@
|
||||
# Milestone 5 Sub-plan: UI & Advanced Hub Features
|
||||
|
||||
## 1. Objective
|
||||
Provide a polished, interactive dashboard for managing workspaces and enhance the Hub with production-grade management features like TTL-based auto-cleanup and expanded multi-tenancy models.
|
||||
|
||||
## 2. Tasks
|
||||
|
||||
### Task 5.1: Workspaces "Ability" (React UI)
|
||||
Create an interactive dashboard within the `gemini-cli`.
|
||||
- [ ] Create `packages/cli/src/ui/abilities/workspaces/`.
|
||||
- [ ] Implement `WorkspacesView` component using Ink.
|
||||
- [ ] Logic to display a live-updating table of workspaces.
|
||||
- [ ] Implement interactive actions: "Connect", "Start/Stop", "Delete" within the UI.
|
||||
|
||||
### Task 5.2: Hub Auto-Cleanup (TTL)
|
||||
Prevent runaway GCP costs by cleaning up idle workspaces.
|
||||
- [ ] Add `last_connected_at` tracking to `WorkspaceService`.
|
||||
- [ ] Implement a `/cleanup` endpoint in the Hub.
|
||||
- [ ] Logic to identify and delete/stop VMs that have been idle past a configurable TTL.
|
||||
|
||||
### Task 5.3: Expanded Multi-Tenancy (Team/Repo)
|
||||
Enhance the Hub to support shared and automated environments.
|
||||
- [ ] Implement `Team` mode logic where workspaces can be shared within a Google Group.
|
||||
- [ ] Implement `Repo` mode primitives to tie workspaces to specific GitHub PRs.
|
||||
|
||||
## 3. Verification & Success Criteria
|
||||
- **UI:** The user can navigate to the "Workspaces" ability and manage their fleet using keyboard shortcuts or a menu.
|
||||
- **Cleanup:** A workspace not connected to for > TTL is automatically terminated by the Hub.
|
||||
- **Tenancy:** Verified isolation and sharing rules in a simulated multi-user environment.
|
||||
|
||||
## 4. Next Steps
|
||||
- Implement Task 5.1: Scaffold the Workspaces Ability in the CLI.
|
||||
@@ -0,0 +1,52 @@
|
||||
# Phase 1 Sub-plan: The Workspace Core
|
||||
|
||||
## 1. Objective
|
||||
|
||||
Establish the foundational execution environment (Container Image) and the
|
||||
initial management service (Hub API).
|
||||
|
||||
## 2. Tasks
|
||||
|
||||
### Task 1.1: Define and Build Workspace Image
|
||||
|
||||
Create a Dockerfile that provides a complete, persistent development environment
|
||||
for `gemini-cli`.
|
||||
|
||||
- [x] Create `packages/workspace-manager/docker/Dockerfile`.
|
||||
- [x] Include: `node:20-slim`, `git`, `gh`, `rsync`, `tmux`, `shpool`.
|
||||
- [x] Add the pre-built `gemini-cli` binary.
|
||||
- [x] Define `entrypoint.sh` with secret injection and `shpool` daemon startup.
|
||||
- [x] Verify image build locally: `docker build -t gemini-workspace:v1 .`.
|
||||
|
||||
### Task 1.2: Workspace Hub API (v1)
|
||||
|
||||
Implement the core API to manage GCE-based workspaces.
|
||||
|
||||
- [x] Initialize `packages/workspace-manager/`.
|
||||
- [x] Implement Express server for `/workspaces` (List, Create, Delete).
|
||||
- [x] Integrate Firestore to track workspace state (owner, instance_id, status).
|
||||
- [x] Integrate `@google-cloud/compute` for GCE instance lifecycle.
|
||||
- [x] Provision a VM with `Container-on-VM` settings pointing to the
|
||||
`gemini-workspace` image.
|
||||
|
||||
### Task 1.3: Cloud Run Deployment (v1)
|
||||
|
||||
Prepare the Hub for self-service deployment.
|
||||
|
||||
- [x] Create `packages/workspace-manager/terraform/` for basic Hub provisioning.
|
||||
- [x] Provide a `scripts/deploy-hub.sh` using `gcloud` for a zero-install
|
||||
alternative.
|
||||
|
||||
## 3. Verification & Success Criteria
|
||||
|
||||
- **Image:** A container started from the image must have `gemini --version` and
|
||||
`gh --version` available.
|
||||
- **API:** A `POST /workspaces` call must result in a new VM appearing in the
|
||||
specified GCP project with the correct container image.
|
||||
- **State:** Firestore must correctly reflect the VM's `PROVISIONING` and
|
||||
`READY` status.
|
||||
|
||||
## 4. Next Steps
|
||||
|
||||
- Milestone 2: Basic CLI Management (Phase 2). Add `workspace` commands to the
|
||||
CLI.
|
||||
@@ -0,0 +1,64 @@
|
||||
# Gemini CLI Workspaces: High-Level Implementation Plan
|
||||
|
||||
## 1. Objective
|
||||
|
||||
Transform the architectural vision of "Gemini CLI Workspaces" into a
|
||||
production-ready, self-service feature for `gemini-cli`.
|
||||
|
||||
## 2. Milestones & Phases
|
||||
|
||||
### Milestone 1: The Workspace Core (Phase 1)
|
||||
|
||||
Build the foundational container environment and the core management API.
|
||||
|
||||
- [x] Define and build the `Workspace Container Image`.
|
||||
- [x] Deploy a basic `Workspace Hub` (Cloud Run) with GCE provisioning.
|
||||
- [x] Implement simple `/create`, `/list`, `/delete` API endpoints.
|
||||
|
||||
### Milestone 2: Basic CLI Management (Phase 2)
|
||||
|
||||
Enable developers to manage their remote fleet from the local CLI. See
|
||||
[Milestone 2 Sub-plan](./milestone-2-cli-management.md) for details.
|
||||
|
||||
- [x] Add `gemini workspace create/list/delete` commands.
|
||||
- [x] Implement Hub authentication (Google OAuth/IAP).
|
||||
- [x] Add local configuration for Hub discovery (`settings.json`).
|
||||
|
||||
### Milestone 3: Connectivity & Persistence (Phase 3)
|
||||
Enable the "Teleport" experience with session persistence.
|
||||
See [Milestone 3 Sub-plan](./milestone-3-connectivity.md) for details.
|
||||
- [ ] Implement `gemini workspace connect`.
|
||||
|
||||
- [ ] Setup `gcloud compute ssh --tunnel-through-iap` logic in the client.
|
||||
- [ ] Integrate `shpool` into the container entrypoint for session detachment.
|
||||
|
||||
### Milestone 4: Secure Sync & Identity (Phase 4)
|
||||
Make the remote workspace "feel like home" with secure credential forwarding.
|
||||
See [Milestone 4 Sub-plan](./milestone-4-sync-and-identity.md) for details.
|
||||
- [ ] Implement `~/.gemini/` configuration synchronization.
|
||||
|
||||
- [ ] Implement SSH Agent Forwarding (`-A`) in the connectivity logic.
|
||||
- [ ] Implement secure GitHub PAT injection via `/dev/shm`.
|
||||
|
||||
### Milestone 5: UI & Advanced Hub Features (Phase 5)
|
||||
Polish the developer experience and add enterprise-grade Hub capabilities.
|
||||
See [Milestone 5 Sub-plan](./milestone-5-ui-and-advanced.md) for details.
|
||||
- [ ] Implement the "Workspaces Ability" in the CLI (interactive React UI).
|
||||
|
||||
- [ ] Implement multi-tenancy models (User, Team, Repo) in the Hub.
|
||||
- [ ] Add auto-cleanup (TTL) and resource monitoring to the Hub.
|
||||
|
||||
## 3. Implementation Strategy
|
||||
|
||||
- **Surgical Changes:** Each phase will be implemented as a series of small,
|
||||
verified PRs.
|
||||
- **Verification:** Every phase must include integration tests (using mocks for
|
||||
GCP if necessary).
|
||||
- **Documentation:** Architecture docs will be updated as implementation details
|
||||
evolve.
|
||||
|
||||
## 4. Next Steps
|
||||
|
||||
1. **Phase 1 Sub-plan:** Define the exact Dockerfile and initial Hub API
|
||||
schema.
|
||||
2. **Phase 1.1:** Build and push the initial `gemini-workspace:latest` image.
|
||||
@@ -0,0 +1,46 @@
|
||||
#!/bin/bash
|
||||
# Copyright 2026 Google LLC
|
||||
# SPDX-License-Identifier: Apache-2.0
|
||||
|
||||
set -e
|
||||
|
||||
# Configuration
|
||||
PROJECT_ID=$(gcloud config get-value project)
|
||||
if [ -z "$PROJECT_ID" ]; then
|
||||
echo "Error: No GCP project configured. Run 'gcloud config set project [PROJECT_ID]'"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
REGION="us-west1"
|
||||
REPO_NAME="workspaces"
|
||||
IMAGE_NAME="workspace-hub"
|
||||
SERVICE_NAME="workspace-hub"
|
||||
IMAGE_URI="$REGION-docker.pkg.dev/$PROJECT_ID/$REPO_NAME/$IMAGE_NAME"
|
||||
|
||||
echo "Using Project: $PROJECT_ID"
|
||||
|
||||
# 0. Ensure Artifact Registry exists
|
||||
if ! gcloud artifacts repositories describe "$REPO_NAME" --location="$REGION" &>/dev/null; then
|
||||
echo "Creating Artifact Registry repository: $REPO_NAME"
|
||||
gcloud artifacts repositories create "$REPO_NAME" \
|
||||
--repository-format=docker \
|
||||
--location="$REGION" \
|
||||
--description="Gemini CLI Workspaces Repository"
|
||||
fi
|
||||
|
||||
# 1. Build and Push the Hub Image
|
||||
echo "Building and pushing $IMAGE_NAME to Artifact Registry..."
|
||||
gcloud builds submit --tag "$IMAGE_URI" packages/workspace-manager/
|
||||
|
||||
# 2. Deploy to Cloud Run
|
||||
echo "Deploying $SERVICE_NAME to Cloud Run..."
|
||||
gcloud run deploy "$SERVICE_NAME" \
|
||||
--image "$IMAGE_URI" \
|
||||
--platform managed \
|
||||
--region "$REGION" \
|
||||
--no-allow-unauthenticated \
|
||||
--service-account "workspace-hub-sa@$PROJECT_ID.iam.gserviceaccount.com" \
|
||||
--set-env-vars "GOOGLE_CLOUD_PROJECT=$PROJECT_ID"
|
||||
|
||||
echo "Deployment complete!"
|
||||
gcloud run services describe "$SERVICE_NAME" --region "$REGION" --format 'value(status.url)'
|
||||
Reference in New Issue
Block a user