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,79 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "openssl"
4
+
5
+ module ShopifyCli
6
+ module Theme
7
+ module DevServer
8
+ class CertificateManager
9
+ attr_reader :ctx, :domain_name, :certificate, :private_key
10
+
11
+ ISSUER_EXTENSIONS = [
12
+ ["subjectKeyIdentifier", "hash", false],
13
+ ["authorityKeyIdentifier", "keyid:always", false],
14
+ ]
15
+
16
+ def initialize(ctx, domain_name)
17
+ @ctx = ctx
18
+ @domain_name = domain_name
19
+ end
20
+
21
+ def find_or_create_certificates!
22
+ @private_key = if (private_key_pem = ShopifyCli::DB.get(:ssl_private_key))
23
+ OpenSSL::PKey::RSA.new(private_key_pem)
24
+ else
25
+ OpenSSL::PKey::RSA.new(2048)
26
+ end
27
+
28
+ @certificate = if (certificate_pem = ShopifyCli::DB.get(:ssl_certificate))
29
+ OpenSSL::X509::Certificate.new(certificate_pem)
30
+ else
31
+ x509_certificate = build_x509_certificate
32
+
33
+ sign_certificate!(x509_certificate)
34
+
35
+ x509_certificate
36
+ end
37
+
38
+ ShopifyCli::DB.set(ssl_certificate: certificate.to_pem)
39
+ ShopifyCli::DB.set(ssl_private_key: private_key.to_pem)
40
+ end
41
+
42
+ private
43
+
44
+ def build_x509_certificate
45
+ certificate = OpenSSL::X509::Certificate.new
46
+
47
+ certificate.public_key = private_key.public_key
48
+ certificate.subject = subject
49
+ certificate.version = 2
50
+ certificate.serial = 0x0
51
+
52
+ certificate.not_before = Time.now.utc
53
+ certificate.not_after = Time.now.utc + 365 * 24 * 60 * 60
54
+
55
+ certificate
56
+ end
57
+
58
+ def sign_certificate!(certificate)
59
+ ef = OpenSSL::X509::ExtensionFactory.new
60
+
61
+ ef.subject_certificate = certificate
62
+ ef.issuer_certificate = certificate
63
+
64
+ ISSUER_EXTENSIONS.each do |args|
65
+ certificate.add_extension(ef.create_extension(*args))
66
+ end
67
+
68
+ certificate.add_extension(ef.create_extension("subjectAltName", "DNS:#{@domain_name}", false))
69
+
70
+ certificate.sign(private_key, OpenSSL::Digest.new("SHA256"))
71
+ end
72
+
73
+ def subject
74
+ OpenSSL::X509::Name.parse("/CN=#{@domain_name}/")
75
+ end
76
+ end
77
+ end
78
+ end
79
+ end
@@ -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.static_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