shopify-cli 2.18.1 → 2.20.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +4 -4
 - data/.github/ISSUE_TEMPLATE/bug_report.yaml +2 -1
 - data/.github/ISSUE_TEMPLATE/config.yml +9 -0
 - data/.github/workflows/cla.yml +22 -0
 - data/CHANGELOG.md +23 -2
 - data/Gemfile.lock +4 -4
 - data/README.md +7 -6
 - data/dev.yml +0 -1
 - data/docs/users/installation.md +1 -1
 - data/lib/project_types/extension/messages/messages.rb +1 -1
 - data/lib/project_types/extension/models/development_server_requirements.rb +1 -6
 - data/lib/project_types/extension/tasks/fetch_specifications.rb +4 -1
 - data/lib/project_types/script/commands/create.rb +1 -1
 - data/lib/project_types/script/config/extension_points.yml +15 -15
 - data/lib/project_types/script/forms/ask_app.rb +0 -5
 - data/lib/project_types/script/layers/domain/metadata.rb +3 -5
 - data/lib/project_types/script/layers/infrastructure/script_service.rb +1 -1
 - data/lib/project_types/theme/commands/push.rb +3 -1
 - data/lib/project_types/theme/commands/serve.rb +1 -0
 - data/lib/project_types/theme/messages/messages.rb +39 -2
 - data/lib/shopify_cli/assets/post_auth_page/index.html.erb +34 -0
 - data/lib/shopify_cli/assets/post_auth_page/style.css +58 -0
 - data/lib/shopify_cli/identity_auth/servlet.rb +4 -20
 - data/lib/shopify_cli/messages/messages.rb +6 -8
 - data/lib/shopify_cli/theme/dev_server/hot-reload-no-script.html +27 -0
 - data/lib/shopify_cli/theme/dev_server/hot-reload.js +16 -4
 - data/lib/shopify_cli/theme/dev_server/hot_reload.rb +2 -0
 - data/lib/shopify_cli/theme/dev_server.rb +3 -2
 - data/lib/shopify_cli/theme/file.rb +5 -0
 - data/lib/shopify_cli/theme/syncer/json_update_handler.rb +21 -7
 - data/lib/shopify_cli/theme/syncer/operation.rb +7 -6
 - data/lib/shopify_cli/theme/syncer/unsupported_script_warning.rb +90 -0
 - data/lib/shopify_cli/theme/syncer.rb +81 -31
 - data/lib/shopify_cli/theme/theme_admin_api.rb +16 -11
 - data/lib/shopify_cli/theme/theme_admin_api_throttler/bulk.rb +102 -0
 - data/lib/shopify_cli/theme/theme_admin_api_throttler/bulk_job.rb +75 -0
 - data/lib/shopify_cli/theme/theme_admin_api_throttler/errors.rb +7 -0
 - data/lib/shopify_cli/theme/theme_admin_api_throttler/put_request.rb +52 -0
 - data/lib/shopify_cli/theme/theme_admin_api_throttler/request_parser.rb +39 -0
 - data/lib/shopify_cli/theme/theme_admin_api_throttler/response_parser.rb +21 -0
 - data/lib/shopify_cli/theme/theme_admin_api_throttler.rb +62 -0
 - data/lib/shopify_cli/version.rb +1 -1
 - data/shopify-cli.gemspec +1 -1
 - metadata +18 -6
 - data/.github/probots.yml +0 -3
 
| 
         @@ -0,0 +1,27 @@ 
     | 
|
| 
      
 1 
     | 
    
         
            +
            <noscript>
         
     | 
| 
      
 2 
     | 
    
         
            +
              <style type="text/css">
         
     | 
| 
      
 3 
     | 
    
         
            +
                .shopify-cli-no-script-message {
         
     | 
| 
      
 4 
     | 
    
         
            +
                  font-family: -apple-system, BlinkMacSystemFont, San Francisco, Segoe UI, Roboto, Helvetica Neue, sans-serif;
         
     | 
| 
      
 5 
     | 
    
         
            +
                  text-size-adjust: 100%;
         
     | 
| 
      
 6 
     | 
    
         
            +
                  text-rendering: optimizeLegibility;
         
     | 
| 
      
 7 
     | 
    
         
            +
                  -webkit-font-smoothing: antialiased;
         
     | 
| 
      
 8 
     | 
    
         
            +
                  -moz-osx-font-smoothing: grayscale;
         
     | 
| 
      
 9 
     | 
    
         
            +
                  position: fixed;
         
     | 
| 
      
 10 
     | 
    
         
            +
                  z-index: 999;
         
     | 
| 
      
 11 
     | 
    
         
            +
                  font-weight: 500;
         
     | 
| 
      
 12 
     | 
    
         
            +
                  left: 0;
         
     | 
| 
      
 13 
     | 
    
         
            +
                  top: 0;
         
     | 
| 
      
 14 
     | 
    
         
            +
                  width: 100vw;
         
     | 
| 
      
 15 
     | 
    
         
            +
                  height: 100vh;
         
     | 
| 
      
 16 
     | 
    
         
            +
                  padding: 10rem 0;
         
     | 
| 
      
 17 
     | 
    
         
            +
                  color: #202223;
         
     | 
| 
      
 18 
     | 
    
         
            +
                  text-align: center;
         
     | 
| 
      
 19 
     | 
    
         
            +
                  background-color: #F6F6F7;
         
     | 
| 
      
 20 
     | 
    
         
            +
                }
         
     | 
| 
      
 21 
     | 
    
         
            +
              </style>
         
     | 
| 
      
 22 
     | 
    
         
            +
              <div class="shopify-cli-no-script-message">
         
     | 
| 
      
 23 
     | 
    
         
            +
                Shopify CLI requires JavaScript to work.
         
     | 
| 
      
 24 
     | 
    
         
            +
                <br />
         
     | 
| 
      
 25 
     | 
    
         
            +
                Activate JavaScript support or try a different browser.
         
     | 
| 
      
 26 
     | 
    
         
            +
              </div>
         
     | 
| 
      
 27 
     | 
    
         
            +
            </noscript>
         
     | 
| 
         @@ -1,3 +1,15 @@ 
     | 
|
| 
      
 1 
     | 
    
         
            +
            (() => {
         
     | 
| 
      
 2 
     | 
    
         
            +
              function verifySSE() {
         
     | 
| 
      
 3 
     | 
    
         
            +
                if (typeof (EventSource) === "undefined") {
         
     | 
| 
      
 4 
     | 
    
         
            +
                  console.error("[HotReload] Error: SSE features are not supported. Try a different browser.");
         
     | 
| 
      
 5 
     | 
    
         
            +
                }
         
     | 
| 
      
 6 
     | 
    
         
            +
              }
         
     | 
| 
      
 7 
     | 
    
         
            +
             
     | 
| 
      
 8 
     | 
    
         
            +
              console.log("[HotReload] Initializing...");
         
     | 
| 
      
 9 
     | 
    
         
            +
             
     | 
| 
      
 10 
     | 
    
         
            +
              verifySSE();
         
     | 
| 
      
 11 
     | 
    
         
            +
            })();
         
     | 
| 
      
 12 
     | 
    
         
            +
             
     | 
| 
       1 
13 
     | 
    
         
             
            (() => {
         
     | 
| 
       2 
14 
     | 
    
         
             
              function connect() {
         
     | 
| 
       3 
15 
     | 
    
         
             
                const eventSource = new EventSource('/hot-reload');
         
     | 
| 
         @@ -32,7 +44,7 @@ 
     | 
|
| 
       32 
44 
     | 
    
         | 
| 
       33 
45 
     | 
    
         
             
              function fetchDOMSections(name) {
         
     | 
| 
       34 
46 
     | 
    
         
             
                const domSections = sectionNamesByType(name).flatMap((n) => querySelectDOMSections(n));
         
     | 
| 
       35 
     | 
    
         
            -
             
     | 
| 
      
 47 
     | 
    
         
            +
             
     | 
| 
       36 
48 
     | 
    
         
             
                if (domSections.length > 0) {
         
     | 
| 
       37 
49 
     | 
    
         
             
                  return domSections;
         
     | 
| 
       38 
50 
     | 
    
         
             
                }
         
     | 
| 
         @@ -40,11 +52,11 @@ 
     | 
|
| 
       40 
52 
     | 
    
         
             
                return querySelectDOMSections(name);
         
     | 
| 
       41 
53 
     | 
    
         
             
              }
         
     | 
| 
       42 
54 
     | 
    
         | 
| 
       43 
     | 
    
         
            -
              function isFullPageReloadMode(){
         
     | 
| 
      
 55 
     | 
    
         
            +
              function isFullPageReloadMode() {
         
     | 
| 
       44 
56 
     | 
    
         
             
                return reloadMode() === 'full-page';
         
     | 
| 
       45 
57 
     | 
    
         
             
              }
         
     | 
| 
       46 
58 
     | 
    
         | 
| 
       47 
     | 
    
         
            -
              function isReloadModeActive(){
         
     | 
| 
      
 59 
     | 
    
         
            +
              function isReloadModeActive() {
         
     | 
| 
       48 
60 
     | 
    
         
             
                return reloadMode() !== 'off';
         
     | 
| 
       49 
61 
     | 
    
         
             
              }
         
     | 
| 
       50 
62 
     | 
    
         | 
| 
         @@ -72,7 +84,7 @@ 
     | 
|
| 
       72 
84 
     | 
    
         | 
| 
       73 
85 
     | 
    
         
             
                // Hot reload cookie expires in 3 seconds
         
     | 
| 
       74 
86 
     | 
    
         
             
                date.setSeconds(date.getSeconds() + 3);
         
     | 
| 
       75 
     | 
    
         
            -
             
     | 
| 
      
 87 
     | 
    
         
            +
             
     | 
| 
       76 
88 
     | 
    
         
             
                var sections = files.join(',');
         
     | 
| 
       77 
89 
     | 
    
         
             
                var expires = date.toUTCString();
         
     | 
| 
       78 
90 
     | 
    
         | 
| 
         @@ -67,7 +67,9 @@ module ShopifyCLI 
     | 
|
| 
       67 
67 
     | 
    
         | 
| 
       68 
68 
     | 
    
         
             
                    def inject_hot_reload_javascript(body)
         
     | 
| 
       69 
69 
     | 
    
         
             
                      hot_reload_js = ::File.read("#{__dir__}/hot-reload.js")
         
     | 
| 
      
 70 
     | 
    
         
            +
                      hot_reload_no_script = ::File.read("#{__dir__}/hot-reload-no-script.html")
         
     | 
| 
       70 
71 
     | 
    
         
             
                      hot_reload_script = [
         
     | 
| 
      
 72 
     | 
    
         
            +
                        hot_reload_no_script,
         
     | 
| 
       71 
73 
     | 
    
         
             
                        "<script>",
         
     | 
| 
       72 
74 
     | 
    
         
             
                        params_js,
         
     | 
| 
       73 
75 
     | 
    
         
             
                        hot_reload_js,
         
     | 
| 
         @@ -28,11 +28,12 @@ module ShopifyCLI 
     | 
|
| 
       28 
28 
     | 
    
         
             
                    attr_accessor :ctx
         
     | 
| 
       29 
29 
     | 
    
         | 
| 
       30 
30 
     | 
    
         
             
                    def start(ctx, root, host: "127.0.0.1", theme: nil, port: 9292, poll: false, editor_sync: false,
         
     | 
| 
       31 
     | 
    
         
            -
                      mode: ReloadMode.default)
         
     | 
| 
      
 31 
     | 
    
         
            +
                      mode: ReloadMode.default, stable: false)
         
     | 
| 
       32 
32 
     | 
    
         
             
                      @ctx = ctx
         
     | 
| 
       33 
33 
     | 
    
         
             
                      theme = find_theme(root, theme)
         
     | 
| 
       34 
34 
     | 
    
         
             
                      ignore_filter = IgnoreFilter.from_path(root)
         
     | 
| 
       35 
     | 
    
         
            -
                      @syncer = Syncer.new(ctx, theme: theme, ignore_filter: ignore_filter, overwrite_json: !editor_sync 
     | 
| 
      
 35 
     | 
    
         
            +
                      @syncer = Syncer.new(ctx, theme: theme, ignore_filter: ignore_filter, overwrite_json: !editor_sync,
         
     | 
| 
      
 36 
     | 
    
         
            +
                        stable: stable)
         
     | 
| 
       36 
37 
     | 
    
         
             
                      watcher = Watcher.new(ctx, theme: theme, ignore_filter: ignore_filter, syncer: @syncer, poll: poll)
         
     | 
| 
       37 
38 
     | 
    
         
             
                      remote_watcher = RemoteWatcher.to(theme: theme, syncer: @syncer)
         
     | 
| 
       38 
39 
     | 
    
         | 
| 
         @@ -5,6 +5,7 @@ module ShopifyCLI 
     | 
|
| 
       5 
5 
     | 
    
         
             
              module Theme
         
     | 
| 
       6 
6 
     | 
    
         
             
                class File < Struct.new(:path)
         
     | 
| 
       7 
7 
     | 
    
         
             
                  attr_accessor :remote_checksum
         
     | 
| 
      
 8 
     | 
    
         
            +
                  attr_writer :warnings
         
     | 
| 
       8 
9 
     | 
    
         | 
| 
       9 
10 
     | 
    
         
             
                  def initialize(path, root)
         
     | 
| 
       10 
11 
     | 
    
         
             
                    super(Pathname.new(path))
         
     | 
| 
         @@ -104,6 +105,10 @@ module ShopifyCLI 
     | 
|
| 
       104 
105 
     | 
    
         
             
                    @relative_path.to_s
         
     | 
| 
       105 
106 
     | 
    
         
             
                  end
         
     | 
| 
       106 
107 
     | 
    
         | 
| 
      
 108 
     | 
    
         
            +
                  def warnings
         
     | 
| 
      
 109 
     | 
    
         
            +
                    @warnings || []
         
     | 
| 
      
 110 
     | 
    
         
            +
                  end
         
     | 
| 
      
 111 
     | 
    
         
            +
             
     | 
| 
       107 
112 
     | 
    
         
             
                  private
         
     | 
| 
       108 
113 
     | 
    
         | 
| 
       109 
114 
     | 
    
         
             
                  def normalize_json(content)
         
     | 
| 
         @@ -8,16 +8,11 @@ module ShopifyCLI 
     | 
|
| 
       8 
8 
     | 
    
         
             
                class Syncer
         
     | 
| 
       9 
9 
     | 
    
         
             
                  module JsonUpdateHandler
         
     | 
| 
       10 
10 
     | 
    
         
             
                    def enqueue_json_updates(files)
         
     | 
| 
       11 
     | 
    
         
            -
                      # Some files must be uploaded after the other ones
         
     | 
| 
       12 
     | 
    
         
            -
                      delayed_files = [
         
     | 
| 
       13 
     | 
    
         
            -
                        theme["config/settings_schema.json"],
         
     | 
| 
       14 
     | 
    
         
            -
                        theme["config/settings_data.json"],
         
     | 
| 
       15 
     | 
    
         
            -
                      ]
         
     | 
| 
       16 
     | 
    
         
            -
             
     | 
| 
       17 
11 
     | 
    
         
             
                      # Update remote JSON files and delays `delayed_files` update
         
     | 
| 
       18 
12 
     | 
    
         
             
                      files = files
         
     | 
| 
       19 
     | 
    
         
            -
                        .select { |file|  
     | 
| 
      
 13 
     | 
    
         
            +
                        .select { |file| ready_to_update?(file) }
         
     | 
| 
       20 
14 
     | 
    
         
             
                        .sort_by { |file| delayed_files.include?(file) ? 1 : 0 }
         
     | 
| 
      
 15 
     | 
    
         
            +
                        .reject { |file| overwrite_json? && delayed_files.include?(file) }
         
     | 
| 
       21 
16 
     | 
    
         | 
| 
       22 
17 
     | 
    
         
             
                      if overwrite_json?
         
     | 
| 
       23 
18 
     | 
    
         
             
                        enqueue_updates(files)
         
     | 
| 
         @@ -27,6 +22,21 @@ module ShopifyCLI 
     | 
|
| 
       27 
22 
     | 
    
         
             
                      end
         
     | 
| 
       28 
23 
     | 
    
         
             
                    end
         
     | 
| 
       29 
24 
     | 
    
         | 
| 
      
 25 
     | 
    
         
            +
                    def enqueue_delayed_files_updates
         
     | 
| 
      
 26 
     | 
    
         
            +
                      return unless overwrite_json?
         
     | 
| 
      
 27 
     | 
    
         
            +
                      # Update delayed files synchronously
         
     | 
| 
      
 28 
     | 
    
         
            +
                      delayed_files.each do |file|
         
     | 
| 
      
 29 
     | 
    
         
            +
                        update(file) if ready_to_update?(file)
         
     | 
| 
      
 30 
     | 
    
         
            +
                      end
         
     | 
| 
      
 31 
     | 
    
         
            +
                    end
         
     | 
| 
      
 32 
     | 
    
         
            +
             
     | 
| 
      
 33 
     | 
    
         
            +
                    def delayed_files
         
     | 
| 
      
 34 
     | 
    
         
            +
                      [
         
     | 
| 
      
 35 
     | 
    
         
            +
                        theme["config/settings_schema.json"],
         
     | 
| 
      
 36 
     | 
    
         
            +
                        theme["config/settings_data.json"],
         
     | 
| 
      
 37 
     | 
    
         
            +
                      ]
         
     | 
| 
      
 38 
     | 
    
         
            +
                    end
         
     | 
| 
      
 39 
     | 
    
         
            +
             
     | 
| 
       30 
40 
     | 
    
         
             
                    private
         
     | 
| 
       31 
41 
     | 
    
         | 
| 
       32 
42 
     | 
    
         
             
                    def handle_update_conflicts(files)
         
     | 
| 
         @@ -76,6 +86,10 @@ module ShopifyCLI 
     | 
|
| 
       76 
86 
     | 
    
         
             
                    def ask_update_strategy(file)
         
     | 
| 
       77 
87 
     | 
    
         
             
                      Forms::SelectUpdateStrategy.ask(@ctx, [], file: file, exists_remotely: file_exist_remotely?(file)).strategy
         
     | 
| 
       78 
88 
     | 
    
         
             
                    end
         
     | 
| 
      
 89 
     | 
    
         
            +
             
     | 
| 
      
 90 
     | 
    
         
            +
                    def ready_to_update?(file)
         
     | 
| 
      
 91 
     | 
    
         
            +
                      !ignore_file?(file) && file.exist? && checksums.file_has_changed?(file)
         
     | 
| 
      
 92 
     | 
    
         
            +
                    end
         
     | 
| 
       79 
93 
     | 
    
         
             
                  end
         
     | 
| 
       80 
94 
     | 
    
         
             
                end
         
     | 
| 
       81 
95 
     | 
    
         
             
              end
         
     | 
| 
         @@ -9,6 +9,7 @@ module ShopifyCLI 
     | 
|
| 
       9 
9 
     | 
    
         
             
                    COLOR_BY_STATUS = {
         
     | 
| 
       10 
10 
     | 
    
         
             
                      error: :red,
         
     | 
| 
       11 
11 
     | 
    
         
             
                      synced: :green,
         
     | 
| 
      
 12 
     | 
    
         
            +
                      warning: :yellow,
         
     | 
| 
       12 
13 
     | 
    
         
             
                      fixed: :cyan,
         
     | 
| 
       13 
14 
     | 
    
         
             
                    }
         
     | 
| 
       14 
15 
     | 
    
         | 
| 
         @@ -26,8 +27,8 @@ module ShopifyCLI 
     | 
|
| 
       26 
27 
     | 
    
         
             
                      as_message_with(status: :error)
         
     | 
| 
       27 
28 
     | 
    
         
             
                    end
         
     | 
| 
       28 
29 
     | 
    
         | 
| 
       29 
     | 
    
         
            -
                    def as_synced_message
         
     | 
| 
       30 
     | 
    
         
            -
                      as_message_with(status: :synced)
         
     | 
| 
      
 30 
     | 
    
         
            +
                    def as_synced_message(color: :green)
         
     | 
| 
      
 31 
     | 
    
         
            +
                      as_message_with(status: :synced, color: color)
         
     | 
| 
       31 
32 
     | 
    
         
             
                    end
         
     | 
| 
       32 
33 
     | 
    
         | 
| 
       33 
34 
     | 
    
         
             
                    def as_fix_message
         
     | 
| 
         @@ -40,11 +41,11 @@ module ShopifyCLI 
     | 
|
| 
       40 
41 
     | 
    
         | 
| 
       41 
42 
     | 
    
         
             
                    private
         
     | 
| 
       42 
43 
     | 
    
         | 
| 
       43 
     | 
    
         
            -
                    def as_message_with(status:)
         
     | 
| 
       44 
     | 
    
         
            -
                       
     | 
| 
       45 
     | 
    
         
            -
                       
     | 
| 
      
 44 
     | 
    
         
            +
                    def as_message_with(status:, color: nil)
         
     | 
| 
      
 45 
     | 
    
         
            +
                      color ||= COLOR_BY_STATUS[status]
         
     | 
| 
      
 46 
     | 
    
         
            +
                      text = @ctx.message("theme.serve.operation.status.#{status}").ljust(6)
         
     | 
| 
       46 
47 
     | 
    
         | 
| 
       47 
     | 
    
         
            -
                      "#{timestamp} {{#{ 
     | 
| 
      
 48 
     | 
    
         
            +
                      "#{timestamp} {{#{color}:#{text}}} {{>}} {{blue:#{self}}}"
         
     | 
| 
       48 
49 
     | 
    
         
             
                    end
         
     | 
| 
       49 
50 
     | 
    
         | 
| 
       50 
51 
     | 
    
         
             
                    def timestamp
         
     | 
| 
         @@ -0,0 +1,90 @@ 
     | 
|
| 
      
 1 
     | 
    
         
            +
            # frozen_string_literal: true
         
     | 
| 
      
 2 
     | 
    
         
            +
             
     | 
| 
      
 3 
     | 
    
         
            +
            module ShopifyCLI
         
     | 
| 
      
 4 
     | 
    
         
            +
              module Theme
         
     | 
| 
      
 5 
     | 
    
         
            +
                class Syncer
         
     | 
| 
      
 6 
     | 
    
         
            +
                  class UnsupportedScriptWarning
         
     | 
| 
      
 7 
     | 
    
         
            +
                    attr_reader :ctx
         
     | 
| 
      
 8 
     | 
    
         
            +
             
     | 
| 
      
 9 
     | 
    
         
            +
                    def initialize(ctx, file)
         
     | 
| 
      
 10 
     | 
    
         
            +
                      @ctx = ctx
         
     | 
| 
      
 11 
     | 
    
         
            +
                      @file = file
         
     | 
| 
      
 12 
     | 
    
         
            +
                    end
         
     | 
| 
      
 13 
     | 
    
         
            +
             
     | 
| 
      
 14 
     | 
    
         
            +
                    def to_s
         
     | 
| 
      
 15 
     | 
    
         
            +
                      "\n\n#{occurrences} #{long_text}"
         
     | 
| 
      
 16 
     | 
    
         
            +
                    end
         
     | 
| 
      
 17 
     | 
    
         
            +
             
     | 
| 
      
 18 
     | 
    
         
            +
                    private
         
     | 
| 
      
 19 
     | 
    
         
            +
             
     | 
| 
      
 20 
     | 
    
         
            +
                    def occurrences
         
     | 
| 
      
 21 
     | 
    
         
            +
                      warnings.map { |w| occurrence(w) }.join("\n")
         
     | 
| 
      
 22 
     | 
    
         
            +
                    end
         
     | 
| 
      
 23 
     | 
    
         
            +
             
     | 
| 
      
 24 
     | 
    
         
            +
                    def occurrence(warning)
         
     | 
| 
      
 25 
     | 
    
         
            +
                      line_number = "{{blue: #{warning.line} |}}"
         
     | 
| 
      
 26 
     | 
    
         
            +
                      pointer = pointer_message(warning)
         
     | 
| 
      
 27 
     | 
    
         
            +
             
     | 
| 
      
 28 
     | 
    
         
            +
                      <<~OCCURRENCE
         
     | 
| 
      
 29 
     | 
    
         
            +
                        #{line_number} #{warning.line_content}
         
     | 
| 
      
 30 
     | 
    
         
            +
                        #{pointer}
         
     | 
| 
      
 31 
     | 
    
         
            +
                      OCCURRENCE
         
     | 
| 
      
 32 
     | 
    
         
            +
                    end
         
     | 
| 
      
 33 
     | 
    
         
            +
             
     | 
| 
      
 34 
     | 
    
         
            +
                    def long_text
         
     | 
| 
      
 35 
     | 
    
         
            +
                      lines_and_columns = warnings.map do |warning|
         
     | 
| 
      
 36 
     | 
    
         
            +
                        message("line_and_column", warning.line, warning.column)
         
     | 
| 
      
 37 
     | 
    
         
            +
                      end
         
     | 
| 
      
 38 
     | 
    
         
            +
             
     | 
| 
      
 39 
     | 
    
         
            +
                      message("unsupported_script_text", lines_and_columns.join)
         
     | 
| 
      
 40 
     | 
    
         
            +
                        .split("\n")
         
     | 
| 
      
 41 
     | 
    
         
            +
                        .reduce("") do |text, line|
         
     | 
| 
      
 42 
     | 
    
         
            +
                          # Add indentation in the long text to improve readability
         
     | 
| 
      
 43 
     | 
    
         
            +
                          line = " #{line}"
         
     | 
| 
      
 44 
     | 
    
         
            +
             
     | 
| 
      
 45 
     | 
    
         
            +
                          # Inline yellow (otherwise `CLI::UI::Frame` breaks multiline formatting)
         
     | 
| 
      
 46 
     | 
    
         
            +
                          line = "{{yellow:#{line}}}"
         
     | 
| 
      
 47 
     | 
    
         
            +
             
     | 
| 
      
 48 
     | 
    
         
            +
                          "#{text}#{line}\n"
         
     | 
| 
      
 49 
     | 
    
         
            +
                        end
         
     | 
| 
      
 50 
     | 
    
         
            +
                    end
         
     | 
| 
      
 51 
     | 
    
         
            +
             
     | 
| 
      
 52 
     | 
    
         
            +
                    def pointer_message(warning)
         
     | 
| 
      
 53 
     | 
    
         
            +
                      padding = warning.column + warning.line.to_s.size + 2
         
     | 
| 
      
 54 
     | 
    
         
            +
                      text = message("unsupported_script")
         
     | 
| 
      
 55 
     | 
    
         
            +
             
     | 
| 
      
 56 
     | 
    
         
            +
                      "{{yellow:#{" " * padding} ^ {{bold:#{text}}}}}"
         
     | 
| 
      
 57 
     | 
    
         
            +
                    end
         
     | 
| 
      
 58 
     | 
    
         
            +
             
     | 
| 
      
 59 
     | 
    
         
            +
                    def message(*args)
         
     | 
| 
      
 60 
     | 
    
         
            +
                      key = args.shift
         
     | 
| 
      
 61 
     | 
    
         
            +
                      @ctx.message("theme.serve.syncer.warnings.#{key}", *args)
         
     | 
| 
      
 62 
     | 
    
         
            +
                    end
         
     | 
| 
      
 63 
     | 
    
         
            +
             
     | 
| 
      
 64 
     | 
    
         
            +
                    def warnings
         
     | 
| 
      
 65 
     | 
    
         
            +
                      @warnings ||= @file.warnings.map { |w| Warning.new(@file, w) }
         
     | 
| 
      
 66 
     | 
    
         
            +
                    end
         
     | 
| 
      
 67 
     | 
    
         
            +
             
     | 
| 
      
 68 
     | 
    
         
            +
                    class Warning
         
     | 
| 
      
 69 
     | 
    
         
            +
                      attr_reader :line, :column
         
     | 
| 
      
 70 
     | 
    
         
            +
             
     | 
| 
      
 71 
     | 
    
         
            +
                      def initialize(file, warning_hash)
         
     | 
| 
      
 72 
     | 
    
         
            +
                        @file = file
         
     | 
| 
      
 73 
     | 
    
         
            +
                        @line = warning_hash["line"].to_i
         
     | 
| 
      
 74 
     | 
    
         
            +
                        @column = warning_hash["column"].to_i
         
     | 
| 
      
 75 
     | 
    
         
            +
                      end
         
     | 
| 
      
 76 
     | 
    
         
            +
             
     | 
| 
      
 77 
     | 
    
         
            +
                      def line_content
         
     | 
| 
      
 78 
     | 
    
         
            +
                        file_lines[line - 1]
         
     | 
| 
      
 79 
     | 
    
         
            +
                      end
         
     | 
| 
      
 80 
     | 
    
         
            +
             
     | 
| 
      
 81 
     | 
    
         
            +
                      private
         
     | 
| 
      
 82 
     | 
    
         
            +
             
     | 
| 
      
 83 
     | 
    
         
            +
                      def file_lines
         
     | 
| 
      
 84 
     | 
    
         
            +
                        @file_lines ||= @file.read.split("\n")
         
     | 
| 
      
 85 
     | 
    
         
            +
                      end
         
     | 
| 
      
 86 
     | 
    
         
            +
                    end
         
     | 
| 
      
 87 
     | 
    
         
            +
                  end
         
     | 
| 
      
 88 
     | 
    
         
            +
                end
         
     | 
| 
      
 89 
     | 
    
         
            +
              end
         
     | 
| 
      
 90 
     | 
    
         
            +
            end
         
     | 
| 
         @@ -13,7 +13,9 @@ require_relative "syncer/json_update_handler" 
     | 
|
| 
       13 
13 
     | 
    
         
             
            require_relative "syncer/merger"
         
     | 
| 
       14 
14 
     | 
    
         
             
            require_relative "syncer/operation"
         
     | 
| 
       15 
15 
     | 
    
         
             
            require_relative "syncer/standard_reporter"
         
     | 
| 
      
 16 
     | 
    
         
            +
            require_relative "syncer/unsupported_script_warning"
         
     | 
| 
       16 
17 
     | 
    
         
             
            require_relative "theme_admin_api"
         
     | 
| 
      
 18 
     | 
    
         
            +
            require_relative "theme_admin_api_throttler"
         
     | 
| 
       17 
19 
     | 
    
         | 
| 
       18 
20 
     | 
    
         
             
            module ShopifyCLI
         
     | 
| 
       19 
21 
     | 
    
         
             
              module Theme
         
     | 
| 
         @@ -31,12 +33,12 @@ module ShopifyCLI 
     | 
|
| 
       31 
33 
     | 
    
         
             
                    :union_merge, # - Union merges the local file content with the remote file content
         
     | 
| 
       32 
34 
     | 
    
         
             
                  ]
         
     | 
| 
       33 
35 
     | 
    
         | 
| 
       34 
     | 
    
         
            -
                  attr_reader :theme, :checksums, :error_checksums
         
     | 
| 
      
 36 
     | 
    
         
            +
                  attr_reader :theme, :checksums, :error_checksums, :api_client
         
     | 
| 
       35 
37 
     | 
    
         
             
                  attr_accessor :include_filter, :ignore_filter
         
     | 
| 
       36 
38 
     | 
    
         | 
| 
       37 
39 
     | 
    
         
             
                  def_delegators :@error_reporter, :has_any_error?
         
     | 
| 
       38 
40 
     | 
    
         | 
| 
       39 
     | 
    
         
            -
                  def initialize(ctx, theme:, include_filter: nil, ignore_filter: nil, overwrite_json: true)
         
     | 
| 
      
 41 
     | 
    
         
            +
                  def initialize(ctx, theme:, include_filter: nil, ignore_filter: nil, overwrite_json: true, stable: false)
         
     | 
| 
       40 
42 
     | 
    
         
             
                    @ctx = ctx
         
     | 
| 
       41 
43 
     | 
    
         
             
                    @theme = theme
         
     | 
| 
       42 
44 
     | 
    
         
             
                    @include_filter = include_filter
         
     | 
| 
         @@ -58,15 +60,21 @@ module ShopifyCLI 
     | 
|
| 
       58 
60 
     | 
    
         
             
                    # Mutex used to pause all threads when backing-off when hitting API rate limits
         
     | 
| 
       59 
61 
     | 
    
         
             
                    @backoff_mutex = Mutex.new
         
     | 
| 
       60 
62 
     | 
    
         | 
| 
      
 63 
     | 
    
         
            +
                    # Mutex used to coordinate changes in the `pending` list
         
     | 
| 
      
 64 
     | 
    
         
            +
                    @pending_mutex = Mutex.new
         
     | 
| 
      
 65 
     | 
    
         
            +
             
     | 
| 
       61 
66 
     | 
    
         
             
                    # Latest theme assets checksums. Updated on each upload.
         
     | 
| 
       62 
67 
     | 
    
         
             
                    @checksums = Checksums.new(theme)
         
     | 
| 
       63 
68 
     | 
    
         | 
| 
       64 
69 
     | 
    
         
             
                    # Checksums of assets with errors.
         
     | 
| 
       65 
70 
     | 
    
         
             
                    @error_checksums = []
         
     | 
| 
       66 
     | 
    
         
            -
                  end
         
     | 
| 
       67 
71 
     | 
    
         | 
| 
       68 
     | 
    
         
            -
             
     | 
| 
       69 
     | 
    
         
            -
                    @api_client  
     | 
| 
      
 72 
     | 
    
         
            +
                    # Initialize `api_client` on main thread
         
     | 
| 
      
 73 
     | 
    
         
            +
                    @api_client = ThemeAdminAPIThrottler.new(
         
     | 
| 
      
 74 
     | 
    
         
            +
                      @ctx,
         
     | 
| 
      
 75 
     | 
    
         
            +
                      ThemeAdminAPI.new(@ctx, @theme.shop),
         
     | 
| 
      
 76 
     | 
    
         
            +
                      !stable
         
     | 
| 
      
 77 
     | 
    
         
            +
                    )
         
     | 
| 
       70 
78 
     | 
    
         
             
                  end
         
     | 
| 
       71 
79 
     | 
    
         | 
| 
       72 
80 
     | 
    
         
             
                  def lock_io!
         
     | 
| 
         @@ -134,6 +142,7 @@ module ShopifyCLI 
     | 
|
| 
       134 
142 
     | 
    
         
             
                  end
         
     | 
| 
       135 
143 
     | 
    
         | 
| 
       136 
144 
     | 
    
         
             
                  def shutdown
         
     | 
| 
      
 145 
     | 
    
         
            +
                    api_client.shutdown
         
     | 
| 
       137 
146 
     | 
    
         
             
                    @queue.close unless @queue.closed?
         
     | 
| 
       138 
147 
     | 
    
         
             
                  ensure
         
     | 
| 
       139 
148 
     | 
    
         
             
                    @threads.each { |thread| thread.join if thread.alive? }
         
     | 
| 
         @@ -185,6 +194,9 @@ module ShopifyCLI 
     | 
|
| 
       185 
194 
     | 
    
         
             
                    unless delay_low_priority_files
         
     | 
| 
       186 
195 
     | 
    
         
             
                      wait!(&block)
         
     | 
| 
       187 
196 
     | 
    
         
             
                    end
         
     | 
| 
      
 197 
     | 
    
         
            +
             
     | 
| 
      
 198 
     | 
    
         
            +
                    api_client.deactivate_throttler!
         
     | 
| 
      
 199 
     | 
    
         
            +
                    enqueue_delayed_files_updates
         
     | 
| 
       188 
200 
     | 
    
         
             
                  end
         
     | 
| 
       189 
201 
     | 
    
         | 
| 
       190 
202 
     | 
    
         
             
                  def download_theme!(delete: true, &block)
         
     | 
| 
         @@ -242,38 +254,57 @@ module ShopifyCLI 
     | 
|
| 
       242 
254 
     | 
    
         
             
                    wait_for_backoff!
         
     | 
| 
       243 
255 
     | 
    
         
             
                    @ctx.debug(operation.to_s)
         
     | 
| 
       244 
256 
     | 
    
         | 
| 
       245 
     | 
    
         
            -
                     
     | 
| 
      
 257 
     | 
    
         
            +
                    send(operation.method, operation.file) do |response|
         
     | 
| 
      
 258 
     | 
    
         
            +
                      raise response if response.is_a?(StandardError)
         
     | 
| 
       246 
259 
     | 
    
         | 
| 
       247 
     | 
    
         
            -
             
     | 
| 
      
 260 
     | 
    
         
            +
                      file = operation.file
         
     | 
| 
       248 
261 
     | 
    
         | 
| 
       249 
     | 
    
         
            -
             
     | 
| 
       250 
     | 
    
         
            -
             
     | 
| 
       251 
     | 
    
         
            -
             
     | 
| 
       252 
     | 
    
         
            -
             
     | 
| 
      
 262 
     | 
    
         
            +
                      if file.warnings.any?
         
     | 
| 
      
 263 
     | 
    
         
            +
                        warning_message =
         
     | 
| 
      
 264 
     | 
    
         
            +
                          operation.as_synced_message(color: :yellow) +
         
     | 
| 
      
 265 
     | 
    
         
            +
                          UnsupportedScriptWarning.new(@ctx, file).to_s
         
     | 
| 
      
 266 
     | 
    
         
            +
                        @error_reporter.report(warning_message)
         
     | 
| 
      
 267 
     | 
    
         
            +
                      else
         
     | 
| 
      
 268 
     | 
    
         
            +
                        @standard_reporter.report(operation.as_synced_message)
         
     | 
| 
      
 269 
     | 
    
         
            +
                      end
         
     | 
| 
      
 270 
     | 
    
         
            +
             
     | 
| 
      
 271 
     | 
    
         
            +
                      # Check if the API told us we're near the rate limit
         
     | 
| 
      
 272 
     | 
    
         
            +
                      if !backingoff? && (limit = response["x-shopify-shop-api-call-limit"])
         
     | 
| 
      
 273 
     | 
    
         
            +
                        used, total = limit.split("/").map(&:to_i)
         
     | 
| 
      
 274 
     | 
    
         
            +
                        backoff_if_near_limit!(used, total)
         
     | 
| 
      
 275 
     | 
    
         
            +
                      end
         
     | 
| 
      
 276 
     | 
    
         
            +
                    rescue StandardError => error
         
     | 
| 
      
 277 
     | 
    
         
            +
                      handle_operation_error(operation, error)
         
     | 
| 
      
 278 
     | 
    
         
            +
                    ensure
         
     | 
| 
      
 279 
     | 
    
         
            +
                      @pending_mutex.synchronize do
         
     | 
| 
      
 280 
     | 
    
         
            +
                        # Avoid abrupt jumps in the progress bar
         
     | 
| 
      
 281 
     | 
    
         
            +
                        wait(0.05)
         
     | 
| 
      
 282 
     | 
    
         
            +
                        @pending.delete(operation)
         
     | 
| 
      
 283 
     | 
    
         
            +
                      end
         
     | 
| 
       253 
284 
     | 
    
         
             
                    end
         
     | 
| 
       254 
     | 
    
         
            -
                  rescue  
     | 
| 
       255 
     | 
    
         
            -
                     
     | 
| 
       256 
     | 
    
         
            -
                    report_error(operation, error_suffix)
         
     | 
| 
       257 
     | 
    
         
            -
                  ensure
         
     | 
| 
       258 
     | 
    
         
            -
                    @pending.delete(operation)
         
     | 
| 
      
 285 
     | 
    
         
            +
                  rescue StandardError => error
         
     | 
| 
      
 286 
     | 
    
         
            +
                    handle_operation_error(operation, error)
         
     | 
| 
       259 
287 
     | 
    
         
             
                  end
         
     | 
| 
       260 
288 
     | 
    
         | 
| 
       261 
289 
     | 
    
         
             
                  def update(file)
         
     | 
| 
       262 
290 
     | 
    
         
             
                    asset = { key: file.relative_path }
         
     | 
| 
      
 291 
     | 
    
         
            +
             
     | 
| 
       263 
292 
     | 
    
         
             
                    if file.text?
         
     | 
| 
       264 
293 
     | 
    
         
             
                      asset[:value] = file.read
         
     | 
| 
       265 
294 
     | 
    
         
             
                    else
         
     | 
| 
       266 
295 
     | 
    
         
             
                      asset[:attachment] = Base64.encode64(file.read)
         
     | 
| 
       267 
296 
     | 
    
         
             
                    end
         
     | 
| 
       268 
297 
     | 
    
         | 
| 
       269 
     | 
    
         
            -
                     
     | 
| 
       270 
     | 
    
         
            -
             
     | 
| 
       271 
     | 
    
         
            -
                      body: JSON.generate(asset: asset)
         
     | 
| 
       272 
     | 
    
         
            -
                    )
         
     | 
| 
      
 298 
     | 
    
         
            +
                    path = "themes/#{@theme.id}/assets.json"
         
     | 
| 
      
 299 
     | 
    
         
            +
                    req_body = JSON.generate(asset: asset)
         
     | 
| 
       273 
300 
     | 
    
         | 
| 
       274 
     | 
    
         
            -
                     
     | 
| 
      
 301 
     | 
    
         
            +
                    api_client.put(path: path, body: req_body) do |_status, resp_body, response|
         
     | 
| 
      
 302 
     | 
    
         
            +
                      update_checksums(resp_body)
         
     | 
| 
      
 303 
     | 
    
         
            +
             
     | 
| 
      
 304 
     | 
    
         
            +
                      file.warnings = resp_body.dig("asset", "warnings")
         
     | 
| 
       275 
305 
     | 
    
         | 
| 
       276 
     | 
    
         
            -
             
     | 
| 
      
 306 
     | 
    
         
            +
                      yield(response) if block_given?
         
     | 
| 
      
 307 
     | 
    
         
            +
                    end
         
     | 
| 
       277 
308 
     | 
    
         
             
                  end
         
     | 
| 
       278 
309 
     | 
    
         | 
| 
       279 
310 
     | 
    
         
             
                  def get(file)
         
     | 
| 
         @@ -291,7 +322,7 @@ module ShopifyCLI 
     | 
|
| 
       291 
322 
     | 
    
         
             
                      file.write(body.dig("asset", "value"))
         
     | 
| 
       292 
323 
     | 
    
         
             
                    end
         
     | 
| 
       293 
324 
     | 
    
         | 
| 
       294 
     | 
    
         
            -
                    response
         
     | 
| 
      
 325 
     | 
    
         
            +
                    yield(response)
         
     | 
| 
       295 
326 
     | 
    
         
             
                  end
         
     | 
| 
       296 
327 
     | 
    
         | 
| 
       297 
328 
     | 
    
         
             
                  def delete(file)
         
     | 
| 
         @@ -302,7 +333,7 @@ module ShopifyCLI 
     | 
|
| 
       302 
333 
     | 
    
         
             
                      })
         
     | 
| 
       303 
334 
     | 
    
         
             
                    )
         
     | 
| 
       304 
335 
     | 
    
         | 
| 
       305 
     | 
    
         
            -
                    response
         
     | 
| 
      
 336 
     | 
    
         
            +
                    yield(response)
         
     | 
| 
       306 
337 
     | 
    
         
             
                  end
         
     | 
| 
       307 
338 
     | 
    
         | 
| 
       308 
339 
     | 
    
         
             
                  def union_merge(file)
         
     | 
| 
         @@ -311,11 +342,11 @@ module ShopifyCLI 
     | 
|
| 
       311 
342 
     | 
    
         
             
                      query: URI.encode_www_form("asset[key]" => file.relative_path),
         
     | 
| 
       312 
343 
     | 
    
         
             
                    )
         
     | 
| 
       313 
344 
     | 
    
         | 
| 
       314 
     | 
    
         
            -
                    return response unless file.text?
         
     | 
| 
      
 345 
     | 
    
         
            +
                    return yield(response) unless file.text?
         
     | 
| 
       315 
346 
     | 
    
         | 
| 
       316 
347 
     | 
    
         
             
                    remote_content = body.dig("asset", "value")
         
     | 
| 
       317 
348 
     | 
    
         | 
| 
       318 
     | 
    
         
            -
                    return response if remote_content.nil?
         
     | 
| 
      
 349 
     | 
    
         
            +
                    return yield(response) if remote_content.nil?
         
     | 
| 
       319 
350 
     | 
    
         | 
| 
       320 
351 
     | 
    
         
             
                    content = Merger.union_merge(file, remote_content)
         
     | 
| 
       321 
352 
     | 
    
         | 
| 
         @@ -323,7 +354,7 @@ module ShopifyCLI 
     | 
|
| 
       323 
354 
     | 
    
         | 
| 
       324 
355 
     | 
    
         
             
                    enqueue(:update, file)
         
     | 
| 
       325 
356 
     | 
    
         | 
| 
       326 
     | 
    
         
            -
                    response
         
     | 
| 
      
 357 
     | 
    
         
            +
                    yield(response)
         
     | 
| 
       327 
358 
     | 
    
         
             
                  end
         
     | 
| 
       328 
359 
     | 
    
         | 
| 
       329 
360 
     | 
    
         
             
                  def update_checksums(api_response)
         
     | 
| 
         @@ -335,19 +366,29 @@ module ShopifyCLI 
     | 
|
| 
       335 
366 
     | 
    
         
             
                    checksums.reject_duplicated_checksums!
         
     | 
| 
       336 
367 
     | 
    
         
             
                  end
         
     | 
| 
       337 
368 
     | 
    
         | 
| 
       338 
     | 
    
         
            -
                  def parse_api_errors(exception)
         
     | 
| 
       339 
     | 
    
         
            -
                    parsed_body =  
     | 
| 
       340 
     | 
    
         
            -
             
     | 
| 
      
 369 
     | 
    
         
            +
                  def parse_api_errors(operation, exception)
         
     | 
| 
      
 370 
     | 
    
         
            +
                    parsed_body = if exception&.response&.is_a?(Hash)
         
     | 
| 
      
 371 
     | 
    
         
            +
                      exception&.response&.[](:body)
         
     | 
| 
      
 372 
     | 
    
         
            +
                    else
         
     | 
| 
      
 373 
     | 
    
         
            +
                      JSON.parse(exception&.response&.body)
         
     | 
| 
      
 374 
     | 
    
         
            +
                    end
         
     | 
| 
      
 375 
     | 
    
         
            +
             
     | 
| 
      
 376 
     | 
    
         
            +
                    errors = parsed_body.dig("errors") # either nil or another type
         
     | 
| 
      
 377 
     | 
    
         
            +
                    errors = errors.dig("asset") if errors&.is_a?(Hash)
         
     | 
| 
      
 378 
     | 
    
         
            +
             
     | 
| 
      
 379 
     | 
    
         
            +
                    message = errors || parsed_body["message"] || exception.message
         
     | 
| 
       341 
380 
     | 
    
         
             
                    # Truncate to first lines
         
     | 
| 
       342 
381 
     | 
    
         
             
                    [message].flatten.map { |m| m.split("\n", 2).first }
         
     | 
| 
       343 
382 
     | 
    
         
             
                  rescue JSON::ParserError
         
     | 
| 
       344 
383 
     | 
    
         
             
                    [exception.message]
         
     | 
| 
      
 384 
     | 
    
         
            +
                  rescue StandardError => e
         
     | 
| 
      
 385 
     | 
    
         
            +
                    ["The asset #{operation.file} is could not be synced (cause: #{e.message})."]
         
     | 
| 
       345 
386 
     | 
    
         
             
                  end
         
     | 
| 
       346 
387 
     | 
    
         | 
| 
       347 
388 
     | 
    
         
             
                  def backoff_if_near_limit!(used, limit)
         
     | 
| 
       348 
389 
     | 
    
         
             
                    if used > limit - @threads.size
         
     | 
| 
       349 
390 
     | 
    
         
             
                      @ctx.debug("Near API call limit, waiting 2 sec…")
         
     | 
| 
       350 
     | 
    
         
            -
                      @backoff_mutex.synchronize {  
     | 
| 
      
 391 
     | 
    
         
            +
                      @backoff_mutex.synchronize { wait(2) }
         
     | 
| 
       351 
392 
     | 
    
         
             
                    end
         
     | 
| 
       352 
393 
     | 
    
         
             
                  end
         
     | 
| 
       353 
394 
     | 
    
         | 
| 
         @@ -363,6 +404,15 @@ module ShopifyCLI 
     | 
|
| 
       363 
404 
     | 
    
         
             
                    # Sleeping in the mutex in another thread. Wait for unlock
         
     | 
| 
       364 
405 
     | 
    
         
             
                    @backoff_mutex.synchronize {} if backingoff?
         
     | 
| 
       365 
406 
     | 
    
         
             
                  end
         
     | 
| 
      
 407 
     | 
    
         
            +
             
     | 
| 
      
 408 
     | 
    
         
            +
                  def handle_operation_error(operation, error)
         
     | 
| 
      
 409 
     | 
    
         
            +
                    error_suffix = ":\n  " + parse_api_errors(operation, error).join("\n  ")
         
     | 
| 
      
 410 
     | 
    
         
            +
                    report_error(operation, error_suffix)
         
     | 
| 
      
 411 
     | 
    
         
            +
                  end
         
     | 
| 
      
 412 
     | 
    
         
            +
             
     | 
| 
      
 413 
     | 
    
         
            +
                  def wait(duration)
         
     | 
| 
      
 414 
     | 
    
         
            +
                    sleep(duration)
         
     | 
| 
      
 415 
     | 
    
         
            +
                  end
         
     | 
| 
       366 
416 
     | 
    
         
             
                end
         
     | 
| 
       367 
417 
     | 
    
         
             
              end
         
     | 
| 
       368 
418 
     | 
    
         
             
            end
         
     | 
| 
         @@ -1,3 +1,5 @@ 
     | 
|
| 
      
 1 
     | 
    
         
            +
            # frozen_string_literal: true
         
     | 
| 
      
 2 
     | 
    
         
            +
             
     | 
| 
       1 
3 
     | 
    
         
             
            module ShopifyCLI
         
     | 
| 
       2 
4 
     | 
    
         
             
              module Theme
         
     | 
| 
       3 
5 
     | 
    
         
             
                class ThemeAdminAPI
         
     | 
| 
         @@ -10,35 +12,36 @@ module ShopifyCLI 
     | 
|
| 
       10 
12 
     | 
    
         
             
                    @shop = shop || get_shop_or_abort
         
     | 
| 
       11 
13 
     | 
    
         
             
                  end
         
     | 
| 
       12 
14 
     | 
    
         | 
| 
       13 
     | 
    
         
            -
                  def get(path:, **args)
         
     | 
| 
       14 
     | 
    
         
            -
                    rest_request(method: "GET", path: path, **args)
         
     | 
| 
      
 15 
     | 
    
         
            +
                  def get(path:, **args, &block)
         
     | 
| 
      
 16 
     | 
    
         
            +
                    rest_request(method: "GET", path: path, **args, &block)
         
     | 
| 
       15 
17 
     | 
    
         
             
                  end
         
     | 
| 
       16 
18 
     | 
    
         | 
| 
       17 
     | 
    
         
            -
                  def put(path:, **args)
         
     | 
| 
       18 
     | 
    
         
            -
                    rest_request(method: "PUT", path: path, **args)
         
     | 
| 
      
 19 
     | 
    
         
            +
                  def put(path:, **args, &block)
         
     | 
| 
      
 20 
     | 
    
         
            +
                    rest_request(method: "PUT", path: path, **args, &block)
         
     | 
| 
       19 
21 
     | 
    
         
             
                  end
         
     | 
| 
       20 
22 
     | 
    
         | 
| 
       21 
     | 
    
         
            -
                  def post(path:, **args)
         
     | 
| 
       22 
     | 
    
         
            -
                    rest_request(method: "POST", path: path, **args)
         
     | 
| 
      
 23 
     | 
    
         
            +
                  def post(path:, **args, &block)
         
     | 
| 
      
 24 
     | 
    
         
            +
                    rest_request(method: "POST", path: path, **args, &block)
         
     | 
| 
       23 
25 
     | 
    
         
             
                  end
         
     | 
| 
       24 
26 
     | 
    
         | 
| 
       25 
     | 
    
         
            -
                  def delete(path:, **args)
         
     | 
| 
       26 
     | 
    
         
            -
                    rest_request(method: "DELETE", path: path, **args)
         
     | 
| 
      
 27 
     | 
    
         
            +
                  def delete(path:, **args, &block)
         
     | 
| 
      
 28 
     | 
    
         
            +
                    rest_request(method: "DELETE", path: path, **args, &block)
         
     | 
| 
       27 
29 
     | 
    
         
             
                  end
         
     | 
| 
       28 
30 
     | 
    
         | 
| 
       29 
31 
     | 
    
         
             
                  def get_shop_or_abort # rubocop:disable Naming/AccessorMethodName
         
     | 
| 
       30 
32 
     | 
    
         
             
                    ShopifyCLI::AdminAPI.get_shop_or_abort(@ctx)
         
     | 
| 
       31 
33 
     | 
    
         
             
                  end
         
     | 
| 
       32 
34 
     | 
    
         | 
| 
       33 
     | 
    
         
            -
                  private
         
     | 
| 
       34 
     | 
    
         
            -
             
     | 
| 
       35 
35 
     | 
    
         
             
                  def rest_request(**args)
         
     | 
| 
       36 
     | 
    
         
            -
                    ShopifyCLI::AdminAPI.rest_request(
         
     | 
| 
      
 36 
     | 
    
         
            +
                    status, body, response = ShopifyCLI::AdminAPI.rest_request(
         
     | 
| 
       37 
37 
     | 
    
         
             
                      @ctx,
         
     | 
| 
       38 
38 
     | 
    
         
             
                      shop: @shop,
         
     | 
| 
       39 
39 
     | 
    
         
             
                      api_version: API_VERSION,
         
     | 
| 
       40 
40 
     | 
    
         
             
                      **args.compact
         
     | 
| 
       41 
41 
     | 
    
         
             
                    )
         
     | 
| 
      
 42 
     | 
    
         
            +
                    return yield(status, body, response) if block_given?
         
     | 
| 
      
 43 
     | 
    
         
            +
             
     | 
| 
      
 44 
     | 
    
         
            +
                    [status, body, response]
         
     | 
| 
       42 
45 
     | 
    
         
             
                  rescue ShopifyCLI::API::APIRequestForbiddenError,
         
     | 
| 
       43 
46 
     | 
    
         
             
                         ShopifyCLI::API::APIRequestUnauthorizedError => error
         
     | 
| 
       44 
47 
     | 
    
         | 
| 
         @@ -62,6 +65,8 @@ module ShopifyCLI 
     | 
|
| 
       62 
65 
     | 
    
         
             
                    raise error
         
     | 
| 
       63 
66 
     | 
    
         
             
                  end
         
     | 
| 
       64 
67 
     | 
    
         | 
| 
      
 68 
     | 
    
         
            +
                  private
         
     | 
| 
      
 69 
     | 
    
         
            +
             
     | 
| 
       65 
70 
     | 
    
         
             
                  def permission_error
         
     | 
| 
       66 
71 
     | 
    
         
             
                    ensure_user_error = @ctx.message("theme.ensure_user_error", shop)
         
     | 
| 
       67 
72 
     | 
    
         
             
                    ensure_user_try_this = @ctx.message("theme.ensure_user_try_this")
         
     | 
| 
         @@ -0,0 +1,102 @@ 
     | 
|
| 
      
 1 
     | 
    
         
            +
            # frozen_string_literal: true
         
     | 
| 
      
 2 
     | 
    
         
            +
             
     | 
| 
      
 3 
     | 
    
         
            +
            require_relative "bulk_job"
         
     | 
| 
      
 4 
     | 
    
         
            +
            require "shopify_cli/thread_pool"
         
     | 
| 
      
 5 
     | 
    
         
            +
             
     | 
| 
      
 6 
     | 
    
         
            +
            module ShopifyCLI
         
     | 
| 
      
 7 
     | 
    
         
            +
              module Theme
         
     | 
| 
      
 8 
     | 
    
         
            +
                class ThemeAdminAPIThrottler
         
     | 
| 
      
 9 
     | 
    
         
            +
                  class Bulk
         
     | 
| 
      
 10 
     | 
    
         
            +
                    MAX_BULK_BYTESIZE = 10_485_760 # 10MB
         
     | 
| 
      
 11 
     | 
    
         
            +
                    MAX_BULK_FILES = 20 # files
         
     | 
| 
      
 12 
     | 
    
         
            +
                    QUEUE_TIMEOUT = 0.2 # 200ms
         
     | 
| 
      
 13 
     | 
    
         
            +
             
     | 
| 
      
 14 
     | 
    
         
            +
                    attr_accessor :admin_api
         
     | 
| 
      
 15 
     | 
    
         
            +
             
     | 
| 
      
 16 
     | 
    
         
            +
                    def initialize(ctx, admin_api, pool_size: 20)
         
     | 
| 
      
 17 
     | 
    
         
            +
                      @ctx = ctx
         
     | 
| 
      
 18 
     | 
    
         
            +
                      @admin_api = admin_api
         
     | 
| 
      
 19 
     | 
    
         
            +
                      @latest_enqueued_at = now
         
     | 
| 
      
 20 
     | 
    
         
            +
             
     | 
| 
      
 21 
     | 
    
         
            +
                      @thread_pool = ShopifyCLI::ThreadPool.new(pool_size: pool_size)
         
     | 
| 
      
 22 
     | 
    
         
            +
             
     | 
| 
      
 23 
     | 
    
         
            +
                      pool_size.times do
         
     | 
| 
      
 24 
     | 
    
         
            +
                        @thread_pool.schedule(
         
     | 
| 
      
 25 
     | 
    
         
            +
                          BulkJob.new(ctx, self)
         
     | 
| 
      
 26 
     | 
    
         
            +
                        )
         
     | 
| 
      
 27 
     | 
    
         
            +
                      end
         
     | 
| 
      
 28 
     | 
    
         
            +
             
     | 
| 
      
 29 
     | 
    
         
            +
                      @put_requests = []
         
     | 
| 
      
 30 
     | 
    
         
            +
                      @mut = Mutex.new
         
     | 
| 
      
 31 
     | 
    
         
            +
                    end
         
     | 
| 
      
 32 
     | 
    
         
            +
             
     | 
| 
      
 33 
     | 
    
         
            +
                    def enqueue(put_request)
         
     | 
| 
      
 34 
     | 
    
         
            +
                      @mut.synchronize do
         
     | 
| 
      
 35 
     | 
    
         
            +
                        @latest_enqueued_at = now
         
     | 
| 
      
 36 
     | 
    
         
            +
                        @put_requests << put_request
         
     | 
| 
      
 37 
     | 
    
         
            +
                      end
         
     | 
| 
      
 38 
     | 
    
         
            +
                    end
         
     | 
| 
      
 39 
     | 
    
         
            +
             
     | 
| 
      
 40 
     | 
    
         
            +
                    def shutdown
         
     | 
| 
      
 41 
     | 
    
         
            +
                      wait_put_requests
         
     | 
| 
      
 42 
     | 
    
         
            +
                      @thread_pool.shutdown
         
     | 
| 
      
 43 
     | 
    
         
            +
                    end
         
     | 
| 
      
 44 
     | 
    
         
            +
             
     | 
| 
      
 45 
     | 
    
         
            +
                    def consume_put_requests
         
     | 
| 
      
 46 
     | 
    
         
            +
                      to_batch = []
         
     | 
| 
      
 47 
     | 
    
         
            +
                      to_batch_size_bytes = 0
         
     | 
| 
      
 48 
     | 
    
         
            +
                      @mut.synchronize do
         
     | 
| 
      
 49 
     | 
    
         
            +
                        # sort requests to perform less retries at the `bulk_job` level
         
     | 
| 
      
 50 
     | 
    
         
            +
                        @put_requests.sort_by! { |r| r.liquid? ? 0 : 1 }
         
     | 
| 
      
 51 
     | 
    
         
            +
             
     | 
| 
      
 52 
     | 
    
         
            +
                        is_ready = false
         
     | 
| 
      
 53 
     | 
    
         
            +
                        until is_ready || @put_requests.empty?
         
     | 
| 
      
 54 
     | 
    
         
            +
                          request = @put_requests.first
         
     | 
| 
      
 55 
     | 
    
         
            +
                          if to_batch.empty? && request.size > MAX_BULK_BYTESIZE
         
     | 
| 
      
 56 
     | 
    
         
            +
                            is_ready = true
         
     | 
| 
      
 57 
     | 
    
         
            +
                            to_batch << request
         
     | 
| 
      
 58 
     | 
    
         
            +
                            to_batch_size_bytes += request.size
         
     | 
| 
      
 59 
     | 
    
         
            +
                            @put_requests.shift
         
     | 
| 
      
 60 
     | 
    
         
            +
                          elsif to_batch.size + 1 > MAX_BULK_FILES || to_batch_size_bytes + request.size > MAX_BULK_BYTESIZE
         
     | 
| 
      
 61 
     | 
    
         
            +
                            is_ready = true
         
     | 
| 
      
 62 
     | 
    
         
            +
                          else
         
     | 
| 
      
 63 
     | 
    
         
            +
                            to_batch << request
         
     | 
| 
      
 64 
     | 
    
         
            +
                            to_batch_size_bytes += request.size
         
     | 
| 
      
 65 
     | 
    
         
            +
                            @put_requests.shift
         
     | 
| 
      
 66 
     | 
    
         
            +
                          end
         
     | 
| 
      
 67 
     | 
    
         
            +
                        end
         
     | 
| 
      
 68 
     | 
    
         
            +
                      end
         
     | 
| 
      
 69 
     | 
    
         
            +
                      [to_batch, to_batch_size_bytes]
         
     | 
| 
      
 70 
     | 
    
         
            +
                    end
         
     | 
| 
      
 71 
     | 
    
         
            +
             
     | 
| 
      
 72 
     | 
    
         
            +
                    def ready?
         
     | 
| 
      
 73 
     | 
    
         
            +
                      queue_timeout? || bulk_size >= MAX_BULK_FILES || bulk_bytesize >= MAX_BULK_BYTESIZE
         
     | 
| 
      
 74 
     | 
    
         
            +
                    end
         
     | 
| 
      
 75 
     | 
    
         
            +
             
     | 
| 
      
 76 
     | 
    
         
            +
                    def bulk_bytesize
         
     | 
| 
      
 77 
     | 
    
         
            +
                      @put_requests.map(&:size).reduce(:+).to_i
         
     | 
| 
      
 78 
     | 
    
         
            +
                    end
         
     | 
| 
      
 79 
     | 
    
         
            +
             
     | 
| 
      
 80 
     | 
    
         
            +
                    private
         
     | 
| 
      
 81 
     | 
    
         
            +
             
     | 
| 
      
 82 
     | 
    
         
            +
                    def bulk_size
         
     | 
| 
      
 83 
     | 
    
         
            +
                      @put_requests.size
         
     | 
| 
      
 84 
     | 
    
         
            +
                    end
         
     | 
| 
      
 85 
     | 
    
         
            +
             
     | 
| 
      
 86 
     | 
    
         
            +
                    def queue_timeout?
         
     | 
| 
      
 87 
     | 
    
         
            +
                      return false if bulk_size.zero?
         
     | 
| 
      
 88 
     | 
    
         
            +
                      elapsed_time = now - @latest_enqueued_at
         
     | 
| 
      
 89 
     | 
    
         
            +
                      elapsed_time > QUEUE_TIMEOUT
         
     | 
| 
      
 90 
     | 
    
         
            +
                    end
         
     | 
| 
      
 91 
     | 
    
         
            +
             
     | 
| 
      
 92 
     | 
    
         
            +
                    def wait_put_requests
         
     | 
| 
      
 93 
     | 
    
         
            +
                      sleep(0.2) until @put_requests.empty?
         
     | 
| 
      
 94 
     | 
    
         
            +
                    end
         
     | 
| 
      
 95 
     | 
    
         
            +
             
     | 
| 
      
 96 
     | 
    
         
            +
                    def now
         
     | 
| 
      
 97 
     | 
    
         
            +
                      Time.now.to_f
         
     | 
| 
      
 98 
     | 
    
         
            +
                    end
         
     | 
| 
      
 99 
     | 
    
         
            +
                  end
         
     | 
| 
      
 100 
     | 
    
         
            +
                end
         
     | 
| 
      
 101 
     | 
    
         
            +
              end
         
     | 
| 
      
 102 
     | 
    
         
            +
            end
         
     |