From dff533c4918750347f7b3c9f5407aaf3e059f5cc Mon Sep 17 00:00:00 2001 From: Waldek Mastykarz Date: Thu, 28 May 2026 14:09:42 +0200 Subject: [PATCH] Migrates root commands to Zod. Closes #7291 --- src/m365/commands/request.spec.ts | 160 ++++++++++------------ src/m365/commands/request.ts | 109 ++++----------- src/m365/commands/search.spec.ts | 220 ++++++++++++++---------------- src/m365/commands/search.ts | 168 ++++++++--------------- src/m365/commands/setup.spec.ts | 86 ++++++------ src/m365/commands/setup.ts | 61 +++------ src/m365/commands/version.ts | 8 ++ 7 files changed, 328 insertions(+), 484 deletions(-) diff --git a/src/m365/commands/request.spec.ts b/src/m365/commands/request.spec.ts index 36e30b707b3..61834a9ecf5 100644 --- a/src/m365/commands/request.spec.ts +++ b/src/m365/commands/request.spec.ts @@ -13,7 +13,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 './request.js'; +import command, { options } from './request.js'; describe(commands.REQUEST, () => { let log: any[]; @@ -21,6 +21,7 @@ describe(commands.REQUEST, () => { let loggerLogSpy: sinon.SinonSpy; let loggerLogToStderrSpy: sinon.SinonSpy; let commandInfo: CommandInfo; + let commandOptionsSchema: typeof options; //#region const mockSPOWebJSONResponse = { "AllowRssFeeds": true, "AlternateCssUrl": "", "AppInstanceId": "00000000-0000-0000-0000-000000000000", "ClassicWelcomePage": null, "Configuration": 0, "Created": "2020-10-08T07:03:47.907", "CurrentChangeToken": { "StringValue": "1;2;d5f1681e-9480-4636-ac33-094bb75c44ff;637960770683600000;495812642" }, "CustomMasterUrl": "/_catalogs/masterpage/seattle.master", "Description": "", "DesignPackageId": "00000000-0000-0000-0000-000000000000", "DocumentLibraryCalloutOfficeWebAppPreviewersDisabled": false, "EnableMinimalDownload": false, "FooterEmphasis": 0, "FooterEnabled": true, "FooterLayout": 0, "HeaderEmphasis": 0, "HeaderLayout": 0, "HideTitleInHeader": false, "HorizontalQuickLaunch": false, "Id": "d5f1681e-9480-4636-ac33-094bb75c44ff", "IsEduClass": false, "IsEduClassProvisionChecked": false, "IsEduClassProvisionPending": false, "IsHomepageModernized": false, "IsMultilingual": true, "IsRevertHomepageLinkHidden": false, "Language": 1033, "LastItemModifiedDate": "2022-08-14T11:31:56Z", "LastItemUserModifiedDate": "2022-08-14T11:31:56Z", "LogoAlignment": 0, "MasterUrl": "/_catalogs/masterpage/seattle.master", "MegaMenuEnabled": true, "NavAudienceTargetingEnabled": false, "NoCrawl": false, "ObjectCacheEnabled": false, "OverwriteTranslationsOnChange": false, "ResourcePath": { "DecodedUrl": "https://contoso.sharepoint.com" }, "QuickLaunchEnabled": true, "RecycleBinEnabled": true, "SearchScope": 0, "ServerRelativeUrl": "/", "SiteLogoUrl": "/SiteAssets/__sitelogo__logo_240x240.png", "SyndicationEnabled": true, "TenantAdminMembersCanShare": 0, "Title": "Contoso Intranet", "TreeViewEnabled": false, "UIVersion": 15, "UIVersionConfigurationEnabled": false, "Url": "https://contoso.sharepoint.com", "WebTemplate": "SITEPAGEPUBLISHING", "WelcomePage": "SitePages/Home.aspx" }; @@ -51,6 +52,7 @@ describe(commands.REQUEST, () => { auth.connection.active = true; sinon.stub(auth, 'ensureAccessToken').callsFake(() => Promise.resolve('ABC')); commandInfo = cli.getCommandInfo(command); + commandOptionsSchema = commandInfo.command.getSchemaToParse() as typeof options; }); beforeEach(() => { @@ -93,80 +95,66 @@ describe(commands.REQUEST, () => { }); it('fails validation if wrong method is set', async () => { - const actual = await command.validate({ - options: { - url: 'https://contoso.sharepoint.com/_api/web', - method: 'gett' - } - }, commandInfo); - assert.notStrictEqual(actual, true); + const actual = commandOptionsSchema.safeParse({ + url: 'https://contoso.sharepoint.com/_api/web', + method: 'gett' + }); + assert.notStrictEqual(actual.success, true); }); it('fails validation if body is set when content-type is not specified', async () => { - const actual = await command.validate({ - options: { - url: 'https://contoso.sharepoint.com/_api/web', - body: '{ "key": "value" }', - method: 'post' - } - }, commandInfo); - assert.notStrictEqual(actual, true); + const actual = commandOptionsSchema.safeParse({ + url: 'https://contoso.sharepoint.com/_api/web', + body: '{ "key": "value" }', + method: 'post' + }); + assert.notStrictEqual(actual.success, true); }); it('fails validation if body is set on GET requests', async () => { - const actual = await command.validate({ - options: { - url: 'https://contoso.sharepoint.com/_api/web', - body: '{ "key": "value" }', - 'content-type': 'application/json', - method: 'get' - } - }, commandInfo); - assert.notStrictEqual(actual, true); + const actual = commandOptionsSchema.safeParse({ + url: 'https://contoso.sharepoint.com/_api/web', + body: '{ "key": "value" }', + 'content-type': 'application/json', + method: 'get' + }); + assert.notStrictEqual(actual.success, true); }); it('fails validation if filePath doesn\'t exist', async () => { sinon.stub(fs, 'existsSync').callsFake(() => false); - const actual = await command.validate({ - options: { - url: "https://contoso.sharepoint.com/_api/web/GetFileById('b2307a39-e878-458b-bc90-03bc578531d6')/$value", - method: 'get', - filePath: 'abc' - } - }, commandInfo); + const actual = commandOptionsSchema.safeParse({ + url: "https://contoso.sharepoint.com/_api/web/GetFileById('b2307a39-e878-458b-bc90-03bc578531d6')/$value", + method: 'get', + filePath: 'abc' + }); sinonUtil.restore(fs.existsSync); - assert.notStrictEqual(actual, true); + assert.notStrictEqual(actual.success, true); }); it('passes validation with body and content-type on POST request', async () => { - const actual = await command.validate({ - options: { - url: 'https://contoso.sharepoint.com/_api/web', - body: '{ "key": "value" }', - 'content-type': 'application/json', - method: 'post' - } - }, commandInfo); - assert.strictEqual(actual, true); + const actual = commandOptionsSchema.safeParse({ + url: 'https://contoso.sharepoint.com/_api/web', + body: '{ "key": "value" }', + 'content-type': 'application/json', + method: 'post' + }); + assert.strictEqual(actual.success, true); }); it('passes validation with correct method set', async () => { - const actual = await command.validate({ - options: { - url: 'https://contoso.sharepoint.com/_api/web', - method: 'get' - } - }, commandInfo); - assert.strictEqual(actual, true); + const actual = commandOptionsSchema.safeParse({ + url: 'https://contoso.sharepoint.com/_api/web', + method: 'get' + }); + assert.strictEqual(actual.success, true); }); it('passes validation with no method set', async () => { - const actual = await command.validate({ - options: { - url: 'https://contoso.sharepoint.com/_api/web' - } - }, commandInfo); - assert.strictEqual(actual, true); + const actual = commandOptionsSchema.safeParse({ + url: 'https://contoso.sharepoint.com/_api/web' + }); + assert.strictEqual(actual.success, true); }); it('correctly defaults to a GET request accepting a json response', async () => { @@ -179,9 +167,9 @@ describe(commands.REQUEST, () => { }); await command.action(logger, { - options: { + options: commandOptionsSchema.parse({ url: 'https://contoso.sharepoint.com/_api/web' - } + }) }); }); @@ -195,10 +183,10 @@ describe(commands.REQUEST, () => { }); await command.action(logger, { - options: { + options: commandOptionsSchema.parse({ url: 'https://contoso.sharepoint.com/_api/web', accept: 'application/json;odata=nometadata' - } + }) }); assert(loggerLogSpy.calledWith(mockSPOWebJSONResponse)); }); @@ -213,10 +201,10 @@ describe(commands.REQUEST, () => { }); await command.action(logger, { - options: { + options: commandOptionsSchema.parse({ url: 'https://contoso.sharepoint.com/_api/web?$select=Title', accept: 'application/xml' - } + }) }); assert(loggerLogSpy.calledWith(mockSPOWebXMLResponse)); }); @@ -231,11 +219,11 @@ describe(commands.REQUEST, () => { }); await command.action(logger, { - options: { + options: commandOptionsSchema.parse({ url: 'https://contoso.sharepoint.com/_api/web', accept: 'application/json;odata=nometadata', debug: true - } + }) }); assert(loggerLogSpy.calledWith(mockSPOWebJSONResponse)); }); @@ -250,13 +238,13 @@ describe(commands.REQUEST, () => { }); await command.action(logger, { - options: { + options: commandOptionsSchema.parse({ url: 'https://contoso.sharepoint.com/_api/web', accept: 'application/json;odata=nometadata', 'content-type': 'application/json', 'x-http-method': 'PATCH', method: 'post' - } + }) }); assert(loggerLogSpy.calledWith(mockSPOWebJSONResponse)); }); @@ -271,11 +259,11 @@ describe(commands.REQUEST, () => { }); await command.action(logger, { - options: { + options: commandOptionsSchema.parse({ url: 'https://contoso.sharepoint.com/_api/web', accept: 'application/json;odata=nometadata', resource: 'https://contoso.sharepoint.com' - } + }) }); assert(loggerLogSpy.calledWith(mockSPOWebJSONResponse)); }); @@ -290,12 +278,12 @@ describe(commands.REQUEST, () => { }); await command.action(logger, { - options: { + options: commandOptionsSchema.parse({ url: 'https://contoso.sharepoint.com/_api/web', accept: 'application/json;odata=nometadata', resource: 'https://contoso.sharepoint.com', debug: true - } + }) }); assert(loggerLogToStderrSpy.called); assert(loggerLogSpy.calledWith(mockSPOWebJSONResponse)); @@ -311,10 +299,10 @@ describe(commands.REQUEST, () => { }); await command.action(logger, { - options: { + options: commandOptionsSchema.parse({ url: '@graph/me', accept: 'application/json;odata.metadata=none' - } + }) }); assert(loggerLogSpy.calledWith(graphResponse)); }); @@ -330,10 +318,10 @@ describe(commands.REQUEST, () => { }); await command.action(logger, { - options: { + options: commandOptionsSchema.parse({ url: '@graphbeta/me', accept: 'application/json;odata.metadata=none' - } + }) }); assert(loggerLogSpy.calledWith(graphResponse)); }); @@ -349,10 +337,10 @@ describe(commands.REQUEST, () => { }); await command.action(logger, { - options: { + options: commandOptionsSchema.parse({ url: '@spo/_api/web', accept: 'application/json;odata=nometadata' - } + }) }); assert(loggerLogSpy.calledWith(mockSPOWebJSONResponse)); }); @@ -360,10 +348,10 @@ describe(commands.REQUEST, () => { it('throws error when using the @spo token when there is nog spoUrl in the auth service', async () => { auth.connection.spoUrl = undefined; await assert.rejects(command.action(logger, { - options: { + options: commandOptionsSchema.parse({ url: '@spo/_api/web', accept: 'application/json;odata=nometadata' - } + }) }), new CommandError(`SharePoint root site URL is unknown. Please set your SharePoint URL using command 'spo set'.`)); }); @@ -373,10 +361,10 @@ describe(commands.REQUEST, () => { }); await assert.rejects(command.action(logger, { - options: { + options: commandOptionsSchema.parse({ url: 'https://contoso.sharepoint.com/_api/web' - } - } as any), new CommandError('Invalid request')); + }) + }), new CommandError('Invalid request')); }); @@ -403,14 +391,14 @@ describe(commands.REQUEST, () => { return Promise.reject('Invalid request'); }); - const options = { + const options = commandOptionsSchema.parse({ verbose: true, url: "https://contoso.sharepoint.com/_api/web/GetFileById('b2307a39-e878-458b-bc90-03bc578531d6')/$value", body: '{ "key": "value" }', 'content-type': 'application/json', - method: 'get', + method: 'post', filePath: 'test1.docx' - }; + }); await command.action(logger, { options: options } as any); assert(fsStub.calledOnce); @@ -442,13 +430,13 @@ describe(commands.REQUEST, () => { return Promise.reject('Invalid request'); }); - const options = { + const options = commandOptionsSchema.parse({ url: "https://contoso.sharepoint.com/_api/web/GetFileById('b2307a39-e878-458b-bc90-03bc578531d6')/$value", body: '{ "key": "value" }', 'content-type': 'application/json', - method: 'get', + method: 'post', filePath: 'test1.docx' - }; + }); await assert.rejects(command.action(logger, { options: options } as any), new CommandError('Writestream throws error')); assert(fsStub.calledOnce); diff --git a/src/m365/commands/request.ts b/src/m365/commands/request.ts index 3a43cc3a935..889a8f0ebd3 100644 --- a/src/m365/commands/request.ts +++ b/src/m365/commands/request.ts @@ -1,28 +1,31 @@ import { AxiosRequestConfig, AxiosResponse, RawAxiosRequestHeaders } from 'axios'; import fs from 'fs'; import path from 'path'; +import { z } from 'zod'; import auth from '../../Auth.js'; -import Command from '../../Command.js'; -import GlobalOptions from '../../GlobalOptions.js'; +import Command, { globalOptionsZod } from '../../Command.js'; import { Logger } from '../../cli/Logger.js'; import request from '../../request.js'; import commands from './commands.js'; +const allowedMethods = ['get', 'post', 'put', 'patch', 'delete', 'head', 'options'] as const; + +export const options = z.looseObject({ + ...globalOptionsZod.shape, + url: z.string().alias('u'), + method: z.enum(allowedMethods).default('get').alias('m'), + resource: z.string().optional().alias('r'), + body: z.string().optional().alias('b'), + filePath: z.string().optional().alias('p') +}); + +declare type Options = z.infer; + interface CommandArgs { options: Options; } -interface Options extends GlobalOptions { - url: string; - method?: string; - resource?: string; - body?: string; - filePath?: string; -} - class RequestCommand extends Command { - private allowedMethods: string[] = ['get', 'post', 'put', 'patch', 'delete', 'head', 'options']; - public get name(): string { return commands.REQUEST; } @@ -31,75 +34,21 @@ class RequestCommand extends Command { return 'Executes the specified web request using CLI for Microsoft 365'; } - public allowUnknownOptions(): boolean | undefined { - return true; + public get schema(): z.ZodType | undefined { + return options; } - constructor() { - super(); - - this.#initTelemetry(); - this.#initOptions(); - this.#initValidators(); - } - - #initTelemetry(): void { - this.telemetry.push((args: CommandArgs) => { - Object.assign(this.telemetryProperties, { - method: args.options.method || 'get', - resource: typeof args.options.resource !== 'undefined', - accept: args.options.accept || 'application/json', - body: typeof args.options.body !== 'undefined', - filePath: typeof args.options.filePath !== 'undefined' + public getRefinedSchema(schema: typeof options): z.ZodObject | undefined { + return schema + .refine(opts => !opts.body || (opts.method !== 'get'), { + error: 'Specify a different method when using body' + }) + .refine(opts => !opts.body || opts['content-type'], { + error: 'Specify the content-type when using body' + }) + .refine(opts => !opts.filePath || fs.existsSync(path.dirname(opts.filePath)), { + error: 'The location specified in the filePath does not exist' }); - this.trackUnknownOptions(this.telemetryProperties, args.options); - }); - } - - #initOptions(): void { - this.options.unshift( - { - option: '-u, --url ' - }, - { - option: '-m, --method [method]', - autocomplete: this.allowedMethods - }, - { - option: '-r, --resource [resource]' - }, - { - option: '-b, --body [body]' - }, - { - option: '-p, --filePath [filePath]' - } - ); - } - - #initValidators(): void { - this.validators.push( - async (args: CommandArgs) => { - const currentMethod = args.options.method || 'get'; - if (this.allowedMethods.indexOf(currentMethod) === -1) { - return `${currentMethod} is not a valid value for method. Allowed values: ${this.allowedMethods.join(', ')}`; - } - - if (args.options.body && (!args.options.method || args.options.method === 'get')) { - return 'Specify a different method when using body'; - } - - if (args.options.body && !args.options['content-type']) { - return 'Specify the content-type when using body'; - } - - if (args.options.filePath && !fs.existsSync(path.dirname(args.options.filePath))) { - return 'The location specified in the filePath does not exist'; - } - - return true; - } - ); } public async commandAction(logger: Logger, args: CommandArgs): Promise { @@ -109,10 +58,10 @@ class RequestCommand extends Command { try { const url = this.resolveUrlTokens(args.options.url); - const method = (args.options.method || 'get').toUpperCase(); + const method = args.options.method.toUpperCase(); const headers: RawAxiosRequestHeaders = {}; - this.addUnknownOptionsToPayload(headers, args.options); + this.addUnknownOptionsToPayloadZod(headers, args.options); if (!headers.accept) { headers.accept = 'application/json'; diff --git a/src/m365/commands/search.spec.ts b/src/m365/commands/search.spec.ts index 32188f6a501..e6353b3fc90 100644 --- a/src/m365/commands/search.spec.ts +++ b/src/m365/commands/search.spec.ts @@ -1,7 +1,7 @@ import assert from 'assert'; import sinon from 'sinon'; import auth from '../../Auth.js'; -import command from './search.js'; +import command, { options } from './search.js'; import { telemetry } from '../../telemetry.js'; import { pid } from '../../utils/pid.js'; import { session } from '../../utils/session.js'; @@ -369,6 +369,7 @@ describe(commands.SEARCH, () => { let logger: Logger; let loggerLogSpy: sinon.SinonSpy; let commandInfo: CommandInfo; + let commandOptionsSchema: typeof options; before(() => { sinon.stub(auth, 'restoreAuth').resolves(); @@ -377,6 +378,7 @@ describe(commands.SEARCH, () => { sinon.stub(session, 'getId').returns(''); auth.connection.active = true; commandInfo = cli.getCommandInfo(command); + commandOptionsSchema = commandInfo.command.getSchemaToParse() as typeof options; }); beforeEach(() => { @@ -416,161 +418,137 @@ describe(commands.SEARCH, () => { }); it('passes validation if scopes contains allowed values', async () => { - const actual = await command.validate({ - options: { - scopes: 'chatMessage,message,event,drive,driveItem,list,listItem,site,bookmark,acronym,person' - } - }, commandInfo); - assert.strictEqual(actual, true); + const actual = commandOptionsSchema.safeParse({ + scopes: 'chatMessage,message,event,drive,driveItem,list,listItem,site,bookmark,acronym,person' + }); + assert.strictEqual(actual.success, true); }); it('passes validation if startIndex equals 0', async () => { - const actual = await command.validate({ - options: { - scopes: 'message', - startIndex: 0 - } - }, commandInfo); - assert.strictEqual(actual, true); + const actual = commandOptionsSchema.safeParse({ + scopes: 'message', + startIndex: 0 + }); + assert.strictEqual(actual.success, true); }); it('passes validation if startIndex is greater than 0', async () => { - const actual = await command.validate({ - options: { - scopes: 'message', - startIndex: 50 - } - }, commandInfo); - assert.strictEqual(actual, true); + const actual = commandOptionsSchema.safeParse({ + scopes: 'message', + startIndex: 50 + }); + assert.strictEqual(actual.success, true); }); it('passes validation if pageSize is in allowed range', async () => { - const actual = await command.validate({ - options: { - scopes: 'message', - pageSize: 50 - } - }, commandInfo); - assert.strictEqual(actual, true); + const actual = commandOptionsSchema.safeParse({ + scopes: 'message', + pageSize: 50 + }); + assert.strictEqual(actual.success, true); }); it('passes validation if enableTopResults is specified for message scope', async () => { - const actual = await command.validate({ - options: { - scopes: 'message', - enableTopResults: true - } - }, commandInfo); - assert.strictEqual(actual, true); + const actual = commandOptionsSchema.safeParse({ + scopes: 'message', + enableTopResults: true + }); + assert.strictEqual(actual.success, true); }); it('passes validation if enableTopResults is specified for chatMessage scope', async () => { - const actual = await command.validate({ - options: { - scopes: 'chatMessage', - enableTopResults: true - } - }, commandInfo); - assert.strictEqual(actual, true); + const actual = commandOptionsSchema.safeParse({ + scopes: 'chatMessage', + enableTopResults: true + }); + assert.strictEqual(actual.success, true); }); it('passes validation if enableTopResults is specified for message and chatMessage scope', async () => { - const actual = await command.validate({ - options: { - scopes: 'message, chatMessage', - enableTopResults: true - } - }, commandInfo); - assert.strictEqual(actual, true); + const actual = commandOptionsSchema.safeParse({ + scopes: 'message, chatMessage', + enableTopResults: true + }); + assert.strictEqual(actual.success, true); }); it('fails validation if scopes contains not allowed value', async () => { - const actual = await command.validate({ - options: { - scopes: 'chatMessage,message,event,drive,driveItem,list,listItem,site,bookmarks,acronyms,person,foo' - } - }, commandInfo); - assert.notStrictEqual(actual, true); + const actual = commandOptionsSchema.safeParse({ + scopes: 'chatMessage,message,event,drive,driveItem,list,listItem,site,bookmarks,acronyms,person,foo' + }); + assert.notStrictEqual(actual.success, true); }); it('fails validation if startIndex is less than 0', async () => { - const actual = await command.validate({ - options: { - scopes: 'message', - startIndex: -1 - } - }, commandInfo); - assert.notStrictEqual(actual, true); + const actual = commandOptionsSchema.safeParse({ + scopes: 'message', + startIndex: -1 + }); + assert.notStrictEqual(actual.success, true); }); it('fails validation if pageSize is less than 1', async () => { - const actual = await command.validate({ - options: { - scopes: 'message', - pageSize: -1 - } - }, commandInfo); - assert.notStrictEqual(actual, true); + const actual = commandOptionsSchema.safeParse({ + scopes: 'message', + pageSize: -1 + }); + assert.notStrictEqual(actual.success, true); }); it('fails validation if pageSize is greater than 500', async () => { - const actual = await command.validate({ - options: { - scopes: 'message', - pageSize: 501 - } - }, commandInfo); - assert.notStrictEqual(actual, true); + const actual = commandOptionsSchema.safeParse({ + scopes: 'message', + pageSize: 501 + }); + assert.notStrictEqual(actual.success, true); }); it('fails validation if enableTopResults is specified together with scope other than message or chatMessage', async () => { - const actual = await command.validate({ - options: { - scopes: 'event', - enableTopResults: true - } - }, commandInfo); - assert.notStrictEqual(actual, true); + const actual = commandOptionsSchema.safeParse({ + scopes: 'event', + enableTopResults: true + }); + assert.notStrictEqual(actual.success, true); }); it('fails validation if enableTopResults is specified together with multiple scopes, but only one is message', async () => { - const actual = await command.validate({ - options: { - scopes: 'message,event', - enableTopResults: true - } - }, commandInfo); - assert.notStrictEqual(actual, true); + const actual = commandOptionsSchema.safeParse({ + scopes: 'message,event', + enableTopResults: true + }); + assert.notStrictEqual(actual.success, true); }); it('fails validation if enableTopResults is specified together with multiple scopes, but only one is chatMessage', async () => { - const actual = await command.validate({ - options: { - scopes: 'chatMessage,event', - enableTopResults: true - } - }, commandInfo); - assert.notStrictEqual(actual, true); + const actual = commandOptionsSchema.safeParse({ + scopes: 'chatMessage,event', + enableTopResults: true + }); + assert.notStrictEqual(actual.success, true); + }); + + it('fails validation if enableTopResults is specified together with more than two scopes', async () => { + const actual = commandOptionsSchema.safeParse({ + scopes: 'message,chatMessage,event', + enableTopResults: true + }); + assert.notStrictEqual(actual.success, true); }); it('fails validation if sortBy is specified for message scope', async () => { - const actual = await command.validate({ - options: { - scopes: 'message', - sortBy: 'subject' - } - }, commandInfo); - assert.notStrictEqual(actual, true); + const actual = commandOptionsSchema.safeParse({ + scopes: 'message', + sortBy: 'subject' + }); + assert.notStrictEqual(actual.success, true); }); it('fails validation if sortBy is specified for event scope', async () => { - const actual = await command.validate({ - options: { - scopes: 'event', - sortBy: 'subject' - } - }, commandInfo); - assert.notStrictEqual(actual, true); + const actual = commandOptionsSchema.safeParse({ + scopes: 'event', + sortBy: 'subject' + }); + assert.notStrictEqual(actual.success, true); }); it('successfully returns search response when queryText is not specified', async () => { @@ -597,7 +575,7 @@ describe(commands.SEARCH, () => { throw 'Invalid request'; }); - await command.action(logger, { options: { scopes: 'message' } }); + await command.action(logger, { options: commandOptionsSchema.parse({ scopes: 'message' }) }); assert(loggerLogSpy.calledOnceWithExactly(fullSearchResponse)); }); @@ -625,7 +603,7 @@ describe(commands.SEARCH, () => { throw 'Invalid request'; }); - await command.action(logger, { options: { scopes: 'message', queryText: 'contoso' } }); + await command.action(logger, { options: commandOptionsSchema.parse({ scopes: 'message', queryText: 'contoso' }) }); assert(loggerLogSpy.calledOnceWithExactly(fullSearchResponse)); }); @@ -653,7 +631,7 @@ describe(commands.SEARCH, () => { throw 'Invalid request'; }); - await command.action(logger, { options: { scopes: 'message', queryText: 'contoso', resultsOnly: true } }); + await command.action(logger, { options: commandOptionsSchema.parse({ scopes: 'message', queryText: 'contoso', resultsOnly: true }) }); assert(loggerLogSpy.calledOnceWithExactly(resultsOnlySearchResponse)); }); @@ -681,7 +659,7 @@ describe(commands.SEARCH, () => { throw 'Invalid request'; }); - await command.action(logger, { options: { scopes: 'message', queryText: 'contoso', pageSize: 1 } }); + await command.action(logger, { options: commandOptionsSchema.parse({ scopes: 'message', queryText: 'contoso', pageSize: 1 }) }); assert(loggerLogSpy.calledOnceWithExactly(fullSearchResponse)); }); @@ -709,7 +687,7 @@ describe(commands.SEARCH, () => { throw 'Invalid request'; }); - await command.action(logger, { options: { scopes: 'message', queryText: 'contoso', startIndex: 10 } }); + await command.action(logger, { options: commandOptionsSchema.parse({ scopes: 'message', queryText: 'contoso', startIndex: 10 }) }); assert(loggerLogSpy.calledOnceWithExactly(fullSearchResponse)); }); @@ -741,7 +719,7 @@ describe(commands.SEARCH, () => { throw 'Invalid request'; }); - await command.action(logger, { options: { scopes: 'message', queryText: 'contoso', select: 'subject,importance' } }); + await command.action(logger, { options: commandOptionsSchema.parse({ scopes: 'message', queryText: 'contoso', select: 'subject,importance' }) }); assert(loggerLogSpy.calledOnceWithExactly(selectedPropertiesSearchResponse)); }); @@ -779,7 +757,7 @@ describe(commands.SEARCH, () => { throw 'Invalid request'; }); - await command.action(logger, { options: { scopes: 'driveItem', queryText: 'contoso', sortBy: 'name:desc,createdDateTime' } }); + await command.action(logger, { options: commandOptionsSchema.parse({ scopes: 'driveItem', queryText: 'contoso', sortBy: 'name:desc,createdDateTime' }) }); assert(loggerLogSpy.calledOnceWithExactly(selectedPropertiesSearchResponse)); }); @@ -810,7 +788,7 @@ describe(commands.SEARCH, () => { throw 'Invalid request'; }); - await command.action(logger, { options: { scopes: 'driveItem', queryText: 'contoso', enableSpellingModification: true, enableSpellingSuggestion: true } }); + await command.action(logger, { options: commandOptionsSchema.parse({ scopes: 'driveItem', queryText: 'contoso', enableSpellingModification: true, enableSpellingSuggestion: true }) }); assert(loggerLogSpy.calledOnceWithExactly(spellingCorrectionSearchResponse)); }); @@ -857,7 +835,7 @@ describe(commands.SEARCH, () => { throw 'Invalid request'; }); - await command.action(logger, { options: { scopes: 'message', queryText: 'contoso', allResults: true } }); + await command.action(logger, { options: commandOptionsSchema.parse({ scopes: 'message', queryText: 'contoso', allResults: true }) }); assert(loggerLogSpy.calledOnceWithExactly(allResults)); }); @@ -919,6 +897,6 @@ describe(commands.SEARCH, () => { throw 'Invalid request'; }); - await assert.rejects(command.action(logger, { options: { scopes: 'message', queryText: '' } }), new CommandError('SearchRequest Invalid (EntityRequest Invalid (searchRequest -> query is invalid (queryString required)))')); + await assert.rejects(command.action(logger, { options: commandOptionsSchema.parse({ scopes: 'message', queryText: '' }) }), new CommandError('SearchRequest Invalid (EntityRequest Invalid (searchRequest -> query is invalid (queryString required)))')); }); }); \ No newline at end of file diff --git a/src/m365/commands/search.ts b/src/m365/commands/search.ts index 223719b0e99..27a97f45bb4 100644 --- a/src/m365/commands/search.ts +++ b/src/m365/commands/search.ts @@ -1,32 +1,49 @@ import { SearchHit, SearchResponse } from '@microsoft/microsoft-graph-types'; +import { z } from 'zod'; import { Logger } from '../../cli/Logger.js'; +import { globalOptionsZod } from '../../Command.js'; import request, { CliRequestOptions } from '../../request.js'; -import GraphCommand from '../base/GraphCommand.js'; -import GlobalOptions from '../../GlobalOptions.js'; import { ODataResponse } from '../../utils/odata.js'; +import GraphCommand from '../base/GraphCommand.js'; import commands from './commands.js'; +const allowedScopes = ['chatMessage', 'message', 'event', 'drive', 'driveItem', 'list', 'listItem', 'site', 'bookmark', 'acronym', 'person'] as const; + +export const options = z.strictObject({ + ...globalOptionsZod.shape, + queryText: z.string().optional().alias('q'), + scopes: z.string() + .refine(value => value.split(',').map(x => x.trim()).every(scope => (allowedScopes as readonly string[]).includes(scope)), { + error: e => { + const scopes = (e.input as string).split(',').map(x => x.trim()); + const invalidScope = scopes.find(scope => !(allowedScopes as readonly string[]).includes(scope)); + return `'${invalidScope}'' is not a valid scope. Allowed scopes are ${allowedScopes.join(', ')}.`; + } + }).alias('s'), + startIndex: z.number() + .refine(n => n >= 0, { + error: e => `'${e.input}' is not a valid value for option 'startIndex'. Start index must be greater or equal to 0.` + }).optional(), + pageSize: z.number() + .refine(n => n >= 1 && n <= 500, { + error: e => `'${e.input}' is not a valid value for option 'pageSize'. Page size must be between 1 and 500.` + }).optional(), + allResults: z.boolean().optional(), + resultsOnly: z.boolean().optional(), + enableTopResults: z.boolean().optional(), + select: z.string().optional(), + sortBy: z.string().optional(), + enableSpellingSuggestion: z.boolean().optional(), + enableSpellingModification: z.boolean().optional() +}); + +declare type Options = z.infer; + interface CommandArgs { options: Options; } -interface Options extends GlobalOptions { - queryText?: string; - scopes: string; - startIndex?: number; - pageSize?: number; - allResults?: boolean; - resultsOnly?: boolean; - enableTopResults?: boolean; - select?: string; - sortBy?: string; - enableSpellingSuggestion?: boolean; - enableSpellingModification?: boolean; -} - class SearchCommand extends GraphCommand { - private allowedScopes: string[] = ['chatMessage', 'message', 'event', 'drive', 'driveItem', 'list', 'listItem', 'site', 'bookmark', 'acronym', 'person']; - public get name(): string { return commands.SEARCH; } @@ -35,101 +52,36 @@ class SearchCommand extends GraphCommand { return 'Uses the Microsoft Search to query Microsoft 365 data'; } - constructor() { - super(); - - this.#initTelemetry(); - this.#initOptions(); - this.#initValidators(); - } - - #initTelemetry(): void { - this.telemetry.push((args: CommandArgs) => { - Object.assign(this.telemetryProperties, { - queryText: typeof args.options.queryText !== 'undefined', - startIndex: typeof args.options.startIndex !== 'undefined', - pageSize: typeof args.options.pageSize !== 'undefined', - allResults: !!args.options.allResults, - resultsOnly: !!args.options.resultsOnly, - enableTopResults: !!args.options.enableTopResults, - select: typeof args.options.select !== 'undefined', - sortBy: typeof args.options.sortBy !== 'undefined', - enableSpellingSuggestion: !!args.options.enableSpellingSuggestion, - enableSpellingModification: !!args.options.enableSpellingModification - }); - }); - } - - #initOptions(): void { - this.options.unshift( - { - option: '-q, --queryText [queryText]' - }, - { - option: '-s, --scopes ', - autocomplete: this.allowedScopes - }, - { - option: '--startIndex [startIndex]' - }, - { - option: '--pageSize [pageSize]' - }, - { - option: '--allResults' - }, - { - option: '--resultsOnly' - }, - { - option: '--enableTopResults' - }, - { - option: '--select [select]' - }, - { - option: '--sortBy [sortBy]' - }, - { - option: '--enableSpellingSuggestion' - }, - { - option: '--enableSpellingModification' - } - ); + public get schema(): z.ZodType | undefined { + return options; } - #initValidators(): void { - this.validators.push( - async (args: CommandArgs) => { - const scopes = args.options.scopes.split(',').map(x => x.trim()); - - if (!scopes.every(scope => this.allowedScopes.indexOf(scope) > -1)) { - const invalidScope = scopes.find(scope => this.allowedScopes.indexOf(scope) === -1); - return `'${invalidScope}'' is not a valid scope. Allowed scopes are ${this.allowedScopes.join(', ')}.`; - } - - if (args.options.startIndex !== undefined && args.options.startIndex < 0) { - return `'${args.options.startIndex}' is not a valid value for option 'startIndex'. Start index must be greater or equal to 0.`; - } - - if (args.options.pageSize !== undefined && (args.options.pageSize < 1 || args.options.pageSize > 500)) { - return `'${args.options.pageSize}' is not a valid value for option 'pageSize'. Page size must be between 1 and 500.`; - } - - if (args.options.sortBy && scopes.some(scope => scope === 'message' || scope === 'event')) { - return 'Sorting the results is not supported for messages and events.'; + public getRefinedSchema(schema: typeof options): z.ZodObject | undefined { + return schema + .refine(opts => { + if (opts.sortBy) { + const scopes = opts.scopes.split(',').map(x => x.trim()); + return !scopes.some(scope => scope === 'message' || scope === 'event'); } - - if (args.options.enableTopResults && - ((scopes.length === 1 && scopes.indexOf('message') === -1 && scopes.indexOf('chatMessage') === -1) || - (scopes.length === 2) && !(scopes.indexOf('message') > -1 && scopes.indexOf('chatMessage') > -1))) { - return 'Top results are only supported for messages and chat messages.'; + return true; + }, { + error: 'Sorting the results is not supported for messages and events.' + }) + .refine(opts => { + if (opts.enableTopResults) { + const scopes = opts.scopes.split(',').map(x => x.trim()); + if (scopes.length === 1) { + return scopes[0] === 'message' || scopes[0] === 'chatMessage'; + } + if (scopes.length === 2) { + return scopes.includes('message') && scopes.includes('chatMessage'); + } + return false; } - return true; - } - ); + }, { + error: 'Top results are only supported for messages and chat messages.' + }); } public async commandAction(logger: Logger, args: CommandArgs): Promise { diff --git a/src/m365/commands/setup.spec.ts b/src/m365/commands/setup.spec.ts index 8e6a6a93bc4..efba63cac9a 100644 --- a/src/m365/commands/setup.spec.ts +++ b/src/m365/commands/setup.spec.ts @@ -16,7 +16,7 @@ import { ConfirmationConfig, SelectionConfig } from '../../utils/prompt.js'; import { session } from '../../utils/session.js'; import { sinonUtil } from '../../utils/sinonUtil.js'; import commands from './commands.js'; -import command, { CliExperience, CliUsageMode, EntraAppConfig, HelpMode, NewEntraAppScopes, Preferences, SettingNames } from './setup.js'; +import command, { CliExperience, CliUsageMode, EntraAppConfig, HelpMode, NewEntraAppScopes, Preferences, SettingNames, options } from './setup.js'; import { interactivePreset, powerShellPreset, scriptingPreset } from './setupPresets.js'; describe(commands.SETUP, () => { @@ -24,6 +24,7 @@ describe(commands.SETUP, () => { let logger: Logger; let loggerLogToStderrSpy: sinon.SinonSpy; let commandInfo: CommandInfo; + let commandOptionsSchema: typeof options; let config: Configstore; let configSetSpy: sinon.SinonSpy; let configDeleteSpy: sinon.SinonSpy; @@ -33,6 +34,7 @@ describe(commands.SETUP, () => { sinon.stub(pid, 'getProcessName').callsFake(() => ''); sinon.stub(session, 'getId').callsFake(() => ''); commandInfo = cli.getCommandInfo(command); + commandOptionsSchema = commandInfo.command.getSchemaToParse() as typeof options; config = cli.getConfig(); configDeleteSpy = sinon.stub(config, 'delete').returns(); configSetSpy = sinon.stub(config, 'set').returns(); @@ -112,7 +114,7 @@ describe(commands.SETUP, () => { } }); - await command.action(logger, { options: {} }); + await command.action(logger, { options: commandOptionsSchema.parse({}) }); assert(configSetSpy.calledWith(settingsNames.helpMode, HelpMode.Full), 'Incorrect help mode'); Object.keys(interactivePreset).forEach(setting => { @@ -140,7 +142,7 @@ describe(commands.SETUP, () => { } }); - await command.action(logger, { options: {} }); + await command.action(logger, { options: commandOptionsSchema.parse({}) }); assert(configSetSpy.calledWith(settingsNames.helpMode, HelpMode.Options), 'Incorrect help mode'); Object.keys(interactivePreset).forEach(setting => { @@ -168,7 +170,7 @@ describe(commands.SETUP, () => { } }); - await command.action(logger, { options: {} }); + await command.action(logger, { options: commandOptionsSchema.parse({}) }); assert(configSetSpy.calledWith(settingsNames.helpMode, HelpMode.Full), 'Incorrect help mode'); Object.keys(scriptingPreset).forEach(setting => { @@ -196,7 +198,7 @@ describe(commands.SETUP, () => { } }); - await command.action(logger, { options: {} }); + await command.action(logger, { options: commandOptionsSchema.parse({}) }); assert(configSetSpy.calledWith(settingsNames.helpMode, HelpMode.Full), 'Incorrect help mode'); Object.keys(scriptingPreset).forEach(setting => { @@ -227,7 +229,7 @@ describe(commands.SETUP, () => { } }); - await command.action(logger, { options: {} }); + await command.action(logger, { options: commandOptionsSchema.parse({}) }); assert(configSetSpy.calledWith(settingsNames.helpMode, HelpMode.Options), 'Incorrect help mode'); Object.keys(scriptingPreset).forEach(setting => { @@ -255,7 +257,7 @@ describe(commands.SETUP, () => { } }); - await command.action(logger, { options: {} }); + await command.action(logger, { options: commandOptionsSchema.parse({}) }); assert(configSetSpy.calledWith(settingsNames.helpMode, HelpMode.Options), 'Incorrect help mode'); Object.keys(scriptingPreset).forEach(setting => { @@ -287,7 +289,7 @@ describe(commands.SETUP, () => { }); const configureSettingsStub = sinon.stub(command as any, 'configureSettings').callsFake(() => { }); - await command.action(logger, { options: {} }); + await command.action(logger, { options: commandOptionsSchema.parse({}) }); assert(configureSettingsStub.notCalled); }); @@ -295,7 +297,7 @@ describe(commands.SETUP, () => { it('sets correct settings for interactive, non-PowerShell via option', async () => { sinon.stub(pid, 'isPowerShell').returns(false); - await command.action(logger, { options: { interactive: true } }); + await command.action(logger, { options: commandOptionsSchema.parse({ interactive: true }) }); Object.keys(interactivePreset).forEach(setting => { assert(configSetSpy.calledWith(setting, (interactivePreset as any)[setting]), `Incorrect setting for ${setting}`); @@ -305,7 +307,7 @@ describe(commands.SETUP, () => { it('sets correct settings for scripting, non-PowerShell via option', async () => { sinon.stub(pid, 'isPowerShell').returns(false); - await command.action(logger, { options: { scripting: true } }); + await command.action(logger, { options: commandOptionsSchema.parse({ scripting: true }) }); Object.keys(scriptingPreset).forEach(setting => { assert(configSetSpy.calledWith(setting, (scriptingPreset as any)[setting]), `Incorrect setting for ${setting}`); @@ -315,7 +317,7 @@ describe(commands.SETUP, () => { it('sets correct settings for interactive, PowerShell via option', async () => { sinon.stub(pid, 'isPowerShell').returns(true); - await command.action(logger, { options: { interactive: true } }); + await command.action(logger, { options: commandOptionsSchema.parse({ interactive: true }) }); Object.keys(interactivePreset).forEach(setting => { assert(configSetSpy.calledWith(setting, (interactivePreset as any)[setting]), `Incorrect setting for ${setting}`); @@ -328,7 +330,7 @@ describe(commands.SETUP, () => { it('sets correct settings for scripting, PowerShell via option', async () => { sinon.stub(pid, 'isPowerShell').returns(true); - await command.action(logger, { options: { scripting: true } }); + await command.action(logger, { options: commandOptionsSchema.parse({ scripting: true }) }); Object.keys(scriptingPreset).forEach(setting => { assert(configSetSpy.calledWith(setting, (scriptingPreset as any)[setting]), `Incorrect setting for ${setting}`); @@ -358,7 +360,7 @@ describe(commands.SETUP, () => { } }); - await command.action(logger, { options: { skipApp: true } }); + await command.action(logger, { options: commandOptionsSchema.parse({ skipApp: true }) }); const expected: SettingNames = { clientId: '00000000-0000-0000-0000-000000000000', @@ -404,7 +406,7 @@ describe(commands.SETUP, () => { } }); - await command.action(logger, { options: {} }); + await command.action(logger, { options: commandOptionsSchema.parse({}) }); const expected: SettingNames = { clientId: '00000000-0000-0000-0000-000000000000', @@ -452,7 +454,7 @@ describe(commands.SETUP, () => { } }); - await command.action(logger, { options: {} }); + await command.action(logger, { options: commandOptionsSchema.parse({}) }); const expected: SettingNames = { clientId: '00000000-0000-0000-0000-000000000000', @@ -498,7 +500,7 @@ describe(commands.SETUP, () => { } }); - await command.action(logger, { options: {} }); + await command.action(logger, { options: commandOptionsSchema.parse({}) }); const expected: SettingNames = { clientId: '00000000-0000-0000-0000-000000000000', @@ -546,7 +548,7 @@ describe(commands.SETUP, () => { } }); - await command.action(logger, { options: {} }); + await command.action(logger, { options: commandOptionsSchema.parse({}) }); const expected: SettingNames = { clientId: '00000000-0000-0000-0000-000000000000', @@ -595,7 +597,7 @@ describe(commands.SETUP, () => { } }); - await command.action(logger, { options: {} }); + await command.action(logger, { options: commandOptionsSchema.parse({}) }); const expected: SettingNames = { clientId: '00000000-0000-0000-0000-000000000000', @@ -659,7 +661,7 @@ describe(commands.SETUP, () => { expiresOn: new Date().toString() }; - await command.action(logger, { options: {} }); + await command.action(logger, { options: commandOptionsSchema.parse({}) }); const expected: SettingNames = { clientId: '00000000-0000-0000-0000-000000000001', @@ -745,7 +747,7 @@ describe(commands.SETUP, () => { expiresOn: new Date().toString() }; - await command.action(logger, { options: { verbose: true } }); + await command.action(logger, { options: commandOptionsSchema.parse({ verbose: true }) }); const expected: SettingNames = { clientId: '00000000-0000-0000-0000-000000000001', @@ -808,7 +810,7 @@ describe(commands.SETUP, () => { }); const clearConnectionInfoSpy = sinon.stub(auth, 'clearConnectionInfo').resolves(); - await assert.rejects(async () => await command.action(logger, { options: {} })); + await assert.rejects(async () => await command.action(logger, { options: commandOptionsSchema.parse({}) })); assert(clearConnectionInfoSpy.notCalled); }); @@ -836,7 +838,7 @@ describe(commands.SETUP, () => { Object.assign(expected, interactivePreset); expected.helpMode = HelpMode.Full; - await command.action(logger, { options: { debug: true } }); + await command.action(logger, { options: commandOptionsSchema.parse({ debug: true }) }); assert(loggerLogToStderrSpy.calledWith(JSON.stringify(expected, null, 2))); }); @@ -865,7 +867,7 @@ describe(commands.SETUP, () => { Object.assign(expected, interactivePreset); expected.helpMode = HelpMode.Full; - await command.action(logger, { options: {} }); + await command.action(logger, { options: commandOptionsSchema.parse({}) }); for (const [key, value] of Object.entries(expected)) { assert(loggerLogToStderrSpy.calledWith(formatting.getStatus(CheckStatus.Success, `${key}: ${value}`)), `Expected ${key} to be set to ${value}`); @@ -887,37 +889,29 @@ describe(commands.SETUP, () => { }); it('fails validation when both interactive and scripting options specified', async () => { - const actual = await command.validate({ - options: { - interactive: true, - scripting: true - } - }, commandInfo); - assert.notStrictEqual(actual, true); + const actual = commandOptionsSchema.safeParse({ + interactive: true, + scripting: true + }); + assert.notStrictEqual(actual.success, true); }); it('passes validation when no options specified', async () => { - const actual = await command.validate({ - options: {} - }, commandInfo); - assert.strictEqual(actual, true); + const actual = commandOptionsSchema.safeParse({}); + assert.strictEqual(actual.success, true); }); it('passes validation when interactive option specified', async () => { - const actual = await command.validate({ - options: { - interactive: true - } - }, commandInfo); - assert.strictEqual(actual, true); + const actual = commandOptionsSchema.safeParse({ + interactive: true + }); + assert.strictEqual(actual.success, true); }); it('passes validation when scripting option specified', async () => { - const actual = await command.validate({ - options: { - scripting: true - } - }, commandInfo); - assert.strictEqual(actual, true); + const actual = commandOptionsSchema.safeParse({ + scripting: true + }); + assert.strictEqual(actual.success, true); }); }); diff --git a/src/m365/commands/setup.ts b/src/m365/commands/setup.ts index b08fa990c63..5d0940af870 100644 --- a/src/m365/commands/setup.ts +++ b/src/m365/commands/setup.ts @@ -1,10 +1,11 @@ import chalk from 'chalk'; import os from 'os'; +import { z } from 'zod'; import auth, { AuthType } from '../../Auth.js'; import { cli } from '../../cli/cli.js'; import { Logger } from '../../cli/Logger.js'; +import { globalOptionsZod } from '../../Command.js'; import config from '../../config.js'; -import GlobalOptions from '../../GlobalOptions.js'; import { settingsNames } from '../../settingsNames.js'; import { accessToken } from '../../utils/accessToken.js'; import { AppCreationOptions, AppInfo, entraApp } from '../../utils/entraApp.js'; @@ -16,6 +17,15 @@ import AnonymousCommand from '../base/AnonymousCommand.js'; import commands from './commands.js'; import { interactivePreset, powerShellPreset, scriptingPreset } from './setupPresets.js'; +export const options = z.strictObject({ + ...globalOptionsZod.shape, + interactive: z.boolean().optional(), + scripting: z.boolean().optional(), + skipApp: z.boolean().optional() +}); + +declare type Options = z.infer; + export interface Preferences { clientId?: string; tenantId?: string; @@ -35,12 +45,6 @@ interface CommandArgs { options: Options; } -interface Options extends GlobalOptions { - interactive?: boolean; - scripting?: boolean; - skipApp?: boolean; -} - export enum CliUsageMode { Interactively = 'interactively', Scripting = 'scripting' @@ -80,44 +84,15 @@ class SetupCommand extends AnonymousCommand { return 'Sets up CLI for Microsoft 365 based on your preferences'; } - constructor() { - super(); - - this.#initTelemetry(); - this.#initOptions(); - this.#initValidators(); - } - - #initTelemetry(): void { - this.telemetry.push((args: CommandArgs) => { - const properties: any = { - interactive: !!args.options.interactive, - scripting: !!args.options.scripting, - skipApp: !!args.options.skipApp - }; - - Object.assign(this.telemetryProperties, properties); - }); - } - - #initOptions(): void { - this.options.unshift( - { option: '--interactive' }, - { option: '--scripting' }, - { option: '--skipApp' } - ); + public get schema(): z.ZodType | undefined { + return options; } - #initValidators(): void { - this.validators.push( - async (args: CommandArgs) => { - if (args.options.interactive && args.options.scripting) { - return 'Specify either interactive or scripting but not both'; - } - - return true; - } - ); + public getRefinedSchema(schema: typeof options): z.ZodObject | undefined { + return schema + .refine(opts => !opts.interactive || !opts.scripting, { + error: 'Specify either interactive or scripting but not both' + }); } public async commandAction(logger: Logger, args: CommandArgs): Promise { diff --git a/src/m365/commands/version.ts b/src/m365/commands/version.ts index 3ecde84692d..83f4638fd6f 100644 --- a/src/m365/commands/version.ts +++ b/src/m365/commands/version.ts @@ -1,8 +1,12 @@ +import { z } from 'zod'; import { Logger } from '../../cli/Logger.js'; +import { globalOptionsZod } from '../../Command.js'; import { app } from '../../utils/app.js'; import AnonymousCommand from '../base/AnonymousCommand.js'; import commands from './commands.js'; +export const options = z.strictObject({ ...globalOptionsZod.shape }); + class VersionCommand extends AnonymousCommand { public get name(): string { return commands.VERSION; @@ -12,6 +16,10 @@ class VersionCommand extends AnonymousCommand { return 'Shows CLI for Microsoft 365 version'; } + public get schema(): z.ZodType | undefined { + return options; + } + public async commandAction(logger: Logger): Promise { await logger.log(`v${app.packageJson().version}`); }