diff --git a/.changeset/cli-access-token-env.md b/.changeset/cli-access-token-env.md new file mode 100644 index 0000000000..aff4793ced --- /dev/null +++ b/.changeset/cli-access-token-env.md @@ -0,0 +1,5 @@ +--- +'@e2b/cli': minor +--- + +`E2B_ACCESS_TOKEN` authenticates the CLI with an OAuth access token, for CI and other places without a login. It needs `E2B_PROJECT_ID`, which is the project for every request, like `E2B_API_KEY` decides the project on its own; setting both credentials is refused, and a stored login is ignored while the token is set. `e2b workspace list` and `e2b project list` work with the token alone, to find the project to set; `e2b project use` doesn't work with it. The token is used as is: once it expires, set a new one. The variable once held the dashboard's access tokens, which no longer work: such a value is ignored with a warning, as if the variable were unset. diff --git a/packages/cli/package.json b/packages/cli/package.json index c42d218e2a..5ffec5404c 100644 --- a/packages/cli/package.json +++ b/packages/cli/package.json @@ -85,6 +85,7 @@ "commander": "~15.0.0", "e2b": "workspace:^", "handlebars": "^4.7.10", + "jose": "^6.2.16", "openapi-fetch": "^0.17.0", "proper-lockfile": "^4.1.2", "simple-update-notifier": "^2.0.0", diff --git a/packages/cli/src/api.ts b/packages/cli/src/api.ts index c179d7ed34..c4179ca84a 100644 --- a/packages/cli/src/api.ts +++ b/packages/cli/src/api.ts @@ -4,7 +4,11 @@ import * as e2b from 'e2b' 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 { + decodeJwtExp, + ensureValidAccessToken, + updateUserConfig, +} from './utils/token-refresh' import { fetchMe, findProject, Me } from './workspaces' // Must run before any ConnectionConfig is constructed (including the @@ -13,6 +17,24 @@ e2b.ConnectionConfig.setIntegration(`e2b-cli/${packageJSON.version}`) export const projectId = process.env.E2B_PROJECT_ID || process.env.E2B_TEAM_ID +/** + * An OAuth access token given instead of logging in. It is used as is (there + * is no refresh token to renew it with), and the stored login is ignored. + * + * The variable used to hold the dashboard's access tokens, which no longer + * work and which some shells and CI jobs still export. A value that isn't an + * OAuth access token (a JWT) is ignored with a warning, as if it were unset. + */ +export const envAccessToken = (() => { + const token = process.env.E2B_ACCESS_TOKEN + if (!token) return undefined + if (decodeJwtExp(token)) return token + console.warn( + `${asBold('E2B_ACCESS_TOKEN')} is not an OAuth access token and is ignored: access tokens from the dashboard are no longer supported. Unset it to remove this warning.` + ) + return undefined +})() + const authErrorBox = () => { const body = `You must be logged in to use this command. Run ${asBold( 'e2b auth login' @@ -20,7 +42,7 @@ const authErrorBox = () => { If you are seeing this message in CI/CD you may need to set the ${asBold( 'E2B_API_KEY' - )} environment variable. + )} (or ${asBold('E2B_ACCESS_TOKEN')}) environment variable. Visit ${asPrimary('https://e2b.dev/dashboard?tab=keys')} to get the API key.` return boxen.default(body, { width: 70, @@ -45,20 +67,68 @@ let me: Promise | undefined /** * The project commands run in: `E2B_PROJECT_ID`, else the one selected with - * `e2b project use`; `undefined` when signed out without `E2B_PROJECT_ID`. + * `e2b project use`. `undefined` when signed out, or with `E2B_ACCESS_TOKEN` + * (which ignores the stored login), without `E2B_PROJECT_ID`. */ export function currentProject(): string | undefined { - return projectId || getUserConfig()?.projectId + if (projectId) return projectId + return envAccessToken ? undefined : getUserConfig()?.projectId +} + +/** + * What is wrong with the credentials in the environment, if anything. + * `E2B_API_KEY` and `E2B_ACCESS_TOKEN` select different projects (the key's + * own, `E2B_PROJECT_ID`), so both at once is a mistake, not a precedence. + */ +export function envCredentialsError(): string | undefined { + if (process.env.E2B_API_KEY && envAccessToken) { + return `Both ${asBold('E2B_API_KEY')} and ${asBold( + 'E2B_ACCESS_TOKEN' + )} are set. Unset one of them.` + } + return undefined +} + +/** + * `E2B_ACCESS_TOKEN` needs the project named, like the key names its own, + * before any request goes to a project. Listing workspaces and projects + * doesn't, so `e2b project list` can find it. + */ +export const accessTokenNeedsProject = () => + `${asBold('E2B_ACCESS_TOKEN')} needs ${asBold( + 'E2B_PROJECT_ID' + )}: set it to the project requests go to (its ID, prj_ ID, slug or name). Run ${asBold( + 'e2b project list' + )} to see your projects.` + +/** + * `E2B_ACCESS_TOKEN`, checked not to have expired, since it can't be + * refreshed. + */ +function checkedEnvAccessToken(): string { + const exp = decodeJwtExp(envAccessToken!)! + if (Math.floor(Date.now() / 1000) >= exp) { + throw new Error( + `${asBold('E2B_ACCESS_TOKEN')} has expired. Set a current one, or unset it and run ${asBold( + 'e2b auth login' + )}.` + ) + } + return envAccessToken! } -/** The logged-in user's workspaces and projects, fetched once per process. */ +/** The user's workspaces and projects, fetched once per process. */ export function getMe(): Promise { me ??= (async () => { - const { accessToken } = await ensureValidAccessToken() + const accessToken = envAccessToken + ? checkedEnvAccessToken() + : (await ensureValidAccessToken()).accessToken const result = await fetchMe(accessToken) if (!result) { throw new Error( - 'Your session was not accepted. Run `e2b auth login` again.' + envAccessToken + ? `${asBold('E2B_ACCESS_TOKEN')} was rejected. Set a current access token.` + : 'Your session was not accepted. Run `e2b auth login` again.' ) } return result @@ -68,19 +138,41 @@ export function getMe(): Promise { /** * {@link getMe} for commands about workspaces and projects. workspace-api - * only accepts the login's access token, so these need a login even when - * `E2B_API_KEY` is set. + * only accepts an access token, so these need a login or `E2B_ACCESS_TOKEN` + * even when `E2B_API_KEY` is set. `E2B_PROJECT_ID` is not needed with the + * token: listing projects is how to find one. */ export async function requireMe(): Promise { - if (!getUserConfig()) { + const envError = envCredentialsError() + if (envError) { + console.error(envError) + process.exit(1) + } + if (!envAccessToken && !getUserConfig()) { console.error( `You must be logged in to see your workspaces and projects. Run ${asBold( 'e2b auth login' - )}.` + )} or set ${asBold('E2B_ACCESS_TOKEN')}.` ) process.exit(1) } - return getMe() + try { + return await getMe() + } catch (err) { + console.error(err instanceof Error ? err.message : err) + process.exit(1) + } +} + +/** + * The project every request with `E2B_ACCESS_TOKEN` goes to: `E2B_PROJECT_ID` + * (required, checked in {@link ensureAuth}), resolved through + * `/v1/me` for its UUID and control plane. `--project` plays no part, as + * with `E2B_API_KEY`. + */ +async function resolveEnvProject(): Promise<{ id: string; domain: string }> { + const { project } = findProject(await getMe(), projectId!) + return { id: project.id, domain: project.control_plane_domain } } /** @@ -115,17 +207,43 @@ async function resolveProject( /** * 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 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. + * `E2B_API_KEY` or `E2B_ACCESS_TOKEN` with `E2B_PROJECT_ID`, for CI/CD and + * other non-interactive use (setting both credentials is refused), each + * ignoring the stored login and `cliProjectId`. Otherwise the logged-in + * user's OAuth access token is used, refreshed first when it is about to + * expire. With a token, `X-Team-ID` + * selects the project and requests go 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 envError = + envCredentialsError() ?? + (envAccessToken && !projectId ? accessTokenNeedsProject() : undefined) + if (envError) { + console.error(envError) + process.exit(1) + } const apiKey = process.env.E2B_API_KEY if (apiKey) return { apiKey } + if (envAccessToken) { + try { + const project = await resolveEnvProject() + return { + apiHeaders: { + Authorization: `Bearer ${checkedEnvAccessToken()}`, + 'X-Team-ID': project.id, + }, + domain: project.domain, + } + } catch (err) { + console.error(err instanceof Error ? err.message : err) + process.exit(1) + } + } + if (!getUserConfig()) { console.error(authErrorBox()) process.exit(1) diff --git a/packages/cli/src/commands/auth/configure.ts b/packages/cli/src/commands/auth/configure.ts index 261d5bbd2b..05fdcda290 100644 --- a/packages/cli/src/commands/auth/configure.ts +++ b/packages/cli/src/commands/auth/configure.ts @@ -1,6 +1,6 @@ import * as commander from '@commander-js/extra-typings' -import { getMe } from 'src/api' +import { envAccessToken, getMe } from 'src/api' import { getConfigRefreshTimestamp, getUserConfig } from 'src/user' import { updateUserConfig } from 'src/utils/token-refresh' import { blockedWarning, pickProject } from 'src/workspaces' @@ -11,6 +11,19 @@ export const configureCommand = new commander.Command('configure') .action(async () => { console.log('Configuring user...\n') + // The token's projects are not the login's, and the token ignores the + // stored selection anyway. + if (envAccessToken) { + console.error( + asFormattedError( + `${asBold('E2B_ACCESS_TOKEN')} is set, so the stored project is not used. Choose the project with ${asBold( + 'E2B_PROJECT_ID' + )} instead.` + ) + ) + process.exit(1) + } + // A file that exists but fails validation counts as signed out too — // getUserConfig returns null for it (and prints the deprecation message). const userConfig = getUserConfig() diff --git a/packages/cli/src/commands/auth/info.ts b/packages/cli/src/commands/auth/info.ts index 5381bf379e..b9bc84b57a 100644 --- a/packages/cli/src/commands/auth/info.ts +++ b/packages/cli/src/commands/auth/info.ts @@ -1,5 +1,11 @@ import * as commander from '@commander-js/extra-typings' +import { + accessTokenNeedsProject, + envAccessToken, + envCredentialsError, + projectId, +} from 'src/api' import { getUserConfig } from 'src/user' import { asFormattedConfig, asFormattedError } from 'src/utils/format' @@ -13,8 +19,26 @@ export const infoCommand = new commander.Command('info') console.error(asFormattedError('Failed to read user config', err)) } + const envError = envCredentialsError() + if (envError) { + console.error(asFormattedError(envError)) + process.exit(1) + } + if (process.env.E2B_API_KEY) { + console.log( + `Authenticating with E2B_API_KEY${userConfig ? '; the login below is not used' : ''}.` + ) + } else if (envAccessToken) { + console.log( + `Authenticating with E2B_ACCESS_TOKEN${userConfig ? '; the login below is not used' : ''}.` + ) + // Reported, not fatal: listing workspaces and projects works without it. + if (!projectId) console.warn(accessTokenNeedsProject()) + } if (!userConfig) { - console.log('Not logged in') + if (!envAccessToken && !process.env.E2B_API_KEY) { + console.log('Not logged in') + } return } diff --git a/packages/cli/src/commands/project/index.ts b/packages/cli/src/commands/project/index.ts index 824bae7632..c6f7fc8d33 100644 --- a/packages/cli/src/commands/project/index.ts +++ b/packages/cli/src/commands/project/index.ts @@ -1,6 +1,12 @@ import * as commander from '@commander-js/extra-typings' -import { currentProject, projectId, requireMe } from 'src/api' +import { + accessTokenNeedsProject, + currentProject, + envAccessToken, + projectId, + requireMe, +} from 'src/api' import { Format, parseFormat } from 'src/options' import { asBold, asFormattedError } from 'src/utils/format' import { renderTable } from 'src/utils/table' @@ -45,12 +51,20 @@ function projectStatus({ status, blocked, block_reasons }: Project) { : 'blocked' } -/** The current project, if it is one `me` lists. */ -function findCurrent(me: Me): ProjectEntry | undefined { +/** The project commands run in, which `me` has to list. */ +function resolveCurrent(me: Me): ProjectEntry { const current = currentProject() - if (!current) return undefined + if (current) return findProject(me, current) + // `e2b project use` refuses with the token, which ignores the stored login. + if (envAccessToken) throw new Error(accessTokenNeedsProject()) + throw new Error( + `No project selected. Run ${asBold('e2b project use ')}.` + ) +} + +function findCurrent(me: Me): ProjectEntry | undefined { try { - return findProject(me, current) + return resolveCurrent(me) } catch { return undefined } @@ -115,6 +129,13 @@ const useCommand = new commander.Command('use') .addOption(workspaceOption('the workspace to pick from or look in')) .action(async (ref, opts) => { try { + if (envAccessToken) { + fail( + `${asBold('E2B_ACCESS_TOKEN')} is set, so the stored project is not used. Choose the project with ${asBold( + 'E2B_PROJECT_ID' + )} instead.` + ) + } const me = await requireMe() const workspace = opts.workspace ? findWorkspace(me, opts.workspace) @@ -174,11 +195,7 @@ const infoCommand = new commander.Command('info') .addOption(formatOption) .action(async (opts) => { try { - const current = currentProject() - if (!current) { - fail(`No project selected. Run ${asBold('e2b project use ')}.`) - } - const { project, workspace } = findProject(await requireMe(), current) + const { project, workspace } = resolveCurrent(await requireMe()) if (opts.format === Format.JSON) { console.log( diff --git a/packages/cli/src/utils/token-refresh.ts b/packages/cli/src/utils/token-refresh.ts index b53e4a4912..a856cadbf4 100644 --- a/packages/cli/src/utils/token-refresh.ts +++ b/packages/cli/src/utils/token-refresh.ts @@ -1,4 +1,5 @@ import * as fs from 'fs' +import { decodeJwt } from 'jose' import { getUserConfig, @@ -20,14 +21,10 @@ const CONFIG_LOCK = { timeoutMs: 45_000, } -function decodeJwtExp(token: string): number | undefined { - const payload = token.split('.')[1] - if (!payload) return undefined +/** The `exp` claim of a JWT, or `undefined` for anything else. */ +export function decodeJwtExp(token: string): number | undefined { try { - const decoded = JSON.parse( - Buffer.from(payload, 'base64url').toString('utf8') - ) as { exp?: number } - return decoded.exp + return decodeJwt(token).exp } catch { return undefined } diff --git a/packages/cli/tests/auth.test.ts b/packages/cli/tests/auth.test.ts index 0ae22393a5..433d9a9e64 100644 --- a/packages/cli/tests/auth.test.ts +++ b/packages/cli/tests/auth.test.ts @@ -123,6 +123,7 @@ beforeEach(() => { vi.resetModules() vi.unstubAllEnvs() vi.stubEnv('E2B_API_KEY', '') + vi.stubEnv('E2B_ACCESS_TOKEN', '') vi.stubEnv('E2B_PROJECT_ID', '') vi.stubEnv('E2B_TEAM_ID', '') mockHome = fs.mkdtempSync(path.join(os.tmpdir(), 'e2b-auth-')) @@ -231,6 +232,177 @@ describe('ensureAuth', () => { ) }) + describe('with E2B_ACCESS_TOKEN', () => { + beforeEach(() => vi.stubEnv('E2B_PROJECT_ID', 'prj_other')) + + const headersOf = async (ref?: string) => { + const { ensureAuth } = await import('../src/api') + return (await ensureAuth(ref)) as { + apiHeaders: Record + domain: string + } + } + const failing = () => { + vi.spyOn(process, 'exit').mockImplementation(((code?: number) => { + throw new Error(`exit ${code}`) + }) as never) + return vi.spyOn(console, 'error').mockImplementation(() => {}) + } + + test('uses the token and E2B_PROJECT_ID, ignoring the stored login and --project', async () => { + const envToken = jwt(3600, 'env') + writeConfig(jwt(3600, 'login')) + vi.stubEnv('E2B_ACCESS_TOKEN', envToken) + const fetchMock = mockWorkspaceApi([ + project('config-project'), + project('other'), + project('flag'), + ]) + + const expected = { + apiHeaders: { + Authorization: `Bearer ${envToken}`, + 'X-Team-ID': 'other', + }, + domain: 'other.e2b.dev', + } + expect(await headersOf()).toEqual(expected) + // Like with E2B_API_KEY, the environment decides the project. + expect(await headersOf('slug-flag')).toEqual(expected) + // /v1/me was asked with the env token, not the login's. + const req = fetchMock.mock.calls[0][0] as Request + expect(req.headers.get('Authorization')).toBe(`Bearer ${envToken}`) + expect(fetchMock).toHaveBeenCalledTimes(1) + }) + + test('needs E2B_PROJECT_ID for requests, pointing at project list', async () => { + vi.stubEnv('E2B_ACCESS_TOKEN', jwt(3600)) + vi.stubEnv('E2B_PROJECT_ID', '') + const fetchMock = mockWorkspaceApi() + const error = failing() + const { ensureAuth } = await import('../src/api') + + await expect(ensureAuth()).rejects.toThrow('exit 1') + const message = String(error.mock.calls[0][0]) + expect(message).toContain('E2B_ACCESS_TOKEN needs E2B_PROJECT_ID') + expect(message).toContain('e2b project list') + expect(fetchMock).not.toHaveBeenCalled() + }) + + test('lets requireMe list projects without E2B_PROJECT_ID', async () => { + vi.stubEnv('E2B_ACCESS_TOKEN', jwt(3600)) + vi.stubEnv('E2B_PROJECT_ID', '') + mockWorkspaceApi() + const { currentProject, requireMe } = await import('../src/api') + + expect((await requireMe()).workspaces).toHaveLength(1) + expect(currentProject()).toBeUndefined() + }) + + test('is refused together with E2B_API_KEY', async () => { + vi.stubEnv('E2B_ACCESS_TOKEN', jwt(3600)) + vi.stubEnv('E2B_API_KEY', 'e2b_env_key') + const fetchMock = mockWorkspaceApi() + const error = failing() + const { ensureAuth, requireMe } = await import('../src/api') + + await expect(ensureAuth()).rejects.toThrow('exit 1') + await expect(requireMe()).rejects.toThrow('exit 1') + for (const call of error.mock.calls) { + expect(String(call[0])).toContain( + 'Both E2B_API_KEY and E2B_ACCESS_TOKEN are set. Unset one of them.' + ) + } + expect(fetchMock).not.toHaveBeenCalled() + }) + + test('rejects an expired token', async () => { + vi.stubEnv('E2B_ACCESS_TOKEN', jwt(-10)) + const fetchMock = mockWorkspaceApi() + const error = failing() + + await expect(headersOf()).rejects.toThrow('exit 1') + expect(String(error.mock.calls[0][0])).toContain( + 'E2B_ACCESS_TOKEN has expired' + ) + expect(fetchMock).not.toHaveBeenCalled() + }) + + test('ignores a value that is not an OAuth token, with a warning', async () => { + const loginToken = jwt(3600, 'login') + writeConfig(loginToken) + vi.stubEnv('E2B_ACCESS_TOKEN', 'legacy-dashboard-token') + vi.stubEnv('E2B_PROJECT_ID', '') + const warn = vi.spyOn(console, 'warn').mockImplementation(() => {}) + + // The stored login is used, as if the variable were unset. + expect(await headersOf()).toEqual({ + apiHeaders: { + Authorization: `Bearer ${loginToken}`, + 'X-Team-ID': 'config-project', + }, + domain: 'config.e2b.dev', + }) + expect(warn).toHaveBeenCalledTimes(1) + expect(String(warn.mock.calls[0][0])).toContain( + 'E2B_ACCESS_TOKEN is not an OAuth access token and is ignored' + ) + }) + + test('ignores a value that is not an OAuth token next to E2B_API_KEY', async () => { + vi.stubEnv('E2B_ACCESS_TOKEN', 'legacy-dashboard-token') + vi.stubEnv('E2B_API_KEY', 'e2b_env_key') + vi.spyOn(console, 'warn').mockImplementation(() => {}) + + // Not refused as both credentials set: the key is used. + expect(await headersOf()).toEqual({ apiKey: 'e2b_env_key' }) + }) + + test('names the token when workspace-api rejects it', async () => { + vi.stubEnv('E2B_ACCESS_TOKEN', jwt(3600)) + vi.stubGlobal( + 'fetch', + vi.fn(async () => new Response(null, { status: 401 })) + ) + const error = failing() + + await expect(headersOf()).rejects.toThrow('exit 1') + expect(String(error.mock.calls[0][0])).toContain( + 'E2B_ACCESS_TOKEN was rejected' + ) + }) + + test('auth info reports the token without E2B_PROJECT_ID, warning it is needed', async () => { + vi.stubEnv('E2B_ACCESS_TOKEN', jwt(3600)) + vi.stubEnv('E2B_PROJECT_ID', '') + const log = vi.spyOn(console, 'log').mockImplementation(() => {}) + const warn = vi.spyOn(console, 'warn').mockImplementation(() => {}) + const exit = vi.spyOn(process, 'exit').mockImplementation((( + code?: number + ) => { + throw new Error(`exit ${code}`) + }) as never) + const { program } = await import('../src/commands') + + await program.parseAsync(['auth', 'info'], { from: 'user' }) + expect(String(log.mock.calls[0][0])).toBe( + 'Authenticating with E2B_ACCESS_TOKEN.' + ) + expect(String(warn.mock.calls[0][0])).toContain( + 'E2B_ACCESS_TOKEN needs E2B_PROJECT_ID' + ) + expect(exit).not.toHaveBeenCalled() + }) + + test('lets requireMe list workspaces without a login', async () => { + vi.stubEnv('E2B_ACCESS_TOKEN', jwt(3600)) + mockWorkspaceApi() + const { requireMe } = await import('../src/api') + + expect((await requireMe()).workspaces).toHaveLength(1) + }) + }) + test('exits with the login hint when signed out', async () => { const exit = vi.spyOn(process, 'exit').mockImplementation((( code?: number diff --git a/packages/cli/tests/commands/project.test.ts b/packages/cli/tests/commands/project.test.ts index 7f7e7904b7..ecc97cd61e 100644 --- a/packages/cli/tests/commands/project.test.ts +++ b/packages/cli/tests/commands/project.test.ts @@ -53,6 +53,7 @@ const me: Me = { const mocks = vi.hoisted(() => ({ updateUserConfig: vi.fn(), current: undefined as string | undefined, + envToken: undefined as string | undefined, select: vi.fn(), })) @@ -64,6 +65,10 @@ vi.mock( ...(await importOriginal()), requireMe: async () => me, currentProject: () => mocks.current, + // The real value is read from the environment once, at import. + get envAccessToken() { + return mocks.envToken + }, }) ) @@ -108,6 +113,7 @@ beforeEach(() => { vi.resetModules() vi.clearAllMocks() mocks.current = 'mine' + mocks.envToken = undefined }) afterEach(() => { @@ -271,3 +277,44 @@ describe('e2b project info', () => { expect(err).toContain('No project selected') }) }) + +describe('with E2B_ACCESS_TOKEN', () => { + test('project use refuses, pointing at E2B_PROJECT_ID', async () => { + mocks.envToken = 'token' + const { err } = await run(['project', 'use', 'prj_shared']) + expect(err).toContain('Choose the project with E2B_PROJECT_ID') + expect(mocks.updateUserConfig).not.toHaveBeenCalled() + }) + + test('auth configure refuses, pointing at E2B_PROJECT_ID', async () => { + mocks.envToken = 'token' + const { err } = await run(['auth', 'configure']) + expect(err).toContain('Choose the project with E2B_PROJECT_ID') + expect(mocks.updateUserConfig).not.toHaveBeenCalled() + }) + + test('project list works without E2B_PROJECT_ID, marking none current', async () => { + mocks.current = undefined + mocks.envToken = 'token' + const { out } = await run(['project', 'list', '-f', 'json']) + const rows = JSON.parse(out) as { current: boolean }[] + expect(rows.length).toBeGreaterThan(0) + expect(rows.some((r) => r.current)).toBe(false) + }) + + test('project info without E2B_PROJECT_ID asks for it', async () => { + mocks.current = undefined + mocks.envToken = 'token' + const { err } = await run(['project', 'info']) + expect(err).toContain('E2B_ACCESS_TOKEN needs E2B_PROJECT_ID') + expect(err).toContain('e2b project list') + }) + + test('project list marks the E2B_PROJECT_ID project current', async () => { + mocks.current = 'prj_shared' + mocks.envToken = 'token' + const { out } = await run(['project', 'list', '-f', 'json']) + const rows = JSON.parse(out) as { id: string; current: boolean }[] + expect(rows.find((r) => r.current)?.id).toBe('shared') + }) +}) diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index a2a894daf3..a374b97a06 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -128,6 +128,9 @@ importers: handlebars: specifier: ^4.7.10 version: 4.7.10 + jose: + specifier: ^6.2.16 + version: 6.2.16 openapi-fetch: specifier: ^0.17.0 version: 0.17.0 @@ -2541,6 +2544,9 @@ packages: resolution: {integrity: sha512-AC/7JofJvZGrrneWNaEnJeOLUx+JlGt7tNa0wZiRPT4MY1wmfKjt2+6O2p2uz2+skll8OZZmJMNqeke7kKbNgQ==} hasBin: true + jose@6.2.16: + resolution: {integrity: sha512-4LSJpbuBuPdGrONCdZUy0xnxtLf8eEz+EY0SMXdHQEBU7bgUHDVblU/JAjryv3wID9/LK7/YuwFwq+AQNfMscQ==} + js-levenshtein@1.1.6: resolution: {integrity: sha512-X2BB11YZtrRqY4EnQcLX5Rh373zbK4alC1FW7D7MBhL2gtcC17cTnr6DmfHZeS0s2rTHjUTMMHfG7gO8SSdw+g==} engines: {node: '>=0.10.0'} @@ -5201,6 +5207,8 @@ snapshots: jiti@2.7.0: {} + jose@6.2.16: {} + js-levenshtein@1.1.6: {} js-tokens@10.0.0: {}