diff --git a/.circleci/config.yml b/.circleci/config.yml index 0127a96ed..6c303f370 100644 --- a/.circleci/config.yml +++ b/.circleci/config.yml @@ -78,6 +78,7 @@ workflows: - 'yarn test:nuts:manifest:create' - 'yarn test:nuts:retrieve' - 'yarn test:nuts:specialTypes' + - 'yarn test:nuts:deploy:destructive' - release-management/release-package: sign: true github-release: true diff --git a/command-snapshot.json b/command-snapshot.json index 275c91f50..ebd5fbb17 100644 --- a/command-snapshot.json +++ b/command-snapshot.json @@ -33,6 +33,8 @@ "loglevel", "manifest", "metadata", + "postdestructivechanges", + "predestructivechanges", "runtests", "soapdeploy", "sourcepath", diff --git a/messages/deploy.json b/messages/deploy.json index a546b350d..4d3b7f077 100644 --- a/messages/deploy.json +++ b/messages/deploy.json @@ -1,5 +1,5 @@ { - "description": "deploy source to an org\nUse this command to deploy source (metadata that’s in source format) to an org.\nTo take advantage of change tracking with scratch orgs, use \"sfdx force:source:push\".\nTo deploy metadata that’s in metadata format, use \"sfdx force:mdapi:deploy\".\n\nThe source you deploy overwrites the corresponding metadata in your org. This command does not attempt to merge your source with the versions in your org.\n\nTo run the command asynchronously, set --wait to 0, which immediately returns the job ID. This way, you can continue to use the CLI.\nTo check the status of the job, use force:source:deploy:report.\n\nIf the comma-separated list you’re supplying contains spaces, enclose the entire comma-separated list in one set of double quotes. On Windows, if the list contains commas, also enclose the entire list in one set of double quotes.\n", + "description": "deploy source to an org\nUse this command to deploy source (metadata that’s in source format) to an org.\nTo take advantage of change tracking with scratch orgs, use \"sfdx force:source:push\".\nTo deploy metadata that’s in metadata format, use \"sfdx force:mdapi:deploy\".\n\nThe source you deploy overwrites the corresponding metadata in your org. This command does not attempt to merge your source with the versions in your org.\n\nTo run the command asynchronously, set --wait to 0, which immediately returns the job ID. This way, you can continue to use the CLI.\nTo check the status of the job, use force:source:deploy:report.\n\nIf the comma-separated list you’re supplying contains spaces, enclose the entire comma-separated list in one set of double quotes. On Windows, if the list contains commas, also enclose the entire list in one set of double quotes.\n If you use the --manifest, --predestructivechanges, or --postdestructivechanges parameters, run the force:source:manifest:create command to easily generate the different types of manifest files.", "examples": [ "To deploy the source files in a directory:\n\t $ sfdx force:source:deploy -p path/to/source", "To deploy a specific Apex class and the objects whose source is in a directory: \n\t$ sfdx force:source:deploy -p \"path/to/apex/classes/MyClass.cls,path/to/source/objects\"", @@ -11,7 +11,9 @@ "To deploy all components listed in a manifest:\n $ sfdx force:source:deploy -x path/to/package.xml", "To run the tests that aren’t in any managed packages as part of a deployment:\n $ sfdx force:source:deploy -m ApexClass -l RunLocalTests", "To check whether a deployment would succeed (to prepare for Quick Deploy):\n $ sfdx force:source:deploy -m ApexClass -l RunAllTestsInOrg -c", - "To deploy an already validated deployment (Quick Deploy):\n $ sfdx force:source:deploy -q 0Af9A00000FTM6pSAH`," + "To deploy an already validated deployment (Quick Deploy):\n $ sfdx force:source:deploy -q 0Af9A00000FTM6pSAH`", + "To run a destructive operation before the deploy occurs:\n $ sfdx force:source:deploy --manifest package.xml --predestructivechanges destructiveChangesPre.xml", + "To run a destructive operation after the deploy occurs:\n $ sfdx force:source:deploy --manifest package.xml --postdestructivechanges destructiveChangesPost.xml" ], "flags": { "sourcePath": "comma-separated list of source file paths to deploy", @@ -25,7 +27,9 @@ "ignoreErrors": "ignore any errors and do not roll back deployment", "ignoreWarnings": "whether a warning will allow a deployment to complete successfully", "validateDeployRequestId": "deploy request ID of the validated deployment to run a Quick Deploy", - "soapDeploy": "deploy metadata with SOAP API instead of REST API" + "soapDeploy": "deploy metadata with SOAP API instead of REST API", + "predestructivechanges": "file path for a manifest (destructiveChangesPre.xml) of components to delete before the deploy", + "postdestructivechanges": "file path for a manifest (destructiveChangesPost.xml) of components to delete after the deploy" }, "flagsLong": { "sourcePath": [ diff --git a/package.json b/package.json index 938c3b25b..b9ed7420d 100644 --- a/package.json +++ b/package.json @@ -8,7 +8,7 @@ "@oclif/config": "^1", "@salesforce/command": "^4.1.3", "@salesforce/core": "^2.28.0", - "@salesforce/source-deploy-retrieve": "^4.5.7", + "@salesforce/source-deploy-retrieve": "^5.0.0", "chalk": "^4.1.2", "cli-ux": "^5.6.3", "open": "^8.2.1", @@ -152,6 +152,7 @@ "test:nuts:retrieve": "cross-env PLUGIN_SOURCE_SEED_FILTER=retrieve ts-node ./test/nuts/generateNuts.ts && mocha \"test/nuts/generated/*.nut.ts\" --slow 4500 --timeout 600000 --parallel --retries 0", "test:nuts:specialTypes": "mocha \"test/nuts/territory2.nut.ts\" \"test/nuts/folderTypes.nut.ts\" --slow 4500 --timeout 600000 --retries 0 --parallel", "test:nuts:territory2": "mocha \"test/nuts/territory2.nut.ts\" --slow 4500 --timeout 600000 --retries 0", + "test:nuts:deploy:destructive": "mocha \"test/nuts/deployDestructive.nut.ts\" --slow 3000 --timeout 600000 --parallel --retries 0", "version": "oclif-dev readme" }, "husky": { diff --git a/src/commands/force/source/delete.ts b/src/commands/force/source/delete.ts index 8a39dba21..3e24f25d5 100644 --- a/src/commands/force/source/delete.ts +++ b/src/commands/force/source/delete.ts @@ -9,7 +9,13 @@ import * as fs from 'fs'; import { confirm } from 'cli-ux/lib/prompt'; import { flags, FlagsConfig } from '@salesforce/command'; import { Messages } from '@salesforce/core'; -import { ComponentSet, MetadataComponent, RequestStatus, SourceComponent } from '@salesforce/source-deploy-retrieve'; +import { + ComponentSet, + DestructiveChangesType, + MetadataComponent, + RequestStatus, + SourceComponent, +} from '@salesforce/source-deploy-retrieve'; import { Duration, env, once } from '@salesforce/kit'; import { getString } from '@salesforce/ts-types'; import { DeployCommand } from '../../../deployCommand'; @@ -119,10 +125,10 @@ export class Delete extends DeployCommand { const cs = new ComponentSet([]); this.components.map((component) => { if (component instanceof SourceComponent) { - cs.add(component, true); + cs.add(component, DestructiveChangesType.POST); } else { // a remote-only delete - cs.add(new SourceComponent({ name: component.fullName, type: component.type }), true); + cs.add(new SourceComponent({ name: component.fullName, type: component.type }), DestructiveChangesType.POST); } }); this.componentSet = cs; diff --git a/src/commands/force/source/deploy.ts b/src/commands/force/source/deploy.ts index 093d5aa78..8913b57f8 100644 --- a/src/commands/force/source/deploy.ts +++ b/src/commands/force/source/deploy.ts @@ -8,12 +8,11 @@ import * as os from 'os'; import { flags, FlagsConfig } from '@salesforce/command'; import { Messages } from '@salesforce/core'; import { AsyncResult, DeployResult, RequestStatus } from '@salesforce/source-deploy-retrieve'; -import { Duration } from '@salesforce/kit'; +import { Duration, env, once } from '@salesforce/kit'; import { getString, isString } from '@salesforce/ts-types'; -import { env, once } from '@salesforce/kit'; import { DeployCommand } from '../../../deployCommand'; import { ComponentSetBuilder } from '../../../componentSetBuilder'; -import { DeployResultFormatter, DeployCommandResult } from '../../../formatters/deployResultFormatter'; +import { DeployCommandResult, DeployResultFormatter } from '../../../formatters/deployResultFormatter'; import { DeployAsyncResultFormatter, DeployCommandAsyncResult } from '../../../formatters/deployAsyncResultFormatter'; import { ProgressFormatter } from '../../../formatters/progressFormatter'; import { DeployProgressBarFormatter } from '../../../formatters/deployProgressBarFormatter'; @@ -107,6 +106,14 @@ export class Deploy extends DeployCommand { longDescription: messages.getMessage('flagsLong.manifest'), exclusive: ['metadata', 'sourcepath'], }), + predestructivechanges: flags.filepath({ + description: messages.getMessage('flags.predestructivechanges'), + dependsOn: ['manifest'], + }), + postdestructivechanges: flags.filepath({ + description: messages.getMessage('flags.postdestructivechanges'), + dependsOn: ['manifest'], + }), }; protected xorFlags = ['manifest', 'metadata', 'sourcepath', 'validateddeployrequestid']; protected readonly lifecycleEventNames = ['predeploy', 'postdeploy']; @@ -149,6 +156,8 @@ export class Deploy extends DeployCommand { manifest: this.flags.manifest && { manifestPath: this.getFlag('manifest'), directoryPaths: this.getPackageDirs(), + destructiveChangesPre: this.getFlag('predestructivechanges'), + destructiveChangesPost: this.getFlag('postdestructivechanges'), }, metadata: this.flags.metadata && { metadataEntries: this.getFlag('metadata'), diff --git a/src/componentSetBuilder.ts b/src/componentSetBuilder.ts index 04e3d9e13..81694963d 100644 --- a/src/componentSetBuilder.ts +++ b/src/componentSetBuilder.ts @@ -7,11 +7,13 @@ import * as path from 'path'; import { ComponentSet, RegistryAccess } from '@salesforce/source-deploy-retrieve'; -import { fs, SfdxError, Logger } from '@salesforce/core'; +import { fs, Logger, SfdxError } from '@salesforce/core'; export type ManifestOption = { manifestPath: string; directoryPaths: string[]; + destructiveChangesPre?: string; + destructiveChangesPost?: string; }; export type MetadataOption = { metadataEntries: string[]; @@ -21,6 +23,7 @@ export type ComponentSetOptions = { packagenames?: string[]; sourcepath?: string[]; manifest?: ManifestOption; + metadata?: MetadataOption; apiversion?: string; sourceapiversion?: string; @@ -68,6 +71,8 @@ export class ComponentSetBuilder { manifestPath: manifest.manifestPath, resolveSourcePaths: options.manifest.directoryPaths, forceAddWildcards: true, + destructivePre: options.manifest.destructiveChangesPre, + destructivePost: options.manifest.destructiveChangesPost, }); } diff --git a/src/deployCommand.ts b/src/deployCommand.ts index ad0017f5b..cb83c8e18 100644 --- a/src/deployCommand.ts +++ b/src/deployCommand.ts @@ -6,7 +6,7 @@ */ import { ComponentSet, DeployResult, MetadataApiDeployStatus } from '@salesforce/source-deploy-retrieve'; -import { SfdxError, ConfigFile, ConfigAggregator, PollingClient, StatusResult } from '@salesforce/core'; +import { ConfigAggregator, ConfigFile, PollingClient, SfdxError, StatusResult } from '@salesforce/core'; import { AnyJson, asString, getBoolean } from '@salesforce/ts-types'; import { Duration, once } from '@salesforce/kit'; import { SourceCommand } from './sourceCommand'; @@ -21,7 +21,6 @@ export abstract class DeployCommand extends SourceCommand { }); protected deployResult: DeployResult; - /** * Request a report of an in-progess or completed deployment. * diff --git a/src/formatters/deployResultFormatter.ts b/src/formatters/deployResultFormatter.ts index 5baf112fb..ab51f1cf7 100644 --- a/src/formatters/deployResultFormatter.ts +++ b/src/formatters/deployResultFormatter.ts @@ -8,14 +8,14 @@ import * as chalk from 'chalk'; import { UX } from '@salesforce/command'; import { Logger, Messages, SfdxError } from '@salesforce/core'; -import { get, getBoolean, getString, getNumber, asString } from '@salesforce/ts-types'; +import { asString, get, getBoolean, getNumber, getString } from '@salesforce/ts-types'; import { - DeployResult, CodeCoverage, + DeployMessage, + DeployResult, FileResponse, MetadataApiDeployStatus, RequestStatus, - DeployMessage, } from '@salesforce/source-deploy-retrieve'; import { ResultFormatter, ResultFormatterOptions, toArray } from './resultFormatter'; @@ -76,6 +76,7 @@ export class DeployResultFormatter extends ResultFormatter { throw new SfdxError(messages.getMessage('deployCanceled', [canceledByName]), 'DeployFailed'); } this.displaySuccesses(); + this.displayDeletions(); this.displayFailures(); this.displayTestResults(); @@ -107,7 +108,7 @@ export class DeployResultFormatter extends ResultFormatter { protected displaySuccesses(): void { if (this.isSuccess() && this.fileResponses?.length) { - const successes = this.fileResponses.filter((f) => f.state !== 'Failed'); + const successes = this.fileResponses.filter((f) => !['Failed', 'Deleted'].includes(f.state)); if (!successes.length) { return; } @@ -126,6 +127,25 @@ export class DeployResultFormatter extends ResultFormatter { } } + protected displayDeletions(): void { + const deletions = this.fileResponses.filter((f) => f.state === 'Deleted'); + if (!deletions.length) { + return; + } + this.sortFileResponses(deletions); + this.asRelativePaths(deletions); + + this.ux.log(''); + this.ux.styledHeader(chalk.blue('Deleted Source')); + this.ux.table(deletions, { + columns: [ + { key: 'fullName', label: 'FULL NAME' }, + { key: 'type', label: 'TYPE' }, + { key: 'filePath', label: 'PROJECT PATH' }, + ], + }); + } + protected displayFailures(): void { if (this.hasStatus(RequestStatus.Failed)) { const failures: Array = []; diff --git a/test/commands/source/componentSetBuilder.test.ts b/test/commands/source/componentSetBuilder.test.ts index 5794964ed..8043f84bb 100644 --- a/test/commands/source/componentSetBuilder.test.ts +++ b/test/commands/source/componentSetBuilder.test.ts @@ -321,6 +321,8 @@ describe('ComponentSetBuilder', () => { forceAddWildcards: true, manifestPath: options.manifest.manifestPath, resolveSourcePaths: [packageDir1], + destructivePre: undefined, + destructivePost: undefined, }); expect(compSet.size).to.equal(1); expect(compSet.has(apexClassComponent)).to.equal(true); @@ -348,6 +350,8 @@ describe('ComponentSetBuilder', () => { forceAddWildcards: true, manifestPath: options.manifest.manifestPath, resolveSourcePaths: [packageDir1, packageDir2], + destructivePre: undefined, + destructivePost: undefined, }); expect(compSet.size).to.equal(2); expect(compSet.has(apexClassComponent)).to.equal(true); diff --git a/test/commands/source/deploy.test.ts b/test/commands/source/deploy.test.ts index 21c7f43ad..806369729 100644 --- a/test/commands/source/deploy.test.ts +++ b/test/commands/source/deploy.test.ts @@ -10,14 +10,14 @@ import * as sinon from 'sinon'; import { expect } from 'chai'; import { MetadataApiDeployOptions } from '@salesforce/source-deploy-retrieve'; import { fromStub, stubInterface, stubMethod } from '@salesforce/ts-sinon'; -import { ConfigAggregator, Lifecycle, Org, SfdxProject, Messages } from '@salesforce/core'; +import { ConfigAggregator, Lifecycle, Messages, Org, SfdxProject } from '@salesforce/core'; import { UX } from '@salesforce/command'; import { IConfig } from '@oclif/config'; import { Deploy } from '../../../src/commands/force/source/deploy'; import { DeployCommandResult, DeployResultFormatter } from '../../../src/formatters/deployResultFormatter'; import { - DeployCommandAsyncResult, DeployAsyncResultFormatter, + DeployCommandAsyncResult, } from '../../../src/formatters/deployAsyncResultFormatter'; import { ComponentSetBuilder, ComponentSetOptions } from '../../../src/componentSetBuilder'; import { DeployProgressBarFormatter } from '../../../src/formatters/deployProgressBarFormatter'; @@ -223,6 +223,8 @@ describe('force:source:deploy', () => { manifest: { manifestPath: manifest, directoryPaths: [defaultDir], + destructiveChangesPost: undefined, + destructiveChangesPre: undefined, }, }); ensureDeployArgs(); @@ -240,6 +242,8 @@ describe('force:source:deploy', () => { manifest: { manifestPath: manifest, directoryPaths: [defaultDir], + destructiveChangesPost: undefined, + destructiveChangesPre: undefined, }, }); ensureDeployArgs(); @@ -258,6 +262,8 @@ describe('force:source:deploy', () => { manifest: { manifestPath: manifest, directoryPaths: [defaultDir], + destructiveChangesPost: undefined, + destructiveChangesPre: undefined, }, }); ensureDeployArgs(); @@ -284,6 +290,8 @@ describe('force:source:deploy', () => { manifest: { manifestPath: manifest, directoryPaths: [defaultDir], + destructiveChangesPost: undefined, + destructiveChangesPre: undefined, }, }); diff --git a/test/nuts/deployDestructive.nut.ts b/test/nuts/deployDestructive.nut.ts new file mode 100644 index 000000000..11a64588c --- /dev/null +++ b/test/nuts/deployDestructive.nut.ts @@ -0,0 +1,164 @@ +/* + * Copyright (c) 2020, salesforce.com, inc. + * All rights reserved. + * Licensed under the BSD 3-Clause license. + * For full license text, see LICENSE.txt file in the repo root or https://opensource.org/licenses/BSD-3-Clause + */ + +import * as path from 'path'; +import * as os from 'os'; +import { expect } from 'chai'; +import { execCmd } from '@salesforce/cli-plugins-testkit'; +import { SourceTestkit } from '@salesforce/source-testkit'; +import { AuthInfo, Connection } from '@salesforce/core'; + +describe('source:delete NUTs', () => { + const executable = path.join(process.cwd(), 'bin', 'run'); + let testkit: SourceTestkit; + + const createApexClass = (apexName = 'myApexClass') => { + // create and deploy an ApexClass that can be deleted without dependency issues + const output = path.join('force-app', 'main', 'default', 'classes'); + const pathToClass = path.join(testkit.projectDir, output, `${apexName}.cls`); + execCmd(`force:apex:class:create --classname ${apexName} --outputdir ${output}`); + execCmd(`force:source:deploy -m ApexClass:${apexName}`); + return { apexName, output, pathToClass }; + }; + + const createManifest = (metadata: string, manifesttype: string) => { + execCmd(`force:source:manifest:create --metadata ${metadata} --manifesttype ${manifesttype}`); + }; + + const isNameObsolete = async (memberType: string, memberName: string): Promise => { + const connection = await Connection.create({ + authInfo: await AuthInfo.create({ username: testkit.username }), + }); + + const res = await connection.singleRecordQuery<{ IsNameObsolete: boolean }>( + `SELECT IsNameObsolete FROM SourceMember WHERE MemberType='${memberType}' AND MemberName='${memberName}'`, + { tooling: true } + ); + + return res.IsNameObsolete; + }; + + before(async () => { + testkit = await SourceTestkit.create({ + nut: __filename, + executable: os.platform() === 'win32' ? executable.replace(/\\/g, '\\\\') : executable, + repository: 'https://github.com/trailheadapps/dreamhouse-lwc.git', + }); + execCmd('force:source:deploy --sourcepath force-app'); + }); + + after(async () => { + await testkit?.clean(); + }); + + describe('destructive changes POST', () => { + it('should deploy and then delete an ApexClass ', async () => { + const { apexName } = createApexClass(); + let deleted = await isNameObsolete('ApexClass', apexName); + + expect(deleted).to.be.false; + createManifest('ApexClass:GeocodingService', 'package'); + createManifest(`ApexClass:${apexName}`, 'post'); + + execCmd('force:source:deploy --json --manifest package.xml --postdestructivechanges destructiveChangesPost.xml', { + ensureExitCode: 0, + }); + + deleted = await isNameObsolete('ApexClass', apexName); + expect(deleted).to.be.true; + }); + }); + + describe('destructive changes PRE', () => { + it('should delete an ApexClass and then deploy a class', async () => { + const { apexName } = createApexClass(); + let deleted = await isNameObsolete('ApexClass', apexName); + + expect(deleted).to.be.false; + createManifest('ApexClass:GeocodingService', 'package'); + createManifest(`ApexClass:${apexName}`, 'pre'); + + execCmd('force:source:deploy --json --manifest package.xml --predestructivechanges destructiveChangesPre.xml', { + ensureExitCode: 0, + }); + + deleted = await isNameObsolete('ApexClass', apexName); + expect(deleted).to.be.true; + }); + }); + + describe('destructive changes POST and PRE', () => { + it('should delete a class, then deploy and then delete an ApexClass', async () => { + const pre = createApexClass('pre').apexName; + const post = createApexClass('post').apexName; + let preDeleted = await isNameObsolete('ApexClass', pre); + let postDeleted = await isNameObsolete('ApexClass', post); + + expect(preDeleted).to.be.false; + expect(postDeleted).to.be.false; + createManifest('ApexClass:GeocodingService', 'package'); + createManifest(`ApexClass:${post}`, 'post'); + createManifest(`ApexClass:${pre}`, 'pre'); + + execCmd( + 'force:source:deploy --json --manifest package.xml --postdestructivechanges destructiveChangesPost.xml --predestructivechanges destructiveChangesPre.xml', + { + ensureExitCode: 0, + } + ); + + preDeleted = await isNameObsolete('ApexClass', pre); + postDeleted = await isNameObsolete('ApexClass', post); + expect(preDeleted).to.be.true; + expect(postDeleted).to.be.true; + }); + }); + + describe('errors', () => { + it('should throw an error when a pre destructive flag is passed without the manifest flag', async () => { + const { apexName } = createApexClass(); + + createManifest('ApexClass:GeocodingService', 'package'); + createManifest(`ApexClass:${apexName}`, 'pre'); + + try { + execCmd('force:source:deploy --json --sourcepath force-app --predestructivechanges destructiveChangesPre.xml'); + } catch (e) { + const err = e as Error; + expect(err).to.not.be.undefined; + expect(err.message).to.include('Error: --manifest= must also be provided when using --predestructivechanges='); + } + }); + + it('should throw an error when a post destructive flag is passed without the manifest flag', async () => { + const { apexName } = createApexClass(); + + createManifest('ApexClass:GeocodingService', 'package'); + createManifest(`ApexClass:${apexName}`, 'pre'); + + try { + execCmd('force:source:deploy --json --sourcepath force-app --postdestructivechanges destructiveChangesPre.xml'); + } catch (e) { + const err = e as Error; + expect(err).to.not.be.undefined; + expect(err.message).to.include('Error: --manifest= must also be provided when using --postdestructivechanges='); + } + }); + + it("should throw an error when a destructive manifest is passed that doesn't exist", () => { + createManifest('ApexClass:GeocodingService', 'package'); + + try { + execCmd('force:source:deploy --json --manifest package.xml --predestructivechanges doesntexist.xml'); + } catch (e) { + const err = e as Error; + expect(err).to.not.be.undefined; + expect(err.message).to.include("ENOENT: no such file or directory, open 'doesntexist.xml'"); + } + }); + }); +}); diff --git a/yarn.lock b/yarn.lock index a6cb69187..9ae9b3d39 100644 --- a/yarn.lock +++ b/yarn.lock @@ -823,10 +823,10 @@ unzipper "0.10.11" xmldom-sfdx-encoding "^0.1.29" -"@salesforce/source-deploy-retrieve@^4.5.7": - version "4.5.7" - resolved "https://registry.yarnpkg.com/@salesforce/source-deploy-retrieve/-/source-deploy-retrieve-4.5.7.tgz#fac64065a0bf3583779a40ac22d0347d7bbcfee8" - integrity sha512-zKvxkbw/9ple0Kp8RYcJmywiFJ9rLgk3lWEXI0h5iX44e6LaCrcbgPNMpImVb4bf7BL/43wyEh+BqpMmnC63UQ== +"@salesforce/source-deploy-retrieve@^5.0.0": + version "5.0.0" + resolved "https://registry.yarnpkg.com/@salesforce/source-deploy-retrieve/-/source-deploy-retrieve-5.0.0.tgz#a27ec48f6ffeadf8b32da9945eaaf48a53110695" + integrity sha512-okHGiAuXVQKNTOu56CYMKg05Oe5EtIbGl8lju6WANzw/fyjkT1v1blTlyupP/cC70gQVEfNEdSO/QytnBEoi7A== dependencies: "@salesforce/core" "2.28.0" "@salesforce/kit" "^1.5.0"