├── .eslintignore ├── .eslintrc ├── .github ├── CODEOWNERS ├── CODE_OF_CONDUCT.md ├── CONTRIBUTING.md ├── ISSUE_TEMPLATE.md ├── pull_request_template.md └── workflows │ ├── gh-ph.yml │ └── test.yml ├── .gitignore ├── .mocharc.json ├── .node-version ├── .nvmrc ├── CHANGELOG.md ├── LICENSE ├── README.md ├── demo.gif ├── docs ├── apps.md ├── autocomplete.md ├── help.md ├── login.md ├── logout.md ├── profiles.md ├── themes.md └── update.md ├── example └── headless_ubuntu_zcli.Dockerfile ├── lerna.json ├── logo.png ├── package.json ├── packages ├── zcli-apps │ ├── CHANGELOG.md │ ├── bin │ │ ├── run │ │ └── run.cmd │ ├── package.json │ ├── src │ │ ├── commands │ │ │ └── apps │ │ │ │ ├── bump.ts │ │ │ │ ├── clean.ts │ │ │ │ ├── create.ts │ │ │ │ ├── new.ts │ │ │ │ ├── package.ts │ │ │ │ ├── server.ts │ │ │ │ ├── update.ts │ │ │ │ └── validate.ts │ │ ├── constants.ts │ │ ├── index.js │ │ ├── lib │ │ │ ├── appPath.test.ts │ │ │ ├── appPath.ts │ │ │ ├── buildAppJSON.test.ts │ │ │ ├── buildAppJSON.ts │ │ │ ├── package.test.ts │ │ │ └── package.ts │ │ ├── types.ts │ │ └── utils │ │ │ ├── appConfig.test.ts │ │ │ ├── appConfig.ts │ │ │ ├── createApp.test.ts │ │ │ ├── createApp.ts │ │ │ ├── fileUtils.test.ts │ │ │ ├── fileUtils.ts │ │ │ ├── getAppSettings.test.ts │ │ │ ├── getAppSettings.ts │ │ │ ├── manifest.test.ts │ │ │ ├── manifest.ts │ │ │ ├── shared.ts │ │ │ ├── uploadApp.ts │ │ │ └── uuid.ts │ ├── tests │ │ └── functional │ │ │ ├── bump.test.ts │ │ │ ├── clean.test.ts │ │ │ ├── create.test.ts │ │ │ ├── env.ts │ │ │ ├── mocks │ │ │ ├── multi_product_app │ │ │ │ ├── assets │ │ │ │ │ ├── iframe.html │ │ │ │ │ ├── sell │ │ │ │ │ │ ├── icon_top_bar.svg │ │ │ │ │ │ └── screenshot.png │ │ │ │ │ └── support │ │ │ │ │ │ ├── icon_nav_bar.svg │ │ │ │ │ │ ├── icon_ticket_editor.svg │ │ │ │ │ │ └── logo-small.png │ │ │ │ ├── manifest.json │ │ │ │ └── zcli.apps.config.json │ │ │ ├── requirements_only_app │ │ │ │ ├── assets │ │ │ │ │ ├── icon_nav_bar.svg │ │ │ │ │ ├── icon_ticket_editor.svg │ │ │ │ │ ├── iframe.html │ │ │ │ │ └── logo-small.png │ │ │ │ ├── manifest.json │ │ │ │ └── zcli.apps.config.json │ │ │ ├── sample_manifest │ │ │ │ ├── manifest_with_invalid_version.json │ │ │ │ └── manifest_with_valid_version.json │ │ │ ├── single_product_another_app │ │ │ │ ├── assets │ │ │ │ │ ├── icon_nav_bar.svg │ │ │ │ │ ├── icon_ticket_editor.svg │ │ │ │ │ ├── iframe.html │ │ │ │ │ └── logo-small.png │ │ │ │ ├── manifest.json │ │ │ │ └── zcli.apps.config.json │ │ │ ├── single_product_app │ │ │ │ ├── assets │ │ │ │ │ ├── icon_nav_bar.svg │ │ │ │ │ ├── icon_ticket_editor.svg │ │ │ │ │ ├── iframe.html │ │ │ │ │ └── logo-small.png │ │ │ │ ├── manifest.json │ │ │ │ └── zcli.apps.config.json │ │ │ ├── single_product_ignore │ │ │ │ ├── .zcliignore │ │ │ │ ├── assets │ │ │ │ │ ├── icon_nav_bar.svg │ │ │ │ │ ├── icon_ticket_editor.svg │ │ │ │ │ ├── iframe.html │ │ │ │ │ ├── logo-small.png │ │ │ │ │ └── testFolder │ │ │ │ │ │ └── 1gn0r3m3.jpg │ │ │ │ ├── manifest.json │ │ │ │ └── zcli.apps.config.json │ │ │ └── snapshot_app.json │ │ │ ├── new.test.ts │ │ │ ├── package.test.ts │ │ │ ├── server.test.ts │ │ │ └── validate.test.ts │ └── tsconfig.json ├── zcli-core │ ├── CHANGELOG.md │ ├── package.json │ ├── src │ │ ├── index.ts │ │ ├── lib │ │ │ ├── auth.test.ts │ │ │ ├── auth.ts │ │ │ ├── authUtils.test.ts │ │ │ ├── authUtils.ts │ │ │ ├── config.test.ts │ │ │ ├── config.ts │ │ │ ├── env.ts │ │ │ ├── request.test.ts │ │ │ ├── request.ts │ │ │ ├── requestUtils.test.ts │ │ │ ├── requestUtils.ts │ │ │ ├── secretType.ts │ │ │ └── secureStore.ts │ │ └── types.ts │ └── tsconfig.json ├── zcli-themes │ ├── CHANGELOG.md │ ├── bin │ │ ├── run │ │ └── run.cmd │ ├── package.json │ ├── src │ │ ├── commands │ │ │ └── themes │ │ │ │ ├── delete.ts │ │ │ │ ├── import.ts │ │ │ │ ├── list.ts │ │ │ │ ├── preview.ts │ │ │ │ ├── publish.ts │ │ │ │ └── update.ts │ │ ├── index.js │ │ ├── lib │ │ │ ├── createThemeImportJob.test.ts │ │ │ ├── createThemeImportJob.ts │ │ │ ├── createThemePackage.test.ts │ │ │ ├── createThemePackage.ts │ │ │ ├── createThemeUpdateJob.test.ts │ │ │ ├── createThemeUpdateJob.ts │ │ │ ├── getAssets.test.ts │ │ │ ├── getAssets.ts │ │ │ ├── getBrandId.test.ts │ │ │ ├── getBrandId.ts │ │ │ ├── getLocalServerBaseUrl.test.ts │ │ │ ├── getLocalServerBaseUrl.ts │ │ │ ├── getManifest.test.ts │ │ │ ├── getManifest.ts │ │ │ ├── getTemplates.test.ts │ │ │ ├── getTemplates.ts │ │ │ ├── getVariables.test.ts │ │ │ ├── getVariables.ts │ │ │ ├── handleThemeApiError.ts │ │ │ ├── pollJobStatus.test.ts │ │ │ ├── pollJobStatus.ts │ │ │ ├── preview.test.ts │ │ │ ├── preview.ts │ │ │ ├── uploadThemePackage.test.ts │ │ │ ├── uploadThemePackage.ts │ │ │ ├── validationErrorsToString.test.ts │ │ │ ├── validationErrorsToString.ts │ │ │ ├── zass.test.ts │ │ │ └── zass.ts │ │ └── types.ts │ ├── tests │ │ └── functional │ │ │ ├── delete.test.ts │ │ │ ├── env.ts │ │ │ ├── import.test.ts │ │ │ ├── list.test.ts │ │ │ ├── mocks │ │ │ └── base_theme │ │ │ │ ├── assets │ │ │ │ └── bike.png │ │ │ │ ├── manifest.json │ │ │ │ ├── script.js │ │ │ │ ├── settings │ │ │ │ └── logo.png │ │ │ │ ├── style.css │ │ │ │ └── templates │ │ │ │ └── document_head.hbs │ │ │ ├── preview.test.ts │ │ │ ├── publish.test.ts │ │ │ └── update.test.ts │ └── tsconfig.json └── zcli │ ├── CHANGELOG.md │ ├── README.md │ ├── bin │ ├── run │ └── run.cmd │ ├── package.json │ ├── src │ ├── commands │ │ ├── login.ts │ │ ├── logout.ts │ │ └── profiles │ │ │ ├── list.ts │ │ │ ├── remove.ts │ │ │ └── use.ts │ ├── index.js │ ├── types.ts │ └── utils │ │ └── helpMessage.ts │ └── tsconfig.json ├── scripts ├── generate_dev_docs.sh ├── git_check.sh ├── link_dev.sh ├── prepack.sh └── release.sh ├── tsconfig.json └── yarn.lock /.eslintignore: -------------------------------------------------------------------------------- 1 | packages/**/node_modules -------------------------------------------------------------------------------- /.eslintrc: -------------------------------------------------------------------------------- 1 | { 2 | "parser": "@typescript-eslint/parser", 3 | "extends": [ 4 | "plugin:@typescript-eslint/recommended", 5 | "standard" 6 | ], 7 | "env": { 8 | "node": true, 9 | "mocha": true 10 | }, 11 | "plugins": [ 12 | "@typescript-eslint" 13 | ], 14 | "rules": { 15 | "semi": "off", 16 | "eol-last": [ 17 | "error", 18 | "always" 19 | ], 20 | "camelcase": "off", 21 | "@typescript-eslint/camelcase": "off", 22 | "@typescript-eslint/semi": [ 23 | "error", 24 | "never" 25 | ], 26 | "space-before-blocks": [ 27 | "error", 28 | "always" 29 | ], 30 | "indent": [ 31 | "error", 32 | 2 33 | ] 34 | }, 35 | "overrides": [ 36 | { 37 | "files": [ 38 | "*.ts" 39 | ], 40 | "rules": { 41 | "@typescript-eslint/explicit-function-return-type": "off" 42 | } 43 | } 44 | ] 45 | } -------------------------------------------------------------------------------- /.github/CODEOWNERS: -------------------------------------------------------------------------------- 1 | # This is a CODEOWNERS file. See https://help.github.com/articles/about-codeowners/ 2 | # Last path that matches wins. Rules need to run from most general to most specific 3 | 4 | # These owners will be the default owners for everything in the repo. 5 | * @zendesk/vegemite 6 | 7 | # Themes 8 | /packages/zcli-themes/ @zendesk/vikings 9 | /docs/themes.md @zendesk/vikings 10 | 11 | # Shared 12 | yarn.lock @zendesk/vegemite @zendesk/vikings 13 | -------------------------------------------------------------------------------- /.github/CODE_OF_CONDUCT.md: -------------------------------------------------------------------------------- 1 | # Contributor Covenant Code of Conduct 2 | 3 | ## Our Pledge 4 | 5 | In the interest of fostering an open and welcoming environment, we as 6 | contributors and maintainers pledge to making participation in our 7 | project and our community a harassment-free experience for everyone, 8 | regardless of age, body size, disability, ethnicity, gender identity and 9 | expression, level of experience, nationality, personal appearance, race, 10 | religion, or sexual identity and orientation. 11 | 12 | ## Our Standards 13 | 14 | Examples of behavior that contributes to creating a positive environment 15 | include: 16 | 17 | - Using welcoming and inclusive language 18 | - Being respectful of differing viewpoints and experiences 19 | - Gracefully accepting constructive criticism 20 | - Focusing on what is best for the community 21 | - Showing empathy towards other community members 22 | 23 | Examples of unacceptable behavior by participants include: 24 | 25 | - The use of sexualized language or imagery and unwelcome sexual 26 | attention or advances 27 | - Trolling, insulting/derogatory comments, and personal or political 28 | attacks 29 | - Public or private harassment 30 | - Publishing others' private information, such as a physical or 31 | electronic address, without explicit permission 32 | - Other conduct which could reasonably be considered inappropriate in a 33 | professional setting 34 | 35 | ## Our Responsibilities 36 | 37 | Project maintainers are responsible for clarifying the standards of 38 | acceptable behavior and are expected to take appropriate and fair 39 | corrective action in response to any instances of unacceptable behavior. 40 | 41 | Project maintainers have the right and responsibility to remove, edit, 42 | or reject comments, commits, code, wiki edits, issues, and other 43 | contributions that are not aligned to this Code of Conduct, or to ban 44 | temporarily or permanently any contributor for other behaviors that they 45 | deem inappropriate, threatening, offensive, or harmful. 46 | 47 | ## Scope 48 | 49 | This Code of Conduct applies both within project spaces and in public 50 | spaces when an individual is representing the project or its community. 51 | Examples of representing a project or community include using an 52 | official project e-mail address, posting via an official social media 53 | account, or acting as an appointed representative at an online or 54 | offline event. Representation of a project may be further defined and 55 | clarified by project maintainers. 56 | 57 | ## Enforcement 58 | 59 | Instances of abusive, harassing, or otherwise unacceptable behavior may 60 | be reported by contacting the project team at vegemite@zendesk.com. The 61 | project team will review and investigate all complaints, and will 62 | respond in a way that it deems appropriate to the circumstances. The 63 | project team is obligated to maintain confidentiality with regard to the 64 | reporter of an incident. Further details of specific enforcement 65 | policies may be posted separately. 66 | 67 | Project maintainers who do not follow or enforce the Code of Conduct in 68 | good faith may face temporary or permanent repercussions as determined 69 | by other members of the project's leadership. 70 | 71 | ## Attribution 72 | 73 | This Code of Conduct is adapted from the [Contributor 74 | Covenant](https://contributor-covenant.org), version 1.4, available at 75 | [https://www.contributor-covenant.org/version/1/4/](https://www.contributor-covenant.org/version/1/4/) 76 | -------------------------------------------------------------------------------- /.github/CONTRIBUTING.md: -------------------------------------------------------------------------------- 1 | # Contributing to ZCLI 2 | 3 | Keen to contribute to ZCLI? We're stoked to have you join us. You may 4 | find that opening an 5 | [issue](https://github.com/zendesk/zcli/issues) is the 6 | best way to get a conversation started. When you're ready to submit a 7 | pull request, follow the [steps](#pull-request-workflow) below. We 8 | follow a [code of conduct](CODE_OF_CONDUCT.md) as our guide for 9 | community behavior. 10 | 11 | This is a multi-package repo which uses [Lerna](https://lernajs.io/) to 12 | manage shared and cross-package dependencies. The basic repo layout 13 | includes: 14 | 15 | - `├── package.json` – the top-level "project" package that contains 16 | the dependencies and scripts needed to manage the multi-package repo. 17 | _This package will never be published to the npm registry._ 18 | - `└─── packages/` – the folder that contains individual packages which are published to the npm registry.
19 |     `├── zcli/` – contains the main package and is published as `@zendesk/zcli`
20 |     `├── zcli-apps/` - contains apps related commands as a npm plugin
21 |     `├── zcli-themes/` - contains themes related commands as a npm plugin
22 | 23 | ## Versioning Workflow 24 | 25 | ZCLI follows [semantic versioning](https://semver.org/). We release 26 | patch versions for bugfixes, minor versions for new features, and major 27 | versions for any breaking changes. 28 | 29 | The [pull request workflow](#pull-request-workflow) along with the [PR 30 | template](PULL_REQUEST_TEMPLATE.md) will help us determine how to 31 | version your contributions. 32 | 33 | All changes are recorded in applicable package CHANGELOG files after 34 | your PR is merged. 35 | 36 | ## Development Workflow 37 | 38 | Before you start, be sure [yarn](https://yarnpkg.com/en/) is installed 39 | on your system. After you clone this repo, run `yarn` to install 40 | dependencies needed for development. After installation, the following 41 | commands are available: 42 | 43 | - `yarn dev` to run zcli 44 | - `yarn lint` to lint your typescript code using standardjs eslint config 45 | - `yarn test` to run test in all the packages 46 | 47 | Running `yarn dev` or `./packages/zcli/bin/run` will run the cli locally. Alternatively, you can also symlink your local CLI as a global `zcli` binary by running `yarn run link:bin`. 48 | 49 | ## Pull Request Workflow 50 | 51 | 1. Fork the repo and create a branch. Format your branch name as 52 | `username/verb-noun`. 53 | 1. If you haven't yet, get comfortable with the [development 54 | environment](#development-workflow). 55 | 1. Regularly `git commit` locally and `git push` to the remote branch. 56 | Use whatever casual commit messaging you find suitable. We'll help 57 | you apply an appropriate squashed [conventional 58 | commit](https://conventionalcommits.org/) message when it's time to 59 | merge to master. 60 | 1. If your changes result in a major modification, be sure all 61 | documentation is up-to-date. 62 | 1. When your branch is ready, open a new pull request via GitHub. 63 | The repo [PR template](PULL_REQUEST_TEMPLATE.md) will guide you 64 | toward describing your contribution in a format that is ultimately 65 | suitable for a structured conventional commit (used to automatically 66 | advance published package versions). 67 | 1. Every PR must pass CI checks and receive at least two :+1: to be 68 | considered for a merge. 69 | 70 | ## License 71 | By contributing to ZCLI, you agree that your contributions will be 72 | licensed under the [Apache License, Version 2.0](LICENSE.md). 73 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE.md: -------------------------------------------------------------------------------- 1 | ## Expectations 2 | 3 | 6 | 7 | ## Reality 8 | 9 | 12 | 13 | ## Steps to Reproduce 14 | 15 | 1. 16 | 1. 17 | 1. 18 | 19 | ### Issue details 20 | 21 | - Command: 22 | - Version: 23 | - OS: 24 | -------------------------------------------------------------------------------- /.github/pull_request_template.md: -------------------------------------------------------------------------------- 1 | 5 | 6 | ## Description 7 | 8 | 11 | 12 | 15 | 16 | Do NOT write here! This section will be filled in by GitHub Action 17 | automatically. If you don't want this, either remove the markers or write 18 | outside the fences. 19 | 20 | 21 | ## Detail 22 | 23 | 24 | 25 | 26 | 27 | ## Checklist 28 | 29 | - [ ] :guardsman: includes new unit and functional tests 30 | -------------------------------------------------------------------------------- /.github/workflows/gh-ph.yml: -------------------------------------------------------------------------------- 1 | name: Pull request history 2 | 3 | on: 4 | pull_request: 5 | 6 | jobs: 7 | gh-ph: 8 | name: Add commit history to pull request description 9 | runs-on: ubuntu-latest 10 | steps: 11 | - uses: zendesk/checkout@v3 12 | with: 13 | fetch-depth: 100 14 | - run: | 15 | git remote set-branches origin '*' 16 | git fetch --depth 100 17 | - uses: zendesk/gh-ph@v1 18 | env: 19 | GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} 20 | -------------------------------------------------------------------------------- /.github/workflows/test.yml: -------------------------------------------------------------------------------- 1 | name: Test 2 | 3 | on: 4 | push: 5 | branches: 6 | - master 7 | pull_request: 8 | 9 | jobs: 10 | build-and-check: 11 | strategy: 12 | matrix: 13 | os: 14 | - ubuntu-latest 15 | - macos-latest 16 | - windows-latest 17 | node-version: ["20.x"] 18 | runs-on: ${{ matrix.os }} 19 | steps: 20 | - uses: zendesk/checkout@v3 21 | - name: Use Node.js 22 | uses: zendesk/setup-node@v4 23 | with: 24 | node-version: "${{ matrix.node-version }}" 25 | cache: "yarn" 26 | - uses: zendesk/cache@v3 27 | with: 28 | path: | 29 | ./node_modules/ 30 | ./packages/zcli/node_modules/ 31 | ./packages/zcli-core/node_modules/ 32 | ./packages/zcli-apps/node_modules/ 33 | ./packages/zcli-themes/node_modules/ 34 | key: node-modules-${{ runner.os }}-${{ hashFiles('**/package.json') }}-${{ hashFiles('yarn.lock') }} 35 | restore-keys: | 36 | node-modules-${{ runner.os }}-${{ hashFiles('**/package.json') }}- 37 | node-modules-${{ runner.os }}- 38 | - run: yarn install 39 | - run: yarn lint 40 | - run: yarn test 41 | - run: yarn test:functional 42 | - run: yarn dev 43 | - run: yarn type:check 44 | env: 45 | CI: true 46 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | node_modules 2 | lerna-debug.log 3 | yarn-error.log 4 | .nyc_output 5 | .vscode 6 | packages/zcli-apps/tests/functional/mocks/*/tmp 7 | packages/**/dist 8 | .DS_Store 9 | .idea 10 | -------------------------------------------------------------------------------- /.mocharc.json: -------------------------------------------------------------------------------- 1 | { 2 | "diff": true, 3 | "extension": ["ts"], 4 | "package": "./package.json", 5 | "reporter": "spec", 6 | "slow": "75", 7 | "timeout": "7000", 8 | "ui": "bdd", 9 | "watch-files": ["lib/**/*.js", "test/**/*.js"], 10 | "watch-ignore": ["lib/vendor"], 11 | "require": ["ts-node/register"], 12 | "recursive": true 13 | } 14 | -------------------------------------------------------------------------------- /.node-version: -------------------------------------------------------------------------------- 1 | v20.17.0 2 | -------------------------------------------------------------------------------- /.nvmrc: -------------------------------------------------------------------------------- 1 | v20.17.0 2 | -------------------------------------------------------------------------------- /demo.gif: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/zendesk/zcli/f825c8fa39ad780ec5ff05e85e8073667268d7a9/demo.gif -------------------------------------------------------------------------------- /docs/apps.md: -------------------------------------------------------------------------------- 1 | `zcli apps` 2 | =========== 3 | 4 | zcli apps commands helps with managing Zendesk apps workflow. 5 | 6 | * [`zcli apps:bump [APPPATH]`](#zcli-appsbump-apppath) 7 | * [`zcli apps:clean [APPPATH]`](#zcli-appsclean-apppath) 8 | * [`zcli apps:create APPDIRECTORIES`](#zcli-appscreate-appdirectories) 9 | * [`zcli apps:new`](#zcli-appsnew) 10 | * [`zcli apps:package APPDIRECTORY`](#zcli-appspackage-appdirectory) 11 | * [`zcli apps:server APPDIRECTORIES`](#zcli-appsserver-appdirectories) 12 | * [`zcli apps:update APPDIRECTORIES`](#zcli-appsupdate-appdirectories) 13 | * [`zcli apps:validate APPDIRECTORY`](#zcli-appsvalidate-appdirectory) 14 | 15 | ## Configuration 16 | 17 | NOTE: You can set your apps config/settings in `zcli.apps.config.json` at the root of your app directory ie 18 | 19 | ``` 20 | { 21 | "plan": "silver", 22 | "app_id": 123, 23 | "installation_id": 12434234, 24 | "parameters": { 25 | "someToken": "fksjdhfb231435", 26 | "someSecret": 123 27 | } 28 | } 29 | 30 | ``` 31 | 32 | See [these mock apps](https://github.com/zendesk/zcli/tree/master/packages/zcli-apps/tests/functional/mocks) for more references of `zcli.apps.config.json`. 33 | 34 | If you wish to specify files/folders to be ignored as part of the packaging process, create a .zcliignore file in your apps root directory. Patterns following the gitignore specification included in a .zcliignore file will be excluded from packaging when any of the following commands are executed: 35 | 36 | * zcli apps:package 37 | * zcli apps:create 38 | * zcli apps:update 39 | * zcli apps:validate 40 | 41 | ## `zcli apps:bump [APPPATH]` 42 | 43 | bumps the version of your app in the manifest file. Accepts major, minor and patch; defaults to patch. 44 | 45 | ``` 46 | USAGE 47 | $ zcli apps:bump [APPPATH] 48 | 49 | OPTIONS 50 | -M, --major Increments the major version by 1 51 | -m, --minor Increments the minor version by 1 52 | -p, --patch Increments the patch version by 1 53 | 54 | EXAMPLES 55 | $ zcli apps:bump ./repl-app2 56 | $ zcli apps:bump -M ./repl-app2 57 | $ zcli apps:bump -m ./repl-app2 58 | $ zcli apps:bump -p ./repl-app2 59 | ``` 60 | 61 | ## `zcli apps:clean [APPPATH]` 62 | 63 | purges any app artifacts which have been generated locally 64 | 65 | ``` 66 | USAGE 67 | $ zcli apps:clean [APPPATH] 68 | 69 | EXAMPLE 70 | $ zcli apps:clean ./app 71 | ``` 72 | 73 | ## `zcli apps:create APPDIRECTORIES` 74 | 75 | creates apps in your desired target account 76 | 77 | ``` 78 | USAGE 79 | $ zcli apps:create APPDIRECTORIES 80 | 81 | EXAMPLES 82 | $ zcli apps:create ./app 83 | $ zcli apps:create ./app1 ./app2 84 | ``` 85 | 86 | ## `zcli apps:new` 87 | 88 | generates a bare bones app locally for development 89 | 90 | ``` 91 | USAGE 92 | $ zcli apps:new 93 | 94 | OPTIONS 95 | --appName=appName Name of the app 96 | --authorEmail=authorEmail Email of app author 97 | --authorName=authorName Name of app author 98 | --path=path Path of your new app 99 | --scaffold=scaffold [default: basic] Choose from open-source Zendesk app scaffold structures 100 | 101 | EXAMPLES 102 | $ zcli apps:new 103 | $ zcli apps:new --scaffold=basic 104 | $ zcli apps:new --scaffold=react 105 | ``` 106 | 107 | ## `zcli apps:package APPDIRECTORY` 108 | 109 | validates and packages your app 110 | 111 | ``` 112 | USAGE 113 | $ zcli apps:package APPDIRECTORY 114 | 115 | ARGUMENTS 116 | APPDIRECTORY [default: .] app path where manifest.json exists 117 | 118 | EXAMPLES 119 | $ zcli apps:package . 120 | $ zcli apps:package ./app1 121 | ``` 122 | 123 | ## `zcli apps:server APPDIRECTORIES` 124 | 125 | serves apps in development mode 126 | 127 | ``` 128 | USAGE 129 | $ zcli apps:server APPDIRECTORIES 130 | 131 | OPTIONS 132 | -h, --help show CLI help 133 | --bind=bind [default: localhost] Bind apps server to a specific host 134 | --logs Tail logs 135 | --port=port [default: 4567] Port for the http server to use 136 | 137 | EXAMPLES 138 | $ zcli apps:server ./repl-app2 139 | $ zcli apps:server ./repl-app2 ./knowledge-capture-app 140 | ``` 141 | 142 | After server is running, add `?zcli_apps=true` to the end of your Zendesk URL to load from the locally served apps. `?zat=true` will *not* work with ZCLI. 143 | 144 | ## `zcli apps:update APPDIRECTORIES` 145 | 146 | updates an existing private app in the Zendesk products specified in the apps manifest file. 147 | 148 | ``` 149 | USAGE 150 | $ zcli apps:update APPDIRECTORIES 151 | ``` 152 | 153 | ## `zcli apps:validate APPDIRECTORY` 154 | 155 | validates your app 156 | 157 | ``` 158 | USAGE 159 | $ zcli apps:validate APPDIRECTORY 160 | 161 | ARGUMENTS 162 | APPDIRECTORY [default: .] app path where manifest.json exists 163 | 164 | EXAMPLES 165 | $ zcli apps:validate . 166 | $ zcli apps:validate ./app1 167 | ``` 168 | -------------------------------------------------------------------------------- /docs/autocomplete.md: -------------------------------------------------------------------------------- 1 | `zcli autocomplete` 2 | =================== 3 | 4 | display autocomplete installation instructions 5 | 6 | * [`zcli autocomplete [SHELL]`](#zcli-autocomplete-shell) 7 | 8 | ## `zcli autocomplete [SHELL]` 9 | 10 | display autocomplete installation instructions 11 | 12 | ``` 13 | USAGE 14 | $ zcli autocomplete [SHELL] 15 | 16 | ARGUMENTS 17 | SHELL shell type 18 | 19 | OPTIONS 20 | -r, --refresh-cache Refresh cache (ignores displaying instructions) 21 | 22 | EXAMPLES 23 | $ zcli autocomplete 24 | $ zcli autocomplete bash 25 | $ zcli autocomplete zsh 26 | $ zcli autocomplete --refresh-cache 27 | ``` 28 | 29 | _See code: [@oclif/plugin-autocomplete](https://github.com/oclif/plugin-autocomplete/blob/v0.2.0/src/commands/autocomplete/index.ts)_ 30 | -------------------------------------------------------------------------------- /docs/help.md: -------------------------------------------------------------------------------- 1 | `zcli help` 2 | =========== 3 | 4 | display help for zcli 5 | 6 | * [`zcli help [COMMAND]`](#zcli-help-command) 7 | 8 | ## `zcli help [COMMAND]` 9 | 10 | display help for zcli 11 | 12 | ``` 13 | USAGE 14 | $ zcli help [COMMAND] 15 | 16 | ARGUMENTS 17 | COMMAND command to show help for 18 | 19 | OPTIONS 20 | --all see all commands in CLI 21 | ``` 22 | 23 | _See code: [@oclif/plugin-help](https://github.com/oclif/plugin-help/blob/v2.2.3/src/commands/help.ts)_ 24 | -------------------------------------------------------------------------------- /docs/login.md: -------------------------------------------------------------------------------- 1 | `zcli login` 2 | ============ 3 | 4 | creates and/or saves an authentication token for the specified subdomain 5 | 6 | * [`zcli login`](#zcli-login) 7 | 8 | ## `zcli login` 9 | 10 | creates and/or saves an authentication token for the specified subdomain 11 | 12 | ``` 13 | USAGE 14 | $ zcli login 15 | 16 | OPTIONS 17 | -h, --help show CLI help 18 | -i, --interactive Use Terminal based login 19 | -s, --subdomain=subdomain Zendesk Subdomain 20 | -d, --domain=domain Zendesk Domain (optional) 21 | 22 | EXAMPLES 23 | $ zcli login -i 24 | $ zcli login -s zendesk-subdomain -i 25 | $ zcli login -s zendesk-subdomain -d example.com -i 26 | $ zcli login -s zendesk-subdomain -d dev.example.com -i 27 | $ zcli login -d example.com -i 28 | ``` 29 | 30 | NOTE: For development purposes, you can specify a domain different from `zendesk.com` for logging in to a different environment. For example, if the environment is hosted on `example.com`, you can run 31 | `zcli login -s zendesk-subdomain -d example.com -i` and you will be logged in to `zendesk-subdomain.example.com`. If the option is not specified, the default `zendesk.com` domain will be used. 32 | 33 | NOTE: For CI/CD or unattended login you can set `ZENDESK_APP_ID`, `ZENDESK_SUBDOMAIN`, `ZENDESK_EMAIL` and `ZENDESK_API_TOKEN` environment variables. You don't need to run the login command if you have set these environment variables. 34 | You can also set the `ZENDESK_DOMAIN` environment variable for different environments. 35 | -------------------------------------------------------------------------------- /docs/logout.md: -------------------------------------------------------------------------------- 1 | `zcli logout` 2 | ============= 3 | 4 | removes an authentication token for an active profile 5 | 6 | * [`zcli logout`](#zcli-logout) 7 | 8 | ## `zcli logout` 9 | 10 | removes an authentication token for an active profile 11 | 12 | ``` 13 | USAGE 14 | $ zcli logout 15 | 16 | OPTIONS 17 | -h, --help show CLI help 18 | -s, --subdomain=subdomain Zendesk Subdomain 19 | 20 | EXAMPLE 21 | $ zcli logout 22 | ``` 23 | -------------------------------------------------------------------------------- /docs/profiles.md: -------------------------------------------------------------------------------- 1 | `zcli profiles` 2 | =============== 3 | 4 | manage cli user profiles 5 | 6 | * [`zcli profiles:list`](#zcli-profileslist) 7 | * [`zcli profiles:remove ACCOUNT`](#zcli-profilesremove-account) 8 | * [`zcli profiles:use ACCOUNT`](#zcli-profilesuse-account) 9 | 10 | Note: `ACCOUNT` means `subdomain` if you logged in using only the subdomain or `subdomain.domain` if you logged in to an environment hosted on a different domain 11 | 12 | ## `zcli profiles:list` 13 | 14 | lists all the profiles 15 | 16 | ``` 17 | USAGE 18 | $ zcli profiles:list 19 | 20 | EXAMPLE 21 | $ zcli profiles:list 22 | ``` 23 | 24 | ## `zcli profiles:remove ACCOUNT` 25 | 26 | removes a profile 27 | 28 | ``` 29 | USAGE 30 | $ zcli profiles:remove ACCOUNT 31 | 32 | EXAMPLE 33 | $ zcli profiles:remove zendesk-subdomain 34 | $ zcli profiles:remove zendesk-subdomain.example.com 35 | ``` 36 | 37 | ## `zcli profiles:use ACCOUNT` 38 | 39 | switches to a profile 40 | 41 | ``` 42 | USAGE 43 | $ zcli profiles:use ACCOUNT 44 | 45 | EXAMPLE 46 | $ zcli profiles:use zendesk-subdomain 47 | $ zcli profiles:use zendesk-subdomain.example.com 48 | ``` 49 | -------------------------------------------------------------------------------- /docs/update.md: -------------------------------------------------------------------------------- 1 | `zcli update` 2 | ============= 3 | 4 | describe the command here 5 | 6 | * [`zcli update [FILE]`](#zcli-update-file) 7 | 8 | ## `zcli update [FILE]` 9 | 10 | describe the command here 11 | 12 | ``` 13 | USAGE 14 | $ zcli update [FILE] 15 | 16 | OPTIONS 17 | -f, --force 18 | -h, --help show CLI help 19 | -n, --name=name name to print 20 | ``` 21 | -------------------------------------------------------------------------------- /example/headless_ubuntu_zcli.Dockerfile: -------------------------------------------------------------------------------- 1 | # Use Ubuntu 22.04 LTS as base image 2 | FROM ubuntu:22.04 3 | 4 | # Install dependencies required for adding repositories 5 | RUN apt-get update && apt-get install -y curl gnupg2 ca-certificates lsb-release 6 | # Install required dependencies 7 | RUN apt-get update && apt-get install -y npm dbus-x11 libsecret-1-dev gnome-keyring 8 | 9 | # Make sure ca-certificates is installed 10 | RUN apt-get install -y ca-certificates 11 | 12 | # Install nvm, Node.js 13 | ENV NVM_DIR /usr/local/nvm 14 | ENV NODE_VERSION 18 15 | 16 | # Use bash shell for the install script 17 | SHELL ["/bin/bash", "-c"] 18 | 19 | # Install nvm (Note: the version might need updating) 20 | RUN mkdir -p $NVM_DIR && curl -o- https://raw.githubusercontent.com/nvm-sh/nvm/v0.39.1/install.sh | bash 21 | 22 | # Install Node.js 18 and set it as the default 23 | RUN source $NVM_DIR/nvm.sh \ 24 | && nvm install $NODE_VERSION \ 25 | && nvm alias default $NODE_VERSION \ 26 | && nvm use default 27 | 28 | # Add nvm.sh to .bashrc for future login shells 29 | RUN echo 'export NVM_DIR="$NVM_DIR"' >> /etc/bash.bashrc \ 30 | && echo '[ -s "$NVM_DIR/nvm.sh" ] && \. "$NVM_DIR/nvm.sh"' >> /etc/bash.bashrc 31 | 32 | # Add node and npm to path so the commands are available 33 | ENV NODE_PATH $NVM_DIR/versions/node/v$NODE_VERSION/lib/node_modules 34 | ENV PATH $NVM_DIR/versions/node/v$NODE_VERSION/bin:$PATH 35 | 36 | RUN npm install @zendesk/zcli -g 37 | 38 | # For headless Linux: 39 | RUN echo 'export $(dbus-launch)' >> /etc/bash.bashrc 40 | # Init kerying storage by creating a default file 41 | RUN echo 'echo "123456" | gnome-keyring-daemon -r --unlock --components=secrets' >> /etc/bash.bashrc 42 | 43 | 44 | # # Set the work directory 45 | WORKDIR /app 46 | 47 | # Command to run when starting the container 48 | CMD [ "bash" ] 49 | -------------------------------------------------------------------------------- /lerna.json: -------------------------------------------------------------------------------- 1 | { 2 | "changelog": { 3 | "repo": "zendesk/zcli", 4 | "labels": { 5 | "PR: Breaking Change :boom:": ":boom: Breaking Change", 6 | "PR: New Feature :rocket:": ":rocket: New Feature", 7 | "PR: Bug Fix :bug:": ":bug: Bug Fix", 8 | "PR: Docs :memo:": ":memo: Documentation", 9 | "PR: Internal :seedling:": ":seedling: Internal" 10 | } 11 | }, 12 | "packages": [ 13 | "packages/*" 14 | ], 15 | "command": { 16 | "publish": { 17 | "registry": "https://registry.npmjs.org/" 18 | }, 19 | "version": { 20 | "message": "chore(release): publish" 21 | } 22 | }, 23 | "npmClient": "yarn", 24 | "npmClientArgs": [ 25 | "--no-lockfile" 26 | ], 27 | "useWorkspaces": true, 28 | "version": "1.0.0-beta.52" 29 | } 30 | -------------------------------------------------------------------------------- /logo.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/zendesk/zcli/f825c8fa39ad780ec5ff05e85e8073667268d7a9/logo.png -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "zcli-monorepo", 3 | "description": "Zendesk cli is a single command line tool for all your zendesk needs", 4 | "version": "0.0.1", 5 | "author": "@vegemite", 6 | "private": true, 7 | "workspaces": [ 8 | "packages/*" 9 | ], 10 | "devDependencies": { 11 | "@oclif/core": "=1.12.0", 12 | "@oclif/test": "=2.1.0", 13 | "@types/chai": "^4", 14 | "@types/express": "^4.17.3", 15 | "@types/mocha": "^9.1.1", 16 | "@types/node": "^14.0.14", 17 | "@types/rimraf": "^3.0.2", 18 | "@typescript-eslint/eslint-plugin": "^5.30.0", 19 | "@typescript-eslint/parser": "^5.30.0", 20 | "chai": "^4", 21 | "eslint": "^8.18.0", 22 | "eslint-config-standard": "^17.0.0", 23 | "eslint-plugin-import": "2", 24 | "eslint-plugin-node": "^11.1.0", 25 | "eslint-plugin-promise": "^6.0.0", 26 | "form-data": "^4.0.0", 27 | "lerna": "^5.6.2", 28 | "lerna-changelog": "^2.2.0", 29 | "mocha": "^10.8.2", 30 | "nyc": "^17.1.0", 31 | "rimraf": "^3.0.2", 32 | "standard": "^17.0.0", 33 | "ts-node": "^10.9.1", 34 | "typescript": "~4.7.4", 35 | "yarn-audit-fix": "^10.1.1" 36 | }, 37 | "engines": { 38 | "node": ">=20.17.0" 39 | }, 40 | "keywords": [ 41 | "zcli", 42 | "zendesk", 43 | "command", 44 | "cli" 45 | ], 46 | "scripts": { 47 | "audit:fix": "yarn-audit-fix", 48 | "postinstall": "lerna bootstrap", 49 | "dev": "ts-node ./packages/zcli/bin/run", 50 | "git:check": "./scripts/git_check.sh", 51 | "link:bin": "bash ./scripts/link_dev.sh", 52 | "lint": "eslint . --ext .ts --config .eslintrc", 53 | "test": "nyc --extension .ts mocha --config=.mocharc.json --forbid-only packages/**/src/**/*.test.ts", 54 | "test:functional": "mocha --config=.mocharc.json -r ts-node/register packages/**/tests/**/*.test.ts", 55 | "changelog": "lerna-changelog", 56 | "type:check": "lerna run type:check" 57 | }, 58 | "license": "Apache-2.0", 59 | "licenses": [ 60 | { 61 | "type": "Apache-2.0", 62 | "url": "http://www.apache.org/licenses/LICENSE-2.0" 63 | } 64 | ], 65 | "repository": "zendesk/zcli", 66 | "types": "lib/index.d.ts" 67 | } 68 | -------------------------------------------------------------------------------- /packages/zcli-apps/bin/run: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env node 2 | 3 | const oclif = require('@oclif/core') 4 | 5 | oclif.run().catch(require('@oclif/core/handle')) 6 | -------------------------------------------------------------------------------- /packages/zcli-apps/bin/run.cmd: -------------------------------------------------------------------------------- 1 | @echo off 2 | 3 | node "%~dp0\run" %* 4 | -------------------------------------------------------------------------------- /packages/zcli-apps/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "@zendesk/zcli-apps", 3 | "description": "zcli apps commands live here", 4 | "version": "1.0.0-beta.50", 5 | "author": "@vegemite", 6 | "npmRegistry": "https://registry.npmjs.org", 7 | "publishConfig": { 8 | "access": "public" 9 | }, 10 | "bin": { 11 | "zcli-apps": "./bin/run" 12 | }, 13 | "scripts": { 14 | "build": "tsc", 15 | "prepack": "tsc && ../../scripts/prepack.sh", 16 | "postpack": "rm -f oclif.manifest.json npm-shrinkwrap.json && rm -rf ./dist && git checkout ./package.json", 17 | "type:check": "tsc" 18 | }, 19 | "dependencies": { 20 | "adm-zip": "0.5.10", 21 | "archiver": "^5.3.1", 22 | "axios": "^1.7.5", 23 | "chalk": "^4.1.2", 24 | "cors": "^2.8.5", 25 | "express": "^4.21.2", 26 | "form-data": "^4.0.0", 27 | "fs-extra": "^10.0.0", 28 | "morgan": "^1.10.0", 29 | "rimraf": "^3.0.2", 30 | "semver": "^7.3.2", 31 | "tslib": "^2.4.0", 32 | "uuid": "^8.3.2" 33 | }, 34 | "devDependencies": { 35 | "@oclif/test": "=2.1.0", 36 | "@types/adm-zip": "^0.5.5", 37 | "@types/archiver": "^5.3.1", 38 | "@types/chai": "^4", 39 | "@types/cors": "^2.8.6", 40 | "@types/mocha": "^9.1.1", 41 | "@types/morgan": "^1.9.0", 42 | "@types/rimraf": "^3.0.2", 43 | "@types/uuid": "^8.3.4", 44 | "chai": "^4", 45 | "eslint": "^8.18.0", 46 | "eslint-config-oclif": "^4.0.0", 47 | "eslint-config-oclif-typescript": "^1.0.2", 48 | "lerna": "^5.6.2", 49 | "mocha": "^10.8.2", 50 | "sinon": "^14.0.0" 51 | }, 52 | "files": [ 53 | "/bin", 54 | "/dist", 55 | "/oclif.manifest.json", 56 | "/npm-shrinkwrap.json" 57 | ], 58 | "keywords": [ 59 | "zcli", 60 | "zendesk", 61 | "cli", 62 | "command" 63 | ], 64 | "license": "MIT", 65 | "main": "src/index.js", 66 | "oclif": { 67 | "commands": "./src/commands", 68 | "bin": "zcli-apps" 69 | }, 70 | "types": "lib/index.d.ts" 71 | } 72 | -------------------------------------------------------------------------------- /packages/zcli-apps/src/commands/apps/bump.ts: -------------------------------------------------------------------------------- 1 | import { Command, Flags } from '@oclif/core' 2 | import * as chalk from 'chalk' 3 | import * as semver from 'semver' 4 | import { getManifestFile, updateManifestFile } from '../../utils/manifest' 5 | import { validateAppPath } from '../../lib/appPath' 6 | 7 | export default class Bump extends Command { 8 | static description = 'bumps the version of your app in the manifest file. Accepts major, minor and patch; defaults to patch.' 9 | 10 | static args = [ 11 | { name: 'appPath' } 12 | ] 13 | 14 | static examples = [ 15 | '$ zcli apps:bump ./repl-app2', 16 | '$ zcli apps:bump -M ./repl-app2', 17 | '$ zcli apps:bump -m ./repl-app2', 18 | '$ zcli apps:bump -p ./repl-app2' 19 | ] 20 | 21 | static flags = { 22 | major: Flags.boolean({ char: 'M', description: 'Increments the major version by 1' }), 23 | minor: Flags.boolean({ char: 'm', description: 'Increments the minor version by 1' }), 24 | patch: Flags.boolean({ char: 'p', description: 'Increments the patch version by 1' }) 25 | } 26 | 27 | async run () { 28 | const { args, flags } = await this.parse(Bump) 29 | const { major, minor } = flags 30 | const appPath = args.appPath || './' 31 | 32 | validateAppPath(appPath) 33 | 34 | try { 35 | const manifest = getManifestFile(appPath) 36 | const version = manifest.version || '' 37 | 38 | if (!semver.valid(version)) { 39 | this.error(chalk.red(`${manifest.version} is not a valid semantic version`)) 40 | } 41 | 42 | if (major) { 43 | manifest.version = semver.inc(version, 'major')! 44 | } else if (minor) { 45 | manifest.version = semver.inc(version, 'minor')! 46 | } else { 47 | manifest.version = semver.inc(version, 'patch')! 48 | } 49 | 50 | updateManifestFile(appPath, manifest) 51 | this.log(chalk.green(`Successfully bumped app version to: ${manifest.version}`)) 52 | } catch (error) { 53 | this.error(chalk.red(error)) 54 | } 55 | } 56 | } 57 | -------------------------------------------------------------------------------- /packages/zcli-apps/src/commands/apps/clean.ts: -------------------------------------------------------------------------------- 1 | import { Command } from '@oclif/core' 2 | import { cleanDirectory } from '../../utils/fileUtils' 3 | import * as path from 'path' 4 | import * as chalk from 'chalk' 5 | import { validateAppPath } from '../../lib/appPath' 6 | 7 | export default class Clean extends Command { 8 | static description = 'purges any app artifacts which have been generated locally' 9 | 10 | static args = [ 11 | { name: 'appPath' } 12 | ] 13 | 14 | static examples = [ 15 | '$ zcli apps:clean ./app' 16 | ] 17 | 18 | async run () { 19 | const tmpDirectoryPath = path.join(process.cwd(), 'tmp') 20 | const { args } = await this.parse(Clean) 21 | const appPath = args.appPath || './' 22 | 23 | validateAppPath(appPath) 24 | 25 | try { 26 | await cleanDirectory(tmpDirectoryPath) 27 | this.log(chalk.green(`Successfully removed ${tmpDirectoryPath} directory.`)) 28 | } catch (error) { 29 | this.error(chalk.red(`Failed to remove ${tmpDirectoryPath} directory.`)) 30 | } 31 | } 32 | } 33 | -------------------------------------------------------------------------------- /packages/zcli-apps/src/commands/apps/create.ts: -------------------------------------------------------------------------------- 1 | import { Command, CliUx } from '@oclif/core' 2 | import { uploadAppPkg, deployApp, createProductInstallation } from '../../utils/createApp' 3 | import * as chalk from 'chalk' 4 | import { getUploadJobStatus } from '../../utils/uploadApp' 5 | import { getManifestFile } from '../../utils/manifest' 6 | import { createAppPkg } from '../../lib/package' 7 | import { validateAppPath } from '../../lib/appPath' 8 | import { getAllConfigs } from '../../utils/appConfig' 9 | import { getAppSettings } from '../../utils/getAppSettings' 10 | 11 | export default class Create extends Command { 12 | static description = 'creates apps in your desired target account' 13 | 14 | static args = [ 15 | { name: 'appDirectories', required: true, default: '.' } 16 | ] 17 | 18 | static examples = [ 19 | '$ zcli apps:create ./app', 20 | '$ zcli apps:create ./app1 ./app2' 21 | ] 22 | 23 | static strict = false 24 | 25 | async run () { 26 | const { argv: appDirectories } = await this.parse(Create) 27 | 28 | for (const appPath of appDirectories) { 29 | validateAppPath(appPath) 30 | 31 | CliUx.ux.action.start('Uploading app') 32 | 33 | const manifest = getManifestFile(appPath) 34 | const pkgPath = await createAppPkg(appPath) 35 | const { id: upload_id } = await uploadAppPkg(pkgPath) 36 | 37 | if (!upload_id) { 38 | CliUx.ux.action.stop('Failed') 39 | this.error(`Failed to upload app ${manifest.name}`) 40 | } 41 | 42 | CliUx.ux.action.stop('Uploaded') 43 | CliUx.ux.action.start('Deploying app') 44 | const { job_id } = await deployApp('POST', 'api/apps.json', upload_id, manifest.name) 45 | 46 | try { 47 | const { app_id }: any = await getUploadJobStatus(job_id, appPath) 48 | CliUx.ux.action.stop('Deployed') 49 | 50 | const allConfigs = getAllConfigs(appPath) 51 | const configParams = allConfigs?.parameters || {} // if there are no parameters in the config, just attach an empty object 52 | 53 | const settings = manifest.parameters ? await getAppSettings(manifest, configParams) : {} 54 | if (!manifest.requirementsOnly && manifest.location) { 55 | Object.keys(manifest.location).forEach(async product => { 56 | if (!createProductInstallation(settings, manifest, app_id, product)) { 57 | this.error(chalk.red(`Failed to install ${manifest.name} with app_id: ${app_id}`)) 58 | } 59 | }) 60 | } 61 | this.log(chalk.green(`Successfully installed app: ${manifest.name} with app_id: ${app_id}`)) 62 | } catch (error) { 63 | CliUx.ux.action.stop('Failed') 64 | // this.error(chalk.red(error)) 65 | console.log(error) 66 | } 67 | } 68 | } 69 | } 70 | -------------------------------------------------------------------------------- /packages/zcli-apps/src/commands/apps/package.ts: -------------------------------------------------------------------------------- 1 | import { Command } from '@oclif/core' 2 | import * as path from 'path' 3 | import * as chalk from 'chalk' 4 | import { createAppPkg, validatePkg } from '../../lib/package' 5 | import { validateAppPath } from '../../lib/appPath' 6 | 7 | export default class Package extends Command { 8 | static description = 'validates and packages your app' 9 | 10 | static args = [ 11 | { name: 'appDirectory', default: '.', required: true, description: 'app path where manifest.json exists' } 12 | ] 13 | 14 | static examples = [ 15 | '$ zcli apps:package .', 16 | '$ zcli apps:package ./app1' 17 | ] 18 | 19 | async run () { 20 | const { args } = await this.parse(Package) 21 | const { appDirectory } = args 22 | 23 | validateAppPath(appDirectory) 24 | 25 | const appPath = path.resolve(appDirectory) 26 | const pkgPath = await createAppPkg(appPath) 27 | 28 | try { 29 | await validatePkg(pkgPath) 30 | this.log(chalk.green(`Package created at ${path.relative(process.cwd(), pkgPath)}`)) 31 | } catch (error) { 32 | this.error(chalk.red(error)) 33 | } 34 | } 35 | } 36 | -------------------------------------------------------------------------------- /packages/zcli-apps/src/commands/apps/server.ts: -------------------------------------------------------------------------------- 1 | import { Command, Flags } from '@oclif/core' 2 | import * as express from 'express' 3 | import * as morgan from 'morgan' 4 | import * as chalk from 'chalk' 5 | import * as cors from 'cors' 6 | import * as fs from 'fs' 7 | import { buildAppJSON } from '../../lib/buildAppJSON' 8 | import { Installation } from '../../types' 9 | import { getAppPaths } from '../../utils/shared' 10 | 11 | const logMiddleware = morgan((tokens, req, res) => 12 | `${chalk.green(tokens.method(req, res))} ${tokens.url(req, res)} ${chalk.bold(tokens.status(req, res))}` 13 | ) 14 | 15 | export default class Server extends Command { 16 | static description = 'serves apps in development mode' 17 | 18 | static flags = { 19 | help: Flags.help({ char: 'h' }), 20 | bind: Flags.string({ default: 'localhost', description: 'Bind apps server to a specific host' }), 21 | port: Flags.string({ default: '4567', description: 'Port for the http server to use' }), 22 | logs: Flags.boolean({ default: false, description: 'Tail logs' }) 23 | // TODO: custom file is not supported for other commands, 24 | // lets come back to this in near future 25 | // config: flags.string({ default: 'zcli.apps.config.json', description: 'Configuration file for zcli::apps' }) 26 | } 27 | 28 | static args = [ 29 | { name: 'appDirectories', required: true, default: '.' } 30 | ] 31 | 32 | static examples = [ 33 | '$ zcli apps:server ./repl-app2', 34 | '$ zcli apps:server ./repl-app2 ./knowledge-capture-app' 35 | ] 36 | 37 | static strict = false 38 | 39 | async run () { 40 | const { flags } = await this.parse(Server) 41 | const port = parseInt(flags.port) 42 | const { logs: tailLogs, bind: host } = flags 43 | const { argv: appDirectories } = await this.parse(Server) 44 | 45 | const appPaths = getAppPaths(appDirectories) 46 | let appJSON = await buildAppJSON(appPaths, port) 47 | 48 | const app = express() 49 | app.use(cors()) 50 | tailLogs && app.use(logMiddleware) 51 | 52 | app.get('/app.json', (req, res) => { 53 | res.setHeader('Content-Type', 'application/json') 54 | res.end(JSON.stringify(appJSON)) 55 | }) 56 | 57 | const setAppAssetsMiddleware = () => { 58 | appJSON.installations.forEach((installation: Installation, index: number) => { 59 | app.use(`/${installation.app_id}/assets`, express.static(`${appPaths[index]}/assets`)) 60 | }) 61 | } 62 | 63 | // Keep references of watchers for unwatching later 64 | const watchers = appPaths.map(appPath => 65 | fs.watch(appPath, async (eventType, filename) => { 66 | if (filename.toLowerCase() === 'manifest.json') { 67 | // Regenerate app.json 68 | appJSON = await buildAppJSON(appPaths, port) 69 | // Reset middlewares for app assets 70 | setAppAssetsMiddleware() 71 | } 72 | })) 73 | 74 | // Set middlewares for app assets 75 | setAppAssetsMiddleware() 76 | 77 | const server = app.listen(port, host, () => { 78 | this.log(`\nApps server is running on ${chalk.green(`http://${host}:${port}`)} 🚀\n`) 79 | this.log(`Add ${chalk.bold('?zcli_apps=true')} to the end of your Zendesk URL to load these apps on your Zendesk account.\n`) 80 | tailLogs && this.log(chalk.bold('Tailing logs')) 81 | }) 82 | 83 | return { 84 | close: () => { 85 | // Stop watching file changes before terminating the server 86 | watchers.forEach(watcher => watcher.close()) 87 | server.close() 88 | } 89 | } 90 | } 91 | } 92 | -------------------------------------------------------------------------------- /packages/zcli-apps/src/commands/apps/update.ts: -------------------------------------------------------------------------------- 1 | import { Command, CliUx } from '@oclif/core' 2 | import { getAllConfigs } from '../../utils/appConfig' 3 | import { CLIError } from '@oclif/core/lib/errors' 4 | import * as chalk from 'chalk' 5 | import { getUploadJobStatus, updateProductInstallation } from '../../utils/uploadApp' 6 | import { uploadAppPkg, deployApp } from '../../utils/createApp' 7 | import { getManifestFile } from '../../utils/manifest' 8 | import { createAppPkg } from '../../lib/package' 9 | import { Manifest, ZcliConfigFileContent } from '../../types' 10 | import { validateAppPath } from '../../lib/appPath' 11 | import { env } from '@zendesk/zcli-core' 12 | 13 | export default class Update extends Command { 14 | static description = 'updates an existing private app in the Zendesk products specified in the apps manifest file.' 15 | 16 | static args = [ 17 | { name: 'appDirectories', required: true, default: '.' } 18 | ] 19 | 20 | static strict = false 21 | 22 | getAppID (appConfig: ZcliConfigFileContent) { 23 | const app_id = process.env[env.EnvVars.APP_ID] || (appConfig ? appConfig.app_id : undefined) 24 | if (!app_id) { throw new CLIError(chalk.red('App ID not found')) } 25 | return app_id 26 | } 27 | 28 | async installApp (appConfig: ZcliConfigFileContent, uploadId: number, appPath: string, manifest: Manifest, appID: string) { 29 | CliUx.ux.action.start('Deploying app') 30 | const { job_id } = await deployApp('PUT', `api/v2/apps/${appID}`, uploadId) 31 | 32 | try { 33 | const { app_id }: any = await getUploadJobStatus(job_id, appPath) 34 | CliUx.ux.action.stop('Deployed') 35 | if (!manifest.requirementsOnly && manifest.location) { 36 | Object.keys(manifest.location).forEach(async product => { 37 | if (!updateProductInstallation(appConfig, manifest, app_id, product)) { 38 | this.error(chalk.red(`Failed to update ${manifest.name} with app_id: ${app_id}`)) 39 | } 40 | }) 41 | } 42 | this.log(chalk.green(`Successfully updated app: ${manifest.name} with app_id: ${app_id}`)) 43 | } catch (error) { 44 | CliUx.ux.action.stop('Failed') 45 | this.error(chalk.red(error)) 46 | } 47 | } 48 | 49 | async run () { 50 | const { argv: appDirectories } = await this.parse(Update) 51 | 52 | for (const appPath of appDirectories) { 53 | validateAppPath(appPath) 54 | 55 | CliUx.ux.action.start('Uploading app') 56 | const appConfig = getAllConfigs(appPath) || {} 57 | const appID = this.getAppID(appConfig) 58 | const manifest = getManifestFile(appPath) 59 | const pkgPath = await createAppPkg(appPath) 60 | const { id: upload_id } = await uploadAppPkg(pkgPath) 61 | 62 | if (!upload_id) { 63 | CliUx.ux.action.stop('Failed') 64 | this.error(`Failed to upload app ${manifest.name}`) 65 | } 66 | 67 | CliUx.ux.action.stop('Uploaded') 68 | try { 69 | await this.installApp(appConfig, upload_id, appPath, manifest, appID) 70 | } catch (error) { 71 | this.error(chalk.red(error)) 72 | } 73 | } 74 | } 75 | } 76 | -------------------------------------------------------------------------------- /packages/zcli-apps/src/commands/apps/validate.ts: -------------------------------------------------------------------------------- 1 | import { Command } from '@oclif/core' 2 | import * as fs from 'fs-extra' 3 | import * as path from 'path' 4 | import * as chalk from 'chalk' 5 | import { createAppPkg, validatePkg } from '../../lib/package' 6 | import { validateAppPath } from '../../lib/appPath' 7 | 8 | export default class Validate extends Command { 9 | static description = 'validates your app' 10 | 11 | static args = [ 12 | { name: 'appDirectory', default: '.', required: true, description: 'app path where manifest.json exists' } 13 | ] 14 | 15 | static examples = [ 16 | '$ zcli apps:validate .', 17 | '$ zcli apps:validate ./app1' 18 | ] 19 | 20 | async run () { 21 | const { args } = await this.parse(Validate) 22 | const { appDirectory } = args 23 | 24 | validateAppPath(appDirectory) 25 | 26 | const appPath = path.resolve(appDirectory) 27 | const pkgPath = await createAppPkg(appPath) 28 | 29 | try { 30 | await validatePkg(pkgPath) 31 | this.log(chalk.green('No validation errors')) 32 | } catch (error) { 33 | this.error(chalk.red(error)) 34 | } 35 | 36 | // clean up 37 | if (pkgPath) await fs.remove(pkgPath) 38 | } 39 | } 40 | -------------------------------------------------------------------------------- /packages/zcli-apps/src/constants.ts: -------------------------------------------------------------------------------- 1 | export const DEFAULT_APPS_CONFIG_FILE = 'zcli.apps.config.json' 2 | -------------------------------------------------------------------------------- /packages/zcli-apps/src/index.js: -------------------------------------------------------------------------------- 1 | export default {} 2 | -------------------------------------------------------------------------------- /packages/zcli-apps/src/lib/appPath.test.ts: -------------------------------------------------------------------------------- 1 | import { expect } from '@oclif/test' 2 | import { validateAppPath } from './appPath' 3 | 4 | describe('appPath', () => { 5 | describe('validateAppPath', () => { 6 | const badPath = './badPath' 7 | 8 | it('should throw an error if path does not exist', () => { 9 | expect(() => { 10 | validateAppPath(badPath) 11 | }).to.throw(`Invalid app path: ${badPath}`) 12 | }) 13 | }) 14 | }) 15 | -------------------------------------------------------------------------------- /packages/zcli-apps/src/lib/appPath.ts: -------------------------------------------------------------------------------- 1 | import * as fs from 'fs' 2 | import * as path from 'path' 3 | import * as chalk from 'chalk' 4 | import { CLIError } from '@oclif/core/lib/errors' 5 | 6 | export const validateAppPath = (appPath: string) => { 7 | if (!fs.existsSync(path.join(appPath, 'manifest.json'))) { 8 | throw new CLIError(chalk.red(`Invalid app path: ${appPath}`)) 9 | } 10 | } 11 | -------------------------------------------------------------------------------- /packages/zcli-apps/src/lib/package.test.ts: -------------------------------------------------------------------------------- 1 | import { expect, test } from '@oclif/test' 2 | import { validatePkg } from './package' 3 | import * as fs from 'fs-extra' 4 | import { request } from '@zendesk/zcli-core' 5 | 6 | describe('package', () => { 7 | describe('validatePkg', () => { 8 | test 9 | .stub(fs, 'pathExistsSync', () => true) 10 | .stub(fs, 'readFile', () => Promise.resolve('file content')) 11 | .stub(request, 'requestAPI', () => Promise.resolve({ status: 200 })) 12 | .it('should return true if package is valid', async () => { 13 | expect(await validatePkg('./app-path')).to.equal(true) 14 | }) 15 | 16 | test 17 | .stub(fs, 'pathExistsSync', () => true) 18 | .stub(fs, 'readFile', () => Promise.resolve('file content')) 19 | .stub(request, 'requestAPI', () => Promise.resolve({ status: 400, data: { description: 'invalid location' } })) 20 | .it('should throw if package has validation errors', async () => { 21 | try { 22 | await validatePkg('./app-path') 23 | } catch (error: any) { 24 | expect(error.message).to.equal('invalid location') 25 | } 26 | }) 27 | 28 | test 29 | .stub(fs, 'pathExistsSync', () => false) 30 | .stub(fs, 'readFile', () => Promise.reject(new Error('Package not found at ./bad-path'))) 31 | .it('should throw if app path is invalid', async () => { 32 | try { 33 | await validatePkg('./bad-path') 34 | } catch (error: any) { 35 | expect(error.message).to.equal('Package not found at ./bad-path') 36 | } 37 | }) 38 | }) 39 | }) 40 | -------------------------------------------------------------------------------- /packages/zcli-apps/src/lib/package.ts: -------------------------------------------------------------------------------- 1 | import * as path from 'path' 2 | import * as fs from 'fs-extra' 3 | import { request } from '@zendesk/zcli-core' 4 | import { CLIError } from '@oclif/core/lib/errors' 5 | import * as archiver from 'archiver' 6 | import { validateAppPath } from './appPath' 7 | import * as FormData from 'form-data' 8 | 9 | const getDateTimeFileName = () => (new Date()).toISOString().replace(/[^0-9]/g, '') 10 | 11 | export const createAppPkg = ( 12 | relativeAppPath: string, 13 | pkgDir = 'tmp' 14 | ): Promise => { 15 | return new Promise((resolve, reject) => { 16 | const appPath = path.resolve(relativeAppPath) 17 | validateAppPath(appPath) 18 | 19 | const pkgName = `app-${getDateTimeFileName()}` 20 | const pkgPath = `${appPath}/${pkgDir}/${pkgName}.zip` 21 | 22 | fs.ensureDirSync(`${appPath}/${pkgDir}`) 23 | const output = fs.createWriteStream(pkgPath) 24 | 25 | output.on('close', () => { 26 | resolve(pkgPath) 27 | }) 28 | 29 | output.on('error', (err) => { 30 | reject(err) 31 | }) 32 | 33 | const archive = archiver('zip') 34 | 35 | let archiveIgnore = ['tmp/**'] 36 | 37 | if (fs.pathExistsSync(`${appPath}/.zcliignore`)) { 38 | archiveIgnore = archiveIgnore.concat(fs.readFileSync(`${appPath}/.zcliignore`).toString().replace(/\r\n/g, '\n').split('\n').filter((item) => { 39 | return (item.trim().startsWith('#') ? null : item.trim()) 40 | })) 41 | } 42 | 43 | archive.glob('**', { 44 | cwd: appPath, 45 | ignore: archiveIgnore 46 | }) 47 | 48 | archive.pipe(output) 49 | 50 | archive.finalize() 51 | 52 | return pkgPath 53 | }) 54 | } 55 | 56 | export const validatePkg = async (pkgPath: string) => { 57 | if (!fs.pathExistsSync(pkgPath)) { 58 | throw new CLIError(`Package not found at ${pkgPath}`) 59 | } 60 | 61 | const file = await fs.readFile(pkgPath) 62 | 63 | const form = new FormData() 64 | form.append('file', file, { 65 | filename: path.basename(pkgPath) 66 | }) 67 | 68 | const res = await request.requestAPI('api/v2/apps/validate', { 69 | method: 'POST', 70 | data: form.getBuffer(), 71 | headers: form.getHeaders() 72 | }) 73 | 74 | if (res.status !== 200) { 75 | throw new CLIError(res.data?.description) 76 | } 77 | 78 | return true 79 | } 80 | -------------------------------------------------------------------------------- /packages/zcli-apps/src/types.ts: -------------------------------------------------------------------------------- 1 | export interface ConfigParameters { 2 | [parameterKey: string]: string | number | boolean; 3 | } 4 | export interface ZcliConfigFileContent { 5 | zat_latest?: string; 6 | zat_update_check?: string; 7 | plan?: string; 8 | app_id?: string; 9 | installation_id?: string; 10 | parameters?: ConfigParameters; 11 | } 12 | 13 | export type Dictionary = { 14 | [key: string]: T; 15 | } 16 | 17 | // Begin AppJSON definitions 18 | export interface AppLocation { 19 | [appLocation: string]: any; 20 | } 21 | 22 | export interface IconLocationAllowlist { 23 | [product: string]: Array; 24 | } 25 | 26 | export interface Location { 27 | [product: string]: AppLocation; 28 | } 29 | 30 | export interface FileList { 31 | name: string; 32 | time: number; 33 | } 34 | 35 | export interface FsExtraError extends Error { 36 | code: string; 37 | } 38 | 39 | export interface ManifestParameter { 40 | name: string; 41 | type: string; 42 | secure: boolean; 43 | } 44 | 45 | export interface Author { 46 | name: string; 47 | email: string; 48 | url?: string; 49 | } 50 | 51 | export interface Manifest { 52 | name?: string; 53 | author: Author; 54 | defaultLocale: string; 55 | private?: boolean; 56 | location?: Location; 57 | version?: string; 58 | frameworkVersion: string; 59 | singleInstall?: boolean; 60 | signedUrls?: boolean; 61 | parameters?: ManifestParameter[]; 62 | requirementsOnly?: boolean; 63 | } 64 | 65 | export interface ProductLocationIcons { 66 | [appLocation: string]: { 67 | [fileType: string]: string; 68 | }; 69 | } 70 | 71 | export interface LocationIcons { 72 | [product: string]: ProductLocationIcons; 73 | } 74 | 75 | export interface App { 76 | asset_url_prefix: string; 77 | id: string; 78 | name?: string; 79 | default_locale: string; 80 | private?: boolean; 81 | locations?: Location; 82 | version?: string; 83 | framework_version: string; 84 | single_install?: boolean; 85 | signed_urls?: boolean; 86 | parameters?: ManifestParameter[]; 87 | } 88 | 89 | export interface AppPayload { 90 | name: string; 91 | id: string; 92 | default_locale: string; 93 | private: boolean; 94 | location: Location; 95 | location_icons: LocationIcons; 96 | version: string; 97 | framework_version: string; 98 | asset_url_prefix: number; 99 | signed_urls: boolean; 100 | single_install: boolean; 101 | } 102 | 103 | export interface Installation { 104 | app_id: string; 105 | name?: string; 106 | collapsible: boolean; 107 | enabled: boolean; 108 | id: string; 109 | plan?: string; 110 | requirements: Array>; 111 | settings: Array>; 112 | updated_at: string; 113 | } 114 | 115 | export interface AppJSONPayload { 116 | apps: AppPayload[]; 117 | installations: Installation[]; 118 | } 119 | 120 | export interface AppJSON { 121 | apps: App[]; 122 | installations: Installation[]; 123 | } 124 | 125 | export interface AppManifest { 126 | name?: string; 127 | author: Author; 128 | default_locale: string; 129 | private?: boolean; 130 | location: Location; 131 | version?: string; 132 | framework_version: string; 133 | } 134 | 135 | // End AppJSON definitions 136 | 137 | export interface Scaffolds { 138 | [name: string]: string; 139 | } 140 | 141 | export interface ManifestPath { 142 | [name: string]: string; 143 | } 144 | 145 | export interface Installations{ 146 | installations: Installation[]; 147 | } 148 | -------------------------------------------------------------------------------- /packages/zcli-apps/src/utils/appConfig.test.ts: -------------------------------------------------------------------------------- 1 | import * as appConfig from './appConfig' 2 | import { expect, test } from '@oclif/test' 3 | import * as fs from 'fs-extra' 4 | import * as chalk from 'chalk' 5 | import * as path from 'path' 6 | 7 | describe('getAllConfigs', () => { 8 | let spyFilePath: string 9 | const appPath = './appPath1' 10 | const configFilePath = path.join('appPath1', 'zcli.apps.config.json') 11 | 12 | test 13 | .stub(fs, 'existsSync', () => true) 14 | .stub(fs, 'readFileSync', (...args) => { 15 | spyFilePath = (args as string[])[0] 16 | return JSON.stringify({ plan: 'silver' }) 17 | }) 18 | .it('should return a JSON object with zcli.apps.config.json file contents', () => { 19 | expect(appConfig.getAllConfigs(appPath, 'zcli.apps.config.json')).to.deep.equal({ plan: 'silver' }) 20 | expect(spyFilePath).to.equal(configFilePath) 21 | }) 22 | 23 | test 24 | .stub(fs, 'existsSync', () => false) 25 | .it('should return undefined', () => { 26 | expect(appConfig.getAllConfigs(appPath, 'zcli.apps.config.json')).to.be.an('undefined') 27 | }) 28 | 29 | test 30 | .stub(fs, 'existsSync', () => true) 31 | .stub(fs, 'readFileSync', () => { 32 | Error('bad json') 33 | }) 34 | .it('should return undefined and trigger a CLIError', () => { 35 | expect(() => { 36 | appConfig.getAllConfigs(appPath, 'zcli.apps.config.json') 37 | }).to.throw(chalk.red(`zcli configuration file was malformed at path: ${configFilePath}`)) 38 | }) 39 | }) 40 | 41 | describe('setConfig', () => { 42 | let spyFileJson: string 43 | const appPath = 'appPath1' 44 | const configFileJson = { plan: 'silver', table: 'tennis' } 45 | 46 | test 47 | .stub(path, 'resolve', (...args) => { 48 | return (args as string[])[0] 49 | }) 50 | .stub(fs, 'pathExists', () => true) 51 | .stub(fs, 'readJson', () => { 52 | return { plan: 'silver' } 53 | }) 54 | .stub(fs, 'outputJson', (...args) => { 55 | spyFileJson = (args as string[])[1] 56 | }) 57 | .it('should store key to zcli.apps.config.json file contents', async () => { 58 | await appConfig.setConfig('table', 'tennis', appPath) 59 | expect(spyFileJson).to.deep.equal(configFileJson) 60 | }) 61 | }) 62 | -------------------------------------------------------------------------------- /packages/zcli-apps/src/utils/appConfig.ts: -------------------------------------------------------------------------------- 1 | import { ZcliConfigFileContent } from '../types' 2 | 3 | import * as path from 'path' 4 | import * as fs from 'fs-extra' 5 | import * as chalk from 'chalk' 6 | import { CLIError } from '@oclif/core/lib/errors' 7 | import { DEFAULT_APPS_CONFIG_FILE } from '../constants' 8 | 9 | export const getAllConfigs = (appPath: string, configFileName: string = DEFAULT_APPS_CONFIG_FILE): ZcliConfigFileContent | undefined => { 10 | const configFilePath = path.join(appPath, configFileName) 11 | 12 | if (fs.existsSync(configFilePath)) { 13 | const zcliConfigFile = fs.readFileSync(configFilePath, 'utf8') 14 | try { 15 | return JSON.parse(zcliConfigFile) 16 | } catch (error) { 17 | throw new CLIError(chalk.red(`zcli configuration file was malformed at path: ${configFilePath}`)) 18 | } 19 | } 20 | } 21 | 22 | export const setConfig = async (key: string, value: string, appPath: string): Promise => { 23 | const configPath = `${path.resolve(appPath)}/zcli.apps.config.json` 24 | if (!await fs.pathExists(configPath)) { 25 | await fs.outputJson(configPath, { [key]: value }) 26 | } else { 27 | const config = await fs.readJson(configPath) || {} 28 | config[key] = value 29 | await fs.outputJson(configPath, config) 30 | } 31 | } 32 | -------------------------------------------------------------------------------- /packages/zcli-apps/src/utils/createApp.test.ts: -------------------------------------------------------------------------------- 1 | import * as createApp from './createApp' 2 | import { expect, test } from '@oclif/test' 3 | import { request } from '@zendesk/zcli-core' 4 | import * as manifest from '../utils/manifest' 5 | 6 | describe('deployApp', () => { 7 | test 8 | .stub(request, 'requestAPI', () => Promise.resolve({ status: 200, data: { job_id: 123 } })) 9 | .it('should return a job_id', async () => { 10 | expect(await createApp.deployApp('POST', 'api/v2/apps.json', 123, 'awesomeName')).to.deep.equal({ job_id: 123 }) 11 | }) 12 | }) 13 | 14 | describe('getManifestAppName', () => { 15 | let appPathSpy: string 16 | 17 | test 18 | .stub(manifest, 'getManifestFile', (...args) => { 19 | appPathSpy = (args as string[])[0] 20 | return { name: 'henry' } 21 | }) 22 | .it('should return the manifest app name', () => { 23 | expect(createApp.getManifestAppName('awesomeApp')).to.equal('henry') 24 | expect(appPathSpy).to.equal('awesomeApp') 25 | }) 26 | }) 27 | -------------------------------------------------------------------------------- /packages/zcli-apps/src/utils/createApp.ts: -------------------------------------------------------------------------------- 1 | import * as fs from 'fs-extra' 2 | import { Dictionary, Manifest, ManifestParameter } from '../types' 3 | import * as FormData from 'form-data' 4 | import { getManifestFile } from '../utils/manifest' 5 | import { request } from '@zendesk/zcli-core' 6 | import { CliUx } from '@oclif/core' 7 | import * as chalk from 'chalk' 8 | import * as path from 'path' 9 | 10 | export const getManifestAppName = (appPath: string): string | undefined => { 11 | return getManifestFile(appPath).name 12 | } 13 | 14 | export const uploadAppPkg = async (pkgPath: string): Promise => { 15 | const pkgBuffer = await fs.readFile(pkgPath) 16 | 17 | const formData = new FormData() 18 | formData.append('uploaded_data', pkgBuffer, { 19 | filename: path.basename(pkgPath), 20 | contentType: 'application/zip' 21 | }) 22 | 23 | const response = await request.requestAPI('api/v2/apps/uploads.json', { 24 | method: 'POST', 25 | data: formData.getBuffer(), 26 | headers: formData.getHeaders() 27 | }) 28 | 29 | // clean up 30 | await fs.remove(pkgPath) 31 | if (response.status !== 201) { 32 | console.log(chalk.red('Upload failed with response: ', response.data)) 33 | } 34 | 35 | return response.data 36 | } 37 | 38 | export const promptAndGetSettings = async (params: ManifestParameter[], appName = 'app', valuesRequired = true) => { 39 | const settings: Dictionary = {} 40 | for (const param of params) { 41 | const value = await CliUx.ux.prompt(`Enter ${appName} setting.${param.name} value`, { type: param.secure ? 'hide' : 'normal', required: valuesRequired }) 42 | if (value) settings[param.name] = value 43 | } 44 | return settings 45 | } 46 | 47 | export const deployApp = async (method: string, url: string, upload_id: number, appName?: string): Promise> => { 48 | let installationPayload 49 | if (appName) { 50 | installationPayload = { upload_id, name: appName } 51 | } else { 52 | installationPayload = { upload_id } 53 | } 54 | const installationOptions = { 55 | data: JSON.stringify(installationPayload), 56 | method, 57 | headers: { 58 | Accept: 'application/json', 59 | 'Content-Type': 'application/json' 60 | } 61 | } 62 | const installationResponse = await request.requestAPI(url, installationOptions) 63 | return installationResponse.data 64 | } 65 | 66 | export const createProductInstallation = async (settings: any, manifest: Manifest, app_id: string, product: string): Promise => { 67 | const installResponse = await request.requestAPI(`api/${product}/apps/installations.json`, { 68 | method: 'POST', 69 | data: JSON.stringify({ app_id: `${app_id}`, settings: { name: manifest.name, ...settings } }), 70 | headers: { 71 | 'Content-Type': 'application/json' 72 | } 73 | }) 74 | if (installResponse.status === 201 || installResponse.status === 200) { 75 | return true 76 | } else { 77 | return false 78 | } 79 | } 80 | -------------------------------------------------------------------------------- /packages/zcli-apps/src/utils/fileUtils.test.ts: -------------------------------------------------------------------------------- 1 | import { expect, test } from '@oclif/test' 2 | import { cleanDirectory, validatePath } from './fileUtils' 3 | 4 | describe('clean directory', () => { 5 | test 6 | .it('shows success message', async () => { 7 | expect(await cleanDirectory('tmp')).to.equal(true) 8 | }) 9 | }) 10 | 11 | describe('validatePath', () => { 12 | const badPath = './badPath' 13 | 14 | it('should throw an error if path does not exist', () => { 15 | expect(() => { 16 | validatePath(badPath) 17 | }).to.throw(`Invalid path: ${badPath}`) 18 | }) 19 | }) 20 | -------------------------------------------------------------------------------- /packages/zcli-apps/src/utils/fileUtils.ts: -------------------------------------------------------------------------------- 1 | import * as rimraf from 'rimraf' 2 | import * as utils from 'util' 3 | import * as fs from 'fs' 4 | import * as chalk from 'chalk' 5 | import { CLIError } from '@oclif/core/lib/errors' 6 | 7 | const removeDirectory = utils.promisify(rimraf) 8 | 9 | export const cleanDirectory = async (directory: string) => { 10 | await removeDirectory(directory) 11 | return true 12 | } 13 | 14 | export const validatePath = (path: string) => { 15 | if (!fs.existsSync(path)) { 16 | throw new CLIError(chalk.red(`Invalid path: ${path}`)) 17 | } 18 | } 19 | -------------------------------------------------------------------------------- /packages/zcli-apps/src/utils/getAppSettings.test.ts: -------------------------------------------------------------------------------- 1 | import { getAppSettings } from './getAppSettings' 2 | import * as createApp from './createApp' 3 | import { Manifest } from '../types' 4 | import { expect, test } from '@oclif/test' 5 | 6 | const manifestOutput: Manifest = { 7 | name: 'app 1', 8 | author: { 9 | name: 'name', 10 | email: 'test@email.com' 11 | }, 12 | defaultLocale: 'en', 13 | location: { 14 | sell: { 15 | top_bar: 'assets/iframe.html' 16 | }, 17 | support: { 18 | ticket_editor: 'assets/iframe.html', 19 | nav_bar: 'assets/iframe.html' 20 | } 21 | }, 22 | singleInstall: true, 23 | signedUrls: false, 24 | frameworkVersion: '2.0', 25 | parameters: [{ 26 | name: 'someToken', 27 | type: 'text', 28 | secure: true 29 | }, { 30 | name: 'salesForceId', 31 | type: 'number', 32 | secure: false 33 | }] 34 | } 35 | 36 | describe('getAppSettings', () => { 37 | test 38 | .stub(createApp, 'promptAndGetSettings', () => ({ someToken: 'ABC123' })) 39 | .it('should return setting from config and prompt for missing config', async () => { 40 | const settings = await getAppSettings(manifestOutput, { salesForceId: 222 }) 41 | expect(settings).to.deep.equals({ 42 | salesForceId: 222, 43 | someToken: 'ABC123' 44 | }) 45 | }) 46 | 47 | test 48 | .it('should return all settings from config', async () => { 49 | const settings = await getAppSettings(manifestOutput, { salesForceId: 222, someToken: 'XYZ786' }) 50 | expect(settings).to.deep.equals({ 51 | salesForceId: 222, 52 | someToken: 'XYZ786' 53 | }) 54 | }) 55 | }) 56 | -------------------------------------------------------------------------------- /packages/zcli-apps/src/utils/getAppSettings.ts: -------------------------------------------------------------------------------- 1 | import { Manifest, ConfigParameters, Dictionary } from '../types' 2 | import { promptAndGetSettings } from './createApp' 3 | 4 | export const getAppSettings = async (manifest: Manifest, configParams: ConfigParameters) => { 5 | if (!manifest.parameters) return {} 6 | const configContainsParam = (paramName: string) => Object.keys(configParams).includes(paramName) 7 | 8 | const paramsNotInConfig = manifest.parameters.filter(param => !configContainsParam(param.name)) 9 | const configSettings = manifest.parameters.reduce((result: Dictionary, param) => { 10 | if (configContainsParam(param.name)) { 11 | result[param.name] = configParams[param.name] as string 12 | } 13 | return result 14 | }, {}) 15 | 16 | const promptSettings = paramsNotInConfig ? await promptAndGetSettings(paramsNotInConfig, manifest.name, false) : {} 17 | return { ...configSettings, ...promptSettings } 18 | } 19 | -------------------------------------------------------------------------------- /packages/zcli-apps/src/utils/manifest.test.ts: -------------------------------------------------------------------------------- 1 | import { expect, test } from '@oclif/test' 2 | import { getManifestFile, updateManifestFile } from './manifest' 3 | import * as path from 'path' 4 | import * as fs from 'fs' 5 | 6 | describe('getManifestFile', () => { 7 | let spyFilePath: string 8 | const manifestFilePath = path.join('appPath1', 'manifest.json') 9 | test 10 | .stub(fs, 'existsSync', () => true) 11 | .stub(fs, 'readFileSync', (...args) => { 12 | spyFilePath = (args as string[])[0] 13 | return JSON.stringify({ name: 'xman' }) 14 | }) 15 | .it('should return a JSON object with manifest.json file contents', () => { 16 | expect(getManifestFile('./appPath1')).to.deep.equal({ name: 'xman' }) 17 | expect(spyFilePath).to.equal(manifestFilePath) 18 | }) 19 | }) 20 | 21 | describe('updateManifestFile', () => { 22 | let spyFilePath: string 23 | let spyFileContent: string 24 | const manifestFilePath = path.join('appPath1', 'manifest.json') 25 | const manifestContent = { 26 | author: { 27 | name: 'name', 28 | email: 'test@email.com' 29 | }, 30 | defaultLocale: 'en', 31 | location: {}, 32 | frameworkVersion: '2.0' 33 | } 34 | test 35 | .stub(fs, 'existsSync', () => true) 36 | .stub(fs, 'writeFileSync', (...args) => { 37 | spyFilePath = (args as string[])[0] 38 | spyFileContent = (args as string[])[1] 39 | }) 40 | .it('should write a JSON object into manifest.json file contents', () => { 41 | updateManifestFile('./appPath1', manifestContent) 42 | expect(spyFilePath).to.equal(manifestFilePath) 43 | expect(spyFileContent).to.equal(JSON.stringify(manifestContent, null, 2)) 44 | }) 45 | }) 46 | -------------------------------------------------------------------------------- /packages/zcli-apps/src/utils/manifest.ts: -------------------------------------------------------------------------------- 1 | import { Manifest } from '../types' 2 | import { validatePath } from './fileUtils' 3 | import * as path from 'path' 4 | import * as fs from 'fs' 5 | import * as chalk from 'chalk' 6 | import { CLIError } from '@oclif/core/lib/errors' 7 | 8 | export const getManifestFile = (appPath: string): Manifest => { 9 | const manifestFilePath = path.join(appPath, 'manifest.json') 10 | validatePath(manifestFilePath) 11 | 12 | const manifest = fs.readFileSync(manifestFilePath, 'utf8') 13 | return JSON.parse(manifest) 14 | } 15 | 16 | export const updateManifestFile = (appPath: string, manifestContent: Manifest): void => { 17 | const manifestFilePath = path.join(appPath, 'manifest.json') 18 | validatePath(manifestFilePath) 19 | try { 20 | fs.writeFileSync(manifestFilePath, JSON.stringify(manifestContent, null, 2)) 21 | } catch (error) { 22 | throw new CLIError(chalk.red(`Failed to update Manifest file at path: ${manifestFilePath}. ${error}`)) 23 | } 24 | } 25 | -------------------------------------------------------------------------------- /packages/zcli-apps/src/utils/shared.ts: -------------------------------------------------------------------------------- 1 | import * as path from 'path' 2 | import * as fs from 'fs' 3 | import * as util from 'util' 4 | 5 | const access = util.promisify(fs.access) 6 | 7 | export const getAppPaths = (appDirectories: string[]): string[] => { 8 | // set default as current dir 9 | if (appDirectories.length === 0) appDirectories.push('.') 10 | 11 | const appPaths = appDirectories.map((dirName: string) => path.resolve(dirName)) 12 | appPaths.forEach((dirPath: string) => { 13 | access(dirPath).catch(err => { 14 | console.error(err.message) 15 | }) 16 | }) 17 | 18 | return appPaths 19 | } 20 | -------------------------------------------------------------------------------- /packages/zcli-apps/src/utils/uploadApp.ts: -------------------------------------------------------------------------------- 1 | import { setConfig } from '../utils/appConfig' 2 | import { request } from '@zendesk/zcli-core' 3 | import { getAppSettings } from './getAppSettings' 4 | import { Manifest, Installations, ZcliConfigFileContent } from '../types' 5 | 6 | export const getUploadJobStatus = async (job_id: string, appPath: string, pollAfter = 1000) => new Promise((resolve, reject) => { 7 | const polling = setInterval(async () => { 8 | const res = await request.requestAPI(`api/v2/apps/job_statuses/${job_id}`, { method: 'GET' }) 9 | const { status, message, app_id } = await res.data 10 | 11 | if (status === 'completed') { 12 | clearInterval(polling) 13 | setConfig('app_id', app_id, appPath) 14 | resolve({ status, message, app_id }) 15 | } else if (status === 'failed') { 16 | clearInterval(polling) 17 | reject(message) 18 | } 19 | }, pollAfter) 20 | }) 21 | 22 | export const updateProductInstallation = async (appConfig: ZcliConfigFileContent, manifest: Manifest, app_id: string, product: string): Promise => { 23 | const installationResp = await request.requestAPI(`/api/${product}/apps/installations.json`, {}, true) 24 | const installations: Installations = installationResp.data 25 | 26 | const configParams = appConfig?.parameters || {} // if there are no parameters in the config, just attach an empty object 27 | const settings = manifest.parameters ? await getAppSettings(manifest, configParams) : {} 28 | const installation_id = installations.installations.filter(i => i.app_id === app_id)[0].id 29 | 30 | const updated = await request.requestAPI(`/api/${product}/apps/installations/${installation_id}.json`, { 31 | method: 'PUT', 32 | data: JSON.stringify({ settings: { name: manifest.name, ...settings } }), 33 | headers: { 34 | 'Content-Type': 'application/json' 35 | } 36 | }) 37 | 38 | if (updated.status === 201 || updated.status === 200) { 39 | return true 40 | } else { 41 | return false 42 | } 43 | } 44 | -------------------------------------------------------------------------------- /packages/zcli-apps/src/utils/uuid.ts: -------------------------------------------------------------------------------- 1 | import * as uuid from 'uuid' 2 | 3 | export const uuidV4 = () => uuid.v4() 4 | -------------------------------------------------------------------------------- /packages/zcli-apps/tests/functional/bump.test.ts: -------------------------------------------------------------------------------- 1 | import { expect, test } from '@oclif/test' 2 | import BumpCommand from '../../src/commands/apps/bump' 3 | import * as fs from 'fs' 4 | import * as path from 'path' 5 | import { getManifestFile } from '../../src/utils/manifest' 6 | 7 | describe('bump', () => { 8 | describe('with valid version in manifest', () => { 9 | let manifestPath 10 | beforeEach(async () => { 11 | manifestPath = path.join(__dirname, '/mocks/sample_manifest') 12 | fs.copyFileSync(path.join(manifestPath, '/manifest_with_valid_version.json'), path.join(manifestPath, '/manifest.json')) 13 | }) 14 | 15 | afterEach(() => fs.unlinkSync(path.join(manifestPath, '/manifest.json'))) 16 | 17 | test 18 | .it('should bump patch version', async () => { 19 | await BumpCommand.run([manifestPath]) 20 | const manifest = getManifestFile(manifestPath) 21 | expect('1.0.1').to.eq(manifest.version) 22 | }) 23 | 24 | test 25 | .it('should bump patch version', async () => { 26 | await BumpCommand.run([manifestPath, '-p']) 27 | const manifest = getManifestFile(manifestPath) 28 | expect('1.0.1').to.eq(manifest.version) 29 | }) 30 | 31 | test 32 | .it('should bump patch version', async () => { 33 | await BumpCommand.run([manifestPath, '--patch']) 34 | const manifest = getManifestFile(manifestPath) 35 | expect('1.0.1').to.eq(manifest.version) 36 | }) 37 | 38 | test 39 | .it('should bump minor version', async () => { 40 | await BumpCommand.run([manifestPath, '-m']) 41 | const manifest = getManifestFile(manifestPath) 42 | expect('1.1.0').to.eq(manifest.version) 43 | }) 44 | 45 | test 46 | .it('should bump minor version', async () => { 47 | await BumpCommand.run([manifestPath, '--minor']) 48 | const manifest = getManifestFile(manifestPath) 49 | expect('1.1.0').to.eq(manifest.version) 50 | }) 51 | 52 | test 53 | .it('should bump major version', async () => { 54 | await BumpCommand.run([manifestPath, '-M']) 55 | const manifest = getManifestFile(manifestPath) 56 | expect('2.0.0').to.eq(manifest.version) 57 | }) 58 | 59 | test 60 | .it('should bump major version', async () => { 61 | await BumpCommand.run([manifestPath, '--major']) 62 | const manifest = getManifestFile(manifestPath) 63 | expect('2.0.0').to.eq(manifest.version) 64 | }) 65 | }) 66 | 67 | describe('with invalid version in manifest', () => { 68 | let manifestPath 69 | beforeEach(async () => { 70 | manifestPath = path.join(__dirname, '/mocks/sample_manifest') 71 | fs.copyFileSync(path.join(manifestPath, '/manifest_with_invalid_version.json'), path.join(manifestPath, '/manifest.json')) 72 | }) 73 | 74 | afterEach(() => fs.unlinkSync(path.join(manifestPath, '/manifest.json'))) 75 | 76 | const failureMessage = '1.0 is not a valid semantic version' 77 | 78 | test 79 | .it('should fail with error', async () => { 80 | try { 81 | await BumpCommand.run([manifestPath]) 82 | } catch (e) { 83 | expect(e.message).to.contain(failureMessage) 84 | } 85 | }) 86 | }) 87 | }) 88 | -------------------------------------------------------------------------------- /packages/zcli-apps/tests/functional/clean.test.ts: -------------------------------------------------------------------------------- 1 | import { expect, test } from '@oclif/test' 2 | import * as fs from 'fs' 3 | import * as path from 'path' 4 | 5 | const singleProductApp = path.join(__dirname, 'mocks/single_product_app') 6 | 7 | describe('clean', () => { 8 | const cleanCmdTest = test 9 | .register('createDirectory', (dir) => { 10 | return { 11 | run () { 12 | if (!fs.existsSync(dir)) { 13 | fs.mkdirSync(dir) 14 | } 15 | } 16 | } 17 | }) 18 | .register('createFile', () => { 19 | return { 20 | run () { 21 | fs.appendFile('./tmp/testFile.ts', 'Test content', function (err) { 22 | if (err) throw err 23 | }) 24 | } 25 | } 26 | }) 27 | 28 | cleanCmdTest 29 | .createDirectory('tmp') 30 | .createFile() 31 | .stdout() 32 | .command(['apps:clean', singleProductApp]) 33 | .it('shows success message and also clears all the files!', async (ctx) => { 34 | const tmpDirectoryPath = path.join(process.cwd(), 'tmp') 35 | expect(ctx.stdout).to.contain( 36 | `Successfully removed ${tmpDirectoryPath} directory` 37 | ) 38 | expect(fs.existsSync('tmp')).to.equal(false) 39 | }) 40 | }) 41 | -------------------------------------------------------------------------------- /packages/zcli-apps/tests/functional/env.ts: -------------------------------------------------------------------------------- 1 | export default { 2 | ZENDESK_SUBDOMAIN: 'z3ntest', 3 | ZENDESK_EMAIL: 'admin@z3ntest.com', 4 | ZENDESK_API_TOKEN: '123456' 5 | } 6 | -------------------------------------------------------------------------------- /packages/zcli-apps/tests/functional/mocks/multi_product_app/assets/iframe.html: -------------------------------------------------------------------------------- 1 | app 2 Iframe 2 | -------------------------------------------------------------------------------- /packages/zcli-apps/tests/functional/mocks/multi_product_app/assets/sell/icon_top_bar.svg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/zendesk/zcli/f825c8fa39ad780ec5ff05e85e8073667268d7a9/packages/zcli-apps/tests/functional/mocks/multi_product_app/assets/sell/icon_top_bar.svg -------------------------------------------------------------------------------- /packages/zcli-apps/tests/functional/mocks/multi_product_app/assets/sell/screenshot.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/zendesk/zcli/f825c8fa39ad780ec5ff05e85e8073667268d7a9/packages/zcli-apps/tests/functional/mocks/multi_product_app/assets/sell/screenshot.png -------------------------------------------------------------------------------- /packages/zcli-apps/tests/functional/mocks/multi_product_app/assets/support/icon_nav_bar.svg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/zendesk/zcli/f825c8fa39ad780ec5ff05e85e8073667268d7a9/packages/zcli-apps/tests/functional/mocks/multi_product_app/assets/support/icon_nav_bar.svg -------------------------------------------------------------------------------- /packages/zcli-apps/tests/functional/mocks/multi_product_app/assets/support/icon_ticket_editor.svg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/zendesk/zcli/f825c8fa39ad780ec5ff05e85e8073667268d7a9/packages/zcli-apps/tests/functional/mocks/multi_product_app/assets/support/icon_ticket_editor.svg -------------------------------------------------------------------------------- /packages/zcli-apps/tests/functional/mocks/multi_product_app/assets/support/logo-small.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/zendesk/zcli/f825c8fa39ad780ec5ff05e85e8073667268d7a9/packages/zcli-apps/tests/functional/mocks/multi_product_app/assets/support/logo-small.png -------------------------------------------------------------------------------- /packages/zcli-apps/tests/functional/mocks/multi_product_app/manifest.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "Test App 2", 3 | "author": { 4 | "name": "Zendesk", 5 | "email": "support@zendesk.com", 6 | "url": "https://help.zendesk.com" 7 | }, 8 | "defaultLocale": "en", 9 | "private": true, 10 | "location": { 11 | "support": { 12 | "ticket_editor": "assets/iframe.html", 13 | "nav_bar": "assets/iframe.html" 14 | }, 15 | "sell": { 16 | "top_bar": "assets/iframe.html" 17 | } 18 | }, 19 | "parameters": [{ 20 | "name": "salesForceId", 21 | "secure": false, 22 | "type": "text" 23 | }], 24 | "version": "1.0", 25 | "frameworkVersion": "2.0" 26 | } 27 | -------------------------------------------------------------------------------- /packages/zcli-apps/tests/functional/mocks/multi_product_app/zcli.apps.config.json: -------------------------------------------------------------------------------- 1 | { 2 | "plan": "silver", 3 | "app_id": 123, 4 | "parameters": { 5 | "someToken": "fksjdhfb231435", 6 | "salesForceId": 123 7 | } 8 | } 9 | -------------------------------------------------------------------------------- /packages/zcli-apps/tests/functional/mocks/requirements_only_app/assets/icon_nav_bar.svg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/zendesk/zcli/f825c8fa39ad780ec5ff05e85e8073667268d7a9/packages/zcli-apps/tests/functional/mocks/requirements_only_app/assets/icon_nav_bar.svg -------------------------------------------------------------------------------- /packages/zcli-apps/tests/functional/mocks/requirements_only_app/assets/icon_ticket_editor.svg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/zendesk/zcli/f825c8fa39ad780ec5ff05e85e8073667268d7a9/packages/zcli-apps/tests/functional/mocks/requirements_only_app/assets/icon_ticket_editor.svg -------------------------------------------------------------------------------- /packages/zcli-apps/tests/functional/mocks/requirements_only_app/assets/iframe.html: -------------------------------------------------------------------------------- 1 | app 1 Iframe 2 | -------------------------------------------------------------------------------- /packages/zcli-apps/tests/functional/mocks/requirements_only_app/assets/logo-small.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/zendesk/zcli/f825c8fa39ad780ec5ff05e85e8073667268d7a9/packages/zcli-apps/tests/functional/mocks/requirements_only_app/assets/logo-small.png -------------------------------------------------------------------------------- /packages/zcli-apps/tests/functional/mocks/requirements_only_app/manifest.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "Test App 1", 3 | "author": { 4 | "name": "Zendesk", 5 | "email": "support@zendesk.com", 6 | "url": "https://help.zendesk.com" 7 | }, 8 | "defaultLocale": "en", 9 | "private": true, 10 | "requirementsOnly": true, 11 | "version": "1.0", 12 | "frameworkVersion": "2.0" 13 | } -------------------------------------------------------------------------------- /packages/zcli-apps/tests/functional/mocks/requirements_only_app/zcli.apps.config.json: -------------------------------------------------------------------------------- 1 | {"app_id":123456} 2 | -------------------------------------------------------------------------------- /packages/zcli-apps/tests/functional/mocks/sample_manifest/manifest_with_invalid_version.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "Test App 1", 3 | "author": { 4 | "name": "Zendesk", 5 | "email": "support@zendesk.com", 6 | "url": "https://help.zendesk.com" 7 | }, 8 | "defaultLocale": "en", 9 | "private": true, 10 | "location": { 11 | "support": { 12 | "ticket_editor": "assets/iframe.html", 13 | "nav_bar": "assets/iframe.html" 14 | } 15 | }, 16 | "version": "1.0", 17 | "frameworkVersion": "2.0" 18 | } 19 | -------------------------------------------------------------------------------- /packages/zcli-apps/tests/functional/mocks/sample_manifest/manifest_with_valid_version.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "Test App 1", 3 | "author": { 4 | "name": "Zendesk", 5 | "email": "support@zendesk.com", 6 | "url": "https://help.zendesk.com" 7 | }, 8 | "defaultLocale": "en", 9 | "private": true, 10 | "location": { 11 | "support": { 12 | "ticket_editor": "assets/iframe.html", 13 | "nav_bar": "assets/iframe.html" 14 | } 15 | }, 16 | "version": "1.0.0", 17 | "frameworkVersion": "2.0" 18 | } 19 | -------------------------------------------------------------------------------- /packages/zcli-apps/tests/functional/mocks/single_product_another_app/assets/icon_nav_bar.svg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/zendesk/zcli/f825c8fa39ad780ec5ff05e85e8073667268d7a9/packages/zcli-apps/tests/functional/mocks/single_product_another_app/assets/icon_nav_bar.svg -------------------------------------------------------------------------------- /packages/zcli-apps/tests/functional/mocks/single_product_another_app/assets/icon_ticket_editor.svg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/zendesk/zcli/f825c8fa39ad780ec5ff05e85e8073667268d7a9/packages/zcli-apps/tests/functional/mocks/single_product_another_app/assets/icon_ticket_editor.svg -------------------------------------------------------------------------------- /packages/zcli-apps/tests/functional/mocks/single_product_another_app/assets/iframe.html: -------------------------------------------------------------------------------- 1 | app 1 Iframe 2 | -------------------------------------------------------------------------------- /packages/zcli-apps/tests/functional/mocks/single_product_another_app/assets/logo-small.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/zendesk/zcli/f825c8fa39ad780ec5ff05e85e8073667268d7a9/packages/zcli-apps/tests/functional/mocks/single_product_another_app/assets/logo-small.png -------------------------------------------------------------------------------- /packages/zcli-apps/tests/functional/mocks/single_product_another_app/manifest.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "Test App 2 modified", 3 | "author": { 4 | "name": "Zendesk", 5 | "email": "support@zendesk.com", 6 | "url": "https://help.zendesk.com" 7 | }, 8 | "defaultLocale": "en", 9 | "private": true, 10 | "location": { 11 | "support": { 12 | "ticket_editor": "assets/iframe.html", 13 | "nav_bar": "assets/iframe.html" 14 | } 15 | }, 16 | "version": "1.0", 17 | "frameworkVersion": "2.0" 18 | } -------------------------------------------------------------------------------- /packages/zcli-apps/tests/functional/mocks/single_product_another_app/zcli.apps.config.json: -------------------------------------------------------------------------------- 1 | {"app_id":123456} 2 | -------------------------------------------------------------------------------- /packages/zcli-apps/tests/functional/mocks/single_product_app/assets/icon_nav_bar.svg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/zendesk/zcli/f825c8fa39ad780ec5ff05e85e8073667268d7a9/packages/zcli-apps/tests/functional/mocks/single_product_app/assets/icon_nav_bar.svg -------------------------------------------------------------------------------- /packages/zcli-apps/tests/functional/mocks/single_product_app/assets/icon_ticket_editor.svg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/zendesk/zcli/f825c8fa39ad780ec5ff05e85e8073667268d7a9/packages/zcli-apps/tests/functional/mocks/single_product_app/assets/icon_ticket_editor.svg -------------------------------------------------------------------------------- /packages/zcli-apps/tests/functional/mocks/single_product_app/assets/iframe.html: -------------------------------------------------------------------------------- 1 | app 1 Iframe 2 | -------------------------------------------------------------------------------- /packages/zcli-apps/tests/functional/mocks/single_product_app/assets/logo-small.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/zendesk/zcli/f825c8fa39ad780ec5ff05e85e8073667268d7a9/packages/zcli-apps/tests/functional/mocks/single_product_app/assets/logo-small.png -------------------------------------------------------------------------------- /packages/zcli-apps/tests/functional/mocks/single_product_app/manifest.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "Test App 1", 3 | "author": { 4 | "name": "Zendesk", 5 | "email": "support@zendesk.com", 6 | "url": "https://help.zendesk.com" 7 | }, 8 | "defaultLocale": "en", 9 | "private": true, 10 | "location": { 11 | "support": { 12 | "ticket_editor": "assets/iframe.html", 13 | "nav_bar": "assets/iframe.html" 14 | } 15 | }, 16 | "version": "1.0", 17 | "frameworkVersion": "2.0" 18 | } -------------------------------------------------------------------------------- /packages/zcli-apps/tests/functional/mocks/single_product_app/zcli.apps.config.json: -------------------------------------------------------------------------------- 1 | {"app_id":123456} 2 | -------------------------------------------------------------------------------- /packages/zcli-apps/tests/functional/mocks/single_product_ignore/.zcliignore: -------------------------------------------------------------------------------- 1 | assets/logo-small.png 2 | assets/iframe.html 3 | assets/icon_nav_bar.svg 4 | manifest.json 5 | testFolder/ignoreFolder 6 | testFolder/1gn0r3m3.jpg 7 | -------------------------------------------------------------------------------- /packages/zcli-apps/tests/functional/mocks/single_product_ignore/assets/icon_nav_bar.svg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/zendesk/zcli/f825c8fa39ad780ec5ff05e85e8073667268d7a9/packages/zcli-apps/tests/functional/mocks/single_product_ignore/assets/icon_nav_bar.svg -------------------------------------------------------------------------------- /packages/zcli-apps/tests/functional/mocks/single_product_ignore/assets/icon_ticket_editor.svg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/zendesk/zcli/f825c8fa39ad780ec5ff05e85e8073667268d7a9/packages/zcli-apps/tests/functional/mocks/single_product_ignore/assets/icon_ticket_editor.svg -------------------------------------------------------------------------------- /packages/zcli-apps/tests/functional/mocks/single_product_ignore/assets/iframe.html: -------------------------------------------------------------------------------- 1 | app 1 Iframe 2 | -------------------------------------------------------------------------------- /packages/zcli-apps/tests/functional/mocks/single_product_ignore/assets/logo-small.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/zendesk/zcli/f825c8fa39ad780ec5ff05e85e8073667268d7a9/packages/zcli-apps/tests/functional/mocks/single_product_ignore/assets/logo-small.png -------------------------------------------------------------------------------- /packages/zcli-apps/tests/functional/mocks/single_product_ignore/assets/testFolder/1gn0r3m3.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/zendesk/zcli/f825c8fa39ad780ec5ff05e85e8073667268d7a9/packages/zcli-apps/tests/functional/mocks/single_product_ignore/assets/testFolder/1gn0r3m3.jpg -------------------------------------------------------------------------------- /packages/zcli-apps/tests/functional/mocks/single_product_ignore/manifest.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "Test App 1", 3 | "author": { 4 | "name": "Zendesk", 5 | "email": "support@zendesk.com", 6 | "url": "https://help.zendesk.com" 7 | }, 8 | "defaultLocale": "en", 9 | "private": true, 10 | "location": { 11 | "support": { 12 | "ticket_editor": "assets/iframe.html", 13 | "nav_bar": "assets/iframe.html" 14 | } 15 | }, 16 | "version": "1.0", 17 | "frameworkVersion": "2.0" 18 | } 19 | -------------------------------------------------------------------------------- /packages/zcli-apps/tests/functional/mocks/single_product_ignore/zcli.apps.config.json: -------------------------------------------------------------------------------- 1 | {"app_id":123456} 2 | -------------------------------------------------------------------------------- /packages/zcli-apps/tests/functional/mocks/snapshot_app.json: -------------------------------------------------------------------------------- 1 | { 2 | "apps": [ 3 | { 4 | "name": "Test App 1", 5 | "id": "db07b518-7730-462e-a481-4693f4e5c686", 6 | "author": { 7 | "name": "Zendesk", 8 | "email": "support@zendesk.com", 9 | "url": "https://help.zendesk.com" 10 | }, 11 | "default_locale": "en", 12 | "private": true, 13 | "location": { 14 | "support": { 15 | "ticket_editor": { 16 | "url": "assets/iframe.html", 17 | "svg": "support/icon_ticket_editor.svg" 18 | } 19 | } 20 | }, 21 | "version": "1.0", 22 | "framework_version": "2.0", 23 | "asset_url_prefix": "http://localhost:4567/db07b518-7730-462e-a481-4693f4e5c686/assets" 24 | }, 25 | { 26 | "name": "Test App 2", 27 | "id": "123", 28 | "author": { 29 | "name": "Zendesk", 30 | "email": "support@zendesk.com", 31 | "url": "https://help.zendesk.com" 32 | }, 33 | "default_locale": "en", 34 | "private": true, 35 | "location": { 36 | "support": { 37 | "ticket_editor": { 38 | "url": "assets/iframe.html", 39 | "svg": "support/icon_ticket_editor.svg" 40 | } 41 | }, 42 | "sell": { 43 | "top_bar": { 44 | "url": "assets/iframe.html", 45 | "svg": "sell/top_bar.svg" 46 | } 47 | } 48 | }, 49 | "version": "1.0", 50 | "framework_version": "2.0", 51 | "asset_url_prefix": "http://localhost:4567/123/assets" 52 | } 53 | ], 54 | "installations": [ 55 | { 56 | "app_id": "db07b518-7730-462e-a481-4693f4e5c686", 57 | "name": "Test App 1", 58 | "collapsible": true, 59 | "enabled": true, 60 | "id": "53eeb750-d471-47fe-9845-cc65f92e1a83", 61 | "requirements": [], 62 | "settings": [{ 63 | "title": "Test App 1" 64 | }], 65 | "updated_at": "2020-03-27T00:20:05.274Z" 66 | }, 67 | { 68 | "app_id": "123", 69 | "name": "Test App 2", 70 | "collapsible": true, 71 | "enabled": true, 72 | "id": "b3b4cc7d-b728-434d-b484-1e79bbca1097", 73 | "plan": "silver", 74 | "requirements": [], 75 | "settings": [{ 76 | "title": "Test App 2" 77 | }, { 78 | "salesForceId": 123 79 | }], 80 | "updated_at": "2020-03-27T00:20:05.274Z" 81 | } 82 | ] 83 | } 84 | -------------------------------------------------------------------------------- /packages/zcli-apps/tests/functional/new.test.ts: -------------------------------------------------------------------------------- 1 | import { expect, test } from '@oclif/test' 2 | import NewCommand from '../../src/commands/apps/new' 3 | import { cleanDirectory } from '../../src/utils/fileUtils' 4 | import * as path from 'path' 5 | import * as fs from 'fs' 6 | 7 | describe('apps new', () => { 8 | const dirName = 'myDir' 9 | const authorName = 'testName' 10 | const authorEmail = 'test@email.com' 11 | const appName = 'testName' 12 | const dirPath = path.join(process.cwd(), dirName) 13 | const authorURL = 'https://test.com' 14 | 15 | describe('--scaffold', () => { 16 | before(async () => { 17 | await NewCommand.run(['--path', dirName, '--authorName', authorName, '--authorEmail', authorEmail, '--appName', appName, '--authorURL', authorURL]) 18 | }) 19 | 20 | after(async () => { 21 | await cleanDirectory(dirPath) 22 | }) 23 | 24 | test.it(`should create a directory with the name ${dirName}`, async () => { 25 | expect(fs.existsSync(dirPath)).to.eq(true) 26 | }) 27 | 28 | // Ensure zip file is not left 29 | test.it('should not indicate remnants of scaffold.zip', async () => { 30 | const zipDirPath = path.join(process.cwd(), 'scaffold.zip') 31 | expect(fs.existsSync(zipDirPath)).to.eq(false) 32 | }) 33 | 34 | test.it('should not create a directory with webpack configs', async () => { 35 | const webpackPath = path.join(process.cwd(), dirName, 'webpack') 36 | expect(fs.existsSync(webpackPath)).to.eq(false) 37 | }) 38 | }) 39 | 40 | describe('--scaffold=basic', () => { 41 | before(async () => { 42 | await NewCommand.run(['--scaffold', 'basic', '--path', dirName, '--authorName', authorName, '--authorEmail', authorEmail, '--appName', appName, '--authorURL', authorURL]) 43 | }) 44 | 45 | after(async () => { 46 | await cleanDirectory(dirPath) 47 | }) 48 | 49 | test.it('should not create a directory with webpack configs', async () => { 50 | const webpackPath = path.join(process.cwd(), dirName, 'webpack') 51 | expect(fs.existsSync(webpackPath)).to.eq(false) 52 | }) 53 | 54 | test.it('updates manifest.json with user input values', async () => { 55 | const manifestPath = path.join(process.cwd(), dirName, 'manifest.json') 56 | const manifest = JSON.parse(fs.readFileSync(manifestPath, 'utf8')) 57 | 58 | expect(manifest.name).to.eq(appName) 59 | expect(manifest.author.name).to.eq(authorName) 60 | expect(manifest.author.email).to.eq(authorEmail) 61 | expect(manifest.author.url).to.eq(authorURL) 62 | }) 63 | }) 64 | 65 | describe('--scaffold=react', () => { 66 | const dirName = 'myDir' 67 | const dirPath = path.join(process.cwd(), dirName) 68 | 69 | before(async () => { 70 | await NewCommand.run(['--scaffold', 'react', '--path', dirName, '--authorName', authorName, '--authorEmail', authorEmail, '--appName', appName, '--authorURL', authorURL]) 71 | }) 72 | 73 | after(async () => { 74 | await cleanDirectory(dirPath) 75 | }) 76 | 77 | test.it('should create a vite config file', async () => { 78 | const viteConfigPath = path.join(process.cwd(), dirName, 'vite.config.js') 79 | expect(fs.existsSync(viteConfigPath)).to.eq(true) 80 | }) 81 | 82 | test.it('updates manifest.json with user input values', async () => { 83 | const manifestPath = path.join(process.cwd(), dirName, 'src', 'manifest.json') 84 | const manifest = JSON.parse(fs.readFileSync(manifestPath, 'utf8')) 85 | 86 | expect(manifest.name).to.eq(appName) 87 | expect(manifest.author.name).to.eq(authorName) 88 | expect(manifest.author.email).to.eq(authorEmail) 89 | expect(manifest.author.url).to.eq(authorURL) 90 | }) 91 | }) 92 | 93 | describe('authorURL correctly populated in manifest', () => { 94 | before(async () => { 95 | await NewCommand.run(['--path', dirName, '--authorName', authorName, '--authorEmail', authorEmail, '--appName', appName, '--authorURL', '\x0D']) 96 | }) 97 | 98 | afterEach(async () => { 99 | await cleanDirectory(dirPath) 100 | }) 101 | test.it('authorURL field is not populated if user skips input ', async () => { 102 | const manifestPath = path.join(process.cwd(), dirName, 'manifest.json') 103 | const manifest = JSON.parse(fs.readFileSync(manifestPath, 'utf8')) 104 | 105 | expect(manifest.author.url).to.eq(undefined) 106 | }) 107 | }) 108 | }) 109 | -------------------------------------------------------------------------------- /packages/zcli-apps/tests/functional/package.test.ts: -------------------------------------------------------------------------------- 1 | import { expect, test } from '@oclif/test' 2 | import * as path from 'path' 3 | import * as fs from 'fs' 4 | import * as sinon from 'sinon' 5 | import * as readline from 'readline' 6 | import * as AdmZip from 'adm-zip' 7 | import env from './env' 8 | import * as requestUtils from '../../../zcli-core/src/lib/requestUtils' 9 | 10 | describe('package', function () { 11 | const appPath = path.join(__dirname, 'mocks/single_product_app') 12 | let fetchStub: sinon.SinonStub 13 | 14 | beforeEach(() => { 15 | fetchStub = sinon.stub(global, 'fetch') 16 | }) 17 | 18 | afterEach(() => { 19 | fetchStub.restore() 20 | }) 21 | 22 | test 23 | .stub(requestUtils, 'getSubdomain', () => Promise.resolve(undefined)) 24 | .stub(requestUtils, 'getDomain', () => Promise.resolve(undefined)) 25 | .env(env) 26 | .do(() => { 27 | fetchStub.withArgs(sinon.match({ 28 | url: 'https://z3ntest.zendesk.com/api/v2/apps/validate' 29 | })).resolves({ 30 | status: 200, 31 | ok: true, 32 | text: () => Promise.resolve('') 33 | }) 34 | }) 35 | .stdout() 36 | .command(['apps:package', appPath]) 37 | .it('should display success message if package is created', ctx => { 38 | const pkgPath = path.join(path.relative(process.cwd(), appPath), 'tmp', 'app') 39 | expect(ctx.stdout).to.contain(`Package created at ${pkgPath}`) 40 | }) 41 | 42 | test 43 | .stub(requestUtils, 'getSubdomain', () => Promise.resolve(undefined)) 44 | .stub(requestUtils, 'getDomain', () => Promise.resolve(undefined)) 45 | .env(env) 46 | .do(() => { 47 | fetchStub.withArgs(sinon.match({ 48 | url: 'https://z3ntest.zendesk.com/api/v2/apps/validate' 49 | })).resolves({ 50 | status: 400, 51 | ok: false, 52 | text: () => Promise.resolve(JSON.stringify({ description: 'invalid location' })) 53 | }) 54 | }) 55 | .command(['apps:package', path.join(__dirname, 'mocks/single_product_app')]) 56 | .catch(err => expect(err.message).to.contain('Error: invalid location')) 57 | .it('should display error message if package fails to create') 58 | }) 59 | 60 | describe('zcliignore', function () { 61 | const appPath = path.join(__dirname, 'mocks/single_product_ignore') 62 | const tmpPath = path.join(appPath, 'tmp') 63 | let fetchStub: sinon.SinonStub 64 | 65 | const file = readline.createInterface({ 66 | input: fs.createReadStream(path.join(appPath, '.zcliignore')), 67 | output: process.stdout, 68 | terminal: false 69 | }) 70 | 71 | const ignoreArr: string[] = [] // array that holds each line of the .ignore file 72 | 73 | file.on('line', (line) => { 74 | ignoreArr.push(line) // add to array dynamically 75 | }) 76 | 77 | beforeEach(() => { 78 | fetchStub = sinon.stub(global, 'fetch') 79 | }) 80 | 81 | afterEach(() => { 82 | fetchStub.restore() 83 | }) 84 | 85 | after(async () => { 86 | fs.readdir(tmpPath, (err, files) => { 87 | if (err) throw err 88 | 89 | for (const file of files) { 90 | fs.unlink(path.join(tmpPath, file), (err) => { 91 | if (err) throw err 92 | }) 93 | } 94 | }) 95 | }) 96 | 97 | test 98 | .stub(requestUtils, 'getSubdomain', () => Promise.resolve(undefined)) 99 | .stub(requestUtils, 'getDomain', () => Promise.resolve(undefined)) 100 | .env(env) 101 | .do(() => { 102 | fetchStub.withArgs(sinon.match({ 103 | url: 'https://z3ntest.zendesk.com/api/v2/apps/validate' 104 | })).resolves({ 105 | status: 200, 106 | ok: true, 107 | text: () => Promise.resolve('') 108 | }) 109 | }) 110 | .stdout() 111 | .command(['apps:package', appPath]) 112 | .it('should not include certain files as specified in .zcliignore', async () => { 113 | const packagePath = path.join(tmpPath, fs.readdirSync(tmpPath).find(fn => fn.startsWith('app')) || '') 114 | const zip = new AdmZip(packagePath) 115 | for (const zipEntry of zip.getEntries()) { 116 | expect(ignoreArr.includes(zipEntry.name)).to.eq(false) 117 | } 118 | }) 119 | }) 120 | -------------------------------------------------------------------------------- /packages/zcli-apps/tests/functional/validate.test.ts: -------------------------------------------------------------------------------- 1 | import { expect, test } from '@oclif/test' 2 | import * as path from 'path' 3 | import * as sinon from 'sinon' 4 | import env from './env' 5 | import * as requestUtils from '../../../zcli-core/src/lib/requestUtils' 6 | 7 | describe('validate', function () { 8 | let fetchStub: sinon.SinonStub 9 | 10 | beforeEach(() => { 11 | fetchStub = sinon.stub(global, 'fetch') 12 | }) 13 | 14 | afterEach(() => { 15 | fetchStub.restore() 16 | }) 17 | 18 | test 19 | .stub(requestUtils, 'getSubdomain', () => Promise.resolve(undefined)) 20 | .stub(requestUtils, 'getDomain', () => Promise.resolve(undefined)) 21 | .env(env) 22 | .do(() => { 23 | fetchStub.withArgs(sinon.match({ 24 | url: 'https://z3ntest.zendesk.com/api/v2/apps/validate' 25 | })).resolves({ 26 | status: 200, 27 | ok: true, 28 | text: () => Promise.resolve('') 29 | }) 30 | }) 31 | .stdout() 32 | .command(['apps:validate', path.join(__dirname, 'mocks/single_product_app')]) 33 | .it('should display success message when no errors', ctx => { 34 | expect(ctx.stdout).to.equal('No validation errors\n') 35 | }) 36 | 37 | test 38 | .stub(requestUtils, 'getSubdomain', () => Promise.resolve(undefined)) 39 | .stub(requestUtils, 'getDomain', () => Promise.resolve(undefined)) 40 | .env(env) 41 | .do(() => { 42 | fetchStub.withArgs(sinon.match({ 43 | url: 'https://z3ntest.zendesk.com/api/v2/apps/validate' 44 | })).resolves({ 45 | status: 400, 46 | ok: false, 47 | text: () => Promise.resolve(JSON.stringify({ description: 'invalid location' })) 48 | }) 49 | }) 50 | .stdout() 51 | .command(['apps:validate', path.join(__dirname, 'mocks/single_product_app')]) 52 | .catch(err => expect(err.message).to.contain('Error: invalid location')) 53 | .it('should display error message if validation fails') 54 | }) 55 | -------------------------------------------------------------------------------- /packages/zcli-apps/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "declaration": true, 4 | "importHelpers": true, 5 | "module": "commonjs", 6 | "outDir": "dist", 7 | "rootDir": "src", 8 | "strict": true, 9 | "target": "es2017", 10 | "skipLibCheck": true 11 | }, 12 | "include": [ 13 | "src/**/*" 14 | ] 15 | } -------------------------------------------------------------------------------- /packages/zcli-core/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "@zendesk/zcli-core", 3 | "version": "1.0.0-beta.49", 4 | "description": "ZCLI core libraries and services", 5 | "main": "src/index.ts", 6 | "npmRegistry": "https://registry.npmjs.org", 7 | "keywords": [ 8 | "zcli", 9 | "zendesk", 10 | "command", 11 | "cli" 12 | ], 13 | "publishConfig": { 14 | "access": "public" 15 | }, 16 | "directories": { 17 | "dist": "dist" 18 | }, 19 | "devDependencies": { 20 | "@types/fs-extra": "9.0.13" 21 | }, 22 | "optionalDependencies": { 23 | "keytar": "^7.9.0" 24 | }, 25 | "dependencies": { 26 | "@oclif/plugin-plugins": "=2.1.12", 27 | "axios": "^1.7.5", 28 | "chalk": "^4.1.2", 29 | "fs-extra": "^10.1.0" 30 | }, 31 | "scripts": { 32 | "prepack": "tsc && ../../scripts/prepack.sh", 33 | "postpack": "rm -rf ./dist && git checkout ./package.json", 34 | "type:check": "tsc" 35 | }, 36 | "author": "@vegemite", 37 | "license": "ISC" 38 | } 39 | -------------------------------------------------------------------------------- /packages/zcli-core/src/index.ts: -------------------------------------------------------------------------------- 1 | export { default as Auth } from './lib/auth' 2 | export { getAccount, getProfileFromAccount } from './lib/authUtils' 3 | export { default as Config } from './lib/config' 4 | export { default as SecureStore } from './lib/secureStore' 5 | export { getBaseUrl } from './lib/requestUtils' 6 | export * as request from './lib/request' 7 | export * as env from './lib/env' 8 | -------------------------------------------------------------------------------- /packages/zcli-core/src/lib/auth.ts: -------------------------------------------------------------------------------- 1 | import { CLIError } from '@oclif/core/lib/errors' 2 | import * as chalk from 'chalk' 3 | import { CliUx } from '@oclif/core' 4 | import Config from './config' 5 | import axios from 'axios' 6 | import SecureStore from './secureStore' 7 | import { Profile } from '../types' 8 | import { getAccount, parseSubdomain } from './authUtils' 9 | import { getBaseUrl } from './requestUtils' 10 | import { SecretType } from './secretType' 11 | 12 | export interface AuthOptions { 13 | secureStore: SecureStore; 14 | } 15 | export default class Auth { 16 | secureStore?: SecureStore 17 | config: Config 18 | 19 | constructor (options?: AuthOptions) { 20 | this.secureStore = options?.secureStore 21 | this.config = new Config() 22 | } 23 | 24 | // 1. If env vars are set, prepare token using them 25 | // 2. If no env vars, check if current profile is set 26 | async getAuthorizationToken () { 27 | const { ZENDESK_EMAIL, ZENDESK_PASSWORD, ZENDESK_API_TOKEN, ZENDESK_OAUTH_TOKEN } = process.env 28 | 29 | if (ZENDESK_OAUTH_TOKEN) { 30 | return `Bearer ${ZENDESK_OAUTH_TOKEN}` 31 | } else if (ZENDESK_EMAIL && ZENDESK_API_TOKEN) { 32 | return this.createBasicAuthToken(`${ZENDESK_EMAIL}`, ZENDESK_API_TOKEN) 33 | } else if (ZENDESK_EMAIL && ZENDESK_PASSWORD) { 34 | return this.createBasicAuthToken(ZENDESK_EMAIL, ZENDESK_PASSWORD, SecretType.PASSWORD) 35 | } else { 36 | const profile = await this.getLoggedInProfile() 37 | if (profile && this.secureStore) { 38 | const authToken = await this.secureStore.getSecret(getAccount(profile.subdomain, profile.domain)) 39 | return authToken 40 | } 41 | 42 | return undefined 43 | } 44 | } 45 | 46 | createBasicAuthToken (user: string, secret: string, secretType: SecretType = SecretType.TOKEN) { 47 | const basicBase64 = (str: string) => `Basic ${Buffer.from(str).toString('base64')}` 48 | if (secretType === SecretType.TOKEN) { 49 | return basicBase64(`${user}/token:${secret}`) 50 | } 51 | throw new CLIError(chalk.red(`Basic authentication of type '${secretType}' is not supported.`)) 52 | } 53 | 54 | getLoggedInProfile () { 55 | return this.config.getConfig('activeProfile') as unknown as Profile 56 | } 57 | 58 | setLoggedInProfile (subdomain: string, domain?: string) { 59 | return this.config.setConfig('activeProfile', { subdomain, domain }) 60 | } 61 | 62 | async loginInteractively (options?: Profile) { 63 | const subdomain = parseSubdomain(options?.subdomain || await CliUx.ux.prompt('Subdomain')) 64 | const domain = options?.domain 65 | const account = getAccount(subdomain, domain) 66 | const baseUrl = getBaseUrl(subdomain, domain) 67 | const email = await CliUx.ux.prompt('Email') 68 | const token = await CliUx.ux.prompt('API Token', { type: 'hide' }) 69 | const authToken = this.createBasicAuthToken(email, token) 70 | const testAuth = await axios.get( 71 | `${baseUrl}/api/v2/account/settings.json`, 72 | { 73 | headers: { Authorization: authToken }, 74 | validateStatus: function (status) { return status < 500 }, 75 | adapter: 'fetch' 76 | }) 77 | 78 | if (testAuth.status === 200 && this.secureStore) { 79 | await this.secureStore.setSecret(account, authToken) 80 | await this.setLoggedInProfile(subdomain, domain) 81 | 82 | return true 83 | } 84 | 85 | return false 86 | } 87 | 88 | async logout () { 89 | if (!this.secureStore) { 90 | throw new CLIError(chalk.red('Secure credentials store not found.')) 91 | } 92 | 93 | const profile = await this.getLoggedInProfile() 94 | if (!profile?.subdomain) throw new CLIError(chalk.red('Failed to log out: no active profile found.')) 95 | await this.config.removeConfig('activeProfile') 96 | const deleted = await this.secureStore.deleteSecret(getAccount(profile.subdomain, profile.domain)) 97 | if (!deleted) throw new CLIError(chalk.red('Failed to log out: Account, Service not found.')) 98 | 99 | return true 100 | } 101 | 102 | async getSavedProfiles () { 103 | return this.secureStore && this.secureStore.getAllCredentials() 104 | } 105 | } 106 | -------------------------------------------------------------------------------- /packages/zcli-core/src/lib/authUtils.test.ts: -------------------------------------------------------------------------------- 1 | import { expect, test } from '@oclif/test' 2 | import { getAccount, getProfileFromAccount, parseSubdomain } from './authUtils' 3 | 4 | describe('authUtils', () => { 5 | describe('parseSubdomain', () => { 6 | test 7 | .it('should extract the subdomain from a url', async () => { 8 | expect(parseSubdomain('https://Test1.zeNDEsk.com/')).to.equal('test1') 9 | expect(parseSubdomain(' hTTp://tEst2.zendesk.com/ ')).to.equal('test2') 10 | expect(parseSubdomain('test3.zendesk.com ')).to.equal('test3') 11 | expect(parseSubdomain('test4')).to.equal('test4') 12 | }) 13 | }) 14 | 15 | describe('getAccount', () => { 16 | test.it('should get the account from subdomain and eventually domain', () => { 17 | expect(getAccount('test')).to.equal('test') 18 | expect(getAccount('test', 'example.com')).to.equal('test.example.com') 19 | }) 20 | }) 21 | 22 | describe('getProfileFromAccount', () => { 23 | test.it('should get the profile from account', () => { 24 | expect(getProfileFromAccount('test')).to.deep.equal({ subdomain: 'test' }) 25 | expect(getProfileFromAccount('test.example.com')).to.deep.equal({ subdomain: 'test', domain: 'example.com' }) 26 | expect(getProfileFromAccount('test.subdomain.example.com')).to.deep.equal({ subdomain: 'test', domain: 'subdomain.example.com' }) 27 | }) 28 | }) 29 | }) 30 | -------------------------------------------------------------------------------- /packages/zcli-core/src/lib/authUtils.ts: -------------------------------------------------------------------------------- 1 | import { Profile } from '../types' 2 | 3 | /** 4 | * Parse a subdomain. 5 | * 6 | * If someone mistakenly provides full hostname or Url instead of a subdomain 7 | * then strip out domain name from it. 8 | * 9 | * @param {string} subdomain - The subdomain. 10 | * @return {string} The parsed subdomain. 11 | */ 12 | export const parseSubdomain = (subdomain: string) => { 13 | subdomain = subdomain.trim().toLowerCase() 14 | const regex = /(?:http[s]*:\/\/)*(.*?)\.zendesk.com[/]?$/i 15 | const result = regex.exec(subdomain) 16 | return result !== null ? result[1] : subdomain 17 | } 18 | 19 | export const getAccount = (subdomain: string, domain?: string): string => { 20 | return domain ? `${subdomain}.${domain}` : subdomain 21 | } 22 | 23 | export const getProfileFromAccount = (account: string): Profile => { 24 | const firstDotIndex = account.indexOf('.') 25 | if (firstDotIndex === -1) { 26 | return { subdomain: account } 27 | } 28 | return { subdomain: account.substring(0, firstDotIndex), domain: account.substring(firstDotIndex + 1) } 29 | } 30 | -------------------------------------------------------------------------------- /packages/zcli-core/src/lib/config.test.ts: -------------------------------------------------------------------------------- 1 | import { expect, test } from '@oclif/test' 2 | import * as fs from 'fs-extra' 3 | import * as sinon from 'sinon' 4 | import Config from './config' 5 | 6 | describe('Config', () => { 7 | describe('ensureConfigFile', () => { 8 | const outputJsonStub = sinon.stub(fs, 'outputJson').resolves() 9 | const config = new Config() 10 | 11 | beforeEach(() => outputJsonStub.reset()) 12 | 13 | test 14 | .stub(fs, 'pathExists', () => Promise.resolve(true)) 15 | .stub(fs, 'outputJson', () => outputJsonStub) 16 | .it('should not create file, if file exists', async () => { 17 | await config.ensureConfigFile() 18 | expect(outputJsonStub.called).to.equal(false) 19 | }) 20 | 21 | test 22 | .stub(fs, 'pathExists', () => Promise.resolve(false)) 23 | .it('should create file, if file does not exists', async () => { 24 | await config.ensureConfigFile() 25 | expect(outputJsonStub.called).to.equal(true) 26 | }) 27 | }) 28 | 29 | describe('getConfig', () => { 30 | const config = new Config() 31 | const mockConfig = { foo: 'bar' } 32 | 33 | test 34 | .stub(config, 'ensureConfigFile', () => Promise.resolve()) 35 | .stub(fs, 'readJson', () => Promise.resolve(mockConfig)) 36 | .it('should read file and return config key', async () => { 37 | expect(await config.getConfig('foo')).to.equal('bar') 38 | }) 39 | 40 | test 41 | .stub(config, 'ensureConfigFile', () => Promise.resolve()) 42 | .stub(fs, 'readJson', () => Promise.resolve()) 43 | .it('should read file and return undefined if key not found', async () => { 44 | expect(await config.getConfig('zoo')).to.equal(undefined) 45 | }) 46 | }) 47 | 48 | describe('setConfig', () => { 49 | const config = new Config() 50 | let mockConfig: object = { foo: 'bar' } 51 | 52 | test 53 | .stub(config, 'ensureConfigFile', () => Promise.resolve()) 54 | .stub(fs, 'readJson', () => Promise.resolve(mockConfig)) 55 | .stub(fs, 'outputJson', (...args) => { 56 | mockConfig = (args as object[])[1] 57 | }) 58 | .it('should update config with new key value', async () => { 59 | await config.setConfig('zoo', 'baz') 60 | expect(mockConfig).to.deep.equal({ foo: 'bar', zoo: 'baz' }) 61 | }) 62 | }) 63 | 64 | describe('removeConfig', () => { 65 | const config = new Config() 66 | let mockConfig: object = { foo: 'bar', zoo: 'baz' } 67 | 68 | test 69 | .stub(config, 'ensureConfigFile', () => Promise.resolve()) 70 | .stub(fs, 'readJson', () => Promise.resolve(mockConfig)) 71 | .stub(fs, 'outputJson', (...args) => { 72 | mockConfig = (args as object[])[1] 73 | }) 74 | .it('should remove key value from config', async () => { 75 | await config.removeConfig('foo') 76 | expect(mockConfig).to.deep.equal({ zoo: 'baz' }) 77 | }) 78 | }) 79 | }) 80 | -------------------------------------------------------------------------------- /packages/zcli-core/src/lib/config.ts: -------------------------------------------------------------------------------- 1 | import * as os from 'os' 2 | import * as fs from 'fs-extra' 3 | import * as path from 'path' 4 | 5 | const HOME_DIR = os.homedir() 6 | export const CONFIG_PATH = path.join(HOME_DIR, '.zcli') 7 | 8 | export default class Config { 9 | async ensureConfigFile () { 10 | if (!await fs.pathExists(CONFIG_PATH)) { 11 | await fs.outputJson(CONFIG_PATH, {}) 12 | } 13 | } 14 | 15 | async getConfig (key: string) { 16 | await this.ensureConfigFile() 17 | const config = await fs.readJson(CONFIG_PATH) || {} 18 | return config[key] 19 | } 20 | 21 | async setConfig (key: string, value: string | object) { 22 | await this.ensureConfigFile() 23 | const config = await fs.readJson(CONFIG_PATH) || {} 24 | config[key] = value 25 | await fs.outputJson(CONFIG_PATH, config) 26 | } 27 | 28 | async removeConfig (key: string) { 29 | await this.ensureConfigFile() 30 | const config = await fs.readJson(CONFIG_PATH) || {} 31 | delete config[key] 32 | await fs.outputJson(CONFIG_PATH, config) 33 | } 34 | } 35 | -------------------------------------------------------------------------------- /packages/zcli-core/src/lib/env.ts: -------------------------------------------------------------------------------- 1 | export const EnvVars = { 2 | SUBDOMAIN: 'ZENDESK_SUBDOMAIN', 3 | DOMAIN: 'ZENDESK_DOMAIN', 4 | EMAIL: 'ZENDESK_EMAIL', 5 | PASSWORD: 'ZENDESK_PASSWORD', 6 | API_TOKEN: 'ZENDESK_API_TOKEN', 7 | OAUTH_TOKEN: 'ZENDESK_OAUTH_TOKEN', 8 | APP_ID: 'ZENDESK_APP_ID' 9 | } 10 | 11 | export const varExists = (...args: any[]) => !args.filter(envVar => !process.env[envVar]).length 12 | -------------------------------------------------------------------------------- /packages/zcli-core/src/lib/request.ts: -------------------------------------------------------------------------------- 1 | import axios from 'axios' 2 | import SecureStore from './secureStore' 3 | import Auth from './auth' 4 | import { CLIError } from '@oclif/core/lib/errors' 5 | import * as chalk from 'chalk' 6 | import { EnvVars, varExists } from './env' 7 | import { getBaseUrl, getDomain, getSubdomain } from './requestUtils' 8 | 9 | const MSG_ENV_OR_LOGIN = 'Set the following environment variables: ZENDESK_SUBDOMAIN, ZENDESK_EMAIL, ZENDESK_API_TOKEN. Or try logging in via `zcli login -i`' 10 | const ERR_AUTH_FAILED = `Authorization failed. ${MSG_ENV_OR_LOGIN}` 11 | const ERR_ENV_SUBDOMAIN_NOT_FOUND = `No subdomain found. ${MSG_ENV_OR_LOGIN}` 12 | 13 | // eslint-disable-next-line @typescript-eslint/no-explicit-any 14 | export const createRequestConfig = async (url: string, options: any = {}) => { 15 | let auth 16 | if ( 17 | varExists(EnvVars.SUBDOMAIN, EnvVars.OAUTH_TOKEN) || 18 | varExists(EnvVars.SUBDOMAIN, EnvVars.EMAIL, EnvVars.API_TOKEN) 19 | ) { 20 | auth = new Auth() 21 | } else { 22 | const secureStore = new SecureStore() 23 | await secureStore.loadKeytar() 24 | auth = new Auth({ secureStore }) 25 | } 26 | const [authToken, profileSubdomain, profileDomain] = 27 | await Promise.all([auth.getAuthorizationToken(), getSubdomain(auth), getDomain(auth)]) 28 | if (!authToken) throw new CLIError(chalk.red(ERR_AUTH_FAILED)) 29 | const subdomain = process.env[EnvVars.SUBDOMAIN] || profileSubdomain 30 | if (!subdomain) throw new CLIError(chalk.red(ERR_ENV_SUBDOMAIN_NOT_FOUND)) 31 | const domain = process.env[EnvVars.SUBDOMAIN] ? process.env[EnvVars.DOMAIN] : profileDomain 32 | 33 | if (options.headers) { 34 | options.headers = { Authorization: authToken, ...options.headers } 35 | } else { 36 | options.headers = { Authorization: authToken } 37 | } 38 | const baseURL = getBaseUrl(subdomain, domain) 39 | 40 | return { 41 | baseURL, 42 | url, 43 | validateStatus: function (status: number) { 44 | return status < 500 45 | }, 46 | ...options 47 | } 48 | } 49 | 50 | // eslint-disable-next-line @typescript-eslint/no-explicit-any 51 | export const requestAPI = async (url: string, options: any = {}, json = false) => { 52 | const requestConfig = await createRequestConfig(url, options) 53 | return axios.request({ 54 | ...requestConfig, 55 | adapter: 'fetch' 56 | }) 57 | } 58 | -------------------------------------------------------------------------------- /packages/zcli-core/src/lib/requestUtils.test.ts: -------------------------------------------------------------------------------- 1 | import { expect, test } from '@oclif/test' 2 | import { getBaseUrl } from './requestUtils' 3 | 4 | describe('requestUtils', () => { 5 | describe('getBaseUrl', () => { 6 | test 7 | .it('should get baseUrl from subdomain and domain', async () => { 8 | expect(getBaseUrl('test')).to.equal('https://test.zendesk.com') 9 | expect(getBaseUrl('test', undefined)).to.equal('https://test.zendesk.com') 10 | expect(getBaseUrl('test', '')).to.equal('https://test.zendesk.com') 11 | expect(getBaseUrl('test', 'example.com')).to.equal('https://test.example.com') 12 | expect(getBaseUrl('test', 'subdomain.example.com')).to.equal('https://test.subdomain.example.com') 13 | }) 14 | }) 15 | }) 16 | -------------------------------------------------------------------------------- /packages/zcli-core/src/lib/requestUtils.ts: -------------------------------------------------------------------------------- 1 | import Auth from './auth' 2 | 3 | export const getSubdomain = async (auth: Auth): Promise => { 4 | return (await auth.getLoggedInProfile())?.subdomain 5 | } 6 | 7 | export const getDomain = async (auth: Auth): Promise => { 8 | return (await auth.getLoggedInProfile())?.domain 9 | } 10 | 11 | export const getBaseUrl = (subdomain: string, domain?: string): string => { 12 | return `https://${subdomain}.${domain || 'zendesk.com'}` 13 | } 14 | -------------------------------------------------------------------------------- /packages/zcli-core/src/lib/secretType.ts: -------------------------------------------------------------------------------- 1 | export enum SecretType { 2 | // eslint-disable-next-line no-unused-vars 3 | TOKEN = 'token', PASSWORD = 'password' 4 | } 5 | -------------------------------------------------------------------------------- /packages/zcli-core/src/lib/secureStore.ts: -------------------------------------------------------------------------------- 1 | import Plugins from '@oclif/plugin-plugins/lib/plugins' 2 | import * as path from 'path' 3 | import { homedir } from 'os' 4 | import { KeyTar } from '../types' 5 | 6 | // eslint-disable-next-line @typescript-eslint/no-var-requires 7 | const packageJson = require('../../package.json') 8 | 9 | export default class SecureStore { 10 | serviceName = 'zcli' 11 | pluginsPath = path.join(homedir(), '/.local/share/zcli') 12 | packageName = 'keytar' 13 | keytarPath = path.join(this.pluginsPath, 'node_modules', this.packageName) 14 | keytar: KeyTar | undefined = undefined 15 | 16 | private async installKeytar () { 17 | const packageTag = `${this.packageName}@${packageJson.optionalDependencies.keytar}` 18 | // eslint-disable-next-line @typescript-eslint/no-explicit-any 19 | const plugins = new (Plugins as any)({ dataDir: this.pluginsPath, cacheDir: this.pluginsPath }) 20 | 21 | try { 22 | await plugins.createPJSON() 23 | await plugins.yarn.exec(['add', '--force', packageTag], { cwd: this.pluginsPath, verbose: false }) 24 | } catch (error) { 25 | // TODO: add telemetry so we know when this fails 26 | } 27 | } 28 | 29 | async loadKeytar () { 30 | try { 31 | // eslint-disable-next-line @typescript-eslint/no-var-requires 32 | this.keytar = require(this.keytarPath) as KeyTar 33 | } catch (error) { 34 | await this.installKeytar() 35 | 36 | try { 37 | // eslint-disable-next-line @typescript-eslint/no-var-requires 38 | this.keytar = require(this.keytarPath) as KeyTar 39 | } catch (error) { 40 | // TODO: add telemetry so we know when this fails 41 | } 42 | } 43 | 44 | return this.keytar 45 | } 46 | 47 | setSecret (account: string, secret: string) { 48 | return this.keytar?.setPassword(this.serviceName, account, secret) 49 | } 50 | 51 | getSecret (account: string) { 52 | return this.keytar?.getPassword(this.serviceName, account) 53 | } 54 | 55 | deleteSecret (account: string) { 56 | return this.keytar?.deletePassword(this.serviceName, account) 57 | } 58 | 59 | getAllCredentials () { 60 | return this.keytar?.findCredentials(this.serviceName) 61 | } 62 | } 63 | -------------------------------------------------------------------------------- /packages/zcli-core/src/types.ts: -------------------------------------------------------------------------------- 1 | // Profiles and Cred Store definitions 2 | export type Credential = { account: string; password: string } 3 | 4 | export interface KeyTar { 5 | getPassword: (service: string, account: string) => Promise; 6 | setPassword: (service: string, account: string, password: string) => Promise; 7 | deletePassword: (service: string, account: string) => Promise; 8 | findPassword: (service: string) => Promise; 9 | findCredentials: (service: string) => Promise>; 10 | } 11 | 12 | export interface Profile { subdomain: string, domain?: string } 13 | 14 | // End profiles and Cred Store definitions 15 | -------------------------------------------------------------------------------- /packages/zcli-core/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "declaration": true, 4 | "importHelpers": true, 5 | "module": "commonjs", 6 | "outDir": "dist", 7 | "rootDir": "src", 8 | "strict": true, 9 | "target": "es2017" 10 | }, 11 | "include": [ 12 | "src/**/*" 13 | ] 14 | } 15 | -------------------------------------------------------------------------------- /packages/zcli-themes/bin/run: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env node 2 | 3 | const oclif = require('@oclif/core') 4 | 5 | oclif.run().catch(require('@oclif/core/handle')) 6 | -------------------------------------------------------------------------------- /packages/zcli-themes/bin/run.cmd: -------------------------------------------------------------------------------- 1 | @echo off 2 | 3 | node "%~dp0\run" %* 4 | -------------------------------------------------------------------------------- /packages/zcli-themes/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "@zendesk/zcli-themes", 3 | "description": "zcli theme commands live here", 4 | "version": "1.0.0-beta.52", 5 | "author": "@zendesk/vikings", 6 | "npmRegistry": "https://registry.npmjs.org", 7 | "publishConfig": { 8 | "access": "public" 9 | }, 10 | "bin": { 11 | "zcli-themes": "./bin/run" 12 | }, 13 | "scripts": { 14 | "build": "tsc", 15 | "prepack": "tsc && ../../scripts/prepack.sh", 16 | "postpack": "rm -f oclif.manifest.json npm-shrinkwrap.json && rm -rf ./dist && git checkout ./package.json", 17 | "type:check": "tsc" 18 | }, 19 | "dependencies": { 20 | "@types/inquirer": "^8.0.0", 21 | "@types/ws": "^8.5.4", 22 | "axios": "^1.7.5", 23 | "chalk": "^4.1.2", 24 | "chokidar": "^3.5.3", 25 | "cors": "^2.8.5", 26 | "express": "^4.21.2", 27 | "glob": "^10.1.0", 28 | "inquirer": "^8.0.0", 29 | "sass": "1.60.0", 30 | "ws": "^8.13.0" 31 | }, 32 | "devDependencies": { 33 | "@oclif/test": "=2.1.0", 34 | "@types/chai": "^4", 35 | "@types/cors": "^2.8.6", 36 | "@types/mocha": "^9.1.1", 37 | "@types/sinon": "^10.0.13", 38 | "chai": "^4", 39 | "eslint": "^8.18.0", 40 | "eslint-config-oclif": "^4.0.0", 41 | "eslint-config-oclif-typescript": "^1.0.2", 42 | "lerna": "^5.6.2", 43 | "mocha": "^10.8.2", 44 | "nock": "^13.2.8", 45 | "sinon": "^14.0.0" 46 | }, 47 | "files": [ 48 | "/bin", 49 | "/dist", 50 | "/oclif.manifest.json", 51 | "/npm-shrinkwrap.json" 52 | ], 53 | "keywords": [ 54 | "zcli", 55 | "zendesk", 56 | "cli", 57 | "command" 58 | ], 59 | "license": "MIT", 60 | "main": "src/index.js", 61 | "oclif": { 62 | "commands": "./src/commands", 63 | "bin": "zcli-themes" 64 | }, 65 | "types": "lib/index.d.ts" 66 | } 67 | -------------------------------------------------------------------------------- /packages/zcli-themes/src/commands/themes/delete.ts: -------------------------------------------------------------------------------- 1 | import { Command, Flags, CliUx } from '@oclif/core' 2 | import { request } from '@zendesk/zcli-core' 3 | import * as chalk from 'chalk' 4 | import type { AxiosError } from 'axios' 5 | import handleThemeApiError from '../../lib/handleThemeApiError' 6 | 7 | export default class Delete extends Command { 8 | static description = 'delete a theme' 9 | 10 | static enableJsonFlag = true 11 | 12 | static flags = { 13 | themeId: Flags.string({ description: 'The id of the theme to delete' }) 14 | } 15 | 16 | static examples = [ 17 | '$ zcli themes:delete --themeId=abcd' 18 | ] 19 | 20 | static strict = false 21 | 22 | async run () { 23 | let { flags: { themeId } } = await this.parse(Delete) 24 | 25 | themeId = themeId || await CliUx.ux.prompt('Theme ID') 26 | 27 | try { 28 | CliUx.ux.action.start('Deleting theme') 29 | await request.requestAPI(`/api/v2/guide/theming/themes/${themeId}`, { 30 | method: 'delete', 31 | headers: { 32 | 'X-Zendesk-Request-Originator': 'zcli themes:delete' 33 | }, 34 | validateStatus: (status: number) => status === 204 35 | }) 36 | CliUx.ux.action.stop('Ok') 37 | this.log(chalk.green('Theme deleted successfully'), `theme ID: ${themeId}`) 38 | return { themeId } 39 | } catch (error) { 40 | handleThemeApiError(error as AxiosError) 41 | } 42 | } 43 | } 44 | -------------------------------------------------------------------------------- /packages/zcli-themes/src/commands/themes/import.ts: -------------------------------------------------------------------------------- 1 | import { Command, Flags } from '@oclif/core' 2 | import * as path from 'path' 3 | import * as chalk from 'chalk' 4 | import createThemeImportJob from '../../lib/createThemeImportJob' 5 | import getBrandId from '../../lib/getBrandId' 6 | import createThemePackage from '../../lib/createThemePackage' 7 | import uploadThemePackage from '../../lib/uploadThemePackage' 8 | import pollJobStatus from '../../lib/pollJobStatus' 9 | 10 | export default class Import extends Command { 11 | static description = 'import a theme' 12 | 13 | static enableJsonFlag = true 14 | 15 | static flags = { 16 | brandId: Flags.string({ description: 'The id of the brand where the theme should be imported to' }) 17 | } 18 | 19 | static args = [ 20 | { name: 'themeDirectory', required: true, default: '.' } 21 | ] 22 | 23 | static examples = [ 24 | '$ zcli themes:import ./copenhagen_theme', 25 | '$ zcli themes:import ./copenhagen_theme --brandId=123456' 26 | ] 27 | 28 | static strict = false 29 | 30 | async run () { 31 | let { flags: { brandId }, argv: [themeDirectory] } = await this.parse(Import) 32 | const themePath = path.resolve(themeDirectory) 33 | 34 | brandId = brandId || await getBrandId() 35 | 36 | const job = await createThemeImportJob(brandId) 37 | const { file, removePackage } = await createThemePackage(themePath) 38 | try { 39 | await uploadThemePackage(job, file, path.basename(themePath)) 40 | } finally { 41 | removePackage() 42 | } 43 | 44 | await pollJobStatus(themePath, job.id) 45 | 46 | const themeId = job.data.theme_id 47 | 48 | this.log(chalk.green('Theme imported successfully'), `theme ID: ${themeId}`) 49 | 50 | return { themeId } 51 | } 52 | } 53 | -------------------------------------------------------------------------------- /packages/zcli-themes/src/commands/themes/list.ts: -------------------------------------------------------------------------------- 1 | import { Command, Flags, CliUx } from '@oclif/core' 2 | import { request } from '@zendesk/zcli-core' 3 | import * as chalk from 'chalk' 4 | import getBrandId from '../../lib/getBrandId' 5 | import type { AxiosError } from 'axios' 6 | import handleThemeApiError from '../../lib/handleThemeApiError' 7 | 8 | export default class List extends Command { 9 | static description = 'list installed themes' 10 | 11 | static enableJsonFlag = true 12 | 13 | static flags = { 14 | brandId: Flags.string({ description: 'The id of the brand where the themes are installed' }) 15 | } 16 | 17 | static examples = [ 18 | '$ zcli themes:list --brandId=123456' 19 | ] 20 | 21 | static strict = false 22 | 23 | async run () { 24 | let { flags: { brandId } } = await this.parse(List) 25 | 26 | brandId = brandId || await getBrandId() 27 | 28 | try { 29 | CliUx.ux.action.start('Listing themes') 30 | const { data: { themes } } = await request.requestAPI(`/api/v2/guide/theming/themes?brand_id=${brandId}`, { 31 | headers: { 32 | 'X-Zendesk-Request-Originator': 'zcli themes:list' 33 | }, 34 | validateStatus: (status: number) => status === 200 35 | }) 36 | CliUx.ux.action.stop('Ok') 37 | this.log(chalk.green('Themes listed successfully'), themes) 38 | return { themes } 39 | } catch (error) { 40 | handleThemeApiError(error as AxiosError) 41 | } 42 | } 43 | } 44 | -------------------------------------------------------------------------------- /packages/zcli-themes/src/commands/themes/publish.ts: -------------------------------------------------------------------------------- 1 | import { Command, Flags, CliUx } from '@oclif/core' 2 | import { request } from '@zendesk/zcli-core' 3 | import * as chalk from 'chalk' 4 | import type { AxiosError } from 'axios' 5 | import handleThemeApiError from '../../lib/handleThemeApiError' 6 | 7 | export default class Publish extends Command { 8 | static description = 'publish a theme' 9 | 10 | static enableJsonFlag = true 11 | 12 | static flags = { 13 | themeId: Flags.string({ description: 'The id of the theme to publish' }) 14 | } 15 | 16 | static examples = [ 17 | '$ zcli themes:publish --themeId=abcd' 18 | ] 19 | 20 | static strict = false 21 | 22 | async run () { 23 | let { flags: { themeId } } = await this.parse(Publish) 24 | 25 | themeId = themeId || await CliUx.ux.prompt('Theme ID') 26 | 27 | try { 28 | CliUx.ux.action.start('Publishing theme') 29 | await request.requestAPI(`/api/v2/guide/theming/themes/${themeId}/publish`, { 30 | method: 'post', 31 | headers: { 32 | 'X-Zendesk-Request-Originator': 'zcli themes:publish' 33 | }, 34 | validateStatus: (status: number) => status === 200 35 | }) 36 | CliUx.ux.action.stop('Ok') 37 | this.log(chalk.green('Theme published successfully'), `theme ID: ${themeId}`) 38 | return { themeId } 39 | } catch (error) { 40 | handleThemeApiError(error as AxiosError) 41 | } 42 | } 43 | } 44 | -------------------------------------------------------------------------------- /packages/zcli-themes/src/commands/themes/update.ts: -------------------------------------------------------------------------------- 1 | import { Command, Flags, CliUx } from '@oclif/core' 2 | import * as path from 'path' 3 | import * as chalk from 'chalk' 4 | import createThemeUpdateJob from '../../lib/createThemeUpdateJob' 5 | import createThemePackage from '../../lib/createThemePackage' 6 | import uploadThemePackage from '../../lib/uploadThemePackage' 7 | import pollJobStatus from '../../lib/pollJobStatus' 8 | 9 | export default class Update extends Command { 10 | static description = 'update a theme' 11 | 12 | static enableJsonFlag = true 13 | 14 | static flags = { 15 | themeId: Flags.string({ description: 'The id of the theme to update' }), 16 | replaceSettings: Flags.boolean({ default: false, description: 'Whether or not to replace the current theme settings' }) 17 | } 18 | 19 | static args = [ 20 | { name: 'themeDirectory', required: true, default: '.' } 21 | ] 22 | 23 | static examples = [ 24 | '$ zcli themes:update ./copenhagen_theme --themeId=123456789100', 25 | '$ zcli themes:update ./copenhagen_theme --themeId=123456789100 --replaceSettings' 26 | ] 27 | 28 | static strict = false 29 | 30 | async run () { 31 | let { flags: { themeId, replaceSettings }, argv: [themeDirectory] } = await this.parse(Update) 32 | const themePath = path.resolve(themeDirectory) 33 | 34 | themeId = themeId || await CliUx.ux.prompt('Theme ID') 35 | 36 | const job = await createThemeUpdateJob(themeId, replaceSettings) 37 | const { file, removePackage } = await createThemePackage(themePath) 38 | 39 | try { 40 | await uploadThemePackage(job, file, path.basename(themePath)) 41 | } finally { 42 | removePackage() 43 | } 44 | 45 | await pollJobStatus(themePath, job.id) 46 | 47 | this.log(chalk.green('Theme updated successfully')) 48 | 49 | return { themeId } 50 | } 51 | } 52 | -------------------------------------------------------------------------------- /packages/zcli-themes/src/index.js: -------------------------------------------------------------------------------- 1 | export default {} 2 | -------------------------------------------------------------------------------- /packages/zcli-themes/src/lib/createThemeImportJob.test.ts: -------------------------------------------------------------------------------- 1 | import * as sinon from 'sinon' 2 | import { expect } from '@oclif/test' 3 | import * as axios from 'axios' 4 | import { request } from '@zendesk/zcli-core' 5 | import createThemeImportJob from './createThemeImportJob' 6 | import * as chalk from 'chalk' 7 | import * as errors from '@oclif/core/lib/errors' 8 | 9 | describe('createThemeImportJob', () => { 10 | beforeEach(() => { 11 | sinon.restore() 12 | }) 13 | 14 | it('calls the jobs/themes/imports endpoint with the correct payload and returns the job', async () => { 15 | const requestStub = sinon.stub(request, 'requestAPI') 16 | const job = { 17 | id: '9999', 18 | status: 'pending', 19 | data: {} 20 | } 21 | 22 | requestStub.returns(Promise.resolve({ data: { job } }) as axios.AxiosPromise) 23 | 24 | expect(await createThemeImportJob('1234')).to.equal(job) 25 | 26 | expect(requestStub.calledWith('/api/v2/guide/theming/jobs/themes/imports', sinon.match({ 27 | method: 'POST', 28 | data: { 29 | job: { 30 | attributes: { 31 | brand_id: '1234', 32 | format: 'zip' 33 | } 34 | } 35 | } 36 | }))).to.equal(true) 37 | }) 38 | 39 | it('errors when creation fails', async () => { 40 | const errorStub = sinon.stub(errors, 'error').callThrough() 41 | 42 | sinon.stub(request, 'requestAPI').throws({ 43 | response: { 44 | data: { 45 | errors: [{ 46 | code: 'TooManyThemes', 47 | title: 'Maximum number of allowed themes reached' 48 | }] 49 | } 50 | } 51 | }) 52 | 53 | try { 54 | await createThemeImportJob('1234') 55 | } catch { 56 | expect(errorStub.calledWith(`${chalk.bold('TooManyThemes')} - Maximum number of allowed themes reached`)).to.equal(true) 57 | } 58 | }) 59 | }) 60 | -------------------------------------------------------------------------------- /packages/zcli-themes/src/lib/createThemeImportJob.ts: -------------------------------------------------------------------------------- 1 | import type { PendingJob } from '../types' 2 | import { CliUx } from '@oclif/core' 3 | import { request } from '@zendesk/zcli-core' 4 | import type { AxiosError } from 'axios' 5 | import handleThemeApiError from './handleThemeApiError' 6 | 7 | export default async function createThemeImportJob (brandId: string): Promise { 8 | CliUx.ux.action.start('Creating theme import job') 9 | 10 | try { 11 | const { data: { job } } = await request.requestAPI('/api/v2/guide/theming/jobs/themes/imports', { 12 | method: 'POST', 13 | headers: { 14 | 'X-Zendesk-Request-Originator': 'zcli themes:import' 15 | }, 16 | data: { 17 | job: { 18 | attributes: { 19 | brand_id: brandId, 20 | format: 'zip' 21 | } 22 | } 23 | }, 24 | validateStatus: (status: number) => status === 202 25 | }) 26 | CliUx.ux.action.stop('Ok') 27 | return job 28 | } catch (error) { 29 | handleThemeApiError(error as AxiosError) 30 | } 31 | } 32 | -------------------------------------------------------------------------------- /packages/zcli-themes/src/lib/createThemePackage.test.ts: -------------------------------------------------------------------------------- 1 | import * as sinon from 'sinon' 2 | import { expect } from '@oclif/test' 3 | import * as fs from 'fs' 4 | import * as createThemePackage from './createThemePackage' 5 | 6 | describe('createThemePackage', () => { 7 | beforeEach(() => { 8 | sinon.restore() 9 | }) 10 | 11 | it('returns an object containing a readStream and a removePackage method', async () => { 12 | const writeStreamStub = sinon.createStubInstance(fs.WriteStream) 13 | sinon.stub(fs, 'createWriteStream').returns(writeStreamStub) 14 | 15 | const readFileSync = sinon.createStubInstance(Buffer) 16 | sinon.stub(fs, 'readFileSync').returns(readFileSync) 17 | 18 | const unlinkSyncStub = sinon.stub(fs, 'unlinkSync') 19 | 20 | const createZipArchiveStub = sinon.stub(createThemePackage, 'createZipArchive') 21 | 22 | createZipArchiveStub.returns({ 23 | pipe: sinon.stub(), 24 | directory: sinon.stub(), 25 | file: sinon.stub(), 26 | finalize: sinon.stub() 27 | } as any) // eslint-disable-line @typescript-eslint/no-explicit-any 28 | 29 | const { file, removePackage } = await createThemePackage.default('theme/path') 30 | 31 | expect(file).to.instanceOf(Buffer) 32 | 33 | removePackage() 34 | expect(unlinkSyncStub.called).to.equal(true) 35 | }) 36 | }) 37 | -------------------------------------------------------------------------------- /packages/zcli-themes/src/lib/createThemePackage.ts: -------------------------------------------------------------------------------- 1 | import { CliUx } from '@oclif/core' 2 | import * as fs from 'fs' 3 | import * as archiver from 'archiver' 4 | 5 | type CreateThemePackage = { 6 | file: Buffer, 7 | removePackage: () => void 8 | } 9 | 10 | export const createZipArchive = (pkgPath: string, themePath: string, pkgName: string) => { 11 | const archive = archiver('zip') 12 | 13 | return new Promise((resolve, reject) => { 14 | const output = fs.createWriteStream(pkgPath) 15 | 16 | output.on('error', (err) => { 17 | reject(err) 18 | }) 19 | 20 | output.on('close', () => { 21 | resolve(archive) 22 | }) 23 | 24 | archive.directory(`${themePath}/assets`, `${pkgName}/assets`) 25 | archive.directory(`${themePath}/settings`, `${pkgName}/settings`) 26 | archive.directory(`${themePath}/templates`, `${pkgName}/templates`) 27 | archive.directory(`${themePath}/translations`, `${pkgName}/translations`) 28 | archive.file(`${themePath}/manifest.json`, { name: `${pkgName}/manifest.json` }) 29 | archive.file(`${themePath}/script.js`, { name: `${pkgName}/script.js` }) 30 | archive.file(`${themePath}/style.css`, { name: `${pkgName}/style.css` }) 31 | archive.file(`${themePath}/thumbnail.png`, { name: `${pkgName}/thumbnail.png` }) 32 | 33 | archive.pipe(output) 34 | 35 | archive.finalize() 36 | }) 37 | } 38 | 39 | export default async function createThemePackage (themePath: string): Promise { 40 | CliUx.ux.action.start('Creating theme package') 41 | 42 | const dateTimeFileName = new Date().toISOString().replace(/[^0-9]/g, '') 43 | const pkgName = `theme-${dateTimeFileName}` 44 | const pkgPath = `${themePath}/${pkgName}.zip` 45 | 46 | await createZipArchive(pkgPath, themePath, pkgName) 47 | 48 | CliUx.ux.action.stop('Ok') 49 | 50 | return { 51 | file: fs.readFileSync(pkgPath), 52 | removePackage: () => fs.unlinkSync(pkgPath) 53 | } 54 | } 55 | -------------------------------------------------------------------------------- /packages/zcli-themes/src/lib/createThemeUpdateJob.test.ts: -------------------------------------------------------------------------------- 1 | import * as sinon from 'sinon' 2 | import { expect } from '@oclif/test' 3 | import * as axios from 'axios' 4 | import { request } from '@zendesk/zcli-core' 5 | import createThemeUpdateJob from './createThemeUpdateJob' 6 | import * as chalk from 'chalk' 7 | import * as errors from '@oclif/core/lib/errors' 8 | 9 | describe('createThemeUpdateJob', () => { 10 | beforeEach(() => { 11 | sinon.restore() 12 | }) 13 | 14 | it('calls the jobs/themes/updates endpoint with the correct payload and returns the job', async () => { 15 | const requestStub = sinon.stub(request, 'requestAPI') 16 | const job = { 17 | id: '9999', 18 | status: 'pending', 19 | data: {} 20 | } 21 | 22 | requestStub.returns(Promise.resolve({ data: { job } }) as axios.AxiosPromise) 23 | 24 | expect(await createThemeUpdateJob('1234', true)).to.equal(job) 25 | 26 | expect(requestStub.calledWith('/api/v2/guide/theming/jobs/themes/updates', sinon.match({ 27 | method: 'POST', 28 | data: { 29 | job: { 30 | attributes: { 31 | theme_id: '1234', 32 | replace_settings: true, 33 | format: 'zip' 34 | } 35 | } 36 | } 37 | }))).to.equal(true) 38 | }) 39 | 40 | it('errors when creation fails', async () => { 41 | const errorStub = sinon.stub(errors, 'error').callThrough() 42 | 43 | sinon.stub(request, 'requestAPI').throws({ 44 | response: { 45 | data: { 46 | errors: [{ 47 | code: 'TooManyThemes', 48 | title: 'Maximum number of allowed themes reached' 49 | }] 50 | } 51 | } 52 | }) 53 | 54 | try { 55 | await createThemeUpdateJob('1234', false) 56 | } catch { 57 | expect(errorStub.calledWith(`${chalk.bold('TooManyThemes')} - Maximum number of allowed themes reached`)).to.equal(true) 58 | } 59 | }) 60 | }) 61 | -------------------------------------------------------------------------------- /packages/zcli-themes/src/lib/createThemeUpdateJob.ts: -------------------------------------------------------------------------------- 1 | import type { PendingJob } from '../types' 2 | import { CliUx } from '@oclif/core' 3 | import { request } from '@zendesk/zcli-core' 4 | import type { AxiosError } from 'axios' 5 | import handleThemeApiError from './handleThemeApiError' 6 | 7 | export default async function createThemeUpdateJob (themeId: string, replaceSettings: boolean): Promise { 8 | CliUx.ux.action.start('Creating theme update job') 9 | 10 | try { 11 | const { data: { job } } = await request.requestAPI('/api/v2/guide/theming/jobs/themes/updates', { 12 | method: 'POST', 13 | headers: { 14 | 'X-Zendesk-Request-Originator': 'zcli themes:update' 15 | }, 16 | data: { 17 | job: { 18 | attributes: { 19 | theme_id: themeId, 20 | replace_settings: replaceSettings, 21 | format: 'zip' 22 | } 23 | } 24 | }, 25 | validateStatus: (status: number) => status === 202 26 | }) 27 | CliUx.ux.action.stop('Ok') 28 | return job 29 | } catch (error) { 30 | handleThemeApiError(error as AxiosError) 31 | } 32 | } 33 | -------------------------------------------------------------------------------- /packages/zcli-themes/src/lib/getAssets.test.ts: -------------------------------------------------------------------------------- 1 | import * as sinon from 'sinon' 2 | import * as fs from 'fs' 3 | import { expect } from '@oclif/test' 4 | import getAssets from './getAssets' 5 | 6 | const flags = { 7 | bind: 'localhost', 8 | port: 1000, 9 | logs: true, 10 | livereload: true 11 | } 12 | 13 | describe('getAssets', () => { 14 | beforeEach(() => { 15 | sinon.restore() 16 | }) 17 | 18 | it('returns an array of tuples containing the parsed path and url for each asset', () => { 19 | const existsSyncStub = sinon.stub(fs, 'existsSync') 20 | const readdirSyncStub = sinon.stub(fs, 'readdirSync') 21 | 22 | existsSyncStub 23 | .withArgs('theme/path/assets') 24 | .returns(true) 25 | 26 | readdirSyncStub.returns(['.gitkeep', 'foo.png', 'bar.png'] as any) 27 | 28 | const assets = getAssets('theme/path', flags) 29 | 30 | expect(assets).to.deep.equal([ 31 | [ 32 | { base: 'foo.png', dir: '', ext: '.png', name: 'foo', root: '' }, 33 | 'http://localhost:1000/guide/assets/foo.png' 34 | ], 35 | [ 36 | { base: 'bar.png', dir: '', ext: '.png', name: 'bar', root: '' }, 37 | 'http://localhost:1000/guide/assets/bar.png' 38 | ] 39 | ]) 40 | }) 41 | 42 | it('throws an error when an asset has illegal characters in its name', () => { 43 | const existsSyncStub = sinon.stub(fs, 'existsSync') 44 | const readdirSyncStub = sinon.stub(fs, 'readdirSync') 45 | 46 | existsSyncStub 47 | .withArgs('theme/path/assets') 48 | .returns(true) 49 | 50 | readdirSyncStub.returns(['unsuported file name.png'] as any) 51 | 52 | expect(() => { 53 | getAssets('theme/path', flags) 54 | }).to.throw('The asset "unsuported file name.png" has illegal characters in its name. Filenames should only have alpha-numerical characters, ., _, -, and +') 55 | }) 56 | }) 57 | -------------------------------------------------------------------------------- /packages/zcli-themes/src/lib/getAssets.ts: -------------------------------------------------------------------------------- 1 | import type { Flags } from '../types' 2 | import { CLIError } from '@oclif/core/lib/errors' 3 | import * as fs from 'fs' 4 | import * as path from 'path' 5 | import { getLocalServerBaseUrl } from './getLocalServerBaseUrl' 6 | 7 | export default function getAssets (themePath: string, flags: Flags): [path.ParsedPath, string][] { 8 | const assetsPath = `${themePath}/assets` 9 | const filenames = fs.existsSync(assetsPath) ? fs.readdirSync(assetsPath) : [] 10 | const assets: [path.ParsedPath, string][] = [] 11 | 12 | filenames.forEach(filename => { 13 | const parsedPath = path.parse(filename) 14 | const name = parsedPath.name.toLowerCase() 15 | if (name.match(/[^a-z0-9-_+.]/)) { 16 | throw new CLIError( 17 | `The asset "${filename}" has illegal characters in its name. Filenames should only have alpha-numerical characters, ., _, -, and +` 18 | ) 19 | } 20 | if (!name.startsWith('.')) { 21 | assets.push([parsedPath, `${getLocalServerBaseUrl(flags)}/guide/assets/${filename}`]) 22 | } 23 | }) 24 | 25 | return assets 26 | } 27 | -------------------------------------------------------------------------------- /packages/zcli-themes/src/lib/getBrandId.test.ts: -------------------------------------------------------------------------------- 1 | import * as sinon from 'sinon' 2 | import { expect } from '@oclif/test' 3 | import * as axios from 'axios' 4 | import { request } from '@zendesk/zcli-core' 5 | import getBrandId from './getBrandId' 6 | import * as inquirer from 'inquirer' 7 | import * as errors from '@oclif/core/lib/errors' 8 | 9 | describe('getBrandId', () => { 10 | beforeEach(() => { 11 | sinon.restore() 12 | }) 13 | 14 | it('returns the brandId of the first brand when there\' only one brand', async () => { 15 | const requestStub = sinon.stub(request, 'requestAPI') 16 | 17 | requestStub.returns(Promise.resolve({ 18 | data: { 19 | brands: [{ 20 | id: 1234 21 | }] 22 | } 23 | }) as axios.AxiosPromise) 24 | 25 | const brandId = await getBrandId() 26 | 27 | expect(brandId).to.equal('1234') 28 | }) 29 | 30 | it('prompts the user when there are multiple brands', async () => { 31 | const requestStub = sinon.stub(request, 'requestAPI') 32 | const promptStub = sinon.stub(inquirer, 'prompt') 33 | 34 | requestStub.returns(Promise.resolve({ 35 | data: { 36 | brands: [ 37 | { id: 1111, name: 'Brand 1' }, 38 | { id: 2222, name: 'Brand 2' } 39 | ] 40 | } 41 | }) as axios.AxiosPromise) 42 | 43 | promptStub.returns(Promise.resolve({ brandId: '2222' })) 44 | 45 | const brandId = await getBrandId() 46 | 47 | expect(brandId).to.equal('2222') 48 | }) 49 | 50 | it('handles failure when requesting brands', async () => { 51 | const requestStub = sinon.stub(request, 'requestAPI') 52 | const errorStub = sinon.stub(errors, 'error') 53 | 54 | requestStub.throws() 55 | 56 | try { 57 | await getBrandId() 58 | } catch { 59 | expect(errorStub.calledWith('Failed to retrieve brands')).to.equal(true) 60 | } 61 | }) 62 | }) 63 | -------------------------------------------------------------------------------- /packages/zcli-themes/src/lib/getBrandId.ts: -------------------------------------------------------------------------------- 1 | import { error } from '@oclif/core/lib/errors' 2 | import type { Brand } from '../types' 3 | import { request } from '@zendesk/zcli-core' 4 | import * as inquirer from 'inquirer' 5 | 6 | export default async function getBrandId (): Promise { 7 | try { 8 | const { data: { brands } } = await request.requestAPI('/api/v2/brands.json', { 9 | validateStatus: (status: number) => status === 200 10 | }) 11 | 12 | if (brands.length === 1) { 13 | return brands[0].id.toString() 14 | } 15 | 16 | const { brandId } = await inquirer.prompt({ 17 | type: 'list', 18 | name: 'brandId', 19 | message: 'What brand should the theme be imported to?', 20 | choices: brands.map((brand: Brand) => ({ 21 | name: brand.name, 22 | value: brand.id.toString() 23 | })) 24 | }) 25 | 26 | return brandId 27 | } catch { 28 | error('Failed to retrieve brands') 29 | } 30 | } 31 | -------------------------------------------------------------------------------- /packages/zcli-themes/src/lib/getLocalServerBaseUrl.test.ts: -------------------------------------------------------------------------------- 1 | import { expect } from '@oclif/test' 2 | import { Flags } from '../types' 3 | import { getLocalServerBaseUrl } from './getLocalServerBaseUrl' 4 | 5 | describe('getLocalServerBaseUrl', () => { 6 | it('should return correct http url', () => { 7 | const flags: Flags = { 8 | bind: 'localhost', 9 | port: 4567, 10 | logs: false, 11 | livereload: true 12 | } 13 | const result = getLocalServerBaseUrl(flags) 14 | const expected = 'http://localhost:4567' 15 | expect(result).to.equal(expected) 16 | }) 17 | 18 | it('should return correct https url', () => { 19 | const flags: Flags = { 20 | bind: 'themes.local', 21 | port: 4567, 22 | logs: false, 23 | livereload: true, 24 | 'https-cert': 'localhost.crt', 25 | 'https-key': 'localhost.key' 26 | } 27 | const result = getLocalServerBaseUrl(flags) 28 | const expected = 'https://themes.local:4567' 29 | expect(result).to.equal(expected) 30 | }) 31 | 32 | it('should return correct ws url', () => { 33 | const flags: Flags = { 34 | bind: 'localhost', 35 | port: 4567, 36 | logs: false, 37 | livereload: true 38 | } 39 | const result = getLocalServerBaseUrl(flags, true) 40 | const expected = 'ws://localhost:4567' 41 | expect(result).to.equal(expected) 42 | }) 43 | 44 | it('should return correct wss url', () => { 45 | const flags: Flags = { 46 | bind: 'themes.local', 47 | port: 4567, 48 | logs: false, 49 | livereload: true, 50 | 'https-cert': 'localhost.crt', 51 | 'https-key': 'localhost.key' 52 | } 53 | const result = getLocalServerBaseUrl(flags, true) 54 | const expected = 'wss://themes.local:4567' 55 | expect(result).to.equal(expected) 56 | }) 57 | }) 58 | -------------------------------------------------------------------------------- /packages/zcli-themes/src/lib/getLocalServerBaseUrl.ts: -------------------------------------------------------------------------------- 1 | import { Flags } from '../types' 2 | 3 | export function getLocalServerBaseUrl (flags: Flags, isWebsocket = false): string { 4 | const { bind: host, port } = flags 5 | return `${getProtocol(flags, isWebsocket)}://${host}:${port}` 6 | } 7 | 8 | function getProtocol (flags: Flags, isWebsocket: boolean): string { 9 | const { 'https-cert': httpsCert, 'https-key': httpsKey } = flags 10 | if (isWebsocket) { 11 | return httpsCert && httpsKey ? 'wss' : 'ws' 12 | } else { 13 | return httpsCert && httpsKey ? 'https' : 'http' 14 | } 15 | } 16 | -------------------------------------------------------------------------------- /packages/zcli-themes/src/lib/getManifest.test.ts: -------------------------------------------------------------------------------- 1 | import * as sinon from 'sinon' 2 | import * as fs from 'fs' 3 | import { expect } from '@oclif/test' 4 | import getManifest from './getManifest' 5 | 6 | describe('getManifest', () => { 7 | beforeEach(() => { 8 | sinon.restore() 9 | }) 10 | 11 | it('returns the manifest.json file parsed as json', () => { 12 | const existsSyncStub = sinon.stub(fs, 'existsSync') 13 | const readFileSyncStub = sinon.stub(fs, 'readFileSync') 14 | 15 | const manifest = { 16 | name: 'Copenhagen theme', 17 | author: 'Jane Doe', 18 | version: '1.0.1', 19 | api_version: 1, 20 | settings: [] 21 | } 22 | 23 | existsSyncStub 24 | .withArgs('theme/path/manifest.json') 25 | .returns(true) 26 | 27 | readFileSyncStub 28 | .withArgs('theme/path/manifest.json') 29 | .returns(JSON.stringify(manifest)) 30 | 31 | expect(getManifest('theme/path')).to.deep.equal(manifest) 32 | }) 33 | 34 | it('throws an error when it can\'t find a manifest.json file', () => { 35 | const existsSyncStub = sinon.stub(fs, 'existsSync') 36 | 37 | existsSyncStub 38 | .withArgs('theme/path/manifest.json') 39 | .returns(false) 40 | 41 | expect(() => { 42 | getManifest('theme/path') 43 | }).to.throw('Couldn\'t find a manifest.json file at path: "theme/path/manifest.json"') 44 | }) 45 | 46 | it('throws an error when the manifest.json file is malformed', () => { 47 | const existsSyncStub = sinon.stub(fs, 'existsSync') 48 | const readFileSyncStub = sinon.stub(fs, 'readFileSync') 49 | 50 | existsSyncStub 51 | .withArgs('theme/path/manifest.json') 52 | .returns(true) 53 | 54 | readFileSyncStub 55 | .withArgs('theme/path/manifest.json') 56 | .returns('{"name": "Copenhagen theme",,, }') 57 | 58 | expect(() => { 59 | getManifest('theme/path') 60 | }).to.throw('manifest.json file was malformed at path: "theme/path/manifest.json"') 61 | }) 62 | }) 63 | -------------------------------------------------------------------------------- /packages/zcli-themes/src/lib/getManifest.ts: -------------------------------------------------------------------------------- 1 | import type { Manifest } from '../types' 2 | import { CLIError } from '@oclif/core/lib/errors' 3 | import * as fs from 'fs' 4 | import * as chalk from 'chalk' 5 | 6 | export default function getManifest (themePath: string): Manifest { 7 | const manifestFilePath = `${themePath}/manifest.json` 8 | 9 | if (fs.existsSync(manifestFilePath)) { 10 | const manifestFile = fs.readFileSync(manifestFilePath, 'utf8') 11 | try { 12 | return JSON.parse(manifestFile) 13 | } catch (error) { 14 | throw new CLIError(chalk.red(`manifest.json file was malformed at path: "${manifestFilePath}"`)) 15 | } 16 | } else { 17 | throw new CLIError(chalk.red(`Couldn't find a manifest.json file at path: "${manifestFilePath}"`)) 18 | } 19 | } 20 | -------------------------------------------------------------------------------- /packages/zcli-themes/src/lib/getTemplates.test.ts: -------------------------------------------------------------------------------- 1 | import * as sinon from 'sinon' 2 | import * as fs from 'fs' 3 | import * as glob from 'glob' 4 | import { expect } from '@oclif/test' 5 | import getTemplates from './getTemplates' 6 | 7 | describe('getTemplates', () => { 8 | beforeEach(() => { 9 | sinon.restore() 10 | }) 11 | 12 | it('returns a map of the templates - identifier: source', () => { 13 | const globSyncStub = sinon.stub(glob, 'globSync') 14 | const readFileSyncStub = sinon.stub(fs, 'readFileSync') 15 | 16 | globSyncStub 17 | .returns([ 18 | '/theme/path/templates/home_page.hbs', 19 | '/theme/path/templates/article_pages/product_updates.hbs', 20 | '/theme/path/templates/custom_pages/faq.hbs' 21 | ]) 22 | 23 | readFileSyncStub 24 | .onFirstCall() 25 | .returns('

Home

') 26 | .onSecondCall() 27 | .returns('

Product updates

') 28 | .onThirdCall() 29 | .returns('

FAQ

') 30 | 31 | expect(getTemplates('theme/path')).to.deep.equal({ 32 | home_page: '

Home

', 33 | 'article_pages/product_updates': '

Product updates

', 34 | 'custom_pages/faq': '

FAQ

' 35 | }) 36 | }) 37 | 38 | it('addresses non-posix path separator on windows', () => { 39 | const globSyncStub = sinon.stub(glob, 'globSync') 40 | const readFileSyncStub = sinon.stub(fs, 'readFileSync') 41 | 42 | globSyncStub 43 | .returns([ 44 | '\\theme\\path\\templates\\home_page.hbs', 45 | '\\theme\\path\\templates\\article_pages\\product_updates.hbs', 46 | '\\theme\\path\\templates\\custom_pages\\faq.hbs' 47 | ]) 48 | 49 | readFileSyncStub 50 | .onFirstCall() 51 | .returns('

Home

') 52 | .onSecondCall() 53 | .returns('

Product updates

') 54 | .onThirdCall() 55 | .returns('

FAQ

') 56 | 57 | expect(getTemplates('theme/path')).to.deep.equal({ 58 | home_page: '

Home

', 59 | 'article_pages/product_updates': '

Product updates

', 60 | 'custom_pages/faq': '

FAQ

' 61 | }) 62 | }) 63 | }) 64 | -------------------------------------------------------------------------------- /packages/zcli-themes/src/lib/getTemplates.ts: -------------------------------------------------------------------------------- 1 | import { globSync } from 'glob' 2 | import * as fs from 'fs' 3 | 4 | export default function getTemplates (themePath: string): Record { 5 | const templates: Record = {} 6 | const filenames = globSync(`${themePath}/templates/**/*.hbs`.replace(/\\/g, '/'), { posix: true }) 7 | 8 | filenames.forEach((template) => { 9 | const identifier = template.replace(/\\/g, '/').split('templates/')[1].split('.hbs')[0] 10 | const source = fs.readFileSync(template, 'utf8') 11 | templates[identifier] = source 12 | }) 13 | 14 | return templates 15 | } 16 | -------------------------------------------------------------------------------- /packages/zcli-themes/src/lib/getVariables.test.ts: -------------------------------------------------------------------------------- 1 | import * as sinon from 'sinon' 2 | import * as fs from 'fs' 3 | import { expect } from '@oclif/test' 4 | import getVariables from './getVariables' 5 | 6 | const settings = [{ 7 | variables: [ 8 | { identifier: 'color', type: 'color', value: '#999' }, 9 | { identifier: 'logo', type: 'file' }, 10 | { identifier: 'favicon', type: 'file' } 11 | ] 12 | }] 13 | 14 | const flags = { 15 | bind: 'localhost', 16 | port: 1000, 17 | logs: true, 18 | livereload: true 19 | } 20 | 21 | describe('getVariables', () => { 22 | beforeEach(() => { 23 | sinon.restore() 24 | }) 25 | 26 | it('returns an array of variables', () => { 27 | const existsSyncStub = sinon.stub(fs, 'existsSync') 28 | const readdirSyncStub = sinon.stub(fs, 'readdirSync') 29 | 30 | existsSyncStub 31 | .withArgs('theme/path/settings') 32 | .returns(true) 33 | 34 | readdirSyncStub.returns(['logo.png', 'favicon.png'] as any) 35 | 36 | expect(getVariables('theme/path', settings, flags)).to.deep.equal([ 37 | { identifier: 'color', type: 'color', value: '#999' }, 38 | { identifier: 'logo', type: 'file', value: 'http://localhost:1000/guide/settings/logo.png' }, 39 | { identifier: 'favicon', type: 'file', value: 'http://localhost:1000/guide/settings/favicon.png' } 40 | ]) 41 | }) 42 | 43 | it('throws an error when it doesn\'t find an asset within the settings folder for a variable of type "file"', () => { 44 | const existsSyncStub = sinon.stub(fs, 'existsSync') 45 | const readdirSyncStub = sinon.stub(fs, 'readdirSync') 46 | 47 | existsSyncStub 48 | .withArgs('theme/path/settings') 49 | .returns(true) 50 | 51 | readdirSyncStub.returns(['logo.png'] as any) 52 | 53 | expect(() => { 54 | getVariables('theme/path', settings, flags) 55 | }).to.throw('The setting "favicon" of type "file" does not have a matching file within the "settings" folder') 56 | }) 57 | }) 58 | -------------------------------------------------------------------------------- /packages/zcli-themes/src/lib/getVariables.ts: -------------------------------------------------------------------------------- 1 | import type { Setting, Variable, Flags } from '../types' 2 | import * as fs from 'fs' 3 | import * as path from 'path' 4 | import { CLIError } from '@oclif/core/lib/errors' 5 | import { getLocalServerBaseUrl } from './getLocalServerBaseUrl' 6 | 7 | export default function getVariables (themePath: string, settings: Setting[], flags: Flags): Variable[] { 8 | const settingsPath = `${themePath}/settings` 9 | const filenames = fs.existsSync(settingsPath) ? fs.readdirSync(settingsPath) : [] 10 | 11 | return settings 12 | .reduce((variables: Variable[], setting) => [...variables, ...setting.variables], []) 13 | .map((variable: Variable) => { 14 | if (variable.type === 'file') { 15 | const file = filenames.find(filename => path.parse(filename).name === variable.identifier) 16 | if (!file) { 17 | throw new CLIError( 18 | `The setting "${variable.identifier}" of type "file" does not have a matching file within the "settings" folder` 19 | ) 20 | } 21 | variable.value = file && `${getLocalServerBaseUrl(flags)}/guide/settings/${file}` 22 | } 23 | return variable 24 | }) 25 | } 26 | -------------------------------------------------------------------------------- /packages/zcli-themes/src/lib/handleThemeApiError.ts: -------------------------------------------------------------------------------- 1 | import type { AxiosError, AxiosResponse } from 'axios' 2 | import * as chalk from 'chalk' 3 | import { error } from '@oclif/core/lib/errors' 4 | 5 | export default function handleThemeApiError (e: AxiosError): never { 6 | const { response, message } = e 7 | 8 | if (response) { 9 | const { errors } = (response as AxiosResponse).data 10 | for (const { code, title } of errors) { 11 | error(`${chalk.bold(code)} - ${title}`) 12 | } 13 | } else if (message) { 14 | error(message) 15 | } 16 | 17 | error(e) 18 | } 19 | -------------------------------------------------------------------------------- /packages/zcli-themes/src/lib/pollJobStatus.test.ts: -------------------------------------------------------------------------------- 1 | import * as sinon from 'sinon' 2 | import { expect } from '@oclif/test' 3 | import * as axios from 'axios' 4 | import { request } from '@zendesk/zcli-core' 5 | import pollJobStatus from './pollJobStatus' 6 | import * as chalk from 'chalk' 7 | import * as errors from '@oclif/core/lib/errors' 8 | 9 | describe('pollJobStatus', () => { 10 | beforeEach(() => { 11 | sinon.restore() 12 | }) 13 | 14 | it('polls the jobs/{jobId} endpoint until the job is completed', async () => { 15 | const requestStub = sinon.stub(request, 'requestAPI') 16 | 17 | requestStub 18 | .onFirstCall() 19 | .returns(Promise.resolve({ data: { job: { status: 'pending' } } }) as axios.AxiosPromise) 20 | .onSecondCall() 21 | .returns(Promise.resolve({ data: { job: { status: 'pending' } } }) as axios.AxiosPromise) 22 | .onThirdCall() 23 | .returns(Promise.resolve({ data: { job: { status: 'completed', theme_id: '1234' } } }) as axios.AxiosPromise) 24 | 25 | await pollJobStatus('theme/path', '9999', 10) 26 | 27 | expect(requestStub.calledWith('/api/v2/guide/theming/jobs/9999')).to.equal(true) 28 | expect(requestStub.callCount).to.equal(3) 29 | }) 30 | 31 | it('times out after the specified number of retries', async () => { 32 | const requestStub = sinon.stub(request, 'requestAPI') 33 | const errorStub = sinon.stub(errors, 'error') 34 | 35 | requestStub 36 | .onFirstCall() 37 | .returns(Promise.resolve({ data: { job: { status: 'pending' } } }) as axios.AxiosPromise) 38 | .onSecondCall() 39 | .returns(Promise.resolve({ data: { job: { status: 'pending' } } }) as axios.AxiosPromise) 40 | .onThirdCall() 41 | .returns(Promise.resolve({ data: { job: { status: 'pending' } } }) as axios.AxiosPromise) 42 | 43 | await pollJobStatus('theme/path', '9999', 10, 3) 44 | 45 | expect(requestStub.callCount).to.equal(3) 46 | expect(errorStub.calledWith('Import job timed out')).to.equal(true) 47 | }) 48 | 49 | it('handles job errors', async () => { 50 | const requestStub = sinon.stub(request, 'requestAPI') 51 | const errorStub = sinon.stub(errors, 'error').callThrough() 52 | 53 | requestStub 54 | .onFirstCall() 55 | .returns(Promise.resolve({ data: { job: { status: 'pending' } } }) as axios.AxiosPromise) 56 | .onSecondCall() 57 | .returns(Promise.resolve({ 58 | data: { 59 | job: { 60 | status: 'failed', 61 | errors: [ 62 | { 63 | message: 'Template(s) with syntax error(s)', 64 | code: 'InvalidTemplates', 65 | meta: { 66 | 'templates/home_page.hbs': [ 67 | { 68 | description: 'not possible to access `names` in `help_center.names`', 69 | line: 1, 70 | column: 45, 71 | length: 5 72 | }, 73 | { 74 | description: "'articles' does not exist", 75 | line: 21, 76 | column: 16, 77 | length: 11 78 | } 79 | ], 80 | 'templates/new_request_page.hbs': [ 81 | { 82 | description: "'post_form' does not exist", 83 | line: 22, 84 | column: 6, 85 | length: 10 86 | } 87 | ] 88 | } 89 | } 90 | ] 91 | } 92 | } 93 | }) as axios.AxiosPromise) 94 | 95 | try { 96 | await pollJobStatus('theme/path', '9999', 10, 2) 97 | } catch { 98 | expect(requestStub.callCount).to.equal(2) 99 | expect(errorStub.calledWithMatch('Template(s) with syntax error(s)')).to.equal(true) 100 | 101 | expect(errorStub.calledWithMatch(`${chalk.bold('Validation error')} theme/path/templates/home_page.hbs:1:45`)).to.equal(true) 102 | expect(errorStub.calledWithMatch('not possible to access `names` in `help_center.names`')).to.equal(true) 103 | 104 | expect(errorStub.calledWithMatch(`${chalk.bold('Validation error')} theme/path/templates/home_page.hbs:21:16`)).to.equal(true) 105 | expect(errorStub.calledWithMatch("'articles' does not exist")).to.equal(true) 106 | 107 | expect(errorStub.calledWithMatch(`${chalk.bold('Validation error')} theme/path/templates/new_request_page.hbs:22:6`)).to.equal(true) 108 | expect(errorStub.calledWithMatch("'post_form' does not exist")).to.equal(true) 109 | } 110 | }) 111 | }) 112 | -------------------------------------------------------------------------------- /packages/zcli-themes/src/lib/pollJobStatus.ts: -------------------------------------------------------------------------------- 1 | import type { Job, JobError, ValidationErrors } from '../types' 2 | import { CliUx } from '@oclif/core' 3 | import { error } from '@oclif/core/lib/errors' 4 | import { request } from '@zendesk/zcli-core' 5 | import * as chalk from 'chalk' 6 | import validationErrorsToString from './validationErrorsToString' 7 | 8 | export default async function pollJobStatus (themePath: string, jobId: string, interval = 1000, retries = 20): Promise { 9 | CliUx.ux.action.start('Polling job status') 10 | 11 | while (retries) { 12 | // Delay issuing a retry 13 | await new Promise(resolve => setTimeout(resolve, interval)) 14 | 15 | const response = await request.requestAPI(`/api/v2/guide/theming/jobs/${jobId}`) 16 | const job: Job = response.data.job 17 | 18 | switch (job.status) { 19 | case 'pending': 20 | retries -= 1 21 | break 22 | case 'completed': { 23 | CliUx.ux.action.stop('Ok') 24 | return 25 | } 26 | case 'failed': { 27 | // Although `data.job.errors` is an array, it usually contains 28 | // only one error at a time. Hence, we only need to handle the 29 | // first error in the array. 30 | const [error] = job.errors 31 | handleJobError(themePath, error) 32 | } 33 | } 34 | } 35 | 36 | error('Import job timed out') 37 | } 38 | 39 | function handleJobError (themePath: string, jobError: JobError): void { 40 | const { code, message, meta } = jobError 41 | const title = `${chalk.bold(code)} - ${message}` 42 | let details = '' 43 | 44 | switch (code) { 45 | case 'InvalidTemplates': 46 | case 'InvalidManifest': 47 | case 'InvalidTranslationFile': 48 | details = validationErrorsToString(themePath, meta as ValidationErrors) 49 | break 50 | default: 51 | details = JSON.stringify(meta) 52 | } 53 | 54 | error(`${title}\n${details}`) 55 | } 56 | -------------------------------------------------------------------------------- /packages/zcli-themes/src/lib/preview.ts: -------------------------------------------------------------------------------- 1 | import type { Flags, ValidationError, ValidationErrors } from '../types' 2 | import getManifest from './getManifest' 3 | import getTemplates from './getTemplates' 4 | import getVariables from './getVariables' 5 | import getAssets from './getAssets' 6 | import * as chalk from 'chalk' 7 | import { request } from '@zendesk/zcli-core' 8 | import { error } from '@oclif/core/lib/errors' 9 | import { CliUx } from '@oclif/core' 10 | import validationErrorsToString from './validationErrorsToString' 11 | import { getLocalServerBaseUrl } from './getLocalServerBaseUrl' 12 | import type { AxiosError } from 'axios' 13 | 14 | export default async function preview (themePath: string, flags: Flags): Promise { 15 | const manifest = getManifest(themePath) 16 | const templates = getTemplates(themePath) 17 | const variables = getVariables(themePath, manifest.settings, flags) 18 | const assets = getAssets(themePath, flags) 19 | const { livereload } = flags 20 | 21 | const variablesPayload = variables.reduce((payload, variable) => ({ 22 | ...payload, 23 | [variable.identifier]: variable.value 24 | }), {}) 25 | 26 | const assetsPayload = assets.reduce((payload, [parsedPath, url]) => ({ 27 | ...payload, 28 | [parsedPath.base]: url 29 | }), {}) 30 | 31 | const metadataPayload = { api_version: manifest.api_version } 32 | 33 | try { 34 | CliUx.ux.action.start('Uploading theme') 35 | const { config: { baseURL } } = await request.requestAPI('/hc/api/internal/theming/local_preview', { 36 | method: 'put', 37 | headers: { 38 | 'X-Zendesk-Request-Originator': 'zcli themes:preview' 39 | }, 40 | data: { 41 | templates: { 42 | ...templates, 43 | css: '', 44 | js: '', 45 | document_head: ` 46 | 47 | ${templates.document_head} 48 | 49 | ${livereload ? livereloadScript(flags) : ''} 50 | `, 51 | assets: assetsPayload, 52 | variables: variablesPayload, 53 | metadata: metadataPayload 54 | } 55 | }, 56 | validateStatus: (status: number) => status === 200 57 | }) 58 | CliUx.ux.action.stop('Ok') 59 | return baseURL 60 | } catch (e) { 61 | CliUx.ux.action.stop(chalk.bold.red('!')) 62 | const { response, message } = e as AxiosError 63 | if (response) { 64 | const { 65 | template_errors: templateErrors, 66 | general_error: generalError 67 | } = response.data as { 68 | template_errors: ValidationErrors, 69 | general_error: string 70 | } 71 | if (templateErrors) handlePreviewError(themePath, templateErrors) 72 | else if (generalError) error(generalError) 73 | else error(message) 74 | } else { 75 | error(e as AxiosError) 76 | } 77 | } 78 | } 79 | 80 | export function livereloadScript (flags: Flags) { 81 | return ` 86 | ` 87 | } 88 | 89 | function handlePreviewError (themePath: string, templateErrors: ValidationErrors) { 90 | const validationErrors: ValidationErrors = {} 91 | for (const [template, errors] of Object.entries(templateErrors)) { 92 | // the preview endpoint returns the template identifier as the 'key' instead of 93 | // the template path. We must fix this so we can reuse `validationErrorsToString` 94 | // and align with the job import error handling 95 | validationErrors[`templates/${template}.hbs`] = errors as ValidationError[] 96 | } 97 | 98 | const title = `${chalk.bold('InvalidTemplates')} - Template(s) with syntax error(s)` 99 | const details = validationErrorsToString(themePath, validationErrors) 100 | 101 | error(`${title}\n${details}`) 102 | } 103 | -------------------------------------------------------------------------------- /packages/zcli-themes/src/lib/uploadThemePackage.test.ts: -------------------------------------------------------------------------------- 1 | import * as sinon from 'sinon' 2 | import { expect } from '@oclif/test' 3 | import * as axios from 'axios' 4 | import { request } from '@zendesk/zcli-core' 5 | import uploadThemePackage, { themeSizeLimit } from './uploadThemePackage' 6 | import * as errors from '@oclif/core/lib/errors' 7 | 8 | const job = { 9 | id: '9999', 10 | status: 'pending' as const, 11 | data: { 12 | theme_id: '1234', 13 | upload: { 14 | url: 'upload/url', 15 | parameters: { 16 | foo: 'foo', 17 | bar: 'bar' 18 | } 19 | } 20 | } 21 | } 22 | 23 | describe('uploadThemePackage', () => { 24 | beforeEach(() => { 25 | sinon.restore() 26 | }) 27 | 28 | it('calls the job upload endpoint with the correct payload and returns the job', async () => { 29 | const file = Buffer.from('file content') 30 | 31 | const requestStub = sinon.stub(request, 'requestAPI') 32 | 33 | await uploadThemePackage(job, file, 'filename') 34 | 35 | expect(requestStub.calledWith('upload/url', sinon.match({ 36 | method: 'POST', 37 | data: sinon.match.instanceOf(Buffer), 38 | maxBodyLength: themeSizeLimit, 39 | maxContentLength: themeSizeLimit 40 | }))).to.equal(true) 41 | }) 42 | 43 | it('errors when the upload fails', async () => { 44 | const file = Buffer.from('file content') 45 | const requestStub = sinon.stub(request, 'requestAPI') 46 | const errorStub = sinon.stub(errors, 'error').callThrough() 47 | const error = new axios.AxiosError('Network error') 48 | 49 | requestStub.throws(error) 50 | 51 | try { 52 | await uploadThemePackage(job, file, 'filename') 53 | } catch { 54 | expect(errorStub.calledWith(error)).to.equal(true) 55 | } 56 | }) 57 | }) 58 | -------------------------------------------------------------------------------- /packages/zcli-themes/src/lib/uploadThemePackage.ts: -------------------------------------------------------------------------------- 1 | import type { PendingJob } from '../types' 2 | import { CliUx } from '@oclif/core' 3 | import * as FormData from 'form-data' 4 | import * as axios from 'axios' 5 | import { request } from '@zendesk/zcli-core' 6 | import { error } from '@oclif/core/lib/errors' 7 | 8 | export const themeSizeLimit = 31457280 9 | 10 | export default async function uploadThemePackage (job: PendingJob, file: Buffer, filename: string): Promise { 11 | CliUx.ux.action.start('Uploading theme package') 12 | 13 | const formData = new FormData() 14 | 15 | for (const key in job.data.upload.parameters) { 16 | formData.append(key, job.data.upload.parameters[key]) 17 | } 18 | 19 | formData.append('file', file, { 20 | filename 21 | }) 22 | 23 | try { 24 | await request.requestAPI(job.data.upload.url, { 25 | method: 'POST', 26 | data: formData.getBuffer(), 27 | headers: formData.getHeaders(), 28 | maxBodyLength: themeSizeLimit, 29 | maxContentLength: themeSizeLimit 30 | }) 31 | CliUx.ux.action.stop('Ok') 32 | } catch (e) { 33 | error(e as axios.AxiosError) 34 | } 35 | } 36 | -------------------------------------------------------------------------------- /packages/zcli-themes/src/lib/validationErrorsToString.test.ts: -------------------------------------------------------------------------------- 1 | import { expect } from '@oclif/test' 2 | import validationErrorsToString from './validationErrorsToString' 3 | 4 | describe('validationErrorsToString', () => { 5 | it('returns a formatted string containing all validation errors', () => { 6 | const validationErrors = { 7 | 'templates/home_page.hbs': [ 8 | { 9 | description: 'not possible to access `names` in `help_center.names`', 10 | line: 1, 11 | column: 45, 12 | length: 5 13 | }, 14 | { 15 | description: "'articles' does not exist", 16 | line: 21, 17 | column: 16, 18 | length: 11 19 | } 20 | ], 21 | 'templates/new_request_page.hbs': [ 22 | { 23 | description: "'post_form' does not exist", 24 | line: 22, 25 | column: 6, 26 | length: 10 27 | } 28 | ] 29 | } 30 | 31 | const string = validationErrorsToString('theme/path', validationErrors) 32 | 33 | expect(string).to.contain('theme/path/templates/home_page.hbs:1:45') 34 | expect(string).to.contain('not possible to access `names` in `help_center.names`') 35 | 36 | expect(string).to.contain('theme/path/templates/home_page.hbs:21:16') 37 | expect(string).to.contain("'articles' does not exist") 38 | 39 | expect(string).to.contain('templates/new_request_page.hbs') 40 | expect(string).to.contain("'post_form' does not exist") 41 | }) 42 | }) 43 | -------------------------------------------------------------------------------- /packages/zcli-themes/src/lib/validationErrorsToString.ts: -------------------------------------------------------------------------------- 1 | import type { ValidationErrors } from '../types' 2 | import * as chalk from 'chalk' 3 | 4 | export default function validationErrorsToString (themePath: string, validationErrors: ValidationErrors): string { 5 | let string = '' 6 | 7 | for (const [template, errors] of Object.entries(validationErrors)) { 8 | for (const { line, column, description } of errors) { 9 | string += `\n${chalk.bold('Validation error')} ${themePath}/${template}${line && column ? `:${line}:${column}` : ''}\n ${description}\n` 10 | } 11 | } 12 | 13 | return string 14 | } 15 | -------------------------------------------------------------------------------- /packages/zcli-themes/src/lib/zass.ts: -------------------------------------------------------------------------------- 1 | import type { Variable } from '../types' 2 | import * as path from 'path' 3 | import * as sass from 'sass' 4 | import { error } from '@oclif/core/lib/errors' 5 | import * as chalk from 'chalk' 6 | 7 | // Help Center themes may use SASS variable syntax to access variables 8 | // defined in the manifest.json file and also the URLs of files placed 9 | // in the assets folder. Read more about it in the article: 10 | // https://support.zendesk.com/hc/en-us/articles/4408846524954-Customizing-the-Settings-panel#using-settings-in-manifest-json-as-variables 11 | // Apart from variable syntax, we also also support 'lighten' and 'darken' functions 12 | export default function zass (source: string, variables: Variable[], assets: [path.ParsedPath, string][]) { 13 | let output = source 14 | 15 | const replacements: Record = {} 16 | const identifiers = variables.map(({ identifier }) => identifier) 17 | 18 | // variables from settings in the `manifest.json` file 19 | for (const { identifier, value } of variables) { 20 | replacements[`$${identifier}`] = value // suport default variable sytax, e.g. `$name` 21 | replacements[`#{$${identifier}}`] = value // suport (undocumented) interpolation, e.g. `#{$name}` 22 | } 23 | 24 | // variables from files in the `assets` folder 25 | for (const [parsedPath, url] of assets) { 26 | const name = parsedPath.name.replace(/\+/g, '-') 27 | const extension = parsedPath.ext.split('.').pop() 28 | const identifier = `assets-${name}-${extension}` 29 | replacements[`$${identifier}`] = url 30 | // make sure to add all asset identifiers before 31 | // composing the regular expression 32 | identifiers.push(identifier) 33 | } 34 | 35 | const groups = `(${identifiers.join('|')})` 36 | const variablesRegex = new RegExp(`(\\$${groups}\\b)|(#\\{\\$${groups}\\})`, 'g') 37 | 38 | // First replace all variables and interpolated variables 39 | output = output.replace(variablesRegex, (match) => { 40 | return (replacements[match] || match).toString() 41 | }) 42 | 43 | const command = /(?lighten|darken)/i 44 | const percentage = /(?\d{1,3})%/ 45 | const functionsRegex = new RegExp(`${command.source}\\s*\\((?.*),\\s*${percentage.source}\\s*\\)`, 'g') 46 | 47 | // `darken` and `lighten` functions may use variables so make sure to replace them last 48 | output = output.replace(functionsRegex, (match/*, command, color, percentage */) => { 49 | const prefix = 'code{color:' 50 | const suffix = '}' 51 | 52 | try { 53 | // `dart-sass` does not provide an api to individually compile `darken` and `lighten` 54 | // functions so we improvise one using `compileString` with a valid SCSS string. 55 | // If such an api ever becomes available, we could switch to using it along with 56 | // the named groups "command", "color" and "percentage" 57 | const compiled = sass.compileString(prefix + match + suffix, { style: 'compressed' }).css 58 | const value = compiled.substring(prefix.length, compiled.length - suffix.length) 59 | 60 | return value 61 | } catch (e) { 62 | // Do no exit but signal the error to the user while maintaining the source string 63 | error(`Could not process ${chalk.red(match)} in style.css`, { exit: false }) 64 | return match 65 | } 66 | }) 67 | 68 | return output 69 | } 70 | -------------------------------------------------------------------------------- /packages/zcli-themes/src/types.ts: -------------------------------------------------------------------------------- 1 | export type Flags = { 2 | help?: string, 3 | bind: string, 4 | port: number, 5 | logs: boolean, 6 | livereload: boolean, 7 | 'https-key'?: string, 8 | 'https-cert'?: string 9 | } 10 | 11 | export type Variable = { 12 | identifier: string, 13 | type: string, 14 | value?: string | boolean | number 15 | } 16 | 17 | export type Setting = { 18 | variables: Variable[] 19 | } 20 | 21 | export type Manifest = { 22 | api_version: number, 23 | settings: Setting[] 24 | } 25 | 26 | export type ValidationError = { 27 | description: string, 28 | line?: number, 29 | column?: number, 30 | length?: number 31 | } 32 | 33 | export type ValidationErrors = { 34 | [path: `templates/${string}.hbs`]: ValidationError[] 35 | } 36 | 37 | export type Brand = { 38 | id: number, 39 | name: string, 40 | } 41 | 42 | export type JobError = { 43 | title: string, 44 | code: string, 45 | message: string, 46 | meta: object 47 | } 48 | 49 | type JobData = { 50 | theme_id: string, 51 | upload: { 52 | url: string, 53 | parameters: { 54 | [key: string]: string 55 | } 56 | } 57 | } 58 | 59 | export type PendingJob = { 60 | id: string, 61 | status: 'pending', 62 | data: JobData 63 | } 64 | 65 | export type CompletedJob = { 66 | id: string, 67 | status: 'completed', 68 | data: JobData 69 | } 70 | 71 | export type FailedJob = { 72 | id: string, 73 | status: 'failed', 74 | errors: JobError[] 75 | } 76 | 77 | export type Job = PendingJob | CompletedJob | FailedJob 78 | -------------------------------------------------------------------------------- /packages/zcli-themes/tests/functional/delete.test.ts: -------------------------------------------------------------------------------- 1 | import { expect, test } from '@oclif/test' 2 | import DeleteCommand from '../../src/commands/themes/delete' 3 | import env from './env' 4 | import * as sinon from 'sinon' 5 | 6 | describe('themes:delete', function () { 7 | let fetchStub: sinon.SinonStub 8 | 9 | beforeEach(() => { 10 | fetchStub = sinon.stub(global, 'fetch') 11 | }) 12 | 13 | afterEach(() => { 14 | fetchStub.restore() 15 | }) 16 | 17 | describe('successful deletion', () => { 18 | const success = test 19 | .env(env) 20 | .do(() => { 21 | fetchStub.withArgs(sinon.match({ 22 | url: 'https://z3ntest.zendesk.com/api/v2/guide/theming/themes/1234', 23 | method: 'DELETE' 24 | })).resolves({ 25 | status: 204, 26 | ok: true, 27 | text: () => Promise.resolve('') 28 | }) 29 | }) 30 | 31 | success 32 | .stdout() 33 | .it('should display success message when the theme is deleted successfully', async ctx => { 34 | await DeleteCommand.run(['--themeId', '1234']) 35 | expect(ctx.stdout).to.contain('Theme deleted successfully theme ID: 1234') 36 | }) 37 | 38 | success 39 | .stdout() 40 | .it('should return an object containing the theme ID when ran with --json', async ctx => { 41 | await DeleteCommand.run(['--themeId', '1234', '--json']) 42 | expect(ctx.stdout).to.equal(JSON.stringify({ themeId: '1234' }, null, 2) + '\n') 43 | }) 44 | }) 45 | 46 | describe('delete failure', () => { 47 | test 48 | .env(env) 49 | .do(() => { 50 | fetchStub.withArgs(sinon.match({ 51 | url: 'https://z3ntest.zendesk.com/api/v2/guide/theming/themes/1234', 52 | method: 'DELETE' 53 | })).resolves({ 54 | status: 400, 55 | ok: false, 56 | text: () => Promise.resolve(JSON.stringify({ 57 | errors: [{ 58 | code: 'ThemeNotFound', 59 | title: 'Invalid id' 60 | }] 61 | })) 62 | }) 63 | }) 64 | .stderr() 65 | .it('should report delete errors', async ctx => { 66 | try { 67 | await DeleteCommand.run(['--themeId', '1234']) 68 | } catch (error) { 69 | expect(ctx.stderr).to.contain('!') 70 | expect(error.message).to.contain('ThemeNotFound') 71 | expect(error.message).to.contain('Invalid id') 72 | } 73 | }) 74 | }) 75 | }) 76 | -------------------------------------------------------------------------------- /packages/zcli-themes/tests/functional/env.ts: -------------------------------------------------------------------------------- 1 | export default { 2 | ZENDESK_SUBDOMAIN: 'z3ntest', 3 | ZENDESK_EMAIL: 'admin@z3ntest.com', 4 | ZENDESK_API_TOKEN: '123456' 5 | } 6 | -------------------------------------------------------------------------------- /packages/zcli-themes/tests/functional/list.test.ts: -------------------------------------------------------------------------------- 1 | import { expect, test } from '@oclif/test' 2 | import * as sinon from 'sinon' 3 | import ListCommand from '../../src/commands/themes/list' 4 | import env from './env' 5 | 6 | describe('themes:list', function () { 7 | let fetchStub: sinon.SinonStub 8 | 9 | beforeEach(() => { 10 | fetchStub = sinon.stub(global, 'fetch') 11 | }) 12 | 13 | afterEach(() => { 14 | fetchStub.restore() 15 | }) 16 | 17 | describe('successful list', () => { 18 | const success = test 19 | .env(env) 20 | .do(() => { 21 | fetchStub.withArgs(sinon.match({ 22 | url: 'https://z3ntest.zendesk.com/api/v2/guide/theming/themes?brand_id=1111', 23 | method: 'GET' 24 | })).resolves({ 25 | status: 200, 26 | ok: true, 27 | text: () => Promise.resolve(JSON.stringify({ themes: [] })) 28 | }) 29 | }) 30 | 31 | success 32 | .stdout() 33 | .it('should display success message when the themes are listed successfully', async ctx => { 34 | await ListCommand.run(['--brandId', '1111']) 35 | expect(ctx.stdout).to.contain('Themes listed successfully []') 36 | }) 37 | 38 | success 39 | .stdout() 40 | .it('should return an object containing the theme ID when ran with --json', async ctx => { 41 | await ListCommand.run(['--brandId', '1111', '--json']) 42 | expect(ctx.stdout).to.equal(JSON.stringify({ themes: [] }, null, 2) + '\n') 43 | }) 44 | }) 45 | 46 | describe('list failure', () => { 47 | test 48 | .env(env) 49 | .do(() => { 50 | fetchStub.withArgs(sinon.match({ 51 | url: 'https://z3ntest.zendesk.com/api/v2/guide/theming/themes?brand_id=1111', 52 | method: 'GET' 53 | })).resolves({ 54 | status: 500, 55 | ok: false, 56 | text: () => Promise.resolve(JSON.stringify({ 57 | errors: [{ 58 | code: 'InternalError', 59 | title: 'Something went wrong' 60 | }] 61 | })) 62 | }) 63 | }) 64 | .stderr() 65 | .it('should report list errors', async ctx => { 66 | try { 67 | await ListCommand.run(['--brandId', '1111']) 68 | } catch (error) { 69 | expect(ctx.stderr).to.contain('!') 70 | expect(error.message).to.contain('InternalError') 71 | expect(error.message).to.contain('Something went wrong') 72 | } 73 | }) 74 | }) 75 | }) 76 | -------------------------------------------------------------------------------- /packages/zcli-themes/tests/functional/mocks/base_theme/assets/bike.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/zendesk/zcli/f825c8fa39ad780ec5ff05e85e8073667268d7a9/packages/zcli-themes/tests/functional/mocks/base_theme/assets/bike.png -------------------------------------------------------------------------------- /packages/zcli-themes/tests/functional/mocks/base_theme/manifest.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "Copenhagen", 3 | "author": "Zendesk", 4 | "version": "1.0.0", 5 | "api_version": 2, 6 | "default_locale": "en-us", 7 | "settings": [ 8 | { 9 | "label": "Brand", 10 | "variables": [ 11 | { 12 | "identifier": "logo", 13 | "type": "file", 14 | "description": "Company logo", 15 | "label": "Logo" 16 | }, 17 | { 18 | "identifier": "brand_color", 19 | "type": "color", 20 | "description": "Brand color for major navigational elements", 21 | "label": "Brand color", 22 | "value": "#17494D" 23 | }, 24 | { 25 | "identifier": "font_size", 26 | "type": "range", 27 | "description": "Text size", 28 | "label": "Text size", 29 | "value": 12 30 | } 31 | ] 32 | } 33 | ] 34 | } -------------------------------------------------------------------------------- /packages/zcli-themes/tests/functional/mocks/base_theme/script.js: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/zendesk/zcli/f825c8fa39ad780ec5ff05e85e8073667268d7a9/packages/zcli-themes/tests/functional/mocks/base_theme/script.js -------------------------------------------------------------------------------- /packages/zcli-themes/tests/functional/mocks/base_theme/settings/logo.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/zendesk/zcli/f825c8fa39ad780ec5ff05e85e8073667268d7a9/packages/zcli-themes/tests/functional/mocks/base_theme/settings/logo.png -------------------------------------------------------------------------------- /packages/zcli-themes/tests/functional/mocks/base_theme/style.css: -------------------------------------------------------------------------------- 1 | .test { 2 | background: url($logo); 3 | color: $brand_color; 4 | cursor: url($assets-bike-png), pointer; 5 | width: #{$font_size}px; 6 | } 7 | -------------------------------------------------------------------------------- /packages/zcli-themes/tests/functional/mocks/base_theme/templates/document_head.hbs: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/zendesk/zcli/f825c8fa39ad780ec5ff05e85e8073667268d7a9/packages/zcli-themes/tests/functional/mocks/base_theme/templates/document_head.hbs -------------------------------------------------------------------------------- /packages/zcli-themes/tests/functional/preview.test.ts: -------------------------------------------------------------------------------- 1 | import type { Manifest } from '../../../zcli-themes/src/types' 2 | import { expect, test } from '@oclif/test' 3 | import * as sinon from 'sinon' 4 | import * as path from 'path' 5 | import * as fs from 'fs' 6 | import axios from 'axios' 7 | import { cloneDeep } from 'lodash' 8 | import PreviewCommand from '../../src/commands/themes/preview' 9 | import env from './env' 10 | 11 | describe('themes:preview', function () { 12 | const baseThemePath = path.join(__dirname, 'mocks/base_theme') 13 | let fetchStub: sinon.SinonStub 14 | 15 | beforeEach(() => { 16 | fetchStub = sinon.stub(global, 'fetch') 17 | }) 18 | 19 | afterEach(() => { 20 | fetchStub.restore() 21 | }) 22 | 23 | describe('successful preview', () => { 24 | let server 25 | 26 | const preview = test 27 | .stdout() 28 | .env(env) 29 | .do(() => { 30 | fetchStub.withArgs(sinon.match({ 31 | url: 'https://z3ntest.zendesk.com/hc/api/internal/theming/local_preview', 32 | method: 'PUT' 33 | })).resolves({ 34 | status: 200, 35 | ok: true, 36 | text: () => Promise.resolve('') 37 | }) 38 | }) 39 | .do(async () => { 40 | server = await PreviewCommand.run([baseThemePath, '--bind', '0.0.0.0', '--port', '9999']) 41 | }) 42 | 43 | afterEach(() => { 44 | server.close() 45 | }) 46 | 47 | preview 48 | .it('should provide links and instructions to start and exit preview', async (ctx) => { 49 | expect(ctx.stdout).to.contain('Ready https://z3ntest.zendesk.com/hc/admin/local_preview/start 🚀') 50 | expect(ctx.stdout).to.contain('You can exit preview mode in the UI or by visiting https://z3ntest.zendesk.com/hc/admin/local_preview/stop') 51 | }) 52 | 53 | preview 54 | .it('should serve assets on the defined host and port', async () => { 55 | expect((await axios.get('http://0.0.0.0:9999/guide/style.css')).status).to.eq(200) 56 | expect((await axios.get('http://0.0.0.0:9999/guide/script.js')).status).to.eq(200) 57 | expect((await axios.get('http://0.0.0.0:9999/guide/settings/logo.png')).status).to.eq(200) 58 | expect((await axios.get('http://0.0.0.0:9999/guide/assets/bike.png')).status).to.eq(200) 59 | }) 60 | 61 | preview 62 | .it('should serve a compiled stylesheet', async () => { 63 | const stylesheet = (await axios.get('http://0.0.0.0:9999/guide/style.css')).data 64 | expect(stylesheet).to.contain('color: #17494D;') 65 | expect(stylesheet).to.contain('background: url(http://0.0.0.0:9999/guide/settings/logo.png);') 66 | expect(stylesheet).to.contain('cursor: url(http://0.0.0.0:9999/guide/assets/bike.png), pointer;') 67 | expect(stylesheet).to.contain('width: 12px;') 68 | }) 69 | 70 | preview 71 | .it('should watch for changes in the manifest.json file', async () => { 72 | // Read manifest.json 73 | const manifestPath = path.join(baseThemePath, 'manifest.json') 74 | const manifest: Manifest = JSON.parse(fs.readFileSync(manifestPath, 'utf8')) 75 | // Modify manifest.json 76 | const clonedManifest = cloneDeep(manifest) 77 | clonedManifest.settings[0].variables[1].value = '#000000' 78 | fs.writeFileSync(manifestPath, JSON.stringify(clonedManifest)) 79 | expect((await axios.get('http://0.0.0.0:9999/guide/style.css')).data).to.contain('color: #000000;') 80 | // Restore manifest.json 81 | fs.writeFileSync(manifestPath, JSON.stringify(manifest, null, 2)) 82 | }) 83 | }) 84 | 85 | describe('validation errors', () => { 86 | test 87 | .stdout() 88 | .env(env) 89 | .do(() => { 90 | fetchStub.withArgs(sinon.match({ 91 | url: 'https://z3ntest.zendesk.com/hc/api/internal/theming/local_preview', 92 | method: 'PUT' 93 | })).resolves({ 94 | status: 400, 95 | ok: false, 96 | text: () => Promise.resolve(JSON.stringify({ 97 | template_errors: { 98 | home_page: [{ 99 | description: "'articles' does not exist", 100 | line: 10, 101 | column: 6, 102 | length: 7 103 | }] 104 | } 105 | })) 106 | }) 107 | }) 108 | .it('should report template errors', async (ctx) => { 109 | try { 110 | await PreviewCommand.run([baseThemePath]) 111 | expect(ctx.stdout).to.contain(`Validation error ${baseThemePath}/templates/home_page.hbs:10:6`) 112 | expect(ctx.stdout).to.contain("'articles' does not exist") 113 | } catch {} 114 | }) 115 | }) 116 | }) 117 | -------------------------------------------------------------------------------- /packages/zcli-themes/tests/functional/publish.test.ts: -------------------------------------------------------------------------------- 1 | import { expect, test } from '@oclif/test' 2 | import * as sinon from 'sinon' 3 | import PublishCommand from '../../src/commands/themes/publish' 4 | import env from './env' 5 | 6 | describe('themes:publish', function () { 7 | let fetchStub: sinon.SinonStub 8 | 9 | beforeEach(() => { 10 | fetchStub = sinon.stub(global, 'fetch') 11 | }) 12 | 13 | afterEach(() => { 14 | fetchStub.restore() 15 | }) 16 | 17 | describe('successful publish', () => { 18 | const success = test 19 | .env(env) 20 | .do(() => { 21 | fetchStub.withArgs(sinon.match({ 22 | url: 'https://z3ntest.zendesk.com/api/v2/guide/theming/themes/1234/publish', 23 | method: 'POST' 24 | })).resolves({ 25 | status: 200, 26 | ok: true, 27 | text: () => Promise.resolve('') 28 | }) 29 | }) 30 | 31 | success 32 | .stdout() 33 | .it('should display success message when the theme is published successfully', async ctx => { 34 | await PublishCommand.run(['--themeId', '1234']) 35 | expect(ctx.stdout).to.contain('Theme published successfully theme ID: 1234') 36 | }) 37 | 38 | success 39 | .stdout() 40 | .it('should return an object containing the theme ID when ran with --json', async ctx => { 41 | await PublishCommand.run(['--themeId', '1234', '--json']) 42 | expect(ctx.stdout).to.equal(JSON.stringify({ themeId: '1234' }, null, 2) + '\n') 43 | }) 44 | }) 45 | 46 | describe('publish failure', () => { 47 | test 48 | .env(env) 49 | .do(() => { 50 | fetchStub.withArgs(sinon.match({ 51 | url: 'https://z3ntest.zendesk.com/api/v2/guide/theming/themes/1234/publish', 52 | method: 'POST' 53 | })).resolves({ 54 | status: 400, 55 | ok: false, 56 | text: () => Promise.resolve(JSON.stringify({ 57 | errors: [{ 58 | code: 'ThemeNotFound', 59 | title: 'Invalid id' 60 | }] 61 | })) 62 | }) 63 | }) 64 | .stderr() 65 | .it('should report publish errors', async ctx => { 66 | try { 67 | await PublishCommand.run(['--themeId', '1234']) 68 | } catch (error) { 69 | expect(ctx.stderr).to.contain('!') 70 | expect(error.message).to.contain('ThemeNotFound') 71 | expect(error.message).to.contain('Invalid id') 72 | } 73 | }) 74 | }) 75 | }) 76 | -------------------------------------------------------------------------------- /packages/zcli-themes/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "declaration": true, 4 | "importHelpers": true, 5 | "module": "commonjs", 6 | "outDir": "dist", 7 | "rootDir": "src", 8 | "strict": true, 9 | "target": "es2017", 10 | "skipLibCheck": true 11 | }, 12 | "include": [ 13 | "src/**/*" 14 | ] 15 | } 16 | -------------------------------------------------------------------------------- /packages/zcli/README.md: -------------------------------------------------------------------------------- 1 | # Usage 2 | ```sh-session 3 | $ yarn global add @zendesk/zcli 4 | $ zcli COMMAND 5 | running command... 6 | $ zcli --version 7 | @zendesk/zcli/1.0.0-beta.23 darwin-x64 node-v20.13.1 8 | $ zcli --help [COMMAND] 9 | USAGE 10 | $ zcli COMMAND 11 | ... 12 | ``` 13 | # Commands 14 | # Command Topics 15 | 16 | * [`zcli apps`](../../docs/apps.md) - manage Zendesk apps workflow 17 | * [`zcli themes`](../../docs/themes.md) - manage Zendesk themes workflow 18 | * [`zcli autocomplete`](../../docs/autocomplete.md) - display autocomplete installation instructions 19 | * [`zcli help`](../../docs/help.md) - display help for zcli 20 | * [`zcli login`](../../docs/login.md) - creates and/or saves an authentication token for the specified subdomain 21 | * [`zcli logout`](../../docs/logout.md) - removes an authentication token for an active profile 22 | * [`zcli profiles`](../../docs/profiles.md) - manage cli user profiles 23 | 24 | 25 | -------------------------------------------------------------------------------- /packages/zcli/bin/run: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env node 2 | 3 | const oclif = require('@oclif/core') 4 | 5 | oclif.run().catch(require('@oclif/core/handle')) 6 | -------------------------------------------------------------------------------- /packages/zcli/bin/run.cmd: -------------------------------------------------------------------------------- 1 | @echo off 2 | 3 | node "%~dp0\run" %* 4 | -------------------------------------------------------------------------------- /packages/zcli/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "@zendesk/zcli", 3 | "description": "Zendesk CLI is a single command line tool for all your zendesk needs", 4 | "version": "1.0.0-beta.52", 5 | "author": "@vegemite", 6 | "npmRegistry": "https://registry.npmjs.org", 7 | "publishConfig": { 8 | "access": "public" 9 | }, 10 | "bin": { 11 | "zcli": "./bin/run" 12 | }, 13 | "dependencies": { 14 | "@oclif/plugin-autocomplete": "=1.3.10", 15 | "@oclif/plugin-help": "=5.1.23", 16 | "@oclif/plugin-not-found": "=2.3.16", 17 | "@oclif/plugin-update": "=3.0.13", 18 | "@oclif/plugin-warn-if-update-available": "=2.0.20", 19 | "@zendesk/zcli-apps": "^1.0.0-beta.50", 20 | "@zendesk/zcli-core": "^1.0.0-beta.49", 21 | "@zendesk/zcli-themes": "^1.0.0-beta.52", 22 | "chalk": "^4.1.2", 23 | "tslib": "^2.4.0" 24 | }, 25 | "files": [ 26 | "/bin", 27 | "/src", 28 | "/dist", 29 | "/oclif.manifest.json", 30 | "/npm-shrinkwrap.json" 31 | ], 32 | "engines": { 33 | "node": ">=20.17.0" 34 | }, 35 | "keywords": [ 36 | "zcli", 37 | "zendesk", 38 | "command", 39 | "cli" 40 | ], 41 | "license": "MIT", 42 | "main": "src/index.js", 43 | "oclif": { 44 | "commands": "./src/commands", 45 | "bin": "zcli", 46 | "plugins": [ 47 | "@oclif/plugin-help", 48 | "@oclif/plugin-autocomplete", 49 | "@oclif/plugin-not-found", 50 | "@oclif/plugin-warn-if-update-available", 51 | "@zendesk/zcli-apps", 52 | "@zendesk/zcli-themes" 53 | ], 54 | "warn-if-update-available": { 55 | "timeoutInDays": 7, 56 | "message": "<%= config.name %> update available from <%= chalk.greenBright(config.version) %> to <%= chalk.greenBright(latest) %>." 57 | }, 58 | "topics": { 59 | "apps": { 60 | "description": "manage Zendesk apps workflow" 61 | }, 62 | "themes": { 63 | "description": "manage Zendesk themes workflow" 64 | }, 65 | "profiles": { 66 | "description": "manage cli user profiles" 67 | } 68 | } 69 | }, 70 | "scripts": { 71 | "prepack": "tsc && ../../scripts/prepack.sh", 72 | "postpack": "rm -f oclif.manifest.json npm-shrinkwrap.json && rm -rf ./dist && git checkout ./package.json", 73 | "type:check": "tsc" 74 | }, 75 | "types": "src/index.d.ts" 76 | } 77 | -------------------------------------------------------------------------------- /packages/zcli/src/commands/login.ts: -------------------------------------------------------------------------------- 1 | import { Command, Flags } from '@oclif/core' 2 | import * as chalk from 'chalk' 3 | import { SecureStore, Auth, getAccount } from '@zendesk/zcli-core' 4 | import { HELP_ENV_VARS } from '../utils/helpMessage' 5 | 6 | export default class Login extends Command { 7 | static description = 'creates and/or saves an authentication token for the specified subdomain' 8 | 9 | static flags = { 10 | help: Flags.help({ char: 'h' }), 11 | subdomain: Flags.string({ char: 's', default: '', description: 'Zendesk Subdomain' }), 12 | domain: Flags.string({ char: 'd', description: 'Zendesk domain' }), 13 | interactive: Flags.boolean({ char: 'i', default: false, description: 'Use Terminal based login' }) 14 | } 15 | 16 | static examples = [ 17 | '$ zcli login -i', 18 | '$ zcli login -s zendesk-subdomain -i', 19 | '$ zcli login -s zendesk-subdomain -d example.com -i' 20 | ] 21 | 22 | async run () { 23 | const secureStore = new SecureStore() 24 | const keytar = await secureStore.loadKeytar() 25 | if (!keytar) { 26 | console.log(chalk.yellow('Failed to load secure credentials store: use environment variables to log in.'), HELP_ENV_VARS) 27 | return 28 | } 29 | 30 | const { flags } = await this.parse(Login) 31 | const { interactive, subdomain, domain } = flags 32 | 33 | if (interactive) { 34 | const auth = new Auth({ secureStore }) 35 | const success = await auth.loginInteractively({ subdomain, domain }) 36 | if (success) { 37 | console.log(chalk.green('Successfully logged in.')) 38 | } else { 39 | const account = getAccount(subdomain, domain) 40 | console.log(chalk.red(`Failed to log in to your account: ${account}.`)) 41 | } 42 | } else { 43 | console.log('Browser login coming soon, use `zcli login -i` for interactive logins.') 44 | } 45 | } 46 | } 47 | -------------------------------------------------------------------------------- /packages/zcli/src/commands/logout.ts: -------------------------------------------------------------------------------- 1 | import { Command, Flags } from '@oclif/core' 2 | import * as chalk from 'chalk' 3 | import { SecureStore, Auth } from '@zendesk/zcli-core' 4 | 5 | export default class Logout extends Command { 6 | static description = 'removes an authentication token for an active profile' 7 | 8 | static flags = { 9 | help: Flags.help({ char: 'h' }), 10 | subdomain: Flags.string({ char: 's', default: '', description: 'Zendesk Subdomain' }) 11 | } 12 | 13 | static examples = [ 14 | '$ zcli logout' 15 | ] 16 | 17 | async run () { 18 | const secureStore = new SecureStore() 19 | const keytar = await secureStore.loadKeytar() 20 | if (!keytar) { 21 | console.log(chalk.yellow('Secure credentials store not found.')) 22 | return 23 | } 24 | 25 | const auth = new Auth({ secureStore }) 26 | const success = await auth.logout() 27 | 28 | if (success) { 29 | console.log(chalk.green('Successfully logged out.')) 30 | } 31 | } 32 | } 33 | -------------------------------------------------------------------------------- /packages/zcli/src/commands/profiles/list.ts: -------------------------------------------------------------------------------- 1 | import { Command, CliUx } from '@oclif/core' 2 | import * as chalk from 'chalk' 3 | import { Auth, SecureStore, getAccount } from '@zendesk/zcli-core' 4 | import { Credential, Profile } from '@zendesk/zcli-core/src/types' 5 | import { HELP_ENV_VARS } from '../../utils/helpMessage' 6 | 7 | export default class List extends Command { 8 | static description = 'lists all the profiles' 9 | 10 | static examples = [ 11 | '$ zcli profiles' 12 | ] 13 | 14 | renderProfiles (profiles: Credential[], loggedInProfile: Profile | undefined) { 15 | CliUx.ux.table(profiles, { 16 | account: { 17 | header: 'Accounts', 18 | get: row => { 19 | let log = row.account 20 | if (row.account === getAccount(loggedInProfile?.subdomain ?? '', loggedInProfile?.domain)) { 21 | log = `${log} ${chalk.bold.green('<= active')}` 22 | } 23 | return log 24 | } 25 | } 26 | }, { 27 | printLine: this.log.bind(this) 28 | }) 29 | } 30 | 31 | async run () { 32 | const secureStore = new SecureStore() 33 | const keytar = await secureStore.loadKeytar() 34 | if (!keytar) { 35 | console.log(chalk.yellow('Failed to load secure credentials store: could not load profiles.'), HELP_ENV_VARS) 36 | return 37 | } 38 | 39 | const auth = new Auth({ secureStore }) 40 | const profiles = await auth.getSavedProfiles() 41 | 42 | if (profiles && profiles.length) { 43 | const loggedInProfile = await auth.getLoggedInProfile() 44 | this.renderProfiles(profiles, loggedInProfile) 45 | } else { 46 | console.log('No profiles were found, use `zcli login` to create an active profile.') 47 | } 48 | } 49 | } 50 | -------------------------------------------------------------------------------- /packages/zcli/src/commands/profiles/remove.ts: -------------------------------------------------------------------------------- 1 | import { Command } from '@oclif/core' 2 | import * as chalk from 'chalk' 3 | import { CLIError } from '@oclif/core/lib/errors' 4 | import { SecureStore } from '@zendesk/zcli-core' 5 | import { HELP_ENV_VARS } from '../../utils/helpMessage' 6 | 7 | export default class Remove extends Command { 8 | static description = 'removes a profile' 9 | 10 | static args = [ 11 | { name: 'account', required: true } 12 | ] 13 | 14 | static examples = [ 15 | '$ zcli profiles:remove [ACCOUNT]' 16 | ] 17 | 18 | async run () { 19 | const { args } = await this.parse(Remove) 20 | const { account } = args 21 | 22 | const secureStore = new SecureStore() 23 | const keytar = await secureStore.loadKeytar() 24 | if (!keytar) { 25 | console.log(chalk.yellow('Failed to load secure credentials store: could not remove profile.'), HELP_ENV_VARS) 26 | return 27 | } 28 | 29 | const deleted = await secureStore.deleteSecret(account) 30 | if (!deleted) throw new CLIError(chalk.red(`Profile ${account} not found.`)) 31 | console.log(chalk.green(`Removed ${account} profile.`)) 32 | } 33 | } 34 | -------------------------------------------------------------------------------- /packages/zcli/src/commands/profiles/use.ts: -------------------------------------------------------------------------------- 1 | import { Command } from '@oclif/core' 2 | import * as chalk from 'chalk' 3 | import { SecureStore, Auth, getProfileFromAccount } from '@zendesk/zcli-core' 4 | import { HELP_ENV_VARS } from '../../utils/helpMessage' 5 | 6 | export default class Remove extends Command { 7 | static description = 'switches to a profile' 8 | 9 | static args = [ 10 | { name: 'account', required: true } 11 | ] 12 | 13 | static examples = [ 14 | '$ zcli profiles:use [ACCOUNT]' 15 | ] 16 | 17 | async run () { 18 | const { args } = await this.parse(Remove) 19 | const { account } = args 20 | 21 | const secureStore = new SecureStore() 22 | const keytar = await secureStore.loadKeytar() 23 | if (!keytar) { 24 | console.log(chalk.yellow(`Failed to load secure credentials store: could not switch to ${account} profile.`), HELP_ENV_VARS) 25 | return 26 | } 27 | 28 | const auth = new Auth({ secureStore }) 29 | const profiles = await auth.getSavedProfiles() 30 | 31 | if (profiles && profiles.length) { 32 | const profileExists = !!profiles.filter((profile) => profile.account === account)?.length 33 | if (profileExists) { 34 | const { subdomain, domain } = getProfileFromAccount(account) 35 | await auth.setLoggedInProfile(subdomain, domain) 36 | console.log(chalk.green(`Switched to ${account} profile.`)) 37 | } else { 38 | console.log(chalk.red(`Failed to find ${account} profile.`)) 39 | } 40 | } else { 41 | console.log(chalk.red(`Failed to find ${account} profile.`)) 42 | } 43 | } 44 | } 45 | -------------------------------------------------------------------------------- /packages/zcli/src/index.js: -------------------------------------------------------------------------------- 1 | export default {} 2 | -------------------------------------------------------------------------------- /packages/zcli/src/types.ts: -------------------------------------------------------------------------------- 1 | export interface Credentials { 2 | account: string; 3 | password: string; 4 | } 5 | 6 | export interface Profile { 7 | subdomain: string; 8 | } 9 | -------------------------------------------------------------------------------- /packages/zcli/src/utils/helpMessage.ts: -------------------------------------------------------------------------------- 1 | export const HELP_ENV_VARS = ` 2 | You can use credentials stored in environment variables: 3 | 4 | ZENDESK_SUBDOMAIN = your account subdomain 5 | ZENDESK_EMAIL = your account email 6 | ZENDESK_API_TOKEN = your account api token see https://{subdomain}.zendesk.com/agent/admin/api/settings 7 | 8 | Once these environment variables are set, zcli profile is not required for authentication and will be ignored. 9 | ` 10 | -------------------------------------------------------------------------------- /packages/zcli/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "declaration": true, 4 | "importHelpers": true, 5 | "module": "commonjs", 6 | "outDir": "dist", 7 | "rootDir": "src", 8 | "strict": true, 9 | "target": "es2017" 10 | }, 11 | "include": [ 12 | "src/**/*" 13 | ] 14 | } 15 | -------------------------------------------------------------------------------- /scripts/generate_dev_docs.sh: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | 3 | # exit when any command fails 4 | set -e 5 | 6 | echo '🔄 Updating dev docs for all packages' 7 | 8 | dirname="zcli" 9 | readme="./packages/$dirname/README.md" 10 | 11 | cd "./packages/$dirname/" 12 | echo "Generating docs for $dirname" 13 | npx oclif readme --dir ../../docs --multi 14 | echo "✅ Done" 15 | cd ../.. 16 | 17 | git add packages/**/*.md 18 | changed_files_count=$(git diff --cached --numstat | wc -l | xargs) 19 | echo "Detected $changed_files_count file changes" 20 | git commit -m "Generate docs" 21 | 22 | exit 0 23 | -------------------------------------------------------------------------------- /scripts/git_check.sh: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | 3 | # git_check determines whether there are any uncommitted changes 4 | # the key purpose of this check is to run after `yarn install` in the test GA workflow 5 | # so as to confirm that `yarn install` does not cause a delta in any of the lock files 6 | 7 | delta=$(git status -s | wc -c | awk '{$1=$1};1') 8 | if((delta > 0)); then 9 | echo "There are uncommited changes." 10 | exit 1 11 | fi 12 | 13 | echo "No uncommited changes." 14 | exit 0 15 | -------------------------------------------------------------------------------- /scripts/link_dev.sh: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env bash 2 | 3 | set -e 4 | 5 | DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" >/dev/null 2>&1 && pwd)" 6 | JAVASCRIPT_ENTRYPOINT_PATH="$DIR/../packages/zcli/bin/run" 7 | if [[ "$OSTYPE" == "cygwin" ]]; then 8 | # Cygwin relies on foreign Node.js installations, which recognise Windows paths 9 | # shellcheck disable=1003 10 | JAVASCRIPT_ENTRYPOINT_PATH="$(cygpath -wa "$JAVASCRIPT_ENTRYPOINT_PATH" | tr '\\' '/')" 11 | fi 12 | 13 | # link zcli-core & zcli-apps into ./packages/zcli/node_modules/@zendesk/ 14 | npx lerna link 15 | 16 | # determine where we should install the stub to 17 | YARN_GLOBAL_BIN_DIR="$(yarn global bin)" 18 | TYPESCRIPT_ENTRYPOINT_PATH="$YARN_GLOBAL_BIN_DIR/zcli" 19 | mkdir -p "$YARN_GLOBAL_BIN_DIR" 20 | 21 | printf '\n\nSetting up %s with contents below for ZCLI development\n\n' "$TYPESCRIPT_ENTRYPOINT_PATH" 22 | touch "$TYPESCRIPT_ENTRYPOINT_PATH" 23 | chmod +x "$TYPESCRIPT_ENTRYPOINT_PATH" 24 | tee "$TYPESCRIPT_ENTRYPOINT_PATH" < $CURR_DIR/package-tmp.json && mv $CURR_DIR/package-tmp.json $CURR_DIR/package.json 9 | jq '.oclif.commands = "./dist/commands"' $CURR_DIR/package.json > $CURR_DIR/package-tmp.json && mv $CURR_DIR/package-tmp.json $CURR_DIR/package.json 10 | -------------------------------------------------------------------------------- /scripts/release.sh: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env bash 2 | 3 | # exit when any command fails 4 | set -e 5 | 6 | if [[ "$(yarn config get @zendesk:registry)" == *'jfrog'* ]]; then 7 | # https://github.com/yarnpkg/yarn/issues/5310 8 | printf 'Please remove Artifactory configurations from ~/.npmrc first\n' 9 | exit 1 10 | fi 11 | 12 | if ! npm whoami &> /dev/null; then 13 | printf 'Please make sure you are logged into NPM\n' 14 | exit 1 15 | fi 16 | 17 | if [[ "$(git branch --show-current)" != "master" ]]; then 18 | printf 'Your are not on master branch at the moment. Really continue? [y/n] ' 19 | read -n1 -r; printf '\n' 20 | if [[ "$REPLY" != 'y' ]]; then 21 | printf 'Aborted\n' 22 | exit 0 23 | fi 24 | fi 25 | 26 | echo '🔄 Generate tag, update docs and changelog' 27 | yarn install 28 | 29 | # TODO: move custom docs part of app.md etc to another place, 30 | # so we can continue to run generate_dev_docs.sh script 31 | # ./scripts/generate_dev_docs.sh 32 | 33 | # Remove beta once we are out of it 34 | npx lerna publish --conventional-commits --yes --preid 'beta' 35 | echo "✅ Done" 36 | exit 0 37 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "declaration": true, 4 | "importHelpers": true, 5 | "module": "commonjs", 6 | "outDir": "lib", 7 | "rootDir": "src", 8 | "strict": false, 9 | "target": "es2017" 10 | }, 11 | "include": [ 12 | "src/**/*" 13 | ] 14 | } 15 | --------------------------------------------------------------------------------