├── .commitlintrc.json ├── .eslintrc.cjs ├── .git2gus └── config.json ├── .gitattributes ├── .github ├── dependabot.yml └── workflows │ ├── automerge.yml │ ├── create-github-release.yml │ ├── devScripts.yml │ ├── failureNotifications.yml │ ├── notify-slack-on-pr-open.yml │ ├── onRelease.yml │ ├── test.yml │ └── validate-pr.yml ├── .gitignore ├── .husky ├── commit-msg ├── pre-commit └── pre-push ├── .mocharc.json ├── .nycrc ├── .prettierrc.json ├── .sfdevrc.json ├── .vscode └── launch.json ├── CHANGELOG.md ├── CODEOWNERS ├── CODE_OF_CONDUCT.md ├── LICENSE.txt ├── MIGRATING_V5-V6.md ├── README.md ├── SECURITY.md ├── messages └── messages.md ├── package.json ├── src ├── SfCommandError.ts ├── compatibility.ts ├── errorFormatting.ts ├── errorHandling.ts ├── exported.ts ├── flags │ ├── duration.ts │ ├── flags.ts │ ├── orgApiVersion.ts │ ├── orgFlags.ts │ └── salesforceId.ts ├── sfCommand.ts ├── stubUx.ts ├── util.ts └── ux │ ├── base.ts │ ├── progress.ts │ ├── prompts.ts │ ├── spinner.ts │ ├── standardColors.ts │ ├── styledObject.ts │ ├── table.ts │ └── ux.ts ├── test ├── .eslintrc.cjs ├── init.js ├── tsconfig.json └── unit │ ├── compatibility.test.ts │ ├── errorFormatting.test.ts │ ├── errorHandling.test.ts │ ├── flags │ ├── apiVersion.test.ts │ ├── duration.test.ts │ ├── id.test.ts │ └── orgFlags.test.ts │ ├── sfCommand.test.ts │ ├── stubUx.test.ts │ ├── util.test.ts │ └── ux │ ├── object.test.ts │ ├── progress.test.ts │ ├── spinner.test.ts │ └── ux.test.ts ├── tsconfig.json ├── typedoc.json └── yarn.lock /.commitlintrc.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": ["@commitlint/config-conventional"] 3 | } 4 | -------------------------------------------------------------------------------- /.eslintrc.cjs: -------------------------------------------------------------------------------- 1 | /* 2 | * Copyright (c) 2020, salesforce.com, inc. 3 | * All rights reserved. 4 | * Licensed under the BSD 3-Clause license. 5 | * For full license text, see LICENSE.txt file in the repo root or https://opensource.org/licenses/BSD-3-Clause 6 | */ 7 | 8 | // Generated - Do not modify. Controlled by @salesforce/dev-scripts 9 | // See more at https://github.com/forcedotcom/sfdx-dev-packages/tree/master/packages/dev-scripts 10 | 11 | module.exports = { 12 | extends: ['eslint-config-salesforce-typescript', 'eslint-config-salesforce-license', 'plugin:sf-plugin/library'], 13 | root: true, 14 | rules: { 15 | 'jsdoc/newline-after-description': 'off', 16 | 'jsdoc/check-indentation': ['warn', { excludeTags: ['example'] }], 17 | }, 18 | }; 19 | -------------------------------------------------------------------------------- /.git2gus/config.json: -------------------------------------------------------------------------------- 1 | { 2 | "productTag": "a1aB00000004Bx8IAE", 3 | "defaultBuild": "offcore.tooling.56", 4 | "issueTypeLabels": { 5 | "feature": "USER STORY", 6 | "regression": "BUG P1", 7 | "bug": "BUG P3" 8 | } 9 | } 10 | -------------------------------------------------------------------------------- /.gitattributes: -------------------------------------------------------------------------------- 1 | **/docs/* linguist-generated=true 2 | **/docs/* linguist-documentation 3 | -------------------------------------------------------------------------------- /.github/dependabot.yml: -------------------------------------------------------------------------------- 1 | version: 2 2 | updates: 3 | - package-ecosystem: 'npm' 4 | directory: '/' 5 | schedule: 6 | interval: 'weekly' 7 | day: 'saturday' 8 | versioning-strategy: 'increase' 9 | labels: 10 | - 'dependencies' 11 | open-pull-requests-limit: 5 12 | pull-request-branch-name: 13 | separator: '-' 14 | commit-message: 15 | # cause a release for non-dev-deps 16 | prefix: fix(deps) 17 | # no release for dev-deps 18 | prefix-development: chore(dev-deps) 19 | ignore: 20 | - dependency-name: '@salesforce/dev-scripts' 21 | - dependency-name: '*' 22 | update-types: ['version-update:semver-major'] 23 | -------------------------------------------------------------------------------- /.github/workflows/automerge.yml: -------------------------------------------------------------------------------- 1 | name: automerge 2 | on: 3 | workflow_dispatch: 4 | schedule: 5 | - cron: '42 2,5,8,11 * * *' 6 | 7 | jobs: 8 | automerge: 9 | uses: salesforcecli/github-workflows/.github/workflows/automerge.yml@main 10 | secrets: inherit 11 | -------------------------------------------------------------------------------- /.github/workflows/create-github-release.yml: -------------------------------------------------------------------------------- 1 | name: create-github-release 2 | 3 | on: 4 | push: 5 | branches: 6 | - main 7 | - prerelease/** 8 | tags-ignore: 9 | - '*' 10 | workflow_dispatch: 11 | inputs: 12 | prerelease: 13 | type: string 14 | description: 'Name to use for the prerelease: beta, dev, etc. NOTE: If this is already set in the package.json, it does not need to be passed in here.' 15 | 16 | jobs: 17 | release: 18 | uses: salesforcecli/github-workflows/.github/workflows/create-github-release.yml@main 19 | secrets: inherit 20 | with: 21 | prerelease: ${{ inputs.prerelease }} 22 | # If this is a push event, we want to skip the release if there are no semantic commits 23 | # However, if this is a manual release (workflow_dispatch), then we want to disable skip-on-empty 24 | # This helps recover from forgetting to add semantic commits ('fix:', 'feat:', etc.) 25 | skip-on-empty: ${{ github.event_name == 'push' }} 26 | # most repos won't use this 27 | # depends on previous job to avoid git collisions, not for any functionality reason 28 | docs: 29 | if: github.ref_name == 'main' 30 | uses: salesforcecli/github-workflows/.github/workflows/publishTypedoc.yml@main 31 | secrets: inherit 32 | needs: release 33 | -------------------------------------------------------------------------------- /.github/workflows/devScripts.yml: -------------------------------------------------------------------------------- 1 | name: devScripts 2 | on: 3 | workflow_dispatch: 4 | schedule: 5 | - cron: '50 6 * * 0' 6 | 7 | jobs: 8 | update: 9 | uses: salesforcecli/github-workflows/.github/workflows/devScriptsUpdate.yml@main 10 | secrets: inherit 11 | -------------------------------------------------------------------------------- /.github/workflows/failureNotifications.yml: -------------------------------------------------------------------------------- 1 | name: failureNotifications 2 | on: 3 | workflow_run: 4 | workflows: 5 | - publish 6 | types: 7 | - completed 8 | jobs: 9 | failure-notify: 10 | runs-on: ubuntu-latest 11 | if: ${{ github.event.workflow_run.conclusion == 'failure' }} 12 | steps: 13 | - name: Announce Failure 14 | id: slack 15 | uses: slackapi/slack-github-action@v1.26.0 16 | env: 17 | # for non-CLI-team-owned plugins, you can send this anywhere you like 18 | SLACK_WEBHOOK_URL: ${{ secrets.CLI_ALERTS_SLACK_WEBHOOK }} 19 | SLACK_WEBHOOK_TYPE: INCOMING_WEBHOOK 20 | with: 21 | # Payload can be visually tested here: https://app.slack.com/block-kit-builder/T01GST6QY0G#%7B%22blocks%22:%5B%5D%7D 22 | # Only copy over the "blocks" array to the Block Kit Builder 23 | payload: | 24 | { 25 | "text": "Workflow \"${{ github.event.workflow_run.name }}\" failed in ${{ github.event.workflow_run.repository.name }}", 26 | "blocks": [ 27 | { 28 | "type": "header", 29 | "text": { 30 | "type": "plain_text", 31 | "text": ":bh-alert: Workflow \"${{ github.event.workflow_run.name }}\" failed in ${{ github.event.workflow_run.repository.name }} :bh-alert:" 32 | } 33 | }, 34 | { 35 | "type": "section", 36 | "text": { 37 | "type": "mrkdwn", 38 | "text": "*Repo:* ${{ github.event.workflow_run.repository.html_url }}\n*Workflow name:* `${{ github.event.workflow_run.name }}`\n*Job url:* ${{ github.event.workflow_run.html_url }}" 39 | } 40 | } 41 | ] 42 | } 43 | -------------------------------------------------------------------------------- /.github/workflows/notify-slack-on-pr-open.yml: -------------------------------------------------------------------------------- 1 | name: Pull Request Slack Notification 2 | 3 | on: 4 | pull_request: 5 | types: [opened, reopened] 6 | 7 | jobs: 8 | build: 9 | runs-on: ubuntu-latest 10 | steps: 11 | - name: Notify Slack on PR open 12 | env: 13 | WEBHOOK_URL : ${{ secrets.CLI_TEAM_SLACK_WEBHOOK_URL }} 14 | PULL_REQUEST_AUTHOR_ICON_URL : ${{ github.event.pull_request.user.avatar_url }} 15 | PULL_REQUEST_AUTHOR_NAME : ${{ github.event.pull_request.user.login }} 16 | PULL_REQUEST_AUTHOR_PROFILE_URL: ${{ github.event.pull_request.user.html_url }} 17 | PULL_REQUEST_BASE_BRANCH_NAME : ${{ github.event.pull_request.base.ref }} 18 | PULL_REQUEST_COMPARE_BRANCH_NAME : ${{ github.event.pull_request.head.ref }} 19 | PULL_REQUEST_NUMBER : ${{ github.event.pull_request.number }} 20 | PULL_REQUEST_REPO: ${{ github.event.pull_request.head.repo.name }} 21 | PULL_REQUEST_TITLE : ${{ github.event.pull_request.title }} 22 | PULL_REQUEST_URL : ${{ github.event.pull_request.html_url }} 23 | uses: salesforcecli/github-workflows/.github/actions/prNotification@main 24 | -------------------------------------------------------------------------------- /.github/workflows/onRelease.yml: -------------------------------------------------------------------------------- 1 | name: publish 2 | 3 | on: 4 | release: 5 | types: [published] 6 | # support manual release in case something goes wrong and needs to be repeated or tested 7 | workflow_dispatch: 8 | inputs: 9 | tag: 10 | description: tag that needs to publish 11 | type: string 12 | required: true 13 | jobs: 14 | # parses the package.json version and detects prerelease tag (ex: beta from 4.4.4-beta.0) 15 | getDistTag: 16 | outputs: 17 | tag: ${{ steps.distTag.outputs.tag }} 18 | runs-on: ubuntu-latest 19 | steps: 20 | - uses: actions/checkout@v4 21 | with: 22 | ref: ${{ github.event.release.tag_name || inputs.tag }} 23 | - uses: salesforcecli/github-workflows/.github/actions/getPreReleaseTag@main 24 | id: distTag 25 | npm: 26 | uses: salesforcecli/github-workflows/.github/workflows/npmPublish.yml@main 27 | needs: [getDistTag] 28 | with: 29 | tag: ${{ needs.getDistTag.outputs.tag || 'latest' }} 30 | githubTag: ${{ github.event.release.tag_name || inputs.tag }} 31 | secrets: inherit 32 | -------------------------------------------------------------------------------- /.github/workflows/test.yml: -------------------------------------------------------------------------------- 1 | name: tests 2 | on: 3 | push: 4 | branches-ignore: [main] 5 | workflow_dispatch: 6 | 7 | jobs: 8 | yarn-lockfile-check: 9 | uses: salesforcecli/github-workflows/.github/workflows/lockFileCheck.yml@main 10 | 11 | # Since the Windows unit tests take much longer, we run the linux unit tests first and then run the windows unit tests in parallel with NUTs 12 | linux-unit-tests: 13 | needs: yarn-lockfile-check 14 | uses: salesforcecli/github-workflows/.github/workflows/unitTestsLinux.yml@main 15 | 16 | windows-unit-tests: 17 | needs: linux-unit-tests 18 | uses: salesforcecli/github-workflows/.github/workflows/unitTestsWindows.yml@main 19 | 20 | external-nuts-deploy-retrieve: 21 | name: external-nuts-deploy-retrieve 22 | needs: linux-unit-tests 23 | strategy: 24 | fail-fast: false 25 | matrix: 26 | command: 27 | - 'yarn test:nuts:tracking' 28 | - 'yarn test:nuts:deploy:metadata:metadata-dir' 29 | - 'yarn test:nuts:deploy:metadata:manifest' 30 | - 'yarn test:nuts:deploy:metadata:metadata' 31 | - 'yarn test:nuts:deploy:metadata:source-dir' 32 | - 'yarn test:nuts:deploy:metadata:test-level' 33 | - 'yarn test:nuts:static' 34 | os: 35 | - ubuntu-latest 36 | - windows-latest 37 | uses: salesforcecli/github-workflows/.github/workflows/externalNut.yml@main 38 | with: 39 | packageName: '@salesforce/sf-plugins-core' 40 | externalProjectGitUrl: 'https://github.com/salesforcecli/plugin-deploy-retrieve' 41 | preBuildCommands: 'shx rm -rf node_modules/@oclif/core node_modules/@oclif/table node_modules/@salesforce/kit node_modules/@salesforce/core node_modules/@salesforce/ts-types node_modules/@salesforce/cli-plugins-testkit' 42 | command: ${{ matrix.command }} 43 | os: ${{ matrix.os }} 44 | secrets: inherit 45 | 46 | external-nuts: 47 | name: external-nuts 48 | needs: linux-unit-tests 49 | strategy: 50 | fail-fast: false 51 | matrix: 52 | repo: 53 | - plugin-org 54 | - plugin-data 55 | - plugin-schema 56 | - plugin-limits 57 | - plugin-signups 58 | - plugin-templates 59 | - plugin-custom-metadata 60 | - plugin-settings 61 | - plugin-community 62 | - plugin-user 63 | os: 64 | - ubuntu-latest 65 | - windows-latest 66 | uses: salesforcecli/github-workflows/.github/workflows/externalNut.yml@main 67 | with: 68 | packageName: '@salesforce/sf-plugins-core' 69 | externalProjectGitUrl: 'https://github.com/salesforcecli/${{matrix.repo}}' 70 | preBuildCommands: 'shx rm -rf node_modules/@oclif/core node_modules/@oclif/table node_modules/@salesforce/kit node_modules/@salesforce/core node_modules/@salesforce/ts-types' 71 | command: yarn test:nuts 72 | os: ${{ matrix.os }} 73 | secrets: inherit 74 | -------------------------------------------------------------------------------- /.github/workflows/validate-pr.yml: -------------------------------------------------------------------------------- 1 | name: pr-validation 2 | 3 | on: 4 | pull_request: 5 | types: [opened, reopened, edited] 6 | # only applies to PRs that want to merge to main 7 | branches: [main] 8 | 9 | jobs: 10 | pr-validation: 11 | uses: salesforcecli/github-workflows/.github/workflows/validatePR.yml@main 12 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # -- CLEAN 2 | 3 | # use yarn by default, so ignore npm 4 | package-lock.json 5 | 6 | # never checkin npm config 7 | .npmrc 8 | 9 | # debug logs 10 | npm-error.log 11 | yarn-error.log 12 | lerna-debug.log 13 | 14 | # compile source 15 | lib 16 | 17 | # test artifacts 18 | *xunit.xml 19 | *checkstyle.xml 20 | *unitcoverage 21 | .nyc_output 22 | coverage 23 | 24 | # generated docs 25 | docs 26 | 27 | # -- CLEAN ALL 28 | *.tsbuildinfo 29 | .eslintcache 30 | .wireit 31 | node_modules 32 | 33 | # -- 34 | # put files here you don't want cleaned with sf-clean 35 | 36 | .sfdx 37 | 38 | # os specific files 39 | .DS_Store 40 | .idea 41 | /Library 42 | -------------------------------------------------------------------------------- /.husky/commit-msg: -------------------------------------------------------------------------------- 1 | #!/bin/sh 2 | . "$(dirname "$0")/_/husky.sh" 3 | 4 | yarn commitlint --edit 5 | -------------------------------------------------------------------------------- /.husky/pre-commit: -------------------------------------------------------------------------------- 1 | #!/bin/sh 2 | . "$(dirname "$0")/_/husky.sh" 3 | 4 | yarn lint && yarn pretty-quick --staged 5 | -------------------------------------------------------------------------------- /.husky/pre-push: -------------------------------------------------------------------------------- 1 | #!/bin/sh 2 | . "$(dirname "$0")/_/husky.sh" 3 | 4 | yarn build && yarn test 5 | -------------------------------------------------------------------------------- /.mocharc.json: -------------------------------------------------------------------------------- 1 | { 2 | "require": "ts-node/register", 3 | "watch-extensions": ["ts", "md"], 4 | "watch-files": ["src", "test", "messages"], 5 | "recursive": true, 6 | "reporter": "spec", 7 | "timeout": 5000, 8 | "node-option": ["loader=ts-node/esm"] 9 | } 10 | -------------------------------------------------------------------------------- /.nycrc: -------------------------------------------------------------------------------- 1 | { 2 | "nyc": { 3 | "extends": "@salesforce/dev-config/nyc", 4 | "lines": 87, 5 | "statements": 87, 6 | "functions": 89, 7 | "branches": 74, 8 | "exclude": ["**/*.d.ts", "test/**/*.ts"] 9 | } 10 | } 11 | -------------------------------------------------------------------------------- /.prettierrc.json: -------------------------------------------------------------------------------- 1 | "@salesforce/prettier-config" 2 | -------------------------------------------------------------------------------- /.sfdevrc.json: -------------------------------------------------------------------------------- 1 | { 2 | "test": { 3 | "testsPath": "test/**/*.test.ts" 4 | } 5 | } 6 | -------------------------------------------------------------------------------- /.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 | } 16 | -------------------------------------------------------------------------------- /CODEOWNERS: -------------------------------------------------------------------------------- 1 | # Techical writers will be added as reviewers on markdown changes. 2 | *.md @salesforcecli/cli-docs 3 | 4 | # Comment line immediately above ownership line is reserved for related other information. Please be careful while editing. 5 | #ECCN:Open Source 5D002 -------------------------------------------------------------------------------- /CODE_OF_CONDUCT.md: -------------------------------------------------------------------------------- 1 | # Salesforce Open Source Community Code of Conduct 2 | 3 | ## About the Code of Conduct 4 | 5 | Equality is a core value at Salesforce. We believe a diverse and inclusive 6 | community fosters innovation and creativity, and are committed to building a 7 | culture where everyone feels included. 8 | 9 | Salesforce open-source projects are committed to providing a friendly, safe, and 10 | welcoming environment for all, regardless of gender identity and expression, 11 | sexual orientation, disability, physical appearance, body size, ethnicity, nationality, 12 | race, age, religion, level of experience, education, socioeconomic status, or 13 | other similar personal characteristics. 14 | 15 | The goal of this code of conduct is to specify a baseline standard of behavior so 16 | that people with different social values and communication styles can work 17 | together effectively, productively, and respectfully in our open source community. 18 | It also establishes a mechanism for reporting issues and resolving conflicts. 19 | 20 | All questions and reports of abusive, harassing, or otherwise unacceptable behavior 21 | in a Salesforce open-source project may be reported by contacting the Salesforce 22 | Open Source Conduct Committee at ossconduct@salesforce.com. 23 | 24 | ## Our Pledge 25 | 26 | In the interest of fostering an open and welcoming environment, we as 27 | contributors and maintainers pledge to making participation in our project and 28 | our community a harassment-free experience for everyone, regardless of gender 29 | identity and expression, sexual orientation, disability, physical appearance, 30 | body size, ethnicity, nationality, race, age, religion, level of experience, education, 31 | socioeconomic status, or other similar personal characteristics. 32 | 33 | ## Our Standards 34 | 35 | Examples of behavior that contributes to creating a positive environment 36 | include: 37 | 38 | - Using welcoming and inclusive language 39 | - Being respectful of differing viewpoints and experiences 40 | - Gracefully accepting constructive criticism 41 | - Focusing on what is best for the community 42 | - Showing empathy toward other community members 43 | 44 | Examples of unacceptable behavior by participants include: 45 | 46 | - The use of sexualized language or imagery and unwelcome sexual attention or 47 | advances 48 | - Personal attacks, insulting/derogatory comments, or trolling 49 | - Public or private harassment 50 | - Publishing, or threatening to publish, others' private information—such as 51 | a physical or electronic address—without explicit permission 52 | - Other conduct which could reasonably be considered inappropriate in a 53 | professional setting 54 | - Advocating for or encouraging any of the above behaviors 55 | 56 | ## Our Responsibilities 57 | 58 | Project maintainers are responsible for clarifying the standards of acceptable 59 | behavior and are expected to take appropriate and fair corrective action in 60 | response to any instances of unacceptable behavior. 61 | 62 | Project maintainers have the right and responsibility to remove, edit, or 63 | reject comments, commits, code, wiki edits, issues, and other contributions 64 | that are not aligned with this Code of Conduct, or to ban temporarily or 65 | permanently any contributor for other behaviors that they deem inappropriate, 66 | threatening, offensive, or harmful. 67 | 68 | ## Scope 69 | 70 | This Code of Conduct applies both within project spaces and in public spaces 71 | when an individual is representing the project or its community. Examples of 72 | representing a project or community include using an official project email 73 | address, posting via an official social media account, or acting as an appointed 74 | representative at an online or offline event. Representation of a project may be 75 | further defined and clarified by project maintainers. 76 | 77 | ## Enforcement 78 | 79 | Instances of abusive, harassing, or otherwise unacceptable behavior may be 80 | reported by contacting the Salesforce Open Source Conduct Committee 81 | at ossconduct@salesforce.com. All complaints will be reviewed and investigated 82 | and will result in a response that is deemed necessary and appropriate to the 83 | circumstances. The committee is obligated to maintain confidentiality with 84 | regard to the reporter of an incident. Further details of specific enforcement 85 | policies may be posted separately. 86 | 87 | Project maintainers who do not follow or enforce the Code of Conduct in good 88 | faith may face temporary or permanent repercussions as determined by other 89 | members of the project's leadership and the Salesforce Open Source Conduct 90 | Committee. 91 | 92 | ## Attribution 93 | 94 | This Code of Conduct is adapted from the [Contributor Covenant][contributor-covenant-home], 95 | version 1.4, available at https://www.contributor-covenant.org/version/1/4/code-of-conduct.html. 96 | It includes adaptions and additions from [Go Community Code of Conduct][golang-coc], 97 | [CNCF Code of Conduct][cncf-coc], and [Microsoft Open Source Code of Conduct][microsoft-coc]. 98 | 99 | This Code of Conduct is licensed under the [Creative Commons Attribution 3.0 License][cc-by-3-us]. 100 | 101 | [contributor-covenant-home]: https://www.contributor-covenant.org 'https://www.contributor-covenant.org/' 102 | [golang-coc]: https://golang.org/conduct 103 | [cncf-coc]: https://github.com/cncf/foundation/blob/master/code-of-conduct.md 104 | [microsoft-coc]: https://opensource.microsoft.com/codeofconduct/ 105 | [cc-by-3-us]: https://creativecommons.org/licenses/by/3.0/us/ 106 | -------------------------------------------------------------------------------- /LICENSE.txt: -------------------------------------------------------------------------------- 1 | Apache License Version 2.0 2 | 3 | Copyright (c) 2025 Salesforce, Inc. 4 | All rights reserved. 5 | 6 | Apache License 7 | Version 2.0, January 2004 8 | http://www.apache.org/licenses/ 9 | 10 | TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION 11 | 12 | 1. Definitions. 13 | 14 | "License" shall mean the terms and conditions for use, reproduction, 15 | and distribution as defined by Sections 1 through 9 of this document. 16 | 17 | "Licensor" shall mean the copyright owner or entity authorized by 18 | the copyright owner that is granting the License. 19 | 20 | "Legal Entity" shall mean the union of the acting entity and all 21 | other entities that control, are controlled by, or are under common 22 | control with that entity. For the purposes of this definition, 23 | "control" means (i) the power, direct or indirect, to cause the 24 | direction or management of such entity, whether by contract or 25 | otherwise, or (ii) ownership of fifty percent (50%) or more of the 26 | outstanding shares, or (iii) beneficial ownership of such entity. 27 | 28 | "You" (or "Your") shall mean an individual or Legal Entity 29 | exercising permissions granted by this License. 30 | 31 | "Source" form shall mean the preferred form for making modifications, 32 | including but not limited to software source code, documentation 33 | source, and configuration files. 34 | 35 | "Object" form shall mean any form resulting from mechanical 36 | transformation or translation of a Source form, including but 37 | not limited to compiled object code, generated documentation, 38 | and conversions to other media types. 39 | 40 | "Work" shall mean the work of authorship, whether in Source or 41 | Object form, made available under the License, as indicated by a 42 | copyright notice that is included in or attached to the work 43 | (an example is provided in the Appendix below). 44 | 45 | "Derivative Works" shall mean any work, whether in Source or Object 46 | form, that is based on (or derived from) the Work and for which the 47 | editorial revisions, annotations, elaborations, or other modifications 48 | represent, as a whole, an original work of authorship. For the purposes 49 | of this License, Derivative Works shall not include works that remain 50 | separable from, or merely link (or bind by name) to the interfaces of, 51 | the Work and Derivative Works thereof. 52 | 53 | "Contribution" shall mean any work of authorship, including 54 | the original version of the Work and any modifications or additions 55 | to that Work or Derivative Works thereof, that is intentionally 56 | submitted to Licensor for inclusion in the Work by the copyright owner 57 | or by an individual or Legal Entity authorized to submit on behalf of 58 | the copyright owner. For the purposes of this definition, "submitted" 59 | means any form of electronic, verbal, or written communication sent 60 | to the Licensor or its representatives, including but not limited to 61 | communication on electronic mailing lists, source code control systems, 62 | and issue tracking systems that are managed by, or on behalf of, the 63 | Licensor for the purpose of discussing and improving the Work, but 64 | excluding communication that is conspicuously marked or otherwise 65 | designated in writing by the copyright owner as "Not a Contribution." 66 | 67 | "Contributor" shall mean Licensor and any individual or Legal Entity 68 | on behalf of whom a Contribution has been received by Licensor and 69 | subsequently incorporated within the Work. 70 | 71 | 2. Grant of Copyright License. Subject to the terms and conditions of 72 | this License, each Contributor hereby grants to You a perpetual, 73 | worldwide, non-exclusive, no-charge, royalty-free, irrevocable 74 | copyright license to reproduce, prepare Derivative Works of, 75 | publicly display, publicly perform, sublicense, and distribute the 76 | Work and such Derivative Works in Source or Object form. 77 | 78 | 3. Grant of Patent License. Subject to the terms and conditions of 79 | this License, each Contributor hereby grants to You a perpetual, 80 | worldwide, non-exclusive, no-charge, royalty-free, irrevocable 81 | (except as stated in this section) patent license to make, have made, 82 | use, offer to sell, sell, import, and otherwise transfer the Work, 83 | where such license applies only to those patent claims licensable 84 | by such Contributor that are necessarily infringed by their 85 | Contribution(s) alone or by combination of their Contribution(s) 86 | with the Work to which such Contribution(s) was submitted. If You 87 | institute patent litigation against any entity (including a 88 | cross-claim or counterclaim in a lawsuit) alleging that the Work 89 | or a Contribution incorporated within the Work constitutes direct 90 | or contributory patent infringement, then any patent licenses 91 | granted to You under this License for that Work shall terminate 92 | as of the date such litigation is filed. 93 | 94 | 4. Redistribution. You may reproduce and distribute copies of the 95 | Work or Derivative Works thereof in any medium, with or without 96 | modifications, and in Source or Object form, provided that You 97 | meet the following conditions: 98 | 99 | (a) You must give any other recipients of the Work or 100 | Derivative Works a copy of this License; and 101 | 102 | (b) You must cause any modified files to carry prominent notices 103 | stating that You changed the files; and 104 | 105 | (c) You must retain, in the Source form of any Derivative Works 106 | that You distribute, all copyright, patent, trademark, and 107 | attribution notices from the Source form of the Work, 108 | excluding those notices that do not pertain to any part of 109 | the Derivative Works; and 110 | 111 | (d) If the Work includes a "NOTICE" text file as part of its 112 | distribution, then any Derivative Works that You distribute must 113 | include a readable copy of the attribution notices contained 114 | within such NOTICE file, excluding those notices that do not 115 | pertain to any part of the Derivative Works, in at least one 116 | of the following places: within a NOTICE text file distributed 117 | as part of the Derivative Works; within the Source form or 118 | documentation, if provided along with the Derivative Works; or, 119 | within a display generated by the Derivative Works, if and 120 | wherever such third-party notices normally appear. The contents 121 | of the NOTICE file are for informational purposes only and 122 | do not modify the License. You may add Your own attribution 123 | notices within Derivative Works that You distribute, alongside 124 | or as an addendum to the NOTICE text from the Work, provided 125 | that such additional attribution notices cannot be construed 126 | as modifying the License. 127 | 128 | You may add Your own copyright statement to Your modifications and 129 | may provide additional or different license terms and conditions 130 | for use, reproduction, or distribution of Your modifications, or 131 | for any such Derivative Works as a whole, provided Your use, 132 | reproduction, and distribution of the Work otherwise complies with 133 | the conditions stated in this License. 134 | 135 | 5. Submission of Contributions. Unless You explicitly state otherwise, 136 | any Contribution intentionally submitted for inclusion in the Work 137 | by You to the Licensor shall be under the terms and conditions of 138 | this License, without any additional terms or conditions. 139 | Notwithstanding the above, nothing herein shall supersede or modify 140 | the terms of any separate license agreement you may have executed 141 | with Licensor regarding such Contributions. 142 | 143 | 6. Trademarks. This License does not grant permission to use the trade 144 | names, trademarks, service marks, or product names of the Licensor, 145 | except as required for reasonable and customary use in describing the 146 | origin of the Work and reproducing the content of the NOTICE file. 147 | 148 | 7. Disclaimer of Warranty. Unless required by applicable law or 149 | agreed to in writing, Licensor provides the Work (and each 150 | Contributor provides its Contributions) on an "AS IS" BASIS, 151 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or 152 | implied, including, without limitation, any warranties or conditions 153 | of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A 154 | PARTICULAR PURPOSE. You are solely responsible for determining the 155 | appropriateness of using or redistributing the Work and assume any 156 | risks associated with Your exercise of permissions under this License. 157 | 158 | 8. Limitation of Liability. In no event and under no legal theory, 159 | whether in tort (including negligence), contract, or otherwise, 160 | unless required by applicable law (such as deliberate and grossly 161 | negligent acts) or agreed to in writing, shall any Contributor be 162 | liable to You for damages, including any direct, indirect, special, 163 | incidental, or consequential damages of any character arising as a 164 | result of this License or out of the use or inability to use the 165 | Work (including but not limited to damages for loss of goodwill, 166 | work stoppage, computer failure or malfunction, or any and all 167 | other commercial damages or losses), even if such Contributor 168 | has been advised of the possibility of such damages. 169 | 170 | 9. Accepting Warranty or Additional Liability. While redistributing 171 | the Work or Derivative Works thereof, You may choose to offer, 172 | and charge a fee for, acceptance of support, warranty, indemnity, 173 | or other liability obligations and/or rights consistent with this 174 | License. However, in accepting such obligations, You may act only 175 | on Your own behalf and on Your sole responsibility, not on behalf 176 | of any other Contributor, and only if You agree to indemnify, 177 | defend, and hold each Contributor harmless for any liability 178 | incurred by, or claims asserted against, such Contributor by reason 179 | of your accepting any such warranty or additional liability. 180 | 181 | END OF TERMS AND CONDITIONS 182 | 183 | APPENDIX: How to apply the Apache License to your work. 184 | 185 | To apply the Apache License to your work, attach the following 186 | boilerplate notice, with the fields enclosed by brackets "{}" 187 | replaced with your own identifying information. (Don't include 188 | the brackets!) The text should be enclosed in the appropriate 189 | comment syntax for the file format. We also recommend that a 190 | file or class name and description of purpose be included on the 191 | same "printed page" as the copyright notice for easier 192 | identification within third-party archives. 193 | 194 | Copyright {yyyy} {name of copyright owner} 195 | 196 | Licensed under the Apache License, Version 2.0 (the "License"); 197 | you may not use this file except in compliance with the License. 198 | You may obtain a copy of the License at 199 | 200 | http://www.apache.org/licenses/LICENSE-2.0 201 | 202 | Unless required by applicable law or agreed to in writing, software 203 | distributed under the License is distributed on an "AS IS" BASIS, 204 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 205 | See the License for the specific language governing permissions and 206 | limitations under the License. 207 | -------------------------------------------------------------------------------- /MIGRATING_V5-V6.md: -------------------------------------------------------------------------------- 1 | # Migrating from v5 to v7 2 | 3 | ## ESM 4 | 5 | v6 is ESM-only, which can't be consumed by plugins written in cjs. Salesforce-owned plugins are ESM now and you can use them as a guide for the necessary changes. 6 | 7 | ## Prompts 8 | 9 | This library uses [`inquirer`](https://github.com/SBoudrias/Inquirer.js) for interactivity. Inquirer made some large changes, including its own ESM rewrite. To take advantage of its improved performance and smaller dependency, we've upgraded. 10 | 11 | The API is completely different, resulting in changes to sf-plugins-core. The new philopsophy is 12 | 13 | 1. provide limited, simplified prompts for common use cases 14 | 2. plugins that need more advanced propting should import the parts of inquirer that they need 15 | 16 | ### Changes 17 | 18 | The `Prompter` class is removed. 19 | 20 | SfCommand contains two prompt methods 21 | 22 | 1. `confirm` provides boolean confirmation prompts 23 | 2. `secretPrompt` takes masked string input from the user 24 | 25 | Unlike the inquirer base prompts (`confirm` and `password`, respectively) these have a built-in default timeout. Both take an object parameter that lets you change the timeout (confirm previously took a series of parameters) 26 | 27 | These methods are also built into the `stubPrompter` method for simplified test stubbing. 28 | 29 | If your command relies heavily on the old inquirer/prompt structure, it's possible to import that as a dependency. 30 | 31 | ### Reorganized exports 32 | 33 | There are more "standalone" exports available. See package.json#exports for options that don't invole importing the entire library. 34 | 35 | Also removed is the "barrel of Ux". Import what you need. 36 | 37 | ## Breaking type changes 38 | 39 | ### SfCommand.project 40 | 41 | Project was typed as an `SfProject` but could be undefined when `requiresProject` wasn't set to true on a command. It's now typed as `SfProject | undefined`. If your command sets `requiresProject = true` you can safely assert `this.project!`. 42 | 43 | ### SfCommand.catch 44 | 45 | Catch was previously typed to return an error, but it always threw the error. It's now properly typed as `never`. If you extended `catch`, your method should also return `never`. 46 | 47 | ### Why 5=>7 What happened to 6? 48 | 49 | The CI published it as 7; we just try to keep the robots happy. 50 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | [![NPM](https://img.shields.io/npm/v/@salesforce/sf-plugins-core.svg)](https://www.npmjs.com/package/@salesforce/sf-plugins-core) 2 | 3 | # Description 4 | 5 | The @salesforce/sf-plugins-core provides utilities for writing [sf](https://github.com/salesforcecli/cli) plugins. 6 | 7 | Docs: [https://salesforcecli.github.io/sf-plugins-core](https://salesforcecli.github.io/sf-plugins-core) 8 | 9 | ## SfCommand Abstract Class 10 | 11 | The SfCommand abstract class extends [@oclif/core's Command class](https://github.com/oclif/core/blob/main/src/command.ts) for examples of how to build a definition. 12 | ) class and adds useful extensions to ease the development of commands for use in the Salesforce Unified CLI. 13 | 14 | - SfCommand takes a generic type that defines the success JSON result 15 | - Enable the json flag support by default 16 | - Provides functions that help place success messages, warnings and errors into the correct location in JSON results 17 | - Enables additional help sections to the standard oclif command help output 18 | - Provides access to the [cli-ux cli actions](https://github.com/oclif/cli-ux#cliaction). This avoids having to import that interface from cli-ux and manually handling the `--json` flag. 19 | - Provides simple, stubbable prompts for confirmation and secrets 20 | 21 | ## Flags 22 | 23 | Flags is a convenience reference to [@oclif/core#Flags](https://github.com/oclif/core/blob/main/src/flags.ts) 24 | 25 | ### Specialty Flags 26 | 27 | These flags can be imported into a command and used like any other flag. See code examples in the links 28 | 29 | - [orgApiVersionFlag](src/flags/orgApiVersion.ts) 30 | - specifies a Salesforce API version. 31 | - reads from Config (if available) 32 | - validates version is still active 33 | - warns if version if deprecated 34 | - [requiredOrgFlag](src/flags/orgFlags.ts) 35 | - accepts a username or alias 36 | - aware of configuration defaults 37 | - throws if org or default doesn't exist or can't be found 38 | - [optionalOrgFlag](src/flags/orgFlags.ts) 39 | - accepts a username or alias 40 | - aware of configuration defaults 41 | - might be undefined if an org isn't found 42 | - [requiredHubFlag](src/flags/orgFlags.ts) 43 | - accepts a username or alias 44 | - aware of configuration defaults 45 | - throws if org or default doesn't exist or can't be found 46 | - throws if an org is found but is not a dev hub 47 | - [durationFlag](src/flags/duration.ts) 48 | - specify a unit 49 | - optionally specify a min, max, and defaultValue 50 | - returns a [Duration](https://github.com/forcedotcom/kit/blob/main/src/duration.ts) 51 | - can be undefined if you don't set the default 52 | - [salesforceIdFlag](src/flags/salesforceId.ts) 53 | - validates that IDs are valid salesforce ID 54 | - optionally restrict to 15/18 char 55 | - optionally require it to be begin with a certain prefix 56 | 57 | ### Unit Test Helpers 58 | 59 | Want to verify SfCommand Ux behavior (warnings, tables, spinners, prompts)? Check out the functions in `stubUx``. 60 | -------------------------------------------------------------------------------- /SECURITY.md: -------------------------------------------------------------------------------- 1 | ## Security 2 | 3 | Please report any security issue to [security@salesforce.com](mailto:security@salesforce.com) 4 | as soon as it is discovered. This library limits its runtime dependencies in 5 | order to reduce the total cost of ownership as much as can be, but all consumers 6 | should remain vigilant and have their security stakeholders review all third-party 7 | products (3PP) like this one and their dependencies. 8 | -------------------------------------------------------------------------------- /messages/messages.md: -------------------------------------------------------------------------------- 1 | # error.prefix 2 | 3 | Error%s: 4 | 5 | # warning.security 6 | 7 | This command will expose sensitive information that allows for subsequent activity using your current authenticated session. Sharing this information is equivalent to logging someone in under the current credential, resulting in unintended access and escalation of privilege. For additional information, please review the authorization section of the 8 | 9 | # errors.RequiresProject 10 | 11 | This command is required to run from within a Salesforce project directory. 12 | 13 | # errors.InvalidIdLength 14 | 15 | The id must be %s characters. 16 | 17 | # errors.InvalidIdLength.or 18 | 19 | or 20 | 21 | # errors.InvalidId 22 | 23 | The id is invalid. 24 | 25 | # errors.InvalidPrefix 26 | 27 | The id must begin with %s. 28 | 29 | # errors.NoDefaultEnv 30 | 31 | No default environment found. Use -o or --target-org to specify an environment. 32 | 33 | # errors.NoDefaultDevHub 34 | 35 | No default dev hub found. Use -v or --target-dev-hub to specify an environment. 36 | 37 | # errors.NotADevHub 38 | 39 | The specified org %s is not a Dev Hub. 40 | 41 | # flags.targetOrg.summary 42 | 43 | Username or alias of the target org. Not required if the `target-org` configuration variable is already set. 44 | 45 | # flags.optionalTargetOrg.summary 46 | 47 | Username or alias of the target org. 48 | 49 | # flags.targetDevHubOrg.summary 50 | 51 | Username or alias of the Dev Hub org. Not required if the `target-dev-hub` configuration variable is already set. 52 | 53 | # flags.optionalTargetDevHubOrg.summary 54 | 55 | Username or alias of the Dev Hub org. 56 | 57 | # flags.apiVersion.description 58 | 59 | Override the api version used for api requests made by this command 60 | 61 | # flags.apiVersion.overrideWarning 62 | 63 | org-api-version configuration overridden at %s 64 | 65 | # flags.apiVersion.warning.deprecated 66 | 67 | API versions up to %s are deprecated. See %s for more information. 68 | 69 | # errors.InvalidApiVersion 70 | 71 | %s is not a valid API version. It should end in '.0' like '54.0'. 72 | 73 | # errors.RetiredApiVersion 74 | 75 | The API version must be greater than %s. 76 | 77 | # errors.InvalidDuration 78 | 79 | The value must be an integer. 80 | 81 | # errors.DurationBounds 82 | 83 | The value must be between %s and %s (inclusive). 84 | 85 | # errors.DurationBoundsMin 86 | 87 | The value must be at least %s. 88 | 89 | # errors.DurationBoundsMax 90 | 91 | The value must be no more than %s. 92 | 93 | # warning.prefix 94 | 95 | Warning: 96 | 97 | # warning.loglevel 98 | 99 | The loglevel flag is no longer in use on this command. You may use it without error, but it will be ignored. 100 | Set the log level using the `SFDX_LOG_LEVEL` environment variable. 101 | 102 | # actions.tryThis 103 | 104 | Try this: 105 | 106 | # warning.CommandInBeta 107 | 108 | This command is currently in beta. Any aspect of this command can change without advanced notice. Don't use beta commands in your scripts. 109 | 110 | # warning.CommandInPreview 111 | 112 | This command is currently in developer preview. Developer preview commands will likely change before shipping, use at your own risk. Don't use developer preview commands in your scripts. 113 | 114 | # error.InvalidArgumentFormat 115 | 116 | Error in the following argument 117 | %s 118 | Set varargs with this format: key=value or key="value with spaces" 119 | 120 | # error.DuplicateArgument 121 | 122 | Found duplicate argument %s. 123 | 124 | # flags.flags-dir.summary 125 | 126 | Import flag values from a directory. 127 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "@salesforce/sf-plugins-core", 3 | "version": "12.2.2", 4 | "description": "Utils for writing Salesforce CLI plugins", 5 | "main": "lib/exported", 6 | "types": "lib/exported.d.ts", 7 | "type": "module", 8 | "license": "Apache-2.0", 9 | "scripts": { 10 | "build": "wireit", 11 | "clean": "sf-clean", 12 | "clean-all": "sf-clean all", 13 | "compile": "wireit", 14 | "docs": "sf-docs", 15 | "fix-license": "eslint src test --fix --rule \"header/header: [2]\"", 16 | "format": "wireit", 17 | "link-check": "wireit", 18 | "lint": "wireit", 19 | "lint-fix": "yarn sf-lint --fix", 20 | "postcompile": "tsc -p test", 21 | "prepack": "sf-prepack", 22 | "prepare": "sf-install", 23 | "test": "wireit", 24 | "test:only": "wireit" 25 | }, 26 | "exports": { 27 | "./SfCommand": "./lib/SfCommand.js", 28 | "./Flags": "./lib/flags/flags.js", 29 | "./Ux": "./lib/ux/ux.js", 30 | "./StandardColors": "./lib/ux/standardColors.js", 31 | ".": "./lib/exported.js" 32 | }, 33 | "repository": "salesforcecli/sf-plugins-core", 34 | "bugs": { 35 | "url": "https://github.com/salesforcecli/sf-plugins-core/issues" 36 | }, 37 | "homepage": "https://github.com/salesforcecli/sf-plugins-core#readme", 38 | "files": [ 39 | "lib", 40 | "!lib/**/*.map", 41 | "/messages" 42 | ], 43 | "engines": { 44 | "node": ">=18.0.0" 45 | }, 46 | "dependencies": { 47 | "@inquirer/confirm": "^3.1.22", 48 | "@inquirer/password": "^2.2.0", 49 | "@oclif/core": "^4.3.0", 50 | "@oclif/table": "^0.4.6", 51 | "@salesforce/core": "^8.10.0", 52 | "@salesforce/kit": "^3.2.3", 53 | "@salesforce/ts-types": "^2.0.12", 54 | "ansis": "^3.3.2", 55 | "cli-progress": "^3.12.0", 56 | "terminal-link": "^3.0.0" 57 | }, 58 | "devDependencies": { 59 | "@inquirer/type": "^1.5.2", 60 | "@oclif/test": "^4.1.9", 61 | "@salesforce/dev-scripts": "^11.0.2", 62 | "@types/cli-progress": "^3.11.6", 63 | "eslint-plugin-sf-plugin": "^1.20.15", 64 | "ts-node": "^10.9.2", 65 | "typescript": "^5.5.4" 66 | }, 67 | "publishConfig": { 68 | "access": "public" 69 | }, 70 | "wireit": { 71 | "build": { 72 | "dependencies": [ 73 | "compile", 74 | "lint" 75 | ] 76 | }, 77 | "compile": { 78 | "command": "tsc -p . --pretty --incremental", 79 | "files": [ 80 | "src/**/*.ts", 81 | "**/tsconfig.json", 82 | "messages/**" 83 | ], 84 | "output": [ 85 | "lib/**", 86 | "*.tsbuildinfo" 87 | ], 88 | "clean": "if-file-deleted" 89 | }, 90 | "format": { 91 | "command": "prettier --write \"+(src|test|schemas)/**/*.+(ts|js|json)|command-snapshot.json\"", 92 | "files": [ 93 | "src/**/*.ts", 94 | "test/**/*.ts", 95 | "schemas/**/*.json", 96 | "command-snapshot.json", 97 | ".prettier*" 98 | ], 99 | "output": [] 100 | }, 101 | "lint": { 102 | "command": "eslint src test --color --cache --cache-location .eslintcache", 103 | "files": [ 104 | "src/**/*.ts", 105 | "test/**/*.ts", 106 | "messages/**", 107 | "**/.eslint*", 108 | "**/tsconfig.json" 109 | ], 110 | "output": [] 111 | }, 112 | "test:compile": { 113 | "command": "tsc -p \"./test\" --pretty", 114 | "files": [ 115 | "test/**/*.ts", 116 | "**/tsconfig.json" 117 | ], 118 | "output": [] 119 | }, 120 | "test": { 121 | "dependencies": [ 122 | "test:only", 123 | "test:compile", 124 | "link-check" 125 | ] 126 | }, 127 | "test:only": { 128 | "command": "nyc mocha \"test/**/*.test.ts\"", 129 | "env": { 130 | "FORCE_COLOR": "2" 131 | }, 132 | "files": [ 133 | "test/**/*.ts", 134 | "src/**/*.ts", 135 | "**/tsconfig.json", 136 | ".mocha*", 137 | "!*.nut.ts", 138 | ".nycrc" 139 | ], 140 | "output": [] 141 | }, 142 | "link-check": { 143 | "command": "node -e \"process.exit(process.env.CI ? 0 : 1)\" || linkinator \"**/*.md\" --skip \"CHANGELOG.md|node_modules|test/|confluence.internal.salesforce.com|my.salesforce.com|localhost|%s\" --markdown --retry --directory-listing --verbosity error", 144 | "files": [ 145 | "./*.md", 146 | "./!(CHANGELOG).md", 147 | "messages/**/*.md" 148 | ], 149 | "output": [] 150 | } 151 | } 152 | } 153 | -------------------------------------------------------------------------------- /src/SfCommandError.ts: -------------------------------------------------------------------------------- 1 | /* 2 | * Copyright (c) 2023, salesforce.com, inc. 3 | * All rights reserved. 4 | * Licensed under the BSD 3-Clause license. 5 | * For full license text, see LICENSE.txt file in the repo root or https://opensource.org/licenses/BSD-3-Clause 6 | */ 7 | import { inspect } from 'node:util'; 8 | import { SfError, StructuredMessage } from '@salesforce/core'; 9 | import { AnyJson } from '@salesforce/ts-types'; 10 | import { computeErrorCode } from './errorHandling.js'; 11 | 12 | // These types are 90% the same as SfErrorOptions (but they aren't exported to extend) 13 | type ErrorDataProperties = AnyJson; 14 | export type SfCommandErrorOptions = { 15 | message: string; 16 | exitCode: number; 17 | code: string; 18 | name?: string; 19 | commandName: string; 20 | data?: T; 21 | cause?: unknown; 22 | context?: string; 23 | actions?: string[]; 24 | result?: unknown; 25 | warnings?: Array; 26 | }; 27 | 28 | type SfCommandErrorJson = SfCommandErrorOptions & { 29 | name: string; 30 | status: number; 31 | stack?: string; 32 | cause?: string; 33 | }; 34 | 35 | export class SfCommandError extends SfError { 36 | public status: number; 37 | public commandName: string; 38 | public warnings?: Array; 39 | public result?: unknown; 40 | public skipOclifErrorHandling: boolean; 41 | public oclif: { exit: number }; 42 | 43 | /** 44 | * SfCommandError is meant to wrap errors from `SfCommand.catch()` for a common 45 | * error format to be logged, sent to telemetry, and re-thrown. Do not create 46 | * instances from the constructor. Call the static method, `SfCommandError.from()` 47 | * and use the returned `SfCommandError`. 48 | */ 49 | private constructor(input: SfCommandErrorOptions) { 50 | super(input.message, input.name, input.actions, input.exitCode, input.cause); 51 | this.data = input.data; 52 | this.status = input.exitCode; 53 | this.warnings = input.warnings; 54 | this.skipOclifErrorHandling = true; 55 | this.commandName = input.commandName; 56 | this.code = input.code; 57 | this.result = input.result; 58 | this.oclif = { exit: input.exitCode }; 59 | this.context = input.context ?? input.commandName; 60 | } 61 | 62 | public static from( 63 | err: Error | SfError | SfCommandError, 64 | commandName: string, 65 | warnings?: Array 66 | ): SfCommandError { 67 | // SfError.wrap() does most of what we want so start with that. 68 | const sfError = SfError.wrap(err); 69 | const exitCode = computeErrorCode(err); 70 | return new this({ 71 | message: sfError.message, 72 | name: err.name ?? 'Error', 73 | actions: 'actions' in err ? err.actions : undefined, 74 | exitCode, 75 | code: 'code' in err && err.code ? err.code : exitCode.toString(10), 76 | cause: sfError.cause, 77 | commandName: 'commandName' in err ? err.commandName : commandName, 78 | data: 'data' in err ? err.data : undefined, 79 | result: 'result' in err ? err.result : undefined, 80 | context: 'context' in err ? err.context : commandName, 81 | warnings, 82 | }); 83 | } 84 | 85 | public toJson(): SfCommandErrorJson { 86 | return { 87 | // toObject() returns name, message, exitCode, actions, context, data 88 | ...this.toObject(), 89 | stack: this.stack, 90 | cause: inspect(this.cause), 91 | warnings: this.warnings, 92 | code: this.code, 93 | status: this.status, 94 | commandName: this.commandName, 95 | result: this.result, 96 | }; 97 | } 98 | 99 | public appendErrorSuggestions(): void { 100 | const output = 101 | // @ts-expect-error error's causes aren't typed, this is what's returned from flag parsing errors 102 | // eslint-disable-next-line @typescript-eslint/no-unsafe-member-access 103 | (this.cause?.parse?.output?.raw as Array<{ flag: string; input: string; type: 'flag' | 'arg' }>) ?? []; 104 | 105 | /* 106 | if there's a group of args, and additional args separated, we could have multiple suggestions 107 | --first my first --second my second => 108 | try this: 109 | --first "my first" 110 | --second "my second" 111 | */ 112 | 113 | const aggregator: Array<{ flag: string; args: string[] }> = []; 114 | output.forEach((k, i) => { 115 | let argCounter = i + 1; 116 | if (k.type === 'flag' && output[argCounter]?.type === 'arg') { 117 | const args: string[] = []; 118 | while (output[argCounter]?.type === 'arg') { 119 | args.push(output[argCounter].input); 120 | argCounter++; 121 | } 122 | aggregator.push({ flag: k.flag, args: [k.input, ...args] }); 123 | } 124 | }); 125 | 126 | this.actions ??= []; 127 | this.actions.push(...aggregator.map((cause) => `--${cause.flag} "${cause.args.join(' ')}"`)); 128 | } 129 | } 130 | -------------------------------------------------------------------------------- /src/compatibility.ts: -------------------------------------------------------------------------------- 1 | /* 2 | * Copyright (c) 2020, salesforce.com, inc. 3 | * All rights reserved. 4 | * Licensed under the BSD 3-Clause license. 5 | * For full license text, see LICENSE.txt file in the repo root or https://opensource.org/licenses/BSD-3-Clause 6 | */ 7 | 8 | import { Flags } from '@oclif/core'; 9 | import { Messages } from '@salesforce/core/messages'; 10 | import { orgApiVersionFlag } from './flags/orgApiVersion.js'; 11 | import { optionalHubFlag, optionalOrgFlag, requiredHubFlag, requiredOrgFlag } from './flags/orgFlags.js'; 12 | 13 | /** 14 | * Adds an alias for the deprecated sfdx-style "apiversion" and provides a warning if it is used 15 | * See orgApiVersionFlag for full details 16 | * 17 | * @deprecated 18 | * @example 19 | * ``` 20 | * import { Flags } from '@salesforce/sf-plugins-core'; 21 | * public static flags = { 22 | * 'api-version': Flags.orgApiVersion({ 23 | * char: 'a', 24 | * description: 'api version for the org' 25 | * }), 26 | * } 27 | * ``` 28 | */ 29 | export const orgApiVersionFlagWithDeprecations = orgApiVersionFlag({ 30 | aliases: ['apiversion'], 31 | deprecateAliases: true, 32 | }); 33 | 34 | Messages.importMessagesDirectoryFromMetaUrl(import.meta.url); 35 | const messages = Messages.loadMessages('@salesforce/sf-plugins-core', 'messages'); 36 | /** 37 | * Use only for commands that maintain sfdx compatibility. 38 | * Flag will be hidden and will show a warning if used. 39 | * Flag does *not* set the loglevel 40 | * 41 | * @deprecated 42 | * 43 | */ 44 | export const loglevel = Flags.string({ 45 | hidden: true, 46 | deprecated: { 47 | message: messages.getMessage('warning.loglevel'), 48 | }, 49 | }); 50 | 51 | const deprecatedOrgAliases = { 52 | aliases: ['targetusername', 'u'], 53 | deprecateAliases: true, 54 | }; 55 | 56 | /** 57 | * @deprecated 58 | */ 59 | export const optionalOrgFlagWithDeprecations = optionalOrgFlag({ 60 | ...deprecatedOrgAliases, 61 | }); 62 | 63 | /** 64 | * @deprecated 65 | */ 66 | export const requiredOrgFlagWithDeprecations = requiredOrgFlag({ 67 | ...deprecatedOrgAliases, 68 | required: true, 69 | }); 70 | 71 | /** 72 | * @deprecated 73 | */ 74 | export const requiredHubFlagWithDeprecations = requiredHubFlag({ 75 | aliases: ['targetdevhubusername'], 76 | deprecateAliases: true, 77 | required: true, 78 | }); 79 | 80 | /** 81 | * @deprecated 82 | */ 83 | export const optionalHubFlagWithDeprecations = optionalHubFlag({ 84 | aliases: ['targetdevhubusername'], 85 | deprecateAliases: true, 86 | required: false, 87 | }); 88 | 89 | export type ArrayWithDeprecationOptions = { 90 | // prevent invalid options from being passed 91 | multiple?: true; 92 | // parse is disallowed because we have to overwrite it 93 | parse?: undefined; 94 | }; 95 | /** 96 | * @deprecated. Use `multiple: true` with `delimiter: ','` instead 97 | */ 98 | export const arrayWithDeprecation = Flags.custom({ 99 | multiple: true, 100 | delimiter: ',', 101 | }); 102 | -------------------------------------------------------------------------------- /src/errorFormatting.ts: -------------------------------------------------------------------------------- 1 | /* 2 | * Copyright (c) 2023, salesforce.com, inc. 3 | * All rights reserved. 4 | * Licensed under the BSD 3-Clause license. 5 | * For full license text, see LICENSE.txt file in the repo root or https://opensource.org/licenses/BSD-3-Clause 6 | */ 7 | 8 | import { inspect } from 'node:util'; 9 | import type { Ansis } from 'ansis'; 10 | import { Mode, Messages, envVars, SfError } from '@salesforce/core'; 11 | import { AnyJson, ensureString, isAnyJson } from '@salesforce/ts-types'; 12 | import { StandardColors } from './ux/standardColors.js'; 13 | import { SfCommandError } from './SfCommandError.js'; 14 | import { Ux } from './ux/ux.js'; 15 | 16 | Messages.importMessagesDirectoryFromMetaUrl(import.meta.url); 17 | const messages = Messages.loadMessages('@salesforce/sf-plugins-core', 'messages'); 18 | 19 | /** 20 | * Format errors and actions for human consumption. Adds 'Error ():', 21 | * When there are actions, we add 'Try this:' in blue 22 | * followed by each action in red on its own line. 23 | * If Error.code is present it is output last in parentheses 24 | * 25 | * @returns {string} Returns decorated messages. 26 | */ 27 | 28 | /** 29 | * Utility function to format actions lines 30 | * 31 | * @param actions 32 | * @param options 33 | * @private 34 | */ 35 | export const formatActions = ( 36 | actions: string[], 37 | options: { actionColor: Ansis } = { actionColor: StandardColors.info } 38 | ): string[] => 39 | actions.length 40 | ? [ 41 | `\n${StandardColors.info(messages.getMessage('actions.tryThis'))}\n`, 42 | ...actions.map((a) => `${options.actionColor(a)}`), 43 | ] 44 | : []; 45 | 46 | export const formatError = (error: SfCommandError): string => 47 | [ 48 | `${formatErrorPrefix(error)} ${error.message}`, 49 | formatMultipleErrorMessages(error), 50 | ...formatActions(error.actions ?? []), 51 | error.stack && envVars.getString('SF_ENV') === Mode.DEVELOPMENT 52 | ? StandardColors.info(`\n*** Internal Diagnostic ***\n\n${inspect(error)}\n******\n`) 53 | : [], 54 | ].join('\n'); 55 | 56 | const formatErrorPrefix = (error: SfCommandError): string => 57 | `${StandardColors.error(messages.getMessage('error.prefix', [formatErrorCode(error)]))}`; 58 | 59 | const formatErrorCode = (error: SfCommandError): string => 60 | typeof error.code === 'string' || typeof error.code === 'number' ? ` (${error.code})` : ''; 61 | 62 | type JsforceApiError = { 63 | errorCode: string; 64 | message?: AnyJson; 65 | }; 66 | 67 | const isJsforceApiError = (item: AnyJson): item is JsforceApiError => 68 | typeof item === 'object' && item !== null && !Array.isArray(item) && ('errorCode' in item || 'message' in item); 69 | 70 | const formatMultipleErrorMessages = (error: SfCommandError): string => { 71 | if (error.code === 'MULTIPLE_API_ERRORS' && error.cause) { 72 | const errorData = getErrorData(error.cause); 73 | if (errorData && Array.isArray(errorData) && errorData.length > 0) { 74 | const errors = errorData.filter(isJsforceApiError).map((d) => ({ 75 | errorCode: d.errorCode, 76 | message: ensureString(d.message ?? ''), 77 | })); 78 | 79 | const ux = new Ux(); 80 | return ux.makeTable({ 81 | data: errors, 82 | columns: [ 83 | { key: 'errorCode', name: 'Error Code' }, 84 | { key: 'message', name: 'Message' }, 85 | ], 86 | }); 87 | } 88 | } 89 | return ''; 90 | }; 91 | 92 | /** 93 | * Utility function to extract error data from an error object. 94 | * Recursively traverses the error chain to find the first error that contains data. 95 | * Returns undefined if no error in the chain contains data or if the input is not an Error/SfError. 96 | * 97 | * This is used in the top-level catch in sfCommand for deeply-nested error data. 98 | * 99 | * @param error - The error object to extract data from 100 | * @returns The error data if found, undefined otherwise 101 | */ 102 | const getErrorData = (error: unknown): AnyJson | undefined => { 103 | if (!(error instanceof Error || error instanceof SfError)) return undefined; 104 | 105 | if ('data' in error && error.data && isAnyJson(error.data)) { 106 | return error.data; 107 | } else if (error.cause) { 108 | return getErrorData(error.cause); 109 | } else { 110 | return undefined; 111 | } 112 | }; 113 | -------------------------------------------------------------------------------- /src/errorHandling.ts: -------------------------------------------------------------------------------- 1 | /* 2 | * Copyright (c) 2023, salesforce.com, inc. 3 | * All rights reserved. 4 | * Licensed under the BSD 3-Clause license. 5 | * For full license text, see LICENSE.txt file in the repo root or https://opensource.org/licenses/BSD-3-Clause 6 | */ 7 | 8 | import { SfError } from '@salesforce/core/sfError'; 9 | import { Errors } from '@oclif/core'; 10 | 11 | /** 12 | * 13 | * Takes an error and returns an exit code. 14 | * Logic: 15 | * - If it looks like a gack, use that code (20) 16 | * - If it looks like a TypeError, use that code (10) 17 | * - use the exitCode if it is a number 18 | * - use the code if it is a number, or 1 if it is present not a number 19 | * - use the process exitCode 20 | * - default to 1 21 | */ 22 | export const computeErrorCode = (e: Error | SfError | Errors.CLIError): number => { 23 | // regardless of the exitCode, we'll set gacks and TypeError to a specific exit code 24 | if (errorIsGack(e)) { 25 | return 20; 26 | } 27 | 28 | if (errorIsTypeError(e)) { 29 | return 10; 30 | } 31 | 32 | if (isOclifError(e) && typeof e.oclif.exit === 'number') { 33 | return e.oclif.exit; 34 | } 35 | 36 | if ('exitCode' in e && typeof e.exitCode === 'number') { 37 | return e.exitCode; 38 | } 39 | 40 | if ('code' in e) { 41 | return typeof e.code !== 'number' ? 1 : e.code; 42 | } 43 | 44 | return typeof process.exitCode === 'number' ? process.exitCode : 1; 45 | }; 46 | 47 | /** identifies gacks via regex. Searches the error message, stack, and recursively checks the cause chain */ 48 | export const errorIsGack = (error: Error | SfError): boolean => { 49 | /** see test for samples */ 50 | const gackRegex = /\d{9,}-\d{3,} \(-?\d{7,}\)/; 51 | return ( 52 | gackRegex.test(error.message) || 53 | (typeof error.stack === 'string' && gackRegex.test(error.stack)) || 54 | // recurse down through the error cause tree to find a gack 55 | ('cause' in error && error.cause instanceof Error && errorIsGack(error.cause)) 56 | ); 57 | }; 58 | 59 | /** identifies TypeError. Searches the error message, stack, and recursively checks the cause chain */ 60 | export const errorIsTypeError = (error: Error | SfError): boolean => 61 | error instanceof TypeError || 62 | error.name === 'TypeError' || 63 | error.message.includes('TypeError') || 64 | Boolean(error.stack?.includes('TypeError')) || 65 | ('cause' in error && error.cause instanceof Error && errorIsTypeError(error.cause)); 66 | 67 | /** custom typeGuard for handling the fact the SfCommand doesn't know about oclif error structure */ 68 | const isOclifError = (e: T): e is T & Errors.CLIError => 69 | 'oclif' in e ? true : false; 70 | -------------------------------------------------------------------------------- /src/exported.ts: -------------------------------------------------------------------------------- 1 | /* 2 | * Copyright (c) 2022, salesforce.com, inc. 3 | * All rights reserved. 4 | * Licensed under the BSD 3-Clause license. 5 | * For full license text, see LICENSE.txt file in the repo root or https://opensource.org/licenses/BSD-3-Clause 6 | */ 7 | 8 | export { toHelpSection, parseVarArgs } from './util.js'; 9 | export { Progress } from './ux/progress.js'; 10 | export { Spinner } from './ux/spinner.js'; 11 | export { Ux } from './ux/ux.js'; 12 | export { convertToNewTableAPI } from './ux/table.js'; 13 | export { StandardColors } from './ux/standardColors.js'; 14 | 15 | export { SfCommand, SfCommandInterface } from './sfCommand.js'; 16 | export * from './compatibility.js'; 17 | export * from './stubUx.js'; 18 | export { Flags } from './flags/flags.js'; 19 | export { prompts } from './ux/prompts.js'; 20 | -------------------------------------------------------------------------------- /src/flags/duration.ts: -------------------------------------------------------------------------------- 1 | /* 2 | * Copyright (c) 2020, salesforce.com, inc. 3 | * All rights reserved. 4 | * Licensed under the BSD 3-Clause license. 5 | * For full license text, see LICENSE.txt file in the repo root or https://opensource.org/licenses/BSD-3-Clause 6 | */ 7 | import { Flags } from '@oclif/core'; 8 | import { Messages } from '@salesforce/core/messages'; 9 | import { Duration } from '@salesforce/kit'; 10 | 11 | Messages.importMessagesDirectoryFromMetaUrl(import.meta.url); 12 | const messages = Messages.loadMessages('@salesforce/sf-plugins-core', 'messages'); 13 | 14 | type DurationUnit = Lowercase; 15 | 16 | export type DurationFlagConfig = { 17 | unit: Required; 18 | defaultValue?: number; 19 | min?: number; 20 | max?: number; 21 | }; 22 | 23 | /** 24 | * Duration flag with built-in default and min/max validation 25 | * You must specify a unit 26 | * Defaults to undefined if you don't specify a default 27 | * 28 | * @example 29 | * 30 | * ``` 31 | * import { Flags } from '@salesforce/sf-plugins-core'; 32 | * public static flags = { 33 | * wait: Flags.duration({ 34 | * min: 1, 35 | * unit: 'minutes' 36 | * defaultValue: 33, 37 | * char: 'w', 38 | * description: 'Wait time in minutes' 39 | * }), 40 | * } 41 | * ``` 42 | */ 43 | export const durationFlag = Flags.custom({ 44 | // eslint-disable-next-line @typescript-eslint/require-await 45 | parse: async (input, _, opts) => validate(input, opts), 46 | // eslint-disable-next-line @typescript-eslint/require-await 47 | default: async (context) => 48 | typeof context.options.defaultValue === 'number' 49 | ? toDuration(context.options.defaultValue, context.options.unit) 50 | : undefined, 51 | // eslint-disable-next-line @typescript-eslint/require-await 52 | defaultHelp: async (context) => 53 | typeof context.options.defaultValue === 'number' 54 | ? toDuration(context.options.defaultValue, context.options.unit).toString() 55 | : undefined, 56 | }); 57 | 58 | const validate = (input: string, config: DurationFlagConfig): Duration => { 59 | const { min, max, unit } = config || {}; 60 | let parsedInput: number; 61 | 62 | try { 63 | parsedInput = parseInt(input, 10); 64 | if (typeof parsedInput !== 'number' || isNaN(parsedInput)) { 65 | throw messages.createError('errors.InvalidDuration'); 66 | } 67 | } catch (e) { 68 | throw messages.createError('errors.InvalidDuration'); 69 | } 70 | 71 | if (min && max && (parsedInput < min || parsedInput > max)) { 72 | throw messages.createError('errors.DurationBounds', [min, max]); 73 | } else if (min && parsedInput < min) { 74 | throw messages.createError('errors.DurationBoundsMin', [min]); 75 | } else if (max && parsedInput > max) { 76 | throw messages.createError('errors.DurationBoundsMax', [max]); 77 | } 78 | 79 | return toDuration(parsedInput, unit); 80 | }; 81 | 82 | const toDuration = (parsedInput: number, unit: DurationUnit): Duration => Duration[unit](parsedInput); 83 | -------------------------------------------------------------------------------- /src/flags/flags.ts: -------------------------------------------------------------------------------- 1 | /* 2 | * Copyright (c) 2023, salesforce.com, inc. 3 | * All rights reserved. 4 | * Licensed under the BSD 3-Clause license. 5 | * For full license text, see LICENSE.txt file in the repo root or https://opensource.org/licenses/BSD-3-Clause 6 | */ 7 | import { Flags as OclifFlags } from '@oclif/core'; 8 | 9 | // custom flags 10 | import { requiredOrgFlag, requiredHubFlag, optionalOrgFlag, optionalHubFlag } from './orgFlags.js'; 11 | import { salesforceIdFlag } from './salesforceId.js'; 12 | import { orgApiVersionFlag } from './orgApiVersion.js'; 13 | import { durationFlag } from './duration.js'; 14 | 15 | export const Flags = { 16 | boolean: OclifFlags.boolean, 17 | directory: OclifFlags.directory, 18 | file: OclifFlags.file, 19 | integer: OclifFlags.integer, 20 | string: OclifFlags.string, 21 | option: OclifFlags.option, 22 | url: OclifFlags.url, 23 | custom: OclifFlags.custom, 24 | duration: durationFlag, 25 | salesforceId: salesforceIdFlag, 26 | orgApiVersion: orgApiVersionFlag, 27 | requiredOrg: requiredOrgFlag, 28 | requiredHub: requiredHubFlag, 29 | optionalOrg: optionalOrgFlag, 30 | optionalHub: optionalHubFlag, 31 | }; 32 | -------------------------------------------------------------------------------- /src/flags/orgApiVersion.ts: -------------------------------------------------------------------------------- 1 | /* 2 | * Copyright (c) 2020, salesforce.com, inc. 3 | * All rights reserved. 4 | * Licensed under the BSD 3-Clause license. 5 | * For full license text, see LICENSE.txt file in the repo root or https://opensource.org/licenses/BSD-3-Clause 6 | */ 7 | import { Flags } from '@oclif/core'; 8 | import { Messages, Lifecycle, OrgConfigProperties, validateApiVersion } from '@salesforce/core'; 9 | 10 | Messages.importMessagesDirectoryFromMetaUrl(import.meta.url); 11 | const messages = Messages.loadMessages('@salesforce/sf-plugins-core', 'messages'); 12 | 13 | // versions below this are retired 14 | export const minValidApiVersion = 21; 15 | // this and all un-retired versions below it are deprecated 16 | export const maxDeprecated = 30; 17 | export const maxDeprecatedUrl = 'https://help.salesforce.com/s/articleView?id=000354473&type=1;'; 18 | 19 | /** 20 | * apiVersion for a salesforce org's rest api. 21 | * Will validate format and that the api version is still supported. 22 | * Will default to the version specified in Config, if it exists (and will provide an override warning) 23 | * 24 | * CAVEAT: unlike the apiversion flag on sfdxCommand, this does not set the version on the org/connection 25 | * We leave this up to the plugins to implement 26 | * 27 | * @example 28 | * 29 | * ``` 30 | * import { Flags } from '@salesforce/sf-plugins-core'; 31 | * public static flags = { 32 | * 'api-version': Flags.orgApiVersion({ 33 | * char: 'a', 34 | * description: 'api version for the org' 35 | * }), 36 | * } 37 | * ``` 38 | */ 39 | export const orgApiVersionFlag = Flags.custom({ 40 | parse: async (input) => validate(input), 41 | default: async () => getDefaultFromConfig(), 42 | description: messages.getMessage('flags.apiVersion.description'), 43 | }); 44 | 45 | const getDefaultFromConfig = async (): Promise => { 46 | // (perf) only import ConfigAggregator if necessary 47 | const { ConfigAggregator } = await import('@salesforce/core'); 48 | const config = await ConfigAggregator.create(); 49 | const apiVersionFromConfig = config.getInfo(OrgConfigProperties.ORG_API_VERSION)?.value as string; 50 | if (apiVersionFromConfig) { 51 | await Lifecycle.getInstance().emitWarning( 52 | messages.getMessage('flags.apiVersion.overrideWarning', [apiVersionFromConfig]) 53 | ); 54 | return validate(apiVersionFromConfig); 55 | } 56 | }; 57 | 58 | const validate = async (input: string): Promise => { 59 | // basic format check 60 | if (!validateApiVersion(input)) { 61 | throw messages.createError('errors.InvalidApiVersion', [input]); 62 | } 63 | const requestedVersion = parseInt(input, 10); 64 | if (requestedVersion < minValidApiVersion) { 65 | throw messages.createError('errors.RetiredApiVersion', [minValidApiVersion]); 66 | } 67 | if (requestedVersion <= maxDeprecated) { 68 | await Lifecycle.getInstance().emitWarning( 69 | messages.getMessage('flags.apiVersion.warning.deprecated', [maxDeprecated, maxDeprecatedUrl]) 70 | ); 71 | } 72 | return input; 73 | }; 74 | -------------------------------------------------------------------------------- /src/flags/orgFlags.ts: -------------------------------------------------------------------------------- 1 | /* 2 | * Copyright (c) 2020, salesforce.com, inc. 3 | * All rights reserved. 4 | * Licensed under the BSD 3-Clause license. 5 | * For full license text, see LICENSE.txt file in the repo root or https://opensource.org/licenses/BSD-3-Clause 6 | */ 7 | import { Flags } from '@oclif/core'; 8 | import { ConfigAggregator, Messages, Org, OrgConfigProperties } from '@salesforce/core'; 9 | 10 | Messages.importMessagesDirectoryFromMetaUrl(import.meta.url); 11 | const messages = Messages.loadMessages('@salesforce/sf-plugins-core', 'messages'); 12 | 13 | export async function maybeGetOrg(input: string): Promise; 14 | export async function maybeGetOrg(input: undefined): Promise; 15 | export async function maybeGetOrg(input?: string | undefined): Promise; 16 | export async function maybeGetOrg(input?: string | undefined): Promise { 17 | try { 18 | return await Org.create({ aliasOrUsername: input }); 19 | } catch (e) { 20 | if (!input) { 21 | return undefined; 22 | } else { 23 | throw e; 24 | } 25 | } 26 | } 27 | 28 | export const maybeGetHub = async (input?: string): Promise => { 29 | let org: Org | undefined; 30 | // user provided input, verify the org exits 31 | if (input) { 32 | org = await getOrgOrThrow(input); 33 | } else { 34 | // no input, check config for a default 35 | const aliasOrUsername = await getDefaultHub(false); 36 | // if there is a default, verify the org exists 37 | if (aliasOrUsername) { 38 | org = await getOrgOrThrow(aliasOrUsername); 39 | } 40 | } 41 | if (org) { 42 | return ensureDevHub(org, org.getUsername()); 43 | } else { 44 | return undefined; 45 | } 46 | }; 47 | 48 | export const getOrgOrThrow = async (input?: string): Promise => { 49 | const org = await maybeGetOrg(input); 50 | if (!org) { 51 | throw messages.createError('errors.NoDefaultEnv'); 52 | } 53 | return org; 54 | }; 55 | 56 | const ensureDevHub = async (org: Org, aliasOrUsername?: string): Promise => { 57 | if (await org.determineIfDevHubOrg()) { 58 | return org; 59 | } 60 | throw messages.createError('errors.NotADevHub', [aliasOrUsername ?? org.getUsername()]); 61 | }; 62 | 63 | async function getDefaultHub(throwIfNotFound: false): Promise; 64 | async function getDefaultHub(throwIfNotFound: true): Promise; 65 | async function getDefaultHub(throwIfNotFound: boolean): Promise { 66 | // check config for a default 67 | const config = await ConfigAggregator.create(); 68 | const aliasOrUsername = config.getInfo(OrgConfigProperties.TARGET_DEV_HUB)?.value as string; 69 | if (throwIfNotFound && !aliasOrUsername) { 70 | throw messages.createError('errors.NoDefaultDevHub'); 71 | } 72 | return aliasOrUsername; 73 | } 74 | 75 | export const getHubOrThrow = async (aliasOrUsername?: string): Promise => { 76 | const resolved = aliasOrUsername ?? (await getDefaultHub(true)); 77 | const org = await Org.create({ aliasOrUsername: resolved, isDevHub: true }); 78 | return ensureDevHub(org, resolved); 79 | }; 80 | 81 | /** 82 | * An optional org specified by username or alias 83 | * Will default to the default org if one is not specified. 84 | * Will not throw if the specified org and default do not exist 85 | * 86 | * @example 87 | * 88 | * ``` 89 | * import { Flags } from '@salesforce/sf-plugins-core'; 90 | * public static flags = { 91 | * // setting length or prefix 92 | * 'target-org': Flags.optionalOrg(), 93 | * // adding properties 94 | * 'flag2': Flags.optionalOrg({ 95 | * required: true, 96 | * description: 'flag2 description', 97 | * }), 98 | * } 99 | * ``` 100 | */ 101 | export const optionalOrgFlag = Flags.custom({ 102 | char: 'o', 103 | noCacheDefault: true, 104 | parse: async (input: string | undefined) => maybeGetOrg(input), 105 | summary: messages.getMessage('flags.optionalTargetOrg.summary'), 106 | default: async () => maybeGetOrg(), 107 | defaultHelp: async (context) => { 108 | if (context.options instanceof Org) { 109 | const org = context.options as Org; 110 | return org.getUsername(); 111 | } 112 | return (await maybeGetOrg())?.getUsername(); 113 | }, 114 | }); 115 | 116 | /** 117 | * A required org, specified by username or alias 118 | * Will throw if the specified org default do not exist 119 | * Will default to the default org if one is not specified. 120 | * Will throw if no default org exists and none is specified 121 | * 122 | * @example 123 | * 124 | * ``` 125 | * import { Flags } from '@salesforce/sf-plugins-core'; 126 | * public static flags = { 127 | * // setting length or prefix 128 | * 'target-org': Flags.requiredOrg(), 129 | * // adding properties 130 | * 'flag2': Flags.requiredOrg({ 131 | * required: true, 132 | * description: 'flag2 description', 133 | * char: 'o' 134 | * }), 135 | * } 136 | * ``` 137 | */ 138 | export const requiredOrgFlag = Flags.custom({ 139 | char: 'o', 140 | summary: messages.getMessage('flags.targetOrg.summary'), 141 | noCacheDefault: true, 142 | parse: async (input: string | undefined) => getOrgOrThrow(input), 143 | default: async () => getOrgOrThrow(), 144 | defaultHelp: async (context) => { 145 | if (context.options instanceof Org) { 146 | const org = context.options as Org; 147 | return org.getUsername(); 148 | } 149 | return (await maybeGetOrg())?.getUsername(); 150 | }, 151 | required: true, 152 | }); 153 | 154 | /** 155 | * A required org that is a devHub 156 | * Will throw if the specified org does not exist 157 | * Will default to the default dev hub if one is not specified 158 | * Will throw if no default deb hub exists and none is specified 159 | * 160 | * @example 161 | * 162 | * ``` 163 | * import { Flags } from '@salesforce/sf-plugins-core'; 164 | * public static flags = { 165 | * // setting length or prefix 166 | * 'target-org': requiredHub(), 167 | * // adding properties 168 | * 'flag2': requiredHub({ 169 | * required: true, 170 | * description: 'flag2 description', 171 | * char: 'h' 172 | * }), 173 | * } 174 | * ``` 175 | */ 176 | export const requiredHubFlag = Flags.custom({ 177 | char: 'v', 178 | summary: messages.getMessage('flags.targetDevHubOrg.summary'), 179 | noCacheDefault: true, 180 | parse: async (input: string | undefined) => getHubOrThrow(input), 181 | default: async () => getHubOrThrow(), 182 | defaultHelp: async (context) => { 183 | if (context.options instanceof Org) { 184 | const org = context.options as Org; 185 | return org.getUsername(); 186 | } 187 | return (await maybeGetHub())?.getUsername(); 188 | }, 189 | required: true, 190 | }); 191 | 192 | /** 193 | * An optional org that, if present, must be a devHub 194 | * Will throw if the specified org does not exist 195 | * Will default to the default dev hub if one is not specified 196 | * Will NOT throw if no default deb hub exists and none is specified 197 | * 198 | * @example 199 | * 200 | * ``` 201 | * import { Flags } from '@salesforce/sf-plugins-core'; 202 | * public static flags = { 203 | * // setting length or prefix 204 | * 'target-org': optionalHubFlag(), 205 | * // adding properties 206 | * 'flag2': optionalHubFlag({ 207 | * description: 'flag2 description', 208 | * char: 'h' 209 | * }), 210 | * } 211 | * ``` 212 | */ 213 | export const optionalHubFlag = Flags.custom({ 214 | char: 'v', 215 | summary: messages.getMessage('flags.optionalTargetDevHubOrg.summary'), 216 | noCacheDefault: true, 217 | parse: async (input: string | undefined) => maybeGetHub(input), 218 | default: async () => maybeGetHub(), 219 | defaultHelp: async (context) => { 220 | if (context.options instanceof Org) { 221 | const org = context.options as Org; 222 | return org.getUsername(); 223 | } 224 | return (await maybeGetHub())?.getUsername(); 225 | }, 226 | required: false, 227 | }); 228 | -------------------------------------------------------------------------------- /src/flags/salesforceId.ts: -------------------------------------------------------------------------------- 1 | /* 2 | * Copyright (c) 2020, salesforce.com, inc. 3 | * All rights reserved. 4 | * Licensed under the BSD 3-Clause license. 5 | * For full license text, see LICENSE.txt file in the repo root or https://opensource.org/licenses/BSD-3-Clause 6 | */ 7 | import { Flags } from '@oclif/core'; 8 | import { Messages, validateSalesforceId } from '@salesforce/core'; 9 | 10 | Messages.importMessagesDirectoryFromMetaUrl(import.meta.url); 11 | const messages = Messages.loadMessages('@salesforce/sf-plugins-core', 'messages'); 12 | 13 | export type IdFlagConfig = { 14 | /** 15 | * Can specify if the version must be 15 or 18 characters long or 'both'. Leave blank to allow either 15 or 18. 16 | */ 17 | length?: 15 | 18 | 'both'; 18 | /** 19 | * If the ID belongs to a certain sobject type, specify the 3 character prefix. 20 | */ 21 | startsWith?: string; 22 | }; 23 | 24 | /** 25 | * Id flag with built-in validation. Short character is `i` 26 | * 27 | * @example 28 | * 29 | * ``` 30 | * import { Flags } from '@salesforce/sf-plugins-core'; 31 | * public static flags = { 32 | * // set length or prefix 33 | * 'flag-name': salesforceId({ length: 15, startsWith: '00D' }), 34 | * // add flag properties 35 | * 'flag2': salesforceId({ 36 | * required: true, 37 | * description: 'flag2 description', 38 | * }), 39 | * // override the character i 40 | * 'flag3': salesforceId({ 41 | * char: 'j', 42 | * }), 43 | * } 44 | * ``` 45 | */ 46 | export const salesforceIdFlag = Flags.custom({ 47 | // eslint-disable-next-line @typescript-eslint/require-await 48 | parse: async (input, _ctx, opts) => validate(input, opts), 49 | char: 'i', 50 | }); 51 | 52 | const validate = (input: string, config?: IdFlagConfig): string => { 53 | const { length, startsWith } = config ?? {}; 54 | 55 | // If the flag doesn't specify a length or specifies "both", then let it accept both 15 or 18. 56 | const allowedIdLength = !length || length === 'both' ? [15, 18] : [length]; 57 | 58 | if (!allowedIdLength.includes(input.length)) { 59 | throw messages.createError('errors.InvalidIdLength', [ 60 | allowedIdLength.join(` ${messages.getMessage('errors.InvalidIdLength.or')} `), 61 | ]); 62 | } 63 | if (!validateSalesforceId(input)) { 64 | throw messages.createError('errors.InvalidId'); 65 | } 66 | if (startsWith && !input.startsWith(startsWith)) { 67 | throw messages.createError('errors.InvalidPrefix', [startsWith]); 68 | } 69 | return input; 70 | }; 71 | -------------------------------------------------------------------------------- /src/sfCommand.ts: -------------------------------------------------------------------------------- 1 | /* 2 | * Copyright (c) 2022, salesforce.com, inc. 3 | * All rights reserved. 4 | * Licensed under the BSD 3-Clause license. 5 | * For full license text, see LICENSE.txt file in the repo root or https://opensource.org/licenses/BSD-3-Clause 6 | */ 7 | import os from 'node:os'; 8 | import { Errors, Command, Config, HelpSection, Flags } from '@oclif/core'; 9 | import { 10 | envVars, 11 | Messages, 12 | SfProject, 13 | Lifecycle, 14 | EnvironmentVariable, 15 | SfError, 16 | ConfigAggregator, 17 | StructuredMessage, 18 | } from '@salesforce/core'; 19 | import type { AnyJson } from '@salesforce/ts-types'; 20 | import { TableOptions } from '@oclif/table'; 21 | import { Progress } from './ux/progress.js'; 22 | import { Spinner } from './ux/spinner.js'; 23 | import { Ux } from './ux/ux.js'; 24 | import { SfCommandError } from './SfCommandError.js'; 25 | import { formatActions, formatError } from './errorFormatting.js'; 26 | import { StandardColors } from './ux/standardColors.js'; 27 | import { confirm, secretPrompt, PromptInputs } from './ux/prompts.js'; 28 | 29 | Messages.importMessagesDirectoryFromMetaUrl(import.meta.url); 30 | const messages = Messages.loadMessages('@salesforce/sf-plugins-core', 'messages'); 31 | 32 | export type SfCommandInterface = { 33 | configurationVariablesSection?: HelpSection; 34 | envVariablesSection?: HelpSection; 35 | errorCodes?: HelpSection; 36 | } & Command.Class; 37 | 38 | /** 39 | * A base command that provided common functionality for all sf commands. 40 | * Functionality includes: 41 | * - JSON support 42 | * - progress bars 43 | * - spinners 44 | * - prompts 45 | * - stylized output (JSON, url, objects, headers) 46 | * - lifecycle events 47 | * - configuration variables help section 48 | * - environment variables help section 49 | * - error codes help section 50 | * 51 | * All implementations of this class need to implement the run() method. 52 | * 53 | * Additionally, all implementations of this class need to provide a generic type that describes the JSON output. 54 | * 55 | * See {@link https://github.com/salesforcecli/plugin-template-sf/blob/main/src/commands/hello/world.ts example implementation}. 56 | * 57 | * @example 58 | * 59 | * ``` 60 | * import { SfCommand } from '@salesforce/sf-plugins-core'; 61 | * export type MyJsonOutput = { success: boolean }; 62 | * export default class MyCommand extends SfCommand { 63 | * public async run(): Promise { 64 | * return { success: true }; 65 | * } 66 | * } 67 | * ``` 68 | */ 69 | 70 | export abstract class SfCommand extends Command { 71 | public static enableJsonFlag = true; 72 | /** 73 | * Add a CONFIGURATION VARIABLES section to the help output. 74 | * 75 | * @example 76 | * ``` 77 | * import { SfCommand, toHelpSection } from '@salesforce/sf-plugins-core'; 78 | * import { OrgConfigProperties } from '@salesforce/core'; 79 | * export default class MyCommand extends SfCommand { 80 | * public static configurationVariablesSection = toHelpSection( 81 | * 'CONFIGURATION VARIABLES', 82 | * OrgConfigProperties.TARGET_ORG, 83 | * OrgConfigProperties.ORG_API_VERSION, 84 | * ); 85 | * } 86 | * ``` 87 | */ 88 | public static configurationVariablesSection?: HelpSection; 89 | 90 | /** 91 | * Add an Environment VARIABLES section to the help output. 92 | * 93 | * @example 94 | * ``` 95 | * import { SfCommand, toHelpSection } from '@salesforce/sf-plugins-core'; 96 | * import { EnvironmentVariable } from '@salesforce/core'; 97 | * export default class MyCommand extends SfCommand { 98 | * public static envVariablesSection = toHelpSection( 99 | * 'ENVIRONMENT VARIABLES', 100 | * EnvironmentVariable.SF_TARGET_ORG, 101 | * EnvironmentVariable.SF_USE_PROGRESS_BAR, 102 | * ); 103 | * } 104 | * ``` 105 | */ 106 | public static envVariablesSection?: HelpSection; 107 | 108 | /** 109 | * Add an ERROR CODES section to the help output. 110 | * 111 | * @example 112 | * ``` 113 | * import { SfCommand, toHelpSection } from '@salesforce/sf-plugins-core'; 114 | * export default class MyCommand extends SfCommand { 115 | * public static errorCodes = toHelpSection( 116 | * 'ERROR CODES', 117 | * { 0: 'Success', 1: 'Failure' }, 118 | * ); 119 | * } 120 | * ``` 121 | */ 122 | public static errorCodes?: HelpSection; 123 | 124 | public static baseFlags = { 125 | 'flags-dir': Flags.directory({ 126 | summary: messages.getMessage('flags.flags-dir.summary'), 127 | helpGroup: 'GLOBAL', 128 | }), 129 | }; 130 | 131 | /** 132 | * Set to true if the command must be executed inside a Salesforce project directory. 133 | * 134 | * If set to true the command will throw an error if the command is executed outside of a Salesforce project directory. 135 | * Additionally, this.project will be set to the current Salesforce project (SfProject). 136 | * 137 | */ 138 | public static requiresProject: boolean; 139 | 140 | /** 141 | * Add a spinner to the console. {@link Spinner} 142 | */ 143 | public spinner: Spinner; 144 | 145 | /** 146 | * Add a progress bar to the console. {@link Progress} 147 | */ 148 | public progress: Progress; 149 | public project?: SfProject; 150 | 151 | /** 152 | * ConfigAggregator instance for accessing global and local configuration. 153 | */ 154 | public configAggregator!: ConfigAggregator; 155 | 156 | private warnings: SfCommand.Warning[] = []; 157 | private warningsToFlush: SfCommand.Warning[] = []; 158 | private ux: Ux; 159 | private lifecycle: Lifecycle; 160 | 161 | public constructor(argv: string[], config: Config) { 162 | super(argv, config); 163 | this.ux = new Ux({ jsonEnabled: this.jsonEnabled() }); 164 | this.progress = new Progress( 165 | this.ux.outputEnabled && envVars.getBoolean(EnvironmentVariable.SF_USE_PROGRESS_BAR, true) 166 | ); 167 | this.spinner = this.ux.spinner; 168 | this.lifecycle = Lifecycle.getInstance(); 169 | } 170 | 171 | protected get statics(): typeof SfCommand { 172 | return this.constructor as typeof SfCommand; 173 | } 174 | 175 | public jsonEnabled(): boolean { 176 | // https://developer.salesforce.com/docs/atlas.en-us.sfdx_setup.meta/sfdx_setup/sfdx_dev_cli_json_support.htm 177 | // can come from either oclif's detection of the flag's presence and truthiness OR from the env 178 | // unless it's been explicitly disabled on the command's statics 179 | return ( 180 | super.jsonEnabled() || 181 | (this.statics.enableJsonFlag !== false && 182 | envVars.getString(EnvironmentVariable.SF_CONTENT_TYPE)?.toUpperCase() === 'JSON') 183 | ); 184 | } 185 | /** 186 | * Log a success message that has the standard success message color applied. 187 | * 188 | * @param message The message to log. 189 | */ 190 | public logSuccess(message: string): void { 191 | this.log(StandardColors.success(message)); 192 | } 193 | 194 | /** 195 | * Log warning to users. If --json is enabled, then the warning will be added to the json output under the warnings property. 196 | * 197 | * @param input {@link SfCommand.Warning} The message to log. 198 | */ 199 | public warn(input: SfCommand.Warning): SfCommand.Warning { 200 | this.warnings.push(input); 201 | const message = typeof input === 'string' ? input : input.message; 202 | 203 | const colorizedArgs = [ 204 | `${StandardColors.warning(messages.getMessage('warning.prefix'))} ${message}`, 205 | ...formatActions(typeof input === 'string' ? [] : input.actions ?? []), 206 | ]; 207 | 208 | this.logToStderr(colorizedArgs.join(os.EOL)); 209 | return input; 210 | } 211 | 212 | /** 213 | * Log info message to users. 214 | * 215 | * @param input {@link SfCommand.Info} The message to log. 216 | */ 217 | public info(input: SfCommand.Info): void { 218 | const message = typeof input === 'string' ? input : input.message; 219 | this.log( 220 | [`${StandardColors.info(message)}`, ...formatActions(typeof input === 'string' ? [] : input.actions ?? [])].join( 221 | os.EOL 222 | ) 223 | ); 224 | } 225 | 226 | /** 227 | * Warn user about sensitive information (access tokens, etc...) before logging to the console. 228 | * 229 | * @param msg The message to log. 230 | */ 231 | public logSensitive(msg?: string): void { 232 | this.warn(messages.getMessage('warning.security')); 233 | this.log(msg); 234 | } 235 | 236 | /** 237 | * Display a table on the console. Will automatically be suppressed when --json flag is present. 238 | */ 239 | public table>(options: TableOptions): void { 240 | this.ux.table(options); 241 | } 242 | 243 | /** 244 | * Log a stylized url to the console. Will automatically be suppressed when --json flag is present. 245 | * 246 | * @param text The text to display for the url. 247 | * @param uri The url to display. 248 | */ 249 | public url(text: string, uri: string, params = {}): void { 250 | this.ux.url(text, uri, params); 251 | } 252 | 253 | /** 254 | * Log stylized JSON to the console. Will automatically be suppressed when --json flag is present. 255 | * 256 | * @param obj The JSON to log. 257 | */ 258 | public styledJSON(obj: AnyJson): void { 259 | this.ux.styledJSON(obj, this.config.theme?.json); 260 | } 261 | 262 | /** 263 | * Log stylized object to the console. Will automatically be suppressed when --json flag is present. 264 | * 265 | * @param obj The object to log. 266 | */ 267 | public styledObject(obj: AnyJson): void { 268 | this.ux.styledObject(obj); 269 | } 270 | 271 | /** 272 | * Log stylized header to the console. Will automatically be suppressed when --json flag is present. 273 | * 274 | * @param text the text to display as a header. 275 | */ 276 | public styledHeader(text: string): void { 277 | this.ux.styledHeader(text); 278 | } 279 | 280 | // leaving AnyJson and unknown to maintain the public API. 281 | // eslint-disable-next-line class-methods-use-this, @typescript-eslint/no-redundant-type-constituents 282 | public logJson(json: AnyJson | unknown): void { 283 | // If `--json` is enabled, then the ux instance on the class will disable output, which 284 | // means that the logJson method will not output anything. So, we need to create a new 285 | // instance of the ux class that does not have output disabled in order to log the json. 286 | new Ux().styledJSON(json as AnyJson, this.config.theme?.json); 287 | } 288 | 289 | /** 290 | * Prompt user for yes/no confirmation. 291 | * Avoid calling in --json scenarios and always provide a `--no-prompt` option for scripting 292 | * 293 | * @param message text to display. Do not include a question mark. 294 | * @param ms milliseconds to wait for user input. Defaults to 60s. Will throw an error when timeout is reached. 295 | * 296 | */ 297 | 298 | // eslint-disable-next-line class-methods-use-this 299 | public async secretPrompt({ message, ms = 60_000 }: PromptInputs): Promise { 300 | return secretPrompt({ message, ms }); 301 | } 302 | 303 | /** 304 | * Prompt user for yes/no confirmation. 305 | * Avoid calling in --json scenarios and always provide a `--no-prompt` option for scripting 306 | * 307 | * @param message text to display. Do not include a question mark or Y/N. 308 | * @param ms milliseconds to wait for user input. Defaults to 10s. Will use the default value when timeout is reached. 309 | * @param defaultAnswer boolean to set the default answer to. Defaults to false. 310 | * 311 | */ 312 | 313 | // eslint-disable-next-line class-methods-use-this 314 | public async confirm({ message, ms = 10_000, defaultAnswer = false }: PromptInputs): Promise { 315 | return confirm({ message, ms, defaultAnswer }); 316 | } 317 | 318 | public async _run(): Promise { 319 | ['SIGINT', 'SIGTERM', 'SIGBREAK', 'SIGHUP'].map((listener) => { 320 | process.on(listener, () => { 321 | this.exit(130); 322 | }); 323 | }); 324 | 325 | [this.configAggregator, this.project] = await Promise.all([ 326 | ConfigAggregator.create(), 327 | ...(this.statics.requiresProject ? [assignProject()] : []), 328 | ]); 329 | 330 | switch (this.statics.state) { 331 | case 'beta': 332 | this.warn(messages.getMessage('warning.CommandInBeta')); 333 | break; 334 | case 'preview': 335 | this.warn(messages.getMessage('warning.CommandInPreview')); 336 | break; 337 | default: 338 | break; 339 | } 340 | 341 | // eslint-disable-next-line @typescript-eslint/require-await 342 | this.lifecycle.onWarning(async (warning: string) => { 343 | this.warningsToFlush.push(warning); 344 | }); 345 | const options = { 346 | Command: this.ctor, 347 | argv: this.argv, 348 | commandId: this.id, 349 | }; 350 | // what hooks are there in the plugins? Subscribe to matching lifecycle events 351 | this.config 352 | .getPluginsList() 353 | // omit oclif and telemetry (which subscribes itself to events already) 354 | .filter((plugin) => !plugin.name.startsWith('@oclif/') && plugin.name !== '@salesforce/plugin-telemetry') 355 | .flatMap((p) => Object.entries(p.hooks)) 356 | .map(([eventName, hooksForEvent]) => { 357 | hooksForEvent.map(() => { 358 | this.lifecycle.on(eventName, async (result: AnyJson) => { 359 | await this.config.runHook(eventName, Object.assign(options, { result })); 360 | }); 361 | }); 362 | }); 363 | 364 | // eslint-disable-next-line no-underscore-dangle 365 | return super._run(); 366 | } 367 | 368 | /** 369 | * Wrap the command result into the standardized JSON structure. 370 | */ 371 | protected toSuccessJson(result: T): SfCommand.Json { 372 | return { 373 | status: typeof process.exitCode === 'number' ? process.exitCode : 0, 374 | result, 375 | warnings: this.warnings, 376 | }; 377 | } 378 | 379 | // eslint-disable-next-line @typescript-eslint/require-await 380 | protected async catch(error: Error | SfError | Errors.CLIError): Promise { 381 | // stop any spinners to prevent it from unintentionally swallowing output. 382 | // If there is an active spinner, it'll say "Error" instead of "Done" 383 | this.spinner.stop(StandardColors.error('Error')); 384 | 385 | // transform an unknown error into a SfCommandError 386 | const sfCommandError = SfCommandError.from(error, this.statics.name, this.warnings); 387 | process.exitCode = sfCommandError.exitCode; 388 | 389 | // no var args (strict = true || undefined), and unexpected arguments when parsing 390 | if ( 391 | this.statics.strict !== false && 392 | sfCommandError.exitCode === 2 && 393 | error.message.includes('Unexpected argument') 394 | ) { 395 | sfCommandError.appendErrorSuggestions(); 396 | } 397 | 398 | if (this.jsonEnabled()) { 399 | this.logJson(sfCommandError.toJson()); 400 | } else { 401 | this.logToStderr(formatError(sfCommandError)); 402 | } 403 | 404 | // Emit an event for plugin-telemetry prerun hook to pick up. 405 | // @ts-expect-error because TS is strict about the events that can be emitted on process. 406 | process.emit('sfCommandError', sfCommandError, this.id); 407 | 408 | throw sfCommandError; 409 | } 410 | 411 | // eslint-disable-next-line @typescript-eslint/require-await, @typescript-eslint/no-explicit-any 412 | protected async finally(_: Error | undefined): Promise { 413 | // flush warnings 414 | this.warningsToFlush.forEach((warning) => { 415 | this.warn(warning); 416 | }); 417 | 418 | return super.finally(_); 419 | } 420 | 421 | public abstract run(): Promise; 422 | } 423 | 424 | const assignProject = async (): Promise => { 425 | try { 426 | return await SfProject.resolve(); 427 | } catch (err) { 428 | if (err instanceof Error && err.name === 'InvalidProjectWorkspaceError') { 429 | throw messages.createError('errors.RequiresProject'); 430 | } 431 | throw err; 432 | } 433 | }; 434 | 435 | export namespace SfCommand { 436 | export type Info = StructuredMessage | string; 437 | export type Warning = StructuredMessage | string; 438 | 439 | export type Json = { 440 | status: number; 441 | result: T; 442 | warnings?: Warning[]; 443 | }; 444 | export type Error = SfCommandError; 445 | } 446 | -------------------------------------------------------------------------------- /src/stubUx.ts: -------------------------------------------------------------------------------- 1 | /* 2 | * Copyright (c) 2023, salesforce.com, inc. 3 | * All rights reserved. 4 | * Licensed under the BSD 3-Clause license. 5 | * For full license text, see LICENSE.txt file in the repo root or https://opensource.org/licenses/BSD-3-Clause 6 | */ 7 | /* eslint-disable @typescript-eslint/explicit-function-return-type */ 8 | 9 | // intentionally keep sinon out of dependencies since this is meant for test-time only 10 | // eslint-disable-next-line import/no-extraneous-dependencies 11 | import { SinonSandbox } from 'sinon'; 12 | import { SfCommand } from './sfCommand.js'; 13 | import { Spinner } from './ux/spinner.js'; 14 | import { Ux } from './ux/ux.js'; 15 | 16 | /** 17 | * Stub methods on the Ux class. 18 | * Even if you plan to make no assertions, this will silence the output to keep your test results clean 19 | * 20 | * @example 21 | * ``` 22 | * import { stubUx } from '@salesforce/sf-plugins-core'; 23 | * let stubUxStubs: ReturnType; 24 | * 25 | * // inside your beforeEach, $$ is a SinonSandbox 26 | * stubUxStubs = stubUx($$.SANDBOX); 27 | * 28 | * // inside some test 29 | * expect(stubUxStubs.log.args.flat()).to.deep.include(`foo`); 30 | * ``` 31 | * 32 | */ 33 | export function stubUx(sandbox: SinonSandbox) { 34 | return { 35 | log: sandbox.stub(Ux.prototype, 'log'), 36 | warn: sandbox.stub(Ux.prototype, 'warn'), 37 | table: sandbox.stub(Ux.prototype, 'table'), 38 | url: sandbox.stub(Ux.prototype, 'url'), 39 | styledHeader: sandbox.stub(Ux.prototype, 'styledHeader'), 40 | styledObject: sandbox.stub(Ux.prototype, 'styledObject'), 41 | styledJSON: sandbox.stub(Ux.prototype, 'styledJSON'), 42 | }; 43 | } 44 | 45 | /** 46 | * Stub methods on the Ux class. 47 | * Even if you plan to make no assertions, this will silence the output to keep your test results clean 48 | * 49 | * @example 50 | * ``` 51 | * import { stubSfCommandUx } from '@salesforce/sf-plugins-core'; 52 | * let stubSfCommandUxStubs: ReturnType; 53 | * 54 | * // inside your beforeEach, $$ is a SinonSandbox 55 | * cmdStubs = stubSfCommandUx($$.SANDBOX); 56 | * 57 | * // inside some test 58 | * expect(cmdStubs.warn.args.flat()).to.deep.include(`foo`); 59 | * ``` 60 | * 61 | */ 62 | export function stubSfCommandUx(sandbox: SinonSandbox) { 63 | return { 64 | log: sandbox.stub(SfCommand.prototype, 'log'), 65 | logJson: sandbox.stub(SfCommand.prototype, 'logJson'), 66 | logToStderr: sandbox.stub(SfCommand.prototype, 'logToStderr'), 67 | logSuccess: sandbox.stub(SfCommand.prototype, 'logSuccess'), 68 | logSensitive: sandbox.stub(SfCommand.prototype, 'logSensitive'), 69 | info: sandbox.stub(SfCommand.prototype, 'info'), 70 | warn: sandbox.stub(SfCommand.prototype, 'warn'), 71 | table: sandbox.stub(SfCommand.prototype, 'table'), 72 | url: sandbox.stub(SfCommand.prototype, 'url'), 73 | styledHeader: sandbox.stub(SfCommand.prototype, 'styledHeader'), 74 | styledObject: sandbox.stub(SfCommand.prototype, 'styledObject'), 75 | styledJSON: sandbox.stub(SfCommand.prototype, 'styledJSON'), 76 | }; 77 | } 78 | 79 | /** 80 | * Stub the SfCommand spinner. 81 | * Even if you plan to make no assertions, this will silence the output to keep your test results clean 82 | * 83 | * @example 84 | * ``` 85 | * import { stubSpinner } from '@salesforce/sf-plugins-core'; 86 | * let spinnerStubs: ReturnType; 87 | * 88 | * // inside your beforeEach, $$ is a SinonSandbox 89 | * spinnerStubs = stubSpinner($$.SANDBOX); 90 | * 91 | * // inside some test 92 | * expect(spinnerStubs.callCount).equals(1); 93 | * ``` 94 | * 95 | */ 96 | export function stubSpinner(sandbox: SinonSandbox) { 97 | return { 98 | start: sandbox.stub(Spinner.prototype, 'start'), 99 | stop: sandbox.stub(Spinner.prototype, 'stop'), 100 | }; 101 | } 102 | 103 | /** 104 | * Stub the SfCommand prompter. 105 | * 106 | * @example 107 | * ``` 108 | * import { stubPrompter } from '@salesforce/sf-plugins-core'; 109 | * let prompterStubs: ReturnType; 110 | * 111 | * // inside your beforeEach, $$ is a SinonSandbox 112 | * prompterStubs = stubPrompter($$.SANDBOX); 113 | * 114 | * // inside some test 115 | * expect(prompterStubs.confirm.firstCall.args[0]).to.equal( 116 | * messages.getMessage('confirmDelete', ['scratch', testOrg.username]) 117 | * ); 118 | * ``` 119 | * 120 | */ 121 | export function stubPrompter(sandbox: SinonSandbox) { 122 | return { 123 | secret: sandbox.stub(SfCommand.prototype, 'secretPrompt'), 124 | confirm: sandbox.stub(SfCommand.prototype, 'confirm'), 125 | }; 126 | } 127 | -------------------------------------------------------------------------------- /src/util.ts: -------------------------------------------------------------------------------- 1 | /* 2 | * Copyright (c) 2022, salesforce.com, inc. 3 | * All rights reserved. 4 | * Licensed under the BSD 3-Clause license. 5 | * For full license text, see LICENSE.txt file in the repo root or https://opensource.org/licenses/BSD-3-Clause 6 | */ 7 | 8 | import { 9 | EnvironmentVariable, 10 | OrgConfigProperties, 11 | ORG_CONFIG_ALLOWED_PROPERTIES, 12 | SfdxPropertyKeys, 13 | SFDX_ALLOWED_PROPERTIES, 14 | SUPPORTED_ENV_VARS, 15 | Messages, 16 | } from '@salesforce/core'; 17 | import { HelpSection, HelpSectionKeyValueTable } from '@oclif/core'; 18 | 19 | /** 20 | * Function to build a help section for command help. 21 | * Takes a string to be used as section header text and an array of enums 22 | * that identify the variable or property to be included in the help 23 | * body. 24 | * 25 | * @param header 26 | * @param vars 27 | */ 28 | export function toHelpSection( 29 | header: string, 30 | ...vars: Array> 31 | ): HelpSection { 32 | const body = vars 33 | .flatMap((v) => { 34 | if (typeof v === 'string') { 35 | const orgConfig = ORG_CONFIG_ALLOWED_PROPERTIES.find(({ key }) => key.toString() === v); 36 | if (orgConfig) { 37 | return { name: orgConfig.key, description: orgConfig.description }; 38 | } 39 | const sfdxProperty = SFDX_ALLOWED_PROPERTIES.find(({ key }) => key.toString() === v); 40 | if (sfdxProperty) { 41 | return { name: sfdxProperty.key.valueOf(), description: sfdxProperty.description }; 42 | } 43 | const envVar = Object.entries(SUPPORTED_ENV_VARS).find(([k]) => k === v); 44 | 45 | if (envVar) { 46 | const [eKey, data] = envVar; 47 | return { name: eKey, description: data.description }; 48 | } 49 | return undefined; 50 | } else { 51 | return Object.entries(v).map(([name, description]) => ({ name, description })); 52 | } 53 | }) 54 | .filter(isHelpSectionBodyEntry); 55 | return { header, body }; 56 | } 57 | 58 | const isHelpSectionBodyEntry = (entry: unknown): entry is HelpSectionKeyValueTable[number] => 59 | typeof entry === 'object' && entry !== null && 'name' in entry && 'description' in entry; 60 | 61 | export function parseVarArgs(args: Record, argv: string[]): Record { 62 | const final: Record = {}; 63 | const argVals = Object.values(args); 64 | 65 | // Remove arguments from varargs 66 | const varargs = argv.filter((val) => !argVals.includes(val)); 67 | 68 | // Support `config set key value` 69 | if (varargs.length === 2 && !varargs[0].includes('=')) { 70 | return { [varargs[0]]: varargs[1] }; 71 | } 72 | 73 | Messages.importMessagesDirectoryFromMetaUrl(import.meta.url); 74 | const messages = Messages.loadMessages('@salesforce/sf-plugins-core', 'messages'); 75 | 76 | // Ensure that all args are in the right format (e.g. key=value key1=value1) 77 | varargs.forEach((arg) => { 78 | const split = arg.split('='); 79 | 80 | if (split.length !== 2) { 81 | throw messages.createError('error.InvalidArgumentFormat', [arg]); 82 | } 83 | 84 | const [name, value] = split; 85 | 86 | if (final[name]) { 87 | throw messages.createError('error.DuplicateArgument', [name]); 88 | } 89 | 90 | final[name] = value || undefined; 91 | }); 92 | 93 | return final; 94 | } 95 | export const removeEmpty = (obj: Record): Record => 96 | Object.fromEntries(Object.entries(obj).filter(([, v]) => v != null)); 97 | -------------------------------------------------------------------------------- /src/ux/base.ts: -------------------------------------------------------------------------------- 1 | /* 2 | * Copyright (c) 2022, salesforce.com, inc. 3 | * All rights reserved. 4 | * Licensed under the BSD 3-Clause license. 5 | * For full license text, see LICENSE.txt file in the repo root or https://opensource.org/licenses/BSD-3-Clause 6 | */ 7 | 8 | import type { AnyFunction } from '@salesforce/ts-types'; 9 | 10 | export class UxBase { 11 | public constructor(public readonly outputEnabled: boolean) {} 12 | 13 | protected maybeNoop(fn: AnyFunction): void { 14 | if (this.outputEnabled) fn(); 15 | } 16 | } 17 | -------------------------------------------------------------------------------- /src/ux/progress.ts: -------------------------------------------------------------------------------- 1 | /* 2 | * Copyright (c) 2022, salesforce.com, inc. 3 | * All rights reserved. 4 | * Licensed under the BSD 3-Clause license. 5 | * For full license text, see LICENSE.txt file in the repo root or https://opensource.org/licenses/BSD-3-Clause 6 | */ 7 | 8 | import util from 'node:util'; 9 | import { Options, SingleBar } from 'cli-progress'; 10 | import { UxBase } from './base.js'; 11 | 12 | function progress(options: Options = {}): Progress.Bar { 13 | return new SingleBar({ noTTYOutput: Boolean(process.env.TERM === 'dumb' || !process.stdin.isTTY), ...options }); 14 | } 15 | 16 | /** 17 | * Class for display a progress bar to the console. Will automatically be suppressed if the --json flag is present. 18 | */ 19 | export class Progress extends UxBase { 20 | private static DEFAULT_OPTIONS = { 21 | title: 'PROGRESS', 22 | format: '%s | {bar} | {value}/{total} Components', 23 | barCompleteChar: '\u2588', 24 | barIncompleteChar: '\u2591', 25 | linewrap: true, 26 | }; 27 | 28 | private bar!: Progress.Bar; 29 | private total!: number; 30 | private started = false; 31 | 32 | public constructor(outputEnabled: boolean) { 33 | super(outputEnabled); 34 | } 35 | 36 | /** 37 | * Set the total number of expected components. 38 | */ 39 | public setTotal(total: number): void { 40 | this.total = total; 41 | if (this.bar) this.bar.setTotal(total); 42 | } 43 | 44 | /** 45 | * Start the progress bar. 46 | */ 47 | public start( 48 | total: number, 49 | payload: Progress.Payload = {}, 50 | options: Partial = Progress.DEFAULT_OPTIONS 51 | ): void { 52 | if (this.started) return; 53 | this.started = true; 54 | 55 | this.maybeNoop(() => { 56 | const { title, ...rest } = { ...Progress.DEFAULT_OPTIONS, ...options }; 57 | this.bar = progress({ 58 | ...rest, 59 | format: util.format(rest.format, title), 60 | }); 61 | 62 | this.bar.setTotal(total); 63 | this.bar.start(total, 0); 64 | if (Object.keys(payload).length) { 65 | this.bar.update(0, payload); 66 | } 67 | }); 68 | } 69 | 70 | /** 71 | * Update the progress bar. 72 | */ 73 | public update(num: number, payload = {}): void { 74 | if (this.bar) this.bar.update(num, payload); 75 | } 76 | 77 | /** 78 | * Update the progress bar with the final number and stop it. 79 | */ 80 | public finish(payload = {}): void { 81 | if (this.bar) { 82 | this.bar.update(this.total, payload); 83 | this.bar.stop(); 84 | } 85 | } 86 | 87 | /** 88 | * Stop the progress bar. 89 | */ 90 | public stop(): void { 91 | if (this.bar) this.bar.stop(); 92 | } 93 | } 94 | 95 | export namespace Progress { 96 | export type Bar = { 97 | start: (total: number, startValue: number, payload?: object) => void; 98 | update: (num: number, payload?: object) => void; 99 | setTotal: (num: number) => void; 100 | stop: () => void; 101 | }; 102 | 103 | export type Options = { 104 | title: string; 105 | format: string; 106 | barCompleteChar: string; 107 | barIncompleteChar: string; 108 | linewrap: boolean; 109 | noTTYOutput: boolean; 110 | }; 111 | 112 | export type Payload = Record; 113 | } 114 | -------------------------------------------------------------------------------- /src/ux/prompts.ts: -------------------------------------------------------------------------------- 1 | /* 2 | * Copyright (c) 2023, salesforce.com, inc. 3 | * All rights reserved. 4 | * Licensed under the BSD 3-Clause license. 5 | * For full license text, see LICENSE.txt file in the repo root or https://opensource.org/licenses/BSD-3-Clause 6 | */ 7 | 8 | import { setTimeout } from 'node:timers/promises'; 9 | import { SfError } from '@salesforce/core/sfError'; 10 | import type { CancelablePromise } from '@inquirer/type'; 11 | 12 | export type PromptInputs = { 13 | /** text to display. Do not include a question mark */ 14 | message: string; 15 | /** after this many ms, the prompt will time out. If a default value is provided, the default will be used. Otherwise the prompt will throw an error */ 16 | ms?: number; 17 | /** 18 | * default value to offer to the user. Will be used if the user does not respond within the timeout period. 19 | */ 20 | defaultAnswer?: T; 21 | }; 22 | 23 | export const confirm = async ({ 24 | message, 25 | ms = 10_000, 26 | defaultAnswer = false, 27 | }: PromptInputs): Promise => { 28 | const promptConfirm = (await import('@inquirer/confirm')).default; 29 | const answer = promptConfirm({ message, default: defaultAnswer }); 30 | return Promise.race([answer, handleTimeout(answer, ms, defaultAnswer)]); 31 | }; 32 | 33 | export const secretPrompt = async ({ message, ms = 60_000 }: PromptInputs): Promise => { 34 | const promptSecret = (await import('@inquirer/password')).default; 35 | const answer = promptSecret({ message }); 36 | return Promise.race([answer, handleTimeout(answer, ms)]); 37 | }; 38 | 39 | const handleTimeout = async (answer: CancelablePromise, ms: number, defaultAnswer?: T): Promise => 40 | setTimeout(ms, undefined, { ref: false }).then(() => { 41 | answer.cancel(); 42 | if (typeof defaultAnswer !== 'undefined') return defaultAnswer; 43 | throw new SfError('Prompt timed out.'); 44 | }); 45 | 46 | export const prompts = { confirm, secretPrompt }; 47 | -------------------------------------------------------------------------------- /src/ux/spinner.ts: -------------------------------------------------------------------------------- 1 | /* 2 | * Copyright (c) 2022, salesforce.com, inc. 3 | * All rights reserved. 4 | * Licensed under the BSD 3-Clause license. 5 | * For full license text, see LICENSE.txt file in the repo root or https://opensource.org/licenses/BSD-3-Clause 6 | */ 7 | 8 | import { ux } from '@oclif/core'; 9 | import { UxBase } from './base.js'; 10 | 11 | /** 12 | * This class is a light wrapper around ux.action that allows us to 13 | * automatically suppress any actions if `--json` flag is present. 14 | */ 15 | export class Spinner extends UxBase { 16 | public constructor(outputEnabled: boolean) { 17 | super(outputEnabled); 18 | } 19 | 20 | /** 21 | * Get the status of the current spinner. 22 | */ 23 | // eslint-disable-next-line class-methods-use-this 24 | public get status(): string | undefined { 25 | return ux.action.status; 26 | } 27 | 28 | /** 29 | * Set the status of the current spinner. 30 | */ 31 | // eslint-disable-next-line class-methods-use-this 32 | public set status(status: string | undefined) { 33 | ux.action.status = status; 34 | } 35 | 36 | /** 37 | * Start a spinner on the console. 38 | */ 39 | public start(action: string, status?: string, opts?: { stdout?: boolean }): void { 40 | this.maybeNoop(() => ux.action.start(action, status, opts)); 41 | } 42 | 43 | /** 44 | * Stop the spinner on the console. 45 | */ 46 | public stop(msg?: string): void { 47 | this.maybeNoop(() => ux.action.stop(msg)); 48 | } 49 | 50 | /** 51 | * Pause the spinner on the console. 52 | */ 53 | public pause(fn: () => unknown, icon?: string): void { 54 | this.maybeNoop(() => ux.action.pause(fn, icon)); 55 | } 56 | } 57 | -------------------------------------------------------------------------------- /src/ux/standardColors.ts: -------------------------------------------------------------------------------- 1 | /* 2 | * Copyright (c) 2023, salesforce.com, inc. 3 | * All rights reserved. 4 | * Licensed under the BSD 3-Clause license. 5 | * For full license text, see LICENSE.txt file in the repo root or https://opensource.org/licenses/BSD-3-Clause 6 | */ 7 | 8 | import ansis from 'ansis'; 9 | 10 | export const StandardColors = { 11 | error: ansis.bold.red, 12 | warning: ansis.bold.yellow, 13 | info: ansis.dim, 14 | success: ansis.bold.green, 15 | }; 16 | -------------------------------------------------------------------------------- /src/ux/styledObject.ts: -------------------------------------------------------------------------------- 1 | /* 2 | * Copyright (c) 2023, salesforce.com, inc. 3 | * All rights reserved. 4 | * Licensed under the BSD 3-Clause license. 5 | * For full license text, see LICENSE.txt file in the repo root or https://opensource.org/licenses/BSD-3-Clause 6 | */ 7 | import { inspect } from 'node:util'; 8 | import ansis from 'ansis'; 9 | import { AnyJson } from '@salesforce/ts-types'; 10 | 11 | function prettyPrint(obj: AnyJson): string { 12 | if (!obj) return inspect(obj); 13 | if (typeof obj === 'string') return obj; 14 | if (typeof obj === 'number') return obj.toString(); 15 | if (typeof obj === 'boolean') return obj.toString(); 16 | if (typeof obj === 'object') { 17 | return Object.entries(obj) 18 | .map(([key, value]) => `${key}: ${inspect(value)}`) 19 | .join(', '); 20 | } 21 | 22 | return inspect(obj); 23 | } 24 | 25 | export default function styledObject(obj: AnyJson, keys?: string[]): string { 26 | if (!obj) return inspect(obj); 27 | if (typeof obj === 'string') return obj; 28 | if (typeof obj === 'number') return obj.toString(); 29 | if (typeof obj === 'boolean') return obj.toString(); 30 | 31 | const output: string[] = []; 32 | const keyLengths = Object.keys(obj).map((key) => key.toString().length); 33 | const maxKeyLength = Math.max(...keyLengths) + 2; 34 | 35 | const logKeyValue = (key: string, value: AnyJson): string => 36 | `${ansis.blue(key)}:` + ' '.repeat(maxKeyLength - key.length - 1) + prettyPrint(value); 37 | 38 | for (const [key, value] of Object.entries(obj)) { 39 | if (keys && !keys.includes(key)) continue; 40 | if (Array.isArray(value)) { 41 | if (value.length > 0) { 42 | output.push(logKeyValue(key, value[0])); 43 | for (const e of value.slice(1)) { 44 | output.push(' '.repeat(maxKeyLength) + prettyPrint(e)); 45 | } 46 | } 47 | } else if (value !== null && value !== undefined) { 48 | output.push(logKeyValue(key, value)); 49 | } 50 | } 51 | 52 | return output.join('\n'); 53 | } 54 | -------------------------------------------------------------------------------- /src/ux/table.ts: -------------------------------------------------------------------------------- 1 | /* 2 | * Copyright (c) 2023, salesforce.com, inc. 3 | * All rights reserved. 4 | * Licensed under the BSD 3-Clause license. 5 | * For full license text, see LICENSE.txt file in the repo root or https://opensource.org/licenses/BSD-3-Clause 6 | */ 7 | import { TableOptions } from '@oclif/table'; 8 | import { env } from '@salesforce/kit'; 9 | 10 | type Column> = { 11 | extended: boolean; 12 | header: string; 13 | minWidth: number; 14 | get(row: T): unknown; 15 | }; 16 | 17 | type Columns> = { [key: string]: Partial> }; 18 | 19 | type Options = { 20 | columns?: string; 21 | extended?: boolean; 22 | filter?: string; 23 | 'no-header'?: boolean; 24 | 'no-truncate'?: boolean; 25 | rowStart?: string; 26 | sort?: string; 27 | title?: string; 28 | printLine?(s: unknown): void; 29 | }; 30 | 31 | /** 32 | * Converts inputs to previous table API to the new table API. 33 | * 34 | * Note that the following options will not be converted: 35 | * - 'extended' 36 | * - 'filter' 37 | * - 'sort' 38 | * - 'no-header' 39 | * - 'no-truncate' 40 | * - 'row-start' 41 | * - 'print-line' 42 | * 43 | * @deprecated Please use the new table API directly. 44 | */ 45 | export function convertToNewTableAPI>( 46 | data: T[], 47 | columns: Columns, 48 | options?: Options 49 | ): TableOptions> { 50 | const cols = Object.entries(columns).map(([key, opts]) => { 51 | if (opts.header) return { key, name: opts.header }; 52 | return key; 53 | }); 54 | const d = data.map((row) => 55 | Object.fromEntries(Object.entries(columns).map(([key, { get }]) => [key, get ? get(row) : row[key]])) 56 | ) as Array>; 57 | 58 | return { data: d, title: options?.title, borderStyle: 'headers-only-with-underline', columns: cols }; 59 | } 60 | 61 | export function getDefaults>(options: TableOptions): Partial> { 62 | const borderStyles = [ 63 | 'all', 64 | 'headers-only-with-outline', 65 | 'headers-only-with-underline', 66 | 'headers-only', 67 | 'horizontal-with-outline', 68 | 'horizontal', 69 | 'none', 70 | 'outline', 71 | 'vertical-with-outline', 72 | 'vertical', 73 | ]; 74 | 75 | const defaultStyle = 'vertical-with-outline'; 76 | const determineBorderStyle = (): TableOptions['borderStyle'] => { 77 | const envVar = env.getString('SF_TABLE_BORDER_STYLE', defaultStyle); 78 | if (borderStyles.includes(envVar)) { 79 | return envVar as TableOptions['borderStyle']; 80 | } 81 | 82 | return defaultStyle; 83 | }; 84 | 85 | const overflowOptions = ['wrap', 'truncate', 'truncate-middle', 'truncate-start', 'truncate-end']; 86 | const determineOverflow = (): TableOptions['overflow'] => { 87 | const envVar = env.getString('SF_TABLE_OVERFLOW'); 88 | if (envVar && overflowOptions.includes(envVar)) { 89 | return envVar as TableOptions['overflow']; 90 | } 91 | 92 | return options.overflow; 93 | }; 94 | 95 | return { 96 | borderStyle: determineBorderStyle(), 97 | noStyle: env.getBoolean('SF_NO_TABLE_STYLE', false), 98 | headerOptions: { 99 | ...options.headerOptions, 100 | formatter: 'capitalCase', 101 | }, 102 | overflow: determineOverflow(), 103 | }; 104 | } 105 | -------------------------------------------------------------------------------- /src/ux/ux.ts: -------------------------------------------------------------------------------- 1 | /* 2 | * Copyright (c) 2022, salesforce.com, inc. 3 | * All rights reserved. 4 | * Licensed under the BSD 3-Clause license. 5 | * For full license text, see LICENSE.txt file in the repo root or https://opensource.org/licenses/BSD-3-Clause 6 | */ 7 | 8 | import ansis from 'ansis'; 9 | import { ux } from '@oclif/core'; 10 | import { AnyJson } from '@salesforce/ts-types'; 11 | import terminalLink from 'terminal-link'; 12 | import { makeTable, printTable, TableOptions } from '@oclif/table'; 13 | import { UxBase } from './base.js'; 14 | import { Spinner } from './spinner.js'; 15 | import styledObject from './styledObject.js'; 16 | import { getDefaults } from './table.js'; 17 | 18 | /** 19 | * UX methods for plugins. Automatically suppress console output if outputEnabled is set to false. 20 | * 21 | * @example 22 | * ``` 23 | * import { SfCommand, Ux } from '@salesforce/sf-plugins-core'; 24 | * import { AnyJson } from '@salesforce/ts-types'; 25 | * 26 | * class MyCommand extends SfCommand { 27 | * public async run(): Promise { 28 | * const ux = new Ux(!this.jsonEnabled()); 29 | * } 30 | * } 31 | * 32 | * ``` 33 | */ 34 | export class Ux extends UxBase { 35 | public readonly spinner: Spinner; 36 | public readonly outputEnabled: boolean; 37 | 38 | public constructor({ jsonEnabled } = { jsonEnabled: false }) { 39 | super(!jsonEnabled); 40 | this.outputEnabled = !jsonEnabled; 41 | this.spinner = new Spinner(this.outputEnabled); 42 | } 43 | 44 | /** 45 | * Log a message to the console. This will be automatically suppressed if output is disabled. 46 | * 47 | * @param message Message to log. Formatting is supported. 48 | * @param args Args to be used for formatting. 49 | */ 50 | public log(message?: string, ...args: string[]): void { 51 | this.maybeNoop(() => ux.stdout(message, ...args)); 52 | } 53 | 54 | /** 55 | * Log a message to stderr. This will be automatically suppressed if output is disabled. 56 | * 57 | * @param message Message to log. Formatting is supported. 58 | * @param args Args to be used for formatting. 59 | */ 60 | public logToStderr(message?: string, ...args: string[]): void { 61 | this.maybeNoop(() => ux.stderr(message, ...args)); 62 | } 63 | 64 | /** 65 | * Log a warning message to the console. This will be automatically suppressed if output is disabled. 66 | * 67 | * @param message Warning message to log. 68 | */ 69 | public warn(message: string | Error): void { 70 | this.maybeNoop(() => ux.warn(message)); 71 | } 72 | 73 | /** 74 | * Display a table to the console. This will be automatically suppressed if output is disabled. 75 | * 76 | * @param options Table properties 77 | */ 78 | public table>(options: TableOptions): void { 79 | this.maybeNoop(() => 80 | printTable({ 81 | ...options, 82 | ...getDefaults(options), 83 | }) 84 | ); 85 | } 86 | 87 | /** 88 | * Return a string rendering of a table. 89 | * 90 | * @param options Table properties 91 | * @returns string rendering of a table 92 | */ 93 | // eslint-disable-next-line class-methods-use-this 94 | public makeTable>(options: TableOptions): string { 95 | return makeTable({ 96 | ...options, 97 | ...getDefaults(options), 98 | }); 99 | } 100 | 101 | /** 102 | * Display a url to the console. This will be automatically suppressed if output is disabled. 103 | * 104 | * @param text text to display 105 | * @param uri URL link 106 | * @param params 107 | */ 108 | public url(text: string, uri: string, params = {}): void { 109 | this.maybeNoop(() => ux.stdout(terminalLink(text, uri, { fallback: () => uri, ...params }))); 110 | } 111 | 112 | /** 113 | * Display stylized JSON to the console. This will be automatically suppressed if output is disabled. 114 | * 115 | * @param obj JSON to display 116 | */ 117 | public styledJSON(obj: AnyJson, theme?: Record): void { 118 | // Default theme if sf's theme.json does not have the json property set. This will allow us 119 | // to ship sf-plugins-core before the theme.json is updated. 120 | const defaultTheme = { 121 | key: 'blueBright', 122 | string: 'greenBright', 123 | number: 'blue', 124 | boolean: 'redBright', 125 | null: 'blackBright', 126 | }; 127 | 128 | const mergedTheme = { ...defaultTheme, ...theme }; 129 | 130 | this.maybeNoop(() => ux.stdout(ux.colorizeJson(obj, { theme: mergedTheme }))); 131 | } 132 | 133 | /** 134 | * Display stylized object to the console. This will be automatically suppressed if output is disabled. 135 | * 136 | * @param obj Object to display 137 | * @param keys Keys of object to display 138 | */ 139 | public styledObject(obj: AnyJson, keys?: string[]): void { 140 | this.maybeNoop(() => ux.stdout(styledObject(obj, keys))); 141 | } 142 | 143 | /** 144 | * Display stylized header to the console. This will be automatically suppressed if output is disabled. 145 | * 146 | * @param text header to display 147 | */ 148 | public styledHeader(text: string): void { 149 | this.maybeNoop(() => ux.stdout(ansis.dim('=== ') + ansis.bold(text) + '\n')); 150 | } 151 | } 152 | -------------------------------------------------------------------------------- /test/.eslintrc.cjs: -------------------------------------------------------------------------------- 1 | /* 2 | * Copyright (c) 2020, salesforce.com, inc. 3 | * All rights reserved. 4 | * Licensed under the BSD 3-Clause license. 5 | * For full license text, see LICENSE.txt file in the repo root or https://opensource.org/licenses/BSD-3-Clause 6 | */ 7 | 8 | // Generated - Do not modify. Controlled by @salesforce/dev-scripts 9 | // See more at https://github.com/forcedotcom/sfdx-dev-packages/tree/master/packages/dev-scripts 10 | 11 | module.exports = { 12 | extends: '../.eslintrc.cjs', 13 | // Allow describe and it 14 | env: { mocha: true }, 15 | rules: { 16 | // Allow assert style expressions. i.e. expect(true).to.be.true 17 | 'no-unused-expressions': 'off', 18 | '@typescript-eslint/no-explicit-any': 'off', 19 | 20 | // Tests usually access private or protected methods/variables 21 | '@typescript-eslint/ban-ts-ignore': 'off', 22 | 23 | // It is common for tests to stub out method. 24 | '@typescript-eslint/camelcase': 'off', 25 | '@typescript-eslint/restrict-template-expressions': 'off', 26 | '@typescript-eslint/unbound-method': 'off', 27 | 28 | // Return types are defined by the source code. Allows for quick overwrites. 29 | '@typescript-eslint/explicit-function-return-type': 'off', 30 | // Mocked out the methods that shouldn't do anything in the tests. 31 | '@typescript-eslint/no-empty-function': 'off', 32 | // Easily return a promise in a mocked method. 33 | '@typescript-eslint/require-await': 'off', 34 | }, 35 | }; 36 | -------------------------------------------------------------------------------- /test/init.js: -------------------------------------------------------------------------------- 1 | /* 2 | * Copyright (c) 2018, salesforce.com, inc. 3 | * All rights reserved. 4 | * SPDX-License-Identifier: BSD-3-Clause 5 | * For full license text, see the LICENSE file in the repo root or https://opensource.org/licenses/BSD-3-Clause 6 | */ 7 | const path = require('path'); 8 | process.env.TS_NODE_PROJECT = path.resolve('test/tsconfig.json'); 9 | -------------------------------------------------------------------------------- /test/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "@salesforce/dev-config/tsconfig-test-strict-esm", 3 | "include": ["unit/**/*.ts", "../node_modules/@types/**/*.d.ts", "integration/**/*.ts"], 4 | "compilerOptions": { 5 | "noEmit": true, 6 | "skipLibCheck": true 7 | } 8 | } 9 | -------------------------------------------------------------------------------- /test/unit/compatibility.test.ts: -------------------------------------------------------------------------------- 1 | /* 2 | * Copyright (c) 2024, salesforce.com, inc. 3 | * All rights reserved. 4 | * Licensed under the BSD 3-Clause license. 5 | * For full license text, see LICENSE.txt file in the repo root or https://opensource.org/licenses/BSD-3-Clause 6 | */ 7 | import { expect } from 'chai'; 8 | import { Command, Interfaces } from '@oclif/core'; 9 | import { arrayWithDeprecation } from '../../src/compatibility.js'; 10 | 11 | describe('arrayWithDeprecation', () => { 12 | class TestCommand extends Command { 13 | public static flags = { 14 | things: arrayWithDeprecation({ 15 | char: 'a', 16 | description: 'api version for the org', 17 | }), 18 | }; 19 | 20 | public async run(): Promise> { 21 | const { flags } = await this.parse(TestCommand); 22 | return flags; 23 | } 24 | } 25 | 26 | it('should split the flags on comma', async () => { 27 | const result = await TestCommand.run(['--things', 'a,b,c']); 28 | expect(result.things).to.deep.equal(['a', 'b', 'c']); 29 | }); 30 | 31 | it('should split the flags on comma and ignore spaces', async () => { 32 | const result = await TestCommand.run(['--things', 'a, b, c']); 33 | expect(result.things).to.deep.equal(['a', 'b', 'c']); 34 | }); 35 | 36 | it('should not split on escaped commas', async () => { 37 | const result = await TestCommand.run(['--things', 'a\\,b,c']); 38 | expect(result.things).to.deep.equal(['a,b', 'c']); 39 | }); 40 | 41 | it('should allow multiple flag inputs', async () => { 42 | const result = await TestCommand.run(['--things', 'a', '--things', 'b', '--things', 'c']); 43 | expect(result.things).to.deep.equal(['a', 'b', 'c']); 44 | }); 45 | }); 46 | -------------------------------------------------------------------------------- /test/unit/errorFormatting.test.ts: -------------------------------------------------------------------------------- 1 | /* 2 | * Copyright (c) 2023, salesforce.com, inc. 3 | * All rights reserved. 4 | * Licensed under the BSD 3-Clause license. 5 | * For full license text, see LICENSE.txt file in the repo root or https://opensource.org/licenses/BSD-3-Clause 6 | */ 7 | import { expect } from 'chai'; 8 | import { Mode, SfError } from '@salesforce/core'; 9 | import { formatError } from '../../src/errorFormatting.js'; 10 | import { SfCommandError } from '../../src/SfCommandError.js'; 11 | 12 | describe('errorFormatting.formatError()', () => { 13 | afterEach(() => { 14 | delete process.env.SF_ENV; 15 | }); 16 | 17 | it('should have correct output for non-development mode, no actions', () => { 18 | const err = SfCommandError.from(new Error('this did not work'), 'thecommand'); 19 | const errorOutput = formatError(err); 20 | expect(errorOutput).to.contain('Error (1)'); 21 | expect(errorOutput).to.contain('this did not work'); 22 | }); 23 | 24 | it('should have correct output for non-development mode, with actions', () => { 25 | const sfError = new SfError('this did not work', 'BadError'); 26 | const err = SfCommandError.from(sfError, 'thecommand'); 27 | err.actions = ['action1', 'action2']; 28 | const errorOutput = formatError(err); 29 | expect(errorOutput).to.contain('Error (BadError)'); 30 | expect(errorOutput).to.contain('this did not work'); 31 | expect(errorOutput).to.contain('Try this:'); 32 | expect(errorOutput).to.contain('action1'); 33 | expect(errorOutput).to.contain('action2'); 34 | }); 35 | 36 | it('should have correct output for development mode, basic error', () => { 37 | process.env.SF_ENV = Mode.DEVELOPMENT; 38 | const err = SfCommandError.from(new SfError('this did not work'), 'thecommand'); 39 | const errorOutput = formatError(err); 40 | expect(errorOutput).to.contain('Error (SfError)'); 41 | expect(errorOutput).to.contain('this did not work'); 42 | expect(errorOutput).to.contain('*** Internal Diagnostic ***'); 43 | expect(errorOutput).to.contain('at Function.from'); 44 | expect(errorOutput).to.contain('actions: undefined'); 45 | expect(errorOutput).to.contain('exitCode: 1'); 46 | expect(errorOutput).to.contain("context: 'thecommand'"); 47 | expect(errorOutput).to.contain('data: undefined'); 48 | expect(errorOutput).to.contain('cause: undefined'); 49 | expect(errorOutput).to.contain('status: 1'); 50 | expect(errorOutput).to.contain("commandName: 'thecommand'"); 51 | expect(errorOutput).to.contain('warnings: undefined'); 52 | expect(errorOutput).to.contain('result: undefined'); 53 | }); 54 | 55 | it('should have correct output for development mode, full error', () => { 56 | process.env.SF_ENV = Mode.DEVELOPMENT; 57 | const sfError = SfError.create({ 58 | name: 'WOMP_WOMP', 59 | message: 'this did not work', 60 | actions: ['action1', 'action2'], 61 | cause: new Error('this is the cause'), 62 | exitCode: 9, 63 | context: 'somecommand', 64 | data: { foo: 'bar' }, 65 | }); 66 | const err = SfCommandError.from(sfError, 'thecommand'); 67 | const errorOutput = formatError(err); 68 | expect(errorOutput).to.contain('Error (WOMP_WOMP)'); 69 | expect(errorOutput).to.contain('this did not work'); 70 | expect(errorOutput).to.contain('*** Internal Diagnostic ***'); 71 | expect(errorOutput).to.contain('at Function.from'); 72 | expect(errorOutput).to.contain("actions: [ 'action1', 'action2' ]"); 73 | expect(errorOutput).to.contain('exitCode: 9'); 74 | expect(errorOutput).to.contain("context: 'somecommand'"); 75 | expect(errorOutput).to.contain("data: { foo: 'bar' }"); 76 | expect(errorOutput).to.contain('cause: Error: this is the cause'); 77 | expect(errorOutput).to.contain('status: 9'); 78 | expect(errorOutput).to.contain("commandName: 'thecommand'"); 79 | expect(errorOutput).to.contain('warnings: undefined'); 80 | expect(errorOutput).to.contain('result: undefined'); 81 | }); 82 | 83 | it('should have correct output for multiple errors in table format when errorCode is MULTIPLE_API_ERRORS', () => { 84 | const innerError = SfError.create({ 85 | message: 'foo', 86 | data: [ 87 | { errorCode: 'ERROR_1', message: 'error 1' }, 88 | { errorCode: 'ERROR_2', message: 'error 2' }, 89 | ], 90 | }); 91 | const sfError = SfError.create({ 92 | name: 'myError', 93 | message: 'foo', 94 | actions: ['bar'], 95 | context: 'myContext', 96 | exitCode: 8, 97 | cause: innerError, 98 | }); 99 | const err = SfCommandError.from(sfError, 'thecommand'); 100 | err.code = 'MULTIPLE_API_ERRORS'; 101 | const errorOutput = formatError(err); 102 | expect(errorOutput).to.match(/Error Code.+Message/); 103 | expect(errorOutput).to.match(/ERROR_1.+error 1/); 104 | expect(errorOutput).to.match(/ERROR_2.+error 2/); 105 | }); 106 | }); 107 | -------------------------------------------------------------------------------- /test/unit/errorHandling.test.ts: -------------------------------------------------------------------------------- 1 | /* 2 | * Copyright (c) 2023, salesforce.com, inc. 3 | * All rights reserved. 4 | * Licensed under the BSD 3-Clause license. 5 | * For full license text, see LICENSE.txt file in the repo root or https://opensource.org/licenses/BSD-3-Clause 6 | */ 7 | import { expect } from 'chai'; 8 | import { SfError } from '@salesforce/core/sfError'; 9 | import { computeErrorCode, errorIsGack, errorIsTypeError } from '../../src/errorHandling.js'; 10 | import { SfCommandError } from '../../src/SfCommandError.js'; 11 | 12 | describe('typeErrors', () => { 13 | let typeError: Error; 14 | 15 | before(() => { 16 | try { 17 | const n = null; 18 | // @ts-expect-error I know it's wrong, I need an error! 19 | // eslint-disable-next-line @typescript-eslint/no-unsafe-call 20 | n.f(); 21 | } catch (e) { 22 | if (e instanceof TypeError) { 23 | typeError = e; 24 | } 25 | } 26 | }); 27 | it('matches on TypeError as error name', () => { 28 | expect(errorIsTypeError(typeError)).to.be.true; 29 | }); 30 | 31 | it('matches on TypeError in stack', () => { 32 | const e = new Error('some error'); 33 | e.stack = e.stack + typeError.name; 34 | expect(errorIsTypeError(e)).to.be.true; 35 | }); 36 | 37 | it('matches on TypeError in stack (check against false positive)', () => { 38 | const e = new Error('some error'); 39 | expect(errorIsTypeError(e)).to.be.false; 40 | }); 41 | 42 | it('matches on TypeError as cause', () => { 43 | const error = new SfError('some error', 'testError', [], 44, typeError); 44 | expect(errorIsTypeError(error)).to.be.true; 45 | }); 46 | }); 47 | describe('gacks', () => { 48 | const realGackSamples = [ 49 | '963190677-320016 (165202460)', 50 | '1662648879-55786 (-1856191902)', 51 | '694826414-169428 (2085174272)', 52 | '1716315817-543601 (74920438)', 53 | '1035887602-340708 (1781437152)', 54 | '671603042-121307 (-766503277)', 55 | '396937656-5973 (-766503277)', 56 | '309676439-91665 (-153174221)', 57 | '956661320-295482 (2000727581)', 58 | '1988392611-333742 (1222029414)', 59 | '1830254280-281143 (331700540)', 60 | ]; 61 | 62 | it('says true for sample gacks', () => { 63 | realGackSamples.forEach((gack) => { 64 | expect(errorIsGack(new SfError(gack))).to.be.true; 65 | }); 66 | }); 67 | 68 | it('error in stack', () => { 69 | const error = new SfError('some error'); 70 | error.stack = realGackSamples[0]; 71 | expect(errorIsGack(error)).to.be.true; 72 | }); 73 | 74 | it('error in sfError cause', () => { 75 | const error = new SfError('some error', 'testError', [], 44, new Error(realGackSamples[0])); 76 | expect(errorIsGack(error)).to.be.true; 77 | }); 78 | }); 79 | 80 | describe('precedence', () => { 81 | it('oclif beats normal exit code', () => { 82 | const e = new SfError('foo', 'foo', [], 44, undefined); 83 | // @ts-expect-error doesn't know about oclif 84 | e.oclif = { 85 | exit: 99, 86 | }; 87 | expect(computeErrorCode(e)).to.equal(99); 88 | }); 89 | it('oclif vs. normal exit code, but oclif has undefined', () => { 90 | const e = new SfError('foo', 'foo', [], 44, undefined); 91 | // @ts-expect-error doesn't know about oclif 92 | e.oclif = {}; 93 | expect(computeErrorCode(e)).to.equal(44); 94 | }); 95 | it('oclif vs. normal exit code, but oclif has 0', () => { 96 | const e = new SfError('foo', 'foo', [], 44, undefined); 97 | // @ts-expect-error doesn't know about oclif 98 | e.oclif = { 99 | exit: 0, 100 | }; 101 | expect(computeErrorCode(e)).to.equal(0); 102 | }); 103 | it('gack beats oclif and normal exit code', () => { 104 | const e = new SfError( 105 | 'for a good time call Salesforce Support and ask for 1830254280-281143 (867530999)', 106 | 'foo', 107 | [], 108 | 44, 109 | undefined 110 | ); 111 | // @ts-expect-error doesn't know about oclif 112 | e.oclif = { 113 | exit: 99, 114 | }; 115 | expect(computeErrorCode(e)).to.equal(20); 116 | }); 117 | it('type error beats oclif and normal exit code', () => { 118 | const e = new SfError('TypeError: stop using as any!', 'TypeError', [], 44, undefined); 119 | // @ts-expect-error doesn't know about oclif 120 | e.oclif = { 121 | exit: 99, 122 | }; 123 | expect(computeErrorCode(e)).to.equal(10); 124 | }); 125 | }); 126 | 127 | describe('SfCommandError.toJson()', () => { 128 | it('basic', () => { 129 | const result = SfCommandError.from(new Error('foo'), 'the:cmd').toJson(); 130 | expect(result).to.deep.include({ 131 | code: '1', 132 | status: 1, 133 | exitCode: 1, 134 | commandName: 'the:cmd', 135 | context: 'the:cmd', 136 | message: 'foo', 137 | name: 'Error', // this is the default 138 | }); 139 | expect(result.stack).to.be.a('string').and.include('Error: foo'); 140 | }); 141 | it('with warnings', () => { 142 | const warnings = ['your version of node is over 10 years old']; 143 | const result = SfCommandError.from(new Error('foo'), 'the:cmd', warnings).toJson(); 144 | expect(result).to.deep.include({ 145 | code: '1', 146 | status: 1, 147 | exitCode: 1, 148 | commandName: 'the:cmd', 149 | context: 'the:cmd', 150 | message: 'foo', 151 | name: 'Error', // this is the default 152 | warnings, 153 | }); 154 | expect(result.stack).to.be.a('string').and.include('Error: foo'); 155 | }); 156 | describe('context', () => { 157 | it('sfError with context', () => { 158 | const sfError = SfError.create({ 159 | name: 'myError', 160 | message: 'foo', 161 | actions: ['bar'], 162 | context: 'myContext', 163 | exitCode: 8, 164 | }); 165 | const result = SfCommandError.from(sfError, 'the:cmd').toJson(); 166 | expect(result).to.deep.include({ 167 | code: 'myError', 168 | status: 8, 169 | exitCode: 8, 170 | commandName: 'the:cmd', 171 | context: 'myContext', 172 | message: 'foo', 173 | name: 'myError', 174 | }); 175 | expect(result.stack).to.be.a('string').and.include('myError: foo'); 176 | }); 177 | it('sfError with undefined context', () => { 178 | const sfError = SfError.create({ 179 | name: 'myError', 180 | message: 'foo', 181 | actions: ['bar'], 182 | context: undefined, 183 | exitCode: 8, 184 | }); 185 | const result = SfCommandError.from(sfError, 'the:cmd').toJson(); 186 | expect(result).to.deep.include({ 187 | code: 'myError', 188 | status: 8, 189 | exitCode: 8, 190 | commandName: 'the:cmd', 191 | // defaults to the command name 192 | context: 'the:cmd', 193 | message: 'foo', 194 | name: 'myError', 195 | }); 196 | expect(result.stack).to.be.a('string').and.include('myError: foo'); 197 | }); 198 | }); 199 | }); 200 | -------------------------------------------------------------------------------- /test/unit/flags/apiVersion.test.ts: -------------------------------------------------------------------------------- 1 | /* 2 | * Copyright (c) 2020, salesforce.com, inc. 3 | * All rights reserved. 4 | * Licensed under the BSD 3-Clause license. 5 | * For full license text, see LICENSE.txt file in the repo root or https://opensource.org/licenses/BSD-3-Clause 6 | */ 7 | import { expect } from 'chai'; 8 | import { Parser } from '@oclif/core'; 9 | import { Lifecycle, Messages } from '@salesforce/core'; 10 | import sinon from 'sinon'; 11 | import { 12 | orgApiVersionFlag, 13 | minValidApiVersion, 14 | maxDeprecated, 15 | maxDeprecatedUrl, 16 | } from '../../../src/flags/orgApiVersion.js'; 17 | 18 | Messages.importMessagesDirectoryFromMetaUrl(import.meta.url); 19 | const messages = Messages.loadMessages('@salesforce/sf-plugins-core', 'messages'); 20 | 21 | describe('fs flags', () => { 22 | const sandbox = sinon.createSandbox(); 23 | let warnStub: sinon.SinonStub; 24 | 25 | beforeEach(() => { 26 | sandbox.stub(Lifecycle, 'getInstance').returns(Lifecycle.prototype); 27 | warnStub = sandbox.stub(Lifecycle.prototype, 'emitWarning'); 28 | }); 29 | 30 | afterEach(() => { 31 | sandbox.restore(); 32 | }); 33 | 34 | it('passes with a valid apiVersion', async () => { 35 | const versionToTest = `${maxDeprecated + 10}.0`; 36 | const out = await Parser.parse([`--api-version=${versionToTest}`], { 37 | flags: { 'api-version': orgApiVersionFlag() }, 38 | }); 39 | expect(out.flags).to.deep.include({ 'api-version': versionToTest }); 40 | // no deprecation warning 41 | expect(warnStub.callCount).to.equal(0); 42 | }); 43 | 44 | it('passes with minimum valid apiVersion', async () => { 45 | const versionToTest = `${maxDeprecated + 1}.0`; 46 | const out = await Parser.parse([`--api-version=${versionToTest}`], { 47 | flags: { 'api-version': orgApiVersionFlag() }, 48 | }); 49 | expect(out.flags).to.deep.include({ 'api-version': versionToTest }); 50 | // no deprecation warning 51 | expect(warnStub.callCount).to.equal(0); 52 | }); 53 | 54 | it('throws on invalid version', async () => { 55 | try { 56 | const out = await Parser.parse(['--api-version=foo'], { 57 | flags: { 'api-version': orgApiVersionFlag() }, 58 | }); 59 | throw new Error(`Should have thrown an error ${JSON.stringify(out)}`); 60 | } catch (err) { 61 | const error = err as Error; 62 | expect(error.message).to.include(messages.getMessage('errors.InvalidApiVersion', ['foo'])); 63 | } 64 | }); 65 | 66 | it('throws on retired version', async () => { 67 | const versionToTest = `${minValidApiVersion - 1}.0`; 68 | 69 | try { 70 | const out = await Parser.parse([`--api-version=${versionToTest}`], { 71 | flags: { 'api-version': orgApiVersionFlag() }, 72 | }); 73 | throw new Error(`Should have thrown an error ${JSON.stringify(out)}`); 74 | } catch (err) { 75 | const error = err as Error; 76 | expect(error.message).to.include(messages.getMessage('errors.RetiredApiVersion', [minValidApiVersion])); 77 | } 78 | }); 79 | 80 | it('warns on highest deprecated version', async () => { 81 | const versionToTest = `${maxDeprecated}.0`; 82 | const out = await Parser.parse([`--api-version=${versionToTest}`], { 83 | flags: { 'api-version': orgApiVersionFlag() }, 84 | }); 85 | expect(out.flags).to.deep.include({ 'api-version': versionToTest }); 86 | expect(warnStub.callCount).to.equal(1); 87 | expect(warnStub.firstCall.args[0]).to.include(maxDeprecatedUrl); 88 | }); 89 | 90 | it('warns on lowest deprecated version', async () => { 91 | const versionToTest = `${minValidApiVersion}.0`; 92 | const out = await Parser.parse([`--api-version=${versionToTest}`], { 93 | flags: { 'api-version': orgApiVersionFlag() }, 94 | }); 95 | expect(out.flags).to.deep.include({ 'api-version': versionToTest }); 96 | expect(warnStub.callCount).to.equal(1); 97 | expect(warnStub.firstCall.args[0]).to.include(maxDeprecatedUrl); 98 | }); 99 | }); 100 | -------------------------------------------------------------------------------- /test/unit/flags/duration.test.ts: -------------------------------------------------------------------------------- 1 | /* 2 | * Copyright (c) 2020, salesforce.com, inc. 3 | * All rights reserved. 4 | * Licensed under the BSD 3-Clause license. 5 | * For full license text, see LICENSE.txt file in the repo root or https://opensource.org/licenses/BSD-3-Clause 6 | */ 7 | 8 | import { Parser } from '@oclif/core'; 9 | import { Messages } from '@salesforce/core/messages'; 10 | import { expect } from 'chai'; 11 | import { Duration } from '@salesforce/kit'; 12 | import { durationFlag } from '../../../src/flags/duration.js'; 13 | 14 | Messages.importMessagesDirectoryFromMetaUrl(import.meta.url); 15 | const messages = Messages.loadMessages('@salesforce/sf-plugins-core', 'messages'); 16 | 17 | describe('duration flag', () => { 18 | describe('no default, hours', () => { 19 | const buildProps = { 20 | flags: { 21 | wait: durationFlag({ 22 | unit: 'hours', 23 | description: 'test', 24 | char: 'w', 25 | }), 26 | }, 27 | }; 28 | it('passes', async () => { 29 | const out = await Parser.parse(['--wait=10'], buildProps); 30 | expect(out.flags.wait?.quantity).to.equal(10); 31 | expect(out.flags.wait?.unit).to.equal(Duration.Unit.HOURS); 32 | }); 33 | it('passes with default', async () => { 34 | const out = await Parser.parse([], buildProps); 35 | expect(out.flags.wait).to.equal(undefined); 36 | }); 37 | }); 38 | 39 | describe('defaultValue zero', () => { 40 | const buildProps = { 41 | flags: { 42 | wait: durationFlag({ 43 | unit: 'hours', 44 | description: 'test', 45 | defaultValue: 0, 46 | char: 'w', 47 | }), 48 | }, 49 | }; 50 | it('passes', async () => { 51 | const out = await Parser.parse(['--wait=10'], buildProps); 52 | expect(out.flags.wait?.quantity).to.equal(10); 53 | expect(out.flags.wait?.unit).to.equal(Duration.Unit.HOURS); 54 | }); 55 | it('passes using defaultValue', async () => { 56 | const out = await Parser.parse([], buildProps); 57 | expect(out.flags.wait?.quantity).to.equal(0); 58 | expect(out.flags.wait?.unit).to.equal(Duration.Unit.HOURS); 59 | }); 60 | }); 61 | 62 | describe('validation with no options and weeks unit', () => { 63 | const defaultValue = 33; 64 | const buildProps = { 65 | flags: { 66 | wait: durationFlag({ 67 | unit: 'weeks', 68 | defaultValue, 69 | description: 'test', 70 | char: 'w', 71 | }), 72 | }, 73 | }; 74 | it('passes', async () => { 75 | const out = await Parser.parse(['--wait=10'], buildProps); 76 | expect(out.flags.wait?.quantity).to.equal(10); 77 | expect(out.flags.wait?.unit).to.equal(Duration.Unit.WEEKS); 78 | }); 79 | it('passes with default', async () => { 80 | const out = await Parser.parse([], buildProps); 81 | expect(out.flags.wait?.quantity).to.equal(33); 82 | }); 83 | }); 84 | 85 | describe('validation with all options', () => { 86 | const min = 1; 87 | const max = 60; 88 | const defaultValue = 33; 89 | const buildProps = { 90 | flags: { 91 | wait: durationFlag({ 92 | defaultValue, 93 | min, 94 | max, 95 | unit: 'minutes', 96 | description: 'test', 97 | char: 'w', 98 | }), 99 | }, 100 | }; 101 | it('passes', async () => { 102 | const out = await Parser.parse(['--wait=10'], buildProps); 103 | expect(out.flags.wait?.quantity).to.equal(10); 104 | }); 105 | it('min passes', async () => { 106 | const out = await Parser.parse([`--wait=${min}`], buildProps); 107 | expect(out.flags.wait?.quantity).to.equal(min); 108 | }); 109 | it('max passes', async () => { 110 | const out = await Parser.parse([`--wait=${max}`], buildProps); 111 | expect(out.flags.wait?.quantity).to.equal(max); 112 | }); 113 | it('default works', async () => { 114 | const out = await Parser.parse([], buildProps); 115 | expect(out.flags.wait?.quantity).to.equal(defaultValue); 116 | }); 117 | it('default default function', async () => { 118 | // @ts-expect-error: type mismatch 119 | buildProps.flags.wait.default = async (context: { options: { defaultValue: number } }) => 120 | Duration.minutes(context.options.defaultValue + 1); 121 | const out = await Parser.parse([], buildProps); 122 | expect(out.flags.wait?.quantity).to.equal(defaultValue + 1); 123 | }); 124 | describe('failures', () => { 125 | it('below min fails', async () => { 126 | try { 127 | const out = await Parser.parse([`--wait=${min - 1}`], buildProps); 128 | 129 | throw new Error(`Should have thrown an error ${JSON.stringify(out)}`); 130 | } catch (err) { 131 | const error = err as Error; 132 | expect(error.message).to.include(messages.getMessage('errors.DurationBounds', [1, 60])); 133 | } 134 | }); 135 | it('above max fails', async () => { 136 | try { 137 | const out = await Parser.parse([`--wait=${max + 1}`], buildProps); 138 | throw new Error(`Should have thrown an error ${JSON.stringify(out)}`); 139 | } catch (err) { 140 | const error = err as Error; 141 | expect(error.message).to.include(messages.getMessage('errors.DurationBounds', [1, 60])); 142 | } 143 | }); 144 | it('invalid input', async () => { 145 | try { 146 | const out = await Parser.parse(['--wait=abc}'], buildProps); 147 | throw new Error(`Should have thrown an error ${JSON.stringify(out)}`); 148 | } catch (err) { 149 | const error = err as Error; 150 | expect(error.message).to.include(messages.getMessage('errors.InvalidDuration')); 151 | } 152 | }); 153 | }); 154 | }); 155 | 156 | describe('validation with min not max', () => { 157 | const min = 1; 158 | const buildProps = { 159 | flags: { 160 | wait: durationFlag({ 161 | min, 162 | unit: 'minutes', 163 | description: 'test', 164 | char: 'w', 165 | }), 166 | }, 167 | }; 168 | it('passes', async () => { 169 | const out = await Parser.parse(['--wait=10'], buildProps); 170 | expect(out.flags.wait?.quantity).to.equal(10); 171 | }); 172 | it('min passes', async () => { 173 | const out = await Parser.parse([`--wait=${min}`], buildProps); 174 | expect(out.flags.wait?.quantity).to.equal(min); 175 | }); 176 | describe('failures', () => { 177 | it('below min fails', async () => { 178 | try { 179 | const out = await Parser.parse([`--wait=${min - 1}`], buildProps); 180 | 181 | throw new Error(`Should have thrown an error ${JSON.stringify(out)}`); 182 | } catch (err) { 183 | const error = err as Error; 184 | expect(error.message).to.include(messages.getMessage('errors.DurationBoundsMin', [min])); 185 | } 186 | }); 187 | }); 188 | }); 189 | 190 | describe('validation with max not min', () => { 191 | const max = 60; 192 | const buildProps = { 193 | flags: { 194 | wait: durationFlag({ 195 | max, 196 | unit: 'minutes', 197 | description: 'test', 198 | char: 'w', 199 | }), 200 | }, 201 | }; 202 | it('passes', async () => { 203 | const out = await Parser.parse(['--wait=10'], buildProps); 204 | expect(out.flags.wait?.quantity).to.equal(10); 205 | }); 206 | it('max passes', async () => { 207 | const out = await Parser.parse([`--wait=${max}`], buildProps); 208 | expect(out.flags.wait?.quantity).to.equal(max); 209 | }); 210 | describe('failures', () => { 211 | it('above max fails', async () => { 212 | try { 213 | const out = await Parser.parse([`--wait=${max + 1}`], buildProps); 214 | throw new Error(`Should have thrown an error ${JSON.stringify(out)}`); 215 | } catch (err) { 216 | const error = err as Error; 217 | expect(error.message).to.include(messages.getMessage('errors.DurationBoundsMax', [max])); 218 | } 219 | }); 220 | }); 221 | }); 222 | }); 223 | -------------------------------------------------------------------------------- /test/unit/flags/id.test.ts: -------------------------------------------------------------------------------- 1 | /* 2 | * Copyright (c) 2020, salesforce.com, inc. 3 | * All rights reserved. 4 | * Licensed under the BSD 3-Clause license. 5 | * For full license text, see LICENSE.txt file in the repo root or https://opensource.org/licenses/BSD-3-Clause 6 | */ 7 | import { expect } from 'chai'; 8 | import { Parser } from '@oclif/core'; 9 | import { Messages } from '@salesforce/core/messages'; 10 | import { salesforceIdFlag } from '../../../src/flags/salesforceId.js'; 11 | 12 | Messages.importMessagesDirectoryFromMetaUrl(import.meta.url); 13 | const messages = Messages.loadMessages('@salesforce/sf-plugins-core', 'messages'); 14 | 15 | describe('id flag', () => { 16 | const id15 = '123456789012345'; 17 | const id18 = '123456789012345678'; 18 | const id16 = '1234567890123456'; 19 | 20 | it('allows 15 or 18 when no length specified', async () => { 21 | const out = await Parser.parse([`--id=${id15}`], { 22 | flags: { id: salesforceIdFlag() }, 23 | }); 24 | expect(out.flags).to.deep.include({ id: id15 }); 25 | }); 26 | 27 | it('allows 15 or 18 when both are specified', async () => { 28 | const out = await Parser.parse([`--id=${id15}`], { 29 | flags: { id: salesforceIdFlag({ length: 'both' }) }, 30 | }); 31 | expect(out.flags).to.deep.include({ id: id15 }); 32 | }); 33 | 34 | it('throws on invalid length id', async () => { 35 | try { 36 | const out = await Parser.parse([`--id=${id16}`], { 37 | flags: { id: salesforceIdFlag() }, 38 | }); 39 | throw new Error(`Should have thrown an error ${JSON.stringify(out)}`); 40 | } catch (err) { 41 | const error = err as Error; 42 | expect(error.message).to.include(messages.getMessage('errors.InvalidIdLength', ['15 or 18'])); 43 | } 44 | }); 45 | 46 | it('throws on invalid characters in id', async () => { 47 | try { 48 | const out = await Parser.parse(['--id=???????????????'], { 49 | flags: { id: salesforceIdFlag() }, 50 | }); 51 | throw new Error(`Should have thrown an error ${JSON.stringify(out)}`); 52 | } catch (err) { 53 | const error = err as Error; 54 | expect(error.message).to.include(messages.getMessage('errors.InvalidId')); 55 | } 56 | }); 57 | it('good 15', async () => { 58 | const out = await Parser.parse([`--id=${id15}`], { 59 | flags: { id: salesforceIdFlag({ length: 15 }) }, 60 | }); 61 | expect(out.flags).to.deep.include({ id: id15 }); 62 | }); 63 | it('bad 15', async () => { 64 | try { 65 | const out = await Parser.parse([`--id=${id18}`], { 66 | flags: { id: salesforceIdFlag({ length: 15 }) }, 67 | }); 68 | throw new Error(`Should have thrown an error ${JSON.stringify(out)}`); 69 | } catch (err) { 70 | const error = err as Error; 71 | expect(error.message).to.include(messages.getMessage('errors.InvalidIdLength', ['15'])); 72 | } 73 | }); 74 | it('good 18', async () => { 75 | const out = await Parser.parse([`--id=${id18}`], { 76 | flags: { id: salesforceIdFlag({ length: 18 }) }, 77 | }); 78 | expect(out.flags).to.deep.include({ id: id18 }); 79 | }); 80 | it('bad 18', async () => { 81 | try { 82 | const out = await Parser.parse([`--id=${id15}`], { 83 | flags: { id: salesforceIdFlag({ length: 18 }) }, 84 | }); 85 | throw new Error(`Should have thrown an error ${JSON.stringify(out)}`); 86 | } catch (err) { 87 | const error = err as Error; 88 | expect(error.message).to.include(messages.getMessage('errors.InvalidIdLength', ['18'])); 89 | } 90 | }); 91 | it('good startsWith', async () => { 92 | const out = await Parser.parse([`--id=${id18}`], { 93 | flags: { id: salesforceIdFlag({ startsWith: '123' }) }, 94 | }); 95 | expect(out.flags).to.deep.include({ id: id18 }); 96 | }); 97 | it('bad startsWith', async () => { 98 | try { 99 | const out = await Parser.parse([`--id=${id15}`], { 100 | flags: { id: salesforceIdFlag({ startsWith: '000' }) }, 101 | }); 102 | throw new Error(`Should have thrown an error ${JSON.stringify(out)}`); 103 | } catch (err) { 104 | const error = err as Error; 105 | expect(error.message).to.include(messages.getMessage('errors.InvalidPrefix', ['000'])); 106 | } 107 | }); 108 | }); 109 | -------------------------------------------------------------------------------- /test/unit/flags/orgFlags.test.ts: -------------------------------------------------------------------------------- 1 | /* 2 | * Copyright (c) 2020, salesforce.com, inc. 3 | * All rights reserved. 4 | * Licensed under the BSD 3-Clause license. 5 | * For full license text, see LICENSE.txt file in the repo root or https://opensource.org/licenses/BSD-3-Clause 6 | */ 7 | import { assert, expect, config } from 'chai'; 8 | import { Org, SfError, OrgConfigProperties } from '@salesforce/core'; 9 | import { MockTestOrgData, shouldThrow, TestContext } from '@salesforce/core/testSetup'; 10 | import { getHubOrThrow, getOrgOrThrow, maybeGetHub, maybeGetOrg } from '../../../src/flags/orgFlags.js'; 11 | 12 | config.truncateThreshold = 0; 13 | 14 | describe('org flags', () => { 15 | const $$ = new TestContext(); 16 | const testOrg = new MockTestOrgData(); 17 | const testHub = new MockTestOrgData(); 18 | // set these into the "cache" to avoid "server checks" 19 | testOrg.isDevHub = false; 20 | testHub.isDevHub = true; 21 | 22 | beforeEach(async () => { 23 | await $$.stubAuths(testOrg, testHub); 24 | }); 25 | afterEach(async () => { 26 | $$.restore(); 27 | }); 28 | 29 | describe('requiredOrg', () => { 30 | it('has input, returns org', async () => { 31 | const retrieved = await getOrgOrThrow(testOrg.username); 32 | expect(retrieved.getOrgId()).to.equal(testOrg.orgId); 33 | }); 34 | // skipped tests are waiting for a fix to core/testSetup https://github.com/forcedotcom/sfdx-core/pull/748 35 | it.skip('has input, no org found => throw', async () => { 36 | try { 37 | await shouldThrow(getOrgOrThrow('nope@bad.fail')); 38 | } catch (e) { 39 | assert(e instanceof SfError); 40 | expect(e).to.have.property('name', 'NamedOrgNotFound'); 41 | } 42 | }); 43 | it('no input, uses default', async () => { 44 | await $$.stubConfig({ [OrgConfigProperties.TARGET_ORG]: testOrg.username }); 45 | expect(await getOrgOrThrow()).to.be.instanceOf(Org); 46 | }); 47 | it('no input, no default => throw', async () => { 48 | await $$.stubConfig({}); 49 | try { 50 | await shouldThrow(getOrgOrThrow()); 51 | } catch (e) { 52 | assert(e instanceof SfError); 53 | expect(e).to.have.property('name', 'NoDefaultEnvError'); 54 | } 55 | }); 56 | }); 57 | describe('optionalOrg', () => { 58 | it('has input, returns org', async () => { 59 | const retrieved = await maybeGetOrg(testOrg.username); 60 | expect(retrieved.getOrgId()).to.equal(testOrg.orgId); 61 | }); 62 | it.skip('has input, no org => throw', async () => { 63 | try { 64 | await shouldThrow(maybeGetOrg('nope@bad.fail')); 65 | } catch (e) { 66 | assert(e instanceof SfError); 67 | expect(e).to.have.property('name', 'NamedOrgNotFound'); 68 | } 69 | }); 70 | it('no input, uses default', async () => { 71 | await $$.stubConfig({ [OrgConfigProperties.TARGET_ORG]: testOrg.username }); 72 | expect(await maybeGetOrg()).to.be.instanceOf(Org); 73 | }); 74 | it('no input, no default => ok', async () => { 75 | expect(await maybeGetOrg()).to.be.undefined; 76 | }); 77 | }); 78 | describe('requiredHub', () => { 79 | it('has input, returns org', async () => { 80 | expect(await getHubOrThrow(testHub.username)).to.be.instanceOf(Org); 81 | }); 82 | it('has input, finds org that is not a hub => throw', async () => { 83 | try { 84 | await shouldThrow(getHubOrThrow(testOrg.username)); 85 | } catch (e) { 86 | assert(e instanceof SfError); 87 | expect(e).to.have.property('name', 'NotADevHubError'); 88 | } 89 | }); 90 | it.skip('has input, no org => throw', async () => { 91 | try { 92 | await shouldThrow(maybeGetHub('nope@bad.fail')); 93 | } catch (e) { 94 | assert(e instanceof SfError); 95 | expect(e).to.have.property('name', 'NamedOrgNotFound'); 96 | } 97 | }); 98 | it('no input, uses default', async () => { 99 | await $$.stubConfig({ [OrgConfigProperties.TARGET_DEV_HUB]: testHub.username }); 100 | const retrieved = await getHubOrThrow(); 101 | expect(retrieved).to.be.instanceOf(Org); 102 | expect(retrieved.getOrgId()).to.equal(testHub.orgId); 103 | }); 104 | it('no input, uses default but is not a hub => throw', async () => { 105 | await $$.stubConfig({ [OrgConfigProperties.TARGET_DEV_HUB]: testOrg.username }); 106 | try { 107 | await shouldThrow(getHubOrThrow()); 108 | } catch (e) { 109 | assert(e instanceof SfError); 110 | expect(e).to.have.property('name', 'NotADevHubError'); 111 | } 112 | }); 113 | it('no input, no default => throws', async () => { 114 | await $$.stubConfig({}); 115 | try { 116 | await shouldThrow(getHubOrThrow()); 117 | } catch (e) { 118 | assert(e instanceof SfError); 119 | expect(e).to.have.property('name', 'NoDefaultDevHubError'); 120 | } 121 | }); 122 | }); 123 | describe('optionalHub', () => { 124 | it('has input, returns org', async () => { 125 | const retrieved = await maybeGetHub(testHub.username); 126 | expect(retrieved).to.be.instanceOf(Org); 127 | }); 128 | it('has input, finds org that is not a hub => throw', async () => { 129 | try { 130 | await shouldThrow(maybeGetHub(testOrg.username)); 131 | } catch (e) { 132 | assert(e instanceof SfError); 133 | expect(e).to.have.property('name', 'NotADevHubError'); 134 | } 135 | }); 136 | it.skip('has input, no org => throws', async () => { 137 | try { 138 | await shouldThrow(maybeGetHub('nope@bad.fail')); 139 | } catch (e) { 140 | assert(e instanceof SfError); 141 | expect(e).to.have.property('name', 'NamedOrgNotFound'); 142 | } 143 | }); 144 | it('no input, uses default', async () => { 145 | await $$.stubConfig({ [OrgConfigProperties.TARGET_DEV_HUB]: testHub.username }); 146 | const retrieved = await maybeGetHub(); 147 | assert(retrieved instanceof Org); 148 | expect(retrieved?.getOrgId()).to.equal(testHub.orgId); 149 | }); 150 | it('no input, uses default but is not a hub => throw', async () => { 151 | await $$.stubConfig({ [OrgConfigProperties.TARGET_DEV_HUB]: testOrg.username }); 152 | try { 153 | await shouldThrow(maybeGetHub()); 154 | } catch (e) { 155 | assert(e instanceof SfError); 156 | expect(e).to.have.property('name', 'NotADevHubError'); 157 | } 158 | }); 159 | it('no input, no default hub, default target org => undefined', async () => { 160 | await $$.stubConfig({ [OrgConfigProperties.TARGET_ORG]: testOrg.username }); 161 | expect(await maybeGetHub()).to.be.undefined; 162 | }); 163 | it('no input, no default => ok', async () => { 164 | await $$.stubConfig({}); 165 | expect(await maybeGetHub()).to.be.undefined; 166 | }); 167 | }); 168 | }); 169 | -------------------------------------------------------------------------------- /test/unit/sfCommand.test.ts: -------------------------------------------------------------------------------- 1 | /* 2 | * Copyright (c) 2020, salesforce.com, inc. 3 | * All rights reserved. 4 | * Licensed under the BSD 3-Clause license. 5 | * For full license text, see LICENSE.txt file in the repo root or https://opensource.org/licenses/BSD-3-Clause 6 | */ 7 | import { Flags } from '@oclif/core'; 8 | import { Errors } from '@oclif/core'; 9 | import { Lifecycle } from '@salesforce/core/lifecycle'; 10 | import { TestContext } from '@salesforce/core/testSetup'; 11 | import { assert, expect } from 'chai'; 12 | import { SfError } from '@salesforce/core/sfError'; 13 | import { Config } from '@oclif/core/interfaces'; 14 | import { SfCommand } from '../../src/sfCommand.js'; 15 | import { SfCommandError } from '../../src/SfCommandError.js'; 16 | import { StandardColors } from '../../src/ux/standardColors.js'; 17 | import { stubSfCommandUx, stubSpinner } from '../../src/stubUx.js'; 18 | 19 | class TestCommand extends SfCommand { 20 | public static readonly flags = { 21 | actions: Flags.boolean({ char: 'a', description: 'show actions' }), 22 | error: Flags.boolean({ char: 'e', description: 'throw an error' }), 23 | warn: Flags.boolean({ char: 'w', description: 'throw a warning' }), 24 | }; 25 | 26 | public async run(): Promise { 27 | const { flags } = await this.parse(TestCommand); 28 | 29 | if (flags.error && !flags.warn) { 30 | const infoError = new SfError('foo bar baz', 'FooError', flags.actions ? ['this', 'is an', 'action'] : undefined); 31 | this.info(infoError); 32 | } else if (flags.warn) { 33 | if (flags.error) { 34 | const warnError = new SfError( 35 | 'foo bar baz', 36 | 'FooError', 37 | flags.actions ? ['this', 'is an', 'action'] : undefined 38 | ); 39 | this.warn(warnError); 40 | } else { 41 | this.warn('foo bar baz'); 42 | } 43 | } else { 44 | this.info('foo bar baz'); 45 | } 46 | } 47 | } 48 | 49 | const errCause = new Error('the error cause'); 50 | const errActions = ['action1', 'action2']; 51 | const errData = { prop1: 'foo', prop2: 'bar' }; 52 | 53 | // A Command that will throw different kinds of errors to ensure 54 | // consistent error behavior. 55 | class TestCommandErrors extends SfCommand { 56 | public static buildFullError = () => { 57 | const err = new Error('full Error message'); 58 | err.name = 'FullErrorName'; 59 | err.cause = errCause; 60 | return err; 61 | }; 62 | 63 | public static buildFullSfError = () => 64 | SfError.create({ 65 | message: 'full SfError message', 66 | name: 'FullSfErrorName', 67 | actions: errActions, 68 | context: 'TestCmdError', // purposely different from the default 69 | exitCode: 69, 70 | cause: errCause, 71 | data: errData, 72 | }); 73 | 74 | public static buildOclifError = () => { 75 | const err = new Errors.CLIError('Nonexistent flag: --INVALID\nSee more help with --help'); 76 | err.oclif = { exit: 2 }; 77 | err.code = undefined; 78 | return err; 79 | }; 80 | 81 | // eslint-disable-next-line @typescript-eslint/member-ordering 82 | public static errors: { [x: string]: Error } = { 83 | error: new Error('error message'), 84 | sfError: new SfError('sfError message'), 85 | fullError: TestCommandErrors.buildFullError(), 86 | fullSfError: TestCommandErrors.buildFullSfError(), 87 | oclifError: TestCommandErrors.buildOclifError(), 88 | }; 89 | 90 | // eslint-disable-next-line @typescript-eslint/member-ordering 91 | public static readonly flags = { 92 | error: Flags.string({ 93 | char: 'e', 94 | description: 'throw this error', 95 | required: true, 96 | options: Object.keys(TestCommandErrors.errors), 97 | }), 98 | }; 99 | 100 | public async run(): Promise { 101 | const { flags } = await this.parse(TestCommandErrors); 102 | throw TestCommandErrors.errors[flags.error]; 103 | } 104 | } 105 | 106 | class NonJsonCommand extends SfCommand { 107 | public static enableJsonFlag = false; 108 | public async run(): Promise { 109 | await this.parse(NonJsonCommand); 110 | } 111 | } 112 | 113 | class SuggestionCommand extends SfCommand { 114 | public static enableJsonFlag = false; 115 | public static readonly flags = { 116 | first: Flags.string({ 117 | default: 'My first flag', 118 | required: true, 119 | }), 120 | second: Flags.string({ 121 | default: 'My second', 122 | required: true, 123 | }), 124 | }; 125 | public async run(): Promise { 126 | await this.parse(SuggestionCommand); 127 | } 128 | } 129 | 130 | describe('jsonEnabled', () => { 131 | afterEach(() => { 132 | delete process.env.SF_CONTENT_TYPE; 133 | }); 134 | 135 | const oclifConfig = {} as unknown as Config; 136 | it('not json', () => { 137 | // @ts-expect-error not really an oclif config 138 | const cmd = new TestCommand([], oclifConfig); 139 | expect(cmd.jsonEnabled()).to.be.false; 140 | }); 141 | it('json via flag but not env', () => { 142 | // @ts-expect-error not really an oclif config 143 | const cmd = new TestCommand(['--json'], oclifConfig); 144 | expect(cmd.jsonEnabled()).to.be.true; 145 | }); 146 | it('json via env but not flag', () => { 147 | process.env.SF_CONTENT_TYPE = 'JSON'; 148 | // @ts-expect-error not really an oclif config 149 | const cmd = new TestCommand([], oclifConfig); 150 | expect(cmd.jsonEnabled()).to.be.true; 151 | }); 152 | it('json via env lowercase', () => { 153 | process.env.SF_CONTENT_TYPE = 'json'; 154 | // @ts-expect-error not really an oclif config 155 | const cmd = new TestCommand([], oclifConfig); 156 | expect(cmd.jsonEnabled()).to.be.true; 157 | }); 158 | it('not json via env that is not json', () => { 159 | process.env.SF_CONTENT_TYPE = 'foo'; 160 | // @ts-expect-error not really an oclif config 161 | const cmd = new TestCommand([], oclifConfig); 162 | expect(cmd.jsonEnabled()).to.be.false; 163 | }); 164 | it('json via both flag and env', () => { 165 | process.env.SF_CONTENT_TYPE = 'JSON'; 166 | // @ts-expect-error not really an oclif config 167 | const cmd = new TestCommand(['--json'], oclifConfig); 168 | expect(cmd.jsonEnabled()).to.be.true; 169 | }); 170 | 171 | describe('non json command', () => { 172 | it('non-json command base case', () => { 173 | // @ts-expect-error not really an oclif config 174 | const cmd = new NonJsonCommand([], oclifConfig); 175 | expect(cmd.jsonEnabled()).to.be.false; 176 | }); 177 | it('non-json command is not affected by env', () => { 178 | process.env.SF_CONTENT_TYPE = 'JSON'; 179 | // @ts-expect-error not really an oclif config 180 | const cmd = new NonJsonCommand([], oclifConfig); 181 | expect(cmd.jsonEnabled()).to.be.false; 182 | }); 183 | }); 184 | }); 185 | 186 | describe('info messages', () => { 187 | const $$ = new TestContext(); 188 | beforeEach(() => { 189 | // @ts-expect-error partial stub 190 | $$.SANDBOX.stub(Lifecycle, 'getInstance').returns({ 191 | on: $$.SANDBOX.stub(), 192 | onWarning: $$.SANDBOX.stub(), 193 | }); 194 | }); 195 | 196 | it('should show a info message from a string', async () => { 197 | const infoStub = $$.SANDBOX.stub(SfCommand.prototype, 'info'); 198 | await TestCommand.run([]); 199 | expect(infoStub.calledWith('foo bar baz')).to.be.true; 200 | }); 201 | 202 | it('should show a info message from Error, no actions', async () => { 203 | const logStub = $$.SANDBOX.stub(SfCommand.prototype, 'log'); 204 | await TestCommand.run(['--error']); 205 | expect(logStub.firstCall.firstArg).to.include('foo bar baz'); 206 | }); 207 | 208 | it('should show a info message, with actions', async () => { 209 | const logStub = $$.SANDBOX.stub(SfCommand.prototype, 'log'); 210 | await TestCommand.run(['--error', '--actions']); 211 | expect(logStub.firstCall.firstArg) 212 | .to.include('foo bar baz') 213 | .and.to.include('this') 214 | .and.to.include('is an') 215 | .and.to.include('action'); 216 | }); 217 | }); 218 | 219 | describe('warning messages', () => { 220 | const $$ = new TestContext(); 221 | beforeEach(() => { 222 | // @ts-expect-error partial stub 223 | $$.SANDBOX.stub(Lifecycle, 'getInstance').returns({ 224 | on: $$.SANDBOX.stub(), 225 | onWarning: $$.SANDBOX.stub(), 226 | }); 227 | }); 228 | 229 | it('should show a info message from a string', async () => { 230 | const logToStderrStub = $$.SANDBOX.stub(SfCommand.prototype, 'logToStderr'); 231 | await TestCommand.run(['--warn']); 232 | expect(logToStderrStub.firstCall.firstArg).to.include('Warning').and.to.include('foo bar baz'); 233 | }); 234 | 235 | it('should show a warning message from Error, no actions', async () => { 236 | const logToStderrStub = $$.SANDBOX.stub(SfCommand.prototype, 'logToStderr'); 237 | await TestCommand.run(['--warn', '--error']); 238 | expect(logToStderrStub.firstCall.firstArg).to.include('Warning').and.to.include('foo bar baz'); 239 | }); 240 | 241 | it('should show a info message from Error, with actions', async () => { 242 | const logToStderrStub = $$.SANDBOX.stub(SfCommand.prototype, 'logToStderr'); 243 | await TestCommand.run(['--warn', '--error', '--actions']); 244 | expect(logToStderrStub.firstCall.firstArg) 245 | .to.include('Warning') 246 | .and.to.include('foo bar baz') 247 | .and.to.include('this') 248 | .and.to.include('is an') 249 | .and.to.include('action'); 250 | }); 251 | }); 252 | 253 | describe('error standardization', () => { 254 | const $$ = new TestContext(); 255 | 256 | let sfCommandErrorData: [Error?, string?]; 257 | const sfCommandErrorCb = (err: Error, cmdId: string) => { 258 | sfCommandErrorData = [err, cmdId]; 259 | }; 260 | 261 | beforeEach(() => { 262 | sfCommandErrorData = []; 263 | process.on('sfCommandError', sfCommandErrorCb); 264 | }); 265 | 266 | afterEach(() => { 267 | process.removeListener('sfCommandError', sfCommandErrorCb); 268 | process.exitCode = undefined; 269 | }); 270 | 271 | it('should log correct error when command throws an oclif Error', async () => { 272 | const logToStderrStub = $$.SANDBOX.stub(SfCommand.prototype, 'logToStderr'); 273 | try { 274 | await TestCommandErrors.run(['--error', 'oclifError']); 275 | expect(false, 'error should have been thrown').to.be.true; 276 | } catch (e: unknown) { 277 | expect(e).to.be.instanceOf(SfCommandError); 278 | const err = e as SfCommand.Error; 279 | 280 | // Ensure the error was logged to the console 281 | expect(logToStderrStub.callCount).to.equal(1); 282 | expect(logToStderrStub.firstCall.firstArg).to.contain(err.message); 283 | 284 | // Ensure the error has expected properties 285 | expect(err).to.have.property('actions', undefined); 286 | expect(err).to.have.property('exitCode', 2); 287 | expect(err).to.have.property('context', 'TestCommandErrors'); 288 | expect(err).to.have.property('data', undefined); 289 | expect(err).to.have.property('cause').and.be.ok; 290 | expect(err).to.have.property('code', '2'); 291 | expect(err).to.have.property('status', 2); 292 | expect(err).to.have.property('stack').and.be.ok; 293 | expect(err).to.have.property('skipOclifErrorHandling', true); 294 | expect(err).to.have.deep.property('oclif', { exit: 2 }); 295 | 296 | // Ensure a sfCommandError event was emitted with the expected data 297 | expect(sfCommandErrorData[0]).to.equal(err); 298 | expect(sfCommandErrorData[1]).to.equal('testcommanderrors'); 299 | } 300 | }); 301 | 302 | it('should log correct error when command throws an Error', async () => { 303 | const logToStderrStub = $$.SANDBOX.stub(SfCommand.prototype, 'logToStderr'); 304 | try { 305 | await TestCommandErrors.run(['--error', 'error']); 306 | expect(false, 'error should have been thrown').to.be.true; 307 | } catch (e: unknown) { 308 | expect(e).to.be.instanceOf(SfCommandError); 309 | const err = e as SfCommand.Error; 310 | 311 | // Ensure the error was logged to the console 312 | expect(logToStderrStub.callCount).to.equal(1); 313 | expect(logToStderrStub.firstCall.firstArg).to.contain(err.message); 314 | 315 | // Ensure the error has expected properties 316 | expect(err).to.have.property('actions', undefined); 317 | expect(err).to.have.property('exitCode', 1); 318 | expect(err).to.have.property('context', 'TestCommandErrors'); 319 | expect(err).to.have.property('data', undefined); 320 | expect(err).to.have.property('cause').and.be.ok; 321 | expect(err).to.have.property('code', '1'); 322 | expect(err).to.have.property('status', 1); 323 | expect(err).to.have.property('stack').and.be.ok; 324 | expect(err).to.have.property('skipOclifErrorHandling', true); 325 | expect(err).to.have.deep.property('oclif', { exit: 1 }); 326 | 327 | // Ensure a sfCommandError event was emitted with the expected data 328 | expect(sfCommandErrorData[0]).to.equal(err); 329 | expect(sfCommandErrorData[1]).to.equal('testcommanderrors'); 330 | } 331 | }); 332 | 333 | it('should log correct error when command throws an Error --json', async () => { 334 | const logJsonStub = $$.SANDBOX.stub(SfCommand.prototype, 'logJson'); 335 | try { 336 | await TestCommandErrors.run(['--error', 'error', '--json']); 337 | expect(false, 'error should have been thrown').to.be.true; 338 | } catch (e: unknown) { 339 | expect(e).to.be.instanceOf(SfCommandError); 340 | const err = e as SfCommand.Error; 341 | 342 | // Ensure the error was logged to the console 343 | expect(logJsonStub.callCount).to.equal(1); 344 | expect(logJsonStub.firstCall.firstArg).to.deep.equal(err.toJson()); 345 | 346 | // Ensure the error has expected properties 347 | expect(err).to.have.property('actions', undefined); 348 | expect(err).to.have.property('exitCode', 1); 349 | expect(err).to.have.property('context', 'TestCommandErrors'); 350 | expect(err).to.have.property('data', undefined); 351 | expect(err).to.have.property('cause').and.be.ok; 352 | expect(err).to.have.property('code', '1'); 353 | expect(err).to.have.property('status', 1); 354 | expect(err).to.have.property('stack').and.be.ok; 355 | expect(err).to.have.property('skipOclifErrorHandling', true); 356 | expect(err).to.have.deep.property('oclif', { exit: 1 }); 357 | 358 | // Ensure a sfCommandError event was emitted with the expected data 359 | expect(sfCommandErrorData[0]).to.equal(err); 360 | expect(sfCommandErrorData[1]).to.equal('testcommanderrors'); 361 | } 362 | }); 363 | 364 | it('should log correct error when command throws an SfError', async () => { 365 | const logToStderrStub = $$.SANDBOX.stub(SfCommand.prototype, 'logToStderr'); 366 | try { 367 | await TestCommandErrors.run(['--error', 'sfError']); 368 | expect(false, 'error should have been thrown').to.be.true; 369 | } catch (e: unknown) { 370 | expect(e).to.be.instanceOf(SfCommandError); 371 | const err = e as SfCommand.Error; 372 | 373 | // Ensure the error was logged to the console 374 | expect(logToStderrStub.callCount).to.equal(1); 375 | expect(logToStderrStub.firstCall.firstArg).to.contain(err.message); 376 | 377 | // Ensure the error has expected properties 378 | expect(err).to.have.property('actions', undefined); 379 | expect(err).to.have.property('exitCode', 1); 380 | expect(err).to.have.property('context', 'TestCommandErrors'); 381 | expect(err).to.have.property('data', undefined); 382 | expect(err).to.have.property('cause', undefined); 383 | expect(err).to.have.property('code', 'SfError'); 384 | expect(err).to.have.property('status', 1); 385 | expect(err).to.have.property('stack').and.be.ok; 386 | expect(err).to.have.property('skipOclifErrorHandling', true); 387 | expect(err).to.have.deep.property('oclif', { exit: 1 }); 388 | 389 | // Ensure a sfCommandError event was emitted with the expected data 390 | expect(sfCommandErrorData[0]).to.equal(err); 391 | expect(sfCommandErrorData[1]).to.equal('testcommanderrors'); 392 | } 393 | }); 394 | 395 | it('should log correct suggestion when user doesnt wrap with quotes', async () => { 396 | const logToStderrStub = $$.SANDBOX.stub(SfCommand.prototype, 'logToStderr'); 397 | try { 398 | await SuggestionCommand.run(['--first', 'my', 'alias', 'with', 'spaces', '--second', 'my second', 'value']); 399 | expect(false, 'error should have been thrown').to.be.true; 400 | } catch (e: unknown) { 401 | expect(e).to.be.instanceOf(SfCommandError); 402 | const err = e as SfCommand.Error; 403 | 404 | // Ensure the error was logged to the console 405 | expect(logToStderrStub.callCount).to.equal(1); 406 | expect(logToStderrStub.firstCall.firstArg).to.contain(err.message); 407 | 408 | // Ensure the error has expected properties 409 | expect(err).to.have.property('actions'); 410 | expect(err.actions).to.deep.equal(['--first "my alias with spaces"', '--second "my second value"']); 411 | expect(err).to.have.property('exitCode', 2); 412 | expect(err).to.have.property('context', 'SuggestionCommand'); 413 | expect(err).to.have.property('data', undefined); 414 | expect(err).to.have.property('cause'); 415 | expect(err).to.have.property('code', '2'); 416 | expect(err).to.have.property('status', 2); 417 | expect(err).to.have.property('stack').and.be.ok; 418 | expect(err).to.have.property('skipOclifErrorHandling', true); 419 | expect(err).to.have.deep.property('oclif', { exit: 2 }); 420 | 421 | // Ensure a sfCommandError event was emitted with the expected data 422 | expect(sfCommandErrorData[0]).to.equal(err); 423 | expect(sfCommandErrorData[1]).to.equal('suggestioncommand'); 424 | } 425 | }); 426 | it('should log correct suggestion when user doesnt wrap with quotes without flag order', async () => { 427 | const logToStderrStub = $$.SANDBOX.stub(SfCommand.prototype, 'logToStderr'); 428 | try { 429 | await SuggestionCommand.run(['--second', 'my second value', '--first', 'my', 'alias', 'with', 'spaces']); 430 | expect(false, 'error should have been thrown').to.be.true; 431 | } catch (e: unknown) { 432 | expect(e).to.be.instanceOf(SfCommandError); 433 | const err = e as SfCommand.Error; 434 | 435 | // Ensure the error was logged to the console 436 | expect(logToStderrStub.callCount).to.equal(1); 437 | expect(logToStderrStub.firstCall.firstArg).to.contain(err.message); 438 | 439 | // Ensure the error has expected properties 440 | expect(err).to.have.property('actions'); 441 | expect(err.actions).to.deep.equal(['--first "my alias with spaces"']); 442 | expect(err).to.have.property('exitCode', 2); 443 | expect(err).to.have.property('context', 'SuggestionCommand'); 444 | expect(err).to.have.property('data', undefined); 445 | expect(err).to.have.property('cause'); 446 | expect(err).to.have.property('code', '2'); 447 | expect(err).to.have.property('status', 2); 448 | expect(err).to.have.property('stack').and.be.ok; 449 | expect(err).to.have.property('skipOclifErrorHandling', true); 450 | expect(err).to.have.deep.property('oclif', { exit: 2 }); 451 | 452 | // Ensure a sfCommandError event was emitted with the expected data 453 | expect(sfCommandErrorData[0]).to.equal(err); 454 | expect(sfCommandErrorData[1]).to.equal('suggestioncommand'); 455 | } 456 | }); 457 | 458 | it('should log correct error when command throws an SfError --json', async () => { 459 | const logJsonStub = $$.SANDBOX.stub(SfCommand.prototype, 'logJson'); 460 | try { 461 | await TestCommandErrors.run(['--error', 'sfError', '--json']); 462 | expect(false, 'error should have been thrown').to.be.true; 463 | } catch (e: unknown) { 464 | expect(e).to.be.instanceOf(SfCommandError); 465 | const err = e as SfCommand.Error; 466 | 467 | // Ensure the error was logged to the console 468 | expect(logJsonStub.callCount).to.equal(1); 469 | expect(logJsonStub.firstCall.firstArg).to.deep.equal(err.toJson()); 470 | 471 | // Ensure the error has expected properties 472 | expect(err).to.have.property('name', 'SfError'); 473 | expect(err).to.have.property('actions', undefined); 474 | expect(err).to.have.property('exitCode', 1); 475 | expect(err).to.have.property('context', 'TestCommandErrors'); 476 | expect(err).to.have.property('data', undefined); 477 | expect(err).to.have.property('cause', undefined); 478 | expect(err).to.have.property('code', 'SfError'); 479 | expect(err).to.have.property('status', 1); 480 | expect(err).to.have.property('stack').and.be.ok; 481 | expect(err).to.have.property('skipOclifErrorHandling', true); 482 | expect(err).to.have.deep.property('oclif', { exit: 1 }); 483 | 484 | // Ensure a sfCommandError event was emitted with the expected data 485 | expect(sfCommandErrorData[0]).to.equal(err); 486 | expect(sfCommandErrorData[1]).to.equal('testcommanderrors'); 487 | } 488 | }); 489 | 490 | // A "full" Error has all props set allowed for an Error 491 | it('should log correct error when command throws a "full" Error', async () => { 492 | const logToStderrStub = $$.SANDBOX.stub(SfCommand.prototype, 'logToStderr'); 493 | try { 494 | await TestCommandErrors.run(['--error', 'fullError']); 495 | expect(false, 'error should have been thrown').to.be.true; 496 | } catch (e: unknown) { 497 | expect(e).to.be.instanceOf(SfCommandError); 498 | const err = e as SfCommand.Error; 499 | 500 | // Ensure the error was logged to the console 501 | expect(logToStderrStub.callCount).to.equal(1); 502 | expect(logToStderrStub.firstCall.firstArg).to.contain(err.message); 503 | 504 | // Ensure the error has expected properties 505 | expect(err).to.have.property('name', 'FullErrorName'); 506 | expect(err).to.have.property('actions', undefined); 507 | expect(err).to.have.property('exitCode', 1); 508 | expect(err).to.have.property('context', 'TestCommandErrors'); 509 | expect(err).to.have.property('data', undefined); 510 | // SfError.wrap() sets the original error as the cause 511 | expect(err.cause).to.have.property('name', 'FullErrorName'); 512 | expect(err.cause).to.have.property('cause', errCause); 513 | expect(err).to.have.property('code', '1'); 514 | expect(err).to.have.property('status', 1); 515 | expect(err).to.have.property('stack').and.be.ok; 516 | expect(err).to.have.property('skipOclifErrorHandling', true); 517 | expect(err).to.have.deep.property('oclif', { exit: 1 }); 518 | 519 | // Ensure a sfCommandError event was emitted with the expected data 520 | expect(sfCommandErrorData[0]).to.equal(err); 521 | expect(sfCommandErrorData[1]).to.equal('testcommanderrors'); 522 | } 523 | }); 524 | 525 | // A "full" Error has all props set allowed for an Error 526 | it('should log correct error when command throws a "full" Error --json', async () => { 527 | const logJsonStub = $$.SANDBOX.stub(SfCommand.prototype, 'logJson'); 528 | try { 529 | await TestCommandErrors.run(['--error', 'fullError', '--json']); 530 | expect(false, 'error should have been thrown').to.be.true; 531 | } catch (e: unknown) { 532 | expect(e).to.be.instanceOf(SfCommandError); 533 | const err = e as SfCommand.Error; 534 | 535 | // Ensure the error was logged to the console 536 | expect(logJsonStub.callCount).to.equal(1); 537 | expect(logJsonStub.firstCall.firstArg).to.deep.equal(err.toJson()); 538 | 539 | // Ensure the error has expected properties 540 | expect(err).to.have.property('name', 'FullErrorName'); 541 | expect(err).to.have.property('actions', undefined); 542 | expect(err).to.have.property('exitCode', 1); 543 | expect(err).to.have.property('context', 'TestCommandErrors'); 544 | expect(err).to.have.property('data', undefined); 545 | // SfError.wrap() sets the original error as the cause 546 | expect(err.cause).to.have.property('name', 'FullErrorName'); 547 | expect(err.cause).to.have.property('cause', errCause); 548 | expect(err).to.have.property('code', '1'); 549 | expect(err).to.have.property('status', 1); 550 | expect(err).to.have.property('stack').and.be.ok; 551 | expect(err).to.have.property('skipOclifErrorHandling', true); 552 | expect(err).to.have.deep.property('oclif', { exit: 1 }); 553 | 554 | // Ensure a sfCommandError event was emitted with the expected data 555 | expect(sfCommandErrorData[0]).to.equal(err); 556 | expect(sfCommandErrorData[1]).to.equal('testcommanderrors'); 557 | } 558 | }); 559 | 560 | // A "full" SfError has all props set allowed for an SfError 561 | it('should log correct error when command throws a "full" SfError', async () => { 562 | const logToStderrStub = $$.SANDBOX.stub(SfCommand.prototype, 'logToStderr'); 563 | try { 564 | await TestCommandErrors.run(['--error', 'fullSfError']); 565 | expect(false, 'error should have been thrown').to.be.true; 566 | } catch (e: unknown) { 567 | expect(e).to.be.instanceOf(SfCommandError); 568 | const err = e as SfCommand.Error; 569 | 570 | // Ensure the error was logged to the console 571 | expect(logToStderrStub.callCount).to.equal(1); 572 | expect(logToStderrStub.firstCall.firstArg).to.contain(err.message); 573 | 574 | // Ensure the error has expected properties 575 | expect(err).to.have.property('name', 'FullSfErrorName'); 576 | expect(err).to.have.property('actions', errActions); 577 | expect(err).to.have.property('exitCode', 69); 578 | expect(err).to.have.property('context', 'TestCmdError'); 579 | expect(err).to.have.property('data', errData); 580 | expect(err).to.have.property('cause', errCause); 581 | expect(err).to.have.property('code', 'FullSfErrorName'); 582 | expect(err).to.have.property('status', 69); 583 | expect(err).to.have.property('stack').and.be.ok; 584 | expect(err).to.have.property('skipOclifErrorHandling', true); 585 | expect(err).to.have.deep.property('oclif', { exit: 69 }); 586 | 587 | // Ensure a sfCommandError event was emitted with the expected data 588 | expect(sfCommandErrorData[0]).to.equal(err); 589 | expect(sfCommandErrorData[1]).to.equal('testcommanderrors'); 590 | } 591 | }); 592 | 593 | // A "full" SfError has all props set allowed for an SfError 594 | it('should log correct error when command throws a "full" SfError --json', async () => { 595 | const logJsonStub = $$.SANDBOX.stub(SfCommand.prototype, 'logJson'); 596 | try { 597 | await TestCommandErrors.run(['--error', 'fullSfError', '--json']); 598 | expect(false, 'error should have been thrown').to.be.true; 599 | } catch (e: unknown) { 600 | expect(e).to.be.instanceOf(SfCommandError); 601 | const err = e as SfCommand.Error; 602 | 603 | // Ensure the error was logged to the console 604 | expect(logJsonStub.callCount).to.equal(1); 605 | expect(logJsonStub.firstCall.firstArg).to.deep.equal(err.toJson()); 606 | 607 | // Ensure the error has expected properties 608 | expect(err).to.have.property('name', 'FullSfErrorName'); 609 | expect(err).to.have.property('actions', errActions); 610 | expect(err).to.have.property('exitCode', 69); 611 | expect(err).to.have.property('context', 'TestCmdError'); 612 | expect(err).to.have.property('data', errData); 613 | expect(err).to.have.property('cause', errCause); 614 | expect(err).to.have.property('code', 'FullSfErrorName'); 615 | expect(err).to.have.property('status', 69); 616 | expect(err).to.have.property('stack').and.be.ok; 617 | expect(err).to.have.property('skipOclifErrorHandling', true); 618 | expect(err).to.have.deep.property('oclif', { exit: 69 }); 619 | 620 | // Ensure a sfCommandError event was emitted with the expected data 621 | expect(sfCommandErrorData[0]).to.equal(err); 622 | expect(sfCommandErrorData[1]).to.equal('testcommanderrors'); 623 | } 624 | }); 625 | }); 626 | 627 | describe('spinner stops on errors', () => { 628 | const $$ = new TestContext(); 629 | 630 | class SpinnerThrow extends SfCommand { 631 | // public static enableJsonFlag = true; 632 | public static flags = { 633 | throw: Flags.boolean(), 634 | }; 635 | public async run(): Promise { 636 | const { flags } = await this.parse(SpinnerThrow); 637 | this.spinner.start('go'); 638 | if (flags.throw) { 639 | throw new Error('boo'); 640 | } 641 | } 642 | } 643 | 644 | it("spinner stops but stop isn't called", async () => { 645 | const spinnerStub = stubSpinner($$.SANDBOX); 646 | stubSfCommandUx($$.SANDBOX); 647 | try { 648 | await SpinnerThrow.run(['--throw']); 649 | throw new Error('should have thrown'); 650 | } catch (e) { 651 | assert(e instanceof Error); 652 | expect(e.message).to.equal('boo'); 653 | expect(spinnerStub.start.callCount).to.equal(1); 654 | expect(spinnerStub.stop.callCount).to.equal(1); 655 | expect(spinnerStub.stop.firstCall.firstArg).to.equal(StandardColors.error('Error')); 656 | } 657 | }); 658 | it('spinner not stopped when no throw', async () => { 659 | const spinnerStub = stubSpinner($$.SANDBOX); 660 | stubSfCommandUx($$.SANDBOX); 661 | await SpinnerThrow.run([]); 662 | 663 | expect(spinnerStub.start.callCount).to.equal(1); 664 | expect(spinnerStub.stop.callCount).to.equal(0); 665 | }); 666 | }); 667 | -------------------------------------------------------------------------------- /test/unit/stubUx.test.ts: -------------------------------------------------------------------------------- 1 | /* 2 | * Copyright (c) 2023, salesforce.com, inc. 3 | * All rights reserved. 4 | * Licensed under the BSD 3-Clause license. 5 | * For full license text, see LICENSE.txt file in the repo root or https://opensource.org/licenses/BSD-3-Clause 6 | */ 7 | import { Interfaces } from '@oclif/core'; 8 | import { expect } from 'chai'; 9 | import { TestContext } from '@salesforce/core/testSetup'; 10 | import { Lifecycle } from '@salesforce/core/lifecycle'; 11 | import { stubUx, stubSfCommandUx, SfCommand, Ux, stubSpinner, Flags } from '../../src/exported.js'; 12 | 13 | const TABLE_DATA = Array.from({ length: 10 }).fill({ id: '123', name: 'foo', value: 'bar' }) as Array< 14 | Record 15 | >; 16 | 17 | class Cmd extends SfCommand { 18 | public static flags = { 19 | method: Flags.custom<'SfCommand' | 'Ux'>({ 20 | options: ['SfCommand', 'Ux'], 21 | })({ 22 | required: true, 23 | }), 24 | info: Flags.boolean(), 25 | log: Flags.boolean(), 26 | logSensitive: Flags.boolean(), 27 | logSuccess: Flags.boolean(), 28 | logToStderr: Flags.boolean(), 29 | spinner: Flags.boolean(), 30 | styledHeader: Flags.boolean(), 31 | styledJSON: Flags.boolean(), 32 | styledObject: Flags.boolean(), 33 | table: Flags.boolean(), 34 | url: Flags.boolean(), 35 | warn: Flags.boolean(), 36 | }; 37 | 38 | private flags!: Interfaces.InferredFlags; 39 | 40 | public async run(): Promise { 41 | const { flags } = await this.parse(Cmd); 42 | this.flags = flags; 43 | 44 | if (flags.info) this.runInfo(); 45 | if (flags.log) this.runLog(); 46 | if (flags.logSensitive) this.runLogSensitive(); 47 | if (flags.logSuccess) this.runLogSuccess(); 48 | if (flags.logToStderr) this.runLogToStderr(); 49 | if (flags.spinner) this.runSpinner(); 50 | if (flags.styledHeader) this.runStyledHeader(); 51 | if (flags.styledJSON) this.runStyledJSON(); 52 | if (flags.styledObject) this.runStyledObject(); 53 | if (flags.table) this.runTable(); 54 | if (flags.url) this.runUrl(); 55 | if (flags.warn) this.runWarn(); 56 | } 57 | 58 | private runInfo(): void { 59 | switch (this.flags.method) { 60 | case 'SfCommand': 61 | this.info('hello'); 62 | break; 63 | case 'Ux': 64 | throw new Error('Ux.info is not implemented'); 65 | default: 66 | throw new Error(`Invalid method: ${this.flags.method}`); 67 | } 68 | this.info('hello'); 69 | } 70 | 71 | private runLog(): void { 72 | switch (this.flags.method) { 73 | case 'SfCommand': 74 | this.log('hello'); 75 | break; 76 | case 'Ux': 77 | new Ux().log('hello'); 78 | break; 79 | default: 80 | throw new Error(`Invalid method: ${this.flags.method}`); 81 | } 82 | } 83 | 84 | private runLogSuccess(): void { 85 | switch (this.flags.method) { 86 | case 'SfCommand': 87 | this.logSuccess('hello'); 88 | break; 89 | case 'Ux': 90 | throw new Error('Ux.logSuccess is not implemented'); 91 | default: 92 | throw new Error(`Invalid method: ${this.flags.method}`); 93 | } 94 | } 95 | 96 | private runLogSensitive(): void { 97 | switch (this.flags.method) { 98 | case 'SfCommand': 99 | this.logSensitive('hello'); 100 | break; 101 | case 'Ux': 102 | throw new Error('Ux.logSensitive is not implemented'); 103 | default: 104 | throw new Error(`Invalid method: ${this.flags.method}`); 105 | } 106 | } 107 | 108 | private runLogToStderr(): void { 109 | switch (this.flags.method) { 110 | case 'SfCommand': 111 | this.logToStderr('hello'); 112 | break; 113 | case 'Ux': 114 | throw new Error('Ux.logToStderr is not implemented'); 115 | default: 116 | throw new Error(`Invalid method: ${this.flags.method}`); 117 | } 118 | } 119 | 120 | private runWarn(): void { 121 | switch (this.flags.method) { 122 | case 'SfCommand': 123 | this.warn('hello'); 124 | break; 125 | case 'Ux': 126 | new Ux().warn('hello'); 127 | break; 128 | default: 129 | throw new Error(`Invalid method: ${this.flags.method}`); 130 | } 131 | } 132 | 133 | private runTable(): void { 134 | switch (this.flags.method) { 135 | case 'SfCommand': 136 | this.table({ data: TABLE_DATA }); 137 | break; 138 | case 'Ux': 139 | new Ux().table({ data: TABLE_DATA }); 140 | break; 141 | default: 142 | throw new Error(`Invalid method: ${this.flags.method}`); 143 | } 144 | } 145 | 146 | private runUrl(): void { 147 | switch (this.flags.method) { 148 | case 'SfCommand': 149 | this.url('oclif', 'https://oclif.io'); 150 | break; 151 | case 'Ux': 152 | new Ux().url('oclif', 'https://oclif.io'); 153 | break; 154 | default: 155 | throw new Error(`Invalid method: ${this.flags.method}`); 156 | } 157 | } 158 | 159 | private runStyledHeader(): void { 160 | switch (this.flags.method) { 161 | case 'SfCommand': 162 | this.styledHeader('hello'); 163 | break; 164 | case 'Ux': 165 | new Ux().styledHeader('hello'); 166 | break; 167 | default: 168 | throw new Error(`Invalid method: ${this.flags.method}`); 169 | } 170 | } 171 | 172 | private runStyledObject(): void { 173 | switch (this.flags.method) { 174 | case 'SfCommand': 175 | this.styledObject({ foo: 'bar' }); 176 | break; 177 | case 'Ux': 178 | new Ux().styledObject({ foo: 'bar' }); 179 | break; 180 | default: 181 | throw new Error(`Invalid method: ${this.flags.method}`); 182 | } 183 | } 184 | 185 | private runStyledJSON(): void { 186 | switch (this.flags.method) { 187 | case 'SfCommand': 188 | this.styledJSON({ foo: 'bar' }); 189 | break; 190 | case 'Ux': 191 | new Ux().styledJSON({ foo: 'bar' }); 192 | break; 193 | default: 194 | throw new Error(`Invalid method: ${this.flags.method}`); 195 | } 196 | } 197 | 198 | private runSpinner(): void { 199 | switch (this.flags.method) { 200 | case 'SfCommand': 201 | this.spinner.start('starting spinner'); 202 | this.spinner.stop('done'); 203 | break; 204 | case 'Ux': 205 | new Ux().spinner.start('starting spinner'); 206 | new Ux().spinner.stop('done'); 207 | break; 208 | default: 209 | throw new Error(`Invalid method: ${this.flags.method}`); 210 | } 211 | } 212 | } 213 | 214 | describe('Ux Stubs', () => { 215 | let uxStubs: ReturnType; 216 | let sfCommandUxStubs: ReturnType; 217 | let spinnerStubs: ReturnType; 218 | 219 | const $$ = new TestContext(); 220 | 221 | beforeEach(() => { 222 | // @ts-expect-error partial stub 223 | $$.SANDBOX.stub(Lifecycle, 'getInstance').returns({ 224 | on: $$.SANDBOX.stub(), 225 | onWarning: $$.SANDBOX.stub(), 226 | }); 227 | 228 | uxStubs = stubUx($$.SANDBOX); 229 | sfCommandUxStubs = stubSfCommandUx($$.SANDBOX); 230 | spinnerStubs = stubSpinner($$.SANDBOX); 231 | }); 232 | 233 | describe('SfCommand methods', () => { 234 | it('should stub log', async () => { 235 | await Cmd.run(['--log', '--method=SfCommand']); 236 | expect(sfCommandUxStubs.log.firstCall.args).to.deep.equal(['hello']); 237 | }); 238 | 239 | it('should stub logSuccess', async () => { 240 | await Cmd.run(['--logSuccess', '--method=SfCommand']); 241 | expect(sfCommandUxStubs.logSuccess.firstCall.args).to.deep.equal(['hello']); 242 | }); 243 | 244 | it('should stub logSensitive', async () => { 245 | await Cmd.run(['--logSensitive', '--method=SfCommand']); 246 | expect(sfCommandUxStubs.logSensitive.firstCall.args).to.deep.equal(['hello']); 247 | }); 248 | 249 | it('should stub logToStderr', async () => { 250 | await Cmd.run(['--logToStderr', '--method=SfCommand']); 251 | expect(sfCommandUxStubs.logToStderr.firstCall.args).to.deep.equal(['hello']); 252 | }); 253 | 254 | it('should stub warn', async () => { 255 | await Cmd.run(['--warn', '--method=SfCommand']); 256 | expect(sfCommandUxStubs.warn.firstCall.args).to.deep.equal(['hello']); 257 | }); 258 | 259 | it('should stub table', async () => { 260 | await Cmd.run(['--table', '--method=SfCommand']); 261 | expect(sfCommandUxStubs.table.firstCall.args).to.deep.equal([{ data: TABLE_DATA }]); 262 | }); 263 | 264 | it('should stub url', async () => { 265 | await Cmd.run(['--url', '--method=SfCommand']); 266 | expect(sfCommandUxStubs.url.firstCall.args).to.deep.equal(['oclif', 'https://oclif.io']); 267 | }); 268 | 269 | it('should stub styledHeader', async () => { 270 | await Cmd.run(['--styledHeader', '--method=SfCommand']); 271 | expect(sfCommandUxStubs.styledHeader.firstCall.args).to.deep.equal(['hello']); 272 | }); 273 | 274 | it('should stub styledObject', async () => { 275 | await Cmd.run(['--styledObject', '--method=SfCommand']); 276 | expect(sfCommandUxStubs.styledObject.firstCall.args).to.deep.equal([{ foo: 'bar' }]); 277 | }); 278 | 279 | it('should stub styledJSON', async () => { 280 | await Cmd.run(['--styledJSON', '--method=SfCommand']); 281 | expect(sfCommandUxStubs.styledJSON.firstCall.args).to.deep.equal([{ foo: 'bar' }]); 282 | }); 283 | 284 | it('should stub spinner', async () => { 285 | await Cmd.run(['--spinner', '--method=SfCommand']); 286 | expect(true).to.be.true; 287 | expect(spinnerStubs.start.firstCall.args).to.deep.equal(['starting spinner']); 288 | expect(spinnerStubs.stop.firstCall.args).to.deep.equal(['done']); 289 | }); 290 | }); 291 | 292 | describe('Ux methods run in SfCommand', () => { 293 | it('should stub log', async () => { 294 | await Cmd.run(['--log', '--method=Ux']); 295 | expect(uxStubs.log.firstCall.args).to.deep.equal(['hello']); 296 | }); 297 | 298 | it('should stub warn', async () => { 299 | await Cmd.run(['--warn', '--method=Ux']); 300 | expect(uxStubs.warn.firstCall.args).to.deep.equal(['hello']); 301 | }); 302 | 303 | it('should stub table', async () => { 304 | await Cmd.run(['--table', '--method=Ux']); 305 | expect(uxStubs.table.firstCall.args).to.deep.equal([{ data: TABLE_DATA }]); 306 | }); 307 | 308 | it('should stub url', async () => { 309 | await Cmd.run(['--url', '--method=Ux']); 310 | expect(uxStubs.url.firstCall.args).to.deep.equal(['oclif', 'https://oclif.io']); 311 | }); 312 | 313 | it('should stub styledHeader', async () => { 314 | await Cmd.run(['--styledHeader', '--method=Ux']); 315 | expect(uxStubs.styledHeader.firstCall.args).to.deep.equal(['hello']); 316 | }); 317 | 318 | it('should stub styledObject', async () => { 319 | await Cmd.run(['--styledObject', '--method=Ux']); 320 | expect(uxStubs.styledObject.firstCall.args).to.deep.equal([{ foo: 'bar' }]); 321 | }); 322 | 323 | it('should stub styledJSON', async () => { 324 | await Cmd.run(['--styledJSON', '--method=Ux']); 325 | expect(uxStubs.styledJSON.firstCall.args).to.deep.equal([{ foo: 'bar' }]); 326 | }); 327 | 328 | it('should stub spinner', async () => { 329 | await Cmd.run(['--spinner', '--method=Ux']); 330 | expect(spinnerStubs.start.firstCall.args).to.deep.equal(['starting spinner']); 331 | expect(spinnerStubs.stop.firstCall.args).to.deep.equal(['done']); 332 | }); 333 | }); 334 | }); 335 | -------------------------------------------------------------------------------- /test/unit/util.test.ts: -------------------------------------------------------------------------------- 1 | /* 2 | * Copyright (c) 2022, salesforce.com, inc. 3 | * All rights reserved. 4 | * Licensed under the BSD 3-Clause license. 5 | * For full license text, see LICENSE.txt file in the repo root or https://opensource.org/licenses/BSD-3-Clause 6 | */ 7 | 8 | import { expect } from 'chai'; 9 | import { 10 | EnvironmentVariable, 11 | SUPPORTED_ENV_VARS, 12 | ORG_CONFIG_ALLOWED_PROPERTIES, 13 | OrgConfigProperties, 14 | SfdxPropertyKeys, 15 | SFDX_ALLOWED_PROPERTIES, 16 | Messages, 17 | } from '@salesforce/core'; 18 | import { parseVarArgs, toHelpSection } from '../../src/util.js'; 19 | 20 | Messages.importMessagesDirectoryFromMetaUrl(import.meta.url); 21 | const messages = Messages.loadMessages('@salesforce/sf-plugins-core', 'messages'); 22 | 23 | describe('toHelpSection', () => { 24 | it('should produce help section for env vars', () => { 25 | const envVarSection = toHelpSection('ENV VAR SECTION', EnvironmentVariable.SFDX_ACCESS_TOKEN); 26 | expect(envVarSection).to.have.property('header', 'ENV VAR SECTION'); 27 | expect(envVarSection).to.have.property('body').to.have.property('length', 1); 28 | expect(envVarSection?.body?.[0]).to.deep.equal({ 29 | name: 'SFDX_ACCESS_TOKEN', 30 | description: SUPPORTED_ENV_VARS[EnvironmentVariable.SFDX_ACCESS_TOKEN].description, 31 | }); 32 | }); 33 | 34 | it('should produce help section for org config vars', () => { 35 | const orgConfigSection = toHelpSection('ORG CONFIG VAR SECTION', OrgConfigProperties.TARGET_ORG); 36 | expect(orgConfigSection).to.have.property('header', 'ORG CONFIG VAR SECTION'); 37 | expect(orgConfigSection).to.have.property('body').to.have.property('length', 1); 38 | const orgConfig = ORG_CONFIG_ALLOWED_PROPERTIES.find(({ key }) => key === OrgConfigProperties.TARGET_ORG); 39 | expect(orgConfigSection?.body?.[0]).to.deep.equal({ 40 | name: 'target-org', 41 | description: orgConfig?.description, 42 | }); 43 | }); 44 | 45 | it('should produce help section for sfdx config vars', () => { 46 | const sfdxConfigSection = toHelpSection('SFDX CONFIG VAR SECTION', SfdxPropertyKeys.INSTANCE_URL); 47 | expect(sfdxConfigSection).to.have.property('header', 'SFDX CONFIG VAR SECTION'); 48 | expect(sfdxConfigSection).to.have.property('body').to.have.property('length', 1); 49 | const sfdxConfig = SFDX_ALLOWED_PROPERTIES.find(({ key }) => key === SfdxPropertyKeys.INSTANCE_URL); 50 | expect(sfdxConfigSection?.body?.[0]).to.deep.equal({ 51 | name: 'instanceUrl', 52 | description: sfdxConfig?.description, 53 | }); 54 | }); 55 | 56 | it('should produce help section for mixed config vars', () => { 57 | const mixedSection = toHelpSection( 58 | 'MIXED VAR SECTION', 59 | EnvironmentVariable.SFDX_ACCESS_TOKEN, 60 | OrgConfigProperties.TARGET_ORG, 61 | SfdxPropertyKeys.INSTANCE_URL 62 | ); 63 | expect(mixedSection).to.have.property('header', 'MIXED VAR SECTION'); 64 | expect(mixedSection).to.have.property('body').to.have.property('length', 3); 65 | const sfdxConfig = SFDX_ALLOWED_PROPERTIES.find(({ key }) => key === SfdxPropertyKeys.INSTANCE_URL); 66 | const orgConfig = ORG_CONFIG_ALLOWED_PROPERTIES.find(({ key }) => key === OrgConfigProperties.TARGET_ORG); 67 | expect(mixedSection?.body).to.deep.equal([ 68 | { 69 | name: 'SFDX_ACCESS_TOKEN', 70 | description: SUPPORTED_ENV_VARS[EnvironmentVariable.SFDX_ACCESS_TOKEN].description, 71 | }, 72 | { 73 | name: 'target-org', 74 | description: orgConfig?.description, 75 | }, 76 | { 77 | name: 'instanceUrl', 78 | description: sfdxConfig?.description, 79 | }, 80 | ]); 81 | }); 82 | 83 | it('should produce help section for arbitrary data', () => { 84 | const envVarSection = toHelpSection('ARBITRARY SECTION', { 'foo bar': 'hello world' }); 85 | expect(envVarSection).to.have.property('header', 'ARBITRARY SECTION'); 86 | expect(envVarSection).to.have.property('body').to.have.property('length', 1); 87 | expect(envVarSection?.body?.[0]).to.deep.equal({ 88 | name: 'foo bar', 89 | description: 'hello world', 90 | }); 91 | }); 92 | }); 93 | 94 | describe('parseVarArgs', () => { 95 | it('should parse varargs', () => { 96 | const varargs = parseVarArgs({}, ['key1=value1']); 97 | expect(varargs).to.deep.equal({ key1: 'value1' }); 98 | }); 99 | 100 | it('should parse multiple varargs', () => { 101 | const varargs = parseVarArgs({}, ['key1=value1', 'key2=value2']); 102 | expect(varargs).to.deep.equal({ key1: 'value1', key2: 'value2' }); 103 | }); 104 | 105 | it('should allow an empty value', () => { 106 | const varargs = parseVarArgs({}, ['key1=']); 107 | expect(varargs).to.deep.equal({ key1: undefined }); 108 | }); 109 | 110 | it('should parse varargs and not arguments', () => { 111 | const varargs = parseVarArgs({ arg1: 'foobar' }, ['foobar', 'key1=value1']); 112 | expect(varargs).to.deep.equal({ key1: 'value1' }); 113 | }); 114 | 115 | it('should parse single set of varargs', () => { 116 | const varargs = parseVarArgs({ arg1: 'foobar' }, ['foobar', 'key1', 'value1']); 117 | expect(varargs).to.deep.equal({ key1: 'value1' }); 118 | }); 119 | 120 | it('should throw if invalid format', () => { 121 | const badArg = 'key2:value2'; 122 | const expectedError = messages.createError('error.InvalidArgumentFormat', [badArg]).message; 123 | expect(() => parseVarArgs({ arg1: 'foobar' }, ['foobar', 'key1=value1', badArg])).to.throw(expectedError); 124 | }); 125 | 126 | it('should throw if duplicates exist', () => { 127 | expect(() => parseVarArgs({ arg1: 'foobar' }, ['foobar', 'key1=value1', 'key1=value1'])).to.throw( 128 | 'Found duplicate argument key1.' 129 | ); 130 | }); 131 | }); 132 | -------------------------------------------------------------------------------- /test/unit/ux/object.test.ts: -------------------------------------------------------------------------------- 1 | /* 2 | * Copyright (c) 2023, salesforce.com, inc. 3 | * All rights reserved. 4 | * Licensed under the BSD 3-Clause license. 5 | * For full license text, see LICENSE.txt file in the repo root or https://opensource.org/licenses/BSD-3-Clause 6 | */ 7 | import { expect } from 'chai'; 8 | import stripAnsi from 'strip-ansi'; 9 | import styledObject from '../../../src/ux/styledObject.js'; 10 | 11 | describe('styledObject', () => { 12 | it('should handle simple object', () => { 13 | const result = styledObject({ foo: 1, bar: 2 }); 14 | expect(stripAnsi(result)).to.equal('foo: 1\nbar: 2'); 15 | }); 16 | 17 | it('should handle object with select keys', () => { 18 | const result = styledObject({ foo: 1, bar: 2 }, ['foo']); 19 | expect(stripAnsi(result)).to.equal('foo: 1'); 20 | }); 21 | 22 | it('should handle deeply nested object', () => { 23 | const result = styledObject({ foo: { bar: { baz: 1 } } }); 24 | expect(stripAnsi(result)).to.equal('foo: bar: { baz: 1 }'); 25 | }); 26 | 27 | it('should handle deeply nested objects with arrays', () => { 28 | const result = styledObject({ foo: { bar: [{ baz: 1 }, { baz: 2 }] } }); 29 | expect(stripAnsi(result)).to.equal('foo: bar: [ { baz: 1 }, { baz: 2 } ]'); 30 | }); 31 | 32 | it('should show array input as table', () => { 33 | const result = styledObject([ 34 | { foo: 1, bar: 1 }, 35 | { foo: 2, bar: 2 }, 36 | { foo: 3, bar: 3 }, 37 | ]); 38 | expect(stripAnsi(result)).to.equal(`0: foo: 1, bar: 1 39 | 1: foo: 2, bar: 2 40 | 2: foo: 3, bar: 3`); 41 | }); 42 | 43 | it('should handle nulls', () => { 44 | const result = styledObject([{ foo: 1, bar: 1 }, null, { foo: 3, bar: 3 }]); 45 | expect(stripAnsi(result)).to.equal(`0: foo: 1, bar: 1 46 | 2: foo: 3, bar: 3`); 47 | }); 48 | 49 | it('should handle null input', () => { 50 | const result = styledObject(null); 51 | expect(stripAnsi(result)).to.equal('null'); 52 | }); 53 | 54 | it('should handle string input', () => { 55 | const result = styledObject('foo'); 56 | expect(stripAnsi(result)).to.equal('foo'); 57 | }); 58 | 59 | it('should handle number input', () => { 60 | const result = styledObject(1); 61 | expect(stripAnsi(result)).to.equal('1'); 62 | }); 63 | 64 | it('should handle boolean input', () => { 65 | const result = styledObject(true); 66 | expect(stripAnsi(result)).to.equal('true'); 67 | }); 68 | }); 69 | -------------------------------------------------------------------------------- /test/unit/ux/progress.test.ts: -------------------------------------------------------------------------------- 1 | /* 2 | * Copyright (c) 2022, salesforce.com, inc. 3 | * All rights reserved. 4 | * Licensed under the BSD 3-Clause license. 5 | * For full license text, see LICENSE.txt file in the repo root or https://opensource.org/licenses/BSD-3-Clause 6 | */ 7 | import { expect } from 'chai'; 8 | import sinon from 'sinon'; 9 | import { Progress } from '../../../src/ux/progress.js'; 10 | 11 | describe('Progress', () => { 12 | let sandbox: sinon.SinonSandbox; 13 | let writeStub: sinon.SinonStub; 14 | 15 | beforeEach(() => { 16 | sandbox = sinon.createSandbox(); 17 | writeStub = sandbox.stub(process.stderr, 'write').withArgs(sinon.match('PROGRESS')).returns(true); 18 | }); 19 | 20 | afterEach(() => { 21 | sandbox.restore(); 22 | }); 23 | 24 | describe('start', () => { 25 | it('should display a progress bar if output is enabled', () => { 26 | const progress = new Progress(true); 27 | progress.start(10); 28 | progress.finish(); 29 | expect(writeStub.firstCall.args[0]).to.match(/^PROGRESS\s\|\s(.*?)\s\|\s0\/10\sComponents/); 30 | }); 31 | 32 | it('should not display anything if output is not enabled', () => { 33 | const progress = new Progress(false); 34 | progress.start(10); 35 | progress.finish(); 36 | expect(writeStub.callCount).to.equal(0); 37 | }); 38 | }); 39 | 40 | describe('update', () => { 41 | it('should update the progress bar', () => { 42 | const progress = new Progress(true); 43 | progress.start(10); 44 | progress.update(5); 45 | progress.finish(); 46 | expect(writeStub.lastCall.args[0]).to.match(/^PROGRESS\s\|\s(.*?)\s\|\s5\/10\sComponents/); 47 | }); 48 | }); 49 | 50 | describe('stop', () => { 51 | it('should stop the progress bar', () => { 52 | const progress = new Progress(true); 53 | progress.start(10); 54 | progress.stop(); 55 | expect(writeStub.lastCall.args[0]).to.match(/^PROGRESS\s\|\s(.*?)\s\|\s0\/10\sComponents/); 56 | }); 57 | }); 58 | }); 59 | -------------------------------------------------------------------------------- /test/unit/ux/spinner.test.ts: -------------------------------------------------------------------------------- 1 | /* 2 | * Copyright (c) 2022, salesforce.com, inc. 3 | * All rights reserved. 4 | * Licensed under the BSD 3-Clause license. 5 | * For full license text, see LICENSE.txt file in the repo root or https://opensource.org/licenses/BSD-3-Clause 6 | */ 7 | import { expect } from 'chai'; 8 | import sinon from 'sinon'; 9 | import { ux } from '@oclif/core'; 10 | import { Spinner } from '../../../src/ux/spinner.js'; 11 | 12 | describe('Spinner', () => { 13 | let sandbox: sinon.SinonSandbox; 14 | let writeStub: sinon.SinonStub; 15 | 16 | beforeEach(() => { 17 | sandbox = sinon.createSandbox(); 18 | // @ts-expect-error because _write is a protected member 19 | writeStub = sandbox.stub(ux.action, '_write'); 20 | }); 21 | 22 | afterEach(() => { 23 | sandbox.restore(); 24 | }); 25 | 26 | describe('start/stop', () => { 27 | it('should start a spinner if output is enabled', () => { 28 | const spinner = new Spinner(true); 29 | spinner.start('Doing things'); 30 | spinner.stop('Finished'); 31 | expect(writeStub.firstCall.args).to.deep.equal(['stderr', 'Doing things...']); 32 | }); 33 | 34 | it('should not log anything if output is not enabled', () => { 35 | const spinner = new Spinner(false); 36 | spinner.start('Doing things'); 37 | spinner.stop('Finished'); 38 | expect(writeStub.callCount).to.equal(0); 39 | }); 40 | }); 41 | 42 | describe('pause', () => { 43 | it('should pause the spinner if output is enabled', () => { 44 | const spinner = new Spinner(true); 45 | spinner.start('Doing things'); 46 | spinner.pause(() => {}); 47 | spinner.stop('Finished'); 48 | expect(writeStub.firstCall.args).to.deep.equal(['stderr', 'Doing things...']); 49 | }); 50 | 51 | it('should not log anything if output is not enabled', () => { 52 | const spinner = new Spinner(false); 53 | spinner.start('Doing things'); 54 | spinner.pause(() => {}); 55 | spinner.stop('Finished'); 56 | expect(writeStub.callCount).to.equal(0); 57 | }); 58 | }); 59 | 60 | describe('status', () => { 61 | it('should set the status of the spinner', () => { 62 | const spinner = new Spinner(true); 63 | spinner.start('Doing things'); 64 | spinner.status = 'running'; 65 | expect(spinner.status).to.equal('running'); 66 | spinner.stop('Finished'); 67 | }); 68 | }); 69 | }); 70 | -------------------------------------------------------------------------------- /test/unit/ux/ux.test.ts: -------------------------------------------------------------------------------- 1 | /* 2 | * Copyright (c) 2022, salesforce.com, inc. 3 | * All rights reserved. 4 | * Licensed under the BSD 3-Clause license. 5 | * For full license text, see LICENSE.txt file in the repo root or https://opensource.org/licenses/BSD-3-Clause 6 | */ 7 | 8 | import { expect } from 'chai'; 9 | import sinon from 'sinon'; 10 | import { ux as coreUx } from '@oclif/core'; 11 | import { captureOutput } from '@oclif/test'; 12 | import { Ux } from '../../../src/ux/ux.js'; 13 | import { convertToNewTableAPI } from '../../../src/ux/table.js'; 14 | 15 | describe('Ux', () => { 16 | let sandbox: sinon.SinonSandbox; 17 | let stdoutStub: sinon.SinonStub; 18 | 19 | beforeEach(() => { 20 | sandbox = sinon.createSandbox(); 21 | stdoutStub = sandbox.stub(coreUx, 'stdout').callsFake(() => {}); 22 | }); 23 | 24 | afterEach(() => { 25 | sandbox.restore(); 26 | }); 27 | 28 | describe('table', () => { 29 | it('should log a table', async () => { 30 | const { stdout } = await captureOutput(async () => { 31 | const ux = new Ux(); 32 | ux.table({ data: [{ key: 'foo', value: 'bar' }], title: 'Title' }); 33 | }); 34 | expect(stdout).to.include('Title'); 35 | expect(stdout).to.match(/Key.+Value/); 36 | expect(stdout).to.match(/foo.+bar/); 37 | }); 38 | 39 | it('should not log anything if output is not enabled', async () => { 40 | const { stdout } = await captureOutput(async () => { 41 | const ux = new Ux({ jsonEnabled: true }); 42 | ux.table({ data: [{ key: 'foo', value: 'bar' }], title: 'Title' }); 43 | }); 44 | expect(stdout).to.equal(''); 45 | }); 46 | }); 47 | 48 | describe('table (with convertToNewTableAPI)', () => { 49 | it('should log a table', async () => { 50 | const { stdout } = await captureOutput(async () => { 51 | const ux = new Ux(); 52 | const opts = convertToNewTableAPI([{ key: 'foo', value: 'bar' }], { key: {}, value: {} }, { title: 'Title' }); 53 | ux.table(opts); 54 | }); 55 | expect(stdout).to.include('Title'); 56 | expect(stdout).to.match(/Key.+Value/); 57 | expect(stdout).to.match(/foo.+bar/); 58 | }); 59 | 60 | it('should not log anything if output is not enabled', async () => { 61 | const { stdout } = await captureOutput(async () => { 62 | const ux = new Ux({ jsonEnabled: true }); 63 | ux.table(convertToNewTableAPI([{ key: 'foo', value: 'bar' }], { key: {}, value: {} })); 64 | }); 65 | expect(stdout).to.equal(''); 66 | }); 67 | }); 68 | 69 | describe('url', () => { 70 | it('should log a url', () => { 71 | const ux = new Ux(); 72 | ux.url('Salesforce', 'https://developer.salesforce.com/'); 73 | expect(stdoutStub.firstCall.args).to.deep.equal(['https://developer.salesforce.com/']); 74 | }); 75 | 76 | it('should not log anything if output is not enabled', () => { 77 | const ux = new Ux({ jsonEnabled: true }); 78 | ux.url('Salesforce', 'https://developer.salesforce.com/'); 79 | expect(stdoutStub.callCount).to.equal(0); 80 | }); 81 | }); 82 | 83 | describe('styledJSON', () => { 84 | it('should log stylized json', () => { 85 | const ux = new Ux(); 86 | ux.styledJSON({ foo: 'bar' }); 87 | expect(stdoutStub.firstCall.args).to.deep.equal(['{\n \u001b[94m"foo"\u001b[39m: \u001b[92m"bar"\u001b[39m\n}']); 88 | }); 89 | 90 | it('should not log anything if output is not enabled', () => { 91 | const ux = new Ux({ jsonEnabled: true }); 92 | ux.styledJSON({ foo: 'bar' }); 93 | expect(stdoutStub.callCount).to.equal(0); 94 | }); 95 | }); 96 | 97 | describe('styledObject', () => { 98 | it('should log stylized object', () => { 99 | const ux = new Ux(); 100 | ux.styledObject({ foo: 'bar' }); 101 | expect(stdoutStub.firstCall.args).to.deep.equal(['\u001b[34mfoo\u001b[39m: bar']); 102 | }); 103 | 104 | it('should not log anything if output is not enabled', () => { 105 | const ux = new Ux({ jsonEnabled: true }); 106 | ux.styledObject({ foo: 'bar' }); 107 | expect(stdoutStub.callCount).to.equal(0); 108 | }); 109 | }); 110 | 111 | describe('styledHeader', () => { 112 | it('should log stylized header', () => { 113 | const ux = new Ux(); 114 | ux.styledHeader('A Stylized Header'); 115 | expect(stdoutStub.firstCall.args).to.deep.equal([ 116 | '\u001b[2m=== \u001b[22m\u001b[1mA Stylized Header\u001b[22m\n', 117 | ]); 118 | }); 119 | 120 | it('should not log anything if output is not enabled', () => { 121 | const ux = new Ux({ jsonEnabled: true }); 122 | ux.styledHeader('A Stylized Header'); 123 | expect(stdoutStub.callCount).to.equal(0); 124 | }); 125 | }); 126 | }); 127 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "@salesforce/dev-config/tsconfig-strict-esm", 3 | "compilerOptions": { 4 | "outDir": "./lib", 5 | "declarationMap": true, 6 | "skipLibCheck": true 7 | }, 8 | "include": ["src/**/*.ts"] 9 | } 10 | -------------------------------------------------------------------------------- /typedoc.json: -------------------------------------------------------------------------------- 1 | { 2 | "theme": "default", 3 | "excludePrivate": true, 4 | "excludeProtected": true, 5 | "entryPoints": ["src"], 6 | "entryPointStrategy": "expand" 7 | } 8 | --------------------------------------------------------------------------------