shopify-cli 2.14.0 → 2.15.2

Sign up to get free protection for your applications and to get access to all the features.
Files changed (64) hide show
  1. checksums.yaml +4 -4
  2. data/.github/CONTRIBUTING.md +1 -1
  3. data/.github/workflows/stale.yml +46 -0
  4. data/CHANGELOG.md +24 -0
  5. data/Gemfile +1 -0
  6. data/Gemfile.lock +39 -7
  7. data/Rakefile +40 -0
  8. data/ext/shopify-extensions/version +1 -1
  9. data/lib/project_types/extension/forms/questions/ask_template.rb +5 -8
  10. data/lib/project_types/extension/messages/messages.rb +11 -1
  11. data/lib/project_types/extension/models/development_server_requirements.rb +13 -7
  12. data/lib/project_types/extension/models/server_config/root.rb +2 -0
  13. data/lib/project_types/extension/models/specification_handlers/checkout_ui_extension.rb +13 -0
  14. data/lib/project_types/script/config/extension_points.yml +18 -0
  15. data/lib/project_types/script/layers/infrastructure/errors.rb +17 -0
  16. data/lib/project_types/script/layers/infrastructure/script_service.rb +2 -0
  17. data/lib/project_types/script/messages/messages.rb +3 -0
  18. data/lib/project_types/script/ui/error_handler.rb +11 -0
  19. data/lib/project_types/theme/commands/pull.rb +2 -2
  20. data/lib/project_types/theme/commands/push.rb +2 -2
  21. data/lib/project_types/theme/commands/serve.rb +1 -0
  22. data/lib/project_types/theme/conversions/base_glob.rb +20 -5
  23. data/lib/project_types/theme/messages/messages.rb +47 -8
  24. data/lib/shopify_cli/changelog.rb +76 -0
  25. data/lib/shopify_cli/command.rb +8 -7
  26. data/lib/shopify_cli/environment.rb +19 -11
  27. data/lib/shopify_cli/git.rb +36 -0
  28. data/lib/shopify_cli/messages/messages.rb +16 -9
  29. data/lib/shopify_cli/release.rb +194 -0
  30. data/lib/shopify_cli/sed.rb +19 -0
  31. data/lib/shopify_cli/services/app/create/node_service.rb +2 -14
  32. data/lib/shopify_cli/services/app/create/php_service.rb +1 -6
  33. data/lib/shopify_cli/services/app/create/rails_service.rb +4 -12
  34. data/lib/shopify_cli/theme/dev_server/hot-reload.js +40 -13
  35. data/lib/shopify_cli/theme/dev_server/hot_reload/remote_file_reloader.rb +1 -1
  36. data/lib/shopify_cli/theme/dev_server/hot_reload/sections_index.rb +51 -0
  37. data/lib/shopify_cli/theme/dev_server/hot_reload.rb +6 -1
  38. data/lib/shopify_cli/theme/dev_server/local_assets.rb +1 -1
  39. data/lib/shopify_cli/theme/dev_server/remote_watcher/json_files_update_job.rb +34 -0
  40. data/lib/shopify_cli/theme/dev_server/remote_watcher.rb +44 -0
  41. data/lib/shopify_cli/theme/dev_server/watcher.rb +1 -1
  42. data/lib/shopify_cli/theme/dev_server.rb +15 -3
  43. data/lib/shopify_cli/theme/file.rb +15 -4
  44. data/lib/shopify_cli/theme/syncer/checksums.rb +60 -0
  45. data/lib/shopify_cli/theme/syncer/forms/apply_to_all.rb +39 -0
  46. data/lib/shopify_cli/theme/syncer/forms/apply_to_all_form.rb +35 -0
  47. data/lib/shopify_cli/theme/syncer/forms/base_strategy_form.rb +62 -0
  48. data/lib/shopify_cli/theme/syncer/forms/select_delete_strategy.rb +27 -0
  49. data/lib/shopify_cli/theme/syncer/forms/select_update_strategy.rb +28 -0
  50. data/lib/shopify_cli/theme/syncer/ignore_helper.rb +33 -0
  51. data/lib/shopify_cli/theme/syncer/json_delete_handler.rb +51 -0
  52. data/lib/shopify_cli/theme/syncer/json_update_handler.rb +82 -0
  53. data/lib/shopify_cli/theme/syncer/merger.rb +53 -0
  54. data/lib/shopify_cli/theme/syncer/operation.rb +1 -1
  55. data/lib/shopify_cli/theme/syncer.rb +79 -63
  56. data/lib/shopify_cli/theme/theme.rb +12 -4
  57. data/lib/shopify_cli/theme/theme_admin_api.rb +24 -23
  58. data/lib/shopify_cli/thread_pool/job.rb +10 -2
  59. data/lib/shopify_cli/thread_pool.rb +15 -3
  60. data/lib/shopify_cli/tunnel.rb +9 -0
  61. data/lib/shopify_cli/version.rb +1 -1
  62. data/shopify-cli.gemspec +3 -1
  63. metadata +21 -4
  64. data/lib/project_types/rails/ruby.rb +0 -17
@@ -0,0 +1,51 @@
1
+ # frozen_string_literal: true
2
+
3
+ module ShopifyCLI
4
+ module Theme
5
+ module DevServer
6
+ class HotReload
7
+ class SectionsIndex
8
+ def initialize(theme)
9
+ @theme = theme
10
+ end
11
+
12
+ def section_names_by_type
13
+ index = {}
14
+
15
+ files.each do |file|
16
+ section_hash(file).each do |key, value|
17
+ name = key
18
+ type = value&.dig("type")
19
+
20
+ next if !name || !type
21
+
22
+ index[type] = [] unless index[type]
23
+ index[type] << name
24
+ end
25
+ end
26
+
27
+ index
28
+ end
29
+
30
+ private
31
+
32
+ def section_hash(file)
33
+ content = JSON.parse(file.read)
34
+ return [] unless content.is_a?(Hash)
35
+
36
+ sections = content["sections"]
37
+ return [] if sections.nil?
38
+
39
+ sections
40
+ rescue JSON::JSONError
41
+ []
42
+ end
43
+
44
+ def files
45
+ @theme.json_files
46
+ end
47
+ end
48
+ end
49
+ end
50
+ end
51
+ end
@@ -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