mirror of
https://github.com/google-gemini/gemini-cli.git
synced 2026-07-24 08:40:58 -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
+484
-1
@@ -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",
|
||||
@@ -5434,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",
|
||||
@@ -7375,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",
|
||||
@@ -9104,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",
|
||||
@@ -11905,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"
|
||||
@@ -13255,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",
|
||||
@@ -16576,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",
|
||||
@@ -18047,6 +18094,442 @@
|
||||
"integrity": "sha512-30sjmas1hQ0gVbX68LAWlm/YYlEqUErunPJJKLpEl+xhK0mKn+jyzlCOpsdTwfkZfPy4U6CDkmygBLC3AB8W9Q==",
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"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": {
|
||||
"mime-types": "~2.1.34",
|
||||
"negotiator": "0.6.3"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">= 0.6"
|
||||
}
|
||||
},
|
||||
"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": {
|
||||
"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": ">= 0.8",
|
||||
"npm": "1.2.8000 || >= 1.4.16"
|
||||
}
|
||||
},
|
||||
"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/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": {
|
||||
"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": ">= 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/sindresorhus"
|
||||
}
|
||||
},
|
||||
"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==",
|
||||
"license": "MIT",
|
||||
"bin": {
|
||||
"mime": "cli.js"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=4"
|
||||
}
|
||||
},
|
||||
"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": "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"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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';
|
||||
@@ -131,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', {
|
||||
@@ -776,6 +778,7 @@ export async function loadCliConfig(
|
||||
toolCallCommand: settings.tools?.callCommand,
|
||||
mcpServerCommand,
|
||||
mcpServers,
|
||||
workspaces: settings.workspaces,
|
||||
mcpEnablementCallbacks,
|
||||
mcpEnabled,
|
||||
extensionsEnabled,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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 ===
|
||||
|
||||
@@ -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;
|
||||
@@ -693,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[];
|
||||
@@ -903,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;
|
||||
@@ -1999,6 +2012,10 @@ export class Config implements McpContext, AgentLoopContext {
|
||||
return this.mcpServers;
|
||||
}
|
||||
|
||||
getWorkspaces(): WorkspaceConfig | undefined {
|
||||
return this.workspaces;
|
||||
}
|
||||
|
||||
getMcpEnabled(): boolean {
|
||||
return this.mcpEnabled;
|
||||
}
|
||||
|
||||
@@ -725,6 +725,26 @@ describe('createContentGeneratorConfig', () => {
|
||||
expect(config.apiKey).toBeUndefined();
|
||||
expect(config.vertexai).toBeUndefined();
|
||||
});
|
||||
it('should configure for GATEWAY using dummy placeholder if GEMINI_API_KEY is set', async () => {
|
||||
vi.stubEnv('GEMINI_API_KEY', 'env-gemini-key');
|
||||
const config = await createContentGeneratorConfig(
|
||||
mockConfig,
|
||||
AuthType.GATEWAY,
|
||||
);
|
||||
expect(config.apiKey).toBe('gateway-placeholder-key');
|
||||
expect(config.vertexai).toBe(false);
|
||||
});
|
||||
|
||||
it('should configure for GATEWAY using dummy placeholder if GEMINI_API_KEY is not set', async () => {
|
||||
vi.stubEnv('GEMINI_API_KEY', '');
|
||||
vi.mocked(loadApiKey).mockResolvedValue(null);
|
||||
const config = await createContentGeneratorConfig(
|
||||
mockConfig,
|
||||
AuthType.GATEWAY,
|
||||
);
|
||||
expect(config.apiKey).toBe('gateway-placeholder-key');
|
||||
expect(config.vertexai).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('validateBaseUrl', () => {
|
||||
|
||||
@@ -150,6 +150,13 @@ export async function createContentGeneratorConfig(
|
||||
return contentGeneratorConfig;
|
||||
}
|
||||
|
||||
if (authType === AuthType.GATEWAY) {
|
||||
contentGeneratorConfig.apiKey = apiKey || 'gateway-placeholder-key';
|
||||
contentGeneratorConfig.vertexai = false;
|
||||
|
||||
return contentGeneratorConfig;
|
||||
}
|
||||
|
||||
return contentGeneratorConfig;
|
||||
}
|
||||
|
||||
|
||||
@@ -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}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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