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
|