diff --git a/src/m365/cli/commands/cli-consent.spec.ts b/src/m365/cli/commands/cli-consent.spec.ts index 19c3a69797b..62951df3263 100644 --- a/src/m365/cli/commands/cli-consent.spec.ts +++ b/src/m365/cli/commands/cli-consent.spec.ts @@ -7,7 +7,7 @@ import { telemetry } from '../../../telemetry.js'; import { pid } from '../../../utils/pid.js'; import { session } from '../../../utils/session.js'; import commands from '../commands.js'; -import command from './cli-consent.js'; +import command, { options } from './cli-consent.js'; import { sinonUtil } from '../../../utils/sinonUtil.js'; describe(commands.CONSENT, () => { @@ -15,12 +15,14 @@ describe(commands.CONSENT, () => { let logger: Logger; let loggerLogSpy: any; let commandInfo: CommandInfo; + let commandOptionsSchema: typeof options; before(() => { sinon.stub(telemetry, 'trackEvent').resolves(); sinon.stub(pid, 'getProcessName').callsFake(() => ''); sinon.stub(session, 'getId').callsFake(() => ''); commandInfo = cli.getCommandInfo(command); + commandOptionsSchema = commandInfo.command.getSchemaToParse() as typeof options; }); beforeEach(() => { @@ -62,17 +64,27 @@ describe(commands.CONSENT, () => { it('shows consent URL for VivaEngage permissions for a custom single-tenant app', async () => { sinon.stub(cli, 'getTenant').returns('fb5cb38f-ecdb-4c6a-a93b-b8cfd56b4a89'); sinon.stub(cli, 'getClientId').returns('2587b55d-a41e-436d-bb1d-6223eb185dd4'); - await command.action(logger, { options: { service: 'VivaEngage' } }); + await command.action(logger, { options: commandOptionsSchema.parse({ service: 'VivaEngage' }) }); assert(loggerLogSpy.calledWith(`To consent permissions for executing VivaEngage commands, navigate in your web browser to https://login.microsoftonline.com/fb5cb38f-ecdb-4c6a-a93b-b8cfd56b4a89/oauth2/v2.0/authorize?client_id=2587b55d-a41e-436d-bb1d-6223eb185dd4&response_type=code&scope=https%3A%2F%2Fapi.yammer.com%2Fuser_impersonation`)); }); - it('fails validation if specified service is invalid ', async () => { - const actual = await command.validate({ options: { service: 'invalid' } }, commandInfo); - assert.notStrictEqual(actual, true); + it('fails validation with no options', () => { + const actual = commandOptionsSchema.safeParse({}); + assert.notStrictEqual(actual.success, true); }); - it('passes validation if service is set to VivaEngage ', async () => { - const actual = await command.validate({ options: { service: 'VivaEngage' } }, commandInfo); - assert.strictEqual(actual, true); + it('fails validation with unknown options', () => { + const actual = commandOptionsSchema.safeParse({ service: 'VivaEngage', unknownOption: 'value' }); + assert.notStrictEqual(actual.success, true); + }); + + it('fails validation if specified service is invalid ', () => { + const actual = commandOptionsSchema.safeParse({ service: 'invalid' }); + assert.notStrictEqual(actual.success, true); + }); + + it('passes validation if service is set to VivaEngage ', () => { + const actual = commandOptionsSchema.safeParse({ service: 'VivaEngage' }); + assert.strictEqual(actual.success, true); }); }); diff --git a/src/m365/cli/commands/cli-consent.ts b/src/m365/cli/commands/cli-consent.ts index 50de2ddb717..1af7b2b2aff 100644 --- a/src/m365/cli/commands/cli-consent.ts +++ b/src/m365/cli/commands/cli-consent.ts @@ -1,17 +1,20 @@ +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 AnonymousCommand from '../../base/AnonymousCommand.js'; import commands from '../commands.js'; +export const options = z.strictObject({ + ...globalOptionsZod.shape, + service: z.enum(['VivaEngage']).alias('s') +}); +declare type Options = z.infer; + interface CommandArgs { options: Options; } -interface Options extends GlobalOptions { - service: string; -} - class CliConsentCommand extends AnonymousCommand { public get name(): string { return commands.CONSENT; @@ -21,41 +24,8 @@ class CliConsentCommand extends AnonymousCommand { return 'Consent additional permissions for the Microsoft Entra application used by the CLI for Microsoft 365'; } - constructor() { - super(); - - this.#initTelemetry(); - this.#initOptions(); - this.#initValidators(); - } - - #initTelemetry(): void { - this.telemetry.push((args: CommandArgs) => { - Object.assign(this.telemetryProperties, { - service: args.options.service - }); - }); - } - - #initOptions(): void { - this.options.unshift( - { - option: '-s, --service ', - autocomplete: ['VivaEngage'] - } - ); - } - - #initValidators(): void { - this.validators.push( - async (args: CommandArgs) => { - if (args.options.service !== 'VivaEngage') { - return `${args.options.service} is not a valid value for the service option. Allowed values: VivaEngage`; - } - - return true; - } - ); + public get schema(): z.ZodType | undefined { + return options; } public async commandAction(logger: Logger, args: CommandArgs): Promise { diff --git a/src/m365/cli/commands/cli-doctor.spec.ts b/src/m365/cli/commands/cli-doctor.spec.ts index e324b047aeb..ad497ce0ea0 100644 --- a/src/m365/cli/commands/cli-doctor.spec.ts +++ b/src/m365/cli/commands/cli-doctor.spec.ts @@ -10,7 +10,7 @@ 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 './cli-doctor.js'; +import command, { options } from './cli-doctor.js'; const require = createRequire(import.meta.url); const packageJSON = require('../../../../package.json'); @@ -19,6 +19,7 @@ describe(commands.DOCTOR, () => { let log: any[]; let logger: Logger; let loggerLogSpy: sinon.SinonSpy; + let commandOptionsSchema: typeof options; before(() => { sinon.stub(auth, 'restoreAuth').callsFake(() => Promise.resolve()); @@ -27,6 +28,8 @@ describe(commands.DOCTOR, () => { sinon.stub(session, 'getId').callsFake(() => ''); auth.connection.active = true; sinon.stub(cli.getConfig(), 'all').value({}); + const commandInfo = cli.getCommandInfo(command); + commandOptionsSchema = commandInfo.command.getSchemaToParse() as typeof options; }); beforeEach(() => { @@ -67,6 +70,16 @@ describe(commands.DOCTOR, () => { assert.notStrictEqual(command.description, null); }); + it('passes validation with no options', () => { + const actual = commandOptionsSchema.safeParse({}); + assert.strictEqual(actual.success, true); + }); + + it('fails validation with unknown options', () => { + const actual = commandOptionsSchema.safeParse({ unknownOption: 'value' }); + assert.notStrictEqual(actual.success, true); + }); + it('retrieves scopes in the diagnostic information about the current environment', async () => { const jwt = JSON.stringify({ aud: 'https://graph.microsoft.com', @@ -90,7 +103,7 @@ describe(commands.DOCTOR, () => { sinon.stub(auth.connection, 'authType').value(AuthType.DeviceCode); sinon.stub(process, 'env').value({ 'CLIMICROSOFT365_ENV': '' }); - await command.action(logger, { options: {} }); + await command.action(logger, { options: commandOptionsSchema.parse({}) }); assert(loggerLogSpy.calledWith({ authMode: 'deviceCode', cliEntraAppId: '31359c7f-bd7e-475c-86db-fdb8c937548e', @@ -163,7 +176,7 @@ describe(commands.DOCTOR, () => { sinon.stub(auth.connection, 'authType').value(AuthType.DeviceCode); sinon.stub(process, 'env').value({ 'CLIMICROSOFT365_ENV': '' }); - await command.action(logger, { options: {} }); + await command.action(logger, { options: commandOptionsSchema.parse({}) }); assert(loggerLogSpy.calledWith({ authMode: 'deviceCode', cliEntraAppId: '31359c7f-bd7e-475c-86db-fdb8c937548e', @@ -210,7 +223,7 @@ describe(commands.DOCTOR, () => { sinon.stub(auth.connection, 'authType').value(AuthType.DeviceCode); sinon.stub(process, 'env').value({ 'CLIMICROSOFT365_ENV': '' }); - await command.action(logger, { options: {} }); + await command.action(logger, { options: commandOptionsSchema.parse({}) }); assert(loggerLogSpy.calledWith({ authMode: 'deviceCode', cliEntraAppId: '31359c7f-bd7e-475c-86db-fdb8c937548e', @@ -254,7 +267,7 @@ describe(commands.DOCTOR, () => { sinon.stub(auth.connection, 'authType').value(AuthType.DeviceCode); sinon.stub(process, 'env').value({ 'CLIMICROSOFT365_ENV': '' }); - await command.action(logger, { options: {} }); + await command.action(logger, { options: commandOptionsSchema.parse({}) }); assert(loggerLogSpy.calledWith({ authMode: 'deviceCode', cliEntraAppId: '31359c7f-bd7e-475c-86db-fdb8c937548e', @@ -291,7 +304,7 @@ describe(commands.DOCTOR, () => { sinon.stub(auth.connection, 'authType').value(AuthType.DeviceCode); sinon.stub(process, 'env').value({ 'CLIMICROSOFT365_ENV': '' }); - await command.action(logger, { options: {} }); + await command.action(logger, { options: commandOptionsSchema.parse({}) }); assert(loggerLogSpy.calledWith({ authMode: 'deviceCode', cliEntraAppId: '31359c7f-bd7e-475c-86db-fdb8c937548e', @@ -335,7 +348,7 @@ describe(commands.DOCTOR, () => { sinon.stub(auth.connection, 'authType').value(AuthType.DeviceCode); sinon.stub(process, 'env').value({ 'CLIMICROSOFT365_ENV': '' }); - await command.action(logger, { options: {} }); + await command.action(logger, { options: commandOptionsSchema.parse({}) }); assert(loggerLogSpy.calledWith({ authMode: 'deviceCode', cliEntraAppId: '31359c7f-bd7e-475c-86db-fdb8c937548e', @@ -371,7 +384,7 @@ describe(commands.DOCTOR, () => { sinon.stub(auth.connection, 'authType').value(AuthType.Certificate); sinon.stub(process, 'env').value({ 'CLIMICROSOFT365_ENV': '' }); - await command.action(logger, { options: {} }); + await command.action(logger, { options: commandOptionsSchema.parse({}) }); assert(loggerLogSpy.calledWith({ authMode: 'certificate', cliEntraAppId: '31359c7f-bd7e-475c-86db-fdb8c937548e', @@ -407,7 +420,7 @@ describe(commands.DOCTOR, () => { sinon.stub(auth.connection, 'authType').value(AuthType.Certificate); sinon.stub(process, 'env').value({ 'CLIMICROSOFT365_ENV': '' }); - await command.action(logger, { options: { debug: true } }); + await command.action(logger, { options: commandOptionsSchema.parse({ debug: true }) }); assert(loggerLogSpy.calledWith({ authMode: 'certificate', cliEntraAppId: '31359c7f-bd7e-475c-86db-fdb8c937548e', @@ -443,7 +456,7 @@ describe(commands.DOCTOR, () => { sinon.stub(auth.connection, 'authType').value(AuthType.Certificate); sinon.stub(process, 'env').value({ 'CLIMICROSOFT365_ENV': '' }); - await command.action(logger, { options: { debug: true } }); + await command.action(logger, { options: commandOptionsSchema.parse({ debug: true }) }); assert(loggerLogSpy.calledWith({ authMode: 'certificate', cliEntraAppId: '31359c7f-bd7e-475c-86db-fdb8c937548e', @@ -479,7 +492,7 @@ describe(commands.DOCTOR, () => { sinon.stub(auth.connection, 'authType').value(AuthType.Certificate); sinon.stub(process, 'env').value({ 'CLIMICROSOFT365_ENV': 'docker' }); - await command.action(logger, { options: { debug: true } }); + await command.action(logger, { options: commandOptionsSchema.parse({ debug: true }) }); assert(loggerLogSpy.calledWith({ authMode: 'certificate', cliEntraAppId: '31359c7f-bd7e-475c-86db-fdb8c937548e', @@ -508,7 +521,7 @@ describe(commands.DOCTOR, () => { sinon.stub(auth.connection, 'authType').value(AuthType.Certificate); sinon.stub(process, 'env').value({ 'CLIMICROSOFT365_ENV': '' }); - await command.action(logger, { options: { debug: true } }); + await command.action(logger, { options: commandOptionsSchema.parse({ debug: true }) }); assert(loggerLogSpy.calledWith({ authMode: 'certificate', cliEntraAppId: '31359c7f-bd7e-475c-86db-fdb8c937548e', @@ -538,7 +551,7 @@ describe(commands.DOCTOR, () => { sinon.stub(auth.connection, 'authType').value(AuthType.Certificate); sinon.stub(process, 'env').value({ 'CLIMICROSOFT365_ENV': '' }); - await command.action(logger, { options: { debug: true } }); + await command.action(logger, { options: commandOptionsSchema.parse({ debug: true }) }); assert(loggerLogSpy.calledWith({ authMode: 'certificate', cliEntraAppId: '31359c7f-bd7e-475c-86db-fdb8c937548e', @@ -576,7 +589,7 @@ describe(commands.DOCTOR, () => { sinonUtil.restore(cli.getConfig().all); sinon.stub(cli.getConfig(), 'all').value({ "showHelpOnFailure": false }); - await command.action(logger, { options: {} }); + await command.action(logger, { options: commandOptionsSchema.parse({}) }); assert(loggerLogSpy.calledWith({ authMode: 'deviceCode', cliEntraAppId: '31359c7f-bd7e-475c-86db-fdb8c937548e', diff --git a/src/m365/cli/commands/cli-doctor.ts b/src/m365/cli/commands/cli-doctor.ts index 782978c5585..4b7db7c53a7 100644 --- a/src/m365/cli/commands/cli-doctor.ts +++ b/src/m365/cli/commands/cli-doctor.ts @@ -1,12 +1,15 @@ import os from 'os'; +import { z } from 'zod'; import auth from '../../../Auth.js'; import { cli } from '../../../cli/cli.js'; import { Logger } from '../../../cli/Logger.js'; -import Command from '../../../Command.js'; +import Command, { globalOptionsZod } from '../../../Command.js'; import { app } from '../../../utils/app.js'; import { validation } from '../../../utils/validation.js'; import commands from '../commands.js'; +export const options = z.strictObject({ ...globalOptionsZod.shape }); + interface CliDiagnosticInfo { os: { platform: string; @@ -33,6 +36,10 @@ class CliDoctorCommand extends Command { return 'Retrieves diagnostic information about the current environment'; } + public get schema(): z.ZodType | undefined { + return options; + } + public async commandAction(logger: Logger): Promise { const roles: string[] = []; const scopes: Map = new Map(); diff --git a/src/m365/cli/commands/cli-issue.spec.ts b/src/m365/cli/commands/cli-issue.spec.ts index 24f35b16f51..9f742bd0f27 100644 --- a/src/m365/cli/commands/cli-issue.spec.ts +++ b/src/m365/cli/commands/cli-issue.spec.ts @@ -8,20 +8,21 @@ import { pid } from '../../../utils/pid.js'; import { session } from '../../../utils/session.js'; import commands from '../commands.js'; import { browserUtil } from '../../../utils/browserUtil.js'; -import command from './cli-issue.js'; +import command, { options } from './cli-issue.js'; describe(commands.ISSUE, () => { let log: any[]; let logger: Logger; let commandInfo: CommandInfo; + let commandOptionsSchema: typeof options; let openStub: sinon.SinonStub; before(() => { sinon.stub(telemetry, 'trackEvent').resolves(); sinon.stub(pid, 'getProcessName').callsFake(() => ''); sinon.stub(session, 'getId').callsFake(() => ''); - (command as any).open = () => { }; commandInfo = cli.getCommandInfo(command); + commandOptionsSchema = commandInfo.command.getSchemaToParse() as typeof options; }); beforeEach(() => { @@ -56,25 +57,34 @@ describe(commands.ISSUE, () => { assert.notStrictEqual(command.description, null); }); - it('accepts Bug issue Type', async () => { - const actual = await command.validate({ options: { type: 'bug' } }, commandInfo); - assert.strictEqual(actual, true); + it('fails validation with no options', () => { + const actual = commandOptionsSchema.safeParse({}); + assert.notStrictEqual(actual.success, true); }); - it('accepts Command issue Type', async () => { - const actual = await command.validate({ options: { type: 'command' } }, commandInfo); - assert.strictEqual(actual, true); + it('fails validation with unknown options', () => { + const actual = commandOptionsSchema.safeParse({ type: 'bug', unknownOption: 'value' }); + assert.notStrictEqual(actual.success, true); }); - it('accepts Sample issue Type', async () => { - const actual = await command.validate({ options: { type: 'sample' } }, commandInfo); - assert.strictEqual(actual, true); + it('accepts Bug issue Type', () => { + const actual = commandOptionsSchema.safeParse({ type: 'bug' }); + assert.strictEqual(actual.success, true); }); - it('rejects invalid issue type', async () => { - const type = 'foo'; - const actual = await command.validate({ options: { type: type } }, commandInfo); - assert.strictEqual(actual, `${type} is not a valid Issue type. Allowed values are bug, command, sample`); + it('accepts Command issue Type', () => { + const actual = commandOptionsSchema.safeParse({ type: 'command' }); + assert.strictEqual(actual.success, true); + }); + + it('accepts Sample issue Type', () => { + const actual = commandOptionsSchema.safeParse({ type: 'sample' }); + assert.strictEqual(actual.success, true); + }); + + it('rejects invalid issue type', () => { + const actual = commandOptionsSchema.safeParse({ type: 'foo' }); + assert.strictEqual(actual.success, false); }); it('Opens URL for a command (debug)', async () => { @@ -88,11 +98,11 @@ describe(commands.ISSUE, () => { throw 'Invalid url'; }); await command.action(logger, { - options: { + options: commandOptionsSchema.parse({ debug: true, type: 'command' - } - } as any); + }) + }); openStub.calledWith(commandUrl); }); @@ -106,11 +116,11 @@ describe(commands.ISSUE, () => { throw 'Invalid url'; }); await command.action(logger, { - options: { + options: commandOptionsSchema.parse({ debug: true, type: 'bug' - } - } as any); + }) + }); openStub.calledWith(bugUrl); }); @@ -124,11 +134,11 @@ describe(commands.ISSUE, () => { throw 'Invalid url'; }); await command.action(logger, { - options: { + options: commandOptionsSchema.parse({ debug: true, type: 'sample' - } - } as any); + }) + }); openStub.calledWith(sampleScriptUrl); }); }); diff --git a/src/m365/cli/commands/cli-issue.ts b/src/m365/cli/commands/cli-issue.ts index 7143f43a687..3ef852a1828 100644 --- a/src/m365/cli/commands/cli-issue.ts +++ b/src/m365/cli/commands/cli-issue.ts @@ -1,17 +1,20 @@ -import GlobalOptions from '../../../GlobalOptions.js'; +import { z } from 'zod'; import { Logger } from '../../../cli/Logger.js'; +import { globalOptionsZod } from '../../../Command.js'; import { browserUtil } from '../../../utils/browserUtil.js'; import AnonymousCommand from '../../base/AnonymousCommand.js'; import commands from '../commands.js'; +export const options = z.strictObject({ + ...globalOptionsZod.shape, + type: z.enum(['bug', 'command', 'sample']).alias('t') +}); +declare type Options = z.infer; + interface CommandArgs { options: Options; } -interface Options extends GlobalOptions { - type: string; -} - class CliIssueCommand extends AnonymousCommand { public get name(): string { return commands.ISSUE; @@ -21,41 +24,8 @@ class CliIssueCommand extends AnonymousCommand { return 'Returns, or opens a URL that takes the user to the right place in the CLI GitHub repo to create a new issue reporting bug, feedback, ideas, etc.'; } - constructor() { - super(); - - this.#initTelemetry(); - this.#initOptions(); - this.#initValidators(); - } - - #initTelemetry(): void { - this.telemetry.push((args: CommandArgs) => { - Object.assign(this.telemetryProperties, { - type: args.options.type - }); - }); - } - - #initOptions(): void { - this.options.unshift( - { - option: '-t, --type ', - autocomplete: CliIssueCommand.issueType - } - ); - } - - #initValidators(): void { - this.validators.push( - async (args: CommandArgs) => { - if (CliIssueCommand.issueType.indexOf(args.options.type) < 0) { - return `${args.options.type} is not a valid Issue type. Allowed values are ${CliIssueCommand.issueType.join(', ')}`; - } - - return true; - } - ); + public get schema(): z.ZodType | undefined { + return options; } public async commandAction(logger: Logger, args: CommandArgs): Promise { @@ -76,12 +46,6 @@ class CliIssueCommand extends AnonymousCommand { await browserUtil.open(issueLink); await logger.log(issueLink); } - - private static issueType: string[] = [ - 'bug', - 'command', - 'sample' - ]; } export default new CliIssueCommand();