diff --git a/.changeset/cli-project-commands.md b/.changeset/cli-project-commands.md new file mode 100644 index 0000000000..28fb616c2b --- /dev/null +++ b/.changeset/cli-project-commands.md @@ -0,0 +1,14 @@ +--- +'@e2b/cli': minor +--- + +Add commands for workspaces and projects: + +```sh +e2b workspace list # workspaces you can access +e2b project list [--workspace ] # projects, the current one marked with * +e2b project use [project] [-w ] # set the project commands run in, or pick one +e2b project info # the current project +``` + +A project can be given by its ID, `prj_` ID, slug or name. `project use` without a project asks for the workspace and then the project; `--workspace` picks from that workspace only and scopes a name to it. Commands run in the project from `E2B_PROJECT_ID`, then the one chosen with `e2b project use` (or `e2b auth configure`, which picks it interactively). With `E2B_API_KEY` set, the API key determines the project. diff --git a/packages/cli/src/api.ts b/packages/cli/src/api.ts index 4c0eb04c34..c179d7ed34 100644 --- a/packages/cli/src/api.ts +++ b/packages/cli/src/api.ts @@ -43,6 +43,14 @@ export type Auth = 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`. + */ +export function currentProject(): string | undefined { + return projectId || getUserConfig()?.projectId +} + /** The logged-in user's workspaces and projects, fetched once per process. */ export function getMe(): Promise { me ??= (async () => { @@ -58,9 +66,26 @@ export function getMe(): Promise { return me } +/** + * {@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. + */ +export async function requireMe(): Promise { + if (!getUserConfig()) { + console.error( + `You must be logged in to see your workspaces and projects. Run ${asBold( + 'e2b auth login' + )}.` + ) + process.exit(1) + } + return getMe() +} + /** * The project requests go to, and its control plane: `cliProjectId`, then - * `E2B_PROJECT_ID`, then the project selected with `e2b auth configure`. + * `E2B_PROJECT_ID`, then the project selected with `e2b project use`. */ async function resolveProject( config: UserConfig, @@ -131,14 +156,5 @@ export async function apiClient(cliProjectId?: string) { ) } -export function ensureUserConfig(): UserConfig { - const userConfig = getUserConfig() - if (!userConfig) { - console.error('No user config found, run `e2b auth login` to log in first.') - process.exit(1) - } - return userConfig -} - // 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 2075c9b91a..261d5bbd2b 100644 --- a/packages/cli/src/commands/auth/configure.ts +++ b/packages/cli/src/commands/auth/configure.ts @@ -7,7 +7,7 @@ import { blockedWarning, pickProject } from 'src/workspaces' import { asBold, asFormattedError } from '../../utils/format' export const configureCommand = new commander.Command('configure') - .description('configure user') + .description('choose the project commands run in, interactively') .action(async () => { console.log('Configuring user...\n') diff --git a/packages/cli/src/commands/auth/login.ts b/packages/cli/src/commands/auth/login.ts index 00135d37da..66dfe2e4ef 100644 --- a/packages/cli/src/commands/auth/login.ts +++ b/packages/cli/src/commands/auth/login.ts @@ -34,7 +34,7 @@ export const loginCommand = new commander.Command('login') console.log( `\nAlready logged in. ${asFormattedConfig( userConfig - )}.\n\nIf you want to log in as a different user, log out first by running 'e2b auth logout'.\nTo change the project, run 'e2b auth configure'.\n` + )}.\n\nIf you want to log in as a different user, log out first by running 'e2b auth logout'.\nTo change the project, run 'e2b project use ' (see 'e2b project list').\n` ) return } else if (!userConfig) { diff --git a/packages/cli/src/commands/index.ts b/packages/cli/src/commands/index.ts index edd71e0f24..fcad1ed401 100644 --- a/packages/cli/src/commands/index.ts +++ b/packages/cli/src/commands/index.ts @@ -4,6 +4,8 @@ import { asPrimary } from 'src/utils/format' import { templateCommand } from './template' import { sandboxCommand } from './sandbox' import { authCommand } from './auth' +import { workspaceCommand } from './workspace' +import { projectCommand } from './project' export const program = new commander.Command() // Fixed so help reads `e2b` however the entrypoint is invoked (npm bin @@ -20,5 +22,7 @@ Visit ${asPrimary( )} to learn how to create sandbox templates and start sandboxes.` ) .addCommand(authCommand) + .addCommand(workspaceCommand) + .addCommand(projectCommand) .addCommand(templateCommand) .addCommand(sandboxCommand) diff --git a/packages/cli/src/commands/project/index.ts b/packages/cli/src/commands/project/index.ts new file mode 100644 index 0000000000..824bae7632 --- /dev/null +++ b/packages/cli/src/commands/project/index.ts @@ -0,0 +1,223 @@ +import * as commander from '@commander-js/extra-typings' + +import { currentProject, projectId, requireMe } from 'src/api' +import { Format, parseFormat } from 'src/options' +import { asBold, asFormattedError } from 'src/utils/format' +import { renderTable } from 'src/utils/table' +import { updateUserConfig } from 'src/utils/token-refresh' +import { + allProjects, + blockedWarning, + findProject, + findWorkspace, + Me, + pickProject, + Project, + ProjectEntry, + ProjectStatus, +} from 'src/workspaces' + +const workspaceOption = (purpose: string) => + new commander.Option( + '-w, --workspace ', + `${purpose}: its ID, wrk_ ID or name` + ) + +const formatOption = new commander.Option( + '-f, --format ', + 'output format, eg. json, pretty' +) + .argParser(parseFormat) + .default(Format.PRETTY) + +function fail(err: unknown): never { + console.error( + asFormattedError(err instanceof Error ? err.message : String(err)) + ) + process.exit(1) +} + +/** `active`, `provisioning`, `deleting`, or `blocked` with its reasons. */ +function projectStatus({ status, blocked, block_reasons }: Project) { + if (!blocked) return status + return block_reasons.length + ? `blocked (${block_reasons.join(', ')})` + : 'blocked' +} + +/** The current project, if it is one `me` lists. */ +function findCurrent(me: Me): ProjectEntry | undefined { + const current = currentProject() + if (!current) return undefined + try { + return findProject(me, current) + } catch { + return undefined + } +} + +const listCommand = new commander.Command('list') + .description('list the projects you can access') + .alias('ls') + .addOption(workspaceOption('only list projects in this workspace')) + .addOption(formatOption) + .action(async (opts) => { + try { + const me = await requireMe() + + let entries = allProjects(me) + if (opts.workspace) { + const workspace = findWorkspace(me, opts.workspace) + entries = entries.filter((e) => e.workspace.id === workspace.id) + } + const currentId = findCurrent(me)?.project.id + + if (opts.format === Format.JSON) { + console.log( + JSON.stringify( + entries.map(({ project, workspace }) => ({ + ...project, + workspace_name: workspace.name, + current: project.id === currentId, + })), + null, + 2 + ) + ) + return + } + + if (!entries.length) { + console.log('No projects found') + return + } + renderTable(entries, [ + { + header: 'Current', + value: ({ project }) => (project.id === currentId ? '*' : ''), + }, + { header: 'Project ID', value: ({ project }) => project.public_id }, + { header: 'Name', value: ({ project }) => project.name }, + { header: 'Workspace', value: ({ workspace }) => workspace.name }, + { header: 'Region', value: ({ project }) => project.region }, + { header: 'Status', value: ({ project }) => projectStatus(project) }, + ]) + } catch (err) { + fail(err) + } + }) + +const useCommand = new commander.Command('use') + .description( + 'set the project commands run in by default; without a project, pick one' + ) + .argument('[project]', 'the project: its ID, prj_ ID, slug or name') + .addOption(workspaceOption('the workspace to pick from or look in')) + .action(async (ref, opts) => { + try { + const me = await requireMe() + const workspace = opts.workspace + ? findWorkspace(me, opts.workspace) + : undefined + let entry: ProjectEntry + if (ref) { + entry = findProject(me, ref, workspace) + } else { + if (!process.stdin.isTTY) { + fail( + `Pass the project to use: ${asBold('e2b project use ')}.` + ) + } + entry = await pickProject(me, { + message: 'Select project', + selectedId: findCurrent(me)?.project.id, + workspace, + }) + } + const { project, workspace: owner } = entry + if (project.status !== ProjectStatus.Active) { + fail( + `Project ${project.name} is ${project.status} and can't be used. Pick an active project from ${asBold( + 'e2b project list' + )}, or try again once it is active.` + ) + } + const warning = blockedWarning(entry) + if (warning) console.warn(warning) + + await updateUserConfig({ + projectId: project.id, + projectName: project.name, + projectDomain: project.control_plane_domain, + }) + console.log( + `Now using project ${asBold(project.name)} (${project.public_id}) in workspace ${asBold(owner.name)}.` + ) + + if (projectId) { + console.warn( + `${asBold('E2B_PROJECT_ID')} is set and takes precedence over the selected project.` + ) + } + if (process.env.E2B_API_KEY) { + console.warn( + `${asBold('E2B_API_KEY')} is set: commands use the API key's project instead.` + ) + } + } catch (err) { + fail(err) + } + }) + +const infoCommand = new commander.Command('info') + .description('show the project commands run in') + .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) + + if (opts.format === Format.JSON) { + console.log( + JSON.stringify( + { + ...project, + workspace_name: workspace.name, + }, + null, + 2 + ) + ) + return + } + + const rows: [string, string][] = [ + ['Name', project.name], + ['Project ID', project.public_id], + ['UUID', project.id], + ['Slug', project.slug], + ['Workspace', `${workspace.name} (${workspace.public_id})`], + ['Region', project.region], + ['Control plane', project.control_plane_domain], + ['Status', projectStatus(project)], + ['Your access', project.capabilities.join(', ') || 'none'], + ] + const width = Math.max(...rows.map(([label]) => label.length)) + 2 + console.log(`\nProject info for ${asBold(project.name)}:`) + for (const [label, value] of rows) { + console.log(` ${`${label}:`.padEnd(width)}${value}`) + } + } catch (err) { + fail(err) + } + }) + +export const projectCommand = new commander.Command('project') + .description('work with projects') + .alias('prj') + .addCommand(listCommand) + .addCommand(useCommand) + .addCommand(infoCommand) diff --git a/packages/cli/src/commands/workspace/index.ts b/packages/cli/src/commands/workspace/index.ts new file mode 100644 index 0000000000..d089f7828a --- /dev/null +++ b/packages/cli/src/commands/workspace/index.ts @@ -0,0 +1,66 @@ +import * as commander from '@commander-js/extra-typings' + +import { requireMe } from 'src/api' +import { Format, parseFormat } from 'src/options' +import { asFormattedError } from 'src/utils/format' +import { renderTable } from 'src/utils/table' +import { Workspace } from 'src/workspaces' + +/** `active`, or `blocked` with its reasons. */ +function workspaceStatus({ blocked, block_reasons }: Workspace) { + if (!blocked) return 'active' + return block_reasons.length + ? `blocked (${block_reasons.join(', ')})` + : 'blocked' +} + +const listCommand = new commander.Command('list') + .description('list the workspaces you can access') + .alias('ls') + .option( + '-f, --format ', + 'output format, eg. json, pretty', + parseFormat, + Format.PRETTY + ) + .action(async (opts) => { + try { + const { workspaces } = await requireMe() + + if (opts.format === Format.JSON) { + console.log( + JSON.stringify( + workspaces.map(({ projects, ...workspace }) => ({ + ...workspace, + project_count: projects.length, + })), + null, + 2 + ) + ) + return + } + + if (!workspaces.length) { + console.log('No workspaces found') + return + } + renderTable(workspaces, [ + { header: 'Workspace ID', value: (w) => w.public_id }, + { header: 'Name', value: (w) => w.name }, + { header: 'Kind', value: (w) => w.kind }, + { header: 'Projects', value: (w) => String(w.projects.length) }, + { header: 'Status', value: workspaceStatus }, + ]) + } catch (err) { + console.error( + asFormattedError(err instanceof Error ? err.message : String(err)) + ) + process.exit(1) + } + }) + +export const workspaceCommand = new commander.Command('workspace') + .description('work with workspaces') + .alias('ws') + .addCommand(listCommand) diff --git a/packages/cli/src/workspaces.ts b/packages/cli/src/workspaces.ts index 1054067767..d9d751caef 100644 --- a/packages/cli/src/workspaces.ts +++ b/packages/cli/src/workspaces.ts @@ -141,6 +141,28 @@ export function personalProject(me: Me): ProjectEntry | undefined { ) } +/** Find a workspace by ID (UUID or `wrk_` public ID) or unambiguous name. */ +export function findWorkspace(me: Me, ref: string): Workspace { + const exact = me.workspaces.find((w) => w.id === ref || w.public_id === ref) + if (exact) return exact + const byName = me.workspaces.filter( + (w) => w.name.toLowerCase() === ref.toLowerCase() + ) + if (byName.length === 1) return byName[0] + if (byName.length > 1) { + throw new Error( + `More than one workspace is named "${ref}": ${byName + .map((w) => w.public_id) + .join(', ')}. Pass the workspace ID instead.` + ) + } + throw new Error( + `Workspace "${ref}" not found. Run ${asBold( + 'e2b workspace list' + )} to see your workspaces.` + ) +} + /** * 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 @@ -180,7 +202,7 @@ export function findProject( 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.` + }. Run ${asBold('e2b project list')} to see them.` ) } diff --git a/packages/cli/tests/commands/project.test.ts b/packages/cli/tests/commands/project.test.ts new file mode 100644 index 0000000000..d7580e5485 --- /dev/null +++ b/packages/cli/tests/commands/project.test.ts @@ -0,0 +1,266 @@ +import { afterEach, beforeEach, describe, expect, test, vi } from 'vitest' + +import type { Me, Project } 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: `${id}.e2b.dev`, + status: 'active', + blocked: false, + block_reasons: [], + capabilities: ['project:read'], + ...overrides, + } +} + +const me: Me = { + workspaces: [ + { + id: 'personal', + public_id: 'wrk_personal', + name: 'Personal', + kind: 'personal', + blocked: false, + block_reasons: [], + capabilities: [], + projects: [project('mine')], + }, + { + id: 'team', + public_id: 'wrk_team', + name: 'Team', + kind: 'standard', + blocked: false, + block_reasons: [], + capabilities: [], + projects: [ + project('shared', { + blocked: true, + block_reasons: ['credit_exhausted'], + }), + project('new', { status: 'provisioning' }), + ], + }, + ], +} + +const mocks = vi.hoisted(() => ({ + updateUserConfig: vi.fn(), + current: undefined as string | undefined, + select: vi.fn(), +})) + +vi.mock('@inquirer/prompts', () => ({ select: mocks.select })) + +vi.mock( + '../../src/api', + async (importOriginal: () => Promise) => ({ + ...(await importOriginal()), + requireMe: async () => me, + currentProject: () => mocks.current, + }) +) + +vi.mock( + '../../src/utils/token-refresh', + async ( + importOriginal: () => Promise< + typeof import('../../src/utils/token-refresh') + > + ) => ({ + ...(await importOriginal()), + updateUserConfig: mocks.updateUserConfig, + }) +) + +async function run(args: string[]) { + const out: string[] = [] + const err: string[] = [] + vi.spyOn(console, 'log').mockImplementation((...a: unknown[]) => + out.push(a.join(' ')) + ) + vi.spyOn(console, 'warn').mockImplementation((...a: unknown[]) => + err.push(a.join(' ')) + ) + vi.spyOn(console, 'error').mockImplementation((...a: unknown[]) => + err.push(a.join(' ')) + ) + 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(args, { from: 'user' }) + .catch((e: Error) => err.push(e.message)) + return { out: out.join('\n'), err: err.join('\n'), exit } +} + +beforeEach(() => { + vi.resetModules() + vi.clearAllMocks() + mocks.current = 'mine' +}) + +afterEach(() => { + vi.restoreAllMocks() +}) + +describe('e2b workspace list', () => { + test('lists workspaces with their project count', async () => { + const { out } = await run(['workspace', 'list', '--format', 'json']) + expect(JSON.parse(out)).toEqual([ + expect.objectContaining({ public_id: 'wrk_personal', project_count: 1 }), + expect.objectContaining({ public_id: 'wrk_team', project_count: 2 }), + ]) + }) +}) + +describe('e2b project list', () => { + test('marks the current project and shows blocked ones', async () => { + const { out } = await run(['project', 'list']) + expect(out).toMatch(/\*\s+prj_mine/) + expect(out).toContain('blocked (credit_exhausted)') + expect(out).toContain('provisioning') + }) + + test('filters by --workspace', async () => { + const { out } = await run(['project', 'list', '-w', 'team', '-f', 'json']) + expect(JSON.parse(out).map((p: Project) => p.public_id)).toEqual([ + 'prj_shared', + 'prj_new', + ]) + }) + + test('rejects an unknown workspace', async () => { + const { err } = await run(['project', 'list', '--workspace', 'nope']) + expect(err).toContain('Workspace "nope" not found') + }) + + test('rejects an ambiguous workspace name', async () => { + me.workspaces.push({ ...me.workspaces[1], id: 'team2', public_id: 'wrk_team2', projects: [] }) + try { + const { err } = await run(['project', 'list', '--workspace', 'TEAM']) + expect(err).toContain('More than one workspace is named "TEAM"') + expect(err).toContain('wrk_team, wrk_team2') + } finally { + me.workspaces.pop() + } + }) +}) + +describe('e2b project use', () => { + test('stores the project, its name and control plane', async () => { + const { out } = await run(['project', 'use', 'slug-shared']) + expect(mocks.updateUserConfig).toHaveBeenCalledWith({ + projectId: 'shared', + projectName: 'Project shared', + projectDomain: 'shared.e2b.dev', + }) + expect(out).toContain('Now using project') + }) + + test('warns about a blocked project', async () => { + const { err } = await run(['project', 'use', 'prj_shared']) + expect(err).toContain('is blocked (credit_exhausted)') + }) + + test('refuses a project that is not active', async () => { + const { err } = await run(['project', 'use', 'prj_new']) + expect(err).toContain("is provisioning and can't be used") + expect(mocks.updateUserConfig).not.toHaveBeenCalled() + }) + + test('looks a name up in --workspace only', async () => { + const { err } = await run([ + 'project', + 'use', + 'Project mine', + '--workspace', + 'Team', + ]) + expect(err).toContain('not found among the projects you can access in workspace Team') + expect(mocks.updateUserConfig).not.toHaveBeenCalled() + }) + + describe('without a project', () => { + afterEach(() => { + Object.defineProperty(process.stdin, 'isTTY', { + value: undefined, + configurable: true, + }) + }) + + test('asks for the workspace, then the project', async () => { + Object.defineProperty(process.stdin, 'isTTY', { + value: true, + configurable: true, + }) + type Prompt = { message: string; choices: { value: unknown }[] } + mocks.select + .mockImplementationOnce(async (p: Prompt) => p.choices[1].value) + .mockImplementationOnce(async (p: Prompt) => p.choices[0].value) + + const { out } = await run(['project', 'use']) + + expect( + mocks.select.mock.calls.map(([p]: [Prompt]) => p.message) + ).toEqual(['Select workspace', 'Select project']) + // Team's only active project is still offered, not selected outright. + expect(mocks.select.mock.calls[1][0].choices).toHaveLength(1) + expect(mocks.updateUserConfig).toHaveBeenCalledWith( + expect.objectContaining({ projectId: 'shared' }) + ) + expect(out).toContain('in workspace Team') + }) + + test('only offers --workspace', async () => { + Object.defineProperty(process.stdin, 'isTTY', { + value: true, + configurable: true, + }) + type Prompt = { message: string; choices: { value: unknown }[] } + mocks.select.mockImplementationOnce( + async (p: Prompt) => p.choices[0].value + ) + const { out } = await run(['project', 'use', '--workspace', 'wrk_team']) + // No workspace prompt; the project prompt lists Team's projects only. + expect( + mocks.select.mock.calls.map(([p]: [Prompt]) => p.message) + ).toEqual(['Select project']) + expect(out).toContain('Now using project Project shared') + }) + + test('needs a terminal', async () => { + const { err } = await run(['project', 'use']) + expect(err).toContain('Pass the project to use') + expect(mocks.updateUserConfig).not.toHaveBeenCalled() + }) + }) +}) + +describe('e2b project info', () => { + test('shows the current project', async () => { + mocks.current = 'prj_shared' + const { out } = await run(['project', 'info', '-f', 'json']) + expect(JSON.parse(out)).toMatchObject({ + id: 'shared', + control_plane_domain: 'shared.e2b.dev', + workspace_name: 'Team', + }) + }) + + test('fails when no project is selected', async () => { + mocks.current = undefined + const { err } = await run(['project', 'info']) + expect(err).toContain('No project selected') + }) +})