Repository navigation
feat(cli): pick projects from workspace-api, route to each project's plane #2104
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Changes from all commits
7fba07e
47e06f2
642d443
cf7df2e
975a377
529f7b4
9fb2c94
f62505c
90650c5
3487696
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| 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`. |
| 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(), | ||
| }) | ||
|
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) | ||
| } | ||
| }) | ||
| 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 { | ||
|
|
@@ -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' | ||
| ) | ||
|
Comment on lines
+52
to
+56
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe 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 Learn moreBrowser sign-in can succeed before workspace-api has created a user's account. In that state, fetchMe returns undefined for Example: Alice signs up, then uses CLI login without opening the dashboard. Browser sign-in returns her access token, but Recommended fix: Restore account initialization on the unprovisioned-user path for both login and 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, | ||
|
|
@@ -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) | ||
|
|
||
Uh oh!
There was an error while loading. Please reload this page.