diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000000..5fade1f402 --- /dev/null +++ b/.gitignore @@ -0,0 +1,8 @@ +/tools/paconn-cli/paconn/__pycache__ +/tools/paconn-cli/paconn/apimanager/__pycache__ +/tools/paconn-cli/paconn/authentication/__pycache__ +/tools/paconn-cli/paconn/commands/__pycache__ +/tools/paconn-cli/paconn/common/__pycache__ +/tools/paconn-cli/paconn/operations/__pycache__ +/tools/paconn-cli/paconn/settings/__pycache__ +tools/paconn-cli/PowerPlatformSolutionPackager.psm1 diff --git a/tools/paconn-cli/README.md b/tools/paconn-cli/README.md index 4ebc39707f..9facf37ff8 100644 --- a/tools/paconn-cli/README.md +++ b/tools/paconn-cli/README.md @@ -266,6 +266,73 @@ Arguments line parameters are ignored. ``` +### Package Power Platform Solution Components + +The package operation creates a structured Power Platform solution package from component ZIP files. This is useful for creating distributable packages that contain multiple Power Platform components (connectors, flows, AI plugins) in a standardized format. + +Package solution components by running: + +`paconn package` + +or + +`paconn package --source [Path to directory containing ZIP files]` + +or + +`paconn package --source [Path to directory] --custom-mappings '{"*MyConnector*": "Connector.zip", "*MyFlow*": "Flow.zip"}'` + +The packaging process follows these steps: + +1. **Rename ZIP files** according to Power Platform conventions: + - Files containing "Connector" → "Connector.zip" (required) + - Files containing "Flow" → "Flow.zip" (required) + - Files containing "AIPlugin" → "AIPlugin.zip" (optional) + +2. **Move all ZIP files** to a "PkgAssets" folder + +3. **Create intro.md** from "readme.md" (or first available .md file) + +4. **Compress PkgAssets** folder into "package.zip" + +5. **Create final ConnectorPackage.zip** containing intro.md and package.zip + +6. **Clean up intermediate files** and folders + +The final ConnectorPackage.zip is ready for distribution and deployment to Power Platform environments. + +``` +Arguments + --source -src : Source directory containing the Power Platform solution ZIP + files to package. Defaults to current directory. + --dest -d : Destination path for the final ConnectorPackage.zip file. + Defaults to current directory. + --format -f : Package format. Currently only "standard" format is supported + (ConnectorPackage.zip with intro.md and package.zip). + --custom-mappings -cm : JSON string containing custom file renaming mappings. + Example: '{"*MyConnector*": "Connector.zip", "*MyFlow*": "Flow.zip"}'. + --overwrite -w : Overwrite existing package files if they exist. + --settings -s : A settings file containing required parameters. + When a settings file is specified some command + line parameters are ignored. +``` + +**Package Examples:** + +```bash +# Package solution components in current directory +paconn package + +# Package components from specific source directory +paconn package --source ./my-solution + +# Package with custom file mappings +paconn package --custom-mappings '{"*MyConnector*": "Connector.zip", "*MyFlow*": "Flow.zip"}' + +# Package and overwrite existing files +paconn package --overwrite +``` + ### Best Practice diff --git a/tools/paconn-cli/paconn.pyproj b/tools/paconn-cli/paconn.pyproj index f8b9ef438e..95c0cc6467 100644 --- a/tools/paconn-cli/paconn.pyproj +++ b/tools/paconn-cli/paconn.pyproj @@ -32,9 +32,11 @@ + + @@ -45,6 +47,7 @@ + diff --git a/tools/paconn-cli/paconn/__init__.py b/tools/paconn-cli/paconn/__init__.py index 929afb2b50..7ae50c7050 100644 --- a/tools/paconn-cli/paconn/__init__.py +++ b/tools/paconn-cli/paconn/__init__.py @@ -19,3 +19,4 @@ _CREATE = 'create' _UPDATE = 'update' _VALIDATE = 'validate' +_PACKAGE = 'package' diff --git a/tools/paconn-cli/paconn/commands/commands.py b/tools/paconn-cli/paconn/commands/commands.py index b4451cc2a1..baaec8ca66 100644 --- a/tools/paconn-cli/paconn/commands/commands.py +++ b/tools/paconn-cli/paconn/commands/commands.py @@ -11,7 +11,7 @@ from knack.commands import CommandGroup from paconn import __CLI_NAME__ -from paconn import _COMMAND_GROUP, _LOGIN, _LOGOUT, _DOWNLOAD, _CREATE, _UPDATE, _VALIDATE +from paconn import _COMMAND_GROUP, _LOGIN, _LOGOUT, _DOWNLOAD, _CREATE, _UPDATE, _VALIDATE, _PACKAGE # pylint: disable=unused-argument @@ -39,3 +39,6 @@ def operation_group(name): with CommandGroup(self, _COMMAND_GROUP, operation_group(_VALIDATE)) as command_group: command_group.command(_VALIDATE, _VALIDATE) + + with CommandGroup(self, _COMMAND_GROUP, operation_group(_PACKAGE)) as command_group: + command_group.command(_PACKAGE, _PACKAGE) diff --git a/tools/paconn-cli/paconn/commands/help.py b/tools/paconn-cli/paconn/commands/help.py index 47820acc10..f5f99379d2 100644 --- a/tools/paconn-cli/paconn/commands/help.py +++ b/tools/paconn-cli/paconn/commands/help.py @@ -9,7 +9,7 @@ """ from knack.help_files import helps # pylint: disable=unused-import -from paconn import _COMMAND_GROUP, _LOGIN, _DOWNLOAD, _CREATE, _UPDATE, _VALIDATE +from paconn import _COMMAND_GROUP, _LOGIN, _DOWNLOAD, _CREATE, _UPDATE, _VALIDATE, _PACKAGE helps[_COMMAND_GROUP] = """ short-summary: Microsoft Power Platform Connectors CLI @@ -55,3 +55,34 @@ - name: Validate swagger text: paconn validate """ + +helps[_PACKAGE] = """ + type: command + short-summary: Package Power Platform solution components into a distributable format. + long-summary: | + Creates a structured Power Platform solution package from component ZIP files. + + The packaging process: + 1. Renames ZIP files according to Power Platform conventions: + - Files containing "Connector" → "Connector.zip" (required) + - Files containing "Flow" → "Flow.zip" (required) + - Files containing "AIPlugin" → "AIPlugin.zip" (optional) + 2. Moves all ZIP files to a "PkgAssets" folder + 3. Creates "intro.md" from "readme.md" (or first available .md file) + 4. Compresses PkgAssets folder into "package.zip" + 5. Creates final "ConnectorPackage.zip" containing intro.md and package.zip + 6. Cleans up intermediate files + + The final ConnectorPackage.zip is ready for distribution and deployment. + examples: + - name: Package solution components in current directory + text: paconn package + - name: Package components from specific source directory + text: paconn package --source ./my-solution + - name: Package with custom file mappings + text: > + paconn package --custom-mappings + '{"*MyConnector*": "Connector.zip", "*MyFlow*": "Flow.zip"}' + - name: Package and overwrite existing files + text: paconn package --overwrite +""" diff --git a/tools/paconn-cli/paconn/commands/package.py b/tools/paconn-cli/paconn/commands/package.py new file mode 100644 index 0000000000..a9136ee891 --- /dev/null +++ b/tools/paconn-cli/paconn/commands/package.py @@ -0,0 +1,72 @@ +# ----------------------------------------------------------------------------- +# Copyright (c) 2025 Troy Taylor (troy@troystaylor.com). All rights reserved. +# Licensed under the MIT License. See License.txt in the project root for +# license information. +# +# Permission is hereby granted to Microsoft Corporation and any other party +# to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +# copies of this software under the terms of the MIT License. +# ----------------------------------------------------------------------------- +""" +Package command - Creates a Power Platform solution package from component files. +""" + +from paconn import _PACKAGE + +from paconn.common.util import display +from paconn.settings.settingsbuilder import SettingsBuilder + +import paconn.operations.package + + +# pylint: disable=too-many-arguments +def package( + source, + destination, + package_format, + settings_file, + overwrite, + custom_mappings=None): + """ + Package command - Creates a Power Platform solution package. + + Processes Power Platform solution zip files in the specified directory: + - Files containing "Connector" are renamed to "Connector.zip" (required) + - Files containing "Flow" are renamed to "Flow.zip" (required) + - Files containing "AIPlugin" are renamed to "AIPlugin.zip" (optional) + After renaming, all zip files are moved to a new "PkgAssets" folder. + The readme.md file (or first available .md file) is copied to intro.md. + The PkgAssets folder is compressed into a "package.zip" file. + Finally, a "ConnectorPackage.zip" is created containing intro.md and package.zip. + """ + # Parse custom mappings if provided + parsed_custom_mappings = None + if custom_mappings: + try: + import json + parsed_custom_mappings = json.loads(custom_mappings) + except json.JSONDecodeError: + from knack.util import CLIError + raise CLIError('Invalid JSON format for --custom-mappings parameter.') + + # Get settings (minimal settings needed for this operation) + settings = SettingsBuilder.get_settings( + environment=None, + settings_file=settings_file, + api_properties=None, + api_definition=None, + icon=None, + script=None, + connector_id=None, + powerapps_url=None, + powerapps_version=None) + + package_path = paconn.operations.package.package( + source=source, + destination=destination, + package_format=package_format, + settings=settings, + overwrite=overwrite, + custom_mappings=parsed_custom_mappings) + + display('Power Platform solution package created: {}'.format(package_path)) diff --git a/tools/paconn-cli/paconn/commands/params.py b/tools/paconn-cli/paconn/commands/params.py index cae118100b..66093360b6 100644 --- a/tools/paconn-cli/paconn/commands/params.py +++ b/tools/paconn-cli/paconn/commands/params.py @@ -9,7 +9,7 @@ """ from knack.arguments import ArgumentsContext -from paconn import _LOGIN, _DOWNLOAD, _CREATE, _UPDATE, _VALIDATE +from paconn import _LOGIN, _DOWNLOAD, _CREATE, _UPDATE, _VALIDATE, _PACKAGE CLIENT_SECRET = 'client_secret' CLIENT_SECRET_OPTIONS = ['--secret', '-r'] @@ -272,28 +272,51 @@ def load_arguments(self, command): required=False, help=SETTINGS_HELP) - with ArgumentsContext(self, _VALIDATE) as arg_context: arg_context.argument( - API_DEFINITION, - options_list=API_DEFINITION_OPTIONS, + SETTINGS, + options_list=SETTINGS_OPTIONS, type=str, required=False, - help=API_DEFINITION_HELP) + help=SETTINGS_HELP) + + with ArgumentsContext(self, _PACKAGE) as arg_context: arg_context.argument( - POWERAPPS_URL, - options_list=POWERAPPS_URL_OPTIONS, + 'source', + options_list=['--source', '-src'], type=str, required=False, - help=POWERAPPS_URL_HELP) + help='Source directory containing the Power Platform solution ZIP files to package. Defaults to current directory.') arg_context.argument( - POWERAPPS_VERSION, - options_list=POWERAPPS_VERSION_OPTIONS, + 'destination', + options_list=['--dest', '-d'], type=str, required=False, - help=POWERAPPS_VERSION_HELP) + help='Destination path for the final ConnectorPackage.zip file. Defaults to current directory.') + arg_context.argument( + 'package_format', + options_list=['--format', '-f'], + type=str, + required=False, + choices=['standard'], + help='Package format. Currently only "standard" format is supported (ConnectorPackage.zip with intro.md and package.zip).') arg_context.argument( SETTINGS, options_list=SETTINGS_OPTIONS, type=str, required=False, help=SETTINGS_HELP) + arg_context.argument( + 'overwrite', + options_list=['--overwrite', '-w'], + type=bool, + required=False, + nargs='?', + default=False, + const=True, + help='Overwrite existing package files if they exist.') + arg_context.argument( + 'custom_mappings', + options_list=['--custom-mappings', '-cm'], + type=str, + required=False, + help='JSON string containing custom file renaming mappings. Example: \'{"*MyConnector*": "Connector.zip", "*MyFlow*": "Flow.zip"}\'.') diff --git a/tools/paconn-cli/paconn/operations/package.py b/tools/paconn-cli/paconn/operations/package.py new file mode 100644 index 0000000000..b39cf18dfc --- /dev/null +++ b/tools/paconn-cli/paconn/operations/package.py @@ -0,0 +1,316 @@ +# ----------------------------------------------------------------------------- +# Copyright (c) 2025 Troy Taylor (troy@troystaylor.com). All rights reserved. +# Licensed under the MIT License. See License.txt in the project root for +# license information. +# +# Permission is hereby granted to Microsoft Corporation and any other party +# to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +# copies of this software under the terms of the MIT License. +# ----------------------------------------------------------------------------- +""" +Package operation - Creates a Power Platform solution package from component files. +""" + +import os +import json +import zipfile +import shutil +import glob +from datetime import datetime + +from knack.util import CLIError +from knack.prompting import prompt_y_n + +from paconn.common.util import format_json +from paconn.settings.util import SETTINGS_FILE + + +def _validate_source_directory(source_path): + """ + Validate that the source directory exists and contains zip files. + """ + if not os.path.isdir(source_path): + raise CLIError('Source directory {} does not exist.'.format(source_path)) + + # Check for zip files + zip_files = glob.glob(os.path.join(source_path, "*.zip")) + + if not zip_files: + raise CLIError( + 'Source directory {} does not contain any ZIP files to package.'.format(source_path)) + + return True + + +def _get_default_mappings(): + """ + Get default file mappings for Power Platform solution types. + """ + return { + "*Connector*": "Connector.zip", + "*Flow*": "Flow.zip", + "*AIPlugin*": "AIPlugin.zip" + } + + +def _match_pattern(filename, pattern): + """ + Check if filename matches the wildcard pattern. + """ + import fnmatch + return fnmatch.fnmatch(filename, pattern) + + +def _rename_solution_files(source_path, custom_mappings=None): + """ + Rename zip files according to Power Platform solution naming conventions. + """ + all_mappings = _get_default_mappings() + + # Merge custom mappings if provided + if custom_mappings: + all_mappings.update(custom_mappings) + + zip_files = glob.glob(os.path.join(source_path, "*.zip")) + renamed_count = 0 + + for file_path in zip_files: + filename = os.path.basename(file_path) + new_name = None + matched_pattern = None + + # Check each mapping pattern + for pattern, target_name in all_mappings.items(): + if _match_pattern(filename, pattern): + new_name = target_name + matched_pattern = pattern + break + + if new_name and filename != new_name: + new_path = os.path.join(source_path, new_name) + + # Check if target file already exists + if os.path.exists(new_path): + print("Warning: Target file '{}' already exists. Skipping '{}'.".format(new_name, filename)) + continue + + os.rename(file_path, new_path) + print("✓ Renamed '{}' to '{}' (matched: {})".format(filename, new_name, matched_pattern)) + renamed_count += 1 + else: + print("○ No matching pattern found for '{}'".format(filename)) + + return renamed_count + + +def _create_pkg_assets_folder(source_path): + """ + Create PkgAssets folder and move all zip files into it. + """ + pkg_assets_path = os.path.join(source_path, "PkgAssets") + + if not os.path.exists(pkg_assets_path): + os.makedirs(pkg_assets_path) + print("Created 'PkgAssets' folder.") + + # Move all zip files to PkgAssets + zip_files = glob.glob(os.path.join(source_path, "*.zip")) + moved_count = 0 + + for zip_file in zip_files: + filename = os.path.basename(zip_file) + destination_path = os.path.join(pkg_assets_path, filename) + + # Check if file already exists in destination + if os.path.exists(destination_path): + print("Warning: File '{}' already exists in PkgAssets folder. Skipping move.".format(filename)) + continue + + shutil.move(zip_file, destination_path) + print("→ Moved '{}' to PkgAssets folder".format(filename)) + moved_count += 1 + + return pkg_assets_path, moved_count + + +def _create_intro_file(source_path): + """ + Copy readme.md to intro.md, or use first available .md file. + """ + readme_path = os.path.join(source_path, "readme.md") + intro_path = os.path.join(source_path, "intro.md") + + if os.path.exists(readme_path): + shutil.copy2(readme_path, intro_path) + print("✓ Copied readme.md to intro.md") + return intro_path + else: + # Look for any .md file as fallback + md_files = [f for f in glob.glob(os.path.join(source_path, "*.md")) + if os.path.basename(f).lower() != "intro.md"] + + if md_files: + source_md_file = md_files[0] + shutil.copy2(source_md_file, intro_path) + print("✓ Copied '{}' to intro.md (readme.md not found)".format(os.path.basename(source_md_file))) + return intro_path + else: + print("Warning: No .md files found to copy to intro.md") + return None + + +def _create_package_zip(source_path, pkg_assets_path): + """ + Create package.zip from PkgAssets folder contents. + """ + package_zip_path = os.path.join(source_path, "package.zip") + + # Remove existing package.zip if it exists + if os.path.exists(package_zip_path): + os.remove(package_zip_path) + print("Removed existing package.zip file.") + + # Create package.zip containing the entire PkgAssets folder + if os.path.exists(pkg_assets_path): + with zipfile.ZipFile(package_zip_path, 'w', zipfile.ZIP_DEFLATED) as zip_file: + # Add the PkgAssets folder and all its contents + for root, dirs, files in os.walk(pkg_assets_path): + for file in files: + file_path = os.path.join(root, file) + # Create archive path that includes PkgAssets folder + arcname = os.path.relpath(file_path, source_path) + zip_file.write(file_path, arcname) + + print("✓ Created package.zip containing PkgAssets folder contents.") + + # Get package size info + package_size = os.path.getsize(package_zip_path) + package_size_kb = round(package_size / 1024, 2) + print("Package size: {} KB".format(package_size_kb)) + + return package_zip_path + else: + print("Warning: PkgAssets folder not found. Cannot create package.zip.") + return None + + +def _create_connector_package(source_path, intro_path, package_zip_path): + """ + Create ConnectorPackage.zip containing intro.md and package.zip. + """ + connector_package_path = os.path.join(source_path, "ConnectorPackage.zip") + + # Remove existing ConnectorPackage.zip if it exists + if os.path.exists(connector_package_path): + os.remove(connector_package_path) + print("Removed existing ConnectorPackage.zip file.") + + # Collect files to include + files_to_include = [] + + if intro_path and os.path.exists(intro_path): + files_to_include.append(intro_path) + else: + print("Warning: intro.md not found. ConnectorPackage.zip will not include intro.md.") + + if package_zip_path and os.path.exists(package_zip_path): + files_to_include.append(package_zip_path) + else: + print("Warning: package.zip not found. ConnectorPackage.zip will not include package.zip.") + + if files_to_include: + with zipfile.ZipFile(connector_package_path, 'w', zipfile.ZIP_DEFLATED) as zip_file: + for file_path in files_to_include: + filename = os.path.basename(file_path) + zip_file.write(file_path, filename) + + print("✓ Created ConnectorPackage.zip containing:") + for file_path in files_to_include: + filename = os.path.basename(file_path) + print(" - {}".format(filename)) + + # Get connector package size info + connector_package_size = os.path.getsize(connector_package_path) + connector_package_size_kb = round(connector_package_size / 1024, 2) + print("ConnectorPackage size: {} KB".format(connector_package_size_kb)) + + return connector_package_path + else: + print("Warning: No files available to create ConnectorPackage.zip.") + return None + + +def _cleanup_intermediate_files(source_path, pkg_assets_path, intro_path, package_zip_path): + """ + Clean up intermediate files and folders. + """ + print("\nCleaning up intermediate files...") + + # Remove PkgAssets folder + if pkg_assets_path and os.path.exists(pkg_assets_path): + shutil.rmtree(pkg_assets_path) + print("✓ Removed PkgAssets folder") + + # Remove intro.md file + if intro_path and os.path.exists(intro_path): + os.remove(intro_path) + print("✓ Removed intro.md file") + + # Remove package.zip file + if package_zip_path and os.path.exists(package_zip_path): + os.remove(package_zip_path) + print("✓ Removed package.zip file") + + print("✓ Cleanup completed!") + + +def package(source, destination, package_format, settings, overwrite, custom_mappings=None): + """ + Package operation - Creates a Power Platform solution package. + + This function: + 1. Renames zip files according to Power Platform conventions (Connector.zip, Flow.zip, AIPlugin.zip) + 2. Moves all zip files to a PkgAssets folder + 3. Creates intro.md from readme.md (or first .md file) + 4. Compresses PkgAssets into package.zip + 5. Creates final ConnectorPackage.zip with intro.md and package.zip + 6. Cleans up intermediate files + """ + # Use current directory as source if not specified + source_path = source if source else os.getcwd() + source_path = os.path.abspath(source_path) + + # Validate source directory + _validate_source_directory(source_path) + + print("Found {} zip file(s) in '{}'".format( + len(glob.glob(os.path.join(source_path, "*.zip"))), source_path)) + + try: + # Step 1: Rename solution files according to conventions + renamed_count = _rename_solution_files(source_path, custom_mappings) + print("\nCompleted: {} file(s) renamed successfully.".format(renamed_count)) + + # Step 2: Create PkgAssets folder and move zip files + pkg_assets_path, moved_count = _create_pkg_assets_folder(source_path) + print("\nMoved {} zip file(s) to PkgAssets folder.".format(moved_count)) + + # Step 3: Create intro.md from readme.md or first .md file + intro_path = _create_intro_file(source_path) + + # Step 4: Create package.zip from PkgAssets folder + package_zip_path = _create_package_zip(source_path, pkg_assets_path) + + # Step 5: Create ConnectorPackage.zip with intro.md and package.zip + connector_package_path = _create_connector_package(source_path, intro_path, package_zip_path) + + # Step 6: Clean up intermediate files + if connector_package_path: + _cleanup_intermediate_files(source_path, pkg_assets_path, intro_path, package_zip_path) + print("✓ ConnectorPackage.zip is ready!") + return connector_package_path + else: + raise CLIError("Failed to create ConnectorPackage.zip") + + except Exception as e: + raise CLIError("An error occurred while packaging: {}".format(str(e)))