shopify-cli 2.18.1 → 2.20.1
Sign up to get free protection for your applications and to get access to all the features.
- 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
|