├── .ruby-version
├── .rspec
├── cucumber.yml
├── Gemfile
├── .gitignore
├── features
├── support
│ ├── env.rb
│ ├── helpers.rb
│ └── webmock.rb
├── fixtures
│ └── quote_character_translation.json
├── clean.feature
├── validate.feature
├── create.feature
├── package.feature
├── new.feature
└── step_definitions
│ └── app_steps.rb
├── scripts
├── compile.sh
└── invoke.sh
├── lib
├── zendesk_apps_tools
│ ├── version.rb
│ ├── locale_identifier.rb
│ ├── array_patch.rb
│ ├── directory.rb
│ ├── command_helpers.rb
│ ├── bump.rb
│ ├── package_helper.rb
│ ├── cache.rb
│ ├── manifest_handler.rb
│ ├── theming
│ │ ├── zass_formatter.rb
│ │ ├── common.rb
│ │ └── server.rb
│ ├── api_connection.rb
│ ├── common.rb
│ ├── server.rb
│ ├── settings.rb
│ ├── deploy.rb
│ ├── theme.rb
│ ├── translate.rb
│ └── command.rb
└── zendesk_apps_tools.rb
├── app_template_iframe
├── assets
│ ├── logo.png
│ ├── logo-small.png
│ ├── logo.svg
│ └── iframe.html
├── README.md
├── translations
│ └── en.json
└── manifest.json.tt
├── spec
├── fixture
│ ├── config
│ │ ├── settings.yml
│ │ └── settings.json
│ ├── i18n_app_to_yml
│ │ ├── manifest.json
│ │ └── translations
│ │ │ ├── en.json
│ │ │ └── expected.yml
│ ├── i18n_app_pseudotranslate
│ │ └── translations
│ │ │ ├── expected.json
│ │ │ └── en.yml
│ └── i18n_app_to_json
│ │ └── translations
│ │ ├── expected.json
│ │ └── en.yml
├── lib
│ └── zendesk_apps_tools
│ │ ├── locale_identifier_spec.rb
│ │ ├── array_patch_spec.rb
│ │ ├── bump_spec.rb
│ │ ├── cache_spec.rb
│ │ ├── common_spec.rb
│ │ ├── deploy_spec.rb
│ │ ├── api_connection_spec.rb
│ │ ├── settings_spec.rb
│ │ ├── translate_spec.rb
│ │ └── command_spec.rb
└── spec_helper.rb
├── .travis.yml
├── Dockerfile
├── Rakefile
├── templates
└── translation.erb.tt
├── .github
├── workflows
│ ├── publish.yml
│ └── actions.yml
└── pull_request_template.md
├── bin
└── zat
├── doc
├── development.md
└── tools.md
├── zendesk_apps_tools.gemspec
├── README.md
├── Gemfile.lock
└── LICENSE
/.ruby-version:
--------------------------------------------------------------------------------
1 | 3.1.0
2 |
--------------------------------------------------------------------------------
/.rspec:
--------------------------------------------------------------------------------
1 | --color
2 | --format progress
3 |
--------------------------------------------------------------------------------
/cucumber.yml:
--------------------------------------------------------------------------------
1 | ---
2 | default: -f progress
3 |
--------------------------------------------------------------------------------
/Gemfile:
--------------------------------------------------------------------------------
1 | source 'https://rubygems.org'
2 |
3 | gemspec
4 |
--------------------------------------------------------------------------------
/.gitignore:
--------------------------------------------------------------------------------
1 | .idea
2 | .bundle
3 | .byebug_history
4 | .rvmrc
5 | vendor
6 | tmp
7 | pkg
8 |
--------------------------------------------------------------------------------
/features/support/env.rb:
--------------------------------------------------------------------------------
1 | require 'aruba/cucumber'
2 |
3 | Before do
4 | @aruba_timeout_seconds = 15
5 | end
6 |
--------------------------------------------------------------------------------
/scripts/compile.sh:
--------------------------------------------------------------------------------
1 | #!/usr/bin/env bash
2 |
3 | # Build the zat service
4 | docker build -f Dockerfile -t zat .
5 |
--------------------------------------------------------------------------------
/lib/zendesk_apps_tools/version.rb:
--------------------------------------------------------------------------------
1 | # frozen_string_literal: true
2 | module ZendeskAppsTools
3 | VERSION = '3.9.5'
4 | end
5 |
--------------------------------------------------------------------------------
/app_template_iframe/assets/logo.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/zendesk/zendesk_apps_tools/HEAD/app_template_iframe/assets/logo.png
--------------------------------------------------------------------------------
/app_template_iframe/assets/logo-small.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/zendesk/zendesk_apps_tools/HEAD/app_template_iframe/assets/logo-small.png
--------------------------------------------------------------------------------
/spec/fixture/config/settings.yml:
--------------------------------------------------------------------------------
1 | text: text
2 | number: 1
3 | checkbox: yes
4 | array:
5 | - test1
6 | object:
7 | test1: value
8 | falsey: false
9 |
--------------------------------------------------------------------------------
/spec/fixture/config/settings.json:
--------------------------------------------------------------------------------
1 | {
2 | "text": "text",
3 | "number": 1,
4 | "checkbox": true,
5 | "array": [ "test1" ],
6 | "object": {
7 | "test1": "value"
8 | }
9 | }
10 |
--------------------------------------------------------------------------------
/.travis.yml:
--------------------------------------------------------------------------------
1 | ---
2 | language: ruby
3 | dist: trusty
4 | before_install:
5 | gem install bundler -v 1.17.3
6 | rvm:
7 | - 2.3.3
8 | - 2.4.0
9 | branches:
10 | only:
11 | - master
12 | sudo: false
13 | cache: bundler
14 |
--------------------------------------------------------------------------------
/Dockerfile:
--------------------------------------------------------------------------------
1 | FROM ruby:2.6
2 |
3 | COPY . /usr/src/app
4 | WORKDIR /usr/src/app
5 | RUN gem install bundler
6 | RUN bundle install
7 | RUN gem build zendesk_apps_tools.gemspec
8 | RUN gem install zendesk_apps_tools-*.gem
9 | EXPOSE 4567
10 | ENTRYPOINT [ "zat" ]
11 |
--------------------------------------------------------------------------------
/features/support/helpers.rb:
--------------------------------------------------------------------------------
1 | # frozen_string_literal: true
2 | def prepare_env_hash_for(cmd)
3 | if cmd.start_with? 'zat create'
4 | path = File.expand_path('../../support/webmock', __FILE__)
5 | { 'RUBYOPT' => "#{ENV['RUBYOPT']} -r #{path}" }
6 | else
7 | {}
8 | end
9 | end
10 |
--------------------------------------------------------------------------------
/app_template_iframe/README.md:
--------------------------------------------------------------------------------
1 | # App name
2 |
3 | [brief description of the app]
4 |
5 | ### The following information is displayed:
6 |
7 | * info1
8 | * info2
9 | * info3
10 |
11 | Please submit bug reports to [Insert Link](). Pull requests are welcome.
12 |
13 | ### Screenshot(s):
14 | [put your screenshots down here.]
15 |
--------------------------------------------------------------------------------
/lib/zendesk_apps_tools/locale_identifier.rb:
--------------------------------------------------------------------------------
1 | module ZendeskAppsTools
2 | class LocaleIdentifier
3 | attr_reader :locale_id
4 |
5 | # Convert :"en-US-x-12" to 'en-US'
6 | def initialize(code)
7 | @locale_id = code.start_with?('en-US') ? 'en-US' : code.sub(/-x-.*/, '').downcase
8 | end
9 | end
10 | end
11 |
--------------------------------------------------------------------------------
/lib/zendesk_apps_tools.rb:
--------------------------------------------------------------------------------
1 | # frozen_string_literal: true
2 |
3 | module ZendeskAppsTools
4 | autoload :Command, 'zendesk_apps_tools/command'
5 | autoload :Translate, 'zendesk_apps_tools/translate'
6 | autoload :Theme, 'zendesk_apps_tools/theme'
7 | autoload :LocaleIdentifier, 'zendesk_apps_tools/locale_identifier'
8 | end
9 |
--------------------------------------------------------------------------------
/app_template_iframe/translations/en.json:
--------------------------------------------------------------------------------
1 | {
2 | "app": {
3 | "name": "Zen Tunes",
4 | "short_description": "Play the famous zen tunes in your help desk.",
5 | "long_description": "Play the famous zen tunes in your help desk and \n listen to the beats it has to offer.",
6 | "installation_instructions": "Simply click install."
7 | }
8 | }
9 |
--------------------------------------------------------------------------------
/spec/fixture/i18n_app_to_yml/manifest.json:
--------------------------------------------------------------------------------
1 | {
2 | "name": "Sample App for I18n",
3 | "author": {
4 | "name": "zam",
5 | "email": "zam@zendesk.com"
6 | },
7 | "defaultLocale": "en",
8 | "private": true,
9 | "single_install": true,
10 | "location": ["ticket_sidebar", "new_ticket_sidebar"],
11 | "frameworkVersion": "1.0"
12 | }
13 |
--------------------------------------------------------------------------------
/features/fixtures/quote_character_translation.json:
--------------------------------------------------------------------------------
1 | {
2 | "app": {
3 | "name": "Zen Tunes",
4 | "short_description": "Play the famous zen tunes in your help desk.",
5 | "long_description": "Play the famous zen tunes in your help desk and \n listen to the beats it has to offer.",
6 | "installation_instructions": "Simply click install."
7 | }
8 | }
9 |
--------------------------------------------------------------------------------
/Rakefile:
--------------------------------------------------------------------------------
1 | require 'bundler/gem_tasks'
2 | require 'rake/clean'
3 | require 'cucumber/rake/task'
4 | require 'rspec/core/rake_task'
5 | require 'bump/tasks'
6 |
7 | RSpec::Core::RakeTask.new(:spec)
8 |
9 | CLEAN << 'tmp'
10 |
11 | Cucumber::Rake::Task.new do |t|
12 | t.cucumber_opts = %w(--format progress)
13 | end
14 |
15 | task default: [:spec, :cucumber]
16 |
--------------------------------------------------------------------------------
/templates/translation.erb.tt:
--------------------------------------------------------------------------------
1 | ---
2 | title: "<%= @app_name %>"
3 | packages:
4 | - default
5 | - app_<%= @package_name %>
6 |
7 | parts:
8 | <% @translations.each do |key, title_value| -%>
9 | - translation:
10 | key: "txt.apps.<%= @package_name %>.<%= key %>"
11 | title: "<%= title_value["title"] %>"
12 | value: "<%= title_value["value"] %>"
13 | <% end %>
--------------------------------------------------------------------------------
/.github/workflows/publish.yml:
--------------------------------------------------------------------------------
1 | name: Ruby Gem Publish
2 |
3 | on:
4 | push:
5 | tags:
6 | - v*
7 |
8 | jobs:
9 | publish:
10 | name: Publish to rubygems.org
11 | uses: zendesk/gw/.github/workflows/ruby-gem-publication.yml@main
12 | secrets:
13 | RUBY_GEMS_API_KEY: ${{ secrets.RUBY_GEMS_API_KEY }}
14 | RUBY_GEMS_TOTP_DEVICE: ${{ secrets.RUBY_GEMS_TOTP_DEVICE }}
15 |
--------------------------------------------------------------------------------
/features/clean.feature:
--------------------------------------------------------------------------------
1 | Feature: clean tmp folder inside the zendesk app
2 |
3 | Background: create a new zendesk app package
4 | Given an app is created in directory "tmp/aruba"
5 | And I run the command "zat package --path tmp/aruba" to package the app
6 |
7 | Scenario: clean tmp folder
8 | When I run the command "zat clean --path tmp/aruba" to clean the app
9 | Then the zip file in "tmp/aruba/tmp" folder should not exist
10 |
--------------------------------------------------------------------------------
/spec/fixture/i18n_app_to_yml/translations/en.json:
--------------------------------------------------------------------------------
1 | {
2 | "app": {
3 | "package" : "my_app",
4 | "abc" : {
5 | "title" : "description for abc field",
6 | "value" : "value of \"abc\""
7 | }
8 | },
9 | "a": {
10 | "a1": {
11 | "title": "description for a1 field",
12 | "value": "value of a1"
13 | },
14 | "b": {
15 | "b1": {
16 | "title": "description for b1 field",
17 | "value": "value of b1"
18 | }
19 | }
20 | }
21 | }
22 |
--------------------------------------------------------------------------------
/app_template_iframe/manifest.json.tt:
--------------------------------------------------------------------------------
1 | {
2 | "name": "<%= @app_name %>",
3 | "author": {
4 | "name": "<%= @author_name %>",
5 | "email": "<%= @author_email %>",
6 | "url": "<%= @author_url %>"
7 | },
8 | "defaultLocale": "en",
9 | "private": true,
10 | "location": {
11 | "support": {
12 | "ticket_sidebar": {
13 | "url": "<%= @iframe_location %>",
14 | "flexible": true
15 | }
16 | }
17 | },
18 | "version": "1.0",
19 | "frameworkVersion": "2.0"
20 | }
21 |
--------------------------------------------------------------------------------
/spec/fixture/i18n_app_pseudotranslate/translations/expected.json:
--------------------------------------------------------------------------------
1 | {
2 | "app": {
3 | "abc": {
4 | "title": "description for abc field",
5 | "value": "[日本value of abcéñđ]"
6 | },
7 | "package": "my_app"
8 | },
9 | "a": {
10 | "a1": {
11 | "title": "description for a1 field",
12 | "value": "[日本value of a1éñđ]"
13 | },
14 | "b": {
15 | "b1": {
16 | "title": "description for b1 field",
17 | "value": "[日本value of b1éñđ]"
18 | }
19 | }
20 | }
21 | }
22 |
--------------------------------------------------------------------------------
/spec/lib/zendesk_apps_tools/locale_identifier_spec.rb:
--------------------------------------------------------------------------------
1 | require 'spec_helper'
2 | require 'locale_identifier'
3 |
4 | describe ZendeskAppsTools::LocaleIdentifier do
5 | it 'should set locale id to en-US when code starts with en-US' do
6 | expect(ZendeskAppsTools::LocaleIdentifier.new('en-US-x-12').locale_id).to eq('en-US')
7 | end
8 |
9 | it 'should set locale id to zh-cn when code starts with zh-CN' do
10 | expect(ZendeskAppsTools::LocaleIdentifier.new('zh-CN-x-12').locale_id).to eq('zh-cn')
11 | end
12 | end
13 |
--------------------------------------------------------------------------------
/spec/fixture/i18n_app_pseudotranslate/translations/en.yml:
--------------------------------------------------------------------------------
1 | ---
2 | packages:
3 | - default
4 | - app_my_app
5 |
6 | parts:
7 | - translation:
8 | key: "txt.apps.my_app.app.abc"
9 | title: "description for abc field"
10 | value: "value of abc"
11 | - translation:
12 | key: "txt.apps.my_app.a.a1"
13 | title: "description for a1 field"
14 | value: "value of a1"
15 | - translation:
16 | key: "txt.apps.my_app.a.b.b1"
17 | title: "description for b1 field"
18 | value: "value of b1"
19 |
--------------------------------------------------------------------------------
/spec/fixture/i18n_app_to_yml/translations/expected.yml:
--------------------------------------------------------------------------------
1 | ---
2 | title: "Sample App for I18n"
3 | packages:
4 | - default
5 | - app_my_app
6 |
7 | parts:
8 | - translation:
9 | key: "txt.apps.my_app.app.abc"
10 | title: "description for abc field"
11 | value: "value of \"abc\""
12 | - translation:
13 | key: "txt.apps.my_app.a.a1"
14 | title: "description for a1 field"
15 | value: "value of a1"
16 | - translation:
17 | key: "txt.apps.my_app.a.b.b1"
18 | title: "description for b1 field"
19 | value: "value of b1"
20 |
--------------------------------------------------------------------------------
/bin/zat:
--------------------------------------------------------------------------------
1 | #!/usr/bin/env ruby
2 | # frozen_string_literal: true
3 | default_ext = Encoding.default_external
4 | unless default_ext == Encoding::UTF_8
5 | Encoding.default_external = Encoding::UTF_8
6 | puts "Warning: unexpected string encoding: #{default_ext}, zat runs best in UTF-8."
7 | puts 'Please set the RUBYOPT environment variable to "-E utf-8".'
8 | end
9 | lib_dir = File.expand_path('../../lib', __FILE__)
10 | $LOAD_PATH.unshift lib_dir unless $LOAD_PATH.include?(lib_dir)
11 | ENV['THOR_SHELL'] = 'Color'
12 | require 'zendesk_apps_tools'
13 | ZendeskAppsTools::Command.start
14 |
--------------------------------------------------------------------------------
/spec/fixture/i18n_app_to_json/translations/expected.json:
--------------------------------------------------------------------------------
1 | {
2 | "app": {
3 | "abc": {
4 | "title": "description for abc field",
5 | "value": "value of abc"
6 | },
7 | "package": "my_app"
8 | },
9 | "a": {
10 | "a1": {
11 | "title": "description for a1 field",
12 | "value": "value of a1"
13 | },
14 | "b": {
15 | "b1": {
16 | "title": "description for b1 field",
17 | "value": "value of b1"
18 | },
19 | "b2": {
20 | "title": "description for b2 field",
21 | "value": "value of b2"
22 | }
23 | }
24 | }
25 | }
26 |
--------------------------------------------------------------------------------
/features/validate.feature:
--------------------------------------------------------------------------------
1 | Feature: validate a zendesk app
2 |
3 | Validate a zendesk app by running 'zat package' command
4 |
5 | Background: create a new zendesk app
6 | Given an app is created in directory "tmp/aruba"
7 |
8 | Scenario: valid app
9 | When I run the command "zat validate --path tmp/aruba" to validate the app
10 | Then it should pass the validation
11 |
12 | Scenario: invalid app (missing manifest.json
13 | Given I remove file "tmp/aruba/manifest.json"
14 | When I run the command "zat validate --path tmp/aruba" to validate the app
15 | Then the command output should contain "Could not find manifest.json"
16 |
--------------------------------------------------------------------------------
/lib/zendesk_apps_tools/array_patch.rb:
--------------------------------------------------------------------------------
1 | class Array
2 | unless instance_methods.include? :to_h
3 | def to_h
4 | if elem_index = index { |elem| !elem.is_a?(Array) }
5 | raise TypeError.new("wrong element type #{self[elem_index].class} at #{elem_index} (expected array)")
6 | end
7 |
8 | each_with_index.inject({}) do |hash, elem|
9 | pair, index = elem
10 |
11 | if pair.size != 2
12 | raise ArgumentError.new("wrong array length at #{index} (expected 2, was #{pair.size})")
13 | end
14 |
15 | hash.tap do |h|
16 | key, val = pair
17 | h[key] = val
18 | end
19 | end
20 | end
21 | end
22 | end
23 |
--------------------------------------------------------------------------------
/app_template_iframe/assets/logo.svg:
--------------------------------------------------------------------------------
1 |
2 |
11 |
--------------------------------------------------------------------------------
/spec/lib/zendesk_apps_tools/array_patch_spec.rb:
--------------------------------------------------------------------------------
1 | require 'spec_helper'
2 | require 'zendesk_apps_tools/array_patch'
3 |
4 | describe Array do
5 | describe '#to_h' do
6 | subject { [[1,2], [3,4]] }
7 |
8 | it 'works' do
9 | expect(subject.to_h).to eq({ 1 => 2, 3 => 4 })
10 | end
11 |
12 | context 'when it has non array element' do
13 | subject { [1,2,3] }
14 |
15 | it 'raises TypeError' do
16 | expect { subject.to_h }.to raise_error(TypeError)
17 | end
18 | end
19 |
20 | context 'when it has element arrays with wrong size' do
21 | subject { [[1,2,3]] }
22 |
23 | it 'raises ArgumentError' do
24 | expect { subject.to_h }.to raise_error(ArgumentError)
25 | end
26 | end
27 | end
28 | end
29 |
--------------------------------------------------------------------------------
/scripts/invoke.sh:
--------------------------------------------------------------------------------
1 | #!/usr/bin/env bash
2 |
3 | PROGNAME="$(basename $0)"
4 | VERSION="0.0.1"
5 |
6 | # Helper functions for guards
7 | error(){
8 | error_code="$1"
9 | echo "ERROR: $2" >&2
10 | echo "($PROGNAME wrapper version: $VERSION, error code: $error_code )" &>2
11 | exit "$1"
12 | }
13 | check_cmd_in_path(){
14 | command -v "$1" >/dev/null 2>&1 || error 1 "Command '$1' not found in PATH"
15 | }
16 |
17 | # Guards (checks for dependencies)
18 | check_cmd_in_path docker
19 |
20 | # When running the zat container, mount the current directory to /app
21 | # so that zat has access to it.
22 | docker run \
23 | --network="bridge" \
24 | --interactive --tty --rm \
25 | --volume "$PWD":/wd \
26 | --workdir /wd \
27 | -p 4567:4567 \
28 | zat "$@"
29 |
--------------------------------------------------------------------------------
/.github/pull_request_template.md:
--------------------------------------------------------------------------------
1 | :v:
2 |
3 | /cc @zendesk/vegemite
4 |
5 | ### Description
6 | Describe the original problem and the changes made on this PR.
7 |
8 | ### Tasks
9 | - [ ] Include comments/inline docs where appropriate
10 | - [ ] Write tests
11 | - [ ] Update changelog [here](https://github.com/zendesk/zaf_docs/blob/master/doc/v2/dev_guide/changelog.md)
12 |
13 | ### References
14 | * JIRA: https://zendesk.atlassian.net/browse/VEG-XXX
15 |
16 | ### Risks
17 |
24 |
--------------------------------------------------------------------------------
/features/create.feature:
--------------------------------------------------------------------------------
1 | Feature: upload an app to the apps marketplace
2 | Background: create a new zendesk app
3 |
4 | Scenario: package a zendesk app by running 'zat create --no-install' command
5 | Given an app is created in directory "tmp/aruba"
6 | Given a .zat file in "tmp/aruba"
7 | When I run the command "zat create --path tmp/aruba --no-install" to create the app
8 | And the command output should contain "info Checking for new version of zendesk_apps_tools"
9 | And the command output should contain "validate OK"
10 | And the command output should contain "package created"
11 | And the command output should contain "Status working"
12 | And the command output should contain "Create OK"
13 | And the zip file should exist in directory "tmp/aruba/tmp"
14 |
--------------------------------------------------------------------------------
/lib/zendesk_apps_tools/directory.rb:
--------------------------------------------------------------------------------
1 | module ZendeskAppsTools
2 | module Directory
3 | def app_dir
4 | @app_dir ||= Pathname.new(destination_root)
5 | end
6 |
7 | def tmp_dir
8 | @tmp_dir ||= Pathname.new(File.join(app_dir, 'tmp')).tap do |dir|
9 | FileUtils.mkdir_p(dir)
10 | end
11 | end
12 |
13 | def prompt_new_app_dir
14 | prompt = "Enter a directory name to save the new app (will create the dir if it does not exist):\n"
15 | opts = { valid_regex: /^(.|\w|\/|\\)*$/, default: './' }
16 | while @app_dir = get_value_from_stdin(prompt, opts)
17 | if !File.exist?(@app_dir)
18 | break
19 | elsif !File.directory?(@app_dir)
20 | say_error 'Invalid dir, try again:'
21 | else
22 | break
23 | end
24 | end
25 | end
26 | end
27 | end
28 |
--------------------------------------------------------------------------------
/doc/development.md:
--------------------------------------------------------------------------------
1 | ## Running ZAT locally
2 |
3 | If you want to run ZAT locally, one way to do this is to use the Docker image.
4 |
5 | (This works around various issues associated to having certain Ruby versions installed on the host machine and other odds and ends.)
6 |
7 | In particular, to run ZAT in this mode:
8 |
9 | * Install Docker on your host machine.
10 | * Clone the ZAT repository.
11 | * Run the following command in the ZAT repository:
12 |
13 | ```
14 | ./scripts/compile.sh
15 | ```
16 | This will build the Docker image for zat.
17 | * Append scripts/invoke.sh to your PATH, or alternatively alias it in your .bashrc, .zshrc, or .profile.
18 | * For example, if you were to append alias zt=/User/your_user/zat/scripts/invoke.sh to your .bashrc, you would be able to run zt to run ZAT locally.
19 | ```
20 | >zt help new
21 | Usage:
22 | zat new
23 |
24 | Options:
25 |
26 | Generate a new app
27 | ```
--------------------------------------------------------------------------------
/spec/spec_helper.rb:
--------------------------------------------------------------------------------
1 | # frozen_string_literal: true
2 |
3 | # This file was generated by the `rspec --init` command. Conventionally, all
4 | # specs live under a `spec` directory, which RSpec adds to the `$LOAD_PATH`.
5 | # Require this file using `require "spec_helper"` to ensure that it is only
6 | # loaded once.
7 | #
8 | # See http://rubydoc.info/gems/rspec-core/RSpec/Core/Configuration
9 |
10 | require 'webmock/rspec'
11 | require 'zendesk_apps_tools'
12 |
13 | $LOAD_PATH.unshift(File.dirname(__FILE__) + '/../lib')
14 | $LOAD_PATH.unshift(File.dirname(__FILE__) + '/../lib/zendesk_apps_tools')
15 |
16 | RSpec.configure do |config|
17 | config.run_all_when_everything_filtered = true
18 | config.filter_run :focus
19 |
20 | # Run specs in random order to surface order dependencies. If you find an
21 | # order dependency and want to debug it, you can fix the order by providing
22 | # the seed, which is printed after each run.
23 | # --seed 1234
24 | config.order = 'random'
25 | end
26 |
--------------------------------------------------------------------------------
/app_template_iframe/assets/iframe.html:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 |
10 |
11 |
12 |
13 | Hello, World!
14 |
15 |
21 |
22 |
23 |
--------------------------------------------------------------------------------
/lib/zendesk_apps_tools/command_helpers.rb:
--------------------------------------------------------------------------------
1 | # frozen_string_literal: true
2 | require 'zendesk_apps_tools/common'
3 | require 'zendesk_apps_tools/api_connection'
4 | require 'zendesk_apps_tools/deploy'
5 | require 'zendesk_apps_tools/directory'
6 | require 'zendesk_apps_tools/package_helper'
7 | require 'zendesk_apps_tools/translate'
8 | require 'zendesk_apps_tools/bump'
9 |
10 | module ZendeskAppsTools
11 | module CommandHelpers
12 | include ZendeskAppsTools::Common
13 | include ZendeskAppsTools::APIConnection
14 | include ZendeskAppsTools::Deploy
15 | include ZendeskAppsTools::Directory
16 | include ZendeskAppsTools::PackageHelper
17 |
18 | def self.included(base)
19 | base.extend(ClassMethods)
20 | end
21 |
22 | def cache
23 | @cache ||= begin
24 | require 'zendesk_apps_tools/cache'
25 | Cache.new(options)
26 | end
27 | end
28 |
29 | def setup_path(path)
30 | @destination_stack << relative_to_original_destination_root(path) unless @destination_stack.last == path
31 | end
32 | end
33 | end
34 |
--------------------------------------------------------------------------------
/features/support/webmock.rb:
--------------------------------------------------------------------------------
1 | # frozen_string_literal: true
2 |
3 | # we only want this file to monkey-patch zat when we shell out to it, but cucumber automatically requires it as well.
4 | unless defined?(Cucumber)
5 |
6 | require 'webmock'
7 |
8 | WebMock::API.stub_request(:post, 'https://app-account.zendesk.com/api/v2/apps/uploads.json').to_return(body: JSON.dump(id: '123'))
9 | WebMock::API.stub_request(:post, 'https://app-account.zendesk.com/api/apps.json').with(body: JSON.dump(name: 'John Test App', upload_id: '123')).to_return(body: JSON.dump(job_id: '987'))
10 | WebMock::API.stub_request(:get, 'https://app-account.zendesk.com/api/v2/apps/job_statuses/987').to_return(body: JSON.dump(status: 'working')).then.to_return(body: JSON.dump(status: 'completed', app_id: '55'))
11 | WebMock::API.stub_request(:get, 'https://rubygems.org/api/v1/gems/zendesk_apps_tools.json').to_return(body: JSON.dump(status: 'working', version: '1.2.3')).then.to_return(body: JSON.dump(name: 'zendesk_apps_tools', version: '2.1.1'))
12 |
13 | WebMock.enable!
14 | WebMock.disable_net_connect!
15 | end
16 |
--------------------------------------------------------------------------------
/spec/fixture/i18n_app_to_json/translations/en.yml:
--------------------------------------------------------------------------------
1 | ---
2 | title: "Sample App for I18n"
3 | packages:
4 | - default
5 | - app_my_app
6 |
7 | parts:
8 | - translation:
9 | key: "txt.apps.my_app.app.abc"
10 | title: "description for abc field"
11 | value: "value of abc"
12 | - translation:
13 | key: "txt.apps.my_app.a.a1"
14 | title: "description for a1 field"
15 | value: "value of a1"
16 | - translation:
17 | key: "txt.apps.my_app.a.b.b1"
18 | title: "description for b1 field"
19 | value: "value of b1"
20 | obsolete: 2200-01-01
21 | - translation:
22 | key: "txt.apps.my_app.a.b.b2"
23 | title: "description for b2 field"
24 | value: "value of b2"
25 | obsolete: "2200-01-01"
26 | - translation:
27 | key: "txt.apps.my_app.a.b.b3"
28 | title: "description for b3 field"
29 | value: "value of b3"
30 | obsolete: 2017-01-01
31 | - translation:
32 | key: "txt.apps.my_app.a.b.b4"
33 | title: "description for b4 field"
34 | value: "value of b4"
35 | obsolete: "2017-01-01"
36 |
--------------------------------------------------------------------------------
/features/package.feature:
--------------------------------------------------------------------------------
1 | Feature: package a zendesk app into a zip file
2 |
3 | Background: create a new zendesk app
4 |
5 |
6 | Scenario: package a zendesk app by running 'zat package' command
7 | Given an app is created in directory "tmp/aruba"
8 | When I run the command "zat package --path tmp/aruba" to package the app
9 | And the command output should contain "adding assets/iframe.html"
10 | And the command output should contain "adding assets/logo-small.png"
11 | And the command output should contain "adding assets/logo.png"
12 | And the command output should contain "adding manifest.json"
13 | And the command output should contain "adding translations/en.json"
14 | And the command output should contain "created"
15 | And the zip file should exist in directory "tmp/aruba/tmp"
16 |
17 | Scenario: package a zendesk app by running 'zat package' command
18 | Given an app is created in directory "tmp/aruba"
19 | When I create a symlink from "./templates/translation.erb.tt" to "tmp/aruba/assets/translation.erb.tt"
20 | Then "tmp/aruba/assets/translation.erb.tt" should be a symlink
21 | When I run the command "zat package --path tmp/aruba" to package the app
22 | Then the zip file in "tmp/aruba/tmp" should not contain any symlinks
23 |
--------------------------------------------------------------------------------
/lib/zendesk_apps_tools/bump.rb:
--------------------------------------------------------------------------------
1 | require 'thor'
2 | require 'zendesk_apps_tools/manifest_handler'
3 |
4 | module ZendeskAppsTools
5 | class Bump < Thor
6 | include Thor::Actions
7 | prepend ManifestHandler
8 |
9 | SHARED_OPTIONS = {
10 | ['commit', '-c'] => false,
11 | ['message', '-m'] => nil,
12 | ['tag', '-t'] => false
13 | }
14 |
15 | desc 'major', 'Bump major version'
16 | method_options SHARED_OPTIONS
17 | def major
18 | semver[:major] += 1
19 | semver[:minor] = 0
20 | semver[:patch] = 0
21 | end
22 |
23 | desc 'minor', 'Bump minor version'
24 | method_options SHARED_OPTIONS
25 | def minor
26 | semver[:minor] += 1
27 | semver[:patch] = 0
28 | end
29 |
30 | desc 'patch', 'Bump patch version'
31 | method_options SHARED_OPTIONS
32 | def patch
33 | semver[:patch] += 1
34 | end
35 |
36 | default_task :patch
37 |
38 | private
39 |
40 | def post_actions
41 | return tag if options[:tag]
42 | commit if options[:commit]
43 | end
44 |
45 | def commit
46 | `git commit -am #{commit_message}`
47 | end
48 |
49 | def commit_message
50 | options[:message] || version(v: true)
51 | end
52 |
53 | def tag
54 | commit
55 | `git tag #{version(v: true)}`
56 | end
57 | end
58 | end
59 |
--------------------------------------------------------------------------------
/.github/workflows/actions.yml:
--------------------------------------------------------------------------------
1 | name: repo-checks
2 |
3 | on:
4 | pull_request:
5 | push:
6 | branches:
7 | - master
8 |
9 | jobs:
10 | main:
11 | name: ruby
12 | runs-on: ubuntu-latest
13 | strategy:
14 | matrix:
15 | ruby-version:
16 | - "3.1"
17 | - "3.2"
18 | - "3.3"
19 | steps:
20 | - uses: zendesk/checkout@v3
21 | - uses: zendesk/setup-ruby@v1
22 | with:
23 | ruby-version: ${{ matrix.ruby-version }}
24 | - name: Vendor Cache
25 | id: vendor-cache
26 | uses: zendesk/cache@v3
27 | with:
28 | path: vendor/cache
29 | key: ${{ runner.os }}-vendor-ruby-${{ matrix.ruby-version }}-lock-${{ hashFiles('Gemfile.lock') }}
30 | restore-keys: |
31 | ${{ runner.os }}-vendor-ruby-${{ matrix.ruby-version }}-
32 | - name: before_script
33 | run: |
34 | gem update --system 3.3.22
35 | bundle config set frozen true
36 | bundle config set --local path 'vendor/cache'
37 | bundle install --jobs=3 --retry=3
38 | gem build zendesk_apps_tools.gemspec
39 | GEM_VERSION="$(sed -En "s/^.*VERSION = '([[:digit:]]+\\.[[:digit:]]+\\.[[:digit:]]+(\\.(beta|pre|rc)([[:digit:]]+|\\.?[[:digit:]]+)?)?)'$/\\1/p" lib/zendesk_apps_tools/version.rb)"
40 | gem install "./zendesk_apps_tools-$GEM_VERSION.gem"
41 | - name: Test
42 | run: |
43 | bundle exec rake
44 |
--------------------------------------------------------------------------------
/lib/zendesk_apps_tools/package_helper.rb:
--------------------------------------------------------------------------------
1 | module ZendeskAppsTools
2 |
3 | module PackageHelper
4 | def app_package
5 | require 'zendesk_apps_support'
6 | @app_package ||= ZendeskAppsSupport::Package.new(app_dir.to_s)
7 | end
8 |
9 | def manifest
10 | require 'zendesk_apps_support'
11 | require 'zendesk_apps_support/manifest/no_override_hash'
12 | begin
13 | @manifest ||= app_package.manifest
14 | rescue JSON::ParserError, ZendeskAppsSupport::Manifest::OverrideError => e
15 | say_status "error", "Manifest file is incorrectly formatted: #{e.message}", :red and exit 1
16 | rescue Errno::ENOENT
17 | say_status "error", "Manifest file cannot be found in the given path. Check you are pointing to the path that contains your manifest.json", :red and exit 1
18 | end
19 | end
20 |
21 | def zip(archive_path)
22 | require 'zip'
23 | Zip::File.open(archive_path, 'w') do |zipfile|
24 | app_package.files.each do |file|
25 | relative_path = file.relative_path
26 | path = relative_path
27 | say_status 'package', "adding #{path}"
28 |
29 | # resolve symlink to source path
30 | if File.symlink? file.absolute_path
31 | path = File.expand_path(File.readlink(file.absolute_path), File.dirname(file.absolute_path))
32 | end
33 | if file.to_s == 'app.scss'
34 | relative_path = relative_path.sub 'app.scss', 'app.css'
35 | end
36 | zipfile.add(relative_path, app_dir.join(path).to_s)
37 | end
38 | end
39 | end
40 | end
41 | end
42 |
--------------------------------------------------------------------------------
/lib/zendesk_apps_tools/cache.rb:
--------------------------------------------------------------------------------
1 | # frozen_string_literal: true
2 | require 'json'
3 |
4 | module ZendeskAppsTools
5 | class Cache
6 | CACHE_FILE_NAME = '.zat'
7 |
8 | attr_reader :options
9 |
10 | def initialize(options)
11 | @options = options
12 | end
13 |
14 | def save(hash)
15 | return if options[:zipfile]
16 |
17 | local_cache.update(hash)
18 | File.open(local_cache_path, 'w') { |f| f.write JSON.pretty_generate(local_cache) }
19 | end
20 |
21 | def fetch(key, subdomain = nil)
22 | # drop the default_proc and replace with Hash#dig if older Ruby versions are unsupported
23 | local_cache[key] || global_cache[subdomain][key] || global_cache['default'][key]
24 | end
25 |
26 | def clear
27 | File.delete local_cache_path if options[:clean] && File.exist?(local_cache_path)
28 | end
29 |
30 | private
31 |
32 | def local_cache
33 | @local_cache ||= File.exist?(local_cache_path) ? JSON.parse(File.read(local_cache_path)) : {}
34 | end
35 |
36 | def global_cache
37 | @global_cache ||= begin
38 | if File.exist?(global_cache_path)
39 | JSON.parse(File.read(global_cache_path)).tap do |cache|
40 | cache.default_proc = proc do |_hash, _key|
41 | {}
42 | end
43 | end
44 | else
45 | Hash.new({})
46 | end
47 | end
48 | end
49 |
50 | def global_cache_path
51 | @global_cache_path ||= File.join(Dir.home, CACHE_FILE_NAME)
52 | end
53 |
54 | def local_cache_path
55 | @local_cache_path ||= File.join(options[:path], CACHE_FILE_NAME)
56 | end
57 | end
58 | end
59 |
--------------------------------------------------------------------------------
/features/new.feature:
--------------------------------------------------------------------------------
1 | Feature: create a template for a new zendesk app
2 |
3 | Scenario: Create a new app in an existing directory
4 | Given an app directory "tmp/aruba" exists
5 | And I move to the app directory
6 | When I run "zat new" command with the following details:
7 | | author name | John Citizen |
8 | | author email | john@example.com |
9 | | author url | http://myapp.com |
10 | | app name | John Test App |
11 | | iframe uri | assets/iframe.html |
12 | | app dir | |
13 |
14 | Then the app file "manifest.json" is created
15 | And I reset the working directory
16 |
17 | Scenario: create a template for a new iframe only app by running 'zat new' command
18 | Given an app directory "tmp/aruba" exists
19 | And I move to the app directory
20 | When I run "zat new" command with the following details:
21 | | author name | John Citizen |
22 | | author email | john@example.com |
23 | | author url | http://myapp.com |
24 | | app name | John Test App |
25 | | iframe uri | assets/iframe.html |
26 | | app dir | tmp/aruba |
27 |
28 | Then the app file "tmp/aruba/manifest.json" is created with:
29 | """
30 | {
31 | "name": "John Test App",
32 | "author": {
33 | "name": "John Citizen",
34 | "email": "john@example.com",
35 | "url": "http://myapp.com"
36 | },
37 | "defaultLocale": "en",
38 | "private": true,
39 | "location": {
40 | "support": {
41 | "ticket_sidebar": {
42 | "url": "assets/iframe.html",
43 | "flexible": true
44 | }
45 | }
46 | },
47 | "version": "1.0",
48 | "frameworkVersion": "2.0"
49 | }
50 | """
51 | And I reset the working directory
52 |
--------------------------------------------------------------------------------
/lib/zendesk_apps_tools/manifest_handler.rb:
--------------------------------------------------------------------------------
1 | require 'zendesk_apps_tools/array_patch'
2 |
3 | module ZendeskAppsTools
4 | module ManifestHandler
5 | VERSION_PARTS = %i(major minor patch)
6 |
7 | attr_reader :semver
8 |
9 | VERSION_PARTS.each do |m|
10 | define_method m do
11 | load_manifest
12 | read_version
13 | normalize_version
14 | super()
15 | update_version
16 | write_manifest
17 | post_actions
18 | end
19 | end
20 |
21 | private
22 |
23 | def manifest_json_path
24 | 'manifest.json'
25 | end
26 |
27 | def load_manifest
28 | require 'json'
29 | manifest_json = File.read(manifest_json_path)
30 | @manifest = JSON.load(manifest_json)
31 | rescue => e
32 | say(e.message, :red) and exit 1
33 | end
34 |
35 | def read_version
36 | version = @manifest.fetch('version', '0.0.0')
37 | sem_parts = sub_semver(version)
38 | @semver = sem_parts.names.map(&:to_sym).zip(sem_parts.to_a.drop(1)).to_h
39 | end
40 |
41 | def normalize_version
42 | VERSION_PARTS.each do |part|
43 | semver[part] = (semver[part] || '0').to_i
44 | end
45 | end
46 |
47 | def update_version
48 | @manifest['version'] = version
49 | end
50 |
51 | def write_manifest
52 | File.open(manifest_json_path, 'w') do |f|
53 | f.write(JSON.pretty_generate(@manifest))
54 | f.write("\n")
55 | end
56 | end
57 |
58 | def sub_semver(v)
59 | v.match(/\A(?v)?(?\d+)(?:\.(?\d+)(?:\.(?\d+))?)?\Z/)
60 | end
61 |
62 | def version(v: false)
63 | [
64 | v ? 'v' : semver[:v],
65 | [
66 | semver[:major],
67 | semver[:minor],
68 | semver[:patch]
69 | ].compact.map(&:to_s).join('.')
70 | ].compact.join
71 | end
72 | end
73 | end
74 |
--------------------------------------------------------------------------------
/spec/lib/zendesk_apps_tools/bump_spec.rb:
--------------------------------------------------------------------------------
1 | require 'spec_helper'
2 | require 'zendesk_apps_tools/bump'
3 |
4 | describe ZendeskAppsTools::Bump do
5 | VERSION_PARTS = ZendeskAppsTools::ManifestHandler::VERSION_PARTS
6 |
7 | subject { ZendeskAppsTools::Bump.new }
8 |
9 | def version
10 | subject.instance_variable_get(:@manifest).fetch('version')
11 | end
12 |
13 | before do
14 | allow(subject).to receive(:load_manifest)
15 | allow(subject).to receive(:write_manifest)
16 | subject.instance_variable_set(:@manifest, { 'version' => '1.2.3' })
17 | end
18 |
19 | describe '#major' do
20 | before { subject.major }
21 |
22 | it 'bumps major version, and resets minor and patch version' do
23 | expect(version).to eq('2.0.0')
24 | end
25 | end
26 |
27 | describe '#minor' do
28 | before { subject.minor }
29 |
30 | it 'bumps minor version, and resets patch version' do
31 | expect(version).to eq('1.3.0')
32 | end
33 | end
34 |
35 | describe '#patch' do
36 | before { subject.patch }
37 |
38 | it 'bumps patch version' do
39 | expect(version).to eq('1.2.4')
40 | end
41 | end
42 |
43 | context 'when version is not complete semver' do
44 | before do
45 | subject.instance_variable_set(:@manifest, { 'version' => '1.0' })
46 | end
47 |
48 | it 'works with incomplete semver version' do
49 | VERSION_PARTS.each do |part|
50 | expect { subject.send(part) }.not_to raise_error
51 | end
52 | end
53 |
54 | it 'corrects the version to semver' do
55 | subject.patch
56 | expect(version).to eq('1.0.1')
57 | end
58 |
59 | it 'corrects the version to semver' do
60 | subject.minor
61 | expect(version).to eq('1.1.0')
62 | end
63 |
64 | it 'corrects the version to semver' do
65 | subject.major
66 | expect(version).to eq('2.0.0')
67 | end
68 | end
69 | end
70 |
--------------------------------------------------------------------------------
/lib/zendesk_apps_tools/theming/zass_formatter.rb:
--------------------------------------------------------------------------------
1 | # frozen_string_literal: true
2 |
3 | require 'sass'
4 | require 'English'
5 |
6 | module ZendeskAppsTools
7 | module Theming
8 | class ZassFormatter
9 | COMMAND = /(?lighten|darken)/i
10 | COLOR = /(?#\h{6}|#\h{3})/
11 | PERCENTAGE = /(?\d{1,3})%/
12 | COLOR_COMMAND_REGEXP = /#{COMMAND}\s*\(\s*#{COLOR}\s*,\s*#{PERCENTAGE}\s*\)/
13 |
14 | def self.format(raw, variables)
15 | joined_keys = "(#{variables.keys.join('|')})"
16 |
17 | keys_regex = /(\$#{joined_keys}\b)|(#\{\$#{joined_keys}\})/
18 |
19 | substitution_hash = variables.each_with_object({}) do |(k, v), hash|
20 | hash["$#{k}"] = v.to_s
21 | hash["\#{$#{k}}"] = v.to_s
22 | end
23 |
24 | body = raw.to_s.dup
25 | body.gsub!(keys_regex, substitution_hash)
26 |
27 | # Color manipulation
28 | body.gsub!(COLOR_COMMAND_REGEXP) do |whole_match|
29 | if $LAST_MATCH_INFO[:command] == 'lighten'
30 | color_adjust($LAST_MATCH_INFO[:color], $LAST_MATCH_INFO[:percentage].to_i, :+) || whole_match
31 | else
32 | color_adjust($LAST_MATCH_INFO[:color], $LAST_MATCH_INFO[:percentage].to_i, :-) || whole_match
33 | end
34 | end
35 |
36 | body
37 | end
38 |
39 | def self.color_adjust(rgb, percentage, op)
40 | color = Sass::Script::Value::Color.from_hex(rgb)
41 |
42 | # Copied from:
43 | # https://github.com/sass/sass/blob/3.4.21/lib/sass/script/functions.rb#L2671
44 | new_color = color.with(lightness: color.lightness.public_send(op, percentage))
45 |
46 | # We set the Sass Output to compressed
47 | new_color.options = { style: :compressed }
48 | new_color.to_sass
49 | rescue ArgumentError
50 | nil
51 | end
52 | end
53 | end
54 | end
55 |
--------------------------------------------------------------------------------
/zendesk_apps_tools.gemspec:
--------------------------------------------------------------------------------
1 | require_relative './lib/zendesk_apps_tools/version'
2 |
3 | Gem::Specification.new do |s|
4 | s.name = 'zendesk_apps_tools'
5 | s.version = ZendeskAppsTools::VERSION
6 | s.executables << 'zat'
7 | s.platform = Gem::Platform::RUBY
8 | s.license = 'Apache-2.0'
9 | s.authors = ['James A. Rosen', 'Kenshiro Nakagawa', 'Shajith Chacko', 'Likun Liu']
10 | s.email = ['dev@zendesk.com']
11 | s.homepage = 'http://github.com/zendesk/zendesk_apps_tools'
12 | s.summary = 'Tools to help you develop Zendesk Apps.'
13 | s.description = s.summary
14 |
15 | s.required_ruby_version = '>= 3.1', '< 3.4'
16 | s.required_rubygems_version = '>= 3.0.0'
17 |
18 | s.add_runtime_dependency 'thor', '~> 0.19.4'
19 | s.add_runtime_dependency 'rubyzip', '>= 1.2.1', '< 2.4.0'
20 | s.add_runtime_dependency 'thin', '~> 2.0'
21 | s.add_runtime_dependency 'sinatra', '~> 4.1'
22 | s.add_runtime_dependency 'rack', '~> 3.0'
23 | s.add_runtime_dependency 'faraday', '~> 0.17.5'
24 | s.add_runtime_dependency 'execjs', '~> 2.7.0'
25 | s.add_runtime_dependency 'nokogiri', '~> 1.18', '>= 1.18.8'
26 | s.add_runtime_dependency 'zendesk_apps_support', '~> 4.39.0'
27 | s.add_runtime_dependency 'sinatra-cross_origin', '~> 0.3.1'
28 | s.add_runtime_dependency 'listen', '~> 2.10'
29 | s.add_runtime_dependency 'rack-livereload', '0.3.17'
30 | s.add_runtime_dependency 'faye-websocket', '>= 0.10.7', '< 0.12.0'
31 |
32 | s.add_development_dependency 'cucumber', '~> 7.1.0'
33 | s.add_development_dependency 'aruba', '~> 0.14.14'
34 | s.add_development_dependency 'rspec'
35 | s.add_development_dependency 'pry'
36 | s.add_development_dependency 'byebug'
37 | s.add_development_dependency 'bump'
38 | s.add_development_dependency 'webmock'
39 | s.add_development_dependency 'rake'
40 |
41 |
42 | s.files = Dir.glob('{bin,lib,app_template*,templates}/**/*') + %w[README.md LICENSE]
43 | s.test_files = Dir.glob('features/**/*')
44 | s.require_path = 'lib'
45 | end
46 |
--------------------------------------------------------------------------------
/spec/lib/zendesk_apps_tools/cache_spec.rb:
--------------------------------------------------------------------------------
1 | # frozen_string_literal: true
2 | require 'spec_helper'
3 | require 'zendesk_apps_tools/cache'
4 | require 'tmpdir'
5 | require 'fileutils'
6 |
7 | describe ZendeskAppsTools::Cache do
8 | context 'with a local cache file' do
9 | let(:tmpdir) { Dir.mktmpdir }
10 | let(:cache) { ZendeskAppsTools::Cache.new(path: tmpdir) }
11 | let(:zat_file) { File.join(tmpdir, '.zat') }
12 |
13 | before do
14 | content = JSON.dump(subdomain: 'under-the-domain', username: 'Roger@something.com', app_id: 12)
15 | File.write(zat_file, content, mode: 'w')
16 | end
17 |
18 | after do
19 | FileUtils.rm_r tmpdir
20 | end
21 |
22 | describe '#fetch' do
23 | it 'reads data from the cache' do
24 | expect(cache.fetch('username')).to eq 'Roger@something.com'
25 | end
26 |
27 | context 'with a global cache' do
28 | before do
29 | fake_home = File.join(tmpdir, 'fake_home')
30 | FileUtils.mkdir_p(fake_home)
31 | allow(Dir).to receive(:home).and_return(fake_home)
32 | global_content = JSON.dump(global_subdomain: { password: 'hunter2' }, default: { subdomain: 'default-domain', password: 'hunter3' })
33 | File.write(File.join(fake_home, '.zat'), global_content, mode: 'w')
34 | end
35 |
36 | it 'falls back to global cache' do
37 | expect(cache.fetch('password', 'global_subdomain')).to eq 'hunter2'
38 | end
39 |
40 | it 'falls back to global cache default' do
41 | expect(cache.fetch('password')).to eq 'hunter3'
42 | end
43 | end
44 | end
45 |
46 | describe '#save' do
47 | it 'works' do
48 | cache.save(other_key: 'value')
49 | expect(JSON.parse(File.read(zat_file))['other_key']).to eq 'value'
50 | end
51 | end
52 |
53 | describe '#clear' do
54 | it 'does nothing by default' do
55 | cache.clear
56 | expect(File.exist?(zat_file)).to be_truthy
57 | end
58 |
59 | it 'works' do
60 | cache = ZendeskAppsTools::Cache.new(path: tmpdir, clean: true)
61 | cache.clear
62 | expect(File.exist?(zat_file)).to be_falsey
63 | end
64 | end
65 | end
66 | end
67 |
--------------------------------------------------------------------------------
/lib/zendesk_apps_tools/api_connection.rb:
--------------------------------------------------------------------------------
1 | module ZendeskAppsTools
2 | module APIConnection
3 | DEFAULT_URL_TEMPLATE = 'https://%s.zendesk.com/'
4 | # taken from zendesk/lib/vars.rb
5 | SUBDOMAIN_VALIDATION_PATTERN = /^[a-z0-9][a-z0-9\-]{1,}[a-z0-9]$/i
6 | ZENDESK_URL_VALIDATION_PATTERN = /^(https?):\/\/[a-z0-9]+(([\.]|[\-]{1,2})[a-z0-9]+)*\.([a-z]{2,16}|[0-9]{1,3})((:[0-9]{1,5})?(\/?|\/.*))?$/ix
7 | EMAIL_REGEX = /^([\w\.%\+\-]+)@([\w\-]+\.)+([\w]{2,})(\/token)?$/i
8 |
9 | EMAIL_ERROR_MSG = 'Please enter a valid email address.'
10 | PROMPT_FOR_URL = 'Enter your Zendesk subdomain or full URL (including protocol):'
11 | URL_ERROR_MSG = [
12 | 'URL error. Example URL: https://mysubdomain.zendesk.com',
13 | 'If you are using a full URL, follow the example format shown above.',
14 | 'If you are using a subdomain, ensure that it contains only valid characters (a-z, A-Z, 0-9, and hyphens).'
15 | ].join('\n')
16 |
17 | def prepare_api_auth
18 | @subdomain ||= ENV['ZAT_URL'] || cache.fetch('subdomain') || get_value_from_stdin(PROMPT_FOR_URL)
19 | say_error_and_exit URL_ERROR_MSG unless valid_subdomain? || valid_full_url?
20 |
21 | @username ||= ENV['ZAT_USERNAME'] || cache.fetch('username', @subdomain) || get_value_from_stdin('Enter your username:')
22 | say_error_and_exit EMAIL_ERROR_MSG unless valid_email?
23 |
24 | @password ||= ENV['ZAT_PASSWORD'] || cache.fetch('password', @subdomain) || get_password_from_stdin('Enter your password:')
25 | end
26 |
27 | def get_connection(encoding = :url_encoded)
28 | require 'net/http'
29 | require 'faraday'
30 | prepare_api_auth unless @subdomain && @username && @password
31 |
32 | Faraday.new full_url do |f|
33 | f.request encoding if encoding
34 | f.adapter :net_http
35 | f.basic_auth @username, @password
36 | end
37 | end
38 |
39 | private
40 |
41 | def full_url
42 | valid_full_url? ? @subdomain : (DEFAULT_URL_TEMPLATE % @subdomain)
43 | end
44 |
45 | def valid_full_url?
46 | !!ZENDESK_URL_VALIDATION_PATTERN.match(@subdomain)
47 | end
48 |
49 | def valid_subdomain?
50 | !!SUBDOMAIN_VALIDATION_PATTERN.match(@subdomain)
51 | end
52 |
53 | def valid_email?
54 | !!EMAIL_REGEX.match(@username)
55 | end
56 | end
57 | end
58 |
--------------------------------------------------------------------------------
/lib/zendesk_apps_tools/theming/common.rb:
--------------------------------------------------------------------------------
1 | module ZendeskAppsTools
2 | module Theming
3 | module Common
4 | def theme_package_path(*file)
5 | File.expand_path(File.join(app_dir, *file))
6 | end
7 |
8 | def url_for(package_file)
9 | relative_path = relative_path_for(package_file)
10 | path_parts = recursive_pathname_split(relative_path)
11 | path_parts.shift
12 | "http://localhost:4567/guide/#{path_parts.join('/')}"
13 | end
14 |
15 | def relative_path_for(filename)
16 | Pathname.new(filename).relative_path_from(Pathname.new(File.expand_path(app_dir))).cleanpath
17 | end
18 |
19 | def assets
20 | assets = Dir.glob(theme_package_path('assets', '*'))
21 | assets.each_with_object({}) do |asset, asset_payload|
22 | asset_payload[File.basename(asset)] = url_for(asset)
23 | end
24 | end
25 |
26 | def assets_hash
27 | assets.each_with_object({}) do |(k,v), h|
28 | parametrized = k.gsub(/[^a-z0-9\-_]+/, '-')
29 | h["assets-#{parametrized}"] = v
30 | end
31 | end
32 |
33 | def manifest
34 | full_manifest_path = theme_package_path('manifest.json')
35 | JSON.parse(File.read(full_manifest_path))
36 | rescue Errno::ENOENT
37 | say_error_and_exit "There is no manifest file in #{full_manifest_path}"
38 | rescue JSON::ParserError
39 | say_error_and_exit "The manifest file is invalid at #{full_manifest_path}"
40 | end
41 |
42 | def settings_hash
43 | manifest['settings'].flat_map { |setting_group| setting_group['variables'] }.each_with_object({}) do |variable, result|
44 | result[variable.fetch('identifier')] = value_for_setting(variable)
45 | end
46 | end
47 |
48 | def metadata_hash
49 | { 'api_version' => manifest['api_version'] }
50 | end
51 |
52 | def value_for_setting(variable)
53 | return variable.fetch('value') unless variable.fetch('type') == 'file'
54 |
55 | files = Dir.glob(theme_package_path('settings', '*.*'))
56 | file = files.find { |f| File.basename(f, '.*') == variable.fetch('identifier') }
57 | url_for(file)
58 | end
59 |
60 | def recursive_pathname_split(relative_path)
61 | split_path = relative_path.split
62 | joined_directories = split_path[0]
63 | return split_path if split_path[0] == joined_directories.split[0]
64 | [*recursive_pathname_split(joined_directories), split_path[1]]
65 | end
66 | end
67 | end
68 | end
69 |
--------------------------------------------------------------------------------
/lib/zendesk_apps_tools/common.rb:
--------------------------------------------------------------------------------
1 | # frozen_string_literal: true
2 | module ZendeskAppsTools
3 | module Common
4 | module ClassMethods
5 | def shared_options(except: [])
6 | unless except.include? :path
7 | method_option :path,
8 | type: :string,
9 | default: './',
10 | aliases: ['-p']
11 | end
12 | unless except.include? :clean
13 | method_option :clean,
14 | type: :boolean,
15 | default: false
16 | end
17 | unless except.include? :unattended
18 | method_option :unattended,
19 | type: :boolean,
20 | default: false,
21 | desc: 'Experimental: Never prompt for input, expecting all input from the original invocation. Many '\
22 | 'commands invoked with this option will just crash.'
23 | end
24 | end
25 | end
26 |
27 | def self.included(base)
28 | base.extend(ClassMethods)
29 | end
30 |
31 | def say_error_and_exit(msg)
32 | say_error msg
33 | exit 1
34 | end
35 |
36 | def say_error(msg)
37 | say msg, :red
38 | end
39 |
40 | def get_value_from_stdin(prompt, opts = {})
41 | error_or_default_if_unattended(prompt, opts) do
42 | options = {
43 | valid_regex: opts[:allow_empty] ? /^.*$/ : /\S+/,
44 | error_msg: 'Invalid, try again:',
45 | allow_empty: false
46 | }.merge(opts)
47 |
48 | thor_options = { default: options[:default] }
49 |
50 | while input = ask(prompt, thor_options)
51 | return '' if options[:allow_empty] && input.empty?
52 | break if input.to_s =~ options[:valid_regex]
53 | say_error options[:error_msg]
54 | end
55 |
56 | input
57 | end
58 | end
59 |
60 | def get_password_from_stdin(prompt)
61 | error_or_default_if_unattended(prompt) do
62 | password = ask(prompt, echo: false)
63 | say ''
64 | password
65 | end
66 | end
67 |
68 | def json_or_die(value)
69 | require 'json'
70 | JSON.parse(value)
71 | rescue JSON::ParserError
72 | say_error_and_exit "\"#{value}\" is an invalid JSON."
73 | end
74 |
75 | private
76 |
77 | def error_or_default_if_unattended(prompt, opts = {})
78 | if options[:unattended]
79 | return opts[:default] if opts.key? :default
80 | say_error 'Would have prompted for a value interactively, but ZAT is not listening to keyboard input.'
81 | say_error_and_exit prompt
82 | else
83 | yield
84 | end
85 | end
86 | end
87 | end
88 |
--------------------------------------------------------------------------------
/spec/lib/zendesk_apps_tools/common_spec.rb:
--------------------------------------------------------------------------------
1 | # frozen_string_literal: true
2 | require 'spec_helper'
3 | require 'zendesk_apps_tools/common'
4 |
5 | describe ZendeskAppsTools::Common do
6 | let(:subject_class) do
7 | Class.new do
8 | include ZendeskAppsTools::Common
9 | def options
10 | {}
11 | end
12 |
13 | def say(message, color = nil)
14 | end
15 |
16 | def ask(prompt, options)
17 | end
18 | end
19 | end
20 | let(:subject) do
21 | subject_class.new
22 | end
23 |
24 | describe '#get_value_from_stdin' do
25 | it 'errors if unattended' do
26 | allow(subject).to receive(:options).and_return(unattended: true)
27 | expect { subject.get_value_from_stdin('prompt') }.to raise_error(SystemExit)
28 | end
29 |
30 | it 'returns the asked value' do
31 | allow(subject).to receive(:ask).and_return('test value')
32 | expect(subject.get_value_from_stdin('prompt')).to eq 'test value'
33 | end
34 | end
35 |
36 | describe '#get_password_from_stdin' do
37 | it 'errors if unattended' do
38 | allow(subject).to receive(:options).and_return(unattended: true)
39 | expect { subject.get_password_from_stdin('prompt') }.to raise_error(SystemExit)
40 | end
41 |
42 | it 'returns the asked value' do
43 | allow(subject).to receive(:ask).and_return('test value')
44 | expect(subject.get_password_from_stdin('prompt')).to eq 'test value'
45 | end
46 |
47 | it 'does not echo' do
48 | expect(subject).to receive(:ask).with('prompt', hash_including(echo: false))
49 | subject.get_password_from_stdin('prompt')
50 | end
51 | end
52 |
53 | describe '#say_error' do
54 | it 'outputs a red message' do
55 | expect(subject).to receive(:say).with('goodbye world', :red)
56 | subject.say_error 'goodbye world'
57 | end
58 | end
59 |
60 | describe '#say_error_and_exit' do
61 | it 'calls say_error and quits' do
62 | expect(subject).to receive(:say).with('goodbye world', :red)
63 | expect { subject.say_error_and_exit 'goodbye world' }.to raise_error(SystemExit)
64 | end
65 | end
66 |
67 | describe '.shared_options' do
68 | it 'calls method_option three times' do
69 | expect(subject.class).to receive(:method_option).exactly(3).times
70 | subject.class.shared_options
71 | end
72 |
73 | it 'respects `except`' do
74 | expect(subject.class).to receive(:method_option).exactly(2).times
75 | subject.class.shared_options(except: [:clean])
76 | end
77 | end
78 |
79 | describe 'json_or_die' do
80 | it 'return json object if valid input is provided' do
81 | expect(subject).not_to receive(:say)
82 | expect(subject.json_or_die '{ "key":"value" }').to eq({ 'key'=>'value' })
83 | end
84 |
85 | it 'raise error if invalid input is provided' do
86 | expect(subject).to receive(:say).with(/is an invalid JSON.$/, :red)
87 | expect { subject.json_or_die '{ "key"="value" }' }.to raise_error(SystemExit)
88 | end
89 | end
90 | end
91 |
--------------------------------------------------------------------------------
/lib/zendesk_apps_tools/server.rb:
--------------------------------------------------------------------------------
1 | require 'sinatra/base'
2 | require 'sinatra/cross_origin'
3 | require 'zendesk_apps_support' # dependency of zendesk_apps_support/package
4 | require 'zendesk_apps_support/package'
5 |
6 | module ZendeskAppsTools
7 | class Server < Sinatra::Base
8 | set :server, :thin
9 | set :logging, true
10 | set :protection, except: :frame_options
11 | ZENDESK_DOMAINS_REGEX = %r{^http(?:s)?://[a-z0-9-]+\.(?:zendesk|zopim|futuresimple|local.futuresimple|zendesk-(?:dev|master|staging))\.com$}
12 |
13 | get '/app.js' do
14 | serve_installed_js
15 | end
16 |
17 | enable :cross_origin
18 |
19 | def serve_installed_js
20 | access_control_allow_origin
21 | content_type 'text/javascript'
22 |
23 | new_settings = settings.settings_helper.refresh!
24 | settings.parameters = new_settings if new_settings
25 |
26 | package = ZendeskAppsSupport::Package.new(settings.root, false)
27 | app_name = package.manifest.name || 'Local App'
28 | installation = ZendeskAppsSupport::Installation.new(
29 | id: settings.app_id,
30 | app_id: settings.app_id,
31 | app_name: app_name,
32 | enabled: true,
33 | requirements: package.requirements_json,
34 | collapsible: true,
35 | settings: settings.parameters.merge(title: app_name),
36 | updated_at: Time.now.iso8601,
37 | created_at: Time.now.iso8601,
38 | plan: { name: settings.plan }
39 | )
40 |
41 | app_js = package.compile(
42 | app_id: settings.app_id,
43 | app_name: app_name,
44 | assets_dir: "http://localhost:#{settings.port}/",
45 | locale: params['locale']
46 | )
47 |
48 | ZendeskAppsSupport::Installed.new([app_js], [installation]).compile
49 | end
50 |
51 | def send_file(*args)
52 | # does the request look like a request from the host product for the generated
53 | # installed.js file? If so, send that. Otherwise send the static file in the
54 | # app's public_folder (./assets).
55 | if request.env['PATH_INFO'] == '/app.js' && params.key?('locale') && params.key?('subdomain')
56 | serve_installed_js
57 | else
58 | access_control_allow_origin
59 | super(*args)
60 | end
61 | end
62 |
63 | def request_from_zendesk?
64 | request.env['HTTP_ORIGIN'] =~ ZENDESK_DOMAINS_REGEX
65 | end
66 |
67 | # This is for any preflight request
68 | # It reads 'Access-Control-Request-Headers' to set 'Access-Control-Allow-Headers'
69 | # And also sets 'Access-Control-Allow-Origin' header
70 | options '*' do
71 | access_control_allow_origin
72 | if request_from_zendesk?
73 | headers 'Access-Control-Allow-Headers' => request.env['HTTP_ACCESS_CONTROL_REQUEST_HEADERS']
74 | end
75 | end
76 |
77 | # This sets the 'Access-Control-Allow-Origin' header for requests coming from zendesk
78 | def access_control_allow_origin
79 | headers 'Access-Control-Allow-Origin' => request.env['HTTP_ORIGIN'] if request_from_zendesk?
80 | end
81 | end
82 | end
83 |
--------------------------------------------------------------------------------
/lib/zendesk_apps_tools/theming/server.rb:
--------------------------------------------------------------------------------
1 | # frozen_string_literal: true
2 |
3 | require 'sinatra/base'
4 | require 'sinatra/cross_origin'
5 | require 'zendesk_apps_tools/theming/common'
6 |
7 | module ZendeskAppsTools
8 | module Theming
9 | class Server < Sinatra::Base
10 | include Common
11 |
12 | enable :cross_origin
13 |
14 | get '/livereload' do
15 | if settings.livereload && Faye::WebSocket.websocket?(env)
16 | ws = Faye::WebSocket.new(env)
17 |
18 | new_callback = ->() { ws.send(JSON.dump(command: 'reload', path: '')) }
19 | settings.callback_map[ws] = new_callback
20 | settings.callbacks_after_load.push(new_callback)
21 |
22 | ws.onmessage = lambda do |event|
23 | message = JSON.parse(event.data)
24 | if message['command'] == 'hello'
25 | ws.send(JSON.dump(
26 | command: 'hello',
27 | protocols: [
28 | 'http://livereload.com/protocols/official-7',
29 | 'http://livereload.com/protocols/official-8',
30 | 'http://livereload.com/protocols/official-9',
31 | 'http://livereload.com/protocols/2.x-origin-version-negotiation',
32 | 'http://livereload.com/protocols/2.x-remote-control'
33 | ],
34 | serverName: 'ZAT LiveReload 2'
35 | ))
36 | end
37 | end
38 |
39 | ws.onclose = lambda do |event|
40 | settings.callbacks_after_load.delete_if do |entry|
41 | entry == settings.callback_map[ws]
42 | end
43 | settings.callback_map.delete ws
44 | ws = nil
45 | end
46 |
47 | # Return async Rack response
48 | ws.rack_response
49 | else
50 | [500, {}, 'Websocket Server Error']
51 | end
52 | end
53 |
54 | get '/guide/style.css' do
55 | content_type 'text/css'
56 | style_css = theme_package_path('style.css')
57 | raise Sinatra::NotFound unless File.exist?(style_css)
58 | zass_source = File.read(style_css)
59 | require 'zendesk_apps_tools/theming/zass_formatter'
60 | response = ZassFormatter.format(zass_source, settings_hash.merge(assets_hash))
61 | response
62 | end
63 |
64 | get '/guide/*' do
65 | access_control_allow_origin
66 |
67 | path = File.join(app_dir, *params[:splat])
68 | return send_file path if File.exist?(path)
69 | raise Sinatra::NotFound
70 | end
71 |
72 | # This is for any preflight request
73 | options "*" do
74 | access_control_allow_origin
75 |
76 | response.headers["Allow"] = "GET, POST, OPTIONS"
77 | response.headers["Access-Control-Allow-Headers"] = "Authorization, Content-Type, Accept, X-User-Email, X-Auth-Token"
78 | 200
79 | end
80 |
81 | def app_dir
82 | settings.root
83 | end
84 |
85 | def access_control_allow_origin
86 | headers 'Access-Control-Allow-Origin' => '*'
87 | end
88 | end
89 | end
90 | end
91 |
--------------------------------------------------------------------------------
/lib/zendesk_apps_tools/settings.rb:
--------------------------------------------------------------------------------
1 | require 'zendesk_apps_tools/common'
2 |
3 | module ZendeskAppsTools
4 | class Settings
5 | def initialize(cli)
6 | @cli = cli
7 | end
8 |
9 | def get_settings_from_user_input(parameters)
10 | return {} if parameters.nil?
11 |
12 | parameters.inject({}) do |settings, param|
13 | if param.key? 'default'
14 | input = @cli.get_value_from_stdin("Enter a value for parameter '#{param['name']}':\n", default: param['default'])
15 | elsif param['required']
16 | input = @cli.get_value_from_stdin("Enter a value for required parameter '#{param['name']}':\n")
17 | else
18 | input = @cli.get_value_from_stdin("Enter a value for optional parameter '#{param['name']}' or press 'Return' to skip:\n", allow_empty: true)
19 | end
20 |
21 | if param['type'] == 'checkbox'
22 | input = convert_to_boolean_for_checkbox(input)
23 | end
24 |
25 | settings[param['name']] = input if input != ''
26 | settings
27 | end
28 | end
29 |
30 | def refresh!
31 | return unless File.file? @filepath
32 | curr_mtime = File.stat(@filepath).mtime
33 | if @last_mtime.nil? || curr_mtime > @last_mtime
34 | @last_mtime = curr_mtime
35 | get_settings_from_file(@filepath, @parameters)
36 | end
37 | end
38 |
39 | def get_settings_from_file(filepath, parameters)
40 | @filepath ||= filepath
41 | @parameters ||= parameters
42 |
43 | return {} if parameters.nil?
44 | return nil unless File.exist? filepath
45 |
46 | begin
47 | settings_file = read_settings(filepath)
48 | settings_data = parse_settings(filepath, settings_file)
49 | rescue => err
50 | @cli.say_error "Failed to load #{filepath}\n#{err.message}"
51 | return nil
52 | end
53 |
54 | parameters.each_with_object({}) do |param, settings|
55 | input = settings_data.fetch(param['name'], param['default'])
56 |
57 | if !input && param['required']
58 | @cli.say_error "'#{param['name']}' is required but not specified in the config file.\n"
59 | return nil
60 | end
61 |
62 | if param['type'] == 'checkbox'
63 | input = convert_to_boolean_for_checkbox(input)
64 | end
65 |
66 | settings[param['name']] = input if input != ''
67 | settings
68 | end
69 | end
70 |
71 | private
72 |
73 | def read_settings(filepath)
74 | @last_mtime = File.stat(filepath).mtime
75 | File.read(filepath)
76 | end
77 |
78 | def parse_settings(filepath, contents)
79 | settings_data =
80 | if filepath =~ /\.json$/ || contents =~ /\A\s*{/
81 | JSON.load(contents)
82 | else
83 | require 'yaml'
84 | YAML.safe_load(contents)
85 | end
86 | settings_data.each do |index, setting|
87 | if setting.is_a?(Hash) || setting.is_a?(Array)
88 | settings_data[index] = JSON.dump(setting)
89 | end
90 | end
91 | settings_data
92 | end
93 |
94 | def convert_to_boolean_for_checkbox(input)
95 | unless [TrueClass, FalseClass].include?(input.class)
96 | return (input =~ /^(true|t|yes|y|1)$/i) ? true : false
97 | end
98 | input
99 | end
100 | end
101 | end
102 |
--------------------------------------------------------------------------------
/features/step_definitions/app_steps.rb:
--------------------------------------------------------------------------------
1 | require 'fileutils'
2 | require 'zip'
3 | require 'English'
4 |
5 | When /^I move to the app directory$/ do
6 | @previous_dir = Dir.pwd
7 | Dir.chdir(@app_dir)
8 | end
9 |
10 | When /^I reset the working directory$/ do
11 | Dir.chdir(@previous_dir)
12 | end
13 |
14 | Given /^an app directory "(.*?)" exists$/ do |app_dir|
15 | @app_dir = app_dir
16 | FileUtils.rm_rf(@app_dir)
17 | FileUtils.mkdir_p(@app_dir)
18 | end
19 |
20 | Given /^a(n|(?: v1)) app is created in directory "(.*?)"$/ do |version, app_dir|
21 | v1 = !!version[/v1/]
22 | steps %(
23 | Given an app directory "#{app_dir}" exists
24 | And I run "zat new #{'--v1' if v1}" command with the following details:
25 | | author name | John Citizen |
26 | | author email | john@example.com |
27 | | author url | http://myapp.com |
28 | | app name | John Test App |
29 | #{'| iframe uri | assets/iframe.html |' unless v1}
30 | | app dir | #{app_dir} |
31 | )
32 | end
33 |
34 | Given /^a \.zat file in "(.*?)"/ do |app_dir|
35 | f = File.new(File.join(app_dir, '.zat'), 'w')
36 | f.write(JSON.dump(username: 'test@user.com', password: 'hunter2', subdomain: 'app-account'))
37 | f.close
38 | end
39 |
40 | When /^I run "(.*?)" command with the following details:$/ do |cmd, table|
41 | IO.popen(cmd, 'w+') do |pipe|
42 | # [ ['parameter name', 'value'] ]
43 | table.raw.each do |row|
44 | pipe.puts row.last
45 | end
46 | pipe.close_write
47 | @output = pipe.readlines
48 | @output.each { |line| puts line }
49 | end
50 | end
51 |
52 | When /^I create a symlink from "(.*?)" to "(.*?)"$/ do |src, dest|
53 | @link_destname = File.basename(dest)
54 | # create a symlink
55 | FileUtils.ln_s(src, dest)
56 | end
57 |
58 | When /^I run the command "(.*?)" to (validate|package|clean|create) the app$/ do |cmd, _action|
59 | env_hash = prepare_env_hash_for(cmd)
60 | IO.popen(env_hash, cmd, 'w+') do |pipe|
61 | pipe.puts "\n"
62 | pipe.close_write
63 | @output = pipe.readlines
64 | @output.each { |line| puts line }
65 | end
66 | end
67 |
68 | Then /^the app file "(.*?)" is created with:$/ do |file, content|
69 | expect(File.read(file).chomp.gsub(' ', '')).to eq content.gsub(' ', '')
70 | end
71 |
72 | Then /^the app file "(.*?)" is created$/ do |filename|
73 | expect(File.exist?(filename)).to be_truthy
74 | end
75 |
76 | Then /^the fixture "(.*?)" is used for "(.*?)"$/ do |fixture, app_file|
77 | fixture_file = File.join('features', 'fixtures', fixture)
78 | app_file_path = File.join(@app_dir, app_file)
79 |
80 | FileUtils.cp(fixture_file, app_file_path)
81 | end
82 |
83 | Then /^the zip file should exist in directory "(.*?)"$/ do |path|
84 | expect(Dir[path + '/app-*.zip'].size).to eq 1
85 | end
86 |
87 | Given /^I remove file "(.*?)"$/ do |file|
88 | File.delete(file)
89 | end
90 |
91 | Then /^the zip file in "(.*?)" folder should not exist$/ do |path|
92 | expect(Dir[path + '/app-*.zip'].size).to eq 0
93 | end
94 |
95 | Then /^it should pass the validation$/ do
96 | expect(@output.last).to match /OK/
97 | expect($CHILD_STATUS).to eq 0
98 | end
99 |
100 | Then /^the command output should contain "(.*?)"$/ do |output|
101 | expect(@output.join).to match /#{output}/
102 | end
103 |
104 | Then /^"(.*?)" should be a symlink$/ do |path|
105 | expect(File.symlink?(path)).to be_truthy
106 | end
107 |
108 | Then /^the zip file in "(.*?)" should not contain any symlinks$/ do |path|
109 | Zip::File.foreach Dir[path + '/app-*.zip'][0] do |p|
110 | expect(p.symlink?).to be_falsy
111 | end
112 | end
113 |
--------------------------------------------------------------------------------
/README.md:
--------------------------------------------------------------------------------
1 | # IN MAINTENANCE MODE
2 |
3 | Zendesk Apps Tools is in maintenance mode. This means no additional feature enhancements or non-security bugs will be fixed. **We recommend switching to using [ZCLI](https://github.com/zendesk/zcli) for our best CLI experience.**
4 |
5 |
6 | # Zendesk Apps Tools
7 |
8 | ## Description
9 | Zendesk Apps Tools (ZAT) are a collection of local development tools that simplify building and deploying [Zendesk apps](https://developer.zendesk.com/apps/docs/apps-v2/getting_started).
10 |
11 | ## Owners
12 | This repo is owned and maintained by the Zendesk Apps team. You can reach us on vegemite@zendesk.com. We are located in Melbourne.
13 |
14 | ## Install and use ZAT
15 | ZAT is a Ruby gem. You don't need to know Ruby to use the tools but you do need to install Ruby to install the gem.
16 |
17 | To install, run `gem install zendesk_apps_tools`.
18 |
19 | To get the latest version, run `gem update zendesk_apps_tools`.
20 |
21 | For information on using the tools, see [Zendesk App Tools](https://developer.zendesk.com/apps/docs/developer-guide/zat) on developer.zendesk.com.
22 |
23 | ## Work on ZAT
24 | If you want to help **develop** this tool, clone this repo and run `bundle install`.
25 |
26 | If you receive this error:
27 | Installing nokogiri 1.10.10 with native extensions
28 | Gem::Ext::BuildError: ERROR: Failed to build gem native extension.
29 | Then run `gem install nokogiri -- --use-system-libraries` followed by run `bundle config build.nokogiri --use-system-libraries` and try running `bundle install` again.
30 |
31 | ZAT uses a gem called [ZAS](https://github.com/zendesk/zendesk_apps_support/). If you're developing ZAT, you'll probably want to edit code in ZAS too. To do so, you need to clone the ZAS repo and add the following line at the end of `Gemfile` in the ZAT project:
32 |
33 | `gem 'zendesk_apps_support', path: '../zendesk_apps_support'`
34 |
35 | Then, comment-out the line referring to `zendesk_apps_support` in this project's `.gemspec` file.
36 |
37 | ```
38 | # s.add_runtime_dependency 'zendesk_apps_support', '~> X.YY.ZZ'
39 | ```
40 |
41 | The path should point to your local ZAS directory. In this way, your clone of ZAT will use a local version of ZAS, which is very helpful for development. Run a `bundle install` after changing the Gemfile.
42 |
43 | ## Deploy ZAT
44 |
45 | * To bump ZAT version, run `bump patch|minor|major --no-bundle` from the root directory. **Note:** `--no-bundle` is required in order to prevent `bundle update` command from running, which is by default triggered by the [bump](https://github.com/gregorym/bump) gem and could lead to incompatible dependencies.
46 | * To publish ZAT to [Rubygems](https://rubygems.org/gems/zendesk_apps_tools), run `bundle exec rake release`.
47 |
48 | ## Testing
49 | This project uses rspec, which you can run with `bundle exec rake`.
50 |
51 | ## Contribute
52 | Improvements are always welcome. To contribute:
53 |
54 | * Put up a PR into the master branch.
55 | * CC and get two +1 from @zendesk/vegemite.
56 |
57 | This repo contains the ZAT documentation published on the developer portal at https://developer.zendesk.com. Please cc **@zendesk/documentation** on any PR that adds or updates the documentation.
58 |
59 | ## Bugs
60 | You can report bugs as issues here on GitHub. You can also submit a bug to support@zendesk.com. Mention "zendesk_apps_tools" in the ticket so it can be assigned to the right team.
61 |
62 | # Copyright and license
63 | Copyright 2013 Zendesk
64 | Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except in compliance with the License.
65 | You may obtain a copy of the License at
66 |
67 | [http://www.apache.org/licenses/LICENSE-2.0](http://www.apache.org/licenses/LICENSE-2.0)
68 |
69 | Unless required by applicable law or agreed to in writing, software distributed under the License is distributed on an "AS IS" BASIS,
70 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
71 | See the License for the specific language governing permissions and limitations under the License.
72 |
--------------------------------------------------------------------------------
/spec/lib/zendesk_apps_tools/deploy_spec.rb:
--------------------------------------------------------------------------------
1 | require 'spec_helper'
2 | require 'zendesk_apps_tools/deploy'
3 | require 'json'
4 | require 'faraday'
5 |
6 | describe ZendeskAppsTools::Deploy do
7 | let(:subject_class) { Class.new { include ZendeskAppsTools::Deploy } }
8 |
9 | describe '#find_app_id' do
10 | let(:valid_response_body) {
11 | {
12 | apps: [
13 | { id: 22, name: 'notification_app' },
14 | { id: 99, name: 'time_tracking_app' }
15 | ]
16 | }.to_json
17 | }
18 | let(:valid_api_response) { Faraday::Response.new({ body: valid_response_body, status: 200}) }
19 | let(:invalid_api_response) { Faraday::Response.new({ body: {}.to_json, status: 200}) }
20 | let(:failed_api_response) { Faraday::Response.new({ body: {}.to_json, status: 403}) }
21 | let(:connection_error_msg) { /Unable to retrieve apps/ }
22 | let(:app_lookup_error_msg) { /App not found/ }
23 |
24 | def mocked_instance_methods_and_api(app_name, api_response)
25 | subject = subject_class.new
26 | allow(subject).to receive(:say_status)
27 | allow(subject).to receive(:get_value_from_stdin) { app_name }
28 | allow(subject).to receive_message_chain(:cached_connection, :get) { api_response }
29 | subject
30 | end
31 |
32 | context 'receives an invalid api response' do
33 | it 'errors and exits system' do
34 | subject = mocked_instance_methods_and_api('notification_app', invalid_api_response)
35 |
36 | expect(subject).to receive(:say_error).with(connection_error_msg)
37 | expect { subject.find_app_id }.to raise_error(SystemExit)
38 | end
39 | end
40 |
41 | context 'user inputs the wrong credentials' do
42 | it 'errors and exits system' do
43 | subject = mocked_instance_methods_and_api('notification_app', failed_api_response)
44 |
45 | expect(subject).to receive(:say_error).with(connection_error_msg)
46 | expect { subject.find_app_id }.to raise_error(SystemExit)
47 | end
48 | end
49 |
50 | context 'user inputs an app name that is NOT in api response' do
51 | it 'errors and exits system' do
52 | subject = mocked_instance_methods_and_api('random_app_name', valid_api_response)
53 |
54 | expect(subject).to receive(:say_error).with(app_lookup_error_msg)
55 | expect { subject.find_app_id }.to raise_error(SystemExit)
56 | end
57 | end
58 |
59 | context 'user inputs an app name that is in api response' do
60 | it 'returns app id 22 for notification_app' do
61 | subject = mocked_instance_methods_and_api('notification_app', valid_api_response)
62 | allow(subject).to receive_message_chain(:cache, :save)
63 |
64 | expect(subject.find_app_id).to eq(22)
65 | end
66 |
67 | it 'returns app id 99 for time_tracking_app' do
68 | subject = mocked_instance_methods_and_api('time_tracking_app', valid_api_response)
69 | allow(subject).to receive_message_chain(:cache, :save)
70 |
71 | expect(subject.find_app_id).to eq(99)
72 | end
73 | end
74 | end
75 |
76 | describe '#check_job' do
77 | let(:random_job_id) { 9999 }
78 | let(:completed_api_response_body) { {'status' => 'completed'} }
79 | let(:failed_api_response_body) { {'status' => 'failed', 'message' => 'You failed!'} }
80 |
81 | let(:subject) { subject_class.new }
82 |
83 | context 'response status is failed' do
84 | it 'errors and exit' do
85 | allow(subject).to receive_message_chain(:cached_connection, :get, :body) { failed_api_response_body.to_json }
86 | allow(subject).to receive(:say_status).with(@command, failed_api_response_body['message'], :red) { exit }
87 |
88 | expect { subject.check_job(random_job_id)}.to raise_error(SystemExit)
89 | end
90 | end
91 |
92 | context 'response status is completed' do
93 | it 'caches subdomain, username and app_id' do
94 | subject = subject_class.new
95 | allow(subject).to receive_message_chain(:cached_connection, :get, :body) { completed_api_response_body.to_json }
96 | allow(subject).to receive_message_chain(:cache, :save)
97 | allow(subject).to receive(:say_status).with(@command, "OK")
98 |
99 | subject.check_job(random_job_id)
100 | end
101 | end
102 | end
103 | end
104 |
--------------------------------------------------------------------------------
/lib/zendesk_apps_tools/deploy.rb:
--------------------------------------------------------------------------------
1 | require 'faraday'
2 | require 'zendesk_apps_tools/common'
3 | require 'zendesk_apps_tools/api_connection'
4 |
5 | module ZendeskAppsTools
6 | module Deploy
7 | include ZendeskAppsTools::Common
8 | include ZendeskAppsTools::APIConnection
9 |
10 | def deploy_app(connection_method, url, body)
11 | body[:upload_id] = upload(options[:path]).to_s
12 | sleep 2 # Because the DB needs time to replicate
13 |
14 | response = cached_connection.send(connection_method) do |req|
15 | req.url url
16 | req.headers[:content_type] = 'application/json'
17 | req.body = JSON.generate body
18 | end
19 |
20 | check_status response
21 |
22 | rescue Faraday::Error::ClientError, JSON::ParserError => e
23 | say_error_and_exit e.message
24 | end
25 |
26 | def app_exists?(app_id)
27 | url = "/api/v2/apps/#{app_id}.json"
28 | response = cached_connection.send(:get) do |req|
29 | req.url url
30 | end
31 |
32 | %w(200 201 202).include? response.status.to_s
33 | end
34 |
35 | def install_app(poll_job, product_name, installation)
36 | response = cached_connection.post do |req|
37 | req.url "api/#{product_name}/apps/installations.json"
38 | req.headers[:content_type] = 'application/json'
39 | req.body = JSON.generate(installation)
40 | end
41 | check_status(response, poll_job)
42 | end
43 |
44 | def upload(path)
45 | zipfile_path = options[:zipfile]
46 |
47 | if zipfile_path
48 | package_path = zipfile_path
49 | else
50 | package
51 | package_path = Dir[File.join path, '/tmp/*.zip'].sort.last
52 | end
53 |
54 | payload = {
55 | uploaded_data: Faraday::UploadIO.new(package_path, 'application/zip')
56 | }
57 |
58 | response = cached_connection(:multipart).post('/api/v2/apps/uploads.json', payload)
59 | json_or_die(response.body)['id']
60 |
61 | rescue Faraday::Error::ClientError => e
62 | say_error_and_exit e.message
63 | end
64 |
65 | def find_app_id(product_name = 'v2') # use the v2 endpoint if no product name is provided
66 | say_status 'Update', 'app ID is missing, searching...'
67 | app_name = get_value_from_stdin('Enter the name of the app:')
68 |
69 | response = cached_connection.get("/api/#{product_name}/apps/owned.json")
70 | owned_apps_json = json_or_die(response.body)
71 |
72 | unless response.success? && owned_apps_json.has_key?('apps')
73 | say_error_and_exit "Unable to retrieve apps. Please check your credentials and internet connection."
74 | else
75 | app = owned_apps_json['apps'].find {
76 | |app| app['name'] == app_name
77 | }
78 | end
79 |
80 | unless app
81 | say_error_and_exit "App not found. Please check that your app name is correct."
82 | end
83 |
84 | app_id = app['id']
85 | cache.save 'app_id' => app_id
86 | app_id
87 |
88 | rescue Faraday::Error::ClientError => e
89 | say_error_and_exit e.message
90 | end
91 |
92 | def check_status(response, poll_job = true)
93 | job_response = json_or_die(response.body)
94 |
95 | say_error_and_exit job_response['error'] if job_response['error']
96 |
97 | if poll_job
98 | job_id = job_response['job_id'] || job_response['pending_job_id']
99 | check_job job_id
100 | end
101 | end
102 |
103 | def check_job(job_id)
104 | loop do
105 | request = cached_connection.get("/api/v2/apps/job_statuses/#{job_id}")
106 | response = json_or_die(request.body)
107 | status = response['status']
108 |
109 | if %w(completed failed).include? status
110 | case status
111 | when 'completed'
112 | cache.save zat_contents(response)
113 | say_status @command, 'OK'
114 | when 'failed'
115 | say_status @command, response['message'], :red
116 | exit 1
117 | end
118 | break
119 | end
120 |
121 | say_status 'Status', status
122 | sleep 3
123 | end
124 | rescue Faraday::Error::ClientError => e
125 | say_error_and_exit e.message
126 | end
127 |
128 | private
129 |
130 | def zat_contents(response)
131 | zat = {}
132 | zat['subdomain'] = @subdomain
133 | zat['username'] = @username
134 | zat['app_id'] = response['app_id'] if response['app_id']
135 |
136 | zat
137 | end
138 |
139 | def cached_connection(encoding = :url_encoded)
140 | @connection ||= {}
141 | @connection[encoding] ||= get_connection(encoding)
142 | end
143 | end
144 | end
145 |
--------------------------------------------------------------------------------
/Gemfile.lock:
--------------------------------------------------------------------------------
1 | PATH
2 | remote: .
3 | specs:
4 | zendesk_apps_tools (3.9.5)
5 | execjs (~> 2.7.0)
6 | faraday (~> 0.17.5)
7 | faye-websocket (>= 0.10.7, < 0.12.0)
8 | listen (~> 2.10)
9 | nokogiri (~> 1.18, >= 1.18.8)
10 | rack (~> 3.0)
11 | rack-livereload (= 0.3.17)
12 | rubyzip (>= 1.2.1, < 2.4.0)
13 | sinatra (~> 4.1)
14 | sinatra-cross_origin (~> 0.3.1)
15 | thin (~> 2.0)
16 | thor (~> 0.19.4)
17 | zendesk_apps_support (~> 4.39.0)
18 |
19 | GEM
20 | remote: https://rubygems.org/
21 | specs:
22 | addressable (2.8.1)
23 | public_suffix (>= 2.0.2, < 6.0)
24 | aruba (0.14.14)
25 | childprocess (>= 0.6.3, < 4.0.0)
26 | contracts (~> 0.9)
27 | cucumber (>= 1.3.19)
28 | ffi (~> 1.9)
29 | rspec-expectations (>= 2.99)
30 | thor (>= 0.19, < 2.0)
31 | base64 (0.3.0)
32 | builder (3.2.4)
33 | bump (0.10.0)
34 | byebug (11.1.3)
35 | celluloid (0.16.0)
36 | timers (~> 4.0.0)
37 | childprocess (3.0.0)
38 | coderay (1.1.3)
39 | concurrent-ruby (1.2.2)
40 | contracts (0.16.1)
41 | crack (0.4.5)
42 | rexml
43 | crass (1.0.6)
44 | cucumber (7.1.0)
45 | builder (~> 3.2, >= 3.2.4)
46 | cucumber-core (~> 10.1, >= 10.1.0)
47 | cucumber-create-meta (~> 6.0, >= 6.0.1)
48 | cucumber-cucumber-expressions (~> 14.0, >= 14.0.0)
49 | cucumber-gherkin (~> 22.0, >= 22.0.0)
50 | cucumber-html-formatter (~> 17.0, >= 17.0.0)
51 | cucumber-messages (~> 17.1, >= 17.1.1)
52 | cucumber-wire (~> 6.2, >= 6.2.0)
53 | diff-lcs (~> 1.4, >= 1.4.4)
54 | mime-types (~> 3.3, >= 3.3.1)
55 | multi_test (~> 0.1, >= 0.1.2)
56 | sys-uname (~> 1.2, >= 1.2.2)
57 | cucumber-core (10.1.1)
58 | cucumber-gherkin (~> 22.0, >= 22.0.0)
59 | cucumber-messages (~> 17.1, >= 17.1.1)
60 | cucumber-tag-expressions (~> 4.1, >= 4.1.0)
61 | cucumber-create-meta (6.0.4)
62 | cucumber-messages (~> 17.1, >= 17.1.1)
63 | sys-uname (~> 1.2, >= 1.2.2)
64 | cucumber-cucumber-expressions (14.0.0)
65 | cucumber-gherkin (22.0.0)
66 | cucumber-messages (~> 17.1, >= 17.1.1)
67 | cucumber-html-formatter (17.0.0)
68 | cucumber-messages (~> 17.1, >= 17.1.0)
69 | cucumber-messages (17.1.1)
70 | cucumber-tag-expressions (4.1.0)
71 | cucumber-wire (6.2.1)
72 | cucumber-core (~> 10.1, >= 10.1.0)
73 | cucumber-cucumber-expressions (~> 14.0, >= 14.0.0)
74 | daemons (1.4.1)
75 | diff-lcs (1.5.0)
76 | erubis (2.7.0)
77 | eventmachine (1.2.7)
78 | execjs (2.7.0)
79 | faraday (0.17.6)
80 | multipart-post (>= 1.2, < 3)
81 | faye-websocket (0.11.1)
82 | eventmachine (>= 0.12.0)
83 | websocket-driver (>= 0.5.1)
84 | ffi (1.15.5)
85 | hashdiff (1.0.1)
86 | hitimes (2.0.0)
87 | i18n (1.12.0)
88 | concurrent-ruby (~> 1.0)
89 | image_size (2.0.2)
90 | ipaddress_2 (0.13.0)
91 | json (2.6.3)
92 | listen (2.10.1)
93 | celluloid (~> 0.16.0)
94 | rb-fsevent (>= 0.9.3)
95 | rb-inotify (>= 0.9)
96 | logger (1.7.0)
97 | loofah (2.19.1)
98 | crass (~> 1.0.2)
99 | nokogiri (>= 1.5.9)
100 | marcel (1.0.2)
101 | method_source (1.0.0)
102 | mime-types (3.4.1)
103 | mime-types-data (~> 3.2015)
104 | mime-types-data (3.2023.0218.1)
105 | mini_portile2 (2.8.9)
106 | multi_test (0.1.2)
107 | multipart-post (2.3.0)
108 | mustermann (3.0.4)
109 | ruby2_keywords (~> 0.0.1)
110 | nokogiri (1.18.10)
111 | mini_portile2 (~> 2.8.2)
112 | racc (~> 1.4)
113 | pry (0.14.2)
114 | coderay (~> 1.1)
115 | method_source (~> 1.0)
116 | public_suffix (5.0.1)
117 | racc (1.6.2)
118 | rack (3.0.18)
119 | rack-livereload (0.3.17)
120 | rack
121 | rack-protection (4.1.1)
122 | base64 (>= 0.1.0)
123 | logger (>= 1.6.0)
124 | rack (>= 3.0.0, < 4)
125 | rack-session (2.1.1)
126 | base64 (>= 0.1.0)
127 | rack (>= 3.0.0)
128 | rake (13.0.6)
129 | rb-fsevent (0.11.2)
130 | rb-inotify (0.9.10)
131 | ffi (>= 0.5.0, < 2)
132 | rexml (3.4.4)
133 | rspec (3.12.0)
134 | rspec-core (~> 3.12.0)
135 | rspec-expectations (~> 3.12.0)
136 | rspec-mocks (~> 3.12.0)
137 | rspec-core (3.12.1)
138 | rspec-support (~> 3.12.0)
139 | rspec-expectations (3.12.2)
140 | diff-lcs (>= 1.2.0, < 2.0)
141 | rspec-support (~> 3.12.0)
142 | rspec-mocks (3.12.3)
143 | diff-lcs (>= 1.2.0, < 2.0)
144 | rspec-support (~> 3.12.0)
145 | rspec-support (3.12.0)
146 | ruby2_keywords (0.0.5)
147 | rubyzip (2.3.2)
148 | sass (3.7.4)
149 | sass-listen (~> 4.0.0)
150 | sass-listen (4.0.0)
151 | rb-fsevent (~> 0.9, >= 0.9.4)
152 | rb-inotify (~> 0.9, >= 0.9.7)
153 | sassc (2.4.0)
154 | ffi (~> 1.9)
155 | sinatra (4.1.1)
156 | logger (>= 1.6.0)
157 | mustermann (~> 3.0)
158 | rack (>= 3.0.0, < 4)
159 | rack-protection (= 4.1.1)
160 | rack-session (>= 2.0.0, < 3)
161 | tilt (~> 2.0)
162 | sinatra-cross_origin (0.3.2)
163 | sys-uname (1.2.2)
164 | ffi (~> 1.1)
165 | thin (2.0.1)
166 | daemons (~> 1.0, >= 1.0.9)
167 | eventmachine (~> 1.0, >= 1.0.4)
168 | logger
169 | rack (>= 1, < 4)
170 | thor (0.19.4)
171 | tilt (2.1.0)
172 | timers (4.0.4)
173 | hitimes
174 | webmock (3.18.1)
175 | addressable (>= 2.8.0)
176 | crack (>= 0.3.2)
177 | hashdiff (>= 0.4.0, < 2.0.0)
178 | websocket-driver (0.7.5)
179 | websocket-extensions (>= 0.1.0)
180 | websocket-extensions (0.1.5)
181 | zendesk_apps_support (4.39.0)
182 | erubis
183 | i18n (>= 1.7.1)
184 | image_size (~> 2.0.2)
185 | ipaddress_2 (~> 0.13.0)
186 | json
187 | loofah (>= 2.3.1, < 2.20.0)
188 | marcel
189 | nokogiri
190 | rb-inotify (= 0.9.10)
191 | sass
192 | sassc
193 |
194 | PLATFORMS
195 | x86_64-darwin-20
196 | x86_64-darwin-21
197 | x86_64-darwin-22
198 | x86_64-linux
199 |
200 | DEPENDENCIES
201 | aruba (~> 0.14.14)
202 | bump
203 | byebug
204 | cucumber (~> 7.1.0)
205 | pry
206 | rake
207 | rspec
208 | webmock
209 | zendesk_apps_tools!
210 |
211 | BUNDLED WITH
212 | 2.3.26
213 |
--------------------------------------------------------------------------------
/lib/zendesk_apps_tools/theme.rb:
--------------------------------------------------------------------------------
1 | # frozen_string_literal: true
2 |
3 | require 'zendesk_apps_tools/theming/common'
4 |
5 | module ZendeskAppsTools
6 | class Theme < Thor
7 | include Thor::Actions
8 | include ZendeskAppsTools::CommandHelpers
9 | include ZendeskAppsTools::Theming::Common
10 |
11 | desc 'preview', 'Preview a theme in development'
12 | shared_options(except: %i[clean unattended])
13 | method_option :port, default: Command::DEFAULT_SERVER_PORT, required: false, desc: 'Port for the http server to use.'
14 | method_option :bind, default: Command::DEFAULT_SERVER_IP, required: false
15 | method_option :livereload, type: :boolean, default: true, desc: 'Enable or disable live-reloading the preview when a change is made.'
16 | method_option :force_polling, type: :boolean, default: false, desc: 'Force the use of the polling adapter.'
17 | def preview
18 | setup_path(options[:path])
19 | ensure_manifest!
20 | require 'faraday'
21 | full_upload
22 | callbacks_after_upload = []
23 | start_listener(callbacks_after_upload)
24 | start_server(callbacks_after_upload)
25 | end
26 |
27 | no_commands do
28 | def full_upload
29 | say_status 'Generating', 'Generating theme from local files'
30 | payload = generate_payload
31 | say_status 'Generating', 'OK'
32 | say_status 'Uploading', 'Uploading theme'
33 | connection = get_connection(nil)
34 | connection.use Faraday::Response::RaiseError
35 | connection.put do |req|
36 | req.url '/hc/api/internal/theming/local_preview'
37 | req.body = JSON.dump(payload)
38 | req.headers['Content-Type'] = 'application/json'
39 | end
40 | say_status 'Uploading', 'OK'
41 | say_status 'Ready', "#{connection.url_prefix}hc/admin/local_preview/start"
42 | say "You can exit preview mode in the UI or by visiting #{connection.url_prefix}hc/admin/local_preview/stop"
43 | rescue Faraday::Error::ClientError => e
44 | say_status 'Uploading', "Failed: #{e.message}", :red
45 | begin
46 | error_hash = JSON.parse(e.response[:body])
47 | broken_templates = error_hash['template_errors']
48 | if broken_templates
49 | broken_templates.each do |template_name, errors|
50 | errors.each do |error|
51 | say_status 'Error', "#{template_name} L#{error['line']}:#{error['column']}: #{error['description']}", :red
52 | end
53 | end
54 | else
55 | say_status 'Error', error_hash
56 | end
57 | rescue JSON::ParserError
58 | say_error_and_exit 'Server error.'
59 | end
60 | end
61 |
62 | def start_listener(callbacks_after_upload)
63 | # TODO: do we need to stop the listener at some point?
64 | require 'listen'
65 | path = Pathname.new(theme_package_path('.')).cleanpath
66 | listener = ::Listen.to(path, ignore: /\.zat/, force_polling: options[:force_polling]) do |modified, added, removed|
67 | need_upload = false
68 | if modified.any? { |file| file[/templates|manifest/] }
69 | need_upload = true
70 | end
71 | if added.any? || removed.any?
72 | need_upload = true
73 | end
74 | if need_upload
75 | full_upload
76 | end
77 | callbacks_after_upload.each do |callback|
78 | callback.call
79 | end
80 | end
81 | listener.start
82 | end
83 |
84 | IDENTIFIER_REGEX = /.*templates\/(?.*)(?=\.hbs)/
85 |
86 | def generate_payload
87 | payload = {}
88 | templates = Dir.glob(theme_package_path('templates/*.hbs')) + Dir.glob(theme_package_path('templates/*/*.hbs'))
89 | templates_payload = {}
90 | templates.each do |template|
91 | identifier = template.match(IDENTIFIER_REGEX)['identifier'].to_s
92 | templates_payload[identifier] = File.read(template)
93 | end
94 | payload['templates'] = templates_payload
95 | payload['templates']['document_head'] = inject_external_tags(payload['templates']['document_head'])
96 | payload['templates']['css'] = ''
97 | payload['templates']['js'] = ''
98 | payload['templates']['assets'] = assets
99 | payload['templates']['variables'] = settings_hash
100 | payload['templates']['metadata'] = metadata_hash
101 | payload
102 | end
103 |
104 | def inject_external_tags(head_template)
105 | live_reload_script_tag = <<-html
106 |
109 |
110 | html
111 |
112 | js_tag = <<-html
113 |
114 | html
115 |
116 | css_tag = <<-html
117 |
118 | html
119 |
120 | template = StringIO.new
121 | template << css_tag
122 | template << head_template
123 | template << js_tag
124 | template << live_reload_script_tag
125 | template.string
126 | end
127 |
128 | alias_method :ensure_manifest!, :manifest
129 |
130 | def start_server(callbacks_after_upload)
131 | require 'zendesk_apps_tools/theming/server'
132 | if options[:livereload]
133 | require 'rack-livereload'
134 | require 'faye/websocket'
135 | Faye::WebSocket.load_adapter('thin')
136 | end
137 |
138 | ZendeskAppsTools::Theming::Server.tap do |server|
139 | server.set :bind, options[:bind] if options[:bind]
140 | server.set :port, options[:port]
141 | server.set :root, app_dir
142 | server.set :public_folder, app_dir
143 | server.set :livereload, options[:livereload]
144 | server.set :callbacks_after_load, callbacks_after_upload
145 | server.set :callback_map, {}
146 | server.use Rack::LiveReload, live_reload_port: 4567 if options[:livereload]
147 | server.run!
148 | end
149 | end
150 | end
151 | end
152 | end
153 |
--------------------------------------------------------------------------------
/doc/tools.md:
--------------------------------------------------------------------------------
1 | ## Zendesk App Tools
2 |
3 | The Zendesk app tools (ZAT) is a collection of local development tools that simplify building and deploying Zendesk apps. The tools lets you create, test, validate, and package your apps locally.
4 |
5 | ZAT is a [Ruby gem](http://rubygems.org/gems/zendesk_apps_tools) -- a self-contained package of Ruby code that extends Ruby. You don't need to know Ruby to use the tools but you do need to install Ruby to install the gem.
6 |
7 | To install the tools, see [Installing and using the Zendesk apps tools](https://develop.zendesk.com/hc/en-us/articles/360001075048). See the [known issues](https://develop.zendesk.com/hc/en-us/articles/360001075068) if you run into any problems installing or using the tools.
8 |
9 | The tools consist of the following commands.
10 |
11 | ### New
12 |
13 | Creates all the files necessary to start building a Zendesk app.
14 |
15 | $ zat new
16 |
17 | Example:
18 |
19 | 
20 |
21 | Zendesk has announced the sunsetting of this version of the Zendesk Apps framework (ZAF v1). Creating a new app targeting the v1 framework was deprecated in June 2017. For more information, see [the announcement](https://support.zendesk.com/hc/articles/115004453028). To get started with the newer version of the framework, see the [App Framework v2](https://developer.zendesk.com/apps/docs/apps-v2/getting_started) docs.
22 |
23 | ### Validate
24 |
25 | Runs a suite of validation tests against your app.
26 |
27 | $ zat validate
28 |
29 | ### Server
30 |
31 | Starts a local HTTP server that lets you run and test your apps locally.
32 |
33 | Note: [Secure requests](./requests#secure_requests) and [app requirements](./apps_requirements) don't work when running the app locally. See [ZAT server limitations](https://develop.zendesk.com/hc/en-us/articles/360001075048#topic_ux4_lv3_ks) for more information and a workaround.
34 |
35 | Follow these steps to run an app locally:
36 |
37 | 1. Use a command-line interface to navigate to the folder containing the app you want to test.
38 |
39 | - Run the following command in the app's folder to start the server:
40 |
41 | $ zat server
42 |
43 | - In a browser, navigate to any ticket in Zendesk. The URL should look something like this:
44 |
45 | https://subdomain.zendesk.com/agent/tickets/321321
46 |
47 | - Insert `?zat=true` at the end of the URL in the Address bar.
48 |
49 | The URL should now look like this:
50 |
51 | https://subdomain.zendesk.com/agent/tickets/321321?zat=true
52 |
53 | - Click the Reload Apps icon in the upper-right side of the Apps panel to load your local app.
54 |
55 | **Note**: If nothing happens and you're using Chrome or Firefox, click the shield icon on the Address Bar and agree to load an unsafe script (Chrome) or to disable protection on the page (Firefox).
56 |
57 | To stop the server, switch to your command-line interface and press Control+C.
58 |
59 | #### App Settings
60 |
61 | When testing your app, you might need to specify some [app settings](manifest#app-settings). If you started the local server with `zat server`, ZAT will ask interactively for the values of all the settings specified in the app's `manifest.json` file. However, you might prefer to specify the settings in a JSON or YAML file.
62 |
63 | 1. Create a JSON or YAML file with your settings. The keys in the file should be the same as in your manifest file.
64 | 2. Start the server with `zat server -c [$CONFIG_FILE]`, where $CONFIG_FILE is the name of your JSON or YAML file. The default filename is `settings.yml`. You don't need to specify it in the command if your file uses that name.
65 |
66 | If `manifest.json` contains the following settings:
67 |
68 | ```json
69 | {
70 | ...
71 | "parameters": [
72 | {
73 | "name": "mySetting"
74 | },
75 | ...
76 | ]
77 | }
78 | ```
79 |
80 | Then you can specify the settings in a file as follows:
81 |
82 | ```json
83 | ./settings.json
84 |
85 | {
86 | "mySetting": "test value"
87 | }
88 | ```
89 |
90 | or
91 |
92 | ```yaml
93 | ./settings.yml
94 |
95 | mySetting: test value
96 | ```
97 |
98 | With the first file, you'd start the server with `zat server -c settings.json`. With the second file, you'd start it with `zat server -c`.
99 |
100 | For details on how to access the settings values from your JavaScript code or Handlebar templates, see [Retrieving setting values](./settings#retrieving-setting-values).
101 |
102 | ### Package
103 |
104 | Creates a zip file that you can [upload and install](https://develop.zendesk.com/hc/en-us/articles/360001069347) in Zendesk.
105 |
106 | $ zat package
107 |
108 | The command saves the zip file in a folder named tmp.
109 |
110 | Example:
111 |
112 | 
113 |
114 | ### Clean
115 |
116 | Removes the zip files in the tmp folder that's created when you package the app.
117 |
118 | $ zat clean
119 |
120 | ### Create
121 |
122 | Packages your app directory into a zip file, then uploads it into a Zendesk account. Also stores the new app's ID and other metadata in a zat file (this is `.zat`).
123 |
124 | You can point to the directory containing the app by using the path option. Example: `zat create --path=./MY_PATH`.
125 |
126 | You can use an existing zip file instead of an app directory by passing a `zipfile` option like `zat create --zipfile=~/zendesk_app/my_zipfile.zip`.
127 |
128 | ### Update
129 |
130 | Much like create, use this command to update an app that you have previously create using `zat create`. This command uses the app ID and other metadata found in the `.zat` file.
131 |
132 | ### Configuration for multiple apps
133 |
134 | If you have multiple apps and you want to re-use the credentials and subdomain for `zat create` and `zat update` without typing them in for each app, create a file called `.zat` in your home directory (`C:\Users\YOUR_NAME\.zat` on Windows or `~/.zat`). To manage many apps under the `mysubdomain` subdomain, populate the file with JSON like this:
135 |
136 | ```json
137 | {
138 | "default": {
139 | "subdomain": "mysubdomain"
140 | },
141 | "mysubdomain": {
142 | "username": "ME@EMAIL.COM"
143 | }
144 | }
145 | ```
146 |
147 | For apps that do not have a `.zat` file with a subdomain, the default value is used.
148 |
--------------------------------------------------------------------------------
/spec/lib/zendesk_apps_tools/api_connection_spec.rb:
--------------------------------------------------------------------------------
1 | require 'spec_helper'
2 | require 'api_connection'
3 |
4 | describe ZendeskAppsTools::APIConnection do
5 | describe 'EMAIL_REGEX' do
6 | let(:email_regex) { ZendeskAppsTools::APIConnection::EMAIL_REGEX }
7 | define_method(:matches_email?) { |email| !!email_regex.match(email) }
8 |
9 | it 'does not match invalid email addresses' do
10 | expect(matches_email?('username')).to eq(false)
11 | expect(matches_email?('username.com')).to eq(false)
12 | expect(matches_email?('u!sername@email.com')).to eq(false)
13 | expect(matches_email?('username@email.com/token:password')).to eq(false)
14 | expect(matches_email?('username@email.com/')).to eq(false)
15 | end
16 |
17 | it 'will match valid email addresses' do
18 | expect(matches_email?('username@email.com')).to eq(true)
19 | expect(matches_email?('username123@e-mail.com')).to eq(true)
20 | end
21 |
22 | it 'will match valid email addresses with api_token' do
23 | expect(matches_email?('username@email.com/token')).to eq(true)
24 | end
25 | end
26 |
27 | describe 'CONSTANTS' do
28 | let(:subdomain_validation_pattern) { ZendeskAppsTools::APIConnection::SUBDOMAIN_VALIDATION_PATTERN }
29 | let(:url_validation_pattern) { ZendeskAppsTools::APIConnection::ZENDESK_URL_VALIDATION_PATTERN }
30 | let(:default_url_template) { ZendeskAppsTools::APIConnection::DEFAULT_URL_TEMPLATE }
31 |
32 | describe 'DEFAULT_URL_TEMPLATE' do
33 | context '% subdomain (used in private method full_url)' do
34 | it 'replaces %s with subdomain in template' do
35 | user_input_subdomain = 'my-subdomain'
36 | expect(default_url_template % user_input_subdomain).to eq("https://my-subdomain.zendesk.com/")
37 | end
38 | end
39 | end
40 |
41 | describe 'SUBDOMAIN_VALIDATION_PATTERN' do
42 | context 'valid_subdomain? (a private method)' do
43 | define_method(:valid_subdomain?) { |subdomain| !!subdomain_validation_pattern.match(subdomain) }
44 |
45 | it 'returns false if subdomain is NOT in valid format' do
46 | expect(valid_subdomain?('sub.domain')).to eq(false)
47 | expect(valid_subdomain?('sub!domain')).to eq(false)
48 | expect(valid_subdomain?('sub~domain')).to eq(false)
49 | expect(valid_subdomain?('sub_domain')).to eq(false)
50 | end
51 |
52 | it 'returns true if subdomain is in valid format' do
53 | expect(valid_subdomain?('subDomain')).to eq(true)
54 | expect(valid_subdomain?('SUBDOMAIN')).to eq(true)
55 | expect(valid_subdomain?('subdomain')).to eq(true)
56 | expect(valid_subdomain?('sub-domain')).to eq(true)
57 | end
58 | end
59 | end
60 |
61 | describe 'ZENDESK_URL_VALIDATION_PATTERN' do
62 | context 'valid_full_url? (a private method)' do
63 | define_method(:valid_full_url?) { |subdomain| !!url_validation_pattern.match(subdomain) }
64 |
65 | context 'with regular zendesk urls' do
66 | it 'returns false when subdomain does not match full url pattern' do
67 | expect(valid_full_url?('www.subdomain.com')).to eq(false)
68 | expect(valid_full_url?('subdomain.com')).to eq(false)
69 | end
70 |
71 | it 'returns true when subdomain does match full url pattern' do
72 | expect(valid_full_url?('http://z3n-subdomain.zendesk.com')).to eq(true)
73 | expect(valid_full_url?('https://subdomain.zendesk.com')).to eq(true)
74 | expect(valid_full_url?('https://my-subdomain.zendesk-staging.com')).to eq(true)
75 | end
76 | end
77 |
78 | context 'with host map urls' do
79 | it 'returns true when subdomain of customized urls matches full url pattern' do
80 | expect(valid_full_url?('https://subdomain.com')).to eq(true)
81 | expect(valid_full_url?('https://www.subdomain.com')).to eq(true)
82 | expect(valid_full_url?('https://subdomain.au')).to eq(true)
83 | end
84 | end
85 | end
86 | end
87 | end
88 |
89 | let(:subject_class) do
90 | Class.new do
91 | include ZendeskAppsTools::APIConnection
92 | attr_reader :cache, :subdomain, :username, :password
93 |
94 | def initialize(subdomain = nil, username = nil, password = nil)
95 | @cache = {
96 | 'subdomain' => @subdomain = subdomain,
97 | 'username' => @username = username,
98 | }
99 |
100 | @cache['password'] = @password = password if password
101 | end
102 | end
103 | end
104 |
105 | describe '#prepare_api_auth' do
106 | let(:url_error_message) { ZendeskAppsTools::APIConnection::URL_ERROR_MSG }
107 | let(:email_error_message) { ZendeskAppsTools::APIConnection::EMAIL_ERROR_MSG }
108 |
109 | context 'invalid subdomain' do
110 | it 'errors and exit' do
111 | subject = subject_class.new('bad!subdomain')
112 |
113 | expect(subject).to receive(:say_error_and_exit).with(url_error_message) { exit }
114 |
115 | expect { subject.prepare_api_auth }.to raise_error(SystemExit)
116 | end
117 | end
118 |
119 | context 'invalid full url' do
120 | it 'errors and exit' do
121 | subject = subject_class.new('www.keith.com')
122 |
123 | expect(subject).to receive(:say_error_and_exit).with(url_error_message) { exit }
124 |
125 | expect { subject.prepare_api_auth }.to raise_error(SystemExit)
126 | end
127 | end
128 |
129 | context 'with invalid email format' do
130 | it 'errors and exit' do
131 | subject = subject_class.new('subdomain', 'bad-email')
132 |
133 | expect(subject).to receive(:say_error_and_exit).with(email_error_message) { exit }
134 |
135 | expect { subject.prepare_api_auth }.to raise_error(SystemExit)
136 | end
137 | end
138 | end
139 |
140 | describe '#get_connection' do
141 | context 'with all credentials (available in cache)' do
142 | let(:subject_with_all_credentials) { subject_class.new('subdomain', 'username', 'password') }
143 |
144 | it 'does not call prepare_api_auth' do
145 | expect(subject_with_all_credentials).to_not receive(:prepare_api_auth)
146 | subject_with_all_credentials.get_connection
147 | end
148 |
149 | it 'creates a faraday client for network requests' do
150 | expect(subject_with_all_credentials.get_connection).to be_instance_of(Faraday::Connection)
151 | end
152 | end
153 |
154 | context 'with one or more missing credentials (in cache)' do
155 | let(:subject_with_1_missing_credential) { subject_class.new('subdomain', 'username') }
156 | let(:subject_with_2_missing_credentials) { subject_class.new('subdomain') }
157 |
158 | it 'calls prepare_api_auth only once' do
159 | expect(subject_with_1_missing_credential).to receive(:prepare_api_auth).exactly(1).times
160 | subject_with_1_missing_credential.get_connection
161 |
162 | expect(subject_with_2_missing_credentials).to receive(:prepare_api_auth).exactly(1).times
163 | subject_with_2_missing_credentials.get_connection
164 | end
165 | end
166 | end
167 | end
168 |
--------------------------------------------------------------------------------
/spec/lib/zendesk_apps_tools/settings_spec.rb:
--------------------------------------------------------------------------------
1 | require 'spec_helper'
2 | require 'common'
3 | require 'settings'
4 |
5 | describe ZendeskAppsTools::Settings do
6 | before(:each) do
7 | @user_input = Object.new
8 | allow(@user_input).to receive(:ask) do |_p, thor_opts| # this represents the default user input
9 | thor_opts && thor_opts[:default] || ''
10 | end
11 | allow(@user_input).to receive(:say)
12 | allow(@user_input).to receive(:options).and_return({})
13 | @user_input.extend(ZendeskAppsTools::Common)
14 | @context = ZendeskAppsTools::Settings.new(@user_input)
15 | end
16 |
17 | describe '#get_settings_from_user_input' do
18 | it 'accepts user input with colon & slashes' do
19 | parameters = [
20 | {
21 | 'name' => 'backend',
22 | 'required' => true,
23 | 'default' => 'https://example.com:3000'
24 | }
25 | ]
26 |
27 | settings = {
28 | 'backend' => 'https://example.com:3000'
29 | }
30 |
31 | allow(@user_input).to receive(:ask).with("Enter a value for required parameter 'backend':\n").and_return('https://example.com:3000')
32 | expect(@context.get_settings_from_user_input(parameters)).to eq(settings)
33 | end
34 |
35 | it 'should use default boolean parameter' do
36 | parameters = [
37 | {
38 | 'name' => 'isUrgent',
39 | 'type' => 'checkbox',
40 | 'required' => true,
41 | 'default' => true
42 | }
43 | ]
44 |
45 | settings = {
46 | 'isUrgent' => true
47 | }
48 |
49 | expect(@context.get_settings_from_user_input(parameters)).to eq(settings)
50 | end
51 |
52 | it 'prompts the user for settings' do
53 | parameters = [
54 | {
55 | 'name' => 'required',
56 | 'required' => true
57 | },
58 | {
59 | 'name' => 'required_with_default',
60 | 'required' => true,
61 | 'default' => '123'
62 | },
63 | {
64 | 'name' => 'not_required'
65 | },
66 | {
67 | 'name' => 'not_required_with_default',
68 | 'default' => '789'
69 | },
70 | {
71 | 'name' => 'skipped'
72 | }
73 | ]
74 |
75 | settings = {
76 | 'required' => 'xyz',
77 | 'required_with_default' => '123',
78 | 'not_required' => '456',
79 | 'not_required_with_default' => '789'
80 | }
81 |
82 | allow(@user_input).to receive(:ask).with("Enter a value for required parameter 'required':\n", { default: nil }).and_return('xyz')
83 | allow(@user_input).to receive(:ask).with("Enter a value for optional parameter 'not_required' or press 'Return' to skip:\n", { default: nil }).and_return('456')
84 | expect(@context.get_settings_from_user_input(parameters)).to eq(settings)
85 | end
86 | end
87 |
88 | describe '#get_settings_from_file' do
89 | context 'when the file doesn\'t exist' do
90 | it 'returns nil' do
91 | expect(@context.get_settings_from_file('spec/fixture/none_existing/settings.yml', [])).to be_nil
92 | end
93 | end
94 |
95 | context 'with a JSON file' do
96 | it 'returns the settings' do
97 | parameters = [
98 | {
99 | 'name' => 'text',
100 | 'type' => 'text'
101 | },
102 | {
103 | 'name' => 'number',
104 | 'type' => 'text'
105 | },
106 | {
107 | 'name' => 'checkbox',
108 | 'type' => 'checkbox'
109 | },
110 | {
111 | 'name' => 'array',
112 | 'type' => 'multiline'
113 | },
114 | {
115 | 'name' => 'object',
116 | 'type' => 'multiline'
117 | }
118 | ]
119 |
120 | settings = {
121 | 'text' => 'text',
122 | 'number' => 1,
123 | 'checkbox' => true,
124 | 'array' => "[\"test1\"]",
125 | 'object' => "{\"test1\":\"value\"}"
126 | }
127 |
128 | expect(@context.get_settings_from_file('spec/fixture/config/settings.json', parameters)).to eq(settings)
129 | end
130 | end
131 |
132 | context 'with a YAML file' do
133 | it 'returns the settings 1 level deep when the file exist' do
134 | parameters = [
135 | {
136 | 'name' => 'text',
137 | 'type' => 'text'
138 | },
139 | {
140 | 'name' => 'number',
141 | 'type' => 'text'
142 | },
143 | {
144 | 'name' => 'checkbox',
145 | 'type' => 'checkbox'
146 | },
147 | {
148 | 'name' => 'array',
149 | 'type' => 'multiline'
150 | },
151 | {
152 | 'name' => 'object',
153 | 'type' => 'multiline'
154 | }
155 | ]
156 |
157 | settings = {
158 | 'text' => 'text',
159 | 'number' => 1,
160 | 'checkbox' => true,
161 | 'array' => "[\"test1\"]",
162 | 'object' => "{\"test1\":\"value\"}"
163 | }
164 |
165 | expect(@context.get_settings_from_file('spec/fixture/config/settings.yml', parameters)).to eq(settings)
166 | end
167 |
168 | it 'returns the default because you forgot to specify a required field with a default' do
169 | parameters = [
170 | {
171 | 'name' => 'required',
172 | 'type' => 'text',
173 | 'required' => true,
174 | 'default' => 'ok'
175 | }
176 | ]
177 |
178 | settings = {
179 | 'required' => 'ok'
180 | }
181 |
182 | expect(@context.get_settings_from_file('spec/fixture/config/settings.yml', parameters)).to eq(settings)
183 | end
184 |
185 | it 'returns nil because you forgot to specifiy a required field without a default' do
186 | parameters = [
187 | {
188 | 'name' => 'required',
189 | 'type' => 'text',
190 | 'required' => true
191 | }
192 | ]
193 |
194 | expect(@context.get_settings_from_file('spec/fixture/config/settings.yml', parameters)).to be_nil
195 | end
196 |
197 | it 'returns false if specified even if the default is true' do
198 | parameters = [
199 | {
200 | 'name' => 'falsey',
201 | 'type' => 'checkbox',
202 | 'default' => true
203 | }
204 | ]
205 |
206 | expect(@context.get_settings_from_file('spec/fixture/config/settings.yml', parameters)).to eq 'falsey' => false
207 | end
208 | end
209 | end
210 |
211 | describe '#refresh!' do
212 | before do
213 | @context.get_settings_from_file('test path', [])
214 | end
215 | it 'does not crash' do
216 | @context.refresh!
217 | end
218 |
219 | context 'when the file exists' do
220 | before do
221 | allow(File).to receive(:file?).and_return(true)
222 | allow(File).to receive(:stat).and_return(double('File::Stat', mtime: Time.now))
223 | end
224 |
225 | it 'works' do
226 | expect(@context).to receive :get_settings_from_file
227 | @context.refresh!
228 | end
229 | end
230 | end
231 | end
232 |
--------------------------------------------------------------------------------
/spec/lib/zendesk_apps_tools/translate_spec.rb:
--------------------------------------------------------------------------------
1 | require 'spec_helper'
2 | require 'translate'
3 |
4 | describe ZendeskAppsTools::Translate do
5 | describe '#to_yml' do
6 | it 'should convert i18n formatted json to translation yml' do
7 | root = 'spec/fixture/i18n_app_to_yml'
8 | target_yml = "#{root}/translations/en.yml"
9 | File.delete(target_yml) if File.exist?(target_yml)
10 | translate = ZendeskAppsTools::Translate.new
11 | translate.setup_path(root)
12 | begin
13 | translate.to_yml
14 | expect(File.read(target_yml)).to eq(File.read("#{root}/translations/expected.yml"))
15 | ensure
16 | File.delete(target_yml) if File.exist?(target_yml)
17 | end
18 | end
19 | end
20 |
21 | describe '#to_json' do
22 | it 'should convert translation yml to i18n formatted json' do
23 | root = 'spec/fixture/i18n_app_to_json'
24 | target_json = "#{root}/translations/en.json"
25 | File.delete(target_json) if File.exist?(target_json)
26 | translate = ZendeskAppsTools::Translate.new
27 | translate.setup_path(root)
28 | begin
29 | translate.to_json
30 | expect(File.read(target_json)).to eq(File.read("#{root}/translations/expected.json"))
31 | ensure
32 | File.delete(target_json) if File.exist?(target_json)
33 | end
34 | end
35 | end
36 |
37 | describe '#nest_translations_hash' do
38 | it 'removes package key prefix' do
39 | translations = { 'txt.apps.my_app.app.description' => 'Description' }
40 |
41 | result = { 'app' => { 'description' => 'Description' } }
42 |
43 | context = ZendeskAppsTools::Translate.new
44 | expect(context.nest_translations_hash(translations, 'txt.apps.my_app.')).to eq(result)
45 | end
46 |
47 | describe 'with a mix of nested and unnested keys' do
48 | it 'returns a mixed depth hash' do
49 | translations = {
50 | 'app.description' => 'This app is awesome',
51 | 'app.parameters.awesomeness.label' => 'Awesomeness level',
52 | 'global.error.title' => 'An error occurred',
53 | 'global.error.message' => 'Please try the previous action again.',
54 | 'global.loading' => 'Waiting for ticket data to load...',
55 | 'global.requesting' => 'Requesting data from Magento...',
56 | 'errormessage' => 'General error' }
57 |
58 | result = {
59 | 'app' => {
60 | 'description' => 'This app is awesome',
61 | 'parameters' => {
62 | 'awesomeness' => { 'label' => 'Awesomeness level' } }
63 | },
64 | 'global' => {
65 | 'error' => {
66 | 'title' => 'An error occurred',
67 | 'message' => 'Please try the previous action again.'
68 | },
69 | 'loading' => 'Waiting for ticket data to load...',
70 | 'requesting' => 'Requesting data from Magento...'
71 | },
72 | 'errormessage' => 'General error'
73 | }
74 |
75 | context = ZendeskAppsTools::Translate.new
76 | expect(context.nest_translations_hash(translations, '')).to eq(result)
77 | end
78 | end
79 | end
80 |
81 | # This would be better as an integration test but it requires significant
82 | # refactoring of the cucumber setup and addition of vcr or something similar
83 | # This is happy day only
84 | describe '#update' do
85 | it 'fetches default locales, translations and generates json files for each' do
86 | translate = ZendeskAppsTools::Translate.new
87 | allow(translate).to receive(:say)
88 | allow(translate).to receive(:ask).with('What is the package name for this app? (without leading app_)', { default: nil }).and_return('my_app')
89 | allow(translate).to receive(:create_file)
90 |
91 | expect(translate).to receive(:nest_translations_hash).once.and_return({})
92 | expect(translate).to receive(:write_json).once.with("translations/en-US.json", {})
93 |
94 | stub_request(:get, "https://static.zdassets.com/translations/admin/manifest.json").
95 | to_return(:status => 200, :body => JSON.dump('json' => [{'name' => 'en-US', 'path' => '/admin/en-au.7deffe494e55e77fb87cd91d16c4fe25.json'}]))
96 |
97 | stub_request(:get, "https://static.zdassets.com/translations/admin/en-au.7deffe494e55e77fb87cd91d16c4fe25.json").
98 | to_return(:status => 200, :body => JSON.dump('locale' => 'en-US', 'translations' => { 'app.description' => 'my awesome app' }))
99 |
100 | translate.update
101 | end
102 |
103 | it 'fetches locales from a given locales file, translations and generates json files for each' do
104 | translate = ZendeskAppsTools::Translate.new
105 | allow(translate).to receive(:say)
106 | allow(translate).to receive(:ask).with('What is the package name for this app? (without leading app_)', { default: nil }).and_return('my_app')
107 | allow(translate).to receive(:create_file)
108 | allow(translate).to receive(:options) { { locales: './locales.json' } }
109 | allow(translate).to receive(:read_file).and_return('["en-ca.c68cff07da3c07bed9849e29aa7566d7"]')
110 |
111 | expect(translate).to receive(:nest_translations_hash).once.and_return({})
112 | expect(translate).to receive(:write_json).once.with("translations/en-ca.json", {})
113 |
114 | stub_request(:get, "https://static.zdassets.com/translations/admin/en-ca.c68cff07da3c07bed9849e29aa7566d7.json").
115 | to_return(:status => 200, :body => JSON.dump('locale' => 'en-ca', 'translations' => { 'app.description' => 'my awesome app' }))
116 |
117 | translate.update
118 | end
119 | end
120 |
121 | describe '#write_json' do
122 | let(:translate) { ZendeskAppsTools::Translate.new }
123 |
124 | before do
125 | allow(translate).to receive(:options) { { unattended: true } }
126 | end
127 |
128 | around(:example) do |example|
129 | Dir.mktmpdir do |dir|
130 | @test_file_path = "#{dir}/test.json"
131 | example.run
132 | end
133 | end
134 |
135 | it 'works for identical ascii' do
136 | translate.create_file(@test_file_path, JSON.pretty_generate({ node: "test abc" }) + "\n", force: true)
137 | expect { translate.write_json(@test_file_path, { node: "test abc" }) }.to output(" identical #{@test_file_path}\n").to_stdout
138 | end
139 |
140 | it 'works for different ascii' do
141 | translate.create_file(@test_file_path, JSON.pretty_generate({ node: "test abc" }) + "\n", force: true)
142 | expect { translate.write_json(@test_file_path, { node: "test xyz" }) }.to output(" force #{@test_file_path}\n").to_stdout
143 | end
144 |
145 | it 'works for identical utf8' do
146 | translate.create_file(@test_file_path, JSON.pretty_generate({ node: "حدثت أخطاء أثناء التحقق من قائمة عملائك" }) + "\n", force: true)
147 | expect { translate.write_json(@test_file_path, { node: "حدثت أخطاء أثناء التحقق من قائمة عملائك" }) }.to output(" identical #{@test_file_path}\n").to_stdout
148 | end
149 |
150 | it 'works for different utf8' do
151 | translate.create_file(@test_file_path, JSON.pretty_generate({ node: "حدثت أخطاء أثناء التحقق من قائمة عملائك" }) + "\n", force: true)
152 | expect { translate.write_json(@test_file_path, { node: "自动回复机器" }) }.to output(" force #{@test_file_path}\n").to_stdout
153 | end
154 | end
155 |
156 | describe "#pseudotranslate" do
157 | it 'generates a json file for the specified locale' do
158 | root = 'spec/fixture/i18n_app_pseudotranslate'
159 | target_json = "#{root}/translations/fr.json"
160 | File.delete(target_json) if File.exist?(target_json)
161 | translate = ZendeskAppsTools::Translate.new
162 | translate.setup_path(root)
163 | begin
164 | translate.pseudotranslate
165 |
166 | expect(File.read(target_json)).to eq(File.read("#{root}/translations/expected.json"))
167 | ensure
168 | File.delete(target_json) if File.exist?(target_json)
169 | end
170 | end
171 | end
172 | end
173 |
--------------------------------------------------------------------------------
/lib/zendesk_apps_tools/translate.rb:
--------------------------------------------------------------------------------
1 | # frozen_string_literal: true
2 | require 'thor'
3 | require 'json'
4 | require 'zendesk_apps_tools/common'
5 | require 'zendesk_apps_tools/locale_identifier'
6 |
7 | module ZendeskAppsTools
8 | class Translate < Thor
9 | include Thor::Shell
10 | include Thor::Actions
11 | include ZendeskAppsTools::Common
12 |
13 | LOCALE_BASE_ENDPOINT = 'https://static.zdassets.com/translations'
14 | LOCALE_ENDPOINT = "#{LOCALE_BASE_ENDPOINT}/admin/manifest.json"
15 |
16 | desc 'to_yml', 'Create Zendesk translation file from en.json'
17 | shared_options(except: [:clean])
18 | def to_yml
19 | setup_path(options[:path]) if options[:path]
20 | manifest = JSON.parse(File.open("#{destination_root}/manifest.json").read)
21 | app_name = manifest['name']
22 |
23 | unless app_name
24 | app_name = get_value_from_stdin('What is the name of this app?', error_msg: 'Invalid name, try again:')
25 | end
26 |
27 | package = package_name_from_json(error_out: true)
28 | en_json['app'].delete('package')
29 |
30 | write_yml(en_json, app_name, package)
31 | end
32 |
33 | desc 'to_json', 'Convert Zendesk translation yml to I18n formatted json'
34 | shared_options(except: [:clean])
35 | def to_json
36 | setup_path(options[:path]) if options[:path]
37 | write_json('translations/en.json', en_hash)
38 | end
39 |
40 | desc 'update', 'Update translation files from Zendesk'
41 | shared_options(except: [:clean])
42 | method_option :package_name, type: :string
43 | method_option :locales, type: :string, desc: 'Path to a JSON file that has a list of locales'
44 | def update
45 | setup_path(options[:path]) if options[:path]
46 | app_package = package_name_for_update
47 | locale_list
48 | .map { |locale| fetch_locale_async locale }
49 | .each do |locale_thread|
50 | locale = locale_thread.value
51 | translations = locale['translations']
52 | locale_name = locale['locale']
53 | if not locale_name.match(/-x-/)
54 | write_json(
55 | "translations/#{locale_name}.json",
56 | nest_translations_hash(translations, "txt.apps.#{app_package}.")
57 | )
58 | end
59 | end
60 | say('Translations updated', :green)
61 | end
62 |
63 | desc 'pseudotranslate', 'Generate a Pseudo-translation to use for testing. This will pretend to be French.'
64 | shared_options(except: [:clean])
65 | def pseudotranslate
66 | setup_path(options[:path]) if options[:path]
67 |
68 | package = package_name(error_out: true)
69 |
70 | pseudo = build_pseudotranslation(en_hash, package)
71 | write_json('translations/fr.json', pseudo)
72 | end
73 |
74 | def self.source_root
75 | File.expand_path(File.join(File.dirname(__FILE__), '../..'))
76 | end
77 |
78 | no_commands do
79 | def fetch_locale_async(locale)
80 | Thread.new do
81 | say("Fetching #{locale['name']}")
82 | json = Faraday.get("#{LOCALE_BASE_ENDPOINT}#{locale['path']}").body
83 | json_or_die(json)
84 | end
85 | end
86 |
87 | def setup_path(path)
88 | @destination_stack << relative_to_original_destination_root(path) unless @destination_stack.last == path
89 | end
90 |
91 | def write_json(filename, translations_hash)
92 | create_file(filename, JSON.pretty_generate(translations_hash).force_encoding('ASCII-8BIT') + "\n", force: options[:unattended])
93 | end
94 |
95 | def nest_translations_hash(translations_hash, key_prefix)
96 | result = {}
97 |
98 | translations_hash.each do |full_key, value|
99 | parts = full_key.gsub(key_prefix, '').split('.')
100 | parts_count = parts.size - 1
101 | context = result
102 |
103 | parts.each_with_index do |part, i|
104 | if parts_count == i
105 | context[part] = value
106 | else
107 | context = context[part] ||= {}
108 | end
109 | end
110 | end
111 |
112 | result
113 | end
114 |
115 | def write_yml(en_json, app_name, package_name)
116 | titles = to_flattened_namespaced_hash(en_json, :title)
117 | values = to_flattened_namespaced_hash(en_json, :value)
118 | @translations = titles.each do |k, v|
119 | titles[k] = { 'title' => v, 'value' => escape_special_characters(values[k]) }
120 | end
121 | @app_name = app_name
122 | @package_name = package_name
123 | template(File.join(Translate.source_root, 'templates/translation.erb.tt'), 'translations/en.yml')
124 | end
125 |
126 | def escape_special_characters(v)
127 | v.gsub('"', '\"')
128 | end
129 |
130 | def to_flattened_namespaced_hash(hash, target_key)
131 | require 'zendesk_apps_support/build_translation'
132 | @includer_class ||= Class.new { include ZendeskAppsSupport::BuildTranslation }
133 | target_key_constant = case target_key
134 | when :title
135 | @includer_class::I18N_TITLE_KEY
136 | when :value
137 | @includer_class::I18N_VALUE_KEY
138 | end
139 | (@includer ||= @includer_class.new)
140 | .to_flattened_namespaced_hash(hash, target_key_constant)
141 | end
142 |
143 | def array_to_nested_hash(array)
144 | array.each_with_object({}) do |item, result|
145 | keys = item['key'].split('.')
146 | current = result
147 | keys[0..-2].each do |key|
148 | current = (current[key] ||= {})
149 | end
150 | current[keys[-1]] = { 'title' => item['title'], 'value' => item['value'] }
151 | result
152 | end
153 | end
154 |
155 | def build_pseudotranslation(translations_hash, package_name)
156 | titles = to_flattened_namespaced_hash(translations_hash, :title)
157 | values = to_flattened_namespaced_hash(translations_hash, :value)
158 | translations = titles.each { |k, v| titles[k] = { 'title' => v, 'value' => "[日本#{values[k]}éñđ]" } }
159 | translations['app.package'] = package_name # don't pseudo translate the package name
160 | nest_translations_hash(translations, '')
161 | end
162 |
163 | def locale_list
164 | say('Fetching translations...')
165 | require 'net/http'
166 | require 'faraday'
167 |
168 | if options[:locales]
169 | content = read_file(options[:locales])
170 | locales = JSON.parse(content)
171 | return locales.map do |locale|
172 | { 'name' => locale, 'path' => "/admin/#{locale}.json" }
173 | end
174 | end
175 |
176 | locale_response = Faraday.get(LOCALE_ENDPOINT)
177 | return json_or_die(locale_response.body)['json'] if locale_response.status == 200
178 | if locale_response.status == 401
179 | say_error_and_exit 'Authentication failed.'
180 | else
181 | say_error_and_exit "Failed to download locales, got HTTP status #{locale_response.status}."
182 | end
183 | end
184 |
185 | def read_file(path)
186 | File.read(File.expand_path(path))
187 | end
188 |
189 | def package_name_for_update
190 | options[:package_name] ||
191 | package_name(error_out: options[:unattended]) ||
192 | get_value_from_stdin('What is the package name for this app? (without leading app_)',
193 | valid_regex: /^[a-z_]+$/,
194 | error_msg: 'Invalid package name, try again:')
195 | end
196 |
197 | def en_json
198 | @en_json ||= begin
199 | path = "#{destination_root}/translations/en.json"
200 | JSON.parse(File.open(path).read) if File.exist? path
201 | end
202 | end
203 |
204 | def en_yaml
205 | @en_yaml ||= begin
206 | path = "#{destination_root}/translations/en.yml"
207 | require 'yaml'
208 | YAML.safe_load_file(path, permitted_classes: [Date]) if File.exist? path
209 | end
210 | end
211 |
212 | def en_hash
213 | @en_hash ||= begin
214 | return nil unless en_yaml
215 | package = /^txt.apps.([^\.]+)/.match(en_yaml['parts'][0]['translation']['key'])[1]
216 | translations = en_yaml['parts'].map { |part| part['translation'] }
217 | translations.select! do |translation|
218 | obsolete = translation['obsolete']
219 | next true unless obsolete
220 | Date.parse(obsolete.to_s) > Date.today
221 | end
222 | en_hash = array_to_nested_hash(translations)['txt']['apps'][package]
223 | en_hash['app']['package'] = package
224 | en_hash
225 | end
226 | end
227 |
228 | def package_name_from_json(error_out: false)
229 | package = en_json && en_json['app']['package']
230 | return package if package
231 | say_error_and_exit 'No package defined inside en.json.' if error_out
232 | end
233 |
234 | def package_name(error_out: false)
235 | package = en_hash && en_hash['app']['package']
236 | return package if package
237 | say_error_and_exit 'No package defined inside en.yml.' if error_out
238 | end
239 | end
240 | end
241 | end
242 |
--------------------------------------------------------------------------------
/LICENSE:
--------------------------------------------------------------------------------
1 | Apache License
2 | Version 2.0, January 2004
3 | http://www.apache.org/licenses/
4 |
5 | TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION
6 |
7 | 1. Definitions.
8 |
9 | "License" shall mean the terms and conditions for use, reproduction,
10 | and distribution as defined by Sections 1 through 9 of this document.
11 |
12 | "Licensor" shall mean the copyright owner or entity authorized by
13 | the copyright owner that is granting the License.
14 |
15 | "Legal Entity" shall mean the union of the acting entity and all
16 | other entities that control, are controlled by, or are under common
17 | control with that entity. For the purposes of this definition,
18 | "control" means (i) the power, direct or indirect, to cause the
19 | direction or management of such entity, whether by contract or
20 | otherwise, or (ii) ownership of fifty percent (50%) or more of the
21 | outstanding shares, or (iii) beneficial ownership of such entity.
22 |
23 | "You" (or "Your") shall mean an individual or Legal Entity
24 | exercising permissions granted by this License.
25 |
26 | "Source" form shall mean the preferred form for making modifications,
27 | including but not limited to software source code, documentation
28 | source, and configuration files.
29 |
30 | "Object" form shall mean any form resulting from mechanical
31 | transformation or translation of a Source form, including but
32 | not limited to compiled object code, generated documentation,
33 | and conversions to other media types.
34 |
35 | "Work" shall mean the work of authorship, whether in Source or
36 | Object form, made available under the License, as indicated by a
37 | copyright notice that is included in or attached to the work
38 | (an example is provided in the Appendix below).
39 |
40 | "Derivative Works" shall mean any work, whether in Source or Object
41 | form, that is based on (or derived from) the Work and for which the
42 | editorial revisions, annotations, elaborations, or other modifications
43 | represent, as a whole, an original work of authorship. For the purposes
44 | of this License, Derivative Works shall not include works that remain
45 | separable from, or merely link (or bind by name) to the interfaces of,
46 | the Work and Derivative Works thereof.
47 |
48 | "Contribution" shall mean any work of authorship, including
49 | the original version of the Work and any modifications or additions
50 | to that Work or Derivative Works thereof, that is intentionally
51 | submitted to Licensor for inclusion in the Work by the copyright owner
52 | or by an individual or Legal Entity authorized to submit on behalf of
53 | the copyright owner. For the purposes of this definition, "submitted"
54 | means any form of electronic, verbal, or written communication sent
55 | to the Licensor or its representatives, including but not limited to
56 | communication on electronic mailing lists, source code control systems,
57 | and issue tracking systems that are managed by, or on behalf of, the
58 | Licensor for the purpose of discussing and improving the Work, but
59 | excluding communication that is conspicuously marked or otherwise
60 | designated in writing by the copyright owner as "Not a Contribution."
61 |
62 | "Contributor" shall mean Licensor and any individual or Legal Entity
63 | on behalf of whom a Contribution has been received by Licensor and
64 | subsequently incorporated within the Work.
65 |
66 | 2. Grant of Copyright License. Subject to the terms and conditions of
67 | this License, each Contributor hereby grants to You a perpetual,
68 | worldwide, non-exclusive, no-charge, royalty-free, irrevocable
69 | copyright license to reproduce, prepare Derivative Works of,
70 | publicly display, publicly perform, sublicense, and distribute the
71 | Work and such Derivative Works in Source or Object form.
72 |
73 | 3. Grant of Patent License. Subject to the terms and conditions of
74 | this License, each Contributor hereby grants to You a perpetual,
75 | worldwide, non-exclusive, no-charge, royalty-free, irrevocable
76 | (except as stated in this section) patent license to make, have made,
77 | use, offer to sell, sell, import, and otherwise transfer the Work,
78 | where such license applies only to those patent claims licensable
79 | by such Contributor that are necessarily infringed by their
80 | Contribution(s) alone or by combination of their Contribution(s)
81 | with the Work to which such Contribution(s) was submitted. If You
82 | institute patent litigation against any entity (including a
83 | cross-claim or counterclaim in a lawsuit) alleging that the Work
84 | or a Contribution incorporated within the Work constitutes direct
85 | or contributory patent infringement, then any patent licenses
86 | granted to You under this License for that Work shall terminate
87 | as of the date such litigation is filed.
88 |
89 | 4. Redistribution. You may reproduce and distribute copies of the
90 | Work or Derivative Works thereof in any medium, with or without
91 | modifications, and in Source or Object form, provided that You
92 | meet the following conditions:
93 |
94 | (a) You must give any other recipients of the Work or
95 | Derivative Works a copy of this License; and
96 |
97 | (b) You must cause any modified files to carry prominent notices
98 | stating that You changed the files; and
99 |
100 | (c) You must retain, in the Source form of any Derivative Works
101 | that You distribute, all copyright, patent, trademark, and
102 | attribution notices from the Source form of the Work,
103 | excluding those notices that do not pertain to any part of
104 | the Derivative Works; and
105 |
106 | (d) If the Work includes a "NOTICE" text file as part of its
107 | distribution, then any Derivative Works that You distribute must
108 | include a readable copy of the attribution notices contained
109 | within such NOTICE file, excluding those notices that do not
110 | pertain to any part of the Derivative Works, in at least one
111 | of the following places: within a NOTICE text file distributed
112 | as part of the Derivative Works; within the Source form or
113 | documentation, if provided along with the Derivative Works; or,
114 | within a display generated by the Derivative Works, if and
115 | wherever such third-party notices normally appear. The contents
116 | of the NOTICE file are for informational purposes only and
117 | do not modify the License. You may add Your own attribution
118 | notices within Derivative Works that You distribute, alongside
119 | or as an addendum to the NOTICE text from the Work, provided
120 | that such additional attribution notices cannot be construed
121 | as modifying the License.
122 |
123 | You may add Your own copyright statement to Your modifications and
124 | may provide additional or different license terms and conditions
125 | for use, reproduction, or distribution of Your modifications, or
126 | for any such Derivative Works as a whole, provided Your use,
127 | reproduction, and distribution of the Work otherwise complies with
128 | the conditions stated in this License.
129 |
130 | 5. Submission of Contributions. Unless You explicitly state otherwise,
131 | any Contribution intentionally submitted for inclusion in the Work
132 | by You to the Licensor shall be under the terms and conditions of
133 | this License, without any additional terms or conditions.
134 | Notwithstanding the above, nothing herein shall supersede or modify
135 | the terms of any separate license agreement you may have executed
136 | with Licensor regarding such Contributions.
137 |
138 | 6. Trademarks. This License does not grant permission to use the trade
139 | names, trademarks, service marks, or product names of the Licensor,
140 | except as required for reasonable and customary use in describing the
141 | origin of the Work and reproducing the content of the NOTICE file.
142 |
143 | 7. Disclaimer of Warranty. Unless required by applicable law or
144 | agreed to in writing, Licensor provides the Work (and each
145 | Contributor provides its Contributions) on an "AS IS" BASIS,
146 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or
147 | implied, including, without limitation, any warranties or conditions
148 | of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A
149 | PARTICULAR PURPOSE. You are solely responsible for determining the
150 | appropriateness of using or redistributing the Work and assume any
151 | risks associated with Your exercise of permissions under this License.
152 |
153 | 8. Limitation of Liability. In no event and under no legal theory,
154 | whether in tort (including negligence), contract, or otherwise,
155 | unless required by applicable law (such as deliberate and grossly
156 | negligent acts) or agreed to in writing, shall any Contributor be
157 | liable to You for damages, including any direct, indirect, special,
158 | incidental, or consequential damages of any character arising as a
159 | result of this License or out of the use or inability to use the
160 | Work (including but not limited to damages for loss of goodwill,
161 | work stoppage, computer failure or malfunction, or any and all
162 | other commercial damages or losses), even if such Contributor
163 | has been advised of the possibility of such damages.
164 |
165 | 9. Accepting Warranty or Additional Liability. While redistributing
166 | the Work or Derivative Works thereof, You may choose to offer,
167 | and charge a fee for, acceptance of support, warranty, indemnity,
168 | or other liability obligations and/or rights consistent with this
169 | License. However, in accepting such obligations, You may act only
170 | on Your own behalf and on Your sole responsibility, not on behalf
171 | of any other Contributor, and only if You agree to indemnify,
172 | defend, and hold each Contributor harmless for any liability
173 | incurred by, or claims asserted against, such Contributor by reason
174 | of your accepting any such warranty or additional liability.
175 |
176 | END OF TERMS AND CONDITIONS
--------------------------------------------------------------------------------
/spec/lib/zendesk_apps_tools/command_spec.rb:
--------------------------------------------------------------------------------
1 | require 'spec_helper'
2 | require 'command'
3 | require 'server'
4 | require 'faraday'
5 | require 'zip'
6 |
7 | describe ZendeskAppsTools::Command do
8 | PREFIX = 'https://subdomain.zendesk.com'
9 | AUTHORIZATION_HEADER = { 'Authorization' => 'Basic dXNlcm5hbWVAc29tZXRoaW5nLmNvbTpwYXNzd29yZA==' }
10 |
11 | before do
12 | @command = ZendeskAppsTools::Command.new
13 | @command.instance_variable_set(:@username, 'username@something.com')
14 | @command.instance_variable_set(:@password, 'password')
15 | @command.instance_variable_set(:@subdomain, 'subdomain')
16 | @command.instance_variable_set(:@app_id, '123')
17 |
18 | allow(@command.cache).to receive(:fetch)
19 | allow(@command.cache).to receive(:save)
20 | allow(@command.cache).to receive(:clear)
21 | allow(@command).to receive(:options) { { clean: false, path: './' } }
22 | allow(@command).to receive(:product_names).and_return(['support'])
23 | end
24 |
25 | describe '#upload' do
26 | context 'when no zipfile is given' do
27 | it 'uploads the newly packaged zipfile and returns an upload id' do
28 | expect(@command).to receive(:package)
29 | allow(@command).to receive(:options) { { zipfile: nil } }
30 | allow(Faraday::UploadIO).to receive(:new)
31 |
32 | stub_request(:post, PREFIX + '/api/v2/apps/uploads.json')
33 | .with(headers: AUTHORIZATION_HEADER)
34 | .to_return(body: '{ "id": 123 }')
35 |
36 | expect(@command.upload('nah')).to eq(123)
37 | end
38 | end
39 |
40 | context 'when zipfile is given' do
41 | it 'uploads the given zipfile and returns an upload id' do
42 | allow(@command).to receive(:options) { { zipfile: 'app.zip' } }
43 | expect(Faraday::UploadIO).to receive(:new).with('app.zip', 'application/zip').and_return(nil)
44 |
45 | stub_request(:post, PREFIX + '/api/v2/apps/uploads.json')
46 | .with(headers: AUTHORIZATION_HEADER)
47 | .to_return(body: '{ "id": 123 }')
48 |
49 | expect(@command.upload('nah')).to eq(123)
50 | end
51 | end
52 | end
53 |
54 | describe '#create' do
55 | context 'when no zipfile is given' do
56 | it 'uploads a file and posts build api' do
57 | expect(@command).to receive(:upload).and_return(123)
58 | allow(@command).to receive(:check_status)
59 | expect(@command).to receive(:manifest).and_return(double('manifest', name: 'abc', original_parameters: [])).at_least(:once)
60 | allow(@command).to receive(:options).and_return(clean: false, path: './', config: './settings.json', install: true)
61 | allow(@command.cache).to receive(:fetch).with('app_id').and_return('987')
62 |
63 | stub_request(:post, PREFIX + '/api/apps.json')
64 | .with(
65 | body: JSON.generate(name: 'abc', upload_id: '123'),
66 | headers: AUTHORIZATION_HEADER
67 | )
68 |
69 | stub_request(:post, PREFIX + '/api/support/apps/installations.json')
70 | .with(
71 | body: JSON.generate(app_id: '987', settings: { name: 'abc' }),
72 | headers: AUTHORIZATION_HEADER.merge({ 'Content-Type' => 'application/json' })
73 | )
74 |
75 | @command.create
76 | end
77 | end
78 |
79 | context 'when zipfile is given' do
80 | it 'uploads the zipfile and posts build api' do
81 | expect(@command).to receive(:upload).and_return(123)
82 | allow(@command).to receive(:check_status)
83 | allow(@command).to receive(:options).and_return(clean: false, path: './', zipfile: 'abc.zip', config: './settings.json', install: true)
84 | expect(@command).to receive(:manifest).and_return(double('manifest', name: 'abc', original_parameters: [])).at_least(:once)
85 |
86 | expect(@command).to receive(:get_value_from_stdin) { 'abc' }
87 |
88 | stub_request(:post, PREFIX + '/api/apps.json')
89 | .with(
90 | body: JSON.generate(name: 'abc', upload_id: '123'),
91 | headers: AUTHORIZATION_HEADER
92 | )
93 |
94 | stub_request(:post, PREFIX + '/api/support/apps/installations.json')
95 | .with(
96 | body: JSON.generate(app_id: nil, settings: { name: 'abc' }),
97 | headers: AUTHORIZATION_HEADER.merge({ 'Content-Type' => 'application/json' })
98 | )
99 |
100 | @command.create
101 | end
102 | end
103 | end
104 |
105 | describe '#update' do
106 | before do
107 | allow(@command).to receive(:manifest).and_return(double('manifest'))
108 | end
109 |
110 | context 'when app id is in cache' do
111 | it 'uploads a file and puts build api' do
112 | stub_request(:put, PREFIX + '/api/v2/apps/456.json')
113 | .with(headers: AUTHORIZATION_HEADER)
114 | stub_request(:get, PREFIX + '/api/v2/apps/456.json')
115 | .with(headers: AUTHORIZATION_HEADER)
116 | .to_return(:status => 200)
117 |
118 | expect(@command).to receive(:upload).and_return(123)
119 | allow(@command).to receive(:check_status)
120 | expect(@command.cache).to receive(:fetch).with('app_id').and_return(456)
121 |
122 | @command.update
123 | end
124 |
125 | context 'and is invalid' do
126 | it 'displays an error message and exits' do
127 | stub_request(:get, PREFIX + '/api/v2/apps/333.json')
128 | .with(headers: AUTHORIZATION_HEADER)
129 | .to_return(:status => 404)
130 |
131 | expect(@command.cache).to receive(:fetch).with('app_id').and_return(333)
132 | expect(@command).to receive(:say_error).with(/^App ID not found/)
133 | expect(@command).to_not receive(:deploy_app)
134 | expect { @command.update }.to raise_error(SystemExit)
135 | end
136 | end
137 | end
138 |
139 | context 'when app id is NOT in cache' do
140 | let (:apps) {
141 | [
142 | { id: 123, name: 'hello' },
143 | { id: 124, name: 'world' },
144 | { id: 125, name: 'itsme' }
145 | ]
146 | }
147 | let (:apps_incomplete) {{
148 | apps: [ apps[0] ]
149 | }}
150 | let (:apps_complete) {{
151 | apps: apps
152 | }}
153 |
154 | before do
155 | @command.instance_variable_set(:@app_id, nil)
156 | allow(@command).to receive(:get_value_from_stdin).and_return('itsme')
157 | end
158 |
159 | context 'and it cannot find the app id' do
160 | it 'displays an error message and exits' do
161 | stub_request(:get, PREFIX + '/api/support/apps/owned.json')
162 | .with(headers: AUTHORIZATION_HEADER)
163 | .to_return(body: JSON.generate(apps_incomplete))
164 | expect(@command).to receive(:say_error).with(/App not found/)
165 | expect { @command.update }.to raise_error(SystemExit)
166 | end
167 | end
168 |
169 | context 'and it finds the app id' do
170 | it 'updates the app' do
171 | stub_request(:get, PREFIX + '/api/support/apps/owned.json')
172 | .with(headers: AUTHORIZATION_HEADER)
173 | .to_return(body: JSON.generate(apps_complete))
174 | stub_request(:get, PREFIX + '/api/v2/apps/owned.json')
175 | .with(headers: AUTHORIZATION_HEADER)
176 | .to_return(body: JSON.generate(apps_complete))
177 | stub_request(:get, PREFIX + '/api/v2/apps/125.json')
178 | .with(headers: AUTHORIZATION_HEADER)
179 | .to_return(:status => 200)
180 |
181 | expect(@command.send(:find_app_id)).to eq(125)
182 |
183 | allow(@command).to receive(:deploy_app)
184 | @command.update
185 | end
186 | end
187 | end
188 | end
189 |
190 | describe '#version' do
191 | context 'when -v is run' do
192 | it 'shows the version' do
193 | old_v = Gem::Version.new '0.0.1'
194 | new_v = nil
195 |
196 | expect(@command).to receive(:say) { |arg| new_v = Gem::Version.new arg }
197 | @command.version
198 |
199 | expect(old_v).to be < new_v
200 | end
201 | end
202 | end
203 |
204 | describe '#check_for_update' do
205 | context 'more than one week since the last check' do
206 | it 'checks for updates' do
207 | allow(@command.cache).to receive(:fetch).with('zat_update_check').and_return('1970-01-01')
208 |
209 | stub_request(:get, "https://rubygems.org/api/v1/gems/zendesk_apps_tools.json")
210 | .to_return(:body => JSON.dump(version: ZendeskAppsTools::VERSION))
211 |
212 | expect(@command).to receive(:say_status).with('info', 'Checking for new version of zendesk_apps_tools')
213 | @command.send(:check_for_update)
214 | end
215 |
216 | context 'the version is outdated' do
217 | it 'display message to update' do
218 | new_v = Gem::Version.new(ZendeskAppsTools::VERSION).bump
219 |
220 | allow(@command.cache).to receive(:fetch).with('zat_update_check').and_return('1970-01-01')
221 |
222 | stub_request(:get, "https://rubygems.org/api/v1/gems/zendesk_apps_tools.json")
223 | .to_return(:body => JSON.dump(version: new_v.to_s))
224 |
225 | expect(@command).to receive(:say_status).with('info', 'Checking for new version of zendesk_apps_tools')
226 | expect(@command).to receive(:say_status).with('warning', 'Your version of Zendesk Apps Tools is outdated. Update by running: gem update zendesk_apps_tools', :yellow)
227 | @command.send(:check_for_update)
228 | end
229 | end
230 | end
231 |
232 | context 'less than one week since the last check' do
233 | it 'does not check for updates' do
234 | allow(@command.cache).to receive(:fetch).with('zat_update_check').and_return(Date.today.to_s)
235 |
236 | expect(@command).not_to receive(:say_status).with('info', 'Checking for new version of zendesk_apps_tools')
237 | @command.send(:check_for_update)
238 | end
239 | end
240 | end
241 |
242 | describe '#server' do
243 | it 'runs the server' do
244 | path = './tmp/tmp_app'
245 | allow(@command).to receive(:options) { { path: path } }
246 | expect(ZendeskAppsTools::Server).to receive(:run!)
247 | @command.directory('app_template_iframe', path, {})
248 | @command.server
249 | end
250 | end
251 |
252 | describe '#new' do
253 | context 'when --scaffold option is given' do
254 | it 'creates a base project with scaffold' do
255 | allow(@command).to receive(:options) { { scaffold: true } }
256 | allow(@command).to receive(:get_value_from_stdin) { 'TestApp' }
257 |
258 | expect(@command).to receive(:directory).with('app_template_iframe', 'TestApp', {:exclude_pattern=>/^((?!manifest.json).)*$/})
259 |
260 | stub_request(:get, 'https://github.com/zendesk/app_scaffold/archive/master.zip')
261 | .to_return(body: 'Mock Zip Body', status: 200)
262 |
263 | mockFile = Zip::Entry.new('', 'mockfile.json')
264 | allow(Zip::File).to receive(:open) {[mockFile]}
265 |
266 | expect(FileUtils).to receive(:mv)
267 | expect(mockFile).to receive(:extract).with('TestApp/mockfile.json')
268 |
269 | @command.new
270 |
271 | end
272 | end
273 | end
274 | end
275 |
--------------------------------------------------------------------------------
/lib/zendesk_apps_tools/command.rb:
--------------------------------------------------------------------------------
1 | require 'thor'
2 | require 'pathname'
3 | require 'json'
4 |
5 | require 'zendesk_apps_tools/version'
6 | require 'zendesk_apps_tools/command_helpers'
7 |
8 | module ZendeskAppsTools
9 | class Command < Thor
10 | include Thor::Actions
11 | include ZendeskAppsTools::CommandHelpers
12 |
13 | map %w[-v] => :version
14 | DEFAULT_SERVER_IP = '0.0.0.0'
15 | DEFAULT_SERVER_PORT = '4567'
16 |
17 | source_root File.expand_path(File.join(File.dirname(__FILE__), '../..'))
18 |
19 | desc 'translate SUBCOMMAND', 'Manage translation files', hide: true
20 | subcommand 'translate', Translate
21 |
22 | desc 'theme SUBCOMMAND', 'Development tools for Theming Center (Beta)', hide: false
23 | subcommand 'theme', Theme
24 |
25 | desc 'bump SUBCOMMAND', 'Bump version for app', hide: true
26 | subcommand 'bump', Bump
27 |
28 | desc 'new', 'Generate a new app'
29 | method_option :'iframe-only', type: :boolean,
30 | default: false,
31 | hide: true,
32 | aliases: ['--v2']
33 | method_option :v1, type: :boolean,
34 | default: false,
35 | hide: true,
36 | desc: 'Create a version 1 app template (Deprecated)'
37 | method_option :scaffold, type: :boolean,
38 | default: false,
39 | hide: true,
40 | desc: 'Create a version 2 app template with latest scaffold'
41 | def new
42 | run_deprecation_checks('error', '1.0') if options[:v1]
43 |
44 | enter = ->(variable) { "Enter this app author's #{variable}:\n" }
45 | invalid = ->(variable) { "Invalid #{variable}, try again:" }
46 | @author_name = get_value_from_stdin(enter.call('name'),
47 | error_msg: invalid.call('name'))
48 | @author_email = get_value_from_stdin(enter.call('email'),
49 | valid_regex: /^.+@.+\..+$/,
50 | error_msg: invalid.call('email'))
51 | @author_url = get_value_from_stdin(enter.call('url'),
52 | valid_regex: %r{^https?://.+$},
53 | error_msg: invalid.call('url'),
54 | allow_empty: true)
55 | @app_name = get_value_from_stdin("Enter a name for this new app:\n",
56 | error_msg: invalid.call('app name'))
57 |
58 | @iframe_location =
59 | if options[:scaffold]
60 | 'assets/iframe.html'
61 | elsif options[:v1]
62 | '_legacy'
63 | else
64 | iframe_uri_text = 'Enter your iFrame URI or leave it blank to use'\
65 | " a default local template page:\n"
66 | get_value_from_stdin(iframe_uri_text, allow_empty: true, default: 'assets/iframe.html')
67 | end
68 |
69 | prompt_new_app_dir
70 |
71 | directory_options =
72 | if options[:scaffold]
73 | # excludes everything but manifest.json
74 | { exclude_pattern: /^((?!manifest.json).)*$/ }
75 | elsif @iframe_location != 'assets/iframe.html'
76 | { exclude_pattern: /iframe.html/ }
77 | else
78 | {}
79 | end
80 |
81 | directory('app_template_iframe', @app_dir, directory_options)
82 |
83 | download_scaffold(@app_dir) if options[:scaffold]
84 | end
85 |
86 | desc 'validate', 'Validate your app'
87 | shared_options
88 | def validate
89 | require 'execjs'
90 | check_for_update
91 | setup_path(options[:path])
92 | begin
93 | errors = app_package.validate(marketplace: false)
94 | rescue ExecJS::RuntimeError
95 | error = "There was an error trying to validate this app.\n"
96 | if ExecJS.runtime.nil?
97 | error += 'Validation relies on a JavaScript runtime. See https://github.com/rails/execjs for a list of available runtimes.'
98 | elsif ExecJS.runtime.name == 'JScript'
99 | error += 'To validate on Windows, please install node from https://nodejs.org/'
100 | end
101 | say_error_and_exit error
102 | end
103 | valid = errors.none?
104 |
105 | if valid
106 | app_package.warnings.each { |w| say_status 'warning', w.to_s, :yellow }
107 | # clean when all apps are upgraded
108 | run_deprecation_checks unless options[:'unattended']
109 | say_status 'validate', 'OK'
110 | else
111 | errors.each { |e| say_status 'validate', e.to_s, :red }
112 | end
113 |
114 | @destination_stack.pop if options[:path]
115 | exit 1 unless valid
116 | true
117 | end
118 |
119 | desc 'package', 'Package your app'
120 | shared_options(except: [:unattended])
121 | def package
122 | return false unless validate
123 |
124 | setup_path(options[:path])
125 |
126 | if app_package.manifest.name
127 | warning = 'Please note that the name key of manifest.json is currently only used in development.'
128 | say_status 'warning', warning, :yellow
129 | end
130 |
131 | archive_path = File.join(tmp_dir, "app-#{Time.now.strftime('%Y%m%d%H%M%S')}.zip")
132 |
133 | archive_rel_path = relative_to_original_destination_root(archive_path)
134 |
135 | zip archive_path
136 |
137 | say_status 'package', "created at #{archive_rel_path}"
138 | true
139 | end
140 |
141 | desc 'clean', 'Remove app packages in temp folder'
142 | method_option :path, default: './', required: false, aliases: '-p'
143 | def clean
144 | require 'fileutils'
145 |
146 | setup_path(options[:path])
147 |
148 | return unless File.exist?(Pathname.new(File.join(app_dir, 'tmp')).to_s)
149 |
150 | FileUtils.rm(Dir["#{tmp_dir}/app-*.zip"])
151 | end
152 |
153 | DEFAULT_SERVER_PATH = './'
154 | DEFAULT_CONFIG_PATH = './settings.yml'
155 | DEFAULT_APP_ID = 0
156 |
157 | desc 'server', 'Run a http server to serve the local app'
158 | shared_options(except: [:clean])
159 | method_option :config, default: DEFAULT_CONFIG_PATH, required: false, aliases: '-c'
160 | method_option :port, default: DEFAULT_SERVER_PORT, required: false, desc: 'Port for the http server to use.'
161 | method_option :app_id, default: DEFAULT_APP_ID, required: false, type: :numeric
162 | method_option :bind, default: DEFAULT_SERVER_IP, required: false
163 | method_option :plan, required: false
164 | def server
165 | setup_path(options[:path])
166 | if app_package.has_file?('assets/app.js')
167 | warning = 'Warning: creating assets/app.js causes zat server to behave badly.'
168 | say_status 'warning', warning, :yellow
169 | end
170 |
171 | require 'zendesk_apps_tools/server'
172 | ZendeskAppsTools::Server.tap do |server|
173 | server.set :settings_helper, settings_helper
174 | server.set :bind, options[:bind] if options[:bind]
175 | server.set :port, options[:port]
176 | server.set :root, options[:path]
177 | server.set :public_folder, File.join(options[:path], 'assets')
178 | server.set :parameters, settings
179 | server.set :app_id, options[:app_id]
180 | server.set :plan, [options[:plan], cache.fetch('plan')].reject(&:nil?).first
181 |
182 | host = options[:bind] || DEFAULT_SERVER_IP
183 | say "If apps fail to load, your browser may be blocking requests to http://#{host}:#{options[:port]}", :yellow
184 | say 'Make sure Local Network Access (LNA) is allowed to access your local apps server.', :yellow
185 | say 'Learn more: https://developer.zendesk.com/documentation/apps/zendesk-app-tools-zat/installing-and-using-zat/#testing-your-app-locally-in-a-browser', :yellow
186 |
187 | server.run!
188 | end
189 | end
190 |
191 | desc 'create', 'Create and install app on your account'
192 | shared_options
193 | method_option :zipfile, default: nil, required: false, type: :string
194 | method_option :config, default: DEFAULT_CONFIG_PATH, required: false, aliases: '-c'
195 | method_option :install, default: true, type: :boolean, desc: 'Also create an installation with some settings immediately after uploading.'
196 | def create
197 | cache.clear
198 | setup_path(options[:path])
199 | @command = 'Create'
200 |
201 | unless options[:zipfile]
202 | app_name = manifest.name
203 | end
204 | app_name ||= get_value_from_stdin('Enter app name:')
205 | deploy_app(:post, '/api/apps.json', name: app_name)
206 | has_requirements = File.exist?(File.join(options[:path], 'requirements.json'))
207 | return unless options[:install]
208 | product_names(manifest).each do |product_name|
209 | say_status 'Install', "installing in #{product_name}"
210 | install_app(has_requirements, product_name, app_id: cache.fetch('app_id'), settings: settings.merge(name: app_name))
211 | end
212 | end
213 |
214 | desc 'update', 'Update app on the server'
215 | shared_options
216 | method_option :zipfile, default: nil, required: false, type: :string
217 | def update
218 | cache.clear
219 | setup_path(options[:path])
220 | @command = 'Update'
221 |
222 | product_name = product_names(manifest).first
223 | app_id = cache.fetch('app_id') || find_app_id(product_name)
224 | app_url = "/api/v2/apps/#{app_id}.json"
225 | unless /\d+/ =~ app_id.to_s && app_exists?(app_id)
226 | say_error_and_exit "App ID not found. Please try running command with --clean or check your internet connection."
227 | end
228 |
229 | deploy_app(:put, app_url, {})
230 | end
231 |
232 | desc "version, -v", "Print the version"
233 | def version
234 | say ZendeskAppsTools::VERSION
235 | end
236 |
237 | protected
238 |
239 | def product_names(manifest)
240 | product_codes(manifest).collect{ |code| ZendeskAppsSupport::Product.find_by( code: code ) }.collect(&:name)
241 | end
242 |
243 | def product_codes(manifest)
244 | manifest.location_options.collect{ |option| option.location.product_code }.uniq
245 | end
246 |
247 | def settings
248 | settings_helper.get_settings_from_file(options[:config], manifest.original_parameters) ||
249 | settings_helper.get_settings_from_user_input(manifest.original_parameters)
250 | end
251 |
252 | def settings_helper
253 | @settings_helper ||= begin
254 | require 'zendesk_apps_tools/settings'
255 | ZendeskAppsTools::Settings.new(self)
256 | end
257 | end
258 |
259 | def run_deprecation_checks(type = 'warning', target_version = manifest.framework_version)
260 | require 'zendesk_apps_support/app_version'
261 | zas = ZendeskAppsSupport::AppVersion.new(target_version)
262 |
263 | version_status = zas.sunsetting? ? 'being sunset' : 'deprecated'
264 | deprecated_message = zas.sunsetting? ? "No new v#{target_version} app framework submissions will be accepted from August 1st, 2017" : "No new v#{target_version} app framework submissions or updates will be accepted"
265 | message = "You are targeting the v#{target_version} app framework, which is #{version_status}. #{deprecated_message}. Consider migrating to the v#{zas.current} framework. For more information: http://goto.zendesk.com/zaf-sunset"
266 |
267 | if zas.deprecated? || type == 'error'
268 | say_error_and_exit message
269 | elsif zas.sunsetting?
270 | say_status 'warning', message, :yellow
271 | end
272 | end
273 |
274 | def check_for_update
275 | begin
276 | require 'net/http'
277 | require 'date'
278 |
279 | return unless (cache.fetch "zat_update_check").nil? || Date.parse(cache.fetch "zat_update_check") < Date.today - 7
280 |
281 | say_status 'info', 'Checking for new version of zendesk_apps_tools'
282 | response = Net::HTTP.get_response(URI('https://rubygems.org/api/v1/gems/zendesk_apps_tools.json'))
283 |
284 | latest_version = Gem::Version.new(JSON.parse(response.body)["version"])
285 | current_version = Gem::Version.new(ZendeskAppsTools::VERSION)
286 |
287 | cache.save 'zat_latest' => latest_version
288 | cache.save 'zat_update_check' => Date.today
289 |
290 | say_status 'warning', 'Your version of Zendesk Apps Tools is outdated. Update by running: gem update zendesk_apps_tools', :yellow if current_version < latest_version
291 | rescue SocketError
292 | say_status 'warning', 'Unable to check for new versions of zendesk_apps_tools gem', :yellow
293 | end
294 | end
295 |
296 | def download_scaffold(app_dir)
297 | tmp_download_name = 'scaffold-download-temp.zip'
298 | manifest_pattern = /manifest.json$/
299 | manifest_path = ''
300 | scaffold_url = 'https://github.com/zendesk/app_scaffold/archive/master.zip'
301 | begin
302 | require 'open-uri'
303 | require 'zip'
304 | download = URI.open(scaffold_url)
305 | IO.copy_stream(download, tmp_download_name)
306 | zip_file = Zip::File.open(tmp_download_name)
307 | zip_file.each do |entry|
308 | filename = entry.name.sub('app_scaffold-master/','')
309 | if manifest_pattern.match(filename)
310 | manifest_path = filename[0..-14]
311 | else
312 | say_status 'info', "Extracting #{filename}"
313 | entry.extract("#{app_dir}/#{filename}")
314 | end
315 | end
316 | say_status 'info', 'Moving manifest.json'
317 | FileUtils.mv("#{app_dir}/manifest.json", "#{app_dir}/#{manifest_path}")
318 | say_status 'info', 'App created'
319 | rescue StandardError => e
320 | say_error "We encountered an error while creating your app: #{e.message}"
321 | end
322 | File.delete(tmp_download_name) if File.exist?(tmp_download_name)
323 | end
324 | end
325 | end
326 |
--------------------------------------------------------------------------------