shopify-cli 1.11.0 → 2.0.0

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 (207) 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/PULL_REQUEST_TEMPLATE.md +1 -1
  6. data/.github/workflows/build.yml +1 -1
  7. data/.gitignore +3 -0
  8. data/.rubocop.yml +3 -1
  9. data/.ruby-version +1 -1
  10. data/CHANGELOG.md +48 -20
  11. data/Gemfile +4 -0
  12. data/Gemfile.lock +32 -0
  13. data/LICENSE +4 -1
  14. data/README.md +92 -26
  15. data/RELEASING.md +29 -7
  16. data/Rakefile +2 -2
  17. data/SECURITY.md +1 -1
  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/find_organization.graphql +2 -1
  36. data/lib/project_types/extension/cli.rb +18 -15
  37. data/lib/project_types/extension/commands/build.rb +4 -5
  38. data/lib/project_types/extension/commands/connect.rb +35 -0
  39. data/lib/project_types/extension/commands/create.rb +12 -16
  40. data/lib/project_types/extension/commands/extension_command.rb +2 -2
  41. data/lib/project_types/extension/commands/info.rb +86 -0
  42. data/lib/project_types/extension/commands/push.rb +8 -7
  43. data/lib/project_types/extension/commands/register.rb +4 -5
  44. data/lib/project_types/extension/commands/serve.rb +5 -8
  45. data/lib/project_types/extension/commands/tunnel.rb +3 -1
  46. data/lib/project_types/extension/errors.rb +9 -0
  47. data/lib/project_types/extension/extension_project.rb +5 -0
  48. data/lib/project_types/extension/features/argo.rb +6 -6
  49. data/lib/project_types/extension/features/argo_runtime.rb +22 -38
  50. data/lib/project_types/extension/features/argo_serve.rb +25 -20
  51. data/lib/project_types/extension/forms/connect.rb +42 -0
  52. data/lib/project_types/extension/forms/questions/ask_name.rb +14 -6
  53. data/lib/project_types/extension/forms/questions/ask_registration.rb +51 -0
  54. data/lib/project_types/extension/messages/messages.rb +75 -11
  55. data/lib/project_types/extension/models/specification.rb +1 -0
  56. data/lib/project_types/extension/models/specification_handlers/{checkout_argo_extension.rb → checkout_ui_extension.rb} +3 -1
  57. data/lib/project_types/extension/models/specification_handlers/default.rb +21 -6
  58. data/lib/project_types/extension/models/specification_handlers/theme_app_extension.rb +86 -0
  59. data/lib/project_types/extension/models/specifications.rb +1 -0
  60. data/lib/project_types/extension/tasks/configure_features.rb +6 -7
  61. data/lib/project_types/extension/tasks/configure_options.rb +20 -0
  62. data/lib/project_types/extension/tasks/get_extensions.rb +32 -0
  63. data/lib/project_types/node/cli.rb +9 -21
  64. data/lib/project_types/node/commands/connect.rb +8 -2
  65. data/lib/project_types/node/commands/create.rb +9 -5
  66. data/lib/project_types/node/commands/deploy.rb +15 -5
  67. data/lib/project_types/node/commands/deploy/heroku.rb +29 -29
  68. data/lib/project_types/node/commands/generate.rb +4 -2
  69. data/lib/project_types/node/commands/open.rb +4 -2
  70. data/lib/project_types/node/commands/serve.rb +3 -2
  71. data/lib/project_types/node/commands/tunnel.rb +4 -2
  72. data/lib/project_types/node/messages/messages.rb +46 -89
  73. data/lib/project_types/rails/cli.rb +9 -21
  74. data/lib/project_types/rails/commands/connect.rb +8 -2
  75. data/lib/project_types/rails/commands/create.rb +10 -6
  76. data/lib/project_types/rails/commands/deploy.rb +15 -5
  77. data/lib/project_types/rails/commands/deploy/heroku.rb +84 -82
  78. data/lib/project_types/rails/commands/generate.rb +15 -5
  79. data/lib/project_types/rails/commands/generate/webhook.rb +28 -26
  80. data/lib/project_types/rails/commands/open.rb +4 -2
  81. data/lib/project_types/rails/commands/serve.rb +3 -2
  82. data/lib/project_types/rails/commands/tunnel.rb +4 -2
  83. data/lib/project_types/rails/messages/messages.rb +54 -101
  84. data/lib/project_types/script/cli.rb +18 -20
  85. data/lib/project_types/script/commands/create.rb +3 -1
  86. data/lib/project_types/script/commands/push.rb +12 -5
  87. data/lib/project_types/script/config/extension_points.yml +0 -3
  88. data/lib/project_types/script/graphql/app_script_update_or_create.graphql +9 -3
  89. data/lib/project_types/script/layers/application/create_script.rb +6 -5
  90. data/lib/project_types/script/layers/application/push_script.rb +2 -1
  91. data/lib/project_types/script/layers/domain/errors.rb +6 -11
  92. data/lib/project_types/script/layers/domain/push_package.rb +4 -8
  93. data/lib/project_types/script/layers/domain/script_json.rb +32 -0
  94. data/lib/project_types/script/layers/domain/script_project.rb +1 -1
  95. data/lib/project_types/script/layers/infrastructure/errors.rb +14 -18
  96. data/lib/project_types/script/layers/infrastructure/languages/assemblyscript_project_creator.rb +105 -0
  97. data/lib/project_types/script/layers/infrastructure/languages/assemblyscript_task_runner.rb +103 -0
  98. data/lib/project_types/script/layers/infrastructure/languages/project_creator.rb +26 -0
  99. data/lib/project_types/script/layers/infrastructure/languages/rust_project_creator.rb +73 -0
  100. data/lib/project_types/script/layers/infrastructure/languages/rust_task_runner.rb +60 -0
  101. data/lib/project_types/script/layers/infrastructure/languages/task_runner.rb +21 -0
  102. data/lib/project_types/script/layers/infrastructure/push_package_repository.rb +2 -4
  103. data/lib/project_types/script/layers/infrastructure/script_project_repository.rb +45 -34
  104. data/lib/project_types/script/layers/infrastructure/script_service.rb +20 -14
  105. data/lib/project_types/script/messages/messages.rb +66 -55
  106. data/lib/project_types/script/tasks/ensure_env.rb +22 -1
  107. data/lib/project_types/script/ui/error_handler.rb +32 -32
  108. data/lib/project_types/theme/cli.rb +15 -27
  109. data/lib/project_types/theme/commands/check.rb +33 -0
  110. data/lib/project_types/theme/commands/delete.rb +64 -0
  111. data/lib/project_types/theme/commands/language_server.rb +16 -0
  112. data/lib/project_types/theme/commands/package.rb +55 -0
  113. data/lib/project_types/theme/commands/publish.rb +43 -0
  114. data/lib/project_types/theme/commands/pull.rb +51 -0
  115. data/lib/project_types/theme/commands/push.rb +58 -32
  116. data/lib/project_types/theme/commands/serve.rb +7 -17
  117. data/lib/project_types/theme/forms/confirm_store.rb +15 -0
  118. data/lib/project_types/theme/forms/select.rb +59 -0
  119. data/lib/project_types/theme/messages/messages.rb +110 -106
  120. data/lib/project_types/theme/ui/sync_progress_bar.rb +20 -0
  121. data/lib/shopify-cli/admin_api.rb +53 -35
  122. data/lib/shopify-cli/admin_api/populate_resource_command.rb +6 -14
  123. data/lib/shopify-cli/admin_api/schema.rb +1 -10
  124. data/lib/shopify-cli/api.rb +29 -14
  125. data/lib/shopify-cli/command.rb +15 -3
  126. data/lib/shopify-cli/commands.rb +7 -2
  127. data/lib/shopify-cli/commands/help.rb +2 -29
  128. data/lib/shopify-cli/commands/login.rb +95 -0
  129. data/lib/shopify-cli/commands/logout.rb +24 -8
  130. data/lib/shopify-cli/commands/populate.rb +23 -0
  131. data/lib/{project_types/node → shopify-cli}/commands/populate/customer.rb +2 -8
  132. data/lib/{project_types/node → shopify-cli}/commands/populate/draft_order.rb +2 -2
  133. data/lib/{project_types/node → shopify-cli}/commands/populate/product.rb +2 -8
  134. data/lib/shopify-cli/commands/store.rb +15 -0
  135. data/lib/shopify-cli/commands/switch.rb +39 -0
  136. data/lib/shopify-cli/commands/system.rb +12 -0
  137. data/lib/shopify-cli/commands/whoami.rb +28 -0
  138. data/lib/shopify-cli/connect.rb +32 -0
  139. data/lib/shopify-cli/context.rb +65 -4
  140. data/lib/shopify-cli/core/entry_point.rb +3 -22
  141. data/lib/shopify-cli/db.rb +4 -4
  142. data/lib/shopify-cli/http_request.rb +10 -0
  143. data/lib/shopify-cli/identity_auth.rb +282 -0
  144. data/lib/shopify-cli/{oauth → identity_auth}/servlet.rb +11 -12
  145. data/lib/shopify-cli/messages/messages.rb +133 -39
  146. data/lib/shopify-cli/partners_api.rb +21 -41
  147. data/lib/shopify-cli/partners_api/organizations.rb +8 -0
  148. data/lib/shopify-cli/project_commands.rb +16 -0
  149. data/lib/shopify-cli/project_type.rb +0 -31
  150. data/lib/shopify-cli/resources/env_file.rb +1 -1
  151. data/lib/shopify-cli/shopifolk.rb +8 -11
  152. data/lib/shopify-cli/sub_command.rb +1 -0
  153. data/lib/shopify-cli/tasks.rb +3 -0
  154. data/lib/shopify-cli/tasks/confirm_store.rb +18 -0
  155. data/lib/shopify-cli/tasks/create_api_client.rb +2 -2
  156. data/lib/shopify-cli/tasks/ensure_authenticated.rb +13 -0
  157. data/lib/shopify-cli/tasks/ensure_loopback_url.rb +1 -1
  158. data/lib/shopify-cli/tasks/ensure_project_type.rb +12 -0
  159. data/lib/shopify-cli/tasks/select_org_and_shop.rb +0 -3
  160. data/lib/shopify-cli/theme/dev_server.rb +98 -0
  161. data/lib/shopify-cli/theme/dev_server/certificate_manager.rb +79 -0
  162. data/lib/shopify-cli/theme/dev_server/header_hash.rb +94 -0
  163. data/lib/shopify-cli/theme/dev_server/hot-reload.js +93 -0
  164. data/lib/shopify-cli/theme/dev_server/hot_reload.rb +76 -0
  165. data/lib/shopify-cli/theme/dev_server/local_assets.rb +87 -0
  166. data/lib/shopify-cli/theme/dev_server/proxy.rb +205 -0
  167. data/lib/shopify-cli/theme/dev_server/sse.rb +75 -0
  168. data/lib/shopify-cli/theme/dev_server/watcher.rb +59 -0
  169. data/lib/shopify-cli/theme/dev_server/web_server.rb +140 -0
  170. data/lib/shopify-cli/theme/development_theme.rb +69 -0
  171. data/lib/shopify-cli/theme/file.rb +112 -0
  172. data/lib/shopify-cli/theme/ignore_filter.rb +109 -0
  173. data/lib/shopify-cli/theme/mime_type.rb +34 -0
  174. data/lib/shopify-cli/theme/syncer.rb +328 -0
  175. data/lib/shopify-cli/theme/theme.rb +204 -0
  176. data/lib/shopify-cli/version.rb +1 -1
  177. data/lib/shopify_cli.rb +18 -11
  178. data/shopify-cli.gemspec +12 -5
  179. data/shopify.fish +1 -1
  180. data/shopify.sh +1 -1
  181. metadata +95 -41
  182. data/.github/workflows/release.yml +0 -61
  183. data/lib/project_types/extension/features/argo_serve_options.rb +0 -40
  184. data/lib/project_types/node/commands/populate.rb +0 -23
  185. data/lib/project_types/rails/commands/populate.rb +0 -23
  186. data/lib/project_types/rails/commands/populate/customer.rb +0 -31
  187. data/lib/project_types/rails/commands/populate/draft_order.rb +0 -28
  188. data/lib/project_types/rails/commands/populate/product.rb +0 -30
  189. data/lib/project_types/script/layers/domain/config_ui.rb +0 -16
  190. data/lib/project_types/script/layers/infrastructure/assemblyscript_project_creator.rb +0 -95
  191. data/lib/project_types/script/layers/infrastructure/assemblyscript_task_runner.rb +0 -101
  192. data/lib/project_types/script/layers/infrastructure/project_creator.rb +0 -24
  193. data/lib/project_types/script/layers/infrastructure/rust_project_creator.rb +0 -71
  194. data/lib/project_types/script/layers/infrastructure/rust_task_runner.rb +0 -58
  195. data/lib/project_types/script/layers/infrastructure/task_runner.rb +0 -19
  196. data/lib/project_types/theme/commands/connect.rb +0 -54
  197. data/lib/project_types/theme/commands/create.rb +0 -48
  198. data/lib/project_types/theme/commands/deploy.rb +0 -38
  199. data/lib/project_types/theme/commands/generate.rb +0 -20
  200. data/lib/project_types/theme/commands/generate/env.rb +0 -79
  201. data/lib/project_types/theme/forms/connect.rb +0 -34
  202. data/lib/project_types/theme/forms/create.rb +0 -22
  203. data/lib/project_types/theme/tasks/ensure_themekit_installed.rb +0 -78
  204. data/lib/project_types/theme/themekit.rb +0 -113
  205. data/lib/shopify-cli/commands/connect.rb +0 -64
  206. data/lib/shopify-cli/commands/create.rb +0 -50
  207. 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,328 @@
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.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
+ value = body.dig("asset", "value") || Base64.decode64(body.dig("asset", "attachment"))
258
+ file.write(value)
259
+
260
+ response
261
+ end
262
+
263
+ def delete(file)
264
+ _status, _body, response = ShopifyCli::AdminAPI.rest_request(
265
+ @ctx,
266
+ shop: @theme.shop,
267
+ path: "themes/#{@theme.id}/assets.json",
268
+ method: "DELETE",
269
+ api_version: API_VERSION,
270
+ body: JSON.generate(asset: {
271
+ key: file.relative_path.to_s
272
+ })
273
+ )
274
+
275
+ response
276
+ end
277
+
278
+ def update_checksums(api_response)
279
+ api_response.values.flatten.each do |asset|
280
+ if asset["key"] && asset["checksum"]
281
+ @checksums[asset["key"]] = asset["checksum"]
282
+ end
283
+ end
284
+ # Generate .liquid asset files are reported twice in checksum:
285
+ # once of generated, once for .liquid. We only keep the .liquid, that's the one we have
286
+ # on disk.
287
+ @checksums.reject! { |key, _| @checksums.key?("#{key}.liquid") }
288
+ end
289
+
290
+ def file_has_changed?(file)
291
+ file.checksum != @checksums[file.relative_path.to_s]
292
+ end
293
+
294
+ def report_error(error)
295
+ if @delay_errors
296
+ @delayed_errors << error
297
+ else
298
+ @ctx.puts(error)
299
+ end
300
+ end
301
+
302
+ def parse_api_errors(exception)
303
+ parsed_body = JSON.parse(exception&.response&.body)
304
+ message = parsed_body.dig("errors", "asset") || parsed_body["message"] || exception.message
305
+ # Truncate to first lines
306
+ [message].flatten.map { |message| message.split("\n", 2).first }
307
+ rescue JSON::ParserError
308
+ [exception.message]
309
+ end
310
+
311
+ def backoff_if_near_limit!(used, limit)
312
+ if used > limit - @threads.size
313
+ @ctx.debug("Near API call limit, waiting 2 sec ...")
314
+ @backoff_mutex.synchronize { sleep 2 }
315
+ end
316
+ end
317
+
318
+ def backingoff?
319
+ @backoff_mutex.locked?
320
+ end
321
+
322
+ def wait_for_backoff!
323
+ # Sleeping in the mutex in another thread. Wait for unlock
324
+ @backoff_mutex.synchronize {} if backingoff?
325
+ end
326
+ end
327
+ end
328
+ end