├── .editorconfig ├── .github └── workflows │ └── default.yml ├── .gitignore ├── .node-version ├── README.md ├── bin ├── dev.cmd ├── dev.js ├── run.cmd └── run.js ├── config └── project-scratch-def.json ├── force-app └── .gitkeep ├── package-lock.json ├── package.json ├── prettier.config.mjs ├── renovate.json ├── sfdx-project.json ├── sfdx-source ├── customobjecttranslations-with-fieldtranslations │ ├── objectTranslations │ │ └── Dummy__c-en_US │ │ │ ├── Dummy__c-en_US.objectTranslation-meta.xml │ │ │ └── Type__c.fieldTranslation-meta.xml │ └── objects │ │ └── Dummy__c │ │ ├── Dummy__c.object-meta.xml │ │ └── fields │ │ └── Type__c.field-meta.xml ├── profile-with-field-permissions │ ├── objects │ │ └── Account │ │ │ ├── Account.object-meta.xml │ │ │ └── fields │ │ │ └── IsTest__c.field-meta.xml │ └── profiles │ │ └── Dummy.profile-meta.xml ├── recordtypes-with-picklistvalues │ └── objects │ │ └── DummyWithRT__c │ │ ├── DummyWithRT__c.object-meta.xml │ │ ├── fields │ │ └── Type__c.field-meta.xml │ │ └── recordTypes │ │ ├── DummyRecordType.recordType-meta.xml │ │ └── DummyRecordType2.recordType-meta.xml └── translations-with-labels │ ├── labels │ └── CustomLabels.labels-meta.xml │ └── translations │ └── en_US.translation-meta.xml ├── src ├── commands │ ├── crud-mdapi │ │ └── read.ts │ └── force │ │ └── source │ │ └── read.ts ├── component-set.ts ├── crud-mdapi.ts ├── index.ts ├── source-component.ts └── utils.ts ├── test ├── commands │ ├── crud-mdapi │ │ └── read.e2e.ts │ └── force │ │ └── source │ │ └── read.e2e.ts ├── component-set.test.ts ├── e2e.ts ├── fixtures │ ├── Admin.profile-meta.xml │ ├── Industry.field-meta.xml │ ├── read-record-types │ │ └── metadata │ └── sourcecomponents.ts ├── source-component.test.ts ├── tsconfig.json └── utils.test.ts └── tsconfig.json /.editorconfig: -------------------------------------------------------------------------------- 1 | root = true 2 | 3 | [*] 4 | indent_style = space 5 | indent_size = 2 6 | charset = utf-8 7 | trim_trailing_whitespace = true 8 | insert_final_newline = true 9 | 10 | [*.md] 11 | trim_trailing_whitespace = false 12 | -------------------------------------------------------------------------------- /.github/workflows/default.yml: -------------------------------------------------------------------------------- 1 | name: Test and Release 2 | 3 | on: 4 | push: 5 | branches: 6 | - main 7 | pull_request: 8 | branches: 9 | - main 10 | 11 | jobs: 12 | default: 13 | runs-on: ubuntu-latest 14 | steps: 15 | - uses: actions/checkout@v4 16 | - uses: actions/setup-node@v4 17 | with: 18 | node-version-file: .node-version 19 | - name: Install dependencies 20 | run: | 21 | npm ci 22 | npm install --global @salesforce/cli 23 | - name: Run tests 24 | run: npm run test 25 | - name: Run E2E tests in Developer Edition 26 | env: 27 | SFDX_AUTH_URL_DEVED: ${{ secrets.SFDX_AUTH_URL_DEVED }} 28 | run: | 29 | echo "${SFDX_AUTH_URL_DEVED}" | sf org login sfdx-url --set-default --alias deved --sfdx-url-stdin 30 | npm run test:e2e 31 | - name: Release package 32 | run: npx semantic-release 33 | env: 34 | GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} 35 | NPM_TOKEN: ${{ secrets.NPM_TOKEN }} 36 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | *-debug.log 2 | *-error.log 3 | /.nyc_output 4 | /dist 5 | /lib 6 | /tmp 7 | node_modules/ 8 | .sfdx/ 9 | .sf/ 10 | /oclif.manifest.json 11 | /force-app/main/ 12 | -------------------------------------------------------------------------------- /.node-version: -------------------------------------------------------------------------------- 1 | 22 2 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # sfdx-plugin-source-read 2 | 3 | > sfdx/sf plugin to read Metadata e.g. full Profiles via CRUD Metadata API 4 | 5 | For certain Metadata Types there is a different behaviour of the [file-based](https://developer.salesforce.com/docs/atlas.en-us.api_meta.meta/api_meta/meta_retrieve.htm) vs. [CRUD-based](https://developer.salesforce.com/docs/atlas.en-us.api_meta.meta/api_meta/meta_readMetadata.htm) Metadata API. 6 | 7 | And additionally the file-based Metadata API even behaves differently for source-tracked vs. non-source-tracked orgs. 8 | 9 | > [!IMPORTANT] 10 | > The CRUD-based Metadata API can be of great help when working with non-source-tracked orgs. 11 | > 12 | > Read more about about this in my [mdapi-issues/retrieve-behavior-scratch-org](https://github.com/mdapi-issues/retrieve-behavior-scratch-org) repository. 13 | 14 | This plugin provides a `sf crud-mdapi read` (formerly `sf force source read`) command to read Metadata using the "CRUD-based" Metadata API similar to `sf project retrieve start` (which uses the "file-based" Metadata API). 15 | 16 | > [!NOTE] 17 | > This plugin simply returns the unfiltered response from the CRUD-based Metadata API. 18 | 19 | In addition to retrieving `Profiles`, this plugin is useful for retrieving `RecordTypes` and `CustomObjectTranslations`. 20 | 21 | ## Installation 22 | 23 | ```console 24 | sf plugins install sfdx-plugin-source-read 25 | ``` 26 | 27 | ## Usage 28 | 29 | ```console 30 | sf crud-mdapi read --metadata "Profile:Admin" --metadata "Profile:Standard" 31 | sf crud-mdapi read --source-dir force-app/main/default/profiles/Admin.profile-meta.xml 32 | sf crud-mdapi read --metadata "RecordType:Account.Business" 33 | sf crud-mdapi read --source-dir force-app/main/default/objects/Account/recordTypes/Business.recordType-meta.xml 34 | sf crud-mdapi read --metadata "CustomObjectTranslation:Task-de" 35 | ``` 36 | 37 | ## Example 38 | 39 | ### Retrieving Profiles using the file-based Metadata API 40 | 41 | When retrieving Profiles, the file-based Metadata API [behaves differently for source-tracked and non source-tracked orgs](https://developer.salesforce.com/docs/atlas.en-us.sfdx_dev.meta/sfdx_dev/sfdx_dev_source_tracking_source_tracking_profiles.htm): 42 | 43 | > Without source tracking, retrieving profiles only returns some profile information 44 | 45 | a.k.a. a minimal Profile containing only `userPermissions` and entries for components listed in the `package.xml` of the retrieve request. 46 | 47 | > With source tracking, retrieving profiles returns profile information pertaining to anything else specified in the package.xml file plus any components getting tracked by source tracking 48 | 49 | a.k.a. a more kind of "full" Profile containing entries for all metadata having a `SourceMember` record in that org. 50 | 51 | ### Reading Profiles using the CRUD Metadata API 52 | 53 | The CRUD Metadata API shows yet another behaviour: 54 | 55 | It returns a kind of "full" Profile independent of source tracking and even containing entries for metadata from Managed Packages etc. 56 | 57 | > [!WARNING] 58 | > Unfortunately Profiles might include `tabVisibilites` for tabs not available in the org (see [#66](https://github.com/amtrack/sfdx-plugin-source-read/issues/66)). 59 | > 60 | > Without further processing this will cause deployment errrors. 61 | -------------------------------------------------------------------------------- /bin/dev.cmd: -------------------------------------------------------------------------------- 1 | @echo off 2 | 3 | node --loader ts-node/esm --no-warnings=ExperimentalWarning "%~dp0\dev" %* 4 | -------------------------------------------------------------------------------- /bin/dev.js: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env -S node --loader ts-node/esm --no-warnings=ExperimentalWarning 2 | // eslint-disable-next-line node/shebang 3 | async function main() { 4 | const { execute } = await import("@oclif/core"); 5 | await execute({ development: true, dir: import.meta.url }); 6 | } 7 | 8 | await main(); 9 | -------------------------------------------------------------------------------- /bin/run.cmd: -------------------------------------------------------------------------------- 1 | @echo off 2 | 3 | node "%~dp0\run" %* 4 | -------------------------------------------------------------------------------- /bin/run.js: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env node 2 | 3 | // eslint-disable-next-line node/shebang 4 | async function main() { 5 | const { execute } = await import("@oclif/core"); 6 | await execute({ dir: import.meta.url }); 7 | } 8 | 9 | await main(); 10 | -------------------------------------------------------------------------------- /config/project-scratch-def.json: -------------------------------------------------------------------------------- 1 | { 2 | "orgName": "ACME", 3 | "edition": "Developer", 4 | "language": "en_US", 5 | "features": [], 6 | "settings": { 7 | "languageSettings": { 8 | "enableTranslationWorkbench": true 9 | } 10 | } 11 | } 12 | -------------------------------------------------------------------------------- /force-app/.gitkeep: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/amtrack/sfdx-plugin-source-read/605c8dcadbc2f6201fac3dfe841bb18e14b880a3/force-app/.gitkeep -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "sfdx-plugin-source-read", 3 | "description": "sfdx plugin to read Metadata e.g. full Profiles via CRUD Metadata API", 4 | "version": "0.0.0-development", 5 | "author": "Matthias Rolke @amtrack", 6 | "bugs": "https://github.com/amtrack/sfdx-plugin-source-read/issues", 7 | "type": "module", 8 | "bin": { 9 | "sfdx-plugin-source-read": "bin/run.js" 10 | }, 11 | "dependencies": { 12 | "@salesforce/sf-plugins-core": "12.2.2", 13 | "@salesforce/source-deploy-retrieve": "12.19.9" 14 | }, 15 | "devDependencies": { 16 | "@salesforce/dev-scripts": "11.0.2", 17 | "execa": "9.6.0", 18 | "oclif": "4.18.1" 19 | }, 20 | "exports": "./lib/index.js", 21 | "files": [ 22 | "/bin", 23 | "/lib", 24 | "/oclif.manifest.json" 25 | ], 26 | "homepage": "https://github.com/amtrack/sfdx-plugin-source-read", 27 | "keywords": [ 28 | "sfdx-plugin", 29 | "sf-plugin" 30 | ], 31 | "license": "MIT", 32 | "oclif": { 33 | "commands": "./lib/commands", 34 | "bin": "sf", 35 | "topicSeparator": " ", 36 | "additionalHelpFlags": [ 37 | "-h" 38 | ], 39 | "topics": { 40 | "crud-mdapi": { 41 | "description": "Work with the CRUD Metadata API." 42 | } 43 | } 44 | }, 45 | "mocha": { 46 | "loader": "ts-node/esm", 47 | "no-warnings": "ExperimentalWarning" 48 | }, 49 | "repository": "amtrack/sfdx-plugin-source-read", 50 | "scripts": { 51 | "build": "rm -rf lib && tsc -p . && oclif manifest", 52 | "prepack": "npm run build", 53 | "prepare": "npm run build", 54 | "test": "tsc -p test && mocha \"test/**/*.test.ts\"", 55 | "test:e2e": "tsc -p test && mocha --timeout 60000 \"test/**/*.e2e.ts\"" 56 | } 57 | } 58 | -------------------------------------------------------------------------------- /prettier.config.mjs: -------------------------------------------------------------------------------- 1 | import salesforcePrettierConfig from "@salesforce/prettier-config"; 2 | 3 | export default { 4 | ...salesforcePrettierConfig, 5 | singleQuote: false, 6 | }; 7 | -------------------------------------------------------------------------------- /renovate.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": ["config:recommended"] 3 | } 4 | -------------------------------------------------------------------------------- /sfdx-project.json: -------------------------------------------------------------------------------- 1 | { 2 | "packageDirectories": [ 3 | { 4 | "path": "force-app", 5 | "default": true 6 | } 7 | ], 8 | "namespace": "", 9 | "sfdcLoginUrl": "https://login.salesforce.com", 10 | "sourceApiVersion": "62.0" 11 | } 12 | -------------------------------------------------------------------------------- /sfdx-source/customobjecttranslations-with-fieldtranslations/objectTranslations/Dummy__c-en_US/Dummy__c-en_US.objectTranslation-meta.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | Dummy__c-en_US 4 | 5 | -------------------------------------------------------------------------------- /sfdx-source/customobjecttranslations-with-fieldtranslations/objectTranslations/Dummy__c-en_US/Type__c.fieldTranslation-meta.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | TEST help text 4 | 5 | Type__c 6 | 7 | -------------------------------------------------------------------------------- /sfdx-source/customobjecttranslations-with-fieldtranslations/objects/Dummy__c/Dummy__c.object-meta.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | Accept 5 | Default 6 | 7 | 8 | Accept 9 | Large 10 | Default 11 | 12 | 13 | Accept 14 | Small 15 | Default 16 | 17 | 18 | CancelEdit 19 | Default 20 | 21 | 22 | CancelEdit 23 | Large 24 | Default 25 | 26 | 27 | CancelEdit 28 | Small 29 | Default 30 | 31 | 32 | Clone 33 | Default 34 | 35 | 36 | Clone 37 | Large 38 | Default 39 | 40 | 41 | Clone 42 | Small 43 | Default 44 | 45 | 46 | Delete 47 | Default 48 | 49 | 50 | Delete 51 | Large 52 | Default 53 | 54 | 55 | Delete 56 | Small 57 | Default 58 | 59 | 60 | Edit 61 | Default 62 | 63 | 64 | Edit 65 | Large 66 | Default 67 | 68 | 69 | Edit 70 | Small 71 | Default 72 | 73 | 74 | List 75 | Default 76 | 77 | 78 | List 79 | Large 80 | Default 81 | 82 | 83 | List 84 | Small 85 | Default 86 | 87 | 88 | New 89 | Default 90 | 91 | 92 | New 93 | Large 94 | Default 95 | 96 | 97 | New 98 | Small 99 | Default 100 | 101 | 102 | SaveEdit 103 | Default 104 | 105 | 106 | SaveEdit 107 | Large 108 | Default 109 | 110 | 111 | SaveEdit 112 | Small 113 | Default 114 | 115 | 116 | Tab 117 | Default 118 | 119 | 120 | Tab 121 | Large 122 | Default 123 | 124 | 125 | Tab 126 | Small 127 | Default 128 | 129 | 130 | View 131 | Default 132 | 133 | 134 | View 135 | Large 136 | Default 137 | 138 | 139 | View 140 | Small 141 | Default 142 | 143 | false 144 | SYSTEM 145 | Deployed 146 | false 147 | true 148 | false 149 | false 150 | false 151 | false 152 | false 153 | true 154 | true 155 | Private 156 | 157 | 158 | 159 | Text 160 | 161 | Dummies 162 | 163 | ReadWrite 164 | Public 165 | 166 | -------------------------------------------------------------------------------- /sfdx-source/customobjecttranslations-with-fieldtranslations/objects/Dummy__c/fields/Type__c.field-meta.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | Type__c 4 | false 5 | Type 6 | 7 | false 8 | false 9 | Picklist 10 | 11 | true 12 | 13 | false 14 | 15 | One 16 | false 17 | 18 | 19 | 20 | Two 21 | false 22 | 23 | 24 | 25 | Three 26 | false 27 | 28 | 29 | 30 | 31 | 32 | -------------------------------------------------------------------------------- /sfdx-source/profile-with-field-permissions/objects/Account/Account.object-meta.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | -------------------------------------------------------------------------------- /sfdx-source/profile-with-field-permissions/objects/Account/fields/IsTest__c.field-meta.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | IsTest__c 4 | false 5 | description 6 | help text 7 | 8 | Checkbox 9 | 10 | -------------------------------------------------------------------------------- /sfdx-source/profile-with-field-permissions/profiles/Dummy.profile-meta.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | true 5 | Account.IsTest__c 6 | true 7 | 8 | true 9 | Salesforce 10 | 11 | -------------------------------------------------------------------------------- /sfdx-source/recordtypes-with-picklistvalues/objects/DummyWithRT__c/DummyWithRT__c.object-meta.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | Accept 5 | Default 6 | 7 | 8 | Accept 9 | Large 10 | Default 11 | 12 | 13 | Accept 14 | Small 15 | Default 16 | 17 | 18 | CancelEdit 19 | Default 20 | 21 | 22 | CancelEdit 23 | Large 24 | Default 25 | 26 | 27 | CancelEdit 28 | Small 29 | Default 30 | 31 | 32 | Clone 33 | Default 34 | 35 | 36 | Clone 37 | Large 38 | Default 39 | 40 | 41 | Clone 42 | Small 43 | Default 44 | 45 | 46 | Delete 47 | Default 48 | 49 | 50 | Delete 51 | Large 52 | Default 53 | 54 | 55 | Delete 56 | Small 57 | Default 58 | 59 | 60 | Edit 61 | Default 62 | 63 | 64 | Edit 65 | Large 66 | Default 67 | 68 | 69 | Edit 70 | Small 71 | Default 72 | 73 | 74 | List 75 | Default 76 | 77 | 78 | List 79 | Large 80 | Default 81 | 82 | 83 | List 84 | Small 85 | Default 86 | 87 | 88 | New 89 | Default 90 | 91 | 92 | New 93 | Large 94 | Default 95 | 96 | 97 | New 98 | Small 99 | Default 100 | 101 | 102 | SaveEdit 103 | Default 104 | 105 | 106 | SaveEdit 107 | Large 108 | Default 109 | 110 | 111 | SaveEdit 112 | Small 113 | Default 114 | 115 | 116 | Tab 117 | Default 118 | 119 | 120 | Tab 121 | Large 122 | Default 123 | 124 | 125 | Tab 126 | Small 127 | Default 128 | 129 | 130 | View 131 | Default 132 | 133 | 134 | View 135 | Large 136 | Default 137 | 138 | 139 | View 140 | Small 141 | Default 142 | 143 | false 144 | SYSTEM 145 | Deployed 146 | false 147 | true 148 | false 149 | false 150 | false 151 | false 152 | false 153 | true 154 | true 155 | Private 156 | 157 | 158 | 159 | Text 160 | 161 | Dummies with RTs 162 | 163 | ReadWrite 164 | Public 165 | 166 | -------------------------------------------------------------------------------- /sfdx-source/recordtypes-with-picklistvalues/objects/DummyWithRT__c/fields/Type__c.field-meta.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | Type__c 4 | false 5 | 6 | false 7 | false 8 | Picklist 9 | 10 | true 11 | 12 | false 13 | 14 | One 15 | false 16 | 17 | 18 | 19 | Two 20 | false 21 | 22 | 23 | 24 | Three 25 | false 26 | 27 | 28 | 29 | 30 | 31 | -------------------------------------------------------------------------------- /sfdx-source/recordtypes-with-picklistvalues/objects/DummyWithRT__c/recordTypes/DummyRecordType.recordType-meta.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | DummyRecordType 4 | true 5 | 6 | 7 | Type__c 8 | 9 | One 10 | false 11 | 12 | 13 | Three 14 | false 15 | 16 | 17 | Two 18 | false 19 | 20 | 21 | 22 | -------------------------------------------------------------------------------- /sfdx-source/recordtypes-with-picklistvalues/objects/DummyWithRT__c/recordTypes/DummyRecordType2.recordType-meta.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | DummyRecordType2 4 | true 5 | 6 | 7 | Type__c 8 | 9 | One 10 | false 11 | 12 | 13 | Three 14 | false 15 | 16 | 17 | Two 18 | false 19 | 20 | 21 | 22 | -------------------------------------------------------------------------------- /sfdx-source/translations-with-labels/labels/CustomLabels.labels-meta.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | Greeting 5 | en_US 6 | false 7 | A dummy label 8 | Hi 9 | 10 | 11 | -------------------------------------------------------------------------------- /sfdx-source/translations-with-labels/translations/en_US.translation-meta.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | Greeting 6 | 7 | 8 | -------------------------------------------------------------------------------- /src/commands/crud-mdapi/read.ts: -------------------------------------------------------------------------------- 1 | import { Flags, SfCommand } from "@salesforce/sf-plugins-core"; 2 | import { ComponentSetBuilder } from "@salesforce/source-deploy-retrieve"; 3 | import { readFromOrg } from "../../component-set.js"; 4 | import { writeComponentSetToDisk } from "../../component-set.js"; 5 | 6 | export class CrudMdapiRead extends SfCommand { 7 | public static readonly summary = "Read Metadata using the CRUD Metadata API"; 8 | public static readonly description = 9 | "Read Metadata e.g. full Profiles using the CRUD Metadata API, convert the JSON result to XML and write as source format to disk."; 10 | 11 | public static readonly examples = [ 12 | `$ <%= config.bin %> <%= command.id %> --metadata "Profile:Admin" --metadata "Profile:Standard"`, 13 | `$ <%= config.bin %> <%= command.id %> --metadata "RecordType:Account.Business"`, 14 | `$ <%= config.bin %> <%= command.id %> --metadata "CustomObjectTranslation:Task-de"`, 15 | `$ <%= config.bin %> <%= command.id %> --source-dir force-app/main/default/objects/Account/recordTypes/Business.recordType-meta.xml`, 16 | ]; 17 | 18 | public static readonly flags = { 19 | "target-org": Flags.requiredOrg(), 20 | metadata: Flags.string({ 21 | char: "m", 22 | summary: `Metadata component names to read.`, 23 | description: `Example values: 'RecordType:Account.Business', 'Profile:Admin'`, 24 | multiple: true, 25 | exclusive: ["manifest", "source-dir"], 26 | }), 27 | manifest: Flags.file({ 28 | char: "x", 29 | summary: 30 | "File path for the manifest (package.xml) that specifies the components to read.", 31 | exclusive: ["metadata", "source-dir"], 32 | exists: true, 33 | }), 34 | "source-dir": Flags.string({ 35 | char: "d", 36 | summary: `File paths for source to read from the org.`, 37 | description: `Example values: 'force-app/main/default/objects/Account/recordTypes/Business.recordType-meta.xml', 'force-app/main/default/profiles/Admin.profile-meta.xml'`, 38 | multiple: true, 39 | exclusive: ["manifest", "metadata"], 40 | }), 41 | "output-dir": Flags.directory({ 42 | char: "r", 43 | summary: "Directory root for the retrieved source files.", 44 | }), 45 | "chunk-size": Flags.integer({ 46 | summary: "Number of components to be read per API call.", 47 | description: 48 | "The limit for readMetadata() is 10. For CustomMetadata and CustomApplication only, the limit is 200.", 49 | max: 10, 50 | default: 10, 51 | }), 52 | }; 53 | 54 | public static readonly requiresProject = true; 55 | 56 | public async run(): Promise { 57 | const { flags } = await this.parse(CrudMdapiRead); 58 | 59 | // 1/4 build a ComponentSet from the flags 60 | const componentSet = await ComponentSetBuilder.build({ 61 | sourcepath: flags["source-dir"], 62 | ...(flags.manifest 63 | ? { 64 | manifest: { 65 | manifestPath: flags.manifest, 66 | directoryPaths: flags["output-dir"] 67 | ? [] 68 | : this.project 69 | .getUniquePackageDirectories() 70 | .map((dir) => dir.fullPath), 71 | }, 72 | } 73 | : {}), 74 | ...(flags.metadata 75 | ? { 76 | metadata: { 77 | metadataEntries: flags.metadata, 78 | directoryPaths: flags["output-dir"] 79 | ? [] 80 | : this.project 81 | .getUniquePackageDirectories() 82 | .map((dir) => dir.fullPath), 83 | }, 84 | } 85 | : {}), 86 | }); 87 | 88 | // 2/4 read the components from the org to a new ComponentSet 89 | const connection = flags["target-org"].getConnection(); 90 | const readComponentSet = await readFromOrg( 91 | componentSet, 92 | connection, 93 | flags["chunk-size"] 94 | ); 95 | 96 | // 3/4 write the components of the ComponentSet to disk 97 | const files = await writeComponentSetToDisk( 98 | readComponentSet, 99 | flags["output-dir"] ?? this.project.getDefaultPackage().path 100 | ); 101 | 102 | // 4/4 print the result: type, name and file path 103 | this.styledHeader("Read Source"); 104 | this.table({ 105 | data: files, 106 | columns: [ 107 | { name: "Name", key: "fullName" }, 108 | { name: "Type", key: "type" }, 109 | { name: "Path", key: "filePath" }, 110 | ], 111 | }); 112 | return { 113 | success: true, 114 | files, 115 | }; 116 | } 117 | } 118 | -------------------------------------------------------------------------------- /src/commands/force/source/read.ts: -------------------------------------------------------------------------------- 1 | import type { MetadataType } from "@jsforce/jsforce-node/lib/api/metadata.js"; 2 | import { 3 | Flags, 4 | SfCommand, 5 | requiredOrgFlagWithDeprecations, 6 | } from "@salesforce/sf-plugins-core"; 7 | import { 8 | ComponentSetBuilder, 9 | SourceComponent, 10 | } from "@salesforce/source-deploy-retrieve"; 11 | import { filePathsFromMetadataComponent } from "@salesforce/source-deploy-retrieve/lib/src/utils/filePathGenerator.js"; 12 | import { mkdir, writeFile } from "fs/promises"; 13 | import { dirname, join } from "path"; 14 | import { 15 | chunk, 16 | convertToXml, 17 | parseCommaSeparatedValues, 18 | } from "../../../utils.js"; 19 | 20 | export class SourceReadCommand extends SfCommand { 21 | public static state = "deprecated"; 22 | public static deprecationOptions = { 23 | message: `The 'sf force source read' command is deprecated and will be removed in the next major version. 24 | Please migrate to 'sf crud-mdapi read': https://github.com/amtrack/sfdx-plugin-source-read/wiki/Migration#sf-crud-mdapi-read`, 25 | }; 26 | public static readonly summary = "Read Metadata using the CRUD Metadata API"; 27 | public static readonly description = 28 | "Read Metadata e.g. full Profiles using the CRUD Metadata API"; 29 | 30 | public static readonly examples = [ 31 | `$ <%= config.bin %> <%= command.id %> -m "Profile:Admin"`, 32 | `$ <%= config.bin %> <%= command.id %> -m "RecordType:Account.Business"`, 33 | `$ <%= config.bin %> <%= command.id %> -m "CustomObjectTranslation:Task-de"`, 34 | `$ <%= config.bin %> <%= command.id %> -p force-app/main/default/objects/Account/recordTypes/Business.recordType-meta.xml`, 35 | ]; 36 | 37 | public static readonly flags = { 38 | "target-org": requiredOrgFlagWithDeprecations, 39 | metadata: Flags.string({ 40 | char: "m", 41 | summary: `comma-separated list of metadata component names 42 | Example: 'RecordType:Account.Business,Profile:Admin'`, 43 | }), 44 | sourcepath: Flags.string({ 45 | char: "p", 46 | summary: `comma-separated list of source file paths to retrieve 47 | Example: 'force-app/main/default/objects/Account/recordTypes/Business.recordType-meta.xml,force-app/main/default/profiles/Admin.profile-meta.xml'`, 48 | }), 49 | "chunk-size": Flags.integer({ 50 | summary: "number of components to be read per API call", 51 | description: 52 | "The limit for readMetadata() is 10. For CustomMetadata and CustomApplication only, the limit is 200.", 53 | max: 10, 54 | default: 10, 55 | }), 56 | }; 57 | 58 | public static readonly requiresProject = true; 59 | 60 | public async run(): Promise { 61 | const { flags } = await this.parse(SourceReadCommand); 62 | const conn = flags["target-org"].getConnection(); 63 | const packageDirectories = this.project.getPackageDirectories(); 64 | const defaultPackageDirectory = this.project.getDefaultPackage().path; 65 | const sourcePaths = packageDirectories.map((dir) => dir.path); 66 | const componentSet = await ComponentSetBuilder.build({ 67 | sourcepath: 68 | flags.sourcepath && parseCommaSeparatedValues(flags.sourcepath), 69 | ...(flags.metadata && { 70 | metadata: { 71 | metadataEntries: parseCommaSeparatedValues(flags.metadata), 72 | directoryPaths: sourcePaths, 73 | }, 74 | }), 75 | }); 76 | const manifestObject = await componentSet.getObject(); 77 | const sourceComponents = componentSet.getSourceComponents(); 78 | for (const typeMember of manifestObject.Package.types) { 79 | const typeName = typeMember.name; 80 | // https://developer.salesforce.com/docs/atlas.en-us.api_meta.meta/api_meta/meta_readMetadata.htm 81 | const chunkSize = 82 | typeName === "CustomApplication" || typeName === "CustomMetadata" 83 | ? 200 84 | : flags["chunk-size"]; 85 | for (const chunkOfMemberNames of chunk(typeMember.members, chunkSize)) { 86 | const componentNames = chunkOfMemberNames.map( 87 | (memberName) => `${typeName}:${memberName}` 88 | ); 89 | this.log("reading", `${componentNames.join(", ")}`, "..."); 90 | const metadataResults = await conn.metadata.read( 91 | typeName as MetadataType, 92 | chunkOfMemberNames 93 | ); 94 | for (const metadataResult of metadataResults) { 95 | let filePath; 96 | const component = 97 | sourceComponents.find( 98 | (cmp) => 99 | cmp.type.name === typeName && 100 | cmp.fullName === metadataResult.fullName 101 | ) || 102 | componentSet.find( 103 | (cmp) => 104 | cmp.type.name === typeName && 105 | cmp.fullName === metadataResult.fullName 106 | ); 107 | if (component instanceof SourceComponent) { 108 | filePath = component.xml; 109 | } else { 110 | filePath = filePathsFromMetadataComponent( 111 | component, 112 | join(defaultPackageDirectory, "main", "default") 113 | ).find((p) => p.endsWith(`.${component.type.suffix}-meta.xml`)); 114 | await mkdir(dirname(filePath), { recursive: true }); 115 | } 116 | await writeFile(filePath, convertToXml(component, metadataResult)); 117 | } 118 | } 119 | } 120 | return; 121 | } 122 | } 123 | -------------------------------------------------------------------------------- /src/component-set.ts: -------------------------------------------------------------------------------- 1 | import type { MetadataType as MetadataTypeName } from "@jsforce/jsforce-node/lib/api/metadata.js"; 2 | import type { Connection } from "@salesforce/core"; 3 | import { 4 | ComponentSet, 5 | MetadataConverter, 6 | RegistryAccess, 7 | type MetadataComponent, 8 | } from "@salesforce/source-deploy-retrieve"; 9 | import { fetchMetadataFromOrg } from "./crud-mdapi.js"; 10 | import { 11 | cloneSourceComponent, 12 | createSourceComponentWithMetadata, 13 | } from "./source-component.js"; 14 | import { chunk, determineMaxChunkSize, groupBy } from "./utils.js"; 15 | 16 | type File = { type: string; fullName: string; filePath: string }; 17 | 18 | export async function writeComponentSetToDisk( 19 | componentSet: ComponentSet, 20 | outputDirectory: string 21 | ): Promise { 22 | // NOTE: source-to-source conversion somehow produces incorrect file results for certain metadata types 23 | // Examples issues: 24 | // - Profile: Standard.profile-meta EmailServicesFunction force-app/main/default/profiles/Standard.profile-meta.xml-meta.xml 25 | // - Translation: de.translation-meta EmailServicesFunction foo/main/default/translations/de.translation-meta.xml-meta.xml 26 | // Workaround: make sure file paths don't end with -meta.xml 27 | const tempComponentSet = new ComponentSet(); 28 | for (const sourceComponent of componentSet.getSourceComponents()) { 29 | tempComponentSet.add( 30 | await cloneSourceComponent(sourceComponent, (filePath) => 31 | filePath.replace("-meta.xml", "") 32 | ) 33 | ); 34 | } 35 | const convertResult = await new MetadataConverter().convert( 36 | tempComponentSet, 37 | "source", 38 | { 39 | type: "merge", 40 | mergeWith: [], 41 | defaultDirectory: outputDirectory, 42 | } 43 | ); 44 | const files: File[] = convertResult.converted.map((c) => ({ 45 | fullName: c.fullName, 46 | type: c.type.name, 47 | filePath: c.xml, 48 | })); 49 | return files; 50 | } 51 | 52 | export async function readFromOrg( 53 | componentSet: ComponentSet, 54 | connection: Connection, 55 | maxChunkSize?: number 56 | ): Promise { 57 | const componentsByType = groupBy( 58 | componentSet.toArray(), 59 | (cmp) => cmp.type.name 60 | ); 61 | const resultSet = new ComponentSet(); 62 | const registry = new RegistryAccess(); 63 | 64 | for (const [typeName, metadataComponents] of Object.entries( 65 | componentsByType 66 | )) { 67 | const parentType = registry.getParentType(typeName); 68 | const metadataComponentsWithParents = addFakeParentToMetadataComponents( 69 | parentType, 70 | metadataComponents 71 | ); 72 | const chunkSize = 73 | maxChunkSize ?? determineMaxChunkSize(typeName as MetadataTypeName); 74 | 75 | for (const chunkOfComponents of chunk( 76 | metadataComponentsWithParents, 77 | chunkSize 78 | )) { 79 | const metadataResults = await fetchMetadataFromOrg( 80 | connection, 81 | typeName, 82 | chunkOfComponents.map((cmp) => cmp.fullName) 83 | ); 84 | for (const [index, metadataResult] of metadataResults.entries()) { 85 | const metadataComponent = chunkOfComponents[index]; 86 | if (!metadataResult?.fullName) { 87 | throw new Error( 88 | `Failed to retrieve ${metadataComponent.type.name}:${metadataComponent.fullName}` 89 | ); 90 | } 91 | const component = await createSourceComponentWithMetadata( 92 | metadataComponent, 93 | metadataResult 94 | ); 95 | resultSet.add(component); 96 | } 97 | } 98 | } 99 | 100 | return resultSet; 101 | } 102 | 103 | export function addFakeParentToMetadataComponents( 104 | parentType, 105 | metadataComponents: MetadataComponent[] 106 | ) { 107 | return !parentType 108 | ? metadataComponents 109 | : metadataComponents.map((mc) => { 110 | if (mc.parent) { 111 | return mc; 112 | } 113 | return { 114 | ...mc, 115 | parent: { 116 | // Is there a more reliable way to get parentName? 117 | fullName: mc.fullName.split(".")[0], 118 | type: parentType, 119 | }, 120 | }; 121 | }); 122 | } 123 | -------------------------------------------------------------------------------- /src/crud-mdapi.ts: -------------------------------------------------------------------------------- 1 | import type { MetadataType as MetadataTypeName } from "@jsforce/jsforce-node/lib/api/metadata.js"; 2 | import type { Connection } from "@salesforce/core"; 3 | 4 | export async function fetchMetadataFromOrg( 5 | connection: Connection, 6 | typeName: string, 7 | memberNames: string[] 8 | ) { 9 | const qualifiedNames = memberNames.map((name) => `${typeName}:${name}`); 10 | console.error("reading", qualifiedNames.join(", "), "..."); 11 | return await connection.metadata.read( 12 | typeName as MetadataTypeName, 13 | memberNames 14 | ); 15 | } 16 | -------------------------------------------------------------------------------- /src/index.ts: -------------------------------------------------------------------------------- 1 | export { writeComponentSetToDisk } from "./component-set.js"; 2 | export { createSourceComponentWithMetadata } from "./source-component.js"; 3 | -------------------------------------------------------------------------------- /src/source-component.ts: -------------------------------------------------------------------------------- 1 | import type { Metadata } from "@jsforce/jsforce-node/lib/api/metadata.js"; 2 | import { 3 | SourceComponent, 4 | ZipTreeContainer, 5 | type MetadataComponent, 6 | } from "@salesforce/source-deploy-retrieve"; 7 | import { 8 | JsToXml, 9 | ZipWriter, 10 | } from "@salesforce/source-deploy-retrieve/lib/src/convert/streams.js"; 11 | import { ComponentProperties } from "@salesforce/source-deploy-retrieve/lib/src/resolve/sourceComponent.js"; 12 | import { filePathsFromMetadataComponent } from "@salesforce/source-deploy-retrieve/lib/src/utils/filePathGenerator.js"; 13 | 14 | /** 15 | * Creates a SourceComponent with zipped content in Metadata format. 16 | * @param metadataComponent A MetadataComponent with parent if it has one 17 | * @param metadataResult The raw response of connection.metadata.read() 18 | * @returns SourceComponent with zipped content in Metadata format 19 | */ 20 | export async function createSourceComponentWithMetadata( 21 | metadataComponent: MetadataComponent, 22 | metadataResult: Metadata 23 | ): Promise { 24 | const filePaths = filePathsFromMetadataComponent(metadataComponent); 25 | const componentProps: ComponentProperties = { 26 | type: metadataComponent.type, 27 | name: metadataComponent.fullName, 28 | xml: filePaths.find((p) => 29 | p.endsWith(`.${metadataComponent.type.suffix}-meta.xml`) 30 | ), 31 | }; 32 | if (metadataComponent.parent) { 33 | componentProps.parentType = metadataComponent.parent.type; 34 | componentProps.parent = new SourceComponent({ 35 | type: metadataComponent.parent.type, 36 | name: metadataComponent.parent.fullName, 37 | }); 38 | // Is there a more reliable way to get childName? 39 | componentProps.name = componentProps.name.split(".")?.[1]; 40 | } 41 | const xmlObject = { 42 | [metadataComponent.type.name]: { 43 | ...metadataResult, 44 | ...(["CustomObject", "Workflow"].includes( 45 | metadataComponent.parent?.type.name 46 | ) 47 | ? { 48 | fullName: metadataComponent.fullName.split(".")[1], 49 | } 50 | : {}), 51 | }, 52 | }; 53 | const xmlStream = new JsToXml(xmlObject); 54 | const tree = await createZipContainer(xmlStream, componentProps.xml); 55 | return new SourceComponent(componentProps, tree); 56 | } 57 | 58 | async function createZipContainer( 59 | xmlStream: JsToXml, 60 | xmlPath: string 61 | ): Promise { 62 | const zipWriter = new ZipWriter(); 63 | zipWriter.addToZip(xmlStream, xmlPath); 64 | await new Promise((resolve, reject) => { 65 | zipWriter._final((err?) => { 66 | if (err) { 67 | console.error(err); 68 | return reject(err); 69 | } 70 | return resolve(); 71 | }); 72 | }); 73 | return ZipTreeContainer.create(zipWriter.buffer); 74 | } 75 | 76 | /** 77 | * Clones a SourceComponent to a new filePath. 78 | * @param sourceComponent 79 | * @param fn new filePath 80 | * @returns 81 | */ 82 | export async function cloneSourceComponent( 83 | sourceComponent: SourceComponent, 84 | fn: (filePath: string) => string 85 | ): Promise { 86 | if (!sourceComponent.xml) { 87 | return sourceComponent; 88 | } 89 | const xml = fn(sourceComponent.xml); 90 | let parent: SourceComponent | undefined; 91 | if (sourceComponent.parent) { 92 | parent = await cloneSourceComponent(sourceComponent.parent, fn); 93 | } 94 | const xmlObject = await sourceComponent.parseXml(sourceComponent.xml); 95 | const xmlStream = new JsToXml(xmlObject); 96 | const tree = await createZipContainer(xmlStream, xml); 97 | const clone = new SourceComponent( 98 | { 99 | ...sourceComponent, 100 | xml, 101 | ...(parent ? { parent } : {}), 102 | }, 103 | tree ?? tree 104 | ); 105 | return clone; 106 | } 107 | -------------------------------------------------------------------------------- /src/utils.ts: -------------------------------------------------------------------------------- 1 | import type { MetadataType as MetadataTypeName } from "@jsforce/jsforce-node/lib/api/metadata.js"; 2 | import { Builder } from "xml2js"; 3 | 4 | export function parseCommaSeparatedValues( 5 | commaSeparatedMetadataComponentNames 6 | ) { 7 | if (!commaSeparatedMetadataComponentNames) { 8 | return []; 9 | } 10 | return commaSeparatedMetadataComponentNames 11 | .split(",") 12 | .map((x) => x.trim()) 13 | .filter(Boolean); 14 | } 15 | 16 | export function convertToXml(component, data) { 17 | if (["CustomObject", "Workflow"].includes(component.parentType?.name)) { 18 | // remove first part of fullName separated by dot 19 | data.fullName = component.fullName.split(".")[1]; 20 | } else { 21 | delete data.fullName; 22 | } 23 | return ( 24 | new Builder({ 25 | xmldec: { 26 | version: "1.0", 27 | encoding: "UTF-8", 28 | }, 29 | rootName: component.type.name, 30 | renderOpts: { 31 | pretty: true, 32 | indent: " ", // 4 spaces 33 | newline: "\n", 34 | }, 35 | }).buildObject({ 36 | ...data, 37 | ...{ 38 | $: { 39 | xmlns: "http://soap.sforce.com/2006/04/metadata", 40 | }, 41 | }, 42 | }) + "\n" 43 | ); 44 | } 45 | 46 | export function chunk(input: T[], size: number): T[][] { 47 | return input.reduce((arr, item, idx) => { 48 | return idx % size === 0 49 | ? [...arr, [item]] 50 | : [...arr.slice(0, -1), [...arr.slice(-1)[0], item]]; 51 | }, [] as T[][]); 52 | } 53 | 54 | export function groupBy( 55 | array: T[], 56 | predicate: (value: T, index: number, array: T[]) => string 57 | ) { 58 | return array.reduce((acc, value, index, array) => { 59 | (acc[predicate(value, index, array)] ||= []).push(value); 60 | return acc; 61 | }, {} as { [key: string]: T[] }); 62 | } 63 | 64 | /** 65 | * Determine the maximum number of members that can be read in a single call 66 | * using the CRUD-based Metadata API according to the Salesforce documentation. 67 | * 68 | * > Limit: 10. (For CustomMetadata and CustomApplication only, the limit is 200.) 69 | * 70 | * Source: https://developer.salesforce.com/docs/atlas.en-us.api_meta.meta/api_meta/meta_readMetadata.htm 71 | * @param typeName The MetadataType name 72 | * @returns The maximum number of members that can be read in a single call 73 | */ 74 | export function determineMaxChunkSize(typeName: MetadataTypeName): number { 75 | const MAX_CHUNK_SIZE = 10; 76 | const MAX_CHUNK_SIZE_SPECIAL_TYPES = 200; 77 | const SPECIAL_TYPES = ["CustomApplication", "CustomMetadata"]; 78 | return SPECIAL_TYPES.includes(typeName) 79 | ? MAX_CHUNK_SIZE_SPECIAL_TYPES 80 | : MAX_CHUNK_SIZE; 81 | } 82 | -------------------------------------------------------------------------------- /test/commands/crud-mdapi/read.e2e.ts: -------------------------------------------------------------------------------- 1 | import { expect } from "chai"; 2 | import { execa } from "execa"; 3 | import { readFileSync, rmSync } from "node:fs"; 4 | import { join } from "node:path"; 5 | import { run } from "../../e2e.js"; 6 | 7 | const DEFAULT_PACKAGE_DIR = join("force-app", "main", "default"); 8 | 9 | describe("crud-mdapi read", () => { 10 | describe("CustomObjectTranslations with FieldTranslations", async () => { 11 | before("deploy", async function () { 12 | this.timeout(300 * 1000); 13 | await execa("sf", [ 14 | "project", 15 | "deploy", 16 | "start", 17 | "--source-dir", 18 | join("sfdx-source", "customobjecttranslations-with-fieldtranslations"), 19 | ]); 20 | }); 21 | it("reads CustomObjectTranslations with FieldTranslations", async () => { 22 | await run( 23 | `crud-mdapi read --metadata CustomObjectTranslation:Dummy__c-en_US` 24 | ); 25 | expect( 26 | readFileSync( 27 | join( 28 | DEFAULT_PACKAGE_DIR, 29 | "objectTranslations", 30 | "Dummy__c-en_US", 31 | "Type__c.fieldTranslation-meta.xml" 32 | ), 33 | "utf8" 34 | ).split("\n") 35 | ).to.contain(` TEST help text`); 36 | }); 37 | after("delete", async function () { 38 | this.timeout(300 * 1000); 39 | rmSync( 40 | join(DEFAULT_PACKAGE_DIR, "objectTranslations", "Dummy__c-en_US"), 41 | { 42 | recursive: true, 43 | } 44 | ); 45 | await execa("sf", [ 46 | "project", 47 | "delete", 48 | "source", 49 | "--no-prompt", 50 | "--metadata", 51 | "CustomObject:Dummy__c", 52 | ]); 53 | }); 54 | }); 55 | 56 | describe("Profile with field permissions", () => { 57 | before("deploy", async function () { 58 | this.timeout(300 * 1000); 59 | await execa("sf", [ 60 | "project", 61 | "deploy", 62 | "start", 63 | "--source-dir", 64 | join("sfdx-source", "profile-with-field-permissions"), 65 | ]); 66 | }); 67 | it("reads a Profile with field permissions", async () => { 68 | await run(`crud-mdapi read --metadata Profile:Dummy`); 69 | const lines = readFileSync( 70 | join(DEFAULT_PACKAGE_DIR, "profiles", "Dummy.profile-meta.xml"), 71 | "utf8" 72 | ).split("\n"); 73 | expect(lines[0]).to.equal(``); 74 | expect(lines[1]).to.match(/Account.IsTest__c`); 76 | }); 77 | after("delete", async function () { 78 | this.timeout(300 * 1000); 79 | await execa("sf", [ 80 | "project", 81 | "delete", 82 | "source", 83 | "--no-prompt", 84 | "--metadata", 85 | "CustomField:Account.IsTest__c", 86 | "--metadata", 87 | "Profile:Dummy", 88 | ]); 89 | }); 90 | }); 91 | 92 | describe("RecordTypes with Picklist values", async () => { 93 | before("deploy", async function () { 94 | this.timeout(300 * 1000); 95 | await execa("sf", [ 96 | "project", 97 | "deploy", 98 | "start", 99 | "--source-dir", 100 | join("sfdx-source", "recordtypes-with-picklistvalues"), 101 | ]); 102 | }); 103 | it("reads RecordTypes with Picklist values", async () => { 104 | await run( 105 | `crud-mdapi read --metadata RecordType:DummyWithRT__c.DummyRecordType --metadata RecordType:DummyWithRT__c.DummyRecordType2` 106 | ); 107 | expect( 108 | readFileSync( 109 | join( 110 | DEFAULT_PACKAGE_DIR, 111 | "objects", 112 | "DummyWithRT__c", 113 | "recordTypes", 114 | "DummyRecordType.recordType-meta.xml" 115 | ), 116 | "utf8" 117 | ).split("\n") 118 | ).to.contain(` Type__c`); 119 | expect( 120 | readFileSync( 121 | join( 122 | DEFAULT_PACKAGE_DIR, 123 | "objects", 124 | "DummyWithRT__c", 125 | "recordTypes", 126 | "DummyRecordType2.recordType-meta.xml" 127 | ), 128 | "utf8" 129 | ).split("\n") 130 | ).to.contain(` Type__c`); 131 | }); 132 | after("delete", async function () { 133 | this.timeout(300 * 1000); 134 | rmSync( 135 | join(DEFAULT_PACKAGE_DIR, "objects", "DummyWithRT__c", "recordTypes"), 136 | { recursive: true } 137 | ); 138 | await execa("sf", [ 139 | "project", 140 | "delete", 141 | "source", 142 | "--no-prompt", 143 | "--metadata", 144 | "CustomObject:DummyWithRT__c", 145 | ]); 146 | }); 147 | }); 148 | 149 | describe("Translations with CustomLabels with --output-dir", async () => { 150 | before("deploy", async function () { 151 | this.timeout(300 * 1000); 152 | await execa("sf", [ 153 | "project", 154 | "deploy", 155 | "start", 156 | "--source-dir", 157 | join("sfdx-source", "translations-with-labels"), 158 | ]); 159 | }); 160 | it("reads Translations with CustomLabels", async () => { 161 | await run( 162 | `crud-mdapi read --metadata Translations:en_US --output-dir tmp` 163 | ); 164 | expect( 165 | readFileSync( 166 | join( 167 | "tmp", 168 | "main", 169 | "default", 170 | "translations", 171 | "en_US.translation-meta.xml" 172 | ), 173 | "utf8" 174 | ).split("\n") 175 | ).to.contain(` `); 176 | }); 177 | after("delete", async function () { 178 | this.timeout(300 * 1000); 179 | await execa("sf", [ 180 | "project", 181 | "delete", 182 | "source", 183 | "--no-prompt", 184 | "--metadata", 185 | "CustomLabel:Greeting", 186 | ]); 187 | rmSync("tmp", { 188 | recursive: true, 189 | }); 190 | }); 191 | }); 192 | }); 193 | -------------------------------------------------------------------------------- /test/commands/force/source/read.e2e.ts: -------------------------------------------------------------------------------- 1 | import { expect } from "chai"; 2 | import { readFileSync, rmSync } from "node:fs"; 3 | import { join } from "node:path"; 4 | import { run } from "../../../e2e.js"; 5 | 6 | describe("E2E", () => { 7 | const expectedProfileFile = join( 8 | "force-app", 9 | "main", 10 | "default", 11 | "profiles", 12 | "Admin.profile-meta.xml" 13 | ); 14 | afterEach(() => { 15 | rmSync(expectedProfileFile, { force: true }); 16 | }); 17 | describe("force source read", () => { 18 | it("reads the Admin Profile", async () => { 19 | const result = await run(`force source read -m Profile:Admin`); 20 | expect(result.stdout).to.contain("reading Profile:Admin"); 21 | const profile = readFileSync(expectedProfileFile, "utf8"); 22 | const lines = profile.split("\n"); 23 | expect(lines[0]).to.equal(``); 24 | expect(lines[1]).to.equal( 25 | `` 26 | ); 27 | expect(lines[2]).to.equal(` `); 28 | expect(lines.length).to.be.greaterThan(100); 29 | }); 30 | }); 31 | }); 32 | -------------------------------------------------------------------------------- /test/component-set.test.ts: -------------------------------------------------------------------------------- 1 | import { ComponentSet } from "@salesforce/source-deploy-retrieve"; 2 | import { expect } from "chai"; 3 | import { readFileSync, rmSync } from "node:fs"; 4 | import { join } from "node:path"; 5 | import { writeComponentSetToDisk } from "../src/component-set.js"; 6 | import { createSourceComponentWithMetadata } from "../src/source-component.js"; 7 | import { 8 | customField, 9 | customFieldMetadataComponent, 10 | customObjectTranslation, 11 | customObjectTranslationMetadataComponent, 12 | translations, 13 | translationsMetadataComponent, 14 | } from "./fixtures/sourcecomponents.js"; 15 | 16 | describe("ComponentSet", () => { 17 | describe("writeComponentSetToDisk", () => { 18 | it("decomposes CustomObjectTranslation", async () => { 19 | const componentSet = new ComponentSet(); 20 | componentSet.add( 21 | await createSourceComponentWithMetadata( 22 | customObjectTranslationMetadataComponent, 23 | customObjectTranslation 24 | ) 25 | ); 26 | const files = await writeComponentSetToDisk(componentSet, "./tmp"); 27 | expect(files).to.have.length.greaterThanOrEqual(2); 28 | expect( 29 | readFileSync( 30 | join( 31 | "./tmp", 32 | "main", 33 | "default", 34 | "objectTranslations", 35 | "Dummy__c-en_US", 36 | "Dummy__c-en_US.objectTranslation-meta.xml" 37 | ), 38 | "utf8" 39 | ).split("\n") 40 | ).to.contain(` Dummy__c-en_US`); 41 | expect( 42 | readFileSync( 43 | join( 44 | "./tmp", 45 | "main", 46 | "default", 47 | "objectTranslations", 48 | "Dummy__c-en_US", 49 | "Type__c.fieldTranslation-meta.xml" 50 | ), 51 | "utf8" 52 | ).split("\n") 53 | ).to.contain(` Type__c`); 54 | }); 55 | it("writes Translations", async () => { 56 | const componentSet = new ComponentSet(); 57 | componentSet.add( 58 | await createSourceComponentWithMetadata( 59 | translationsMetadataComponent, 60 | translations 61 | ) 62 | ); 63 | const files = await writeComponentSetToDisk(componentSet, "./tmp"); 64 | expect(files).to.have.lengthOf(1); 65 | expect( 66 | readFileSync( 67 | join( 68 | "./tmp", 69 | "main", 70 | "default", 71 | "translations", 72 | "en_US.translation-meta.xml" 73 | ), 74 | "utf8" 75 | ).split("\n") 76 | ).to.contain(` `); 77 | }); 78 | it("writes a CustomField", async () => { 79 | const componentSet = new ComponentSet(); 80 | componentSet.add( 81 | await createSourceComponentWithMetadata( 82 | customFieldMetadataComponent, 83 | customField 84 | ) 85 | ); 86 | const files = await writeComponentSetToDisk(componentSet, "./tmp"); 87 | expect(files).to.have.lengthOf(1); 88 | expect( 89 | readFileSync( 90 | join( 91 | "./tmp", 92 | "main", 93 | "default", 94 | "objects", 95 | "Account", 96 | "fields", 97 | "Industry.field-meta.xml" 98 | ), 99 | "utf8" 100 | ).split("\n") 101 | ).to.contain(` Picklist`); 102 | }); 103 | afterEach(() => { 104 | rmSync("./tmp", { recursive: true, force: true }); 105 | }); 106 | }); 107 | }); 108 | -------------------------------------------------------------------------------- /test/e2e.ts: -------------------------------------------------------------------------------- 1 | import { execaCommand } from "execa"; 2 | import { resolve } from "node:path"; 3 | 4 | export async function run(pluginCommand) { 5 | return await execaCommand(`${resolve("bin", "run.js")} ${pluginCommand}`); 6 | } 7 | -------------------------------------------------------------------------------- /test/fixtures/Admin.profile-meta.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | false 4 | Salesforce 5 | 6 | true 7 | ViewSetup 8 | 9 | 10 | -------------------------------------------------------------------------------- /test/fixtures/Industry.field-meta.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | Industry 4 | false 5 | Picklist 6 | 7 | -------------------------------------------------------------------------------- /test/fixtures/read-record-types/metadata: -------------------------------------------------------------------------------- 1 | RecordType:DummyWithRT__c.DummyRecordType 2 | RecordType:DummyWithRT__c.DummyRecordType2 3 | -------------------------------------------------------------------------------- /test/fixtures/sourcecomponents.ts: -------------------------------------------------------------------------------- 1 | import { 2 | CustomField, 3 | CustomObject, 4 | CustomObjectTranslation, 5 | Translations, 6 | } from "@jsforce/jsforce-node/lib/api/metadata.js"; 7 | import { 8 | MetadataComponent, 9 | RegistryAccess, 10 | } from "@salesforce/source-deploy-retrieve"; 11 | 12 | const registry = new RegistryAccess(); 13 | 14 | export const customObjectMetadataComponent: MetadataComponent = { 15 | fullName: "Account", 16 | type: registry.getTypeByName("CustomObject"), 17 | }; 18 | export const customObject: CustomObject = { 19 | fullName: "Account", 20 | actionOverrides: [], 21 | businessProcesses: [], 22 | compactLayouts: [], 23 | fieldSets: [], 24 | fields: [], 25 | indexes: [], 26 | listViews: [], 27 | profileSearchLayouts: [], 28 | recordTypes: [], 29 | sharingReasons: [], 30 | sharingRecalculations: [], 31 | validationRules: [], 32 | webLinks: [], 33 | }; 34 | 35 | export const customObjectTranslationMetadataComponent: MetadataComponent = { 36 | fullName: "Dummy__c-en_US", 37 | type: registry.getTypeByName("CustomObjectTranslation"), 38 | }; 39 | 40 | export const customObjectTranslation: CustomObjectTranslation = { 41 | fullName: "Dummy__c-en_US", 42 | fields: [ 43 | { 44 | help: "TEST help text", 45 | label: "TEST Type", 46 | name: "Type__c", 47 | caseValues: [], 48 | picklistValues: [], 49 | }, 50 | ], 51 | caseValues: [], 52 | fieldSets: [], 53 | layouts: [], 54 | quickActions: [], 55 | recordTypes: [], 56 | sharingReasons: [], 57 | standardFields: [], 58 | validationRules: [], 59 | webLinks: [], 60 | workflowTasks: [], 61 | }; 62 | 63 | export const customFieldMetadataComponent: MetadataComponent = { 64 | fullName: "Account.Industry", 65 | type: registry.getTypeByName("CustomField"), 66 | parent: { 67 | fullName: "Account", 68 | type: registry.getTypeByName("CustomObject"), 69 | }, 70 | }; 71 | 72 | export const customField: CustomField = { 73 | fullName: "Account.Industry", 74 | summaryFilterItems: [], 75 | trackFeedHistory: false, 76 | type: "Picklist", 77 | }; 78 | 79 | export const translationsMetadataComponent: MetadataComponent = { 80 | fullName: "en_US", 81 | type: registry.getTypeByName("Translations"), 82 | }; 83 | 84 | export const translations: Translations = { 85 | fullName: "en_US", 86 | customApplications: [], 87 | customDataTypeTranslations: [], 88 | customLabels: [ 89 | { 90 | label: "Hello", 91 | name: "Greeting", 92 | }, 93 | ], 94 | customPageWebLinks: [], 95 | customTabs: [], 96 | flowDefinitions: [], 97 | quickActions: [], 98 | reportTypes: [], 99 | scontrols: [], 100 | }; 101 | -------------------------------------------------------------------------------- /test/source-component.test.ts: -------------------------------------------------------------------------------- 1 | import { expect } from "chai"; 2 | import { 3 | cloneSourceComponent, 4 | createSourceComponentWithMetadata, 5 | } from "../src/source-component.js"; 6 | import { 7 | customField, 8 | customFieldMetadataComponent, 9 | customObject, 10 | customObjectMetadataComponent, 11 | } from "./fixtures/sourcecomponents.js"; 12 | 13 | describe("SourceComponent", () => { 14 | describe("createSourceComponentWithMetadata", () => { 15 | it("standalone CustomObject", async () => { 16 | const sourceComponent = await createSourceComponentWithMetadata( 17 | customObjectMetadataComponent, 18 | customObject 19 | ); 20 | expect(sourceComponent).to.have.property("fullName", "Account"); 21 | expect(sourceComponent.type).to.have.property("name", "CustomObject"); 22 | expect(sourceComponent).to.have.property( 23 | "xml", 24 | "objects/Account/Account.object-meta.xml" 25 | ); 26 | }); 27 | 28 | it("CustomField with auto-inferred parent", async () => { 29 | const sourceComponent = await createSourceComponentWithMetadata( 30 | customFieldMetadataComponent, 31 | customField 32 | ); 33 | expect(sourceComponent).to.have.property("fullName", "Account.Industry"); 34 | expect(sourceComponent.type).to.have.property("name", "CustomField"); 35 | expect(sourceComponent).to.have.property( 36 | "xml", 37 | "objects/Account/fields/Industry.field-meta.xml" 38 | ); 39 | expect(sourceComponent).to.have.property("parent"); 40 | expect(sourceComponent.parent).to.have.property("fullName", "Account"); 41 | // fake source components don't have xml and tree 42 | expect(sourceComponent.parent).to.have.property("xml", undefined); 43 | }); 44 | }); 45 | 46 | describe("cloneSourceComponent", () => { 47 | it("removes -meta.xml", async () => { 48 | const sourceComponent = await createSourceComponentWithMetadata( 49 | customFieldMetadataComponent, 50 | customField 51 | ); 52 | const adjustedSourceComponent = await cloneSourceComponent( 53 | sourceComponent, 54 | (filePath) => filePath.replace("-meta.xml", "") 55 | ); 56 | expect(adjustedSourceComponent).to.have.property( 57 | "xml", 58 | "objects/Account/fields/Industry.field" 59 | ); 60 | expect(adjustedSourceComponent.parent).to.have.property("xml", undefined); 61 | // original source component is unchanged 62 | expect(sourceComponent).to.have.property( 63 | "xml", 64 | "objects/Account/fields/Industry.field-meta.xml" 65 | ); 66 | }); 67 | }); 68 | }); 69 | -------------------------------------------------------------------------------- /test/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "@salesforce/dev-config/tsconfig-test-esm.json", 3 | "include": ["./**/*.ts"], 4 | "compilerOptions": { 5 | "skipLibCheck": true 6 | } 7 | } 8 | -------------------------------------------------------------------------------- /test/utils.test.ts: -------------------------------------------------------------------------------- 1 | import { expect } from "chai"; 2 | import { readFileSync } from "node:fs"; 3 | import { join } from "node:path"; 4 | import { 5 | chunk, 6 | convertToXml, 7 | groupBy, 8 | parseCommaSeparatedValues, 9 | } from "../src/utils.js"; 10 | 11 | describe("utils", () => { 12 | describe("parseCommaSeparatedValues", () => { 13 | it("should parse", () => { 14 | expect(parseCommaSeparatedValues("foo-bar,baz,bazn")).to.deep.equal([ 15 | "foo-bar", 16 | "baz", 17 | "bazn", 18 | ]); 19 | }); 20 | it("should parse an empty string", () => { 21 | expect(parseCommaSeparatedValues("")).to.deep.equal([]); 22 | }); 23 | }); 24 | describe("convertToXml", () => { 25 | it("should convert a Profile", () => { 26 | const component = { 27 | fullName: "Admin", 28 | type: { 29 | name: "Profile", 30 | }, 31 | }; 32 | const data = { 33 | custom: false, 34 | userLicense: "Salesforce", 35 | userPermissions: [ 36 | { 37 | enabled: true, 38 | name: "ViewSetup", 39 | }, 40 | ], 41 | }; 42 | const mdXml = readFileSync( 43 | join("test", "fixtures", "Admin.profile-meta.xml"), 44 | "utf8" 45 | ); 46 | expect(convertToXml(component, data)).to.deep.equal(mdXml); 47 | }); 48 | it("should convert a CustomField", () => { 49 | const component = { 50 | fullName: "Account.Industry", 51 | type: { 52 | name: "CustomField", 53 | }, 54 | parentType: { 55 | name: "CustomObject", 56 | }, 57 | }; 58 | const data = { 59 | fullName: "Account.Industry", 60 | trackFeedHistory: false, 61 | type: "Picklist", 62 | }; 63 | const mdXml = readFileSync( 64 | join("test", "fixtures", "Industry.field-meta.xml"), 65 | "utf8" 66 | ); 67 | expect(convertToXml(component, data)).to.deep.equal(mdXml); 68 | }); 69 | }); 70 | describe("chunk", () => { 71 | it("should split an array into chunks of 2", () => { 72 | expect(chunk([1, 2, 3, 4, 5], 2)).to.deep.equal([[1, 2], [3, 4], [5]]); 73 | }); 74 | }); 75 | describe("groupBy", () => { 76 | it("should group an array by odd/even", () => { 77 | expect( 78 | groupBy([1, 2, 3, 4, 5], (item) => (item % 2 === 0 ? "even" : "odd")) 79 | ).to.deep.equal({ even: [2, 4], odd: [1, 3, 5] }); 80 | }); 81 | }); 82 | }); 83 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "@salesforce/dev-config/tsconfig-esm.json", 3 | "compilerOptions": { 4 | "outDir": "lib", 5 | "rootDir": "src", 6 | "skipLibCheck": true 7 | }, 8 | "include": ["./src/**/*.ts"] 9 | } 10 | --------------------------------------------------------------------------------