shopify-cli 2.15.1 → 2.15.2

Sign up to get free protection for your applications and to get access to all the features.
Files changed (40) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +6 -0
  3. data/Gemfile.lock +1 -1
  4. data/Rakefile +13 -0
  5. data/lib/project_types/extension/messages/messages.rb +1 -1
  6. data/lib/project_types/script/layers/infrastructure/errors.rb +17 -0
  7. data/lib/project_types/script/layers/infrastructure/script_service.rb +2 -0
  8. data/lib/project_types/script/messages/messages.rb +3 -0
  9. data/lib/project_types/script/ui/error_handler.rb +11 -0
  10. data/lib/project_types/theme/commands/serve.rb +1 -0
  11. data/lib/project_types/theme/messages/messages.rb +40 -8
  12. data/lib/shopify_cli/git.rb +36 -0
  13. data/lib/shopify_cli/messages/messages.rb +5 -4
  14. data/lib/shopify_cli/release.rb +120 -20
  15. data/lib/shopify_cli/theme/dev_server/hot-reload.js +40 -13
  16. data/lib/shopify_cli/theme/dev_server/hot_reload/remote_file_reloader.rb +1 -1
  17. data/lib/shopify_cli/theme/dev_server/hot_reload/sections_index.rb +51 -0
  18. data/lib/shopify_cli/theme/dev_server/hot_reload.rb +6 -1
  19. data/lib/shopify_cli/theme/dev_server/local_assets.rb +1 -1
  20. data/lib/shopify_cli/theme/dev_server/remote_watcher/json_files_update_job.rb +34 -0
  21. data/lib/shopify_cli/theme/dev_server/remote_watcher.rb +44 -0
  22. data/lib/shopify_cli/theme/dev_server/watcher.rb +1 -1
  23. data/lib/shopify_cli/theme/dev_server.rb +15 -3
  24. data/lib/shopify_cli/theme/file.rb +15 -4
  25. data/lib/shopify_cli/theme/syncer/checksums.rb +60 -0
  26. data/lib/shopify_cli/theme/syncer/forms/apply_to_all.rb +39 -0
  27. data/lib/shopify_cli/theme/syncer/forms/apply_to_all_form.rb +35 -0
  28. data/lib/shopify_cli/theme/syncer/forms/base_strategy_form.rb +62 -0
  29. data/lib/shopify_cli/theme/syncer/forms/select_delete_strategy.rb +27 -0
  30. data/lib/shopify_cli/theme/syncer/forms/select_update_strategy.rb +28 -0
  31. data/lib/shopify_cli/theme/syncer/ignore_helper.rb +33 -0
  32. data/lib/shopify_cli/theme/syncer/json_delete_handler.rb +51 -0
  33. data/lib/shopify_cli/theme/syncer/json_update_handler.rb +82 -0
  34. data/lib/shopify_cli/theme/syncer/merger.rb +53 -0
  35. data/lib/shopify_cli/theme/syncer/operation.rb +1 -1
  36. data/lib/shopify_cli/theme/syncer.rb +79 -63
  37. data/lib/shopify_cli/thread_pool/job.rb +10 -2
  38. data/lib/shopify_cli/thread_pool.rb +15 -3
  39. data/lib/shopify_cli/version.rb +1 -1
  40. metadata +15 -2
@@ -1,6 +1,7 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  require_relative "hot_reload/remote_file_reloader"
4
+ require_relative "hot_reload/sections_index"
4
5
 
5
6
  module ShopifyCLI
6
7
  module Theme
@@ -13,6 +14,7 @@ module ShopifyCLI
13
14
  @mode = mode
14
15
  @streams = SSE::Streams.new
15
16
  @remote_file_reloader = RemoteFileReloader.new(ctx, theme: @theme, streams: @streams)
17
+ @sections_index = SectionsIndex.new(@theme)
16
18
  @watcher = watcher
17
19
  @watcher.add_observer(self, :notify_streams_of_file_change)
18
20
  @ignore_filter = ignore_filter
@@ -78,7 +80,10 @@ module ShopifyCLI
78
80
  end
79
81
 
80
82
  def params_js
81
- env = { mode: @mode }
83
+ env = {
84
+ mode: @mode,
85
+ section_names_by_type: @sections_index.section_names_by_type,
86
+ }
82
87
  <<~JS
83
88
  (() => {
84
89
  window.__SHOPIFY_CLI_ENV__ = #{env.to_json};
@@ -71,7 +71,7 @@ module ShopifyCLI
71
71
 
72
72
  def replace_asset_urls(body)
73
73
  replaced_body = body.join.gsub(ASSET_REGEX) do |match|
74
- path = Pathname.new(Regexp.last_match[1])
74
+ path = Regexp.last_match[1]
75
75
  if @theme.static_asset_paths.include?(path)
76
76
  "/#{path}"
77
77
  else
@@ -0,0 +1,34 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "shopify_cli/thread_pool/job"
4
+
5
+ module ShopifyCLI
6
+ module Theme
7
+ module DevServer
8
+ class RemoteWatcher
9
+ class JsonFilesUpdateJob < ShopifyCLI::ThreadPool::Job
10
+ def initialize(theme, syncer, interval)
11
+ super(interval)
12
+
13
+ @theme = theme
14
+ @syncer = syncer
15
+ end
16
+
17
+ def perform!
18
+ @syncer.fetch_checksums!
19
+ @syncer.enqueue_get(json_files)
20
+ end
21
+
22
+ private
23
+
24
+ def json_files
25
+ @theme
26
+ .json_files
27
+ .reject { |file| @syncer.pending_updates.include?(file) }
28
+ .reject { |file| @syncer.broken_file?(file) }
29
+ end
30
+ end
31
+ end
32
+ end
33
+ end
34
+ end
@@ -0,0 +1,44 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "shopify_cli/thread_pool"
4
+
5
+ require_relative "remote_watcher/json_files_update_job"
6
+
7
+ module ShopifyCLI
8
+ module Theme
9
+ module DevServer
10
+ class RemoteWatcher
11
+ SYNC_INTERVAL = 3 # seconds
12
+
13
+ class << self
14
+ def to(theme:, syncer:)
15
+ new(theme, syncer)
16
+ end
17
+ end
18
+
19
+ def start
20
+ thread_pool.schedule(recurring_job)
21
+ end
22
+
23
+ def stop
24
+ thread_pool.shutdown
25
+ end
26
+
27
+ private
28
+
29
+ def initialize(theme, syncer)
30
+ @theme = theme
31
+ @syncer = syncer
32
+ end
33
+
34
+ def thread_pool
35
+ @thread_pool ||= ShopifyCLI::ThreadPool.new(pool_size: 1)
36
+ end
37
+
38
+ def recurring_job
39
+ JsonFilesUpdateJob.new(@theme, @syncer, SYNC_INTERVAL)
40
+ end
41
+ end
42
+ end
43
+ end
44
+ end
@@ -59,7 +59,7 @@ module ShopifyCLI
59
59
  private
60
60
 
61
61
  def ignore_file?(file)
62
- @ignore_filter&.ignore?(file.relative_path.to_s)
62
+ @ignore_filter&.ignore?(file.relative_path)
63
63
  end
64
64
  end
65
65
  end
@@ -11,6 +11,7 @@ require_relative "dev_server/local_assets"
11
11
  require_relative "dev_server/proxy"
12
12
  require_relative "dev_server/sse"
13
13
  require_relative "dev_server/watcher"
14
+ require_relative "dev_server/remote_watcher"
14
15
  require_relative "dev_server/web_server"
15
16
  require_relative "dev_server/certificate_manager"
16
17
 
@@ -26,12 +27,13 @@ module ShopifyCLI
26
27
  class << self
27
28
  attr_accessor :ctx
28
29
 
29
- def start(ctx, root, host: "127.0.0.1", port: 9292, poll: false, mode: ReloadMode.default)
30
+ def start(ctx, root, host: "127.0.0.1", port: 9292, poll: false, editor_sync: false, mode: ReloadMode.default)
30
31
  @ctx = ctx
31
32
  theme = DevelopmentTheme.find_or_create!(ctx, root: root)
32
33
  ignore_filter = IgnoreFilter.from_path(root)
33
- @syncer = Syncer.new(ctx, theme: theme, ignore_filter: ignore_filter)
34
+ @syncer = Syncer.new(ctx, theme: theme, ignore_filter: ignore_filter, overwrite_json: !editor_sync)
34
35
  watcher = Watcher.new(ctx, theme: theme, syncer: @syncer, ignore_filter: ignore_filter, poll: poll)
36
+ remote_watcher = RemoteWatcher.to(theme: theme, syncer: @syncer)
35
37
 
36
38
  # Setup the middleware stack. Mimics Rack::Builder / config.ru, but in reverse order
37
39
  @app = Proxy.new(ctx, theme: theme, syncer: @syncer)
@@ -57,9 +59,17 @@ module ShopifyCLI
57
59
 
58
60
  return if stopped
59
61
 
62
+ preview_suffix = editor_sync ? "" : ctx.message("theme.serve.download_changes")
63
+ preview_message = ctx.message(
64
+ "theme.serve.customize_or_preview",
65
+ preview_suffix,
66
+ theme.editor_url,
67
+ theme.preview_url
68
+ )
69
+
60
70
  ctx.puts(ctx.message("theme.serve.serving", theme.root))
61
71
  ctx.open_url!(address)
62
- ctx.puts(ctx.message("theme.serve.customize_or_preview", theme.editor_url, theme.preview_url))
72
+ ctx.puts(preview_message)
63
73
  end
64
74
 
65
75
  logger = if ctx.debug?
@@ -69,6 +79,7 @@ module ShopifyCLI
69
79
  end
70
80
 
71
81
  watcher.start
82
+ remote_watcher.start if editor_sync
72
83
  WebServer.run(
73
84
  @app,
74
85
  BindAddress: host,
@@ -76,6 +87,7 @@ module ShopifyCLI
76
87
  Logger: logger,
77
88
  AccessLog: [],
78
89
  )
90
+ remote_watcher.stop if editor_sync
79
91
  watcher.stop
80
92
 
81
93
  rescue ShopifyCLI::API::APIRequestForbiddenError,
@@ -4,7 +4,6 @@ require_relative "mime_type"
4
4
  module ShopifyCLI
5
5
  module Theme
6
6
  class File < Struct.new(:path)
7
- attr_reader :relative_path
8
7
  attr_accessor :remote_checksum
9
8
 
10
9
  def initialize(path, root)
@@ -42,7 +41,7 @@ module ShopifyCLI
42
41
  end
43
42
 
44
43
  def mime_type
45
- @mime_type ||= MimeType.by_filename(relative_path)
44
+ @mime_type ||= MimeType.by_filename(@relative_path)
46
45
  end
47
46
 
48
47
  def text?
@@ -54,7 +53,7 @@ module ShopifyCLI
54
53
  end
55
54
 
56
55
  def liquid_css?
57
- relative_path.to_s.end_with?(".css.liquid")
56
+ relative_path.end_with?(".css.liquid")
58
57
  end
59
58
 
60
59
  def json?
@@ -62,7 +61,7 @@ module ShopifyCLI
62
61
  end
63
62
 
64
63
  def template?
65
- relative_path.to_s.start_with?("templates/")
64
+ relative_path.start_with?("templates/")
66
65
  end
67
66
 
68
67
  def checksum
@@ -84,6 +83,18 @@ module ShopifyCLI
84
83
  relative_path == other.relative_path
85
84
  end
86
85
 
86
+ def name(*args)
87
+ ::File.basename(path, *args)
88
+ end
89
+
90
+ def absolute_path
91
+ path.realpath.to_s
92
+ end
93
+
94
+ def relative_path
95
+ @relative_path.to_s
96
+ end
97
+
87
98
  private
88
99
 
89
100
  def normalize_json(content)
@@ -0,0 +1,60 @@
1
+ # frozen_string_literal: true
2
+
3
+ module ShopifyCLI
4
+ module Theme
5
+ class Syncer
6
+ class Checksums
7
+ def initialize(theme)
8
+ @theme = theme
9
+ @checksum_by_key = {}
10
+
11
+ # Mutex used to coordinate changes in the checksums (shared accross `Syncer` threads)
12
+ @checksums_mutex = Mutex.new
13
+ end
14
+
15
+ def has?(file)
16
+ checksum_by_key.key?(to_key(file))
17
+ end
18
+
19
+ def file_has_changed?(file)
20
+ file.checksum != checksum_by_key[file.relative_path]
21
+ end
22
+
23
+ def keys
24
+ checksum_by_key.keys
25
+ end
26
+
27
+ def [](key)
28
+ checksum_by_key[key]
29
+ end
30
+
31
+ def []=(key, value)
32
+ checksums_mutex.synchronize do
33
+ checksum_by_key[key] = value
34
+ end
35
+ end
36
+
37
+ # Generate .liquid asset files are reported twice in checksum:
38
+ # once of generated, once for .liquid. We only keep the .liquid, that's the one we have
39
+ # on disk.
40
+ def reject_duplicated_checksums!
41
+ checksums_mutex.synchronize do
42
+ checksum_by_key.reject! { |key, _| checksum_by_key.key?("#{key}.liquid") }
43
+ end
44
+ end
45
+
46
+ private
47
+
48
+ def to_key(file)
49
+ theme[file].relative_path
50
+ end
51
+
52
+ # Private getters only used in unit tests
53
+
54
+ attr_reader :checksum_by_key
55
+ attr_reader :theme
56
+ attr_reader :checksums_mutex
57
+ end
58
+ end
59
+ end
60
+ end
@@ -0,0 +1,39 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "apply_to_all_form"
4
+
5
+ module ShopifyCLI
6
+ module Theme
7
+ class Syncer
8
+ module Forms
9
+ class ApplyToAll
10
+ attr_reader :value
11
+
12
+ def initialize(ctx, number_of_files)
13
+ @ctx = ctx
14
+ @number_of_files = number_of_files
15
+ @value = nil
16
+ @apply = nil
17
+ end
18
+
19
+ def apply?(value)
20
+ return unless @number_of_files > 1
21
+
22
+ if @apply.nil?
23
+ @apply = ask.apply?
24
+ @value = value if @apply
25
+ end
26
+
27
+ @apply
28
+ end
29
+
30
+ private
31
+
32
+ def ask
33
+ ApplyToAllForm.ask(@ctx, [], number_of_files: @number_of_files)
34
+ end
35
+ end
36
+ end
37
+ end
38
+ end
39
+ end
@@ -0,0 +1,35 @@
1
+ # frozen_string_literal: true
2
+
3
+ module ShopifyCLI
4
+ module Theme
5
+ class Syncer
6
+ module Forms
7
+ class ApplyToAllForm < ShopifyCLI::Form
8
+ attr_accessor :apply
9
+ flag_arguments :number_of_files
10
+
11
+ def ask
12
+ title = message("title", number_of_files - 1)
13
+
14
+ self.apply = CLI::UI::Prompt.ask(title, allow_empty: false) do |handler|
15
+ handler.option(message("yes")) { true }
16
+ handler.option(message("no")) { false }
17
+ end
18
+
19
+ self
20
+ end
21
+
22
+ def apply?
23
+ apply
24
+ end
25
+
26
+ private
27
+
28
+ def message(key, *params)
29
+ ctx.message("theme.serve.syncer.forms.apply_to_all.#{key}", *params)
30
+ end
31
+ end
32
+ end
33
+ end
34
+ end
35
+ end
@@ -0,0 +1,62 @@
1
+ # frozen_string_literal: true
2
+
3
+ module ShopifyCLI
4
+ module Theme
5
+ class Syncer
6
+ module Forms
7
+ class BaseStrategyForm < ShopifyCLI::Form
8
+ attr_accessor :strategy
9
+
10
+ def ask
11
+ ctx.puts(title_context(file))
12
+
13
+ self.strategy = CLI::UI::Prompt.ask(title_question, allow_empty: false) do |handler|
14
+ strategies.each do |strategy|
15
+ handler.option(as_text(strategy)) { strategy }
16
+ end
17
+ end
18
+
19
+ exit_cli if self.strategy == :exit
20
+
21
+ self
22
+ end
23
+
24
+ protected
25
+
26
+ ##
27
+ # List of strategies that populate the form options
28
+ #
29
+ def strategies
30
+ raise "`#{self.class.name}#strategies' must be defined"
31
+ end
32
+
33
+ ##
34
+ # Message prefix for the form title and options (strategies).
35
+ # See the methods `title` and `as_text`
36
+ #
37
+ def prefix
38
+ raise "`#{self.class.name}#prefix' must be defined"
39
+ end
40
+
41
+ private
42
+
43
+ def exit_cli
44
+ exit(0)
45
+ end
46
+
47
+ def title_context(file)
48
+ ctx.message("#{prefix}.title_context", file.relative_path)
49
+ end
50
+
51
+ def title_question
52
+ ctx.message("#{prefix}.title_question")
53
+ end
54
+
55
+ def as_text(strategy)
56
+ ctx.message("#{prefix}.#{strategy}")
57
+ end
58
+ end
59
+ end
60
+ end
61
+ end
62
+ end
@@ -0,0 +1,27 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "base_strategy_form"
4
+
5
+ module ShopifyCLI
6
+ module Theme
7
+ class Syncer
8
+ module Forms
9
+ class SelectDeleteStrategy < BaseStrategyForm
10
+ flag_arguments :file
11
+
12
+ def strategies
13
+ %i[
14
+ delete
15
+ restore
16
+ exit
17
+ ]
18
+ end
19
+
20
+ def prefix
21
+ "theme.serve.syncer.forms.delete_strategy"
22
+ end
23
+ end
24
+ end
25
+ end
26
+ end
27
+ end
@@ -0,0 +1,28 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "base_strategy_form"
4
+
5
+ module ShopifyCLI
6
+ module Theme
7
+ class Syncer
8
+ module Forms
9
+ class SelectUpdateStrategy < BaseStrategyForm
10
+ flag_arguments :file
11
+
12
+ def strategies
13
+ %i[
14
+ keep_remote
15
+ keep_local
16
+ union_merge
17
+ exit
18
+ ]
19
+ end
20
+
21
+ def prefix
22
+ "theme.serve.syncer.forms.update_strategy"
23
+ end
24
+ end
25
+ end
26
+ end
27
+ end
28
+ end
@@ -0,0 +1,33 @@
1
+ # frozen_string_literal: true
2
+
3
+ module ShopifyCLI
4
+ module Theme
5
+ class Syncer
6
+ module IgnoreHelper
7
+ def ignore_operation?(operation)
8
+ path = operation.file_path
9
+ ignore_path?(path)
10
+ end
11
+
12
+ def ignore_file?(file)
13
+ path = file.path
14
+ ignore_path?(path)
15
+ end
16
+
17
+ def ignore_path?(path)
18
+ ignored_by_ignore_filter?(path) || ignored_by_include_filter?(path)
19
+ end
20
+
21
+ private
22
+
23
+ def ignored_by_ignore_filter?(path)
24
+ ignore_filter&.ignore?(path)
25
+ end
26
+
27
+ def ignored_by_include_filter?(path)
28
+ !!include_filter && !include_filter.match?(path)
29
+ end
30
+ end
31
+ end
32
+ end
33
+ end
@@ -0,0 +1,51 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "forms/apply_to_all"
4
+ require_relative "forms/select_delete_strategy"
5
+
6
+ module ShopifyCLI
7
+ module Theme
8
+ class Syncer
9
+ module JsonDeleteHandler
10
+ def enqueue_json_deletes(files)
11
+ files = files.select { |file| !ignore_file?(file) }
12
+
13
+ if overwrite_json?
14
+ enqueue_deletes(files)
15
+ else
16
+ # Handle conflicts when JSON files cannot be overwritten
17
+ handle_delete_conflicts(files)
18
+ end
19
+ end
20
+
21
+ private
22
+
23
+ def handle_delete_conflicts(files)
24
+ to_delete = []
25
+ to_get = []
26
+
27
+ apply_to_all = Forms::ApplyToAll.new(@ctx, files.size)
28
+
29
+ files.each do |file|
30
+ delete_strategy = apply_to_all.value || ask_delete_strategy(file)
31
+ apply_to_all.apply?(delete_strategy)
32
+
33
+ case delete_strategy
34
+ when :delete
35
+ to_delete << file
36
+ when :restore
37
+ to_get << file
38
+ end
39
+ end
40
+
41
+ enqueue_deletes(to_delete)
42
+ enqueue_get(to_get)
43
+ end
44
+
45
+ def ask_delete_strategy(file)
46
+ Forms::SelectDeleteStrategy.ask(@ctx, [], file: file).strategy
47
+ end
48
+ end
49
+ end
50
+ end
51
+ end
@@ -0,0 +1,82 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "forms/apply_to_all"
4
+ require_relative "forms/select_update_strategy"
5
+
6
+ module ShopifyCLI
7
+ module Theme
8
+ class Syncer
9
+ module JsonUpdateHandler
10
+ def enqueue_json_updates(files)
11
+ # Some files must be uploaded after the other ones
12
+ delayed_files = [
13
+ theme["config/settings_schema.json"],
14
+ theme["config/settings_data.json"],
15
+ ]
16
+
17
+ # Update remote JSON files and delays `delayed_files` update
18
+ files = files
19
+ .select { |file| !ignore_file?(file) && file.exist? && checksums.file_has_changed?(file) }
20
+ .sort_by { |file| delayed_files.include?(file) ? 1 : 0 }
21
+
22
+ if overwrite_json?
23
+ enqueue_updates(files)
24
+ else
25
+ # Handle conflicts when JSON files cannot be overwritten
26
+ handle_update_conflicts(files)
27
+ end
28
+ end
29
+
30
+ private
31
+
32
+ def handle_update_conflicts(files)
33
+ to_get = []
34
+ to_delete = []
35
+ to_update = []
36
+ to_union_merge = []
37
+
38
+ apply_to_all = Forms::ApplyToAll.new(@ctx, files.size)
39
+
40
+ files.each do |file|
41
+ update_strategy = apply_to_all.value || ask_update_strategy(file)
42
+ apply_to_all.apply?(update_strategy)
43
+
44
+ case update_strategy
45
+ when :keep_remote
46
+ if file_exist_remotely?(file)
47
+ to_get << file
48
+ else
49
+ delete_locally(file)
50
+ end
51
+ when :keep_local
52
+ to_update << file
53
+ when :union_merge
54
+ if file_exist_remotely?(file)
55
+ to_union_merge << file
56
+ else
57
+ to_update << file
58
+ end
59
+ end
60
+ end
61
+
62
+ enqueue_get(to_get)
63
+ enqueue_deletes(to_delete)
64
+ enqueue_updates(to_update)
65
+ enqueue_union_merges(to_union_merge)
66
+ end
67
+
68
+ def file_exist_remotely?(file)
69
+ !checksums[file.relative_path].nil?
70
+ end
71
+
72
+ def delete_locally(file)
73
+ ::File.delete(file.absolute_path)
74
+ end
75
+
76
+ def ask_update_strategy(file)
77
+ Forms::SelectUpdateStrategy.ask(@ctx, [], file: file).strategy
78
+ end
79
+ end
80
+ end
81
+ end
82
+ end