diff --git a/.changeset/cli-workspace-api-projects.md b/.changeset/cli-workspace-api-projects.md new file mode 100644 index 0000000000..1ab0bec9bb --- /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 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/api.ts b/packages/cli/src/api.ts index dc6d9ec69d..4c0eb04c34 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,63 @@ 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) + // 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 } +} /** * 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 +107,20 @@ 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) + process.exit(1) + } + return { apiHeaders: { Authorization: `Bearer ${accessToken}`, - 'X-Team-ID': resolveProjectId(cliProjectId) ?? config.projectId, + 'X-Team-ID': project.id, }, + domain: project.domain, } } @@ -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() 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..00135d37da 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,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') @@ -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' + ) + } + // 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, @@ -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) 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/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/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..b53e4a4912 100644 --- a/packages/cli/src/utils/token-refresh.ts +++ b/packages/cli/src/utils/token-refresh.ts @@ -153,3 +153,30 @@ export async function ensureValidAccessToken(): Promise<{ CONFIG_LOCK ) } + +/** + * Apply `patch` to the stored config under the config lock, so it can't + * 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 | ((config: UserConfig) => Partial) +): Promise { + 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, + ...(typeof patch === 'function' ? patch(config) : patch), + } + writeUserConfig(USER_CONFIG_PATH, updated) + return updated + }, + CONFIG_LOCK + ) +} diff --git a/packages/cli/src/workspaces.ts b/packages/cli/src/workspaces.ts new file mode 100644 index 0000000000..1054067767 --- /dev/null +++ b/packages/cli/src/workspaces.ts @@ -0,0 +1,276 @@ +import { select } from '@inquirer/prompts' +import { ConnectionConfig } from 'e2b' +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) }) + +/** 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 + +/** 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(/\/+$/, '') + + // 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' + } + 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(WORKSPACE_API_REQUEST_TIMEOUT_MS), + }) +} + +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 +} + +/** 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 === ProjectStatus.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 === WorkspaceKind.Personal + ) +} + +/** + * 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, + 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 + ) + 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 matches "${ref}": ${matches + .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${ + workspace ? ` in workspace ${workspace.name}` : '' + }. Run ${asBold('e2b auth configure')} to choose one.` + ) +} + +/** 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 (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)} (${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: first the workspace, then one of its + * 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. + */ +export async function pickProject( + me: Me, + { + message, + selectedId, + workspace, + }: { message: string; selectedId?: string; workspace?: Workspace } +): Promise { + const entries = activeProjects(me).filter( + (entry) => !workspace || entry.workspace.id === workspace.id + ) + if (entries.length === 0) { + throw new Error( + 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 (!process.stdin.isTTY) return current ?? entries[0] + + const workspaces = me.workspaces.filter((candidate) => + entries.some((entry) => entry.workspace.id === candidate.id) + ) + const chosen = + 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) + return select({ + message, + pageSize: 20, + choices: candidates.map((entry) => ({ + name: projectLabel(entry, selectedId), + value: entry, + })), + default: current?.workspace.id === chosen.id ? current : undefined, + }) +} diff --git a/packages/cli/tests/auth.test.ts b/packages/cli/tests/auth.test.ts index 709506f0a4..610bbaa040 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,86 @@ 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('exits with the login hint when workspace-api rejects the session', async () => { + writeConfig(jwt(3600), 'refresh-1', {}) + 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') + + 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 () => { + writeConfig(jwt(3600)) + vi.stubEnv('E2B_PROJECT_ID', 'prj_other') + const fetchMock = mockWorkspaceApi([ + project('config-project'), + project('other'), + project('flag'), + ]) + const { ensureAuth } = await import('../src/api') + + 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('selects the project from --project, then E2B_PROJECT_ID', async () => { + test('exits with a hint when the project is not accessible', async () => { writeConfig(jwt(3600)) - vi.stubEnv('E2B_PROJECT_ID', 'env-project') + 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') - 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') + 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..bca364a974 --- /dev/null +++ b/packages/cli/tests/workspaces.test.ts @@ -0,0 +1,346 @@ +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, + 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' }), + ]), + workspace('other', [ + project('d', { slug: 'slug-b' }), + project('e', { slug: '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 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') + }) +}) + +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' + ) + }) + + 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('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('asks even when a step has a single choice', async () => { + withTTY() + 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( + 'a' + ) + expect(asked()).toEqual([ + { message: 'Select workspace', names: [expect.any(String)] }, + { message: 'Pick', names: [expect.stringContaining('Project a')] }, + ]) + }) + + 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', () => { + 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() + }) +})