diff --git a/packages/cli/e2e/defaults.ts b/packages/cli/e2e/defaults.ts index fba75638441..9db9d0e8bfb 100644 --- a/packages/cli/e2e/defaults.ts +++ b/packages/cli/e2e/defaults.ts @@ -9,6 +9,7 @@ const allowDeprecated = false const isPublic = false const visibility = 'private' as const const minify = true +const profile = undefined export default { minify, @@ -22,4 +23,5 @@ export default { allowDeprecated, public: isPublic, visibility, + profile, } as const diff --git a/packages/cli/package.json b/packages/cli/package.json index c67716c6e86..d2de44109a3 100644 --- a/packages/cli/package.json +++ b/packages/cli/package.json @@ -1,6 +1,6 @@ { "name": "@botpress/cli", - "version": "4.13.0", + "version": "4.14.0", "description": "Botpress CLI", "scripts": { "build": "pnpm run bundle && pnpm run template:gen", @@ -51,6 +51,7 @@ "devDependencies": { "@bpinternal/log4bot": "^0.0.4", "@types/bluebird": "^3.5.38", + "@types/ini": "^4.1.1", "@types/json-schema": "^7.0.12", "@types/prompts": "^2.0.14", "@types/semver": "^7.3.11", diff --git a/packages/cli/src/command-implementations/global-command.ts b/packages/cli/src/command-implementations/global-command.ts index 92074049f30..aacc82ce207 100644 --- a/packages/cli/src/command-implementations/global-command.ts +++ b/packages/cli/src/command-implementations/global-command.ts @@ -1,10 +1,12 @@ +import { z } from '@botpress/sdk' import type { YargsConfig } from '@bpinternal/yargs-extra' import chalk from 'chalk' +import * as fs from 'fs' import latestVersion from 'latest-version' import _ from 'lodash' import semver from 'semver' import type { ApiClientFactory } from '../api/client' -import type * as config from '../config' +import * as config from '../config' import * as consts from '../consts' import * as errors from '../errors' import type { CommandArgv, CommandDefinition } from '../typings' @@ -14,16 +16,24 @@ import { BaseCommand } from './base-command' export type GlobalCommandDefinition = CommandDefinition export type GlobalCache = { apiUrl: string; token: string; workspaceId: string } -export type ConfigurableGlobalPaths = { botpressHomeDir: string; cliRootDir: utils.path.AbsolutePath } +export type ConfigurableGlobalPaths = { + botpressHomeDir: string + cliRootDir: utils.path.AbsolutePath + profilesPath: string +} export type ConstantGlobalPaths = typeof consts.fromHomeDir & typeof consts.fromCliRootDir export type AllGlobalPaths = ConfigurableGlobalPaths & ConstantGlobalPaths +const profileCredentialSchema = z.object({ apiUrl: z.string(), workspaceId: z.string(), token: z.string() }) +type ProfileCredentials = z.infer + class GlobalPaths extends utils.path.PathStore { public constructor(argv: CommandArgv) { const absBotpressHome = utils.path.absoluteFrom(utils.path.cwd(), argv.botpressHome) super({ cliRootDir: consts.cliRootDir, botpressHomeDir: absBotpressHome, + profilesPath: utils.path.absoluteFrom(absBotpressHome, consts.profileFileName), ..._.mapValues(consts.fromHomeDir, (p) => utils.path.absoluteFrom(absBotpressHome, p)), ..._.mapValues(consts.fromCliRootDir, (p) => utils.path.absoluteFrom(consts.cliRootDir, p)), }) @@ -73,9 +83,22 @@ export abstract class GlobalCommand extends B protected async getAuthenticatedClient(credentials: Partial>) { const cache = this.globalCache - const token = credentials.token ?? (await cache.get('token')) - const workspaceId = credentials.workspaceId ?? (await cache.get('workspaceId')) - const apiUrl = credentials.apiUrl ?? (await cache.get('apiUrl')) + let token: string | undefined + let workspaceId: string | undefined + let apiUrl: string | undefined + + if (this.argv.profile) { + if (credentials.token || credentials.workspaceId || credentials.apiUrl) { + this.logger.warn( + 'You are currently using credential command line arguments or environment variables as well as a profile. Your profile has overwritten the variables' + ) + } + ;({ token, workspaceId, apiUrl } = await this._readProfileFromFS(this.argv.profile)) + } else { + token = credentials.token ?? (await cache.get('token')) + workspaceId = credentials.workspaceId ?? (await cache.get('workspaceId')) + apiUrl = credentials.apiUrl ?? (await cache.get('apiUrl')) + } if (!(token && workspaceId && apiUrl)) { return null @@ -88,6 +111,28 @@ export abstract class GlobalCommand extends B return this.api.newClient({ apiUrl, token, workspaceId }, this.logger) } + private async _readProfileFromFS(profile: string): Promise { + if (!fs.existsSync(this.globalPaths.abs.profilesPath)) { + throw new errors.BotpressCLIError(`Profile file not found at "${this.globalPaths.abs.profilesPath}"`) + } + const fileContent = await fs.promises.readFile(this.globalPaths.abs.profilesPath, 'utf-8') + const parsedProfiles = JSON.parse(fileContent) + + const zodParseResult = z.record(profileCredentialSchema).safeParse(parsedProfiles, {}) + if (!zodParseResult.success) { + throw errors.BotpressCLIError.wrap(zodParseResult.error, 'Error parsing profiles: ') + } + + const profileData = parsedProfiles[profile] + if (!profileData) { + throw new errors.BotpressCLIError( + `Profile "${profile}" not found in "${this.globalPaths.abs.profilesPath}". Found profiles '${Object.keys(parsedProfiles).join("', '")}'.` + ) + } + + return parsedProfiles[profile] + } + protected async ensureLoginAndCreateClient(credentials: YargsConfig) { const client = await this.getAuthenticatedClient(credentials) @@ -98,7 +143,7 @@ export abstract class GlobalCommand extends B return client } - private _notifyUpdateCli = async (): Promise => { + private readonly _notifyUpdateCli = async (): Promise => { try { this.logger.debug('Checking if cli is up to date') diff --git a/packages/cli/src/command-implementations/project-command.ts b/packages/cli/src/command-implementations/project-command.ts index f34bdcdfb8a..291e72480ca 100644 --- a/packages/cli/src/command-implementations/project-command.ts +++ b/packages/cli/src/command-implementations/project-command.ts @@ -7,7 +7,7 @@ import _ from 'lodash' import semver from 'semver' import * as apiUtils from '../api' import * as codegen from '../code-generation' -import type * as config from '../config' +import * as config from '../config' import * as consts from '../consts' import * as errors from '../errors' import { validateIntegrationDefinition, validateBotDefinition } from '../sdk' diff --git a/packages/cli/src/config.ts b/packages/cli/src/config.ts index 736ba9192cb..4439d99c3af 100644 --- a/packages/cli/src/config.ts +++ b/packages/cli/src/config.ts @@ -125,6 +125,10 @@ const globalSchema = { description: 'The path to the Botpress home directory', default: consts.defaultBotpressHome, }, + profile: { + type: 'string', + description: 'The CLI profile defined in the $BP_BOTPRESS_HOME/.profiles json format file', + }, } satisfies CommandSchema const projectSchema = { diff --git a/packages/cli/src/consts.ts b/packages/cli/src/consts.ts index 0221e824556..0d3465b480b 100644 --- a/packages/cli/src/consts.ts +++ b/packages/cli/src/consts.ts @@ -18,6 +18,7 @@ export const cliRootDir = CLI_ROOT_DIR export const installDirName = 'bp_modules' export const outDirName = '.botpress' export const distDirName = 'dist' +export const profileFileName = '.profiles' export const fromCliRootDir = {} diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 9289c182a0b..f2a6f6f3ac8 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -2096,6 +2096,9 @@ importers: '@types/bluebird': specifier: ^3.5.38 version: 3.5.38 + '@types/ini': + specifier: ^4.1.1 + version: 4.1.1 '@types/json-schema': specifier: ^7.0.12 version: 7.0.12 @@ -8484,6 +8487,10 @@ packages: '@types/node': 22.16.4 dev: true + /@types/ini@4.1.1: + resolution: {integrity: sha512-MIyNUZipBTbyUNnhvuXJTY7B6qNI78meck9Jbv3wk0OgNwRyOOVEKDutAkOs1snB/tx0FafyR6/SN4Ps0hZPeg==} + dev: true + /@types/is-stream@1.1.0: resolution: {integrity: sha512-jkZatu4QVbR60mpIzjINmtS1ZF4a/FqdTUTBeQDVOQ2PYyidtwFKr0B5G6ERukKwliq+7mIXvxyppwzG5EgRYg==} dependencies: