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.
Files changed (45) hide show
  1. checksums.yaml +4 -4
  2. data/.github/ISSUE_TEMPLATE/bug_report.yaml +2 -1
  3. data/.github/ISSUE_TEMPLATE/config.yml +9 -0
  4. data/.github/workflows/cla.yml +22 -0
  5. data/CHANGELOG.md +23 -2
  6. data/Gemfile.lock +4 -4
  7. data/README.md +7 -6
  8. data/dev.yml +0 -1
  9. data/docs/users/installation.md +1 -1
  10. data/lib/project_types/extension/messages/messages.rb +1 -1
  11. data/lib/project_types/extension/models/development_server_requirements.rb +1 -6
  12. data/lib/project_types/extension/tasks/fetch_specifications.rb +4 -1
  13. data/lib/project_types/script/commands/create.rb +1 -1
  14. data/lib/project_types/script/config/extension_points.yml +15 -15
  15. data/lib/project_types/script/forms/ask_app.rb +0 -5
  16. data/lib/project_types/script/layers/domain/metadata.rb +3 -5
  17. data/lib/project_types/script/layers/infrastructure/script_service.rb +1 -1
  18. data/lib/project_types/theme/commands/push.rb +3 -1
  19. data/lib/project_types/theme/commands/serve.rb +1 -0
  20. data/lib/project_types/theme/messages/messages.rb +39 -2
  21. data/lib/shopify_cli/assets/post_auth_page/index.html.erb +34 -0
  22. data/lib/shopify_cli/assets/post_auth_page/style.css +58 -0
  23. data/lib/shopify_cli/identity_auth/servlet.rb +4 -20
  24. data/lib/shopify_cli/messages/messages.rb +6 -8
  25. data/lib/shopify_cli/theme/dev_server/hot-reload-no-script.html +27 -0
  26. data/lib/shopify_cli/theme/dev_server/hot-reload.js +16 -4
  27. data/lib/shopify_cli/theme/dev_server/hot_reload.rb +2 -0
  28. data/lib/shopify_cli/theme/dev_server.rb +3 -2
  29. data/lib/shopify_cli/theme/file.rb +5 -0
  30. data/lib/shopify_cli/theme/syncer/json_update_handler.rb +21 -7
  31. data/lib/shopify_cli/theme/syncer/operation.rb +7 -6
  32. data/lib/shopify_cli/theme/syncer/unsupported_script_warning.rb +90 -0
  33. data/lib/shopify_cli/theme/syncer.rb +81 -31
  34. data/lib/shopify_cli/theme/theme_admin_api.rb +16 -11
  35. data/lib/shopify_cli/theme/theme_admin_api_throttler/bulk.rb +102 -0
  36. data/lib/shopify_cli/theme/theme_admin_api_throttler/bulk_job.rb +75 -0
  37. data/lib/shopify_cli/theme/theme_admin_api_throttler/errors.rb +7 -0
  38. data/lib/shopify_cli/theme/theme_admin_api_throttler/put_request.rb +52 -0
  39. data/lib/shopify_cli/theme/theme_admin_api_throttler/request_parser.rb +39 -0
  40. data/lib/shopify_cli/theme/theme_admin_api_throttler/response_parser.rb +21 -0
  41. data/lib/shopify_cli/theme/theme_admin_api_throttler.rb +62 -0
  42. data/lib/shopify_cli/version.rb +1 -1
  43. data/shopify-cli.gemspec +1 -1
  44. metadata +18 -6
  45. 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| !ignore_file?(file) && file.exist? && checksums.file_has_changed?(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
- status_color = COLOR_BY_STATUS[status]
45
- status_text = @ctx.message("theme.serve.operation.status.#{status}").ljust(6)
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} {{#{status_color}:#{status_text}}} {{>}} {{blue:#{self}}}"
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
- def api_client
69
- @api_client ||= ThemeAdminAPI.new(@ctx, @theme.shop)
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
- response = send(operation.method, operation.file)
257
+ send(operation.method, operation.file) do |response|
258
+ raise response if response.is_a?(StandardError)
246
259
 
247
- @standard_reporter.report(operation.as_synced_message)
260
+ file = operation.file
248
261
 
249
- # Check if the API told us we're near the rate limit
250
- if !backingoff? && (limit = response["x-shopify-shop-api-call-limit"])
251
- used, total = limit.split("/").map(&:to_i)
252
- backoff_if_near_limit!(used, total)
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 ShopifyCLI::API::APIRequestError => e
255
- error_suffix = ":\n " + parse_api_errors(e).join("\n ")
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
- _status, body, response = api_client.put(
270
- path: "themes/#{@theme.id}/assets.json",
271
- body: JSON.generate(asset: asset)
272
- )
298
+ path = "themes/#{@theme.id}/assets.json"
299
+ req_body = JSON.generate(asset: asset)
273
300
 
274
- update_checksums(body)
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
- response
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 = JSON.parse(exception&.response&.body)
340
- message = parsed_body.dig("errors", "asset") || parsed_body["message"] || exception.message
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 { sleep(2) }
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