diff --git a/node-src/index.test.ts b/node-src/index.test.ts index ba770b9e0..ece046695 100644 --- a/node-src/index.test.ts +++ b/node-src/index.test.ts @@ -75,7 +75,10 @@ vi.mock('node-fetch', () => ({ // Authenticate if (query?.match('CreateAppTokenMutation')) { - return { data: { createAppToken: 'token' } }; + return { data: { appToken: 'token' } }; + } + if (query?.match('CreateCLITokenMutation')) { + return { data: { cliToken: 'token' } }; } if (query?.match('AnnounceBuildMutation')) { @@ -401,6 +404,14 @@ it('runs in simple situations', async () => { }); }); +it('supports projectId + userToken', async () => { + const ctx = getContext([]); + ctx.env.CHROMATIC_PROJECT_TOKEN = ''; + ctx.extraOptions = { projectId: 'project-id', userToken: 'user-token' }; + await runAll(ctx); + expect(ctx.exitCode).toBe(1); +}); + it('returns 0 with exit-zero-on-changes', async () => { const ctx = getContext(['--project-token=asdf1234', '--exit-zero-on-changes']); await runAll(ctx); diff --git a/node-src/io/GraphQLClient.ts b/node-src/io/GraphQLClient.ts index acf9230f5..c66c5c768 100644 --- a/node-src/io/GraphQLClient.ts +++ b/node-src/io/GraphQLClient.ts @@ -33,13 +33,13 @@ export default class GraphQLClient { async runQuery( query: string, variables: Record, - { headers = {}, retries = 2 } = {} + { endpoint = this.endpoint, headers = {}, retries = 2 } = {} ): Promise { return retry( async (bail) => { const { data, errors } = await this.client .fetch( - this.endpoint, + endpoint, { body: JSON.stringify({ query, variables }), headers: { ...this.headers, ...headers }, diff --git a/node-src/lib/getOptions.ts b/node-src/lib/getOptions.ts index 2e25e3230..fff9cdc88 100644 --- a/node-src/lib/getOptions.ts +++ b/node-src/lib/getOptions.ts @@ -148,7 +148,7 @@ export default function getOptions({ log.setInteractive(false); } - if (!options.projectToken) { + if (!options.projectToken && !(options.projectId && options.userToken)) { throw new Error(missingProjectToken()); } diff --git a/node-src/tasks/auth.test.ts b/node-src/tasks/auth.test.ts index 2efef9757..47c1febf8 100644 --- a/node-src/tasks/auth.test.ts +++ b/node-src/tasks/auth.test.ts @@ -5,9 +5,21 @@ import { setAuthorizationToken } from './auth'; describe('setAuthorizationToken', () => { it('updates the GraphQL client with an app token from the index', async () => { const client = { runQuery: vi.fn(), setAuthorization: vi.fn() }; - client.runQuery.mockReturnValue({ createAppToken: 'token' }); + client.runQuery.mockReturnValue({ appToken: 'app-token' }); await setAuthorizationToken({ client, options: { projectToken: 'test' } } as any); - expect(client.setAuthorization).toHaveBeenCalledWith('token'); + expect(client.setAuthorization).toHaveBeenCalledWith('app-token'); + }); + + it('supports projectId + userToken', async () => { + const client = { runQuery: vi.fn(), setAuthorization: vi.fn() }; + client.runQuery.mockReturnValue({ cliToken: 'cli-token' }); + + await setAuthorizationToken({ + client, + env: { CHROMATIC_INDEX_URL: 'https://index.chromatic.com' }, + options: { projectId: 'Project:abc123', userToken: 'user-token' }, + } as any); + expect(client.setAuthorization).toHaveBeenCalledWith('cli-token'); }); }); diff --git a/node-src/tasks/auth.ts b/node-src/tasks/auth.ts index cd4481f8d..3fcd1952f 100644 --- a/node-src/tasks/auth.ts +++ b/node-src/tasks/auth.ts @@ -1,31 +1,59 @@ import { createTask, transitionTo } from '../lib/tasks'; import { Context } from '../types'; +import invalidProjectId from '../ui/messages/errors/invalidProjectId'; import invalidProjectToken from '../ui/messages/errors/invalidProjectToken'; import { authenticated, authenticating, initial } from '../ui/tasks/auth'; +const CreateCLITokenMutation = ` + mutation CreateCLITokenMutation($projectId: String!) { + cliToken: createCLIToken(projectId: $projectId) + } +`; + +// Legacy mutation const CreateAppTokenMutation = ` mutation CreateAppTokenMutation($projectToken: String!) { - createAppToken(code: $projectToken) + appToken: createAppToken(code: $projectToken) } `; -interface CreateAppTokenMutationResult { - createAppToken: string; -} +const getToken = async (ctx: Context) => { + const { projectId, projectToken, userToken } = ctx.options; -export const setAuthorizationToken = async (ctx: Context) => { - const { client, options } = ctx; - const variables = { projectToken: options.projectToken }; + if (projectId && userToken) { + const { cliToken } = await ctx.client.runQuery<{ cliToken: string }>( + CreateCLITokenMutation, + { projectId }, + { + endpoint: `${ctx.env.CHROMATIC_INDEX_URL}/api`, + headers: { Authorization: `Bearer ${userToken}` }, + } + ); + return cliToken; + } + if (projectToken) { + const { appToken } = await ctx.client.runQuery<{ appToken: string }>(CreateAppTokenMutation, { + projectToken, + }); + return appToken; + } + + // Should never happen since we check for this in getOptions + throw new Error('No projectId or projectToken'); +}; + +export const setAuthorizationToken = async (ctx: Context) => { try { - const { createAppToken: appToken } = await client.runQuery( - CreateAppTokenMutation, - variables - ); - client.setAuthorization(appToken); + const token = await getToken(ctx); + ctx.client.setAuthorization(token); } catch (errors) { - if (errors[0] && errors[0].message && errors[0].message.match('No app with code')) { - throw new Error(invalidProjectToken(variables)); + const message = errors[0]?.message; + if (message?.match('Must login') || message?.match('No Access')) { + throw new Error(invalidProjectId({ projectId: ctx.options.projectId })); + } + if (message?.match('No app with code')) { + throw new Error(invalidProjectToken({ projectToken: ctx.options.projectToken })); } throw errors; } diff --git a/node-src/types.ts b/node-src/types.ts index 6f6873602..6a31f0eaa 100644 --- a/node-src/types.ts +++ b/node-src/types.ts @@ -52,8 +52,9 @@ export interface Flags { preserveMissing?: boolean; } -export interface Options { +export interface Options extends Configuration { projectToken: string; + userToken?: string; configFile?: Flags['configFile']; onlyChanged: boolean | string; diff --git a/node-src/ui/messages/errors/invalidProjectId.stories.ts b/node-src/ui/messages/errors/invalidProjectId.stories.ts new file mode 100644 index 000000000..2ef7b595d --- /dev/null +++ b/node-src/ui/messages/errors/invalidProjectId.stories.ts @@ -0,0 +1,7 @@ +import invalidProjectId from './invalidProjectId'; + +export default { + title: 'CLI/Messages/Errors', +}; + +export const InvalidProjectId = () => invalidProjectId({ projectId: '5d67dc0374b2e300209c41e8' }); diff --git a/node-src/ui/messages/errors/invalidProjectId.ts b/node-src/ui/messages/errors/invalidProjectId.ts new file mode 100644 index 000000000..c05952566 --- /dev/null +++ b/node-src/ui/messages/errors/invalidProjectId.ts @@ -0,0 +1,12 @@ +import chalk from 'chalk'; +import { dedent } from 'ts-dedent'; + +import { error, info } from '../../components/icons'; +import link from '../../components/link'; + +export default ({ projectId }: { projectId: string }) => + dedent(chalk` + ${error} Invalid project ID: ${projectId} + You may not sufficient permissions to create builds on this project, or it may not exist. + ${info} Read more at ${link('https://www.chromatic.com/docs/setup')} + `); diff --git a/node-src/ui/tasks/auth.ts b/node-src/ui/tasks/auth.ts index 94e31c5f5..721cf585a 100644 --- a/node-src/ui/tasks/auth.ts +++ b/node-src/ui/tasks/auth.ts @@ -22,7 +22,9 @@ export const authenticating = (ctx: Context) => ({ export const authenticated = (ctx: Context) => ({ status: 'success', title: `Authenticated with Chromatic${env(ctx.env.CHROMATIC_INDEX_URL)}`, - output: `Using project token '${mask(ctx.options.projectToken)}'`, + output: ctx.options.projectToken + ? `Using project token '${mask(ctx.options.projectToken)}'` + : `Using project ID '${ctx.options.projectId}' and user token`, }); export const invalidToken = (ctx: Context) => ({