shopify-cli 1.13.1 → 2.1.0

Sign up to get free protection for your applications and to get access to all the features.
Files changed (200) hide show
  1. checksums.yaml +4 -4
  2. data/.github/CODEOWNERS +1 -1
  3. data/.github/CONTRIBUTING.md +7 -7
  4. data/.github/DESIGN.md +3 -3
  5. data/.github/workflows/build.yml +1 -1
  6. data/.gitignore +3 -0
  7. data/.rubocop.yml +3 -1
  8. data/.ruby-version +1 -1
  9. data/CHANGELOG.md +60 -26
  10. data/Gemfile +4 -0
  11. data/Gemfile.lock +32 -0
  12. data/LICENSE +4 -1
  13. data/README.md +94 -26
  14. data/RELEASING.md +31 -7
  15. data/Rakefile +2 -2
  16. data/SECURITY.md +1 -1
  17. data/THEMEKIT_MIGRATION.md +18 -0
  18. data/bin/load_shopify.rb +1 -1
  19. data/bin/shopify +3 -3
  20. data/dev.yml +1 -1
  21. data/docs/app/node/index.md +1 -1
  22. data/docs/app/rails/index.md +1 -1
  23. data/docs/core/index.md +1 -1
  24. data/docs/getting-started/index.md +1 -1
  25. data/docs/getting-started/install/index.md +1 -1
  26. data/docs/getting-started/migrate/index.md +1 -1
  27. data/docs/getting-started/uninstall/index.md +1 -1
  28. data/docs/getting-started/upgrade/index.md +1 -1
  29. data/docs/help/start-app/index.md +1 -1
  30. data/docs/index.md +1 -1
  31. data/ext/shopify-cli/extconf.rb +17 -5
  32. data/install.sh +1 -1
  33. data/lib/docgen/index_template.md.erb +2 -2
  34. data/lib/graphql/all_orgs_with_extensions.graphql +37 -0
  35. data/lib/graphql/api_versions.graphql +1 -1
  36. data/lib/graphql/find_organization.graphql +2 -1
  37. data/lib/project_types/extension/cli.rb +19 -15
  38. data/lib/project_types/extension/commands/build.rb +4 -5
  39. data/lib/project_types/extension/commands/check.rb +44 -0
  40. data/lib/project_types/extension/commands/connect.rb +35 -0
  41. data/lib/project_types/extension/commands/create.rb +12 -16
  42. data/lib/project_types/extension/commands/extension_command.rb +2 -2
  43. data/lib/project_types/extension/commands/info.rb +86 -0
  44. data/lib/project_types/extension/commands/push.rb +8 -7
  45. data/lib/project_types/extension/commands/register.rb +4 -5
  46. data/lib/project_types/extension/commands/serve.rb +5 -8
  47. data/lib/project_types/extension/commands/tunnel.rb +3 -1
  48. data/lib/project_types/extension/errors.rb +9 -0
  49. data/lib/project_types/extension/extension_project.rb +24 -1
  50. data/lib/project_types/extension/extension_project_keys.rb +1 -0
  51. data/lib/project_types/extension/features/argo.rb +6 -6
  52. data/lib/project_types/extension/features/argo_runtime.rb +22 -66
  53. data/lib/project_types/extension/features/argo_serve.rb +25 -18
  54. data/lib/project_types/extension/forms/connect.rb +42 -0
  55. data/lib/project_types/extension/forms/questions/ask_name.rb +14 -6
  56. data/lib/project_types/extension/forms/questions/ask_registration.rb +51 -0
  57. data/lib/project_types/extension/messages/messages.rb +84 -16
  58. data/lib/project_types/extension/models/specification.rb +1 -0
  59. data/lib/project_types/extension/models/specification_handlers/{checkout_argo_extension.rb → checkout_ui_extension.rb} +3 -1
  60. data/lib/project_types/extension/models/specification_handlers/default.rb +13 -3
  61. data/lib/project_types/extension/models/specification_handlers/theme_app_extension.rb +89 -0
  62. data/lib/project_types/extension/models/specifications.rb +1 -0
  63. data/lib/project_types/extension/tasks/configure_features.rb +6 -7
  64. data/lib/project_types/extension/tasks/configure_options.rb +20 -0
  65. data/lib/project_types/extension/tasks/get_extensions.rb +32 -0
  66. data/lib/project_types/node/cli.rb +9 -21
  67. data/lib/project_types/node/commands/connect.rb +8 -2
  68. data/lib/project_types/node/commands/create.rb +9 -5
  69. data/lib/project_types/node/commands/deploy.rb +15 -5
  70. data/lib/project_types/node/commands/deploy/heroku.rb +29 -29
  71. data/lib/project_types/node/commands/generate.rb +4 -2
  72. data/lib/project_types/node/commands/open.rb +4 -2
  73. data/lib/project_types/node/commands/serve.rb +3 -2
  74. data/lib/project_types/node/commands/tunnel.rb +4 -2
  75. data/lib/project_types/node/messages/messages.rb +47 -90
  76. data/lib/project_types/rails/cli.rb +9 -21
  77. data/lib/project_types/rails/commands/connect.rb +8 -2
  78. data/lib/project_types/rails/commands/create.rb +10 -6
  79. data/lib/project_types/rails/commands/deploy.rb +15 -5
  80. data/lib/project_types/rails/commands/deploy/heroku.rb +84 -82
  81. data/lib/project_types/rails/commands/generate.rb +15 -5
  82. data/lib/project_types/rails/commands/generate/webhook.rb +28 -26
  83. data/lib/project_types/rails/commands/open.rb +4 -2
  84. data/lib/project_types/rails/commands/serve.rb +3 -2
  85. data/lib/project_types/rails/commands/tunnel.rb +4 -2
  86. data/lib/project_types/rails/messages/messages.rb +72 -119
  87. data/lib/project_types/script/cli.rb +6 -8
  88. data/lib/project_types/script/commands/create.rb +3 -1
  89. data/lib/project_types/script/commands/push.rb +7 -4
  90. data/lib/project_types/script/graphql/app_script_update_or_create.graphql +9 -3
  91. data/lib/project_types/script/layers/application/create_script.rb +4 -3
  92. data/lib/project_types/script/layers/domain/errors.rb +6 -11
  93. data/lib/project_types/script/layers/domain/push_package.rb +4 -8
  94. data/lib/project_types/script/layers/domain/script_json.rb +32 -0
  95. data/lib/project_types/script/layers/domain/script_project.rb +1 -1
  96. data/lib/project_types/script/layers/infrastructure/errors.rb +13 -17
  97. data/lib/project_types/script/layers/infrastructure/languages/assemblyscript_project_creator.rb +29 -21
  98. data/lib/project_types/script/layers/infrastructure/push_package_repository.rb +2 -4
  99. data/lib/project_types/script/layers/infrastructure/script_project_repository.rb +45 -34
  100. data/lib/project_types/script/layers/infrastructure/script_service.rb +37 -16
  101. data/lib/project_types/script/messages/messages.rb +64 -54
  102. data/lib/project_types/script/tasks/ensure_env.rb +3 -1
  103. data/lib/project_types/script/ui/error_handler.rb +32 -32
  104. data/lib/project_types/theme/cli.rb +16 -27
  105. data/lib/project_types/theme/commands/check.rb +33 -0
  106. data/lib/project_types/theme/commands/delete.rb +64 -0
  107. data/lib/project_types/theme/commands/init.rb +42 -0
  108. data/lib/project_types/theme/commands/language_server.rb +16 -0
  109. data/lib/project_types/theme/commands/package.rb +55 -0
  110. data/lib/project_types/theme/commands/publish.rb +43 -0
  111. data/lib/project_types/theme/commands/pull.rb +51 -0
  112. data/lib/project_types/theme/commands/push.rb +58 -32
  113. data/lib/project_types/theme/commands/serve.rb +8 -16
  114. data/lib/project_types/theme/forms/confirm_store.rb +15 -0
  115. data/lib/project_types/theme/forms/select.rb +59 -0
  116. data/lib/project_types/theme/messages/messages.rb +118 -103
  117. data/lib/project_types/theme/ui/sync_progress_bar.rb +20 -0
  118. data/lib/shopify-cli/admin_api.rb +57 -38
  119. data/lib/shopify-cli/admin_api/populate_resource_command.rb +6 -14
  120. data/lib/shopify-cli/admin_api/schema.rb +1 -10
  121. data/lib/shopify-cli/api.rb +29 -14
  122. data/lib/shopify-cli/command.rb +15 -3
  123. data/lib/shopify-cli/commands.rb +7 -2
  124. data/lib/shopify-cli/commands/help.rb +2 -29
  125. data/lib/shopify-cli/commands/login.rb +95 -0
  126. data/lib/shopify-cli/commands/logout.rb +24 -8
  127. data/lib/shopify-cli/commands/populate.rb +23 -0
  128. data/lib/{project_types/node → shopify-cli}/commands/populate/customer.rb +2 -8
  129. data/lib/{project_types/node → shopify-cli}/commands/populate/draft_order.rb +2 -2
  130. data/lib/{project_types/node → shopify-cli}/commands/populate/product.rb +2 -8
  131. data/lib/shopify-cli/commands/store.rb +15 -0
  132. data/lib/shopify-cli/commands/switch.rb +39 -0
  133. data/lib/shopify-cli/commands/system.rb +12 -0
  134. data/lib/shopify-cli/commands/whoami.rb +28 -0
  135. data/lib/shopify-cli/connect.rb +32 -0
  136. data/lib/shopify-cli/context.rb +65 -4
  137. data/lib/shopify-cli/core/entry_point.rb +3 -22
  138. data/lib/shopify-cli/core/monorail.rb +6 -2
  139. data/lib/shopify-cli/db.rb +4 -4
  140. data/lib/shopify-cli/http_request.rb +16 -0
  141. data/lib/shopify-cli/identity_auth.rb +282 -0
  142. data/lib/shopify-cli/{oauth → identity_auth}/servlet.rb +11 -12
  143. data/lib/shopify-cli/messages/messages.rb +140 -46
  144. data/lib/shopify-cli/packager.rb +5 -5
  145. data/lib/shopify-cli/partners_api.rb +21 -44
  146. data/lib/shopify-cli/partners_api/organizations.rb +8 -0
  147. data/lib/shopify-cli/project_commands.rb +16 -0
  148. data/lib/shopify-cli/project_type.rb +0 -31
  149. data/lib/shopify-cli/shopifolk.rb +8 -11
  150. data/lib/shopify-cli/sub_command.rb +1 -0
  151. data/lib/shopify-cli/tasks.rb +3 -0
  152. data/lib/shopify-cli/tasks/confirm_store.rb +18 -0
  153. data/lib/shopify-cli/tasks/create_api_client.rb +2 -2
  154. data/lib/shopify-cli/tasks/ensure_authenticated.rb +13 -0
  155. data/lib/shopify-cli/tasks/ensure_loopback_url.rb +1 -1
  156. data/lib/shopify-cli/tasks/ensure_project_type.rb +12 -0
  157. data/lib/shopify-cli/tasks/select_org_and_shop.rb +0 -3
  158. data/lib/shopify-cli/theme/dev_server.rb +98 -0
  159. data/lib/shopify-cli/theme/dev_server/certificate_manager.rb +79 -0
  160. data/lib/shopify-cli/theme/dev_server/header_hash.rb +94 -0
  161. data/lib/shopify-cli/theme/dev_server/hot-reload.js +93 -0
  162. data/lib/shopify-cli/theme/dev_server/hot_reload.rb +76 -0
  163. data/lib/shopify-cli/theme/dev_server/local_assets.rb +87 -0
  164. data/lib/shopify-cli/theme/dev_server/proxy.rb +205 -0
  165. data/lib/shopify-cli/theme/dev_server/sse.rb +75 -0
  166. data/lib/shopify-cli/theme/dev_server/watcher.rb +59 -0
  167. data/lib/shopify-cli/theme/dev_server/web_server.rb +140 -0
  168. data/lib/shopify-cli/theme/development_theme.rb +69 -0
  169. data/lib/shopify-cli/theme/file.rb +112 -0
  170. data/lib/shopify-cli/theme/ignore_filter.rb +109 -0
  171. data/lib/shopify-cli/theme/mime_type.rb +34 -0
  172. data/lib/shopify-cli/theme/syncer.rb +332 -0
  173. data/lib/shopify-cli/theme/theme.rb +204 -0
  174. data/lib/shopify-cli/tunnel.rb +1 -1
  175. data/lib/shopify-cli/version.rb +1 -1
  176. data/lib/shopify_cli.rb +18 -11
  177. data/shopify-cli.gemspec +12 -5
  178. data/shopify.fish +1 -1
  179. data/shopify.sh +1 -1
  180. metadata +92 -35
  181. data/.github/workflows/release.yml +0 -59
  182. data/lib/project_types/extension/features/argo_serve_options.rb +0 -42
  183. data/lib/project_types/node/commands/populate.rb +0 -23
  184. data/lib/project_types/rails/commands/populate.rb +0 -23
  185. data/lib/project_types/rails/commands/populate/customer.rb +0 -31
  186. data/lib/project_types/rails/commands/populate/draft_order.rb +0 -28
  187. data/lib/project_types/rails/commands/populate/product.rb +0 -30
  188. data/lib/project_types/script/layers/domain/config_ui.rb +0 -16
  189. data/lib/project_types/theme/commands/connect.rb +0 -54
  190. data/lib/project_types/theme/commands/create.rb +0 -48
  191. data/lib/project_types/theme/commands/deploy.rb +0 -38
  192. data/lib/project_types/theme/commands/generate.rb +0 -20
  193. data/lib/project_types/theme/commands/generate/env.rb +0 -79
  194. data/lib/project_types/theme/forms/connect.rb +0 -34
  195. data/lib/project_types/theme/forms/create.rb +0 -22
  196. data/lib/project_types/theme/tasks/ensure_themekit_installed.rb +0 -78
  197. data/lib/project_types/theme/themekit.rb +0 -113
  198. data/lib/shopify-cli/commands/connect.rb +0 -64
  199. data/lib/shopify-cli/commands/create.rb +0 -50
  200. data/lib/shopify-cli/oauth.rb +0 -198
@@ -0,0 +1,109 @@
1
+ # frozen_string_literal: true
2
+
3
+ module ShopifyCli
4
+ module Theme
5
+ class IgnoreFilter
6
+ FILE = ".shopifyignore"
7
+
8
+ DEFAULT_REGEXES = [
9
+ /\.git/,
10
+ /\.hg/,
11
+ /\.bzr/,
12
+ /\.svn/,
13
+ /_darcs/,
14
+ /CVS/,
15
+ /\.sublime-(project|workspace)/,
16
+ /\.DS_Store/,
17
+ /\.sass-cache/,
18
+ /Thumbs\.db/,
19
+ /desktop\.ini/,
20
+ /config.yml/,
21
+ /node_modules/,
22
+ ].freeze
23
+
24
+ DEFAULT_GLOBS = [].freeze
25
+
26
+ attr_reader :root, :globs, :regexes
27
+
28
+ def self.from_path(root)
29
+ root = Pathname.new(root)
30
+ ignore_file = root.join(FILE)
31
+ patterns = if ignore_file.file?
32
+ parse_ignore_file(ignore_file)
33
+ else
34
+ []
35
+ end
36
+ new(root, patterns: patterns)
37
+ end
38
+
39
+ def self.parse_ignore_file(file)
40
+ patterns = []
41
+
42
+ file.each_line do |line|
43
+ line.strip!
44
+
45
+ next if line.empty? || line.start_with?("#")
46
+
47
+ patterns << line
48
+ end
49
+
50
+ patterns
51
+ end
52
+
53
+ def initialize(root, patterns: [])
54
+ @root = root
55
+
56
+ regexes, globs = patterns_to_regexes_and_globs(patterns)
57
+
58
+ @regexes = regexes
59
+ @globs = globs
60
+ end
61
+
62
+ def match?(path)
63
+ path = path.to_s
64
+
65
+ return true if path.empty?
66
+
67
+ regexes.each do |regex|
68
+ return true if regex.match(path)
69
+ end
70
+
71
+ globs.each do |glob|
72
+ return true if ::File.fnmatch?(glob, path)
73
+ end
74
+
75
+ false
76
+ end
77
+ alias_method :ignore?, :match?
78
+
79
+ private
80
+
81
+ # Take in string patterns and convert them to either
82
+ # regex patterns or glob patterns so that they are handled in an expected manner.
83
+ def patterns_to_regexes_and_globs(patterns)
84
+ new_regexes = DEFAULT_REGEXES.dup
85
+ new_globs = DEFAULT_GLOBS.dup
86
+
87
+ patterns.each do |pattern|
88
+ pattern = pattern.strip
89
+
90
+ if pattern.start_with?("/") && pattern.end_with?("/")
91
+ new_regexes << Regexp.new(pattern.gsub(%r{^\/|\/$}, ""))
92
+ next
93
+ end
94
+
95
+ # if specifying a directory, match everything below it
96
+ pattern += "*" if pattern.end_with?("/")
97
+
98
+ # The pattern will be scoped to root directory, so it should match anything
99
+ # within that space
100
+ pattern.prepend("*") unless pattern.start_with?("*")
101
+
102
+ new_globs << pattern
103
+ end
104
+
105
+ [new_regexes, new_globs]
106
+ end
107
+ end
108
+ end
109
+ end
@@ -0,0 +1,34 @@
1
+ # frozen_string_literal: true
2
+ require "webrick"
3
+
4
+ module ShopifyCli
5
+ module Theme
6
+ class MimeType < Struct.new(:name)
7
+ MIME_TYPES = WEBrick::HTTPUtils::DefaultMimeTypes.merge(
8
+ "liquid" => "text/x-liquid",
9
+ )
10
+
11
+ class << self
12
+ def by_filename(filename)
13
+ new(WEBrick::HTTPUtils.mime_type(filename.to_s, MIME_TYPES))
14
+ end
15
+ end
16
+
17
+ def text?
18
+ /text/.match?(name) || json? || javascript?
19
+ end
20
+
21
+ def json?
22
+ name == "application/json"
23
+ end
24
+
25
+ def javascript?
26
+ name == "application/javascript"
27
+ end
28
+
29
+ def to_s
30
+ name
31
+ end
32
+ end
33
+ end
34
+ end
@@ -0,0 +1,332 @@
1
+ # frozen_string_literal: true
2
+ require "thread"
3
+ require "json"
4
+ require "base64"
5
+
6
+ module ShopifyCli
7
+ module Theme
8
+ class Syncer
9
+ class Operation < Struct.new(:method, :file)
10
+ def to_s
11
+ "#{method} #{file&.relative_path}"
12
+ end
13
+ end
14
+ API_VERSION = "unstable"
15
+
16
+ attr_reader :checksums
17
+ attr_accessor :ignore_filter
18
+
19
+ def initialize(ctx, theme:, ignore_filter: nil)
20
+ @ctx = ctx
21
+ @theme = theme
22
+ @ignore_filter = ignore_filter
23
+
24
+ # Queue of `Operation`s waiting to be picked up from a thread for processing.
25
+ @queue = Queue.new
26
+ # `Operation`s will be removed from this Array completed.
27
+ @pending = []
28
+ # Thread making the API requests.
29
+ @threads = []
30
+ # Mutex used to pause all threads when backing-off when hitting API rate limits
31
+ @backoff_mutex = Mutex.new
32
+
33
+ # Allows delaying log of errors, mainly to not break the progress bar.
34
+ @delay_errors = false
35
+ @delayed_errors = []
36
+
37
+ # Latest theme assets checksums. Updated on each upload.
38
+ @checksums = {}
39
+ end
40
+
41
+ def enqueue_updates(files)
42
+ files.each { |file| enqueue(:update, file) }
43
+ end
44
+
45
+ def enqueue_get(files)
46
+ files.each { |file| enqueue(:get, file) }
47
+ end
48
+
49
+ def enqueue_deletes(files)
50
+ files.each { |file| enqueue(:delete, file) }
51
+ end
52
+
53
+ def size
54
+ @pending.size
55
+ end
56
+
57
+ def empty?
58
+ @pending.empty?
59
+ end
60
+
61
+ def pending_updates
62
+ @pending.select { |op| op.method == :update }.map(&:file)
63
+ end
64
+
65
+ def remote_file?(file)
66
+ checksums.key?(@theme[file].relative_path.to_s)
67
+ end
68
+
69
+ def wait!
70
+ raise ThreadError, "No syncer threads" if @threads.empty?
71
+ total = size
72
+ last_size = size
73
+ until empty? || @queue.closed?
74
+ if block_given? && last_size != size
75
+ yield size, total
76
+ last_size = size
77
+ end
78
+ Thread.pass
79
+ end
80
+ end
81
+
82
+ def fetch_checksums!
83
+ _status, response = ShopifyCli::AdminAPI.rest_request(
84
+ @ctx,
85
+ shop: @theme.shop,
86
+ path: "themes/#{@theme.id}/assets.json",
87
+ api_version: API_VERSION,
88
+ )
89
+ update_checksums(response)
90
+ end
91
+
92
+ def shutdown
93
+ @queue.close unless @queue.closed?
94
+ ensure
95
+ @threads.each { |thread| thread.join if thread.alive? }
96
+ end
97
+
98
+ def start_threads(count = 2)
99
+ count.times do
100
+ @threads << Thread.new do
101
+ loop do
102
+ operation = @queue.pop
103
+ break if operation.nil? # shutdown was called
104
+ perform(operation)
105
+ rescue Exception => e
106
+ report_error(
107
+ "{{red:ERROR}} {{blue:#{operation}}}: #{e}" +
108
+ (@ctx.debug? ? "\n\t#{e.backtrace.join("\n\t")}" : "")
109
+ )
110
+ end
111
+ end
112
+ end
113
+ end
114
+
115
+ def delay_errors!
116
+ @delay_errors = true
117
+ end
118
+
119
+ def report_errors!
120
+ @delay_errors = false
121
+ @delayed_errors.each { |error| report_error(error) }
122
+ @delayed_errors.clear
123
+ end
124
+
125
+ def upload_theme!(delay_low_priority_files: false, delete: true, &block)
126
+ fetch_checksums!
127
+
128
+ if delete
129
+ # Delete remote files not present locally
130
+ removed_files = checksums.keys - @theme.theme_files.map { |file| file.relative_path.to_s }
131
+ enqueue_deletes(removed_files)
132
+ end
133
+
134
+ # Some files must be uploaded after the other ones
135
+ delayed_config_files = [
136
+ @theme["config/settings_schema.json"],
137
+ @theme["config/settings_data.json"],
138
+ ]
139
+
140
+ enqueue_updates(@theme.liquid_files)
141
+ enqueue_updates(@theme.json_files - delayed_config_files)
142
+ enqueue_updates(delayed_config_files)
143
+
144
+ if delay_low_priority_files
145
+ # Wait for liquid & JSON files to upload, because those are rendered remotely
146
+ wait!(&block)
147
+ end
148
+
149
+ # Process lower-priority files in the background
150
+
151
+ # Assets are served locally, so can be uploaded in the background
152
+ enqueue_updates(@theme.static_asset_files)
153
+
154
+ unless delay_low_priority_files
155
+ wait!(&block)
156
+ end
157
+ end
158
+
159
+ def download_theme!(delete: true, &block)
160
+ fetch_checksums!
161
+
162
+ if delete
163
+ # Delete local files not present remotely
164
+ missing_files = @theme.theme_files
165
+ .reject { |file| checksums.key?(file.relative_path.to_s) }.uniq
166
+ .reject { |file| @ignore_filter&.ignore?(file) }
167
+ missing_files.each do |file|
168
+ @ctx.debug("rm #{file.relative_path}")
169
+ file.delete
170
+ end
171
+ end
172
+
173
+ enqueue_get(checksums.keys)
174
+
175
+ wait!(&block)
176
+ end
177
+
178
+ private
179
+
180
+ def enqueue(method, file)
181
+ raise ArgumentError, "file required" unless file
182
+
183
+ operation = Operation.new(method, @theme[file])
184
+
185
+ # Already enqueued
186
+ return if @pending.include?(operation)
187
+
188
+ if @ignore_filter&.ignore?(operation.file.relative_path)
189
+ @ctx.debug("ignore #{operation.file.relative_path}")
190
+ return
191
+ end
192
+
193
+ if [:update, :get].include?(method) && operation.file.exist? && !file_has_changed?(operation.file)
194
+ @ctx.debug("skip #{operation}")
195
+ return
196
+ end
197
+
198
+ @pending << operation
199
+ @queue << operation unless @queue.closed?
200
+ end
201
+
202
+ def perform(operation)
203
+ return if @queue.closed?
204
+ wait_for_backoff!
205
+ @ctx.debug(operation.to_s)
206
+
207
+ response = send(operation.method, operation.file)
208
+
209
+ # Check if the API told us we're near the rate limit
210
+ if !backingoff? && (limit = response["x-shopify-shop-api-call-limit"])
211
+ used, total = limit.split("/").map(&:to_i)
212
+ backoff_if_near_limit!(used, total)
213
+ end
214
+ rescue ShopifyCli::API::APIRequestError => e
215
+ report_error(
216
+ "{{red:ERROR}} {{blue:#{operation}}}:\n " +
217
+ parse_api_errors(e).join("\n ")
218
+ )
219
+ ensure
220
+ @pending.delete(operation)
221
+ end
222
+
223
+ def update(file)
224
+ asset = { key: file.relative_path.to_s }
225
+ if file.text?
226
+ asset[:value] = file.read
227
+ else
228
+ asset[:attachment] = Base64.encode64(file.read)
229
+ end
230
+
231
+ _status, body, response = ShopifyCli::AdminAPI.rest_request(
232
+ @ctx,
233
+ shop: @theme.shop,
234
+ path: "themes/#{@theme.id}/assets.json",
235
+ method: "PUT",
236
+ api_version: API_VERSION,
237
+ body: JSON.generate(asset: asset)
238
+ )
239
+
240
+ update_checksums(body)
241
+
242
+ response
243
+ end
244
+
245
+ def get(file)
246
+ _status, body, response = ShopifyCli::AdminAPI.rest_request(
247
+ @ctx,
248
+ shop: @theme.shop,
249
+ path: "themes/#{@theme.id}/assets.json",
250
+ method: "GET",
251
+ api_version: API_VERSION,
252
+ query: URI.encode_www_form("asset[key]" => file.relative_path.to_s),
253
+ )
254
+
255
+ update_checksums(body)
256
+
257
+ attachment = body.dig("asset", "attachment")
258
+ value = if attachment
259
+ file.write(Base64.decode64(attachment), 0, mode: "wb")
260
+ else
261
+ file.write(body.dig("asset", "value"))
262
+ end
263
+
264
+ response
265
+ end
266
+
267
+ def delete(file)
268
+ _status, _body, response = ShopifyCli::AdminAPI.rest_request(
269
+ @ctx,
270
+ shop: @theme.shop,
271
+ path: "themes/#{@theme.id}/assets.json",
272
+ method: "DELETE",
273
+ api_version: API_VERSION,
274
+ body: JSON.generate(asset: {
275
+ key: file.relative_path.to_s
276
+ })
277
+ )
278
+
279
+ response
280
+ end
281
+
282
+ def update_checksums(api_response)
283
+ api_response.values.flatten.each do |asset|
284
+ if asset["key"]
285
+ @checksums[asset["key"]] = asset["checksum"]
286
+ end
287
+ end
288
+ # Generate .liquid asset files are reported twice in checksum:
289
+ # once of generated, once for .liquid. We only keep the .liquid, that's the one we have
290
+ # on disk.
291
+ @checksums.reject! { |key, _| @checksums.key?("#{key}.liquid") }
292
+ end
293
+
294
+ def file_has_changed?(file)
295
+ file.checksum != @checksums[file.relative_path.to_s]
296
+ end
297
+
298
+ def report_error(error)
299
+ if @delay_errors
300
+ @delayed_errors << error
301
+ else
302
+ @ctx.puts(error)
303
+ end
304
+ end
305
+
306
+ def parse_api_errors(exception)
307
+ parsed_body = JSON.parse(exception&.response&.body)
308
+ message = parsed_body.dig("errors", "asset") || parsed_body["message"] || exception.message
309
+ # Truncate to first lines
310
+ [message].flatten.map { |message| message.split("\n", 2).first }
311
+ rescue JSON::ParserError
312
+ [exception.message]
313
+ end
314
+
315
+ def backoff_if_near_limit!(used, limit)
316
+ if used > limit - @threads.size
317
+ @ctx.debug("Near API call limit, waiting 2 sec…")
318
+ @backoff_mutex.synchronize { sleep 2 }
319
+ end
320
+ end
321
+
322
+ def backingoff?
323
+ @backoff_mutex.locked?
324
+ end
325
+
326
+ def wait_for_backoff!
327
+ # Sleeping in the mutex in another thread. Wait for unlock
328
+ @backoff_mutex.synchronize {} if backingoff?
329
+ end
330
+ end
331
+ end
332
+ end