From 7fba07ecebd2e5be634e08c93a07ef73edb260f0 Mon Sep 17 00:00:00 2001 From: Mish Ushakov <10400064+mishushakov@users.noreply.github.com> Date: Fri, 6 Nov 2026 18:45:38 +0100 Subject: [PATCH 01/10] feat(cli): pick projects from workspace-api, route to each project's plane Login and `auth configure` read the user's workspaces and projects from workspace-api `GET /v1/me` instead of the API's `GET /teams`, which minted an API key in every project on each call (SDK-400). - workspace-api is only deployed at workspace-api.e2b.dev: E2B_DOMAIN e2b.app / e2b.dev map there, others to workspace-api., and E2B_WORKSPACE_API_URL overrides it. Responses are validated with zod. - Login selects the active project in the personal workspace (the rule workspace-api uses for is_default). SSO-only users get their only active project, or a prompt (the first one without a terminal). A 401 means the user isn't set up yet: POST /v1/users/bootstrap, then retry. Blocked projects print a warning. - Projects live on different control planes; api.e2b.app answers 401 for any project outside foxtrot. The config now stores the project's control_plane_domain and ensureAuth() returns it as the SDK `domain`. Configs from earlier versions get it looked up once and saved. - E2B_PROJECT_ID / --project resolve through /v1/me (fetched once per process) by ID, prj_ public ID, slug or unambiguous name. - Config updates outside a token refresh go through updateUserConfig(), which holds the refresh lock. Co-Authored-By: Claude Opus 5.5 (1M context) --- .changeset/cli-workspace-api-projects.md | 10 + packages/cli/src/api.ts | 70 +++++- packages/cli/src/commands/auth/configure.ts | 88 +++----- packages/cli/src/commands/auth/login.ts | 53 +++-- packages/cli/src/commands/template/create.ts | 4 +- packages/cli/src/user.ts | 3 + packages/cli/src/utils/format.ts | 13 +- packages/cli/src/utils/token-refresh.ts | 21 ++ packages/cli/src/workspaces.ts | 218 +++++++++++++++++++ packages/cli/tests/auth.test.ts | 111 +++++++++- packages/cli/tests/workspaces.test.ts | 184 ++++++++++++++++ 11 files changed, 668 insertions(+), 107 deletions(-) create mode 100644 .changeset/cli-workspace-api-projects.md create mode 100644 packages/cli/src/workspaces.ts create mode 100644 packages/cli/tests/workspaces.test.ts diff --git a/.changeset/cli-workspace-api-projects.md b/.changeset/cli-workspace-api-projects.md new file mode 100644 index 0000000000..6c972369d4 --- /dev/null +++ b/.changeset/cli-workspace-api-projects.md @@ -0,0 +1,10 @@ +--- +'@e2b/cli': minor +--- + +`e2b auth login` and `e2b auth configure` list your workspaces and projects from workspace-api instead of the API's `GET /teams`, which created a new API key in every project on each call. + +- Login selects the project in your personal workspace. Without one (SSO-only accounts), it selects your only active project or asks which one to use. Blocked projects show a warning. +- Requests go to the selected project's own control plane, so projects outside the default region work. +- `E2B_PROJECT_ID` (and `--project` where available) accepts a project's ID, `prj_` public ID, slug or name. +- workspace-api is reached at `workspace-api.e2b.dev` (for `E2B_DOMAIN` `e2b.app` and `e2b.dev`), or at `E2B_WORKSPACE_API_URL`. diff --git a/packages/cli/src/api.ts b/packages/cli/src/api.ts index dc6d9ec69d..382f0eccb1 100644 --- a/packages/cli/src/api.ts +++ b/packages/cli/src/api.ts @@ -4,15 +4,13 @@ import * as e2b from 'e2b' import * as packageJSON from '../package.json' import { getUserConfig, UserConfig } from './user' import { asBold, asPrimary } from './utils/format' -import { ensureValidAccessToken } from './utils/token-refresh' +import { ensureValidAccessToken, updateUserConfig } from './utils/token-refresh' +import { fetchMe, findProject, Me } from './workspaces' // Must run before any ConnectionConfig is constructed (including the // module-level one below) — configs read the integration at construction time. e2b.ConnectionConfig.setIntegration(`e2b-cli/${packageJSON.version}`) -export type Teams = - e2b.paths['/teams']['get']['responses'][200]['content']['application/json'] - export const projectId = process.env.E2B_PROJECT_ID || process.env.E2B_TEAM_ID const authErrorBox = () => { @@ -41,16 +39,57 @@ Visit ${asPrimary('https://e2b.dev/dashboard?tab=keys')} to get the API key.` */ export type Auth = | Required> - | Required> + | Required> + +let me: Promise | undefined + +/** The logged-in user's workspaces and projects, fetched once per process. */ +export function getMe(): Promise { + me ??= (async () => { + const { accessToken } = await ensureValidAccessToken() + const result = await fetchMe(accessToken) + if (!result) { + throw new Error( + 'Your session was not accepted. Run `e2b auth login` again.' + ) + } + return result + })() + return me +} + +/** + * The project requests go to, and its control plane: `cliProjectId`, then + * `E2B_PROJECT_ID`, then the project selected with `e2b auth configure`. + */ +async function resolveProject( + config: UserConfig, + cliProjectId?: string +): Promise<{ id: string; domain: string }> { + const ref = cliProjectId || projectId + if (ref && ref !== config.projectId) { + const { project } = findProject(await getMe(), ref) + return { id: project.id, domain: project.control_plane_domain } + } + if (config.projectDomain) { + return { id: config.projectId, domain: config.projectDomain } + } + + // Selected before the CLI stored control-plane domains: look it up once. + const { project } = findProject(await getMe(), config.projectId) + await updateUserConfig({ projectDomain: project.control_plane_domain }) + return { id: project.id, domain: project.control_plane_domain } +} /** * Resolve credentials for an E2B API call. * * `E2B_API_KEY` wins, for CI/CD and other non-interactive use. Otherwise the * logged-in user's OAuth access token is used, refreshed first when it is - * about to expire, with `X-Team-ID` selecting the project. Call this right - * before each request (not once per command) so long-running commands keep - * working after the hour-long access token expires. + * about to expire, with `X-Team-ID` selecting the project and requests sent + * to that project's control plane (`domain`). Call this right before each + * request (not once per command) so long-running commands keep working after + * the hour-long access token expires. */ export async function ensureAuth(cliProjectId?: string): Promise { const apiKey = process.env.E2B_API_KEY @@ -62,11 +101,24 @@ export async function ensureAuth(cliProjectId?: string): Promise { } const { accessToken, config } = await ensureValidAccessToken() + let project: { id: string; domain: string } + try { + project = await resolveProject(config, cliProjectId) + } catch (err) { + console.error( + `${err instanceof Error ? err.message : err} Run ${asBold( + 'e2b auth configure' + )} to choose a project.` + ) + process.exit(1) + } + return { apiHeaders: { Authorization: `Bearer ${accessToken}`, - 'X-Team-ID': resolveProjectId(cliProjectId) ?? config.projectId, + 'X-Team-ID': project.id, }, + domain: project.domain, } } diff --git a/packages/cli/src/commands/auth/configure.ts b/packages/cli/src/commands/auth/configure.ts index b627212c9d..2075c9b91a 100644 --- a/packages/cli/src/commands/auth/configure.ts +++ b/packages/cli/src/commands/auth/configure.ts @@ -1,70 +1,48 @@ import * as commander from '@commander-js/extra-typings' -import * as chalk from 'chalk' -import * as e2b from 'e2b' -import { - USER_CONFIG_PATH, - getConfigRefreshTimestamp, - getUserConfig, - writeUserConfig, -} from 'src/user' -import { ensureUserConfig, Teams } from 'src/api' -import { ensureValidAccessToken } from 'src/utils/token-refresh' -import { asBold, asFormattedTeam } from '../../utils/format' -import { handleE2BRequestError } from '../../utils/errors' +import { getMe } from 'src/api' +import { getConfigRefreshTimestamp, getUserConfig } from 'src/user' +import { updateUserConfig } from 'src/utils/token-refresh' +import { blockedWarning, pickProject } from 'src/workspaces' +import { asBold, asFormattedError } from '../../utils/format' export const configureCommand = new commander.Command('configure') .description('configure user') .action(async () => { - const inquirer = await import('inquirer') - console.log('Configuring user...\n') // A file that exists but fails validation counts as signed out too — // getUserConfig returns null for it (and prints the deprecation message). - if (!getUserConfig()) { + const userConfig = getUserConfig() + if (!userConfig) { console.log('No user config found, run `e2b auth login` to log in first.') return } - const { accessToken, config: userConfig } = await ensureValidAccessToken() - - const config = new e2b.ConnectionConfig({ - apiHeaders: { Authorization: `Bearer ${accessToken}` }, - }) - const authClient = new e2b.ApiClient(config, { requireApiKey: false }) - - const res = await authClient.api.GET('/teams', { - signal: config.getSignal(), - }) - - handleE2BRequestError(res, 'Error getting projects') - const teams = res.data as Teams - - const team = ( - await inquirer.default.prompt([ - { - name: 'project', - message: chalk.default.underline('Select project'), - type: 'list', - pageSize: 50, - choices: teams.map((team) => ({ - name: asFormattedTeam(team, userConfig.projectId), - value: team, - })), - }, - ]) - )['project'] - - // Re-read the config: another e2b process may have refreshed (and so - // rotated) the tokens while the prompt was open, and writing back the - // copy read before it would sign the user out on the next refresh. - writeUserConfig(USER_CONFIG_PATH, { - ...ensureUserConfig(), - projectName: team.name, - projectId: team.teamID, - last_refresh: getConfigRefreshTimestamp(), - }) - - console.log(`Project ${asBold(team.name)} (${team.teamID}) selected.\n`) + try { + const selected = await pickProject(await getMe(), { + message: 'Select project', + selectedId: userConfig.projectId, + }) + const warning = blockedWarning(selected) + if (warning) console.warn(warning) + + // Under the refresh lock: another e2b process may refresh (and so + // rotate) the tokens while the prompt is open. + await updateUserConfig({ + projectName: selected.project.name, + projectId: selected.project.id, + projectDomain: selected.project.control_plane_domain, + last_refresh: getConfigRefreshTimestamp(), + }) + + console.log( + `Project ${asBold(selected.project.name)} (${selected.project.public_id}) selected.\n` + ) + } catch (err) { + console.error( + asFormattedError(err instanceof Error ? err.message : String(err)) + ) + process.exit(1) + } }) diff --git a/packages/cli/src/commands/auth/login.ts b/packages/cli/src/commands/auth/login.ts index 8935376c5b..1395d0eb29 100644 --- a/packages/cli/src/commands/auth/login.ts +++ b/packages/cli/src/commands/auth/login.ts @@ -1,7 +1,6 @@ import * as listen from 'async-listen' import * as commander from '@commander-js/extra-typings' import * as http from 'http' -import * as e2b from 'e2b' import * as pkg from '../../../package.json' import { @@ -14,8 +13,13 @@ import { } from 'src/user' import { asBold, asFormattedConfig, asFormattedError } from 'src/utils/format' import { openUrlInBrowser } from 'src/utils/openBrowser' -import { connectionConfig, Teams } from 'src/api' -import { handleE2BRequestError } from '../../utils/errors' +import { + blockedWarning, + bootstrapUser, + fetchMe, + personalProject, + pickProject, +} from 'src/workspaces' export const loginCommand = new commander.Command('login') .description('log in to CLI') @@ -44,25 +48,35 @@ export const loginCommand = new commander.Command('login') const accessToken = signInResponse.accessToken - const signal = connectionConfig.getSignal() - const config = new e2b.ConnectionConfig({ - apiHeaders: { Authorization: `Bearer ${accessToken}` }, - }) - const client = new e2b.ApiClient(config, { requireApiKey: false }) - const res = await client.api.GET('/teams', { - signal, - }) - - handleE2BRequestError(res, 'Error getting projects') - const teams = res.data as Teams + // A user who signed up but never opened the dashboard isn't set up in + // workspace-api yet (401); bootstrapping creates the account. + let me = await fetchMe(accessToken) + if (!me) { + await bootstrapUser(accessToken) + me = await fetchMe(accessToken) + } + if (!me) { + console.error( + asFormattedError( + 'Your account could not be set up, please contact support' + ) + ) + process.exit(1) + } - const defaultTeam = teams.find((team) => team.isDefault) - if (!defaultTeam) { + let selected + try { + selected = + personalProject(me) ?? + (await pickProject(me, { message: 'Select the project to use' })) + } catch (err) { console.error( - asFormattedError('No default project found, please contact support') + asFormattedError(err instanceof Error ? err.message : String(err)) ) process.exit(1) } + const warning = blockedWarning(selected) + if (warning) console.warn(warning) userConfig = { version: 2, @@ -79,8 +93,9 @@ export const loginCommand = new commander.Command('login') refresh_token: signInResponse.refreshToken, }, last_refresh: getConfigRefreshTimestamp(), - projectName: defaultTeam.name, - projectId: defaultTeam.teamID, + projectName: selected.project.name, + projectId: selected.project.id, + projectDomain: selected.project.control_plane_domain, } writeUserConfig(USER_CONFIG_PATH, userConfig) diff --git a/packages/cli/src/commands/template/create.ts b/packages/cli/src/commands/template/create.ts index c96d24a844..da74ec7cb1 100644 --- a/packages/cli/src/commands/template/create.ts +++ b/packages/cli/src/commands/template/create.ts @@ -1,7 +1,7 @@ import * as boxen from 'boxen' import * as commander from '@commander-js/extra-typings' import { defaultBuildLogger, Template, TemplateClass } from 'e2b' -import { connectionConfig, ensureAuth } from 'src/api' +import { ensureAuth } from 'src/api' import { defaultDockerfileName, fallbackDockerfileName, @@ -109,7 +109,6 @@ export const createCommand = new commander.Command('create') // Prepare API credentials for SDK const auth = await ensureAuth() - const domain = connectionConfig.domain // Build the template using SDK try { @@ -120,7 +119,6 @@ export const createCommand = new commander.Command('create') minFreeDiskMb, skipCache: opts.cache === false, ...auth, - domain: domain, onBuildLogs: defaultBuildLogger(), }) } catch (error) { diff --git a/packages/cli/src/user.ts b/packages/cli/src/user.ts index 4c768ef1a8..f50a62dc4a 100644 --- a/packages/cli/src/user.ts +++ b/packages/cli/src/user.ts @@ -24,6 +24,9 @@ const userConfigSchema = z.object({ ...authFields, projectName: z.string(), projectId: z.string(), + // The selected project's control plane (its API is `https://api.`). + // Absent in configs written before projects came from workspace-api. + projectDomain: z.string().optional(), }) export type UserConfig = z.infer diff --git a/packages/cli/src/utils/format.ts b/packages/cli/src/utils/format.ts index 91883e828b..6ee73ca11a 100644 --- a/packages/cli/src/utils/format.ts +++ b/packages/cli/src/utils/format.ts @@ -17,17 +17,6 @@ export function asFormattedConfig(config: UserConfig) { return `You are logged in as ${email},\nSelected project: ${project} (${projectId})` } -export function asFormattedTeam( - team: e2b.components['schemas']['Team'], - selected: string -) { - const name = asBold(team.name) - const id = asBold(team.teamID) - const isSelected = - team.teamID == selected ? asPrimary(' (currently selected project)') : '' - return `${name} (${id})${isSelected}` -} - export type SandboxTemplateRef = Pick< e2b.components['schemas']['Template'], 'templateID' @@ -44,7 +33,7 @@ export function asFormattedSandboxTemplate(template: SandboxTemplateRef) { return `${id}${name}`.trim() } -function asRed(text: string) { +export function asRed(text: string) { return chalk.default.redBright(text) } diff --git a/packages/cli/src/utils/token-refresh.ts b/packages/cli/src/utils/token-refresh.ts index bb8e6314a8..cc2d57261d 100644 --- a/packages/cli/src/utils/token-refresh.ts +++ b/packages/cli/src/utils/token-refresh.ts @@ -153,3 +153,24 @@ export async function ensureValidAccessToken(): Promise<{ CONFIG_LOCK ) } + +/** + * Apply `patch` to the stored config under the refresh lock, so it can't + * revert tokens another e2b process refreshes at the same time. + */ +export async function updateUserConfig( + patch: Partial +): Promise { + const release = await acquireRefreshLock() + try { + const config = getUserConfig() + if (!config) { + throw new Error('No user config found, run `e2b auth login` first.') + } + const updated = { ...config, ...patch } + writeUserConfig(USER_CONFIG_PATH, updated) + return updated + } finally { + release() + } +} diff --git a/packages/cli/src/workspaces.ts b/packages/cli/src/workspaces.ts new file mode 100644 index 0000000000..49a30f6c1b --- /dev/null +++ b/packages/cli/src/workspaces.ts @@ -0,0 +1,218 @@ +import { select } from '@inquirer/prompts' +import { z } from 'zod' + +import * as packageJSON from '../package.json' +import { asBold, asPrimary, asRed } from './utils/format' + +// Only the fields the CLI uses. `status` and `kind` stay plain strings so a +// value added server-side doesn't break the CLI. +const projectSchema = z.object({ + id: z.string(), + public_id: z.string(), + workspace_id: z.string(), + name: z.string(), + slug: z.string(), + region: z.string(), + // The project's control plane: its API is `https://api.`. + control_plane_domain: z.string(), + status: z.string(), + blocked: z.boolean(), + block_reasons: z.array(z.string()), + capabilities: z.array(z.string()), +}) + +const workspaceSchema = z.object({ + id: z.string(), + public_id: z.string(), + name: z.string(), + kind: z.string(), + blocked: z.boolean(), + block_reasons: z.array(z.string()), + capabilities: z.array(z.string()), + projects: z.array(projectSchema), +}) + +const meSchema = z.object({ workspaces: z.array(workspaceSchema) }) + +export type Project = z.infer +export type Workspace = z.infer +export type Me = z.infer + +/** A project together with the workspace it is listed under. */ +export type ProjectEntry = { project: Project; workspace: Workspace } + +/** + * Base URL of workspace-api, which owns workspaces and projects. + * + * It is only deployed at workspace-api.e2b.dev, also for the e2b.app API + * domain the CLI defaults to. `E2B_WORKSPACE_API_URL` overrides it. + */ +export function workspaceApiUrl(): string { + const override = process.env.E2B_WORKSPACE_API_URL + if (override) return override.replace(/\/+$/, '') + + const domain = process.env.E2B_DOMAIN || 'e2b.dev' + if (domain === 'e2b.app' || domain === 'e2b.dev') { + return 'https://workspace-api.e2b.dev' + } + return `https://workspace-api.${domain}` +} + +async function workspaceApi( + method: 'GET' | 'POST', + path: string, + accessToken: string +): Promise { + return fetch(`${workspaceApiUrl()}${path}`, { + method, + headers: { + Authorization: `Bearer ${accessToken}`, + 'User-Agent': `e2b-cli/${packageJSON.version}`, + }, + signal: AbortSignal.timeout(30_000), + }) +} + +async function requestError(res: Response, action: string) { + const body = await res.text().catch(() => '') + return new Error( + `Failed to ${action} (${res.status} from ${workspaceApiUrl()})${ + body ? `: ${body}` : '' + }` + ) +} + +/** + * The caller's reachable workspaces and projects (`GET /v1/me`). + * + * Resolves to `undefined` on 401, which also means "not set up in + * workspace-api yet" for a user who never opened the dashboard. + */ +export async function fetchMe(accessToken: string): Promise { + const res = await workspaceApi('GET', '/v1/me', accessToken) + if (res.status === 401) return undefined + if (!res.ok) throw await requestError(res, 'list your workspaces') + + const parsed = meSchema.safeParse(await res.json()) + if (!parsed.success) { + throw new Error( + `Unexpected response from ${workspaceApiUrl()}/v1/me:\n${z.prettifyError(parsed.error)}` + ) + } + return parsed.data +} + +/** + * Create the account (a personal workspace with a first project, or SSO + * workspace membership) for a signed-in user workspace-api doesn't know yet. + * Idempotent: an existing account is returned unchanged. + */ +export async function bootstrapUser(accessToken: string): Promise { + const res = await workspaceApi('POST', '/v1/users/bootstrap', accessToken) + if (!res.ok) throw await requestError(res, 'set up your account') +} + +/** Every project in `me`, with its workspace. */ +export function allProjects(me: Me): ProjectEntry[] { + return me.workspaces.flatMap((workspace) => + workspace.projects.map((project) => ({ project, workspace })) + ) +} + +/** Projects that can be used: provisioned and not being deleted. */ +export function activeProjects(me: Me): ProjectEntry[] { + return allProjects(me).filter(({ project }) => project.status === 'active') +} + +/** + * The project selected when none is chosen: the active project in the + * personal workspace, the same rule workspace-api uses for `is_default` on the + * control planes. SSO-only users have no personal workspace, so none. + */ +export function personalProject(me: Me): ProjectEntry | undefined { + return activeProjects(me).find( + ({ workspace }) => workspace.kind === 'personal' + ) +} + +/** + * Find a project by ID (UUID or `prj_` public ID), slug or name. Names are + * matched case-insensitively and must be unambiguous. + */ +export function findProject(me: Me, ref: string): ProjectEntry { + const entries = allProjects(me) + const exact = entries.find( + ({ project }) => + project.id === ref || project.public_id === ref || project.slug === ref + ) + if (exact) return exact + + const byName = entries.filter( + ({ project }) => project.name.toLowerCase() === ref.toLowerCase() + ) + if (byName.length === 1) return byName[0] + if (byName.length > 1) { + throw new Error( + `More than one project is named "${ref}": ${byName + .map( + ({ project, workspace }) => `${project.public_id} (${workspace.name})` + ) + .join(', ')}. Pass the project ID instead.` + ) + } + throw new Error( + `Project "${ref}" not found among the projects you can access.` + ) +} + +/** A warning to print for a blocked project, or `undefined`. */ +export function blockedWarning({ project }: ProjectEntry): string | undefined { + if (!project.blocked) return undefined + const reasons = project.block_reasons.length + ? ` (${project.block_reasons.join(', ')})` + : '' + return `Project ${project.name} is blocked${reasons}, so requests to it may be refused. Check the project in the dashboard.` +} + +/** `name — workspace (region)`, flagged when blocked or currently selected. */ +export function projectLabel( + { project, workspace }: ProjectEntry, + selectedId?: string +): string { + const flags = [ + project.blocked ? asRed('blocked') : undefined, + project.id === selectedId ? asPrimary('current') : undefined, + ].filter(Boolean) + return `${asBold(project.name)} — ${workspace.name} (${project.region})${ + flags.length ? ` [${flags.join(', ')}]` : '' + }` +} + +/** + * Ask which active project to use. Without a terminal to prompt in, or with + * a single candidate, the first active project is returned. + */ +export async function pickProject( + me: Me, + { message, selectedId }: { message: string; selectedId?: string } +): Promise { + const entries = activeProjects(me) + if (entries.length === 0) { + throw new Error( + `You don't have any active projects. Create one at ${asPrimary( + 'https://e2b.dev/dashboard' + )}.` + ) + } + if (entries.length === 1 || !process.stdin.isTTY) return entries[0] + + return select({ + message, + pageSize: 20, + choices: entries.map((entry) => ({ + name: projectLabel(entry, selectedId), + value: entry, + })), + default: entries.find(({ project }) => project.id === selectedId), + }) +} diff --git a/packages/cli/tests/auth.test.ts b/packages/cli/tests/auth.test.ts index 709506f0a4..89d4c852fe 100644 --- a/packages/cli/tests/auth.test.ts +++ b/packages/cli/tests/auth.test.ts @@ -27,7 +27,11 @@ function configPath() { return path.join(mockHome, '.e2b', 'config.json') } -function writeConfig(accessToken: string, refreshToken = 'refresh-1') { +function writeConfig( + accessToken: string, + refreshToken = 'refresh-1', + extra: Record = { projectDomain: 'config.e2b.dev' } +) { fs.mkdirSync(path.dirname(configPath()), { recursive: true }) fs.writeFileSync( configPath(), @@ -43,10 +47,53 @@ function writeConfig(accessToken: string, refreshToken = 'refresh-1') { projectName: 'default', projectId: 'config-project', futureField: 'kept', + ...extra, }) ) } +function project(id: string, overrides: Record = {}) { + return { + id, + public_id: `prj_${id}`, + workspace_id: 'ws', + name: `Project ${id}`, + slug: `slug-${id}`, + region: 'us-west-1', + control_plane_domain: `${id}.e2b.dev`, + status: 'active', + blocked: false, + block_reasons: [], + capabilities: ['project:read'], + ...overrides, + } +} + +/** workspace-api `GET /v1/me` listing the given projects. */ +function mockWorkspaceApi( + projects = [project('config-project'), project('other')] +) { + const fetchMock = vi.fn(async (url: string) => { + expect(url).toBe('https://workspace-api.e2b.dev/v1/me') + return Response.json({ + workspaces: [ + { + id: 'ws', + public_id: 'wrk_ws', + name: 'Workspace', + kind: 'personal', + blocked: false, + block_reasons: [], + capabilities: [], + projects, + }, + ], + }) + }) + vi.stubGlobal('fetch', fetchMock) + return fetchMock +} + function readConfig() { return JSON.parse(fs.readFileSync(configPath(), 'utf8')) } @@ -100,6 +147,7 @@ describe('ensureAuth', () => { test('uses the access token and the configured project otherwise', async () => { const token = jwt(3600) writeConfig(token) + const fetchMock = mockWorkspaceApi() const { ensureAuth } = await import('../src/api') expect(await ensureAuth()).toEqual({ @@ -107,23 +155,68 @@ describe('ensureAuth', () => { Authorization: `Bearer ${token}`, 'X-Team-ID': 'config-project', }, + domain: 'config.e2b.dev', + }) + // The stored project needs no lookup. + expect(fetchMock).not.toHaveBeenCalled() + }) + + test('looks up and stores the domain of a project selected by an earlier version', async () => { + writeConfig(jwt(3600), 'refresh-1', {}) + const fetchMock = mockWorkspaceApi() + const { ensureAuth } = await import('../src/api') + + expect(await ensureAuth()).toMatchObject({ + domain: 'config-project.e2b.dev', }) + expect(readConfig().projectDomain).toBe('config-project.e2b.dev') + await ensureAuth() + expect(fetchMock).toHaveBeenCalledTimes(1) }) - test('selects the project from --project, then E2B_PROJECT_ID', async () => { + test('resolves --project, then E2B_PROJECT_ID, to the project ID and its control plane', async () => { writeConfig(jwt(3600)) - vi.stubEnv('E2B_PROJECT_ID', 'env-project') + vi.stubEnv('E2B_PROJECT_ID', 'prj_other') + const fetchMock = mockWorkspaceApi([ + project('config-project'), + project('other'), + project('flag'), + ]) const { ensureAuth } = await import('../src/api') - const headers = async (project?: string) => - ((await ensureAuth(project)) as { apiHeaders: Record }) - .apiHeaders['X-Team-ID'] - expect(await headers()).toBe('env-project') - expect(await headers('flag-project')).toBe('flag-project') + const target = async (ref?: string) => { + const auth = (await ensureAuth(ref)) as { + apiHeaders: Record + domain: string + } + return [auth.apiHeaders['X-Team-ID'], auth.domain] + } + expect(await target()).toEqual(['other', 'other.e2b.dev']) + expect(await target('slug-flag')).toEqual(['flag', 'flag.e2b.dev']) + expect(await target('project FLAG')).toEqual(['flag', 'flag.e2b.dev']) + // /v1/me is fetched once per process. + expect(fetchMock).toHaveBeenCalledTimes(1) + }) + + test('exits with a hint when the project is not accessible', async () => { + writeConfig(jwt(3600)) + mockWorkspaceApi() + vi.spyOn(process, 'exit').mockImplementation(((code?: number) => { + throw new Error(`exit ${code}`) + }) as never) + const error = vi.spyOn(console, 'error').mockImplementation(() => {}) + const { ensureAuth } = await import('../src/api') + + await expect(ensureAuth('prj_missing')).rejects.toThrow('exit 1') + expect(String(error.mock.calls[0][0])).toContain( + 'Project "prj_missing" not found' + ) }) test('exits with the login hint when signed out', async () => { - const exit = vi.spyOn(process, 'exit').mockImplementation(((code?: number) => { + const exit = vi.spyOn(process, 'exit').mockImplementation((( + code?: number + ) => { throw new Error(`exit ${code}`) }) as never) const error = vi.spyOn(console, 'error').mockImplementation(() => {}) diff --git a/packages/cli/tests/workspaces.test.ts b/packages/cli/tests/workspaces.test.ts new file mode 100644 index 0000000000..59f25cda21 --- /dev/null +++ b/packages/cli/tests/workspaces.test.ts @@ -0,0 +1,184 @@ +import { afterEach, describe, expect, test, vi } from 'vitest' + +import { + activeProjects, + blockedWarning, + fetchMe, + findProject, + Me, + personalProject, + pickProject, + Project, + Workspace, + workspaceApiUrl, +} from '../src/workspaces' + +function project(id: string, overrides: Partial = {}): Project { + return { + id, + public_id: `prj_${id}`, + workspace_id: 'ws', + name: `Project ${id}`, + slug: `slug-${id}`, + region: 'us-west-1', + control_plane_domain: 'e2b-foxtrot.dev', + status: 'active', + blocked: false, + block_reasons: [], + capabilities: [], + ...overrides, + } +} + +function workspace( + kind: string, + projects: Project[], + overrides: Partial = {} +): Workspace { + return { + id: kind, + public_id: `wrk_${kind}`, + name: `${kind} workspace`, + kind, + blocked: false, + block_reasons: [], + capabilities: [], + projects, + ...overrides, + } +} + +afterEach(() => { + vi.unstubAllEnvs() + vi.unstubAllGlobals() +}) + +describe('workspaceApiUrl', () => { + test.each([ + ['', 'https://workspace-api.e2b.dev'], + ['e2b.app', 'https://workspace-api.e2b.dev'], + ['e2b.dev', 'https://workspace-api.e2b.dev'], + ['example.com', 'https://workspace-api.example.com'], + ])('maps E2B_DOMAIN=%j to %s', (domain: string, url: string) => { + vi.stubEnv('E2B_WORKSPACE_API_URL', '') + vi.stubEnv('E2B_DOMAIN', domain) + expect(workspaceApiUrl()).toBe(url) + }) + + test('E2B_WORKSPACE_API_URL overrides it', () => { + vi.stubEnv('E2B_DOMAIN', 'e2b.app') + vi.stubEnv('E2B_WORKSPACE_API_URL', 'http://localhost:8080/') + expect(workspaceApiUrl()).toBe('http://localhost:8080') + }) +}) + +describe('personalProject', () => { + test('picks the active project in the personal workspace', () => { + const me: Me = { + workspaces: [ + workspace('standard', [project('team')]), + workspace('personal', [ + project('deleting', { status: 'deleting' }), + project('mine'), + ]), + ], + } + expect(personalProject(me)?.project.id).toBe('mine') + }) + + test('is undefined for SSO-only users', () => { + const me: Me = { workspaces: [workspace('standard', [project('team')])] } + expect(personalProject(me)).toBeUndefined() + expect(activeProjects(me).map(({ project }) => project.id)).toEqual([ + 'team', + ]) + }) +}) + +describe('findProject', () => { + const me: Me = { + workspaces: [ + workspace('personal', [project('a', { name: 'API' })]), + workspace('standard', [ + project('b', { name: 'Shared' }), + project('c', { name: 'shared' }), + ]), + ], + } + + test.each(['a', 'prj_a', 'slug-a', 'api'])('finds %j', (ref: string) => { + expect(findProject(me, ref).project.id).toBe('a') + }) + + test('rejects an ambiguous name', () => { + expect(() => findProject(me, 'Shared')).toThrow( + 'More than one project is named "Shared": prj_b (standard workspace), prj_c (standard workspace)' + ) + }) + + test('rejects an unknown project', () => { + expect(() => findProject(me, 'nope')).toThrow('Project "nope" not found') + }) +}) + +describe('pickProject', () => { + test('fails without active projects', async () => { + const me: Me = { + workspaces: [ + workspace('standard', [project('p', { status: 'provisioning' })]), + ], + } + await expect(pickProject(me, { message: 'Pick' })).rejects.toThrow( + "You don't have any active projects" + ) + }) + + test('returns the first active project without a terminal', async () => { + // vitest's stdin is not a terminal. + const me: Me = { + workspaces: [ + workspace('standard', [project('first'), project('second')]), + ], + } + expect((await pickProject(me, { message: 'Pick' })).project.id).toBe( + 'first' + ) + }) +}) + +describe('fetchMe', () => { + test('returns undefined for a user workspace-api does not know (401)', async () => { + vi.stubGlobal( + 'fetch', + vi.fn(async () => new Response('', { status: 401 })) + ) + expect(await fetchMe('token')).toBeUndefined() + }) + + test('sends the access token and validates the response', async () => { + const fetchMock = vi.fn(async (_url: string, _init: RequestInit) => + Response.json({ workspaces: [{ id: 'missing-fields' }] }) + ) + vi.stubGlobal('fetch', fetchMock) + + await expect(fetchMe('token')).rejects.toThrow('Unexpected response') + const headers = fetchMock.mock.calls[0][1].headers as Record + expect(headers.Authorization).toBe('Bearer token') + }) +}) + +describe('blockedWarning', () => { + test('names the block reasons', () => { + const entry = { + project: project('p', { + blocked: true, + block_reasons: ['credit_exhausted'], + }), + workspace: workspace('standard', []), + } + expect(blockedWarning(entry)).toContain( + 'Project Project p is blocked (credit_exhausted)' + ) + expect(blockedWarning({ ...entry, project: project('ok') })).toBeUndefined() + }) +}) From 47e06f25ebcb17d3bf0da0df5118a19c102bcd2f Mon Sep 17 00:00:00 2001 From: Mish Ushakov <10400064+mishushakov@users.noreply.github.com> Date: Tue, 10 Nov 2026 14:51:21 +0100 Subject: [PATCH 02/10] fix(cli): set up workspace-api accounts on demand, keep the project off-TTY - getMe bootstraps the account when /v1/me returns 401, as login does, so sessions from before workspace-api recover without logging out. - pickProject keeps the selected project without a terminal instead of silently switching to the first one. - Named ProjectStatus/WorkspaceKind members and a request timeout constant; the domain comes from ConnectionConfig like the SDK. Co-Authored-By: Claude Fable 5.1 --- packages/cli/src/api.ts | 10 +++++-- packages/cli/src/workspaces.ts | 33 +++++++++++++++++---- packages/cli/tests/auth.test.ts | 41 +++++++++++++++++++++++++++ packages/cli/tests/workspaces.test.ts | 12 ++++++++ 4 files changed, 88 insertions(+), 8 deletions(-) diff --git a/packages/cli/src/api.ts b/packages/cli/src/api.ts index 382f0eccb1..7c3bf41649 100644 --- a/packages/cli/src/api.ts +++ b/packages/cli/src/api.ts @@ -5,7 +5,7 @@ import * as packageJSON from '../package.json' import { getUserConfig, UserConfig } from './user' import { asBold, asPrimary } from './utils/format' import { ensureValidAccessToken, updateUserConfig } from './utils/token-refresh' -import { fetchMe, findProject, Me } from './workspaces' +import { bootstrapUser, fetchMe, findProject, Me } from './workspaces' // Must run before any ConnectionConfig is constructed (including the // module-level one below) — configs read the integration at construction time. @@ -47,7 +47,13 @@ let me: Promise | undefined export function getMe(): Promise { me ??= (async () => { const { accessToken } = await ensureValidAccessToken() - const result = await fetchMe(accessToken) + let result = await fetchMe(accessToken) + if (!result) { + // A user who logged in before workspace-api, or never opened the + // dashboard, has no account there yet: set it up, as login does. + await bootstrapUser(accessToken) + result = await fetchMe(accessToken) + } if (!result) { throw new Error( 'Your session was not accepted. Run `e2b auth login` again.' diff --git a/packages/cli/src/workspaces.ts b/packages/cli/src/workspaces.ts index 49a30f6c1b..6b282257ba 100644 --- a/packages/cli/src/workspaces.ts +++ b/packages/cli/src/workspaces.ts @@ -1,4 +1,5 @@ import { select } from '@inquirer/prompts' +import { ConnectionConfig } from 'e2b' import { z } from 'zod' import * as packageJSON from '../package.json' @@ -34,6 +35,18 @@ const workspaceSchema = z.object({ const meSchema = z.object({ workspaces: z.array(workspaceSchema) }) +/** The `status` values the CLI acts on; others are passed through as text. */ +export enum ProjectStatus { + Active = 'active', +} + +/** The workspace `kind` values the CLI acts on. */ +export enum WorkspaceKind { + Personal = 'personal', +} + +const WORKSPACE_API_REQUEST_TIMEOUT_MS = 30_000 + export type Project = z.infer export type Workspace = z.infer export type Me = z.infer @@ -51,7 +64,8 @@ export function workspaceApiUrl(): string { const override = process.env.E2B_WORKSPACE_API_URL if (override) return override.replace(/\/+$/, '') - const domain = process.env.E2B_DOMAIN || 'e2b.dev' + // The same resolution as the SDK: E2B_DOMAIN, then its default. + const domain = new ConnectionConfig().domain if (domain === 'e2b.app' || domain === 'e2b.dev') { return 'https://workspace-api.e2b.dev' } @@ -69,7 +83,7 @@ async function workspaceApi( Authorization: `Bearer ${accessToken}`, 'User-Agent': `e2b-cli/${packageJSON.version}`, }, - signal: AbortSignal.timeout(30_000), + signal: AbortSignal.timeout(WORKSPACE_API_REQUEST_TIMEOUT_MS), }) } @@ -121,7 +135,9 @@ export function allProjects(me: Me): ProjectEntry[] { /** Projects that can be used: provisioned and not being deleted. */ export function activeProjects(me: Me): ProjectEntry[] { - return allProjects(me).filter(({ project }) => project.status === 'active') + return allProjects(me).filter( + ({ project }) => project.status === ProjectStatus.Active + ) } /** @@ -131,7 +147,7 @@ export function activeProjects(me: Me): ProjectEntry[] { */ export function personalProject(me: Me): ProjectEntry | undefined { return activeProjects(me).find( - ({ workspace }) => workspace.kind === 'personal' + ({ workspace }) => workspace.kind === WorkspaceKind.Personal ) } @@ -190,7 +206,8 @@ export function projectLabel( /** * Ask which active project to use. Without a terminal to prompt in, or with - * a single candidate, the first active project is returned. + * a single candidate, the currently selected project is kept, or the first + * active one is returned. */ export async function pickProject( me: Me, @@ -204,7 +221,11 @@ export async function pickProject( )}.` ) } - if (entries.length === 1 || !process.stdin.isTTY) return entries[0] + if (entries.length === 1 || !process.stdin.isTTY) { + return ( + entries.find(({ project }) => project.id === selectedId) ?? entries[0] + ) + } return select({ message, diff --git a/packages/cli/tests/auth.test.ts b/packages/cli/tests/auth.test.ts index 89d4c852fe..9dcbec4e5e 100644 --- a/packages/cli/tests/auth.test.ts +++ b/packages/cli/tests/auth.test.ts @@ -174,6 +174,47 @@ describe('ensureAuth', () => { expect(fetchMock).toHaveBeenCalledTimes(1) }) + test('sets up the account in workspace-api when /v1/me does not know the user yet', async () => { + writeConfig(jwt(3600), 'refresh-1', {}) + const calls: string[] = [] + let bootstrapped = false + vi.stubGlobal( + 'fetch', + vi.fn(async (url: string, init: RequestInit) => { + calls.push(`${init.method} ${new URL(url).pathname}`) + if (url.endsWith('/v1/users/bootstrap')) { + bootstrapped = true + return Response.json({}) + } + if (!bootstrapped) return new Response(null, { status: 401 }) + return Response.json({ + workspaces: [ + { + id: 'ws', + public_id: 'wrk_ws', + name: 'Workspace', + kind: 'personal', + blocked: false, + block_reasons: [], + capabilities: [], + projects: [project('config-project')], + }, + ], + }) + }) + ) + const { ensureAuth } = await import('../src/api') + + expect(await ensureAuth()).toMatchObject({ + domain: 'config-project.e2b.dev', + }) + expect(calls).toEqual([ + 'GET /v1/me', + 'POST /v1/users/bootstrap', + 'GET /v1/me', + ]) + }) + test('resolves --project, then E2B_PROJECT_ID, to the project ID and its control plane', async () => { writeConfig(jwt(3600)) vi.stubEnv('E2B_PROJECT_ID', 'prj_other') diff --git a/packages/cli/tests/workspaces.test.ts b/packages/cli/tests/workspaces.test.ts index 59f25cda21..3f1602346c 100644 --- a/packages/cli/tests/workspaces.test.ts +++ b/packages/cli/tests/workspaces.test.ts @@ -144,6 +144,18 @@ describe('pickProject', () => { 'first' ) }) + + test('keeps the selected project without a terminal', async () => { + const me: Me = { + workspaces: [ + workspace('standard', [project('first'), project('second')]), + ], + } + expect( + (await pickProject(me, { message: 'Pick', selectedId: 'second' })) + .project.id + ).toBe('second') + }) }) describe('fetchMe', () => { From 642d4436a0321d1e4d465f767e3828d7b8538b88 Mon Sep 17 00:00:00 2001 From: Mish Ushakov <10400064+mishushakov@users.noreply.github.com> Date: Wed, 11 Nov 2026 16:23:37 +0100 Subject: [PATCH 03/10] refactor(cli): update the user config under withFileLock Co-Authored-By: Claude Opus 5.5 (1M context) --- packages/cli/src/utils/token-refresh.ts | 27 +++++++++++++------------ 1 file changed, 14 insertions(+), 13 deletions(-) diff --git a/packages/cli/src/utils/token-refresh.ts b/packages/cli/src/utils/token-refresh.ts index cc2d57261d..cbd3cfee52 100644 --- a/packages/cli/src/utils/token-refresh.ts +++ b/packages/cli/src/utils/token-refresh.ts @@ -155,22 +155,23 @@ export async function ensureValidAccessToken(): Promise<{ } /** - * Apply `patch` to the stored config under the refresh lock, so it can't + * Apply `patch` to the stored config under the config lock, so it can't * revert tokens another e2b process refreshes at the same time. */ export async function updateUserConfig( patch: Partial ): Promise { - const release = await acquireRefreshLock() - try { - const config = getUserConfig() - if (!config) { - throw new Error('No user config found, run `e2b auth login` first.') - } - const updated = { ...config, ...patch } - writeUserConfig(USER_CONFIG_PATH, updated) - return updated - } finally { - release() - } + return withFileLock( + USER_CONFIG_PATH, + () => { + const config = getUserConfig() + if (!config) { + throw new Error('No user config found, run `e2b auth login` first.') + } + const updated = { ...config, ...patch } + writeUserConfig(USER_CONFIG_PATH, updated) + return updated + }, + CONFIG_LOCK + ) } From cf7df2efaeda14c0cc10145e915708312cdc6b69 Mon Sep 17 00:00:00 2001 From: Mish Ushakov <10400064+mishushakov@users.noreply.github.com> Date: Wed, 11 Nov 2026 19:18:42 +0100 Subject: [PATCH 04/10] fix(cli): resolve template list projects through ensureAuth, reject shared slugs - `template list` no longer sends the project as the `teamID` query param, which accepts IDs only: `--project` and `E2B_PROJECT_ID` given as a slug or name failed there while X-Team-ID already selected the project. With that gone `resolveProjectId` had no caller. - `findProject` treats a slug shared across workspaces as ambiguous, like a name, instead of taking the first match. - `auth login` prints workspace-api failures as an error instead of an unhandled rejection. - The one-time control-plane domain backfill only patches the project still selected under the lock, so `e2b auth configure` switching projects in the meantime can't end up with the other project's domain. Co-Authored-By: Claude Fable 5.1 --- packages/cli/src/api.ts | 25 ++++++------------- packages/cli/src/commands/auth/login.ts | 28 ++++++++++------------ packages/cli/src/commands/template/list.ts | 15 +++++------- packages/cli/src/utils/token-refresh.ts | 11 ++++++--- packages/cli/src/workspaces.ts | 26 +++++++++++--------- packages/cli/tests/workspaces.test.ts | 17 ++++++++++++- 6 files changed, 64 insertions(+), 58 deletions(-) diff --git a/packages/cli/src/api.ts b/packages/cli/src/api.ts index 7c3bf41649..e72ff84cca 100644 --- a/packages/cli/src/api.ts +++ b/packages/cli/src/api.ts @@ -83,7 +83,13 @@ async function resolveProject( // Selected before the CLI stored control-plane domains: look it up once. const { project } = findProject(await getMe(), config.projectId) - await updateUserConfig({ projectDomain: project.control_plane_domain }) + // Only for the project still selected: `e2b auth configure` may have + // switched to another one since `config` was read. + await updateUserConfig((current) => + current.projectId === project.id + ? { projectDomain: project.control_plane_domain } + : {} + ) return { id: project.id, domain: project.control_plane_domain } } @@ -144,22 +150,5 @@ export function ensureUserConfig(): UserConfig { return userConfig } -/** - * Resolve project ID with proper precedence: - * 1. CLI --project flag (or the deprecated --team flag) - * 2. E2B_PROJECT_ID env var (or the deprecated E2B_TEAM_ID) - * 3. ~/.e2b/config.json projectId (only if E2B_API_KEY env var is NOT set, - * to avoid mismatch between env var API key and config file project ID) - */ -export function resolveProjectId(cliProjectId?: string): string | undefined { - if (cliProjectId) return cliProjectId - if (projectId) return projectId - if (!process.env.E2B_API_KEY) { - const config = getUserConfig() - return config?.projectId - } - return undefined -} - // Carries no credentials: used for the API domain and request signals only. export const connectionConfig = new e2b.ConnectionConfig() diff --git a/packages/cli/src/commands/auth/login.ts b/packages/cli/src/commands/auth/login.ts index 1395d0eb29..9df90d1146 100644 --- a/packages/cli/src/commands/auth/login.ts +++ b/packages/cli/src/commands/auth/login.ts @@ -48,24 +48,20 @@ export const loginCommand = new commander.Command('login') const accessToken = signInResponse.accessToken - // A user who signed up but never opened the dashboard isn't set up in - // workspace-api yet (401); bootstrapping creates the account. - let me = await fetchMe(accessToken) - if (!me) { - await bootstrapUser(accessToken) - me = await fetchMe(accessToken) - } - if (!me) { - console.error( - asFormattedError( - 'Your account could not be set up, please contact support' - ) - ) - process.exit(1) - } - let selected try { + // A user who signed up but never opened the dashboard isn't set up in + // workspace-api yet (401); bootstrapping creates the account. + let me = await fetchMe(accessToken) + if (!me) { + await bootstrapUser(accessToken) + me = await fetchMe(accessToken) + } + if (!me) { + throw new Error( + 'Your account could not be set up, please contact support' + ) + } selected = personalProject(me) ?? (await pickProject(me, { message: 'Select the project to use' })) diff --git a/packages/cli/src/commands/template/list.ts b/packages/cli/src/commands/template/list.ts index 5a4966f71e..a79bf8fb67 100644 --- a/packages/cli/src/commands/template/list.ts +++ b/packages/cli/src/commands/template/list.ts @@ -4,7 +4,7 @@ import * as e2b from 'e2b' import { listAliases } from '../../utils/format' import { renderTable } from 'src/utils/table' import { sortTemplatesAliases } from 'src/utils/templateSort' -import { apiClient, resolveProjectId } from 'src/api' +import { apiClient } from 'src/api' import { deprecatedTeamOption, Format, @@ -42,7 +42,7 @@ export const listCommand = new commander.Command('list') process.stdout.write('\n') const { templates, hasMore } = await listSandboxTemplates({ - projectId: resolveProjectId(projectIdFromOptions(opts)), + projectId: projectIdFromOptions(opts), limit, }) @@ -119,19 +119,16 @@ async function listSandboxTemplates({ pageLimit = PAGE_LIMIT } - // With an access token, X-Team-ID (from the same project ID) selects the - // project; with an API key, the key does and the teamID query param is - // ignored. + // The project (an ID, slug or name, or the selected one) is resolved by + // ensureAuth: with an access token X-Team-ID selects it, with an API key + // the key does. The teamID query param accepts IDs only, so it's not sent. const client = await apiClient(projectId) const templates: e2b.components['schemas']['Template'][] = [] let nextToken: string | undefined do { const res = await client.api.GET('/v2/templates', { - params: { - // the backend API still calls the project ID "teamID" - query: { teamID: projectId, limit: pageLimit, nextToken }, - }, + params: { query: { limit: pageLimit, nextToken } }, }) if (!res.response.ok || !res.data) { diff --git a/packages/cli/src/utils/token-refresh.ts b/packages/cli/src/utils/token-refresh.ts index cbd3cfee52..b53e4a4912 100644 --- a/packages/cli/src/utils/token-refresh.ts +++ b/packages/cli/src/utils/token-refresh.ts @@ -156,10 +156,12 @@ export async function ensureValidAccessToken(): Promise<{ /** * Apply `patch` to the stored config under the config lock, so it can't - * revert tokens another e2b process refreshes at the same time. + * revert tokens another e2b process refreshes at the same time. A function + * gets the config as it is under the lock, which may differ from the one + * the caller read. */ export async function updateUserConfig( - patch: Partial + patch: Partial | ((config: UserConfig) => Partial) ): Promise { return withFileLock( USER_CONFIG_PATH, @@ -168,7 +170,10 @@ export async function updateUserConfig( if (!config) { throw new Error('No user config found, run `e2b auth login` first.') } - const updated = { ...config, ...patch } + const updated = { + ...config, + ...(typeof patch === 'function' ? patch(config) : patch), + } writeUserConfig(USER_CONFIG_PATH, updated) return updated }, diff --git a/packages/cli/src/workspaces.ts b/packages/cli/src/workspaces.ts index 6b282257ba..8bb5ca1463 100644 --- a/packages/cli/src/workspaces.ts +++ b/packages/cli/src/workspaces.ts @@ -157,19 +157,23 @@ export function personalProject(me: Me): ProjectEntry | undefined { */ export function findProject(me: Me, ref: string): ProjectEntry { const entries = allProjects(me) - const exact = entries.find( - ({ project }) => - project.id === ref || project.public_id === ref || project.slug === ref + const byId = entries.find( + ({ project }) => project.id === ref || project.public_id === ref ) - if (exact) return exact - - const byName = entries.filter( - ({ project }) => project.name.toLowerCase() === ref.toLowerCase() - ) - if (byName.length === 1) return byName[0] - if (byName.length > 1) { + if (byId) return byId + + // Slugs are unique within a workspace, names not even there; across + // workspaces either can repeat. + const bySlug = entries.filter(({ project }) => project.slug === ref) + const matches = bySlug.length + ? bySlug + : entries.filter( + ({ project }) => project.name.toLowerCase() === ref.toLowerCase() + ) + if (matches.length === 1) return matches[0] + if (matches.length > 1) { throw new Error( - `More than one project is named "${ref}": ${byName + `More than one project matches "${ref}": ${matches .map( ({ project, workspace }) => `${project.public_id} (${workspace.name})` ) diff --git a/packages/cli/tests/workspaces.test.ts b/packages/cli/tests/workspaces.test.ts index 3f1602346c..6ca61773e0 100644 --- a/packages/cli/tests/workspaces.test.ts +++ b/packages/cli/tests/workspaces.test.ts @@ -103,6 +103,10 @@ describe('findProject', () => { project('b', { name: 'Shared' }), project('c', { name: 'shared' }), ]), + workspace('other', [ + project('d', { slug: 'slug-b' }), + project('e', { slug: 'shared' }), + ]), ], } @@ -112,10 +116,21 @@ describe('findProject', () => { test('rejects an ambiguous name', () => { expect(() => findProject(me, 'Shared')).toThrow( - 'More than one project is named "Shared": prj_b (standard workspace), prj_c (standard workspace)' + 'More than one project matches "Shared": prj_b (standard workspace), prj_c (standard workspace)' + ) + }) + + test('rejects a slug shared across workspaces', () => { + expect(() => findProject(me, 'slug-b')).toThrow( + 'More than one project matches "slug-b": prj_b (standard workspace), prj_d (other workspace)' ) }) + test('prefers a slug over names', () => { + // "shared" is e's slug and, case-insensitively, b's and c's name. + expect(findProject(me, 'shared').project.id).toBe('e') + }) + test('rejects an unknown project', () => { expect(() => findProject(me, 'nope')).toThrow('Project "nope" not found') }) From 975a3777a7a1ac1fb12129db5d821ee58253342b Mon Sep 17 00:00:00 2001 From: Mish Ushakov <10400064+mishushakov@users.noreply.github.com> Date: Wed, 11 Nov 2026 21:08:07 +0100 Subject: [PATCH 05/10] fix(cli): let each project-resolution error carry its own remedy ensureAuth appended "Run `e2b auth configure`" to every resolveProject failure, including session, network and schema errors from workspace-api that already say what to do. The hint now lives in findProject's not-found message only. Co-Authored-By: Claude Fable 5.1 --- packages/cli/src/api.ts | 6 +----- packages/cli/src/workspaces.ts | 4 +++- 2 files changed, 4 insertions(+), 6 deletions(-) diff --git a/packages/cli/src/api.ts b/packages/cli/src/api.ts index e72ff84cca..750d2aac88 100644 --- a/packages/cli/src/api.ts +++ b/packages/cli/src/api.ts @@ -117,11 +117,7 @@ export async function ensureAuth(cliProjectId?: string): Promise { try { project = await resolveProject(config, cliProjectId) } catch (err) { - console.error( - `${err instanceof Error ? err.message : err} Run ${asBold( - 'e2b auth configure' - )} to choose a project.` - ) + console.error(err instanceof Error ? err.message : err) process.exit(1) } diff --git a/packages/cli/src/workspaces.ts b/packages/cli/src/workspaces.ts index 8bb5ca1463..30898b7cc5 100644 --- a/packages/cli/src/workspaces.ts +++ b/packages/cli/src/workspaces.ts @@ -181,7 +181,9 @@ export function findProject(me: Me, ref: string): ProjectEntry { ) } throw new Error( - `Project "${ref}" not found among the projects you can access.` + `Project "${ref}" not found among the projects you can access. Run ${asBold( + 'e2b auth configure' + )} to choose one.` ) } From 529f7b46e1fd1ff397cce63ba76cdaf286ced916 Mon Sep 17 00:00:00 2001 From: Mish Ushakov <10400064+mishushakov@users.noreply.github.com> Date: Thu, 12 Nov 2026 08:47:53 +0100 Subject: [PATCH 06/10] docs(cli): custom E2B_DOMAIN deployments use E2B_API_KEY Login resolves projects through workspace-api, which only exists for e2b.dev; the changeset says other domains are API-key only. Co-Authored-By: Claude Fable 5.1 --- .changeset/cli-workspace-api-projects.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.changeset/cli-workspace-api-projects.md b/.changeset/cli-workspace-api-projects.md index 6c972369d4..ff313a97dc 100644 --- a/.changeset/cli-workspace-api-projects.md +++ b/.changeset/cli-workspace-api-projects.md @@ -7,4 +7,4 @@ - Login selects the project in your personal workspace. Without one (SSO-only accounts), it selects your only active project or asks which one to use. Blocked projects show a warning. - Requests go to the selected project's own control plane, so projects outside the default region work. - `E2B_PROJECT_ID` (and `--project` where available) accepts a project's ID, `prj_` public ID, slug or name. -- workspace-api is reached at `workspace-api.e2b.dev` (for `E2B_DOMAIN` `e2b.app` and `e2b.dev`), or at `E2B_WORKSPACE_API_URL`. +- workspace-api is reached at `workspace-api.e2b.dev` (for `E2B_DOMAIN` `e2b.app` and `e2b.dev`), or at `E2B_WORKSPACE_API_URL`. Logging in needs it: on any other `E2B_DOMAIN`, use `E2B_API_KEY`. From 9fb2c94ddfb0148f3d0947a550b319ad290d77ed Mon Sep 17 00:00:00 2001 From: Mish Ushakov <10400064+mishushakov@users.noreply.github.com> Date: Thu, 12 Nov 2026 10:34:30 +0100 Subject: [PATCH 07/10] feat(cli): pick the workspace first, then the project pickProject asks for the workspace and then for one of its active projects, skipping a step with a single choice, and takes a `workspace` to offer that workspace's projects only. findProject can be scoped to a workspace the same way. Login and `auth configure` use the new flow. Co-Authored-By: Claude Fable 5.1 --- .changeset/cli-workspace-api-projects.md | 2 +- packages/cli/src/workspaces.ts | 97 +++++++++++----- packages/cli/tests/workspaces.test.ts | 138 +++++++++++++++++++++++ 3 files changed, 210 insertions(+), 27 deletions(-) diff --git a/.changeset/cli-workspace-api-projects.md b/.changeset/cli-workspace-api-projects.md index ff313a97dc..9bb24c057b 100644 --- a/.changeset/cli-workspace-api-projects.md +++ b/.changeset/cli-workspace-api-projects.md @@ -4,7 +4,7 @@ `e2b auth login` and `e2b auth configure` list your workspaces and projects from workspace-api instead of the API's `GET /teams`, which created a new API key in every project on each call. -- Login selects the project in your personal workspace. Without one (SSO-only accounts), it selects your only active project or asks which one to use. Blocked projects show a warning. +- Login selects the project in your personal workspace. Without one (SSO-only accounts), it selects your only active project or asks for the workspace and then the project. `e2b auth configure` asks the same way. Blocked projects show a warning. - Requests go to the selected project's own control plane, so projects outside the default region work. - `E2B_PROJECT_ID` (and `--project` where available) accepts a project's ID, `prj_` public ID, slug or name. - workspace-api is reached at `workspace-api.e2b.dev` (for `E2B_DOMAIN` `e2b.app` and `e2b.dev`), or at `E2B_WORKSPACE_API_URL`. Logging in needs it: on any other `E2B_DOMAIN`, use `E2B_API_KEY`. diff --git a/packages/cli/src/workspaces.ts b/packages/cli/src/workspaces.ts index 30898b7cc5..8e846117f8 100644 --- a/packages/cli/src/workspaces.ts +++ b/packages/cli/src/workspaces.ts @@ -152,11 +152,18 @@ export function personalProject(me: Me): ProjectEntry | undefined { } /** - * Find a project by ID (UUID or `prj_` public ID), slug or name. Names are - * matched case-insensitively and must be unambiguous. + * Find a project by ID (UUID or `prj_` public ID), slug or name, among the + * projects of `workspace` when given. Names are matched case-insensitively + * and must be unambiguous. */ -export function findProject(me: Me, ref: string): ProjectEntry { - const entries = allProjects(me) +export function findProject( + me: Me, + ref: string, + workspace?: Workspace +): ProjectEntry { + const entries = allProjects(me).filter( + (entry) => !workspace || entry.workspace.id === workspace.id + ) const byId = entries.find( ({ project }) => project.id === ref || project.public_id === ref ) @@ -181,9 +188,9 @@ export function findProject(me: Me, ref: string): ProjectEntry { ) } throw new Error( - `Project "${ref}" not found among the projects you can access. Run ${asBold( - 'e2b auth configure' - )} to choose one.` + `Project "${ref}" not found among the projects you can access${ + workspace ? ` in workspace ${workspace.name}` : '' + }. Run ${asBold('e2b auth configure')} to choose one.` ) } @@ -196,50 +203,88 @@ export function blockedWarning({ project }: ProjectEntry): string | undefined { return `Project ${project.name} is blocked${reasons}, so requests to it may be refused. Check the project in the dashboard.` } -/** `name — workspace (region)`, flagged when blocked or currently selected. */ -export function projectLabel( - { project, workspace }: ProjectEntry, - selectedId?: string -): string { +/** `name (region)`, flagged when blocked or currently selected. */ +function projectLabel({ project }: ProjectEntry, selectedId?: string): string { const flags = [ project.blocked ? asRed('blocked') : undefined, project.id === selectedId ? asPrimary('current') : undefined, ].filter(Boolean) - return `${asBold(project.name)} — ${workspace.name} (${project.region})${ + return `${asBold(project.name)} (${project.region})${ flags.length ? ` [${flags.join(', ')}]` : '' }` } +/** `name (kind, N active projects)`, flagged when it holds the selected project. */ +function workspaceLabel( + workspace: Workspace, + entries: ProjectEntry[], + selectedId?: string +): string { + const own = entries.filter((entry) => entry.workspace.id === workspace.id) + const count = `${own.length} active project${own.length === 1 ? '' : 's'}` + const current = own.some(({ project }) => project.id === selectedId) + return `${asBold(workspace.name)} (${workspace.kind}, ${count})${ + current ? ` [${asPrimary('current')}]` : '' + }` +} + /** - * Ask which active project to use. Without a terminal to prompt in, or with - * a single candidate, the currently selected project is kept, or the first - * active one is returned. + * Ask which active project to use: first the workspace, then one of its + * projects, skipping a step that has a single choice. With `workspace` only + * its projects are offered. Without a terminal to prompt in, the currently + * selected project is kept if it is a candidate, else the first one is + * returned. */ export async function pickProject( me: Me, - { message, selectedId }: { message: string; selectedId?: string } + { + message, + selectedId, + workspace, + }: { message: string; selectedId?: string; workspace?: Workspace } ): Promise { - const entries = activeProjects(me) + const entries = activeProjects(me).filter( + (entry) => !workspace || entry.workspace.id === workspace.id + ) if (entries.length === 0) { throw new Error( - `You don't have any active projects. Create one at ${asPrimary( - 'https://e2b.dev/dashboard' - )}.` + workspace + ? `Workspace ${workspace.name} has no active projects.` + : `You don't have any active projects. Create one at ${asPrimary( + 'https://e2b.dev/dashboard' + )}.` ) } + const current = entries.find(({ project }) => project.id === selectedId) if (entries.length === 1 || !process.stdin.isTTY) { - return ( - entries.find(({ project }) => project.id === selectedId) ?? entries[0] - ) + return current ?? entries[0] } + const workspaces = me.workspaces.filter((candidate) => + entries.some((entry) => entry.workspace.id === candidate.id) + ) + const chosen = + workspaces.length === 1 + ? workspaces[0] + : await select({ + message: 'Select workspace', + pageSize: 20, + choices: workspaces.map((candidate) => ({ + name: workspaceLabel(candidate, entries, selectedId), + value: candidate, + })), + default: current?.workspace, + }) + + const candidates = entries.filter((entry) => entry.workspace.id === chosen.id) + if (candidates.length === 1) return candidates[0] return select({ message, pageSize: 20, - choices: entries.map((entry) => ({ + choices: candidates.map((entry) => ({ name: projectLabel(entry, selectedId), value: entry, })), - default: entries.find(({ project }) => project.id === selectedId), + default: current?.workspace.id === chosen.id ? current : undefined, }) } diff --git a/packages/cli/tests/workspaces.test.ts b/packages/cli/tests/workspaces.test.ts index 6ca61773e0..b68ac0350e 100644 --- a/packages/cli/tests/workspaces.test.ts +++ b/packages/cli/tests/workspaces.test.ts @@ -1,5 +1,8 @@ import { afterEach, describe, expect, test, vi } from 'vitest' +const prompts = vi.hoisted(() => ({ select: vi.fn() })) +vi.mock('@inquirer/prompts', () => ({ select: prompts.select })) + import { activeProjects, blockedWarning, @@ -171,6 +174,141 @@ describe('pickProject', () => { .project.id ).toBe('second') }) + + describe('with a terminal', () => { + type Choice = { name: string; value: unknown } + type Prompt = { message: string; choices: Choice[]; default?: unknown } + + const me: Me = { + workspaces: [ + workspace('personal', [project('a')]), + workspace('standard', [ + project('b'), + project('c'), + project('d', { status: 'provisioning' }), + ]), + ], + } + + function answer(...picks: ((prompt: Prompt) => unknown)[]) { + for (const pick of picks) { + prompts.select.mockImplementationOnce(async (prompt: Prompt) => + pick(prompt) + ) + } + } + const asked = () => + prompts.select.mock.calls.map(([prompt]: [Prompt]) => ({ + message: prompt.message, + names: prompt.choices.map((c) => c.name), + })) + + afterEach(() => { + prompts.select.mockReset() + Object.defineProperty(process.stdin, 'isTTY', { + value: undefined, + configurable: true, + }) + }) + const withTTY = () => + Object.defineProperty(process.stdin, 'isTTY', { + value: true, + configurable: true, + }) + + test('asks for the workspace, then one of its active projects', async () => { + withTTY() + answer( + (p) => p.choices[1].value, + (p) => p.choices[1].value + ) + const picked = await pickProject(me, { message: 'Pick', selectedId: 'a' }) + + expect(picked.project.id).toBe('c') + expect(asked()).toEqual([ + { + message: 'Select workspace', + names: [ + expect.stringContaining('personal workspace'), + expect.stringContaining('standard workspace'), + ], + }, + { + message: 'Pick', + names: [ + expect.stringContaining('Project b'), + expect.stringContaining('Project c'), + ], + }, + ]) + expect(asked()[0].names[0]).toContain('1 active project') + expect(asked()[0].names[0]).toContain('current') + expect(asked()[0].names[1]).toContain('2 active projects') + // The selected project's workspace is preselected. + expect(prompts.select.mock.calls[0][0].default).toBe(me.workspaces[0]) + }) + + test('skips the project step when the workspace has one project', async () => { + withTTY() + answer((p) => p.choices[0].value) + expect((await pickProject(me, { message: 'Pick' })).project.id).toBe('a') + expect(asked()).toHaveLength(1) + }) + + test('skips the workspace step with a single workspace', async () => { + withTTY() + const single: Me = { workspaces: [me.workspaces[1]] } + answer((p) => p.choices[0].value) + expect((await pickProject(single, { message: 'Pick' })).project.id).toBe( + 'b' + ) + expect(asked()).toEqual([ + { message: 'Pick', names: [expect.any(String), expect.any(String)] }, + ]) + }) + + test('only offers the given workspace', async () => { + withTTY() + answer((p) => p.choices[1].value) + const picked = await pickProject(me, { + message: 'Pick', + workspace: me.workspaces[1], + }) + expect(picked.project.id).toBe('c') + expect(asked()).toHaveLength(1) + expect(asked()[0].message).toBe('Pick') + }) + + test('fails when the given workspace has no active project', async () => { + withTTY() + const empty = workspace('empty', [project('x', { status: 'deleting' })]) + await expect( + pickProject({ workspaces: [...me.workspaces, empty] }, { + message: 'Pick', + workspace: empty, + }) + ).rejects.toThrow('Workspace empty workspace has no active projects') + }) + }) +}) + +describe('findProject in a workspace', () => { + const me: Me = { + workspaces: [ + workspace('one', [project('a', { name: 'Shared' })]), + workspace('two', [project('b', { name: 'Shared' })]), + ], + } + + test('scopes names to the workspace', () => { + expect(findProject(me, 'shared', me.workspaces[1]).project.id).toBe('b') + }) + + test('does not find projects of other workspaces', () => { + expect(() => findProject(me, 'prj_a', me.workspaces[1])).toThrow( + 'not found among the projects you can access in workspace two workspace' + ) + }) }) describe('fetchMe', () => { From f62505c80c7595ec6346ce4671208cc680b53c7d Mon Sep 17 00:00:00 2001 From: Mish Ushakov <10400064+mishushakov@users.noreply.github.com> Date: Thu, 12 Nov 2026 10:44:58 +0100 Subject: [PATCH 08/10] fix(cli): always show both picker steps The workspace and project prompts are shown even with one choice, so the user sees what is being selected. Co-Authored-By: Claude Fable 5.1 --- packages/cli/src/workspaces.ts | 28 ++++++++++++--------------- packages/cli/tests/workspaces.test.ts | 21 +++++++++----------- 2 files changed, 21 insertions(+), 28 deletions(-) diff --git a/packages/cli/src/workspaces.ts b/packages/cli/src/workspaces.ts index 8e846117f8..8473cd2228 100644 --- a/packages/cli/src/workspaces.ts +++ b/packages/cli/src/workspaces.ts @@ -230,7 +230,7 @@ function workspaceLabel( /** * Ask which active project to use: first the workspace, then one of its - * projects, skipping a step that has a single choice. With `workspace` only + * projects, each step shown even with a single choice. With `workspace` only * its projects are offered. Without a terminal to prompt in, the currently * selected project is kept if it is a candidate, else the first one is * returned. @@ -256,28 +256,24 @@ export async function pickProject( ) } const current = entries.find(({ project }) => project.id === selectedId) - if (entries.length === 1 || !process.stdin.isTTY) { - return current ?? entries[0] - } + if (!process.stdin.isTTY) return current ?? entries[0] const workspaces = me.workspaces.filter((candidate) => entries.some((entry) => entry.workspace.id === candidate.id) ) const chosen = - workspaces.length === 1 - ? workspaces[0] - : await select({ - message: 'Select workspace', - pageSize: 20, - choices: workspaces.map((candidate) => ({ - name: workspaceLabel(candidate, entries, selectedId), - value: candidate, - })), - default: current?.workspace, - }) + workspace ?? + (await select({ + message: 'Select workspace', + pageSize: 20, + choices: workspaces.map((candidate) => ({ + name: workspaceLabel(candidate, entries, selectedId), + value: candidate, + })), + default: current?.workspace, + })) const candidates = entries.filter((entry) => entry.workspace.id === chosen.id) - if (candidates.length === 1) return candidates[0] return select({ message, pageSize: 20, diff --git a/packages/cli/tests/workspaces.test.ts b/packages/cli/tests/workspaces.test.ts index b68ac0350e..bca364a974 100644 --- a/packages/cli/tests/workspaces.test.ts +++ b/packages/cli/tests/workspaces.test.ts @@ -248,22 +248,19 @@ describe('pickProject', () => { expect(prompts.select.mock.calls[0][0].default).toBe(me.workspaces[0]) }) - test('skips the project step when the workspace has one project', async () => { + test('asks even when a step has a single choice', async () => { withTTY() - answer((p) => p.choices[0].value) - expect((await pickProject(me, { message: 'Pick' })).project.id).toBe('a') - expect(asked()).toHaveLength(1) - }) - - test('skips the workspace step with a single workspace', async () => { - withTTY() - const single: Me = { workspaces: [me.workspaces[1]] } - answer((p) => p.choices[0].value) + const single: Me = { workspaces: [me.workspaces[0]] } + answer( + (p) => p.choices[0].value, + (p) => p.choices[0].value + ) expect((await pickProject(single, { message: 'Pick' })).project.id).toBe( - 'b' + 'a' ) expect(asked()).toEqual([ - { message: 'Pick', names: [expect.any(String), expect.any(String)] }, + { message: 'Select workspace', names: [expect.any(String)] }, + { message: 'Pick', names: [expect.stringContaining('Project a')] }, ]) }) From 90650c51df98061c3cd5736a06a77a61fb19b7b5 Mon Sep 17 00:00:00 2001 From: Mish Ushakov <10400064+mishushakov@users.noreply.github.com> Date: Thu, 12 Nov 2026 13:17:56 +0100 Subject: [PATCH 09/10] refactor(cli): drop the workspace-api bootstrap, assume the user is set up A 401 from /v1/me is a rejected session, not a missing account: login and every other command report it instead of calling POST /v1/users/bootstrap first. Co-Authored-By: Claude Fable 5.1 --- packages/cli/src/api.ts | 10 +---- packages/cli/src/commands/auth/login.ts | 11 +----- packages/cli/src/workspaces.ts | 10 ----- packages/cli/tests/auth.test.ts | 49 +++++++------------------ 4 files changed, 17 insertions(+), 63 deletions(-) diff --git a/packages/cli/src/api.ts b/packages/cli/src/api.ts index 750d2aac88..4c0eb04c34 100644 --- a/packages/cli/src/api.ts +++ b/packages/cli/src/api.ts @@ -5,7 +5,7 @@ import * as packageJSON from '../package.json' import { getUserConfig, UserConfig } from './user' import { asBold, asPrimary } from './utils/format' import { ensureValidAccessToken, updateUserConfig } from './utils/token-refresh' -import { bootstrapUser, fetchMe, findProject, Me } from './workspaces' +import { fetchMe, findProject, Me } from './workspaces' // Must run before any ConnectionConfig is constructed (including the // module-level one below) — configs read the integration at construction time. @@ -47,13 +47,7 @@ let me: Promise | undefined export function getMe(): Promise { me ??= (async () => { const { accessToken } = await ensureValidAccessToken() - let result = await fetchMe(accessToken) - if (!result) { - // A user who logged in before workspace-api, or never opened the - // dashboard, has no account there yet: set it up, as login does. - await bootstrapUser(accessToken) - result = await fetchMe(accessToken) - } + const result = await fetchMe(accessToken) if (!result) { throw new Error( 'Your session was not accepted. Run `e2b auth login` again.' diff --git a/packages/cli/src/commands/auth/login.ts b/packages/cli/src/commands/auth/login.ts index 9df90d1146..ee179a7877 100644 --- a/packages/cli/src/commands/auth/login.ts +++ b/packages/cli/src/commands/auth/login.ts @@ -15,7 +15,6 @@ import { asBold, asFormattedConfig, asFormattedError } from 'src/utils/format' import { openUrlInBrowser } from 'src/utils/openBrowser' import { blockedWarning, - bootstrapUser, fetchMe, personalProject, pickProject, @@ -50,16 +49,10 @@ export const loginCommand = new commander.Command('login') let selected try { - // A user who signed up but never opened the dashboard isn't set up in - // workspace-api yet (401); bootstrapping creates the account. - let me = await fetchMe(accessToken) - if (!me) { - await bootstrapUser(accessToken) - me = await fetchMe(accessToken) - } + const me = await fetchMe(accessToken) if (!me) { throw new Error( - 'Your account could not be set up, please contact support' + 'Your login was not accepted by workspace-api, please contact support' ) } selected = diff --git a/packages/cli/src/workspaces.ts b/packages/cli/src/workspaces.ts index 8473cd2228..1054067767 100644 --- a/packages/cli/src/workspaces.ts +++ b/packages/cli/src/workspaces.ts @@ -116,16 +116,6 @@ export async function fetchMe(accessToken: string): Promise { return parsed.data } -/** - * Create the account (a personal workspace with a first project, or SSO - * workspace membership) for a signed-in user workspace-api doesn't know yet. - * Idempotent: an existing account is returned unchanged. - */ -export async function bootstrapUser(accessToken: string): Promise { - const res = await workspaceApi('POST', '/v1/users/bootstrap', accessToken) - if (!res.ok) throw await requestError(res, 'set up your account') -} - /** Every project in `me`, with its workspace. */ export function allProjects(me: Me): ProjectEntry[] { return me.workspaces.flatMap((workspace) => diff --git a/packages/cli/tests/auth.test.ts b/packages/cli/tests/auth.test.ts index 9dcbec4e5e..610bbaa040 100644 --- a/packages/cli/tests/auth.test.ts +++ b/packages/cli/tests/auth.test.ts @@ -174,45 +174,22 @@ describe('ensureAuth', () => { expect(fetchMock).toHaveBeenCalledTimes(1) }) - test('sets up the account in workspace-api when /v1/me does not know the user yet', async () => { + test('exits with the login hint when workspace-api rejects the session', async () => { writeConfig(jwt(3600), 'refresh-1', {}) - const calls: string[] = [] - let bootstrapped = false - vi.stubGlobal( - 'fetch', - vi.fn(async (url: string, init: RequestInit) => { - calls.push(`${init.method} ${new URL(url).pathname}`) - if (url.endsWith('/v1/users/bootstrap')) { - bootstrapped = true - return Response.json({}) - } - if (!bootstrapped) return new Response(null, { status: 401 }) - return Response.json({ - workspaces: [ - { - id: 'ws', - public_id: 'wrk_ws', - name: 'Workspace', - kind: 'personal', - blocked: false, - block_reasons: [], - capabilities: [], - projects: [project('config-project')], - }, - ], - }) - }) - ) + const fetchMock = vi.fn(async () => new Response(null, { status: 401 })) + vi.stubGlobal('fetch', fetchMock) + vi.spyOn(process, 'exit').mockImplementation(((code?: number) => { + throw new Error(`exit ${code}`) + }) as never) + const error = vi.spyOn(console, 'error').mockImplementation(() => {}) const { ensureAuth } = await import('../src/api') - expect(await ensureAuth()).toMatchObject({ - domain: 'config-project.e2b.dev', - }) - expect(calls).toEqual([ - 'GET /v1/me', - 'POST /v1/users/bootstrap', - 'GET /v1/me', - ]) + await expect(ensureAuth()).rejects.toThrow('exit 1') + expect(String(error.mock.calls[0][0])).toContain( + 'Your session was not accepted. Run `e2b auth login` again.' + ) + // Nothing is set up on the user's behalf. + expect(fetchMock).toHaveBeenCalledTimes(1) }) test('resolves --project, then E2B_PROJECT_ID, to the project ID and its control plane', async () => { From 3487696179b637617428327af1873327493b200d Mon Sep 17 00:00:00 2001 From: Mish Ushakov <10400064+mishushakov@users.noreply.github.com> Date: Thu, 12 Nov 2026 13:25:24 +0100 Subject: [PATCH 10/10] feat(cli): let login pick the workspace and project, personal ones preselected Co-Authored-By: Claude Fable 5.1 --- .changeset/cli-workspace-api-projects.md | 2 +- packages/cli/src/commands/auth/login.ts | 10 +++++++--- 2 files changed, 8 insertions(+), 4 deletions(-) diff --git a/.changeset/cli-workspace-api-projects.md b/.changeset/cli-workspace-api-projects.md index 9bb24c057b..1ab0bec9bb 100644 --- a/.changeset/cli-workspace-api-projects.md +++ b/.changeset/cli-workspace-api-projects.md @@ -4,7 +4,7 @@ `e2b auth login` and `e2b auth configure` list your workspaces and projects from workspace-api instead of the API's `GET /teams`, which created a new API key in every project on each call. -- Login selects the project in your personal workspace. Without one (SSO-only accounts), it selects your only active project or asks for the workspace and then the project. `e2b auth configure` asks the same way. Blocked projects show a warning. +- Login asks for the workspace and then the project, with your personal workspace and its project preselected. Without a terminal, the personal project (or your only active one) is used. `e2b auth configure` asks the same way, with the current project preselected. Blocked projects show a warning. - Requests go to the selected project's own control plane, so projects outside the default region work. - `E2B_PROJECT_ID` (and `--project` where available) accepts a project's ID, `prj_` public ID, slug or name. - workspace-api is reached at `workspace-api.e2b.dev` (for `E2B_DOMAIN` `e2b.app` and `e2b.dev`), or at `E2B_WORKSPACE_API_URL`. Logging in needs it: on any other `E2B_DOMAIN`, use `E2B_API_KEY`. diff --git a/packages/cli/src/commands/auth/login.ts b/packages/cli/src/commands/auth/login.ts index ee179a7877..00135d37da 100644 --- a/packages/cli/src/commands/auth/login.ts +++ b/packages/cli/src/commands/auth/login.ts @@ -55,9 +55,13 @@ export const loginCommand = new commander.Command('login') 'Your login was not accepted by workspace-api, please contact support' ) } - selected = - personalProject(me) ?? - (await pickProject(me, { message: 'Select the project to use' })) + // Ask, with the personal workspace and its project preselected; + // without a terminal that default (or the only active project) is + // taken as is. + selected = await pickProject(me, { + message: 'Select the project to use', + selectedId: personalProject(me)?.project.id, + }) } catch (err) { console.error( asFormattedError(err instanceof Error ? err.message : String(err))