├── .github └── workflows │ └── ci.yml ├── .gitignore ├── .rubocop.yml ├── .streerc ├── CHANGELOG.md ├── CODE_OF_CONDUCT.md ├── Gemfile ├── Guardfile ├── LICENSE.txt ├── README.md ├── Rakefile ├── bin └── discourse_theme ├── discourse_theme.gemspec ├── lib ├── discourse_theme.rb └── discourse_theme │ ├── cli.rb │ ├── cli_commands │ └── rspec.rb │ ├── client.rb │ ├── config.rb │ ├── downloader.rb │ ├── scaffold.rb │ ├── ui.rb │ ├── uploader.rb │ ├── version.rb │ └── watcher.rb └── test ├── fixtures ├── discourse-test-theme.tar.gz ├── discourse-test-theme.zip └── skeleton-lite │ ├── .github │ └── test │ ├── LICENSE │ ├── README.md │ ├── about.json │ ├── javascripts │ └── discourse │ │ └── api-initializers │ │ └── todo.js │ ├── locales │ └── en.yml │ └── package.json ├── test_cli.rb ├── test_config.rb └── test_helper.rb /.github/workflows/ci.yml: -------------------------------------------------------------------------------- 1 | name: CI 2 | 3 | on: 4 | pull_request: 5 | push: 6 | branches: 7 | - main 8 | 9 | jobs: 10 | lint: 11 | runs-on: ubuntu-latest 12 | 13 | steps: 14 | - uses: actions/checkout@v4 15 | 16 | - name: Setup ruby 17 | uses: ruby/setup-ruby@v1 18 | with: 19 | ruby-version: "3.3" 20 | bundler-cache: true 21 | 22 | - name: Lint 23 | run: bundle exec rubocop 24 | 25 | - name: syntax_tree 26 | if: ${{ !cancelled() }} 27 | run: | 28 | bundle exec stree check Gemfile $(git ls-files '*.rb') $(git ls-files '*.rake') $(git ls-files '*.thor') 29 | 30 | test: 31 | runs-on: ubuntu-latest 32 | 33 | strategy: 34 | matrix: 35 | ruby: 36 | - "3.1" 37 | - "3.2" 38 | - "3.3" 39 | 40 | steps: 41 | - uses: actions/checkout@v4 42 | 43 | - name: Setup Git 44 | run: | 45 | git config --global user.email "ci@ci.invalid" 46 | git config --global user.name "Discourse CI" 47 | 48 | - name: Install pnpm 49 | run: npm install -g pnpm 50 | 51 | - name: Setup ruby 52 | uses: ruby/setup-ruby@v1 53 | with: 54 | ruby-version: ${{ matrix.ruby }} 55 | bundler-cache: true 56 | 57 | - name: Tests 58 | run: bundle exec rake test 59 | 60 | publish: 61 | if: github.event_name == 'push' && github.ref == 'refs/heads/main' 62 | needs: [lint, test] 63 | runs-on: ubuntu-latest 64 | 65 | steps: 66 | - uses: actions/checkout@v4 67 | 68 | - name: Release Gem 69 | uses: discourse/publish-rubygems-action@v3 70 | env: 71 | RUBYGEMS_API_KEY: ${{ secrets.RUBYGEMS_API_KEY }} 72 | GIT_EMAIL: team@discourse.org 73 | GIT_NAME: discoursebot 74 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | Gemfile.lock 2 | -------------------------------------------------------------------------------- /.rubocop.yml: -------------------------------------------------------------------------------- 1 | inherit_gem: 2 | rubocop-discourse: stree-compat.yml 3 | inherit_mode: 4 | merge: 5 | - Exclude 6 | Discourse/NoChdir: 7 | Enabled: false 8 | -------------------------------------------------------------------------------- /.streerc: -------------------------------------------------------------------------------- 1 | --print-width=100 2 | --plugins=plugin/trailing_comma,plugin/disable_auto_ternary -------------------------------------------------------------------------------- /CHANGELOG.md: -------------------------------------------------------------------------------- 1 | # Changelog 2 | 3 | All notable changes to this project will be documented in this file. 4 | 5 | The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/), 6 | and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). 7 | 8 | ## [2.1.3] - 2024-10-09 9 | 10 | ### Added 11 | 12 | - Made `new` command compatible with the replacement of `yarn` with `pnpm` as a package manager, and will prompt users to install `pnpm` if not installed already. 13 | 14 | ## [2.1.2] - 2024-04-16 15 | 16 | ### Added 17 | 18 | - Suggest the root URL of the local site when running `watch` command 19 | 20 | ## [2.1.1] - 2024-03-25 21 | 22 | ### Added 23 | 24 | - `--version` to CLI (#46) 25 | 26 | ## [2.1.0] - 2024-02-28 27 | 28 | ### Changed 29 | 30 | - `new` command now uses discourse-theme-skeleton repo (#44) 31 | 32 | ## [2.0.0] - 2024-01-31 33 | 34 | ### Added 35 | 36 | - `watch` command for `discourse_theme` will prompt user if pending theme migrations should be run (#40) 37 | 38 | ### Removed 39 | 40 | - Remove upload theme migrations prompt to `watch` command for `discourse_theme` CLI previously added in #38. Theme migrations 41 | files are always uploaded going forward. 42 | 43 | ## [1.1.0] - 2024-01-10 44 | 45 | ### Added 46 | 47 | - Add upload theme migrations prompt to `watch` command for `discourse_theme` CLI (#38) 48 | 49 | ## [1.0.2] - 2023-12-08 50 | 51 | ### Fixed 52 | 53 | - `discourse_theme rspec` command using Docker container not copying theme to the right directory that is mounted inside 54 | the Docker container. 55 | 56 | ## [1.0.1] - 2023-10-19 57 | 58 | ### Fixed 59 | 60 | - Spec path was not preserved when running rspec against a local Discourse repository. 61 | 62 | ## [1.0.0] - 2023-10-09 63 | 64 | ### Fixed 65 | 66 | - Change `--headless` option for the rspec command to `--headful` which is the correct name. 67 | 68 | ## [0.9.1] - 2023-10-06 69 | 70 | ### Fixed 71 | 72 | - `rspec` command saving settings using wrong dir 73 | 74 | ## [0.9.0] - 2023-09-27 75 | 76 | ### Added 77 | 78 | - Added the `rspec` command to the CLI to support running RSpec system tests for a theme using either a Docker container 79 | running the `discourse/discourse_test` image or a local Discourse development environment. See 100f320847a22e11c145886588fac04479c143bb and 80 | c0c920280bef7869f0515f5e4220cf5cd3e408ef for more details. 81 | 82 | ## [0.7.6] - 2023-09-16 83 | 84 | ### Fixed 85 | 86 | - Remove trailing slash when storing URL (#25) 87 | -------------------------------------------------------------------------------- /CODE_OF_CONDUCT.md: -------------------------------------------------------------------------------- 1 | # Contributor Covenant Code of Conduct 2 | 3 | ## Our Pledge 4 | 5 | In the interest of fostering an open and welcoming environment, we as 6 | contributors and maintainers pledge to making participation in our project and 7 | our community a harassment-free experience for everyone, regardless of age, body 8 | size, disability, ethnicity, gender identity and expression, level of experience, 9 | nationality, personal appearance, race, religion, or sexual identity and 10 | orientation. 11 | 12 | ## Our Standards 13 | 14 | Examples of behavior that contributes to creating a positive environment 15 | include: 16 | 17 | * Using welcoming and inclusive language 18 | * Being respectful of differing viewpoints and experiences 19 | * Gracefully accepting constructive criticism 20 | * Focusing on what is best for the community 21 | * Showing empathy towards other community members 22 | 23 | Examples of unacceptable behavior by participants include: 24 | 25 | * The use of sexualized language or imagery and unwelcome sexual attention or 26 | advances 27 | * Trolling, insulting/derogatory comments, and personal or political attacks 28 | * Public or private harassment 29 | * Publishing others' private information, such as a physical or electronic 30 | address, without explicit permission 31 | * Other conduct which could reasonably be considered inappropriate in a 32 | professional setting 33 | 34 | ## Our Responsibilities 35 | 36 | Project maintainers are responsible for clarifying the standards of acceptable 37 | behavior and are expected to take appropriate and fair corrective action in 38 | response to any instances of unacceptable behavior. 39 | 40 | Project maintainers have the right and responsibility to remove, edit, or 41 | reject comments, commits, code, wiki edits, issues, and other contributions 42 | that are not aligned to this Code of Conduct, or to ban temporarily or 43 | permanently any contributor for other behaviors that they deem inappropriate, 44 | threatening, offensive, or harmful. 45 | 46 | ## Scope 47 | 48 | This Code of Conduct applies both within project spaces and in public spaces 49 | when an individual is representing the project or its community. Examples of 50 | representing a project or community include using an official project e-mail 51 | address, posting via an official social media account, or acting as an appointed 52 | representative at an online or offline event. Representation of a project may be 53 | further defined and clarified by project maintainers. 54 | 55 | ## Enforcement 56 | 57 | Instances of abusive, harassing, or otherwise unacceptable behavior may be 58 | reported by contacting the project team at sam.saffron@gmail.com. All 59 | complaints will be reviewed and investigated and will result in a response that 60 | is deemed necessary and appropriate to the circumstances. The project team is 61 | obligated to maintain confidentiality with regard to the reporter of an incident. 62 | Further details of specific enforcement policies may be posted separately. 63 | 64 | Project maintainers who do not follow or enforce the Code of Conduct in good 65 | faith may face temporary or permanent repercussions as determined by other 66 | members of the project's leadership. 67 | 68 | ## Attribution 69 | 70 | This Code of Conduct is adapted from the [Contributor Covenant][homepage], version 1.4, 71 | available at [http://contributor-covenant.org/version/1/4][version] 72 | 73 | [homepage]: http://contributor-covenant.org 74 | [version]: http://contributor-covenant.org/version/1/4/ 75 | -------------------------------------------------------------------------------- /Gemfile: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | source "https://rubygems.org" 3 | 4 | git_source(:github) { |repo_name| "https://github.com/#{repo_name}" } 5 | 6 | # Specify your gem's dependencies in discourse_theme.gemspec 7 | gemspec 8 | -------------------------------------------------------------------------------- /Guardfile: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | directories %w(app lib test) \ 3 | .select { |d| Dir.exist?(d) ? d : UI.warning("Directory #{d} does not exist") } 4 | 5 | guard :minitest do 6 | # with Minitest::Unit 7 | watch(%r{^test/(.*)\/?test_(.*)\.rb$}) 8 | watch(%r{^lib/(.*/)?([^/]+)\.rb$}) { |m| "test/test_#{m[2]}.rb" } 9 | watch(%r{^test/test_helper\.rb$}) { 'test' } 10 | end 11 | -------------------------------------------------------------------------------- /LICENSE.txt: -------------------------------------------------------------------------------- 1 | The MIT License (MIT) 2 | 3 | Copyright (c) 2018 Civilized Discourse Construction Kit 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in 13 | all copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN 21 | THE SOFTWARE. 22 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Discourse Theme 2 | 3 | This CLI contains helpers for creating [Discourse themes](https://meta.discourse.org/c/theme) and theme components. 4 | 5 | ## Installation 6 | 7 | To install the CLI use: 8 | 9 | $ gem install discourse_theme 10 | 11 | ## Why this gem exists? 12 | 13 | This gem allows you to use your editor of choice when developing Discourse themes and theme components. As you save files the CLI will update the remote theme or component and changes to it will appear live! 14 | 15 | ## Usage 16 | 17 | For help run: 18 | 19 | ``` 20 | discourse_theme 21 | ``` 22 | 23 | ### `discourse_theme new PATH` 24 | 25 | Creates a new blank theme. The CLI will guide you through the process. 26 | 27 | ### `discourse_theme download PATH` 28 | 29 | Downloads a theme from the server and stores in the designated directory. 30 | 31 | ### `discourse_theme watch PATH` 32 | 33 | Monitors a theme or component for changes. When changed the program will synchronize the theme or component to your Discourse of choice. 34 | 35 | ### `discourse_theme upload PATH` 36 | 37 | Uploads a theme to the server. Requires the theme to have been previously synchronized via `watch`. 38 | 39 | ### `discourse_theme rspec PATH` 40 | 41 | Runs the [RSpec](https://rspec.info/) system tests under the `spec` folder in the designated theme directory. 42 | 43 | On the first run for the given directory, you will be asked if you'll like to use a local Discourse repository to run the tests. 44 | 45 | If you select 'Y' and proceeds to configure the path to the local Discourse repository, the tests will be ran using the local Discourse development environment provided by the local Discourse repository. Note that you'll have to set up the local test environment before 46 | the tests can be ran successfully. 47 | 48 | If the 'n' option is selected, the tests will run in a Docker container created using the [`discourse/discourse_test:release`](https://hub.docker.com/r/discourse/discourse_test) Docker image. Note that this requires [Docker](https://docs.docker.com/engine/install/) to be installed. 49 | 50 | When the `--headless` option is used, a local installation of the [Google Chrome browser](https://www.google.com/chrome/) is required. 51 | 52 | Run `discourse_theme --help` for more usage details. 53 | 54 | ## Contributing 55 | 56 | Bug reports and pull requests are welcome at [Meta Discourse](https://meta.discourse.org). This project is intended to be a safe, welcoming space for collaboration, and contributors are expected to adhere to the [Contributor Covenant](http://contributor-covenant.org) code of conduct. 57 | 58 | ## License 59 | 60 | The gem is available as open source under the terms of the [MIT License](https://opensource.org/licenses/MIT). 61 | 62 | ## Code of Conduct 63 | 64 | Everyone interacting in the DiscourseTheme project’s codebases, issue trackers, chat rooms and mailing lists is expected to follow the [code of conduct](https://github.com/discourse/discourse_theme/blob/main/CODE_OF_CONDUCT.md). 65 | -------------------------------------------------------------------------------- /Rakefile: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | require "bundler/gem_tasks" 3 | require "rake/testtask" 4 | 5 | Rake::TestTask.new(:test) do |t| 6 | t.libs << "test" 7 | t.libs << "lib" 8 | t.test_files = FileList["test/**/test_*.rb"] 9 | end 10 | 11 | task default: :test 12 | -------------------------------------------------------------------------------- /bin/discourse_theme: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env ruby 2 | # frozen_string_literal: true 3 | 4 | require_relative "../lib/discourse_theme" 5 | 6 | DiscourseTheme::Cli.new.run(ARGV) 7 | -------------------------------------------------------------------------------- /discourse_theme.gemspec: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | lib = File.expand_path("../lib", __FILE__) 4 | $LOAD_PATH.unshift(lib) if !$LOAD_PATH.include?(lib) 5 | require "discourse_theme/version" 6 | 7 | Gem::Specification.new do |spec| 8 | spec.name = "discourse_theme" 9 | spec.version = DiscourseTheme::VERSION 10 | spec.authors = ["Discourse Team"] 11 | spec.email = ["team@discourse.org"] 12 | 13 | spec.summary = "CLI helper for creating Discourse themes" 14 | spec.description = "CLI helper for creating Discourse themes" 15 | spec.homepage = "https://github.com/discourse/discourse_theme" 16 | spec.license = "MIT" 17 | 18 | spec.files = `git ls-files -z`.split("\x0").reject { |f| f.match(%r{^(test|spec|features)/}) } 19 | 20 | spec.bindir = "bin" 21 | spec.executables = spec.files.grep(%r{^bin/}) { |f| File.basename(f) } 22 | spec.require_paths = ["lib"] 23 | 24 | spec.required_ruby_version = ">= 3.0.0" 25 | 26 | spec.add_runtime_dependency "minitar", "~> 0.6" 27 | spec.add_runtime_dependency "listen", "~> 3.1" 28 | spec.add_runtime_dependency "multipart-post", "~> 2.0" 29 | spec.add_runtime_dependency "tty-prompt", "~> 0.18" 30 | spec.add_runtime_dependency "rubyzip", "~> 2.3" 31 | spec.add_runtime_dependency "selenium-webdriver", "> 4.11" 32 | 33 | spec.add_development_dependency "bundler" 34 | spec.add_development_dependency "rake" 35 | spec.add_development_dependency "minitest" 36 | spec.add_development_dependency "guard" 37 | spec.add_development_dependency "guard-minitest" 38 | spec.add_development_dependency "webmock" 39 | spec.add_development_dependency "rubocop-discourse", "~> 3.8.1" 40 | spec.add_development_dependency "m" 41 | spec.add_development_dependency "syntax_tree" 42 | spec.add_development_dependency "mocha" 43 | end 44 | -------------------------------------------------------------------------------- /lib/discourse_theme.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | require "fileutils" 3 | require "pathname" 4 | require "tempfile" 5 | require "securerandom" 6 | require "minitar" 7 | require "zlib" 8 | require "find" 9 | require "net/http" 10 | require "net/http/post/multipart" 11 | require "uri" 12 | require "listen" 13 | require "json" 14 | require "yaml" 15 | require "tty/prompt" 16 | 17 | require_relative "discourse_theme/version" 18 | require_relative "discourse_theme/config" 19 | require_relative "discourse_theme/ui" 20 | require_relative "discourse_theme/cli" 21 | require_relative "discourse_theme/client" 22 | require_relative "discourse_theme/downloader" 23 | require_relative "discourse_theme/uploader" 24 | require_relative "discourse_theme/watcher" 25 | require_relative "discourse_theme/scaffold" 26 | 27 | module DiscourseTheme 28 | class ThemeError < StandardError 29 | end 30 | end 31 | -------------------------------------------------------------------------------- /lib/discourse_theme/cli.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require_relative "cli_commands/rspec" 4 | module DiscourseTheme 5 | class Cli 6 | @@cli_settings_filename = File.expand_path("~/.discourse_theme") 7 | 8 | def self.settings_file 9 | @@cli_settings_filename 10 | end 11 | 12 | def self.settings_file=(filename) 13 | @@cli_settings_filename = filename 14 | end 15 | 16 | def self.command?(cmd) 17 | system "which", cmd, out: File::NULL, err: File::NULL 18 | end 19 | 20 | def usage 21 | puts <<~USAGE 22 | Usage: discourse_theme COMMAND [DIR] [OPTIONS] 23 | 24 | Commands: 25 | new DIR - Creates a new theme in the specified directory. 26 | download DIR - Downloads a theme from the server and stores it in the specified directory. 27 | upload DIR - Uploads the theme from the specified directory to Discourse. 28 | watch DIR - Watches the theme in the specified directory and synchronizes any changes with Discourse. 29 | rspec DIR [OPTIONS] - Runs the RSpec tests in the specified directory. The tests can be run using a local Discourse repository or a Docker container. 30 | --headful - Runs the RSpec system type tests in headful mode. Applies to both modes. 31 | 32 | If specified directory has been configured to run in a Docker container, the additional options are supported. 33 | --rebuild - Forces a rebuilds of Docker container. 34 | --verbose - Runs the command to prepare the Docker container in verbose mode. 35 | 36 | Global Options: 37 | --reset - Resets the configuration for the specified directory. 38 | USAGE 39 | 40 | exit 1 41 | end 42 | 43 | def run(args) 44 | if args.delete("--version") 45 | puts VERSION 46 | return 47 | end 48 | 49 | usage unless args[1] 50 | 51 | reset = !!args.delete("--reset") 52 | 53 | command = args[0].to_s.downcase 54 | dir = File.expand_path(args[1]) 55 | 56 | config = DiscourseTheme::Config.new(self.class.settings_file) 57 | settings = config[dir] 58 | 59 | theme_id = settings.theme_id 60 | components = settings.components 61 | 62 | if command == "new" 63 | if Dir.exist?(dir) && !Dir.empty?(dir) 64 | raise DiscourseTheme::ThemeError.new "'#{dir}' is not empty" 65 | end 66 | raise DiscourseTheme::ThemeError.new "git is not installed" if !Cli.command?("git") 67 | 68 | DiscourseTheme::Scaffold.generate(dir, name: args[1]) 69 | watch_theme?(args) 70 | elsif command == "watch" 71 | raise DiscourseTheme::ThemeError.new "'#{dir} does not exist" unless Dir.exist?(dir) 72 | client = DiscourseTheme::Client.new(dir, settings, reset: reset) 73 | 74 | theme_list = client.get_themes_list 75 | 76 | options = {} 77 | 78 | if theme_id && theme = theme_list.find { |t| t["id"] == theme_id } 79 | options["Sync with existing theme: '#{theme["name"]}' (id:#{theme_id})"] = :default 80 | end 81 | 82 | options["Create and sync with a new theme"] = :create 83 | options["Select a different theme"] = :select 84 | 85 | choice = UI.select("How would you like to sync this theme?", options.keys) 86 | 87 | if options[choice] == :create 88 | theme_id = nil 89 | elsif options[choice] == :select 90 | themes = render_theme_list(theme_list) 91 | choice = UI.select("Which theme would you like to sync with?", themes) 92 | theme_id = extract_theme_id(choice) 93 | theme = theme_list.find { |t| t["id"] == theme_id } 94 | end 95 | 96 | about_json = 97 | begin 98 | JSON.parse(File.read(File.join(dir, "about.json"))) 99 | rescue StandardError 100 | nil 101 | end 102 | 103 | already_uploaded = !!theme 104 | is_component = theme&.[]("component") 105 | component_count = about_json&.[]("components")&.length || 0 106 | 107 | if !already_uploaded && !is_component && component_count > 0 108 | options = {} 109 | options["Yes"] = :sync 110 | options["No"] = :none 111 | options = options.sort_by { |_, b| b == components.to_sym ? 0 : 1 }.to_h if components 112 | choice = UI.select("Would you like to update child theme components?", options.keys) 113 | settings.components = components = options[choice].to_s 114 | end 115 | 116 | uploader = 117 | DiscourseTheme::Uploader.new( 118 | dir: dir, 119 | client: client, 120 | theme_id: theme_id, 121 | components: components, 122 | ) 123 | 124 | UI.progress "Uploading theme from #{dir}" 125 | 126 | settings.theme_id = 127 | theme_id = uploader.upload_full_theme(skip_migrations: skip_migrations(theme, dir)) 128 | 129 | UI.success "Theme uploaded (id:#{theme_id})" 130 | UI.info "Preview: #{client.url}/?preview_theme_id=#{theme_id}" 131 | 132 | if client.is_theme_creator 133 | UI.info "Manage: #{client.url}/my/themes" 134 | else 135 | UI.info "Manage: #{client.url}/admin/customize/themes/#{theme_id}" 136 | end 137 | 138 | UI.info "Tests: #{client.url}/theme-qunit?id=#{theme_id}" 139 | 140 | watcher = DiscourseTheme::Watcher.new(dir: dir, uploader: uploader) 141 | UI.progress "Watching for changes in #{dir}..." 142 | watcher.watch 143 | elsif command == "download" 144 | client = DiscourseTheme::Client.new(dir, settings, reset: reset) 145 | downloader = DiscourseTheme::Downloader.new(dir: dir, client: client) 146 | 147 | FileUtils.mkdir_p dir unless Dir.exist?(dir) 148 | raise DiscourseTheme::ThemeError.new "'#{dir} is not empty" unless Dir.empty?(dir) 149 | 150 | UI.progress "Loading theme list..." 151 | themes = render_theme_list(client.get_themes_list) 152 | 153 | choice = UI.select("Which theme would you like to download?", themes) 154 | theme_id = extract_theme_id(choice) 155 | 156 | UI.progress "Downloading theme into #{dir}" 157 | 158 | downloader.download_theme(theme_id) 159 | settings.theme_id = theme_id 160 | 161 | UI.success "Theme downloaded" 162 | 163 | watch_theme?(args) 164 | elsif command == "upload" 165 | raise DiscourseTheme::ThemeError.new "'#{dir} does not exist" unless Dir.exist?(dir) 166 | if theme_id == 0 167 | raise DiscourseTheme::ThemeError.new "No theme_id is set, please sync via the 'watch' command initially" 168 | end 169 | client = DiscourseTheme::Client.new(dir, settings, reset: reset) 170 | 171 | theme_list = client.get_themes_list 172 | 173 | theme = theme_list.find { |t| t["id"] == theme_id } 174 | unless theme 175 | raise DiscourseTheme::ThemeError.new "theme_id is set, but the theme does not exist in Discourse" 176 | end 177 | 178 | uploader = 179 | DiscourseTheme::Uploader.new( 180 | dir: dir, 181 | client: client, 182 | theme_id: theme_id, 183 | components: components, 184 | ) 185 | 186 | UI.progress "Uploading theme (id:#{theme_id}) from #{dir} " 187 | settings.theme_id = theme_id = uploader.upload_full_theme 188 | 189 | UI.success "Theme uploaded (id:#{theme_id})" 190 | UI.info "Preview: #{client.root}/?preview_theme_id=#{theme_id}" 191 | if client.is_theme_creator 192 | UI.info "Manage: #{client.root}/my/themes" 193 | else 194 | UI.info "Manage: #{client.root}/admin/customize/themes/#{theme_id}" 195 | end 196 | elsif command == "rspec" 197 | DiscourseTheme::CliCommands::Rspec.run( 198 | settings: config[dir.split("/spec")[0]], 199 | dir: dir, 200 | args: args, 201 | reset: reset, 202 | ) 203 | else 204 | usage 205 | end 206 | 207 | UI.progress "Exiting..." 208 | rescue DiscourseTheme::ThemeError => e 209 | UI.error "#{e.message}" 210 | rescue Interrupt, TTY::Reader::InputInterrupt => e 211 | UI.error "Interrupted" 212 | end 213 | 214 | private 215 | 216 | def skip_migrations(theme, dir) 217 | return true unless theme && Dir.exist?(File.join(dir, "migrations")) 218 | 219 | migrated_migrations = 220 | theme 221 | .dig("theme_fields") 222 | &.filter_map do |theme_field| 223 | if theme_field["target"] == "migrations" && theme_field["migrated"] == true 224 | theme_field["name"] 225 | end 226 | end || [] 227 | 228 | pending_migrations = 229 | Dir["#{dir}/migrations/**/*.js"] 230 | .reject do |f| 231 | migrated_migrations.any? do |existing_migration| 232 | File.basename(f).include?(existing_migration) 233 | end 234 | end 235 | .map { |f| Pathname.new(f).relative_path_from(Pathname.new(dir)).to_s } 236 | 237 | return true if pending_migrations.empty? 238 | 239 | options = { "No" => :no, "Yes" => :yes } 240 | 241 | choice = UI.select(<<~TEXT, options.keys) 242 | Would you like to run the following pending theme migration(s): #{pending_migrations.join(", ")} 243 | Select 'No' if you are in the midst of adding or modifying theme migration(s). 244 | TEXT 245 | 246 | if options[choice] == :no 247 | UI.warn "Pending theme migrations have not been run, run `discourse_theme upload #{dir}` if you wish to run the theme migrations." 248 | true 249 | else 250 | false 251 | end 252 | end 253 | 254 | def watch_theme?(args) 255 | if UI.yes?("Would you like to start 'watching' this theme?") 256 | args[0] = "watch" 257 | UI.progress "Running discourse_theme #{args.join(" ")}" 258 | run(args) 259 | end 260 | end 261 | 262 | def render_theme_list(themes) 263 | themes 264 | .sort_by { |t| t["updated_at"] } 265 | .reverse 266 | .map { |theme| "#{theme["name"]} (id:#{theme["id"]})" } 267 | end 268 | 269 | def extract_theme_id(rendered_name) 270 | /\(id:([0-9]+)\)$/.match(rendered_name)[1].to_i 271 | end 272 | end 273 | end 274 | -------------------------------------------------------------------------------- /lib/discourse_theme/cli_commands/rspec.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require "selenium-webdriver" 4 | 5 | module DiscourseTheme 6 | module CliCommands 7 | class Rspec 8 | DISCOURSE_TEST_DOCKER_CONTAINER_NAME_PREFIX = "discourse_theme_test" 9 | DISCOURSE_THEME_TEST_TMP_DIR = "/tmp/.discourse_theme_test" 10 | SELENIUM_HEADFUL_ENV = "SELENIUM_HEADLESS=0" 11 | 12 | class << self 13 | def discourse_test_docker_container_name 14 | "#{DISCOURSE_TEST_DOCKER_CONTAINER_NAME_PREFIX}_#{DiscourseTheme::VERSION}" 15 | end 16 | 17 | def run(settings:, dir:, args:, reset: false) 18 | settings.local_discourse_directory = nil if reset 19 | 20 | spec_path = "/spec" 21 | index = dir.index(spec_path) 22 | 23 | if index 24 | spec_path = dir[index..-1] 25 | dir = dir[0..index - 1] 26 | end 27 | 28 | spec_directory = File.join(dir, "/spec") 29 | 30 | unless Dir.exist?(spec_directory) 31 | raise DiscourseTheme::ThemeError.new "'#{spec_directory} does not exist" 32 | end 33 | 34 | configure_local_directory(settings) 35 | 36 | headless = !args.delete("--headful") 37 | 38 | if settings.local_discourse_directory.empty? 39 | run_tests_with_docker( 40 | dir, 41 | spec_directory, 42 | spec_path, 43 | headless: headless, 44 | verbose: !!args.delete("--verbose"), 45 | rebuild: !!args.delete("--rebuild"), 46 | ) 47 | else 48 | run_tests_locally( 49 | settings.local_discourse_directory, 50 | File.join(dir, spec_path), 51 | headless: headless, 52 | ) 53 | end 54 | end 55 | 56 | private 57 | 58 | def execute(command:, message: nil, exit_on_error: true, stream: false) 59 | UI.progress(message) if message 60 | 61 | success = false 62 | output = +"" 63 | 64 | Open3.popen2e(command) do |stdin, stdout_and_stderr, wait_thr| 65 | Thread.new do 66 | stdout_and_stderr.each do |line| 67 | puts line if stream 68 | output << line 69 | end 70 | end 71 | 72 | exit_status = wait_thr.value 73 | success = exit_status.success? 74 | 75 | unless success 76 | UI.error "Error occurred while running: `#{command}`:\n\n#{output}" unless stream 77 | exit 1 if exit_on_error 78 | end 79 | end 80 | 81 | output 82 | end 83 | 84 | def run_tests_locally(local_directory, spec_path, headless: false) 85 | UI.progress( 86 | "Running RSpec tests using local Discourse repository located at '#{local_directory}'...", 87 | ) 88 | 89 | Kernel.exec( 90 | ENV, 91 | "cd #{local_directory} && #{headless ? "" : SELENIUM_HEADFUL_ENV} bundle exec rspec #{spec_path}", 92 | ) 93 | end 94 | 95 | def run_tests_with_docker( 96 | theme_directory, 97 | spec_directory, 98 | spec_path, 99 | headless: false, 100 | verbose: false, 101 | rebuild: false 102 | ) 103 | theme_directory_name = File.basename(theme_directory) 104 | 105 | image = "discourse/discourse_test:release" 106 | UI.progress("Running RSpec tests using '#{image}' Docker image...") 107 | 108 | unless Dir.exist?(DISCOURSE_THEME_TEST_TMP_DIR) 109 | FileUtils.mkdir_p DISCOURSE_THEME_TEST_TMP_DIR 110 | end 111 | 112 | # Checks if the container is running 113 | container_name = discourse_test_docker_container_name 114 | is_running = false 115 | 116 | if !( 117 | output = 118 | execute( 119 | command: "docker ps -a --filter name=#{container_name} --format '{{json .}}'", 120 | ) 121 | ).empty? 122 | is_running = JSON.parse(output)["State"] == "running" 123 | end 124 | 125 | if !is_running || rebuild 126 | # Stop older versions of Docker container 127 | existing_docker_container_ids = 128 | execute( 129 | command: 130 | "docker ps -a -q --filter name=#{DISCOURSE_TEST_DOCKER_CONTAINER_NAME_PREFIX}", 131 | ).split("\n").join(" ") 132 | 133 | if !existing_docker_container_ids.empty? 134 | execute(command: "docker stop #{existing_docker_container_ids}") 135 | execute(command: "docker rm -f #{existing_docker_container_ids}") 136 | end 137 | 138 | execute( 139 | command: <<~CMD.squeeze(" "), 140 | docker run -d \ 141 | -p 31337:31337 \ 142 | --add-host host.docker.internal:host-gateway \ 143 | --entrypoint=/sbin/boot \ 144 | --name=#{container_name} \ 145 | --pull=always \ 146 | -v #{DISCOURSE_THEME_TEST_TMP_DIR}:/tmp \ 147 | #{image} 148 | CMD 149 | message: "Creating #{image} Docker container...", 150 | stream: verbose, 151 | ) 152 | 153 | execute( 154 | command: 155 | "docker exec -u discourse:discourse #{container_name} ruby script/docker_test.rb --no-tests --checkout-ref origin/tests-passed", 156 | message: "Checking out latest Discourse source code...", 157 | stream: verbose, 158 | ) 159 | 160 | execute( 161 | command: 162 | "docker exec -e SKIP_MULTISITE=1 -u discourse:discourse #{container_name} bundle exec rake docker:test:setup", 163 | message: "Setting up Discourse test environment...", 164 | stream: verbose, 165 | ) 166 | 167 | execute( 168 | command: "docker exec -u discourse:discourse #{container_name} bin/ember-cli --build", 169 | message: "Building Ember CLI assets...", 170 | stream: verbose, 171 | ) 172 | end 173 | 174 | rspec_envs = [] 175 | 176 | if !headless 177 | container_ip = 178 | execute( 179 | command: 180 | "docker inspect #{container_name} --format '{{.NetworkSettings.IPAddress}}'", 181 | ).chomp("\n") 182 | 183 | service = 184 | start_chromedriver(allowed_origin: "host.docker.internal", allowed_ip: container_ip) 185 | 186 | rspec_envs.push(SELENIUM_HEADFUL_ENV) 187 | rspec_envs.push("CAPYBARA_SERVER_HOST=0.0.0.0") 188 | rspec_envs.push( 189 | "CAPYBARA_REMOTE_DRIVER_URL=http://host.docker.internal:#{service.uri.port}", 190 | ) 191 | end 192 | 193 | rspec_envs = rspec_envs.map { |env| "-e #{env}" }.join(" ") 194 | 195 | begin 196 | FileUtils.cp_r(theme_directory, DISCOURSE_THEME_TEST_TMP_DIR) 197 | 198 | execute( 199 | command: 200 | "docker exec #{rspec_envs} -t -u discourse:discourse #{container_name} bundle exec rspec #{File.join("/tmp", theme_directory_name, spec_path)}".squeeze( 201 | " ", 202 | ), 203 | stream: true, 204 | ) 205 | ensure 206 | FileUtils.rm_rf(File.join(DISCOURSE_THEME_TEST_TMP_DIR, theme_directory_name)) 207 | end 208 | end 209 | 210 | def configure_local_directory(settings) 211 | return if settings.local_discourse_directory_configured? 212 | 213 | should_configure_local_directory = 214 | UI.yes?( 215 | "Would you like to configure a local Discourse repository used to run the RSpec tests? If you select 'n', the tests will be run using a Docker container.", 216 | ) 217 | 218 | if should_configure_local_directory 219 | local_discourse_directory = 220 | UI.ask("Please enter the path to the local Discourse directory:") 221 | 222 | unless Dir.exist?(local_discourse_directory) 223 | raise DiscourseTheme::ThemeError.new "'#{local_discourse_directory} does not exist" 224 | end 225 | 226 | unless File.exist?("#{local_discourse_directory}/lib/discourse.rb") 227 | raise DiscourseTheme::ThemeError.new "'#{local_discourse_directory} is not a Discourse repository" 228 | end 229 | 230 | settings.local_discourse_directory = local_discourse_directory 231 | else 232 | settings.local_discourse_directory = "" 233 | end 234 | end 235 | 236 | def start_chromedriver(allowed_ip:, allowed_origin:) 237 | service = Selenium::WebDriver::Service.chrome 238 | options = Selenium::WebDriver::Options.chrome 239 | service.executable_path = Selenium::WebDriver::DriverFinder.path(options, service.class) 240 | service.args = ["--allowed-ips=#{allowed_ip}", "--allowed-origins=#{allowed_origin}"] 241 | service.launch 242 | end 243 | end 244 | end 245 | end 246 | end 247 | -------------------------------------------------------------------------------- /lib/discourse_theme/client.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | module DiscourseTheme 3 | class Client 4 | THEME_CREATOR_REGEX = 5 | %r{^https://(theme-creator\.discourse\.org|discourse\.theme-creator\.io)$}i 6 | 7 | attr_reader :url 8 | 9 | def initialize(dir, settings, reset:) 10 | @reset = reset 11 | @url = guess_url(settings) 12 | @api_key = guess_api_key(settings) 13 | 14 | raise "Missing site to synchronize with!" if !@url 15 | raise "Missing api key!" if !@api_key 16 | 17 | @is_theme_creator = !!(THEME_CREATOR_REGEX =~ @url) 18 | 19 | if !self.class.has_needed_version?(discourse_version, "2.3.0.beta1") 20 | UI.info "discourse_theme is designed for Discourse 2.3.0.beta1 or above" 21 | UI.info "download will not function, and syncing destination will be unpredictable" 22 | end 23 | end 24 | 25 | # From https://github.com/discourse/discourse/blob/main/lib/version.rb 26 | def self.has_needed_version?(current, needed) 27 | current_split = current.split(".") 28 | needed_split = needed.split(".") 29 | 30 | (0..[current_split.size, needed_split.size].max).each do |idx| 31 | current_str = current_split[idx] || "" 32 | 33 | c0 = (needed_split[idx] || "").sub("beta", "").to_i 34 | c1 = (current_str || "").sub("beta", "").to_i 35 | 36 | # beta is less than stable 37 | return false if current_str.include?("beta") && (c0 == 0) && (c1 > 0) 38 | 39 | return true if c1 > c0 40 | return false if c0 > c1 41 | end 42 | 43 | true 44 | end 45 | 46 | def get_themes_list 47 | endpoint = 48 | root + 49 | ( 50 | if @is_theme_creator 51 | "/user_themes.json" 52 | else 53 | "/admin/customize/themes.json" 54 | end 55 | ) 56 | 57 | response = request(Net::HTTP::Get.new(endpoint), never_404: true) 58 | json = JSON.parse(response.body) 59 | @is_theme_creator ? json["user_themes"] : json["themes"] 60 | end 61 | 62 | def get_raw_theme_export(id) 63 | endpoint = 64 | root + 65 | ( 66 | if @is_theme_creator 67 | "/user_themes/#{id}/export" 68 | else 69 | "/admin/customize/themes/#{id}/export" 70 | end 71 | ) 72 | 73 | response = request(Net::HTTP::Get.new endpoint) 74 | raise "Error downloading theme: #{response.code}" unless response.code.to_i == 200 75 | raise "Error downloading theme: no content disposition" unless response["content-disposition"] 76 | [response.body, response["content-disposition"].match(/filename=(\"?)(.+)\1/)[2]] 77 | end 78 | 79 | def update_theme(id, args) 80 | endpoint = root + (@is_theme_creator ? "/user_themes/#{id}" : "/admin/themes/#{id}") 81 | 82 | put = Net::HTTP::Put.new(endpoint, "Content-Type" => "application/json") 83 | put.body = args.to_json 84 | request(put) 85 | end 86 | 87 | def upload_full_theme(tgz, theme_id:, components:, skip_migrations: false) 88 | endpoint = 89 | root + 90 | ( 91 | if @is_theme_creator 92 | "/user_themes/import.json" 93 | else 94 | "/admin/themes/import.json" 95 | end 96 | ) 97 | 98 | params = { 99 | "theme_id" => theme_id, 100 | "components" => components, 101 | "bundle" => UploadIO.new(tgz, "application/tar+gzip", "bundle.tar.gz"), 102 | } 103 | 104 | params["skip_migrations"] = true if skip_migrations 105 | 106 | post = Net::HTTP::Post::Multipart.new(endpoint, params) 107 | request(post) 108 | end 109 | 110 | def discourse_version 111 | endpoint = "#{root}/about.json" 112 | response = request(Net::HTTP::Get.new(endpoint), never_404: true) 113 | json = JSON.parse(response.body) 114 | json["about"]["version"] 115 | end 116 | 117 | def root 118 | parsed = URI.parse(@url) 119 | # we must strip the username/password so it does not 120 | # confuse AWS albs 121 | parsed.user = nil 122 | parsed.password = nil 123 | parsed.to_s 124 | end 125 | 126 | def is_theme_creator 127 | @is_theme_creator 128 | end 129 | 130 | private 131 | 132 | def request(request, never_404: false) 133 | uri = URI.parse(@url) 134 | 135 | if uri.userinfo 136 | username, password = uri.userinfo.split(":", 2) 137 | request.basic_auth username, password 138 | end 139 | 140 | http = Net::HTTP.new(uri.host, uri.port) 141 | http.use_ssl = URI::HTTPS === uri 142 | add_headers(request) 143 | http 144 | .request(request) 145 | .tap do |response| 146 | if response.code == "404" && never_404 147 | raise DiscourseTheme::ThemeError.new "Error: Incorrect site URL, or API key does not have the correct privileges" 148 | elsif !%w[200 201].include?(response.code) 149 | errors = 150 | begin 151 | JSON.parse(response.body)["errors"].join(", ") 152 | rescue StandardError 153 | nil 154 | end 155 | raise DiscourseTheme::ThemeError.new "Error #{response.code} for #{request.path.split("?")[0]}#{(": " + errors) if errors}" 156 | end 157 | end 158 | rescue Errno::ECONNREFUSED 159 | raise DiscourseTheme::ThemeError.new "Connection refused for #{request.path}" 160 | end 161 | 162 | def add_headers(request) 163 | if @is_theme_creator 164 | request["User-Api-Key"] = @api_key 165 | else 166 | request["Api-Key"] = @api_key 167 | end 168 | end 169 | 170 | def guess_url(settings) 171 | url = normalize_url(ENV["DISCOURSE_URL"]) 172 | UI.progress "Using #{url} from DISCOURSE_URL" if url 173 | 174 | if !url && settings.url 175 | url = normalize_url(settings.url) 176 | UI.progress "Using #{url} from #{DiscourseTheme::Cli.settings_file}" 177 | end 178 | 179 | if !url || @reset 180 | url = 181 | normalize_url( 182 | UI.ask("What is the root URL of your Discourse site?", default: settings.possible_url), 183 | ) 184 | url = "http://#{url}" unless url =~ %r{^https?://} 185 | 186 | # maybe this is an HTTPS redirect 187 | uri = URI.parse(url) 188 | if URI::HTTP === uri && uri.port == 80 && is_https_redirect?(url) 189 | UI.info "Detected that #{url} should be accessed over https" 190 | url = url.sub("http", "https") 191 | end 192 | 193 | if UI.yes?("Would you like this site name stored in #{DiscourseTheme::Cli.settings_file}?") 194 | settings.url = url 195 | else 196 | settings.url = nil 197 | end 198 | end 199 | 200 | url 201 | end 202 | 203 | def normalize_url(url) 204 | url&.strip&.chomp("/") 205 | end 206 | 207 | def guess_api_key(settings) 208 | api_key = ENV["DISCOURSE_API_KEY"] 209 | UI.progress "Using api key from DISCOURSE_API_KEY" if api_key 210 | 211 | if !api_key && settings.api_key 212 | api_key = settings.api_key 213 | UI.progress "Using api key from #{DiscourseTheme::Cli.settings_file}" 214 | end 215 | 216 | if !api_key || @reset 217 | api_key = UI.ask("What is your API key?", default: api_key).strip 218 | if UI.yes?("Would you like this API key stored in #{DiscourseTheme::Cli.settings_file}?") 219 | settings.api_key = api_key 220 | else 221 | settings.api_key = nil 222 | end 223 | end 224 | 225 | api_key 226 | end 227 | 228 | def is_https_redirect?(url) 229 | url = URI.parse(url) 230 | path = url.path 231 | path = "/" if path.empty? 232 | req = Net::HTTP::Get.new("/") 233 | response = Net::HTTP.start(url.host, url.port) { |http| http.request(req) } 234 | Net::HTTPRedirection === response && response["location"] =~ /^https/i 235 | end 236 | end 237 | end 238 | -------------------------------------------------------------------------------- /lib/discourse_theme/config.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | class DiscourseTheme::Config 3 | class PathSetting 4 | def initialize(config, path) 5 | @config = config 6 | @path = path 7 | end 8 | 9 | def api_key 10 | search_api_key(url) || safe_config["api_key"] 11 | end 12 | 13 | def api_key=(val) 14 | set_api_key(url, val) 15 | end 16 | 17 | def url 18 | safe_config["url"] 19 | end 20 | 21 | def url=(val) 22 | set("url", val) 23 | end 24 | 25 | def possible_url 26 | return safe_config["url"] if safe_config["url"] 27 | 28 | first_config = @config.raw_config.values.find { |config| config["url"] } 29 | first_config["url"] if first_config 30 | end 31 | 32 | def theme_id 33 | safe_config["theme_id"].to_i 34 | end 35 | 36 | def theme_id=(theme_id) 37 | set("theme_id", theme_id.to_i) 38 | end 39 | 40 | def components 41 | safe_config["components"] 42 | end 43 | 44 | def components=(val) 45 | set("components", val) 46 | end 47 | 48 | def local_discourse_directory_configured? 49 | !safe_config["local_discourse_directory"].nil? 50 | end 51 | 52 | def local_discourse_directory 53 | safe_config["local_discourse_directory"] 54 | end 55 | 56 | def local_discourse_directory=(dir) 57 | set("local_discourse_directory", dir) 58 | end 59 | 60 | protected 61 | 62 | def set(name, val) 63 | hash = @config.raw_config[@path] ||= {} 64 | hash[name] = val 65 | @config.save 66 | val 67 | end 68 | 69 | def safe_config 70 | config = @config.raw_config[@path] 71 | if Hash === config 72 | config 73 | else 74 | {} 75 | end 76 | end 77 | 78 | def search_api_key(url) 79 | hash = @config.raw_config["api_keys"] 80 | hash[url] if hash 81 | end 82 | 83 | def set_api_key(url, api_key) 84 | hash = @config.raw_config["api_keys"] ||= {} 85 | hash[url] = api_key 86 | @config.save 87 | api_key 88 | end 89 | end 90 | 91 | attr_reader :raw_config, :filename 92 | 93 | def initialize(filename) 94 | @filename = filename 95 | 96 | if File.exist?(@filename) 97 | begin 98 | @raw_config = YAML.load_file(@filename) 99 | raise unless Hash === @raw_config 100 | rescue StandardError 101 | @raw_config = {} 102 | $stderr.puts "ERROR: #{@filename} contains invalid config, resetting" 103 | end 104 | else 105 | @raw_config = {} 106 | end 107 | end 108 | 109 | def save 110 | File.write(@filename, @raw_config.to_yaml) 111 | end 112 | 113 | def [](path) 114 | PathSetting.new(self, path) 115 | end 116 | end 117 | -------------------------------------------------------------------------------- /lib/discourse_theme/downloader.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | require "zip" 3 | 4 | class DiscourseTheme::Downloader 5 | def initialize(dir:, client:) 6 | @dir = dir 7 | @client = client 8 | @theme_id = nil 9 | end 10 | 11 | def download_theme(id) 12 | raw, filename = @client.get_raw_theme_export(id) 13 | 14 | if filename.end_with?(".zip") 15 | Zip::File.open_buffer(raw) do |zip_file| 16 | zip_file.each do |entry| 17 | new_path = File.join(@dir, entry.name) 18 | entry.extract(new_path) 19 | end 20 | end 21 | else 22 | sio = StringIO.new(raw) 23 | gz = Zlib::GzipReader.new(sio) 24 | Minitar.unpack(gz, @dir) 25 | 26 | # Minitar extracts into a sub directory, move all the files up one dir 27 | Dir.chdir(@dir) do 28 | folders = Dir.glob("*/") 29 | raise "Extraction failed" unless folders.length == 1 30 | FileUtils.mv(Dir.glob("#{folders[0]}*"), "./") 31 | FileUtils.remove_dir(folders[0]) 32 | end 33 | end 34 | end 35 | 36 | private 37 | 38 | def add_headers(request) 39 | request["User-Api-Key"] = @api_key if @is_theme_creator 40 | end 41 | end 42 | -------------------------------------------------------------------------------- /lib/discourse_theme/scaffold.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require "date" 4 | require "json" 5 | require "yaml" 6 | require "resolv" 7 | 8 | module DiscourseTheme 9 | class Scaffold 10 | def self.generate(dir, name:) 11 | UI.progress "Generating a scaffold theme at #{dir}" 12 | 13 | name = UI.ask("What would you like to call your theme?", default: name).to_s.strip 14 | is_component = UI.yes?("Is this a component?") 15 | 16 | get_theme_skeleton(dir) 17 | 18 | Dir.chdir dir do 19 | author = UI.ask("Who is authoring the theme?", default: "Discourse").to_s.strip 20 | description = UI.ask("How would you describe this theme?").to_s.strip 21 | 22 | about = JSON.parse(File.read("about.json")) 23 | about["name"] = name 24 | about["authors"] = author 25 | if !is_component 26 | about.delete("component") 27 | about["color_schemes"] = {} 28 | end 29 | File.write("about.json", JSON.pretty_generate(about)) 30 | 31 | if author != "Discourse" 32 | license = File.read("LICENSE") 33 | license.sub!(/^(Copyright\s\(c\))\s(.+)/, "\\1 #{author}") 34 | File.write("LICENSE", license) 35 | end 36 | 37 | readme = File.read("README.md") 38 | readme.sub!("**Theme Name**", name) 39 | File.write("README.md", readme) 40 | 41 | encoded_name = name.downcase.gsub(/[^a-zA-Z0-9_-]+/, "-") 42 | 43 | todo_initializer = "javascripts/discourse/api-initializers/todo.js" 44 | if File.exist?(todo_initializer) 45 | FileUtils.mv( 46 | "javascripts/discourse/api-initializers/todo.js", 47 | "javascripts/discourse/api-initializers/#{encoded_name}.gjs", 48 | ) 49 | end 50 | 51 | i18n = YAML.safe_load(File.read("locales/en.yml")) 52 | i18n["en"]["theme_metadata"]["description"] = description 53 | File.write("locales/en.yml", YAML.safe_dump(i18n).sub(/\A---\n/, "")) 54 | 55 | UI.info "Initializing git repo" 56 | FileUtils.rm_rf(".git") 57 | FileUtils.rm_rf("**/.gitkeep") 58 | system "git", "init", exception: true 59 | system "git", "symbolic-ref", "HEAD", "refs/heads/main", exception: true 60 | root_files = Dir.glob("*").select { |f| File.file?(f) } 61 | system "git", "add", *root_files, exception: true 62 | system "git", "add", ".*", exception: true 63 | system "git", "add", "locales", exception: true 64 | system "git", 65 | "commit", 66 | "-m", 67 | "Initial commit by `discourse_theme` CLI", 68 | "--quiet", 69 | exception: true 70 | 71 | if Cli.command?("pnpm") 72 | UI.info "Installing dependencies" 73 | system "pnpm", "install", exception: true 74 | else 75 | UI.warn "`pnpm` is not installed, skipping installation of linting dependencies" 76 | end 77 | end 78 | 79 | puts "✅ Done!" 80 | puts "See https://meta.discourse.org/t/93648 for more information!" 81 | end 82 | 83 | private 84 | 85 | def self.get_theme_skeleton(dir) 86 | if online? 87 | puts "Downloading discourse-theme-skeleton" 88 | tmp = Dir.mktmpdir 89 | system "git", 90 | "clone", 91 | "https://github.com/discourse/discourse-theme-skeleton", 92 | tmp, 93 | "--depth", 94 | "1", 95 | "--quiet", 96 | exception: true 97 | FileUtils.rm_rf(skeleton_dir) 98 | # Store the local copy for offline use 99 | FileUtils.cp_r(tmp, skeleton_dir) 100 | 101 | FileUtils.cp_r(skeleton_dir, dir) 102 | elsif Dir.exist?(skeleton_dir) 103 | puts "⚠️ No internet connection detected, using the local copy of discourse-theme-skeleton" 104 | FileUtils.cp_r(skeleton_dir, dir) 105 | else 106 | raise "🛑 Couldn't download discourse-theme-skeleton" 107 | end 108 | end 109 | 110 | def self.online? 111 | !!Resolv::DNS.new.getaddress("github.com") 112 | rescue Resolv::ResolvError => e 113 | false 114 | end 115 | 116 | def self.skeleton_dir 117 | File.expand_path("~/.discourse_theme_skeleton") 118 | end 119 | end 120 | end 121 | -------------------------------------------------------------------------------- /lib/discourse_theme/ui.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | module DiscourseTheme 3 | class UI 4 | @@prompt = ::TTY::Prompt.new(help_color: :cyan) 5 | @@pastel = Pastel.new 6 | 7 | def self.yes?(message) 8 | @@prompt.yes?(@@pastel.cyan("? ") + message) 9 | end 10 | 11 | def self.ask(message, default: nil) 12 | @@prompt.ask(@@pastel.cyan("? ") + message, default: default) 13 | end 14 | 15 | def self.select(message, options) 16 | @@prompt.select(@@pastel.cyan("? ") + message, options) 17 | end 18 | 19 | def self.info(message) 20 | puts @@pastel.blue("i ") + message 21 | end 22 | 23 | def self.progress(message) 24 | puts @@pastel.yellow("» ") + message 25 | end 26 | 27 | def self.error(message) 28 | puts @@pastel.red("✘ #{message}") 29 | end 30 | 31 | def self.warn(message) 32 | puts @@pastel.yellow("⚠ #{message}") 33 | end 34 | 35 | def self.success(message) 36 | puts @@pastel.green("✔ #{message}") 37 | end 38 | end 39 | end 40 | -------------------------------------------------------------------------------- /lib/discourse_theme/uploader.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | module DiscourseTheme 3 | class Uploader 4 | def initialize(dir:, client:, theme_id: nil, components: nil) 5 | @dir = dir 6 | @client = client 7 | @theme_id = theme_id 8 | @components = components 9 | end 10 | 11 | def compress_dir(gzip, dir) 12 | sgz = Zlib::GzipWriter.new(File.open(gzip, "wb")) 13 | tar = Archive::Tar::Minitar::Output.new(sgz) 14 | 15 | Dir.chdir(dir + "/../") do 16 | Find.find(File.basename(dir)) do |x| 17 | bn = File.basename(x) 18 | Find.prune if bn == "node_modules" || bn == "src" || bn[0] == "." 19 | next if File.directory?(x) 20 | 21 | Minitar.pack_file(x, tar) 22 | end 23 | end 24 | ensure 25 | tar.close 26 | sgz.close 27 | end 28 | 29 | def diagnose_errors(json) 30 | count = 0 31 | json["theme"]["theme_fields"].each do |row| 32 | if (error = row["error"]) && error.length > 0 33 | count += 1 34 | UI.error "" 35 | UI.error "Error in #{row["target"]} #{row["name"]}: #{row["error"]}" 36 | UI.error "" 37 | end 38 | end 39 | count 40 | end 41 | 42 | def upload_theme_field(target:, name:, type_id:, value:) 43 | raise "expecting theme_id to be set!" unless @theme_id 44 | 45 | args = { 46 | theme: { 47 | theme_fields: [{ name: name, target: target, type_id: type_id, value: value }], 48 | }, 49 | } 50 | 51 | response = @client.update_theme(@theme_id, args) 52 | json = JSON.parse(response.body) 53 | UI.error "(end of errors)" if diagnose_errors(json) != 0 54 | end 55 | 56 | def upload_full_theme(skip_migrations: false) 57 | filename = "#{Pathname.new(Dir.tmpdir).realpath}/bundle_#{SecureRandom.hex}.tar.gz" 58 | 59 | compress_dir(filename, @dir) 60 | 61 | File.open(filename) do |tgz| 62 | response = 63 | @client.upload_full_theme( 64 | tgz, 65 | theme_id: @theme_id, 66 | components: @components, 67 | skip_migrations: skip_migrations, 68 | ) 69 | 70 | json = JSON.parse(response.body) 71 | @theme_id = json["theme"]["id"] 72 | UI.error "(end of errors)" if diagnose_errors(json) != 0 73 | @theme_id 74 | end 75 | ensure 76 | FileUtils.rm_f(filename) 77 | end 78 | end 79 | end 80 | -------------------------------------------------------------------------------- /lib/discourse_theme/version.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | module DiscourseTheme 3 | VERSION = "2.1.6" 4 | end 5 | -------------------------------------------------------------------------------- /lib/discourse_theme/watcher.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | module DiscourseTheme 3 | class Watcher 4 | def self.return_immediately! 5 | @return_immediately = true 6 | end 7 | 8 | def self.return_immediately? 9 | !!@return_immediately 10 | end 11 | 12 | def initialize(dir:, uploader:) 13 | @dir = dir 14 | @uploader = uploader 15 | end 16 | 17 | def watch 18 | listener = 19 | Listen.to(@dir, ignore: %r{^node_modules/}) do |modified, added, removed| 20 | begin 21 | if modified.length == 1 && added.length == 0 && removed.length == 0 && 22 | (resolved = resolve_file(modified[0])) 23 | target, name, type_id = resolved 24 | UI.progress "Fast updating #{target}.scss" 25 | 26 | @uploader.upload_theme_field( 27 | target: target, 28 | name: name, 29 | value: File.read(modified[0]), 30 | type_id: type_id, 31 | ) 32 | else 33 | count = modified.length + added.length + removed.length 34 | 35 | if count > 1 36 | UI.progress "Detected changes in #{count} files, uploading theme" 37 | else 38 | filename = modified[0] || added[0] || removed[0] 39 | UI.progress "Detected changes in #{filename.gsub(@dir, "")}, uploading theme" 40 | end 41 | 42 | @uploader.upload_full_theme(skip_migrations: true) 43 | end 44 | UI.success "Done! Watching for changes..." 45 | rescue DiscourseTheme::ThemeError => e 46 | UI.error "#{e.message}" 47 | UI.progress "Watching for changes..." 48 | end 49 | end 50 | 51 | listener.start 52 | sleep if !self.class.return_immediately? 53 | end 54 | 55 | protected 56 | 57 | def resolve_file(path) 58 | dir_len = File.expand_path(@dir).length 59 | name = File.expand_path(path)[dir_len + 1..-1] 60 | 61 | target, file = name.split("/") 62 | 63 | if %w[common desktop mobile].include?(target) 64 | if file == "#{target}.scss" 65 | # a CSS file 66 | return target, "scss", 1 67 | end 68 | end 69 | 70 | nil 71 | end 72 | end 73 | end 74 | -------------------------------------------------------------------------------- /test/fixtures/discourse-test-theme.tar.gz: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/discourse/discourse_theme/cdc18da0174c2b70989424d5546749063698aed8/test/fixtures/discourse-test-theme.tar.gz -------------------------------------------------------------------------------- /test/fixtures/discourse-test-theme.zip: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/discourse/discourse_theme/cdc18da0174c2b70989424d5546749063698aed8/test/fixtures/discourse-test-theme.zip -------------------------------------------------------------------------------- /test/fixtures/skeleton-lite/.github/test: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/discourse/discourse_theme/cdc18da0174c2b70989424d5546749063698aed8/test/fixtures/skeleton-lite/.github/test -------------------------------------------------------------------------------- /test/fixtures/skeleton-lite/LICENSE: -------------------------------------------------------------------------------- 1 | The MIT License (MIT) 2 | 3 | Copyright (c) Civilized Discourse Construction Kit, Inc. 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /test/fixtures/skeleton-lite/README.md: -------------------------------------------------------------------------------- 1 | # **Theme Name** 2 | 3 | **Theme Summary** 4 | 5 | For more information, please see: **url to meta topic** 6 | -------------------------------------------------------------------------------- /test/fixtures/skeleton-lite/about.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "TODO", 3 | "component": true, 4 | "authors": "Discourse", 5 | "about_url": "TODO: Put your theme's public repo or Meta topic URL here", 6 | "license_url": "TODO: Put your theme's LICENSE URL here", 7 | "learn_more": "TODO", 8 | "theme_version": "0.0.1", 9 | "minimum_discourse_version": null, 10 | "maximum_discourse_version": null, 11 | "assets": {}, 12 | "modifiers": {} 13 | } 14 | -------------------------------------------------------------------------------- /test/fixtures/skeleton-lite/javascripts/discourse/api-initializers/todo.js: -------------------------------------------------------------------------------- 1 | // import { apiInitializer } from "discourse/lib/api"; 2 | 3 | // export default apiInitializer("1.8.0", (api) => { 4 | // console.log("hello world from api initializer!"); 5 | // }); 6 | -------------------------------------------------------------------------------- /test/fixtures/skeleton-lite/locales/en.yml: -------------------------------------------------------------------------------- 1 | en: 2 | theme_metadata: 3 | description: "TODO" 4 | -------------------------------------------------------------------------------- /test/fixtures/skeleton-lite/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "skeleton-lite", 3 | "version": "1.0.0", 4 | "description": "", 5 | "main": "index.js", 6 | "scripts": { 7 | "test": "echo \"Error: no test specified\" && exit 1" 8 | }, 9 | "keywords": [], 10 | "author": "", 11 | "license": "MIT" 12 | } 13 | -------------------------------------------------------------------------------- /test/test_cli.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require "test_helper" 4 | require "base64" 5 | require "mocha/minitest" 6 | 7 | class TestCli < Minitest::Test 8 | def setup 9 | WebMock.reset! 10 | @dir = Dir.mktmpdir 11 | @spec_dir = Dir.mktmpdir 12 | FileUtils.mkdir_p(File.join(@spec_dir, "/spec")) 13 | @discourse_dir = Dir.mktmpdir 14 | FileUtils.mkdir_p(File.join(@discourse_dir, "/lib")) 15 | File.new(File.join(@discourse_dir, "lib/discourse.rb"), "w") 16 | 17 | @root_stub = 18 | stub_request(:get, "http://my.forum.com").to_return(status: 200, body: "", headers: {}) 19 | 20 | @about_stub = 21 | stub_request(:get, "http://my.forum.com/about.json").to_return( 22 | status: 200, 23 | body: { about: { version: "2.2.0" } }.to_json, 24 | ) 25 | 26 | @themes_stub = 27 | stub_request(:get, "http://my.forum.com/admin/customize/themes.json").to_return( 28 | status: 200, 29 | body: { 30 | themes: [ 31 | { 32 | id: 0, 33 | name: "Some theme", 34 | theme_fields: [ 35 | { 36 | "name" => "0001-rename-settings", 37 | "target" => "migrations", 38 | "value" => "export default function migrate(settings) {\n return settings;\n}\n", 39 | "type_id" => 6, 40 | "migrated" => true, 41 | }, 42 | ], 43 | }, 44 | { id: 1, name: "Magic theme" }, 45 | { id: 5, name: "Amazing theme" }, 46 | ], 47 | }.to_json, 48 | ) 49 | 50 | @import_stub = 51 | stub_request(:post, "http://my.forum.com/admin/themes/import.json").to_return( 52 | status: 200, 53 | body: { theme: { id: "6", name: "Uploaded theme", theme_fields: [] } }.to_json, 54 | ) 55 | 56 | @download_tar_stub = 57 | stub_request(:get, "http://my.forum.com/admin/customize/themes/5/export").to_return( 58 | status: 200, 59 | body: File.new("test/fixtures/discourse-test-theme.tar.gz"), 60 | headers: { 61 | "content-disposition" => 'attachment; filename="testfile.tar.gz"', 62 | }, 63 | ) 64 | 65 | ENV["DISCOURSE_URL"] = "http://my.forum.com" 66 | ENV["DISCOURSE_API_KEY"] = "abc" 67 | 68 | DiscourseTheme::Watcher.return_immediately! 69 | end 70 | 71 | DiscourseTheme::Cli.settings_file = Tempfile.new("settings") 72 | 73 | def teardown 74 | [@dir, @spec_dir, @discourse_dir].each { |dir| FileUtils.remove_dir(dir) } 75 | end 76 | 77 | def capture_output(output_name) 78 | previous_output = output_name == :stdout ? $stdout : $stderr 79 | 80 | io = StringIO.new 81 | output_name == :stdout ? $stdout = io : $stderr = io 82 | 83 | yield 84 | io.string 85 | ensure 86 | output_name == :stdout ? $stdout = previous_output : $stderr = previous_output 87 | end 88 | 89 | def capture_stdout(&block) 90 | capture_output(:stdout, &block) 91 | end 92 | 93 | def capture_stderr(&block) 94 | capture_output(:stderr, &block) 95 | end 96 | 97 | def suppress_output 98 | original_stdout, original_stderr = $stdout.clone, $stderr.clone 99 | $stderr.reopen File.new("/dev/null", "w") 100 | $stdout.reopen File.new("/dev/null", "w") 101 | yield 102 | ensure 103 | $stdout.reopen original_stdout 104 | $stderr.reopen original_stderr 105 | end 106 | 107 | def settings(setting_dir = @dir) 108 | DiscourseTheme::Config.new(DiscourseTheme::Cli.settings_file)[setting_dir] 109 | end 110 | 111 | def wait_for(timeout, &blk) 112 | till = Time.now + (timeout.to_f / 1000) 113 | 114 | while !blk.call 115 | raise "Timeout waiting for block to return true" if Time.now > till 116 | 117 | sleep 0.001 118 | end 119 | end 120 | 121 | def test_watch 122 | args = ["watch", @dir] 123 | 124 | # Stub interactive prompts to always return the first option, or "value" 125 | DiscourseTheme::UI.stub(:select, ->(question, options) { options[0] }) do 126 | suppress_output { DiscourseTheme::Cli.new.run(args) } 127 | end 128 | 129 | assert_requested(@about_stub, times: 1) 130 | assert_requested(@themes_stub, times: 1) 131 | assert_requested(@import_stub, times: 1) 132 | assert_requested(@download_tar_stub, times: 0) 133 | 134 | assert_equal(settings.theme_id, 6) 135 | end 136 | 137 | def test_watch_with_trailing_slash_in_url_removes_trailing_slash 138 | ENV["DISCOURSE_URL"] = nil 139 | args = ["watch", @dir] 140 | 141 | DiscourseTheme::UI.stub(:select, ->(question, options) { options[0] }) do 142 | DiscourseTheme::UI.stub(:ask, "http://my.forum.com/") do 143 | DiscourseTheme::UI.stub(:yes?, true) do 144 | suppress_output { DiscourseTheme::Cli.new.run(args) } 145 | end 146 | end 147 | end 148 | 149 | assert_equal(settings.url, "http://my.forum.com") 150 | end 151 | 152 | def test_watch_with_basic_auth 153 | ENV["DISCOURSE_URL"] = "http://username:password@my.forum.com" 154 | args = ["watch", @dir] 155 | 156 | # Stub interactive prompts to always return the first option, or "value" 157 | DiscourseTheme::UI.stub(:select, ->(question, options) { options[0] }) do 158 | suppress_output { DiscourseTheme::Cli.new.run(args) } 159 | end 160 | 161 | expected_header = { "Authorization" => "Basic #{Base64.strict_encode64("username:password")}" } 162 | 163 | assert_requested(@about_stub.with(headers: expected_header), times: 1) 164 | assert_requested(@themes_stub.with(headers: expected_header), times: 1) 165 | assert_requested(@import_stub.with(headers: expected_header), times: 1) 166 | assert_requested(@download_tar_stub, times: 0) 167 | 168 | assert_equal(settings.theme_id, 6) 169 | end 170 | 171 | def test_watch_uploads_theme_with_skip_migrations_params_when_user_does_not_want_to_run_migrations_after_prompted 172 | args = ["watch", @dir] 173 | 174 | FileUtils.mkdir_p(File.join(@dir, "migrations", "settings")) 175 | 176 | File.write(File.join(@dir, "migrations", "settings", "0001-rename-settings.js"), <<~JS) 177 | export default function migrate(settings) { 178 | return settings; 179 | } 180 | JS 181 | 182 | File.write(File.join(@dir, "migrations", "settings", "0002-rename-settings.js"), <<~JS) 183 | export default function migrate(settings) { 184 | return settings; 185 | } 186 | JS 187 | 188 | DiscourseTheme::UI.stub( 189 | :select, 190 | ->(question, options) do 191 | case question 192 | when "How would you like to sync this theme?" 193 | options[0] 194 | when "Would you like to run the following pending theme migration(s): migrations/settings/0002-rename-settings.js\n Select 'No' if you are in the midst of adding or modifying theme migration(s).\n" 195 | options[0] 196 | end 197 | end, 198 | ) { DiscourseTheme::Cli.new.run(args) } 199 | 200 | assert_requested(:post, "http://my.forum.com/admin/themes/import.json", times: 1) do |req| 201 | req.body.include?("skip_migrations") 202 | end 203 | end 204 | 205 | def test_watch_uploads_theme_without_skip_migrations_params_when_user_wants_to_run_migrations_after_prompted 206 | args = ["watch", @dir] 207 | 208 | FileUtils.mkdir_p(File.join(@dir, "migrations", "settings")) 209 | 210 | File.write(File.join(@dir, "migrations", "settings", "0001-rename-settings.js"), <<~JS) 211 | export default function migrate(settings) { 212 | return settings; 213 | } 214 | JS 215 | 216 | File.write(File.join(@dir, "migrations", "settings", "0002-rename-settings.js"), <<~JS) 217 | export default function migrate(settings) { 218 | return settings; 219 | } 220 | JS 221 | 222 | DiscourseTheme::UI.stub( 223 | :select, 224 | ->(question, options) do 225 | case question 226 | when "How would you like to sync this theme?" 227 | options[0] 228 | when "Would you like to run the following pending theme migration(s): migrations/settings/0002-rename-settings.js\n Select 'No' if you are in the midst of adding or modifying theme migration(s).\n" 229 | options[1] 230 | end 231 | end, 232 | ) { suppress_output { DiscourseTheme::Cli.new.run(args) } } 233 | 234 | assert_requested(:post, "http://my.forum.com/admin/themes/import.json", times: 1) do |req| 235 | !req.body.include?("skip_migrations") 236 | end 237 | end 238 | 239 | def test_child_theme_prompt 240 | args = ["watch", @dir] 241 | 242 | questions_asked = [] 243 | DiscourseTheme::UI.stub( 244 | :select, 245 | ->(question, options) do 246 | questions_asked << question 247 | options[0] 248 | end, 249 | ) { suppress_output { DiscourseTheme::Cli.new.run(args) } } 250 | assert(!questions_asked.join("\n").include?("child theme components")) 251 | 252 | File.write( 253 | File.join(@dir, "about.json"), 254 | { components: ["https://github.com/myorg/myrepo"] }.to_json, 255 | ) 256 | 257 | questions_asked = [] 258 | DiscourseTheme::UI.stub( 259 | :select, 260 | ->(question, options) do 261 | questions_asked << question 262 | options[0] 263 | end, 264 | ) { suppress_output { DiscourseTheme::Cli.new.run(args) } } 265 | assert(questions_asked.join("\n").include?("child theme components")) 266 | end 267 | 268 | def test_upload 269 | import_stub = 270 | stub_request(:post, "http://my.forum.com/admin/themes/import.json").to_return( 271 | status: 200, 272 | body: { theme: { id: "1", name: "Existing theme", theme_fields: [] } }.to_json, 273 | ) 274 | 275 | args = ["upload", @dir] 276 | 277 | # Set an existing theme_id, as this is required for upload. 278 | settings.theme_id = 1 279 | 280 | suppress_output { DiscourseTheme::Cli.new.run(args) } 281 | 282 | assert_requested(@about_stub, times: 1) 283 | assert_requested(@themes_stub, times: 1) 284 | assert_requested(import_stub, times: 1) 285 | assert_requested(@download_tar_stub, times: 0) 286 | 287 | assert_equal(settings.theme_id, 1) 288 | end 289 | 290 | def test_download 291 | @download_zip_stub = 292 | stub_request(:get, "http://my.forum.com/admin/customize/themes/5/export").to_return( 293 | status: 200, 294 | body: File.new("test/fixtures/discourse-test-theme.zip"), 295 | headers: { 296 | "content-disposition" => 'attachment; filename="testfile.zip"', 297 | }, 298 | ) 299 | 300 | args = ["download", @dir] 301 | 302 | DiscourseTheme::UI.stub(:select, ->(question, options) { options[0] }) do 303 | DiscourseTheme::UI.stub(:yes?, false) do 304 | suppress_output { DiscourseTheme::Cli.new.run(args) } 305 | end 306 | end 307 | 308 | assert_requested(@about_stub, times: 1) 309 | assert_requested(@themes_stub, times: 1) 310 | assert_requested(@import_stub, times: 0) 311 | assert_requested(@download_tar_stub, times: 1) 312 | 313 | # Check it got downloaded correctly 314 | Dir.chdir(@dir) do 315 | folders = Dir.glob("**/*").reject { |f| File.file?(f) } 316 | assert(folders.sort == %w[assets common locales mobile].sort) 317 | 318 | files = Dir.glob("**/*").reject { |f| File.directory?(f) } 319 | assert( 320 | files.sort == 321 | %w[ 322 | about.json 323 | assets/logo.png 324 | common/body_tag.html 325 | locales/en.yml 326 | mobile/mobile.scss 327 | settings.yml 328 | ].sort, 329 | ) 330 | 331 | assert(File.read("common/body_tag.html") == "testtheme1") 332 | assert( 333 | File.read("mobile/mobile.scss") == 334 | "body {background-color: $background_color; font-size: $font-size}", 335 | ) 336 | assert(File.read("settings.yml") == "somesetting: test") 337 | end 338 | end 339 | 340 | def test_download_zip 341 | @download_zip_stub = 342 | stub_request(:get, "http://my.forum.com/admin/customize/themes/5/export").to_return( 343 | status: 200, 344 | body: File.new("test/fixtures/discourse-test-theme.zip"), 345 | headers: { 346 | "content-disposition" => 'attachment; filename="testfile.zip"', 347 | }, 348 | ) 349 | 350 | test_download 351 | end 352 | 353 | def test_new 354 | DiscourseTheme::Scaffold.expects(:online?).returns(false) 355 | DiscourseTheme::Scaffold 356 | .expects(:skeleton_dir) 357 | .at_least_once 358 | .returns(File.join(File.expand_path(File.dirname(__FILE__)), "/fixtures/skeleton-lite")) 359 | 360 | DiscourseTheme::UI 361 | .stubs(:ask) 362 | .with("What would you like to call your theme?", anything) 363 | .returns("my theme") 364 | DiscourseTheme::UI.stubs(:ask).with("Who is authoring the theme?", anything).returns("Jane") 365 | DiscourseTheme::UI 366 | .stubs(:ask) 367 | .with("How would you describe this theme?", anything) 368 | .returns("A magical theme") 369 | DiscourseTheme::UI.stubs(:yes?).with("Is this a component?").returns(false) 370 | DiscourseTheme::UI 371 | .stubs(:yes?) 372 | .with("Would you like to start 'watching' this theme?") 373 | .returns(false) 374 | 375 | suppress_output { Dir.chdir(@dir) { DiscourseTheme::Cli.new.run(%w[new foo]) } } 376 | 377 | Dir.chdir(@dir + "/foo") do 378 | assert(File.exist?("javascripts/discourse/api-initializers/my-theme.gjs")) 379 | 380 | assert_equal( 381 | "A magical theme", 382 | YAML.safe_load(File.read("locales/en.yml"))["en"]["theme_metadata"]["description"], 383 | ) 384 | 385 | about = JSON.parse(File.read("about.json")) 386 | assert_equal("my theme", about["name"]) 387 | assert_equal("Jane", about["authors"]) 388 | assert_nil(about["component"]) 389 | assert_equal({}, about["color_schemes"]) 390 | 391 | assert_match("Copyright (c) Jane", File.read("LICENSE")) 392 | assert_match("# my theme\n", File.read("README.md")) 393 | assert(File.exist?(".github/test")) 394 | end 395 | end 396 | 397 | def mock_rspec_local_discourse_commands(dir, spec_dir, rspec_path: "/spec", headless: true) 398 | Kernel.expects(:exec).with( 399 | anything, 400 | "cd #{dir} && #{headless ? "" : DiscourseTheme::CliCommands::Rspec::SELENIUM_HEADFUL_ENV} bundle exec rspec #{File.join(spec_dir, rspec_path)}", 401 | ) 402 | end 403 | 404 | def mock_rspec_docker_commands( 405 | verbose:, 406 | setup_commands:, 407 | rspec_path: "/spec", 408 | container_state: nil, 409 | headless: true 410 | ) 411 | DiscourseTheme::CliCommands::Rspec 412 | .expects(:execute) 413 | .with( 414 | command: 415 | "docker ps -a --filter name=#{DiscourseTheme::CliCommands::Rspec.discourse_test_docker_container_name} --format '{{json .}}'", 416 | ) 417 | .returns( 418 | ( 419 | if container_state 420 | %({"Names":"#{DiscourseTheme::CliCommands::Rspec.discourse_test_docker_container_name}","State":"#{container_state}"}) 421 | else 422 | "" 423 | end 424 | ), 425 | ) 426 | 427 | if setup_commands 428 | DiscourseTheme::CliCommands::Rspec 429 | .expects(:execute) 430 | .with( 431 | command: 432 | "docker ps -a -q --filter name=#{DiscourseTheme::CliCommands::Rspec::DISCOURSE_TEST_DOCKER_CONTAINER_NAME_PREFIX}", 433 | ) 434 | .returns("12345\n678910") 435 | 436 | DiscourseTheme::CliCommands::Rspec.expects(:execute).with(command: "docker stop 12345 678910") 437 | DiscourseTheme::CliCommands::Rspec.expects(:execute).with( 438 | command: "docker rm -f 12345 678910", 439 | ) 440 | 441 | DiscourseTheme::CliCommands::Rspec.expects(:execute).with( 442 | command: <<~COMMAND.squeeze(" "), 443 | docker run -d \ 444 | -p 31337:31337 \ 445 | --add-host host.docker.internal:host-gateway \ 446 | --entrypoint=/sbin/boot \ 447 | --name=#{DiscourseTheme::CliCommands::Rspec.discourse_test_docker_container_name} \ 448 | --pull=always \ 449 | -v #{DiscourseTheme::CliCommands::Rspec::DISCOURSE_THEME_TEST_TMP_DIR}:/tmp \ 450 | discourse/discourse_test:release 451 | COMMAND 452 | message: "Creating discourse/discourse_test:release Docker container...", 453 | stream: verbose, 454 | ) 455 | 456 | DiscourseTheme::CliCommands::Rspec.expects(:execute).with( 457 | command: 458 | "docker exec -u discourse:discourse #{DiscourseTheme::CliCommands::Rspec.discourse_test_docker_container_name} ruby script/docker_test.rb --no-tests --checkout-ref origin/tests-passed", 459 | message: "Checking out latest Discourse source code...", 460 | stream: verbose, 461 | ) 462 | 463 | DiscourseTheme::CliCommands::Rspec.expects(:execute).with( 464 | command: 465 | "docker exec -e SKIP_MULTISITE=1 -u discourse:discourse #{DiscourseTheme::CliCommands::Rspec.discourse_test_docker_container_name} bundle exec rake docker:test:setup", 466 | message: "Setting up Discourse test environment...", 467 | stream: verbose, 468 | ) 469 | 470 | DiscourseTheme::CliCommands::Rspec.expects(:execute).with( 471 | command: 472 | "docker exec -u discourse:discourse #{DiscourseTheme::CliCommands::Rspec.discourse_test_docker_container_name} bin/ember-cli --build", 473 | message: "Building Ember CLI assets...", 474 | stream: verbose, 475 | ) 476 | end 477 | 478 | FileUtils.expects(:rm_rf).with( 479 | File.join( 480 | DiscourseTheme::CliCommands::Rspec::DISCOURSE_THEME_TEST_TMP_DIR, 481 | File.basename(@spec_dir), 482 | ), 483 | ) 484 | 485 | FileUtils.expects(:cp_r).with( 486 | @spec_dir, 487 | DiscourseTheme::CliCommands::Rspec::DISCOURSE_THEME_TEST_TMP_DIR, 488 | ) 489 | 490 | if !headless 491 | fake_ip = "123.456.789" 492 | 493 | DiscourseTheme::CliCommands::Rspec 494 | .expects(:execute) 495 | .with( 496 | command: 497 | "docker inspect #{DiscourseTheme::CliCommands::Rspec.discourse_test_docker_container_name} --format '{{.NetworkSettings.IPAddress}}'", 498 | ) 499 | .returns(fake_ip) 500 | 501 | DiscourseTheme::CliCommands::Rspec 502 | .expects(:start_chromedriver) 503 | .with(allowed_ip: fake_ip, allowed_origin: "host.docker.internal") 504 | .returns(mock(uri: URI("http://localhost:9515"))) 505 | 506 | DiscourseTheme::CliCommands::Rspec.expects(:execute).with( 507 | command: 508 | "docker exec -e SELENIUM_HEADLESS=0 -e CAPYBARA_SERVER_HOST=0.0.0.0 -e CAPYBARA_REMOTE_DRIVER_URL=http://host.docker.internal:9515 -t -u discourse:discourse #{DiscourseTheme::CliCommands::Rspec.discourse_test_docker_container_name} bundle exec rspec #{@spec_dir}#{rspec_path}", 509 | stream: true, 510 | ) 511 | else 512 | DiscourseTheme::CliCommands::Rspec.expects(:execute).with( 513 | command: 514 | "docker exec -t -u discourse:discourse #{DiscourseTheme::CliCommands::Rspec.discourse_test_docker_container_name} bundle exec rspec #{@spec_dir}#{rspec_path}", 515 | stream: true, 516 | ) 517 | end 518 | end 519 | 520 | def run_cli_rspec_with_docker(cli, args) 521 | DiscourseTheme::UI.stub(:yes?, false) { suppress_output { cli.run(args) } } 522 | end 523 | 524 | def run_cli_rspec_with_local_discourse_repository( 525 | cli, 526 | args, 527 | local_discourse_directory, 528 | suppress_output: true 529 | ) 530 | DiscourseTheme::UI.stub(:ask, local_discourse_directory) do 531 | DiscourseTheme::UI.stub(:yes?, true) do 532 | if suppress_output 533 | suppress_output { cli.run(args) } 534 | else 535 | cli.run(args) 536 | end 537 | end 538 | end 539 | end 540 | 541 | def test_rspec_using_local_discourse_repository 542 | args = ["rspec", @spec_dir] 543 | 544 | cli = DiscourseTheme::Cli.new 545 | 546 | mock_rspec_local_discourse_commands(@discourse_dir, @spec_dir) 547 | run_cli_rspec_with_local_discourse_repository(cli, args, @discourse_dir) 548 | 549 | assert_equal(settings(@spec_dir).local_discourse_directory, @discourse_dir) 550 | end 551 | 552 | def test_rspec_using_local_discourse_repository_dir_path_to_custom_rspec_folder 553 | args = ["rspec", File.join(@spec_dir, "/spec/system")] 554 | 555 | cli = DiscourseTheme::Cli.new 556 | 557 | mock_rspec_local_discourse_commands(@discourse_dir, @spec_dir, rspec_path: "/spec/system") 558 | run_cli_rspec_with_local_discourse_repository(cli, args, @discourse_dir) 559 | 560 | assert_equal(settings(@spec_dir).local_discourse_directory, @discourse_dir) 561 | end 562 | 563 | def test_rspec_using_local_discourse_repository_dir_path_to_custom_rspec_file 564 | args = ["rspec", File.join(@spec_dir, "/spec/system/some_spec.rb")] 565 | 566 | cli = DiscourseTheme::Cli.new 567 | 568 | mock_rspec_local_discourse_commands( 569 | @discourse_dir, 570 | @spec_dir, 571 | rspec_path: "/spec/system/some_spec.rb", 572 | ) 573 | 574 | run_cli_rspec_with_local_discourse_repository(cli, args, @discourse_dir) 575 | 576 | assert_equal(settings(@spec_dir).local_discourse_directory, @discourse_dir) 577 | end 578 | 579 | def test_rspec_using_local_discourse_repository_dir_path_to_custom_rspec_file_with_line_number 580 | args = ["rspec", File.join(@spec_dir, "/spec/system/some_spec.rb:44")] 581 | 582 | cli = DiscourseTheme::Cli.new 583 | 584 | mock_rspec_local_discourse_commands( 585 | @discourse_dir, 586 | @spec_dir, 587 | rspec_path: "/spec/system/some_spec.rb:44", 588 | ) 589 | 590 | run_cli_rspec_with_local_discourse_repository(cli, args, @discourse_dir) 591 | 592 | assert_equal(settings(@spec_dir).local_discourse_directory, @discourse_dir) 593 | end 594 | 595 | def test_rspec_using_local_discourse_repository_with_headful_option 596 | args = ["rspec", @spec_dir, "--headful"] 597 | 598 | cli = DiscourseTheme::Cli.new 599 | 600 | mock_rspec_local_discourse_commands(@discourse_dir, @spec_dir, headless: false) 601 | run_cli_rspec_with_local_discourse_repository(cli, args, @discourse_dir) 602 | end 603 | 604 | def test_rspec_using_local_discourse_repository_with_non_existence_directory 605 | args = ["rspec", @spec_dir] 606 | 607 | cli = DiscourseTheme::Cli.new 608 | 609 | output = 610 | capture_stdout do 611 | run_cli_rspec_with_local_discourse_repository( 612 | cli, 613 | args, 614 | "/non/existence/directory", 615 | suppress_output: false, 616 | ) 617 | end 618 | 619 | assert_match("/non/existence/directory does not exist", output) 620 | assert_nil(settings(@spec_dir).local_discourse_directory) 621 | end 622 | 623 | def test_rspec_using_local_discourse_repository_with_directory_that_is_not_a_discourse_repository 624 | args = ["rspec", @spec_dir] 625 | 626 | cli = DiscourseTheme::Cli.new 627 | 628 | output = 629 | capture_stdout do 630 | run_cli_rspec_with_local_discourse_repository(cli, args, @dir, suppress_output: false) 631 | end 632 | 633 | assert_match("#{@dir} is not a Discourse repository", output) 634 | end 635 | 636 | def test_rspec_using_docker 637 | args = ["rspec", @spec_dir] 638 | 639 | cli = DiscourseTheme::Cli.new 640 | mock_rspec_docker_commands(verbose: false, setup_commands: true) 641 | 642 | run_cli_rspec_with_docker(cli, args) 643 | end 644 | 645 | def test_rspec_using_docker_directory_without_spec_folder 646 | args = ["rspec", @spec_dir] 647 | FileUtils.rm_rf(File.join(@spec_dir, "/spec")) 648 | 649 | cli = DiscourseTheme::Cli.new 650 | cli.expects(:execute).never 651 | 652 | run_cli_rspec_with_docker(cli, args) 653 | end 654 | 655 | def test_rspec_using_docker_with_headful_option 656 | args = ["rspec", @spec_dir, "--headful"] 657 | 658 | cli = DiscourseTheme::Cli.new 659 | mock_rspec_docker_commands(verbose: false, setup_commands: true, headless: false) 660 | 661 | run_cli_rspec_with_docker(cli, args) 662 | end 663 | 664 | def test_rspec_using_docker_with_verbose_option 665 | args = ["rspec", @spec_dir, "--verbose"] 666 | 667 | cli = DiscourseTheme::Cli.new 668 | mock_rspec_docker_commands(verbose: true, setup_commands: true) 669 | 670 | run_cli_rspec_with_docker(cli, args) 671 | end 672 | 673 | def test_rspec_using_docker_with_rebuild_option 674 | args = ["rspec", @spec_dir, "--rebuild"] 675 | 676 | cli = DiscourseTheme::Cli.new 677 | 678 | mock_rspec_docker_commands(verbose: false, setup_commands: true, container_state: "running") 679 | 680 | run_cli_rspec_with_docker(cli, args) 681 | end 682 | 683 | def test_rspec_using_docker_when_docker_container_is_already_running 684 | args = ["rspec", @spec_dir] 685 | 686 | cli = DiscourseTheme::Cli.new 687 | 688 | mock_rspec_docker_commands(verbose: false, setup_commands: false, container_state: "running") 689 | 690 | run_cli_rspec_with_docker(cli, args) 691 | end 692 | 693 | def test_rspec_using_docker_with_dir_path_to_rspec_folder 694 | args = ["rspec", File.join(@spec_dir, "/spec")] 695 | 696 | cli = DiscourseTheme::Cli.new 697 | 698 | mock_rspec_docker_commands(verbose: false, setup_commands: false, container_state: "running") 699 | 700 | run_cli_rspec_with_docker(cli, args) 701 | 702 | assert_equal(settings(@spec_dir).local_discourse_directory, "") 703 | end 704 | 705 | def test_rspec_using_docker_with_dir_path_to_custom_rspec_folder 706 | args = ["rspec", File.join(@spec_dir, "/spec/system")] 707 | 708 | cli = DiscourseTheme::Cli.new 709 | 710 | mock_rspec_docker_commands( 711 | verbose: false, 712 | setup_commands: false, 713 | rspec_path: "/spec/system", 714 | container_state: "running", 715 | ) 716 | 717 | run_cli_rspec_with_docker(cli, args) 718 | 719 | assert_equal(settings(@spec_dir).local_discourse_directory, "") 720 | end 721 | 722 | def test_rspec_using_docker_with_dir_path_to_rspec_file 723 | args = ["rspec", File.join(@spec_dir, "/spec/system/some_spec.rb")] 724 | 725 | cli = DiscourseTheme::Cli.new 726 | 727 | mock_rspec_docker_commands( 728 | verbose: false, 729 | setup_commands: false, 730 | rspec_path: "/spec/system/some_spec.rb", 731 | container_state: "running", 732 | ) 733 | 734 | run_cli_rspec_with_docker(cli, args) 735 | 736 | assert_equal(settings(@spec_dir).local_discourse_directory, "") 737 | end 738 | 739 | def test_rspec_using_docker_with_dir_path_to_rspec_file_with_line_number 740 | args = ["rspec", File.join(@spec_dir, "/spec/system/some_spec.rb:3")] 741 | 742 | cli = DiscourseTheme::Cli.new 743 | 744 | mock_rspec_docker_commands( 745 | verbose: false, 746 | setup_commands: false, 747 | rspec_path: "/spec/system/some_spec.rb:3", 748 | container_state: "running", 749 | ) 750 | 751 | run_cli_rspec_with_docker(cli, args) 752 | 753 | assert_equal(settings(@spec_dir).local_discourse_directory, "") 754 | end 755 | end 756 | -------------------------------------------------------------------------------- /test/test_config.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | require "test_helper" 3 | require "tempfile" 4 | 5 | class TestConfig < Minitest::Test 6 | def new_temp_filename 7 | f = Tempfile.new 8 | f.close 9 | filename = f.path 10 | f.unlink 11 | filename 12 | end 13 | 14 | def capture_stderr 15 | before = $stderr 16 | $stderr = StringIO.new 17 | yield 18 | $stderr.string 19 | ensure 20 | $stderr = before 21 | end 22 | 23 | def test_config_serialization 24 | f = Tempfile.new 25 | f.write <<~CONF 26 | "/a/b/c": 27 | api_key: abc 28 | url: http://test.com 29 | CONF 30 | f.close 31 | 32 | config = DiscourseTheme::Config.new f.path 33 | 34 | settings = config["/a/b/c"] 35 | assert_equal("abc", settings.api_key) 36 | assert_equal("http://test.com", settings.url) 37 | ensure 38 | f.unlink 39 | end 40 | 41 | def test_corrupt_settings 42 | filename = new_temp_filename 43 | 44 | File.write(filename, "x\nb:") 45 | 46 | captured = capture_stderr { DiscourseTheme::Config.new filename } 47 | 48 | assert(captured.include? "ERROR") 49 | ensure 50 | File.unlink filename 51 | end 52 | 53 | def test_can_amend_settings 54 | filename = new_temp_filename 55 | 56 | config = DiscourseTheme::Config.new filename 57 | settings = config["/test"] 58 | settings.api_key = "abc" 59 | 60 | config = DiscourseTheme::Config.new filename 61 | assert_nil(config["/test"].url) 62 | assert_equal("abc", config["/test"].api_key) 63 | ensure 64 | File.unlink(filename) 65 | end 66 | 67 | def test_config_can_be_written 68 | filename = new_temp_filename 69 | 70 | config = DiscourseTheme::Config.new filename 71 | config["/a/b/c"].url = "http://a.com" 72 | config["/a/b/c"].api_key = "bla" 73 | 74 | config = DiscourseTheme::Config.new filename 75 | settings = config["/a/b/c"] 76 | 77 | assert_equal("bla", settings.api_key) 78 | assert_equal("http://a.com", settings.url) 79 | ensure 80 | File.unlink(filename) 81 | end 82 | end 83 | -------------------------------------------------------------------------------- /test/test_helper.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | $LOAD_PATH.unshift File.expand_path("../../lib", __FILE__) 3 | require "discourse_theme" 4 | 5 | require "minitest/autorun" 6 | require "webmock/minitest" 7 | --------------------------------------------------------------------------------