From 278de45dfaa796bc4578ce931e45916d50036ba6 Mon Sep 17 00:00:00 2001 From: Olivier Lamothe Date: Thu, 1 Feb 2024 09:11:10 -0500 Subject: [PATCH] feat(cli): add support to associate a search hub when creating a search page (#1417) https://coveord.atlassian.net/browse/CDX-1485 --- .../src/platform/authenticatedClient.spec.ts | 12 +++++- .../src/platform/authenticatedClient.ts | 11 +++++- .../src/preconditions/platformPrivilege.ts | 15 ++++++++ packages/cli/commons/src/utils/ux.ts | 24 ++++++++++++ packages/cli/core/src/commands/atomic/init.ts | 4 +- .../core/src/commands/ui/create/angular.ts | 20 ++++++++-- .../cli/core/src/commands/ui/create/atomic.ts | 2 + .../cli/core/src/commands/ui/create/react.ts | 19 ++++++++-- .../cli/core/src/commands/ui/create/shared.ts | 37 +++++++++++++++++++ .../cli/core/src/commands/ui/create/vue.ts | 18 +++++++-- .../src/lib/atomic/createAtomicProject.ts | 10 ++++- .../ui/atomic/create-atomic/src/plopfile.ts | 17 +++++++++ 12 files changed, 175 insertions(+), 14 deletions(-) create mode 100644 packages/cli/core/src/commands/ui/create/shared.ts diff --git a/packages/cli/commons/src/platform/authenticatedClient.spec.ts b/packages/cli/commons/src/platform/authenticatedClient.spec.ts index 43e89820db..7d58901fb6 100644 --- a/packages/cli/commons/src/platform/authenticatedClient.spec.ts +++ b/packages/cli/commons/src/platform/authenticatedClient.spec.ts @@ -148,12 +148,22 @@ describe('AuthenticatedClient', () => { fancyIt()( '#createImpersonateApiKey should create an API key with impersonate privileges', async () => { - await new AuthenticatedClient().createImpersonateApiKey('my-key'); + await new AuthenticatedClient().createImpersonateApiKey( + 'my-key', + 'my-hub' + ); expect(mockCreate).toHaveBeenCalledWith( expect.objectContaining({ displayName: 'cli-my-key', description: 'Generated by the Coveo CLI', + additionalConfiguration: { + search: { + enforcedQueryPipelineConfiguration: { + searchHub: 'my-hub', + }, + }, + }, enabled: true, privileges: [ {owner: 'SEARCH_API', targetDomain: 'IMPERSONATE', targetId: '*'}, diff --git a/packages/cli/commons/src/platform/authenticatedClient.ts b/packages/cli/commons/src/platform/authenticatedClient.ts index 45c2f1afb9..02a4ffe07c 100644 --- a/packages/cli/commons/src/platform/authenticatedClient.ts +++ b/packages/cli/commons/src/platform/authenticatedClient.ts @@ -58,11 +58,20 @@ export class AuthenticatedClient { return orgs.some((o) => o.id === org); } - public async createImpersonateApiKey(name: string) { + public async createImpersonateApiKey(name: string, searchHub?: string) { const platformClient = await this.getClient(); return await platformClient.apiKey.create({ displayName: `cli-${name}`, description: 'Generated by the Coveo CLI', + ...(searchHub && { + additionalConfiguration: { + search: { + enforcedQueryPipelineConfiguration: { + searchHub, + }, + }, + }, + }), enabled: true, privileges: [ {targetDomain: 'IMPERSONATE', targetId: '*', owner: 'SEARCH_API'}, diff --git a/packages/cli/commons/src/preconditions/platformPrivilege.ts b/packages/cli/commons/src/preconditions/platformPrivilege.ts index e8e65a185f..94dae67d16 100644 --- a/packages/cli/commons/src/preconditions/platformPrivilege.ts +++ b/packages/cli/commons/src/preconditions/platformPrivilege.ts @@ -236,3 +236,18 @@ export const readOrganizationPrivilege: PlatformPrivilege = { : `You are not authorized to view the organization. Make sure you are granted this privilege before running the command again. See https://docs.coveo.com/en/1707#organization-domain`, }; + +export const listSearchHubsPrivilege: PlatformPrivilege = { + models: [ + { + type: 'VIEW', + targetDomain: 'SEARCH_USAGE_METRICS', + targetId: '*', + owner: 'SEARCH_API', + }, + ], + unsatisfiedConditionMessage: (anonymous: boolean) => + anonymous + ? 'Your access token is missing the privilege to list available search hubs in your organization. Make sure to grant this privilege before running the command again. See https://docs.coveo.com/en/1707/#search-usage-metrics-domain.' + : 'You are not authorized to list available search hubs in your organization. Please contact an administrator of your Coveo organization and ask for that privilege. See https://docs.coveo.com/en/1707/#search-usage-metrics-domain.', +}; diff --git a/packages/cli/commons/src/utils/ux.ts b/packages/cli/commons/src/utils/ux.ts index e68e5027d7..70a8f3d874 100644 --- a/packages/cli/commons/src/utils/ux.ts +++ b/packages/cli/commons/src/utils/ux.ts @@ -1,6 +1,7 @@ import {CliUx} from '@oclif/core'; import isCi from 'is-ci'; import {red, green, magenta} from 'chalk'; +import inquirer from 'inquirer'; function isWindows() { return process.platform === 'win32'; @@ -36,3 +37,26 @@ export const formatOrgId = (orgId: TemplateStringsArray | string) => export const confirm = (message: string, ciDefault: boolean) => isCi ? Promise.resolve(ciDefault) : CliUx.ux.confirm(message); + +export const prompt = (message: string, ciDefault: string) => + isCi ? Promise.resolve(ciDefault) : CliUx.ux.prompt(message); + +export const promptChoices = async ( + message: string, + choices: (string | inquirer.Separator)[], + ciDefault: string +) => { + if (isCi) { + return ciDefault; + } + const response = await inquirer.prompt([ + { + name: 'choice', + message, + type: 'list', + choices, + }, + ]); + + return response.choice; +}; diff --git a/packages/cli/core/src/commands/atomic/init.ts b/packages/cli/core/src/commands/atomic/init.ts index 9521202eeb..9bf502b499 100644 --- a/packages/cli/core/src/commands/atomic/init.ts +++ b/packages/cli/core/src/commands/atomic/init.ts @@ -10,6 +10,8 @@ import { createAtomicApp, createAtomicLib, } from '../../lib/atomic/createAtomicProject'; +import {promptForSearchHub} from '../ui/create/shared'; +import {AuthenticatedClient} from '@coveo/cli-commons/platform/authenticatedClient'; export default class AtomicInit extends CLICommand { public static description = @@ -67,7 +69,7 @@ export default class AtomicInit extends CLICommand { } @Before(...atomicAppPreconditions) - private createAtomicApp(projectName: string) { + private async createAtomicApp(projectName: string) { const cfg = this.configuration.get(); return createAtomicApp({ projectName, diff --git a/packages/cli/core/src/commands/ui/create/angular.ts b/packages/cli/core/src/commands/ui/create/angular.ts index 86a702cc0b..8059fcc671 100644 --- a/packages/cli/core/src/commands/ui/create/angular.ts +++ b/packages/cli/core/src/commands/ui/create/angular.ts @@ -20,8 +20,10 @@ import {IsNgVersionInRange} from '../../../lib/decorators/preconditions/ng'; import { createApiKeyPrivilege, impersonatePrivilege, + listSearchHubsPrivilege, } from '@coveo/cli-commons/preconditions/platformPrivilege'; import {Trackable} from '@coveo/cli-commons/preconditions/trackable'; +import {promptForSearchHub} from './shared'; export default class Angular extends CLICommand { public static templateName = '@coveo/angular'; @@ -66,7 +68,11 @@ export default class Angular extends CLICommand { IsNodeVersionInRange(Angular.requiredNodeVersion), IsNpmVersionInRange(Angular.requiredNpmVersion), IsNgVersionInRange(Angular.requiredNgVersion), - HasNecessaryCoveoPrivileges(createApiKeyPrivilege, impersonatePrivilege) + HasNecessaryCoveoPrivileges( + createApiKeyPrivilege, + impersonatePrivilege, + listSearchHubsPrivilege + ) ) public async run() { const {args, flags} = await this.parse(Angular); @@ -86,10 +92,18 @@ export default class Angular extends CLICommand { } private async addCoveoToProject(applicationName: string, defaults: boolean) { + const authenticatedClient = new AuthenticatedClient(); + + const platformClient = await authenticatedClient.getClient(); + const searchHub = await promptForSearchHub(platformClient); + const {flags, args} = await this.parse(Angular); const cfg = this.configuration.get(); - const authenticatedClient = new AuthenticatedClient(); - const apiKey = await authenticatedClient.createImpersonateApiKey(args.name); + + const apiKey = await authenticatedClient.createImpersonateApiKey( + args.name, + searchHub + ); const username = await authenticatedClient.getUsername(); const schematicVersion = flags.version || getPackageVersion(Angular.templateName); diff --git a/packages/cli/core/src/commands/ui/create/atomic.ts b/packages/cli/core/src/commands/ui/create/atomic.ts index 8ff78af2d9..1c1e6c0845 100644 --- a/packages/cli/core/src/commands/ui/create/atomic.ts +++ b/packages/cli/core/src/commands/ui/create/atomic.ts @@ -9,6 +9,8 @@ import { atomicAppPreconditions, createAtomicApp, } from '../../../lib/atomic/createAtomicProject'; +import {AuthenticatedClient} from '@coveo/cli-commons/platform/authenticatedClient'; +import {promptForSearchHub} from './shared'; export default class Atomic extends CLICommand { public static description = diff --git a/packages/cli/core/src/commands/ui/create/react.ts b/packages/cli/core/src/commands/ui/create/react.ts index 28e05c21b9..efe02a8e67 100644 --- a/packages/cli/core/src/commands/ui/create/react.ts +++ b/packages/cli/core/src/commands/ui/create/react.ts @@ -15,12 +15,14 @@ import { import { createApiKeyPrivilege, impersonatePrivilege, + listSearchHubsPrivilege, } from '@coveo/cli-commons/preconditions/platformPrivilege'; import {Trackable} from '@coveo/cli-commons/preconditions/trackable'; import { IsNodeVersionInRange, IsNpxInstalled, } from '../../../lib/decorators/preconditions'; +import {promptForSearchHub} from './shared'; type ReactProcessEnv = { orgId: string; @@ -72,7 +74,11 @@ export default class React extends CLICommand { IsAuthenticated([AuthenticationType.OAuth]), IsNodeVersionInRange(React.requiredNodeVersion), IsNpxInstalled(), - HasNecessaryCoveoPrivileges(createApiKeyPrivilege, impersonatePrivilege) + HasNecessaryCoveoPrivileges( + createApiKeyPrivilege, + impersonatePrivilege, + listSearchHubsPrivilege + ) ) public async run() { const {args} = await this.parse(React); @@ -81,11 +87,18 @@ export default class React extends CLICommand { } private async setupEnvironmentVariables(name: string) { + const authenticatedClient = new AuthenticatedClient(); + const platformClient = await authenticatedClient.getClient(); + const searchHub = await promptForSearchHub(platformClient); + const {args} = await this.parse(React); const cfg = this.configuration.get(); - const authenticatedClient = new AuthenticatedClient(); + const username = await authenticatedClient.getUsername(); - const apiKey = await authenticatedClient.createImpersonateApiKey(args.name); + const apiKey = await authenticatedClient.createImpersonateApiKey( + args.name, + searchHub + ); const env: ReactProcessEnv = { orgId: cfg.organization, diff --git a/packages/cli/core/src/commands/ui/create/shared.ts b/packages/cli/core/src/commands/ui/create/shared.ts new file mode 100644 index 0000000000..acdc1cc3dc --- /dev/null +++ b/packages/cli/core/src/commands/ui/create/shared.ts @@ -0,0 +1,37 @@ +import {confirm, promptChoices, prompt} from '@coveo/cli-commons/utils/ux'; +import PlatformClient from '@coveo/platform-client'; +import inquirer from 'inquirer'; + +const manuallyEnterSearchHub = () => + prompt('Enter the search hub you want to use', ''); + +export async function promptForSearchHub(client: PlatformClient) { + const createSearchHub = await confirm( + 'An API key will be created against your organization. We strongly recommends that you associate this API key with a search hub. Would you like to do so now ? y/n', + false + ); + if (!createSearchHub) { + return; + } + + const availableHubs = await client.searchUsageMetrics.searchHubs.list(); + if (availableHubs.hubs.length === 0) { + return await manuallyEnterSearchHub(); + } + + const choice = await promptChoices( + 'Use an existing search hub, or create a new one?', + [ + new inquirer.Separator(), + 'Create a new one', + new inquirer.Separator(), + ...availableHubs.hubs.map((hub) => hub.name), + ], + '' + ); + + if (choice === 'Create a new one') { + return await manuallyEnterSearchHub(); + } + return choice; +} diff --git a/packages/cli/core/src/commands/ui/create/vue.ts b/packages/cli/core/src/commands/ui/create/vue.ts index 706f0ceeab..64ef7b421c 100644 --- a/packages/cli/core/src/commands/ui/create/vue.ts +++ b/packages/cli/core/src/commands/ui/create/vue.ts @@ -16,6 +16,7 @@ import {appendCmdIfWindows} from '@coveo/cli-commons/utils/os'; import { createApiKeyPrivilege, impersonatePrivilege, + listSearchHubsPrivilege, } from '@coveo/cli-commons/preconditions/platformPrivilege'; import {Trackable} from '@coveo/cli-commons/preconditions/trackable'; import { @@ -25,6 +26,7 @@ import { import {cwd} from 'node:process'; import {mkdirSync, readdirSync, statSync, writeFileSync} from 'node:fs'; import dedent from 'ts-dedent'; +import {promptForSearchHub} from './shared'; export default class Vue extends CLICommand { public static packageName = '@coveo/create-headless-vue'; @@ -67,7 +69,11 @@ export default class Vue extends CLICommand { IsAuthenticated([AuthenticationType.OAuth]), IsNodeVersionInRange(Vue.requiredNodeVersion), IsNpxInstalled(), - HasNecessaryCoveoPrivileges(createApiKeyPrivilege, impersonatePrivilege) + HasNecessaryCoveoPrivileges( + createApiKeyPrivilege, + impersonatePrivilege, + listSearchHubsPrivilege + ) ) public async run() { const {args, flags} = await this.parse(Vue); @@ -82,11 +88,17 @@ export default class Vue extends CLICommand { } private async createEnvFile(dirName: string) { + const authenticatedClient = new AuthenticatedClient(); + const platformClient = await authenticatedClient.getClient(); + const searchHub = await promptForSearchHub(platformClient); const {args} = await this.parse(Vue); const cfg = this.configuration.get(); - const authenticatedClient = new AuthenticatedClient(); + const username = await authenticatedClient.getUsername(); - const apiKey = await authenticatedClient.createImpersonateApiKey(args.name); + const apiKey = await authenticatedClient.createImpersonateApiKey( + args.name, + searchHub + ); writeFileSync( join(dirName, '.env'), diff --git a/packages/cli/core/src/lib/atomic/createAtomicProject.ts b/packages/cli/core/src/lib/atomic/createAtomicProject.ts index 237b344783..8bd39f2fab 100644 --- a/packages/cli/core/src/lib/atomic/createAtomicProject.ts +++ b/packages/cli/core/src/lib/atomic/createAtomicProject.ts @@ -13,6 +13,7 @@ import { import { createApiKeyPrivilege, impersonatePrivilege, + listSearchHubsPrivilege, viewSearchPagesPrivilege, } from '@coveo/cli-commons/preconditions/platformPrivilege'; import { @@ -23,6 +24,7 @@ import {getPackageVersion} from '../utils/misc'; import npf from '@coveo/cli-commons/npm/npf'; import {SubprocessError} from '../errors/subprocessError'; import {isErrorLike} from '../utils/errorSchemas'; +import {promptForSearchHub} from '../../commands/ui/create/shared'; interface CreateAppOptions { initializerVersion?: string; @@ -50,13 +52,15 @@ export const atomicAppPreconditions = [ HasNecessaryCoveoPrivileges( createApiKeyPrivilege, impersonatePrivilege, - viewSearchPagesPrivilege + viewSearchPagesPrivilege, + listSearchHubsPrivilege ), ]; export async function createAtomicApp(options: CreateAppOptions) { const authenticatedClient = new AuthenticatedClient(); - + const platformClient = await authenticatedClient.getClient(); + const searchHub = await promptForSearchHub(platformClient); const username = await authenticatedClient.getUsername(); const cliArgs: string[] = [ `${atomicAppInitializerPackage}@${ @@ -78,6 +82,8 @@ export async function createAtomicApp(options: CreateAppOptions) { options.cfg.environment, '--user', username, + '--search-hub', + searchHub, ]; if (options.pageId) { diff --git a/packages/ui/atomic/create-atomic/src/plopfile.ts b/packages/ui/atomic/create-atomic/src/plopfile.ts index ce395111a3..d9ed59562c 100644 --- a/packages/ui/atomic/create-atomic/src/plopfile.ts +++ b/packages/ui/atomic/create-atomic/src/plopfile.ts @@ -20,6 +20,7 @@ interface PlopData { 'platform-environment': string; page: IManifest; user: string; + 'search-hub': string; } export default function (plop: NodePlopAPI) { @@ -42,11 +43,21 @@ export default function (plop: NodePlopAPI) { async function createSearchApiKey( name: string, + searchHub: string, platformClient: PlatformClient ) { return await platformClient.apiKey.create({ displayName: `cli-${name}`, description: 'Generated by the Coveo CLI', + ...(searchHub && { + additionalConfiguration: { + search: { + enforcedQueryPipelineConfiguration: { + searchHub, + }, + }, + }, + }), enabled: true, privileges: [ {targetDomain: 'EXECUTE_QUERY', targetId: '*', owner: 'SEARCH_API'}, @@ -114,6 +125,11 @@ export default function (plop: NodePlopAPI) { message: 'The name of the security identity to impersonate, e.g. "alicesmith@example.com". See https://docs.coveo.com/en/56/#name-string-required.', }, + { + type: 'input', + name: 'search-hub', + message: 'The search hub to use', + }, { // Custom type necessary to allow bypassing async choices type: 'customList', @@ -162,6 +178,7 @@ export default function (plop: NodePlopAPI) { const platformClient = initPlatformClient(answers); const apiKeyModel = await createSearchApiKey( answers['project'], + answers['search-hub'], platformClient ); answers['api-key'] = apiKeyModel.value!;