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