diff --git a/CHANGELOG.md b/CHANGELOG.md index 3806e87..e724a72 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -47,4 +47,7 @@ ## [0.3.3] - Load / error messages controlled via verbosity setting -- Update internal fspath handling \ No newline at end of file +- Update internal fspath handling + +## [0.3.4] +- Support for updating Plugin Packages \ No newline at end of file diff --git a/README.md b/README.md index c6774b7..4f8570d 100644 --- a/README.md +++ b/README.md @@ -2,7 +2,7 @@ Bamboo is a simple, friendly, and ⚡*blazingly*⚡ fast customization manager, designed to speed up development time on the [Microsoft Power Platform](https://powerplatform.microsoft.com/en-us/). 🚀 -Currently supporting [web resources](https://learn.microsoft.com/en-us/power-apps/developer/model-driven-apps/web-resources) and [custom controls](https://learn.microsoft.com/en-us/power-apps/developer/component-framework/create-custom-controls-using-pcf), Bamboo provides a seamless experience for developers to edit and manage these solution components - all from within VS Code. +Currently supporting [web resources](https://learn.microsoft.com/en-us/power-apps/developer/model-driven-apps/web-resources), [custom controls](https://learn.microsoft.com/en-us/power-apps/developer/component-framework/create-custom-controls-using-pcf), and [plugin packages](https://learn.microsoft.com/en-us/power-apps/developer/data-platform/build-and-package), Bamboo provides a seamless experience for developers to edit and manage these solution components - all from within VS Code. ## Features Bamboo provides the following features inside VS Code: @@ -12,21 +12,23 @@ Bamboo provides the following features inside VS Code: - Add web resources to a solution automatically. - Manage custom controls (PCF components) through the import + publish of solutions. - List all web resources and custom controls in a given solution in a VS Code tree view. +- Update plugin packages #### Component Tree ![Component Tree](./images/component_tree.png) #### Commands -![Command Palette](./images/command_palette.png) +![Command Palette](./images/command_palette2.png) ## Getting Started 1. Install the extension [here](https://marketplace.visualstudio.com/publishers/root16). -2. Add a `bamboo.conf.json` at the **root** of your VS Code workspace. - - ![Example Project Strucutre](./images/project_structure.png) - - **Do not check `bamboo.conf.json` into source control.** -3. Populate the json file with the following data: +2. Add the files: `bamboo.conf.json` and `.bamboo_tokens/tokenCache.json` at the **root** of your VS Code workspace. + - A suggested structure is: + - ![Example Project Structure](./images/project_structure.png) + - **Do not check `bamboo.conf.json` or `.bamboo_tokens` into source control.** +3. Populate `bamboo.conf.json` with the following data: ```json { @@ -61,6 +63,13 @@ Bamboo provides the following features inside VS Code: "solutionName": "ControlTwoSolution" }, ... + ], + "pluginPackages": [ + { + "pluginPackageName": "new_NEW.Plugins", + "relativePathOnDiskToNugetPackage": "path/to/NEW.Plugins.1.0.0.nupkg" + }, + ... ] } ``` @@ -81,13 +90,14 @@ Bamboo provides the following features inside VS Code: - `baseUrl` must *not* end with a `/`. - The [app registration](https://learn.microsoft.com/en-us/power-apps/developer/data-platform/walkthrough-register-app-azure-active-directory#confidential-client-app-registration) specified must have: - Access to the specified Dataverse environment - - The appropiate Security Role necessary to: + - The appropriate Security Role necessary to: - Upload solutions - Publish solutions - Upload web resources - Publish web resources + - Upload plugin packages - Add components to solutions -- `relativePathOnDisk` and `relativePathOnDiskToSolution` must *not* start with a `/`. +- `relativePathOnDisk`, `relativePathOnDiskToSolution` and `relativePathOnDiskToNugetPackage` must *not* start with a `/`. - For web resources, `dataverseName` and `relativePathOnDisk` don't *need* to be similar (as shown in the example), this is just encouraged for ease of development ## Usage @@ -97,14 +107,11 @@ Bamboo provides the following features inside VS Code: | `bamboo.syncCurrentFile` | Sync current file. (Must be present on conf.) | | `bamboo.syncAllFiles` | Sync all files. (Each file present in the conf.) | | `bamboo.syncCustomControl` | Sync a Custom Control. (Opens up a choice dropdown for each control specified in the conf.) | +| `bamboo.syncPluginPackage` | Sync a Plugin Package. (Opens up a choice dropdown for each package specified in the conf.) | - All command can be run in the command palette. -## Token Refresh + Cache -- Bamboo can use the previously cached token to speed up initial load times. -- Add the file: `/bamboo_tokens/tokenCache.json` and then restart VS Code. - ## Extension Settings | Property | Type | Default | Description | @@ -125,7 +132,9 @@ Bamboo provides the following features inside VS Code: - [❌] Automatically add custom controls to solution - [❌] Manage upload / sync from context of tree view - [❌] Sync data from Power Apps to local files -- [❌] Plugin support +- [❌] Create a Plugin Package or Plugin Assembly +- [✅] Update / sync a *Plugin Package* +- [❌] Update / sync a *Plugin Assembly* ## License Distributed under the MIT License. See [`LICENSE`](LICENSE) for more information. diff --git a/images/command_palette.png b/images/command_palette.png deleted file mode 100644 index 47ec92b..0000000 Binary files a/images/command_palette.png and /dev/null differ diff --git a/images/command_palette2.png b/images/command_palette2.png new file mode 100644 index 0000000..16530f0 Binary files /dev/null and b/images/command_palette2.png differ diff --git a/package-lock.json b/package-lock.json index 2c19232..c951593 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,23 +1,27 @@ { "name": "bamboo-pa-vscode", - "version": "0.3.0", + "version": "0.3.4", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "bamboo-pa-vscode", - "version": "0.3.0", + "version": "0.3.4", "dependencies": { "@azure/msal-node": "^3.2.3", + "adm-zip": "^0.5.16", "jsonwebtoken": "^9.0.2", - "open": "^10.1.0" + "open": "^10.1.0", + "xml2js": "^0.6.2" }, "devDependencies": { + "@types/adm-zip": "^0.5.7", "@types/glob": "^7.1.4", "@types/jsonwebtoken": "^9.0.9", "@types/mocha": "^9.0.0", "@types/node": "14.x", "@types/vscode": "^1.73.0", + "@types/xml2js": "^0.4.14", "@typescript-eslint/eslint-plugin": "^4.31.1", "@typescript-eslint/parser": "^4.31.1", "@vscode/test-electron": "^2.4.1", @@ -260,6 +264,16 @@ "node": ">= 8" } }, + "node_modules/@types/adm-zip": { + "version": "0.5.7", + "resolved": "https://registry.npmjs.org/@types/adm-zip/-/adm-zip-0.5.7.tgz", + "integrity": "sha512-DNEs/QvmyRLurdQPChqq0Md4zGvPwHerAJYWk9l2jCbD1VPpnzRJorOdiq4zsw09NFbYnhfsoEhWtxIzXpn2yw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/node": "*" + } + }, "node_modules/@types/glob": { "version": "7.2.0", "resolved": "https://registry.npmjs.org/@types/glob/-/glob-7.2.0.tgz", @@ -324,6 +338,16 @@ "dev": true, "license": "MIT" }, + "node_modules/@types/xml2js": { + "version": "0.4.14", + "resolved": "https://registry.npmjs.org/@types/xml2js/-/xml2js-0.4.14.tgz", + "integrity": "sha512-4YnrRemBShWRO2QjvUin8ESA41rH+9nQGLUGZV/1IDhi3SL9OhdpNC/MrulTWuptXKwhx/aDxE7toV0f/ypIXQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/node": "*" + } + }, "node_modules/@typescript-eslint/eslint-plugin": { "version": "4.33.0", "resolved": "https://registry.npmjs.org/@typescript-eslint/eslint-plugin/-/eslint-plugin-4.33.0.tgz", @@ -535,6 +559,15 @@ "acorn": "^6.0.0 || ^7.0.0 || ^8.0.0" } }, + "node_modules/adm-zip": { + "version": "0.5.16", + "resolved": "https://registry.npmjs.org/adm-zip/-/adm-zip-0.5.16.tgz", + "integrity": "sha512-TGw5yVi4saajsSEgz25grObGHEUaDrniwvA2qwSC060KfqGPdglhvPMA2lPIoxs3PQIItj2iag35fONcQqgUaQ==", + "license": "MIT", + "engines": { + "node": ">=12.0" + } + }, "node_modules/agent-base": { "version": "7.1.3", "resolved": "https://registry.npmjs.org/agent-base/-/agent-base-7.1.3.tgz", @@ -2919,6 +2952,12 @@ ], "license": "MIT" }, + "node_modules/sax": { + "version": "1.4.1", + "resolved": "https://registry.npmjs.org/sax/-/sax-1.4.1.tgz", + "integrity": "sha512-+aWOz7yVScEGoKNd4PA10LZ8sk0A/z5+nXQG5giUO5rprX9jgYsTdov9qCchZiPIZezbZH+jRut8nPodFAX4Jg==", + "license": "ISC" + }, "node_modules/semver": { "version": "7.7.1", "resolved": "https://registry.npmjs.org/semver/-/semver-7.7.1.tgz", @@ -3315,6 +3354,28 @@ "dev": true, "license": "ISC" }, + "node_modules/xml2js": { + "version": "0.6.2", + "resolved": "https://registry.npmjs.org/xml2js/-/xml2js-0.6.2.tgz", + "integrity": "sha512-T4rieHaC1EXcES0Kxxj4JWgaUQHDk+qwHcYOCFHfiwKz7tOVPLq7Hjq9dM1WCMhylqMEfP7hMcOIChvotiZegA==", + "license": "MIT", + "dependencies": { + "sax": ">=0.6.0", + "xmlbuilder": "~11.0.0" + }, + "engines": { + "node": ">=4.0.0" + } + }, + "node_modules/xmlbuilder": { + "version": "11.0.1", + "resolved": "https://registry.npmjs.org/xmlbuilder/-/xmlbuilder-11.0.1.tgz", + "integrity": "sha512-fDlsI/kFEx7gLvbecc0/ohLG50fugQp8ryHzMTuW9vSa1GJ0XYWKnhsUx7oie3G98+r56aTQIUB4kht42R3JvA==", + "license": "MIT", + "engines": { + "node": ">=4.0" + } + }, "node_modules/y18n": { "version": "5.0.8", "resolved": "https://registry.npmjs.org/y18n/-/y18n-5.0.8.tgz", diff --git a/package.json b/package.json index e5ce581..d51a78c 100644 --- a/package.json +++ b/package.json @@ -6,7 +6,7 @@ "url": "https://github.com/Root16/bamboo" }, "description": "Enables users to create, update, and publish Web Resources and Custom Controls for Microsoft Power Platform — directly from VS Code.", - "version": "0.3.3", + "version": "0.3.4", "icon": "resources/bamboo-green.png", "engines": { "vscode": "^1.73.0" @@ -80,6 +80,11 @@ "light": "resources/light/refresh.svg", "dark": "resources/dark/refresh.svg" } + }, + { + "command": "bamboo.syncPluginPackage", + "title": "Sync a Plugin Package.", + "category": "Bamboo" } ], "menus": { @@ -103,11 +108,13 @@ "package": "vsce package" }, "devDependencies": { + "@types/adm-zip": "^0.5.7", "@types/glob": "^7.1.4", "@types/jsonwebtoken": "^9.0.9", "@types/mocha": "^9.0.0", "@types/node": "14.x", "@types/vscode": "^1.73.0", + "@types/xml2js": "^0.4.14", "@typescript-eslint/eslint-plugin": "^4.31.1", "@typescript-eslint/parser": "^4.31.1", "@vscode/test-electron": "^2.4.1", @@ -118,7 +125,9 @@ }, "dependencies": { "@azure/msal-node": "^3.2.3", + "adm-zip": "^0.5.16", "jsonwebtoken": "^9.0.2", - "open": "^10.1.0" + "open": "^10.1.0", + "xml2js": "^0.6.2" } } diff --git a/src/classes/syncer/BambooConfig.ts b/src/classes/syncer/BambooConfig.ts index 589df3d..578a254 100644 --- a/src/classes/syncer/BambooConfig.ts +++ b/src/classes/syncer/BambooConfig.ts @@ -4,6 +4,7 @@ export interface BambooConfig { credential: Credential; webResources: WebResourceMapping[]; customControls: CustomControlMapping[]; + pluginPackages: PluginPackageMapping[]; } export enum CredentialType { @@ -28,3 +29,8 @@ export interface CustomControlMapping { relativePathOnDiskToSolution: string; solutionName: string; } + +export interface PluginPackageMapping { + pluginPackageName: string; + relativePathOnDiskToNugetPackage: string; +} diff --git a/src/classes/syncer/BambooManager.ts b/src/classes/syncer/BambooManager.ts index 3a6e0fc..25986bf 100644 --- a/src/classes/syncer/BambooManager.ts +++ b/src/classes/syncer/BambooManager.ts @@ -1,6 +1,6 @@ import * as vscode from 'vscode'; -import { BambooConfig, CredentialType, CustomControlMapping } from './BambooConfig'; -import path from 'path'; +import { BambooConfig, CredentialType, CustomControlMapping, PluginPackageMapping } from './BambooConfig'; +import path, { relative } from 'path'; import { IWebResource } from '../../dataverse/IWebResource'; import { logErrorMessage, logMessage, logMessageWithProgress, logTemporaryMessage, VerboseSetting } from '../../log/message'; import { DataverseClient } from '../../dataverse/DataverseClient'; @@ -113,6 +113,7 @@ export class BambooManager { solutionUniqueName: rawData.solutionUniqueName, webResources: rawData.webResources, customControls: rawData.customControls, + pluginPackages: rawData.pluginPackages, credential: { ...rawData.credential, type: credentialTypeMap[rawData.credential.type] ?? CredentialType.ClientSecret, @@ -341,4 +342,37 @@ export class BambooManager { } } + public async updatePluginPackage( + currentWorkspace: vscode.WorkspaceFolder, + pluginPackage: PluginPackageMapping + ): Promise { + const config = await this.getConfig(); + if (!config) { + return; + } + + const token = await this.getToken(); + if (token === null) { + return; + } + + const workspaceRoot = currentWorkspace.uri.fsPath; + + const fullPath = path.join(workspaceRoot, pluginPackage.relativePathOnDiskToNugetPackage); + + const normalizedPath = path.normalize(fullPath).replace(/\\/g, "/"); + + const [success, errorMessage] = await this.client.registerPluginPackage( + pluginPackage.pluginPackageName, + normalizedPath, + token, + ); + + if (success) { + logTemporaryMessage(`Synced plugin package: ${pluginPackage.pluginPackageName}.`, VerboseSetting.Low); + } else { + logErrorMessage(errorMessage!, VerboseSetting.Low); + } + } + } diff --git a/src/dataverse/DataverseClient.ts b/src/dataverse/DataverseClient.ts index f2ab6d5..285a62f 100644 --- a/src/dataverse/DataverseClient.ts +++ b/src/dataverse/DataverseClient.ts @@ -8,6 +8,8 @@ import { ISolution } from "./ISolution"; import { logErrorMessage, logMessage, logMessageWithProgress, logTemporaryMessage, VerboseSetting } from "../log/message"; import { BambooConfig } from "../classes/syncer/BambooConfig"; import { ICustomControl } from "./ICustomControl"; +import AdmZip from "adm-zip"; +import { parseStringPromise } from "xml2js"; import * as crypto from 'crypto'; export class DataverseClient { @@ -17,6 +19,8 @@ export class DataverseClient { private publishApi: string; private publishAllApi: string; private importSolutionApi: string; + private pluginPackagesApi: string; + private pluginPackagesExportKeyApi: string; constructor(private config: BambooConfig) { this.webResourcesApi = `${this.config.baseUrl}/api/data/v9.2/webresourceset`; @@ -25,6 +29,8 @@ export class DataverseClient { this.publishApi = `${this.config.baseUrl}/api/data/v9.2/PublishXml`; this.publishAllApi = `${this.config.baseUrl}/api/data/v9.0/PublishAllXml`; this.importSolutionApi = `${this.config.baseUrl}/api/data/v9.0/ImportSolution`; + this.pluginPackagesApi = `${this.config.baseUrl}/api/data/v9.2/pluginpackages`; + this.pluginPackagesExportKeyApi = `${this.config.baseUrl}/api/data/v9.2/UpdatePluginTypeExportKey`; } public async syncSolution(solutionName: string, solutionPath: string, token: string): Promise<[boolean, string | null]> { @@ -304,7 +310,7 @@ export class DataverseClient { }); if (!response.ok) { - const data = await response.json(); + const data = await response.json(); console.log(data); return [false, `Failed to publish all customizations: ${response.statusText}`]; } @@ -449,6 +455,166 @@ export class DataverseClient { return [true, null]; } + /** + * Registers (create or update) a plugin package in D365. + * @param filePath Path to the .nupkg file + * @param token OAuth access token + * @param solutionUniqueName (Optional) solution to add the package to + */ + public async registerPluginPackage( + pluginPackageName: string, + filePath: string, + token: string, + ): Promise<[boolean, string | null]> { + try { + const { id, version } = await this.analyzeNupkg(filePath); + if (!id || !version) throw new Error("Could not read .nuspec metadata"); + + const content = (await fs.readFile(filePath)).toString("base64"); + const name = id; + const uniquename = id; + + const existing = await this.findPluginPackage(pluginPackageName, token); + + if (existing) { + await logMessageWithProgress(`Updating existing plugin package: ${name}`, () => { + return this.updatePluginPackage(existing.pluginpackageid, content, token); + }); + + await logMessageWithProgress(`Refreshing types for plugin package: ${name}`, () => { + return this.refreshAllPluginTypesForPackage(existing.pluginpackageid, token); + }); + + } else { + return [false, `Package: ${name} is not found. Creating a plugin package is not implemented.`]; + } + + return [true, null]; + } catch (err: any) { + console.error("Error registering plugin package:", err); + return [false, err.message]; + } + } + + private async analyzeNupkg(filePath: string): Promise<{ id: string; version: string }> { + try { + await fs.access(filePath); + } catch { + throw new Error(`File not found: ${filePath}`); + } + + const buffer = await fs.readFile(filePath); + const zip = new AdmZip(buffer); + const nuspecEntry = zip.getEntries().find(e => e.entryName.endsWith(".nuspec")); + if (!nuspecEntry) throw new Error("Could not find .nuspec in package"); + + const xmlContent = nuspecEntry.getData().toString("utf-8"); + const parsed = await parseStringPromise(xmlContent); + const metadata = parsed.package?.metadata?.[0]; + const id = metadata?.id?.[0]; + const version = metadata?.version?.[0]; + + return { id, version }; + } + + private async findPluginPackage(name: string, token: string): Promise { + const query = `${this.pluginPackagesApi}?$select=pluginpackageid,name&$filter=name eq '${name}'`; + //@ts-expect-error cause i said so + const res = await fetch(query, { + headers: { Authorization: `Bearer ${token}`, Accept: "application/json" }, + }); + if (!res.ok) throw new Error(`Failed to query pluginpackage: ${res.statusText}`); + const data = await res.json(); + return data.value?.[0] ?? null; + } + + private async updatePluginPackage(id: string, content: string, token: string): Promise { + //@ts-expect-error cause i said so + const res = await fetch(`${this.pluginPackagesApi}(${id})`, { + method: "PATCH", + headers: { + Authorization: `Bearer ${token}`, + "Content-Type": "application/json", + Accept: "application/json", + }, + body: JSON.stringify({ content }), + }); + + if (!res.ok) { + const err = await res.text(); + logErrorMessage(`Failed to update pluginpackage: ${res.statusText} ${err}`, VerboseSetting.High); + } + } + + private async refreshAllPluginTypesForPackage( + pluginPackageId: string, + token: string + ): Promise { + const apiBase = `${this.config.baseUrl}/api/data/v9.0`; + + const fetchXml = ` + + + + + + + + + + + + `; + + const url = `${apiBase}/plugintypes?fetchXml=${encodeURIComponent(fetchXml)}`; + + //@ts-expect-error cause i said so + const typeRes = await fetch(url, { + headers: { + Authorization: `Bearer ${token}`, + Accept: "application/json", + "OData-Version": "4.0", + "OData-MaxVersion": "4.0", + }, + }); + + if (!typeRes.ok) { + const err = await typeRes.text(); + logErrorMessage(`Failed to retrieve plugintypes: ${typeRes.status} ${typeRes.statusText}\n${err}`, VerboseSetting.High); + } + + const { value: pluginTypes } = await typeRes.json(); + if (!pluginTypes || pluginTypes.length === 0) { + logErrorMessage("No plugintypes found for this package.", VerboseSetting.High); + return; + } + + for (const pluginType of pluginTypes) { + const refreshUrl = `${apiBase}/plugintypes(${pluginType.plugintypeid})/Microsoft.Dynamics.CRM.UpdatePluginTypeExportKey`; + + //@ts-expect-error cause i said so + const res = await fetch(refreshUrl, { + method: "POST", + headers: { + Authorization: `Bearer ${token}`, + "Content-Type": "application/json; charset=utf-8", + Accept: "application/json", + "OData-Version": "4.0", + "OData-MaxVersion": "4.0", + }, + body: JSON.stringify({}), // empty body + }); + + if (!res.ok) { + const errText = await res.text(); + //Even a fail response is a success. Go figure + logMessage('Even though the .', VerboseSetting.High); + } else { + logMessage('Success? This should never hit.', VerboseSetting.High); + } + } + } + public async getOAuthToken(): Promise { const cachedToken = await this.loadCachedToken(); if (cachedToken && cachedToken.expires_at > Date.now() + 60_000) { diff --git a/src/extension.ts b/src/extension.ts index f126412..7a2b284 100644 --- a/src/extension.ts +++ b/src/extension.ts @@ -111,6 +111,37 @@ export async function activate(context: vscode.ExtensionContext) { } }); + vscode.commands.registerCommand('bamboo.syncPluginPackage', async () => { + const currentWorkspaceFolders = vscode.workspace.workspaceFolders; + if (currentWorkspaceFolders === undefined || currentWorkspaceFolders?.length > 1) { + logErrorMessage(`Either no workspace is open - or too many are! Please open only one workspace in order to use Bamboo`, VerboseSetting.High); + return; + } + + const currentWorkspacePath = currentWorkspaceFolders![0]; + + const config = await bambooManager.getConfig(); + + if (config === null) { + return; + } + + const items: vscode.QuickPickItem[] = config.pluginPackages.map(c => { + return { label: c.pluginPackageName, description: c.relativePathOnDiskToNugetPackage }; + }); + + const selected = await vscode.window.showQuickPick(items, { + placeHolder: 'Select a Plugin Package...', + canPickMany: false + }); + + if (selected) { + const selectedPluginPackages = config.pluginPackages.filter(c => c.pluginPackageName === selected.label)![0]; + + await bambooManager.updatePluginPackage(currentWorkspacePath, selectedPluginPackages); + } + }); + logMessage(`Bamboo initialized successfully.`, VerboseSetting.High) }