shopify-cli 1.12.0 → 2.0.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (208) 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 +52 -21
  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 +31 -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 -59
  50. data/lib/project_types/extension/features/argo_serve.rb +25 -21
  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 +13 -13
  58. data/lib/project_types/extension/models/specification_handlers/theme_app_extension.rb +89 -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 +37 -16
  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 +16 -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/init.rb +42 -0
  112. data/lib/project_types/theme/commands/language_server.rb +16 -0
  113. data/lib/project_types/theme/commands/package.rb +55 -0
  114. data/lib/project_types/theme/commands/publish.rb +43 -0
  115. data/lib/project_types/theme/commands/pull.rb +51 -0
  116. data/lib/project_types/theme/commands/push.rb +58 -32
  117. data/lib/project_types/theme/commands/serve.rb +8 -16
  118. data/lib/project_types/theme/forms/confirm_store.rb +15 -0
  119. data/lib/project_types/theme/forms/select.rb +59 -0
  120. data/lib/project_types/theme/messages/messages.rb +117 -102
  121. data/lib/project_types/theme/ui/sync_progress_bar.rb +20 -0
  122. data/lib/shopify-cli/admin_api.rb +53 -35
  123. data/lib/shopify-cli/admin_api/populate_resource_command.rb +6 -14
  124. data/lib/shopify-cli/admin_api/schema.rb +1 -10
  125. data/lib/shopify-cli/api.rb +29 -14
  126. data/lib/shopify-cli/command.rb +15 -3
  127. data/lib/shopify-cli/commands.rb +7 -2
  128. data/lib/shopify-cli/commands/help.rb +2 -29
  129. data/lib/shopify-cli/commands/login.rb +95 -0
  130. data/lib/shopify-cli/commands/logout.rb +24 -8
  131. data/lib/shopify-cli/commands/populate.rb +23 -0
  132. data/lib/{project_types/node → shopify-cli}/commands/populate/customer.rb +2 -8
  133. data/lib/{project_types/node → shopify-cli}/commands/populate/draft_order.rb +2 -2
  134. data/lib/{project_types/node → shopify-cli}/commands/populate/product.rb +2 -8
  135. data/lib/shopify-cli/commands/store.rb +15 -0
  136. data/lib/shopify-cli/commands/switch.rb +39 -0
  137. data/lib/shopify-cli/commands/system.rb +12 -0
  138. data/lib/shopify-cli/commands/whoami.rb +28 -0
  139. data/lib/shopify-cli/connect.rb +32 -0
  140. data/lib/shopify-cli/context.rb +65 -4
  141. data/lib/shopify-cli/core/entry_point.rb +3 -22
  142. data/lib/shopify-cli/db.rb +4 -4
  143. data/lib/shopify-cli/http_request.rb +16 -0
  144. data/lib/shopify-cli/identity_auth.rb +282 -0
  145. data/lib/shopify-cli/{oauth → identity_auth}/servlet.rb +11 -12
  146. data/lib/shopify-cli/messages/messages.rb +133 -39
  147. data/lib/shopify-cli/partners_api.rb +21 -41
  148. data/lib/shopify-cli/partners_api/organizations.rb +8 -0
  149. data/lib/shopify-cli/project_commands.rb +16 -0
  150. data/lib/shopify-cli/project_type.rb +0 -31
  151. data/lib/shopify-cli/resources/env_file.rb +1 -1
  152. data/lib/shopify-cli/shopifolk.rb +8 -11
  153. data/lib/shopify-cli/sub_command.rb +1 -0
  154. data/lib/shopify-cli/tasks.rb +3 -0
  155. data/lib/shopify-cli/tasks/confirm_store.rb +18 -0
  156. data/lib/shopify-cli/tasks/create_api_client.rb +2 -2
  157. data/lib/shopify-cli/tasks/ensure_authenticated.rb +13 -0
  158. data/lib/shopify-cli/tasks/ensure_loopback_url.rb +1 -1
  159. data/lib/shopify-cli/tasks/ensure_project_type.rb +12 -0
  160. data/lib/shopify-cli/tasks/select_org_and_shop.rb +0 -3
  161. data/lib/shopify-cli/theme/dev_server.rb +98 -0
  162. data/lib/shopify-cli/theme/dev_server/certificate_manager.rb +79 -0
  163. data/lib/shopify-cli/theme/dev_server/header_hash.rb +94 -0
  164. data/lib/shopify-cli/theme/dev_server/hot-reload.js +93 -0
  165. data/lib/shopify-cli/theme/dev_server/hot_reload.rb +76 -0
  166. data/lib/shopify-cli/theme/dev_server/local_assets.rb +87 -0
  167. data/lib/shopify-cli/theme/dev_server/proxy.rb +205 -0
  168. data/lib/shopify-cli/theme/dev_server/sse.rb +75 -0
  169. data/lib/shopify-cli/theme/dev_server/watcher.rb +59 -0
  170. data/lib/shopify-cli/theme/dev_server/web_server.rb +140 -0
  171. data/lib/shopify-cli/theme/development_theme.rb +69 -0
  172. data/lib/shopify-cli/theme/file.rb +112 -0
  173. data/lib/shopify-cli/theme/ignore_filter.rb +109 -0
  174. data/lib/shopify-cli/theme/mime_type.rb +34 -0
  175. data/lib/shopify-cli/theme/syncer.rb +328 -0
  176. data/lib/shopify-cli/theme/theme.rb +204 -0
  177. data/lib/shopify-cli/version.rb +1 -1
  178. data/lib/shopify_cli.rb +18 -11
  179. data/shopify-cli.gemspec +12 -5
  180. data/shopify.fish +1 -1
  181. data/shopify.sh +1 -1
  182. metadata +96 -41
  183. data/.github/workflows/release.yml +0 -61
  184. data/lib/project_types/extension/features/argo_serve_options.rb +0 -41
  185. data/lib/project_types/node/commands/populate.rb +0 -23
  186. data/lib/project_types/rails/commands/populate.rb +0 -23
  187. data/lib/project_types/rails/commands/populate/customer.rb +0 -31
  188. data/lib/project_types/rails/commands/populate/draft_order.rb +0 -28
  189. data/lib/project_types/rails/commands/populate/product.rb +0 -30
  190. data/lib/project_types/script/layers/domain/config_ui.rb +0 -16
  191. data/lib/project_types/script/layers/infrastructure/assemblyscript_project_creator.rb +0 -95
  192. data/lib/project_types/script/layers/infrastructure/assemblyscript_task_runner.rb +0 -101
  193. data/lib/project_types/script/layers/infrastructure/project_creator.rb +0 -24
  194. data/lib/project_types/script/layers/infrastructure/rust_project_creator.rb +0 -71
  195. data/lib/project_types/script/layers/infrastructure/rust_task_runner.rb +0 -58
  196. data/lib/project_types/script/layers/infrastructure/task_runner.rb +0 -19
  197. data/lib/project_types/theme/commands/connect.rb +0 -54
  198. data/lib/project_types/theme/commands/create.rb +0 -48
  199. data/lib/project_types/theme/commands/deploy.rb +0 -38
  200. data/lib/project_types/theme/commands/generate.rb +0 -20
  201. data/lib/project_types/theme/commands/generate/env.rb +0 -79
  202. data/lib/project_types/theme/forms/connect.rb +0 -34
  203. data/lib/project_types/theme/forms/create.rb +0 -22
  204. data/lib/project_types/theme/tasks/ensure_themekit_installed.rb +0 -78
  205. data/lib/project_types/theme/themekit.rb +0 -113
  206. data/lib/shopify-cli/commands/connect.rb +0 -64
  207. data/lib/shopify-cli/commands/create.rb +0 -50
  208. data/lib/shopify-cli/oauth.rb +0 -198
@@ -0,0 +1,94 @@
1
+ # frozen_string_literal: true
2
+
3
+ module ShopifyCli
4
+ module Theme
5
+ module DevServer
6
+ # Based on Rack::HeaderHash
7
+ class HeaderHash < Hash
8
+ def self.[](headers)
9
+ if headers.is_a?(HeaderHash) && !headers.frozen?
10
+ headers
11
+ else
12
+ new(headers)
13
+ end
14
+ end
15
+
16
+ def initialize(hash = {})
17
+ super()
18
+ @names = {}
19
+ hash.each { |k, v| self[k] = v }
20
+ end
21
+
22
+ # on dup/clone, we need to duplicate @names hash
23
+ def initialize_copy(other)
24
+ super
25
+ @names = other.names.dup
26
+ end
27
+
28
+ # on clear, we need to clear @names hash
29
+ def clear
30
+ super
31
+ @names.clear
32
+ end
33
+
34
+ def each
35
+ super do |k, v|
36
+ yield(k, v.respond_to?(:to_ary) ? v.to_ary.join("\n") : v)
37
+ end
38
+ end
39
+
40
+ def to_hash
41
+ hash = {}
42
+ each { |k, v| hash[k] = v }
43
+ hash
44
+ end
45
+
46
+ def [](k)
47
+ super(k) || super(@names[k.downcase])
48
+ end
49
+
50
+ def []=(k, v)
51
+ canonical = k.downcase.freeze
52
+ # .delete is expensive, don't invoke it unless necessary
53
+ delete(k) if @names[canonical] && @names[canonical] != k
54
+ @names[canonical] = k
55
+ super(k, v)
56
+ end
57
+
58
+ def delete(k)
59
+ canonical = k.downcase
60
+ result = super(@names.delete(canonical))
61
+ result
62
+ end
63
+
64
+ def include?(k)
65
+ super || @names.include?(k.downcase)
66
+ end
67
+
68
+ alias_method :has_key?, :include?
69
+ alias_method :member?, :include?
70
+ alias_method :key?, :include?
71
+
72
+ def merge!(other)
73
+ other.each { |k, v| self[k] = v }
74
+ self
75
+ end
76
+
77
+ def merge(other)
78
+ hash = dup
79
+ hash.merge!(other)
80
+ end
81
+
82
+ def replace(other)
83
+ clear
84
+ other.each { |k, v| self[k] = v }
85
+ self
86
+ end
87
+
88
+ protected
89
+
90
+ attr_reader :names
91
+ end
92
+ end
93
+ end
94
+ end
@@ -0,0 +1,93 @@
1
+ (() => {
2
+ function connect() {
3
+ const eventSource = new EventSource('/hot-reload');
4
+
5
+ eventSource.onmessage = handleUpdate;
6
+
7
+ eventSource.onopen = () => console.log('[HotReload] SSE connected.');
8
+
9
+ eventSource.onclose = () => {
10
+ console.log('[HotReload] SSE closed. Attempting to reconnect...');
11
+
12
+ setTimeout(connect, 5000);
13
+ }
14
+
15
+ eventSource.onerror = () => eventSource.close();
16
+ }
17
+
18
+ connect();
19
+
20
+ function handleUpdate(message) {
21
+ var data = JSON.parse(message.data);
22
+
23
+ // Assume only one file is modified at a time
24
+ var modified = data.modified[0];
25
+
26
+ if (isCssFile(modified)) {
27
+ reloadCssFile(modified)
28
+ } else if (isSectionFile(modified)) {
29
+ reloadSection(modified);
30
+ } else {
31
+ console.log(`[HotReload] Refreshing entire page`);
32
+ window.location.reload();
33
+ }
34
+ }
35
+
36
+ function isCssFile(filename) {
37
+ return filename.endsWith('.css');
38
+ }
39
+
40
+ function reloadCssFile(filename) {
41
+ // Find a stylesheet link starting with /assets (locally-served only) containing the filename
42
+ let link = document.querySelector(`link[href^="/assets"][href*="${filename}"][rel="stylesheet"]`);
43
+
44
+ if (!link) {
45
+ console.log(`[HotReload] Could not find link for stylesheet ${filename}`);
46
+ } else {
47
+ link.href = new URL(link.href).pathname + `?v=${Date.now()}`;
48
+ console.log(`[HotReload] Reloaded stylesheet ${filename}`);
49
+ }
50
+ }
51
+
52
+ function isSectionFile(filename) {
53
+ return new Section(filename).valid();
54
+ }
55
+
56
+ function reloadSection(filename) {
57
+ new Section(filename).refresh();
58
+ }
59
+
60
+ class Section {
61
+ constructor(filename) {
62
+ this.filename = filename;
63
+ this.name = filename.split('/').pop().replace('.liquid', '');
64
+ this.element = document.querySelector(`[id^='shopify-section'][id$='${this.name}']`);
65
+ }
66
+
67
+ valid() {
68
+ return this.filename.startsWith('sections/') && this.element;
69
+ }
70
+
71
+ async refresh() {
72
+ var url = new URL(window.location.href);
73
+ url.searchParams.append('section_id', this.name);
74
+
75
+ try {
76
+ const response = await fetch(url);
77
+ if (response.headers.get('x-templates-from-params') == '1') {
78
+ const html = await response.text();
79
+ this.element.outerHTML = html;
80
+
81
+ console.log(`[HotReload] Reloaded ${this.name} section`);
82
+ } else {
83
+ window.location.reload()
84
+
85
+ console.log(`[HotReload] Hot-reloading not supported, fully reloading ${this.name} section`);
86
+ }
87
+
88
+ } catch (e) {
89
+ console.log(`[HotReload] Failed to reload ${this.name} section: ${e.message}`);
90
+ }
91
+ }
92
+ }
93
+ })();
@@ -0,0 +1,76 @@
1
+ # frozen_string_literal: true
2
+
3
+ module ShopifyCli
4
+ module Theme
5
+ module DevServer
6
+ class HotReload
7
+ def initialize(ctx, app, theme:, watcher:, ignore_filter: nil)
8
+ @ctx = ctx
9
+ @app = app
10
+ @theme = theme
11
+ @streams = SSE::Streams.new
12
+ @watcher = watcher
13
+ @watcher.add_observer(self, :notify_streams_of_file_change)
14
+ @ignore_filter = ignore_filter
15
+ end
16
+
17
+ def call(env)
18
+ if env["PATH_INFO"] == "/hot-reload"
19
+ create_stream
20
+ else
21
+ status, headers, body = @app.call(env)
22
+
23
+ body = inject_hot_reload_javascript(body) if request_is_html?(headers)
24
+
25
+ [status, headers, body]
26
+ end
27
+ end
28
+
29
+ def close
30
+ @streams.close
31
+ end
32
+
33
+ def notify_streams_of_file_change(modified, added, _removed)
34
+ files = (modified + added).reject { |file| @ignore_filter&.ignore?(file) }
35
+ .map { |file| @theme[file].relative_path }
36
+
37
+ @streams.broadcast(JSON.generate(
38
+ modified: files,
39
+ ))
40
+
41
+ @ctx.debug("[HotReload] Modified #{files.join(", ")}")
42
+ end
43
+
44
+ private
45
+
46
+ def request_is_html?(headers)
47
+ headers["content-type"]&.start_with?("text/html")
48
+ end
49
+
50
+ def inject_hot_reload_javascript(body)
51
+ hot_reload_js = ::File.read("#{__dir__}/hot-reload.js")
52
+ hot_reload_script = "<script>\n#{hot_reload_js}</script>"
53
+ body = body.join.gsub("</body>", "#{hot_reload_script}\n</body>")
54
+
55
+ [body]
56
+ end
57
+
58
+ def create_stream
59
+ stream = @streams.new
60
+
61
+ @ctx.debug("[HotReload] Connected to SSE stream")
62
+
63
+ [
64
+ 200,
65
+ {
66
+ "Content-Type" => "text/event-stream",
67
+ "Cache-Control" => "no-cache",
68
+ "webrick.chunked" => true,
69
+ },
70
+ stream,
71
+ ]
72
+ end
73
+ end
74
+ end
75
+ end
76
+ end
@@ -0,0 +1,87 @@
1
+ # frozen_string_literal: true
2
+
3
+ module ShopifyCli
4
+ module Theme
5
+ module DevServer
6
+ class LocalAssets
7
+ ASSET_REGEX = %r{//cdn.shopify.com/s/.*?/(assets/.+\.(?:css|js))}
8
+
9
+ class FileBody
10
+ def initialize(path)
11
+ @path = path
12
+ end
13
+
14
+ # Naive implementation. Only used in unit tests.
15
+ def each
16
+ yield @path.read
17
+ end
18
+
19
+ # Rack will stream a body that responds to `to_path`
20
+ def to_path
21
+ @path.to_path
22
+ end
23
+ end
24
+
25
+ def initialize(ctx, app, theme:)
26
+ @ctx = ctx
27
+ @app = app
28
+ @theme = theme
29
+ end
30
+
31
+ def call(env)
32
+ if env["PATH_INFO"].start_with?("/assets")
33
+ # Serve from disk
34
+ serve_file(env["PATH_INFO"])
35
+ else
36
+ # Proxy the request, and replace the URLs in the response
37
+ status, headers, body = @app.call(env)
38
+ body = replace_asset_urls(body)
39
+ [status, headers, body]
40
+ end
41
+ end
42
+
43
+ private
44
+
45
+ def serve_file(path_info)
46
+ path = @theme.root.join(path_info[1..-1])
47
+ if path.file? && path.readable?
48
+ [
49
+ 200,
50
+ {
51
+ "Content-Type" => MimeType.by_filename(path).to_s,
52
+ "Content-Length" => path.size.to_s,
53
+ },
54
+ FileBody.new(path),
55
+ ]
56
+ else
57
+ fail(404, "Not found")
58
+ end
59
+ end
60
+
61
+ def fail(status, body)
62
+ [
63
+ status,
64
+ {
65
+ "Content-Type" => "text/plain",
66
+ "Content-Length" => body.size.to_s,
67
+ },
68
+ [body],
69
+ ]
70
+ end
71
+
72
+ def replace_asset_urls(body)
73
+ replaced_body = body.join.gsub(ASSET_REGEX) do |match|
74
+ path = Pathname.new(Regexp.last_match[1])
75
+ if @theme.asset_paths.include?(path)
76
+ "/#{path}"
77
+ else
78
+ match
79
+ end
80
+ end
81
+
82
+ [replaced_body]
83
+ end
84
+ end
85
+ end
86
+ end
87
+ end
@@ -0,0 +1,205 @@
1
+ # frozen_string_literal: true
2
+ require "net/http"
3
+ require "stringio"
4
+ require "time"
5
+
6
+ module ShopifyCli
7
+ module Theme
8
+ module DevServer
9
+ HOP_BY_HOP_HEADERS = [
10
+ "connection",
11
+ "keep-alive",
12
+ "proxy-authenticate",
13
+ "proxy-authorization",
14
+ "te",
15
+ "trailer",
16
+ "transfer-encoding",
17
+ "upgrade",
18
+ ]
19
+
20
+ class Proxy
21
+ SESSION_COOKIE_NAME = "_secure_session_id"
22
+ SESSION_COOKIE_REGEXP = /#{SESSION_COOKIE_NAME}=(\h+)/
23
+ SESSION_COOKIE_MAX_AGE = 60 * 60 * 23 # 1 day - leeway of 1h
24
+
25
+ def initialize(ctx, theme:, syncer:)
26
+ @ctx = ctx
27
+ @theme = theme
28
+ @syncer = syncer
29
+ @core_endpoints = Set.new
30
+
31
+ @secure_session_id = nil
32
+ @last_session_cookie_refresh = nil
33
+ end
34
+
35
+ def call(env)
36
+ headers = extract_http_request_headers(env)
37
+ headers["Host"] = @theme.shop
38
+ headers["Cookie"] = add_session_cookie(headers["Cookie"])
39
+ headers["Accept-Encoding"] = "none"
40
+ headers["User-Agent"] = "Shopify CLI"
41
+
42
+ query = URI.decode_www_form(env["QUERY_STRING"]).to_h
43
+ replace_templates = build_replace_templates_param(env)
44
+
45
+ response = if replace_templates.any?
46
+ # Pass to SFR the recently modified templates in `replace_templates` body param
47
+ headers["Authorization"] = "Bearer #{bearer_token}"
48
+ form_data = URI.decode_www_form(env["rack.input"].read).to_h
49
+ request(
50
+ "POST", env["PATH_INFO"],
51
+ headers: headers,
52
+ query: query,
53
+ form_data: form_data.merge(replace_templates).merge(_method: env["REQUEST_METHOD"]),
54
+ )
55
+ else
56
+ request(
57
+ env["REQUEST_METHOD"], env["PATH_INFO"],
58
+ headers: headers,
59
+ query: query,
60
+ body_stream: (env["rack.input"] if has_body?(headers)),
61
+ )
62
+ end
63
+
64
+ headers = get_response_headers(response)
65
+
66
+ unless headers["x-storefront-renderer-rendered"]
67
+ @core_endpoints << env["PATH_INFO"]
68
+ end
69
+
70
+ body = response.body || [""]
71
+ body = [body] unless body.respond_to?(:each)
72
+ [response.code, headers, body]
73
+ end
74
+
75
+ private
76
+
77
+ def has_body?(headers)
78
+ headers["Content-Length"] || headers["Transfer-Encoding"]
79
+ end
80
+
81
+ def bearer_token
82
+ ShopifyCli::DB.get(:storefront_renderer_production_exchange_token) ||
83
+ raise(KeyError, "storefront_renderer_production_exchange_token missing")
84
+ end
85
+
86
+ def extract_http_request_headers(env)
87
+ headers = HeaderHash.new
88
+
89
+ env.each do |name, value|
90
+ next if value.nil?
91
+
92
+ if /^HTTP_[A-Z0-9_]+$/.match?(name) || name == "CONTENT_TYPE" || name == "CONTENT_LENGTH"
93
+ headers[reconstruct_header_name(name)] = value
94
+ end
95
+ end
96
+
97
+ x_forwarded_for = (headers["X-Forwarded-For"].to_s.split(/, +/) << env["REMOTE_ADDR"]).join(", ")
98
+ headers["X-Forwarded-For"] = x_forwarded_for
99
+
100
+ headers
101
+ end
102
+
103
+ def normalize_headers(headers)
104
+ mapped = headers.map do |k, v|
105
+ [k, v.is_a?(Array) ? v.join("\n") : v]
106
+ end
107
+ HeaderHash.new(Hash[mapped])
108
+ end
109
+
110
+ def reconstruct_header_name(name)
111
+ name.sub(/^HTTP_/, "").gsub("_", "-")
112
+ end
113
+
114
+ def build_replace_templates_param(env)
115
+ params = {}
116
+
117
+ # Core doesn't support replace_templates
118
+ return params if @core_endpoints.include?(env["PATH_INFO"])
119
+
120
+ pending_templates = @syncer.pending_updates.select do |file|
121
+ # Only replace Liquid or JSON files
122
+ file.liquid? || file.json?
123
+ end
124
+
125
+ pending_templates.each do |path|
126
+ params["replace_templates[#{path.relative_path}]"] = path.read
127
+ end
128
+
129
+ params
130
+ end
131
+
132
+ def add_session_cookie(cookie_header)
133
+ cookie_header = if cookie_header
134
+ cookie_header.dup
135
+ else
136
+ +""
137
+ end
138
+
139
+ expected_session_cookie = "#{SESSION_COOKIE_NAME}=#{secure_session_id}"
140
+
141
+ unless cookie_header.include?(expected_session_cookie)
142
+ if cookie_header.include?(SESSION_COOKIE_NAME)
143
+ cookie_header.sub!(SESSION_COOKIE_REGEXP, expected_session_cookie)
144
+ else
145
+ cookie_header << "; " unless cookie_header.empty?
146
+ cookie_header << expected_session_cookie
147
+ end
148
+ end
149
+
150
+ cookie_header
151
+ end
152
+
153
+ def secure_session_id_expired?
154
+ return true unless @secure_session_id && @last_session_cookie_refresh
155
+ Time.now - @last_session_cookie_refresh >= SESSION_COOKIE_MAX_AGE
156
+ end
157
+
158
+ def secure_session_id
159
+ if secure_session_id_expired?
160
+ @ctx.debug("Refreshing preview _secure_session_id cookie")
161
+ response = request("HEAD", "/", query: { preview_theme_id: @theme.id })
162
+ @secure_session_id = response["set-cookie"][SESSION_COOKIE_REGEXP, 1]
163
+ @last_session_cookie_refresh = Time.now
164
+ end
165
+
166
+ @secure_session_id
167
+ end
168
+
169
+ def get_response_headers(response)
170
+ response_headers = normalize_headers(
171
+ response.respond_to?(:headers) ? response.headers : response.to_hash
172
+ )
173
+ # According to https://tools.ietf.org/html/draft-ietf-httpbis-p1-messaging-14#section-7.1.3.1Acc
174
+ # should remove hop-by-hop header fields
175
+ # (Taken from Rack::Proxy)
176
+ response_headers.reject! { |k| HOP_BY_HOP_HEADERS.include?(k.downcase) }
177
+
178
+ if response_headers["location"]&.include?("myshopify.com")
179
+ response_headers["location"].gsub!(%r{(https://#{@theme.shop})}, "http://127.0.0.1:9292")
180
+ end
181
+
182
+ response_headers
183
+ end
184
+
185
+ def request(method, path, headers: nil, query: {}, form_data: nil, body_stream: nil)
186
+ uri = URI.join("https://#{@theme.shop}", path)
187
+ uri.query = URI.encode_www_form(query.merge(_fd: 0, pb: 0))
188
+
189
+ @ctx.debug("Proxying #{method} #{uri}")
190
+
191
+ Net::HTTP.start(uri.host, 443, use_ssl: true) do |http|
192
+ req_class = Net::HTTP.const_get(method.capitalize)
193
+ req = req_class.new(uri)
194
+ req.initialize_http_header(headers) if headers
195
+ req.set_form_data(form_data) if form_data
196
+ req.body_stream = body_stream if body_stream
197
+ response = http.request(req)
198
+ @ctx.debug("`-> #{response.code} request_id: #{response["x-request-id"]}")
199
+ response
200
+ end
201
+ end
202
+ end
203
+ end
204
+ end
205
+ end