diff --git a/.gitignore b/.gitignore index 4146a43b816..add596b6b82 100644 --- a/.gitignore +++ b/.gitignore @@ -87,4 +87,5 @@ docs/.env.production.local docs/npm-debug.log* docs/yarn-debug.log* -docs/yarn-error.log* \ No newline at end of file +docs/yarn-error.log* +.impeccable/ diff --git a/src/m365/pp/commands/aibuildermodel/aibuildermodel-get.spec.ts b/src/m365/pp/commands/aibuildermodel/aibuildermodel-get.spec.ts index 20f27517b33..533ee527ae5 100644 --- a/src/m365/pp/commands/aibuildermodel/aibuildermodel-get.spec.ts +++ b/src/m365/pp/commands/aibuildermodel/aibuildermodel-get.spec.ts @@ -11,13 +11,14 @@ import { pid } from '../../../../utils/pid.js'; import { powerPlatform } from '../../../../utils/powerPlatform.js'; import { sinonUtil } from '../../../../utils/sinonUtil.js'; import commands from '../../commands.js'; -import command from './aibuildermodel-get.js'; +import command, { options } from './aibuildermodel-get.js'; import { session } from '../../../../utils/session.js'; import { settingsNames } from '../../../../settingsNames.js'; import { accessToken } from '../../../../utils/accessToken.js'; describe(commands.AIBUILDERMODEL_GET, () => { let commandInfo: CommandInfo; + let commandOptionsSchema: typeof options; //#region Mocked Responses const validEnvironment = '4be50206-9576-4237-8b17-38d8aadfaa36'; const validId = '3a081d91-5ea8-40a7-8ac9-abbaa3fcb893'; @@ -79,6 +80,7 @@ describe(commands.AIBUILDERMODEL_GET, () => { sinon.stub(accessToken, 'assertAccessTokenType').returns(); auth.connection.active = true; commandInfo = cli.getCommandInfo(command); + commandOptionsSchema = commandInfo.command.getSchemaToParse() as typeof options; sinon.stub(cli, 'getSettingWithDefaultValue').callsFake((settingName: string, defaultValue: any) => { if (settingName === 'prompt') { return false; @@ -126,24 +128,41 @@ describe(commands.AIBUILDERMODEL_GET, () => { assert.notStrictEqual(command.description, null); }); - it('fails validation if id is not a valid guid.', async () => { - const actual = await command.validate({ - options: { - environmentName: validEnvironment, - id: 'Invalid GUID' - } - }, commandInfo); - assert.notStrictEqual(actual, true); + it('fails validation if id is not a valid guid.', () => { + const actual = commandOptionsSchema.safeParse({ + environmentName: validEnvironment, + id: 'Invalid GUID' + }); + assert.strictEqual(actual.success, false); + }); + + it('passes validation if required options specified (id)', () => { + const actual = commandOptionsSchema.safeParse({ environmentName: validEnvironment, id: validId }); + assert.strictEqual(actual.success, true); + }); + + it('passes validation if required options specified (name)', () => { + const actual = commandOptionsSchema.safeParse({ environmentName: validEnvironment, name: validName }); + assert.strictEqual(actual.success, true); + }); + + it('fails validation with unknown options', () => { + const actual = commandOptionsSchema.safeParse({ + environmentName: validEnvironment, + id: validId, + unknownOption: 'value' + }); + assert.strictEqual(actual.success, false); }); - it('passes validation if required options specified (id)', async () => { - const actual = await command.validate({ options: { environmentName: validEnvironment, id: validId } }, commandInfo); - assert.strictEqual(actual, true); + it('fails validation if both id and name are specified', () => { + const actual = commandOptionsSchema.safeParse({ environmentName: validEnvironment, id: validId, name: validName }); + assert.strictEqual(actual.success, false); }); - it('passes validation if required options specified (name)', async () => { - const actual = await command.validate({ options: { environmentName: validEnvironment, name: validName } }, commandInfo); - assert.strictEqual(actual, true); + it('fails validation if neither id nor name is specified', () => { + const actual = commandOptionsSchema.safeParse({ environmentName: validEnvironment }); + assert.strictEqual(actual.success, false); }); it('throws error when multiple AI builder models with same name were found', async () => { @@ -174,10 +193,10 @@ describe(commands.AIBUILDERMODEL_GET, () => { }); await assert.rejects(command.action(logger, { - options: { + options: commandOptionsSchema.parse({ environmentName: validEnvironment, name: validName - } + }) }), new CommandError("Multiple AI builder models with name 'CLI 365 AI Builder Model' found. Found: 69703efe-4149-ed11-bba2-000d3adf7537, 3a081d91-5ea8-40a7-8ac9-abbaa3fcb893.")); }); @@ -202,7 +221,7 @@ describe(commands.AIBUILDERMODEL_GET, () => { sinon.stub(cli, 'handleMultipleResultsFound').resolves(aiBuilderModelResponse.value[0]); - await command.action(logger, { options: { verbose: true, environment: validEnvironment, name: validName } }); + await command.action(logger, { options: commandOptionsSchema.parse({ verbose: true, environmentName: validEnvironment, name: validName }) }); assert(loggerLogSpy.calledWith(aiBuilderModelResponse.value[0])); }); @@ -220,10 +239,10 @@ describe(commands.AIBUILDERMODEL_GET, () => { }); await assert.rejects(command.action(logger, { - options: { + options: commandOptionsSchema.parse({ environmentName: validEnvironment, name: validName - } + }) }), new CommandError(`The specified AI builder model '${validName}' does not exist.`)); }); @@ -240,7 +259,7 @@ describe(commands.AIBUILDERMODEL_GET, () => { throw `Invalid request ${opts.url}`; }); - await command.action(logger, { options: { verbose: true, environmentName: validEnvironment, name: validName } }); + await command.action(logger, { options: commandOptionsSchema.parse({ verbose: true, environmentName: validEnvironment, name: validName }) }); assert(loggerLogSpy.calledWith(aiBuilderModelResponse.value[0])); }); @@ -257,7 +276,7 @@ describe(commands.AIBUILDERMODEL_GET, () => { throw 'Invalid request'; }); - await command.action(logger, { options: { verbose: true, environmentName: validEnvironment, id: validId } }); + await command.action(logger, { options: commandOptionsSchema.parse({ verbose: true, environmentName: validEnvironment, id: validId }) }); assert(loggerLogSpy.calledWith(aiBuilderModelResponse.value[0])); }); @@ -281,7 +300,7 @@ describe(commands.AIBUILDERMODEL_GET, () => { } }); - await assert.rejects(command.action(logger, { options: { environmentName: validEnvironment, name: validName } } as any), + await assert.rejects(command.action(logger, { options: commandOptionsSchema.parse({ environmentName: validEnvironment, name: validName }) }), new CommandError(`Resource '' does not exist or one of its queried reference-property objects are not present`)); }); }); \ No newline at end of file diff --git a/src/m365/pp/commands/aibuildermodel/aibuildermodel-get.ts b/src/m365/pp/commands/aibuildermodel/aibuildermodel-get.ts index d1b90be2fac..4d99d8f7973 100644 --- a/src/m365/pp/commands/aibuildermodel/aibuildermodel-get.ts +++ b/src/m365/pp/commands/aibuildermodel/aibuildermodel-get.ts @@ -1,6 +1,7 @@ +import { z } from 'zod'; import { cli } from '../../../../cli/cli.js'; import { Logger } from '../../../../cli/Logger.js'; -import GlobalOptions from '../../../../GlobalOptions.js'; +import { globalOptionsZod } from '../../../../Command.js'; import request, { CliRequestOptions } from '../../../../request.js'; import { formatting } from '../../../../utils/formatting.js'; import { powerPlatform } from '../../../../utils/powerPlatform.js'; @@ -8,17 +9,22 @@ import { validation } from '../../../../utils/validation.js'; import PowerPlatformCommand from '../../../base/PowerPlatformCommand.js'; import commands from '../../commands.js'; +export const options = z.strictObject({ + ...globalOptionsZod.shape, + environmentName: z.string().alias('e'), + id: z.string().refine(val => validation.isValidGuid(val), { + error: 'The value must be a valid GUID.' + }).optional().alias('i'), + name: z.string().optional().alias('n'), + asAdmin: z.boolean().optional() +}); + +declare type Options = z.infer; + interface CommandArgs { options: Options; } -export interface Options extends GlobalOptions { - environmentName: string; - id?: string; - name?: string; - asAdmin?: boolean; -} - class PpAiBuilderModelGetCommand extends PowerPlatformCommand { public get name(): string { return commands.AIBUILDERMODEL_GET; @@ -28,58 +34,19 @@ class PpAiBuilderModelGetCommand extends PowerPlatformCommand { return 'Get an AI builder model in the specified Power Platform environment.'; } - constructor() { - super(); - - this.#initTelemetry(); - this.#initOptions(); - this.#initOptionSets(); - this.#initValidators(); + public get schema(): z.ZodType { + return options; } - #initTelemetry(): void { - this.telemetry.push((args: CommandArgs) => { - Object.assign(this.telemetryProperties, { - id: typeof args.options.id !== 'undefined', - name: typeof args.options.name !== 'undefined', - asAdmin: !!args.options.asAdmin - }); - }); - } - - #initOptions(): void { - this.options.unshift( - { - option: '-e, --environmentName ' - }, - { - option: '-i, --id [id]' - }, - { - option: '-n, --name [name]' - }, - { - option: '--asAdmin' - } - ); - } - - #initOptionSets(): void { - this.optionSets.push( - { options: ['id', 'name'] } - ); - } - - #initValidators(): void { - this.validators.push( - async (args: CommandArgs) => { - if (args.options.id && !validation.isValidGuid(args.options.id as string)) { - return `${args.options.id} is not a valid GUID`; + public getRefinedSchema(schema: typeof options): z.ZodObject | undefined { + return schema + .refine(opts => [opts.id, opts.name].filter(x => x !== undefined).length === 1, { + error: `Specify either 'id' or 'name', but not both.`, + params: { + customCode: 'optionSet', + options: ['id', 'name'] } - - return true; - } - ); + }); } public async commandAction(logger: Logger, args: CommandArgs): Promise { diff --git a/src/m365/pp/commands/aibuildermodel/aibuildermodel-list.spec.ts b/src/m365/pp/commands/aibuildermodel/aibuildermodel-list.spec.ts index f0816996b25..2a32c61730a 100644 --- a/src/m365/pp/commands/aibuildermodel/aibuildermodel-list.spec.ts +++ b/src/m365/pp/commands/aibuildermodel/aibuildermodel-list.spec.ts @@ -1,6 +1,8 @@ import assert from 'assert'; import sinon from 'sinon'; import auth from '../../../../Auth.js'; +import { cli } from '../../../../cli/cli.js'; +import { CommandInfo } from '../../../../cli/CommandInfo.js'; import { Logger } from '../../../../cli/Logger.js'; import { CommandError } from '../../../../Command.js'; import request from '../../../../request.js'; @@ -10,10 +12,12 @@ import { powerPlatform } from '../../../../utils/powerPlatform.js'; import { session } from '../../../../utils/session.js'; import { sinonUtil } from '../../../../utils/sinonUtil.js'; import commands from '../../commands.js'; -import command from './aibuildermodel-list.js'; +import command, { options } from './aibuildermodel-list.js'; import { accessToken } from '../../../../utils/accessToken.js'; describe(commands.AIBUILDERMODEL_LIST, () => { + let commandInfo: CommandInfo; + let commandOptionsSchema: typeof options; //#region Mocked Responses const envUrl = "https://contoso-dev.api.crm4.dynamics.com"; const validEnvironment = "4be50206-9576-4237-8b17-38d8aadfaa36"; @@ -73,6 +77,8 @@ describe(commands.AIBUILDERMODEL_LIST, () => { sinon.stub(session, 'getId').returns(''); sinon.stub(accessToken, 'assertAccessTokenType').returns(); auth.connection.active = true; + commandInfo = cli.getCommandInfo(command); + commandOptionsSchema = commandInfo.command.getSchemaToParse() as typeof options; }); beforeEach(() => { @@ -115,6 +121,14 @@ describe(commands.AIBUILDERMODEL_LIST, () => { assert.deepStrictEqual(command.defaultProperties(), ['msdyn_name', 'msdyn_aimodelid', 'createdon', 'modifiedon']); }); + it('fails validation with unknown options', () => { + const actual = commandOptionsSchema.safeParse({ + environmentName: validEnvironment, + unknownOption: 'value' + }); + assert.strictEqual(actual.success, false); + }); + it('retrieves AI Builder models', async () => { sinon.stub(powerPlatform, 'getDynamicsInstanceApiUrl').callsFake(async () => envUrl); @@ -130,7 +144,7 @@ describe(commands.AIBUILDERMODEL_LIST, () => { throw 'Invalid request'; }); - await command.action(logger, { options: { verbose: true, environmentName: validEnvironment } }); + await command.action(logger, { options: commandOptionsSchema.parse({ verbose: true, environmentName: validEnvironment }) }); assert(loggerLogSpy.calledWith(modelsResponse.value)); }); @@ -155,7 +169,7 @@ describe(commands.AIBUILDERMODEL_LIST, () => { } }); - await assert.rejects(command.action(logger, { options: { environmentName: validEnvironment } } as any), + await assert.rejects(command.action(logger, { options: commandOptionsSchema.parse({ environmentName: validEnvironment }) }), new CommandError(`Resource '' does not exist or one of its queried reference-property objects are not present`)); }); }); diff --git a/src/m365/pp/commands/aibuildermodel/aibuildermodel-list.ts b/src/m365/pp/commands/aibuildermodel/aibuildermodel-list.ts index 244b4c1c323..943acc77387 100644 --- a/src/m365/pp/commands/aibuildermodel/aibuildermodel-list.ts +++ b/src/m365/pp/commands/aibuildermodel/aibuildermodel-list.ts @@ -1,19 +1,23 @@ +import { z } from 'zod'; import { Logger } from '../../../../cli/Logger.js'; -import GlobalOptions from '../../../../GlobalOptions.js'; +import { globalOptionsZod } from '../../../../Command.js'; import { odata } from '../../../../utils/odata.js'; import { powerPlatform } from '../../../../utils/powerPlatform.js'; import PowerPlatformCommand from '../../../base/PowerPlatformCommand.js'; import commands from '../../commands.js'; +export const options = z.strictObject({ + ...globalOptionsZod.shape, + environmentName: z.string().alias('e'), + asAdmin: z.boolean().optional() +}); + +declare type Options = z.infer; + interface CommandArgs { options: Options; } -interface Options extends GlobalOptions { - environmentName: string; - asAdmin?: boolean; -} - class PpAiBuilderModelListCommand extends PowerPlatformCommand { public get name(): string { return commands.AIBUILDERMODEL_LIST; @@ -27,30 +31,8 @@ class PpAiBuilderModelListCommand extends PowerPlatformCommand { return ['msdyn_name', 'msdyn_aimodelid', 'createdon', 'modifiedon']; } - constructor() { - super(); - - this.#initTelemetry(); - this.#initOptions(); - } - - #initTelemetry(): void { - this.telemetry.push((args: CommandArgs) => { - Object.assign(this.telemetryProperties, { - asAdmin: !!args.options.asAdmin - }); - }); - } - - #initOptions(): void { - this.options.unshift( - { - option: '-e, --environmentName ' - }, - { - option: '--asAdmin' - } - ); + public get schema(): z.ZodType { + return options; } public async commandAction(logger: Logger, args: CommandArgs): Promise { diff --git a/src/m365/pp/commands/aibuildermodel/aibuildermodel-remove.spec.ts b/src/m365/pp/commands/aibuildermodel/aibuildermodel-remove.spec.ts index b8c3044ee5c..ed4d175f335 100644 --- a/src/m365/pp/commands/aibuildermodel/aibuildermodel-remove.spec.ts +++ b/src/m365/pp/commands/aibuildermodel/aibuildermodel-remove.spec.ts @@ -13,11 +13,12 @@ import { powerPlatform } from '../../../../utils/powerPlatform.js'; import { sinonUtil } from '../../../../utils/sinonUtil.js'; import commands from '../../commands.js'; import ppAiBuilderModelGetCommand from './aibuildermodel-get.js'; -import command from './aibuildermodel-remove.js'; +import command, { options } from './aibuildermodel-remove.js'; import { accessToken } from '../../../../utils/accessToken.js'; describe(commands.AIBUILDERMODEL_REMOVE, () => { let commandInfo: CommandInfo; + let commandOptionsSchema: typeof options; //#region Mocked Responses const validEnvironment = '4be50206-9576-4237-8b17-38d8aadfaa36'; const validId = '08ffffbe-ec1c-4e64-b64b-dd1db926c613'; @@ -76,6 +77,7 @@ describe(commands.AIBUILDERMODEL_REMOVE, () => { sinon.stub(accessToken, 'assertAccessTokenType').returns(); auth.connection.active = true; commandInfo = cli.getCommandInfo(command); + commandOptionsSchema = commandInfo.command.getSchemaToParse() as typeof options; }); beforeEach(() => { @@ -122,34 +124,51 @@ describe(commands.AIBUILDERMODEL_REMOVE, () => { assert.notStrictEqual(command.description, null); }); - it('fails validation if id is not a valid guid.', async () => { - const actual = await command.validate({ - options: { - environmentName: validEnvironment, - id: 'Invalid GUID' - } - }, commandInfo); - assert.notStrictEqual(actual, true); + it('fails validation if id is not a valid guid.', () => { + const actual = commandOptionsSchema.safeParse({ + environmentName: validEnvironment, + id: 'Invalid GUID' + }); + assert.strictEqual(actual.success, false); + }); + + it('passes validation if required options specified (id)', () => { + const actual = commandOptionsSchema.safeParse({ environmentName: validEnvironment, id: validId }); + assert.strictEqual(actual.success, true); + }); + + it('passes validation if required options specified (name)', () => { + const actual = commandOptionsSchema.safeParse({ environmentName: validEnvironment, name: validName }); + assert.strictEqual(actual.success, true); }); - it('passes validation if required options specified (id)', async () => { - const actual = await command.validate({ options: { environmentName: validEnvironment, id: validId } }, commandInfo); - assert.strictEqual(actual, true); + it('fails validation with unknown options', () => { + const actual = commandOptionsSchema.safeParse({ + environmentName: validEnvironment, + id: validId, + unknownOption: 'value' + }); + assert.strictEqual(actual.success, false); }); - it('passes validation if required options specified (name)', async () => { - const actual = await command.validate({ options: { environmentName: validEnvironment, name: validName } }, commandInfo); - assert.strictEqual(actual, true); + it('fails validation if both id and name are specified', () => { + const actual = commandOptionsSchema.safeParse({ environmentName: validEnvironment, id: validId, name: validName }); + assert.strictEqual(actual.success, false); + }); + + it('fails validation if neither id nor name is specified', () => { + const actual = commandOptionsSchema.safeParse({ environmentName: validEnvironment }); + assert.strictEqual(actual.success, false); }); it('prompts before removing the specified AI builder model owned by the currently signed-in user when force option not passed', async () => { sinon.stub(powerPlatform, 'getDynamicsInstanceApiUrl').callsFake(async () => envUrl); await command.action(logger, { - options: { + options: commandOptionsSchema.parse({ environmentName: validEnvironment, id: validId - } + }) }); assert(promptIssued); @@ -160,10 +179,10 @@ describe(commands.AIBUILDERMODEL_REMOVE, () => { sinonUtil.restore(cli.promptForConfirmation); sinon.stub(cli, 'promptForConfirmation').resolves(false); await command.action(logger, { - options: { + options: commandOptionsSchema.parse({ environmentName: validEnvironment, id: validId - } + }) }); assert(postSpy.notCalled); }); @@ -192,11 +211,11 @@ describe(commands.AIBUILDERMODEL_REMOVE, () => { sinonUtil.restore(cli.promptForConfirmation); sinon.stub(cli, 'promptForConfirmation').resolves(true); await command.action(logger, { - options: { + options: commandOptionsSchema.parse({ debug: true, environmentName: validEnvironment, name: validName - } + }) }); assert(loggerLogToStderrSpy.called); }); @@ -213,12 +232,12 @@ describe(commands.AIBUILDERMODEL_REMOVE, () => { }); await command.action(logger, { - options: { + options: commandOptionsSchema.parse({ debug: true, environmentName: validEnvironment, id: validId, force: true - } + }) }); assert(loggerLogToStderrSpy.called); }); @@ -231,12 +250,12 @@ describe(commands.AIBUILDERMODEL_REMOVE, () => { sinon.stub(request, 'delete').callsFake(async () => { throw { error: { error: { message: errorMessage } } }; }); await assert.rejects(command.action(logger, { - options: { + options: commandOptionsSchema.parse({ debug: true, environmentName: validEnvironment, id: validId, force: true - } + }) }), new CommandError(errorMessage)); }); }); diff --git a/src/m365/pp/commands/aibuildermodel/aibuildermodel-remove.ts b/src/m365/pp/commands/aibuildermodel/aibuildermodel-remove.ts index 6f7cff5dcb2..618704d18df 100644 --- a/src/m365/pp/commands/aibuildermodel/aibuildermodel-remove.ts +++ b/src/m365/pp/commands/aibuildermodel/aibuildermodel-remove.ts @@ -1,26 +1,31 @@ +import { z } from 'zod'; import { cli } from '../../../../cli/cli.js'; import { Logger } from '../../../../cli/Logger.js'; -import Command from '../../../../Command.js'; -import GlobalOptions from '../../../../GlobalOptions.js'; +import Command, { globalOptionsZod } from '../../../../Command.js'; import request, { CliRequestOptions } from '../../../../request.js'; import { powerPlatform } from '../../../../utils/powerPlatform.js'; import { validation } from '../../../../utils/validation.js'; import PowerPlatformCommand from '../../../base/PowerPlatformCommand.js'; import commands from '../../commands.js'; -import ppAiBuilderModelGetCommand, { Options as PpAiBuilderModelGetCommandOptions } from './aibuildermodel-get.js'; +import ppAiBuilderModelGetCommand from './aibuildermodel-get.js'; + +export const options = z.strictObject({ + ...globalOptionsZod.shape, + environmentName: z.string().alias('e'), + id: z.string().refine(val => validation.isValidGuid(val), { + error: 'The value must be a valid GUID.' + }).optional().alias('i'), + name: z.string().optional().alias('n'), + asAdmin: z.boolean().optional(), + force: z.boolean().optional().alias('f') +}); + +declare type Options = z.infer; interface CommandArgs { options: Options; } -interface Options extends GlobalOptions { - environmentName: string; - id?: string; - name?: string; - asAdmin?: boolean; - force?: boolean; -} - class PpAiBuilderModelRemoveCommand extends PowerPlatformCommand { public get name(): string { @@ -31,62 +36,19 @@ class PpAiBuilderModelRemoveCommand extends PowerPlatformCommand { return 'Removes an AI builder model in the specified Power Platform environment.'; } - constructor() { - super(); - - this.#initTelemetry(); - this.#initOptions(); - this.#initValidators(); - this.#initOptionSets(); - } - - #initTelemetry(): void { - this.telemetry.push((args: CommandArgs) => { - Object.assign(this.telemetryProperties, { - id: typeof args.options.id !== 'undefined', - name: typeof args.options.name !== 'undefined', - asAdmin: !!args.options.asAdmin, - force: !!args.options.force - }); - }); - } - - #initOptions(): void { - this.options.unshift( - { - option: '-e, --environmentName ' - }, - { - option: '-i, --id [id]' - }, - { - option: '-n, --name [name]' - }, - { - option: '--asAdmin' - }, - { - option: '-f, --force' - } - ); - } - - #initOptionSets(): void { - this.optionSets.push( - { options: ['id', 'name'] } - ); + public get schema(): z.ZodType { + return options; } - #initValidators(): void { - this.validators.push( - async (args: CommandArgs) => { - if (args.options.id && !validation.isValidGuid(args.options.id)) { - return `${args.options.id} is not a valid GUID`; + public getRefinedSchema(schema: typeof options): z.ZodObject | undefined { + return schema + .refine(opts => [opts.id, opts.name].filter(x => x !== undefined).length === 1, { + error: `Specify either 'id' or 'name', but not both.`, + params: { + customCode: 'optionSet', + options: ['id', 'name'] } - - return true; - } - ); + }); } public async commandAction(logger: Logger, args: any): Promise { @@ -111,7 +73,7 @@ class PpAiBuilderModelRemoveCommand extends PowerPlatformCommand { return args.options.id; } - const options: PpAiBuilderModelGetCommandOptions = { + const options = { environmentName: args.options.environmentName, name: args.options.name, output: 'json', diff --git a/src/m365/pp/commands/copilot/copilot-get.spec.ts b/src/m365/pp/commands/copilot/copilot-get.spec.ts index a134b3e7700..47cf7853bac 100644 --- a/src/m365/pp/commands/copilot/copilot-get.spec.ts +++ b/src/m365/pp/commands/copilot/copilot-get.spec.ts @@ -13,12 +13,13 @@ import { powerPlatform } from '../../../../utils/powerPlatform.js'; import { session } from '../../../../utils/session.js'; import { sinonUtil } from '../../../../utils/sinonUtil.js'; import commands from '../../commands.js'; -import command from './copilot-get.js'; +import command, { options } from './copilot-get.js'; import { settingsNames } from '../../../../settingsNames.js'; import { accessToken } from '../../../../utils/accessToken.js'; describe(commands.COPILOT_GET, () => { let commandInfo: CommandInfo; + let commandOptionsSchema: typeof options; //#region Mocked Responses const validEnvironment = '4be50206-9576-4237-8b17-38d8aadfaa36'; const validId = '3a081d91-5ea8-40a7-8ac9-abbaa3fcb893'; @@ -90,6 +91,7 @@ describe(commands.COPILOT_GET, () => { sinon.stub(accessToken, 'assertAccessTokenType').returns(); auth.connection.active = true; commandInfo = cli.getCommandInfo(command); + commandOptionsSchema = commandInfo.command.getSchemaToParse() as typeof options; sinon.stub(cli, 'getSettingWithDefaultValue').callsFake((settingName: string, defaultValue: any) => { if (settingName === 'prompt') { return false; @@ -137,24 +139,41 @@ describe(commands.COPILOT_GET, () => { assert.notStrictEqual(command.description, null); }); - it('fails validation if id is not a valid guid.', async () => { - const actual = await command.validate({ - options: { - environmentName: validEnvironment, - id: 'Invalid GUID' - } - }, commandInfo); - assert.notStrictEqual(actual, true); + it('fails validation if id is not a valid guid.', () => { + const actual = commandOptionsSchema.safeParse({ + environmentName: validEnvironment, + id: 'Invalid GUID' + }); + assert.strictEqual(actual.success, false); + }); + + it('passes validation if required options specified (id)', () => { + const actual = commandOptionsSchema.safeParse({ environmentName: validEnvironment, id: validId }); + assert.strictEqual(actual.success, true); + }); + + it('passes validation if required options specified (name)', () => { + const actual = commandOptionsSchema.safeParse({ environmentName: validEnvironment, name: validName }); + assert.strictEqual(actual.success, true); + }); + + it('fails validation with unknown options', () => { + const actual = commandOptionsSchema.safeParse({ + environmentName: validEnvironment, + id: validId, + unknownOption: 'value' + }); + assert.strictEqual(actual.success, false); }); - it('passes validation if required options specified (id)', async () => { - const actual = await command.validate({ options: { environmentName: validEnvironment, id: validId } }, commandInfo); - assert.strictEqual(actual, true); + it('fails validation if both id and name are specified', () => { + const actual = commandOptionsSchema.safeParse({ environmentName: validEnvironment, id: validId, name: validName }); + assert.strictEqual(actual.success, false); }); - it('passes validation if required options specified (name)', async () => { - const actual = await command.validate({ options: { environmentName: validEnvironment, name: validName } }, commandInfo); - assert.strictEqual(actual, true); + it('fails validation if neither id nor name is specified', () => { + const actual = commandOptionsSchema.safeParse({ environmentName: validEnvironment }); + assert.strictEqual(actual.success, false); }); it('throws error when multiple copilots found with the same name', async () => { @@ -185,10 +204,10 @@ describe(commands.COPILOT_GET, () => { }); await assert.rejects(command.action(logger, { - options: { + options: commandOptionsSchema.parse({ environmentName: validEnvironment, name: validName - } + }) }), new CommandError("Multiple copilots with name 'CLI 365 Copilot' found. Found: 69703efe-4149-ed11-bba2-000d3adf7537, 3a081d91-5ea8-40a7-8ac9-abbaa3fcb893.")); }); @@ -213,7 +232,7 @@ describe(commands.COPILOT_GET, () => { sinon.stub(cli, 'handleMultipleResultsFound').resolves(botResponse.value[0]); - await command.action(logger, { options: { verbose: true, environment: validEnvironment, name: validName } }); + await command.action(logger, { options: commandOptionsSchema.parse({ verbose: true, environmentName: validEnvironment, name: validName }) }); assert(loggerLogSpy.calledWith(botResponse.value[0])); }); @@ -231,10 +250,10 @@ describe(commands.COPILOT_GET, () => { }); await assert.rejects(command.action(logger, { - options: { + options: commandOptionsSchema.parse({ environmentName: validEnvironment, name: validName - } + }) }), new CommandError(`The specified copilot '${validName}' does not exist.`)); }); @@ -251,7 +270,7 @@ describe(commands.COPILOT_GET, () => { throw `Invalid request ${opts.url}`; }); - await command.action(logger, { options: { verbose: true, environmentName: validEnvironment, name: validName } }); + await command.action(logger, { options: commandOptionsSchema.parse({ verbose: true, environmentName: validEnvironment, name: validName }) }); assert(loggerLogSpy.calledWith(botResponse.value[0])); }); @@ -268,7 +287,7 @@ describe(commands.COPILOT_GET, () => { throw 'Invalid request'; }); - await command.action(logger, { options: { verbose: true, environmentName: validEnvironment, id: validId } }); + await command.action(logger, { options: commandOptionsSchema.parse({ verbose: true, environmentName: validEnvironment, id: validId }) }); assert(loggerLogSpy.calledWith(botResponse.value[0])); }); @@ -289,7 +308,7 @@ describe(commands.COPILOT_GET, () => { } }); - await assert.rejects(command.action(logger, { options: { environmentName: validEnvironment, name: validName } } as any), + await assert.rejects(command.action(logger, { options: commandOptionsSchema.parse({ environmentName: validEnvironment, name: validName }) }), new CommandError(`bot With Id = ${validId} Does Not Exist`)); }); }); diff --git a/src/m365/pp/commands/copilot/copilot-get.ts b/src/m365/pp/commands/copilot/copilot-get.ts index 0bb309099cb..6e84be923fa 100644 --- a/src/m365/pp/commands/copilot/copilot-get.ts +++ b/src/m365/pp/commands/copilot/copilot-get.ts @@ -1,5 +1,6 @@ +import { z } from 'zod'; import { Logger } from '../../../../cli/Logger.js'; -import GlobalOptions from '../../../../GlobalOptions.js'; +import { globalOptionsZod } from '../../../../Command.js'; import request, { CliRequestOptions } from '../../../../request.js'; import { formatting } from '../../../../utils/formatting.js'; import { powerPlatform } from '../../../../utils/powerPlatform.js'; @@ -8,17 +9,22 @@ import PowerPlatformCommand from '../../../base/PowerPlatformCommand.js'; import commands from '../../commands.js'; import { cli } from '../../../../cli/cli.js'; +export const options = z.strictObject({ + ...globalOptionsZod.shape, + environmentName: z.string().alias('e'), + id: z.string().refine(val => validation.isValidGuid(val), { + error: 'The value must be a valid GUID.' + }).optional().alias('i'), + name: z.string().optional().alias('n'), + asAdmin: z.boolean().optional() +}); + +declare type Options = z.infer; + interface CommandArgs { options: Options; } -export interface Options extends GlobalOptions { - environmentName: string; - id?: string; - name?: string; - asAdmin?: boolean; -} - class PpCopilotGetCommand extends PowerPlatformCommand { public get name(): string { @@ -29,58 +35,19 @@ class PpCopilotGetCommand extends PowerPlatformCommand { return 'Get information about the specified copilot'; } - constructor() { - super(); - - this.#initTelemetry(); - this.#initOptions(); - this.#initValidators(); - this.#initOptionSets(); + public get schema(): z.ZodType { + return options; } - #initTelemetry(): void { - this.telemetry.push((args: CommandArgs) => { - Object.assign(this.telemetryProperties, { - id: typeof args.options.id !== 'undefined', - name: typeof args.options.name !== 'undefined', - asAdmin: !!args.options.asAdmin - }); - }); - } - - #initOptions(): void { - this.options.unshift( - { - option: '-e, --environmentName ' - }, - { - option: '-i, --id [id]' - }, - { - option: '-n, --name [name]' - }, - { - option: '--asAdmin' - } - ); - } - - #initOptionSets(): void { - this.optionSets.push( - { options: ['id', 'name'] } - ); - } - - #initValidators(): void { - this.validators.push( - async (args: CommandArgs) => { - if (args.options.id && !validation.isValidGuid(args.options.id)) { - return `${args.options.id} is not a valid GUID`; + public getRefinedSchema(schema: typeof options): z.ZodObject | undefined { + return schema + .refine(opts => [opts.id, opts.name].filter(x => x !== undefined).length === 1, { + error: `Specify either 'id' or 'name', but not both.`, + params: { + customCode: 'optionSet', + options: ['id', 'name'] } - - return true; - } - ); + }); } public async commandAction(logger: Logger, args: CommandArgs): Promise { diff --git a/src/m365/pp/commands/copilot/copilot-list.spec.ts b/src/m365/pp/commands/copilot/copilot-list.spec.ts index af7e0acc5df..cb78101847a 100644 --- a/src/m365/pp/commands/copilot/copilot-list.spec.ts +++ b/src/m365/pp/commands/copilot/copilot-list.spec.ts @@ -2,6 +2,8 @@ import assert from 'assert'; import sinon from 'sinon'; import auth from '../../../../Auth.js'; import { CommandError } from '../../../../Command.js'; +import { cli } from '../../../../cli/cli.js'; +import { CommandInfo } from '../../../../cli/CommandInfo.js'; import { Logger } from '../../../../cli/Logger.js'; import request from '../../../../request.js'; import { telemetry } from '../../../../telemetry.js'; @@ -10,10 +12,12 @@ import { powerPlatform } from '../../../../utils/powerPlatform.js'; import { session } from '../../../../utils/session.js'; import { sinonUtil } from '../../../../utils/sinonUtil.js'; import commands from '../../commands.js'; -import command from './copilot-list.js'; +import command, { options } from './copilot-list.js'; import { accessToken } from '../../../../utils/accessToken.js'; describe(commands.COPILOT_LIST, () => { + let commandInfo: CommandInfo; + let commandOptionsSchema: typeof options; const envUrl = "https://contoso-dev.api.crm4.dynamics.com"; const validEnvironment = '4be50206-9576-4237-8b17-38d8aadfaa36'; const fetchXml: string = ` @@ -98,6 +102,8 @@ describe(commands.COPILOT_LIST, () => { sinon.stub(session, 'getId').returns(''); sinon.stub(accessToken, 'assertAccessTokenType').returns(); auth.connection.active = true; + commandInfo = cli.getCommandInfo(command); + commandOptionsSchema = commandInfo.command.getSchemaToParse() as typeof options; }); beforeEach(() => { @@ -140,6 +146,14 @@ describe(commands.COPILOT_LIST, () => { assert.deepStrictEqual(command.defaultProperties(), ['name', 'botid', 'publishedOn', 'createdOn', 'botModifiedOn']); }); + it('fails validation with unknown options', () => { + const actual = commandOptionsSchema.safeParse({ + environmentName: validEnvironment, + unknownOption: 'value' + }); + assert.strictEqual(actual.success, false); + }); + it('retrieves copilot bots', async () => { sinon.stub(powerPlatform, 'getDynamicsInstanceApiUrl').callsFake(async () => envUrl); @@ -155,7 +169,7 @@ describe(commands.COPILOT_LIST, () => { throw 'Invalid request'; }); - await command.action(logger, { options: { debug: true, environmentName: validEnvironment } }); + await command.action(logger, { options: commandOptionsSchema.parse({ debug: true, environmentName: validEnvironment }) }); assert(loggerLogSpy.calledWith(copilotResponse.value)); }); @@ -179,7 +193,7 @@ describe(commands.COPILOT_LIST, () => { } }); - await assert.rejects(command.action(logger, { options: { environmentName: validEnvironment } } as any), + await assert.rejects(command.action(logger, { options: commandOptionsSchema.parse({ environmentName: validEnvironment }) }), new CommandError(`Resource '' does not exist or one of its queried reference-property objects are not present`)); }); }); diff --git a/src/m365/pp/commands/copilot/copilot-list.ts b/src/m365/pp/commands/copilot/copilot-list.ts index a0164a5937e..c860838bb66 100644 --- a/src/m365/pp/commands/copilot/copilot-list.ts +++ b/src/m365/pp/commands/copilot/copilot-list.ts @@ -1,19 +1,23 @@ +import { z } from 'zod'; import { Logger } from '../../../../cli/Logger.js'; -import GlobalOptions from '../../../../GlobalOptions.js'; +import { globalOptionsZod } from '../../../../Command.js'; import { odata } from '../../../../utils/odata.js'; import { powerPlatform } from '../../../../utils/powerPlatform.js'; import PowerPlatformCommand from '../../../base/PowerPlatformCommand.js'; import commands from '../../commands.js'; +export const options = z.strictObject({ + ...globalOptionsZod.shape, + environmentName: z.string().alias('e'), + asAdmin: z.boolean().optional() +}); + +declare type Options = z.infer; + interface CommandArgs { options: Options; } -interface Options extends GlobalOptions { - environmentName: string; - asAdmin?: boolean; -} - class PpCopilotListCommand extends PowerPlatformCommand { public get name(): string { return commands.COPILOT_LIST; @@ -27,30 +31,8 @@ class PpCopilotListCommand extends PowerPlatformCommand { return ['name', 'botid', 'publishedOn', 'createdOn', 'botModifiedOn']; } - constructor() { - super(); - - this.#initTelemetry(); - this.#initOptions(); - } - - #initTelemetry(): void { - this.telemetry.push((args: CommandArgs) => { - Object.assign(this.telemetryProperties, { - asAdmin: !!args.options.asAdmin - }); - }); - } - - #initOptions(): void { - this.options.unshift( - { - option: '-e, --environmentName ' - }, - { - option: '--asAdmin' - } - ); + public get schema(): z.ZodType { + return options; } public async commandAction(logger: Logger, args: CommandArgs): Promise { diff --git a/src/m365/pp/commands/copilot/copilot-remove.spec.ts b/src/m365/pp/commands/copilot/copilot-remove.spec.ts index 401cbbd3d71..2417851598f 100644 --- a/src/m365/pp/commands/copilot/copilot-remove.spec.ts +++ b/src/m365/pp/commands/copilot/copilot-remove.spec.ts @@ -13,11 +13,12 @@ import { session } from '../../../../utils/session.js'; import { sinonUtil } from '../../../../utils/sinonUtil.js'; import commands from '../../commands.js'; import ppCopilotGetCommand from './copilot-get.js'; -import command from './copilot-remove.js'; +import command, { options } from './copilot-remove.js'; import { accessToken } from '../../../../utils/accessToken.js'; describe(commands.COPILOT_REMOVE, () => { let commandInfo: CommandInfo; + let commandOptionsSchema: typeof options; //#region Mocked Responses const validEnvironment = '4be50206-9576-4237-8b17-38d8aadfaa36'; const validId = '3a081d91-5ea8-40a7-8ac9-abbaa3fcb893'; @@ -38,6 +39,7 @@ describe(commands.COPILOT_REMOVE, () => { sinon.stub(accessToken, 'assertAccessTokenType').returns(); auth.connection.active = true; commandInfo = cli.getCommandInfo(command); + commandOptionsSchema = commandInfo.command.getSchemaToParse() as typeof options; }); beforeEach(() => { @@ -84,32 +86,49 @@ describe(commands.COPILOT_REMOVE, () => { assert.notStrictEqual(command.description, null); }); - it('fails validation if id is not a valid guid.', async () => { - const actual = await command.validate({ - options: { - environmentName: validEnvironment, - id: 'Invalid GUID' - } - }, commandInfo); - assert.notStrictEqual(actual, true); + it('fails validation if id is not a valid guid.', () => { + const actual = commandOptionsSchema.safeParse({ + environmentName: validEnvironment, + id: 'Invalid GUID' + }); + assert.strictEqual(actual.success, false); + }); + + it('passes validation if required options specified (id)', () => { + const actual = commandOptionsSchema.safeParse({ environmentName: validEnvironment, id: validId }); + assert.strictEqual(actual.success, true); + }); + + it('passes validation if required options specified (name)', () => { + const actual = commandOptionsSchema.safeParse({ environmentName: validEnvironment, name: validName }); + assert.strictEqual(actual.success, true); }); - it('passes validation if required options specified (id)', async () => { - const actual = await command.validate({ options: { environmentName: validEnvironment, id: validId } }, commandInfo); - assert.strictEqual(actual, true); + it('fails validation with unknown options', () => { + const actual = commandOptionsSchema.safeParse({ + environmentName: validEnvironment, + id: validId, + unknownOption: 'value' + }); + assert.strictEqual(actual.success, false); }); - it('passes validation if required options specified (name)', async () => { - const actual = await command.validate({ options: { environmentName: validEnvironment, name: validName } }, commandInfo); - assert.strictEqual(actual, true); + it('fails validation if both id and name are specified', () => { + const actual = commandOptionsSchema.safeParse({ environmentName: validEnvironment, id: validId, name: validName }); + assert.strictEqual(actual.success, false); + }); + + it('fails validation if neither id nor name is specified', () => { + const actual = commandOptionsSchema.safeParse({ environmentName: validEnvironment }); + assert.strictEqual(actual.success, false); }); it('prompts before removing the specified copilot owned by the currently signed-in user when force option not passed', async () => { await command.action(logger, { - options: { + options: commandOptionsSchema.parse({ environmentName: validEnvironment, id: validId - } + }) }); assert(promptIssued); @@ -119,10 +138,10 @@ describe(commands.COPILOT_REMOVE, () => { const postSpy = sinon.spy(request, 'post'); await command.action(logger, { - options: { + options: commandOptionsSchema.parse({ environmentName: validEnvironment, id: validId - } + }) }); assert(postSpy.notCalled); }); @@ -151,11 +170,11 @@ describe(commands.COPILOT_REMOVE, () => { sinonUtil.restore(cli.promptForConfirmation); sinon.stub(cli, 'promptForConfirmation').resolves(true); await command.action(logger, { - options: { + options: commandOptionsSchema.parse({ verbose: true, environmentName: validEnvironment, name: validName - } + }) }); assert(postStub.called); }); @@ -172,12 +191,12 @@ describe(commands.COPILOT_REMOVE, () => { }); await command.action(logger, { - options: { + options: commandOptionsSchema.parse({ verbose: true, environmentName: validEnvironment, id: validId, force: true - } + }) }); assert(loggerLogToStderrSpy.called); }); @@ -190,12 +209,12 @@ describe(commands.COPILOT_REMOVE, () => { sinon.stub(request, 'post').callsFake(async () => { throw { error: { error: { message: errorMessage } } }; }); await assert.rejects(command.action(logger, { - options: { + options: commandOptionsSchema.parse({ debug: true, environmentName: validEnvironment, id: validId, force: true - } + }) }), new CommandError(errorMessage)); }); }); diff --git a/src/m365/pp/commands/copilot/copilot-remove.ts b/src/m365/pp/commands/copilot/copilot-remove.ts index ac6fc5da8d2..d2fe1fe5ce1 100644 --- a/src/m365/pp/commands/copilot/copilot-remove.ts +++ b/src/m365/pp/commands/copilot/copilot-remove.ts @@ -1,26 +1,31 @@ +import { z } from 'zod'; import { cli } from '../../../../cli/cli.js'; import { Logger } from '../../../../cli/Logger.js'; -import Command from '../../../../Command.js'; -import GlobalOptions from '../../../../GlobalOptions.js'; +import Command, { globalOptionsZod } from '../../../../Command.js'; import request, { CliRequestOptions } from '../../../../request.js'; import { powerPlatform } from '../../../../utils/powerPlatform.js'; import { validation } from '../../../../utils/validation.js'; import PowerPlatformCommand from '../../../base/PowerPlatformCommand.js'; import commands from '../../commands.js'; -import ppCopilotGetCommand, { Options as PpCopilotGetCommandOptions } from './copilot-get.js'; +import ppCopilotGetCommand from './copilot-get.js'; + +export const options = z.strictObject({ + ...globalOptionsZod.shape, + environmentName: z.string().alias('e'), + id: z.string().refine(val => validation.isValidGuid(val), { + error: 'The value must be a valid GUID.' + }).optional().alias('i'), + name: z.string().optional().alias('n'), + asAdmin: z.boolean().optional(), + force: z.boolean().optional().alias('f') +}); + +declare type Options = z.infer; interface CommandArgs { options: Options; } -interface Options extends GlobalOptions { - environmentName: string; - id?: string; - name?: string; - asAdmin?: boolean; - force?: boolean; -} - class PpCopilotRemoveCommand extends PowerPlatformCommand { public get name(): string { @@ -31,62 +36,19 @@ class PpCopilotRemoveCommand extends PowerPlatformCommand { return 'Removes the specified copilot'; } - constructor() { - super(); - - this.#initTelemetry(); - this.#initOptions(); - this.#initValidators(); - this.#initOptionSets(); - } - - #initTelemetry(): void { - this.telemetry.push((args: CommandArgs) => { - Object.assign(this.telemetryProperties, { - id: typeof args.options.id !== 'undefined', - name: typeof args.options.name !== 'undefined', - asAdmin: !!args.options.asAdmin, - force: !!args.options.force - }); - }); - } - - #initOptions(): void { - this.options.unshift( - { - option: '-e, --environmentName ' - }, - { - option: '-i, --id [id]' - }, - { - option: '-n, --name [name]' - }, - { - option: '--asAdmin' - }, - { - option: '-f, --force' - } - ); - } - - #initOptionSets(): void { - this.optionSets.push( - { options: ['id', 'name'] } - ); + public get schema(): z.ZodType { + return options; } - #initValidators(): void { - this.validators.push( - async (args: CommandArgs) => { - if (args.options.id && !validation.isValidGuid(args.options.id)) { - return `${args.options.id} is not a valid GUID`; + public getRefinedSchema(schema: typeof options): z.ZodObject | undefined { + return schema + .refine(opts => [opts.id, opts.name].filter(x => x !== undefined).length === 1, { + error: `Specify either 'id' or 'name', but not both.`, + params: { + customCode: 'optionSet', + options: ['id', 'name'] } - - return true; - } - ); + }); } public async commandAction(logger: Logger, args: CommandArgs): Promise { @@ -111,7 +73,7 @@ class PpCopilotRemoveCommand extends PowerPlatformCommand { return args.options.id; } - const options: PpCopilotGetCommandOptions = { + const options = { environmentName: args.options.environmentName, name: args.options.name, output: 'json', diff --git a/src/m365/pp/commands/dataverse/dataverse-table-get.spec.ts b/src/m365/pp/commands/dataverse/dataverse-table-get.spec.ts index 2566fbc4986..4e2dde7d018 100644 --- a/src/m365/pp/commands/dataverse/dataverse-table-get.spec.ts +++ b/src/m365/pp/commands/dataverse/dataverse-table-get.spec.ts @@ -1,6 +1,8 @@ import assert from 'assert'; import sinon from 'sinon'; import auth from '../../../../Auth.js'; +import { cli } from '../../../../cli/cli.js'; +import { CommandInfo } from '../../../../cli/CommandInfo.js'; import { Logger } from '../../../../cli/Logger.js'; import { CommandError } from '../../../../Command.js'; import request from '../../../../request.js'; @@ -10,10 +12,12 @@ import { powerPlatform } from '../../../../utils/powerPlatform.js'; import { session } from '../../../../utils/session.js'; import { sinonUtil } from '../../../../utils/sinonUtil.js'; import commands from '../../commands.js'; -import command from './dataverse-table-get.js'; +import command, { options } from './dataverse-table-get.js'; import { accessToken } from '../../../../utils/accessToken.js'; describe(commands.DATAVERSE_TABLE_GET, () => { + let commandInfo: CommandInfo; + let commandOptionsSchema: typeof options; //#region Mocked Responses const validName = "aaduser"; const validEnvironment = "4be50206-9576-4237-8b17-38d8aadfaa36"; @@ -92,6 +96,8 @@ describe(commands.DATAVERSE_TABLE_GET, () => { sinon.stub(session, 'getId').returns(''); sinon.stub(accessToken, 'assertAccessTokenType').returns(); auth.connection.active = true; + commandInfo = cli.getCommandInfo(command); + commandOptionsSchema = commandInfo.command.getSchemaToParse() as typeof options; }); beforeEach(() => { @@ -130,6 +136,15 @@ describe(commands.DATAVERSE_TABLE_GET, () => { assert.notStrictEqual(command.description, null); }); + it('fails validation with unknown options', () => { + const actual = commandOptionsSchema.safeParse({ + environmentName: validEnvironment, + name: validName, + unknownOption: 'value' + }); + assert.strictEqual(actual.success, false); + }); + it('retrieves data for a specific dataverse table', async () => { sinon.stub(powerPlatform, 'getDynamicsInstanceApiUrl').callsFake(async () => envUrl); @@ -145,7 +160,7 @@ describe(commands.DATAVERSE_TABLE_GET, () => { throw 'Invalid request'; }); - await command.action(logger, { options: { debug: true, environmentName: validEnvironment, name: validName } }); + await command.action(logger, { options: commandOptionsSchema.parse({ debug: true, environmentName: validEnvironment, name: validName }) }); assert(loggerLogSpy.calledWith(tableResponse)); }); @@ -164,7 +179,7 @@ describe(commands.DATAVERSE_TABLE_GET, () => { throw 'Invalid request'; }); - await command.action(logger, { options: { debug: true, environmentName: validEnvironment, name: validName, asAdmin: true } }); + await command.action(logger, { options: commandOptionsSchema.parse({ debug: true, environmentName: validEnvironment, name: validName, asAdmin: true }) }); assert(loggerLogSpy.calledWith(tableResponse)); }); @@ -188,7 +203,7 @@ describe(commands.DATAVERSE_TABLE_GET, () => { } }); - await assert.rejects(command.action(logger, { options: { environmentName: validEnvironment, name: validName } } as any), + await assert.rejects(command.action(logger, { options: commandOptionsSchema.parse({ environmentName: validEnvironment, name: validName }) }), new CommandError(`Resource '' does not exist or one of its queried reference-property objects are not present`)); }); }); diff --git a/src/m365/pp/commands/dataverse/dataverse-table-get.ts b/src/m365/pp/commands/dataverse/dataverse-table-get.ts index cec423264d3..2cac97e2de2 100644 --- a/src/m365/pp/commands/dataverse/dataverse-table-get.ts +++ b/src/m365/pp/commands/dataverse/dataverse-table-get.ts @@ -1,21 +1,24 @@ - +import { z } from 'zod'; import { Logger } from '../../../../cli/Logger.js'; -import GlobalOptions from '../../../../GlobalOptions.js'; +import { globalOptionsZod } from '../../../../Command.js'; import request, { CliRequestOptions } from '../../../../request.js'; import { powerPlatform } from '../../../../utils/powerPlatform.js'; import PowerPlatformCommand from '../../../base/PowerPlatformCommand.js'; import commands from '../../commands.js'; +export const options = z.strictObject({ + ...globalOptionsZod.shape, + environmentName: z.string().alias('e'), + name: z.string().alias('n'), + asAdmin: z.boolean().optional() +}); + +declare type Options = z.infer; + interface CommandArgs { options: Options; } -interface Options extends GlobalOptions { - environmentName: string; - name: string; - asAdmin: boolean; -} - class PpDataverseTableGetCommand extends PowerPlatformCommand { public get name(): string { return commands.DATAVERSE_TABLE_GET; @@ -25,33 +28,8 @@ class PpDataverseTableGetCommand extends PowerPlatformCommand { return 'Gets a dataverse table in a given environment'; } - constructor() { - super(); - - this.#initTelemetry(); - this.#initOptions(); - } - - #initTelemetry(): void { - this.telemetry.push((args: CommandArgs) => { - Object.assign(this.telemetryProperties, { - asAdmin: !!args.options.asAdmin - }); - }); - } - - #initOptions(): void { - this.options.unshift( - { - option: '-e, --environmentName ' - }, - { - option: '-n, --name ' - }, - { - option: '--asAdmin' - } - ); + public get schema(): z.ZodType { + return options; } public async commandAction(logger: Logger, args: CommandArgs): Promise { diff --git a/src/m365/pp/commands/dataverse/dataverse-table-list.spec.ts b/src/m365/pp/commands/dataverse/dataverse-table-list.spec.ts index ecc576bc775..cbc98aa67e3 100644 --- a/src/m365/pp/commands/dataverse/dataverse-table-list.spec.ts +++ b/src/m365/pp/commands/dataverse/dataverse-table-list.spec.ts @@ -1,6 +1,8 @@ import assert from 'assert'; import sinon from 'sinon'; import auth from '../../../../Auth.js'; +import { cli } from '../../../../cli/cli.js'; +import { CommandInfo } from '../../../../cli/CommandInfo.js'; import { Logger } from '../../../../cli/Logger.js'; import { CommandError } from '../../../../Command.js'; import request from '../../../../request.js'; @@ -9,10 +11,12 @@ import { pid } from '../../../../utils/pid.js'; import { session } from '../../../../utils/session.js'; import { sinonUtil } from '../../../../utils/sinonUtil.js'; import commands from '../../commands.js'; -import command from './dataverse-table-list.js'; +import command, { options } from './dataverse-table-list.js'; import { accessToken } from '../../../../utils/accessToken.js'; describe(commands.DATAVERSE_TABLE_LIST, () => { + let commandInfo: CommandInfo; + let commandOptionsSchema: typeof options; //#region Mocked Responses const envResponse: any = { "properties": { "linkedEnvironmentMetadata": { "instanceApiUrl": "https://contoso-dev.api.crm4.dynamics.com" } } }; const dataverseResponse: any = { @@ -157,6 +161,8 @@ describe(commands.DATAVERSE_TABLE_LIST, () => { sinon.stub(session, 'getId').returns(''); sinon.stub(accessToken, 'assertAccessTokenType').returns(); auth.connection.active = true; + commandInfo = cli.getCommandInfo(command); + commandOptionsSchema = commandInfo.command.getSchemaToParse() as typeof options; }); beforeEach(() => { @@ -198,6 +204,14 @@ describe(commands.DATAVERSE_TABLE_LIST, () => { assert.deepStrictEqual(command.defaultProperties(), ['SchemaName', 'EntitySetName', 'LogicalName', 'IsManaged']); }); + it('fails validation with unknown options', () => { + const actual = commandOptionsSchema.safeParse({ + environmentName: '4be50206-9576-4237-8b17-38d8aadfaa36', + unknownOption: 'value' + }); + assert.strictEqual(actual.success, false); + }); + it('retrieves data from dataverse', async () => { sinon.stub(request, 'get').callsFake((opts) => { if (opts.url === `https://api.bap.microsoft.com/providers/Microsoft.BusinessAppPlatform/environments/4be50206-9576-4237-8b17-38d8aadfaa36?api-version=2020-10-01&$select=properties.linkedEnvironmentMetadata.instanceApiUrl`) { @@ -219,7 +233,7 @@ describe(commands.DATAVERSE_TABLE_LIST, () => { throw 'Invalid request'; }); - await command.action(logger, { options: { debug: true, environmentName: '4be50206-9576-4237-8b17-38d8aadfaa36' } }); + await command.action(logger, { options: commandOptionsSchema.parse({ debug: true, environmentName: '4be50206-9576-4237-8b17-38d8aadfaa36' }) }); assert(loggerLogSpy.calledWith(dataverseResponse.value)); }); @@ -244,7 +258,7 @@ describe(commands.DATAVERSE_TABLE_LIST, () => { throw 'Invalid request'; }); - await command.action(logger, { options: { debug: true, environmentName: '4be50206-9576-4237-8b17-38d8aadfaa36', asAdmin: true } }); + await command.action(logger, { options: commandOptionsSchema.parse({ debug: true, environmentName: '4be50206-9576-4237-8b17-38d8aadfaa36', asAdmin: true }) }); assert(loggerLogSpy.calledWith(dataverseResponse.value)); }); @@ -271,7 +285,7 @@ describe(commands.DATAVERSE_TABLE_LIST, () => { }); try { - await command.action(logger, { options: { environmentName: '4be50206-9576-4237-8b17-38d8aadfaa36' } }); + await command.action(logger, { options: commandOptionsSchema.parse({ environmentName: '4be50206-9576-4237-8b17-38d8aadfaa36' }) }); assert.fail('No error message thrown.'); } catch (ex) { diff --git a/src/m365/pp/commands/dataverse/dataverse-table-list.ts b/src/m365/pp/commands/dataverse/dataverse-table-list.ts index e01dc5e2c38..106fb38bfae 100644 --- a/src/m365/pp/commands/dataverse/dataverse-table-list.ts +++ b/src/m365/pp/commands/dataverse/dataverse-table-list.ts @@ -1,19 +1,23 @@ +import { z } from 'zod'; import { Logger } from '../../../../cli/Logger.js'; -import GlobalOptions from '../../../../GlobalOptions.js'; +import { globalOptionsZod } from '../../../../Command.js'; import { odata } from '../../../../utils/odata.js'; import { powerPlatform } from '../../../../utils/powerPlatform.js'; import PowerPlatformCommand from '../../../base/PowerPlatformCommand.js'; import commands from '../../commands.js'; +export const options = z.strictObject({ + ...globalOptionsZod.shape, + environmentName: z.string().alias('e'), + asAdmin: z.boolean().optional() +}); + +declare type Options = z.infer; + interface CommandArgs { options: Options; } -interface Options extends GlobalOptions { - environmentName: string; - asAdmin: boolean; -} - class PpDataverseTableListCommand extends PowerPlatformCommand { public get name(): string { return commands.DATAVERSE_TABLE_LIST; @@ -27,30 +31,8 @@ class PpDataverseTableListCommand extends PowerPlatformCommand { return ['SchemaName', 'EntitySetName', 'LogicalName', 'IsManaged']; } - constructor() { - super(); - - this.#initTelemetry(); - this.#initOptions(); - } - - #initTelemetry(): void { - this.telemetry.push((args: CommandArgs) => { - Object.assign(this.telemetryProperties, { - asAdmin: !!args.options.asAdmin - }); - }); - } - - #initOptions(): void { - this.options.unshift( - { - option: '-e, --environmentName ' - }, - { - option: '--asAdmin' - } - ); + public get schema(): z.ZodType { + return options; } public async commandAction(logger: Logger, args: CommandArgs): Promise { diff --git a/src/m365/pp/commands/dataverse/dataverse-table-remove.spec.ts b/src/m365/pp/commands/dataverse/dataverse-table-remove.spec.ts index ee1bb7f1181..ce2fc857cdc 100644 --- a/src/m365/pp/commands/dataverse/dataverse-table-remove.spec.ts +++ b/src/m365/pp/commands/dataverse/dataverse-table-remove.spec.ts @@ -2,6 +2,7 @@ import assert from 'assert'; import sinon from 'sinon'; import auth from '../../../../Auth.js'; import { cli } from '../../../../cli/cli.js'; +import { CommandInfo } from '../../../../cli/CommandInfo.js'; import { Logger } from '../../../../cli/Logger.js'; import { CommandError } from '../../../../Command.js'; import request from '../../../../request.js'; @@ -11,10 +12,12 @@ import { powerPlatform } from '../../../../utils/powerPlatform.js'; import { session } from '../../../../utils/session.js'; import { sinonUtil } from '../../../../utils/sinonUtil.js'; import commands from '../../commands.js'; -import command from './dataverse-table-remove.js'; +import command, { options } from './dataverse-table-remove.js'; import { accessToken } from '../../../../utils/accessToken.js'; describe(commands.DATAVERSE_TABLE_REMOVE, () => { + let commandInfo: CommandInfo; + let commandOptionsSchema: typeof options; //#region Mocked Responses const validEnvironment = '4be50206-9576-4237-8b17-38d8aadfaa36'; const validName = 'aaduser'; @@ -33,6 +36,8 @@ describe(commands.DATAVERSE_TABLE_REMOVE, () => { sinon.stub(session, 'getId').returns(''); sinon.stub(accessToken, 'assertAccessTokenType').returns(); auth.connection.active = true; + commandInfo = cli.getCommandInfo(command); + commandOptionsSchema = commandInfo.command.getSchemaToParse() as typeof options; }); beforeEach(() => { @@ -78,12 +83,21 @@ describe(commands.DATAVERSE_TABLE_REMOVE, () => { assert.notStrictEqual(command.description, null); }); + it('fails validation with unknown options', () => { + const actual = commandOptionsSchema.safeParse({ + environmentName: validEnvironment, + name: validName, + unknownOption: 'value' + }); + assert.strictEqual(actual.success, false); + }); + it('prompts before removing the specified table owned by the currently signed-in user when force option not passed', async () => { await command.action(logger, { - options: { + options: commandOptionsSchema.parse({ environmentName: validEnvironment, name: validName - } + }) }); assert(promptIssued); @@ -93,10 +107,10 @@ describe(commands.DATAVERSE_TABLE_REMOVE, () => { const postSpy = sinon.spy(request, 'delete'); await command.action(logger, { - options: { + options: commandOptionsSchema.parse({ environmentName: validEnvironment, name: validName - } + }) }); assert(postSpy.notCalled); }); @@ -115,11 +129,11 @@ describe(commands.DATAVERSE_TABLE_REMOVE, () => { sinonUtil.restore(cli.promptForConfirmation); sinon.stub(cli, 'promptForConfirmation').resolves(true); await command.action(logger, { - options: { + options: commandOptionsSchema.parse({ debug: true, environmentName: validEnvironment, name: validName - } + }) }); assert(loggerLogToStderrSpy.called); }); @@ -136,12 +150,12 @@ describe(commands.DATAVERSE_TABLE_REMOVE, () => { }); await command.action(logger, { - options: { + options: commandOptionsSchema.parse({ debug: true, environmentName: validEnvironment, name: validName, force: true - } + }) }); assert(loggerLogToStderrSpy.called); }); @@ -166,7 +180,7 @@ describe(commands.DATAVERSE_TABLE_REMOVE, () => { } }); - await assert.rejects(command.action(logger, { options: { environmentName: validEnvironment, name: validName, force: true } } as any), + await assert.rejects(command.action(logger, { options: commandOptionsSchema.parse({ environmentName: validEnvironment, name: validName, force: true }) }), new CommandError(`Resource '' does not exist or one of its queried reference-property objects are not present`)); }); }); diff --git a/src/m365/pp/commands/dataverse/dataverse-table-remove.ts b/src/m365/pp/commands/dataverse/dataverse-table-remove.ts index b3f596affb1..5542d99cea8 100644 --- a/src/m365/pp/commands/dataverse/dataverse-table-remove.ts +++ b/src/m365/pp/commands/dataverse/dataverse-table-remove.ts @@ -1,22 +1,26 @@ +import { z } from 'zod'; import { cli } from '../../../../cli/cli.js'; import { Logger } from '../../../../cli/Logger.js'; -import GlobalOptions from '../../../../GlobalOptions.js'; +import { globalOptionsZod } from '../../../../Command.js'; import request, { CliRequestOptions } from '../../../../request.js'; import { powerPlatform } from '../../../../utils/powerPlatform.js'; import PowerPlatformCommand from '../../../base/PowerPlatformCommand.js'; import commands from '../../commands.js'; +export const options = z.strictObject({ + ...globalOptionsZod.shape, + environmentName: z.string().alias('e'), + name: z.string().alias('n'), + asAdmin: z.boolean().optional(), + force: z.boolean().optional().alias('f') +}); + +declare type Options = z.infer; + interface CommandArgs { options: Options; } -interface Options extends GlobalOptions { - environmentName: string; - name: string; - force?: true; - asAdmin?: boolean; -} - class PpDataverseTableRemoveCommand extends PowerPlatformCommand { public get name(): string { return commands.DATAVERSE_TABLE_REMOVE; @@ -26,37 +30,8 @@ class PpDataverseTableRemoveCommand extends PowerPlatformCommand { return 'Removes a dataverse table in a given environment'; } - constructor() { - super(); - - this.#initTelemetry(); - this.#initOptions(); - } - - #initTelemetry(): void { - this.telemetry.push((args: CommandArgs) => { - Object.assign(this.telemetryProperties, { - asAdmin: !!args.options.asAdmin, - force: !!args.options.force - }); - }); - } - - #initOptions(): void { - this.options.unshift( - { - option: '-e, --environmentName ' - }, - { - option: '-n, --name ' - }, - { - option: '--asAdmin' - }, - { - option: '-f, --force' - } - ); + public get schema(): z.ZodType { + return options; } public async commandAction(logger: Logger, args: CommandArgs): Promise { diff --git a/src/m365/pp/commands/dataverse/dataverse-table-row-list.spec.ts b/src/m365/pp/commands/dataverse/dataverse-table-row-list.spec.ts index 63e347b7836..3ca81bfa461 100644 --- a/src/m365/pp/commands/dataverse/dataverse-table-row-list.spec.ts +++ b/src/m365/pp/commands/dataverse/dataverse-table-row-list.spec.ts @@ -12,12 +12,13 @@ import { powerPlatform } from '../../../../utils/powerPlatform.js'; import { session } from '../../../../utils/session.js'; import { sinonUtil } from '../../../../utils/sinonUtil.js'; import commands from '../../commands.js'; -import command from './dataverse-table-row-list.js'; +import command, { options } from './dataverse-table-row-list.js'; import { accessToken } from '../../../../utils/accessToken.js'; describe(commands.DATAVERSE_TABLE_ROW_LIST, () => { //#region Mocked Responses let commandInfo: CommandInfo; + let commandOptionsSchema: typeof options; const validEnvironment = '4be50206-9576-4237-8b17-38d8aadfaa36'; const validTableName = 'cr6c3_clitable'; const validEntitySetName = 'cr6c3_clitables'; @@ -64,6 +65,7 @@ describe(commands.DATAVERSE_TABLE_ROW_LIST, () => { sinon.stub(accessToken, 'assertAccessTokenType').returns(); auth.connection.active = true; commandInfo = cli.getCommandInfo(command); + commandOptionsSchema = commandInfo.command.getSchemaToParse() as typeof options; }); beforeEach(() => { @@ -102,14 +104,33 @@ describe(commands.DATAVERSE_TABLE_ROW_LIST, () => { assert.notStrictEqual(command.description, null); }); - it('passes validation if required options specified (entitySetName)', async () => { - const actual = await command.validate({ options: { environmentName: validEnvironment, entitySetName: validEntitySetName } }, commandInfo); - assert.strictEqual(actual, true); + it('passes validation if required options specified (entitySetName)', () => { + const actual = commandOptionsSchema.safeParse({ environmentName: validEnvironment, entitySetName: validEntitySetName }); + assert.strictEqual(actual.success, true); }); - it('passes validation if required options specified (name)', async () => { - const actual = await command.validate({ options: { environmentName: validEnvironment, tableName: validTableName } }, commandInfo); - assert.strictEqual(actual, true); + it('passes validation if required options specified (name)', () => { + const actual = commandOptionsSchema.safeParse({ environmentName: validEnvironment, tableName: validTableName }); + assert.strictEqual(actual.success, true); + }); + + it('fails validation with unknown options', () => { + const actual = commandOptionsSchema.safeParse({ + environmentName: validEnvironment, + entitySetName: validEntitySetName, + unknownOption: 'value' + }); + assert.strictEqual(actual.success, false); + }); + + it('fails validation if both entitySetName and tableName are specified', () => { + const actual = commandOptionsSchema.safeParse({ environmentName: validEnvironment, entitySetName: validEntitySetName, tableName: validTableName }); + assert.strictEqual(actual.success, false); + }); + + it('fails validation if neither entitySetName nor tableName is specified', () => { + const actual = commandOptionsSchema.safeParse({ environmentName: validEnvironment }); + assert.strictEqual(actual.success, false); }); it('retrieves dataverse table rows with the entitySetName parameter', async () => { @@ -125,7 +146,7 @@ describe(commands.DATAVERSE_TABLE_ROW_LIST, () => { throw `Invalid request ${opts.url}`; }); - await command.action(logger, { options: { verbose: true, environmentName: validEnvironment, entitySetName: validEntitySetName } }); + await command.action(logger, { options: commandOptionsSchema.parse({ verbose: true, environmentName: validEnvironment, entitySetName: validEntitySetName }) }); assert(loggerLogSpy.calledWith(rowsResponse.value)); }); @@ -148,7 +169,7 @@ describe(commands.DATAVERSE_TABLE_ROW_LIST, () => { throw `Invalid request ${opts.url}`; }); - await command.action(logger, { options: { verbose: true, environmentName: validEnvironment, tableName: validTableName } }); + await command.action(logger, { options: commandOptionsSchema.parse({ verbose: true, environmentName: validEnvironment, tableName: validTableName }) }); assert(loggerLogSpy.calledWith(rowsResponse.value)); }); @@ -172,7 +193,7 @@ describe(commands.DATAVERSE_TABLE_ROW_LIST, () => { } }); - await assert.rejects(command.action(logger, { options: { environmentName: validEnvironment, entitySetName: validEntitySetName } } as any), + await assert.rejects(command.action(logger, { options: commandOptionsSchema.parse({ environmentName: validEnvironment, entitySetName: validEntitySetName }) }), new CommandError(`Resource '' does not exist or one of its queried reference-property objects are not present`)); }); }); diff --git a/src/m365/pp/commands/dataverse/dataverse-table-row-list.ts b/src/m365/pp/commands/dataverse/dataverse-table-row-list.ts index d9d41b7ca22..e32990c7563 100644 --- a/src/m365/pp/commands/dataverse/dataverse-table-row-list.ts +++ b/src/m365/pp/commands/dataverse/dataverse-table-row-list.ts @@ -1,22 +1,26 @@ +import { z } from 'zod'; import { Logger } from '../../../../cli/Logger.js'; -import GlobalOptions from '../../../../GlobalOptions.js'; +import { globalOptionsZod } from '../../../../Command.js'; import request, { CliRequestOptions } from '../../../../request.js'; import { odata } from '../../../../utils/odata.js'; import { powerPlatform } from '../../../../utils/powerPlatform.js'; import PowerPlatformCommand from '../../../base/PowerPlatformCommand.js'; import commands from '../../commands.js'; +export const options = z.strictObject({ + ...globalOptionsZod.shape, + environmentName: z.string().alias('e'), + entitySetName: z.string().optional(), + tableName: z.string().optional(), + asAdmin: z.boolean().optional() +}); + +declare type Options = z.infer; + interface CommandArgs { options: Options; } -interface Options extends GlobalOptions { - environmentName: string; - entitySetName?: string; - tableName?: string; - asAdmin?: boolean; -} - class PpDataverseTableRowListCommand extends PowerPlatformCommand { public get name(): string { @@ -27,45 +31,19 @@ class PpDataverseTableRowListCommand extends PowerPlatformCommand { return 'Lists table rows for the given Dataverse table'; } - constructor() { - super(); - - this.#initTelemetry(); - this.#initOptions(); - this.#initOptionSets(); + public get schema(): z.ZodType { + return options; } - #initTelemetry(): void { - this.telemetry.push((args: CommandArgs) => { - Object.assign(this.telemetryProperties, { - entitySetName: typeof args.options.entitySetName !== 'undefined', - tableName: typeof args.options.tableName !== 'undefined', - asAdmin: !!args.options.asAdmin + public getRefinedSchema(schema: typeof options): z.ZodObject | undefined { + return schema + .refine(opts => [opts.entitySetName, opts.tableName].filter(x => x !== undefined).length === 1, { + error: `Specify either 'entitySetName' or 'tableName', but not both.`, + params: { + customCode: 'optionSet', + options: ['entitySetName', 'tableName'] + } }); - }); - } - - #initOptions(): void { - this.options.unshift( - { - option: '-e, --environmentName ' - }, - { - option: '--entitySetName [entitySetName]' - }, - { - option: '--tableName [tableName]' - }, - { - option: '--asAdmin' - } - ); - } - - #initOptionSets(): void { - this.optionSets.push( - { options: ['entitySetName', 'tableName'] } - ); } public async commandAction(logger: Logger, args: CommandArgs): Promise { diff --git a/src/m365/pp/commands/dataverse/dataverse-table-row-remove.spec.ts b/src/m365/pp/commands/dataverse/dataverse-table-row-remove.spec.ts index 7d98b3b1fd8..42509cb8c45 100644 --- a/src/m365/pp/commands/dataverse/dataverse-table-row-remove.spec.ts +++ b/src/m365/pp/commands/dataverse/dataverse-table-row-remove.spec.ts @@ -12,11 +12,12 @@ import { powerPlatform } from '../../../../utils/powerPlatform.js'; import { session } from '../../../../utils/session.js'; import { sinonUtil } from '../../../../utils/sinonUtil.js'; import commands from '../../commands.js'; -import command from './dataverse-table-row-remove.js'; +import command, { options } from './dataverse-table-row-remove.js'; import { accessToken } from '../../../../utils/accessToken.js'; describe(commands.DATAVERSE_TABLE_ROW_REMOVE, () => { let commandInfo: CommandInfo; + let commandOptionsSchema: typeof options; //#region Mocked Responses const validEnvironment = '4be50206-9576-4237-8b17-38d8aadfaa36'; const validId = '3a081d91-5ea8-40a7-8ac9-abbaa3fcb893'; @@ -41,6 +42,7 @@ describe(commands.DATAVERSE_TABLE_ROW_REMOVE, () => { sinon.stub(accessToken, 'assertAccessTokenType').returns(); auth.connection.active = true; commandInfo = cli.getCommandInfo(command); + commandOptionsSchema = commandInfo.command.getSchemaToParse() as typeof options; }); beforeEach(() => { @@ -88,35 +90,54 @@ describe(commands.DATAVERSE_TABLE_ROW_REMOVE, () => { assert.notStrictEqual(command.description, null); }); - it('fails validation if id is not a valid guid.', async () => { - const actual = await command.validate({ - options: { - environmentName: validEnvironment, - id: 'Invalid GUID', - tableName: validTableName - } - }, commandInfo); - assert.notStrictEqual(actual, true); + it('fails validation if id is not a valid guid.', () => { + const actual = commandOptionsSchema.safeParse({ + environmentName: validEnvironment, + id: 'Invalid GUID', + tableName: validTableName + }); + assert.strictEqual(actual.success, false); + }); + + it('passes validation if required options specified (tableName)', () => { + const actual = commandOptionsSchema.safeParse({ environmentName: validEnvironment, tableName: validTableName, id: validId }); + assert.strictEqual(actual.success, true); + }); + + it('passes validation if required options specified (entitySetName)', () => { + const actual = commandOptionsSchema.safeParse({ environmentName: validEnvironment, entitySetName: validEntitySetName, id: validId }); + assert.strictEqual(actual.success, true); + }); + + it('fails validation with unknown options', () => { + const actual = commandOptionsSchema.safeParse({ + environmentName: validEnvironment, + id: validId, + entitySetName: validEntitySetName, + unknownOption: 'value' + }); + assert.strictEqual(actual.success, false); }); - it('passes validation if required options specified (tableName)', async () => { - const actual = await command.validate({ options: { environmentName: validEnvironment, tableName: validTableName, id: validId } }, commandInfo); - assert.strictEqual(actual, true); + it('fails validation if both entitySetName and tableName are specified', () => { + const actual = commandOptionsSchema.safeParse({ environmentName: validEnvironment, entitySetName: validEntitySetName, tableName: validTableName, id: validId }); + assert.strictEqual(actual.success, false); }); - it('passes validation if required options specified (entitySetName)', async () => { - const actual = await command.validate({ options: { environmentName: validEnvironment, entitySetName: validEntitySetName, id: validId } }, commandInfo); - assert.strictEqual(actual, true); + it('fails validation if neither entitySetName nor tableName is specified', () => { + const actual = commandOptionsSchema.safeParse({ environmentName: validEnvironment, id: validId }); + assert.strictEqual(actual.success, false); }); it('prompts before removing the specified row from a dataverse table owned by the currently signed-in user when force option not passed', async () => { sinon.stub(powerPlatform, 'getDynamicsInstanceApiUrl').callsFake(async () => envUrl); await command.action(logger, { - options: { + options: commandOptionsSchema.parse({ environmentName: validEnvironment, - id: validId - } + id: validId, + entitySetName: validEntitySetName + }) }); assert(promptIssued); @@ -127,10 +148,11 @@ describe(commands.DATAVERSE_TABLE_ROW_REMOVE, () => { sinonUtil.restore(cli.promptForConfirmation); sinon.stub(cli, 'promptForConfirmation').resolves(false); await command.action(logger, { - options: { + options: commandOptionsSchema.parse({ environmentName: validEnvironment, - id: validId - } + id: validId, + entitySetName: validEntitySetName + }) }); assert(postSpy.notCalled); }); @@ -149,12 +171,12 @@ describe(commands.DATAVERSE_TABLE_ROW_REMOVE, () => { sinonUtil.restore(cli.promptForConfirmation); sinon.stub(cli, 'promptForConfirmation').resolves(true); await command.action(logger, { - options: { + options: commandOptionsSchema.parse({ debug: true, environmentName: validEnvironment, id: validId, entitySetName: validEntitySetName - } + }) }); assert(loggerLogToStderrSpy.called); }); @@ -181,13 +203,13 @@ describe(commands.DATAVERSE_TABLE_ROW_REMOVE, () => { }); await command.action(logger, { - options: { + options: commandOptionsSchema.parse({ debug: true, environmentName: validEnvironment, id: validId, tableName: validTableName, force: true - } + }) }); assert(loggerLogToStderrSpy.called); }); @@ -200,13 +222,13 @@ describe(commands.DATAVERSE_TABLE_ROW_REMOVE, () => { sinon.stub(request, 'delete').callsFake(async () => { throw { error: { error: { message: errorMessage } } }; }); await assert.rejects(command.action(logger, { - options: { + options: commandOptionsSchema.parse({ debug: true, environmentName: validEnvironment, id: validId, force: true, entitySetName: validEntitySetName - } + }) }), new CommandError(errorMessage)); }); @@ -222,13 +244,13 @@ describe(commands.DATAVERSE_TABLE_ROW_REMOVE, () => { }); await command.action(logger, { - options: { + options: commandOptionsSchema.parse({ debug: true, environmentName: validEnvironment, id: validId, entitySetName: validEntitySetName, force: true - } + }) }); assert(loggerLogToStderrSpy.called); diff --git a/src/m365/pp/commands/dataverse/dataverse-table-row-remove.ts b/src/m365/pp/commands/dataverse/dataverse-table-row-remove.ts index da10b5ceca7..f9ea91cf910 100644 --- a/src/m365/pp/commands/dataverse/dataverse-table-row-remove.ts +++ b/src/m365/pp/commands/dataverse/dataverse-table-row-remove.ts @@ -1,25 +1,31 @@ +import { z } from 'zod'; import { cli } from '../../../../cli/cli.js'; import { Logger } from '../../../../cli/Logger.js'; -import GlobalOptions from '../../../../GlobalOptions.js'; +import { globalOptionsZod } from '../../../../Command.js'; import request, { CliRequestOptions } from '../../../../request.js'; import { powerPlatform } from '../../../../utils/powerPlatform.js'; import { validation } from '../../../../utils/validation.js'; import PowerPlatformCommand from '../../../base/PowerPlatformCommand.js'; import commands from '../../commands.js'; +export const options = z.strictObject({ + ...globalOptionsZod.shape, + environmentName: z.string().alias('e'), + id: z.string().refine(val => validation.isValidGuid(val), { + error: 'The value must be a valid GUID.' + }).alias('i'), + entitySetName: z.string().optional(), + tableName: z.string().optional(), + asAdmin: z.boolean().optional(), + force: z.boolean().optional().alias('f') +}); + +declare type Options = z.infer; + interface CommandArgs { options: Options; } -interface Options extends GlobalOptions { - environmentName: string; - id: string; - entitySetName?: string; - tableName?: string; - asAdmin?: boolean; - force?: boolean; -} - class PpDataverseTableRowRemoveCommand extends PowerPlatformCommand { public get name(): string { @@ -30,65 +36,19 @@ class PpDataverseTableRowRemoveCommand extends PowerPlatformCommand { return 'Removes a specific row from a dataverse table in the specified Power Platform environment.'; } - constructor() { - super(); - - this.#initTelemetry(); - this.#initOptions(); - this.#initValidators(); - this.#initOptionSets(); + public get schema(): z.ZodType { + return options; } - #initTelemetry(): void { - this.telemetry.push((args: CommandArgs) => { - Object.assign(this.telemetryProperties, { - entitySetName: typeof args.options.entitySetName !== 'undefined', - tableName: typeof args.options.tableName !== 'undefined', - asAdmin: !!args.options.asAdmin, - force: !!args.options.force - }); - }); - } - - #initOptions(): void { - this.options.unshift( - { - option: '-e, --environmentName ' - }, - { - option: '-i, --id ' - }, - { - option: '--entitySetName [entitySetName]' - }, - { - option: '--tableName [tableName]' - }, - { - option: '--asAdmin' - }, - { - option: '-f, --force' - } - ); - } - - #initValidators(): void { - this.validators.push( - async (args: CommandArgs) => { - if (args.options.id && !validation.isValidGuid(args.options.id)) { - return `${args.options.id} is not a valid GUID`; + public getRefinedSchema(schema: typeof options): z.ZodObject | undefined { + return schema + .refine(opts => [opts.entitySetName, opts.tableName].filter(x => x !== undefined).length === 1, { + error: `Specify either 'entitySetName' or 'tableName', but not both.`, + params: { + customCode: 'optionSet', + options: ['entitySetName', 'tableName'] } - - return true; - } - ); - } - - #initOptionSets(): void { - this.optionSets.push( - { options: ['entitySetName', 'tableName'] } - ); + }); } public async commandAction(logger: Logger, args: any): Promise {