├── .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 |
--------------------------------------------------------------------------------