From eb130b1e725f77ef1bbc5cfc070034223e7d9c1b Mon Sep 17 00:00:00 2001 From: Adam-it Date: Fri, 31 Jul 2026 01:53:17 +0200 Subject: [PATCH] Adds export of site level app catalogs with apps --- package.json | 13 +++- src/constants/Commands.ts | 1 + src/models/SiteAppCatalogExport.ts | 5 ++ src/models/index.ts | 1 + src/services/actions/CliActions.ts | 112 ++++++++++++++++++++++++++--- 5 files changed, 122 insertions(+), 10 deletions(-) create mode 100644 src/models/SiteAppCatalogExport.ts diff --git a/package.json b/package.json index 2756a416..18bd1a3a 100644 --- a/package.json +++ b/package.json @@ -1296,6 +1296,12 @@ "category": "SPFx Toolkit", "icon": "$(trash)" }, + { + "command": "spfx-toolkit.exportSiteAppCatalogs", + "title": "Export Site App Catalogs", + "category": "SPFx Toolkit", + "icon": "$(export)" + }, { "command": "spfx-toolkit.moreTenantWideExtensionActions", "title": "...", @@ -1450,7 +1456,12 @@ { "command": "spfx-toolkit.addSiteAppCatalog", "when": "viewItem == sp-app-catalog-root", - "group": "inline" + "group": "inline@1" + }, + { + "command": "spfx-toolkit.exportSiteAppCatalogs", + "when": "viewItem == sp-app-catalog-root", + "group": "inline@2" }, { "command": "spfx-toolkit.removeSiteAppCatalog", diff --git a/src/constants/Commands.ts b/src/constants/Commands.ts index b8f73f67..bdb2421c 100644 --- a/src/constants/Commands.ts +++ b/src/constants/Commands.ts @@ -101,6 +101,7 @@ export const Commands = { addTenantAppCatalog: `${EXTENSION_NAME}.addTenantAppCatalog`, addSiteAppCatalog: `${EXTENSION_NAME}.addSiteAppCatalog`, removeSiteAppCatalog: `${EXTENSION_NAME}.removeSiteAppCatalog`, + exportSiteAppCatalogs: `${EXTENSION_NAME}.exportSiteAppCatalogs`, // Set form customizer setFormCustomizer: `${EXTENSION_NAME}.setFormCustomizer`, diff --git a/src/models/SiteAppCatalogExport.ts b/src/models/SiteAppCatalogExport.ts new file mode 100644 index 00000000..92847042 --- /dev/null +++ b/src/models/SiteAppCatalogExport.ts @@ -0,0 +1,5 @@ +export interface SiteAppCatalogExport { + url: string; + apps: Record[]; + error?: string; +} diff --git a/src/models/index.ts b/src/models/index.ts index 1434317e..b00875a4 100644 --- a/src/models/index.ts +++ b/src/models/index.ts @@ -5,6 +5,7 @@ export * from './GenerateWorkflowCommandInput'; export * from './Sample'; export * from './ServeConfig'; export * from './SiteAppCatalog'; +export * from './SiteAppCatalogExport'; export * from './solution-add-result'; export * from './SpfxAddComponentCommandInput'; export * from './SpfxDoctorOutput'; diff --git a/src/services/actions/CliActions.ts b/src/services/actions/CliActions.ts index 6ca4a159..761d8774 100644 --- a/src/services/actions/CliActions.ts +++ b/src/services/actions/CliActions.ts @@ -1,8 +1,9 @@ import { readFileSync, writeFileSync } from 'fs'; +import { homedir } from 'os'; import { Folders } from '../check/Folders'; import { commands, Progress, ProgressLocation, Uri, window, workspace, WorkspaceFolder } from 'vscode'; import { Commands, SpfxCompatibilityMatrix, WebViewType, WebviewCommand, WorkflowType } from '../../constants'; -import { AppCatalogApp, GenerateWorkflowCommandInput, SiteAppCatalog, SolutionAddResult, SpfxDoctorOutput, Subscription } from '../../models'; +import { AppCatalogApp, GenerateWorkflowCommandInput, SiteAppCatalog, SiteAppCatalogExport, SolutionAddResult, SpfxDoctorOutput, Subscription } from '../../models'; import { Extension } from '../dataType/Extension'; import { CliExecuter } from '../executeWrappers/CliCommandExecuter'; import { Notifications } from '../dataType/Notifications'; @@ -59,6 +60,9 @@ export class CliActions { subscriptions.push( commands.registerCommand(Commands.removeSiteAppCatalog, CliActions.removeSiteAppCatalog) ); + subscriptions.push( + commands.registerCommand(Commands.exportSiteAppCatalogs, CliActions.exportSiteAppCatalogs) + ); } /** @@ -175,15 +179,8 @@ export class CliActions { */ public static async getAppCatalogApps(appCatalogUrl?: string): Promise { try { - const commandOptions: any = appCatalogUrl && appCatalogUrl.trim() !== '' ? { - appCatalogScope: 'sitecollection', - appCatalogUrl: appCatalogUrl - } : {}; - - const response = (await CliExecuter.execute('spo app list', 'json', commandOptions)); - const apps = response?.stdout || '[]'; + const appsJson = await CliActions.getAppCatalogAppsRaw(appCatalogUrl); - const appsJson: any[] = JSON.parse(apps); const appList = appsJson.map(({ ID, Title, Deployed, IsEnabled }) => { return { ID, @@ -200,6 +197,23 @@ export class CliActions { } } + /** + * Retrieves the unmapped 'spo app list' output for the tenant or site app catalog. + * + * @param appCatalogUrl The URL of the tenant or site app catalog. + * @returns A promise that resolves to the app objects as returned by the CLI, with all their properties. + */ + public static async getAppCatalogAppsRaw(appCatalogUrl?: string): Promise { + const commandOptions: any = appCatalogUrl && appCatalogUrl.trim() !== '' ? { + appCatalogScope: 'sitecollection', + appCatalogUrl: appCatalogUrl + } : {}; + + const response = (await CliExecuter.execute('spo app list', 'json', commandOptions)); + + return JSON.parse(response?.stdout || '[]'); + } + /** * Retrieves the tenant-wide extensions from the specified tenant app catalog URL. * @param tenantAppCatalogUrl The URL of the tenant app catalog. @@ -671,6 +685,86 @@ export class CliActions { } } + /** + * Exports an inventory of all site collection app catalogs and the apps they contain. + */ + public static async exportSiteAppCatalogs() { + try { + const appCatalogUrls = await CliActions.appCatalogUrlsGet(); + // the first entry is the tenant app catalog, which is out of scope for this export + const siteAppCatalogUrls = appCatalogUrls?.slice(1) ?? []; + + if (siteAppCatalogUrls.length === 0) { + Notifications.warning('No site app catalogs found to export.'); + return; + } + + const siteAppCatalogs = await window.withProgress({ + location: ProgressLocation.Notification, + title: `Collecting the site app catalog details... Check [output window](command:${Commands.showOutputChannel}) to follow the progress.`, + cancellable: false + }, async () => { + const catalogs: SiteAppCatalogExport[] = []; + + for (const siteAppCatalogUrl of siteAppCatalogUrls) { + try { + catalogs.push({ + url: siteAppCatalogUrl, + apps: await CliActions.getAppCatalogAppsRaw(siteAppCatalogUrl) + }); + } catch (e: any) { + catalogs.push({ + url: siteAppCatalogUrl, + apps: [], + error: e?.error?.message || e?.message || 'Failed to retrieve the apps of this site app catalog.' + }); + } + } + + return catalogs; + }); + + const timestamp = new Date().toISOString().slice(0, 16).replace(/[:T]/g, '-'); + const defaultPath = join(workspace.workspaceFolders?.[0]?.uri.fsPath || homedir(), `site-app-catalogs-${timestamp}.json`); + + const targetUri = await window.showSaveDialog({ + defaultUri: Uri.file(defaultPath), + filters: { JSON: ['json'] }, + saveLabel: 'Export' + }); + + if (!targetUri) { + return; + } + + writeFileSync(targetUri.fsPath, CliActions.buildSiteAppCatalogsExport(siteAppCatalogs), 'utf8'); + + const openFile = 'Open file'; + Notifications.info(`Exported ${siteAppCatalogs.length} site app catalog(s) to '${basename(targetUri.fsPath)}'.`, openFile).then((selectedOption) => { + if (selectedOption === openFile) { + commands.executeCommand('vscode.open', targetUri); + } + }); + } catch (e: any) { + const message = e?.error?.message || e?.message || 'An unexpected error occurred during the export.'; + Notifications.error(message); + } + } + + /** + * Builds the content of the site app catalogs export file. + * + * @param siteAppCatalogs The site app catalogs to include in the export. + * @returns The serialized export content. + */ + private static buildSiteAppCatalogsExport(siteAppCatalogs: SiteAppCatalogExport[]): string { + return JSON.stringify({ + generatedOn: new Date().toISOString(), + tenantUrl: EnvironmentInformation.tenantUrl, + siteAppCatalogs + }, null, 2); + } + /** * Upgrades the project by generating the upgrade steps and displaying them in a Markdown preview. * @private