diff --git a/package.json b/package.json index dcdc726..da1ab54 100755 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@mondaycom/apps-cli", - "version": "4.0.1", + "version": "4.1.0", "description": "A cli tool to manage apps (and monday-code projects) in monday.com", "author": "monday.com Apps Team", "type": "module", diff --git a/src/commands/code/env.ts b/src/commands/code/env.ts index 43a16f3..46ba303 100644 --- a/src/commands/code/env.ts +++ b/src/commands/code/env.ts @@ -2,37 +2,43 @@ import { Flags } from '@oclif/core'; import { Relationship } from '@oclif/core/lib/interfaces/parser'; import { AuthenticatedCommand } from 'commands-base/authenticated-command'; -import { APP_ENV_MANAGEMENT_MODES } from 'consts/manage-app-env'; +import { APP_VARIABLE_MANAGEMENT_MODES } from 'consts/manage-app-variables'; import { DynamicChoicesService } from 'services/dynamic-choices-service'; import { handleEnvironmentRequest, listAppEnvKeys } from 'services/manage-app-env-service'; import { PromptService } from 'services/prompt-service'; -import { ManageAppEnvFlags } from 'types/commands/manage-app-env'; +import { ManageAppVariableFlags } from 'types/commands/manage-app-variable'; import { AppId } from 'types/general'; +import { Region } from 'types/general/region'; import logger from 'utils/logger'; import { addRegionToFlags, chooseRegionIfNeeded, getRegionFromString } from 'utils/region'; -const MODES_WITH_KEYS: Array = [ - APP_ENV_MANAGEMENT_MODES.SET, - APP_ENV_MANAGEMENT_MODES.DELETE, +const MODES_WITH_KEYS: Array = [ + APP_VARIABLE_MANAGEMENT_MODES.SET, + APP_VARIABLE_MANAGEMENT_MODES.DELETE, ]; -const isKeyRequired = (mode: APP_ENV_MANAGEMENT_MODES) => MODES_WITH_KEYS.includes(mode); -const isValueRequired = (mode: APP_ENV_MANAGEMENT_MODES) => mode === APP_ENV_MANAGEMENT_MODES.SET; +const isKeyRequired = (mode: APP_VARIABLE_MANAGEMENT_MODES) => MODES_WITH_KEYS.includes(mode); +const isValueRequired = (mode: APP_VARIABLE_MANAGEMENT_MODES) => mode === APP_VARIABLE_MANAGEMENT_MODES.SET; -const promptForModeIfNotProvided = async (mode?: APP_ENV_MANAGEMENT_MODES) => { +const promptForModeIfNotProvided = async (mode?: APP_VARIABLE_MANAGEMENT_MODES) => { if (!mode) { - mode = await PromptService.promptSelectionWithAutoComplete( + mode = await PromptService.promptSelectionWithAutoComplete( 'Select app environment variables management mode', - Object.values(APP_ENV_MANAGEMENT_MODES), + Object.values(APP_VARIABLE_MANAGEMENT_MODES), ); } return mode; }; -const promptForKeyIfNotProvided = async (mode: APP_ENV_MANAGEMENT_MODES, appId: AppId, key?: string) => { +const promptForKeyIfNotProvided = async ( + mode: APP_VARIABLE_MANAGEMENT_MODES, + appId: AppId, + key?: string, + region?: Region, +) => { if (!key && isKeyRequired(mode)) { - const existingKeys = await listAppEnvKeys(appId); + const existingKeys = await listAppEnvKeys(appId, region); key = await PromptService.promptSelectionWithAutoComplete('Enter key for environment variable', existingKeys, { includeInputInSelection: true, }); @@ -41,12 +47,12 @@ const promptForKeyIfNotProvided = async (mode: APP_ENV_MANAGEMENT_MODES, appId: return key; }; -const promptForValueIfNotProvided = async (mode: APP_ENV_MANAGEMENT_MODES, value?: string) => { +const promptForValueIfNotProvided = async (mode: APP_VARIABLE_MANAGEMENT_MODES, value?: string) => { if (!value && isValueRequired(mode)) { value = await PromptService.promptForHiddenInput( 'value', 'Enter value for environment variable', - 'You must enter a value value', + 'You must enter a value', ); } @@ -58,7 +64,6 @@ const flagsWithModeRelationships: Relationship = { flags: [ { name: 'mode', - when: async (flags: Record) => isValueRequired(flags.mode as (typeof MODES_WITH_KEYS)[number]), }, ], @@ -79,7 +84,7 @@ export default class Env extends AuthenticatedCommand { mode: Flags.string({ char: 'm', description: 'management mode', - options: Object.values(APP_ENV_MANAGEMENT_MODES), + options: Object.values(APP_VARIABLE_MANAGEMENT_MODES), }), key: Flags.string({ char: 'k', @@ -101,7 +106,7 @@ export default class Env extends AuthenticatedCommand { const { flags } = await this.parse(Env); const { region: strRegion } = flags; const region = getRegionFromString(strRegion); - let { mode, key, value, appId } = flags as ManageAppEnvFlags; + let { mode, key, value, appId } = flags as ManageAppVariableFlags; if (!appId) { appId = Number(await DynamicChoicesService.chooseApp()); @@ -110,7 +115,7 @@ export default class Env extends AuthenticatedCommand { const selectedRegion = await chooseRegionIfNeeded(region, { appId }); mode = await promptForModeIfNotProvided(mode); - key = await promptForKeyIfNotProvided(mode, appId, key); + key = await promptForKeyIfNotProvided(mode, appId, key, selectedRegion); value = await promptForValueIfNotProvided(mode, value); this.preparePrintCommand(this, { appId, mode, key, value, region: selectedRegion }); diff --git a/src/commands/code/secret.ts b/src/commands/code/secret.ts new file mode 100644 index 0000000..4ec32ce --- /dev/null +++ b/src/commands/code/secret.ts @@ -0,0 +1,128 @@ +import { Flags } from '@oclif/core'; +import { Relationship } from '@oclif/core/lib/interfaces/parser'; + +import { AuthenticatedCommand } from 'commands-base/authenticated-command'; +import { APP_VARIABLE_MANAGEMENT_MODES } from 'consts/manage-app-variables'; +import { DynamicChoicesService } from 'services/dynamic-choices-service'; +import { handleSecretRequest, listAppSecretKeys } from 'services/manage-app-secret-service'; +import { PromptService } from 'services/prompt-service'; +import { ManageAppVariableFlags } from 'types/commands/manage-app-variable'; +import { AppId } from 'types/general'; +import { Region } from 'types/general/region'; +import logger from 'utils/logger'; +import { addRegionToFlags, chooseRegionIfNeeded, getRegionFromString } from 'utils/region'; + +const MODES_WITH_KEYS: Array = [ + APP_VARIABLE_MANAGEMENT_MODES.SET, + APP_VARIABLE_MANAGEMENT_MODES.DELETE, +]; + +const isKeyRequired = (mode: APP_VARIABLE_MANAGEMENT_MODES) => MODES_WITH_KEYS.includes(mode); +const isValueRequired = (mode: APP_VARIABLE_MANAGEMENT_MODES) => mode === APP_VARIABLE_MANAGEMENT_MODES.SET; + +const promptForModeIfNotProvided = async (mode?: APP_VARIABLE_MANAGEMENT_MODES) => { + if (!mode) { + mode = await PromptService.promptSelectionWithAutoComplete( + 'Select app secret variables management mode', + Object.values(APP_VARIABLE_MANAGEMENT_MODES), + ); + } + + return mode; +}; + +const promptForKeyIfNotProvided = async ( + mode: APP_VARIABLE_MANAGEMENT_MODES, + appId: AppId, + key?: string, + region?: Region, +) => { + if (!key && isKeyRequired(mode)) { + const existingKeys = await listAppSecretKeys(appId, region); + key = await PromptService.promptSelectionWithAutoComplete('Enter key for secret variable', existingKeys, { + includeInputInSelection: true, + }); + } + + return key; +}; + +const promptForValueIfNotProvided = async (mode: APP_VARIABLE_MANAGEMENT_MODES, value?: string) => { + if (!value && isValueRequired(mode)) { + value = await PromptService.promptForHiddenInput( + 'value', + 'Enter value for secret variable', + 'You must enter a value', + ); + } + + return value; +}; + +const flagsWithModeRelationships: Relationship = { + type: 'all', + flags: [ + { + name: 'mode', + when: async (flags: Record) => isValueRequired(flags.mode as (typeof MODES_WITH_KEYS)[number]), + }, + ], +}; + +export default class Secret extends AuthenticatedCommand { + static description = 'Manage secret variables for your app hosted on monday-code.'; + + static examples = ['<%= config.bin %> <%= command.id %>']; + + static flags = Secret.serializeFlags( + addRegionToFlags({ + appId: Flags.integer({ + char: 'i', + aliases: ['a'], + description: 'The id of the app to manage secret variables for', + }), + mode: Flags.string({ + char: 'm', + description: 'management mode', + options: Object.values(APP_VARIABLE_MANAGEMENT_MODES), + }), + key: Flags.string({ + char: 'k', + description: 'variable key [required for set and delete]]', + relationships: [flagsWithModeRelationships], + }), + value: Flags.string({ + char: 'v', + description: 'variable value [required for set]', + relationships: [flagsWithModeRelationships], + }), + }), + ); + + static args = {}; + DEBUG_TAG = 'secret'; + public async run(): Promise { + try { + const { flags } = await this.parse(Secret); + const { region: strRegion } = flags; + const region = getRegionFromString(strRegion); + let { mode, key, value, appId } = flags as ManageAppVariableFlags; + + if (!appId) { + appId = Number(await DynamicChoicesService.chooseApp()); + } + + const selectedRegion = await chooseRegionIfNeeded(region, { appId }); + mode = await promptForModeIfNotProvided(mode); + key = await promptForKeyIfNotProvided(mode, appId, key, selectedRegion); + value = await promptForValueIfNotProvided(mode, value); + this.preparePrintCommand(this, { appId, mode, key, value, region: selectedRegion }); + await handleSecretRequest(appId, mode, key, value, selectedRegion); + } catch (error: any) { + logger.debug(error, this.DEBUG_TAG); + + // need to signal to the parent process that the command failed + process.exit(1); + } + } +} diff --git a/src/consts/manage-app-env.ts b/src/consts/manage-app-variables.ts similarity index 59% rename from src/consts/manage-app-env.ts rename to src/consts/manage-app-variables.ts index 11c275d..4d92f35 100644 --- a/src/consts/manage-app-env.ts +++ b/src/consts/manage-app-variables.ts @@ -1,4 +1,4 @@ -export enum APP_ENV_MANAGEMENT_MODES { +export enum APP_VARIABLE_MANAGEMENT_MODES { LIST_KEYS = 'list-keys', SET = 'set', DELETE = 'delete', diff --git a/src/consts/urls.ts b/src/consts/urls.ts index 5977b4f..c61fe38 100644 --- a/src/consts/urls.ts +++ b/src/consts/urls.ts @@ -86,6 +86,14 @@ export const appEnvironmentKeysUrl = (appId: AppId): string => { return `/api/code/${appId}/env-keys`; }; +export const appSecretUrl = (appId: AppId, key: string): string => { + return `/api/code/${appId}/secrets/${key}`; +}; + +export const appSecretKeysUrl = (appId: AppId): string => { + return `/api/code/${appId}/secret-keys`; +}; + export const appReleasesUrl = (appVersionId: AppId): string => { return `/apps_ms/app-versions/${appVersionId}/releases`; }; diff --git a/src/services/manage-app-env-service.ts b/src/services/manage-app-env-service.ts index c959ddc..ffdd2a4 100644 --- a/src/services/manage-app-env-service.ts +++ b/src/services/manage-app-env-service.ts @@ -1,6 +1,6 @@ import { StatusCodes } from 'http-status-codes'; -import { APP_ENV_MANAGEMENT_MODES } from 'consts/manage-app-env'; +import { APP_VARIABLE_MANAGEMENT_MODES } from 'consts/manage-app-variables'; import { appEnvironmentKeysUrl, appEnvironmentUrl } from 'consts/urls'; import { execute } from 'services/api-service'; import { listAppEnvironmentKeysResponseSchema } from 'services/schemas/manage-app-env-service-schemas'; @@ -130,17 +130,17 @@ const handleEnvironmentListKeys = async (appId: AppId, region: Region | undefine }; const MAP_MODE_TO_HANDLER: Record< - APP_ENV_MANAGEMENT_MODES, + APP_VARIABLE_MANAGEMENT_MODES, (appId: AppId, region: Region | undefined, key: string, value: string) => Promise > = { - [APP_ENV_MANAGEMENT_MODES.SET]: handleEnvironmentSet, - [APP_ENV_MANAGEMENT_MODES.DELETE]: handleEnvironmentDelete, - [APP_ENV_MANAGEMENT_MODES.LIST_KEYS]: handleEnvironmentListKeys, + [APP_VARIABLE_MANAGEMENT_MODES.SET]: handleEnvironmentSet, + [APP_VARIABLE_MANAGEMENT_MODES.DELETE]: handleEnvironmentDelete, + [APP_VARIABLE_MANAGEMENT_MODES.LIST_KEYS]: handleEnvironmentListKeys, }; export const handleEnvironmentRequest = async ( appId: AppId, - mode: APP_ENV_MANAGEMENT_MODES, + mode: APP_VARIABLE_MANAGEMENT_MODES, key?: string, value?: string, region?: Region, diff --git a/src/services/manage-app-secret-service.ts b/src/services/manage-app-secret-service.ts new file mode 100644 index 0000000..8ba740c --- /dev/null +++ b/src/services/manage-app-secret-service.ts @@ -0,0 +1,156 @@ +import { StatusCodes } from 'http-status-codes'; + +import { APP_VARIABLE_MANAGEMENT_MODES } from 'consts/manage-app-variables'; +import { appSecretKeysUrl, appSecretUrl } from 'consts/urls'; +import { execute } from 'services/api-service'; +import { listAppSecretKeysResponseSchema } from 'services/schemas/manage-app-secret-service-schemas'; +import { HttpError } from 'types/errors'; +import { AppId } from 'types/general'; +import { Region } from 'types/general/region'; +import { HttpMethodTypes } from 'types/services/api-service'; +import { ListAppSecretKeysResponse } from 'types/services/manage-app-secret-service'; +import logger from 'utils/logger'; +import { addRegionToQuery } from 'utils/region'; +import { appsUrlBuilder } from 'utils/urls-builder'; + +const handleHttpErrors = (error: HttpError) => { + switch (error.code) { + case StatusCodes.NOT_FOUND: { + throw new Error('monday-code deployment not found for the requested app'); + } + + case StatusCodes.FORBIDDEN: { + throw new Error('You are not authorized to access the requested app'); + } + + default: { + throw error; + } + } +}; + +export const listAppSecretKeys = async (appId: AppId, region?: Region): Promise> => { + try { + const path = appSecretKeysUrl(appId); + const url = appsUrlBuilder(path); + const query = addRegionToQuery({}, region); + const response = await execute( + { + query, + url, + headers: { Accept: 'application/json' }, + method: HttpMethodTypes.GET, + }, + listAppSecretKeysResponseSchema, + ); + + return response.keys; + } catch (error: any) { + if (error instanceof HttpError) { + handleHttpErrors(error); + } + + throw new Error('failed to list app secret keys'); + } +}; + +export const setSecret = async (appId: AppId, key: string, value: string, region?: Region) => { + try { + const path = appSecretUrl(appId, key); + const url = appsUrlBuilder(path); + const query = addRegionToQuery({}, region); + await execute({ + query, + url, + headers: { Accept: 'application/json' }, + method: HttpMethodTypes.PUT, + body: { value }, + }); + } catch (error: any) { + if (error instanceof HttpError) { + handleHttpErrors(error); + } + + throw new Error('failed to set secret variable'); + } +}; + +export const deleteSecret = async (appId: AppId, key: string, region?: Region) => { + try { + const path = appSecretUrl(appId, key); + const url = appsUrlBuilder(path); + const query = addRegionToQuery({}, region); + + await execute({ + query, + url, + headers: { Accept: 'application/json' }, + method: HttpMethodTypes.DELETE, + }); + + return true; + } catch (error: any) { + if (error instanceof HttpError) { + handleHttpErrors(error); + } + + throw new Error('failed to delete secret variable'); + } +}; + +const handleSecretSet = async (appId: AppId, region: Region | undefined, key: string, value: string) => { + if (!key || !value) { + throw new Error('key and value are required'); + } + + await setSecret(appId, key, value, region); + logger.info(`Secret variable connected to key: "${key}", was set`); +}; + +const handleSecretDelete = async (appId: AppId, region: Region | undefined, key: string) => { + if (!key) { + throw new Error('key is required'); + } + + await deleteSecret(appId, key, region); + logger.info(`Secret variable connected to key: "${key}", was deleted`); +}; + +const handleSecretListKeys = async (appId: AppId, region: Region | undefined) => { + const response = await listAppSecretKeys(appId, region); + if (response?.length === 0) { + logger.info('No secret variables found'); + return; + } + + logger.info('App secret variable keys:'); + logger.table(response.map(key => ({ keys: key }))); +}; + +const MAP_MODE_TO_HANDLER: Record< + APP_VARIABLE_MANAGEMENT_MODES, + (appId: AppId, region: Region | undefined, key: string, value: string) => Promise +> = { + [APP_VARIABLE_MANAGEMENT_MODES.SET]: handleSecretSet, + [APP_VARIABLE_MANAGEMENT_MODES.DELETE]: handleSecretDelete, + [APP_VARIABLE_MANAGEMENT_MODES.LIST_KEYS]: handleSecretListKeys, +}; + +export const handleSecretRequest = async ( + appId: AppId, + mode: APP_VARIABLE_MANAGEMENT_MODES, + key?: string, + value?: string, + region?: Region, +) => { + if (!appId || !mode) { + throw new Error('appId and mode are required'); + } + + const modeHandler = MAP_MODE_TO_HANDLER[mode]; + if (!modeHandler) { + throw new Error('invalid mode'); + } + + await modeHandler(appId, region, key!, value!); +}; diff --git a/src/services/schemas/manage-app-secret-service-schemas.ts b/src/services/schemas/manage-app-secret-service-schemas.ts new file mode 100644 index 0000000..1341579 --- /dev/null +++ b/src/services/schemas/manage-app-secret-service-schemas.ts @@ -0,0 +1,9 @@ +import { z } from 'zod'; + +import { baseResponseHttpMetaDataSchema } from 'services/schemas/api-service-schemas'; + +export const listAppSecretKeysResponseSchema = z + .object({ + keys: z.array(z.string()), + }) + .merge(baseResponseHttpMetaDataSchema); diff --git a/src/types/commands/manage-app-env.ts b/src/types/commands/manage-app-env.ts deleted file mode 100644 index e940e16..0000000 --- a/src/types/commands/manage-app-env.ts +++ /dev/null @@ -1,9 +0,0 @@ -import { APP_ENV_MANAGEMENT_MODES } from 'consts/manage-app-env'; -import { AppId } from 'types/general'; - -export type ManageAppEnvFlags = { - mode?: APP_ENV_MANAGEMENT_MODES; - key?: string; - value?: string; - appId?: AppId; -}; diff --git a/src/types/commands/manage-app-variable.ts b/src/types/commands/manage-app-variable.ts new file mode 100644 index 0000000..a286c45 --- /dev/null +++ b/src/types/commands/manage-app-variable.ts @@ -0,0 +1,9 @@ +import { APP_VARIABLE_MANAGEMENT_MODES } from 'consts/manage-app-variables'; +import { AppId } from 'types/general'; + +export type ManageAppVariableFlags = { + mode?: APP_VARIABLE_MANAGEMENT_MODES; + key?: string; + value?: string; + appId?: AppId; +}; diff --git a/src/types/services/manage-app-secret-service.ts b/src/types/services/manage-app-secret-service.ts new file mode 100644 index 0000000..ef07085 --- /dev/null +++ b/src/types/services/manage-app-secret-service.ts @@ -0,0 +1,5 @@ +import { z } from 'zod'; + +import { listAppSecretKeysResponseSchema } from 'services/schemas/manage-app-secret-service-schemas'; + +export type ListAppSecretKeysResponse = z.infer;