From b32d3cd34d3d834f378008f9ab1259a8184a2ce7 Mon Sep 17 00:00:00 2001 From: Antanina Druzhkina Date: Tue, 27 Jan 2026 03:02:52 +0400 Subject: [PATCH] New command: m365 pp website remove. Closes #6257 --- docs/docs/cmd/pp/website/website-remove.mdx | 89 +++++ docs/src/config/sidebars.ts | 5 + src/m365/pp/commands.ts | 3 +- .../commands/website/website-remove.spec.ts | 317 ++++++++++++++++++ .../pp/commands/website/website-remove.ts | 100 ++++++ 5 files changed, 513 insertions(+), 1 deletion(-) create mode 100644 docs/docs/cmd/pp/website/website-remove.mdx create mode 100644 src/m365/pp/commands/website/website-remove.spec.ts create mode 100644 src/m365/pp/commands/website/website-remove.ts diff --git a/docs/docs/cmd/pp/website/website-remove.mdx b/docs/docs/cmd/pp/website/website-remove.mdx new file mode 100644 index 00000000000..ed904ae6ab5 --- /dev/null +++ b/docs/docs/cmd/pp/website/website-remove.mdx @@ -0,0 +1,89 @@ +import Global from '../../_global.mdx'; +import Tabs from '@theme/Tabs'; +import TabItem from '@theme/TabItem'; + +# pp website remove + +Removes the specified Power Pages website from the list of active sites. + +## Usage + +```sh +m365 pp website remove [options] +``` + +## Options + +```md definition-list +`-u, --url [url]` +: The URL of the website to remove. Specify either `url`, `name` or `id`. + +`-n, --name [name]` +: The name of the website to remove. Specify either `url`, `name` or `id`. + +`-i, --id [id]` +: The WebSite Id (GUID) of the website to remove. Specify either `url`, `name` or `id`. + +`-e, --environmentName ` +: The name of the environment from which to remove the Power Pages website. + +`-f, --force` +: Don't prompt for confirmation +``` + + + +## Permissions + + + + + | Resource | Permissions | + |--------------------|---------------------------| + | Power Platform API | PowerPages.Websites.Write | + + + + +## Examples + +Remove Power Pages website by name. + +```sh +m365 pp website remove --name Demo --environmentName Default-d87a7535-dd31-4437-bfe1-95340acd55c5 +``` + +Remove Power Pages website by name without confirmation. + +```sh +m365 pp website remove --name Demo --environmentName Default-d87a7535-dd31-4437-bfe1-95340acd55c5 --force +``` + +Remove Power Pages website by id. + +```sh +m365 pp website remove --id 4916bb2c-91e1-4716-91d5-b6171928fac9 --environmentName Default-d87a7535-dd31-4437-bfe1-95340acd55c5 +``` + +Remove Power Pages website by id without confirmation. + +```sh +m365 pp website remove --id 4916bb2c-91e1-4716-91d5-b6171928fac9 --environmentName Default-d87a7535-dd31-4437-bfe1-95340acd55c5 --force +``` + +Remove Power Pages website by url. + +```sh +m365 pp website remove --url https://site-0uaq9.powerappsportals.com --environmentName Default-d87a7535-dd31-4437-bfe1-95340acd55c5 +``` + +Remove Power Pages website by url without confirmation. + +```sh +m365 pp website remove --url https://site-0uaq9.powerappsportals.com --environmentName Default-d87a7535-dd31-4437-bfe1-95340acd55c5 --force +``` + +## Response + +The command won't return a response on success. + diff --git a/docs/src/config/sidebars.ts b/docs/src/config/sidebars.ts index 55e13dca27c..eee5853900b 100644 --- a/docs/src/config/sidebars.ts +++ b/docs/src/config/sidebars.ts @@ -1996,6 +1996,11 @@ const sidebars: SidebarsConfig = { type: 'doc', label: 'website get', id: 'cmd/pp/website/website-get' + }, + { + type: 'doc', + label: 'website remove', + id: 'cmd/pp/website/website-remove' } ] } diff --git a/src/m365/pp/commands.ts b/src/m365/pp/commands.ts index 148f15f3cde..0b0fa58c2aa 100644 --- a/src/m365/pp/commands.ts +++ b/src/m365/pp/commands.ts @@ -28,5 +28,6 @@ export default { SOLUTION_PUBLISHER_REMOVE: `${prefix} solution publisher remove`, TENANT_SETTINGS_LIST: `${prefix} tenant settings list`, TENANT_SETTINGS_SET: `${prefix} tenant settings set`, - WEBSITE_GET: `${prefix} website get` + WEBSITE_GET: `${prefix} website get`, + WEBSITE_REMOVE: `${prefix} website remove` }; \ No newline at end of file diff --git a/src/m365/pp/commands/website/website-remove.spec.ts b/src/m365/pp/commands/website/website-remove.spec.ts new file mode 100644 index 00000000000..de8f0a70c6c --- /dev/null +++ b/src/m365/pp/commands/website/website-remove.spec.ts @@ -0,0 +1,317 @@ +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 { telemetry } from '../../../../telemetry.js'; +import { accessToken } from '../../../../utils/accessToken.js'; +import { pid } from '../../../../utils/pid.js'; +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, { options } from './website-remove.js'; +import request from '../../../../request.js'; + +const environment = 'Default-727dc1e9-3cd1-4d1f-8102-ab5c936e52f0'; +const websiteId = '4916bb2c-91e1-4716-91d5-b6171928fac9'; +const websiteName = 'Site 1'; +const websiteUrl = 'https://site-0uaq9.powerappsportals.com'; + +const powerPageResponse = { + id: websiteId, + name: websiteName, + createdOn: "2024-10-27T12:00:03", + templateName: "DefaultPortalTemplate", + websiteUrl: websiteUrl, + tenantId: "727dc1e9-3cd1-4d1f-8102-ab5c936e52f0", + dataverseInstanceUrl: "https://org0cd4b2b9.crm4.dynamics.com/", + environmentName: "Contoso (default)", + environmentId: environment, + dataverseOrganizationId: "2d58aeac-74d4-4939-98d1-e05a70a655ba", + selectedBaseLanguage: 1033, + customHostNames: [], + websiteRecordId: "5eb107a6-5ac2-4e1c-a3b9-d5c21bbc10ce", + subdomain: "site-0uaq9", + packageInstallStatus: "Installed", + type: "Trial", + trialExpiringInDays: 86, + suspendedWebsiteDeletingInDays: 93, + packageVersion: "9.6.9.39", + isEarlyUpgradeEnabled: false, + isCustomErrorEnabled: true, + applicationUserAadAppId: "3f57aca7-5051-41b2-989d-26da8af7a53e", + ownerId: "33469a62-c3af-4cfe-b893-854eceab96da", + status: "OperationComplete", + siteVisibility: "private", + dataModel: "Enhanced" +}; + +describe(commands.WEBSITE_REMOVE, () => { + let log: any[]; + let logger: Logger; + let loggerLogToStderrSpy: sinon.SinonSpy; + let commandInfo: CommandInfo; + let commandOptionsSchema: typeof options; + let promptIssued: boolean = false; + + before(() => { + sinon.stub(auth, 'restoreAuth').resolves(); + sinon.stub(telemetry, 'trackEvent').resolves(); + sinon.stub(pid, 'getProcessName').returns(''); + 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(() => { + log = []; + logger = { + log: async (msg: string) => { + log.push(msg); + }, + logRaw: async (msg: string) => { + log.push(msg); + }, + logToStderr: async (msg: string) => { + log.push(msg); + } + }; + loggerLogToStderrSpy = sinon.spy(logger, 'logToStderr'); + sinon.stub(cli, 'promptForConfirmation').callsFake(async () => { + promptIssued = true; + return false; + }); + + promptIssued = false; + }); + + afterEach(() => { + sinonUtil.restore([ + powerPlatform.getWebsiteById, + powerPlatform.getWebsiteByName, + powerPlatform.getWebsiteByUrl, + request.delete, + cli.promptForConfirmation + ]); + }); + + after(() => { + sinon.restore(); + auth.connection.active = false; + }); + + it('has correct name', () => { + assert.strictEqual(command.name.startsWith(commands.WEBSITE_REMOVE), true); + }); + + it('has a description', () => { + assert.notStrictEqual(command.description, null); + }); + + it('fails validation if the url option is not a valid Power Pages site URL', async () => { + const actual = commandOptionsSchema.safeParse({ environmentName: environment, url: 'https://site-0uaq9.contoso.com' }); + assert.strictEqual(actual.success, false); + }); + + it('passes validation if the url option is a valid Power Pages site URL', async () => { + const actual = commandOptionsSchema.safeParse({ environmentName: environment, url: 'https://site-0uaq9.powerappsportals.com' }); + assert.strictEqual(actual.success, true); + }); + + it('fails validation if url and name are used at the same time', () => { + const actual = commandOptionsSchema.safeParse({ + environmentName: environment, + url: 'https://site-0uaq9.powerappsportals.com', + name: 'Site 1' + }); + assert.strictEqual(actual.success, false); + }); + + it('fails validation if url and id are used at the same time', () => { + const actual = commandOptionsSchema.safeParse({ + environmentName: environment, + url: 'https://site-0uaq9.powerappsportals.com', + id: '4916bb2c-91e1-4716-91d5-b6171928fac9' + }); + assert.strictEqual(actual.success, false); + }); + + it('fails validation if name and id are used at the same time', () => { + const actual = commandOptionsSchema.safeParse({ + environmentName: environment, + id: '4916bb2c-91e1-4716-91d5-b6171928fac9', + name: 'Site 1' + }); + assert.strictEqual(actual.success, false); + }); + + it('fails validation if url, id, and name are all used at the same time', () => { + const actual = commandOptionsSchema.safeParse({ + environmentName: environment, + url: 'https://site-0uaq9.powerappsportals.com', + id: '4916bb2c-91e1-4716-91d5-b6171928fac9', + name: 'Site 1' + }); + assert.strictEqual(actual.success, false); + }); + + it('fails validation if neither url, id, nor name are provided', () => { + const actual = commandOptionsSchema.safeParse({ + environmentName: environment + }); + assert.strictEqual(actual.success, false); + }); + + it('passes validation with only url', () => { + const actual = commandOptionsSchema.safeParse({ + environmentName: environment, + url: 'https://site-0uaq9.powerappsportals.com' + }); + assert.strictEqual(actual.success, true); + }); + + it('passes validation with only id', () => { + const actual = commandOptionsSchema.safeParse({ + environmentName: environment, + id: '4916bb2c-91e1-4716-91d5-b6171928fac9' + }); + assert.strictEqual(actual.success, true); + }); + + it('passes validation with only name', () => { + const actual = commandOptionsSchema.safeParse({ + environmentName: environment, + name: 'Site 1' + }); + assert.strictEqual(actual.success, true); + }); + + it('removes the Power Pages website by id with force option', async () => { + const deleteStub = sinon.stub(request, 'delete').resolves(); + + await command.action(logger, { + options: { + environmentName: environment, + id: websiteId, + force: true + } + }); + + assert(deleteStub.calledOnce); + }); + + it('removes the Power Pages website by name when prompt confirmed', async () => { + sinon.stub(powerPlatform, 'getWebsiteByName').resolves(powerPageResponse); + sinon.stub(request, 'delete').resolves(); + + sinonUtil.restore(cli.promptForConfirmation); + sinon.stub(cli, 'promptForConfirmation').resolves(true); + + await command.action(logger, { + options: { + verbose: true, + environmentName: environment, + name: websiteName + } + }); + + assert(loggerLogToStderrSpy.called); + }); + + it('removes the Power Pages website by id when prompt confirmed with verbose', async () => { + sinon.stub(request, 'delete').resolves(); + + sinonUtil.restore(cli.promptForConfirmation); + sinon.stub(cli, 'promptForConfirmation').resolves(true); + + await command.action(logger, { + options: { + verbose: true, + environmentName: environment, + id: websiteId + } + }); + + assert(loggerLogToStderrSpy.called); + }); + + it('removes the Power Pages website by url when prompt confirmed with verbose', async () => { + sinon.stub(powerPlatform, 'getWebsiteByUrl').resolves(powerPageResponse); + sinon.stub(request, 'delete').resolves(); + + sinonUtil.restore(cli.promptForConfirmation); + sinon.stub(cli, 'promptForConfirmation').resolves(true); + + await command.action(logger, { + options: { + verbose: true, + environmentName: environment, + url: websiteUrl + } + }); + + assert(loggerLogToStderrSpy.called); + }); + + it('removes the Power Pages website by url with force option', async () => { + sinon.stub(powerPlatform, 'getWebsiteByUrl').resolves(powerPageResponse); + const deleteStub = sinon.stub(request, 'delete').resolves(); + + await command.action(logger, { + options: { + environmentName: environment, + url: websiteUrl, + force: true + } + }); + + assert(deleteStub.calledOnce); + }); + + it('does not remove website when prompt is not confirmed', async () => { + const deleteStub = sinon.stub(request, 'delete').resolves(); + + await command.action(logger, { + options: { + environmentName: environment, + id: websiteId + } + }); + + assert(deleteStub.notCalled); + }); + + it('prompts before removing website by url', async () => { + sinon.stub(powerPlatform, 'getWebsiteByUrl').resolves(powerPageResponse); + + await command.action(logger, { + options: { + environmentName: environment, + url: websiteUrl + } + }); + + assert(promptIssued); + }); + + it('correctly handles error when removing website fails', async () => { + const errorMessage = 'An error has occurred'; + sinon.stub(request, 'delete').rejects(new Error(errorMessage)); + + await assert.rejects( + command.action(logger, { + options: { + environmentName: environment, + id: websiteId, + force: true + } + }), + new CommandError(errorMessage) + ); + }); +}); \ No newline at end of file diff --git a/src/m365/pp/commands/website/website-remove.ts b/src/m365/pp/commands/website/website-remove.ts new file mode 100644 index 00000000000..df4c0bef76c --- /dev/null +++ b/src/m365/pp/commands/website/website-remove.ts @@ -0,0 +1,100 @@ +import { Logger } from '../../../../cli/Logger.js'; +import { globalOptionsZod } from '../../../../Command.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 { z } from 'zod'; +import request, { CliRequestOptions } from '../../../../request.js'; +import { cli } from '../../../../cli/cli.js'; + +export const options = z.strictObject({ + ...globalOptionsZod.shape, + url: z.string().optional() + .refine(url => url === undefined || validation.isValidPowerPagesUrl(url) === true, { + error: e => `'${e.input}' is not a valid Power Pages URL.` + }) + .alias('u'), + id: z.uuid().optional().alias('i'), + name: z.string().optional().alias('n'), + environmentName: z.string().alias('e'), + force: z.boolean().optional().alias('f') +}); +declare type Options = z.infer; + +interface CommandArgs { + options: Options; +} + +class PpWebSiteRemoveCommand extends PowerPlatformCommand { + public get name(): string { + return commands.WEBSITE_REMOVE; + } + + public get description(): string { + return 'Removes the specified Power Pages website from the list of active sites.'; + } + + public get schema(): z.ZodTypeAny | undefined { + return options; + } + + public getRefinedSchema(schema: typeof options): z.ZodObject | undefined { + return schema + .refine(options => [options.url, options.id, options.name].filter(x => x !== undefined).length === 1, { + error: `Specify either url, id or name, but not multiple.` + }); + } + + public async commandAction(logger: Logger, args: CommandArgs): Promise { + if (args.options.verbose) { + await logger.logToStderr(`Removing website '${args.options.id || args.options.name || args.options.url}'...`); + } + + if (args.options.force) { + await this.deleteWebsite(args); + } + else { + const result = await cli.promptForConfirmation({ message: `Are you sure you want to remove website '${args.options.id || args.options.name || args.options.url}'?` }); + + if (result) { + await this.deleteWebsite(args); + } + } + } + + private async getWebsiteId(args: CommandArgs): Promise { + if (args.options.id) { + return args.options.id; + } + + if (args.options.name) { + const website = await powerPlatform.getWebsiteByName(args.options.environmentName, args.options.name); + return website.id; + } + + const website = await powerPlatform.getWebsiteByUrl(args.options.environmentName, args.options.url!); + return website.id; + } + + private async deleteWebsite(args: CommandArgs): Promise { + try { + const websiteId = await this.getWebsiteId(args); + + const requestOptions: CliRequestOptions = { + url: `https://api.powerplatform.com/powerpages/environments/${args.options.environmentName}/websites/${websiteId}?api-version=2022-03-01-preview`, + headers: { + accept: 'application/json;odata.metadata=none' + }, + responseType: 'json' + }; + + await request.delete(requestOptions); + } + catch (err: any) { + this.handleRejectedODataJsonPromise(err); + } + } +} + +export default new PpWebSiteRemoveCommand(); \ No newline at end of file