ruflet 0.0.17 → 0.0.19

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.
@@ -6,8 +6,6 @@ module Ruflet
6
6
  module CLI
7
7
  module ExtraCommand
8
8
  include FlutterSdk
9
- include EnvironmentSetup
10
- include AndroidSdk
11
9
  include NewCommand
12
10
 
13
11
  def command_create(args)
@@ -18,26 +16,23 @@ module Ruflet
18
16
  verbose = args.delete("--verbose") || args.delete("-v")
19
17
  fix = args.delete("--fix")
20
18
  client_dir = detect_client_dir
21
- template_root = resolve_ruflet_client_template_root
19
+ template_root =
20
+ if fix
21
+ ensure_cached_ruflet_client_template!(force: true, verbose: !!verbose)
22
+ else
23
+ resolve_ruflet_client_template_root
24
+ end
22
25
  puts "Ruflet doctor"
23
26
  puts " Ruby: #{RUBY_VERSION}"
24
27
  puts " Flutter host target: #{flutter_host || 'unsupported'}"
25
-
26
- # System prerequisites first: Flutter cannot be installed (or even
27
- # extracted) without them.
28
- environment_issues = environment_setup!(fix: !!fix, verbose: !!verbose)
29
- return 1 unless flutter_host
30
-
31
28
  if template_root
32
29
  puts " Template: #{template_root}"
30
+ revision = cached_template_revision if respond_to?(:cached_template_revision, true)
31
+ puts " Template revision: #{revision}" if revision
33
32
  elsif fix
34
- template_root = ensure_cached_ruflet_client_template!(force: true, verbose: !!verbose)
35
- unless template_root
36
- warn " Template: missing"
37
- warn "Failed to fetch the Ruflet Flutter template from GitHub."
38
- return 1
39
- end
40
- puts " Template: #{template_root}"
33
+ warn " Template: missing"
34
+ warn "Failed to fetch the Ruflet Flutter template from GitHub."
35
+ return 1
41
36
  else
42
37
  warn " Template: missing"
43
38
  warn "Run `ruflet doctor --fix` to fetch the Flutter template from GitHub."
@@ -54,13 +49,9 @@ module Ruflet
54
49
  end
55
50
  end
56
51
  puts " Flutter: #{flutter_version_summary(tools)}"
57
- environment_issues += android_environment_setup!(fix: !!fix, verbose: !!verbose)
58
- ok = system(android_build_env(tools[:env]), tools[:flutter], "doctor", *(verbose ? ["-v"] : []))
59
- status = ok ? 0 : ($?&.exitstatus || 1)
60
- if environment_issues.any?
61
- warn "Unresolved environment issues: #{environment_issues.join('; ')}"
62
- status = 1 if status.zero?
63
- end
52
+ ok = system(tools[:env], tools[:flutter], "doctor", *(verbose ? ["-v"] : []))
53
+ status = $?.exitstatus if $?
54
+ status ||= ok ? 0 : 1
64
55
  status
65
56
  end
66
57
 
@@ -47,9 +47,18 @@ module Ruflet
47
47
  private
48
48
 
49
49
  def flutter_tools(client_dir: nil, auto_install: true)
50
- # Prefer FVM when available so Flutter/Dart match a pinned SDK.
51
- fvm_tools = flutter_tools_via_fvm(client_dir: client_dir, auto_install: auto_install)
52
- return fvm_tools if fvm_tools
50
+ desired = desired_flutter_spec(client_dir: client_dir)
51
+
52
+ # Environment and .fvmrc versions are intentional pins. Flutter's
53
+ # generated .metadata revision only records which SDK created the
54
+ # project; it must not force every build to download that old SDK.
55
+ if %i[env fvmrc].include?(desired[:source])
56
+ fvm_tools = flutter_tools_via_fvm(client_dir: client_dir, auto_install: auto_install)
57
+ return fvm_tools if fvm_tools
58
+ else
59
+ system_tools = flutter_tools_via_system
60
+ return system_tools if system_tools
61
+ end
53
62
 
54
63
  managed_tools = flutter_tools_via_managed_sdk(client_dir: client_dir, auto_install: auto_install)
55
64
  return managed_tools if managed_tools
@@ -57,6 +66,13 @@ module Ruflet
57
66
  nil
58
67
  end
59
68
 
69
+ def flutter_tools_via_system
70
+ flutter = which_command("flutter")
71
+ return nil unless flutter
72
+
73
+ tools_from_flutter_bin(flutter)
74
+ end
75
+
60
76
  def flutter_tools_via_managed_sdk(client_dir: nil, auto_install: true)
61
77
  return nil unless auto_install
62
78
 
@@ -84,12 +100,8 @@ module Ruflet
84
100
  File.write(fvmrc_path, "{\"flutter\":\"#{version}\"}\n")
85
101
  end
86
102
 
87
- # Installing a Flutter SDK through FVM downloads/extracts hundreds of
88
- # megabytes. Stream FVM's output so the run does not look frozen.
89
- puts " $ fvm install #{version}"
90
- system(fvm_env, fvm, "install", version.to_s, chdir: project_dir, out: $stdout, err: $stderr)
91
- puts " $ fvm use --force #{version}"
92
- system(fvm_env, fvm, "use", "--force", version.to_s, chdir: project_dir, out: $stdout, err: $stderr)
103
+ system(fvm_env, fvm, "install", version.to_s, chdir: project_dir, out: File::NULL, err: File::NULL)
104
+ system(fvm_env, fvm, "use", "--force", version.to_s, chdir: project_dir, out: File::NULL, err: File::NULL)
93
105
 
94
106
  flutter = flutter_bin_path(project_dir)
95
107
  return nil unless File.executable?(flutter)
@@ -111,8 +123,7 @@ module Ruflet
111
123
  end
112
124
  return nil unless dart && File.executable?(dart)
113
125
 
114
- puts " $ dart pub global activate fvm"
115
- system(dart, "pub", "global", "activate", "fvm", out: $stdout, err: $stderr)
126
+ system(dart, "pub", "global", "activate", "fvm", out: File::NULL, err: File::NULL)
116
127
  installed_fvm = File.join(pub_cache_bin_dir, fvm_executable_name)
117
128
  return installed_fvm if File.executable?(installed_fvm)
118
129
 
@@ -153,13 +164,10 @@ module Ruflet
153
164
  return sdk_root if File.executable?(flutter_bin)
154
165
 
155
166
  FileUtils.mkdir_p(install_root)
156
- puts " Installing Flutter #{release.fetch('version')} (#{host}) into #{install_root}"
157
167
  Dir.mktmpdir("ruflet-flutter-sdk-") do |tmpdir|
158
168
  archive_path = File.join(tmpdir, File.basename(archive))
159
- download_file("#{RELEASES_BASE}/#{archive}", archive_path, label: "Flutter SDK")
160
- puts " Extracting #{File.basename(archive)} (this can take a minute)"
169
+ download_file("#{RELEASES_BASE}/#{archive}", archive_path)
161
170
  extract_archive(archive_path, install_root)
162
- puts " Extracted Flutter SDK"
163
171
  end
164
172
 
165
173
  return sdk_root if File.executable?(flutter_bin)
@@ -207,7 +215,12 @@ module Ruflet
207
215
  return { version: fvm, source: :fvmrc } if fvm
208
216
 
209
217
  metadata = parse_flutter_metadata(find_flutter_metadata(client_dir))
210
- return metadata.merge(source: :metadata) if metadata
218
+ if metadata
219
+ return {
220
+ channel: metadata[:channel] || DEFAULT_FLUTTER_CHANNEL,
221
+ source: :metadata
222
+ }
223
+ end
211
224
 
212
225
  { channel: DEFAULT_FLUTTER_CHANNEL, version: DEFAULT_FLUTTER_CHANNEL, source: :default }
213
226
  end
@@ -297,8 +310,6 @@ module Ruflet
297
310
  return JSON.parse(response.body) if response.is_a?(Net::HTTPSuccess)
298
311
 
299
312
  nil
300
- rescue OpenSSL::SSL::SSLError
301
- JSON.parse(curl_get(url, headers: ["User-Agent: ruflet-cli"]))
302
313
  end
303
314
 
304
315
  def pick_release(manifest, version: nil, revision: nil, channel: nil)
@@ -330,10 +341,7 @@ module Ruflet
330
341
  if os.match?(/darwin/i)
331
342
  return machine_arch.include?("arm") ? "macos_arm64" : "macos"
332
343
  end
333
- if os.match?(/linux/i)
334
- # Flutter publishes Linux archives for x64 only.
335
- return machine_arch.match?(/arm|aarch64/) ? nil : "linux"
336
- end
344
+ return "linux" if os.match?(/linux/i)
337
345
  return "windows" if os.match?(/mswin|mingw|cygwin/i)
338
346
 
339
347
  nil
@@ -374,7 +382,7 @@ module Ruflet
374
382
  nil
375
383
  end
376
384
 
377
- def download_file(url, destination, limit: 5, label: nil)
385
+ def download_file(url, destination, limit: 5)
378
386
  raise "Too many redirects while downloading #{url}" if limit <= 0
379
387
 
380
388
  uri = URI(url)
@@ -384,73 +392,15 @@ module Ruflet
384
392
  http.request(req) do |res|
385
393
  case res
386
394
  when Net::HTTPSuccess
387
- name = label || File.basename(destination)
388
- total = res["content-length"].to_i
389
- downloaded = 0
390
- marker = -1
391
- File.open(destination, "wb") do |f|
392
- res.read_body do |chunk|
393
- f.write(chunk)
394
- downloaded += chunk.bytesize
395
- marker = report_download_progress(name, downloaded, total, marker)
396
- end
397
- end
398
- finish_download_progress(name, downloaded, total)
395
+ File.open(destination, "wb") { |f| res.read_body { |chunk| f.write(chunk) } }
399
396
  return destination
400
397
  when Net::HTTPRedirection
401
- return download_file(res["location"], destination, limit: limit - 1, label: label)
398
+ return download_file(res["location"], destination, limit: limit - 1)
402
399
  else
403
400
  raise "Download failed (#{res.code})"
404
401
  end
405
402
  end
406
403
  end
407
- rescue OpenSSL::SSL::SSLError
408
- curl_download(url, destination)
409
- end
410
-
411
- # Net::HTTP gives no progress for a multi-hundred-MB SDK download, which
412
- # looks like a hang. Report percentage on a TTY (rewritten in place) and
413
- # at coarse steps when piped, so logs stay readable either way.
414
- def report_download_progress(name, downloaded, total, marker)
415
- if total.positive?
416
- percent = downloaded * 100 / total
417
- return marker if percent <= marker
418
-
419
- line = " Downloading #{name}: #{percent}% (#{human_size(downloaded)} / #{human_size(total)})"
420
- if $stdout.tty?
421
- $stdout.print("\r#{line}")
422
- elsif (percent % 10).zero?
423
- puts line
424
- end
425
- percent
426
- else
427
- step = downloaded / (5 * 1024 * 1024)
428
- return marker if step <= marker
429
-
430
- line = " Downloading #{name}: #{human_size(downloaded)}"
431
- $stdout.tty? ? $stdout.print("\r#{line}") : puts(line)
432
- step
433
- end
434
- end
435
-
436
- def finish_download_progress(name, downloaded, total)
437
- summary = total.positive? ? "#{human_size(downloaded)} / #{human_size(total)}" : human_size(downloaded)
438
- if $stdout.tty?
439
- $stdout.print("\r Downloaded #{name}: #{summary}\n")
440
- else
441
- puts " Downloaded #{name}: #{summary}"
442
- end
443
- end
444
-
445
- def human_size(bytes)
446
- units = %w[B KB MB GB TB]
447
- size = bytes.to_f
448
- unit = units.shift
449
- while size >= 1024 && units.any?
450
- size /= 1024
451
- unit = units.shift
452
- end
453
- format("%.1f %s", size, unit)
454
404
  end
455
405
 
456
406
  def extract_archive(archive, destination)
@@ -458,25 +408,14 @@ module Ruflet
458
408
  if windows_host?
459
409
  return system("powershell", "-NoProfile", "-Command", "Expand-Archive -Path '#{archive}' -DestinationPath '#{destination}' -Force")
460
410
  end
461
-
462
- require_extract_tool!("unzip")
463
411
  return system("unzip", "-oq", archive, "-d", destination)
464
412
  end
465
413
 
466
414
  if archive.end_with?(".tar.xz") || archive.end_with?(".tar.gz") || archive.end_with?(".tgz")
467
- require_extract_tool!("tar")
468
- require_extract_tool!("xz") if archive.end_with?(".tar.xz") && !windows_host?
469
415
  return system("tar", "-xf", archive, "-C", destination)
470
416
  end
471
417
 
472
- raise "Unsupported archive format: #{File.basename(archive)}"
473
- end
474
-
475
- def require_extract_tool!(name)
476
- return if which_command(name)
477
-
478
- raise "`#{name}` is required to extract the Flutter SDK but was not found. " \
479
- "Run `ruflet doctor --fix` to install the missing system tools."
418
+ false
480
419
  end
481
420
  end
482
421
  end
@@ -1,6 +1,7 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  require "fileutils"
4
+ require "open3"
4
5
  require "tmpdir"
5
6
  require "yaml"
6
7
 
@@ -8,10 +9,10 @@ module Ruflet
8
9
  module CLI
9
10
  module NewCommand
10
11
  TEMPLATE_REPO_URL = ENV.fetch("RUFLET_TEMPLATE_REPO_URL", "https://github.com/AdamMusa/ruflet-template.git")
12
+ TEMPLATE_REPO_REF = ENV.fetch("RUFLET_TEMPLATE_REPO_REF", "main")
11
13
  RUNTIME_REPO_URL = ENV.fetch("RUFLET_RUNTIME_REPO_URL", "https://github.com/AdamMusa/ruflet.git")
12
14
 
13
15
  CLIENT_EXTENSION_MAP = {
14
- "ads" => { package: "flet_ads", alias: "ruflet_ads" },
15
16
  "audio" => { package: "flet_audio", alias: "ruflet_audio" },
16
17
  "audio_recorder" => { package: "flet_audio_recorder", alias: "ruflet_audio_recorder" },
17
18
  "camera" => { package: "flet_camera", alias: "ruflet_camera" },
@@ -24,8 +25,8 @@ module Ruflet
24
25
  "lottie" => { package: "flet_lottie", alias: "ruflet_lottie" },
25
26
  "map" => { package: "flet_map", alias: "ruflet_map" },
26
27
  "permission_handler" => { package: "flet_permission_handler", alias: "ruflet_permission_handler" },
28
+ "rive" => { package: "flet_rive", alias: "ruflet_rive" },
27
29
  "secure_storage" => { package: "flet_secure_storage", alias: "ruflet_secure_storage" },
28
- "spinkit" => { package: "flet_spinkit", alias: "ruflet_spinkit" },
29
30
  "video" => { package: "flet_video", alias: "ruflet_video" },
30
31
  "webview" => { package: "flet_webview", alias: "ruflet_webview" }
31
32
  }.freeze
@@ -69,8 +70,10 @@ module Ruflet
69
70
 
70
71
  target = hidden_flutter_client_dir(root)
71
72
  FileUtils.mkdir_p(File.dirname(target))
73
+ FileUtils.rm_rf(target)
72
74
  FileUtils.cp_r(template_root, target)
73
75
  prune_client_template(target)
76
+ write_client_template_revision(target, installed_template_revision(template_root))
74
77
  end
75
78
 
76
79
  def hidden_flutter_client_dir(root = Dir.pwd)
@@ -85,20 +88,7 @@ module Ruflet
85
88
  end
86
89
 
87
90
  cached_template = cached_ruflet_client_template_root
88
- if Dir.exist?(cached_template)
89
- return cached_template if cached_template_current?(cached_template)
90
-
91
- # The cache was written by a different ruflet version; stale
92
- # framework Dart breaks newer asset layouts. Refresh, but never
93
- # block a build on the network: fall back to the stale copy loudly.
94
- refreshed = download_ruflet_template(force: true)
95
- return refreshed if refreshed && Dir.exist?(refreshed)
96
-
97
- warn "ruflet: could not refresh the Flutter client template cache; " \
98
- "using a stale copy from another ruflet version at #{cached_template}. " \
99
- "Delete it or run with network access to update."
100
- return cached_template
101
- end
91
+ return cached_template if Dir.exist?(cached_template)
102
92
 
103
93
  [
104
94
  File.expand_path("../../../ruflet_client", __dir__),
@@ -112,8 +102,6 @@ module Ruflet
112
102
 
113
103
  def ensure_cached_ruflet_client_template!(force: false, verbose: false)
114
104
  cached_template = cached_ruflet_client_template_root
115
- return cached_template if !force && Dir.exist?(cached_template)
116
-
117
105
  download_ruflet_template(force: force, verbose: verbose)
118
106
  Dir.exist?(cached_template) ? cached_template : nil
119
107
  end
@@ -126,19 +114,8 @@ module Ruflet
126
114
  File.join(template_cache_root, "ruflet_flutter_template")
127
115
  end
128
116
 
129
- TEMPLATE_CACHE_STAMP = ".ruflet_template_version"
130
-
131
- def cached_template_current?(target)
132
- stamp = File.join(target, TEMPLATE_CACHE_STAMP)
133
- File.file?(stamp) && File.read(stamp).strip == Ruflet::VERSION
134
- rescue StandardError
135
- false
136
- end
137
-
138
- def write_template_cache_stamp(target)
139
- File.write(File.join(target, TEMPLATE_CACHE_STAMP), Ruflet::VERSION)
140
- rescue StandardError
141
- nil
117
+ def cached_ruflet_client_template_revision_path
118
+ File.join(template_cache_root, "ruflet_flutter_template.revision")
142
119
  end
143
120
 
144
121
  def cached_ruby_runtime_root
@@ -154,41 +131,104 @@ module Ruflet
154
131
  end
155
132
 
156
133
  def download_ruflet_assets(force: false, verbose: false)
157
- template_target = cached_ruflet_client_template_root
158
- return true if !force && Dir.exist?(template_target)
159
-
160
- Dir.exist?(template_target) || download_ruflet_template(force: force, verbose: verbose)
134
+ !ensure_cached_ruflet_client_template!(force: force, verbose: verbose).nil?
161
135
  end
162
136
 
163
137
  def download_ruflet_template(force: false, verbose: false)
164
138
  target = cached_ruflet_client_template_root
165
- return target if !force && Dir.exist?(target)
139
+ remote_revision = remote_template_revision(verbose: verbose)
140
+ current_revision = cached_template_revision
141
+ cache_available = valid_ruflet_template?(target)
142
+ if !force && cache_available && remote_revision && current_revision == remote_revision
143
+ return target
144
+ end
145
+ if !force && cache_available && remote_revision.nil?
146
+ build_log(verbose, "Unable to check the Ruflet template revision; using cached #{current_revision || 'template'}") if respond_to?(:build_log, true)
147
+ return target
148
+ end
166
149
 
167
150
  FileUtils.mkdir_p(template_cache_root)
168
151
 
169
152
  Dir.mktmpdir("ruflet-assets") do |tmp|
170
153
  repo_dir = File.join(tmp, "Ruflet")
171
- clone_cmd = ["git", "clone", "--depth", "1", "--filter=blob:none", "--sparse", TEMPLATE_REPO_URL, repo_dir]
172
- return nil unless run_template_command(clone_cmd, verbose: verbose)
173
- return nil unless run_template_command(["git", "-C", repo_dir, "sparse-checkout", "set", "templates/ruflet_flutter_template"], verbose: verbose)
154
+ clone_cmd = ["git", "clone", "--depth", "1", "--branch", TEMPLATE_REPO_REF, "--filter=blob:none", "--sparse", TEMPLATE_REPO_URL, repo_dir]
155
+ return target_if_present(target) unless run_template_command(clone_cmd, verbose: verbose)
156
+ return target_if_present(target) unless run_template_command(["git", "-C", repo_dir, "sparse-checkout", "set", "templates/ruflet_flutter_template"], verbose: verbose)
174
157
 
175
158
  source = File.join(repo_dir, "templates", "ruflet_flutter_template")
176
- return nil unless Dir.exist?(source)
159
+ return target_if_present(target) unless valid_ruflet_template?(source)
177
160
 
161
+ fetched_revision = git_revision(repo_dir) || remote_revision
162
+ staged_target = File.join(tmp, "ruflet_flutter_template")
163
+ FileUtils.cp_r(source, staged_target)
178
164
  FileUtils.rm_rf(target)
179
- FileUtils.cp_r(source, target)
165
+ FileUtils.mv(staged_target, target)
166
+ File.write(cached_ruflet_client_template_revision_path, "#{fetched_revision}\n") if fetched_revision
180
167
  end
181
168
 
182
- write_template_cache_stamp(target)
183
169
  target
184
170
  rescue StandardError => e
185
171
  warn "Failed to fetch Ruflet template: #{e.class}: #{e.message}"
186
- nil
172
+ target_if_present(target)
187
173
  end
188
174
 
189
175
  def run_template_command(cmd, verbose: false)
190
176
  output = verbose ? $stdout : File::NULL
191
- system(*cmd, out: output, err: verbose ? $stderr : File::NULL)
177
+ env = { "GIT_TERMINAL_PROMPT" => "0" }
178
+ system(env, *cmd, out: output, err: verbose ? $stderr : File::NULL)
179
+ end
180
+
181
+ def remote_template_revision(verbose: false)
182
+ return @ruflet_template_remote_revision if defined?(@ruflet_template_remote_revision)
183
+
184
+ env = { "GIT_TERMINAL_PROMPT" => "0" }
185
+ stdout, stderr, status = Open3.capture3(env, "git", "ls-remote", TEMPLATE_REPO_URL, "refs/heads/#{TEMPLATE_REPO_REF}")
186
+ warn stderr unless status.success? || !verbose || stderr.empty?
187
+ @ruflet_template_remote_revision = status.success? ? stdout.split.first : nil
188
+ rescue StandardError => e
189
+ warn "Failed to check Ruflet template revision: #{e.message}" if verbose
190
+ @ruflet_template_remote_revision = nil
191
+ end
192
+
193
+ def cached_template_revision
194
+ return nil unless File.file?(cached_ruflet_client_template_revision_path)
195
+
196
+ File.read(cached_ruflet_client_template_revision_path).strip.then { |value| value.empty? ? nil : value }
197
+ end
198
+
199
+ def installed_template_revision(template_root)
200
+ return cached_template_revision if File.expand_path(template_root) == File.expand_path(cached_ruflet_client_template_root)
201
+
202
+ git_revision(File.expand_path("../..", template_root))
203
+ end
204
+
205
+ def write_client_template_revision(target, revision)
206
+ return unless revision
207
+
208
+ File.write(File.join(target, ".ruflet-template-revision"), "#{revision}\n")
209
+ end
210
+
211
+ def client_template_current?(target, template_root)
212
+ expected = installed_template_revision(template_root)
213
+ return false unless expected
214
+
215
+ marker = File.join(target, ".ruflet-template-revision")
216
+ File.file?(marker) && File.read(marker).strip == expected
217
+ end
218
+
219
+ def git_revision(repo_dir)
220
+ stdout, _stderr, status = Open3.capture3("git", "-C", repo_dir, "rev-parse", "HEAD")
221
+ status.success? ? stdout.strip : nil
222
+ rescue StandardError
223
+ nil
224
+ end
225
+
226
+ def valid_ruflet_template?(path)
227
+ Dir.exist?(path) && File.file?(File.join(path, "pubspec.yaml")) && File.file?(File.join(path, "lib", "main.dart"))
228
+ end
229
+
230
+ def target_if_present(target)
231
+ valid_ruflet_template?(target) ? target : nil
192
232
  end
193
233
 
194
234
  def prune_client_template(target)
@@ -225,10 +265,8 @@ module Ruflet
225
265
  # Example: https://api.example.com
226
266
  backend_url: ""
227
267
 
228
- # Flutter extension packages included in the managed client.
229
- # Permission-backed extensions such as camera, audio_recorder,
230
- # geolocator, and permission_handler are activated by services.yaml
231
- # and ignored here.
268
+ # Optional Flutter client extensions. Only listed extensions are bundled.
269
+ # Examples: audio, charts, code_editor, map, rive, video, webview
232
270
  extensions: []
233
271
 
234
272
  # Build assets configuration consumed by `ruflet build`.
@@ -246,7 +284,9 @@ module Ruflet
246
284
  YAML
247
285
 
248
286
  File.write(File.join(root, "services.yaml"), <<~YAML)
249
- # Protected device access requested by this app.
287
+ # Native capabilities that require platform permissions. Ruflet activates
288
+ # the matching client extensions and writes Android/iOS permissions.
289
+ # Supported services: camera, microphone, location, motion
250
290
  services: []
251
291
  YAML
252
292
  end