├── .eslintignore ├── src ├── index.ts ├── common │ ├── constants.ts │ ├── utils │ │ ├── sfCommandConfig.ts │ │ ├── queryRunner.ts │ │ ├── wrapChildprocess.ts │ │ └── fileUtils.ts │ ├── comms │ │ ├── eventBus.ts │ │ └── processingEvents.ts │ ├── validationResult.ts │ ├── reporters │ │ ├── csvResultsReporter.ts │ │ ├── humanResultsReporter.ts │ │ └── resultsReporter.ts │ ├── migrationPlanLoader.ts │ ├── planCache.ts │ ├── jscSfCommandFlags.ts │ ├── packageManifestDirectory.ts │ ├── packageManifestBuilder.ts │ ├── apex-scheduler │ │ └── stopSingleJobTask.ts │ └── metadata │ │ └── toolingApiHelper.ts ├── types │ ├── sfStandardApiTypes.ts │ ├── orgManifestGlobalConstants.ts │ ├── scheduledApexTypes.ts │ ├── migrationPlanObjectData.ts │ ├── platformTypes.ts │ ├── orgManifestOutputSchema.ts │ └── orgManifestInputSchema.ts ├── garbage-collection │ ├── packageManifestTypes.ts │ ├── entityDefinitionHandler.ts │ ├── entity-handlers │ │ ├── customObject.ts │ │ ├── fullNameSingleRecord.ts │ │ ├── nameEntity.ts │ │ ├── developerNameEntity.ts │ │ ├── sobjectBasedDevNameEntity.ts │ │ ├── outdatedFlowVersions.ts │ │ ├── dynamicDevNamedEntityRelated.ts │ │ ├── customField.ts │ │ ├── approvalProcessDefinition.ts │ │ ├── layout.ts │ │ ├── customMetadataRecord.ts │ │ └── index.ts │ ├── packageMemberFilter.ts │ ├── queries.ts │ ├── customObjects.ts │ └── packageGarbageTypes.ts ├── field-usage │ └── fieldUsageTypes.ts ├── release-manifest │ ├── artifact-deploy-strategies │ │ └── artifactDeployStrategy.ts │ ├── artifactDeploySfCommand.ts │ └── releaseManifestLoader.ts └── commands │ └── jsc │ ├── maintain │ └── flow-export │ │ ├── unused.ts │ │ └── obsolete.ts │ ├── apex │ └── schedule │ │ └── start.ts │ └── manifest │ └── validate.ts ├── test ├── data │ ├── file-utils │ │ ├── empty-file.yaml │ │ ├── some-csv-file.csv │ │ ├── invalid-types.yaml │ │ ├── invalid.yaml │ │ └── valid.yaml │ ├── manifests │ │ ├── empty-artifacts-invalid.yaml │ │ ├── minimal.yaml │ │ ├── invalid.yaml │ │ ├── invalid-path-no-envs.yaml │ │ ├── simple-multi-step.yaml │ │ ├── cron-job-manifest.yaml │ │ ├── complex-with-global-options.yaml │ │ ├── with-flags.yaml │ │ └── complex-with-envs.yaml │ ├── test-sfdx-project │ │ ├── jobs │ │ │ ├── empty-jobs.yaml │ │ │ ├── invalid-job-config.yaml │ │ │ ├── scheduled-jobs.yaml │ │ │ └── updated-scheduled-jobs.yaml │ │ ├── src │ │ │ ├── classes │ │ │ │ ├── TestClassForUnpackagedDeploys.cls │ │ │ │ ├── TestJob.cls │ │ │ │ ├── TestSchedulable2.cls │ │ │ │ ├── TestSchedulable3.cls │ │ │ │ ├── TestJob.cls-meta.xml │ │ │ │ ├── TestSchedulable2.cls-meta.xml │ │ │ │ ├── TestSchedulable3.cls-meta.xml │ │ │ │ └── TestClassForUnpackagedDeploys.cls-meta.xml │ │ │ ├── objects │ │ │ │ └── Account │ │ │ │ │ └── recordTypes │ │ │ │ │ ├── Partner.recordType-meta.xml │ │ │ │ │ └── Customer.recordType-meta.xml │ │ │ └── flows │ │ │ │ ├── Active_Test_Flow.flow-meta.xml │ │ │ │ └── Inactive_Test_Flow.flow-meta.xml │ │ ├── manifest.yml │ │ ├── config │ │ │ └── default-scratch-def.json │ │ ├── data │ │ │ ├── plans │ │ │ │ └── minimal-plan.json │ │ │ ├── contacts.json │ │ │ └── accounts.json │ │ ├── sfdx-project.json │ │ ├── export-plans │ │ │ ├── plan-for-empty-bind.yml │ │ │ ├── plan-with-invalid-bind.yml │ │ │ └── test-plan.yml │ │ └── scripts │ │ │ ├── plain-scratch-setup.sh │ │ │ └── scratch-setup.sh │ ├── query-results │ │ ├── empty-result.json │ │ └── subscriber-package.json │ ├── soql │ │ ├── accounts.sql │ │ ├── custom-fields.sql │ │ ├── users.sql │ │ └── package-members.sql │ ├── plans │ │ ├── invalid-plan.yaml │ │ ├── duplicate-parent-ids.yaml │ │ ├── unknown-parent-ids.yaml │ │ ├── metadata-plan.yaml │ │ ├── test-plan.yaml │ │ └── complex-plan-all-data.yaml │ ├── garbage-collection │ │ ├── package-2.json │ │ ├── workflow-field-update-defs.json │ │ ├── all-quick-actions.json │ │ ├── subscriber-package.json │ │ ├── package-members │ │ │ ├── quick-action.json │ │ │ ├── workflow-field-updates.json │ │ │ ├── label-and-list-view.json │ │ │ ├── layouts.json │ │ │ ├── cmd-records.json │ │ │ ├── custom-fields.json │ │ │ ├── workflow-alerts.json │ │ │ └── mixed-with-package-infos.json │ │ ├── custom-labels.json │ │ ├── layouts.json │ │ ├── label-listview-entity-definitions.json │ │ ├── packaged-flows.json │ │ ├── cmd-m01-records.json │ │ ├── filtered-entity-definitions.json │ │ ├── workflow-alert-definitions.json │ │ └── cmd-m00-records.json │ ├── apex-schedule-service │ │ ├── cron-trigger-details.json │ │ ├── schedule-stop-success.json │ │ ├── schedule-start-success.json │ │ ├── job-is-already-aborted.json │ │ ├── is-already-scheduled-error.json │ │ └── invalid-cron-error.json │ ├── flow-export │ │ ├── obsolete-flows.json │ │ └── unused-flows.json │ ├── api │ │ └── queryResults.ts │ └── describes │ │ └── mockDescribeResults.ts ├── tsconfig.json ├── .eslintrc.cjs ├── common │ ├── planCache.test.ts │ ├── packageManifestBuilder.test.ts │ ├── migrationPlan.test.ts │ ├── describeApi.test.ts │ └── toolingApiConnection.test.ts ├── mock-utils │ ├── sfQueryApiMocks.ts │ ├── apexSchedulerMocks.ts │ ├── garbageCollectionMocks.ts │ └── flowExportTestContext.ts └── commands │ └── jsc │ └── manifest │ └── rollout.nut.ts ├── .husky ├── pre-push └── pre-commit ├── .prettierrc.json ├── bin ├── run.cmd ├── dev.cmd ├── run.js └── dev.js ├── commitlint.config.cjs ├── .lintstagedrc.cjs ├── .nycrc ├── .prettierignore ├── messages ├── fileutils.md ├── sobjectanalyser.md ├── exportplan.md ├── jsc.manifest.validate.md ├── orgmanifest.md ├── apexscheduler.md ├── jsc.data.export.md ├── jsc.maintain.flow-export.unused.md ├── jsc.maintain.flow-export.obsolete.md ├── jsc.apex.schedule.export.md ├── jsc.manifest.rollout.md ├── jsc.maintain.common.md ├── jsc.apex.schedule.manage.md ├── garbagecollection.md ├── jsc.maintain.garbage.collect.md ├── jsc.apex.schedule.start.md └── jsc.apex.schedule.stop.md ├── .mocharc.json ├── .eslintrc.cjs ├── tsconfig.json ├── .vscode ├── extensions.json ├── tasks.json ├── settings.json └── launch.json ├── .sfdevrc.json ├── .editorconfig ├── .release-it.json ├── .github ├── workflows │ └── tests.yml └── FUNDING.yml └── .gitignore /.eslintignore: -------------------------------------------------------------------------------- 1 | *.cjs/ 2 | -------------------------------------------------------------------------------- /src/index.ts: -------------------------------------------------------------------------------- 1 | export default {}; 2 | -------------------------------------------------------------------------------- /test/data/file-utils/empty-file.yaml: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /.husky/pre-push: -------------------------------------------------------------------------------- 1 | yarn build && yarn test 2 | -------------------------------------------------------------------------------- /.prettierrc.json: -------------------------------------------------------------------------------- 1 | "@salesforce/prettier-config" 2 | -------------------------------------------------------------------------------- /bin/run.cmd: -------------------------------------------------------------------------------- 1 | @echo off 2 | 3 | node "%~dp0\run" %* 4 | -------------------------------------------------------------------------------- /test/data/manifests/empty-artifacts-invalid.yaml: -------------------------------------------------------------------------------- 1 | artifacts: -------------------------------------------------------------------------------- /.husky/pre-commit: -------------------------------------------------------------------------------- 1 | yarn lint && yarn pretty-quick --staged 2 | -------------------------------------------------------------------------------- /src/common/constants.ts: -------------------------------------------------------------------------------- 1 | export const LOCAL_CACHE_DIR = '.jsc'; 2 | -------------------------------------------------------------------------------- /test/data/file-utils/some-csv-file.csv: -------------------------------------------------------------------------------- 1 | Col1,Col2 2 | "Data 1","Data 2" -------------------------------------------------------------------------------- /commitlint.config.cjs: -------------------------------------------------------------------------------- 1 | module.exports = { extends: ['@commitlint/config-conventional'] }; 2 | -------------------------------------------------------------------------------- /test/data/test-sfdx-project/jobs/empty-jobs.yaml: -------------------------------------------------------------------------------- 1 | options: 2 | stop_other_jobs: true 3 | -------------------------------------------------------------------------------- /.lintstagedrc.cjs: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | '**/*.{js,json,md}?(x)': () => 'npm run reformat', 3 | }; 4 | -------------------------------------------------------------------------------- /bin/dev.cmd: -------------------------------------------------------------------------------- 1 | @echo off 2 | 3 | node --loader ts-node/esm --no-warnings=ExperimentalWarning "%~dp0\dev" %* 4 | -------------------------------------------------------------------------------- /test/data/query-results/empty-result.json: -------------------------------------------------------------------------------- 1 | { 2 | "records": [], 3 | "totalSize": 0, 4 | "done": true 5 | } 6 | -------------------------------------------------------------------------------- /src/common/utils/sfCommandConfig.ts: -------------------------------------------------------------------------------- 1 | export type SfCommandConfig = { 2 | args: string[]; 3 | name?: string; 4 | }; 5 | -------------------------------------------------------------------------------- /test/data/soql/accounts.sql: -------------------------------------------------------------------------------- 1 | SELECT 2 | Id, 3 | Name, 4 | BillingStreet 5 | FROM 6 | Account 7 | LIMIT 8 | 9500 -------------------------------------------------------------------------------- /test/data/test-sfdx-project/src/classes/TestClassForUnpackagedDeploys.cls: -------------------------------------------------------------------------------- 1 | public class TestClassForUnpackagedDeploys { 2 | } 3 | -------------------------------------------------------------------------------- /src/common/comms/eventBus.ts: -------------------------------------------------------------------------------- 1 | import { EventEmitter } from 'node:events'; 2 | 3 | export const eventBus = new EventEmitter(); 4 | -------------------------------------------------------------------------------- /.nycrc: -------------------------------------------------------------------------------- 1 | { 2 | "check-coverage": true, 3 | "lines": 75, 4 | "statements": 75, 5 | "functions": 75, 6 | "branches": 75 7 | } 8 | -------------------------------------------------------------------------------- /test/data/manifests/minimal.yaml: -------------------------------------------------------------------------------- 1 | artifacts: 2 | basic_happy_soup: 3 | type: Unpackaged 4 | path: test/data/mock-src/unpackaged/my-happy-soup -------------------------------------------------------------------------------- /test/data/manifests/invalid.yaml: -------------------------------------------------------------------------------- 1 | not_artifacts_key: 2 | basic_happy_soup: 3 | type: Unpackaged 4 | path: test/data/mock-src/unpackaged/my-happy-soup -------------------------------------------------------------------------------- /src/types/sfStandardApiTypes.ts: -------------------------------------------------------------------------------- 1 | export type QueryError = { 2 | errorCode: string; 3 | name: string; 4 | data: { message: string; errorCode: string }; 5 | }; 6 | -------------------------------------------------------------------------------- /.prettierignore: -------------------------------------------------------------------------------- 1 | # examples in these messages use wild-cards, that prettier escapes 2 | messages/jsc.apex.schedule.start.md 3 | messages/jsc.maintain.field-usage.analyse.md 4 | -------------------------------------------------------------------------------- /test/data/soql/custom-fields.sql: -------------------------------------------------------------------------------- 1 | SELECT 2 | Id, 3 | DeveloperName, 4 | TableEnumOrId, 5 | ManageableState, 6 | EntityDefinition.DeveloperName 7 | FROM 8 | CustomField -------------------------------------------------------------------------------- /messages/fileutils.md: -------------------------------------------------------------------------------- 1 | # InvalidFilePath 2 | 3 | Failed to read file from path: %s. 4 | 5 | # InvalidSchema 6 | 7 | Cannot parse file contents to schema. Review these errors: %s 8 | -------------------------------------------------------------------------------- /test/data/file-utils/invalid-types.yaml: -------------------------------------------------------------------------------- 1 | options: 2 | boolProp: string 3 | stringProp: 1 4 | record: 5 | Entry 1: 6 | prop1: entry 1 prop 7 | prop2: entry 1 prop2 8 | -------------------------------------------------------------------------------- /test/data/file-utils/invalid.yaml: -------------------------------------------------------------------------------- 1 | invalidProperty: 2 | boolProp: true 3 | stringProp: test 4 | record: 5 | Entry 1: 6 | prop1: entry 1 prop 7 | prop2: entry 1 prop2 8 | -------------------------------------------------------------------------------- /.mocharc.json: -------------------------------------------------------------------------------- 1 | { 2 | "require": ["tsx"], 3 | "extension": ["ts"], 4 | "watch-extensions": "ts", 5 | "recursive": true, 6 | "reporter": "spec", 7 | "timeout": 600000 8 | } 9 | -------------------------------------------------------------------------------- /.eslintrc.cjs: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | extends: ['eslint-config-salesforce-typescript', 'plugin:sf-plugin/recommended'], 3 | root: true, 4 | rules: { 5 | header: 'off', 6 | }, 7 | }; 8 | -------------------------------------------------------------------------------- /test/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "@salesforce/dev-config/tsconfig-test-strict-esm", 3 | "include": ["./**/*.ts"], 4 | "compilerOptions": { 5 | "skipLibCheck": true 6 | } 7 | } 8 | -------------------------------------------------------------------------------- /test/data/test-sfdx-project/jobs/invalid-job-config.yaml: -------------------------------------------------------------------------------- 1 | options: 2 | stop_other_jobs: false 3 | jobs: 4 | 'Testing Job': 5 | class: SomeInvalidApexClass 6 | expression: '0 0 1 * * ?' 7 | -------------------------------------------------------------------------------- /test/data/test-sfdx-project/src/classes/TestJob.cls: -------------------------------------------------------------------------------- 1 | public class TestJob implements Schedulable { 2 | public void execute(SchedulableContext sc) { 3 | System.debug('Does nothing'); 4 | } 5 | } 6 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "@salesforce/dev-config/tsconfig-strict-esm", 3 | "compilerOptions": { 4 | "outDir": "lib", 5 | "rootDir": "src" 6 | }, 7 | "include": ["./src/**/*.ts"] 8 | } 9 | -------------------------------------------------------------------------------- /.vscode/extensions.json: -------------------------------------------------------------------------------- 1 | { 2 | "recommendations": [ 3 | "esbenp.prettier-vscode", 4 | "hbenl.vscode-mocha-test-adapter", 5 | "mhutchie.git-graph", 6 | "GitHub.vscode-github-actions" 7 | ] 8 | } 9 | -------------------------------------------------------------------------------- /.sfdevrc.json: -------------------------------------------------------------------------------- 1 | { 2 | "test": { 3 | "testsPath": "test/**/*.test.ts" 4 | }, 5 | "wireit": { 6 | "test": { 7 | "dependencies": ["test:compile", "test:only", "lint"] 8 | } 9 | } 10 | } 11 | -------------------------------------------------------------------------------- /test/data/test-sfdx-project/manifest.yml: -------------------------------------------------------------------------------- 1 | artifacts: 2 | apex_utils: 3 | type: UnlockedPackage 4 | package_id: 0Ho6f000000TN1jCAG 5 | version: 0.2.0 6 | unpackaged: 7 | type: Unpackaged 8 | path: src 9 | -------------------------------------------------------------------------------- /test/data/plans/invalid-plan.yaml: -------------------------------------------------------------------------------- 1 | name: Invalid Test Plan 2 | objects: 3 | # account does not have query or file 4 | - objectName: Account 5 | - objectName: Contact 6 | queryString: SELECT Id,Invalid__x FROM Contact 7 | -------------------------------------------------------------------------------- /test/data/test-sfdx-project/src/classes/TestSchedulable2.cls: -------------------------------------------------------------------------------- 1 | public class TestSchedulable2 implements Schedulable { 2 | public void execute(SchedulableContext sc) { 3 | System.debug('Does nothing'); 4 | } 5 | } 6 | -------------------------------------------------------------------------------- /test/data/test-sfdx-project/src/classes/TestSchedulable3.cls: -------------------------------------------------------------------------------- 1 | public class TestSchedulable3 implements Schedulable { 2 | public void execute(SchedulableContext sc) { 3 | System.debug('Does nothing'); 4 | } 5 | } 6 | -------------------------------------------------------------------------------- /.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 | -------------------------------------------------------------------------------- /test/data/soql/users.sql: -------------------------------------------------------------------------------- 1 | SELECT 2 | Id, 3 | IsActive, 4 | FirstName, 5 | LastName, 6 | Username, 7 | Email, 8 | UserRole.Name, 9 | Profile.Name 10 | FROM 11 | User 12 | WHERE 13 | CompanyName = 'The Mobility House LLC' -------------------------------------------------------------------------------- /test/data/test-sfdx-project/config/default-scratch-def.json: -------------------------------------------------------------------------------- 1 | { 2 | "orgName": "JSC - CLI Plugin Scratch Org", 3 | "edition": "Enterprise", 4 | "features": [], 5 | "country": "US", 6 | "language": "en_US", 7 | "settings": {} 8 | } 9 | -------------------------------------------------------------------------------- /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 | -------------------------------------------------------------------------------- /test/data/test-sfdx-project/src/classes/TestJob.cls-meta.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 61.0 4 | Active 5 | 6 | -------------------------------------------------------------------------------- /test/data/test-sfdx-project/src/classes/TestSchedulable2.cls-meta.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 61.0 4 | Active 5 | 6 | -------------------------------------------------------------------------------- /test/data/test-sfdx-project/src/classes/TestSchedulable3.cls-meta.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 61.0 4 | Active 5 | 6 | -------------------------------------------------------------------------------- /test/data/manifests/invalid-path-no-envs.yaml: -------------------------------------------------------------------------------- 1 | environments: 2 | prod: test@example.com 3 | artifacts: 4 | basic_happy_soup: 5 | type: Unpackaged 6 | path: 7 | staging: test/data/mock-src/unpackaged/qa 8 | prod: test/data/mock-src/unpackaged/prod-only -------------------------------------------------------------------------------- /test/data/soql/package-members.sql: -------------------------------------------------------------------------------- 1 | SELECT 2 | Id, 3 | MaxPackageVersion.Name, 4 | SubscriberPackage.Name, 5 | SubjectId, 6 | SubjectKeyPrefix 7 | FROM 8 | Package2Member 9 | WHERE 10 | MaxPackageVersionId != NULL 11 | ORDER BY 12 | SubjectKeyPrefix -------------------------------------------------------------------------------- /test/data/test-sfdx-project/src/classes/TestClassForUnpackagedDeploys.cls-meta.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 61.0 4 | Active 5 | 6 | -------------------------------------------------------------------------------- /test/data/test-sfdx-project/src/objects/Account/recordTypes/Partner.recordType-meta.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | Partner 4 | true 5 | 6 | 7 | -------------------------------------------------------------------------------- /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 | -------------------------------------------------------------------------------- /test/data/test-sfdx-project/src/objects/Account/recordTypes/Customer.recordType-meta.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | Customer 4 | true 5 | 6 | 7 | -------------------------------------------------------------------------------- /test/data/plans/duplicate-parent-ids.yaml: -------------------------------------------------------------------------------- 1 | name: Duplicate Exports 2 | objects: 3 | - objectName: Account 4 | queryString: SELECT Id FROM Account 5 | exports: 6 | Id: accIds 7 | - objectName: Contact 8 | query: 9 | fetchAllFields: true 10 | exports: 11 | AccountId: accIds 12 | -------------------------------------------------------------------------------- /test/data/plans/unknown-parent-ids.yaml: -------------------------------------------------------------------------------- 1 | name: Invalid Parent Binds 2 | objects: 3 | - objectName: Account 4 | query: 5 | fetchAllFields: true 6 | - objectName: Contact 7 | query: 8 | fetchAllFields: true 9 | bind: 10 | variable: myAccountIds 11 | field: AccountId 12 | -------------------------------------------------------------------------------- /messages/sobjectanalyser.md: -------------------------------------------------------------------------------- 1 | # info.not-a-custom-field 2 | 3 | --custom-fields-only specified 4 | 5 | # info.is-calculated 6 | 7 | --exclude-formulas specified 8 | 9 | # info.type-not-supported 10 | 11 | Type not supported for analysis 12 | 13 | # info.not-filterable 14 | 15 | Field not filterable in SOQL 16 | -------------------------------------------------------------------------------- /test/data/file-utils/valid.yaml: -------------------------------------------------------------------------------- 1 | options: 2 | boolProp: true 3 | stringProp: test 4 | record: 5 | Entry 1: 6 | prop1: entry 1 prop 7 | prop2: entry 1 prop2 8 | Entry 2: 9 | prop1: entry 2 prop 10 | prop2: entry 2 prop2 11 | Entry 3: 12 | prop1: entry 3 prop 13 | prop2: entry 3 prop2 14 | -------------------------------------------------------------------------------- /src/types/orgManifestGlobalConstants.ts: -------------------------------------------------------------------------------- 1 | import { z } from 'zod'; 2 | 3 | export const DeployStrategies = z.enum(['SourceDeploy', 'PackageInstall', 'CronJobSchedule']); 4 | export const ArtifactTypes = z.enum(['UnlockedPackage', 'Unpackaged', 'CronJob']); 5 | export const DeployStatus = z.enum(['Pending', 'Resolved', 'Skipped', 'Success', 'Failed']); 6 | -------------------------------------------------------------------------------- /test/data/test-sfdx-project/data/plans/minimal-plan.json: -------------------------------------------------------------------------------- 1 | [ 2 | { 3 | "sobject": "Account", 4 | "saveRefs": true, 5 | "resolveRefs": false, 6 | "files": ["../accounts.json"] 7 | }, 8 | { 9 | "sobject": "Contact", 10 | "saveRefs": true, 11 | "resolveRefs": true, 12 | "files": ["../contacts.json"] 13 | } 14 | ] 15 | -------------------------------------------------------------------------------- /test/data/manifests/simple-multi-step.yaml: -------------------------------------------------------------------------------- 1 | artifacts: 2 | basic_happy_soup: 3 | type: Unpackaged 4 | path: test/data/mock-src/unpackaged/my-happy-soup 5 | apex_utils: 6 | type: UnlockedPackage 7 | package_id: 0Ho690000000000AAA 8 | installation_key: APEX_UTILS_INSTALLATION_KEY 9 | version: 1.28.0 10 | skip_if_installed: false -------------------------------------------------------------------------------- /test/data/manifests/cron-job-manifest.yaml: -------------------------------------------------------------------------------- 1 | artifacts: 2 | scheduled_jobs: 3 | type: CronJob 4 | jobs: 5 | Test Job 1: 6 | class: TestJob 7 | expression: 0 0 0 1 * * * 8 | Test Job 2: 9 | class: TestSchedulable2 10 | expression: 0 0 1 ? * * * 11 | Test Job 3: 12 | class: TestSchedulable2 13 | expression: 0 0 2 ? * * * 14 | -------------------------------------------------------------------------------- /test/data/garbage-collection/package-2.json: -------------------------------------------------------------------------------- 1 | { 2 | "records": [ 3 | { 4 | "attributes": { 5 | "type": "Package2", 6 | "url": "/services/data/v62.0/tooling/sobjects/Package2/0Ho6f000000TN1eCAG" 7 | }, 8 | "Id": "0Ho6f000000TN1eCAG", 9 | "SubscriberPackageId": "0330X0000000000AAA" 10 | } 11 | ], 12 | "totalSize": 1, 13 | "done": true 14 | } 15 | -------------------------------------------------------------------------------- /test/data/test-sfdx-project/sfdx-project.json: -------------------------------------------------------------------------------- 1 | { 2 | "packageDirectories": [ 3 | { 4 | "path": "src", 5 | "default": true 6 | } 7 | ], 8 | "namespace": "", 9 | "sfdcLoginUrl": "https://tmh.my.salesforce.com", 10 | "sourceApiVersion": "61.0", 11 | "packageAliases": { 12 | "Test Package @ 0.1.0": "04tPl0000006rJNIAY", 13 | "Test Package @ LATEST": "04tPl0000006s5lIAA" 14 | } 15 | } 16 | -------------------------------------------------------------------------------- /src/common/validationResult.ts: -------------------------------------------------------------------------------- 1 | export default class ValidationResult { 2 | public issues: ValidationIssue[] = []; 3 | public infos: string[] = []; 4 | 5 | public isValid(): boolean { 6 | return this.issues.length === 0; 7 | } 8 | } 9 | 10 | export type ValidationIssue = { 11 | issueType: ValidationIssueType; 12 | message: string; 13 | }; 14 | 15 | export enum ValidationIssueType { 16 | 'generic', 17 | } 18 | -------------------------------------------------------------------------------- /test/data/plans/metadata-plan.yaml: -------------------------------------------------------------------------------- 1 | name: Metadata Test Plan 2 | objects: 3 | - objectName: Package2Member 4 | isToolingObject: true 5 | queryFile: test/data/soql/package-members.sql 6 | - objectName: CustomObject 7 | isToolingObject: true 8 | queryString: SELECT Id,DeveloperName FROM CustomObject 9 | - objectName: CustomField 10 | isToolingObject: true 11 | queryFile: test/data/soql/custom-fields.sql -------------------------------------------------------------------------------- /messages/exportplan.md: -------------------------------------------------------------------------------- 1 | # TooManyQueriesDefined 2 | 3 | More than one query provided. queryString OR queryFile or queryObject are allowed. 4 | 5 | # invalid-query-syntax 6 | 7 | Invalid query syntax: %s. 8 | 9 | # NoQueryDefinedForSObject 10 | 11 | No query defined for: %s 12 | 13 | # InvalidSObjectName 14 | 15 | Failed to fetch describe for %s: %s 16 | 17 | # InvalidRecordTypeId 18 | 19 | Failed to fetch metadata for record type id: %s. Does not exist. 20 | -------------------------------------------------------------------------------- /test/data/manifests/complex-with-global-options.yaml: -------------------------------------------------------------------------------- 1 | artifacts: 2 | apex_utils: 3 | type: UnlockedPackage 4 | package_id: 0Ho690000000000AAA 5 | installation_key: APEX_UTILS_INSTALLATION_KEY 6 | version: 1.28.0 7 | skip_if_installed: true 8 | lwc_utils: 9 | type: UnlockedPackage 10 | package_id: 0Ho690000000001AAA 11 | installation_key: LWC_UTILS_INSTALLATION_KEY 12 | version: 0.12.0 13 | options: 14 | skip_if_installed: false -------------------------------------------------------------------------------- /test/data/garbage-collection/workflow-field-update-defs.json: -------------------------------------------------------------------------------- 1 | { 2 | "records": [ 3 | { 4 | "attributes": { 5 | "type": "WorkflowFieldUpdate", 6 | "url": "/services/data/v63.0/tooling/sobjects/WorkflowFieldUpdate/04Y0X0000000gb0UAA" 7 | }, 8 | "Id": "04Y0X0000000gb0UAA", 9 | "Name": "Set some test value", 10 | "FullName": "Account.My_Test_Field_Update" 11 | } 12 | ], 13 | "totalSize": 1, 14 | "done": true 15 | } 16 | -------------------------------------------------------------------------------- /.vscode/tasks.json: -------------------------------------------------------------------------------- 1 | { 2 | "version": "2.0.0", 3 | "problemMatcher": "$tsc", 4 | "tasks": [ 5 | { 6 | "label": "Compile tests", 7 | "group": { 8 | "kind": "build", 9 | "isDefault": true 10 | }, 11 | "command": "yarn", 12 | "type": "shell", 13 | "presentation": { 14 | "focus": false, 15 | "panel": "dedicated" 16 | }, 17 | "args": ["run", "pretest"], 18 | "isBackground": false 19 | } 20 | ] 21 | } 22 | -------------------------------------------------------------------------------- /test/data/test-sfdx-project/jobs/scheduled-jobs.yaml: -------------------------------------------------------------------------------- 1 | options: 2 | stop_other_jobs: true 3 | jobs: 4 | 'Name of my job': 5 | class: TestJob 6 | expression: '0 0 1 * * ?' 7 | 'My job 2': 8 | class: TestJob 9 | expression: '0 0 2 * * ?' 10 | Yet another job: 11 | class: TestSchedulable2 12 | expression: '0 0 1 * * ?' 13 | or_name_job_like_this: 14 | class: TestSchedulable2 15 | expression: '0 0 1 * * ?' 16 | TestSchedulable3: 17 | expression: '0 0 1 * * ?' 18 | -------------------------------------------------------------------------------- /messages/jsc.manifest.validate.md: -------------------------------------------------------------------------------- 1 | # summary 2 | 3 | Validate a manifest file. Same result as running "rollout" with "--validate-only". 4 | 5 | # description 6 | 7 | The manifest file is validated against a DevHub and Target Org. It tries to resolve package versions and deploy paths for all artifacts, but does not attempt to rollout the artifacts to the target org. 8 | 9 | All artifacts are returned as RESOLVED, if validation succeeds. 10 | 11 | # examples 12 | 13 | - <%= config.bin %> <%= command.id %> 14 | -------------------------------------------------------------------------------- /test/data/garbage-collection/all-quick-actions.json: -------------------------------------------------------------------------------- 1 | { 2 | "records": [ 3 | { 4 | "attributes": { 5 | "type": "QuickActionDefinition", 6 | "url": "/services/data/v62.0/tooling/sobjects/QuickActionDefinition/09D0X000002XMPUUA4" 7 | }, 8 | "Id": "09D0X000002XMPUUA4", 9 | "DeveloperName": "New_ChargePilot_Contract", 10 | "EntityDefinitionId": "Account", 11 | "SobjectType": "Account" 12 | } 13 | ], 14 | "totalSize": 1, 15 | "done": true 16 | } 17 | -------------------------------------------------------------------------------- /.release-it.json: -------------------------------------------------------------------------------- 1 | { 2 | "$schema": "https://unpkg.com/release-it/schema/release-it.json", 3 | "github": { 4 | "release": true, 5 | "releaseName": "${version}" 6 | }, 7 | "git": { 8 | "commitMessage": "admin: release v${version}", 9 | "push": true, 10 | "tag": true 11 | }, 12 | "npm": { 13 | "publish": true 14 | }, 15 | "plugins": { 16 | "@release-it/conventional-changelog": { 17 | "preset": { 18 | "name": "angular" 19 | } 20 | } 21 | } 22 | } 23 | -------------------------------------------------------------------------------- /test/data/query-results/subscriber-package.json: -------------------------------------------------------------------------------- 1 | { 2 | "records": [ 3 | { 4 | "attributes": { 5 | "type": "SubscriberPackage", 6 | "url": "/services/data/v63.0/tooling/sobjects/SubscriberPackage/0336f000000G8roAAC" 7 | }, 8 | "Id": "0336f000000G8roAAC", 9 | "Name": "JS Apex Utils", 10 | "Description": "Awesome Apex and Visualforce utilities", 11 | "IsPackageValid": true, 12 | "NamespacePrefix": null 13 | } 14 | ], 15 | "totalSize": 1, 16 | "done": true 17 | } 18 | -------------------------------------------------------------------------------- /test/data/garbage-collection/subscriber-package.json: -------------------------------------------------------------------------------- 1 | { 2 | "records": [ 3 | { 4 | "attributes": { 5 | "type": "SubscriberPackage", 6 | "url": "/services/data/v63.0/tooling/sobjects/SubscriberPackage/0330X0000000000AAA" 7 | }, 8 | "Id": "0330X0000000000AAA", 9 | "Name": "My Test Package", 10 | "Description": "Awesome Apex and Visualforce utilities", 11 | "IsPackageValid": true, 12 | "NamespacePrefix": null 13 | } 14 | ], 15 | "totalSize": 1, 16 | "done": true 17 | } 18 | -------------------------------------------------------------------------------- /test/data/plans/test-plan.yaml: -------------------------------------------------------------------------------- 1 | name: Test Plan 2 | objects: 3 | - objectName: Account 4 | queryFile: test/data/soql/accounts.sql 5 | exports: 6 | Id: myAccountIds 7 | - objectName: Contact 8 | query: 9 | fetchAllFields: true 10 | bind: 11 | field: AccountId 12 | variable: myAccountIds 13 | - objectName: Order 14 | queryString: SELECT Id,AccountId,BillToContactId FROM Order LIMIT 100 15 | - objectName: Opportunity 16 | queryString: SELECT Id,AccountId FROM Opportunity LIMIT 10 17 | -------------------------------------------------------------------------------- /test/data/test-sfdx-project/jobs/updated-scheduled-jobs.yaml: -------------------------------------------------------------------------------- 1 | options: 2 | stop_other_jobs: false 3 | jobs: 4 | 'Name of my job': 5 | class: TestJob 6 | expression: '0 0 1 * * ?' 7 | 'My job 2': 8 | class: TestJob 9 | expression: '0 0 2 * * ?' 10 | Yet another job: 11 | class: TestSchedulable2 12 | expression: '0 0 1 * * ?' 13 | or_name_job_like_this: 14 | class: TestSchedulable2 15 | expression: '0 0 2 * * ?' 16 | TestSchedulable3: 17 | expression: '0 0 2 * * ?' 18 | TestJob: 19 | expression: '0 0 3 * * ?' 20 | -------------------------------------------------------------------------------- /.vscode/settings.json: -------------------------------------------------------------------------------- 1 | { 2 | "files.exclude": { 3 | "**/.git": true, 4 | "**/.svn": true, 5 | "**/.hg": true, 6 | "**/CVS": true, 7 | "**/.DS_Store": true 8 | }, 9 | "search.exclude": { 10 | "**/lib": true, 11 | "**/bin": true 12 | }, 13 | "editor.tabSize": 2, 14 | "editor.formatOnSave": true, 15 | "editor.defaultFormatter": "esbenp.prettier-vscode", 16 | "mochaExplorer.files": "test/**/*.test.ts", 17 | "mochaExplorer.require": "tsx", 18 | "github-actions.workflows.pinned.workflows": [".github/workflows/tests.yml"] 19 | } 20 | -------------------------------------------------------------------------------- /test/data/test-sfdx-project/export-plans/plan-for-empty-bind.yml: -------------------------------------------------------------------------------- 1 | name: Plan With Empty Binds 2 | objects: 3 | - objectName: User 4 | queryString: SELECT Id FROM User LIMIT 0 5 | exports: 6 | Id: emptyOwnerIds 7 | - objectName: Account 8 | query: 9 | fetchAllFields: false 10 | bind: 11 | field: OwnerId 12 | variable: emptyOwnerIds 13 | exports: 14 | Id: emptyAccountIds 15 | - objectName: Contact 16 | query: 17 | fetchAllFields: false 18 | bind: 19 | field: AccountId 20 | variable: emptyAccountIds 21 | -------------------------------------------------------------------------------- /test/data/manifests/with-flags.yaml: -------------------------------------------------------------------------------- 1 | environments: 2 | dev: admin-salesforce@mobilityhouse.com.dev 3 | qa: admin@example.com.qa 4 | prod: admin@example.com 5 | artifacts: 6 | org_shape_settings: 7 | type: Unpackaged 8 | path: test/data/mock-src/unpackaged/org-shape 9 | flags: ignore-conflicts concise 10 | apex_utils: 11 | type: UnlockedPackage 12 | package_id: 0Ho690000000000AAA 13 | installation_key: APEX_UTILS_INSTALLATION_KEY 14 | version: 1.28.0 15 | skip_if_installed: false 16 | flags: upgrade-type=DeprecateOnly wait=20 apex-compile=package 17 | -------------------------------------------------------------------------------- /test/data/test-sfdx-project/export-plans/plan-with-invalid-bind.yml: -------------------------------------------------------------------------------- 1 | name: Plan With Invalid Binds 2 | objects: 3 | - objectName: User 4 | queryString: SELECT Id FROM User LIMIT 0 5 | exports: 6 | Id: emptyOwnerIds 7 | - objectName: Account 8 | query: 9 | fetchAllFields: false 10 | bind: 11 | field: OwnerId 12 | variable: emptyOwnerIds 13 | exports: 14 | Id: emptyAccountIds 15 | - objectName: Contact 16 | query: 17 | fetchAllFields: false 18 | bind: 19 | field: InvalidParentId__c 20 | variable: emptyAccountIds 21 | -------------------------------------------------------------------------------- /test/data/garbage-collection/package-members/quick-action.json: -------------------------------------------------------------------------------- 1 | { 2 | "records": [ 3 | { 4 | "attributes": { 5 | "type": "Package2Member", 6 | "url": "/services/data/v62.0/tooling/sobjects/Package2Member/03v0X0000004CxiQAE" 7 | }, 8 | "Id": "03v0X0000004CxiQAE", 9 | "CurrentPackageVersionId": "04t670000015YcsAAE", 10 | "MaxPackageVersionId": "04t670000015YcsAAE", 11 | "SubjectId": "09D0X000002XMPUUA4", 12 | "SubjectKeyPrefix": "09D", 13 | "SubjectManageableState": "deprecatedEditable" 14 | } 15 | ], 16 | "totalSize": 1, 17 | "done": true 18 | } 19 | -------------------------------------------------------------------------------- /src/common/comms/processingEvents.ts: -------------------------------------------------------------------------------- 1 | export enum ProcessingStatus { 2 | Started, 3 | InProgress, 4 | Completed, 5 | } 6 | 7 | export type CommandStatusEvent = { 8 | status: ProcessingStatus; 9 | message?: string; 10 | exitCode?: number; 11 | exitDetails?: unknown; 12 | }; 13 | 14 | export type PlanObjectEvent = CommandStatusEvent & { 15 | objectName: string; 16 | totalBatches: number; 17 | batchesCompleted: number; 18 | totalRecords: number; 19 | files: string[]; 20 | }; 21 | 22 | export type PlanObjectValidationEvent = CommandStatusEvent & { 23 | objectName: string; 24 | planName: string; 25 | }; 26 | -------------------------------------------------------------------------------- /test/data/garbage-collection/package-members/workflow-field-updates.json: -------------------------------------------------------------------------------- 1 | { 2 | "records": [ 3 | { 4 | "attributes": { 5 | "type": "Package2Member", 6 | "url": "/services/data/v62.0/tooling/sobjects/Package2Member/03v0X0000004D6kQAE" 7 | }, 8 | "Id": "03v0X0000004D6kQAE", 9 | "CurrentPackageVersionId": "04t4P0000000002AAA", 10 | "MaxPackageVersionId": "04t4P0000000002AAA", 11 | "SubjectId": "04Y0X0000000gb0UAA", 12 | "SubjectKeyPrefix": "04Y", 13 | "SubjectManageableState": "deprecatedEditable" 14 | } 15 | ], 16 | "totalSize": 1, 17 | "done": true 18 | } 19 | -------------------------------------------------------------------------------- /.github/workflows/tests.yml: -------------------------------------------------------------------------------- 1 | name: tests 2 | on: 3 | push: 4 | branches-ignore: [main] 5 | workflow_dispatch: 6 | 7 | jobs: 8 | unit-tests: 9 | uses: salesforcecli/github-workflows/.github/workflows/unitTest.yml@main 10 | nuts: 11 | needs: unit-tests 12 | uses: salesforcecli/github-workflows/.github/workflows/nut.yml@main 13 | secrets: 14 | TESTKIT_HUB_USERNAME: ${{ secrets.TESTKIT_HUB_USERNAME }} 15 | TESTKIT_AUTH_URL: ${{ secrets.TESTKIT_AUTH_URL }} 16 | strategy: 17 | matrix: 18 | os: [ubuntu-latest, windows-latest] 19 | fail-fast: false 20 | with: 21 | os: ${{ matrix.os }} 22 | -------------------------------------------------------------------------------- /test/data/garbage-collection/custom-labels.json: -------------------------------------------------------------------------------- 1 | { 2 | "records": [ 3 | { 4 | "attributes": { 5 | "type": "ExternalString", 6 | "url": "/services/data/v62.0/tooling/sobjects/ExternalString/1014P00000B3laTQAR" 7 | }, 8 | "Id": "1014P00000B3laTQAR", 9 | "Name": "Test_Label_1" 10 | }, 11 | { 12 | "attributes": { 13 | "type": "ExternalString", 14 | "url": "/services/data/v62.0/tooling/sobjects/ExternalString/1014P00000B3v7KQAR" 15 | }, 16 | "Id": "1014P00000B3v7KQAR", 17 | "Name": "Test_Label_2" 18 | } 19 | ], 20 | "totalSize": 2, 21 | "done": true 22 | } 23 | -------------------------------------------------------------------------------- /test/data/test-sfdx-project/data/contacts.json: -------------------------------------------------------------------------------- 1 | { 2 | "records": [ 3 | { 4 | "attributes": { 5 | "type": "Contact", 6 | "referenceId": "ContactRef1" 7 | }, 8 | "Salutation": "Mr.", 9 | "FirstName": "William", 10 | "LastName": "Adama", 11 | "Email": "w.adama@galactica.com", 12 | "AccountId": "@AccountRef1" 13 | }, 14 | { 15 | "attributes": { 16 | "type": "Contact", 17 | "referenceId": "ContactRef2" 18 | }, 19 | "Salutation": "Ms.", 20 | "FirstName": "Laura", 21 | "LastName": "Roslin", 22 | "Email": "l.roslin@colonial-one.com", 23 | "AccountId": "@AccountRef2" 24 | } 25 | ] 26 | } 27 | -------------------------------------------------------------------------------- /src/garbage-collection/packageManifestTypes.ts: -------------------------------------------------------------------------------- 1 | // copied from https://github.com/forcedotcom/source-deploy-retrieve/blob/main/src/common/constants.ts 2 | // all credits to mshanemc 3 | export const XML_DECL = '\n'; 4 | export const XML_NS_URL = 'http://soap.sforce.com/2006/04/metadata'; 5 | export const XML_NS_KEY = '@_xmlns'; 6 | export const XML_COMMENT_PROP_NAME = '#xml__comment'; 7 | 8 | export type PackageTypeMembers = { 9 | name: string; 10 | members: string[]; 11 | }; 12 | 13 | export type PackageManifestObject = { 14 | Package: { 15 | types: PackageTypeMembers[]; 16 | version: string; 17 | fullName?: string; 18 | [XML_NS_KEY]?: string; 19 | }; 20 | }; 21 | -------------------------------------------------------------------------------- /test/.eslintrc.cjs: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | extends: '../.eslintrc.cjs', 3 | // Allow describe and it 4 | env: { mocha: true }, 5 | rules: { 6 | // Allow assert style expressions. i.e. expect(true).to.be.true 7 | 'no-unused-expressions': 'off', 8 | 9 | // It is common for tests to stub out method. 10 | 11 | // Return types are defined by the source code. Allows for quick overwrites. 12 | '@typescript-eslint/explicit-function-return-type': 'off', 13 | // Mocked out the methods that shouldn't do anything in the tests. 14 | '@typescript-eslint/no-empty-function': 'off', 15 | // Easily return a promise in a mocked method. 16 | '@typescript-eslint/require-await': 'off', 17 | header: 'off', 18 | }, 19 | }; 20 | -------------------------------------------------------------------------------- /test/data/test-sfdx-project/export-plans/test-plan.yml: -------------------------------------------------------------------------------- 1 | name: Test Data Export Plan 2 | objects: 3 | - objectName: Account 4 | queryString: SELECT Id,Name FROM Account WHERE Type = 'Civilian Ship' 5 | exports: 6 | Id: testAccIds 7 | - objectName: Contact 8 | query: 9 | fetchAllFields: true 10 | bind: 11 | field: AccountId 12 | variable: testAccIds 13 | exports: 14 | Id: contactIds 15 | - objectName: Order 16 | query: 17 | fetchAllFields: true 18 | bind: 19 | field: AccountId 20 | variable: testAccIds 21 | - objectName: Opportunity 22 | query: 23 | fetchAllFields: true 24 | bind: 25 | field: ContactId 26 | variable: contactIds 27 | -------------------------------------------------------------------------------- /messages/orgmanifest.md: -------------------------------------------------------------------------------- 1 | # errors.no-env-configured-with-strict-validation 2 | 3 | No environment configured for target org %s, but strict validation was set. 4 | 5 | # errors.no-released-package-version 6 | 7 | No released version found for package id %s and version %s on devhub %s. 8 | 9 | # errors.package-requires-install-key 10 | 11 | The package version %s (%s) requires an installation key, but no key export was specified for the artifact. Specify the environment variable that holds the installation key in the property installation_key. 12 | 13 | # errors.install-key-defined-but-empty 14 | 15 | Installation key specified with %s, but the corresponding environment variable is not set. 16 | 17 | # errors.source-path-is-empty 18 | 19 | Artifact %s specified an empty path: %s 20 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # -- CLEAN 2 | tmp/ 3 | .jsc 4 | exports/ 5 | .sf/ 6 | .sfdx/ 7 | 8 | # use yarn by default, so ignore npm 9 | package-lock.json 10 | 11 | # never checkin npm config 12 | .npmrc 13 | 14 | # debug logs 15 | npm-error.log 16 | yarn-error.log 17 | 18 | # compile source 19 | lib 20 | 21 | # test artifacts 22 | *xunit.xml 23 | *checkstyle.xml 24 | *unitcoverage 25 | .nyc_output 26 | coverage 27 | test_session* 28 | 29 | # generated docs 30 | docs 31 | 32 | # ignore sfdx-trust files 33 | *.tgz 34 | *.sig 35 | package.json.bak. 36 | 37 | npm-shrinkwrap.json 38 | oclif.manifest.json 39 | oclif.lock 40 | 41 | # -- CLEAN ALL 42 | *.tsbuildinfo 43 | .eslintcache 44 | .wireit 45 | node_modules 46 | 47 | # -- 48 | # put files here you don't want cleaned with sf-clean 49 | 50 | # os specific files 51 | .DS_Store 52 | .idea 53 | -------------------------------------------------------------------------------- /src/common/reporters/csvResultsReporter.ts: -------------------------------------------------------------------------------- 1 | import { json2csv } from 'json-2-csv'; 2 | import ResultsReporter, { FormattingOptions } from './resultsReporter.js'; 3 | 4 | export type CsvFormattingOptions = FormattingOptions & { 5 | /** 6 | * Render a title for the table 7 | */ 8 | title?: string; 9 | /** 10 | * Explicitly remove columns from display. 11 | */ 12 | excludeColumns?: string[]; 13 | }; 14 | 15 | /** 16 | * Prints input data to the standard UX table. 17 | */ 18 | export default class CsvResultsReporter> extends ResultsReporter { 19 | public constructor(public data: T[], public options?: CsvFormattingOptions) { 20 | super(data, options); 21 | } 22 | 23 | public print(): void { 24 | const csvOutput = json2csv(this.data); 25 | this.ux.log(csvOutput); 26 | } 27 | } 28 | -------------------------------------------------------------------------------- /messages/apexscheduler.md: -------------------------------------------------------------------------------- 1 | # GenericCompileFail 2 | 3 | Failed to compile the code to schedule apex class. Diagnostic details: %s. 4 | 5 | # Unexpected 6 | 7 | An unexpected error happened: %s 8 | 9 | # UnableToParseJobId 10 | 11 | Unable to retrieve Job Id from result. 12 | 13 | # SystemAsyncException 14 | 15 | %s 16 | 17 | # FailedToRetrieveJobDetails 18 | 19 | Unexpected error happend when trying to retrieve details about job %s. Please check Setup > Scheduled Jobs and try again 20 | 21 | # InvalidCronExpression 22 | 23 | Failed to parse the cron expression "%s": %s 24 | 25 | # JobAlreadyAborted 26 | 27 | %s 28 | 29 | # JobManagementFailure 30 | 31 | An unexpected error happened when managing jobs. This probably means not all jobs were started or stopped correctly. Verify the results on the org, fix the following error, and try again: %s 32 | -------------------------------------------------------------------------------- /messages/jsc.data.export.md: -------------------------------------------------------------------------------- 1 | # summary 2 | 3 | Export all data from a plan definition. 4 | 5 | # description 6 | 7 | Takes a plan definition and exports all data from the source org. The created files are 8 | compatible with the "data import tree" command. Lookups are automatically resolved to 9 | referenceIds to retain relationships. This command allows tree exports that are orders 10 | of magnitute more complex than the basic "data export tree". 11 | 12 | # flags.source-org.summary 13 | 14 | The source org from where data is exported. 15 | 16 | # flags.plan.summary 17 | 18 | Path to the plan file that defines the export. 19 | 20 | # flags.output-dir.summary 21 | 22 | Output directory to export all fields. 23 | 24 | # flags.validate-only.summary 25 | 26 | Does not retrieve records. Only validates the plan. 27 | 28 | # examples 29 | 30 | - <%= config.bin %> <%= command.id %> 31 | -------------------------------------------------------------------------------- /src/garbage-collection/entityDefinitionHandler.ts: -------------------------------------------------------------------------------- 1 | /* eslint-disable @typescript-eslint/consistent-type-definitions */ 2 | import QueryBuilder from '../common/utils/queryBuilder.js'; 3 | import { Package2Member } from '../types/sfToolingApiTypes.js'; 4 | import { PackageGarbageContainer } from './packageGarbageTypes.js'; 5 | 6 | export interface EntityDefinitionHandler { 7 | resolve(packageMembers: Package2Member[]): Promise; 8 | } 9 | 10 | export function buildSubjectIdFilter(packageMembers: Package2Member[]): string { 11 | return QueryBuilder.buildParamListFilter('Id', extractSubjectIds(packageMembers)); 12 | } 13 | 14 | export function extractSubjectIds(packageMembers: Package2Member[]): string[] { 15 | const subjectIds: string[] = []; 16 | packageMembers.forEach((member) => { 17 | subjectIds.push(member.SubjectId); 18 | }); 19 | return subjectIds; 20 | } 21 | -------------------------------------------------------------------------------- /messages/jsc.maintain.flow-export.unused.md: -------------------------------------------------------------------------------- 1 | # summary 2 | 3 | Exports unpackaged unused flows from a target org. 4 | 5 | # description 6 | 7 | Finds versions from completely inactive flows that are not part of a package. The export contains all versions of the inactive flow. This is a complimentary command to the garbage collector, which exclusively analyses packaged flows. 8 | 9 | # flags.target-org.summary 10 | 11 | Target org to analyse. 12 | 13 | # examples 14 | 15 | - Analyse MyTargetOrg and export all unused flow versions to destructiveChanges.xml in tmp. 16 | 17 | <%= config.bin %> <%= command.id %> -o MyTargetOrg --output-dir tmp --output-format DestructiveChangesXML 18 | 19 | - Analyse MyTargetOrg and print a table with all unused flow versions 20 | 21 | <%= config.bin %> <%= command.id %> -o MyTargetOrg 22 | 23 | # success.no-unused-flows-found 24 | 25 | No unused flows found. You're all set. 26 | -------------------------------------------------------------------------------- /.github/FUNDING.yml: -------------------------------------------------------------------------------- 1 | # These are supported funding model platforms 2 | 3 | github: [j-schreiber] 4 | patreon: # Replace with a single Patreon username 5 | open_collective: # Replace with a single Open Collective username 6 | ko_fi: # Replace with a single Ko-fi username 7 | tidelift: # Replace with a single Tidelift platform-name/package-name e.g., npm/babel 8 | community_bridge: # Replace with a single Community Bridge project-name e.g., cloud-foundry 9 | liberapay: # Replace with a single Liberapay username 10 | issuehunt: # Replace with a single IssueHunt username 11 | lfx_crowdfunding: # Replace with a single LFX Crowdfunding project-name e.g., cloud-foundry 12 | polar: # Replace with a single Polar username 13 | buy_me_a_coffee: # Replace with a single Buy Me a Coffee username 14 | thanks_dev: # Replace with a single thanks.dev username 15 | custom: # Replace with up to 4 custom sponsorship URLs e.g., ['link1', 'link2'] 16 | -------------------------------------------------------------------------------- /src/common/reporters/humanResultsReporter.ts: -------------------------------------------------------------------------------- 1 | import ResultsReporter, { FormattingOptions } from './resultsReporter.js'; 2 | 3 | export type HumanFormattingOptions = FormattingOptions & { 4 | /** 5 | * Render a title for the table 6 | */ 7 | title?: string; 8 | /** 9 | * Explicitly remove columns from display. 10 | */ 11 | excludeColumns?: string[]; 12 | }; 13 | 14 | /** 15 | * Prints input data to the standard UX table. 16 | */ 17 | export default class HumanResultsReporter> extends ResultsReporter { 18 | public constructor(public data: T[], public options?: HumanFormattingOptions) { 19 | super(data, options); 20 | } 21 | 22 | public print(): void { 23 | this.ux.table({ 24 | data: this.data, 25 | columns: this.columns, 26 | title: this.options?.title, 27 | titleOptions: { bold: true, underline: true }, 28 | }); 29 | } 30 | } 31 | -------------------------------------------------------------------------------- /messages/jsc.maintain.flow-export.obsolete.md: -------------------------------------------------------------------------------- 1 | # summary 2 | 3 | Exports unpackaged obsolete flows from a target org. 4 | 5 | # description 6 | 7 | Finds and exports inactive (Obsolete or Draft) versions of unpackaged flows. The active version is never included. This is a complimentary command to the garbage collector, which exclusively analyses packaged flows. 8 | 9 | # flags.target-org.summary 10 | 11 | Target org to analyse. 12 | 13 | # examples 14 | 15 | - Analyse MyTargetOrg and export all obsolete flow versions to destructiveChanges.xml in directory tmp/dev-obsolete. 16 | 17 | <%= config.bin %> <%= command.id %> -o MyTargetOrg --output-dir tmp/dev-obsolete --output-format DestructiveChangesXML 18 | 19 | - Analyse MyTargetOrg and print a table with all obsolete flow versions 20 | 21 | <%= config.bin %> <%= command.id %> -o MyTargetOrg 22 | 23 | # success.no-obsolete-versions-found 24 | 25 | No obsolete flow versions found. You're all set. 26 | -------------------------------------------------------------------------------- /test/data/apex-schedule-service/cron-trigger-details.json: -------------------------------------------------------------------------------- 1 | { 2 | "records": [ 3 | { 4 | "attributes": { 5 | "type": "AsyncApexJob", 6 | "url": "/services/data/v62.0/sobjects/AsyncApexJob/7079b00000lpqHLAAY" 7 | }, 8 | "Id": "7079b00000lpqHLAAY", 9 | "Status": "Queued", 10 | "CronTrigger": { 11 | "attributes": { 12 | "type": "CronTrigger", 13 | "url": "/services/data/v62.0/sobjects/CronTrigger/08e9b00000KiFENAA3" 14 | }, 15 | "CronJobDetail": { 16 | "attributes": { 17 | "type": "CronJobDetail", 18 | "url": "/services/data/v62.0/sobjects/CronJobDetail/08a9b00000L1jvRAAR" 19 | }, 20 | "Name": "Job Name 5" 21 | }, 22 | "State": "WAITING", 23 | "StartTime": "2025-01-26T13:08:20.000+0000", 24 | "NextFireTime": "2025-01-27T00:05:00.000+0000" 25 | } 26 | } 27 | ], 28 | "totalSize": 1, 29 | "done": true 30 | } 31 | -------------------------------------------------------------------------------- /src/common/utils/queryRunner.ts: -------------------------------------------------------------------------------- 1 | import { EventEmitter } from 'node:events'; 2 | import { Connection } from '@salesforce/core'; 3 | import { Record } from '@jsforce/jsforce-node'; 4 | import { CommandStatusEvent, ProcessingStatus } from '../comms/processingEvents.js'; 5 | 6 | export default class QueryRunner extends EventEmitter { 7 | public constructor(private readonly orgConnection: Connection | Connection['tooling']) { 8 | super(); 9 | } 10 | 11 | public async fetchRecords(queryString: string): Promise { 12 | this.emit('queryProgress', { 13 | message: 'placeholder', 14 | status: ProcessingStatus.Started, 15 | } as CommandStatusEvent); 16 | const queryResult = await this.orgConnection.query(queryString, { autoFetch: true, maxFetch: 50_000 }); 17 | this.emit('queryProgress', { 18 | message: 'placeholder', 19 | status: ProcessingStatus.Completed, 20 | } as CommandStatusEvent); 21 | return queryResult.records; 22 | } 23 | } 24 | -------------------------------------------------------------------------------- /test/data/garbage-collection/layouts.json: -------------------------------------------------------------------------------- 1 | { 2 | "records": [ 3 | { 4 | "attributes": { 5 | "type": "Layout", 6 | "url": "/services/data/v62.0/tooling/sobjects/Layout/00h0X00000XRV9HQAX" 7 | }, 8 | "Id": "00h0X00000XRV9HQAX", 9 | "Name": "Service Contract Layout", 10 | "TableEnumOrId": "ServiceContract" 11 | }, 12 | { 13 | "attributes": { 14 | "type": "Layout", 15 | "url": "/services/data/v62.0/tooling/sobjects/Layout/00h0X00000Y4ruHQAR" 16 | }, 17 | "Id": "00h0X00000Y4ruHQAR", 18 | "Name": "Organization Profile Layout", 19 | "TableEnumOrId": "01I4P000000wXoVUAU" 20 | }, 21 | { 22 | "attributes": { 23 | "type": "Layout", 24 | "url": "/services/data/v62.0/tooling/sobjects/Layout/00h0X00000Y4ruhQAB" 25 | }, 26 | "Id": "00h0X00000Y4ruhQAB", 27 | "Name": "Price Book Layout", 28 | "TableEnumOrId": "Pricebook2" 29 | } 30 | ], 31 | "totalSize": 3, 32 | "done": true 33 | } 34 | -------------------------------------------------------------------------------- /test/data/garbage-collection/label-listview-entity-definitions.json: -------------------------------------------------------------------------------- 1 | { 2 | "records": [ 3 | { 4 | "attributes": { 5 | "type": "EntityDefinition", 6 | "url": "/services/data/v62.0/tooling/sobjects/EntityDefinition/ExternalString" 7 | }, 8 | "Id": "000000000000000AAA", 9 | "DurableId": "ExternalString", 10 | "QualifiedApiName": "ExternalString", 11 | "DeveloperName": "ExternalString", 12 | "MasterLabel": "Custom Label", 13 | "KeyPrefix": "101", 14 | "IsRetrieveable": true 15 | }, 16 | { 17 | "attributes": { 18 | "type": "EntityDefinition", 19 | "url": "/services/data/v62.0/tooling/sobjects/EntityDefinition/ListView" 20 | }, 21 | "Id": "000000000000000AAA", 22 | "DurableId": "ListView", 23 | "QualifiedApiName": "ListView", 24 | "DeveloperName": "ListView", 25 | "MasterLabel": "List View", 26 | "KeyPrefix": "00B", 27 | "IsRetrieveable": true 28 | } 29 | ], 30 | "totalSize": 3, 31 | "done": true 32 | } 33 | -------------------------------------------------------------------------------- /src/field-usage/fieldUsageTypes.ts: -------------------------------------------------------------------------------- 1 | import { Optional } from '@jsforce/jsforce-node'; 2 | 3 | export type SObjectAnalysisResult = { 4 | /** Map of record types (by developer name) and scoped analysis results */ 5 | recordTypes: Record; 6 | 7 | /** Total number of records for the entire sobject */ 8 | totalRecords: number; 9 | }; 10 | 11 | /** 12 | * Usage stats for a record type. All analysed and skipped fields 13 | * and additional information about the record type. 14 | */ 15 | export type FieldUsageTable = { 16 | totalRecords: number; 17 | isActive: boolean; 18 | analysedFields: FieldUsageStats[]; 19 | skippedFields: FieldSkippedInfo[]; 20 | }; 21 | 22 | export type FieldUsageStats = { 23 | name: string; 24 | type: string; 25 | absolutePopulated: number; 26 | percentagePopulated: number; 27 | defaultValue?: Optional; 28 | histories?: number; 29 | lastUpdated?: string; 30 | }; 31 | 32 | export type FieldSkippedInfo = { 33 | name: string; 34 | type: string; 35 | reason: string; 36 | }; 37 | -------------------------------------------------------------------------------- /test/data/test-sfdx-project/data/accounts.json: -------------------------------------------------------------------------------- 1 | { 2 | "records": [ 3 | { 4 | "attributes": { 5 | "type": "Account", 6 | "referenceId": "AccountRef1" 7 | }, 8 | "Name": "Starship Galactica Ltd.", 9 | "Type": "Warship", 10 | "BillingStreet": "Allersberger Str. 8-10", 11 | "BillingPostalCode": "90461", 12 | "BillingCity": "Nürnberg" 13 | }, 14 | { 15 | "attributes": { 16 | "type": "Account", 17 | "referenceId": "AccountRef2" 18 | }, 19 | "Name": "Colonial One", 20 | "Type": "Civilian Ship", 21 | "BillingStreet": "Bayerstraße 10", 22 | "BillingPostalCode": "80335", 23 | "BillingCity": "München" 24 | }, 25 | { 26 | "attributes": { 27 | "type": "Account", 28 | "referenceId": "AccountRef3" 29 | }, 30 | "Name": "Cloud 9 GmbH", 31 | "Type": "Civilian Ship", 32 | "BillingStreet": "Viktoriastraße 7-5", 33 | "BillingPostalCode": "86150", 34 | "BillingCity": "Augsburg" 35 | } 36 | ] 37 | } 38 | -------------------------------------------------------------------------------- /src/common/utils/wrapChildprocess.ts: -------------------------------------------------------------------------------- 1 | import util from 'node:util'; 2 | import childProcess from 'node:child_process'; 3 | import { SfCommandConfig } from './sfCommandConfig.js'; 4 | 5 | const exec = util.promisify(childProcess.exec); 6 | 7 | export default class OclifUtils { 8 | public static async execCoreCommand(conf: SfCommandConfig): Promise { 9 | const commandName = `sf ${conf.name!.split(':').join(' ')} ${[...conf.args, '--json'].join(' ')}`; 10 | try { 11 | // could use stdout and stderr callbacks to bubble events 12 | // up to the parent command for display 13 | const cmdOutput = await exec(commandName); 14 | return { status: 0, result: JSON.parse(cmdOutput.stdout) }; 15 | } catch (e) { 16 | if (e instanceof Error && 'stdout' in e) { 17 | return { status: 1, result: JSON.parse(e.stdout as string) }; 18 | } else { 19 | return { status: 1, result: e }; 20 | } 21 | } 22 | } 23 | } 24 | 25 | export type CommandResult = { 26 | status: number | null; 27 | result: unknown; 28 | }; 29 | -------------------------------------------------------------------------------- /src/release-manifest/artifact-deploy-strategies/artifactDeployStrategy.ts: -------------------------------------------------------------------------------- 1 | import { Connection } from '@salesforce/core'; 2 | import { ZArtifactDeployResultType } from '../../types/orgManifestOutputSchema.js'; 3 | 4 | export type ArtifactDeployStrategy = { 5 | /** 6 | * Used by resolve API to determine, if the manifest loads at least 7 | * one deploy step that requires to be run from an sfdx project. 8 | */ 9 | requiresSfdxProject: boolean; 10 | 11 | /** 12 | * Return the internal state of the job, for inspection 13 | */ 14 | getStatus(): Partial; 15 | 16 | /** 17 | * Deploys the artifact step with the current internal state. 18 | * Throws an exception, if called on a step that is not resolved. 19 | */ 20 | deploy(): Promise; 21 | 22 | /** 23 | * Prepare internal state of the step before "deploy" is run. 24 | * Requires both orgs connections ?? -> need to check 25 | * 26 | * @param targetOrg 27 | * @param devhubOrg 28 | */ 29 | resolve(targetOrg: Connection, devhubOrg: Connection): Promise; 30 | }; 31 | -------------------------------------------------------------------------------- /test/data/garbage-collection/package-members/label-and-list-view.json: -------------------------------------------------------------------------------- 1 | { 2 | "records": [ 3 | { 4 | "attributes": { 5 | "type": "Package2Member", 6 | "url": "/services/data/v62.0/tooling/sobjects/Package2Member/03v4P0000004JPzQAM" 7 | }, 8 | "Id": "03v4P0000004JPzQAM", 9 | "CurrentPackageVersionId": "04t4P0000000003AAA", 10 | "MaxPackageVersionId": "04t4P0000000003AAA", 11 | "SubjectId": "1014P00000B3v7KQAR", 12 | "SubjectKeyPrefix": "101", 13 | "SubjectManageableState": "deprecatedEditable", 14 | "SubscriberPackageId": "0330X0000000000AAA" 15 | }, 16 | { 17 | "attributes": { 18 | "type": "Package2Member", 19 | "url": "/services/data/v62.0/tooling/sobjects/Package2Member/03v4P0000004JGmQAM" 20 | }, 21 | "Id": "03v4P0000004JGmQAM", 22 | "CurrentPackageVersionId": "04t4P0000000001AAA", 23 | "MaxPackageVersionId": "04t4P0000000001AAA", 24 | "SubjectId": "00B4P000009HFvqUAG", 25 | "SubjectKeyPrefix": "00B", 26 | "SubjectManageableState": "deprecatedEditable", 27 | "SubscriberPackageId": "0330X0000000000AAA" 28 | } 29 | ], 30 | "totalSize": 2, 31 | "done": true 32 | } 33 | -------------------------------------------------------------------------------- /test/data/garbage-collection/packaged-flows.json: -------------------------------------------------------------------------------- 1 | { 2 | "records": [ 3 | { 4 | "attributes": { 5 | "type": "Package2Member", 6 | "url": "/services/data/v62.0/tooling/sobjects/Package2Member/03v67000000Go9rAAC" 7 | }, 8 | "Id": "03v67000000Go9rAAC", 9 | "CurrentPackageVersionId": "04tWo0000001111AAA", 10 | "SubscriberPackageId": "0330X0000000000AAA", 11 | "MaxPackageVersionId": null, 12 | "MaxPackageVersion": null, 13 | "SubjectId": "3009Q00000EZMYfQAP", 14 | "SubjectKeyPrefix": "300", 15 | "SubjectManageableState": "installedEditable" 16 | }, 17 | { 18 | "attributes": { 19 | "type": "Package2Member", 20 | "url": "/services/data/v62.0/tooling/sobjects/Package2Member/03v67000000PBEVAA4" 21 | }, 22 | "Id": "03v67000000PBEVAA4", 23 | "CurrentPackageVersionId": "04tbO0000002222AAA", 24 | "SubscriberPackageId": "0330X0000000001AAA", 25 | "MaxPackageVersionId": null, 26 | "MaxPackageVersion": null, 27 | "SubjectId": "3007a000000PIKoAAO", 28 | "SubjectKeyPrefix": "300", 29 | "SubjectManageableState": "installedEditable" 30 | } 31 | ], 32 | "totalSize": 2, 33 | "done": true 34 | } 35 | -------------------------------------------------------------------------------- /test/data/apex-schedule-service/schedule-stop-success.json: -------------------------------------------------------------------------------- 1 | { 2 | "compiled": true, 3 | "success": true, 4 | "logs": "62.0 APEX_CODE,DEBUG;APEX_PROFILING,INFO\nExecute Anonymous: System.abortJob('08e9b00000L02llAAB');\n13:03:15.23 (23772157)|USER_INFO|[EXTERNAL]|0059b00000EyPaY|test-hm5zkhjbc6hb@example.com|(GMT+01:00) Central European Standard Time (Europe/Berlin)|GMT+01:00\n13:03:15.23 (23792666)|EXECUTION_STARTED\n13:03:15.23 (23800684)|CODE_UNIT_STARTED|[EXTERNAL]|execute_anonymous_apex\n13:03:15.108 (108733192)|CUMULATIVE_LIMIT_USAGE\n13:03:15.108 (108733192)|LIMIT_USAGE_FOR_NS|(default)|\n Number of SOQL queries: 0 out of 100\n Number of query rows: 0 out of 50000\n Number of SOSL queries: 0 out of 20\n Number of DML statements: 1 out of 150\n Number of Publish Immediate DML: 0 out of 150\n Number of DML rows: 1 out of 10000\n Maximum CPU time: 0 out of 10000\n Maximum heap size: 0 out of 6000000\n Number of callouts: 0 out of 100\n Number of Email Invocations: 0 out of 10\n Number of future calls: 0 out of 50\n Number of queueable jobs added to the queue: 0 out of 50\n Number of Mobile Apex push calls: 0 out of 10\n\n13:03:15.108 (108733192)|CUMULATIVE_LIMIT_USAGE_END\n\n13:03:15.23 (108802455)|CODE_UNIT_FINISHED|execute_anonymous_apex\n13:03:15.23 (108822073)|EXECUTION_FINISHED\n" 5 | } 6 | -------------------------------------------------------------------------------- /src/garbage-collection/entity-handlers/customObject.ts: -------------------------------------------------------------------------------- 1 | /* eslint-disable no-console */ 2 | import { Connection } from '@salesforce/core'; 3 | import { Package2Member } from '../../types/sfToolingApiTypes.js'; 4 | import { EntityDefinitionHandler } from '../entityDefinitionHandler.js'; 5 | import { PackageGarbage, PackageGarbageContainer } from '../packageGarbageTypes.js'; 6 | import ToolingApiConnection from '../toolingApiConnection.js'; 7 | 8 | export class CustomObject implements EntityDefinitionHandler { 9 | public constructor(private readonly queryConnection: Connection) {} 10 | 11 | public async resolve(packageMembers: Package2Member[]): Promise { 12 | const garbageList: PackageGarbage[] = []; 13 | const objectsByDurableId = await ToolingApiConnection.getInstance( 14 | this.queryConnection 15 | ).fetchObjectDefinitionsByDurableId(); 16 | packageMembers.forEach((member) => { 17 | const definition = objectsByDurableId.get(member.SubjectId.substring(0, 15)); 18 | if (definition) { 19 | garbageList.push(new PackageGarbage(member, definition.DeveloperName, definition.QualifiedApiName)); 20 | } 21 | }); 22 | return { metadataType: 'CustomObject', componentCount: garbageList.length, components: garbageList }; 23 | } 24 | } 25 | -------------------------------------------------------------------------------- /messages/jsc.apex.schedule.export.md: -------------------------------------------------------------------------------- 1 | # summary 2 | 3 | List all scheduled jobs on the target org. 4 | 5 | # description 6 | 7 | Export all jobs currently scheduled on the target org or specify additional filters to narrow down search results. 8 | 9 | # flags.target-org.summary 10 | 11 | Target org to check. 12 | 13 | # flags.apex-class-name.summary 14 | 15 | Only list jobs from a specific apex class. 16 | 17 | # flags.job-name.summary 18 | 19 | Only list jobs with a specific job name. Supports partial matches. 20 | 21 | # flags.output-dir.summary 22 | 23 | Writes exported jobs to a config file that can be used with "manage" command. 24 | 25 | # flags.concise.summary 26 | 27 | Minimize columns displayed in output table. 28 | 29 | # examples 30 | 31 | - List all jobs on the target org 32 | 33 | <%= config.bin %> <%= command.id %> -o MyTargetOrg 34 | 35 | - List jobs that match apex class and job name 36 | 37 | <%= config.bin %> <%= command.id %> -o MyTargetOrg -c MyScheduledJobClass -n "Scheduled Job Name" 38 | 39 | - List jobs that start with "Auto" and export them to tmp/dev/jobs.yaml 40 | 41 | <%= config.bin %> <%= command.id %> -j "Auto" -d tmp/dev 42 | 43 | # info.wrote-output-to-file 44 | 45 | Successfully wrote export to config file: %s 46 | 47 | # info.no-scheduled-jobs-found 48 | 49 | No scheduled jobs found on org. Nothing to show. 50 | -------------------------------------------------------------------------------- /src/common/migrationPlanLoader.ts: -------------------------------------------------------------------------------- 1 | import { Connection, SfError } from '@salesforce/core'; 2 | import { ZMigrationPlan, ZMigrationPlanType } from '../types/migrationPlanObjectData.js'; 3 | import MigrationPlan from './migrationPlan.js'; 4 | import { parseYaml } from './utils/fileUtils.js'; 5 | 6 | export default class MigrationPlanLoader { 7 | public static async loadPlan(filePath: string, sourcecon: Connection): Promise { 8 | const planData = parseYaml(filePath, ZMigrationPlan); 9 | this.assertVariableExports(planData); 10 | const plan = new MigrationPlan(planData, sourcecon); 11 | await plan.load(); 12 | return plan; 13 | } 14 | 15 | private static assertVariableExports(plan: ZMigrationPlanType): void { 16 | const exportedVariables: string[] = []; 17 | plan.objects.forEach((objectDef) => { 18 | if (objectDef.query?.bind) { 19 | if (!exportedVariables.includes(objectDef.query.bind.variable)) { 20 | throw new SfError( 21 | `${objectDef.objectName} references a parent bind that was not defined: ${objectDef.query.bind.variable}`, 22 | 'InvalidPlanFileSyntax' 23 | ); 24 | } 25 | } 26 | if (objectDef.exports) { 27 | Object.values(objectDef.exports).forEach((exportedVars) => exportedVariables.push(exportedVars)); 28 | } 29 | }); 30 | } 31 | } 32 | -------------------------------------------------------------------------------- /test/data/garbage-collection/package-members/layouts.json: -------------------------------------------------------------------------------- 1 | { 2 | "records": [ 3 | { 4 | "attributes": { 5 | "type": "Package2Member", 6 | "url": "/services/data/v62.0/tooling/sobjects/Package2Member/03v0X0000004CxhQAE" 7 | }, 8 | "Id": "03v0X0000004CxhQAE", 9 | "CurrentPackageVersionId": "04t670000015YcsAAE", 10 | "MaxPackageVersionId": "04t670000015YcsAAE", 11 | "SubjectId": "00h0X00000XRV9HQAX", 12 | "SubjectKeyPrefix": "00h" 13 | }, 14 | { 15 | "attributes": { 16 | "type": "Package2Member", 17 | "url": "/services/data/v62.0/tooling/sobjects/Package2Member/03v0X0000004Cy0QAE" 18 | }, 19 | "Id": "03v0X0000004Cy0QAE", 20 | "CurrentPackageVersionId": "04t670000015YcsAAE", 21 | "MaxPackageVersionId": "04t670000015YcsAAE", 22 | "SubjectId": "00h0X00000Y4ruhQAB", 23 | "SubjectKeyPrefix": "00h" 24 | }, 25 | { 26 | "attributes": { 27 | "type": "Package2Member", 28 | "url": "/services/data/v62.0/tooling/sobjects/Package2Member/03v67000000sXv2AAE" 29 | }, 30 | "Id": "03v67000000sXv2AAE", 31 | "CurrentPackageVersionId": "04t67000000iE0WAAU", 32 | "MaxPackageVersionId": "04t67000000iE0WAAU", 33 | "SubjectId": "00h0X00000Y4ruHQAR", 34 | "SubjectKeyPrefix": "00h" 35 | } 36 | ], 37 | "totalSize": 2, 38 | "done": true 39 | } 40 | -------------------------------------------------------------------------------- /src/garbage-collection/packageMemberFilter.ts: -------------------------------------------------------------------------------- 1 | import { EntityDefinition, Package2, Package2Member } from '../types/sfToolingApiTypes.js'; 2 | 3 | export default class PackageMemberFilter { 4 | private readonly allowedSubscriberPackages?: string[]; 5 | private readonly allowedKeyPrefixes?: string[]; 6 | 7 | public constructor(packages: Package2[], entities: EntityDefinition[]) { 8 | if (packages && packages.length > 0) { 9 | this.allowedSubscriberPackages = packages.map((pgk) => pgk.SubscriberPackageId); 10 | } 11 | if (entities && entities.length > 0) { 12 | this.allowedKeyPrefixes = entities.map((entity) => entity.KeyPrefix); 13 | } 14 | } 15 | 16 | public isAllowed(member: Package2Member): boolean { 17 | return this.isAllowedSubscriberPackage(member) && this.isAllowedKeyPrefix(member); 18 | } 19 | 20 | private isAllowedKeyPrefix(member: Package2Member): boolean { 21 | if (!this.allowedKeyPrefixes || this.allowedKeyPrefixes.length === 0) { 22 | return true; 23 | } 24 | return this.allowedKeyPrefixes.includes(member.SubjectKeyPrefix); 25 | } 26 | 27 | private isAllowedSubscriberPackage(member: Package2Member): boolean { 28 | if (!this.allowedSubscriberPackages || this.allowedSubscriberPackages.length === 0) { 29 | return true; 30 | } 31 | return this.allowedSubscriberPackages.includes(member.SubscriberPackageId); 32 | } 33 | } 34 | -------------------------------------------------------------------------------- /test/data/plans/complex-plan-all-data.yaml: -------------------------------------------------------------------------------- 1 | name: Complex Test Plan ALL DATA 2 | objects: 3 | - objectName: Account 4 | query: 5 | fetchAllFields: true 6 | - objectName: Contact 7 | query: 8 | fetchAllFields: true 9 | - objectName: Case 10 | query: 11 | fetchAllFields: true 12 | - objectName: Order 13 | query: 14 | fetchAllFields: true 15 | - objectName: OrderItem 16 | query: 17 | fetchAllFields: true 18 | - objectName: Quote 19 | query: 20 | fetchAllFields: true 21 | - objectName: QuoteLineItem 22 | query: 23 | fetchAllFields: true 24 | - objectName: Opportunity 25 | query: 26 | fetchAllFields: true 27 | - objectName: OpportunityLineItem 28 | query: 29 | fetchAllFields: true 30 | - objectName: ServiceContract 31 | query: 32 | fetchAllFields: true 33 | - objectName: ContractLineItem 34 | query: 35 | fetchAllFields: true 36 | - objectName: Site__c 37 | query: 38 | fetchAllFields: true 39 | - objectName: Asset 40 | query: 41 | fetchAllFields: true 42 | - objectName: SiteContact__c 43 | query: 44 | fetchAllFields: true 45 | - objectName: Product2 46 | query: 47 | fetchAllFields: true 48 | - objectName: Pricebook2 49 | query: 50 | fetchAllFields: true 51 | - objectName: PricebookEntry 52 | query: 53 | fetchAllFields: true 54 | -------------------------------------------------------------------------------- /.vscode/launch.json: -------------------------------------------------------------------------------- 1 | { 2 | // Use IntelliSense to learn about possible attributes. 3 | // Hover to view descriptions of existing attributes. 4 | // For more information, visit: https://go.microsoft.com/fwlink/?linkid=830387 5 | "version": "0.2.0", 6 | "configurations": [ 7 | { 8 | "type": "node", 9 | "request": "attach", 10 | "name": "Attach", 11 | "port": 9229, 12 | "skipFiles": ["/**"] 13 | }, 14 | { 15 | "name": "Run All Tests", 16 | "type": "node", 17 | "request": "launch", 18 | "program": "${workspaceFolder}/node_modules/mocha/bin/mocha", 19 | "args": ["--inspect", "--colors", "test/**/*.test.ts"], 20 | "env": { 21 | "NODE_ENV": "development", 22 | "SFDX_ENV": "development" 23 | }, 24 | "sourceMaps": true, 25 | "smartStep": true, 26 | "internalConsoleOptions": "openOnSessionStart" 27 | }, 28 | { 29 | "type": "node", 30 | "request": "launch", 31 | "name": "Run Current Test", 32 | "program": "${workspaceFolder}/node_modules/mocha/bin/mocha", 33 | "args": ["--inspect", "--colors", "${file}"], 34 | "env": { 35 | "NODE_ENV": "development", 36 | "SFDX_ENV": "development" 37 | }, 38 | "sourceMaps": true, 39 | "smartStep": true, 40 | "internalConsoleOptions": "openOnSessionStart" 41 | } 42 | ] 43 | } 44 | -------------------------------------------------------------------------------- /test/data/manifests/complex-with-envs.yaml: -------------------------------------------------------------------------------- 1 | environments: 2 | dev: admin-salesforce@mobilityhouse.com.dev 3 | qa: admin@example.com.qa 4 | prod: admin@example.com 5 | artifacts: 6 | org_shape_settings: 7 | type: Unpackaged 8 | path: test/data/mock-src/unpackaged/org-shape 9 | apex_utils: 10 | type: UnlockedPackage 11 | package_id: 0Ho690000000000AAA 12 | installation_key: APEX_UTILS_INSTALLATION_KEY 13 | version: 1.28.0 14 | skip_if_installed: false 15 | lwc_utils: 16 | type: UnlockedPackage 17 | package_id: 0Ho690000000001AAA 18 | installation_key: LWC_UTILS_INSTALLATION_KEY 19 | version: 0.12.0 20 | core_crm: 21 | type: UnlockedPackage 22 | package_id: 0Ho690000000002AAA 23 | installation_key: CORE_INSTALLATION_KEY 24 | version: 2.4.2 25 | core_crm_overrides: 26 | type: Unpackaged 27 | path: 28 | dev: test/data/mock-src/package-overrides/core-crm/dev 29 | qa: test/data/mock-src/package-overrides/core-crm/dev 30 | prod: test/data/mock-src/package-overrides/core-crm/prod 31 | core_crm_extensions: 32 | type: Unpackaged 33 | path: test/data/mock-src/package-extensions/core-crm 34 | pims: 35 | type: UnlockedPackage 36 | installation_key: PIMS_INSTALLATION_KEY 37 | package_id: 0Ho690000000003AAA 38 | version: 2.9.0 39 | pims_overrides: 40 | type: Unpackaged 41 | path: test/data/mock-src/package-overrides/pims -------------------------------------------------------------------------------- /messages/jsc.manifest.rollout.md: -------------------------------------------------------------------------------- 1 | # summary 2 | 3 | Roll out a manifest. This deploys the artifacts of the manifest (unpackaged, package, etc) to the target org. 4 | 5 | # description 6 | 7 | The command takes an Org Manifest and rolls out its artifacts to a target org. Dynamic paths for unpackaged artifacts are resolved based on mapped environments, package versions are resolved based on the DevHub org. 8 | 9 | # flags.manifest.summary 10 | 11 | A manifest file that defines the desired state of the target org 12 | 13 | # flags.target-org.summary 14 | 15 | Target org (sandbox, production, etc) where artifacts of the manifest should be rolled out. 16 | 17 | # flags.devhub-org.summary 18 | 19 | Devhub that owns the packages. Needed to resolve package versions. 20 | 21 | # flags.verbose.summary 22 | 23 | Placeholder - Prints all subcommand outputs to terminal (e.g. deployed source files, package install status, etc) 24 | 25 | # flags.validate-only.summary 26 | 27 | Only validate the manifest file, do not perform any rollout actions like package installs or source deploys. 28 | 29 | # examples 30 | 31 | - <%= config.bin %> <%= command.id %> 32 | 33 | # errors.manifest-requires-project 34 | 35 | Manifest has at least one step that requires a project, but no valid sfdx project was found in this directory. 36 | 37 | # infos.target-org-info 38 | 39 | Target org for rollout: %s 40 | 41 | # infos.devhub-org-info 42 | 43 | Devhub to resolve packages: %s 44 | -------------------------------------------------------------------------------- /test/data/garbage-collection/cmd-m01-records.json: -------------------------------------------------------------------------------- 1 | { 2 | "records": [ 3 | { 4 | "attributes": { 5 | "type": "HandlerControl__mdt", 6 | "url": "/services/data/v62.0/sobjects/HandlerControl__mdt/m014P0000009mf9QAA" 7 | }, 8 | "Id": "m014P0000009mf9QAA", 9 | "DeveloperName": "Budget" 10 | }, 11 | { 12 | "attributes": { 13 | "type": "HandlerControl__mdt", 14 | "url": "/services/data/v62.0/sobjects/HandlerControl__mdt/m014P0000009mfAQAQ" 15 | }, 16 | "Id": "m014P0000009mfAQAQ", 17 | "DeveloperName": "Invoice" 18 | }, 19 | { 20 | "attributes": { 21 | "type": "HandlerControl__mdt", 22 | "url": "/services/data/v62.0/sobjects/HandlerControl__mdt/m014P0000009ms0QAA" 23 | }, 24 | "Id": "m014P0000009ms0QAA", 25 | "DeveloperName": "InvoiceLineItem" 26 | }, 27 | { 28 | "attributes": { 29 | "type": "HandlerControl__mdt", 30 | "url": "/services/data/v62.0/sobjects/HandlerControl__mdt/m014P0000009mw2QAA" 31 | }, 32 | "Id": "m014P0000009mw2QAA", 33 | "DeveloperName": "InvoicePdfGeneration" 34 | }, 35 | { 36 | "attributes": { 37 | "type": "HandlerControl__mdt", 38 | "url": "/services/data/v62.0/sobjects/HandlerControl__mdt/m014P0000009mfBQAQ" 39 | }, 40 | "Id": "m014P0000009mfBQAQ", 41 | "DeveloperName": "TimeEntry" 42 | } 43 | ], 44 | "totalSize": 5, 45 | "done": true 46 | } 47 | -------------------------------------------------------------------------------- /test/common/planCache.test.ts: -------------------------------------------------------------------------------- 1 | import { expect } from 'chai'; 2 | import PlanCache from '../../src/common/planCache.js'; 3 | 4 | describe('plan cache', () => { 5 | afterEach(() => { 6 | PlanCache.flush(); 7 | }); 8 | 9 | it('returns single chunk for id length below chunk size', () => { 10 | // Arrange 11 | const ids = []; 12 | for (let i = 0; i < PlanCache.CHUNK_SIZE; i++) { 13 | ids.push(`A${i}`); 14 | } 15 | PlanCache.push('myIds', ids); 16 | 17 | // Act 18 | const returnedIds = PlanCache.getChunks('myIds'); 19 | 20 | // Assert 21 | expect(returnedIds.length).equals(1); 22 | expect(returnedIds[0].length).equals(ids.length); 23 | }); 24 | 25 | it('returns multiple chunks for id length above chunk size', () => { 26 | // Arrange 27 | PlanCache.CHUNK_SIZE = 100; 28 | const ids = []; 29 | for (let i = 0; i < PlanCache.CHUNK_SIZE * 5 - 10; i++) { 30 | ids.push(`A${i}`); 31 | } 32 | PlanCache.push('myIds', ids); 33 | 34 | // Act 35 | const returnedIds = PlanCache.getChunks('myIds'); 36 | 37 | // Assert 38 | expect(returnedIds.length).equals(5); 39 | expect(returnedIds[0].length).equals(PlanCache.CHUNK_SIZE); 40 | expect(returnedIds[1].length).equals(PlanCache.CHUNK_SIZE); 41 | expect(returnedIds[2].length).equals(PlanCache.CHUNK_SIZE); 42 | expect(returnedIds[3].length).equals(PlanCache.CHUNK_SIZE); 43 | expect(returnedIds[4].length).equals(PlanCache.CHUNK_SIZE - 10); 44 | }); 45 | }); 46 | -------------------------------------------------------------------------------- /test/data/garbage-collection/filtered-entity-definitions.json: -------------------------------------------------------------------------------- 1 | { 2 | "records": [ 3 | { 4 | "attributes": { 5 | "type": "EntityDefinition", 6 | "url": "/services/data/v62.0/tooling/sobjects/EntityDefinition/ExternalString" 7 | }, 8 | "Id": "000000000000000AAA", 9 | "DurableId": "ExternalString", 10 | "QualifiedApiName": "ExternalString", 11 | "DeveloperName": "ExternalString", 12 | "MasterLabel": "Custom Label", 13 | "KeyPrefix": "101", 14 | "IsRetrieveable": true 15 | }, 16 | { 17 | "attributes": { 18 | "type": "EntityDefinition", 19 | "url": "/services/data/v62.0/tooling/sobjects/EntityDefinition/01I4P000000wXQ1" 20 | }, 21 | "Id": "000000000000000AAA", 22 | "DurableId": "01I4P000000wXQ1", 23 | "QualifiedApiName": "CompanyData__mdt", 24 | "DeveloperName": "CompanyData", 25 | "MasterLabel": "Company Data", 26 | "KeyPrefix": "m00", 27 | "IsRetrieveable": true 28 | }, 29 | { 30 | "attributes": { 31 | "type": "EntityDefinition", 32 | "url": "/services/data/v62.0/tooling/sobjects/EntityDefinition/Layout" 33 | }, 34 | "Id": "000000000000000AAA", 35 | "DurableId": "Layout", 36 | "QualifiedApiName": "Layout", 37 | "DeveloperName": null, 38 | "MasterLabel": "Page Layout", 39 | "KeyPrefix": "00h", 40 | "IsRetrieveable": true 41 | } 42 | ], 43 | "totalSize": 3, 44 | "done": true 45 | } 46 | -------------------------------------------------------------------------------- /test/data/test-sfdx-project/scripts/plain-scratch-setup.sh: -------------------------------------------------------------------------------- 1 | #! /bin/bash 2 | # shellcheck disable=SC1091 3 | set -e 4 | 5 | alias=TestOrg 6 | duration=7 7 | configFile='config/default-scratch-def.json' 8 | devhubusername= 9 | 10 | while getopts a:d:f:v: option; do 11 | case "${option}" in 12 | a) alias=${OPTARG} ;; 13 | d) duration=${OPTARG} ;; 14 | f) configFile=${OPTARG} ;; 15 | v) devhubusername=${OPTARG} ;; 16 | *) ;; 17 | esac 18 | done 19 | 20 | echo "============================================" 21 | echo "Creating Scratch Org with these properties:" 22 | echo "Devhub: $devhubusername" 23 | echo "Config: $configFile" 24 | echo "Duration: $duration" 25 | echo "Alias: $alias" 26 | echo "============================================" 27 | 28 | if [ -z "$devhubusername" ]; then 29 | echo "sf org create scratch -y $duration -f $configFile -a $alias -d --json" 30 | sf org create scratch -y "$duration" -f "$configFile" -a "$alias" -d --json 31 | else 32 | echo "sf org create scratch -v $devhubusername -y $duration -f $configFile -a $alias -d --json" 33 | sf org create scratch -v "$devhubusername" -y "$duration" -f "$configFile" -a "$alias" -d --json 34 | fi 35 | 36 | echo "Deploy unpackaged source" 37 | sf project deploy start --ignore-conflicts 38 | 39 | echo "Generating login link for debugging" 40 | sf org open -o "$alias" -r 41 | 42 | echo "sf org open -o $alias -p \"/lightning/setup/SetupOneHome/home\"" 43 | sf org open -o "$alias" -p "/lightning/setup/SetupOneHome/home" 44 | -------------------------------------------------------------------------------- /src/common/planCache.ts: -------------------------------------------------------------------------------- 1 | export default class PlanCache { 2 | public static CHUNK_SIZE = 400; 3 | private static cache: Map> = new Map>(); 4 | 5 | public static isSet(key: string): boolean { 6 | return this.cache.has(key); 7 | } 8 | 9 | public static getChunks(key: string): string[][] { 10 | const allIds = this.getNullSafe(key); 11 | const chunks = []; 12 | for (let i = 0; i < allIds.length; i += PlanCache.CHUNK_SIZE) { 13 | chunks.push(allIds.slice(i, i + PlanCache.CHUNK_SIZE)); 14 | } 15 | return chunks; 16 | } 17 | 18 | public static get(key: string): string[] | undefined { 19 | if (this.cache.get(key) !== undefined) { 20 | return Array.from(this.cache.get(key)!); 21 | } 22 | return; 23 | } 24 | 25 | public static getNullSafe(key: string): string[] { 26 | if (this.isSet(key)) { 27 | return Array.from(this.cache.get(key)!); 28 | } 29 | return []; 30 | } 31 | 32 | public static set(key: string, ids: string[]): void { 33 | this.cache.set(key, new Set(ids)); 34 | } 35 | 36 | public static push(key: string, ids: string[]): void { 37 | if (this.cache.has(key)) { 38 | const cachedIds = this.cache.get(key)!; 39 | // eslint-disable-next-line @typescript-eslint/unbound-method 40 | ids.forEach(cachedIds.add, cachedIds); 41 | } else { 42 | this.set(key, ids); 43 | } 44 | } 45 | 46 | public static flush(): void { 47 | this.cache.clear(); 48 | } 49 | } 50 | -------------------------------------------------------------------------------- /messages/jsc.maintain.common.md: -------------------------------------------------------------------------------- 1 | # flags.output-format.summary 2 | 3 | Specify in which manifest file the content is written. 4 | 5 | # flags.output-format.description 6 | 7 | The default option prepares a package.xml with all exported components. If you specify DestructiveChangesXML, the command creates an empty package.xml and writes all components into destructiveChanges.xml. This flag only has an effect, if the output directory is set. No source is retrieved or deployed. 8 | 9 | # flags.output-dir.summary 10 | 11 | Path where package manifests will be created. 12 | 13 | # flags.output-dir.description 14 | 15 | When provided, creates manifest file (package.xml) at the target location with all exported content. Use the --output-format flag to write contents to destructiveChanges.xml. 16 | 17 | # flags.concise.summary 18 | 19 | Summarize flow output table. 20 | 21 | # flags.concise.description 22 | 23 | Instead of showing individual exported flow versions, show aggregated information with the flow name and the total number of versions. Only modifies the formatted output table, not the JSON output or generated package manifests. 24 | 25 | # flags.result-format.summary 26 | 27 | Change the display formatting of output tables. 28 | 29 | # flags.result-format.description 30 | 31 | Changes output format of table results that are printed to stdout. Use a format that is easier to copy-paste or export into other programs that support the format. For example, use markdown to copy-paste table outputs to Obsidian or Confluence. 32 | -------------------------------------------------------------------------------- /test/common/packageManifestBuilder.test.ts: -------------------------------------------------------------------------------- 1 | import { expect } from 'chai'; 2 | import PackageManifestBuilder from '../../src/common/packageManifestBuilder.js'; 3 | 4 | const EMPTY_PACKAGE_XML = ` 5 | 62.0 6 | 7 | `; 8 | 9 | const MULTI_TYPE_PACKAGE_XML = ` 10 | 11 | Flow 12 | Test_Flow-1 13 | 14 | 15 | CustomLabel 16 | Member_One 17 | Member_Two 18 | 19 | 64.0 20 | 21 | `; 22 | 23 | describe('package manifest builder', () => { 24 | it('builds empty XML with default params and no types added', () => { 25 | // Act 26 | const manifest = new PackageManifestBuilder(); 27 | const xmlOutput = manifest.toXML(); 28 | 29 | // Assert 30 | expect(xmlOutput).to.equal(EMPTY_PACKAGE_XML); 31 | }); 32 | 33 | it('formats correct package XML from multiple types with members', () => { 34 | // Act 35 | const manifest = new PackageManifestBuilder('64.0'); 36 | manifest.addMember('Flow', 'Test_Flow-1'); 37 | manifest.addMember('CustomLabel', 'Member_One'); 38 | manifest.addMember('CustomLabel', 'Member_Two'); 39 | const xmlOutput = manifest.toXML(); 40 | 41 | // Assert 42 | expect(xmlOutput).to.equal(MULTI_TYPE_PACKAGE_XML); 43 | }); 44 | }); 45 | -------------------------------------------------------------------------------- /src/types/scheduledApexTypes.ts: -------------------------------------------------------------------------------- 1 | /* eslint-disable camelcase */ 2 | import { z } from 'zod'; 3 | 4 | export type AsyncApexJob = { 5 | Id: string; 6 | CronTriggerId: string; 7 | CronTrigger: CronTrigger; 8 | ApexClass: ApexClass; 9 | }; 10 | 11 | export type AsyncApexJobFlat = { 12 | CronTriggerId: string; 13 | ApexClassName: string; 14 | CronTriggerState: string; 15 | NextFireTime: Date; 16 | StartTime: Date; 17 | CronJobDetailName: string; 18 | TimesTriggered: number; 19 | CronExpression?: string; 20 | }; 21 | 22 | export type CronTrigger = { 23 | State: string; 24 | StartTime: string; 25 | NextFireTime: string; 26 | CronJobDetail: CronJobDetail; 27 | TimesTriggered: number; 28 | CronExpression?: string; 29 | }; 30 | 31 | export type CronJobDetail = { 32 | Name: string; 33 | }; 34 | 35 | export type ApexClass = { 36 | Name: string; 37 | }; 38 | 39 | const ScheduledJobConfigOptions = z 40 | .object({ 41 | stop_other_jobs: z.boolean().default(false), 42 | }) 43 | .strict('Valid options are: stop_other_jobs') 44 | .default({}); 45 | 46 | const SingleScheduledJobConfig = z 47 | .object({ class: z.string().optional(), expression: z.string().nonempty('A valid cron expression is required') }) 48 | .strict(); 49 | 50 | export const ScheduledJobConfig = z 51 | .object({ 52 | options: ScheduledJobConfigOptions, 53 | jobs: z.record(SingleScheduledJobConfig).default({}), 54 | }) 55 | .strict(); 56 | 57 | export type ScheduledJobConfigType = z.infer; 58 | -------------------------------------------------------------------------------- /test/data/garbage-collection/package-members/cmd-records.json: -------------------------------------------------------------------------------- 1 | { 2 | "records": [ 3 | { 4 | "attributes": { 5 | "type": "Package2Member", 6 | "url": "/services/data/v62.0/tooling/sobjects/Package2Member/03v4P0000004JJRQA2" 7 | }, 8 | "Id": "03v4P0000004JJRQA2", 9 | "CurrentPackageVersionId": "04t4P0000000002AAA", 10 | "MaxPackageVersionId": "04t4P0000000002AAA", 11 | "SubjectId": "m004P0000009UQvQAM", 12 | "SubjectKeyPrefix": "m00", 13 | "SubjectManageableState": "deprecatedEditable" 14 | }, 15 | { 16 | "attributes": { 17 | "type": "Package2Member", 18 | "url": "/services/data/v62.0/tooling/sobjects/Package2Member/03v4P0000004JJSQA2" 19 | }, 20 | "Id": "03v4P0000004JJSQA2", 21 | "CurrentPackageVersionId": "04t4P0000000002AAA", 22 | "MaxPackageVersionId": "04t4P0000000002AAA", 23 | "SubjectId": "m004P0000009UQwQAM", 24 | "SubjectKeyPrefix": "m00", 25 | "SubjectManageableState": "deprecatedEditable" 26 | }, 27 | { 28 | "attributes": { 29 | "type": "Package2Member", 30 | "url": "/services/data/v62.0/tooling/sobjects/Package2Member/03v4P0000004JJTQA2" 31 | }, 32 | "Id": "03v4P0000004JJTQA2", 33 | "CurrentPackageVersionId": "04t4P0000000002AAA", 34 | "MaxPackageVersionId": "04t4P0000000002AAA", 35 | "SubjectId": "m014P0000009mfAQAQ", 36 | "SubjectKeyPrefix": "m01", 37 | "SubjectManageableState": "deprecatedEditable" 38 | } 39 | ], 40 | "totalSize": 3, 41 | "done": true 42 | } 43 | -------------------------------------------------------------------------------- /test/data/garbage-collection/package-members/custom-fields.json: -------------------------------------------------------------------------------- 1 | { 2 | "records": [ 3 | { 4 | "attributes": { 5 | "type": "Package2Member", 6 | "url": "/services/data/v62.0/tooling/sobjects/Package2Member/03v4P0000004JHeQAM" 7 | }, 8 | "Id": "03v4P0000004JHeQAM", 9 | "CurrentPackageVersionId": "04t4P0000000004AAA", 10 | "MaxPackageVersionId": "04t4P0000000004AAA", 11 | "SubjectId": "00N4P00000GRKqFUAX", 12 | "SubjectKeyPrefix": "00N", 13 | "SubjectManageableState": "deprecatedEditable" 14 | }, 15 | { 16 | "attributes": { 17 | "type": "Package2Member", 18 | "url": "/services/data/v62.0/tooling/sobjects/Package2Member/03v4P0000004JHdQAM" 19 | }, 20 | "Id": "03v4P0000004JHdQAM", 21 | "CurrentPackageVersionId": "04t4P0000000004AAA", 22 | "MaxPackageVersionId": "04t4P0000000004AAA", 23 | "SubjectId": "00N4P00000GRKqHUAX", 24 | "SubjectKeyPrefix": "00N", 25 | "SubjectManageableState": "deprecatedEditable" 26 | }, 27 | { 28 | "attributes": { 29 | "type": "Package2Member", 30 | "url": "/services/data/v62.0/tooling/sobjects/Package2Member/03v4P0000004JHcQAM" 31 | }, 32 | "Id": "03v4P0000004JHcQAM", 33 | "CurrentPackageVersionId": "04t4P0000000004AAA", 34 | "MaxPackageVersionId": "04t4P0000000004AAA", 35 | "SubjectId": "00N4P00000GRKqGUAX", 36 | "SubjectKeyPrefix": "00N", 37 | "SubjectManageableState": "deprecatedEditable" 38 | } 39 | ], 40 | "totalSize": 2, 41 | "done": true 42 | } 43 | -------------------------------------------------------------------------------- /test/data/apex-schedule-service/schedule-start-success.json: -------------------------------------------------------------------------------- 1 | { 2 | "compiled": true, 3 | "success": true, 4 | "logs": "62.0 APEX_CODE,DEBUG;APEX_PROFILING,INFO\nExecute Anonymous: \nExecute Anonymous: String jobName = 'CaseReminderJob';\nExecute Anonymous: String cronExpression = '0 0 0 ? * * * *';\nExecute Anonymous: Id jobId = System.schedule(jobName, cronExpression, new CaseReminderJob());\nExecute Anonymous: System.debug(jobId);\n18:19:47.314 (314949726)|USER_INFO|[EXTERNAL]|0059b00000EaACh|test-sgsqwpzichd0@example.com|(GMT+01:00) Central European Standard Time (Europe/Berlin)|GMT+01:00\n18:19:47.314 (314975513)|EXECUTION_STARTED\n18:19:47.314 (315001153)|CODE_UNIT_STARTED|[EXTERNAL]|execute_anonymous_apex\n18:19:47.314 (347812205)|USER_DEBUG|[5]|DEBUG|08e9b00000KiFENAA3\n18:19:47.355 (355654490)|CUMULATIVE_LIMIT_USAGE\n18:19:47.355 (355654490)|LIMIT_USAGE_FOR_NS|(default)|\n Number of SOQL queries: 0 out of 100\n Number of query rows: 0 out of 50000\n Number of SOSL queries: 0 out of 20\n Number of DML statements: 0 out of 150\n Number of Publish Immediate DML: 0 out of 150\n Number of DML rows: 0 out of 10000\n Maximum CPU time: 0 out of 10000\n Maximum heap size: 0 out of 6000000\n Number of callouts: 0 out of 100\n Number of Email Invocations: 0 out of 10\n Number of future calls: 0 out of 50\n Number of queueable jobs added to the queue: 0 out of 50\n Number of Mobile Apex push calls: 0 out of 10\n\n18:19:47.355 (355654490)|CUMULATIVE_LIMIT_USAGE_END\n\n18:19:47.314 (355759120)|CODE_UNIT_FINISHED|execute_anonymous_apex\n18:19:47.314 (355782865)|EXECUTION_FINISHED\n" 5 | } 6 | -------------------------------------------------------------------------------- /test/data/garbage-collection/package-members/workflow-alerts.json: -------------------------------------------------------------------------------- 1 | { 2 | "records": [ 3 | { 4 | "attributes": { 5 | "type": "Package2Member", 6 | "url": "/services/data/v62.0/tooling/sobjects/Package2Member/03v0X0000004CyGQAU" 7 | }, 8 | "Id": "03v0X0000004CyGQAU", 9 | "CurrentPackageVersionId": "04t4P0000000002AAA", 10 | "MaxPackageVersionId": "04t4P0000000002AAA", 11 | "SubjectId": "01W0X0000000Jr2UAE", 12 | "SubjectKeyPrefix": "01W", 13 | "SubjectManageableState": "deprecatedEditable" 14 | }, 15 | { 16 | "attributes": { 17 | "type": "Package2Member", 18 | "url": "/services/data/v62.0/tooling/sobjects/Package2Member/03v0X0000004CyJQAU" 19 | }, 20 | "Id": "03v0X0000004CyJQAU", 21 | "CurrentPackageVersionId": "04t4P0000000001AAA", 22 | "MaxPackageVersionId": "04t4P0000000001AAA", 23 | "SubjectId": "01W0X0000000Jr1UAE", 24 | "SubjectKeyPrefix": "01W", 25 | "SubjectManageableState": "deprecatedEditable" 26 | }, 27 | { 28 | "attributes": { 29 | "type": "Package2Member", 30 | "url": "/services/data/v62.0/tooling/sobjects/Package2Member/03v0X0000004D4WQAU" 31 | }, 32 | "Id": "03v0X0000004D4WQAU", 33 | "CurrentPackageVersionId": "04t4P0000000001AAA", 34 | "MaxPackageVersionId": "04t4P0000000001AAA", 35 | "SubjectId": "01W0X0000000Jv8UAE", 36 | "SubjectKeyPrefix": "01W", 37 | "SubjectManageableState": "deprecatedEditable" 38 | } 39 | ], 40 | "totalSize": 3, 41 | "done": true 42 | } 43 | -------------------------------------------------------------------------------- /src/types/migrationPlanObjectData.ts: -------------------------------------------------------------------------------- 1 | import { z } from 'zod'; 2 | 3 | export type MigrationPlanObjectData = { 4 | objectName: string; 5 | queryFile?: string; 6 | queryString?: string; 7 | isToolingObject?: boolean; 8 | query?: ZQueryObjectType; 9 | }; 10 | 11 | export type MigrationPlanObjectQueryResult = { 12 | isSuccess: boolean; 13 | queryString: string; 14 | totalSize: number; 15 | files: string[]; 16 | executedFullQueryStrings: string[]; 17 | }; 18 | 19 | const ZParentBind = z.object({ field: z.string(), variable: z.string() }); 20 | const ZIdExports = z.record(z.string()); 21 | 22 | const ZQueryObject = z.object({ 23 | fetchAllFields: z.boolean(), 24 | limit: z.number().optional(), 25 | filter: z.string().optional(), 26 | bind: ZParentBind.optional(), 27 | }); 28 | 29 | const ZMigrationPlanObjectData = z.object({ 30 | objectName: z.string(), 31 | queryFile: z.string().optional(), 32 | queryString: z.string().optional(), 33 | isToolingObject: z.boolean().optional(), 34 | exportIds: z.string().optional(), 35 | exports: ZIdExports.optional(), 36 | query: ZQueryObject.optional(), 37 | }); 38 | 39 | export const ZMigrationPlan = z.object({ 40 | name: z.string(), 41 | objects: z.array(ZMigrationPlanObjectData), 42 | }); 43 | 44 | export type ZParentBindType = z.infer; 45 | export type ZIdExportsType = z.infer; 46 | export type ZQueryObjectType = z.infer; 47 | export type ZMigrationPlanObjectDataType = z.infer; 48 | export type ZMigrationPlanType = z.infer; 49 | -------------------------------------------------------------------------------- /src/common/utils/fileUtils.ts: -------------------------------------------------------------------------------- 1 | import fs from 'node:fs'; 2 | import yaml from 'js-yaml'; 3 | import { z } from 'zod'; 4 | import { Messages } from '@salesforce/core'; 5 | 6 | Messages.importMessagesDirectoryFromMetaUrl(import.meta.url); 7 | const messages = Messages.loadMessages('@j-schreiber/sf-plugin', 'fileutils'); 8 | 9 | export function pathHasNoFiles(path: string): boolean { 10 | const dirContent = fs.readdirSync(path, { recursive: true }); 11 | let hasFiles = false; 12 | dirContent.forEach((dirContentPath) => { 13 | hasFiles = hasFiles || !fs.lstatSync(`${path}/${String(dirContentPath)}`).isDirectory(); 14 | }); 15 | return !hasFiles; 16 | } 17 | 18 | export function parseYaml(path: string, schema: T): z.infer { 19 | let fileContent; 20 | try { 21 | fileContent = yaml.load(fs.readFileSync(path, 'utf8')); 22 | } catch (error) { 23 | throw messages.createError('InvalidFilePath', [path]); 24 | } 25 | const parseResult = schema.safeParse(fileContent); 26 | if (parseResult.success) { 27 | // eslint-disable-next-line @typescript-eslint/no-unsafe-return 28 | return parseResult.data as z.infer; 29 | } else { 30 | const errsFlat = parseResult.error.flatten(); 31 | const formErrors = errsFlat.formErrors.join(','); 32 | const fieldErrors = Object.entries(errsFlat.fieldErrors) 33 | // eslint-disable-next-line @typescript-eslint/restrict-template-expressions 34 | .map(([errPath, errors]) => `${errPath}: ${errors?.join(', ')}`) 35 | .join('\n'); 36 | const combinedMsgs = [formErrors, fieldErrors].filter(Boolean).join('\n'); 37 | throw messages.createError('InvalidSchema', [combinedMsgs]); 38 | } 39 | } 40 | -------------------------------------------------------------------------------- /src/common/jscSfCommandFlags.ts: -------------------------------------------------------------------------------- 1 | import { Messages } from '@salesforce/core'; 2 | import { Flags } from '@salesforce/sf-plugins-core'; 3 | 4 | Messages.importMessagesDirectoryFromMetaUrl(import.meta.url); 5 | const messages = Messages.loadMessages('@j-schreiber/sf-plugin', 'jsc.maintain.common'); 6 | 7 | /** 8 | * Formats for package manifests 9 | */ 10 | export enum OutputFormats { 11 | PackageXML = 'PackageXML', 12 | DestructiveChangesXML = 'DestructiveChangesXML', 13 | } 14 | 15 | /** 16 | * Formats for table output to stdout 17 | */ 18 | export enum ResultFormats { 19 | human = 'human', 20 | csv = 'csv', 21 | markdown = 'markdown', 22 | } 23 | 24 | export const outputFormatFlag = Flags.custom({ 25 | char: 'f', 26 | summary: messages.getMessage('flags.output-format.summary'), 27 | description: messages.getMessage('flags.output-format.description'), 28 | options: Object.values(OutputFormats), 29 | dependsOn: ['output-dir'], 30 | }); 31 | 32 | export const resultFormatFlag = Flags.custom({ 33 | char: 'r', 34 | summary: messages.getMessage('flags.result-format.summary'), 35 | description: messages.getMessage('flags.result-format.description'), 36 | options: Object.values(ResultFormats), 37 | default: ResultFormats.human, 38 | }); 39 | 40 | export const manifestOutputDirFlag = Flags.file({ 41 | exists: false, 42 | summary: messages.getMessage('flags.output-dir.summary'), 43 | description: messages.getMessage('flags.output-dir.description'), 44 | char: 'd', 45 | }); 46 | 47 | export const conciseFlowExportTable = Flags.boolean({ 48 | summary: messages.getMessage('flags.concise.summary'), 49 | description: messages.getMessage('flags.concise.description'), 50 | }); 51 | -------------------------------------------------------------------------------- /test/data/garbage-collection/workflow-alert-definitions.json: -------------------------------------------------------------------------------- 1 | { 2 | "records": [ 3 | { 4 | "attributes": { 5 | "type": "WorkflowAlert", 6 | "url": "/services/data/v62.0/tooling/sobjects/WorkflowAlert/01W0X0000000Jr2UAE" 7 | }, 8 | "Id": "01W0X0000000Jr2UAE", 9 | "DeveloperName": "Test_Alert_1", 10 | "EntityDefinition": { 11 | "attributes": { 12 | "type": "EntityDefinition", 13 | "url": "/services/data/v62.0/tooling/sobjects/EntityDefinition/Case" 14 | }, 15 | "QualifiedApiName": "Case" 16 | } 17 | }, 18 | { 19 | "attributes": { 20 | "type": "WorkflowAlert", 21 | "url": "/services/data/v62.0/tooling/sobjects/WorkflowAlert/01W0X0000000Jr1UAE" 22 | }, 23 | "Id": "01W0X0000000Jr1UAE", 24 | "DeveloperName": "Test_Alert_2", 25 | "EntityDefinition": { 26 | "attributes": { 27 | "type": "EntityDefinition", 28 | "url": "/services/data/v62.0/tooling/sobjects/EntityDefinition/CustomObject__c" 29 | }, 30 | "QualifiedApiName": "CustomObject__c" 31 | } 32 | }, 33 | { 34 | "attributes": { 35 | "type": "WorkflowAlert", 36 | "url": "/services/data/v62.0/tooling/sobjects/WorkflowAlert/01W0X0000000Jv8UAE" 37 | }, 38 | "Id": "01W0X0000000Jv8UAE", 39 | "DeveloperName": "Test_Alert_3", 40 | "EntityDefinition": { 41 | "attributes": { 42 | "type": "EntityDefinition", 43 | "url": "/services/data/v62.0/tooling/sobjects/EntityDefinition/CustomObject__c" 44 | }, 45 | "QualifiedApiName": "CustomObject__c" 46 | } 47 | } 48 | ], 49 | "totalSize": 3, 50 | "done": true 51 | } 52 | -------------------------------------------------------------------------------- /test/data/test-sfdx-project/src/flows/Active_Test_Flow.flow-meta.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 64.0 4 | An empty screen flow for testing purposes. 5 | Default 6 | Active Test Flow {!$Flow.CurrentDateTime} 7 | 8 | 9 | BuilderType 10 | 11 | LightningFlowBuilder 12 | 13 | 14 | 15 | CanvasMode 16 | 17 | AUTO_LAYOUT_CANVAS 18 | 19 | 20 | 21 | OriginBuilderType 22 | 23 | LightningFlowBuilder 24 | 25 | 26 | Flow 27 | 28 | Empty_Screen 29 | 30 | 0 31 | 0 32 | true 33 | true 34 | true 35 | true 36 | false 37 | 38 | 39 | 0 40 | 0 41 | 42 | Empty_Screen 43 | 44 | 45 | Active 46 | 47 | -------------------------------------------------------------------------------- /test/data/test-sfdx-project/src/flows/Inactive_Test_Flow.flow-meta.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 64.0 4 | An empty screen flow for testing purposes. 5 | Default 6 | Inactive Test Flow {!$Flow.CurrentDateTime} 7 | 8 | 9 | BuilderType 10 | 11 | LightningFlowBuilder 12 | 13 | 14 | 15 | CanvasMode 16 | 17 | AUTO_LAYOUT_CANVAS 18 | 19 | 20 | 21 | OriginBuilderType 22 | 23 | LightningFlowBuilder 24 | 25 | 26 | Flow 27 | 28 | Empty_Screen 29 | 30 | 0 31 | 0 32 | true 33 | true 34 | true 35 | true 36 | false 37 | 38 | 39 | 0 40 | 0 41 | 42 | Empty_Screen 43 | 44 | 45 | Draft 46 | 47 | -------------------------------------------------------------------------------- /test/data/test-sfdx-project/scripts/scratch-setup.sh: -------------------------------------------------------------------------------- 1 | #! /bin/bash 2 | # shellcheck disable=SC1091 3 | set -e 4 | 5 | alias=TestOrg 6 | duration=7 7 | configFile='config/default-scratch-def.json' 8 | devhubusername= 9 | 10 | while getopts a:d:f:v: option; do 11 | case "${option}" in 12 | a) alias=${OPTARG} ;; 13 | d) duration=${OPTARG} ;; 14 | f) configFile=${OPTARG} ;; 15 | v) devhubusername=${OPTARG} ;; 16 | *) ;; 17 | esac 18 | done 19 | 20 | echo "============================================" 21 | echo "Creating Scratch Org with these properties:" 22 | echo "Devhub: $devhubusername" 23 | echo "Config: $configFile" 24 | echo "Duration: $duration" 25 | echo "Alias: $alias" 26 | echo "============================================" 27 | 28 | if [ -z "$devhubusername" ]; then 29 | echo "sf org create scratch -y $duration -f $configFile -a $alias -d --json" 30 | sf org create scratch -y "$duration" -f "$configFile" -a "$alias" -d --json 31 | else 32 | echo "sf org create scratch -v $devhubusername -y $duration -f $configFile -a $alias -d --json" 33 | sf org create scratch -v "$devhubusername" -y "$duration" -f "$configFile" -a "$alias" -d --json 34 | fi 35 | 36 | echo "sf package install -p Test Package @ LATEST -o $alias -w 10 --json" 37 | sf package install -p "Test Package @ LATEST" -o "$alias" -w 10 --json 38 | 39 | echo "sf package install -p Test Package @ 0.1.0 -o $alias -w 10 --json --upgrade-type DeprecateOnly" 40 | sf package install -p "Test Package @ 0.1.0" -o "$alias" -w 10 --json --upgrade-type DeprecateOnly 41 | 42 | echo "Generating login link for debugging" 43 | sf org open -o "$alias" -r 44 | 45 | echo "sf org open -o $alias -p \"/lightning/setup/SetupOneHome/home\"" 46 | sf org open -o "$alias" -p "/lightning/setup/SetupOneHome/home" 47 | -------------------------------------------------------------------------------- /test/data/flow-export/obsolete-flows.json: -------------------------------------------------------------------------------- 1 | [ 2 | { 3 | "attributes": { 4 | "type": "Flow", 5 | "url": "/services/data/v64.0/tooling/sobjects/Flow/3013X000000rwdpQAA" 6 | }, 7 | "Id": "3013X000000rwdpQAA", 8 | "DefinitionId": "3003X000000BMf8QAG", 9 | "Definition": { 10 | "attributes": { 11 | "type": "FlowDefinition", 12 | "url": "/services/data/v64.0/tooling/sobjects/FlowDefinition/3003X000000BMf8QAG" 13 | }, 14 | "DeveloperName": "Test_Flow_1" 15 | }, 16 | "Status": "Obsolete", 17 | "ProcessType": "AutoLaunchedFlow", 18 | "VersionNumber": 2 19 | }, 20 | { 21 | "attributes": { 22 | "type": "Flow", 23 | "url": "/services/data/v64.0/tooling/sobjects/Flow/301Fg00000YpIgtIAF" 24 | }, 25 | "Id": "301Fg00000YpIgtIAF", 26 | "DefinitionId": "3003X000000BMf8QAG", 27 | "Definition": { 28 | "attributes": { 29 | "type": "FlowDefinition", 30 | "url": "/services/data/v64.0/tooling/sobjects/FlowDefinition/3003X000000BMf8QAG" 31 | }, 32 | "DeveloperName": "Test_Flow_1" 33 | }, 34 | "Status": "Draft", 35 | "ProcessType": "AutoLaunchedFlow", 36 | "VersionNumber": 3 37 | }, 38 | { 39 | "attributes": { 40 | "type": "Flow", 41 | "url": "/services/data/v64.0/tooling/sobjects/Flow/3013X000000f03KQAQ" 42 | }, 43 | "Id": "3013X000000f03KQAQ", 44 | "DefinitionId": "3003X000000gO19QAE", 45 | "Definition": { 46 | "attributes": { 47 | "type": "FlowDefinition", 48 | "url": "/services/data/v64.0/tooling/sobjects/FlowDefinition/3003X000000gO19QAE" 49 | }, 50 | "DeveloperName": "Test_Flow_2" 51 | }, 52 | "Status": "Obsolete", 53 | "ProcessType": "Workflow", 54 | "VersionNumber": 6 55 | } 56 | ] 57 | -------------------------------------------------------------------------------- /src/common/packageManifestDirectory.ts: -------------------------------------------------------------------------------- 1 | import fs from 'node:fs'; 2 | import path from 'node:path'; 3 | import PackageManifestBuilder from './packageManifestBuilder.js'; 4 | import { OutputFormats } from './jscSfCommandFlags.js'; 5 | 6 | /** 7 | * Initialise an empty manifest directory at the path. The directory 8 | * writes manifest XMLs (package.xml and destructiveChanges.xml) when 9 | * closed. 10 | */ 11 | export default class PackageManifestDirectory { 12 | private packageXml: PackageManifestBuilder; 13 | private destructiveChangesXml?: PackageManifestBuilder; 14 | 15 | public constructor(public readonly directoryPath: string, public readonly outputFormat?: OutputFormats) { 16 | fs.mkdirSync(this.directoryPath, { recursive: true }); 17 | this.packageXml = new PackageManifestBuilder(); 18 | if (this.outputFormat === OutputFormats.DestructiveChangesXML) { 19 | this.destructiveChangesXml = new PackageManifestBuilder(); 20 | } 21 | } 22 | 23 | /** 24 | * Returns the primary builder that holds contents, 25 | * based on the output format. 26 | */ 27 | public getBuilder(): PackageManifestBuilder { 28 | return this.outputFormat === OutputFormats.DestructiveChangesXML ? this.destructiveChangesXml! : this.packageXml; 29 | } 30 | 31 | /** 32 | * Writes all manifest files to output dir and clears 33 | * obsolete files if they exist. 34 | */ 35 | public write(): void { 36 | if (this.outputFormat === OutputFormats.DestructiveChangesXML) { 37 | this.destructiveChangesXml!.writeToXmlFile(path.join(this.directoryPath, 'destructiveChanges.xml')); 38 | } else { 39 | fs.rmSync(path.join(this.directoryPath, 'destructiveChanges.xml'), { force: true }); 40 | } 41 | this.packageXml.writeToXmlFile(path.join(this.directoryPath, 'package.xml')); 42 | } 43 | } 44 | -------------------------------------------------------------------------------- /test/data/garbage-collection/package-members/mixed-with-package-infos.json: -------------------------------------------------------------------------------- 1 | { 2 | "records": [ 3 | { 4 | "attributes": { 5 | "type": "Package2Member", 6 | "url": "/services/data/v62.0/tooling/sobjects/Package2Member/03v4P0000004JPzQAM" 7 | }, 8 | "Id": "03v4P0000004JPzQAM", 9 | "CurrentPackageVersionId": "04t4P0000000003AAA", 10 | "MaxPackageVersionId": "04t4P0000000003AAA", 11 | "MaxPackageVersion": { 12 | "attributes": { 13 | "type": "SubscriberPackageVersion", 14 | "url": "/services/data/v62.0/tooling/sobjects/SubscriberPackageVersion/04t4P0000000003AAA" 15 | }, 16 | "MajorVersion": 1, 17 | "MinorVersion": 2, 18 | "PatchVersion": 3 19 | }, 20 | "SubjectId": "1014P00000B3v7KQAR", 21 | "SubjectKeyPrefix": "101", 22 | "SubjectManageableState": "deprecatedEditable", 23 | "SubscriberPackageId": "0330X0000000000AAA" 24 | }, 25 | { 26 | "attributes": { 27 | "type": "Package2Member", 28 | "url": "/services/data/v62.0/tooling/sobjects/Package2Member/03v0X0000004CxhQAE" 29 | }, 30 | "Id": "03v0X0000004CxhQAE", 31 | "CurrentPackageVersionId": "04t670000015YcsAAE", 32 | "MaxPackageVersionId": "04t670000015YcsAAE", 33 | "MaxPackageVersion": { 34 | "attributes": { 35 | "type": "SubscriberPackageVersion", 36 | "url": "/services/data/v62.0/tooling/sobjects/SubscriberPackageVersion/04t4P0000000004AAA" 37 | }, 38 | "MajorVersion": 1, 39 | "MinorVersion": 2, 40 | "PatchVersion": 4 41 | }, 42 | "SubjectId": "00h0X00000XRV9HQAX", 43 | "SubjectKeyPrefix": "00h", 44 | "SubscriberPackageId": "0330X0000000000AAA" 45 | } 46 | ], 47 | "totalSize": 2, 48 | "done": true 49 | } 50 | -------------------------------------------------------------------------------- /test/data/apex-schedule-service/job-is-already-aborted.json: -------------------------------------------------------------------------------- 1 | { 2 | "compiled": true, 3 | "success": false, 4 | "logs": "62.0 APEX_CODE,DEBUG;APEX_PROFILING,INFO\nExecute Anonymous: System.abortJob('08e9b00000Kz6hmAAB');\n00:06:13.40 (40270026)|USER_INFO|[EXTERNAL]|0059b00000EyPaY|test-hm5zkhjbc6hb@example.com|(GMT+01:00) Central European Standard Time (Europe/Berlin)|GMT+01:00\n00:06:13.40 (40296947)|EXECUTION_STARTED\n00:06:13.40 (40310265)|CODE_UNIT_STARTED|[EXTERNAL]|execute_anonymous_apex\n00:06:13.40 (44366873)|EXCEPTION_THROWN|[1]|System.StringException: Job does not exist or is already aborted.\n00:06:13.40 (44552752)|FATAL_ERROR|System.StringException: Job does not exist or is already aborted.\n\nAnonymousBlock: line 1, column 1\n00:06:13.44 (44719892)|CUMULATIVE_LIMIT_USAGE\n00:06:13.44 (44719892)|LIMIT_USAGE_FOR_NS|(default)|\n Number of SOQL queries: 0 out of 100\n Number of query rows: 0 out of 50000\n Number of SOSL queries: 0 out of 20\n Number of DML statements: 1 out of 150\n Number of Publish Immediate DML: 0 out of 150\n Number of DML rows: 1 out of 10000\n Maximum CPU time: 0 out of 10000\n Maximum heap size: 0 out of 6000000\n Number of callouts: 0 out of 100\n Number of Email Invocations: 0 out of 10\n Number of future calls: 0 out of 50\n Number of queueable jobs added to the queue: 0 out of 50\n Number of Mobile Apex push calls: 0 out of 10\n\n00:06:13.44 (44719892)|CUMULATIVE_LIMIT_USAGE_END\n\n00:06:13.40 (44780053)|CODE_UNIT_FINISHED|execute_anonymous_apex\n00:06:13.40 (44792421)|EXECUTION_FINISHED\n", 5 | "diagnostic": [ 6 | { 7 | "lineNumber": "1", 8 | "columnNumber": "1", 9 | "compileProblem": "", 10 | "exceptionMessage": "System.StringException: Job does not exist or is already aborted.", 11 | "exceptionStackTrace": "AnonymousBlock: line 1, column 1" 12 | } 13 | ] 14 | } 15 | -------------------------------------------------------------------------------- /src/garbage-collection/entity-handlers/fullNameSingleRecord.ts: -------------------------------------------------------------------------------- 1 | /* eslint-disable no-console */ 2 | import { Connection } from '@salesforce/core'; 3 | import { FullNameSingleRecordEntity, Package2Member } from '../../types/sfToolingApiTypes.js'; 4 | import { EntityDefinitionHandler } from '../entityDefinitionHandler.js'; 5 | import { PackageGarbage, PackageGarbageContainer } from '../packageGarbageTypes.js'; 6 | import QueryRunner from '../../common/utils/queryRunner.js'; 7 | 8 | export class FullNameSingleRecord implements EntityDefinitionHandler { 9 | private readonly queryRunner: QueryRunner; 10 | 11 | public constructor( 12 | private readonly queryConnection: Connection['tooling'], 13 | private readonly entityName: string, 14 | private readonly metadataTypeName?: string 15 | ) { 16 | this.queryRunner = new QueryRunner(this.queryConnection); 17 | } 18 | 19 | public async resolve(packageMembers: Package2Member[]): Promise { 20 | const garbageList: PackageGarbage[] = []; 21 | for (const packageMember of packageMembers) { 22 | // queries with "FullName" throw exception, if more than one record is queried 23 | // so yes, we do "SOQL in a loop" here. "For performance reasons" (as stated in the docs). LOL. 24 | // eslint-disable-next-line no-await-in-loop 25 | const entityDef = await this.queryRunner.fetchRecords( 26 | `SELECT Id,FullName FROM ${this.entityName} WHERE Id = '${packageMember.SubjectId}' LIMIT 1` 27 | ); 28 | if (entityDef.length > 0) { 29 | garbageList.push(new PackageGarbage(packageMember, entityDef[0].FullName)); 30 | } 31 | } 32 | return { 33 | metadataType: this.metadataTypeName ?? this.entityName, 34 | componentCount: garbageList.length, 35 | components: garbageList, 36 | }; 37 | } 38 | } 39 | -------------------------------------------------------------------------------- /messages/jsc.apex.schedule.manage.md: -------------------------------------------------------------------------------- 1 | # summary 2 | 3 | Manages all cron jobs on a target org from config file. 4 | 5 | # description 6 | 7 | Provide the path to a config file that contains a definition of scheduled jobs. The manage command will try to start non-existing jobs and, depending on options, also stop obsolete running jobs. All options for this command are read from the config file. 8 | 9 | # flags.target-org.summary 10 | 11 | Target org where the job will be scheduled. 12 | 13 | # flags.config-file.summary 14 | 15 | Path to a valid config file that configures scheduled jobs. 16 | 17 | # flags.dry-run.summary 18 | 19 | Simulate a run and get information on how many jos would be started, stopped, and left untouched. 20 | 21 | # flags.config-file.description 22 | 23 | Specify the jobs to start, together with options how obsolete or redundant jobs should be treated. Jobs are identified by their unique job name. You can specify the same apex class multiple times, as long as job names are different. For convenience, you can also use the apex class as job name and omit class property - in that case, you can only schedule an apex class once for obvious reasons. 24 | 25 | # flags.dry-run.description 26 | 27 | Queries existing jobs on the target org and evaluates, which jobs would be changed. However, dry-run cannot compile the apex class and cron expression. This means, that invalid cron expressions or apex classes will not be caught and the command may still fail, when run without this flag. 28 | 29 | # examples 30 | 31 | - Sync all jobs on target org with config file 32 | 33 | <%= config.bin %> <%= command.id %> -o MyTargetOrg -f scheduled-job-definitions.yaml 34 | 35 | # infos.dry-run-mode 36 | 37 | Running in simulation mode. No jobs will be changed. 38 | 39 | # infos.dry-run-cannot-compile 40 | 41 | Dry-run cannot validate apex classes and cron expressions. 42 | -------------------------------------------------------------------------------- /src/types/platformTypes.ts: -------------------------------------------------------------------------------- 1 | import { DescribeSObjectResult, Optional } from '@jsforce/jsforce-node'; 2 | 3 | export type DescribeRecordTypeResult = { 4 | id: string; 5 | active: boolean; 6 | label: string; 7 | businessProcess: Optional; 8 | compactLayoutAssignment: Optional; 9 | description: Optional; 10 | picklistValues: RecordTypePicklist[]; 11 | }; 12 | 13 | export type RecordTypePicklist = { 14 | picklist: string; 15 | values: Array>; 16 | }; 17 | 18 | export type RecordTypeInfo = { 19 | available: boolean; 20 | active: boolean; 21 | defaultRecordTypeMapping: boolean; 22 | master: boolean; 23 | name: string; 24 | developerName: string; 25 | recordTypeId: string; 26 | urls: { 27 | [key: string]: string; 28 | }; 29 | }; 30 | 31 | export type ChildRelationship = { 32 | cascadeDelete: boolean; 33 | childSObject: string; 34 | deprecatedAndHidden: boolean; 35 | field: string; 36 | junctionIdListNames: string[]; 37 | junctionReferenceTo: string[]; 38 | relationshipName: Optional; 39 | restrictedDelete: boolean; 40 | }; 41 | 42 | export type DescribeHistoryResult = { 43 | relationship: ChildRelationship; 44 | describe: DescribeSObjectResult; 45 | }; 46 | 47 | type RecordTypePicklistValue = { 48 | valueName: string; 49 | allowEmail: Optional; 50 | color: Optional; 51 | controllingFieldValues: Optional; 52 | converted: Optional; 53 | cssExposed: Optional; 54 | description: Optional; 55 | forecastCategory: Optional; 56 | highPriority: Optional; 57 | isActive: Optional; 58 | probability: Optional; 59 | reverseRole: Optional; 60 | urls: Optional; 61 | default: false; 62 | closed: Optional; 63 | reviewed: Optional; 64 | won: Optional; 65 | }; 66 | -------------------------------------------------------------------------------- /messages/garbagecollection.md: -------------------------------------------------------------------------------- 1 | # infos.excluded-from-result-not-in-filter 2 | 3 | Excluded from result because metadata type filter was active and this type was not filtered. 4 | 5 | # infos.not-fully-supported-by-tooling-api 6 | 7 | Entity %s (%s) is not fully supported by Tooling API: %s. %s members are ignored. 8 | 9 | # infos.not-yet-implemented 10 | 11 | Entity %s (%s) is not yet implemented. %s members are ignored. 12 | 13 | # infos.unknown-keyprefix 14 | 15 | Failed to resolve keyprefix %s to a valid entity definition. %s members are ignored. 16 | 17 | # infos.not-a-sfdx-project 18 | 19 | Could not initialise project (%s). 20 | 21 | # infos.unknown-error-initialising-project 22 | 23 | Failed to initialise project with an unknown error. 24 | 25 | # infos.package-aliases-not-supported 26 | 27 | To use package aliases, run this command within a valid project. You can still use package ids (0Ho). 28 | 29 | # infos.packages-filter-active 30 | 31 | Packages filter active. Only include members from these packages: %s 32 | 33 | # infos.metadata-filter-active 34 | 35 | Metadata types filter active. Only include members of these types: %s 36 | 37 | # infos.resolved-package-id 38 | 39 | Resolved %s (Package2) to %s (SubscriberPackage) 40 | 41 | # warnings.failed-to-resolved-package-id 42 | 43 | Could not resolve %s (Package2) to its corresponding SubscriberPackageId (033). The Id does not exist on DevHub (%s). Id will be ignored for filtering package members. Did you use the correct DevHub? 44 | 45 | # packages-filter-no-devhub 46 | 47 | Packages filter specified, but no Devhub was provided. 48 | 49 | # provide-valid-devhub-with-filter 50 | 51 | Provide a valid DevHub, when you specify a packages filter. 52 | 53 | # record-types-cannot-be-deleted 54 | 55 | Record types cannot be deleted via metadata API. 56 | 57 | # deprecated-list-views-not-accessible 58 | 59 | List views are not accessible via APIs, when member is deprecated. 60 | -------------------------------------------------------------------------------- /src/garbage-collection/queries.ts: -------------------------------------------------------------------------------- 1 | import QueryBuilder from '../common/utils/queryBuilder.js'; 2 | 3 | export const PACKAGE_MEMBER_BASE = `SELECT 4 | Id, 5 | CurrentPackageVersionId, 6 | MaxPackageVersionId, 7 | SubscriberPackageId, 8 | MaxPackageVersion.MajorVersion, 9 | MaxPackageVersion.MinorVersion, 10 | MaxPackageVersion.PatchVersion, 11 | SubjectId, 12 | SubjectKeyPrefix, 13 | SubjectManageableState 14 | FROM 15 | Package2Member`; 16 | 17 | // flow definitions (prefix 300) are processed separately 18 | export const ALL_DEPRECATED_PACKAGE_MEMBERS = QueryBuilder.sanitise(`${PACKAGE_MEMBER_BASE} 19 | WHERE 20 | SubjectManageableState IN ('deprecatedEditable', 'deprecated') 21 | AND SubjectKeyPrefix NOT IN ('300') 22 | ORDER BY 23 | SubjectKeyPrefix`); 24 | 25 | export const ENTITY_DEFINITION_QUERY = QueryBuilder.sanitise(`SELECT 26 | Id, 27 | DurableId, 28 | QualifiedApiName, 29 | DeveloperName, 30 | MasterLabel, 31 | KeyPrefix, 32 | IsRetrieveable 33 | FROM 34 | EntityDefinition`); 35 | 36 | export const OBSOLETE_FLOWS: string = QueryBuilder.sanitise(`SELECT 37 | Id, 38 | VersionNumber, 39 | Definition.DeveloperName, 40 | DefinitionId, 41 | Status 42 | FROM 43 | Flow 44 | WHERE 45 | Status = 'Obsolete' 46 | ORDER BY 47 | Definition.DeveloperName, 48 | VersionNumber ASC 49 | LIMIT 2000`); 50 | 51 | export const ALL_CUSTOM_OBJECTS: string = QueryBuilder.sanitise(`SELECT 52 | Id, 53 | DurableId, 54 | QualifiedApiName, 55 | DeveloperName, 56 | MasterLabel, 57 | KeyPrefix, 58 | IsRetrieveable 59 | FROM 60 | EntityDefinition 61 | WHERE 62 | KeyPrefix LIKE 'a%' 63 | OR KeyPrefix LIKE 'm%' 64 | OR KeyPrefix LIKE 'e%'`); 65 | 66 | export const PACKAGE_2: string = QueryBuilder.sanitise(`SELECT 67 | Id, 68 | SubscriberPackageId 69 | FROM 70 | Package2`); 71 | 72 | export const SUBSCRIBER_PACKAGE_FIELDS = ['Id', 'Name', 'Description', 'IsPackageValid', 'NamespacePrefix']; 73 | -------------------------------------------------------------------------------- /test/mock-utils/sfQueryApiMocks.ts: -------------------------------------------------------------------------------- 1 | import fs from 'node:fs'; 2 | import path from 'node:path'; 3 | import { type AnyJson } from '@salesforce/ts-types'; 4 | import { QueryResult, Record } from '@jsforce/jsforce-node'; 5 | import { EmptyQueryResult } from '../data/api/queryResults.js'; 6 | import { MockAnyObjectResult } from '../data/describes/mockDescribeResults.js'; 7 | 8 | export function mockQueryResponseWithQueryMore(request: AnyJson): Promise { 9 | const url = (request as { url: string }).url; 10 | if (url.includes('/query?')) { 11 | const orderBatch1 = JSON.parse(fs.readFileSync('./test/data/api/orders-1.json', 'utf-8')) as QueryResult; 12 | return Promise.resolve(orderBatch1); 13 | } 14 | if (url.includes('/query/0r8xx3d3FsAvwqKAIR-20')) { 15 | const orderBatch2 = JSON.parse(fs.readFileSync('./test/data/api/orders-2.json', 'utf-8')) as QueryResult; 16 | return Promise.resolve(orderBatch2); 17 | } 18 | return Promise.reject({ data: { message: 'Unexpected query was executed' } }); 19 | } 20 | 21 | export function mockAnySObjectDescribe(request: AnyJson): Promise { 22 | if (request?.toString().endsWith('/describe')) { 23 | const requestUrl = String(request).split('/'); 24 | const sobjectName = requestUrl[requestUrl.length - 2]; 25 | const describe = { ...MockAnyObjectResult, name: sobjectName }; 26 | return Promise.resolve(describe as AnyJson); 27 | } 28 | const url = (request as { url: string }).url; 29 | if (url.includes('/query?')) { 30 | return Promise.resolve(EmptyQueryResult); 31 | } 32 | return Promise.resolve({}); 33 | } 34 | 35 | export function parseFileAsQueryResult(filePath: string[]) { 36 | return JSON.parse(fs.readFileSync(path.join(...filePath), 'utf8')) as QueryResult; 37 | } 38 | 39 | export function parseFile(filePath: string[]) { 40 | return JSON.parse(fs.readFileSync(path.join(...filePath), 'utf8')) as T; 41 | } 42 | -------------------------------------------------------------------------------- /test/data/garbage-collection/cmd-m00-records.json: -------------------------------------------------------------------------------- 1 | { 2 | "records": [ 3 | { 4 | "attributes": { 5 | "type": "CompanyData__mdt", 6 | "url": "/services/data/v62.0/sobjects/CompanyData__mdt/m004P0000009UQsQAM" 7 | }, 8 | "Id": "m004P0000009UQsQAM", 9 | "DeveloperName": "CITY" 10 | }, 11 | { 12 | "attributes": { 13 | "type": "CompanyData__mdt", 14 | "url": "/services/data/v62.0/sobjects/CompanyData__mdt/m004P0000009UQtQAM" 15 | }, 16 | "Id": "m004P0000009UQtQAM", 17 | "DeveloperName": "COUNTRY" 18 | }, 19 | { 20 | "attributes": { 21 | "type": "CompanyData__mdt", 22 | "url": "/services/data/v62.0/sobjects/CompanyData__mdt/m004P0000009UQuQAM" 23 | }, 24 | "Id": "m004P0000009UQuQAM", 25 | "DeveloperName": "EMAIL" 26 | }, 27 | { 28 | "attributes": { 29 | "type": "CompanyData__mdt", 30 | "url": "/services/data/v62.0/sobjects/CompanyData__mdt/m004P0000009UQvQAM" 31 | }, 32 | "Id": "m004P0000009UQvQAM", 33 | "DeveloperName": "NAME" 34 | }, 35 | { 36 | "attributes": { 37 | "type": "CompanyData__mdt", 38 | "url": "/services/data/v62.0/sobjects/CompanyData__mdt/m004P0000009UQwQAM" 39 | }, 40 | "Id": "m004P0000009UQwQAM", 41 | "DeveloperName": "PHONE" 42 | }, 43 | { 44 | "attributes": { 45 | "type": "CompanyData__mdt", 46 | "url": "/services/data/v62.0/sobjects/CompanyData__mdt/m004P0000009UQxQAM" 47 | }, 48 | "Id": "m004P0000009UQxQAM", 49 | "DeveloperName": "POSTAL_CODE" 50 | }, 51 | { 52 | "attributes": { 53 | "type": "CompanyData__mdt", 54 | "url": "/services/data/v62.0/sobjects/CompanyData__mdt/m004P0000009UQyQAM" 55 | }, 56 | "Id": "m004P0000009UQyQAM", 57 | "DeveloperName": "STREET" 58 | } 59 | ], 60 | "totalSize": 7, 61 | "done": true 62 | } 63 | -------------------------------------------------------------------------------- /test/mock-utils/apexSchedulerMocks.ts: -------------------------------------------------------------------------------- 1 | import fs from 'node:fs'; 2 | import path from 'node:path'; 3 | import { AnyJson } from '@salesforce/ts-types'; 4 | import { ExecuteAnonymousResponse } from '@salesforce/apex-node'; 5 | import { QueryResult } from '@jsforce/jsforce-node'; 6 | import { AsyncApexJob } from '../../src/types/scheduledApexTypes.js'; 7 | 8 | const testDataPath = path.join('test', 'data', 'apex-schedule-service'); 9 | 10 | export default class ApexSchedulerMocks { 11 | public SCHEDULE_START_SUCCESS = parseMockResult('schedule-start-success.json'); 12 | public SCHEDULE_STOP_SUCCESS = parseMockResult('schedule-stop-success.json'); 13 | public ALREADY_SCHEDULED_ERROR = parseMockResult('is-already-scheduled-error.json'); 14 | public INVALID_CRON_EXPRESSION_ERROR = parseMockResult('invalid-cron-error.json'); 15 | public JOB_ALREADY_ABORTED = parseMockResult('job-is-already-aborted.json'); 16 | public JOB_DETAILS = parseMockResult>('cron-trigger-details.json'); 17 | public ALL_JOBS = parseMockResult>('all-jobs.json'); 18 | 19 | public mockQueryResults(request: AnyJson): Promise { 20 | const url = (request as { url: string }).url; 21 | if (url.includes(encodeURIComponent("FROM AsyncApexJob WHERE JobType = 'ScheduledApex' AND Status = 'Queued'"))) { 22 | return Promise.resolve(this.ALL_JOBS); 23 | } 24 | if (url.includes(encodeURIComponent("FROM AsyncApexJob WHERE CronTriggerId = '08e9b00000KiFENAA3' LIMIT 1"))) { 25 | return Promise.resolve(this.JOB_DETAILS); 26 | } 27 | return Promise.reject(new Error(`No mock was defined for: ${decodeURIComponent(url)}`)); 28 | } 29 | } 30 | 31 | function parseMockResult(filePath: string) { 32 | return JSON.parse(fs.readFileSync(`${path.join(testDataPath, filePath)}`, 'utf8')) as T; 33 | } 34 | -------------------------------------------------------------------------------- /src/garbage-collection/entity-handlers/nameEntity.ts: -------------------------------------------------------------------------------- 1 | /* eslint-disable no-console */ 2 | import { Connection } from '@salesforce/core'; 3 | import { NamedRecord, Package2Member } from '../../types/sfToolingApiTypes.js'; 4 | import { buildSubjectIdFilter, EntityDefinitionHandler } from '../entityDefinitionHandler.js'; 5 | import { PackageGarbage, PackageGarbageContainer } from '../packageGarbageTypes.js'; 6 | import QueryRunner from '../../common/utils/queryRunner.js'; 7 | 8 | export class NameEntity implements EntityDefinitionHandler { 9 | private readonly queryRunner: QueryRunner; 10 | 11 | public constructor( 12 | private readonly queryConnection: Connection | Connection['tooling'], 13 | private readonly entityName: string, 14 | private readonly metadataTypeName?: string 15 | ) { 16 | this.queryRunner = new QueryRunner(this.queryConnection); 17 | } 18 | 19 | public async resolve(packageMembers: Package2Member[]): Promise { 20 | const garbageList: PackageGarbage[] = []; 21 | const entityDefs = await this.fetchEntities(packageMembers); 22 | packageMembers.forEach((packageMember) => { 23 | if (entityDefs.has(packageMember.SubjectId)) { 24 | const def = entityDefs.get(packageMember.SubjectId)!; 25 | garbageList.push(new PackageGarbage(packageMember, def.Name)); 26 | } 27 | }); 28 | return { 29 | metadataType: this.metadataTypeName ?? this.entityName, 30 | componentCount: garbageList.length, 31 | components: garbageList, 32 | }; 33 | } 34 | 35 | private async fetchEntities(packageMembers: Package2Member[]): Promise> { 36 | const entitiesById = new Map(); 37 | const entityDefs = await this.queryRunner.fetchRecords( 38 | `SELECT Id,Name FROM ${this.entityName} WHERE ${buildSubjectIdFilter(packageMembers)}` 39 | ); 40 | entityDefs.forEach((ed) => entitiesById.set(ed.Id, ed)); 41 | return entitiesById; 42 | } 43 | } 44 | -------------------------------------------------------------------------------- /src/garbage-collection/entity-handlers/developerNameEntity.ts: -------------------------------------------------------------------------------- 1 | /* eslint-disable no-console */ 2 | import { Connection } from '@salesforce/core'; 3 | import { DeveloperNamedRecord, Package2Member } from '../../types/sfToolingApiTypes.js'; 4 | import { buildSubjectIdFilter, EntityDefinitionHandler } from '../entityDefinitionHandler.js'; 5 | import { PackageGarbage, PackageGarbageContainer } from '../packageGarbageTypes.js'; 6 | import QueryRunner from '../../common/utils/queryRunner.js'; 7 | 8 | export class DeveloperNameEntity implements EntityDefinitionHandler { 9 | private readonly queryRunner: QueryRunner; 10 | 11 | public constructor( 12 | private readonly queryConnection: Connection | Connection['tooling'], 13 | private readonly entityName: string, 14 | private readonly metadataTypeName?: string 15 | ) { 16 | this.queryRunner = new QueryRunner(this.queryConnection); 17 | } 18 | 19 | public async resolve(packageMembers: Package2Member[]): Promise { 20 | const garbageList: PackageGarbage[] = []; 21 | const entities = await this.fetchEntities(packageMembers); 22 | packageMembers.forEach((member) => { 23 | if (entities.has(member.SubjectId)) { 24 | garbageList.push(new PackageGarbage(member, entities.get(member.SubjectId)!.DeveloperName)); 25 | } 26 | }); 27 | return { 28 | metadataType: this.metadataTypeName ?? this.entityName, 29 | componentCount: garbageList.length, 30 | components: garbageList, 31 | }; 32 | } 33 | 34 | private async fetchEntities(packageMembers: Package2Member[]): Promise> { 35 | const entitiesById = new Map(); 36 | const entityDefs = await this.queryRunner.fetchRecords( 37 | `SELECT Id,DeveloperName FROM ${this.entityName} WHERE ${buildSubjectIdFilter(packageMembers)}` 38 | ); 39 | entityDefs.forEach((ed) => entitiesById.set(ed.Id, ed)); 40 | return entitiesById; 41 | } 42 | } 43 | -------------------------------------------------------------------------------- /src/common/packageManifestBuilder.ts: -------------------------------------------------------------------------------- 1 | import fs from 'node:fs'; 2 | import { XMLBuilder } from 'fast-xml-parser'; 3 | 4 | // copied from https://github.com/forcedotcom/source-deploy-retrieve/blob/main/src/common/constants.ts 5 | // all credits to mshanemc 6 | const XML_NS_URL = 'http://soap.sforce.com/2006/04/metadata'; 7 | const XML_NS_KEY = '@_xmlns'; 8 | 9 | export type PackageTypeMembers = { 10 | name: string; 11 | members: string[]; 12 | }; 13 | 14 | export type PackageManifest = { 15 | Package: { 16 | types: Record; 17 | version: string; 18 | }; 19 | }; 20 | 21 | export default class PackageManifestBuilder { 22 | private types: Record; 23 | 24 | public constructor(public apiVersion?: string) { 25 | this.types = {}; 26 | } 27 | 28 | /** 29 | * Adds a new member to a type container. If the type does not exist yet, 30 | * the container is created. 31 | * 32 | * @param type 33 | * @param memberName 34 | */ 35 | public addMember(type: string, memberName: string): void { 36 | if (this.types[type] === undefined) { 37 | this.types[type] = { 38 | name: type, 39 | members: [], 40 | }; 41 | } 42 | this.types[type].members.push(memberName); 43 | } 44 | 45 | /** 46 | * Formats the current package manifest instance to XML string. 47 | * 48 | * @returns 49 | */ 50 | public toXML(): string { 51 | const builder = new XMLBuilder({ 52 | format: true, 53 | indentBy: ''.padEnd(4, ' '), 54 | ignoreAttributes: false, 55 | }); 56 | return builder.build({ 57 | Package: { 58 | types: Object.values(this.types), 59 | version: this.apiVersion ?? '62.0', 60 | [XML_NS_KEY]: XML_NS_URL, 61 | }, 62 | }); 63 | } 64 | 65 | /** 66 | * Writes the contents as XML to the destination path. 67 | * 68 | * @param path 69 | */ 70 | public writeToXmlFile(path: string): void { 71 | fs.writeFileSync(path, this.toXML()); 72 | } 73 | } 74 | -------------------------------------------------------------------------------- /test/common/migrationPlan.test.ts: -------------------------------------------------------------------------------- 1 | import { expect } from 'chai'; 2 | import sinon from 'sinon'; 3 | import { SfError } from '@salesforce/core'; 4 | import { TestContext, MockTestOrgData } from '@salesforce/core/testSetup'; 5 | import MigrationPlanLoader from '../../src/common/migrationPlanLoader.js'; 6 | import { mockAnySObjectDescribe } from '../mock-utils/sfQueryApiMocks.js'; 7 | 8 | describe('migration plan', () => { 9 | const $$ = new TestContext(); 10 | const testOrg = new MockTestOrgData(); 11 | 12 | beforeEach(async () => { 13 | await $$.stubAuths(testOrg); 14 | $$.fakeConnectionRequest = mockAnySObjectDescribe; 15 | }); 16 | 17 | afterEach(async () => { 18 | $$.SANDBOX.restore(); 19 | sinon.restore(); 20 | }); 21 | 22 | it('loads valid plan file successfully', async () => { 23 | // Act 24 | const plan = await MigrationPlanLoader.loadPlan('test/data/plans/test-plan.yaml', await testOrg.getConnection()); 25 | 26 | // Assert 27 | expect(plan.getObjects().length).equals(4); 28 | }); 29 | 30 | it('throws parse error if child binds to unknown parent ids', async () => { 31 | // Act 32 | try { 33 | await MigrationPlanLoader.loadPlan('test/data/plans/unknown-parent-ids.yaml', await testOrg.getConnection()); 34 | expect.fail('Should throw exception'); 35 | } catch (err) { 36 | if (err instanceof SfError) { 37 | expect(err.name).to.equal('InvalidPlanFileSyntax'); 38 | expect(err.message).to.contain('Contact references a parent bind that was not defined: myAccountIds'); 39 | } else { 40 | expect.fail('Expected SfError, but got: ' + JSON.stringify(err)); 41 | } 42 | } 43 | }); 44 | 45 | it('parses plan with duplicate exports successfully', async () => { 46 | // Act 47 | const plan = await MigrationPlanLoader.loadPlan( 48 | 'test/data/plans/duplicate-parent-ids.yaml', 49 | await testOrg.getConnection() 50 | ); 51 | 52 | // Assert 53 | expect(plan.getObjects().length).equals(2); 54 | }); 55 | }); 56 | -------------------------------------------------------------------------------- /src/types/orgManifestOutputSchema.ts: -------------------------------------------------------------------------------- 1 | import { z } from 'zod'; 2 | import { DeployStatus, DeployStrategies } from './orgManifestGlobalConstants.js'; 3 | 4 | /** JSON output schema - all camelCase */ 5 | 6 | const ArtifactDeployResult = z.object({ 7 | deployStrategy: z.string(), 8 | status: DeployStatus, 9 | targetUsername: z.string().optional(), 10 | displayMessage: z.string().optional(), 11 | errorDetails: z.unknown(), 12 | }); 13 | 14 | const AggregatedArtifactResult = z.object({ 15 | status: DeployStatus, 16 | message: z.string().optional(), 17 | }); 18 | 19 | const ManifestDeployResult = z.record(z.string(), z.array(ArtifactDeployResult)); 20 | 21 | const SourceDeployResult = ArtifactDeployResult.extend({ 22 | deployStrategy: z.literal(DeployStrategies.Enum.SourceDeploy), 23 | sourcePath: z.string().optional(), 24 | }); 25 | 26 | const PackageInstallResult = ArtifactDeployResult.extend({ 27 | deployStrategy: z.literal(DeployStrategies.Enum.PackageInstall), 28 | shouldSkipIfInstalled: z.boolean(), 29 | requestedVersion: z.string(), 30 | requestedVersionId: z.string(), 31 | installedVersion: z.string().optional(), 32 | installedVersionId: z.string().optional(), 33 | useInstallationKey: z.boolean(), 34 | installationKey: z.string().optional(), 35 | }); 36 | 37 | const CronJobScheduleResult = ArtifactDeployResult.extend({ 38 | deployStrategy: z.literal(DeployStrategies.Enum.CronJobSchedule), 39 | jobName: z.string(), 40 | }); 41 | 42 | const ZArtifactDeployResult = z.discriminatedUnion('deployStrategy', [ 43 | SourceDeployResult, 44 | PackageInstallResult, 45 | CronJobScheduleResult, 46 | ]); 47 | 48 | export type ZManifestDeployResultType = z.infer; 49 | export type ZArtifactDeployResultType = z.infer; 50 | export type ZSourceDeployResultType = z.infer; 51 | export type ZPackageInstallResultType = z.infer; 52 | export type ZAggregatedArtifactResult = z.infer; 53 | -------------------------------------------------------------------------------- /test/data/api/queryResults.ts: -------------------------------------------------------------------------------- 1 | export const InvalidFieldInQuery = { 2 | data: { 3 | message: 4 | "\nSELECT Id,Invalid__x FROM Contact LIMIT 1\n ^\nERROR at Row:1:Column:11\nNo such column 'Invalid__x' on entity 'Contact'. If you are attempting to use a custom field, be sure to append the '__c' after the custom field name. Please reference your WSDL or the describe call for the appropriate names.", 5 | errorCode: 'INVALID_FIELD', 6 | }, 7 | errorCode: 'INVALID_FIELD', 8 | name: 'INVALID_FIELD', 9 | }; 10 | 11 | export const GenericRejection = { 12 | errorCode: 'UNEXPECTED_REJECT_WRONG_REQUEST', 13 | name: 'GENERIC_REJECTION', 14 | }; 15 | 16 | export const GenericSuccess = { 17 | status: 0, 18 | records: [], 19 | }; 20 | 21 | export const EmptyQueryResult = { 22 | done: true, 23 | totalSize: 0, 24 | records: [], 25 | }; 26 | 27 | export const MockAccounts = [ 28 | { 29 | attributes: { 30 | type: 'Account', 31 | url: '/services/data/v62.0/sobjects/Account/0019Q00000eC8UKQA0', 32 | }, 33 | Id: '0019Q00000eC8UKQA0', 34 | Name: 'Sample Account for Entitlements', 35 | }, 36 | { 37 | attributes: { 38 | type: 'Account', 39 | url: '/services/data/v62.0/sobjects/Account/0019Q00000eDKbNQAW', 40 | }, 41 | Id: '0019Q00000eDKbNQAW', 42 | Name: 'Starship Galactica Ltd.', 43 | }, 44 | { 45 | attributes: { 46 | type: 'Account', 47 | url: '/services/data/v62.0/sobjects/Account/0019Q00000eDKbOQAW', 48 | }, 49 | Id: '0019Q00000eDKbOQAW', 50 | Name: 'Colonial One', 51 | }, 52 | { 53 | attributes: { 54 | type: 'Account', 55 | url: '/services/data/v62.0/sobjects/Account/0019Q00000eDKbPQAW', 56 | }, 57 | Id: '0019Q00000eDKbPQAW', 58 | Name: 'Cloud 9 GmbH', 59 | }, 60 | ]; 61 | 62 | export const MockOrders = [ 63 | { 64 | attributes: { 65 | type: 'Order', 66 | url: '/services/data/v62.0/sobjects/Order/8019X0000046Tl1QAE', 67 | }, 68 | Id: '8019X0000046Tl1QAE', 69 | StatusCode: 'Draft', 70 | CurrencyIsoCode: 'EUR', 71 | }, 72 | ]; 73 | -------------------------------------------------------------------------------- /src/garbage-collection/customObjects.ts: -------------------------------------------------------------------------------- 1 | import { Connection } from '@salesforce/core'; 2 | import QueryRunner from '../common/utils/queryRunner.js'; 3 | import { EntityDefinition } from '../types/sfToolingApiTypes.js'; 4 | import { ALL_CUSTOM_OBJECTS } from './queries.js'; 5 | 6 | /** 7 | * Caches all custom objects from org and allows to retrieve & resolve by 8 | * Durable Id, key Prefix, etc. 9 | */ 10 | export default class CustomObjects { 11 | private readonly toolingObjectsRunner: QueryRunner; 12 | private readonly objectsByKey = new Map(); 13 | private readonly objectsByDurableId = new Map(); 14 | private readonly objectsByDeveloperName = new Map(); 15 | private readonly objectsByApiName = new Map(); 16 | private isInitialised = false; 17 | 18 | public constructor(private readonly targetOrgConnection: Connection) { 19 | this.toolingObjectsRunner = new QueryRunner(this.targetOrgConnection.tooling); 20 | } 21 | 22 | public async resolveKeyPrefix(keyPrefix: string): Promise { 23 | if (!this.isInitialised) { 24 | await this.loadObjects(); 25 | } 26 | return this.objectsByKey.get(keyPrefix); 27 | } 28 | 29 | public async resolve18CharDurableId(objectId: string): Promise { 30 | if (!this.isInitialised) { 31 | await this.loadObjects(); 32 | } 33 | return this.objectsByDurableId.get(objectId.substring(0, 15)); 34 | } 35 | 36 | private async loadObjects(): Promise { 37 | const entityDefinitions = await this.toolingObjectsRunner.fetchRecords(ALL_CUSTOM_OBJECTS); 38 | entityDefinitions.forEach((entityDef) => { 39 | this.objectsByKey.set(entityDef.KeyPrefix, entityDef); 40 | this.objectsByDeveloperName.set(entityDef.DeveloperName, entityDef); 41 | this.objectsByApiName.set(entityDef.QualifiedApiName, entityDef); 42 | this.objectsByDurableId.set(entityDef.DurableId, entityDef); 43 | }); 44 | this.isInitialised = true; 45 | } 46 | } 47 | -------------------------------------------------------------------------------- /src/garbage-collection/entity-handlers/sobjectBasedDevNameEntity.ts: -------------------------------------------------------------------------------- 1 | /* eslint-disable no-console */ 2 | import { Connection } from '@salesforce/core'; 3 | import { Package2Member, SobjectTypeDevNamedEntity } from '../../types/sfToolingApiTypes.js'; 4 | import { buildSubjectIdFilter, EntityDefinitionHandler } from '../entityDefinitionHandler.js'; 5 | import { PackageGarbage, PackageGarbageContainer } from '../packageGarbageTypes.js'; 6 | import QueryRunner from '../../common/utils/queryRunner.js'; 7 | 8 | export class SObjectBasedDefNameEntity implements EntityDefinitionHandler { 9 | private readonly queryRunner: QueryRunner; 10 | 11 | public constructor( 12 | private readonly queryConnection: Connection, 13 | private readonly entityName: string, 14 | private readonly metadataType?: string 15 | ) { 16 | this.queryRunner = new QueryRunner(this.queryConnection.tooling); 17 | } 18 | 19 | public async resolve(packageMembers: Package2Member[]): Promise { 20 | const garbageList: PackageGarbage[] = []; 21 | const defs = await this.fetchDefinitions(packageMembers); 22 | packageMembers.forEach((member) => { 23 | const actionDef = defs.get(member.SubjectId); 24 | if (actionDef) { 25 | garbageList.push( 26 | new PackageGarbage(member, actionDef.DeveloperName, `${actionDef.SobjectType}.${actionDef.DeveloperName}`) 27 | ); 28 | } 29 | }); 30 | return { 31 | metadataType: this.metadataType ?? this.entityName, 32 | componentCount: garbageList.length, 33 | components: garbageList, 34 | }; 35 | } 36 | 37 | private async fetchDefinitions(packageMembers: Package2Member[]): Promise> { 38 | const entitiesById = new Map(); 39 | const entityDefs = await this.queryRunner.fetchRecords( 40 | `SELECT Id,DeveloperName,SobjectType FROM ${this.entityName} WHERE ${buildSubjectIdFilter(packageMembers)}` 41 | ); 42 | entityDefs.forEach((ed) => entitiesById.set(ed.Id, ed)); 43 | return entitiesById; 44 | } 45 | } 46 | -------------------------------------------------------------------------------- /src/common/apex-scheduler/stopSingleJobTask.ts: -------------------------------------------------------------------------------- 1 | import { EventEmitter } from 'node:events'; 2 | import { ApexDiagnostic, ExecuteAnonymousResponse, ExecuteService } from '@salesforce/apex-node'; 3 | import { Connection, Messages } from '@salesforce/core'; 4 | import { assertCompileSuccess, assertSuccess } from './apexScheduleService.js'; 5 | 6 | Messages.importMessagesDirectoryFromMetaUrl(import.meta.url); 7 | const messages = Messages.loadMessages('@j-schreiber/sf-plugin', 'apexscheduler'); 8 | 9 | const JOB_ID_PLACEHOLDER = '%%%JOB_ID%%%'; 10 | const STOP_SINGLE_JOB_TEMPLATE = `System.abortJob('${JOB_ID_PLACEHOLDER}');`; 11 | 12 | export default class StopSingleJobTask extends EventEmitter { 13 | private readonly executor: ExecuteService; 14 | 15 | public constructor(private readonly targetOrgCon: Connection) { 16 | super(); 17 | this.executor = new ExecuteService(this.targetOrgCon); 18 | } 19 | 20 | public async stop(id: string): Promise { 21 | const apexCode = prepareCode(id); 22 | const anonResult = await this.executor.executeAnonymous({ apexCode }); 23 | this.emit('apexExecution', anonResult); 24 | return { jobId: id, status: parseExecutionResult(anonResult) }; 25 | } 26 | } 27 | 28 | function parseExecutionResult(result: ExecuteAnonymousResponse): string { 29 | assertCompileSuccess(result); 30 | if (isAlreadyAborted(result.diagnostic)) { 31 | throw messages.createError('JobAlreadyAborted', [result.diagnostic![0].exceptionMessage.substring(24)]); 32 | } 33 | assertSuccess(result); 34 | return 'STOPPED'; 35 | } 36 | 37 | function prepareCode(jobId: string): string { 38 | let apexCode = STOP_SINGLE_JOB_TEMPLATE; 39 | apexCode = apexCode.replace(JOB_ID_PLACEHOLDER, jobId); 40 | return apexCode; 41 | } 42 | 43 | function isAlreadyAborted(diagnostics?: ApexDiagnostic[]): boolean { 44 | return ( 45 | diagnostics !== undefined && 46 | diagnostics.length >= 1 && 47 | diagnostics[0].exceptionMessage.endsWith('Job does not exist or is already aborted.') 48 | ); 49 | } 50 | 51 | export type StopScheduledApexResult = { 52 | jobId: string; 53 | status: string; 54 | }; 55 | -------------------------------------------------------------------------------- /test/data/apex-schedule-service/is-already-scheduled-error.json: -------------------------------------------------------------------------------- 1 | { 2 | "compiled": true, 3 | "success": false, 4 | "logs": "62.0 APEX_CODE,DEBUG;APEX_PROFILING,INFO\nExecute Anonymous: \nExecute Anonymous: String jobName = 'CaseReminderJob';\nExecute Anonymous: String cronExpression = '0 0 0 ? * * * *';\nExecute Anonymous: Id jobId = System.schedule(jobName, cronExpression, new CaseReminderJob());\nExecute Anonymous: System.debug('%%%JOB_ID%%%=' + jobId);\n18:19:20.248 (248960423)|USER_INFO|[EXTERNAL]|0059b00000EaACh|test-sgsqwpzichd0@example.com|(GMT+01:00) Central European Standard Time (Europe/Berlin)|GMT+01:00\n18:19:20.248 (248980452)|EXECUTION_STARTED\n18:19:20.248 (249001338)|CODE_UNIT_STARTED|[EXTERNAL]|execute_anonymous_apex\n18:19:20.248 (256028405)|EXCEPTION_THROWN|[4]|System.AsyncException: The Apex job named \"CaseReminderJob\" is already scheduled for execution.\n18:19:20.248 (256225342)|FATAL_ERROR|System.AsyncException: The Apex job named \"CaseReminderJob\" is already scheduled for execution.\n\nAnonymousBlock: line 4, column 1\n18:19:20.256 (256401787)|CUMULATIVE_LIMIT_USAGE\n18:19:20.256 (256401787)|LIMIT_USAGE_FOR_NS|(default)|\n Number of SOQL queries: 0 out of 100\n Number of query rows: 0 out of 50000\n Number of SOSL queries: 0 out of 20\n Number of DML statements: 0 out of 150\n Number of Publish Immediate DML: 0 out of 150\n Number of DML rows: 0 out of 10000\n Maximum CPU time: 0 out of 10000\n Maximum heap size: 0 out of 6000000\n Number of callouts: 0 out of 100\n Number of Email Invocations: 0 out of 10\n Number of future calls: 0 out of 50\n Number of queueable jobs added to the queue: 0 out of 50\n Number of Mobile Apex push calls: 0 out of 10\n\n18:19:20.256 (256401787)|CUMULATIVE_LIMIT_USAGE_END\n\n18:19:20.248 (256467360)|CODE_UNIT_FINISHED|execute_anonymous_apex\n18:19:20.248 (256478097)|EXECUTION_FINISHED\n", 5 | "diagnostic": [ 6 | { 7 | "lineNumber": "4", 8 | "columnNumber": "1", 9 | "compileProblem": "", 10 | "exceptionMessage": "System.AsyncException: The Apex job named \"CaseReminderJob\" is already scheduled for execution.", 11 | "exceptionStackTrace": "AnonymousBlock: line 4, column 1" 12 | } 13 | ] 14 | } 15 | -------------------------------------------------------------------------------- /src/types/orgManifestInputSchema.ts: -------------------------------------------------------------------------------- 1 | /* eslint-disable camelcase */ 2 | 3 | import { z } from 'zod'; 4 | import { ArtifactTypes } from './orgManifestGlobalConstants.js'; 5 | import { ScheduledJobConfig } from './scheduledApexTypes.js'; 6 | 7 | /** All schema are YAML input - therefore snake_case */ 8 | 9 | const ZEnvironments = z.record(z.string()); 10 | 11 | const ZArtifactBase = z.object({ 12 | flags: z.string().optional(), 13 | }); 14 | 15 | const ZUnlockedPackage = ZArtifactBase.extend({ 16 | type: z.literal(ArtifactTypes.Enum.UnlockedPackage), 17 | package_id: z.string(), 18 | installation_key: z.string().optional(), 19 | skip_if_installed: z.boolean().optional(), 20 | version: z.string().regex(/^(\d+\.\d+\.\d+)$/, { message: 'Set version as MAJOR.MINOR.PATH (e.g. 1.4.0)' }), 21 | }); 22 | 23 | const ZUnpackagedSource = ZArtifactBase.extend({ 24 | type: z.literal(ArtifactTypes.Enum.Unpackaged), 25 | path: z.string().or(z.record(z.string())), 26 | }); 27 | 28 | const ZCronJob = ZArtifactBase.extend({ 29 | ...ScheduledJobConfig.shape, 30 | type: z.literal(ArtifactTypes.Enum.CronJob), 31 | }); 32 | 33 | const ZManifestOptions = z 34 | .object({ 35 | skip_if_installed: z.boolean().default(true), 36 | requires_promoted_versions: z.boolean().default(true), 37 | strict_environments: z.boolean().default(false), 38 | pipefail: z.boolean().default(true), 39 | }) 40 | .default({}); 41 | 42 | const ZArtifact = z.discriminatedUnion('type', [ZUnlockedPackage, ZUnpackagedSource, ZCronJob]); 43 | 44 | export const ZReleaseManifest = z 45 | .object({ 46 | environments: ZEnvironments.optional(), 47 | artifacts: z.record(ZArtifact, { required_error: 'At least one artifact is required' }), 48 | options: ZManifestOptions, 49 | }) 50 | .strict(); 51 | 52 | export type ZManifestEnvsType = z.infer; 53 | export type ZManifestOptionsType = z.infer; 54 | export type ZReleaseManifestType = z.infer; 55 | export type ZArtifactType = z.infer; 56 | export type ZUnlockedPackageArtifact = z.infer; 57 | export type ZUnpackagedSourceArtifact = z.infer; 58 | -------------------------------------------------------------------------------- /test/data/apex-schedule-service/invalid-cron-error.json: -------------------------------------------------------------------------------- 1 | { 2 | "compiled": true, 3 | "success": false, 4 | "logs": "62.0 APEX_CODE,DEBUG;APEX_PROFILING,INFO\nExecute Anonymous: String jobName = 'Test Name 1';\nExecute Anonymous: String cronExpression = '0 A';\nExecute Anonymous: Id jobId = System.schedule(jobName, cronExpression, new CaseReminderJob());\nExecute Anonymous: System.debug(jobId);\n16:18:44.236 (236029154)|USER_INFO|[EXTERNAL]|0059b00000EaACh|test-sgsqwpzichd0@example.com|(GMT+01:00) Central European Standard Time (Europe/Berlin)|GMT+01:00\n16:18:44.236 (236056959)|EXECUTION_STARTED\n16:18:44.236 (236067323)|CODE_UNIT_STARTED|[EXTERNAL]|execute_anonymous_apex\n16:18:44.236 (239880378)|EXCEPTION_THROWN|[3]|System.StringException: Illegal cron expression format (java.lang.StringIndexOutOfBoundsException: begin 0, end 3, length 1)\n16:18:44.236 (240032430)|FATAL_ERROR|System.StringException: Illegal cron expression format (java.lang.StringIndexOutOfBoundsException: begin 0, end 3, length 1)\n\nAnonymousBlock: line 3, column 1\n16:18:44.240 (240070770)|CUMULATIVE_LIMIT_USAGE\n16:18:44.240 (240070770)|LIMIT_USAGE_FOR_NS|(default)|\n Number of SOQL queries: 0 out of 100\n Number of query rows: 0 out of 50000\n Number of SOSL queries: 0 out of 20\n Number of DML statements: 0 out of 150\n Number of Publish Immediate DML: 0 out of 150\n Number of DML rows: 0 out of 10000\n Maximum CPU time: 0 out of 10000\n Maximum heap size: 0 out of 6000000\n Number of callouts: 0 out of 100\n Number of Email Invocations: 0 out of 10\n Number of future calls: 0 out of 50\n Number of queueable jobs added to the queue: 0 out of 50\n Number of Mobile Apex push calls: 0 out of 10\n\n16:18:44.240 (240070770)|CUMULATIVE_LIMIT_USAGE_END\n\n16:18:44.236 (240132482)|CODE_UNIT_FINISHED|execute_anonymous_apex\n16:18:44.236 (240141087)|EXECUTION_FINISHED\n", 5 | "diagnostic": [ 6 | { 7 | "lineNumber": "3", 8 | "columnNumber": "1", 9 | "compileProblem": "", 10 | "exceptionMessage": "System.StringException: Illegal cron expression format (java.lang.StringIndexOutOfBoundsException: begin 0, end 3, length 1)", 11 | "exceptionStackTrace": "AnonymousBlock: line 3, column 1" 12 | } 13 | ] 14 | } 15 | -------------------------------------------------------------------------------- /src/garbage-collection/entity-handlers/outdatedFlowVersions.ts: -------------------------------------------------------------------------------- 1 | /* eslint-disable no-console */ 2 | import { Connection } from '@salesforce/core'; 3 | import { FlowVersionDefinition, Package2Member } from '../../types/sfToolingApiTypes.js'; 4 | import { EntityDefinitionHandler } from '../entityDefinitionHandler.js'; 5 | import { PackageGarbage, PackageGarbageContainer } from '../packageGarbageTypes.js'; 6 | import QueryRunner from '../../common/utils/queryRunner.js'; 7 | import { OBSOLETE_FLOWS } from '../queries.js'; 8 | 9 | export class OutdatedFlowVersions implements EntityDefinitionHandler { 10 | private readonly queryRunner: QueryRunner; 11 | 12 | public constructor(private readonly queryConnection: Connection) { 13 | this.queryRunner = new QueryRunner(this.queryConnection.tooling); 14 | } 15 | 16 | public async resolve(packageMembers: Package2Member[]): Promise { 17 | const garbageList: PackageGarbage[] = []; 18 | const outdatedVersions = await this.fetchOutdatedFlowVersions(); 19 | packageMembers.forEach((packageMember) => { 20 | const versions = outdatedVersions.get(packageMember.SubjectId); 21 | if (versions && versions.length > 0) { 22 | versions.forEach((flowVersion) => { 23 | const pg = new PackageGarbage( 24 | packageMember, 25 | `${flowVersion.Definition.DeveloperName}-${flowVersion.VersionNumber}` 26 | ); 27 | pg.subjectId = flowVersion.Id; 28 | garbageList.push(pg); 29 | }); 30 | } 31 | }); 32 | return { metadataType: 'Flow', componentCount: garbageList.length, components: garbageList }; 33 | } 34 | 35 | private async fetchOutdatedFlowVersions(): Promise> { 36 | const versionsMap = new Map(); 37 | const outdatedVersions = await this.queryRunner.fetchRecords(OBSOLETE_FLOWS); 38 | outdatedVersions.forEach((ver) => { 39 | if (!versionsMap.has(ver.DefinitionId)) { 40 | versionsMap.set(ver.DefinitionId, new Array()); 41 | } 42 | versionsMap.get(ver.DefinitionId)?.push(ver); 43 | }); 44 | return versionsMap; 45 | } 46 | } 47 | -------------------------------------------------------------------------------- /src/garbage-collection/entity-handlers/dynamicDevNamedEntityRelated.ts: -------------------------------------------------------------------------------- 1 | /* eslint-disable no-console */ 2 | import { Connection } from '@salesforce/core'; 3 | import { DynamicallyNamedEntity, Package2Member } from '../../types/sfToolingApiTypes.js'; 4 | import { EntityDefinitionHandler, buildSubjectIdFilter } from '../entityDefinitionHandler.js'; 5 | import { PackageGarbage, PackageGarbageContainer } from '../packageGarbageTypes.js'; 6 | import QueryRunner from '../../common/utils/queryRunner.js'; 7 | 8 | export class DynamicDevNamedEntityRelated implements EntityDefinitionHandler { 9 | private readonly queryRunner: QueryRunner; 10 | 11 | public constructor( 12 | private readonly queryConnection: Connection, 13 | private readonly entityName: string, 14 | private readonly devName: string, 15 | private readonly metadataName?: string 16 | ) { 17 | this.queryRunner = new QueryRunner(this.queryConnection.tooling); 18 | } 19 | 20 | public async resolve(packageMembers: Package2Member[]): Promise { 21 | const garbageList: PackageGarbage[] = []; 22 | const definitions = await this.fetchDefinitions(packageMembers); 23 | packageMembers.forEach((member) => { 24 | const entity = definitions.get(member.SubjectId); 25 | if (entity) { 26 | garbageList.push( 27 | new PackageGarbage( 28 | member, 29 | entity[this.devName] as string, 30 | `${entity.EntityDefinition.QualifiedApiName!}.${entity[this.devName] as string}` 31 | ) 32 | ); 33 | } 34 | }); 35 | return { 36 | metadataType: this.metadataName ?? this.entityName, 37 | componentCount: garbageList.length, 38 | components: garbageList, 39 | }; 40 | } 41 | 42 | private async fetchDefinitions(packageMembers: Package2Member[]): Promise> { 43 | const entitiesById = new Map(); 44 | const entityDefs = await this.queryRunner.fetchRecords( 45 | `SELECT Id,${this.devName},EntityDefinition.QualifiedApiName FROM ${this.entityName} WHERE ${buildSubjectIdFilter( 46 | packageMembers 47 | )}` 48 | ); 49 | entityDefs.forEach((ed) => entitiesById.set(ed.Id, ed)); 50 | return entitiesById; 51 | } 52 | } 53 | -------------------------------------------------------------------------------- /src/common/reporters/resultsReporter.ts: -------------------------------------------------------------------------------- 1 | import { Ux } from '@salesforce/sf-plugins-core'; 2 | 3 | export type FormattingOptions = { 4 | /** 5 | * Run "capitalCase" transformer on columns ("myValue" is converted to "My Value") 6 | */ 7 | capitalizeHeaders?: boolean; 8 | /** 9 | * Render a title for the table 10 | */ 11 | title?: string; 12 | /** 13 | * Explicitly specify the columns to display. The table will always show these 14 | * columns, even if the data does not have the keys. If this is not specified, 15 | * the table will determine columns dynamically. 16 | */ 17 | columns?: string[]; 18 | /** 19 | * Explicitly remove columns from display. If columns are specified as well, 20 | * they are removed from the table again. This is mostly useful when columns 21 | * are determined automatically. 22 | */ 23 | excludeColumns?: string[]; 24 | /** 25 | * Passes the --json flag to all reporters to surpress output when command 26 | * is executed with --json. The default behavior is JSON "disabled", 27 | * meaning "output enabled". 28 | */ 29 | jsonEnabled?: boolean; 30 | }; 31 | 32 | export default abstract class ResultsReporter> { 33 | public columns: string[]; 34 | public ux: Ux; 35 | 36 | public constructor(public data: T[], public options?: FormattingOptions) { 37 | this.columns = retrieveColumns(data, options); 38 | this.ux = new Ux({ jsonEnabled: this.options?.jsonEnabled ?? false }); 39 | } 40 | 41 | /** 42 | * Prints results to stdout 43 | */ 44 | public abstract print(): void; 45 | } 46 | 47 | function retrieveColumns(data: Array>, options?: FormattingOptions): string[] { 48 | let cols: string[]; 49 | if (options?.columns !== undefined) { 50 | cols = [...options.columns]; 51 | } else { 52 | // iterate each entry in data to make sure we gather all populated properties 53 | const allKeys = new Set(); 54 | data.forEach((entry) => { 55 | Object.keys(entry).forEach((col) => allKeys.add(col)); 56 | }); 57 | cols = Array.from(allKeys) as string[]; 58 | } 59 | if (data.length === 0) { 60 | return []; 61 | } 62 | if (options?.excludeColumns) { 63 | return cols.filter((col) => !options.excludeColumns?.includes(col)); 64 | } else { 65 | return cols; 66 | } 67 | } 68 | -------------------------------------------------------------------------------- /test/commands/jsc/manifest/rollout.nut.ts: -------------------------------------------------------------------------------- 1 | import path from 'node:path'; 2 | import { expect } from 'chai'; 3 | import { env } from '@salesforce/kit'; 4 | import { execCmd, TestSession } from '@salesforce/cli-plugins-testkit'; 5 | import { JscManifestRolloutResult } from '../../../../src/commands/jsc/manifest/rollout.js'; 6 | import { DeployStatus } from '../../../../src/types/orgManifestGlobalConstants.js'; 7 | 8 | const scratchOrgAlias = 'TestTargetOrg'; 9 | const devhubUsername = env.getString('TESTKIT_HUB_USERNAME'); 10 | 11 | describe('jsc manifest rollout NUTs*', () => { 12 | let session: TestSession; 13 | before(async () => { 14 | session = await TestSession.create({ 15 | project: { 16 | name: 'manifestRolloutProject', 17 | sourceDir: path.join('test', 'data', 'test-sfdx-project'), 18 | }, 19 | devhubAuthStrategy: 'AUTO', 20 | scratchOrgs: [ 21 | { 22 | alias: scratchOrgAlias, 23 | config: path.join('config', 'default-scratch-def.json'), 24 | setDefault: false, 25 | duration: 1, 26 | }, 27 | ], 28 | }); 29 | }); 30 | 31 | after(async () => { 32 | await session?.clean(); 33 | }); 34 | 35 | describe('rollout simple manifest', () => { 36 | it('should successfully roll out the valid manifest with packaged and unpackaged artifacts', () => { 37 | const result = execCmd( 38 | `jsc:manifest:rollout --manifest manifest.yml --target-org ${scratchOrgAlias} --devhub-org ${session.hubOrg.username} --json`, 39 | { ensureExitCode: 0 } 40 | ).jsonOutput; 41 | expect(result).to.not.be.undefined; 42 | expect(result!.status).to.equal(0); 43 | const scratchOrgUsername = session.orgs.get(scratchOrgAlias)?.username; 44 | expect(result!.result.targetOrgUsername).to.equal(scratchOrgUsername); 45 | expect(result!.result.devhubOrgUsername).to.equal(devhubUsername); 46 | expect(result!.result.deployedArtifacts['apex_utils']).to.not.be.undefined; 47 | expect(result!.result.deployedArtifacts['apex_utils'][0].status).to.equal(DeployStatus.Enum.Success); 48 | expect(result!.result.deployedArtifacts['unpackaged']).to.not.be.undefined; 49 | expect(result!.result.deployedArtifacts['unpackaged'][0].status).to.equal(DeployStatus.Enum.Success); 50 | }); 51 | }); 52 | }); 53 | -------------------------------------------------------------------------------- /messages/jsc.maintain.garbage.collect.md: -------------------------------------------------------------------------------- 1 | # summary 2 | 3 | Collect garbage on your org and export to json or package.xml for more actions. 4 | 5 | # description 6 | 7 | Identifies left-overs from package upgrades. This includes deprecated components (custom fields, objects, layouts, etc that were removed from package content, but not deleted on target org after install), outdated flow versions, empty test suites, etc. The structured JSON output gives you insight into the metadata types still on the org and how they can be processed. You can optionally generate a package.xml or destructiveChanges.xml for further processing. 8 | 9 | # flags.target-org.summary 10 | 11 | Target org to analyse. 12 | 13 | # flags.target-org.description 14 | 15 | The org that is queried for deprecated package members and outdated flow versions. 16 | 17 | # flags.devhub-org.summary 18 | 19 | Used to resolve package ids when garbage must be filtered by package (--package). 20 | 21 | # flags.devhub-org.description 22 | 23 | Package filters are set with the "0Ho"-Id of the Package2 container. The DevHub is needed to resolve these ids to the canonical "033" Ids. If your target org is a devhub, it will automatically be used. This parameter is only needed, if you specify at least one package flag. 24 | 25 | # flags.metadata-type.summary 26 | 27 | Only include specific metadata types in the result. 28 | 29 | # flags.metadata-type.description 30 | 31 | Only provided metadata types are processed and added to "deprecated components" result. All other will be ignored. You can specify this flag multiple times. Use the developer name of the entity definition (e.g. ExternalString instead of CustomLabel). Values are case insensitive. 32 | 33 | # flags.package.summary 34 | 35 | Only include metadata from specific packages. 36 | 37 | # flags.package.description 38 | 39 | You can specify the package id (0Ho) or a local package alias from your sfdx-project.json to narrow down package members only from a specific package. You can specify this flag multiple times. 40 | 41 | # examples 42 | 43 | - <%= config.bin %> <%= command.id %> -o Production --json 44 | - <%= config.bin %> <%= command.id %> -o Production -d my-package-dir 45 | - <%= config.bin %> <%= command.id %> -o Production -m ExternalString -m CustomObject 46 | - <%= config.bin %> <%= command.id %> -o Production -m ExternalString -p SalesCRM -d tmp/test-run 47 | -------------------------------------------------------------------------------- /src/garbage-collection/entity-handlers/customField.ts: -------------------------------------------------------------------------------- 1 | /* eslint-disable no-console */ 2 | import { Connection } from '@salesforce/core'; 3 | import { Package2Member } from '../../types/sfToolingApiTypes.js'; 4 | import { EntityDefinitionHandler, extractSubjectIds } from '../entityDefinitionHandler.js'; 5 | import { PackageGarbage, PackageGarbageContainer } from '../packageGarbageTypes.js'; 6 | import ToolingApiConnection from '../toolingApiConnection.js'; 7 | 8 | export class CustomField implements EntityDefinitionHandler { 9 | private readonly apiConnection: ToolingApiConnection; 10 | 11 | public constructor(private readonly queryConnection: Connection) { 12 | this.apiConnection = ToolingApiConnection.getInstance(this.queryConnection); 13 | } 14 | 15 | public async resolve(packageMembers: Package2Member[]): Promise { 16 | const garbageList: PackageGarbage[] = []; 17 | const objectsByDurableId = await this.apiConnection.fetchObjectDefinitionsByDurableId(); 18 | const customFieldDefinitions = await this.apiConnection.fetchCustomFields(extractSubjectIds(packageMembers)); 19 | packageMembers.forEach((member) => { 20 | const fieldDef = customFieldDefinitions.get(member.SubjectId); 21 | if (fieldDef && isNotDeleted(fieldDef.DeveloperName)) { 22 | // field belongs to a custom object (or custom metadata, platform event, etc) 23 | if (fieldDef.TableEnumOrId.startsWith('01I')) { 24 | const customObjDef = objectsByDurableId.get(fieldDef.TableEnumOrId.substring(0, 15)); 25 | if (customObjDef) { 26 | garbageList.push( 27 | new PackageGarbage( 28 | member, 29 | fieldDef.DeveloperName, 30 | `${customObjDef.QualifiedApiName}.${fieldDef.DeveloperName}__c` 31 | ) 32 | ); 33 | } 34 | } else { 35 | // must be a custom field to a standard object 36 | garbageList.push( 37 | new PackageGarbage(member, fieldDef.DeveloperName, `${fieldDef.TableEnumOrId}.${fieldDef.DeveloperName}__c`) 38 | ); 39 | } 40 | } 41 | }); 42 | return { metadataType: 'CustomField', componentCount: garbageList.length, components: garbageList }; 43 | } 44 | } 45 | 46 | function isNotDeleted(devName: string): boolean { 47 | return devName.search(/(_del)[\d]*$/) < 0; 48 | } 49 | -------------------------------------------------------------------------------- /src/garbage-collection/entity-handlers/approvalProcessDefinition.ts: -------------------------------------------------------------------------------- 1 | /* eslint-disable no-console */ 2 | import { Connection } from '@salesforce/core'; 3 | import { Package2Member } from '../../types/sfToolingApiTypes.js'; 4 | import { buildSubjectIdFilter, EntityDefinitionHandler } from '../entityDefinitionHandler.js'; 5 | import { PackageGarbage, PackageGarbageContainer } from '../packageGarbageTypes.js'; 6 | import QueryRunner from '../../common/utils/queryRunner.js'; 7 | 8 | export class ApprovalProcessDefinition implements EntityDefinitionHandler { 9 | private readonly queryRunner: QueryRunner; 10 | 11 | public constructor(private readonly queryConnection: Connection) { 12 | this.queryRunner = new QueryRunner(this.queryConnection); 13 | } 14 | 15 | public async resolve(packageMembers: Package2Member[]): Promise { 16 | const garbageList: PackageGarbage[] = []; 17 | const defs = await this.fetchDefinitions(packageMembers); 18 | packageMembers.forEach((member) => { 19 | const approvalProcessDef = defs.get(member.SubjectId); 20 | if (approvalProcessDef) { 21 | // other entities have "01I" ids for custom objects, but approval processes have the 22 | // actual API name of the custom object (e.g. TestObject__c) 23 | garbageList.push( 24 | new PackageGarbage( 25 | member, 26 | approvalProcessDef.DeveloperName, 27 | `${approvalProcessDef.TableEnumOrId}.${approvalProcessDef.DeveloperName}` 28 | ) 29 | ); 30 | } 31 | }); 32 | return { 33 | metadataType: 'ApprovalProcess', 34 | componentCount: garbageList.length, 35 | components: garbageList, 36 | }; 37 | } 38 | 39 | private async fetchDefinitions(packageMembers: Package2Member[]): Promise> { 40 | const entitiesById = new Map(); 41 | const entityDefs = await this.queryRunner.fetchRecords( 42 | `SELECT Id,DeveloperName,TableEnumOrId FROM ProcessDefinition WHERE ${buildSubjectIdFilter( 43 | packageMembers 44 | )} AND Type = 'Approval'` 45 | ); 46 | entityDefs.forEach((ed) => entitiesById.set(ed.Id, ed)); 47 | return entitiesById; 48 | } 49 | } 50 | 51 | type ProcessDefinition = { 52 | Id: string; 53 | DeveloperName: string; 54 | TableEnumOrId: string; 55 | }; 56 | -------------------------------------------------------------------------------- /src/release-manifest/artifactDeploySfCommand.ts: -------------------------------------------------------------------------------- 1 | import { SfCommandConfig } from '../common/utils/sfCommandConfig.js'; 2 | 3 | export default class ArtifactDeploySfCommand { 4 | /** 5 | * Colon-separated oclif identifier, e.g. project:deploy:start or package:install 6 | */ 7 | public oclifIdentifier: string; 8 | 9 | /** 10 | * All parsed flags that will be applied when config is build. Flags are 11 | * unique by their identifier. 12 | */ 13 | public commandFlags: Map; 14 | 15 | public constructor(oclifIdentifier: string, flags?: SfCommandFlag[]) { 16 | this.oclifIdentifier = oclifIdentifier; 17 | this.commandFlags = new Map(); 18 | if (flags) { 19 | flags.forEach((conf) => { 20 | this.commandFlags.set(conf.name, conf.value); 21 | }); 22 | } 23 | } 24 | 25 | /** 26 | * Parses unstructured string input to internal flags dictionary. All 27 | * values are written into the internal state. 28 | * 29 | * @param flagsInput 30 | */ 31 | public parseFlags(flagsInput?: string): void { 32 | if (!flagsInput || flagsInput.length === 0) { 33 | return; 34 | } 35 | flagsInput 36 | .trim() 37 | .split(/\s+/) 38 | .forEach((flagIdentifier) => { 39 | const flagKeyValue = flagIdentifier.replace(/^-+/, '').split('='); 40 | if (flagKeyValue[0].length > 1) { 41 | this.commandFlags.set(flagKeyValue[0], flagKeyValue[1]); 42 | } 43 | }); 44 | } 45 | 46 | /** 47 | * Adds a flag config to the internal flags dictionary. 48 | * 49 | * @param identifier 50 | * @param value 51 | */ 52 | public addFlag(identifier: string, value?: string): void { 53 | if (identifier.length === 0) { 54 | throw Error('Flag identifier cannot be empty'); 55 | } 56 | this.commandFlags.set(identifier, value); 57 | } 58 | 59 | /** 60 | * Builds the command arguments that can be used to execute 61 | */ 62 | public buildConfig(): SfCommandConfig { 63 | const args = []; 64 | for (const [key, value] of this.commandFlags.entries()) { 65 | args.push(`--${key}`); 66 | if (value) { 67 | args.push(`${value}`); 68 | } 69 | } 70 | return { 71 | name: this.oclifIdentifier, 72 | args, 73 | }; 74 | } 75 | } 76 | 77 | export type SfCommandFlag = { 78 | name: string; 79 | value?: string; 80 | }; 81 | -------------------------------------------------------------------------------- /src/commands/jsc/maintain/flow-export/unused.ts: -------------------------------------------------------------------------------- 1 | import { SfCommand, Flags } from '@salesforce/sf-plugins-core'; 2 | import { Messages } from '@salesforce/core'; 3 | import { 4 | conciseFlowExportTable, 5 | manifestOutputDirFlag, 6 | outputFormatFlag, 7 | } from '../../../../common/jscSfCommandFlags.js'; 8 | import FlowExporter, { FlowClutter, summarize, writeFlowsToXml } from '../../../../common/flowExporter.js'; 9 | 10 | Messages.importMessagesDirectoryFromMetaUrl(import.meta.url); 11 | const messages = Messages.loadMessages('@j-schreiber/sf-plugin', 'jsc.maintain.flow-export.unused'); 12 | 13 | export type JscMaintainExportUnusedFlowsResult = { 14 | unusedVersions: FlowClutter[]; 15 | }; 16 | export default class JscMaintainExportUnusedFlows extends SfCommand { 17 | public static readonly summary = messages.getMessage('summary'); 18 | public static readonly description = messages.getMessage('description'); 19 | public static readonly examples = messages.getMessages('examples'); 20 | 21 | public static readonly flags = { 22 | 'target-org': Flags.requiredOrg({ 23 | summary: messages.getMessage('flags.target-org.summary'), 24 | char: 'o', 25 | required: true, 26 | }), 27 | 'output-dir': manifestOutputDirFlag, 28 | 'output-format': outputFormatFlag(), 29 | concise: conciseFlowExportTable, 30 | 'api-version': Flags.orgApiVersion(), 31 | }; 32 | 33 | public async run(): Promise { 34 | const { flags } = await this.parse(JscMaintainExportUnusedFlows); 35 | const exporter = new FlowExporter(flags['target-org'].getConnection(flags['api-version'])); 36 | const results = await exporter.exportUnusedFlows(); 37 | if (results.length === 0) { 38 | this.logSuccess(messages.getMessage('success.no-unused-flows-found')); 39 | } else { 40 | this.printResults(results, flags.concise); 41 | } 42 | if (flags['output-dir']) { 43 | this.info(`Writing output to: ${flags['output-dir']}`); 44 | writeFlowsToXml(results, flags['output-dir'], flags['output-format']); 45 | } 46 | return { unusedVersions: results }; 47 | } 48 | 49 | public printResults(clutter: FlowClutter[], concise?: boolean): void { 50 | if (concise) { 51 | this.table({ data: summarize(clutter) }); 52 | } else { 53 | this.table({ data: clutter }); 54 | } 55 | } 56 | } 57 | -------------------------------------------------------------------------------- /src/garbage-collection/packageGarbageTypes.ts: -------------------------------------------------------------------------------- 1 | import { Package2Member } from '../types/sfToolingApiTypes.js'; 2 | 3 | export class PackageGarbage { 4 | /** 5 | * Local id of the metadata entity. Prefix equals "keyPrefix". 6 | */ 7 | public subjectId: string; 8 | /** 9 | * Regular developer name without additional parent context 10 | */ 11 | public developerName: string; 12 | /** 13 | * Fully resolved name, including parent object (e.g. on custom fields, workflows, etc) 14 | */ 15 | public fullyQualifiedName: string; 16 | /** 17 | * Package version that deprecated the component (the first version to NOT contain it). 18 | * May be empty, if the component is obsolete for other reasons. 19 | */ 20 | public deprecatedSinceVersion?: string; 21 | /** 22 | * Name of the package that contains the component. 23 | */ 24 | public packageName?: string; 25 | /** 26 | * Globally unique subscriber package id (can be used to identify `Package2Id`) 27 | */ 28 | public subscriberPackageId: string; 29 | 30 | public constructor(packageMember: Package2Member, developerName: string, fullyQualifiedName?: string) { 31 | this.deprecatedSinceVersion = packageMember.MaxPackageVersion 32 | ? `${packageMember.MaxPackageVersion.MajorVersion}.${packageMember.MaxPackageVersion.MinorVersion}.${packageMember.MaxPackageVersion.PatchVersion}` 33 | : undefined; 34 | this.packageName = packageMember.SubscriberPackage?.Name; 35 | this.subscriberPackageId = packageMember.SubscriberPackageId; 36 | this.subjectId = packageMember.SubjectId; 37 | this.developerName = developerName; 38 | this.fullyQualifiedName = fullyQualifiedName ?? developerName; 39 | } 40 | } 41 | 42 | export type UnknownPackageGarbage = { 43 | subjectId: string; 44 | }; 45 | 46 | export type PackageGarbageContainer = { 47 | metadataType: string; 48 | componentCount: number; 49 | components: PackageGarbage[]; 50 | }; 51 | 52 | export type UnsupportedGarbageContainer = { 53 | keyPrefix: string; 54 | entityName?: string; 55 | componentCount: number; 56 | reason: string; 57 | }; 58 | 59 | export type PackageGarbageResult = { 60 | deprecatedMembers: { [x: string]: PackageGarbageContainer }; 61 | unsupported: UnsupportedGarbageContainer[]; 62 | totalDeprecatedComponentCount: number; 63 | }; 64 | 65 | export type GarbageFilter = { 66 | includeOnly?: string[]; 67 | packages?: string[]; 68 | }; 69 | -------------------------------------------------------------------------------- /src/commands/jsc/maintain/flow-export/obsolete.ts: -------------------------------------------------------------------------------- 1 | import { SfCommand, Flags } from '@salesforce/sf-plugins-core'; 2 | import { Messages } from '@salesforce/core'; 3 | import { 4 | conciseFlowExportTable, 5 | manifestOutputDirFlag, 6 | outputFormatFlag, 7 | } from '../../../../common/jscSfCommandFlags.js'; 8 | import FlowExporter, { FlowClutter, summarize, writeFlowsToXml } from '../../../../common/flowExporter.js'; 9 | 10 | Messages.importMessagesDirectoryFromMetaUrl(import.meta.url); 11 | const messages = Messages.loadMessages('@j-schreiber/sf-plugin', 'jsc.maintain.flow-export.obsolete'); 12 | 13 | export type JscMaintainExportObsoleteFlowsResult = { 14 | obsoleteVersions: FlowClutter[]; 15 | }; 16 | 17 | export default class JscMaintainExportObsoleteFlowVersions extends SfCommand { 18 | public static readonly summary = messages.getMessage('summary'); 19 | public static readonly description = messages.getMessage('description'); 20 | public static readonly examples = messages.getMessages('examples'); 21 | 22 | public static readonly flags = { 23 | 'target-org': Flags.requiredOrg({ 24 | summary: messages.getMessage('flags.target-org.summary'), 25 | char: 'o', 26 | required: true, 27 | }), 28 | 'output-dir': manifestOutputDirFlag, 29 | 'output-format': outputFormatFlag(), 30 | concise: conciseFlowExportTable, 31 | 'api-version': Flags.orgApiVersion(), 32 | }; 33 | 34 | public async run(): Promise { 35 | const { flags } = await this.parse(JscMaintainExportObsoleteFlowVersions); 36 | const exporter = new FlowExporter(flags['target-org'].getConnection(flags['api-version'])); 37 | const results = await exporter.exportObsoleteFlows(); 38 | if (results.length === 0) { 39 | this.logSuccess(messages.getMessage('success.no-obsolete-versions-found')); 40 | } else { 41 | this.printResults(results, flags.concise); 42 | } 43 | if (flags['output-dir']) { 44 | this.info(`Writing output to: ${flags['output-dir']}`); 45 | writeFlowsToXml(results, flags['output-dir'], flags['output-format']); 46 | } 47 | return { obsoleteVersions: results }; 48 | } 49 | 50 | public printResults(clutter: FlowClutter[], concise?: boolean): void { 51 | if (concise) { 52 | this.table({ data: summarize(clutter) }); 53 | } else { 54 | this.table({ data: clutter }); 55 | } 56 | } 57 | } 58 | -------------------------------------------------------------------------------- /messages/jsc.apex.schedule.start.md: -------------------------------------------------------------------------------- 1 | # summary 2 | 3 | Schedule a cron job on the target org. 4 | 5 | # description 6 | 7 | Provide the name of an apex class that implements the `Schedulable` interface and a cron expression to schedule a cron job (`CronTrigger`). Use the official Documentation to learn more about cron expressions: https://developer.salesforce.com/docs/atlas.en-us.apexcode.meta/apexcode/apex_scheduler.htm. 8 | 9 | # flags.target-org.summary 10 | 11 | Target org where the job will be scheduled. 12 | 13 | # flags.name.summary 14 | 15 | Unique name of the cron job. 16 | 17 | # flags.name.description 18 | 19 | If you leave this empty, the name of the apex class is used. Jobs must be unique by name: Use different names if you plan to schedule the same apex class multiple times. 20 | 21 | # flags.apex-class-name.summary 22 | 23 | Name of the Apex class to schedule. 24 | 25 | # flags.apex-class-name.description 26 | 27 | Must implement the System.Schedulable interface. 28 | 29 | # flags.cron-expression.summary 30 | 31 | The cron expression that specifies execution of the job. 32 | 33 | # flags.cron-expression.description 34 | 35 | Provide the expression in unix-compatible format (see Apex Documentation for more details). The basic syntax of the expression is "Seconds Minutes Hours Day_of_month Month Day_of_week Optional_year". See examples for commonly used cron expressions. 36 | 37 | # flags.trace.summary 38 | 39 | Log detailed debug information of command execution. 40 | 41 | # flags.trace.description 42 | 43 | Due to limitations of available Salesforce APIs, this command uses an anonymous apex execution under the hood. The execution may fail due to a variety of reasons, and the scheduler tries its best to extract the correct error messages. If this doesn't help, use the --trace flag to output full debug logs from the execution. 44 | 45 | # examples 46 | 47 | - Schedule a class to run every day at 1 am: 48 | 49 | <%= config.bin %> <%= command.id %> -c MyJobImplementationName -e '0 0 1 * * ?' 50 | 51 | - Schedule a class to run on weekdays (Monday to Friday) at 10 am: 52 | 53 | <%= config.bin %> <%= command.id %> -c MyJobImplementationName -e '0 0 10 ? * MON-FRI' 54 | 55 | - Schedule a job with a custom name to run every day at 5:30pm: 56 | 57 | <%= config.bin %> <%= command.id %> -c MyJobImplementationName -e "0 30 17 * * ?" -n "My Job Name" 58 | 59 | # info.success 60 | 61 | Successfully scheduled job with id: %s. First execution planned for: %s. 62 | -------------------------------------------------------------------------------- /test/mock-utils/garbageCollectionMocks.ts: -------------------------------------------------------------------------------- 1 | import fs from 'node:fs'; 2 | import path from 'node:path'; 3 | import { QueryResult, Record } from '@jsforce/jsforce-node'; 4 | import { 5 | EntityDefinition, 6 | FieldDefinition, 7 | FlowVersionDefinition, 8 | NamedRecord, 9 | Package2, 10 | Package2Member, 11 | SubscriberPackage, 12 | WorkflowAlertEntity, 13 | } from '../../src/types/sfToolingApiTypes.js'; 14 | import { PackageGarbageResult } from '../../src/garbage-collection/packageGarbageTypes.js'; 15 | 16 | const testDataPath = path.join('test', 'data', 'garbage-collection'); 17 | 18 | export default class GarbageCollectionMocks { 19 | public PACKAGE_2 = parseMockResult('package-2.json'); 20 | public PACKAGE_2_MEMBERS = parseMockResult('package-members/mixed.json'); 21 | public PACKAGED_FLOWS = parseMockResult('packaged-flows.json'); 22 | public OBSOLETE_FLOW_VERSIONS = parseMockResult('outdated-flow-versions.json'); 23 | public ENTITY_DEFINITIONS = parseMockResult('entity-definitions.json'); 24 | public FILTERED_ENTITY_DEFINITIONS = parseMockResult('filtered-entity-definitions.json'); 25 | public CUSTOM_LABELS = parseMockResult('custom-labels.json'); 26 | public CUSTOM_OBJECT_ENTITY_DEFS = parseMockResult('custom-object-entity-defs.json'); 27 | public ALL_CUSTOM_FIELDS = parseMockResult('all-custom-fields.json'); 28 | public ALL_QUICK_ACTIONS = parseMockResult('all-quick-actions.json'); 29 | public ALL_LAYOUTS = parseMockResult('layouts.json'); 30 | public M00_CMDS = parseMockResult('cmd-m00-records.json'); 31 | public M01_CMDS = parseMockResult('cmd-m01-records.json'); 32 | public WORKFLOW_ALERTS = parseMockResult('workflow-alert-definitions.json'); 33 | public WORKFLOW_FIELD_UPDATES = parseMockResult('workflow-field-update-defs.json'); 34 | public SUBSCRIBER_PACKAGE = parseMockResult('subscriber-package.json'); 35 | } 36 | 37 | export const EXPECTED_E2E_GARBAGE = JSON.parse( 38 | fs.readFileSync(path.join('test', 'data', 'garbage-collection', 'expected-garbage-NUTs.json'), 'utf8') 39 | ) as PackageGarbageResult; 40 | 41 | export function parseMockResult(filePath: string) { 42 | return JSON.parse(fs.readFileSync(`${path.join(testDataPath, filePath)}`, 'utf8')) as QueryResult; 43 | } 44 | -------------------------------------------------------------------------------- /test/data/describes/mockDescribeResults.ts: -------------------------------------------------------------------------------- 1 | import { DescribeSObjectResult, Field } from '@jsforce/jsforce-node'; 2 | 3 | export const MockAnyObjectResult: Partial = { 4 | custom: true, 5 | createable: true, 6 | name: 'AnyObject', 7 | fields: [ 8 | { name: 'Id', type: 'id' } as Field, 9 | { name: 'Name', type: 'string' } as Field, 10 | { name: 'AccountId', type: 'reference' } as Field, 11 | ], 12 | urls: { 13 | sobject: '/services/data/v60.0/sobjects/AnyObject', 14 | }, 15 | }; 16 | 17 | export const MockAccountDescribeResult: Partial = { 18 | custom: false, 19 | createable: true, 20 | name: 'Account', 21 | fields: [ 22 | { name: 'Id', type: 'id' } as Field, 23 | { name: 'Name', type: 'string' } as Field, 24 | { name: 'AccountNumber', type: 'string' } as Field, 25 | { name: 'CreatedDate', type: 'datetime' } as Field, 26 | { name: 'BillingStreet', type: 'textarea' } as Field, 27 | ], 28 | urls: { 29 | sobject: '/services/data/v60.0/sobjects/Account', 30 | }, 31 | }; 32 | 33 | export const MockOrderDescribeResult: Partial = { 34 | custom: false, 35 | createable: true, 36 | name: 'Order', 37 | fields: [ 38 | { name: 'Id', type: 'id' } as Field, 39 | { name: 'OrderNumber' } as Field, 40 | { name: 'AccountId', type: 'reference' } as Field, 41 | { name: 'BillToContactId', type: 'reference' } as Field, 42 | ], 43 | urls: { 44 | sobject: '/services/data/v60.0/sobjects/Order', 45 | }, 46 | }; 47 | 48 | export const MockPackageMemberDescribeResult: Partial = { 49 | custom: false, 50 | createable: false, 51 | name: 'Package2Member', 52 | fields: [{ name: 'Id', type: 'id' } as Field, { name: 'SubjectId' } as Field], 53 | urls: { 54 | sobject: '/services/data/v60.0/tooling/sobjects/Package2Member', 55 | }, 56 | }; 57 | 58 | // eslint-disable-next-line @typescript-eslint/no-unused-vars 59 | export const mockDescribeResults = (objectName: string, isToolingObject?: boolean): Promise => { 60 | switch (objectName) { 61 | case 'Account': 62 | return Promise.resolve(MockAccountDescribeResult as DescribeSObjectResult); 63 | case 'Order': 64 | return Promise.resolve(MockOrderDescribeResult as DescribeSObjectResult); 65 | case 'Package2Member': 66 | return Promise.resolve(MockPackageMemberDescribeResult as DescribeSObjectResult); 67 | default: 68 | return Promise.resolve(MockAnyObjectResult as DescribeSObjectResult); 69 | } 70 | }; 71 | -------------------------------------------------------------------------------- /messages/jsc.apex.schedule.stop.md: -------------------------------------------------------------------------------- 1 | # summary 2 | 3 | Stop one or more cron jobs on the target org. 4 | 5 | # description 6 | 7 | The command allows to stop one or more scheduled jobs, based on the provided inputs. You can provide the name of an apex class, the name of a scheduled job or a list of ids (08e). The command is idempotent: That means it succeeds, even if no job was actually stopped. If you provide multiple filters (e.g. an apex class and an id), all jobs that satisfy at least one of the criteria are stopped. 8 | 9 | # flags.target-org.summary 10 | 11 | Target org where the job will stopped. 12 | 13 | # flags.name.summary 14 | 15 | Identify the scheduled job by its provided name. 16 | 17 | # flags.apex-class-name.summary 18 | 19 | Name of an apex class to stop. 20 | 21 | # flags.apex-class-name.description 22 | 23 | The command finds all scheduled instances of this apex class and stops them. 24 | 25 | # flags.id.summary 26 | 27 | The CronTrigger Id of the job to stop. 28 | 29 | # flags.id.description 30 | 31 | Provide the Id of the cron trigger that was returned by `System.schedule`. If the Id is invalid, an error is returned. You can add this flag multiple times to specify multiple jobs. 32 | 33 | # flags.trace.summary 34 | 35 | Log detailed debug information of command execution. 36 | 37 | # flags.trace.description 38 | 39 | Due to limitations of available Salesforce APIs, this command uses an anonymous apex execution under the hood. The execution may fail due to a variety of reasons, and the scheduler tries its best to extract the correct error messages. If this doesn't help, use the --trace flag to output full debug logs from the execution. 40 | 41 | # flags.no-prompt.summary 42 | 43 | Don't prompt before performing destructive changes. 44 | 45 | # flags.no-prompt.description 46 | 47 | Without this flag, the command asks for confirmation before stopping them. Use this flag in CI pipelines. 48 | 49 | # examples 50 | 51 | - Stop all scheduled jobs on a target org 52 | 53 | <%= config.bin %> <%= command.id %> -o MyTargetOrg 54 | 55 | - Stop a job by its id on your default org 56 | 57 | <%= config.bin %> <%= command.id %> -i 08e9b00000KktvqAAB 58 | 59 | - Stop all scheduled jobs of a particular apex class on a target org 60 | 61 | <%= config.bin %> <%= command.id %> -c MyCaseReminderJob -o MyTargetOrg 62 | 63 | # allJobsStopped 64 | 65 | Successfully stopped %s jobs. 66 | 67 | # confirmJobDeletion 68 | 69 | You are about to stop %s jobs. Please confirm this is what you want. 70 | 71 | # abortCommand 72 | 73 | Aborted by user. No jobs were stopped. 74 | -------------------------------------------------------------------------------- /src/release-manifest/releaseManifestLoader.ts: -------------------------------------------------------------------------------- 1 | import fs from 'node:fs'; 2 | import { Messages, SfError } from '@salesforce/core'; 3 | import { ZReleaseManifest, ZReleaseManifestType } from '../types/orgManifestInputSchema.js'; 4 | import { parseYaml, pathHasNoFiles } from '../common/utils/fileUtils.js'; 5 | import OrgManifest from './OrgManifest.js'; 6 | 7 | Messages.importMessagesDirectoryFromMetaUrl(import.meta.url); 8 | const messages = Messages.loadMessages('@j-schreiber/sf-plugin', 'orgmanifest'); 9 | 10 | export default class ReleaseManifestLoader { 11 | public static load(filePath: string): OrgManifest { 12 | const manifestType = parseYaml(filePath, ZReleaseManifest); 13 | ReleaseManifestLoader.parseArtifactPaths(manifestType); 14 | return new OrgManifest(manifestType); 15 | } 16 | 17 | private static parseArtifactPaths(manifestType: ZReleaseManifestType): void { 18 | for (const [artifactName, artifact] of Object.entries(manifestType.artifacts)) { 19 | if (artifact.type === 'Unpackaged') { 20 | if (!artifact.path) { 21 | continue; 22 | } 23 | if (typeof artifact.path != 'string') { 24 | ReleaseManifestLoader.assertPathEnvironmentMapping(artifact.path, manifestType.environments!, artifactName); 25 | Object.values(artifact.path).forEach((value) => { 26 | ReleaseManifestLoader.assertPathExists(value, artifactName); 27 | }); 28 | } else if (typeof artifact.path === 'string') { 29 | ReleaseManifestLoader.assertPathExists(artifact.path, artifactName); 30 | } 31 | } 32 | } 33 | } 34 | 35 | private static assertPathEnvironmentMapping( 36 | pathsObject: Record, 37 | envs: Record, 38 | artifactName: string 39 | ): void { 40 | Object.keys(pathsObject).forEach((env) => { 41 | if (envs && !(env in envs)) { 42 | throw new SfError( 43 | `Error parsing artifact "${artifactName}": "${env}" is not defined in environments.`, 44 | 'UnknownEnvironmentMapped' 45 | ); 46 | } 47 | }); 48 | } 49 | 50 | private static assertPathExists(path: string, artifactName: string): void { 51 | if (!fs.existsSync(path)) { 52 | throw new SfError(`Error parsing artifact "${artifactName}": ${path} does not exist.`, 'NoOrEmptySourcePath'); 53 | } 54 | if (pathHasNoFiles(path)) { 55 | throw new SfError( 56 | messages.getMessage('errors.source-path-is-empty', [artifactName, path]), 57 | 'NoOrEmptySourcePath' 58 | ); 59 | } 60 | } 61 | } 62 | -------------------------------------------------------------------------------- /test/common/describeApi.test.ts: -------------------------------------------------------------------------------- 1 | import fs from 'node:fs'; 2 | import { isString, type AnyJson } from '@salesforce/ts-types'; 3 | import { TestContext, MockTestOrgData } from '@salesforce/core/testSetup'; 4 | import { expect } from 'chai'; 5 | import { stubSfCommandUx } from '@salesforce/sf-plugins-core'; 6 | import DescribeApi from '../../src/common/metadata/describeApi.js'; 7 | import { LOCAL_CACHE_DIR } from '../../src/common/constants.js'; 8 | 9 | const testUsername = 'describe-api-test@lietzau-consulting.de'; 10 | const expectedAccountDescribe = JSON.parse(fs.readFileSync('test/data/describes/Account.json', 'utf8')) as AnyJson; 11 | const cacheDir = `./${LOCAL_CACHE_DIR}/${testUsername}/describes`; 12 | 13 | describe('describe api', () => { 14 | const $$ = new TestContext(); 15 | const testOrg = new MockTestOrgData(); 16 | 17 | beforeEach(async () => { 18 | testOrg.username = testUsername; 19 | await $$.stubAuths(testOrg); 20 | stubSfCommandUx($$.SANDBOX); 21 | }); 22 | 23 | afterEach(async () => { 24 | $$.SANDBOX.restore(); 25 | fs.rmSync(`./${LOCAL_CACHE_DIR}/${testUsername}`, { recursive: true, force: true }); 26 | }); 27 | 28 | it('no describe results cached for sobject > calls sobject describe', async () => { 29 | $$.fakeConnectionRequest = (request: AnyJson): Promise => { 30 | // mock org uses v42.0 and this cannot be overwritten 31 | if (isString(request) && request.includes('/services/data/v42.0/sobjects/Account/describe')) { 32 | return Promise.resolve(expectedAccountDescribe); 33 | } 34 | return Promise.resolve({}); 35 | }; 36 | 37 | // Act 38 | const descApi: DescribeApi = new DescribeApi(await testOrg.getConnection()); 39 | const result = await descApi.describeSObject('Account'); 40 | 41 | // Assert 42 | expect(result).to.deep.equal(expectedAccountDescribe); 43 | }); 44 | 45 | it('describe result cached for sobject > reads file from cache', async () => { 46 | // Arrange 47 | const mockOrderDescribeResult = { actionOverrides: [], fields: [] }; 48 | // return empty result for any request that is performed 49 | $$.fakeConnectionRequest = (): Promise => Promise.resolve({}); 50 | fs.mkdirSync(`${cacheDir}`, { recursive: true }); 51 | fs.writeFileSync(`${cacheDir}/Order.json`, JSON.stringify(mockOrderDescribeResult)); 52 | 53 | // Act 54 | const descApi: DescribeApi = new DescribeApi(await testOrg.getConnection()); 55 | const result = await descApi.describeSObject('Order'); 56 | 57 | // Assert 58 | expect(result).to.deep.equal(mockOrderDescribeResult); 59 | }); 60 | }); 61 | -------------------------------------------------------------------------------- /test/common/toolingApiConnection.test.ts: -------------------------------------------------------------------------------- 1 | import { expect } from 'chai'; 2 | import { AnyJson } from '@salesforce/ts-types'; 3 | import { TestContext, MockTestOrgData } from '@salesforce/core/testSetup'; 4 | import { SubscriberPackage } from '../../src/types/sfToolingApiTypes.js'; 5 | import ToolingApiConnection from '../../src/garbage-collection/toolingApiConnection.js'; 6 | import { parseFileAsQueryResult } from '../mock-utils/sfQueryApiMocks.js'; 7 | 8 | const TEST_FILES_ROOT = ['test', 'data', 'query-results']; 9 | 10 | describe('tooling API connection', () => { 11 | const $$ = new TestContext(); 12 | const testOrg = new MockTestOrgData(); 13 | let testConnection: ToolingApiConnection; 14 | 15 | const SUBSCRIBER_PACKAGE = parseFileAsQueryResult([...TEST_FILES_ROOT, 'subscriber-package.json']); 16 | const EMPTY_QUERY_RESULT = parseFileAsQueryResult([...TEST_FILES_ROOT, 'empty-result.json']); 17 | 18 | beforeEach(async () => { 19 | testOrg.isDevHub = false; 20 | await $$.stubAuths(testOrg); 21 | $$.fakeConnectionRequest = mockQueryResults; 22 | // getInstance caches and shares the same instance accross test classes 23 | testConnection = new ToolingApiConnection(await testOrg.getConnection()); 24 | }); 25 | 26 | afterEach(() => { 27 | $$.SANDBOX.restore(); 28 | }); 29 | 30 | function mockQueryResults(request: AnyJson): Promise { 31 | const url = (request as { url: string }).url; 32 | if (url.includes(encodeURIComponent("FROM SubscriberPackage WHERE Id = '0336f000000G8roAAC'"))) { 33 | return Promise.resolve(SUBSCRIBER_PACKAGE); 34 | } 35 | if (url.includes(encodeURIComponent("FROM SubscriberPackage WHERE Id = '0330X0000000000AAA'"))) { 36 | return Promise.resolve(EMPTY_QUERY_RESULT); 37 | } 38 | throw new Error(`Request not mocked: ${JSON.stringify(request)}`); 39 | } 40 | 41 | it('queries subscriber package when resolving a package id', async () => { 42 | // Act 43 | const actualSubPackage = await testConnection.resolveSubscriberPackageId('0336f000000G8roAAC'); 44 | 45 | // Assert 46 | expect(actualSubPackage).not.to.be.undefined; 47 | expect(actualSubPackage!.Id).to.equal('0336f000000G8roAAC'); 48 | expect(actualSubPackage!.Name).to.equal('JS Apex Utils'); 49 | }); 50 | 51 | it('returns undefined if queried with an invalid or unknown subscriber package id', async () => { 52 | // Act 53 | const actualSubPackage = await testConnection.resolveSubscriberPackageId('0330X0000000000AAA'); 54 | 55 | // Assert 56 | expect(actualSubPackage).to.be.undefined; 57 | }); 58 | }); 59 | -------------------------------------------------------------------------------- /src/garbage-collection/entity-handlers/layout.ts: -------------------------------------------------------------------------------- 1 | /* eslint-disable no-console */ 2 | import { Connection } from '@salesforce/core'; 3 | import { NamedSObjectChildType, Package2Member } from '../../types/sfToolingApiTypes.js'; 4 | import { EntityDefinitionHandler, extractSubjectIds } from '../entityDefinitionHandler.js'; 5 | import { PackageGarbage, PackageGarbageContainer } from '../packageGarbageTypes.js'; 6 | import ToolingApiConnection from '../toolingApiConnection.js'; 7 | import QueryRunner from '../../common/utils/queryRunner.js'; 8 | import QueryBuilder from '../../common/utils/queryBuilder.js'; 9 | 10 | export class Layout implements EntityDefinitionHandler { 11 | private readonly queryRunner: QueryRunner; 12 | private readonly apiConnection: ToolingApiConnection; 13 | 14 | public constructor(private readonly queryConnection: Connection) { 15 | this.apiConnection = ToolingApiConnection.getInstance(this.queryConnection); 16 | this.queryRunner = new QueryRunner(this.queryConnection.tooling); 17 | } 18 | 19 | public async resolve(packageMembers: Package2Member[]): Promise { 20 | const garbageList: PackageGarbage[] = []; 21 | const objectsByDurableId = await this.apiConnection.fetchObjectDefinitionsByDurableId(); 22 | const definitions = await this.fetchDefinitions(extractSubjectIds(packageMembers)); 23 | packageMembers.forEach((member) => { 24 | const def = definitions.get(member.SubjectId); 25 | if (def) { 26 | // custom object 27 | if (def.TableEnumOrId.startsWith('01I')) { 28 | const customObjDef = objectsByDurableId.get(def.TableEnumOrId.substring(0, 15)); 29 | if (customObjDef) { 30 | garbageList.push(new PackageGarbage(member, def.Name, `${customObjDef.QualifiedApiName}-${def.Name}`)); 31 | } 32 | } else { 33 | // standard object 34 | garbageList.push(new PackageGarbage(member, def.Name, `${def.TableEnumOrId}-${def.Name}`)); 35 | } 36 | } 37 | }); 38 | return { metadataType: 'Layout', componentCount: garbageList.length, components: garbageList }; 39 | } 40 | 41 | private async fetchDefinitions(subjectIds: string[]): Promise> { 42 | const childDefsMap = new Map(); 43 | const entities = await this.queryRunner.fetchRecords( 44 | `SELECT Id,Name,TableEnumOrId FROM Layout WHERE ${QueryBuilder.buildParamListFilter('Id', subjectIds)}` 45 | ); 46 | entities.forEach((childDef) => { 47 | childDefsMap.set(childDef.Id, childDef); 48 | }); 49 | return childDefsMap; 50 | } 51 | } 52 | -------------------------------------------------------------------------------- /test/mock-utils/flowExportTestContext.ts: -------------------------------------------------------------------------------- 1 | import fs from 'node:fs'; 2 | import path from 'node:path'; 3 | import { Record } from '@jsforce/jsforce-node'; 4 | import { AnyJson } from '@salesforce/ts-types'; 5 | import { MockTestOrgData, TestContext } from '@salesforce/core/testSetup'; 6 | import { XMLParser } from 'fast-xml-parser'; 7 | import { FlowVersionDefinition } from '../../src/types/sfToolingApiTypes.js'; 8 | import { OBSOLETE_FLOW_VERSIONS, UNUSED_FLOW_VERSIONS } from '../../src/common/flowExporter.js'; 9 | import { PackageManifest } from '../../src/common/packageManifestBuilder.js'; 10 | 11 | export default class FlowExportTestContext { 12 | public coreContext: TestContext; 13 | public testTargetOrg: MockTestOrgData; 14 | public unusedFlows: FlowVersionDefinition[] = []; 15 | public obsoleteFlows: FlowVersionDefinition[] = []; 16 | public outputDir = path.join('tmp', 'flow-export'); 17 | 18 | public constructor() { 19 | this.coreContext = new TestContext(); 20 | this.testTargetOrg = new MockTestOrgData(); 21 | } 22 | 23 | public async init() { 24 | this.coreContext.fakeConnectionRequest = this.mockQueryResults; 25 | } 26 | 27 | public restore() { 28 | this.coreContext.restore(); 29 | this.unusedFlows = []; 30 | this.obsoleteFlows = []; 31 | fs.rmSync(this.outputDir, { recursive: true, force: true }); 32 | } 33 | 34 | public readonly mockQueryResults = (request: AnyJson): Promise => { 35 | const url = (request as { url: string }).url; 36 | if (url.includes(encodeURIComponent(OBSOLETE_FLOW_VERSIONS))) { 37 | return Promise.resolve({ done: true, records: this.obsoleteFlows, totalRecords: this.obsoleteFlows.length }); 38 | } 39 | if (url.includes(encodeURIComponent(UNUSED_FLOW_VERSIONS))) { 40 | return Promise.resolve({ done: true, records: this.unusedFlows, totalRecords: this.unusedFlows.length }); 41 | } 42 | return Promise.reject(new Error(`No mock was defined for: ${JSON.stringify(request)}`)); 43 | }; 44 | } 45 | 46 | /** 47 | * Expects a JSON file with a list of records - NOT a QueryResult (as other mock results) 48 | * 49 | * @param filePath 50 | * @returns 51 | */ 52 | export function parseMockResult(filePath: string): T[] { 53 | return JSON.parse(fs.readFileSync(path.join('test', 'data', 'flow-export', filePath), 'utf8')) as T[]; 54 | } 55 | 56 | export function parsePackageXml(filePath: string): PackageManifest { 57 | const xmlContents = fs.readFileSync(filePath, 'utf-8'); 58 | // options "isArray" ensures, that "types" is always parsed as list, 59 | // even if only one entry exists 60 | return new XMLParser({ isArray: (name, jpath) => jpath === 'Package.types' }).parse(xmlContents) as PackageManifest; 61 | } 62 | -------------------------------------------------------------------------------- /src/garbage-collection/entity-handlers/customMetadataRecord.ts: -------------------------------------------------------------------------------- 1 | /* eslint-disable no-console */ 2 | import { Connection } from '@salesforce/core'; 3 | import { DeveloperNamedRecord, Package2Member } from '../../types/sfToolingApiTypes.js'; 4 | import { buildSubjectIdFilter, EntityDefinitionHandler } from '../entityDefinitionHandler.js'; 5 | import { PackageGarbage, PackageGarbageContainer } from '../packageGarbageTypes.js'; 6 | import QueryRunner from '../../common/utils/queryRunner.js'; 7 | import ToolingApiConnection from '../toolingApiConnection.js'; 8 | 9 | export class CustomMetadataRecord implements EntityDefinitionHandler { 10 | private readonly queryRunner: QueryRunner; 11 | private readonly apiConnection: ToolingApiConnection; 12 | 13 | public constructor(private readonly queryConnection: Connection) { 14 | this.queryRunner = new QueryRunner(this.queryConnection); 15 | this.apiConnection = ToolingApiConnection.getInstance(this.queryConnection); 16 | } 17 | 18 | public async resolve(packageMembers: Package2Member[]): Promise { 19 | const garbageList: PackageGarbage[] = []; 20 | // members are already sorted - all have the same prefix 21 | if (packageMembers.length > 0) { 22 | const prefix = packageMembers[0].SubjectKeyPrefix; 23 | const customMetadataObjectDefs = await this.apiConnection.fetchObjectDefinitionsByKeyPrefix(); 24 | const metadataObject = customMetadataObjectDefs.get(prefix); 25 | if (metadataObject === undefined) { 26 | return { 27 | metadataType: 'CustomMetadata', 28 | componentCount: garbageList.length, 29 | components: garbageList, 30 | }; 31 | } 32 | const records = await this.fetchRecords(packageMembers, metadataObject.QualifiedApiName); 33 | packageMembers.forEach((member) => { 34 | const record = records.get(member.SubjectId); 35 | if (record !== undefined) { 36 | garbageList.push( 37 | new PackageGarbage(member, record.DeveloperName, `${metadataObject.DeveloperName}.${record.DeveloperName}`) 38 | ); 39 | } 40 | }); 41 | } 42 | return { 43 | metadataType: 'CustomMetadata', 44 | componentCount: garbageList.length, 45 | components: garbageList, 46 | }; 47 | } 48 | 49 | private async fetchRecords( 50 | packageMembers: Package2Member[], 51 | objectName: string 52 | ): Promise> { 53 | const result = new Map(); 54 | const entityDefs = await this.queryRunner.fetchRecords( 55 | `SELECT Id,DeveloperName FROM ${objectName} WHERE ${buildSubjectIdFilter(packageMembers)}` 56 | ); 57 | entityDefs.forEach((def) => { 58 | result.set(def.Id, def); 59 | }); 60 | return result; 61 | } 62 | } 63 | -------------------------------------------------------------------------------- /test/data/flow-export/unused-flows.json: -------------------------------------------------------------------------------- 1 | [ 2 | { 3 | "attributes": { 4 | "type": "Flow", 5 | "url": "/services/data/v64.0/tooling/sobjects/Flow/3013X000000rwdpQAA" 6 | }, 7 | "Id": "3013X000000rwdpQAA", 8 | "DefinitionId": "3003X000000BMf8QAG", 9 | "Definition": { 10 | "attributes": { 11 | "type": "FlowDefinition", 12 | "url": "/services/data/v64.0/tooling/sobjects/FlowDefinition/3003X000000BMf8QAG" 13 | }, 14 | "DeveloperName": "Test_Flow_1" 15 | }, 16 | "Status": "Obsolete", 17 | "ProcessType": "AutoLaunchedFlow", 18 | "VersionNumber": 2 19 | }, 20 | { 21 | "attributes": { 22 | "type": "Flow", 23 | "url": "/services/data/v64.0/tooling/sobjects/Flow/301Fg00000YpIgtIAF" 24 | }, 25 | "Id": "301Fg00000YpIgtIAF", 26 | "DefinitionId": "3003X000000BMf8QAG", 27 | "Definition": { 28 | "attributes": { 29 | "type": "FlowDefinition", 30 | "url": "/services/data/v64.0/tooling/sobjects/FlowDefinition/3003X000000BMf8QAG" 31 | }, 32 | "DeveloperName": "Test_Flow_1" 33 | }, 34 | "Status": "Draft", 35 | "ProcessType": "AutoLaunchedFlow", 36 | "VersionNumber": 3 37 | }, 38 | { 39 | "attributes": { 40 | "type": "Flow", 41 | "url": "/services/data/v64.0/tooling/sobjects/Flow/3013X000000f03KQAQ" 42 | }, 43 | "Id": "3013X000000f03KQAQ", 44 | "DefinitionId": "3003X000000gO19QAE", 45 | "Definition": { 46 | "attributes": { 47 | "type": "FlowDefinition", 48 | "url": "/services/data/v64.0/tooling/sobjects/FlowDefinition/3003X000000gO19QAE" 49 | }, 50 | "DeveloperName": "Test_Flow_2" 51 | }, 52 | "Status": "Obsolete", 53 | "ProcessType": "Workflow", 54 | "VersionNumber": 6 55 | }, 56 | { 57 | "attributes": { 58 | "type": "Flow", 59 | "url": "/services/data/v64.0/tooling/sobjects/Flow/3013X000000wldBQAQ" 60 | }, 61 | "Id": "3013X000000wldBQAQ", 62 | "DefinitionId": "3003X000000HdV3QAK", 63 | "Definition": { 64 | "attributes": { 65 | "type": "FlowDefinition", 66 | "url": "/services/data/v64.0/tooling/sobjects/FlowDefinition/3003X000000HdV3QAK" 67 | }, 68 | "DeveloperName": "Test_Flow_3" 69 | }, 70 | "Status": "Obsolete", 71 | "ProcessType": "AutoLaunchedFlow", 72 | "VersionNumber": 1 73 | }, 74 | { 75 | "attributes": { 76 | "type": "Flow", 77 | "url": "/services/data/v64.0/tooling/sobjects/Flow/3013X000000wldGQAQ" 78 | }, 79 | "Id": "3013X000000wldGQAQ", 80 | "DefinitionId": "3003X000000HdV3QAK", 81 | "Definition": { 82 | "attributes": { 83 | "type": "FlowDefinition", 84 | "url": "/services/data/v64.0/tooling/sobjects/FlowDefinition/3003X000000HdV3QAK" 85 | }, 86 | "DeveloperName": "Test_Flow_3" 87 | }, 88 | "Status": "Obsolete", 89 | "ProcessType": "AutoLaunchedFlow", 90 | "VersionNumber": 2 91 | } 92 | ] 93 | -------------------------------------------------------------------------------- /src/commands/jsc/apex/schedule/start.ts: -------------------------------------------------------------------------------- 1 | import { SfCommand, Flags } from '@salesforce/sf-plugins-core'; 2 | import { Messages } from '@salesforce/core'; 3 | import ApexScheduleService from '../../../../common/apex-scheduler/apexScheduleService.js'; 4 | import { CommandStatusEvent } from '../../../../common/comms/processingEvents.js'; 5 | 6 | Messages.importMessagesDirectoryFromMetaUrl(import.meta.url); 7 | const messages = Messages.loadMessages('@j-schreiber/sf-plugin', 'jsc.apex.schedule.start'); 8 | 9 | export type JscApexScheduleStartResult = { 10 | jobId: string; 11 | nextFireTime: Date; 12 | }; 13 | 14 | export default class JscApexScheduleStart extends SfCommand { 15 | public static readonly summary = messages.getMessage('summary'); 16 | public static readonly description = messages.getMessage('description'); 17 | public static readonly examples = messages.getMessages('examples'); 18 | 19 | public static readonly flags = { 20 | 'target-org': Flags.requiredOrg({ 21 | summary: messages.getMessage('flags.target-org.summary'), 22 | char: 'o', 23 | required: true, 24 | }), 25 | 'apex-class-name': Flags.string({ 26 | required: true, 27 | char: 'c', 28 | summary: messages.getMessage('flags.apex-class-name.summary'), 29 | description: messages.getMessage('flags.apex-class-name.description'), 30 | }), 31 | 'cron-expression': Flags.string({ 32 | required: true, 33 | char: 'e', 34 | summary: messages.getMessage('flags.cron-expression.summary'), 35 | description: messages.getMessage('flags.cron-expression.description'), 36 | }), 37 | name: Flags.string({ 38 | char: 'n', 39 | summary: messages.getMessage('flags.name.summary'), 40 | description: messages.getMessage('flags.name.description'), 41 | }), 42 | trace: Flags.boolean({ 43 | summary: messages.getMessage('flags.trace.summary'), 44 | description: messages.getMessage('flags.trace.description'), 45 | }), 46 | }; 47 | 48 | public async run(): Promise { 49 | const { flags } = await this.parse(JscApexScheduleStart); 50 | const scheduleService = new ApexScheduleService(flags['target-org'].getConnection('62.0')); 51 | if (flags.trace) { 52 | scheduleService.on('logOutput', (payload: CommandStatusEvent) => { 53 | this.info(payload.message ?? 'No logs received. Nothing to trace.'); 54 | }); 55 | scheduleService.on('diagnostics', (payload: CommandStatusEvent) => { 56 | this.info(payload.message ?? 'No diagnostics received. Nothing to trace.'); 57 | }); 58 | } 59 | const result = await scheduleService.scheduleJob({ 60 | apexClassName: flags['apex-class-name'], 61 | cronExpression: flags['cron-expression'], 62 | jobName: flags.name, 63 | }); 64 | this.logSuccess(messages.getMessage('info.success', [result.jobId, result.nextFireTime.toUTCString()])); 65 | return result; 66 | } 67 | } 68 | -------------------------------------------------------------------------------- /src/garbage-collection/entity-handlers/index.ts: -------------------------------------------------------------------------------- 1 | import { Connection } from '@salesforce/core'; 2 | import { EntityDefinitionHandler } from '../entityDefinitionHandler.js'; 3 | import { CustomObject } from './customObject.js'; 4 | import { DeveloperNameEntity } from './developerNameEntity.js'; 5 | import { CustomField } from './customField.js'; 6 | import { NameEntity } from './nameEntity.js'; 7 | import { Layout } from './layout.js'; 8 | import { CustomMetadataRecord } from './customMetadataRecord.js'; 9 | import { OutdatedFlowVersions } from './outdatedFlowVersions.js'; 10 | import { FullNameSingleRecord } from './fullNameSingleRecord.js'; 11 | import { DynamicDevNamedEntityRelated } from './dynamicDevNamedEntityRelated.js'; 12 | import { SObjectBasedDefNameEntity } from './sobjectBasedDevNameEntity.js'; 13 | import { ApprovalProcessDefinition } from './approvalProcessDefinition.js'; 14 | 15 | // eslint-disable-next-line arrow-body-style 16 | export const loadSupportedMetadataTypes = (orgConnection: Connection): { [x: string]: EntityDefinitionHandler } => { 17 | return { 18 | ExternalString: new NameEntity(orgConnection.tooling, 'ExternalString', 'CustomLabel'), 19 | ApexClass: new NameEntity(orgConnection.tooling, 'ApexClass'), 20 | BusinessProcess: new NameEntity(orgConnection.tooling, 'BusinessProcess'), 21 | AuraDefinitionBundle: new DeveloperNameEntity(orgConnection.tooling, 'AuraDefinitionBundle'), 22 | FlowDefinition: new OutdatedFlowVersions(orgConnection), 23 | LightningComponentBundle: new DeveloperNameEntity(orgConnection.tooling, 'LightningComponentBundle'), 24 | FlexiPage: new DeveloperNameEntity(orgConnection.tooling, 'FlexiPage'), 25 | CustomMetadataRecord: new CustomMetadataRecord(orgConnection), 26 | Layout: new Layout(orgConnection), 27 | CustomObject: new CustomObject(orgConnection), 28 | CustomField: new CustomField(orgConnection), 29 | QuickActionDefinition: new SObjectBasedDefNameEntity(orgConnection, 'QuickActionDefinition', 'QuickAction'), 30 | WorkflowAlert: new DynamicDevNamedEntityRelated(orgConnection, 'WorkflowAlert', 'DeveloperName'), 31 | WorkflowFieldUpdate: new FullNameSingleRecord(orgConnection.tooling, 'WorkflowFieldUpdate'), 32 | StaticResource: new NameEntity(orgConnection.tooling, 'StaticResource'), 33 | CustomTab: new FullNameSingleRecord(orgConnection.tooling, 'CustomTab'), 34 | PermissionSet: new NameEntity(orgConnection.tooling, 'PermissionSet'), 35 | ValidationRule: new DynamicDevNamedEntityRelated(orgConnection, 'ValidationRule', 'ValidationName'), 36 | EmailTemplate: new FullNameSingleRecord(orgConnection.tooling, 'EmailTemplate'), 37 | CompactLayout: new SObjectBasedDefNameEntity(orgConnection, 'CompactLayout'), 38 | GlobalValueSet: new FullNameSingleRecord(orgConnection.tooling, 'GlobalValueSet'), 39 | FieldSet: new DynamicDevNamedEntityRelated(orgConnection, 'FieldSet', 'DeveloperName'), 40 | CustomApplication: new DeveloperNameEntity(orgConnection.tooling, 'CustomApplication'), 41 | ProcessDefinition: new ApprovalProcessDefinition(orgConnection), 42 | }; 43 | }; 44 | -------------------------------------------------------------------------------- /src/common/metadata/toolingApiHelper.ts: -------------------------------------------------------------------------------- 1 | import { Connection, Messages, SfError } from '@salesforce/core'; 2 | import { Package2Version, SubscriberPackageVersion } from '../../types/sfToolingApiTypes.js'; 3 | 4 | Messages.importMessagesDirectoryFromMetaUrl(import.meta.url); 5 | const messages = Messages.loadMessages('@j-schreiber/sf-plugin', 'orgmanifest'); 6 | 7 | export async function resolvePackageVersionId( 8 | packageVersionLiteral: string, 9 | packageId: string, 10 | devhubCon: Connection 11 | ): Promise { 12 | const versionArray = packageVersionLiteral.split('.'); 13 | const queryString = `SELECT 14 | SubscriberPackageVersionId, 15 | Package2.SubscriberPackageId, 16 | SubscriberPackageVersion.IsBeta, 17 | SubscriberPackageVersion.IsPasswordProtected 18 | FROM Package2Version 19 | WHERE Package2Id = '${packageId}' 20 | AND MajorVersion = ${versionArray[0]} 21 | AND MinorVersion = ${versionArray[1]} 22 | AND PatchVersion = ${versionArray[2]} 23 | AND IsReleased = true LIMIT 1`; 24 | const queryResult = await devhubCon.tooling.query(queryString); 25 | if (queryResult.records.length === 0) { 26 | throw new SfError( 27 | messages.getMessage('errors.no-released-package-version', [ 28 | packageId, 29 | packageVersionLiteral, 30 | devhubCon.getUsername(), 31 | ]), 32 | 'NoReleasedPackageVersionFound' 33 | ); 34 | } 35 | const record = queryResult.records[0]; 36 | return { 37 | id: record.SubscriberPackageVersionId, 38 | requiresInstallationKey: record.SubscriberPackageVersion.IsPasswordProtected, 39 | subscriberPackageId: record.Package2.SubscriberPackageId, 40 | }; 41 | } 42 | 43 | export async function resolveInstalledVersionId( 44 | subscriberId: string, 45 | targetOrgCon: Connection 46 | ): Promise { 47 | const queryString = `SELECT 48 | SubscriberPackageVersionId, 49 | SubscriberPackageVersion.MajorVersion, 50 | SubscriberPackageVersion.MinorVersion, 51 | SubscriberPackageVersion.PatchVersion, 52 | SubscriberPackageVersion.IsPasswordProtected 53 | FROM InstalledSubscriberPackage 54 | WHERE SubscriberPackageId = '${subscriberId}' LIMIT 1`; 55 | const queryResult = await targetOrgCon.tooling.query(queryString); 56 | if (queryResult.records.length === 0) { 57 | return { id: undefined }; 58 | } 59 | const record = queryResult.records[0]; 60 | return { 61 | id: record.SubscriberPackageVersionId, 62 | versionName: mergeVersionName(record.SubscriberPackageVersion), 63 | requiresInstallationKey: record.SubscriberPackageVersion.IsPasswordProtected, 64 | }; 65 | } 66 | 67 | function mergeVersionName(subscriberPackage: SubscriberPackageVersion): string { 68 | return `${subscriberPackage.MajorVersion}.${subscriberPackage.MinorVersion}.${subscriberPackage.PatchVersion}`; 69 | } 70 | 71 | export type PackageVersionDetails = { 72 | id?: string; 73 | versionName?: string; 74 | requiresInstallationKey?: boolean; 75 | subscriberPackageId?: string; 76 | }; 77 | -------------------------------------------------------------------------------- /src/commands/jsc/manifest/validate.ts: -------------------------------------------------------------------------------- 1 | import { Config } from '@oclif/core'; 2 | import { SfCommand, Flags } from '@salesforce/sf-plugins-core'; 3 | import { Messages } from '@salesforce/core'; 4 | import { eventBus } from '../../../common/comms/eventBus.js'; 5 | import { CommandStatusEvent, ProcessingStatus } from '../../../common/comms/processingEvents.js'; 6 | import ReleaseManifestLoader from '../../../release-manifest/releaseManifestLoader.js'; 7 | import { JscManifestRolloutResult } from './rollout.js'; 8 | 9 | Messages.importMessagesDirectoryFromMetaUrl(import.meta.url); 10 | const valMessages = Messages.loadMessages('@j-schreiber/sf-plugin', 'jsc.manifest.validate'); 11 | const rolloutMessages = Messages.loadMessages('@j-schreiber/sf-plugin', 'jsc.manifest.rollout'); 12 | 13 | export default class JscManifestValidate extends SfCommand { 14 | public static readonly summary = valMessages.getMessage('summary'); 15 | public static readonly description = valMessages.getMessage('description'); 16 | public static readonly examples = valMessages.getMessages('examples'); 17 | 18 | public static readonly flags = { 19 | manifest: Flags.file({ 20 | summary: rolloutMessages.getMessage('flags.manifest.summary'), 21 | char: 'm', 22 | required: true, 23 | exists: true, 24 | }), 25 | 'target-org': Flags.requiredOrg({ 26 | summary: rolloutMessages.getMessage('flags.target-org.summary'), 27 | char: 't', 28 | required: true, 29 | }), 30 | 'devhub-org': Flags.requiredOrg({ 31 | summary: rolloutMessages.getMessage('flags.devhub-org.summary'), 32 | char: 'o', 33 | required: true, 34 | }), 35 | }; 36 | 37 | public constructor(argv: string[], config: Config) { 38 | super(argv, config); 39 | eventBus.on('manifestRollout', (payload: CommandStatusEvent) => this.handleStatusEvent(payload)); 40 | eventBus.on('simpleMessage', (payload: CommandStatusEvent) => this.log(payload.message)); 41 | } 42 | 43 | public async run(): Promise { 44 | const { flags } = await this.parse(JscManifestValidate); 45 | this.info(rolloutMessages.getMessage('infos.target-org-info', [flags['target-org'].getUsername()])); 46 | this.info(rolloutMessages.getMessage('infos.devhub-org-info', [flags['devhub-org'].getUsername()])); 47 | const manifest = ReleaseManifestLoader.load(flags.manifest); 48 | const resolveResults = await manifest.resolve( 49 | flags['target-org'].getConnection('60.0'), 50 | flags['devhub-org'].getConnection('60.0') 51 | ); 52 | return { 53 | targetOrgUsername: flags['target-org'].getUsername(), 54 | devhubOrgUsername: flags['devhub-org'].getUsername(), 55 | deployedArtifacts: resolveResults, 56 | }; 57 | } 58 | 59 | private handleStatusEvent(payload: CommandStatusEvent): void { 60 | if (payload.status === ProcessingStatus.Started) { 61 | this.spinner.start(payload.message!); 62 | } 63 | this.spinner.status = payload.message; 64 | if (payload.status === ProcessingStatus.Completed) { 65 | this.spinner.stop(payload.message); 66 | } 67 | } 68 | } 69 | --------------------------------------------------------------------------------