From f4d267c2d924703c39c6f245f8787d2475984a8c Mon Sep 17 00:00:00 2001 From: Waldek Mastykarz Date: Thu, 28 May 2026 14:22:03 +0200 Subject: [PATCH] Migrate context commands to Zod Migrates context-init and option-set commands from the old #initOptions() pattern to Zod schemas, completing the migration of all context commands. Changes: - context-init: Add Zod schema with global options - option-set: Replace #initOptions() constructor with Zod schema defining name and value as required string options with aliases - Update test files to import and use commandOptionsSchema.parse() - Add schema validation tests for both commands Closes pnp/cli-microsoft365#7293 Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- npm-shrinkwrap.json | 7 +++ .../context/commands/context-init.spec.ts | 34 +++++++++---- src/m365/context/commands/context-init.ts | 8 +++ .../commands/option/option-set.spec.ts | 49 ++++++++++++++++--- .../context/commands/option/option-set.ts | 34 +++++-------- 5 files changed, 95 insertions(+), 37 deletions(-) diff --git a/npm-shrinkwrap.json b/npm-shrinkwrap.json index d76c64cf835..acbe534d0bb 100644 --- a/npm-shrinkwrap.json +++ b/npm-shrinkwrap.json @@ -902,6 +902,7 @@ "node_modules/@opentelemetry/api": { "version": "1.9.1", "license": "Apache-2.0", + "peer": true, "engines": { "node": ">=8.0.0" } @@ -1750,6 +1751,7 @@ "node_modules/@types/node": { "version": "24.12.2", "license": "MIT", + "peer": true, "dependencies": { "undici-types": "~7.16.0" } @@ -1859,6 +1861,7 @@ "version": "8.59.2", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@typescript-eslint/scope-manager": "8.59.2", "@typescript-eslint/types": "8.59.2", @@ -2388,6 +2391,7 @@ "node_modules/acorn": { "version": "8.16.0", "license": "MIT", + "peer": true, "bin": { "acorn": "bin/acorn" }, @@ -3144,6 +3148,7 @@ "node_modules/diagnostic-channel": { "version": "1.1.1", "license": "MIT", + "peer": true, "dependencies": { "semver": "^7.5.3" } @@ -3302,6 +3307,7 @@ "version": "10.2.1", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@eslint-community/eslint-utils": "^4.8.0", "@eslint-community/regexpp": "^4.12.2", @@ -5900,6 +5906,7 @@ "node_modules/typescript": { "version": "5.9.3", "license": "Apache-2.0", + "peer": true, "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" diff --git a/src/m365/context/commands/context-init.spec.ts b/src/m365/context/commands/context-init.spec.ts index c67335d7021..97556f09da0 100644 --- a/src/m365/context/commands/context-init.spec.ts +++ b/src/m365/context/commands/context-init.spec.ts @@ -1,19 +1,25 @@ import assert from 'assert'; import fs from 'fs'; import sinon from 'sinon'; +import { cli } from '../../../cli/cli.js'; +import { CommandInfo } from '../../../cli/CommandInfo.js'; import { Logger } from '../../../cli/Logger.js'; import { telemetry } from '../../../telemetry.js'; import { CommandError } from '../../../Command.js'; import { sinonUtil } from '../../../utils/sinonUtil.js'; import commands from '../commands.js'; -import command from './context-init.js'; +import command, { options } from './context-init.js'; describe(commands.INIT, () => { let log: any[]; let logger: Logger; + let commandInfo: CommandInfo; + let commandOptionsSchema: typeof options; before(() => { sinon.stub(telemetry, 'trackEvent').resolves(); + commandInfo = cli.getCommandInfo(command); + commandOptionsSchema = commandInfo.command.getSchemaToParse() as typeof options; }); beforeEach(() => { @@ -51,6 +57,16 @@ describe(commands.INIT, () => { 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({ option: "value" }); + assert.strictEqual(actual.success, false); + }); + it('logs an error if an error occurred while reading the .m365rc.json', async () => { const originalFsExistsSync = fs.existsSync; const originalFsReadFileSync = fs.readFileSync; @@ -72,7 +88,7 @@ describe(commands.INIT, () => { } }); - await assert.rejects(command.action(logger, { options: { verbose: true } }), new CommandError('Error reading .m365rc.json: Error: An error has occurred. Please add context info to .m365rc.json manually.')); + await assert.rejects(command.action(logger, { options: commandOptionsSchema.parse({ verbose: true }) }), new CommandError('Error reading .m365rc.json: Error: An error has occurred. Please add context info to .m365rc.json manually.')); }); it(`logs an error if the .m365rc.json file contents couldn't be parsed`, async () => { @@ -104,7 +120,7 @@ describe(commands.INIT, () => { errorMessage = err; } - await assert.rejects(command.action(logger, { options: { verbose: true } }), new CommandError(`Error reading .m365rc.json: ${errorMessage}. Please add context info to .m365rc.json manually.`)); + await assert.rejects(command.action(logger, { options: commandOptionsSchema.parse({ verbose: true }) }), new CommandError(`Error reading .m365rc.json: ${errorMessage}. Please add context info to .m365rc.json manually.`)); }); it(`logs an error if the content can't be written to the .m365rc.json file`, async () => { @@ -131,7 +147,7 @@ describe(commands.INIT, () => { }); sinon.stub(fs, 'writeFileSync').callsFake(_ => { throw new Error('An error has occurred'); }); - await assert.rejects(() => command.action(logger, { options: { verbose: true } }), new CommandError('Error writing .m365rc.json: Error: An error has occurred. Please add context info to .m365rc.json manually.')); + await assert.rejects(() => command.action(logger, { options: commandOptionsSchema.parse({ verbose: true }) }), new CommandError('Error writing .m365rc.json: Error: An error has occurred. Please add context info to .m365rc.json manually.')); }); it(`creates the .m365rc.json file if it doesn't exist and saves context info`, async () => { @@ -147,7 +163,7 @@ describe(commands.INIT, () => { }); const fsWriteFileSyncStub = sinon.stub(fs, 'writeFileSync').callsFake(() => { }); - await command.action(logger, { options: { verbose: true } }); + await command.action(logger, { options: commandOptionsSchema.parse({ verbose: true }) }); assert(fsWriteFileSyncStub.calledWith('.m365rc.json', JSON.stringify({ context: {} @@ -176,7 +192,7 @@ describe(commands.INIT, () => { }); const fsWriteFileSyncStub = sinon.stub(fs, 'writeFileSync').callsFake(() => { }); - await command.action(logger, { options: { verbose: true } }); + await command.action(logger, { options: commandOptionsSchema.parse({ verbose: true }) }); assert(fsWriteFileSyncStub.calledWith('.m365rc.json', JSON.stringify({ context: {} @@ -207,7 +223,7 @@ describe(commands.INIT, () => { }); const fsWriteFileSyncSpy = sinon.spy(fs, 'writeFileSync'); - await command.action(logger, { options: { verbose: true } }); + await command.action(logger, { options: commandOptionsSchema.parse({ verbose: true }) }); assert(fsWriteFileSyncSpy.notCalled); }); @@ -235,7 +251,7 @@ describe(commands.INIT, () => { }); const fsWriteFileSyncSpy = sinon.spy(fs, 'writeFileSync'); - await assert.rejects(command.action(logger, { options: { verbose: true } }), new CommandError('Error reading .m365rc.json: Error: An error has occurred. Please add context info to .m365rc.json manually.')); + await assert.rejects(command.action(logger, { options: commandOptionsSchema.parse({ verbose: true }) }), new CommandError('Error reading .m365rc.json: Error: An error has occurred. Please add context info to .m365rc.json manually.')); assert(fsWriteFileSyncSpy.notCalled); }); @@ -251,6 +267,6 @@ describe(commands.INIT, () => { }); sinon.stub(fs, 'writeFileSync').callsFake(_ => { throw new Error('An error has occurred'); }); - await assert.rejects(command.action(logger, { options: { verbose: true } }), new CommandError('Error writing .m365rc.json: Error: An error has occurred. Please add context info to .m365rc.json manually.')); + await assert.rejects(command.action(logger, { options: commandOptionsSchema.parse({ verbose: true }) }), new CommandError('Error writing .m365rc.json: Error: An error has occurred. Please add context info to .m365rc.json manually.')); }); }); \ No newline at end of file diff --git a/src/m365/context/commands/context-init.ts b/src/m365/context/commands/context-init.ts index 45c5967e3ba..9e30adec9fb 100644 --- a/src/m365/context/commands/context-init.ts +++ b/src/m365/context/commands/context-init.ts @@ -1,6 +1,10 @@ +import { z } from 'zod'; +import { globalOptionsZod } from '../../../Command.js'; import ContextCommand from '../../base/ContextCommand.js'; import commands from '../commands.js'; +export const options = z.strictObject({ ...globalOptionsZod.shape }); + class ContextInitCommand extends ContextCommand { public get name(): string { return commands.INIT; @@ -10,6 +14,10 @@ class ContextInitCommand extends ContextCommand { return 'Initiates CLI for Microsoft 365 context in the current working folder'; } + public get schema(): z.ZodType | undefined { + return options; + } + public async commandAction(): Promise { await this.saveContextInfo({}); } diff --git a/src/m365/context/commands/option/option-set.spec.ts b/src/m365/context/commands/option/option-set.spec.ts index 2222a72b450..d5ca0888059 100644 --- a/src/m365/context/commands/option/option-set.spec.ts +++ b/src/m365/context/commands/option/option-set.spec.ts @@ -1,19 +1,25 @@ import assert from 'assert'; import fs from 'fs'; import sinon from 'sinon'; +import { cli } from '../../../../cli/cli.js'; +import { CommandInfo } from '../../../../cli/CommandInfo.js'; import { Logger } from '../../../../cli/Logger.js'; import { CommandError } from '../../../../Command.js'; import { telemetry } from '../../../../telemetry.js'; import { sinonUtil } from '../../../../utils/sinonUtil.js'; import commands from '../../commands.js'; -import command from './option-set.js'; +import command, { options } from './option-set.js'; describe(commands.OPTION_SET, () => { let log: any[]; let logger: Logger; + let commandInfo: CommandInfo; + let commandOptionsSchema: typeof options; before(() => { sinon.stub(telemetry, 'trackEvent').resolves(); + commandInfo = cli.getCommandInfo(command); + commandOptionsSchema = commandInfo.command.getSchemaToParse() as typeof options; }); beforeEach(() => { @@ -51,11 +57,42 @@ describe(commands.OPTION_SET, () => { assert.notStrictEqual(command.description, null); }); + it('passes validation when name and value are specified', () => { + const actual = commandOptionsSchema.safeParse({ + name: 'listName', + value: 'testList' + }); + assert.strictEqual(actual.success, true); + }); + + it('fails validation when name is not specified', () => { + const actual = commandOptionsSchema.safeParse({ + value: 'testList' + }); + assert.strictEqual(actual.success, false); + }); + + it('fails validation when value is not specified', () => { + const actual = commandOptionsSchema.safeParse({ + name: 'listName' + }); + assert.strictEqual(actual.success, false); + }); + + it('fails validation with unknown options', () => { + const actual = commandOptionsSchema.safeParse({ + name: 'listName', + value: 'testList', + unknown: 'option' + }); + assert.strictEqual(actual.success, false); + }); + it('handles an error when reading file contents fails', async () => { sinon.stub(fs, 'existsSync').callsFake(_ => true); sinon.stub(fs, 'readFileSync').callsFake(_ => { throw new Error('An error has occurred'); }); - await assert.rejects(command.action(logger, { options: { debug: true, name: 'listName', value: 'testList' } }), new CommandError(`Error reading .m365rc.json: Error: An error has occurred. Please add listName to .m365rc.json manually.`)); + await assert.rejects(command.action(logger, { options: commandOptionsSchema.parse({ debug: true, name: 'listName', value: 'testList' }) }), new CommandError(`Error reading .m365rc.json: Error: An error has occurred. Please add listName to .m365rc.json manually.`)); }); it('handles an error when writing file contents fails', async () => { @@ -71,7 +108,7 @@ describe(commands.OPTION_SET, () => { })); sinon.stub(fs, 'writeFileSync').callsFake(_ => { throw new Error('An error has occurred'); }); - await assert.rejects(command.action(logger, { options: { debug: true, name: 'listName', value: 'testList' } }), new CommandError(`Error writing .m365rc.json: Error: An error has occurred. Please add listName to .m365rc.json manually.`)); + await assert.rejects(command.action(logger, { options: commandOptionsSchema.parse({ debug: true, name: 'listName', value: 'testList' }) }), new CommandError(`Error writing .m365rc.json: Error: An error has occurred. Please add listName to .m365rc.json manually.`)); }); it('adds a new key with value when context is present', async () => { @@ -85,7 +122,7 @@ describe(commands.OPTION_SET, () => { fileContents = contents as string; }); - await command.action(logger, { options: { verbose: true, name: 'listName', value: 'testList' } }); + await command.action(logger, { options: commandOptionsSchema.parse({ verbose: true, name: 'listName', value: 'testList' }) }); assert.strictEqual(filePath, '.m365rc.json'); assert.strictEqual(fileContents, JSON.stringify({ context: { listName: 'testList' } @@ -101,7 +138,7 @@ describe(commands.OPTION_SET, () => { filePath = _.toString(); fileContents = contents as string; }); - await assert.doesNotReject(command.action(logger, { options: { debug: true, name: 'listName', value: 'testList' } })); + await assert.doesNotReject(command.action(logger, { options: commandOptionsSchema.parse({ debug: true, name: 'listName', value: 'testList' }) })); assert.strictEqual(filePath, '.m365rc.json'); assert.strictEqual(fileContents, JSON.stringify({ context: { listName: 'testList' } @@ -129,7 +166,7 @@ describe(commands.OPTION_SET, () => { fileContents = contents as string; }); - await command.action(logger, { options: { verbose: true, name: 'listName', value: 'testList' } }); + await command.action(logger, { options: commandOptionsSchema.parse({ verbose: true, name: 'listName', value: 'testList' }) }); assert.strictEqual(filePath, '.m365rc.json'); assert.strictEqual(fileContents, JSON.stringify({ "apps": [ diff --git a/src/m365/context/commands/option/option-set.ts b/src/m365/context/commands/option/option-set.ts index bc3ef2a200b..355696f95aa 100644 --- a/src/m365/context/commands/option/option-set.ts +++ b/src/m365/context/commands/option/option-set.ts @@ -1,20 +1,23 @@ import fs from 'fs'; +import { z } from 'zod'; import { Logger } from '../../../../cli/Logger.js'; -import { CommandError } from '../../../../Command.js'; -import GlobalOptions from '../../../../GlobalOptions.js'; +import { CommandError, globalOptionsZod } from '../../../../Command.js'; import ContextCommand from '../../../base/ContextCommand.js'; import { M365RcJson } from '../../../base/M365RcJson.js'; import commands from '../../commands.js'; +export const options = z.strictObject({ + ...globalOptionsZod.shape, + name: z.string().alias('n'), + value: z.string().alias('v') +}); + +declare type Options = z.infer; + interface CommandArgs { options: Options; } -interface Options extends GlobalOptions { - name: string; - value: string; -} - class ContextOptionSetCommand extends ContextCommand { public get name(): string { return commands.OPTION_SET; @@ -24,21 +27,8 @@ class ContextOptionSetCommand extends ContextCommand { return 'Allows to add a new name for the option and value to the local context file.'; } - constructor() { - super(); - - this.#initOptions(); - } - - #initOptions(): void { - this.options.unshift( - { - option: '-n, --name ' - }, - { - option: '-v, --value ' - } - ); + public get schema(): z.ZodType | undefined { + return options; } public async commandAction(logger: Logger, args: CommandArgs): Promise {