shopify-cli 1.11.0 → 2.0.0
Sign up to get free protection for your applications and to get access to all the features.
- checksums.yaml +4 -4
- data/.github/CODEOWNERS +1 -1
- data/.github/CONTRIBUTING.md +7 -7
- data/.github/DESIGN.md +3 -3
- data/.github/PULL_REQUEST_TEMPLATE.md +1 -1
- data/.github/workflows/build.yml +1 -1
- data/.gitignore +3 -0
- data/.rubocop.yml +3 -1
- data/.ruby-version +1 -1
- data/CHANGELOG.md +48 -20
- data/Gemfile +4 -0
- data/Gemfile.lock +32 -0
- data/LICENSE +4 -1
- data/README.md +92 -26
- data/RELEASING.md +29 -7
- data/Rakefile +2 -2
- data/SECURITY.md +1 -1
- data/bin/load_shopify.rb +1 -1
- data/bin/shopify +3 -3
- data/dev.yml +1 -1
- data/docs/app/node/index.md +1 -1
- data/docs/app/rails/index.md +1 -1
- data/docs/core/index.md +1 -1
- data/docs/getting-started/index.md +1 -1
- data/docs/getting-started/install/index.md +1 -1
- data/docs/getting-started/migrate/index.md +1 -1
- data/docs/getting-started/uninstall/index.md +1 -1
- data/docs/getting-started/upgrade/index.md +1 -1
- data/docs/help/start-app/index.md +1 -1
- data/docs/index.md +1 -1
- data/ext/shopify-cli/extconf.rb +17 -5
- data/install.sh +1 -1
- data/lib/docgen/index_template.md.erb +2 -2
- data/lib/graphql/all_orgs_with_extensions.graphql +37 -0
- data/lib/graphql/find_organization.graphql +2 -1
- data/lib/project_types/extension/cli.rb +18 -15
- data/lib/project_types/extension/commands/build.rb +4 -5
- data/lib/project_types/extension/commands/connect.rb +35 -0
- data/lib/project_types/extension/commands/create.rb +12 -16
- data/lib/project_types/extension/commands/extension_command.rb +2 -2
- data/lib/project_types/extension/commands/info.rb +86 -0
- data/lib/project_types/extension/commands/push.rb +8 -7
- data/lib/project_types/extension/commands/register.rb +4 -5
- data/lib/project_types/extension/commands/serve.rb +5 -8
- data/lib/project_types/extension/commands/tunnel.rb +3 -1
- data/lib/project_types/extension/errors.rb +9 -0
- data/lib/project_types/extension/extension_project.rb +5 -0
- data/lib/project_types/extension/features/argo.rb +6 -6
- data/lib/project_types/extension/features/argo_runtime.rb +22 -38
- data/lib/project_types/extension/features/argo_serve.rb +25 -20
- data/lib/project_types/extension/forms/connect.rb +42 -0
- data/lib/project_types/extension/forms/questions/ask_name.rb +14 -6
- data/lib/project_types/extension/forms/questions/ask_registration.rb +51 -0
- data/lib/project_types/extension/messages/messages.rb +75 -11
- data/lib/project_types/extension/models/specification.rb +1 -0
- data/lib/project_types/extension/models/specification_handlers/{checkout_argo_extension.rb → checkout_ui_extension.rb} +3 -1
- data/lib/project_types/extension/models/specification_handlers/default.rb +21 -6
- data/lib/project_types/extension/models/specification_handlers/theme_app_extension.rb +86 -0
- data/lib/project_types/extension/models/specifications.rb +1 -0
- data/lib/project_types/extension/tasks/configure_features.rb +6 -7
- data/lib/project_types/extension/tasks/configure_options.rb +20 -0
- data/lib/project_types/extension/tasks/get_extensions.rb +32 -0
- data/lib/project_types/node/cli.rb +9 -21
- data/lib/project_types/node/commands/connect.rb +8 -2
- data/lib/project_types/node/commands/create.rb +9 -5
- data/lib/project_types/node/commands/deploy.rb +15 -5
- data/lib/project_types/node/commands/deploy/heroku.rb +29 -29
- data/lib/project_types/node/commands/generate.rb +4 -2
- data/lib/project_types/node/commands/open.rb +4 -2
- data/lib/project_types/node/commands/serve.rb +3 -2
- data/lib/project_types/node/commands/tunnel.rb +4 -2
- data/lib/project_types/node/messages/messages.rb +46 -89
- data/lib/project_types/rails/cli.rb +9 -21
- data/lib/project_types/rails/commands/connect.rb +8 -2
- data/lib/project_types/rails/commands/create.rb +10 -6
- data/lib/project_types/rails/commands/deploy.rb +15 -5
- data/lib/project_types/rails/commands/deploy/heroku.rb +84 -82
- data/lib/project_types/rails/commands/generate.rb +15 -5
- data/lib/project_types/rails/commands/generate/webhook.rb +28 -26
- data/lib/project_types/rails/commands/open.rb +4 -2
- data/lib/project_types/rails/commands/serve.rb +3 -2
- data/lib/project_types/rails/commands/tunnel.rb +4 -2
- data/lib/project_types/rails/messages/messages.rb +54 -101
- data/lib/project_types/script/cli.rb +18 -20
- data/lib/project_types/script/commands/create.rb +3 -1
- data/lib/project_types/script/commands/push.rb +12 -5
- data/lib/project_types/script/config/extension_points.yml +0 -3
- data/lib/project_types/script/graphql/app_script_update_or_create.graphql +9 -3
- data/lib/project_types/script/layers/application/create_script.rb +6 -5
- data/lib/project_types/script/layers/application/push_script.rb +2 -1
- data/lib/project_types/script/layers/domain/errors.rb +6 -11
- data/lib/project_types/script/layers/domain/push_package.rb +4 -8
- data/lib/project_types/script/layers/domain/script_json.rb +32 -0
- data/lib/project_types/script/layers/domain/script_project.rb +1 -1
- data/lib/project_types/script/layers/infrastructure/errors.rb +14 -18
- data/lib/project_types/script/layers/infrastructure/languages/assemblyscript_project_creator.rb +105 -0
- data/lib/project_types/script/layers/infrastructure/languages/assemblyscript_task_runner.rb +103 -0
- data/lib/project_types/script/layers/infrastructure/languages/project_creator.rb +26 -0
- data/lib/project_types/script/layers/infrastructure/languages/rust_project_creator.rb +73 -0
- data/lib/project_types/script/layers/infrastructure/languages/rust_task_runner.rb +60 -0
- data/lib/project_types/script/layers/infrastructure/languages/task_runner.rb +21 -0
- data/lib/project_types/script/layers/infrastructure/push_package_repository.rb +2 -4
- data/lib/project_types/script/layers/infrastructure/script_project_repository.rb +45 -34
- data/lib/project_types/script/layers/infrastructure/script_service.rb +20 -14
- data/lib/project_types/script/messages/messages.rb +66 -55
- data/lib/project_types/script/tasks/ensure_env.rb +22 -1
- data/lib/project_types/script/ui/error_handler.rb +32 -32
- data/lib/project_types/theme/cli.rb +15 -27
- data/lib/project_types/theme/commands/check.rb +33 -0
- data/lib/project_types/theme/commands/delete.rb +64 -0
- data/lib/project_types/theme/commands/language_server.rb +16 -0
- data/lib/project_types/theme/commands/package.rb +55 -0
- data/lib/project_types/theme/commands/publish.rb +43 -0
- data/lib/project_types/theme/commands/pull.rb +51 -0
- data/lib/project_types/theme/commands/push.rb +58 -32
- data/lib/project_types/theme/commands/serve.rb +7 -17
- data/lib/project_types/theme/forms/confirm_store.rb +15 -0
- data/lib/project_types/theme/forms/select.rb +59 -0
- data/lib/project_types/theme/messages/messages.rb +110 -106
- data/lib/project_types/theme/ui/sync_progress_bar.rb +20 -0
- data/lib/shopify-cli/admin_api.rb +53 -35
- data/lib/shopify-cli/admin_api/populate_resource_command.rb +6 -14
- data/lib/shopify-cli/admin_api/schema.rb +1 -10
- data/lib/shopify-cli/api.rb +29 -14
- data/lib/shopify-cli/command.rb +15 -3
- data/lib/shopify-cli/commands.rb +7 -2
- data/lib/shopify-cli/commands/help.rb +2 -29
- data/lib/shopify-cli/commands/login.rb +95 -0
- data/lib/shopify-cli/commands/logout.rb +24 -8
- data/lib/shopify-cli/commands/populate.rb +23 -0
- data/lib/{project_types/node → shopify-cli}/commands/populate/customer.rb +2 -8
- data/lib/{project_types/node → shopify-cli}/commands/populate/draft_order.rb +2 -2
- data/lib/{project_types/node → shopify-cli}/commands/populate/product.rb +2 -8
- data/lib/shopify-cli/commands/store.rb +15 -0
- data/lib/shopify-cli/commands/switch.rb +39 -0
- data/lib/shopify-cli/commands/system.rb +12 -0
- data/lib/shopify-cli/commands/whoami.rb +28 -0
- data/lib/shopify-cli/connect.rb +32 -0
- data/lib/shopify-cli/context.rb +65 -4
- data/lib/shopify-cli/core/entry_point.rb +3 -22
- data/lib/shopify-cli/db.rb +4 -4
- data/lib/shopify-cli/http_request.rb +10 -0
- data/lib/shopify-cli/identity_auth.rb +282 -0
- data/lib/shopify-cli/{oauth → identity_auth}/servlet.rb +11 -12
- data/lib/shopify-cli/messages/messages.rb +133 -39
- data/lib/shopify-cli/partners_api.rb +21 -41
- data/lib/shopify-cli/partners_api/organizations.rb +8 -0
- data/lib/shopify-cli/project_commands.rb +16 -0
- data/lib/shopify-cli/project_type.rb +0 -31
- data/lib/shopify-cli/resources/env_file.rb +1 -1
- data/lib/shopify-cli/shopifolk.rb +8 -11
- data/lib/shopify-cli/sub_command.rb +1 -0
- data/lib/shopify-cli/tasks.rb +3 -0
- data/lib/shopify-cli/tasks/confirm_store.rb +18 -0
- data/lib/shopify-cli/tasks/create_api_client.rb +2 -2
- data/lib/shopify-cli/tasks/ensure_authenticated.rb +13 -0
- data/lib/shopify-cli/tasks/ensure_loopback_url.rb +1 -1
- data/lib/shopify-cli/tasks/ensure_project_type.rb +12 -0
- data/lib/shopify-cli/tasks/select_org_and_shop.rb +0 -3
- data/lib/shopify-cli/theme/dev_server.rb +98 -0
- data/lib/shopify-cli/theme/dev_server/certificate_manager.rb +79 -0
- data/lib/shopify-cli/theme/dev_server/header_hash.rb +94 -0
- data/lib/shopify-cli/theme/dev_server/hot-reload.js +93 -0
- data/lib/shopify-cli/theme/dev_server/hot_reload.rb +76 -0
- data/lib/shopify-cli/theme/dev_server/local_assets.rb +87 -0
- data/lib/shopify-cli/theme/dev_server/proxy.rb +205 -0
- data/lib/shopify-cli/theme/dev_server/sse.rb +75 -0
- data/lib/shopify-cli/theme/dev_server/watcher.rb +59 -0
- data/lib/shopify-cli/theme/dev_server/web_server.rb +140 -0
- data/lib/shopify-cli/theme/development_theme.rb +69 -0
- data/lib/shopify-cli/theme/file.rb +112 -0
- data/lib/shopify-cli/theme/ignore_filter.rb +109 -0
- data/lib/shopify-cli/theme/mime_type.rb +34 -0
- data/lib/shopify-cli/theme/syncer.rb +328 -0
- data/lib/shopify-cli/theme/theme.rb +204 -0
- data/lib/shopify-cli/version.rb +1 -1
- data/lib/shopify_cli.rb +18 -11
- data/shopify-cli.gemspec +12 -5
- data/shopify.fish +1 -1
- data/shopify.sh +1 -1
- metadata +95 -41
- data/.github/workflows/release.yml +0 -61
- data/lib/project_types/extension/features/argo_serve_options.rb +0 -40
- data/lib/project_types/node/commands/populate.rb +0 -23
- data/lib/project_types/rails/commands/populate.rb +0 -23
- data/lib/project_types/rails/commands/populate/customer.rb +0 -31
- data/lib/project_types/rails/commands/populate/draft_order.rb +0 -28
- data/lib/project_types/rails/commands/populate/product.rb +0 -30
- data/lib/project_types/script/layers/domain/config_ui.rb +0 -16
- data/lib/project_types/script/layers/infrastructure/assemblyscript_project_creator.rb +0 -95
- data/lib/project_types/script/layers/infrastructure/assemblyscript_task_runner.rb +0 -101
- data/lib/project_types/script/layers/infrastructure/project_creator.rb +0 -24
- data/lib/project_types/script/layers/infrastructure/rust_project_creator.rb +0 -71
- data/lib/project_types/script/layers/infrastructure/rust_task_runner.rb +0 -58
- data/lib/project_types/script/layers/infrastructure/task_runner.rb +0 -19
- data/lib/project_types/theme/commands/connect.rb +0 -54
- data/lib/project_types/theme/commands/create.rb +0 -48
- data/lib/project_types/theme/commands/deploy.rb +0 -38
- data/lib/project_types/theme/commands/generate.rb +0 -20
- data/lib/project_types/theme/commands/generate/env.rb +0 -79
- data/lib/project_types/theme/forms/connect.rb +0 -34
- data/lib/project_types/theme/forms/create.rb +0 -22
- data/lib/project_types/theme/tasks/ensure_themekit_installed.rb +0 -78
- data/lib/project_types/theme/themekit.rb +0 -113
- data/lib/shopify-cli/commands/connect.rb +0 -64
- data/lib/shopify-cli/commands/create.rb +0 -50
- 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
|