diff --git a/docs/docs/cmd/outlook/calendargroup/calendargroup-remove.mdx b/docs/docs/cmd/outlook/calendargroup/calendargroup-remove.mdx
new file mode 100644
index 00000000000..36cd813b0b1
--- /dev/null
+++ b/docs/docs/cmd/outlook/calendargroup/calendargroup-remove.mdx
@@ -0,0 +1,91 @@
+import Global from '../../_global.mdx';
+import Tabs from '@theme/Tabs';
+import TabItem from '@theme/TabItem';
+
+# outlook calendargroup remove
+
+Removes a calendar group.
+
+## Usage
+
+```sh
+m365 outlook calendargroup remove [options]
+```
+
+## Options
+
+```md definition-list
+`--id [id]`
+: ID of the calendar group to remove. Specify either `id` or `name`, but not both.
+
+`--name [name]`
+: Name of the calendar group to remove. Specify either `id` or `name`, but not both.
+
+`--userId [userId]`
+: ID of the user. Specify either `userId` or `userName`, but not both. This option is required when using application permissions.
+
+`--userName [userName]`
+: UPN of the user. Specify either `userId` or `userName`, but not both. This option is required when using application permissions.
+
+`-f, --force`
+: Don't prompt for confirmation.
+```
+
+
+
+## Permissions
+
+
+
+
+ | Resource | Permissions |
+ |-----------------|----------------------|
+ | Microsoft Graph | Calendars.ReadWrite |
+
+
+
+
+ | Resource | Permissions |
+ |-----------------|----------------------|
+ | Microsoft Graph | Calendars.ReadWrite |
+
+
+
+
+## Remarks
+
+:::warning
+
+The calendar group must be empty before it can be removed. Make sure to delete all calendars in the group first.
+
+:::
+
+## Examples
+
+Remove a calendar group specified by id for the signed-in user.
+
+```sh
+m365 outlook calendargroup remove --id "AAMkAGE0MGM1Y2M5LWEzMmUtNGVlNy05MjRlLTk0YmYyY2I5NTM3ZAAuAAAAAAC_0WfqSjt_SqLtNkuO-bj1AQAbfYq5lmBxQ6a4t1fGbeYAAAAAAEOAAA="
+```
+
+Remove a calendar group specified by name for the signed-in user.
+
+```sh
+m365 outlook calendargroup remove --name "Personal Events"
+```
+
+Remove a calendar group specified by id for a user specified by id.
+
+```sh
+m365 outlook calendargroup remove --id "AAMkADIxYjJiYm" --userId "44288f7d-7710-4293-8c8e-36f310ed2e6a"
+```
+
+Remove a calendar group specified by name for a user specified by UPN without prompting for confirmation.
+
+```sh
+m365 outlook calendargroup remove --name "Personal Events" --userName "john.doe@contoso.com" --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 e0f837d676a..f80219aa048 100644
--- a/docs/src/config/sidebars.ts
+++ b/docs/src/config/sidebars.ts
@@ -1327,6 +1327,11 @@ const sidebars: SidebarsConfig = {
type: 'doc',
label: 'calendargroup list',
id: 'cmd/outlook/calendargroup/calendargroup-list'
+ },
+ {
+ type: 'doc',
+ label: 'calendargroup remove',
+ id: 'cmd/outlook/calendargroup/calendargroup-remove'
}
]
},
diff --git a/src/m365/outlook/commands.ts b/src/m365/outlook/commands.ts
index fb433f5842c..8ad0d08071c 100644
--- a/src/m365/outlook/commands.ts
+++ b/src/m365/outlook/commands.ts
@@ -5,6 +5,7 @@ export default {
CALENDAR_GET: `${prefix} calendar get`,
CALENDAR_REMOVE: `${prefix} calendar remove`,
CALENDARGROUP_LIST: `${prefix} calendargroup list`,
+ CALENDARGROUP_REMOVE: `${prefix} calendargroup remove`,
EVENT_CANCEL: `${prefix} event cancel`,
EVENT_LIST: `${prefix} event list`,
EVENT_REMOVE: `${prefix} event remove`,
diff --git a/src/m365/outlook/commands/calendargroup/calendargroup-remove.spec.ts b/src/m365/outlook/commands/calendargroup/calendargroup-remove.spec.ts
new file mode 100644
index 00000000000..31c956a538b
--- /dev/null
+++ b/src/m365/outlook/commands/calendargroup/calendargroup-remove.spec.ts
@@ -0,0 +1,312 @@
+import assert from 'assert';
+import sinon from 'sinon';
+import auth from '../../../../Auth.js';
+import { CommandError } from '../../../../Command.js';
+import { CommandInfo } from '../../../../cli/CommandInfo.js';
+import { Logger } from '../../../../cli/Logger.js';
+import { cli } from '../../../../cli/cli.js';
+import request from '../../../../request.js';
+import { telemetry } from '../../../../telemetry.js';
+import { accessToken } from '../../../../utils/accessToken.js';
+import { calendarGroup } from '../../../../utils/calendarGroup.js';
+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, { options } from './calendargroup-remove.js';
+
+describe(commands.CALENDARGROUP_REMOVE, () => {
+ const calendarGroupId = 'AAMkAGE0MGM1Y2M5LWEzMmUtNGVlNy05MjRlLTk0YmYyY2I5NTM3ZAAuAAAAAAC_0WfqSjt_SqLtNkuO-bj1AQAbfYq5lmBxQ6a4t1fGbeYAAAAAAEOAAA=';
+ const calendarGroupName = 'Personal Events';
+ const userId = 'b743445a-112c-4fda-9afd-05943f9c7b36';
+ const userName = 'john.doe@contoso.com';
+
+ let log: string[];
+ let logger: Logger;
+ let commandInfo: CommandInfo;
+ let commandOptionsSchema: typeof options;
+ let promptIssued: boolean;
+
+ before(() => {
+ sinon.stub(auth, 'restoreAuth').resolves();
+ sinon.stub(telemetry, 'trackEvent').resolves();
+ sinon.stub(pid, 'getProcessName').returns('');
+ sinon.stub(session, 'getId').returns('');
+ auth.connection.active = true;
+ if (!auth.connection.accessTokens[auth.defaultResource]) {
+ auth.connection.accessTokens[auth.defaultResource] = {
+ expiresOn: 'abc',
+ accessToken: 'abc'
+ };
+ }
+ 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);
+ }
+ };
+ sinon.stub(cli, 'promptForConfirmation').callsFake(() => {
+ promptIssued = true;
+ return Promise.resolve(false);
+ });
+ sinon.stub(accessToken, 'isAppOnlyAccessToken').returns(false);
+ promptIssued = false;
+ });
+
+ afterEach(() => {
+ sinonUtil.restore([
+ accessToken.isAppOnlyAccessToken,
+ calendarGroup.getUserCalendarGroupByName,
+ request.get,
+ request.delete,
+ cli.promptForConfirmation
+ ]);
+ });
+
+ after(() => {
+ sinon.restore();
+ auth.connection.active = false;
+ });
+
+ it('has correct name', () => {
+ assert.strictEqual(command.name, commands.CALENDARGROUP_REMOVE);
+ });
+
+ it('has a description', () => {
+ assert.notStrictEqual(command.description, null);
+ });
+
+ it('fails validation when neither id nor name is specified', () => {
+ const actual = commandOptionsSchema.safeParse({});
+ assert.strictEqual(actual.success, false);
+ });
+
+ it('fails validation when both id and name are specified', () => {
+ const actual = commandOptionsSchema.safeParse({ id: calendarGroupId, name: calendarGroupName });
+ assert.strictEqual(actual.success, false);
+ });
+
+ it('fails validation when both userId and userName are specified', () => {
+ const actual = commandOptionsSchema.safeParse({ id: calendarGroupId, userId, userName });
+ assert.strictEqual(actual.success, false);
+ });
+
+ it('fails validation when userId is not a valid GUID', () => {
+ const actual = commandOptionsSchema.safeParse({ id: calendarGroupId, userId: 'foo' });
+ assert.strictEqual(actual.success, false);
+ });
+
+ it('fails validation when userName is not a valid UPN', () => {
+ const actual = commandOptionsSchema.safeParse({ id: calendarGroupId, userName: 'foo' });
+ assert.strictEqual(actual.success, false);
+ });
+
+ it('fails validation with unknown options', () => {
+ const actual = commandOptionsSchema.safeParse({ unknownOption: 'value' });
+ assert.strictEqual(actual.success, false);
+ });
+
+ it('passes validation with id', () => {
+ const actual = commandOptionsSchema.safeParse({ id: calendarGroupId });
+ assert.strictEqual(actual.success, true);
+ });
+
+ it('passes validation with name', () => {
+ const actual = commandOptionsSchema.safeParse({ name: calendarGroupName });
+ assert.strictEqual(actual.success, true);
+ });
+
+ it('passes validation with id and userId', () => {
+ const actual = commandOptionsSchema.safeParse({ id: calendarGroupId, userId });
+ assert.strictEqual(actual.success, true);
+ });
+
+ it('passes validation with id and userName', () => {
+ const actual = commandOptionsSchema.safeParse({ id: calendarGroupId, userName });
+ assert.strictEqual(actual.success, true);
+ });
+
+ it('prompts before removing when force option not passed', async () => {
+ await command.action(logger, { options: commandOptionsSchema.parse({ id: calendarGroupId }) });
+
+ assert(promptIssued);
+ });
+
+ it('aborts removing when prompt not confirmed', async () => {
+ const deleteSpy = sinon.stub(request, 'delete').resolves();
+
+ await command.action(logger, { options: commandOptionsSchema.parse({ id: calendarGroupId }) });
+ assert(deleteSpy.notCalled);
+ });
+
+ it('removes the calendar group specified by id for the signed-in user without prompting', async () => {
+ const deleteRequestStub = sinon.stub(request, 'delete').callsFake(async (opts) => {
+ if (opts.url === `https://graph.microsoft.com/v1.0/me/calendarGroups/${calendarGroupId}`) {
+ return;
+ }
+
+ throw 'Invalid request';
+ });
+
+ await command.action(logger, { options: commandOptionsSchema.parse({ id: calendarGroupId, force: true }) });
+ assert(deleteRequestStub.calledOnce);
+ });
+
+ it('removes the calendar group specified by id for the signed-in user (verbose)', async () => {
+ const deleteRequestStub = sinon.stub(request, 'delete').callsFake(async (opts) => {
+ if (opts.url === `https://graph.microsoft.com/v1.0/me/calendarGroups/${calendarGroupId}`) {
+ return;
+ }
+
+ throw 'Invalid request';
+ });
+
+ await command.action(logger, { options: commandOptionsSchema.parse({ id: calendarGroupId, force: true, verbose: true }) });
+ assert(deleteRequestStub.calledOnce);
+ });
+
+ it('removes the calendar group specified by name for the signed-in user', async () => {
+ sinon.stub(calendarGroup, 'getUserCalendarGroupByName').resolves({ id: calendarGroupId, name: calendarGroupName });
+
+ const deleteRequestStub = sinon.stub(request, 'delete').callsFake(async (opts) => {
+ if (opts.url === `https://graph.microsoft.com/v1.0/me/calendarGroups/${calendarGroupId}`) {
+ return;
+ }
+
+ throw 'Invalid request';
+ });
+
+ sinonUtil.restore(cli.promptForConfirmation);
+ sinon.stub(cli, 'promptForConfirmation').resolves(true);
+
+ await command.action(logger, { options: commandOptionsSchema.parse({ name: calendarGroupName }) });
+ assert(deleteRequestStub.calledOnce);
+ });
+
+ it('removes the calendar group specified by name for the signed-in user (verbose)', async () => {
+ sinon.stub(calendarGroup, 'getUserCalendarGroupByName').resolves({ id: calendarGroupId, name: calendarGroupName });
+
+ const deleteRequestStub = sinon.stub(request, 'delete').callsFake(async (opts) => {
+ if (opts.url === `https://graph.microsoft.com/v1.0/me/calendarGroups/${calendarGroupId}`) {
+ return;
+ }
+
+ throw 'Invalid request';
+ });
+
+ await command.action(logger, { options: commandOptionsSchema.parse({ name: calendarGroupName, force: true, verbose: true }) });
+ assert(deleteRequestStub.calledOnce);
+ });
+
+ it('removes the calendar group specified by id for a user specified by userId', async () => {
+ const deleteRequestStub = sinon.stub(request, 'delete').callsFake(async (opts) => {
+ if (opts.url === `https://graph.microsoft.com/v1.0/users('${userId}')/calendarGroups/${calendarGroupId}`) {
+ return;
+ }
+
+ throw 'Invalid request';
+ });
+
+ await command.action(logger, { options: commandOptionsSchema.parse({ id: calendarGroupId, userId, force: true }) });
+ assert(deleteRequestStub.calledOnce);
+ });
+
+ it('removes the calendar group specified by id for a user specified by userName', async () => {
+ const deleteRequestStub = sinon.stub(request, 'delete').callsFake(async (opts) => {
+ if (opts.url === `https://graph.microsoft.com/v1.0/users('john.doe%40contoso.com')/calendarGroups/${calendarGroupId}`) {
+ return;
+ }
+
+ throw 'Invalid request';
+ });
+
+ await command.action(logger, { options: commandOptionsSchema.parse({ id: calendarGroupId, userName, force: true }) });
+ assert(deleteRequestStub.calledOnce);
+ });
+
+ it('removes the calendar group specified by name for a user specified by userId', async () => {
+ sinon.stub(calendarGroup, 'getUserCalendarGroupByName').resolves({ id: calendarGroupId, name: calendarGroupName });
+
+ const deleteRequestStub = sinon.stub(request, 'delete').callsFake(async (opts) => {
+ if (opts.url === `https://graph.microsoft.com/v1.0/users('${userId}')/calendarGroups/${calendarGroupId}`) {
+ return;
+ }
+
+ throw 'Invalid request';
+ });
+
+ await command.action(logger, { options: commandOptionsSchema.parse({ name: calendarGroupName, userId, force: true }) });
+ assert(deleteRequestStub.calledOnce);
+ });
+
+ it('removes the calendar group specified by name for a user specified by userName', async () => {
+ sinon.stub(calendarGroup, 'getUserCalendarGroupByName').resolves({ id: calendarGroupId, name: calendarGroupName });
+
+ const deleteRequestStub = sinon.stub(request, 'delete').callsFake(async (opts) => {
+ if (opts.url === `https://graph.microsoft.com/v1.0/users('john.doe%40contoso.com')/calendarGroups/${calendarGroupId}`) {
+ return;
+ }
+
+ throw 'Invalid request';
+ });
+
+ await command.action(logger, { options: commandOptionsSchema.parse({ name: calendarGroupName, userName, force: true }) });
+ assert(deleteRequestStub.calledOnce);
+ });
+
+ it('removes the calendar group specified by id using app-only permissions', async () => {
+ sinonUtil.restore(accessToken.isAppOnlyAccessToken);
+ sinon.stub(accessToken, 'isAppOnlyAccessToken').returns(true);
+
+ const deleteRequestStub = sinon.stub(request, 'delete').callsFake(async (opts) => {
+ if (opts.url === `https://graph.microsoft.com/v1.0/users('${userId}')/calendarGroups/${calendarGroupId}`) {
+ return;
+ }
+
+ throw 'Invalid request';
+ });
+
+ await command.action(logger, { options: commandOptionsSchema.parse({ id: calendarGroupId, userId, force: true }) });
+ assert(deleteRequestStub.calledOnce);
+ });
+
+ it('throws error when running with app-only permissions without userId or userName', async () => {
+ sinonUtil.restore(accessToken.isAppOnlyAccessToken);
+ sinon.stub(accessToken, 'isAppOnlyAccessToken').returns(true);
+
+ await assert.rejects(
+ command.action(logger, { options: commandOptionsSchema.parse({ id: calendarGroupId, force: true }) }),
+ new CommandError('When running with application permissions either userId or userName is required.')
+ );
+ });
+
+ it('throws error when calendar group specified by name is not found', async () => {
+ sinon.stub(calendarGroup, 'getUserCalendarGroupByName').rejects(new Error(`The specified calendar group '${calendarGroupName}' does not exist.`));
+
+ await assert.rejects(
+ command.action(logger, { options: commandOptionsSchema.parse({ name: calendarGroupName, force: true }) }),
+ new CommandError(`The specified calendar group '${calendarGroupName}' does not exist.`)
+ );
+ });
+
+ it('correctly handles API OData error', async () => {
+ const errorMessage = `Your request can't be completed. The calendar group '${calendarGroupName}' is not empty.`;
+ sinon.stub(request, 'delete').rejects({ error: { error: { code: 'ErrorInvalidRequest', message: errorMessage } } });
+
+ await assert.rejects(
+ command.action(logger, { options: commandOptionsSchema.parse({ id: calendarGroupId, force: true }) }),
+ new CommandError(errorMessage)
+ );
+ });
+});
diff --git a/src/m365/outlook/commands/calendargroup/calendargroup-remove.ts b/src/m365/outlook/commands/calendargroup/calendargroup-remove.ts
new file mode 100644
index 00000000000..e1fcc47ed3c
--- /dev/null
+++ b/src/m365/outlook/commands/calendargroup/calendargroup-remove.ts
@@ -0,0 +1,123 @@
+import { z } from 'zod';
+import { globalOptionsZod } from '../../../../Command.js';
+import GraphCommand from '../../../base/GraphCommand.js';
+import { Logger } from '../../../../cli/Logger.js';
+import { cli } from '../../../../cli/cli.js';
+import commands from '../../commands.js';
+import { validation } from '../../../../utils/validation.js';
+import { accessToken } from '../../../../utils/accessToken.js';
+import auth from '../../../../Auth.js';
+import request, { CliRequestOptions } from '../../../../request.js';
+import { formatting } from '../../../../utils/formatting.js';
+import { calendarGroup } from '../../../../utils/calendarGroup.js';
+
+export const options = z.strictObject({
+ ...globalOptionsZod.shape,
+ id: z.string().optional(),
+ name: z.string().optional(),
+ userId: z.string().refine(id => validation.isValidGuid(id), {
+ error: e => `'${e.input}' is not a valid GUID.`
+ }).optional(),
+ userName: z.string().refine(name => validation.isValidUserPrincipalName(name), {
+ error: e => `'${e.input}' is not a valid UPN.`
+ }).optional(),
+ force: z.boolean().optional().alias('f')
+});
+
+declare type Options = z.infer;
+
+interface CommandArgs {
+ options: Options;
+}
+
+class OutlookCalendarGroupRemoveCommand extends GraphCommand {
+ public get name(): string {
+ return commands.CALENDARGROUP_REMOVE;
+ }
+
+ public get description(): string {
+ return 'Removes a calendar group';
+ }
+
+ public get schema(): z.ZodType | undefined {
+ return options;
+ }
+
+ public getRefinedSchema(schema: typeof options): z.ZodObject | undefined {
+ return schema
+ .refine(options => options.id || options.name, {
+ error: 'Specify either id or name.'
+ })
+ .refine(options => !(options.id && options.name), {
+ error: 'Specify either id or name, but not both.'
+ })
+ .refine(options => !(options.userId && options.userName), {
+ error: 'Specify either userId or userName, but not both.'
+ });
+ }
+
+ public async commandAction(logger: Logger, args: CommandArgs): Promise {
+ const removeCalendarGroup = async (): Promise => {
+ try {
+ const token = auth.connection.accessTokens[auth.defaultResource].accessToken;
+ const isAppOnlyAccessToken = accessToken.isAppOnlyAccessToken(token);
+
+ if (isAppOnlyAccessToken && !args.options.userId && !args.options.userName) {
+ throw 'When running with application permissions either userId or userName is required.';
+ }
+
+ let endpoint: string;
+ let graphUserId: string;
+
+ if (args.options.userId || args.options.userName) {
+ graphUserId = (args.options.userId ?? args.options.userName)!;
+ endpoint = `${this.resource}/v1.0/users('${formatting.encodeQueryParameter(graphUserId)}')`;
+ }
+ else {
+ graphUserId = 'me';
+ endpoint = `${this.resource}/v1.0/me`;
+ }
+
+ let calendarGroupId = args.options.id;
+
+ if (args.options.name) {
+ if (this.verbose) {
+ await logger.logToStderr(`Retrieving calendar group by name '${args.options.name}'...`);
+ }
+
+ const calendarGroupResult = await calendarGroup.getUserCalendarGroupByName(graphUserId, args.options.name);
+ calendarGroupId = calendarGroupResult.id!;
+ }
+
+ if (this.verbose) {
+ await logger.logToStderr(`Removing calendar group '${calendarGroupId}'...`);
+ }
+
+ const requestOptions: CliRequestOptions = {
+ url: `${endpoint}/calendarGroups/${calendarGroupId}`,
+ headers: {
+ accept: 'application/json;odata.metadata=none'
+ }
+ };
+
+ await request.delete(requestOptions);
+ }
+ catch (err: any) {
+ this.handleRejectedODataJsonPromise(err);
+ }
+ };
+
+ if (args.options.force) {
+ await removeCalendarGroup();
+ }
+ else {
+ const result = await cli.promptForConfirmation({ message: `Are you sure you want to remove calendar group '${args.options.id || args.options.name}'?` });
+
+ if (result) {
+ await removeCalendarGroup();
+ }
+ }
+ }
+}
+
+export default new OutlookCalendarGroupRemoveCommand();