{"id":23891323,"url":"https://github.com/mcarvin8/sf-decomposer","last_synced_at":"2026-05-04T17:01:59.086Z","repository":{"id":219188422,"uuid":"748402999","full_name":"mcarvin8/sf-decomposer","owner":"mcarvin8","description":"Split large Salesforce metadata files into version-control-friendly pieces and rebuild deployment-ready files.","archived":false,"fork":false,"pushed_at":"2026-04-30T13:45:02.000Z","size":4218,"stargazers_count":21,"open_issues_count":1,"forks_count":3,"subscribers_count":0,"default_branch":"main","last_synced_at":"2026-04-30T14:22:57.789Z","etag":null,"topics":[],"latest_commit_sha":null,"homepage":"","language":"TypeScript","has_issues":true,"has_wiki":null,"has_pages":null,"mirror_url":null,"source_name":null,"license":"mit","status":null,"scm":"git","pull_requests_enabled":true,"icon_url":"https://github.com/mcarvin8.png","metadata":{"files":{"readme":"README.md","changelog":"CHANGELOG.md","contributing":"CONTRIBUTING.md","funding":null,"license":"LICENSE.md","code_of_conduct":null,"threat_model":null,"audit":null,"citation":null,"codeowners":null,"security":null,"support":null,"governance":null,"roadmap":null,"authors":null,"dei":null,"publiccode":null,"codemeta":null,"zenodo":null,"notice":null,"maintainers":null,"copyright":null,"agents":null,"dco":null,"cla":null}},"created_at":"2024-01-25T22:23:19.000Z","updated_at":"2026-04-30T13:45:04.000Z","dependencies_parsed_at":"2024-02-02T04:29:00.875Z","dependency_job_id":"c9d22306-78f0-4f41-81d4-a558f8ca1f14","html_url":"https://github.com/mcarvin8/sf-decomposer","commit_stats":null,"previous_names":["mcarvin8/sfdx-decomposer-plugin","mcarvin8/sf-decomposer"],"tags_count":148,"template":false,"template_full_name":null,"purl":"pkg:github/mcarvin8/sf-decomposer","repository_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/repositories/mcarvin8%2Fsf-decomposer","tags_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/repositories/mcarvin8%2Fsf-decomposer/tags","releases_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/repositories/mcarvin8%2Fsf-decomposer/releases","manifests_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/repositories/mcarvin8%2Fsf-decomposer/manifests","owner_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/owners/mcarvin8","download_url":"https://codeload.github.com/mcarvin8/sf-decomposer/tar.gz/refs/heads/main","sbom_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/repositories/mcarvin8%2Fsf-decomposer/sbom","scorecard":null,"host":{"name":"GitHub","url":"https://github.com","kind":"github","repositories_count":286080680,"owners_count":32616270,"icon_url":"https://github.com/github.png","version":null,"created_at":"2022-05-30T11:31:42.601Z","updated_at":"2026-05-04T10:08:07.713Z","status":"ssl_error","status_checked_at":"2026-05-04T10:08:02.005Z","response_time":58,"last_error":"SSL_read: unexpected eof while reading","robots_txt_status":"success","robots_txt_updated_at":"2025-07-24T06:49:26.215Z","robots_txt_url":"https://github.com/robots.txt","online":false,"can_crawl_api":true,"host_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub","repositories_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/repositories","repository_names_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/repository_names","owners_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/owners"}},"keywords":["continuous-integration","decomposition","deployments","devops","metadata","salesforce","version-control"],"created_at":"2025-01-04T12:03:40.283Z","updated_at":"2026-05-04T17:01:59.079Z","avatar_url":"https://github.com/mcarvin8.png","language":"TypeScript","funding_links":[],"categories":[],"sub_categories":[],"readme":"# sf-decomposer\n\n[![NPM](https://img.shields.io/npm/v/sf-decomposer.svg?label=sf-decomposer)](https://www.npmjs.com/package/sf-decomposer)\n[![Downloads/week](https://img.shields.io/npm/dw/sf-decomposer.svg)](https://npmjs.org/package/sf-decomposer)\n[![License](https://img.shields.io/badge/License-MIT-yellow.svg)](https://raw.githubusercontent.com/mcarvin8/sf-decomposer/main/LICENSE.md)\n[![Maintainability](https://qlty.sh/badges/8492c1c6-0f93-4d37-bfad-32fd3b788a2d/maintainability.svg)](https://qlty.sh/gh/mcarvin8/projects/sf-decomposer)\n[![codecov](https://codecov.io/gh/mcarvin8/sf-decomposer/graph/badge.svg?token=YFU52L4XM5)](https://codecov.io/gh/mcarvin8/sf-decomposer)\n[![Performance](https://img.shields.io/badge/Performance-Dashboard-58a6ff)](https://mcarvin8.github.io/sf-decomposer/dev/bench/runtime/)\n\nA Salesforce CLI plugin that **decomposes** large metadata XML files into smaller, version-control–friendly files (XML, JSON, YAML, JSON5), and **recomposes** them back into deployment-ready metadata.\n\n\u003c!-- TABLE OF CONTENTS --\u003e\n\u003cdetails\u003e\n  \u003csummary\u003eTable of Contents\u003c/summary\u003e\n\n- [Quick Start](#quick-start)\n- [Why sf-decomposer?](#why-sf-decomposer)\n- [Commands](#commands)\n  - [sf decomposer decompose](#sf-decomposer-decompose)\n  - [sf decomposer recompose](#sf-decomposer-recompose)\n  - [sf decomposer verify](#sf-decomposer-verify)\n- [Manifest-scoped runs](#manifest-scoped-runs)\n- [Decompose Strategies](#decompose-strategies)\n  - [Custom Labels](#custom-labels-decomposition)\n  - [Permission Sets (grouped-by-tag)](#additional-permission-set-decomposition)\n  - [Loyalty Program Setup](#loyalty-program-setup-decomposition)\n- [Supported Metadata](#supported-metadata)\n  - [Exceptions](#exceptions)\n- [Troubleshooting](#troubleshooting)\n- [Hooks](#hooks)\n- [Per-Type \u0026 Per-Component Overrides](#per-type--per-component-overrides)\n  - [splitTags grammar](#splittags-grammar)\n  - [multiLevel grammar](#multilevel-grammar)\n- [Ignore Files](#ignore-files)\n  - [.forceignore](#forceignore)\n  - [.sfdecomposerignore](#sfdecomposerignore)\n  - [.gitignore](#gitignore)\n- [Issues](#issues)\n- [Requirements](#requirements)\n- [Built With](#built-with)\n- [Contributing](#contributing)\n- [License](#license)\n\u003c/details\u003e\n\n---\n\n## Quick Start\n\n1. **Install the plugin**\n\n   ```bash\n   sf plugins install sf-decomposer@x.y.z\n   ```\n\n2. **Retrieve metadata** into your Salesforce DX project (e.g. `sf project retrieve start`).\n\n3. **Decompose** the metadata types you need:\n\n   ```bash\n   sf decomposer decompose -m \"flow\" -m \"labels\" --postpurge\n   ```\n\n\u003e Combine steps 2 \u0026 3 by configuring the [hooks](#hooks).\n\n4. **Add decomposed paths to [.forceignore](#forceignore)**  \n   This is **required** so the Salesforce CLI does not treat decomposed files as source. Use the [sample .forceignore](https://raw.githubusercontent.com/mcarvin8/sf-decomposer/main/examples/.forceignore) and adjust extensions for your chosen format (`.xml`, `.json`, `.yaml`, etc.).\n\n5. **Commit** the decomposed files to version control.\n\n6. **Before deploy**, recompose and then deploy:\n\n   ```bash\n   sf decomposer recompose -m \"flow\" -m \"labels\"\n   sf project deploy start\n   ```\n\n   Or scope the recompose to just the components in your deploy manifest:\n\n   ```bash\n   sf decomposer recompose -x \"manifest/package.xml\"\n   sf project deploy start -x \"manifest/package.xml\"\n   ```\n\n   Or run the deploy command directly after configuring the [hooks](#hooks) to run the recompose automatically before deploying.\n\n---\n\n## Requirements\n\nThe [config-disassembler-node](https://github.com/mcarvin8/config-disassembler-node) package, which depends on a Rust crate, ships with native binaries for these platforms:\n\n| Platform    | Architectures                      |\n| ----------- | ---------------------------------- |\n| **macOS**   | x64 (Intel), arm64 (Apple Silicon) |\n| **Linux**   | x64, arm64, ia32                   |\n| **Windows** | x64                                |\n\nIf other platforms or architectures require support, please open an issue in [config-disassembler-node](https://github.com/mcarvin8/config-disassembler-node/issues).\n\n---\n\n## Why sf-decomposer?\n\nSalesforce’s built-in decomposition is limited. sf-decomposer gives admins and developers more control, flexibility, and better versioning.\n\n### Benefits\n\n- **Broader metadata support** – Works with most Metadata API types, not just the subset Salesforce decomposes.\n- **Selective decomposition** – Decompose only what you need; use [.sfdecomposerignore](#sfdecomposerignore) to skip specific files.\n- **Manifest-scoped runs** – Pass `-x package.xml` to decompose or recompose only the components listed in a Salesforce manifest, mirroring `sf project deploy start -x`. Ideal for CI/CD pipelines that only ship a subset of metadata per deployment.\n- **Two [strategies](#decompose-strategies)**:\n  - **unique-id** (default): one file per nested element, named by content or hash.\n  - **grouped-by-tag**: one file per tag (e.g. all `fieldPermissions` in a permission set in `fieldPermissions.xml`). Use `--decompose-nested-permissions` for deeper permission set and muting permission set decomposition.\n- **Full decomposition** – Fully decompose types that Salesforce only partially supports (e.g. permission sets).\n- **Stable ordering** – Elements are sorted consistently to reduce noisy diffs.\n- **Multiple formats** – Output as XML, JSON, JSON5, or YAML.\n- **CI/CD hooks** – Auto decompose after retrieve and recompose before deploy via [.sfdecomposer.config.json](#hooks).\n- **Better reviews** – Smaller, structured files mean clearer pull requests and fewer merge conflicts.\n\n---\n\n## Commands\n\n| Command                   | Description                                                                         |\n| ------------------------- | ----------------------------------------------------------------------------------- |\n| `sf decomposer decompose` | Decompose metadata in package directories into smaller files.                       |\n| `sf decomposer recompose` | Recompose decomposed files back into deployment-ready metadata.                     |\n| `sf decomposer verify`    | Round-trip check: decompose + recompose in a temp directory and diff the originals. |\n\n### sf decomposer decompose\n\nDecomposes metadata in all local package directories (from `sfdx-project.json`) into smaller files.\n\n```\nUSAGE\n  $ sf decomposer decompose [-m \u003cvalue\u003e] [-x \u003cvalue\u003e] [-f \u003cvalue\u003e] [-i \u003cvalue\u003e] [-s \u003cvalue\u003e] [--prepurge --postpurge -p -c --json]\n\nFLAGS\n  -m, --metadata-type=\u003cvalue\u003e             Metadata suffix to process (e.g. flow, labels). Repeatable. Optional when --manifest is provided.\n  -x, --manifest=\u003cvalue\u003e                  Path to a package.xml manifest. When provided, only the components listed in the manifest are decomposed.\n  -f, --format=\u003cvalue\u003e                    Output format: xml | yaml | json | json5 [default: xml]\n  -i, --ignore-package-directory=\u003cvalue\u003e  Package directory to skip (as in sfdx-project.json). Repeatable.\n  -s, --strategy=\u003cvalue\u003e                  unique-id | grouped-by-tag [default: unique-id]\n  --prepurge                              Remove existing decomposed files before decomposing [default: false]\n  --postpurge                             Remove original metadata files after decomposing [default: false]\n  -p, --decompose-nested-permissions      With grouped-by-tag, further decompose permission set and muting permission set object/field permissions\n  -c, --config                            Load per-type and per-component overrides from .sfdecomposer.config.json in the repo root. Only the \"overrides\" array is consumed. See Per-Type \u0026 Per-Component Overrides. [default: false]\n\nGLOBAL FLAGS\n  --json  Output as JSON.\n```\n\n\u003e At least one of `--metadata-type` or `--manifest` is required. When both are supplied, the run is scoped to the intersection of the two.\n\n**Examples**\n\n```bash\n# Decompose flows (XML), purge before/after\nsf decomposer decompose -m \"flow\" -f \"xml\" --prepurge --postpurge\n\n# Decompose flows and labels in YAML\nsf decomposer decompose -m \"flow\" -m \"labels\" -f \"yaml\" --prepurge --postpurge\n\n# Decompose flows, excluding the force-app package\nsf decomposer decompose -m \"flow\" -i \"force-app\"\n\n# Decompose only the components listed in a manifest\nsf decomposer decompose -x \"manifest/package.xml\" --prepurge\n\n# Restrict a manifest run to a single metadata type\nsf decomposer decompose -x \"manifest/package.xml\" -m \"permissionset\"\n```\n\n### sf decomposer recompose\n\nRecomposes decomposed files into deployment-compatible metadata.\n\n```\nUSAGE\n  $ sf decomposer recompose [-m \u003cvalue\u003e] [-x \u003cvalue\u003e] [-i \u003cvalue\u003e] [--postpurge --json]\n\nFLAGS\n  -m, --metadata-type=\u003cvalue\u003e             Metadata suffix to process (e.g. flow, labels). Repeatable. Optional when --manifest is provided.\n  -x, --manifest=\u003cvalue\u003e                  Path to a package.xml manifest. When provided, only the components listed in the manifest are recomposed.\n  -i, --ignore-package-directory=\u003cvalue\u003e  Package directory to skip. Repeatable.\n  --postpurge                             Remove decomposed files after recomposing [default: false]\n\nGLOBAL FLAGS\n  --json  Output as JSON.\n```\n\n\u003e At least one of `--metadata-type` or `--manifest` is required. When both are supplied, the run is scoped to the intersection of the two.\n\n**Examples**\n\n```bash\nsf decomposer recompose -m \"flow\" --postpurge\nsf decomposer recompose -m \"flow\" -i \"force-app\"\n\n# Recompose only the components listed in a deploy manifest before deploying\nsf decomposer recompose -x \"manifest/package.xml\"\nsf project deploy start -x \"manifest/package.xml\"\n```\n\n### sf decomposer verify\n\nNon-destructive round-trip check: copies your package directories into a temp directory under your OS's `tmpdir()`, runs decompose then recompose there, and diffs the rebuilt parents against the originals using **structural XML equality** (sibling and attribute order are ignored). Exits non-zero on any drift; your working tree is never modified.\n\n```\nUSAGE\n  $ sf decomposer verify [-m \u003cvalue\u003e] [-x \u003cvalue\u003e] [-f \u003cvalue\u003e] [-i \u003cvalue\u003e] [-s \u003cvalue\u003e] [-p -c --json]\n\nFLAGS\n  -m, --metadata-type=\u003cvalue\u003e             Metadata suffix to verify (e.g. flow, labels). Repeatable. Optional when --manifest is provided.\n  -x, --manifest=\u003cvalue\u003e                  Path to a package.xml manifest. When provided, only the components listed in the manifest are verified.\n  -f, --format=\u003cvalue\u003e                    Output format used for the round-trip decompose: xml | yaml | json | json5 [default: xml]\n  -i, --ignore-package-directory=\u003cvalue\u003e  Package directory to skip. Repeatable.\n  -s, --strategy=\u003cvalue\u003e                  unique-id | grouped-by-tag [default: unique-id]\n  -p, --decompose-nested-permissions      With grouped-by-tag, further decompose permission set and muting permission set object/field permissions.\n  -c, --config                            Load per-type and per-component overrides from .sfdecomposer.config.json in the repo root, the same as `decompose --config`. [default: false]\n\nGLOBAL FLAGS\n  --json  Output as JSON.\n```\n\n\u003e At least one of `--metadata-type` or `--manifest` is required. When both are supplied, the run is scoped to the intersection of the two.\n\n**Examples**\n\n```bash\n# Verify two metadata types round-trip cleanly with defaults\nsf decomposer verify -m \"permissionset\" -m \"profile\"\n\n# Verify a different strategy + nested-perms split before committing the change\nsf decomposer verify -m \"permissionset\" -s \"grouped-by-tag\" -p\n\n# CI gate: verify just the components in a deploy manifest, using the repo-root config\nsf decomposer verify -x \"manifest/package.xml\" --config\n```\n\nFiles where the **only** delta is sibling or attribute ordering are surfaced separately as informational notices (\"Note: N file(s) round-tripped semantically but with sibling/attribute reordering\") rather than as drift. This is safe — Salesforce treats metadata as order-agnostic and `config-disassembler` does not preserve original sibling order — but it tells you up front that committing the post-recompose output will produce a diff in git even though the metadata is functionally identical.\n\n---\n\n## Manifest-scoped runs\n\nThe `-x` / `--manifest` flag is supported by every `sf decomposer` command (`decompose`, `recompose`, `verify`) and accepts any standard Salesforce `package.xml`, limiting the work to just the components it lists. This is especially useful for CI/CD pipelines that deploy a subset of metadata per change.\n\nHow it works:\n\n- The manifest is parsed with `@salesforce/source-deploy-retrieve`'s `ManifestResolver`, so the same XML you pass to `sf project deploy start -x` is honored here.\n- For each entry, the plugin resolves the matching parent metadata files in your local package directories (using each metadata type's `directoryName`, `suffix`, `strictDirectoryName`, and `folderType` from the SDR registry).\n- Only those files are decomposed/recomposed; everything else on disk is left untouched.\n- Wildcards (`\u003cmembers\u003e*\u003c/members\u003e`) expand against your local source. Folder-typed members (e.g. `MyFolder/MyReport`) are resolved by walking the folder.\n- Types in the manifest that the plugin does not support (e.g. `CustomObject`, `ApexClass`) are skipped with a warning instead of failing the run, so a single manifest can drive both deploys and decomposer runs.\n- If both `--metadata-type` and `--manifest` are provided, the run is scoped to the intersection (only types present in both).\n\nExample manifest:\n\n```xml\n\u003c?xml version=\"1.0\" encoding=\"UTF-8\"?\u003e\n\u003cPackage xmlns=\"http://soap.sforce.com/2006/04/metadata\"\u003e\n  \u003ctypes\u003e\n    \u003cmembers\u003eHR_Admin\u003c/members\u003e\n    \u003cname\u003ePermissionSet\u003c/name\u003e\n  \u003c/types\u003e\n  \u003ctypes\u003e\n    \u003cmembers\u003eCase\u003c/members\u003e\n    \u003cname\u003eWorkflow\u003c/name\u003e\n  \u003c/types\u003e\n  \u003cversion\u003e58.0\u003c/version\u003e\n\u003c/Package\u003e\n```\n\n```bash\nsf decomposer recompose -x \"manifest/package.xml\"\nsf project deploy start -x \"manifest/package.xml\"\n```\n\n---\n\n## Decompose Strategies\n\n\u003e **Tip:** A single decompose run can mix strategies and formats across metadata types — and even across components within the same type — through the `overrides` array (see [Per-Type \u0026 Per-Component Overrides](#per-type--per-component-overrides)). Recompose is deterministic from the on-disk sidecar, so any combination round-trips. When switching strategies for an existing component, pass `--prepurge` (or set `prePurge: true`) so leftover files from the previous strategy are removed before the new ones are written.\n\n- **unique-id** (default): Each nested element goes to its own file, named by unique-id fields or content hash. Leaf elements stay in a file named like the original XML.\n- **grouped-by-tag**: All elements with the same tag (e.g. `\u003cfieldPermissions\u003e`) go into one file named after the tag (e.g. `fieldPermissions.xml`). Leaf elements are still grouped in the original-named file.\n\n**Permission set – unique-id**\n\n```\npermissionsets/\n└── HR_Admin/\n    ├── HR_Admin.permissionset-meta.xml             ← leaf properties (label, description, userLicense, ...)\n    ├── .key_order.json                             ← preserves original element order\n    ├── applicationVisibilities/\n    │   └── JobApps__Recruiting.applicationVisibilities-meta.xml\n    ├── classAccesses/\n    │   └── Send_Email_Confirmation.classAccesses-meta.xml\n    ├── fieldPermissions/\n    │   ├── Job_Request__c.SalaryPay__c.fieldPermissions-meta.xml\n    │   └── Job_Request__c.Salary__c.fieldPermissions-meta.xml\n    ├── objectPermissions/\n    │   └── Job_Request__c.objectPermissions-meta.xml\n    ├── pageAccesses/\n    │   └── Job_Request_Web_Form.pageAccesses-meta.xml\n    ├── recordTypeVisibilities/\n    │   └── Recruiting.DevManager.recordTypeVisibilities-meta.xml\n    ├── tabSettings/\n    │   └── Job_Request__c.tabSettings-meta.xml\n    └── userPermissions/\n        └── APIEnabled.userPermissions-meta.xml\n```\n\n**Permission set – grouped-by-tag**\n\n```\npermissionsets/\n└── HR_Admin/\n    ├── HR_Admin.permissionset-meta.xml             ← leaf properties only\n    ├── .key_order.json\n    ├── applicationVisibilities.xml                 ← all applicationVisibilities entries\n    ├── classAccesses.xml                           ← all classAccesses entries\n    ├── fieldPermissions.xml                        ← all fieldPermissions entries\n    ├── objectPermissions.xml\n    ├── pageAccesses.xml\n    ├── recordTypeVisibilities.xml\n    ├── tabSettings.xml\n    └── userPermissions.xml\n```\n\n### Custom Labels Decomposition\n\nCustom labels use only the **unique-id** strategy. If you pass `grouped-by-tag`, the plugin overrides to `unique-id` and continues. Grouping labels by tag would produce no difference from the original file since all elements share the same tag. Each label is written to its own file.\n\n```\nlabels/\n├── CustomLabels.labels-meta.xml                    ← original wrapper kept (empty after decompose)\n├── quoteAuto.label-meta.xml                        ← one file per \u003clabels\u003e entry, named by fullName\n└── quoteManual.label-meta.xml\n```\n\n### Additional Permission Set Decomposition\n\nWith **grouped-by-tag**, use `--decompose-nested-permissions` (`-p`) to further decompose permission sets and muting permission sets:\n\n- Write each `\u003cobjectPermissions\u003e` to its own file under `objectPermissions/`.\n- Group `\u003cfieldPermissions\u003e` by object under `fieldPermissions/`.\n\nSimilar to Salesforce’s `decomposePermissionSetBeta2`, with more control and format options. Muting permission sets extend the permission set metadata type and support the same decomposition.\n\n```bash\nsf decomposer decompose -m \"permissionset\" -s \"grouped-by-tag\" -p\nsf decomposer decompose -m \"mutingpermissionset\" -s \"grouped-by-tag\" -p\n```\n\n```\npermissionsets/\n└── HR_Admin/\n    ├── HR_Admin.permissionset-meta.xml             ← leaf properties\n    ├── .key_order.json\n    ├── applicationVisibilities.xml                 ← grouped-by-tag stays grouped\n    ├── classAccesses.xml\n    ├── pageAccesses.xml\n    ├── recordTypeVisibilities.xml\n    ├── tabSettings.xml\n    ├── userPermissions.xml\n    ├── fieldPermissions/                           ← grouped per object (decompose-nested-permissions)\n    │   └── Job_Request__c.fieldPermissions-meta.xml\n    └── objectPermissions/                          ← one file per object\n        └── Job_Request__c.objectPermissions-meta.xml\n```\n\n### Loyalty Program Setup Decomposition\n\n`loyaltyProgramSetup` supports only the **unique-id** strategy. If you pass `grouped-by-tag`, the plugin overrides to `unique-id` and continues. The metadata is automatically decomposed further under unique-id:\n\n- Each `\u003cprogramProcesses\u003e` element → its own file.\n- Each `\u003cparameters\u003e` and `\u003crules\u003e` child → its own file.\n\n\u003e Recomposition for loyalty program setup removes decomposed files even without `--postpurge`. Use version control or CI to keep them if needed.\n\n```\nloyaltyProgramSetups/\n└── Cloud_Kicks_Inner_Circle/\n    ├── Cloud_Kicks_Inner_Circle.loyaltyProgramSetup-meta.xml   ← leaf properties (e.g. label)\n    ├── .key_order.json\n    ├── .multi_level.json                                       ← required for recompose; do not hand-edit\n    └── programProcesses/                                       ← one folder per process, named by processName\n        ├── Manual Points Adjustments/\n        │   ├── Manual Points Adjustments.xml                   ← process leaf properties\n        │   ├── .key_order.json\n        │   ├── parameters/                                     ← one file per parameter, named by parameterName\n        │   │   ├── EA_PerAdjustmentRewardTracking.parameters-meta.xml\n        │   │   ├── EventType.parameters-meta.xml\n        │   │   └── ...\n        │   └── rules/                                          ← one file per rule, named by ruleName\n        │       ├── Bulk Voucher Upload.rules-meta.xml\n        │       ├── Finalize.rules-meta.xml\n        │       └── Set Up Step.rules-meta.xml\n        ├── Member Enrollment Process/\n        │   └── ...                                             ← same shape per process\n        └── ...\n```\n\n\u003e **Tip:** This three-level layout (`programProcesses` → `parameters`/`rules`) is exactly the multi-level decomposition pattern. The same pattern powers Bots, Flexipages, and Layouts via opt-in `multiLevel` overrides — see the [admin handbook](https://github.com/mcarvin8/sf-decomposer/blob/main/HANDBOOK.md) for those recipes.\n\n---\n\n## Supported Metadata\n\nAll parent metadata types from this plugin’s version of **@salesforce/source-deploy-retrieve** (SDR) are supported, except where noted below.\n\nUse the metadata **suffix** for `-m` / `--metadata-type`, as in [SDR’s metadataRegistry.json](https://github.com/forcedotcom/source-deploy-retrieve/blob/main/src/registry/metadataRegistry.json), or infer from the file name: `*.{suffix}-meta.xml`.\n\n| Metadata Type               | CLI value                  | Notes                                                                                                                                                                      |\n| --------------------------- | -------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |\n| Custom Labels               | `labels`                   | Strategy overridden to `unique-id` if `grouped-by-tag` is provided (grouping labels by tag would be no different from the original file).                                  |\n| Workflows                   | `workflow`                 |                                                                                                                                                                            |\n| Profiles                    | `profile`                  |                                                                                                                                                                            |\n| Permission Sets             | `permissionset`            | Supports `--decompose-nested-permissions` with grouped-by-tag.                                                                                                             |\n| Muting Permission Sets      | `mutingpermissionset`      | Extends permission set metadata type. Supports `--decompose-nested-permissions` with grouped-by-tag.                                                                       |\n| AI Scoring Model Definition | `aiScoringModelDefinition` |                                                                                                                                                                            |\n| Decision Matrix Definition  | `decisionMatrixDefinition` |                                                                                                                                                                            |\n| Bot                         | `bot`                      |                                                                                                                                                                            |\n| Marketing App Extension     | `marketingappextension`    |                                                                                                                                                                            |\n| Loyalty Program Setup       | `loyaltyProgramSetup`      | Only `unique-id` strategy supported; `grouped-by-tag` is overridden. Automatically decomposed further (see [Loyalty Program Setup](#loyalty-program-setup-decomposition)). |\n\n### Exceptions\n\n| Situation                                                                                      | Message                                                                                                                           |\n| ---------------------------------------------------------------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------- |\n| `botVersion` used directly                                                                     | `botVersion suffix should not be used. Please use bot to decompose/recompose bot and bot version files.`                          |\n| Custom Objects                                                                                 | `Custom Objects are not supported by this plugin.`                                                                                |\n| Unsupported SDR strategies (e.g. matchingContentFile, digitalExperience, mixedContent, bundle) | `Metadata types with [matchingContentFile, digitalExperience, mixedContent, bundle] strategies are not supported by this plugin.` |\n| Child types (e.g. custom fields) or invalid suffix                                             | `Metadata type not found for the given suffix: field.`                                                                            |\n\n---\n\n## Troubleshooting\n\n### Missing sfdx-project.json\n\nThe plugin looks for `sfdx-project.json` from the current directory up to the drive root. If it’s not found:\n\n```\nError (1): sfdx-project.json not found in any parent directory.\n```\n\n### Package Directories Not Found for Given Metadata Type\n\nThis plugin relies on the @salesforce/source-deploy-retrieve metadata registry to map each metadata type to its expected directory name.\n\nIf you provide a metadata type whose corresponding directory does not exist in any of your package directories, the plugin will fail with the following error:\n\n```\nNo directories named ${metadataTypeEntry.directoryName} were found in any package directory.\n```\n\nFor example, if you attempt to decompose Custom Labels but none of your package directories contain a \"labels\" folder, the plugin will throw this error.\n\n### XML disassemble output (Rust crate)\n\nThe config-disassembler Node plugin uses a **Rust crate** for XML decomposing and recomposing. Disassemble errors and messages are shown in the terminal.\n\nControl verbosity with the `RUST_LOG` environment variable (e.g. `RUST_LOG=debug` for detailed output).\n\nExample output in the terminal (Rust log format):\n\n```\n[2026-04-30T12:34:38Z ERROR config_disassembler::xml::builders::build_disassembled_files] The XML file C:\\Users\\matthew.carvin\\Documents\\sf-decomposer\\fixtures\\package-dir-1\\permissionsets\\only_leafs.permissionset-meta.xml only has leaf elements. This file will not be disassembled.\n```\n\n### Files with only leaf elements\n\nIf a metadata file has only leaf elements (primitives, no nested structure), there is nothing to decompose. The Rust crate skips the file and logs an ERROR like the example above.\n\n---\n\n## Hooks\n\n\u003e Configure [.forceignore](#forceignore) so the Salesforce CLI ignores decomposed files; otherwise `sf` commands can fail.\n\nPut **.sfdecomposer.config.json** in the project root to run:\n\n- **After** `sf project retrieve start`: decompose.\n- **Before** `sf project deploy start` / `sf project deploy validate`: recompose.\n\nCopy and customize the [sample config](https://raw.githubusercontent.com/mcarvin8/sf-decomposer/main/examples/.sfdecomposer.config.json), or the [sample config with overrides](https://raw.githubusercontent.com/mcarvin8/sf-decomposer/main/examples/.sfdecomposer.config.overrides.json) to vary format/strategy/etc. by metadata type or by individual component.\n\n| Option                       | Required    | Description                                                                                                                                                                                                                   |\n| ---------------------------- | ----------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |\n| `metadataSuffixes`           | Conditional | Comma-separated metadata suffixes to decompose/recompose. Required unless `manifest` is set; when both are set, the run is scoped to the intersection.                                                                        |\n| `manifest`                   | Conditional | Path (relative to the project root) to a `package.xml` manifest. When set, only the components listed in the manifest are decomposed/recomposed. See `-x` above.                                                              |\n| `ignorePackageDirectories`   | No          | Comma-separated package directories to skip.                                                                                                                                                                                  |\n| `prePurge`                   | No          | Remove existing decomposed files before decomposing (default: false).                                                                                                                                                         |\n| `postPurge`                  | No          | After decompose: remove originals; after recompose: remove decomposed files (default: false).                                                                                                                                 |\n| `decomposedFormat`           | No          | xml, json, json5, or yaml (default: xml).                                                                                                                                                                                     |\n| `strategy`                   | No          | `unique-id` \\| `grouped-by-tag` (default: unique-id).                                                                                                                                                                         |\n| `decomposeNestedPermissions` | No          | With grouped-by-tag, set true to further decompose permission set and muting permission set object/field permissions.                                                                                                         |\n| `overrides`                  | No          | Array of per-type and/or per-component overrides for `decomposedFormat`, `strategy`, `decomposeNestedPermissions`, `prePurge`, and `postPurge`. See [Per-Type \u0026 Per-Component Overrides](#per-type--per-component-overrides). |\n\n---\n\n## Per-Type \u0026 Per-Component Overrides\n\nOverrides apply to **decompose only**. Recompose is a deterministic round-trip — it auto-detects format from the on-disk files and does not depend on strategy — so it ignores the `overrides` array.\n\nBy default, a single decompose run uses one format and one strategy across every metadata type. The optional `overrides` array in `.sfdecomposer.config.json` lets you vary a small set of options per metadata suffix (**type-scope**) or per individual SDR component (**component-scope**) without splitting the run into multiple invocations.\n\n```json\n{\n  \"metadataSuffixes\": \"labels,workflow,profile,flow,permissionset\",\n  \"ignorePackageDirectories\": \"force-app,examples\",\n  \"prePurge\": true,\n  \"postPurge\": true,\n  \"decomposedFormat\": \"xml\",\n  \"strategy\": \"unique-id\",\n  \"overrides\": [\n    { \"metadataTypes\": [\"flow\"], \"decomposedFormat\": \"yaml\" },\n    {\n      \"metadataTypes\": [\"permissionset\", \"mutingpermissionset\"],\n      \"strategy\": \"grouped-by-tag\",\n      \"decomposeNestedPermissions\": true\n    },\n    {\n      \"components\": [\"permissionset:HR_Admin\", \"permissionset:Big_PermSet\"],\n      \"strategy\": \"grouped-by-tag\",\n      \"decomposeNestedPermissions\": true\n    }\n  ]\n}\n```\n\n### What can be overridden\n\n| Field                        | Notes                                                                                                                                                                                                                                                        |\n| ---------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ |\n| `metadataTypes`              | Optional (required if `components` is omitted). Array of metadata suffixes (same vocabulary as `--metadata-type` / `metadataSuffixes`). Each suffix may appear in at most one override.                                                                      |\n| `components`                 | Optional (required if `metadataTypes` is omitted). Array of `\u003cmetadataSuffix\u003e:\u003cfullName\u003e` keys (e.g. `permissionset:HR_Admin`, `report:MyFolder/MyReport`). Each component may appear in at most one override.                                               |\n| `decomposedFormat`           | `xml` \\| `json` \\| `json5` \\| `yaml`.                                                                                                                                                                                                                        |\n| `strategy`                   | `unique-id` \\| `grouped-by-tag`. Hard rules still win — `labels` and `loyaltyProgramSetup` are always treated as `unique-id`.                                                                                                                                |\n| `decomposeNestedPermissions` | Only applies to `permissionset` / `mutingpermissionset` with `grouped-by-tag`. Sets a known-good `splitTags` default; ignored if `splitTags` is also set in the same scope.                                                                                  |\n| `splitTags`                  | Custom `splitTags` spec for `grouped-by-tag` strategy. See [splitTags grammar](#splittags-grammar). Ignored when the resolved strategy is not `grouped-by-tag`.                                                                                              |\n| `multiLevel`                 | One or more `multiLevel` specs for nested-array decomposition. Pass a string, a `string[]`, or a `;`-separated string. See [multiLevel grammar](#multilevel-grammar). When set, replaces the hardcoded `loyaltyProgramSetup` default for the targeted scope. |\n| `prePurge`                   | Per-scope prePurge (decompose). Component-scope `prePurge` only purges the named component's decomposed directory.                                                                                                                                           |\n| `postPurge`                  | Per-scope postPurge (decompose: remove originals after decomposing).                                                                                                                                                                                         |\n\nRun-scope options (`metadataSuffixes`, `manifest`, `ignorePackageDirectories`) are **not** valid inside an override; the plugin will throw if they are present.\n\n#### Component key conventions\n\nThe `\u003cfullName\u003e` part of a component key is the SDR fullName for the component, matching the basename of the decomposed directory:\n\n- **Plain types** (e.g. `permissionset`, `flow`, `profile`, `workflow`): use the file stem, e.g. `permissionset:HR_Admin` for `permissionsets/HR_Admin.permissionset-meta.xml`.\n- **Strict-directory types** (e.g. `bot`): use the bot directory name, e.g. `bot:My_Bot` for `bots/My_Bot/My_Bot.bot-meta.xml`.\n- **Folder-typed metadata** (e.g. `report`, `dashboard`, `email`, `document`): the unit of decomposition is the folder; use the folder name, e.g. `report:MyFolder` to scope every report inside `reports/MyFolder/`.\n- **`labels`**: there is exactly one labels file per labels directory, so component-scope keys are not meaningful — use the type-scope `metadataTypes: [\"labels\"]` instead.\n\nComponent overrides are not a filter. If `--metadata` / `metadataSuffixes` includes `permissionset`, every permission set is still decomposed; the override only changes how the named ones are decomposed. Use `--manifest` / the hook's `manifest` field if you want to scope the run itself to a subset of components.\n\n### Precedence\n\nFor each component, each option is resolved independently in this order (highest first):\n\n1. The component-scope override value (matching `\u003csuffix\u003e:\u003cfullName\u003e` in `components`), if set.\n2. The type-scope override value (matching `\u003csuffix\u003e` in `metadataTypes`), if set.\n3. The run-wide value (CLI flag, hook config top-level field, or built-in default).\n4. Hard plugin rules (e.g. `labels` and `loyaltyProgramSetup` forced to `unique-id`) override all of the above.\n\n### splitTags grammar\n\n`splitTags` lets you control how `grouped-by-tag` writes nested arrays for any metadata type. The plugin already applies a known-good default for permission sets when `decomposeNestedPermissions: true` is set; setting `splitTags` directly takes precedence and works for any metadata type.\n\n**Spec:** Comma-separated rules. Each rule has 3 or 4 colon-separated parts:\n\n- `\u003ctag\u003e:\u003cmode\u003e:\u003cfield\u003e` — read array items from the top-level `\u003ctag\u003e`.\n- `\u003ctag\u003e:\u003cpath\u003e:\u003cmode\u003e:\u003cfield\u003e` — read array items from the nested `\u003cpath\u003e` (defaults to `\u003ctag\u003e`).\n\n`\u003cmode\u003e` is one of:\n\n- **`split`** — write one file per array item, named after the value of `\u003cfield\u003e` on each item.\n- **`group`** — group array items by the value of `\u003cfield\u003e`, writing one file per group.\n\nEach `\u003ctag\u003e` may appear at most once in a spec. The plugin validates the grammar at config-load time. Deeper checks (e.g. unknown tag names for the metadata type) are surfaced by the underlying disassembler crate at runtime.\n\n#### splitTags cookbook\n\n```json\n\"overrides\": [\n  {\n    \"metadataTypes\": [\"permissionset\", \"mutingpermissionset\"],\n    \"strategy\": \"grouped-by-tag\",\n    \"splitTags\": \"objectPermissions:split:object,fieldPermissions:group:field\"\n  },\n  {\n    \"metadataTypes\": [\"profile\"],\n    \"strategy\": \"grouped-by-tag\",\n    \"splitTags\": \"objectPermissions:split:object,fieldPermissions:group:field,layoutAssignments:group:layout\"\n  },\n  {\n    \"metadataTypes\": [\"flow\"],\n    \"strategy\": \"grouped-by-tag\",\n    \"splitTags\": \"actionCalls:split:name,decisions:split:name,assignments:split:name\"\n  },\n  {\n    \"metadataTypes\": [\"workflow\"],\n    \"strategy\": \"grouped-by-tag\",\n    \"splitTags\": \"rules:split:fullName,alerts:split:fullName,fieldUpdates:split:fullName,tasks:split:fullName\"\n  }\n]\n```\n\n\u003e **Caveat:** When using `mode: split`, the chosen `\u003cfield\u003e` must produce a unique value for every array item — otherwise two items would map to the same filename. If two items share a field value, prefer `mode: group` instead, which is designed for that case.\n\n### multiLevel grammar\n\n`multiLevel` enables a second decomposition pass on inner-level files for metadata types whose XML has deeply nested repeatable blocks (e.g. `loyaltyProgramSetup`'s `programProcesses → parameters → ...`, or a Bot's `botVersion → botDialogs → botSteps`). The plugin already applies a known-good default for `loyaltyProgramSetup` when running the `unique-id` strategy; setting `multiLevel` directly takes precedence and works for any metadata type.\n\n**Spec:** Each rule has exactly 3 colon-separated parts (the third part is itself a comma-separated list):\n\n```\n\u003cfile_pattern\u003e:\u003croot_to_strip\u003e:\u003cunique_id_elements\u003e\n```\n\n- **`\u003cfile_pattern\u003e`** — basename pattern that selects which inner-level files get the second decomposition pass (e.g. `programProcesses`).\n- **`\u003croot_to_strip\u003e`** — XML root tag to strip from each matched file before splitting.\n- **`\u003cunique_id_elements\u003e`** — comma-separated list of element names used to derive a stable filename for each inner-level item (e.g. `parameterName,ruleName`). The first element that resolves to a non-empty value wins.\n\nA scope may target several nested sections by passing **multiple rules**. Three input shapes are supported:\n\n- a single rule string (legacy, unchanged behaviour);\n- a JSON `string[]` of rules (preferred — clearest intent, easiest to diff);\n- a single `;`-separated string of rules (compact form, also accepted).\n\nWithin one scope, the `(file_pattern, root_to_strip)` pair must be unique across rules. The plugin validates the grammar at config-load time; deeper checks (whether a file pattern matches anything, whether the unique-id elements actually appear on the inner XML) are surfaced by the underlying disassembler crate at runtime.\n\n```json\n\"overrides\": [\n  {\n    \"metadataTypes\": [\"dashboard\"],\n    \"multiLevel\": \"components:components:title\"\n  },\n  {\n    \"metadataTypes\": [\"layout\"],\n    \"multiLevel\": [\n      \"layoutSections:layoutSections:label\",\n      \"layoutItems:layoutItems:field,customLink,emptySpace\"\n    ]\n  }\n]\n```\n\n\u003e **`bot` and `loyaltyProgramSetup` ship with built-in `multiLevel` defaults**, so you don't need to add an override for either type to get the canonical decomposed layout — supply your own only if you want to replace the default. The full registry lives in [`src/metadata/multiLevelDefaults.ts`](https://github.com/mcarvin8/sf-decomposer/blob/main/src/metadata/multiLevelDefaults.ts).\n\n\u003e **Why one call:** Pass every rule for a given component in a single override. Sequential single-rule decompositions rewrite the on-disk `.multi_level.json` and only the last rule survives — so multi-rule scenarios must travel together.\n\n\u003e **Tip:** Use [`sf decomposer verify`](#sf-decomposer-verify) to non-destructively confirm a new override config still round-trips before committing it.\n\n\u003e **Tip:** See the [admin handbook](https://github.com/mcarvin8/sf-decomposer/blob/main/HANDBOOK.md) for end-to-end recipes for Bots, Flexipages, Layouts, and other deeply-nested metadata.\n\n### Opting in from the CLI\n\nCLI users can opt into overrides on `decompose` with the boolean `--config` (`-c`) flag. When set, the plugin reads `.sfdecomposer.config.json` from the repo root (the nearest ancestor directory that contains `sfdx-project.json`):\n\n```bash\nsf decomposer decompose -m \"flow\" -m \"permissionset\" -c\n```\n\nWhen `--config` is set, **only** the `overrides` array is consumed from the file. Top-level fields like `decomposedFormat`, `strategy`, `metadataSuffixes`, etc. are ignored — the CLI flags remain the source of truth for run-wide values. This keeps direct CLI behavior predictable and lets you reuse the same config file as the post-retrieve hook without any surprises.\n\nIf `--config` is set but `.sfdecomposer.config.json` is missing from the repo root, the command fails with a clear error.\n\n`recompose` does not accept `--config` because it does not need the override information — format is auto-detected from the decomposed files on disk and recompose does not depend on strategy.\n\nThe post-retrieve hook automatically picks up `overrides` from `.sfdecomposer.config.json` — no extra setup required. Existing config files without an `overrides` field continue to behave exactly as before.\n\n---\n\n## Ignore Files\n\n### .forceignore\n\nThe Salesforce CLI must **ignore** decomposed files and **allow** recomposed files. Use the [sample .forceignore](https://raw.githubusercontent.com/mcarvin8/sf-decomposer/main/examples/.forceignore) and set patterns for the extensions you use (`.xml`, `.json`, `.yaml`, etc.).\n\n### .sfdecomposerignore\n\nOptional. In the project root, list paths/patterns to skip when **decomposing** (same syntax as [.gitignore 2.22.1](https://git-scm.com/docs/gitignore)). Ignored files are not recomposed from.\n\n### .gitignore\n\nOptional. Ignore recomposed metadata so it aren’t committed. See the [sample .gitignore](https://raw.githubusercontent.com/mcarvin8/sf-decomposer/main/examples/.gitignore).\n\n---\n\n## Issues\n\nBugs and feature requests: [open an issue](https://github.com/mcarvin8/sf-decomposer/issues).\n\n---\n\n## Built With\n\n- [config-disassembler-node](https://github.com/mcarvin8/config-disassembler-node) – Disassemble XML (and other config formats) into smaller, manageable files and reassemble when needed. Node.js + Rust (Neon). See [Requirements](#requirements).\n- [@salesforce/source-deploy-retrieve](https://github.com/forcedotcom/source-deploy-retrieve) – JavaScript toolkit for working with Salesforce metadata.\n\n---\n\n## Contributing\n\nContributions are welcome. See [CONTRIBUTING.md](https://github.com/mcarvin8/sf-decomposer/blob/main/CONTRIBUTING.md).\n\n## License\n\n[MIT](https://raw.githubusercontent.com/mcarvin8/sf-decomposer/main/LICENSE.md)\n","project_url":"https://awesome.ecosyste.ms/api/v1/projects/github.com%2Fmcarvin8%2Fsf-decomposer","html_url":"https://awesome.ecosyste.ms/projects/github.com%2Fmcarvin8%2Fsf-decomposer","lists_url":"https://awesome.ecosyste.ms/api/v1/projects/github.com%2Fmcarvin8%2Fsf-decomposer/lists"}