├── .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 | 4 | 6 | 8 | 10 | 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 | ![image](https://zen-marketing-documentation.s3.amazonaws.com/docs/en/zat_mac_cmd_new.png) 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 | ![image](https://zen-marketing-documentation.s3.amazonaws.com/docs/en/zat_mac_cmd_package.png) 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 | --------------------------------------------------------------------------------