From 83475973aff5d034f99ec17e2b0b5a8ba11ebaeb Mon Sep 17 00:00:00 2001 From: Saurabh Tripathi Date: Wed, 17 Jun 2026 20:25:10 +0200 Subject: [PATCH] New command: spo folder unarchive. Closes #7182 --- docs/docs/cmd/spo/folder/folder-unarchive.mdx | 68 ++++ docs/src/config/sidebars.ts | 5 + src/m365/spo/commands.ts | 1 + .../commands/folder/folder-unarchive.spec.ts | 362 ++++++++++++++++++ .../spo/commands/folder/folder-unarchive.ts | 112 ++++++ 5 files changed, 548 insertions(+) create mode 100644 docs/docs/cmd/spo/folder/folder-unarchive.mdx create mode 100644 src/m365/spo/commands/folder/folder-unarchive.spec.ts create mode 100644 src/m365/spo/commands/folder/folder-unarchive.ts diff --git a/docs/docs/cmd/spo/folder/folder-unarchive.mdx b/docs/docs/cmd/spo/folder/folder-unarchive.mdx new file mode 100644 index 00000000000..3dfd4596025 --- /dev/null +++ b/docs/docs/cmd/spo/folder/folder-unarchive.mdx @@ -0,0 +1,68 @@ +import Global from '../../_global.mdx'; +import Tabs from '@theme/Tabs'; +import TabItem from '@theme/TabItem'; + +# spo folder unarchive + +Unarchives a folder + +## Usage + +```sh +m365 spo folder unarchive [options] +``` + +## Options + +```md definition-list +`-u, --webUrl ` +: The URL of the site where the folder is located. + +`--url [url]` +: The server- or site-relative decoded URL of the folder to unarchive. Specify either `url` or `id`, but not both. + +`-i, --id [id]` +: The UniqueId (GUID) of the folder to unarchive. Specify either `url` or `id`, but not both. + +`-f, --force` +: Don't prompt for confirmation. +``` + + + +## Permissions + + + + + | Resource | Permissions | + |------------|----------------| + | SharePoint | AllSites.Write | + + + + + | Resource | Permissions | + |------------|---------------------| + | SharePoint | Sites.ReadWrite.All | + + + + +## Examples + +Unarchive a folder without prompting for confirmation + +```sh +m365 spo folder unarchive --webUrl https://contoso.sharepoint.com/sites/Marketing --id 7a8c9207-7745-4cda-b0e2-be2618ee3030 --force +``` + +Unarchive a folder by URL with prompting for confirmation + +```sh +m365 spo folder unarchive --webUrl https://contoso.sharepoint.com/sites/Marketing --url '/sites/Marketing/documents/general' +``` + +## 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 08073562637..0b84a16221e 100644 --- a/docs/src/config/sidebars.ts +++ b/docs/src/config/sidebars.ts @@ -2964,6 +2964,11 @@ const sidebars: SidebarsConfig = { type: 'doc', label: 'folder sharinglink set', id: 'cmd/spo/folder/folder-sharinglink-set' + }, + { + type: 'doc', + label: 'folder unarchive', + id: 'cmd/spo/folder/folder-unarchive' } ] }, diff --git a/src/m365/spo/commands.ts b/src/m365/spo/commands.ts index f96a7cc6ddb..71bb1892b66 100644 --- a/src/m365/spo/commands.ts +++ b/src/m365/spo/commands.ts @@ -113,6 +113,7 @@ export default { FOLDER_SHARINGLINK_LIST: `${prefix} folder sharinglink list`, FOLDER_SHARINGLINK_REMOVE: `${prefix} folder sharinglink remove`, FOLDER_SHARINGLINK_SET: `${prefix} folder sharinglink set`, + FOLDER_UNARCHIVE: `${prefix} folder unarchive`, GET: `${prefix} get`, GROUP_ADD: `${prefix} group add`, GROUP_GET: `${prefix} group get`, diff --git a/src/m365/spo/commands/folder/folder-unarchive.spec.ts b/src/m365/spo/commands/folder/folder-unarchive.spec.ts new file mode 100644 index 00000000000..0a98b569803 --- /dev/null +++ b/src/m365/spo/commands/folder/folder-unarchive.spec.ts @@ -0,0 +1,362 @@ +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 request from '../../../../request.js'; +import { telemetry } from '../../../../telemetry.js'; +import { pid } from '../../../../utils/pid.js'; +import { session } from '../../../../utils/session.js'; +import { sinonUtil } from '../../../../utils/sinonUtil.js'; +import { formatting } from '../../../../utils/formatting.js'; +import { z } from 'zod'; +import commands from '../../commands.js'; +import command from './folder-unarchive.js'; + +describe(commands.FOLDER_UNARCHIVE, () => { + let log: any[]; + let logger: Logger; + let commandInfo: CommandInfo; + let commandOptionsSchema: z.ZodTypeAny; + let confirmationPromptStub: sinon.SinonStub; + let loggerLogSpy: sinon.SinonSpy; + + const unarchiveResponse = { + value: '{"IsArchive":false,"TotalFileCount":2,"CreatedUtcDateTime":"2026-06-17T12:32:15.3479567Z","LastStartedUtcDateTime":"0001-01-01T00:00:00","FolderArchiveStatus":"Unknown","ProcessedFileCount":0,"SuccessCount":0,"FailureCount":0,"NotArchivableFileCount":0,"ProgressPercentage":0.0}' + }; + + 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; + auth.connection.spoUrl = 'https://contoso.sharepoint.com'; + commandInfo = cli.getCommandInfo(command); + commandOptionsSchema = commandInfo.command.getSchemaToParse()!; + }); + + beforeEach(() => { + log = []; + logger = { + log: async (msg: string) => { + log.push(msg); + }, + logRaw: async (msg: string) => { + log.push(msg); + }, + logToStderr: async (msg: string) => { + log.push(msg); + } + }; + loggerLogSpy = sinon.spy(logger, 'log'); + confirmationPromptStub = sinon.stub(cli, 'promptForConfirmation').resolves(false); + }); + + afterEach(() => { + sinonUtil.restore([ + request.get, + request.post, + cli.promptForConfirmation + ]); + }); + + after(() => { + sinon.restore(); + auth.connection.active = false; + auth.connection.spoUrl = undefined; + }); + + it('has correct name', () => { + assert.strictEqual(command.name, commands.FOLDER_UNARCHIVE); + }); + + it('has a description', () => { + assert.notStrictEqual(command.description, null); + }); + + it('excludes options from URL processing', () => { + assert.deepStrictEqual((command as any).getExcludedOptionsWithUrls(), ['url']); + }); + + it('fails validation if webUrl is not a valid SharePoint URL', async () => { + const actual = commandOptionsSchema.safeParse({ + webUrl: 'invalid-url', + id: '00000000-0000-0000-0000-000000000000', + force: true + }); + assert.strictEqual(actual.success, false); + }); + + it('fails validation if both url and id are specified', async () => { + const actual = commandOptionsSchema.safeParse({ + webUrl: 'https://contoso.sharepoint.com', + url: '/sites/test/Shared documents/folder', + id: '00000000-0000-0000-0000-000000000000', + force: true + }); + assert.strictEqual(actual.success, false); + }); + + it('fails validation if neither url nor id are specified', async () => { + const actual = commandOptionsSchema.safeParse({ + webUrl: 'https://contoso.sharepoint.com', + force: true + }); + assert.strictEqual(actual.success, false); + }); + + it('fails validation if the id option is not a valid GUID', async () => { + const actual = commandOptionsSchema.safeParse({ + webUrl: 'https://contoso.sharepoint.com', + id: 'invalid-guid', + force: true + }); + assert.strictEqual(actual.success, false); + }); + + it('passes validation with valid options (url)', async () => { + const actual = commandOptionsSchema.safeParse({ + webUrl: 'https://contoso.sharepoint.com', + url: '/sites/test/Shared documents/folder', + force: true + }); + assert.strictEqual(actual.success, true); + }); + + it('passes validation with valid options (id)', async () => { + const actual = commandOptionsSchema.safeParse({ + webUrl: 'https://contoso.sharepoint.com', + id: '00000000-0000-0000-0000-000000000000', + force: true + }); + assert.strictEqual(actual.success, true); + }); + + it('prompts before unarchiving folder when confirmation argument not passed', async () => { + sinon.stub(request, 'get').resolves({ Exists: true, ListItemAllFields: { Id: 1, ParentList: { Id: 'b2307a39-e878-458b-bc90-03bc578531d6' } } }); + sinon.stub(request, 'post').resolves(); + + await command.action(logger, { + options: { + webUrl: 'https://contoso.sharepoint.com', + id: '00000000-0000-0000-0000-000000000000' + } + }); + assert(confirmationPromptStub.calledOnce); + }); + + it('aborts unarchiving folder when prompt not confirmed', async () => { + const getStub = sinon.stub(request, 'get').resolves({ Exists: true, ListItemAllFields: { Id: 1, ParentList: { Id: 'b2307a39-e878-458b-bc90-03bc578531d6' } } }); + const postStub = sinon.stub(request, 'post').resolves(); + + await command.action(logger, { + options: { + webUrl: 'https://contoso.sharepoint.com', + url: '/sites/test/Shared documents/folder' + } + }); + + assert(getStub.notCalled); + assert(postStub.notCalled); + }); + + it('unarchives folder by url', async () => { + sinon.stub(request, 'get').callsFake(async (opts) => { + if (opts.url === `https://contoso.sharepoint.com/sites/test/_api/web/GetFolderByServerRelativePath(DecodedUrl='${formatting.encodeQueryParameter('/sites/test/Shared documents/folder')}')?$select=Exists,ListItemAllFields/Id,ListItemAllFields/ParentList/Id&$expand=ListItemAllFields,ListItemAllFields/ParentList`) { + return { + Exists: true, + ListItemAllFields: { + Id: 1, + ParentList: { + Id: 'b2307a39-e878-458b-bc90-03bc578531d6' + } + } + }; + } + + throw 'Invalid request'; + }); + + const postStub = sinon.stub(request, 'post').callsFake(async (opts) => { + if (opts.url === `https://contoso.sharepoint.com/sites/test/_api/Lists(guid'b2307a39-e878-458b-bc90-03bc578531d6')/items(1)/UnArchive`) { + return unarchiveResponse; + } + + throw 'Invalid request'; + }); + + await command.action(logger, { + options: { + webUrl: 'https://contoso.sharepoint.com/sites/test', + url: '/sites/test/Shared documents/folder', + force: true + } + }); + + assert(postStub.calledOnce); + }); + + it('unarchives folder by id', async () => { + sinon.stub(request, 'get').callsFake(async (opts) => { + if (opts.url === `https://contoso.sharepoint.com/sites/test/_api/web/GetFolderById('${formatting.encodeQueryParameter('00000000-0000-0000-0000-000000000000')}')?$select=Exists,ListItemAllFields/Id,ListItemAllFields/ParentList/Id&$expand=ListItemAllFields,ListItemAllFields/ParentList`) { + return { + Exists: true, + ListItemAllFields: { + Id: 1, + ParentList: { + Id: 'b2307a39-e878-458b-bc90-03bc578531d6' + } + } + }; + } + + throw 'Invalid request'; + }); + + const postStub = sinon.stub(request, 'post').callsFake(async (opts) => { + if (opts.url === `https://contoso.sharepoint.com/sites/test/_api/Lists(guid'b2307a39-e878-458b-bc90-03bc578531d6')/items(1)/UnArchive`) { + return unarchiveResponse; + } + + throw 'Invalid request'; + }); + + await command.action(logger, { + options: { + webUrl: 'https://contoso.sharepoint.com/sites/test', + id: '00000000-0000-0000-0000-000000000000', + verbose: true, + force: true + } + }); + + assert(postStub.calledOnce); + }); + + it('unarchives folder using site-relative url', async () => { + sinon.stub(request, 'get').callsFake(async (opts) => { + if (opts.url === `https://contoso.sharepoint.com/sites/test/_api/web/GetFolderByServerRelativePath(DecodedUrl='${formatting.encodeQueryParameter('/sites/test/Shared Documents/folder')}')?$select=Exists,ListItemAllFields/Id,ListItemAllFields/ParentList/Id&$expand=ListItemAllFields,ListItemAllFields/ParentList`) { + return { + Exists: true, + ListItemAllFields: { + Id: 1, + ParentList: { + Id: 'b2307a39-e878-458b-bc90-03bc578531d6' + } + } + }; + } + + throw 'Invalid request'; + }); + + const postStub = sinon.stub(request, 'post').callsFake(async (opts) => { + if (opts.url === `https://contoso.sharepoint.com/sites/test/_api/Lists(guid'b2307a39-e878-458b-bc90-03bc578531d6')/items(1)/UnArchive`) { + return unarchiveResponse; + } + + throw 'Invalid request'; + }); + + await command.action(logger, { + options: { + webUrl: 'https://contoso.sharepoint.com/sites/test', + url: '/Shared Documents/folder', + force: true + } + }); + + assert(postStub.calledOnce); + }); + + it('outputs no result when unarchiving a folder', async () => { + sinon.stub(request, 'get').resolves({ Exists: true, ListItemAllFields: { Id: 1, ParentList: { Id: 'b2307a39-e878-458b-bc90-03bc578531d6' } } }); + sinon.stub(request, 'post').resolves(); + + await command.action(logger, { + options: { + webUrl: 'https://contoso.sharepoint.com/sites/test', + url: '/sites/test/Shared documents/folder', + force: true + } + }); + + assert(loggerLogSpy.notCalled); + }); + + it('throws an error when trying to unarchive the root folder of a document library by url', async () => { + sinon.stub(request, 'get').resolves({ Exists: true, ListItemAllFields: {} }); + + await assert.rejects(command.action(logger, { + options: { + webUrl: 'https://contoso.sharepoint.com/sites/test', + url: '/Shared Documents', + force: true + } + }), new CommandError(`The folder '/Shared Documents' is the root folder of a document library and cannot be unarchived. Unarchive a subfolder instead.`)); + }); + + it('throws an error when trying to unarchive the root folder of a document library by id', async () => { + sinon.stub(request, 'get').resolves({ Exists: true, ListItemAllFields: {} }); + + await assert.rejects(command.action(logger, { + options: { + webUrl: 'https://contoso.sharepoint.com/sites/test', + id: '00000000-0000-0000-0000-000000000000', + force: true + } + }), new CommandError(`The folder '00000000-0000-0000-0000-000000000000' is the root folder of a document library and cannot be unarchived. Unarchive a subfolder instead.`)); + }); + + it('throws an error when the folder does not exist by url', async () => { + sinon.stub(request, 'get').resolves({}); + + await assert.rejects(command.action(logger, { + options: { + webUrl: 'https://contoso.sharepoint.com/sites/test', + url: '/Shared Documents/temp1', + force: true + } + }), new CommandError(`The folder '/Shared Documents/temp1' does not exist.`)); + }); + + it('throws an error when the folder does not exist by id', async () => { + sinon.stub(request, 'get').resolves({}); + + await assert.rejects(command.action(logger, { + options: { + webUrl: 'https://contoso.sharepoint.com/sites/test', + id: '00000000-0000-0000-0000-000000000000', + force: true + } + }), new CommandError(`The folder '00000000-0000-0000-0000-000000000000' does not exist.`)); + }); + + it('handles error correctly', async () => { + const error = { + error: { + 'odata.error': { + code: "-2130575338, Microsoft.SharePoint.SPException", + message: { + lang: "en-US", + value: 'The folder /sites/test/Shared documents/folder does not exist.' + } + } + } + }; + + sinon.stub(request, 'get').rejects(error); + + await assert.rejects(command.action(logger, { + options: { + webUrl: 'https://contoso.sharepoint.com/sites/test', + url: '/sites/test/Shared documents/folder', + force: true + } + }), new CommandError(error.error['odata.error'].message.value)); + }); +}); \ No newline at end of file diff --git a/src/m365/spo/commands/folder/folder-unarchive.ts b/src/m365/spo/commands/folder/folder-unarchive.ts new file mode 100644 index 00000000000..a86ba6295a2 --- /dev/null +++ b/src/m365/spo/commands/folder/folder-unarchive.ts @@ -0,0 +1,112 @@ +import commands from '../../commands.js'; +import { Logger } from '../../../../cli/Logger.js'; +import SpoCommand from '../../../base/SpoCommand.js'; +import { globalOptionsZod } from '../../../../Command.js'; +import { z } from 'zod'; +import { validation } from '../../../../utils/validation.js'; +import { cli } from '../../../../cli/cli.js'; +import { urlUtil } from '../../../../utils/urlUtil.js'; +import request, { CliRequestOptions } from '../../../../request.js'; +import { formatting } from '../../../../utils/formatting.js'; + +export const options = z.strictObject({ + ...globalOptionsZod.shape, + webUrl: z.string() + .refine(url => validation.isValidSharePointUrl(url) === true, { + error: e => `'${e.input}' is not a valid SharePoint Online site URL.` + }) + .alias('u'), + url: z.string().optional(), + id: z.uuid().optional().alias('i'), + force: z.boolean().optional().alias('f') +}); + +declare type Options = z.infer; + +interface CommandArgs { + options: Options; +} + +class SpoFolderUnarchiveCommand extends SpoCommand { + public get name(): string { + return commands.FOLDER_UNARCHIVE; + } + + public get description(): string { + return 'Unarchives a folder'; + } + + public get schema(): z.ZodType | undefined { + return options; + } + + public getRefinedSchema(schema: typeof options): z.ZodObject | undefined { + return schema + .refine(options => [options.url, options.id].filter(o => o !== undefined).length === 1, { + error: `Specify 'url' or 'id', but not both.` + }); + } + + protected getExcludedOptionsWithUrls(): string[] | undefined { + return ['url']; + } + + public async commandAction(logger: Logger, args: CommandArgs): Promise { + const { webUrl, url, id, force } = args.options; + + if (!force) { + const result = await cli.promptForConfirmation({ message: `Reactivation could take up to 24 hours. Folders that are reactivated cannot be archived again for 120 days. Are you sure you would like to unarchive this item?` }); + if (!result) { + return; + } + } + + try { + if (this.verbose) { + await logger.logToStderr(`Unarchiving folder ${url || id} at site ${webUrl}...`); + } + + let requestUrl: string = `${webUrl}/_api/web`; + + if (id) { + requestUrl += `/GetFolderById('${formatting.encodeQueryParameter(id)}')`; + } + else if (url) { + const serverRelativePath = urlUtil.getServerRelativePath(webUrl, url); + requestUrl += `/GetFolderByServerRelativePath(DecodedUrl='${formatting.encodeQueryParameter(serverRelativePath)}')`; + } + requestUrl += '?$select=Exists,ListItemAllFields/Id,ListItemAllFields/ParentList/Id&$expand=ListItemAllFields,ListItemAllFields/ParentList'; + + const folderInfo = await request.get<{ Exists?: boolean; ListItemAllFields?: { Id: number; ParentList: { Id: string } } }>({ + url: requestUrl, + headers: { + accept: 'application/json;odata=nometadata' + }, + responseType: 'json' + }); + + if (!folderInfo.Exists) { + throw `The folder '${url || id}' does not exist.`; + } + + if (!folderInfo.ListItemAllFields?.ParentList) { + throw `The folder '${url || id}' is the root folder of a document library and cannot be unarchived. Unarchive a subfolder instead.`; + } + + const requestOptions: CliRequestOptions = { + url: `${webUrl}/_api/Lists(guid'${folderInfo.ListItemAllFields.ParentList.Id}')/items(${folderInfo.ListItemAllFields.Id})/UnArchive`, + headers: { + accept: 'application/json;odata=nometadata' + }, + responseType: 'json' + }; + + await request.post(requestOptions); + } + catch (err: any) { + this.handleRejectedODataJsonPromise(err); + } + } +} + +export default new SpoFolderUnarchiveCommand(); \ No newline at end of file