diff --git a/.changeset/soft-dogs-join.md b/.changeset/soft-dogs-join.md new file mode 100644 index 0000000000..90ea49eb0d --- /dev/null +++ b/.changeset/soft-dogs-join.md @@ -0,0 +1,5 @@ +--- +'@commercetools-frontend/mc-scripts': minor +--- + +The CLI command `config:sync` now attempts to automatically refresh the access token. diff --git a/packages/mc-scripts/bin/cli.js b/packages/mc-scripts/bin/cli.js index eff80ed69b..7c3313d7b6 100755 --- a/packages/mc-scripts/bin/cli.js +++ b/packages/mc-scripts/bin/cli.js @@ -1,3 +1,8 @@ #!/usr/bin/env node -require('@commercetools-frontend/mc-scripts/cli').run(); +const { run } = require('@commercetools-frontend/mc-scripts/cli'); + +run().catch((error) => { + console.error(error.message || error.stack || error); + process.exit(1); +}); diff --git a/packages/mc-scripts/package.json b/packages/mc-scripts/package.json index 6a3cc16040..10b5dc0524 100644 --- a/packages/mc-scripts/package.json +++ b/packages/mc-scripts/package.json @@ -73,7 +73,8 @@ "dotenv": "16.0.3", "dotenv-expand": "8.0.3", "fs-extra": "10.1.0", - "graphql-request": "^4.3.0", + "graphql": "16.6.0", + "graphql-request": "5.0.0", "graphql-tag": "^2.12.6", "html-webpack-plugin": "5.5.0", "json-loader": "0.5.7", diff --git a/packages/mc-scripts/src/cli.ts b/packages/mc-scripts/src/cli.ts index b7eb653fd7..75e11640e6 100755 --- a/packages/mc-scripts/src/cli.ts +++ b/packages/mc-scripts/src/cli.ts @@ -23,7 +23,7 @@ process.on('unhandledRejection', (err) => { // Get the current directory where the CLI is executed from. Usually this is the application folder. const applicationDirectory = fs.realpathSync(process.cwd()); -const run = () => { +async function run() { cli.option( '--env ', `(optional) Parses the file path as a dotenv file and adds the variables to the environment. Multiple flags are allowed.` @@ -199,8 +199,9 @@ const run = () => { cli.help(); cli.version(pkgJson.version); - cli.parse(); -}; + cli.parse(process.argv, { run: false }); + await cli.runMatchedCommand(); +} // Load dotenv files into the process environment. // This is essentially what `dotenv-cli` does, but it's now built into this CLI. diff --git a/packages/mc-scripts/src/commands/config-sync.ts b/packages/mc-scripts/src/commands/config-sync.ts index c55d078287..7afae86a85 100644 --- a/packages/mc-scripts/src/commands/config-sync.ts +++ b/packages/mc-scripts/src/commands/config-sync.ts @@ -32,21 +32,23 @@ async function run(options: TCliCommandConfigSyncOptions) { const { data: localCustomAppData } = applicationConfig; const { mcApiUrl } = applicationConfig.env; - const token = credentialsStorage.getToken(mcApiUrl); - if (!token) { + console.log(`Using Merchant Center environment "${chalk.green(mcApiUrl)}".`); + console.log(); + + const isSessionValid = credentialsStorage.isSessionValid(mcApiUrl); + if (!isSessionValid) { throw new Error( - `You don't have a valid session for the ${mcApiUrl} environment. Please, run the "mc-scripts login" command to authenticate yourself.` + `You don't have a valid session. Please, run the "mc-scripts login" command to authenticate yourself.` ); } const fetchedCustomApplication = await fetchCustomApplication({ mcApiUrl, - token, entryPointUriPath: localCustomAppData.entryPointUriPath, }); if (!fetchedCustomApplication) { - const userOrganizations = await fetchUserOrganizations({ mcApiUrl, token }); + const userOrganizations = await fetchUserOrganizations({ mcApiUrl }); let organizationId: string, organizationName: string; @@ -71,7 +73,7 @@ async function run(options: TCliCommandConfigSyncOptions) { const { organizationId: selectedOrganizationId } = await prompts({ type: 'select', name: 'organizationId', - message: 'Select Organization', + message: 'Select an Organization', choices: organizationChoices, initial: 0, }); @@ -91,7 +93,15 @@ async function run(options: TCliCommandConfigSyncOptions) { const { confirmation } = await prompts({ type: 'text', name: 'confirmation', - message: `You are about to create a new Custom Application in the "${organizationName}" organization for the ${mcApiUrl} environment. Are you sure you want to proceed?`, + message: [ + `You are about to create a new Custom Application in the "${chalk.green( + organizationName + )}" organization. Are you sure you want to proceed?`, + options.dryRun && + chalk.gray('Using "--dry-run", no data will be created.'), + ] + .filter(Boolean) + .join('\n'), initial: 'yes', }); if (!confirmation || confirmation.toLowerCase().charAt(0) !== 'y') { @@ -101,16 +111,16 @@ async function run(options: TCliCommandConfigSyncOptions) { const data = omit(localCustomAppData, ['id']); if (options.dryRun) { - console.log(chalk.gray('DRY RUN mode')); + console.log(); console.log( - `A new Custom Application would be created for the Organization ${organizationName} with the following payload:` + `The following payload would be used to create a new Custom Application.` ); - console.log(JSON.stringify(data)); + console.log(); + console.log(chalk.gray(JSON.stringify(data, null, 2))); return; } const createdCustomApplication = await createCustomApplication({ mcApiUrl, - token, organizationId, data, }); @@ -130,9 +140,16 @@ async function run(options: TCliCommandConfigSyncOptions) { ); console.log( chalk.green( - `Custom Application created.\nThe "applicationId" in your local Custom Application config file should be updated with the application ID: ${createdCustomApplication.id}.\nYou can see the Custom Application data in the Merchant Center at ${customAppLink}.` + `Custom Application created.\nPlease update the "env.production.applicationId" field in your local Custom Application config file with the following value: "${chalk.green( + createdCustomApplication.id + )}".` ) ); + console.log( + `You can inspect the Custom Application data in the Merchant Center at "${chalk.gray( + customAppLink + )}".` + ); return; } @@ -148,20 +165,31 @@ async function run(options: TCliCommandConfigSyncOptions) { ); if (!configDiff) { + console.log(chalk.green(`Custom Application up-to-date.`)); console.log( - chalk.green( - `Custom Application is already up to date.\nYou can see the Custom Application data in the Merchant Center at ${customAppLink}.` - ) + `You can inspect the Custom Application data in the Merchant Center at "${chalk.gray( + customAppLink + )}".` ); return; } + console.log('Changes detected:'); console.log(configDiff); + console.log(); const { confirmation } = await prompts({ type: 'text', name: 'confirmation', - message: `You are about to update the Custom Application "${localCustomAppData.entryPointUriPath}" with the changes above, in the ${mcApiUrl} environment. Are you sure you want to proceed?`, + message: [ + `You are about to update the Custom Application "${chalk.green( + localCustomAppData.entryPointUriPath + )}" with the changes above. Are you sure you want to proceed?`, + options.dryRun && + chalk.gray('Using "--dry-run", no data will be updated.'), + ] + .filter(Boolean) + .join('\n'), initial: 'yes', }); if (!confirmation || confirmation.toLowerCase().charAt(0) !== 'y') { @@ -171,26 +199,29 @@ async function run(options: TCliCommandConfigSyncOptions) { const data = omit(localCustomAppData, ['id']); if (options.dryRun) { - console.log(chalk.gray('DRY RUN mode')); + console.log(); console.log( - `The Custom Application ${data.name} would be updated with the following payload:` + `The following payload would be used to update the Custom Application "${chalk.green( + data.entryPointUriPath + )}".` ); - console.log(JSON.stringify(data)); + console.log(); + console.log(chalk.gray(JSON.stringify(data, null, 2))); return; } await updateCustomApplication({ mcApiUrl, - token, organizationId: fetchedCustomApplication.organizationId, data: omit(localCustomAppData, ['id']), applicationId: fetchedCustomApplication.application.id, }); + console.log(chalk.green(`Custom Application updated.`)); console.log( - chalk.green( - `Custom Application updated.\nYou can see the Custom Application data in the Merchant Center at ${customAppLink}.` - ) + `You can inspect the Custom Application data in the Merchant Center at "${chalk.gray( + customAppLink + )}".` ); } diff --git a/packages/mc-scripts/src/commands/login.ts b/packages/mc-scripts/src/commands/login.ts index 984091395e..766bd9cb2b 100644 --- a/packages/mc-scripts/src/commands/login.ts +++ b/packages/mc-scripts/src/commands/login.ts @@ -10,16 +10,15 @@ async function run() { const applicationConfig = processConfig(); const { mcApiUrl } = applicationConfig.env; + console.log(`Using Merchant Center environment "${chalk.green(mcApiUrl)}".`); + console.log(); + if (credentialsStorage.isSessionValid(mcApiUrl)) { - console.log( - `You already have a valid session for the ${mcApiUrl} environment.\n` - ); + console.log(`You already have a valid session.`); return; } - console.log( - chalk.gray(`Enter the login credentials for the ${mcApiUrl} environment.\n`) - ); + console.log(`Enter the login credentials:`); const { email } = await prompts({ type: 'text', diff --git a/packages/mc-scripts/src/utils/graphql-requests.spec.ts b/packages/mc-scripts/src/utils/graphql-requests.spec.ts index fce779e819..1a6c0a1fd4 100644 --- a/packages/mc-scripts/src/utils/graphql-requests.spec.ts +++ b/packages/mc-scripts/src/utils/graphql-requests.spec.ts @@ -59,7 +59,6 @@ describe('fetch custom application data', () => { await fetchCustomApplication({ entryPointUriPath: 'test-custom-app', mcApiUrl, - token: 'test-token', }); expect( organizationExtensionForCustomApplication?.application.entryPointUriPath @@ -107,7 +106,6 @@ describe('register custom application', () => { it('should match returned data', async () => { const createdCustomAppsData = await createCustomApplication({ mcApiUrl, - token: 'token', organizationId: 'organization-id', data: { url: 'https://test.com', @@ -154,7 +152,6 @@ describe('update custom application', () => { it('should match returned data', async () => { const updatedCustomAppsData = await updateCustomApplication({ mcApiUrl, - token: 'token', organizationId: 'organization-id', applicationId: 'application-id', data: { @@ -208,7 +205,6 @@ describe('fetch user organizations', () => { it('should match returned data', async () => { const data = await fetchUserOrganizations({ mcApiUrl, - token: 'test-token', }); expect(data.results[0].id).toEqual('test-organization-id'); expect(data.results[0].name).toEqual('test-organization-name'); diff --git a/packages/mc-scripts/src/utils/graphql-requests.ts b/packages/mc-scripts/src/utils/graphql-requests.ts index 181d056069..c12d412ccd 100644 --- a/packages/mc-scripts/src/utils/graphql-requests.ts +++ b/packages/mc-scripts/src/utils/graphql-requests.ts @@ -1,8 +1,7 @@ -import { GraphQLClient } from 'graphql-request'; -import { - GRAPHQL_TARGETS, - type TGraphQLTargets, -} from '@commercetools-frontend/constants'; +import chalk from 'chalk'; +import { type DocumentNode, print } from 'graphql'; +import { ClientError, GraphQLClient } from 'graphql-request'; +import { GRAPHQL_TARGETS } from '@commercetools-frontend/constants'; import type { TCreateCustomApplicationFromCliMutation, TCreateCustomApplicationFromCliMutationVariables, @@ -21,111 +20,189 @@ import FetchCustomApplicationFromCli from './fetch-custom-application.settings.g import UpdateCustomApplicationFromCli from './update-custom-application.settings.graphql'; import CreateCustomApplicationFromCli from './create-custom-application.settings.graphql'; import FetchMyOrganizationsFromCli from './fetch-user-organizations.core.graphql'; +import CredentialsStorage from './credentials-storage'; type TFetchCustomApplicationOptions = { mcApiUrl: string; - token: string; entryPointUriPath: string; }; type TUpdateCustomApplicationOptions = { mcApiUrl: string; - token: string; applicationId: string; organizationId: string; data: TCustomApplicationDraftDataInput; }; type TCreateCustomApplicationOptions = { mcApiUrl: string; - token: string; organizationId: string; data: TCustomApplicationDraftDataInput; }; type TFetchUserOrganizationsOptions = { mcApiUrl: string; - token: string; }; -const graphQLClient = ( - url: string, - token: string, - target: TGraphQLTargets = GRAPHQL_TARGETS.SETTINGS_SERVICE -) => - new GraphQLClient(`${url}/graphql`, { +const credentialsStorage = new CredentialsStorage(); + +const client = new GraphQLClient( + '', // <-- Set on demand + { headers: { Accept: 'application/json', 'Content-Type': 'application/json', - 'x-graphql-target': target, - 'x-mc-cli-access-token': token, 'x-user-agent': userAgent, }, - }); + } +); + +async function requestWithTokenRetry( + document: DocumentNode, + requestOptions: { + variables?: QueryVariables; + mcApiUrl: string; + headers: HeadersInit; + }, + retryCount: number = 0 +): Promise { + const token = credentialsStorage.getToken(requestOptions.mcApiUrl); + + client.setEndpoint(`${requestOptions.mcApiUrl}/graphql`); + client.setHeaders(requestOptions.headers); + if (token) { + client.setHeader('x-mc-cli-access-token', token); + } + + try { + const result = await client.rawRequest( + print(document), + requestOptions.variables + ); + + // In case a new session token is returned from the server, save it. + const refreshedSessionToken = result.headers.get( + 'x-refreshed-session-token' + ); + if (refreshedSessionToken) { + console.log(chalk.green('Session token refreshed.')); + console.log(); + const refreshedSessionTokenExpiresAt = result.headers.get( + 'x-refreshed-session-token-expires-at' + ); + // Store the updated access token. + credentialsStorage.setToken(requestOptions.mcApiUrl, { + token: refreshedSessionToken, + expiresAt: Number(refreshedSessionTokenExpiresAt), + }); + } + + return result.data; + } catch (error) { + if (error instanceof ClientError) { + // If it's an unauthorized error, retry the request to force the token to be refreshed. + if ( + retryCount === 0 && + error.response.errors && + error.response.errors.length > 0 + ) { + const isUnauthorizedError = error.response.errors.some( + (graphqlError) => graphqlError.extensions?.code === 'UNAUTHENTICATED' + ); + if (isUnauthorizedError) { + console.log( + chalk.yellow( + 'Expired or invalid session token, attempting to retry the request with a refreshed token...' + ) + ); + return requestWithTokenRetry( + document, + { + ...requestOptions, + headers: { + ...requestOptions.headers, + 'X-Force-Token': 'true', + }, + }, + retryCount + 1 + ); + } + } + } + throw error; + } +} const fetchCustomApplication = async ({ mcApiUrl, - token, entryPointUriPath, }: TFetchCustomApplicationOptions) => { - const variables = { - entryPointUriPath, - }; - - const customAppData = await graphQLClient(mcApiUrl, token).request< + const customAppData = await requestWithTokenRetry< TFetchCustomApplicationFromCliQuery, TFetchCustomApplicationFromCliQueryVariables - >(FetchCustomApplicationFromCli, variables); + >(FetchCustomApplicationFromCli, { + variables: { entryPointUriPath }, + mcApiUrl, + headers: { + 'x-graphql-target': GRAPHQL_TARGETS.SETTINGS_SERVICE, + }, + }); return customAppData.organizationExtensionForCustomApplication; }; const updateCustomApplication = async ({ mcApiUrl, - token, applicationId, organizationId, data, }: TUpdateCustomApplicationOptions) => { - const variables = { - organizationId, - applicationId, - data, - }; - - const updatedCustomAppsData = await graphQLClient(mcApiUrl, token).request< + const updatedCustomAppsData = await requestWithTokenRetry< TUpdateCustomApplicationFromCliMutation, TUpdateCustomApplicationFromCliMutationVariables - >(UpdateCustomApplicationFromCli, variables); + >(UpdateCustomApplicationFromCli, { + variables: { + organizationId, + applicationId, + data, + }, + mcApiUrl, + headers: { + 'x-graphql-target': GRAPHQL_TARGETS.SETTINGS_SERVICE, + }, + }); return updatedCustomAppsData.updateCustomApplication; }; const createCustomApplication = async ({ mcApiUrl, - token, organizationId, data, }: TCreateCustomApplicationOptions) => { - const variables = { - organizationId, - data, - }; - - const createdCustomAppData = await graphQLClient(mcApiUrl, token).request< + const createdCustomAppData = await requestWithTokenRetry< TCreateCustomApplicationFromCliMutation, TCreateCustomApplicationFromCliMutationVariables - >(CreateCustomApplicationFromCli, variables); + >(CreateCustomApplicationFromCli, { + variables: { + organizationId, + data, + }, + mcApiUrl, + headers: { + 'x-graphql-target': GRAPHQL_TARGETS.SETTINGS_SERVICE, + }, + }); return createdCustomAppData.createCustomApplication; }; const fetchUserOrganizations = async ({ mcApiUrl, - token, }: TFetchUserOrganizationsOptions) => { - const userOrganizations = await graphQLClient( - mcApiUrl, - token, - GRAPHQL_TARGETS.ADMINISTRATION_SERVICE - ).request< + const userOrganizations = await requestWithTokenRetry< TFetchMyOrganizationsFromCliQuery, TFetchMyOrganizationsFromCliQueryVariables - >(FetchMyOrganizationsFromCli); + >(FetchMyOrganizationsFromCli, { + mcApiUrl, + headers: { + 'x-graphql-target': GRAPHQL_TARGETS.ADMINISTRATION_SERVICE, + }, + }); return userOrganizations.myOrganizations; }; diff --git a/yarn.lock b/yarn.lock index c9f1e36693..ab8e7a3704 100644 --- a/yarn.lock +++ b/yarn.lock @@ -4164,7 +4164,8 @@ __metadata: dotenv: 16.0.3 dotenv-expand: 8.0.3 fs-extra: 10.1.0 - graphql-request: ^4.3.0 + graphql: 16.6.0 + graphql-request: 5.0.0 graphql-tag: ^2.12.6 html-webpack-plugin: 5.5.0 json-loader: 0.5.7 @@ -22106,7 +22107,21 @@ __metadata: languageName: node linkType: hard -"graphql-request@npm:^4.0.0, graphql-request@npm:^4.3.0": +"graphql-request@npm:5.0.0": + version: 5.0.0 + resolution: "graphql-request@npm:5.0.0" + dependencies: + "@graphql-typed-document-node/core": ^3.1.1 + cross-fetch: ^3.1.5 + extract-files: ^9.0.0 + form-data: ^3.0.0 + peerDependencies: + graphql: 14 - 16 + checksum: 2e8900d10cded401b253a05650719321123e3b0ad5bc57523288892e0226dccf43668ec937753420084f649fb4e3f998f2547c884b746a3e91fa0a43f93e62d4 + languageName: node + linkType: hard + +"graphql-request@npm:^4.0.0": version: 4.3.0 resolution: "graphql-request@npm:4.3.0" dependencies: