feat(cli): coworkers set up their own cloud environment from the CLI

Merged
SA
1mo ago
+12166-128

Summary

People were starting cloud workspaces on Satya's environment, which carries his personal tokens. This PR makes the setup self-serve from the CLI. It also removes the naming rules and name-based defaults that made using the wrong environment easy.

  1. CLI. Environment commands create, save, replace, update, archive and repos. A new superset connections group for the credentials a person's cloud workspaces run with: agent sign-in, and GitHub as yourself.
  2. API. environment.promote can replace an environment's golden in place. Saving as new copies the source's variables. Promote takes a scope. The tRPC maxDuration goes 60 → 300 s so a promote is not killed partway through.
  3. Ids, not names. Environments are referenced by id everywhere (CLI, MCP, shared resolver), and the (organization_id, name) unique index is dropped (migration 0128).
  4. Release. The release rebuilds the internal environment by id, not by name. internal-setup.sh becomes satya-setup.sh.
  5. Setup. A cloud workspace runs bun install on its first boot.

Why

  • Workspaces were landing on Satya's environment. ws create and the MCP workspaces create tool fell back to "the first environment with repositories". That is alphabetical, and in our org it was "Superset", Satya's environment.
  • Nobody had another path. Every other route to your own environment needed the desktop.
  • The name rules got in the way. Names were unique forever, archived ones included. Matching was case-insensitive, but uniqueness was case-sensitive.
  • Renaming broke the release. The release looked the row up by name, so renaming it would have created a second environment.

What / How

CLI (internal audience, like environments)

CommandDoes
environments reposRepos the org's GitHub App can see; the values --repo takes.
environments create --name --repo … [--hooks-repo] [--scope] [--region]Image-based environment from owner/name repos.
environments save --workspace --name [--scope]Always a new environment from a ready box.
environments replace <env-id> --workspace [--scope] [--yes]Swaps that environment's golden for the box's, keeping id, name and variables.
environments update <env-id> [--name] [--scope]Rename, or change who can use it.
environments archive <env-id> [--yes]Archive; deletes the golden.
connections agents set|list|removeAgent sign-in (agentCredential.*). The value comes from a hidden prompt or stdin, never a flag.
connections github connect|status|disconnectOpens the authorize URL and waits until connected (githubUser.*).
  • Confirmation: replace and archive make you type the environment's name in a terminal, and refuse without --yes when there isn't one. That covers agents.
  • Timeouts: save and replace treat a 502/504 as "the server is still going". They then poll environment.list for the result. save looks for a row that was not there before the call, because names are no longer unique.

API: environment.promote

  • New or replace. It takes either name (new environment) or environmentId (replace in place).
  • Saving as new.
    • The source environment's variables are copied: decrypted and re-encrypted, since each value is bound to its row.
    • The new row and its repos are written in one transaction.
  • Replacing in place.
    • It updates the row's source, region, bundle and hooks repo, and rewrites its repos, in one transaction.
    • The new golden gets its own name, because the row forks from the old one until the update lands.
    • The old golden is deleted after the transaction.
  • Scope. scope is optional. Setting personal records the caller as creator when the row has none (the release-made row). It refuses when someone else created it. This check runs before the snapshot, so a refusal can't leave an orphaned golden.
  • Desktop. The desktop's { cloudWorkspaceId, name } call still works unchanged.

Ids only

  • Resolution. selectCloudEnvironment and the CLI's resolveEnvironment match by exact id.
  • Defaults. With nothing requested, an environment is picked only when exactly one qualifies. Otherwise the error lists id (name) pairs.

Release

  • Lookup by id. SUPERSET_INTERNAL_ENVIRONMENT_ID (set in sandbox.yml to prod's row) selects the environment to update. Name, scope and creator are never touched, so a rename or making it personal survives releases.
  • No id set. Without the variable (a fresh dev database) the release creates one and logs its id.
  • Default seed. The shared Default seed used ON CONFLICT (organization_id, name). It now finds its row and then updates it, or inserts one.

Migration 0128

ALTER TABLE "environments" DROP CONSTRAINT "environments_organization_id_name_unique";, generated by drizzle-kit.

  • Production size: environments is 96 kB with 9 rows (read-only query, 2026-09-28).
  • No type change, no FK: the lock is ACCESS EXCLUSIVE on environments only, briefly.
  • Under load: rehearsed on this workspace's Neon branch, a production child created today. pgbench ran 8 clients reading and inserting into environments while PGOPTIONS="-c lock_timeout=5s -c statement_timeout=60s" drizzle-kit migrate ran. It applied in 1 s; 5,700 transactions, 0 errors. That was one pass, since the migration can't be reapplied on the same branch.
  • Seed afterwards: ran seed.ts twice on the migrated branch. There is one Default row, updated both times.

Manual QA

Verified against production with Satya's CLI login, on personal test environments that were archived afterwards:

  • environments repos lists the org's 22 repos.
  • connections agents list and connections github status read correctly.
  • create → update --name → archive work, and the repo and hooks repo are stored.
  • create with an unknown repo points to environments repos.
  • connections agents set codex with a bad key: the server refuses it and nothing is stored.
  • archive without --yes and without a terminal refuses, and the environment stays. With --yes it archives. In a pty, typing the right name archives. A wrong name followed by Ctrl-C prints "Cancelled" and the environment stays.
  • replace without --yes and without a terminal refuses before any server call. Satya's golden is unchanged.
  • --environment Superset (a name) is rejected; the error lists the ids.

Not yet run for real: the new promote paths (save as new with variables, replace in place, scope). These need this API deployed, because a box can't reach a local API to become ready. The plan after deploy: create a box, save, fork it, replace, then confirm the old golden is gone and the variables came across.

Testing

  • bun run typecheck in cli, trpc, shared, mcp, sandbox, db and desktop.
  • bun test: cli 281, trpc 396, shared 1147, mcp 11. New tests are promoteWorkspace.test.ts (timeout polling, same-name environments, refusals) and cloud-environments.test.ts (ids only, single-choice default).
  • biome check is clean.

Risks / Rollout

  • Breaking change for names. Scripts and agent prompts that pass an environment name, or rely on ws create picking one, now fail with a list of ids. Only the internal audience is affected.
  • Merging starts a production release, since the PR touches packages/sandbox/**. The release updates 1dcfb8ac… by id, so there is no rename race. After it runs, Satya's environment becomes "Satya's Superset", personal, via superset environments update.
  • Rollback: revert the PR. Migration 0128 is forward-only. Putting the unique index back would fail once two environments share a name, and nothing needs it any more.

Follow-ups

  • Inside a cloud box, commands marked sandbox: false say "Unknown command". A "run this from your machine" message needs a framework change.
  • --env-file sets keys one by one, so a failure partway leaves a partial set.
  • There's no MCP tool for listing environments. An agent finds ids from the error message.

Summary by cubic

Lets coworkers create and manage their own cloud environments from the CLI, instead of workspaces falling back to Satya's. Environments are now addressed by id everywhere, not name, and the name-unique index is dropped.

New Features

  • New environments create|save|replace|update|archive|repos commands, plus connections agents and connections github for the credentials a person's cloud workspaces run with.
  • environment.promote saves a workspace as a new environment (re-encrypting its variables onto the new row, read before the snapshot) or swaps an existing one's golden in place; a replace/archive needs the name typed in a terminal, or --yes.
  • save and replace poll for the finished row on gateway timeouts, since names are no longer unique.
  • Promote cleans up on failure: an unused golden is deleted if the DB write fails, and a failed delete of a replaced golden is logged. replace won't update an environment archived underneath it (its new golden is then deleted), and requires the golden, scope and creator it loaded, so a concurrent change becomes a conflict. archive deletes the golden the row points at when archived, and connections github connect reports an existing connection instead of authorizing again.
  • The release rebuilds the internal environment by id (set in sandbox.yml), so a rename no longer forks a second one; a cloud workspace runs bun install on first boot.

Migration

  • Migration 0128 drops environments_organization_id_name_unique; names are labels, and CLI and MCP now require environment ids (errors list id (name) pairs).
  • Merging starts a production release because it touches packages/sandbox; rollback needs a revert, the migration is forward-only.

Summary by CodeRabbit

  • New Features

    • Added CLI commands to connect and disconnect GitHub, check connection status, and list, sign in, or sign out agents for cloud workspaces.
    • Added commands to create, update, archive, replace, save, and list cloud environments and connected repositories.
    • Cloud workspaces can be saved to new or existing environments, with optional scope settings. Saving restarts the workspace.
  • Changes

    • Environment selection now uses IDs; names are no longer accepted, and ambiguous selections require an explicit ID.
    • Environments in the same organization can now share a name.
    • Environment archive and replacement operations require confirmation unless --yes is supplied.