Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
10 changes: 10 additions & 0 deletions .changeset/cli-workspace-api-projects.md
Original file line number Diff line number Diff line change
@@ -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 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`.
89 changes: 63 additions & 26 deletions packages/cli/src/api.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 = () => {
Expand Down Expand Up @@ -41,16 +39,63 @@ Visit ${asPrimary('https://e2b.dev/dashboard?tab=keys')} to get the API key.`
*/
export type Auth =
| Required<Pick<e2b.ConnectionOpts, 'apiKey'>>
| Required<Pick<e2b.ConnectionOpts, 'apiHeaders'>>
| Required<Pick<e2b.ConnectionOpts, 'apiHeaders' | 'domain'>>

let me: Promise<Me> | undefined

/** The logged-in user's workspaces and projects, fetched once per process. */
export function getMe(): Promise<Me> {
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
}
Comment thread
cursor[bot] marked this conversation as resolved.
Resolved

/**
* 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 }
Comment thread
mishushakov marked this conversation as resolved.
Resolved
Comment thread
mishushakov marked this conversation as resolved.
Resolved
}
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)
// 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 }
: {}
)
Comment thread
mishushakov marked this conversation as resolved.
Resolved
return { id: project.id, domain: project.control_plane_domain }
Comment thread
cursor[bot] marked this conversation as resolved.
Resolved
Comment thread
mishushakov marked this conversation as resolved.
Resolved
}

/**
* 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<Auth> {
const apiKey = process.env.E2B_API_KEY
Expand All @@ -62,11 +107,20 @@ export async function ensureAuth(cliProjectId?: string): Promise<Auth> {
}

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)
process.exit(1)
}
Comment thread
mishushakov marked this conversation as resolved.
Resolved

return {
apiHeaders: {
Authorization: `Bearer ${accessToken}`,
'X-Team-ID': resolveProjectId(cliProjectId) ?? config.projectId,
'X-Team-ID': project.id,
},
domain: project.domain,
}
}

Expand All @@ -86,22 +140,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()
88 changes: 33 additions & 55 deletions packages/cli/src/commands/auth/configure.ts
Original file line number Diff line number Diff line change
@@ -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(),
})
Comment thread
cursor[bot] marked this conversation as resolved.
Resolved

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)
}
})
48 changes: 28 additions & 20 deletions packages/cli/src/commands/auth/login.ts
Original file line number Diff line number Diff line change
@@ -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 {
Expand All @@ -14,8 +13,12 @@ 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,
fetchMe,
personalProject,
pickProject,
} from 'src/workspaces'

export const loginCommand = new commander.Command('login')
.description('log in to CLI')
Expand Down Expand Up @@ -44,25 +47,29 @@ 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

const defaultTeam = teams.find((team) => team.isDefault)
if (!defaultTeam) {
let selected
try {
const me = await fetchMe(accessToken)
if (!me) {
throw new Error(
'Your login was not accepted by workspace-api, please contact support'
)
Comment on lines +52 to +56

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🔴 Unprovisioned users cannot log in

A valid user absent from workspace-api gets a 401 from fetchMe, so login exits without saving credentials. getMe also rejects these users, blocking project resolution for older configs.

Learn more

Browser sign-in can succeed before workspace-api has created a user's account. In that state, fetchMe returns undefined for /v1/me's 401, even though the access token is valid. Login then exits without writing the config; getMe also rejects existing configurations that need workspace-api to find their project. Running login again cannot create the missing account.

Example: Alice signs up, then uses CLI login without opening the dashboard. Browser sign-in returns her access token, but /v1/me returns 401 because Alice is not set up there. Login exits, and repeating it produces the same result instead of selecting her project.

Recommended fix: Restore account initialization on the unprovisioned-user path for both login and getMe, then retry /v1/me. If 401 can also mean an invalid token, distinguish that response before attempting initialization and preserve the rejected-session error for genuinely invalid credentials.

Devin Review


Was this helpful? React with 👍 or 👎 to provide feedback.

}
// 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('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,
Expand All @@ -79,8 +86,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)
Expand Down
4 changes: 1 addition & 3 deletions packages/cli/src/commands/template/create.ts
Original file line number Diff line number Diff line change
@@ -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,
Expand Down Expand Up @@ -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 {
Expand All @@ -120,7 +119,6 @@ export const createCommand = new commander.Command('create')
minFreeDiskMb,
skipCache: opts.cache === false,
...auth,
domain: domain,
onBuildLogs: defaultBuildLogger(),
})
} catch (error) {
Expand Down
15 changes: 6 additions & 9 deletions packages/cli/src/commands/template/list.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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,
})

Expand Down Expand Up @@ -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) {
Expand Down
Loading
Loading