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)))