Skip to content
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

feat(cli): token command #7961

Open
wants to merge 1 commit into
base: next
Choose a base branch
from
Open
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
1 change: 1 addition & 0 deletions packages/@sanity/cli/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -62,6 +62,7 @@
"@sanity/telemetry": "^0.7.7",
"@sanity/util": "3.65.1",
"chalk": "^4.1.2",
"cli-table3": "^0.6.5",
"debug": "^4.3.4",
"decompress": "^4.2.0",
"esbuild": "0.21.5",
Expand Down
51 changes: 51 additions & 0 deletions packages/@sanity/cli/src/actions/token/createToken.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,51 @@
import {randomBytes} from 'node:crypto'

import {debug} from '../../debug'
import {type CliCommandArguments, type CliCommandContext} from '../../types'
import {API_VERSION, fetchRoles, selectProject} from './tokenUtils'

export async function createToken(
args: CliCommandArguments,
context: CliCommandContext,
): Promise<void> {
const {output, cliConfig, prompt, apiClient} = context
const {print} = output
const client = apiClient({requireUser: true, requireProject: false})

const projectId = cliConfig?.api?.projectId || (await selectProject(client, prompt))
const roles = await fetchRoles(client, projectId)

debug('Filtering roles that apply to robots')
const robotRoles = roles.filter((role) => role.appliesToRobots)
const roleChoices = robotRoles.map((role) => ({
value: role.name,
name: `${role.title} (${role.name})`,
}))

debug('Prompting for token role')
const roleName = await prompt.single({
message: 'Choose access level for the token:',
type: 'list',
choices: roleChoices,
default: 'viewer',
})

debug('Prompting for token label')
const selectedRole = robotRoles.find((role) => role.name === roleName)
const label = await prompt.single({
message: 'Give this token a descriptive name:',
type: 'input',
default: `${selectedRole?.title} token (${randomBytes(2).toString('hex')})`,
})

debug('Creating token')
const {key} = await client.config({apiVersion: API_VERSION}).request<{key: string}>({
uri: `/projects/${projectId}/tokens`,
method: 'POST',
body: {label, roleName},
})

print("New token created. Make sure to copy it now - you won't see it again:")
print('')
print(key)
}
56 changes: 56 additions & 0 deletions packages/@sanity/cli/src/actions/token/deleteToken.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,56 @@
import {debug} from '../../debug'
import {type CliCommandArguments, type CliCommandContext} from '../../types'
import {fetchTokens, selectProject} from './tokenUtils'

export async function deleteToken(
args: CliCommandArguments,
context: CliCommandContext,
): Promise<void> {
const {output, cliConfig, prompt, apiClient} = context
const {print} = output
const client = apiClient({requireUser: true, requireProject: false})

const projectId = cliConfig?.api?.projectId || (await selectProject(client, prompt))
let tokenId = args.argsWithoutOptions[0]

if (!tokenId) {
const tokens = await fetchTokens(client, projectId)

if (tokens.length === 0) {
print('No tokens found in project')
return
}

tokens.sort((a, b) => b.createdAt.localeCompare(a.createdAt))

debug('No token ID provided, showing list of choices')
const tokenChoices = tokens.map((token) => ({
value: token.id,
name: `${token.label} (created ${new Date(token.createdAt).toLocaleString()})`,
}))

tokenId = await prompt.single({
message: 'Select token to delete',
type: 'list',
choices: tokenChoices,
})
}

const confirm = await prompt.single({
message: 'Are you sure you want to delete this token?',
type: 'confirm',
})

if (!confirm) {
print('Token deletion cancelled')
return
}

debug('Deleting token:', tokenId)
await client.config({apiVersion: 'v2021-06-07'}).request({
uri: `/projects/${projectId}/tokens/${tokenId}`,
method: 'DELETE',
})

print('Token deleted successfully')
}
39 changes: 39 additions & 0 deletions packages/@sanity/cli/src/actions/token/listTokens.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,39 @@
import Table from 'cli-table3'

import {type CliCommandArguments, type CliCommandContext} from '../../types'
import {fetchTokens, selectProject} from './tokenUtils'

export async function listTokens(
args: CliCommandArguments,
context: CliCommandContext,
): Promise<void> {
const {output, cliConfig, prompt, apiClient} = context
const {print} = output
const client = apiClient({requireUser: true, requireProject: false})

const projectId = cliConfig?.api?.projectId || (await selectProject(client, prompt))
const tokens = await fetchTokens(client, projectId)

if (tokens.length === 0) {
print('No tokens found in project')
return
}

const sortedTokens = tokens.sort(
(a, b) => new Date(b.createdAt).getTime() - new Date(a.createdAt).getTime(),
)

const table = new Table({
head: ['ID', 'Label', 'Roles', 'Created'],
colWidths: [16, 32, 18, 14],
style: {head: []}, // Remove the default header style
})

for (const token of sortedTokens) {
const roles = token.roles.map((role) => role.title).join(', ')
const date = new Date(token.createdAt).toLocaleDateString()
table.push([token.id, token.label, roles, date])
}

print(table.toString())
}
63 changes: 63 additions & 0 deletions packages/@sanity/cli/src/actions/token/tokenUtils.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,63 @@
import {type SanityClient} from '@sanity/client'

import {debug} from '../../debug'
import {type CliPrompter} from '../../types'

export const API_VERSION = 'v2021-06-07'

export type Token = {
id: string
label: string
projectUserId: string
createdAt: string
roles: Array<{
name: string
title: string
}>
}

export type Role = {
name: string
title: string
description: string
isCustom: boolean
projectId: string
appliesToUsers: boolean
appliesToRobots: boolean
}

export async function selectProject(client: SanityClient, prompt: CliPrompter): Promise<string> {
debug('No project ID in config, fetching projects list')
const projects = await client.projects
.list({includeMembers: false})
.then((allProjects) => allProjects.sort((a, b) => b.createdAt.localeCompare(a.createdAt)))

debug(`User has ${projects.length} project(s) already, showing list of choices`)

const projectChoices = projects.map((project) => ({
value: project.id,
name: `${project.displayName} [${project.id}]`,
}))

return prompt.single({
message: 'Select project to use',
type: 'list',
choices: projectChoices,
})
}

export async function fetchTokens(client: SanityClient, projectId: string): Promise<Token[]> {
debug('Fetching tokens for project:', projectId)
return client.config({apiVersion: API_VERSION}).request<Token[]>({
uri: `/projects/${projectId}/tokens`,
method: 'GET',
})
}

export async function fetchRoles(client: SanityClient, projectId: string): Promise<Role[]> {
debug('Fetching roles for project:', projectId)
return client.config({apiVersion: API_VERSION}).request<Role[]>({
uri: `/projects/${projectId}/roles`,
method: 'GET',
})
}
8 changes: 8 additions & 0 deletions packages/@sanity/cli/src/commands/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,10 @@ import disableTelemetryCommand from './telemetry/disableTelemetryCommand'
import enableTelemetryCommand from './telemetry/enableTelemetryCommand'
import telemetryGroup from './telemetry/telemetryGroup'
import telemetryStatusCommand from './telemetry/telemetryStatusCommand'
import tokenCreateCommand from './token/tokenCreateCommand'
import tokenDeleteCommand from './token/tokenDeleteCommand'
import tokenGroup from './token/tokenGroup'
import tokenListCommand from './token/tokenListCommand'
import generateTypegenCommand from './typegen/generateTypesCommand'
import typegenGroup from './typegen/typegenGroup'
import upgradeCommand from './upgrade/upgradeCommand'
Expand All @@ -41,4 +45,8 @@ export const baseCommands: (CliCommandDefinition | CliCommandGroupDefinition)[]
telemetryStatusCommand,
generateTypegenCommand,
typegenGroup,
tokenGroup,
tokenCreateCommand,
tokenDeleteCommand,
tokenListCommand,
]
13 changes: 13 additions & 0 deletions packages/@sanity/cli/src/commands/token/tokenCreateCommand.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
import {createToken} from '../../actions/token/createToken'
import {type CliCommandDefinition} from '../../types'

const tokenCommand: CliCommandDefinition = {
name: 'create',
group: 'token',
signature: '',
helpText: 'Create a new API token for project',
description: 'Create a new API token for project',
action: createToken,
}

export default tokenCommand
13 changes: 13 additions & 0 deletions packages/@sanity/cli/src/commands/token/tokenDeleteCommand.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
import {deleteToken} from '../../actions/token/deleteToken'
import {type CliCommandDefinition} from '../../types'

const tokenDeleteCommand: CliCommandDefinition = {
name: 'delete',
group: 'token',
signature: '[id]',
helpText: 'Delete an API token from project',
description: 'Delete an API token from project',
action: deleteToken,
}

export default tokenDeleteCommand
10 changes: 10 additions & 0 deletions packages/@sanity/cli/src/commands/token/tokenGroup.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
import {type CliCommandGroupDefinition} from '../../types'

const tokenGroup: CliCommandGroupDefinition = {
name: 'token',
signature: '[COMMAND]',
isGroupRoot: true,
description: 'Manages project API tokens',
}

export default tokenGroup
24 changes: 24 additions & 0 deletions packages/@sanity/cli/src/commands/token/tokenListCommand.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,24 @@
import {listTokens} from '../../actions/token/listTokens'
import {type CliCommandDefinition} from '../../types'

type Token = {
id: string
label: string
projectUserId: string
createdAt: string
roles: Array<{
name: string
title: string
}>
}

const tokenListCommand: CliCommandDefinition = {
name: 'list',
group: 'token',
signature: '',
helpText: 'List all API tokens in project',
description: 'List all API tokens in project',
action: listTokens,
}

export default tokenListCommand
Loading
Loading