fluid_cli 0.1.8 → 0.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 95f54f4f147815bbde7b9477dde64b103a41bfa721e0c88b1cdd98a526421b2a
4
- data.tar.gz: 61151c84b36ec0ff0566e61e9503b36df20f560df7f4f1a3b46a7650b55f7fac
3
+ metadata.gz: 84ff5fea48f57756d085af8e58148aec8b8b98161b152f5c7e95252f51e2348b
4
+ data.tar.gz: 0b65b1eccfcf8e095961e4518eb9c5e73cb4b6884b029f12074bfe477b1dc40a
5
5
  SHA512:
6
- metadata.gz: a81fc2e51ad5e6d26de123c83cfe854405cf1b574fcb4d8e834220d2af36e5b62afa269063f4d9f9c05cfb277fb5dd0dd3a1e56ba67565226c3fe794cf252708
7
- data.tar.gz: 1e7e62ba1049194533c5559c87394fedab9d11c20bcd437f98c3820d1723236aa4221be85e3d774fb5ab13de7bd9ff840356cbefe2710c81932d570c84464ba0
6
+ metadata.gz: a951d7bfafd5ce072e8b2b8e91f308878021a13697848f0eb6fa294f09e5e0d2118a02339530914fa3dcd0647b2c7e7f29bf36bc4d2f89adcfebaf3e3b225937
7
+ data.tar.gz: 1031eb13ffbfb6025e7adbea17dca9bc76d7ab220840e3a8e5782422ba2edba49ee8dc8ccbc7a631e244e04aca5d62718db9f1d85043ec4ad5770008c00e2533
data/README.md CHANGED
@@ -1 +1,185 @@
1
- # fluid_cli
1
+ # Fluid CLI
2
+
3
+ The official command-line interface for the [Fluid](https://fluid.app) e-commerce platform. Build, preview, and deploy themes for your Fluid storefront directly from the terminal.
4
+
5
+ ## Installation
6
+
7
+ ### Prerequisites
8
+
9
+ - Ruby >= 3.1.0
10
+ - Bundler
11
+
12
+ ### From Source
13
+
14
+ ```sh
15
+ git clone https://github.com/fluid-commerce/fluid-cli.git
16
+ cd fluid-cli
17
+ bundle install
18
+ ```
19
+
20
+ ### As a Gem
21
+
22
+ ```sh
23
+ gem install fluid_cli
24
+ ```
25
+
26
+ ## Quick Start
27
+
28
+ ```sh
29
+ # Authenticate with your Fluid account
30
+ fluid login
31
+
32
+ # Initialize a new theme from the base template
33
+ fluid theme init
34
+
35
+ # Start the local development server
36
+ fluid theme dev
37
+
38
+ # Push your theme to Fluid
39
+ fluid theme push
40
+ ```
41
+
42
+ ## Authentication
43
+
44
+ Fluid CLI uses OAuth2 for authentication. Running `fluid login` will open your browser and redirect you to the Fluid authentication page. Once authenticated, your session is stored locally.
45
+
46
+ ```sh
47
+ # Log in to your Fluid account
48
+ fluid login
49
+
50
+ # Log in to a specific company
51
+ fluid login --company=my-store
52
+
53
+ # Check who you're logged in as
54
+ fluid whoami
55
+
56
+ # Switch between companies
57
+ fluid switch --company=another-store
58
+
59
+ # Log out
60
+ fluid logout
61
+ ```
62
+
63
+ You can also authenticate via environment variables:
64
+
65
+ | Variable | Description |
66
+ |----------|-------------|
67
+ | `FLUID_AUTH_TOKEN` | Bearer token for API authentication |
68
+ | `FLUID_COMPANY` | Default company subdomain |
69
+
70
+ ## Commands
71
+
72
+ ### `fluid login`
73
+
74
+ Authenticate with your Fluid account via the browser.
75
+
76
+ | Flag | Description |
77
+ |------|-------------|
78
+ | `-c, --company=COMPANY` | Specify the company to log in to |
79
+
80
+ ### `fluid logout`
81
+
82
+ Clear stored authentication tokens.
83
+
84
+ ### `fluid whoami`
85
+
86
+ Display the currently authenticated company.
87
+
88
+ ### `fluid switch`
89
+
90
+ Switch between companies on your account.
91
+
92
+ | Flag | Description |
93
+ |------|-------------|
94
+ | `-c, --company=COMPANY` | Specify the company to switch to |
95
+
96
+ ### `fluid theme init`
97
+
98
+ Initialize a new theme by cloning the base template into your current directory.
99
+
100
+ | Flag | Description |
101
+ |------|-------------|
102
+ | `-u, --clone-url=URL` | Custom Git URL to clone from |
103
+
104
+ ### `fluid theme dev`
105
+
106
+ Start a local development server with hot reload. The dev server proxies requests to your Fluid storefront and injects local theme files, enabling a fast development workflow.
107
+
108
+ | Flag | Description |
109
+ |------|-------------|
110
+ | `--host=HOST` | Host to bind the dev server to |
111
+ | `--port=PORT` | Port to bind the dev server to |
112
+ | `--poll` | Use polling instead of filesystem events for file watching |
113
+ | `--live-reload=MODE` | Reload mode: `full-page` or `hot-reload` |
114
+ | `-t, --theme=THEME_ID` | Use a specific theme |
115
+ | `-f, --force` | Force create a new development theme |
116
+ | `--overwrite-json` | Overwrite remote JSON files with local versions |
117
+ | `--navigate` | Open the interactive route/resource navigator |
118
+
119
+ ### `fluid theme push`
120
+
121
+ Upload your local theme files to Fluid.
122
+
123
+ | Flag | Description |
124
+ |------|-------------|
125
+ | `-n, --nodelete` | Don't delete remote files that are missing locally |
126
+ | `-t, --theme=THEME_ID` | Push to a specific theme |
127
+ | `-u, --unpublished` | Push to a new unpublished theme |
128
+ | `-j, --json` | Include JSON settings files |
129
+ | `-a, --allow-live` | Allow pushing to the live theme |
130
+ | `-p, --publish` | Publish the theme after pushing |
131
+ | `-f, --force` | Skip confirmation prompts |
132
+
133
+ ### `fluid theme pull`
134
+
135
+ Download theme files from Fluid to your local directory.
136
+
137
+ | Flag | Description |
138
+ |------|-------------|
139
+ | `-t, --theme=THEME_ID` | Pull from a specific theme |
140
+
141
+ ### `fluid theme navigate`
142
+
143
+ Launch an interactive navigator to browse routes and resources for your storefront.
144
+
145
+ | Flag | Description |
146
+ |------|-------------|
147
+ | `--host=HOST` | Host for the navigator server |
148
+ | `--port=PORT` | Port for the navigator server |
149
+ | `-t, --theme=THEME_ID` | Theme to navigate |
150
+
151
+ ## `.fluidignore`
152
+
153
+ You can create a `.fluidignore` file in your theme's root directory to exclude files and directories from all theme operations (dev, push, pull, and file watching). The syntax is identical to `.gitignore`:
154
+
155
+ ```gitignore
156
+ # Ignore node_modules
157
+ node_modules/
158
+
159
+ # Ignore all log files
160
+ *.log
161
+
162
+ # Ignore a specific directory
163
+ tmp/
164
+
165
+ # Negate a pattern (re-include a previously ignored file)
166
+ !important.log
167
+ ```
168
+
169
+ ## Development
170
+
171
+ After cloning the repository:
172
+
173
+ ```sh
174
+ bundle install
175
+
176
+ # Run the CLI locally
177
+ bundle exec fluid
178
+
179
+ # Run tests
180
+ bundle exec rake test
181
+ ```
182
+
183
+ ## License
184
+
185
+ This project is available under the [MIT License](LICENSE.txt).
@@ -24,6 +24,7 @@ module FluidCLI
24
24
  parser.on("-t", "--theme=NAME_OR_ID") { |theme| flags[:theme] = theme }
25
25
  parser.on("-f", "--force") { flags[:force] = true }
26
26
  parser.on("--overwrite-json") { flags[:overwrite_json] = true }
27
+ parser.on("--navigate") { flags[:navigate] = true }
27
28
  end
28
29
 
29
30
  def call(_args, name)
@@ -0,0 +1,57 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "fluid_cli"
4
+
5
+ module FluidCLI
6
+ module Commands
7
+ class Theme
8
+ class Navigate < FluidCLI::Command
9
+ DEFAULT_HTTP_HOST = "127.0.0.1"
10
+ DEFAULT_PORT = 9292
11
+
12
+ options do |parser, flags|
13
+ parser.on("--host=HOST") { |host| flags[:host] = host.to_s }
14
+ parser.on("--port=PORT") { |port| flags[:port] = port.to_i }
15
+ parser.on("-t", "--theme=NAME_OR_ID") { |theme| flags[:theme] = theme }
16
+ end
17
+
18
+ def call(_args, _name)
19
+ valid_authentication_method!
20
+
21
+ flags = options.flags.dup
22
+ host = flags[:host] || DEFAULT_HTTP_HOST
23
+ port = flags[:port] || DEFAULT_PORT
24
+ address = "http://#{host}:#{port}"
25
+
26
+ theme_id = resolve_theme_id(flags[:theme])
27
+
28
+ FluidCLI::Theme::Navigation::RouteNavigator.new(@ctx, address, theme_id).navigate
29
+ end
30
+
31
+ def self.help
32
+ "Interactively select a route and resource to navigate to in the theme dev server."
33
+ end
34
+
35
+ private
36
+
37
+ def valid_authentication_method!
38
+ if FluidCLI::Environment.auth_token.nil? && FluidCLI::DB.get(:jwt).nil?
39
+ FluidCLI::Context.abort("Auth token is missing", "Please login using 'fluid login' command.")
40
+ end
41
+ end
42
+
43
+ def resolve_theme_id(theme_identifier)
44
+ if theme_identifier
45
+ theme = FluidCLI::Theme::Theme.find_by_identifier(@ctx, root: ".", identifier: theme_identifier)
46
+ @ctx.abort("Theme not found: #{theme_identifier}") unless theme
47
+ theme.id
48
+ else
49
+ id = FluidCLI::DB.get(:development_theme_id)
50
+ @ctx.abort("No active development theme found. Run 'fluid theme dev' first, or specify a theme with --theme.") unless id
51
+ id
52
+ end
53
+ end
54
+ end
55
+ end
56
+ end
57
+ end
@@ -22,7 +22,7 @@ module FluidCLI
22
22
  parser.on("-j", "--json") { flags[:json] = true }
23
23
  parser.on("-a", "--allow-live") { flags[:allow_live] = true }
24
24
  parser.on("-p", "--publish") { flags[:publish] = true }
25
- # parser.on("-f", "--force") { flags[:force] = true }
25
+ parser.on("-f", "--force") { flags[:force] = true }
26
26
  # parser.on("--development-theme-id=DEVELOPMENT_THEME_ID") do |development_theme_id|
27
27
  # flags[:development_theme_id] = development_theme_id.to_i
28
28
  # end
@@ -45,7 +45,7 @@ module FluidCLI
45
45
  # return unless CLI::UI::Prompt.confirm(question)
46
46
  # end
47
47
 
48
- syncer = FluidCLI::Theme::Syncer.new(@ctx, theme: theme)
48
+ syncer = FluidCLI::Theme::Syncer.new(@ctx, theme: theme, force: options.flags[:force])
49
49
  begin
50
50
  syncer.start_threads
51
51
  if options.flags[:json]
@@ -3,10 +3,11 @@ require "fluid_cli"
3
3
  module FluidCLI
4
4
  module Commands
5
5
  class Theme < FluidCLI::Command
6
- subcommand :Dev, 'dev', 'fluid_cli/commands/theme/dev'
7
- subcommand :Init, 'init', 'fluid_cli/commands/theme/init'
8
- subcommand :Pull, 'pull', 'fluid_cli/commands/theme/pull'
9
- subcommand :Push, 'push', 'fluid_cli/commands/theme/push'
6
+ subcommand :Dev, 'dev', 'fluid_cli/commands/theme/dev'
7
+ subcommand :Init, 'init', 'fluid_cli/commands/theme/init'
8
+ subcommand :Navigate, 'navigate', 'fluid_cli/commands/theme/navigate'
9
+ subcommand :Pull, 'pull', 'fluid_cli/commands/theme/pull'
10
+ subcommand :Push, 'push', 'fluid_cli/commands/theme/push'
10
11
 
11
12
  def call(*args)
12
13
  self.class.help(*args)
@@ -6,11 +6,14 @@ module FluidCLI
6
6
  class FileSystemListener
7
7
  include Observable
8
8
 
9
- def initialize(root:, force_poll:)
9
+ def initialize(root:, force_poll:, ignore_pattern: nil)
10
10
  @root = root
11
11
  @force_poll = force_poll
12
12
 
13
- @listener = Listen.to(@root, force_polling: @force_poll) do |updated, added, removed|
13
+ listen_opts = { force_polling: @force_poll }
14
+ listen_opts[:ignore] = ignore_pattern if ignore_pattern
15
+
16
+ @listener = Listen.to(@root, **listen_opts) do |updated, added, removed|
14
17
  changed
15
18
  notify_observers(updated, added, removed)
16
19
  end
@@ -15,7 +15,13 @@ module FluidCLI
15
15
  @ctx = ctx
16
16
  @theme = theme
17
17
  @syncer = syncer
18
- @listener = FileSystemListener.new(root: @theme.root, force_poll: poll)
18
+
19
+ ignore_pattern = @theme.fluid_ignore.to_listen_regexp
20
+ @listener = FileSystemListener.new(
21
+ root: @theme.root,
22
+ force_poll: poll,
23
+ ignore_pattern: ignore_pattern
24
+ )
19
25
 
20
26
  add_observer(self, :upload_files_when_changed)
21
27
  end
@@ -28,8 +28,8 @@ module FluidCLI
28
28
  class DevServer
29
29
  include Singleton
30
30
 
31
- attr_reader :app, :stopped, :ctx, :root, :host, :theme_identifier, :port, :poll, :editor_sync, :mode,
32
- :block, :includes, :ignores
31
+ attr_reader :app, :stopped, :ctx, :root, :host, :theme_identifier, :port, :poll, :editor_sync, :force, :mode,
32
+ :block, :includes, :ignores, :navigate
33
33
 
34
34
  class << self
35
35
  def start(
@@ -40,7 +40,9 @@ module FluidCLI
40
40
  port: 9292,
41
41
  poll: false,
42
42
  editor_sync: false,
43
+ force: false,
43
44
  overwrite_json: false,
45
+ navigate: false,
44
46
  mode: ReloadMode.default,
45
47
  includes: nil,
46
48
  ignores: nil,
@@ -54,7 +56,9 @@ module FluidCLI
54
56
  port,
55
57
  poll,
56
58
  editor_sync,
59
+ force,
57
60
  overwrite_json,
61
+ navigate,
58
62
  mode,
59
63
  includes,
60
64
  ignores,
@@ -77,7 +81,9 @@ module FluidCLI
77
81
  port,
78
82
  poll,
79
83
  editor_sync,
84
+ force,
80
85
  overwrite_json,
86
+ navigate,
81
87
  mode,
82
88
  includes,
83
89
  ignores,
@@ -90,7 +96,9 @@ module FluidCLI
90
96
  @port = port
91
97
  @poll = poll
92
98
  @editor_sync = editor_sync
99
+ @force = force
93
100
  @overwrite_json = overwrite_json
101
+ @navigate = navigate
94
102
  @mode = mode
95
103
  @includes = includes
96
104
  @ignores = ignores
@@ -175,10 +183,12 @@ module FluidCLI
175
183
  return if stopped
176
184
 
177
185
  ctx.puts(serving_theme_message)
178
-
186
+
179
187
  ctx.puts(browser_open_message)
180
188
 
181
189
  ctx.puts(preview_message)
190
+
191
+ navigate_to_resource if @navigate
182
192
  end
183
193
  end
184
194
 
@@ -195,7 +205,8 @@ module FluidCLI
195
205
  @syncer ||= Syncer.new(
196
206
  ctx,
197
207
  theme: theme,
198
- overwrite_json: !editor_sync || @overwrite_json
208
+ overwrite_json: !editor_sync || @overwrite_json,
209
+ force: @force
199
210
  )
200
211
  end
201
212
 
@@ -238,6 +249,10 @@ module FluidCLI
238
249
  @address ||= "http://#{host}:#{port}"
239
250
  end
240
251
 
252
+ def navigate_to_resource
253
+ FluidCLI::Theme::Navigation::RouteNavigator.new(ctx, address, theme.id).navigate
254
+ end
255
+
241
256
  # Messages
242
257
 
243
258
  def ensure_user_message
@@ -1,5 +1,6 @@
1
1
  # frozen_string_literal: true
2
2
  require_relative "mime_type"
3
+ require_relative "schema_validator"
3
4
 
4
5
  module FluidCLI
5
6
  module Theme
@@ -7,6 +8,11 @@ module FluidCLI
7
8
  attr_accessor :remote_checksum
8
9
  attr_writer :warnings
9
10
 
11
+ TEMPLATE_TYPES = %w[
12
+ product medium enrollment_pack shop_page library category_page collection_page cart_page
13
+ home_page join_page page collection post category post_page navbar footer
14
+ ].freeze
15
+
10
16
  def initialize(path, root)
11
17
  super(Pathname.new(path))
12
18
 
@@ -71,7 +77,7 @@ module FluidCLI
71
77
  end
72
78
 
73
79
  def template?
74
- relative_path.start_with?("templates/")
80
+ TEMPLATE_TYPES.any? { |type| relative_path.start_with?(type) }
75
81
  end
76
82
 
77
83
  def checksum
@@ -100,6 +106,25 @@ module FluidCLI
100
106
  def warnings
101
107
  @warnings || []
102
108
  end
109
+
110
+ def validate_schema
111
+ return [] if !liquid?
112
+
113
+ FluidCLI::Theme::SchemaValidator
114
+ .new(self, blocks_schema_type: template? ? "object" : "array")
115
+ .validate
116
+ end
117
+
118
+ def validate_schema_and_raise
119
+ diags = validate_schema
120
+ return if diags.empty?
121
+
122
+ message = "Schema validation errors in #{relative_path}:\n"
123
+ diags.each do |diag|
124
+ message += "- [#{diag[:severity]}] #{diag[:message]}\n"
125
+ end
126
+ raise FluidCLI::Theme::SchemaValidationError, message
127
+ end
103
128
  end
104
129
  end
105
130
  end
@@ -0,0 +1,93 @@
1
+ # frozen_string_literal: true
2
+
3
+ module FluidCLI
4
+ module Theme
5
+ class FluidIgnore
6
+ IGNORE_FILE = ".fluidignore"
7
+
8
+ attr_reader :patterns
9
+
10
+ def initialize(root)
11
+ @root = root
12
+ @patterns = parse(ignore_file_path)
13
+ end
14
+
15
+ # Returns true if the given relative path should be ignored.
16
+ # Last matching pattern wins (supports negation with !).
17
+ def ignore?(relative_path)
18
+ relative_path = relative_path.to_s
19
+ result = false
20
+ @patterns.each do |negated, pattern|
21
+ if match?(pattern, relative_path)
22
+ result = !negated
23
+ end
24
+ end
25
+ result
26
+ end
27
+
28
+ # Returns a Regexp suitable for Listen.to(ignore: ...).
29
+ # Returns nil if there are no positive patterns.
30
+ def to_listen_regexp
31
+ positive_patterns = @patterns.reject(&:first).map(&:last)
32
+ return nil if positive_patterns.empty?
33
+
34
+ parts = positive_patterns.map { |pat| fnmatch_to_regex(pat) }
35
+ Regexp.new(parts.join("|"))
36
+ end
37
+
38
+ def any?
39
+ @patterns.any?
40
+ end
41
+
42
+ private
43
+
44
+ def ignore_file_path
45
+ ::File.join(@root.to_s, IGNORE_FILE)
46
+ end
47
+
48
+ def parse(path)
49
+ return [] unless ::File.exist?(path)
50
+
51
+ ::File.readlines(path, chomp: true).filter_map do |line|
52
+ line = line.strip
53
+ next if line.empty? || line.start_with?("#")
54
+
55
+ negated = line.start_with?("!")
56
+ line = line[1..] if negated
57
+
58
+ # Leading slash means relative to root — strip it for fnmatch
59
+ line = line[1..] if line.start_with?("/")
60
+
61
+ [negated, line]
62
+ end
63
+ end
64
+
65
+ def match?(pattern, path)
66
+ if pattern.end_with?("/")
67
+ # Directory pattern — match anything under this directory
68
+ path.start_with?(pattern) || path == pattern.chomp("/")
69
+ elsif pattern.include?("/")
70
+ # Path pattern — match against the full relative path
71
+ ::File.fnmatch(pattern, path, ::File::FNM_PATHNAME | ::File::FNM_DOTMATCH)
72
+ else
73
+ # Basename pattern — match against both full path and basename
74
+ ::File.fnmatch(pattern, path, ::File::FNM_PATHNAME | ::File::FNM_DOTMATCH) ||
75
+ ::File.fnmatch(pattern, ::File.basename(path), ::File::FNM_DOTMATCH)
76
+ end
77
+ end
78
+
79
+ def fnmatch_to_regex(pattern)
80
+ if pattern.end_with?("/")
81
+ # Directory pattern — match the directory and anything under it
82
+ Regexp.escape(pattern) + ".*"
83
+ else
84
+ re = Regexp.escape(pattern)
85
+ re.gsub("\\*\\*", "DOUBLE_STAR")
86
+ .gsub("\\*", "[^/]*")
87
+ .gsub("DOUBLE_STAR", ".*")
88
+ .gsub("\\?", "[^/]")
89
+ end
90
+ end
91
+ end
92
+ end
93
+ end
@@ -0,0 +1,25 @@
1
+ # frozen_string_literal: true
2
+
3
+ module FluidCLI
4
+ module Theme
5
+ module Navigation
6
+ class ResourceFetcher
7
+ def initialize(ctx, theme_id)
8
+ @ctx = ctx
9
+ @theme_id = theme_id
10
+ end
11
+
12
+ def fetch(themeable_type)
13
+ _status, body = FluidCLI::API.new(@ctx).get(
14
+ path: "application_themes/#{@theme_id}/available_themeables",
15
+ query: URI.encode_www_form(themeable: themeable_type, per_page: 50),
16
+ )
17
+ body["available_themeables"] || []
18
+ rescue FluidCLI::API::APIRequestError => e
19
+ @ctx.debug("Failed to fetch #{themeable_type} themeables: #{e.message}")
20
+ []
21
+ end
22
+ end
23
+ end
24
+ end
25
+ end
@@ -0,0 +1,87 @@
1
+ # frozen_string_literal: true
2
+
3
+ module FluidCLI
4
+ module Theme
5
+ module Navigation
6
+ class RouteNavigator
7
+ STATIC_ROUTES = [
8
+ { label: "Home", path: "/home" },
9
+ { label: "Shop", path: "/home/shop" },
10
+ { label: "Join / Sign Up", path: "/home/join" },
11
+ { label: "Cart", path: "/cart" },
12
+ { label: "Blog", path: "/home/blog" },
13
+ { label: "Categories (all)", path: "/home/categories" },
14
+ { label: "Collections (all)", path: "/home/collections" },
15
+ ].freeze
16
+
17
+ RESOURCE_ROUTES = [
18
+ { label: "Category", themeable_type: "category", path_template: "/home/categories/%<id>s", fallback_path: "/home/categories" },
19
+ { label: "Collection", themeable_type: "collection", path_template: "/home/collections/%<id>s", fallback_path: "/home/collections" },
20
+ { label: "Product", themeable_type: "product", path_template: "/home/products/%<id>s", fallback_path: "/home/shop" },
21
+ { label: "Library", themeable_type: "library", path_template: "/home/libraries/%<id>s", fallback_path: "/home/libraries" },
22
+ { label: "Post", themeable_type: "post", path_template: "/home/posts/%<id>s", fallback_path: "/home/blog" },
23
+ { label: "Media", themeable_type: "medium", path_template: "/home/media/%<id>s", fallback_path: "/home/media" },
24
+ { label: "Enrollment Pack", themeable_type: "enrollment_pack", path_template: "/home/enrollments/%<id>s", fallback_path: "/home/join" },
25
+ ].freeze
26
+
27
+ def initialize(ctx, address, theme_id)
28
+ @ctx = ctx
29
+ @address = address
30
+ @theme_id = theme_id
31
+ end
32
+
33
+ def navigate
34
+ path = select_destination
35
+ return unless path
36
+
37
+ url = "#{@address}#{path}"
38
+ @ctx.puts("\n{{bold:Navigate to: {{green:#{url}}}}}\n")
39
+ open_browser(url)
40
+ rescue => e
41
+ @ctx.debug("Navigation error: #{e.message}")
42
+ end
43
+
44
+ private
45
+
46
+ def select_destination
47
+ all_options = STATIC_ROUTES.map { |r| [:static, r] } + RESOURCE_ROUTES.map { |r| [:resource, r] }
48
+ type, data = CLI::UI::Prompt.ask("Select a route to navigate to") do |handler|
49
+ all_options.each { |t, r| handler.option(option_label(t, r)) { [t, r] } }
50
+ end
51
+ type == :static ? data[:path] : resolve_resource_path(data)
52
+ end
53
+
54
+ def option_label(type, data)
55
+ type == :static ? data[:label] : "#{data[:label]} (select specific)"
56
+ end
57
+
58
+ def resolve_resource_path(route)
59
+ resources = ResourceFetcher.new(@ctx, @theme_id).fetch(route[:themeable_type])
60
+
61
+ if resources.empty?
62
+ @ctx.puts("{{yellow:No #{route[:label].downcase} resources found, navigating to listing page.}}")
63
+ return route[:fallback_path]
64
+ end
65
+
66
+ resource = CLI::UI::Prompt.ask("Select a #{route[:label].downcase}") do |handler|
67
+ resources.each { |r| handler.option(display_name(r)) { r } }
68
+ end
69
+
70
+ format(route[:path_template], id: resource["slug"])
71
+ end
72
+
73
+ def display_name(resource)
74
+ resource["title"] || resource["id"].to_s
75
+ end
76
+
77
+ def open_browser(url)
78
+ case RbConfig::CONFIG["host_os"]
79
+ when /darwin/ then system("open", url)
80
+ when /linux/ then system("xdg-open", url)
81
+ when /mswin|mingw|cygwin/ then system("start", "", url)
82
+ end
83
+ end
84
+ end
85
+ end
86
+ end
87
+ end
@@ -1,5 +1,6 @@
1
1
  # frozen_string_literal: true
2
2
  require_relative "file"
3
+ require_relative "fluid_ignore"
3
4
  require "pathname"
4
5
 
5
6
  module FluidCLI
@@ -24,11 +25,16 @@ module FluidCLI
24
25
  glob("**/*.json")
25
26
  end
26
27
 
28
+ def fluid_ignore
29
+ @fluid_ignore ||= FluidIgnore.new(root)
30
+ end
31
+
27
32
  def glob(pattern, raise_on_dir: false)
28
33
  root
29
34
  .glob(pattern)
30
35
  .select { |path| file?(path, raise_on_dir) }
31
36
  .map { |path| File.new(path, root) }
37
+ .reject { |file| fluid_ignore.ignore?(file.relative_path) }
32
38
  end
33
39
 
34
40
  def static_asset_file?(file)
@@ -0,0 +1,274 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "json"
4
+
5
+ module FluidCLI
6
+ module Theme
7
+
8
+ class SchemaValidator
9
+ Diagnostic = Struct.new(:severity, :message, keyword_init: true)
10
+
11
+ VALID_SETTING_TYPES = %w[
12
+ text rich_text richtext textarea range select checkbox color text_alignment
13
+ color_background radio url image image_picker video_picker font_picker html
14
+ html_textarea link_list header blog posts posts_list products products_list
15
+ product product_list forms collections collections_list collection
16
+ collection_list categories categories_list category category_list enrollments
17
+ enrollments_list enrollment_pack enrollment enrollment_list
18
+ ].freeze
19
+
20
+ def initialize(file, blocks_schema_type: "unknown")
21
+ @file = file
22
+ @blocks_schema_type = blocks_schema_type
23
+ end
24
+
25
+ def validate
26
+ text = @file.read.to_s
27
+ diagnostics = []
28
+
29
+ match = text.match(/{% schema %}([\s\S]*?){% endschema %}/)
30
+
31
+ return diagnostics.map(&:to_h) unless match
32
+
33
+ json_text = match[1] || ""
34
+
35
+ schema = nil
36
+ begin
37
+ schema = JSON.parse(json_text)
38
+ rescue JSON::ParserError => e
39
+ diagnostics << Diagnostic.new(severity: "error", message: "Invalid JSON:\n #{e.message}")
40
+ return diagnostics.map(&:to_h)
41
+ end
42
+
43
+ scan = JsonTokenScan.scan(json_text)
44
+
45
+ scan.duplicate_blocks_key_tokens.each do
46
+ diagnostics << Diagnostic.new(
47
+ severity: "error",
48
+ message: "Error in blocks: duplicate 'blocks' key in the same object"
49
+ )
50
+ end
51
+
52
+ if schema.is_a?(Hash) && schema["settings"].is_a?(Array)
53
+ diagnostics.concat(validate_settings(schema["settings"]))
54
+ end
55
+
56
+ if @blocks_schema_type == "object" && !schema["blocks"].is_a?(Hash)
57
+ scan.blocks_array_value_tokens.each do
58
+ diagnostics << Diagnostic.new(
59
+ severity: "error",
60
+ message: "Error in blocks: expected an object ({})"
61
+ )
62
+ end
63
+ end
64
+
65
+ if schema.is_a?(Hash) && schema.key?("blocks")
66
+ blocks = schema["blocks"]
67
+
68
+ case @blocks_schema_type
69
+ when "array"
70
+ if !blocks.is_a?(Array)
71
+ diagnostics << Diagnostic.new(
72
+ severity: "error",
73
+ message: "Error in blocks: expected an array ([])"
74
+ )
75
+ else
76
+ diagnostics.concat(validate_blocks(blocks))
77
+ end
78
+ when "object"
79
+ unless blocks.is_a?(Hash)
80
+ diagnostics << Diagnostic.new(
81
+ severity: "error",
82
+ message: "Error in blocks: expected an object ({})"
83
+ )
84
+ end
85
+ else # "unknown"
86
+ diagnostics.concat(validate_blocks(blocks)) if blocks.is_a?(Array)
87
+ end
88
+ end
89
+
90
+ diagnostics.map(&:to_h)
91
+ end
92
+
93
+ private
94
+
95
+ def validate_settings(settings)
96
+ diagnostics = []
97
+ ids = {}
98
+
99
+ settings.each_with_index do |setting, index|
100
+ setting ||= {}
101
+ setting = {} unless setting.is_a?(Hash)
102
+
103
+ id = setting["id"]
104
+ type = setting["type"]
105
+
106
+ if id.is_a?(String) && id.strip.empty?
107
+ diagnostics << Diagnostic.new(
108
+ severity: "error",
109
+ message: "Error in settings: id cannot be empty"
110
+ )
111
+ elsif id && ids.key?(id)
112
+ diagnostics << Diagnostic.new(
113
+ severity: "error",
114
+ message: "Error in settings: duplicate id '#{id}' found"
115
+ )
116
+ elsif id
117
+ ids[id] = true
118
+ end
119
+
120
+ if !type
121
+ diagnostics << Diagnostic.new(
122
+ severity: "error",
123
+ message: "Error in setting '#{id || index}': missing required field 'type'"
124
+ )
125
+ elsif !VALID_SETTING_TYPES.include?(type)
126
+ diagnostics << Diagnostic.new(
127
+ severity: "error",
128
+ message: "Invalid settings type: '#{type}'"
129
+ )
130
+ end
131
+ end
132
+
133
+ diagnostics
134
+ end
135
+
136
+ def validate_blocks(blocks)
137
+ diagnostics = []
138
+ types = {}
139
+
140
+ blocks.each_with_index do |block, index|
141
+ block ||= {}
142
+ block = {} unless block.is_a?(Hash)
143
+
144
+ type = block["type"]
145
+ name = block["name"]
146
+
147
+ if !type
148
+ diagnostics << Diagnostic.new(
149
+ severity: "error",
150
+ message: "Error in blocks at index #{index}: missing required field 'type'"
151
+ )
152
+ elsif types.key?(type)
153
+ diagnostics << Diagnostic.new(
154
+ severity: "warning",
155
+ message: "Warning in blocks: duplicate type '#{type}' found"
156
+ )
157
+ else
158
+ types[type] = true
159
+ end
160
+
161
+ if !name
162
+ diagnostics << Diagnostic.new(
163
+ severity: "error",
164
+ message: "Error in block '#{type || index}': missing required field 'name'"
165
+ )
166
+ end
167
+
168
+ if block["settings"].is_a?(Array)
169
+ diagnostics.concat(validate_settings(block["settings"]))
170
+ end
171
+ end
172
+
173
+ diagnostics
174
+ end
175
+
176
+ # Minimal token scanner:
177
+ # - detects duplicate "blocks" keys inside the same object
178
+ # - detects when "blocks" value starts with an array ([)
179
+ class JsonTokenScan
180
+ Token = Struct.new(:value)
181
+
182
+ attr_reader :duplicate_blocks_key_tokens, :blocks_array_value_tokens
183
+
184
+ def initialize
185
+ @duplicate_blocks_key_tokens = []
186
+ @blocks_array_value_tokens = []
187
+ end
188
+
189
+ def self.scan(json_text)
190
+ new.tap { |s| s.scan!(json_text) }
191
+ end
192
+
193
+ def scan!(s)
194
+ stack = []
195
+ pending_key = nil
196
+ i = 0
197
+
198
+ while i < s.length
199
+ ch = s.getbyte(i)
200
+
201
+ if whitespace?(ch)
202
+ i += 1
203
+ next
204
+ end
205
+
206
+ case ch
207
+ when 0x7B # {
208
+ stack << { type: :object, keys: {}, expecting_key: true }
209
+ pending_key = nil
210
+ i += 1
211
+ when 0x7D # }
212
+ stack.pop
213
+ pending_key = nil
214
+ i += 1
215
+ when 0x5B # [
216
+ if pending_key == "blocks"
217
+ @blocks_array_value_tokens << Token.new("blocks-array")
218
+ end
219
+ pending_key = nil
220
+ stack << { type: :array }
221
+ i += 1
222
+ when 0x5D # ]
223
+ stack.pop
224
+ pending_key = nil
225
+ i += 1
226
+ when 0x3A # :
227
+ # after colon, we are reading a value next
228
+ i += 1
229
+ when 0x2C # ,
230
+ if stack.last&.dig(:type) == :object
231
+ stack.last[:expecting_key] = true
232
+ end
233
+ pending_key = nil
234
+ i += 1
235
+ when 0x22 # "
236
+ str, i = read_string(s, i)
237
+
238
+ if stack.last&.dig(:type) == :object && stack.last[:expecting_key]
239
+ # this string is a key
240
+ if str == "blocks" && stack.last[:keys][str]
241
+ @duplicate_blocks_key_tokens << Token.new(str)
242
+ end
243
+ stack.last[:keys][str] = true
244
+ stack.last[:expecting_key] = false
245
+ pending_key = str
246
+ else
247
+ pending_key = nil
248
+ end
249
+ else
250
+ # literals / numbers / etc.
251
+ pending_key = nil
252
+ i += 1
253
+ end
254
+ end
255
+ end
256
+
257
+ def read_string(s, start)
258
+ i = start + 1
259
+ while i < s.length
260
+ break if s.getbyte(i) == 0x22 && s.getbyte(i - 1) != 0x5C
261
+ i += 1
262
+ end
263
+ [s[start + 1...i], i + 1]
264
+ end
265
+
266
+ def whitespace?(ch)
267
+ ch == 0x20 || ch == 0x0A || ch == 0x0D || ch == 0x09
268
+ end
269
+ end
270
+ end
271
+
272
+ class SchemaValidationError < StandardError; end
273
+ end
274
+ end
@@ -31,10 +31,11 @@ module FluidCLI
31
31
 
32
32
  def_delegators :@error_reporter, :has_any_error?
33
33
 
34
- def initialize(ctx, theme:, overwrite_json: true)
34
+ def initialize(ctx, theme:, overwrite_json: true, force: false)
35
35
  @ctx = ctx
36
36
  @theme = theme
37
37
  @overwrite_json = overwrite_json
38
+ @force = force
38
39
  @error_reporter = ErrorReporter.new(ctx)
39
40
  @standard_reporter = StandardReporter.new(ctx)
40
41
  @reporters = [@error_reporter, @standard_reporter]
@@ -230,12 +231,15 @@ module FluidCLI
230
231
  else
231
232
  JSON.parse(response&.body)
232
233
  end
233
- end
234
-
235
- errors = parsed_body.dig("errors") # either nil or another type
236
- errors = errors.dig("asset") if errors&.is_a?(Hash)
234
+ errors = parsed_body.dig("errors") # either nil or another type
235
+ errors = errors.dig("asset") if errors&.is_a?(Hash)
237
236
 
238
- ["#{parsed_body['error_message']}\n#{errors}"]
237
+ ["#{parsed_body['error_message']}\n#{errors}"]
238
+ elsif exception.respond_to?(:message)
239
+ [exception.message]
240
+ else
241
+ ["An unknown error occurred while syncing the asset #{file}"]
242
+ end
239
243
  rescue JSON::ParserError
240
244
  [exception.message]
241
245
  rescue StandardError => e
@@ -310,6 +314,10 @@ module FluidCLI
310
314
  path = "application_themes/#{@theme.id}/resources"
311
315
 
312
316
  if file.text?
317
+ if file.liquid? && !@force
318
+ file.validate_schema_and_raise
319
+ end
320
+
313
321
  application_theme_resource[:content] = file.read
314
322
  _status, body, response = api_client.put(
315
323
  path: path,
@@ -1,3 +1,3 @@
1
1
  module FluidCLI
2
- VERSION = "0.1.8"
2
+ VERSION = "0.2.0"
3
3
  end
data/lib/fluid_cli.rb CHANGED
@@ -32,6 +32,11 @@ module FluidCLI
32
32
  module UI
33
33
  autoload(:SyncProgressBar, 'fluid_cli/theme/ui/sync_progress_bar')
34
34
  end
35
+
36
+ module Navigation
37
+ autoload(:RouteNavigator, 'fluid_cli/theme/navigation/route_navigator')
38
+ autoload(:ResourceFetcher, 'fluid_cli/theme/navigation/resource_fetcher')
39
+ end
35
40
  end
36
41
 
37
42
  def self.cache_dir
metadata CHANGED
@@ -1,13 +1,13 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: fluid_cli
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.1.8
4
+ version: 0.2.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Fluid
8
8
  bindir: exe
9
9
  cert_chain: []
10
- date: 2026-01-28 00:00:00.000000000 Z
10
+ date: 2026-03-02 00:00:00.000000000 Z
11
11
  dependencies:
12
12
  - !ruby/object:Gem::Dependency
13
13
  name: bugsnag
@@ -105,6 +105,7 @@ files:
105
105
  - lib/fluid_cli/commands/theme/dev.rb
106
106
  - lib/fluid_cli/commands/theme/help.rb
107
107
  - lib/fluid_cli/commands/theme/init.rb
108
+ - lib/fluid_cli/commands/theme/navigate.rb
108
109
  - lib/fluid_cli/commands/theme/pull.rb
109
110
  - lib/fluid_cli/commands/theme/push.rb
110
111
  - lib/fluid_cli/commands/whoami.rb
@@ -141,11 +142,15 @@ files:
141
142
  - lib/fluid_cli/theme/dev_server/web_server.rb
142
143
  - lib/fluid_cli/theme/development_theme.rb
143
144
  - lib/fluid_cli/theme/file.rb
145
+ - lib/fluid_cli/theme/fluid_ignore.rb
144
146
  - lib/fluid_cli/theme/forms/select.rb
145
147
  - lib/fluid_cli/theme/mime_type.rb
148
+ - lib/fluid_cli/theme/navigation/resource_fetcher.rb
149
+ - lib/fluid_cli/theme/navigation/route_navigator.rb
146
150
  - lib/fluid_cli/theme/presenters/theme_presenter.rb
147
151
  - lib/fluid_cli/theme/presenters/themes_presenter.rb
148
152
  - lib/fluid_cli/theme/root.rb
153
+ - lib/fluid_cli/theme/schema_validator.rb
149
154
  - lib/fluid_cli/theme/syncer.rb
150
155
  - lib/fluid_cli/theme/syncer/checksums.rb
151
156
  - lib/fluid_cli/theme/syncer/downloader.rb