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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
14 changes: 14 additions & 0 deletions .changeset/cli-project-commands.md
Original file line number Diff line number Diff line change
@@ -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 <ws>] # projects, the current one marked with *
e2b project use [project] [-w <ws>] # 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.
36 changes: 26 additions & 10 deletions packages/cli/src/api.ts
Original file line number Diff line number Diff line change
Expand Up @@ -43,6 +43,14 @@ export type Auth =

let me: Promise<Me> | 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
}
Comment thread
mishushakov marked this conversation as resolved.
Resolved

/** The logged-in user's workspaces and projects, fetched once per process. */
export function getMe(): Promise<Me> {
me ??= (async () => {
Expand All @@ -58,9 +66,26 @@ export function getMe(): Promise<Me> {
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<Me> {
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,
Expand Down Expand Up @@ -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()
2 changes: 1 addition & 1 deletion packages/cli/src/commands/auth/configure.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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')

Expand Down
2 changes: 1 addition & 1 deletion packages/cli/src/commands/auth/login.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 <project>' (see 'e2b project list').\n`
)
return
} else if (!userConfig) {
Expand Down
4 changes: 4 additions & 0 deletions packages/cli/src/commands/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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)
223 changes: 223 additions & 0 deletions packages/cli/src/commands/project/index.ts
Original file line number Diff line number Diff line change
@@ -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 <workspace>',
`${purpose}: its ID, wrk_ ID or name`
)

const formatOption = new commander.Option(
'-f, --format <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 <project>')}.`
)
}
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 <project>')}.`)
}
Comment thread
cursor[bot] marked this conversation as resolved.
Resolved
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)
66 changes: 66 additions & 0 deletions packages/cli/src/commands/workspace/index.ts
Original file line number Diff line number Diff line change
@@ -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 <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)
Loading
Loading