nightona 0.191.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.
Files changed (43) hide show
  1. checksums.yaml +7 -0
  2. data/.rspec +3 -0
  3. data/.rubocop.yml +22 -0
  4. data/.ruby-version +1 -0
  5. data/CODE_OF_CONDUCT.md +132 -0
  6. data/LICENSE +190 -0
  7. data/README.md +184 -0
  8. data/Rakefile +12 -0
  9. data/lib/nightona/code_interpreter.rb +359 -0
  10. data/lib/nightona/common/charts.rb +124 -0
  11. data/lib/nightona/common/code_interpreter.rb +56 -0
  12. data/lib/nightona/common/code_language.rb +14 -0
  13. data/lib/nightona/common/file_system.rb +26 -0
  14. data/lib/nightona/common/git.rb +19 -0
  15. data/lib/nightona/common/image.rb +500 -0
  16. data/lib/nightona/common/nightona.rb +230 -0
  17. data/lib/nightona/common/process.rb +149 -0
  18. data/lib/nightona/common/pty.rb +309 -0
  19. data/lib/nightona/common/resources.rb +39 -0
  20. data/lib/nightona/common/response.rb +83 -0
  21. data/lib/nightona/common/snapshot.rb +124 -0
  22. data/lib/nightona/computer_use.rb +919 -0
  23. data/lib/nightona/config.rb +116 -0
  24. data/lib/nightona/file_system.rb +451 -0
  25. data/lib/nightona/file_transfer.rb +383 -0
  26. data/lib/nightona/git.rb +334 -0
  27. data/lib/nightona/lsp_server.rb +139 -0
  28. data/lib/nightona/nightona.rb +336 -0
  29. data/lib/nightona/object_storage.rb +172 -0
  30. data/lib/nightona/otel.rb +183 -0
  31. data/lib/nightona/process.rb +550 -0
  32. data/lib/nightona/sandbox.rb +751 -0
  33. data/lib/nightona/sdk/version.rb +10 -0
  34. data/lib/nightona/sdk.rb +56 -0
  35. data/lib/nightona/snapshot_service.rb +238 -0
  36. data/lib/nightona/util.rb +80 -0
  37. data/lib/nightona/volume.rb +46 -0
  38. data/lib/nightona/volume_service.rb +61 -0
  39. data/lib/nightona.rb +10 -0
  40. data/project.json +100 -0
  41. data/scripts/generate-docs.rb +402 -0
  42. data/sig/nightona/sdk.rbs +6 -0
  43. metadata +242 -0
@@ -0,0 +1,500 @@
1
+ # Copyright Daytona Platforms Inc.
2
+ # SPDX-License-Identifier: Apache-2.0
3
+
4
+ # frozen_string_literal: true
5
+
6
+ require 'digest'
7
+ require 'fileutils'
8
+ require 'pathname'
9
+ require 'shellwords'
10
+
11
+ module Nightona
12
+ class Context
13
+ attr_reader :source_path
14
+ attr_reader :archive_path
15
+
16
+ # @param source_path [String] The path to the source file or directory
17
+ # @param archive_path [String, nil] The path inside the archive file in object storage
18
+ def initialize(source_path:, archive_path: nil)
19
+ @source_path = source_path
20
+ @archive_path = archive_path
21
+ end
22
+ end
23
+
24
+ # Represents an image definition for a Nightona sandbox.
25
+ # Do not construct this class directly. Instead use one of its static factory methods,
26
+ # such as `Image.base()`, `Image.debian_slim()`, or `Image.from_dockerfile()`.
27
+ class Image # rubocop:disable Metrics/ClassLength
28
+ # @return [String, nil] The generated Dockerfile for the image
29
+ attr_reader :dockerfile
30
+
31
+ # @return [Array<Context>] List of context files for the image
32
+ attr_reader :context_list
33
+
34
+ # Supported Python series
35
+ SUPPORTED_PYTHON_SERIES = %w[3.9 3.10 3.11 3.12 3.13].freeze
36
+ LATEST_PYTHON_MICRO_VERSIONS = %w[3.9.22 3.10.17 3.11.12 3.12.10 3.13.3].freeze
37
+
38
+ # @param dockerfile [String, nil] The Dockerfile content
39
+ # @param context_list [Array<Context>] List of context files
40
+ def initialize(dockerfile: nil, context_list: [])
41
+ @dockerfile = dockerfile || ''
42
+ @context_list = context_list
43
+ end
44
+
45
+ # Adds commands to install packages using pip
46
+ #
47
+ # @param packages [Array<String>] The packages to install
48
+ # @param find_links [Array<String>, nil] The find-links to use
49
+ # @param index_url [String, nil] The index URL to use
50
+ # @param extra_index_urls [Array<String>, nil] The extra index URLs to use
51
+ # @param pre [Boolean] Whether to install pre-release packages
52
+ # @param extra_options [String] Additional options to pass to pip
53
+ # @return [Image] The image with the pip install commands added
54
+ #
55
+ # @example
56
+ # image = Image.debian_slim("3.12").pip_install("requests", "pandas")
57
+ def pip_install(*packages, find_links: nil, index_url: nil, extra_index_urls: nil, pre: false, extra_options: '') # rubocop:disable Metrics/ParameterLists
58
+ pkgs = flatten_str_args('pip_install', 'packages', packages)
59
+ return self if pkgs.empty?
60
+
61
+ extra_args = format_pip_install_args(find_links:, index_url:, extra_index_urls:, pre:, extra_options:)
62
+ @dockerfile += "RUN python -m pip install #{Shellwords.join(pkgs.sort)}#{extra_args}\n"
63
+
64
+ self
65
+ end
66
+
67
+ # Installs dependencies from a requirements.txt file
68
+ #
69
+ # @param requirements_txt [String] The path to the requirements.txt file
70
+ # @param find_links [Array<String>, nil] The find-links to use
71
+ # @param index_url [String, nil] The index URL to use
72
+ # @param extra_index_urls [Array<String>, nil] The extra index URLs to use
73
+ # @param pre [Boolean] Whether to install pre-release packages
74
+ # @param extra_options [String] Additional options to pass to pip
75
+ # @return [Image] The image with the pip install commands added
76
+ # @raise [Sdk::Error] If the requirements file does not exist
77
+ #
78
+ # @example
79
+ # image = Image.debian_slim("3.12").pip_install_from_requirements("requirements.txt")
80
+ def pip_install_from_requirements(requirements_txt, find_links: nil, index_url: nil, extra_index_urls: nil, # rubocop:disable Metrics/ParameterLists
81
+ pre: false, extra_options: '')
82
+ requirements_txt = File.expand_path(requirements_txt)
83
+ raise Sdk::Error, "Requirements file #{requirements_txt} does not exist" unless File.exist?(requirements_txt)
84
+
85
+ extra_args = format_pip_install_args(find_links:, index_url:, extra_index_urls:, pre:, extra_options:)
86
+
87
+ archive_path = ObjectStorage.compute_archive_base_path(requirements_txt)
88
+ @context_list << Context.new(source_path: requirements_txt, archive_path:)
89
+ @dockerfile += "COPY #{archive_path} /.requirements.txt\n"
90
+ @dockerfile += "RUN python -m pip install -r /.requirements.txt#{extra_args}\n"
91
+
92
+ self
93
+ end
94
+
95
+ # Installs dependencies from a pyproject.toml file
96
+ #
97
+ # @param pyproject_toml [String] The path to the pyproject.toml file
98
+ # @param optional_dependencies [Array<String>] The optional dependencies to install
99
+ # @param find_links [String, nil] The find-links to use
100
+ # @param index_url [String, nil] The index URL to use
101
+ # @param extra_index_url [String, nil] The extra index URL to use
102
+ # @param pre [Boolean] Whether to install pre-release packages
103
+ # @param extra_options [String] Additional options to pass to pip
104
+ # @return [Image] The image with the pip install commands added
105
+ # @raise [Sdk::Error] If pyproject.toml parsing is not supported
106
+ #
107
+ # @example
108
+ # image = Image.debian_slim("3.12").pip_install_from_pyproject("pyproject.toml", optional_dependencies: ["dev"])
109
+ def pip_install_from_pyproject(pyproject_toml, optional_dependencies: [], find_links: nil, index_url: nil, # rubocop:disable Metrics/MethodLength, Metrics/ParameterLists
110
+ extra_index_url: nil, pre: false, extra_options: '')
111
+ data = TOML.load_file(pyproject_toml)
112
+ dependencies = data.dig('project', 'dependencies')
113
+
114
+ unless dependencies
115
+ raise Sdk::Error, 'No [project.dependencies] section in pyproject.toml file. ' \
116
+ 'See https://packaging.python.org/en/latest/guides/writing-pyproject-toml ' \
117
+ 'for further file format guidelines.'
118
+ end
119
+
120
+ return unless optional_dependencies
121
+
122
+ optionals = data.dig('project', 'optional-dependencies')
123
+ optional_dependencies.each do |group|
124
+ dependencies.concat(optionals.fetch(group, []))
125
+ end
126
+
127
+ pip_install(*dependencies, find_links:, index_url:, extra_index_urls: extra_index_url, pre:, extra_options:)
128
+ end
129
+
130
+ # Adds a local file to the image
131
+ #
132
+ # @param local_path [String] The path to the local file
133
+ # @param remote_path [String] The path to the file in the image
134
+ # @return [Image] The image with the local file added
135
+ #
136
+ # @example
137
+ # image = Image.debian_slim("3.12").add_local_file("package.json", "/home/nightona/package.json")
138
+ def add_local_file(local_path, remote_path)
139
+ remote_path = "#{remote_path}/#{File.basename(local_path)}" if remote_path.end_with?('/')
140
+
141
+ local_path = File.expand_path(local_path)
142
+ archive_path = ObjectStorage.compute_archive_base_path(local_path)
143
+ @context_list << Context.new(source_path: local_path, archive_path: archive_path)
144
+ @dockerfile += "COPY #{archive_path} #{remote_path}\n"
145
+
146
+ self
147
+ end
148
+
149
+ # Adds a local directory to the image
150
+ #
151
+ # @param local_path [String] The path to the local directory
152
+ # @param remote_path [String] The path to the directory in the image
153
+ # @return [Image] The image with the local directory added
154
+ #
155
+ # @example
156
+ # image = Image.debian_slim("3.12").add_local_dir("src", "/home/nightona/src")
157
+ def add_local_dir(local_path, remote_path)
158
+ local_path = File.expand_path(local_path)
159
+ archive_path = ObjectStorage.compute_archive_base_path(local_path)
160
+ @context_list << Context.new(source_path: local_path, archive_path: archive_path)
161
+ @dockerfile += "COPY #{archive_path} #{remote_path}\n"
162
+
163
+ self
164
+ end
165
+
166
+ # Runs commands in the image
167
+ #
168
+ # @param commands [Array<String>] The commands to run
169
+ # @return [Image] The image with the commands added
170
+ #
171
+ # @example
172
+ # image = Image.debian_slim("3.12").run_commands('echo "Hello, world!"', 'echo "Hello again!"')
173
+ def run_commands(*commands)
174
+ commands.each do |command|
175
+ if command.is_a?(Array)
176
+ escaped = command.map { |c| c.gsub('"', '\\"').gsub("'", "\\'") }
177
+ @dockerfile += "RUN #{escaped.map { |c| "\"#{c}\"" }.join(' ')}\n"
178
+ else
179
+ @dockerfile += "RUN #{command}\n"
180
+ end
181
+ end
182
+
183
+ self
184
+ end
185
+
186
+ # Sets environment variables in the image
187
+ #
188
+ # @param env_vars [Hash<String, String>] The environment variables to set
189
+ # @return [Image] The image with the environment variables added
190
+ #
191
+ # @example
192
+ # image = Image.debian_slim("3.12").env({"PROJECT_ROOT" => "/home/nightona"})
193
+ def env(env_vars)
194
+ non_str_keys = env_vars.reject { |_key, val| val.is_a?(String) }.keys
195
+ raise Sdk::Error, "Image ENV variables must be strings. Invalid keys: #{non_str_keys}" unless non_str_keys.empty?
196
+
197
+ env_vars.each do |key, val|
198
+ @dockerfile += "ENV #{key}=#{Shellwords.escape(val)}\n"
199
+ end
200
+
201
+ self
202
+ end
203
+
204
+ # Sets the working directory in the image
205
+ #
206
+ # @param path [String] The path to the working directory
207
+ # @return [Image] The image with the working directory added
208
+ #
209
+ # @example
210
+ # image = Image.debian_slim("3.12").workdir("/home/nightona")
211
+ def workdir(path)
212
+ @dockerfile += "WORKDIR #{Shellwords.escape(path.to_s)}\n"
213
+ self
214
+ end
215
+
216
+ # Sets the entrypoint for the image
217
+ #
218
+ # @param entrypoint_commands [Array<String>] The commands to set as the entrypoint
219
+ # @return [Image] The image with the entrypoint added
220
+ #
221
+ # @example
222
+ # image = Image.debian_slim("3.12").entrypoint(["/bin/bash"])
223
+ def entrypoint(entrypoint_commands)
224
+ unless entrypoint_commands.is_a?(Array) && entrypoint_commands.all? { |x| x.is_a?(String) }
225
+ raise Sdk::Error, 'entrypoint_commands must be a list of strings.'
226
+ end
227
+
228
+ args_str = flatten_str_args('entrypoint', 'entrypoint_commands', entrypoint_commands)
229
+ args_str = args_str.map { |arg| "\"#{arg}\"" }.join(', ') if args_str.any?
230
+ @dockerfile += "ENTRYPOINT [#{args_str}]\n"
231
+
232
+ self
233
+ end
234
+
235
+ # Sets the default command for the image
236
+ #
237
+ # @param cmd [Array<String>] The commands to set as the default command
238
+ # @return [Image] The image with the default command added
239
+ #
240
+ # @example
241
+ # image = Image.debian_slim("3.12").cmd(["/bin/bash"])
242
+ def cmd(cmd)
243
+ unless cmd.is_a?(Array) && cmd.all? { |x| x.is_a?(String) }
244
+ raise Sdk::Error, 'Image CMD must be a list of strings.'
245
+ end
246
+
247
+ cmd_str = flatten_str_args('cmd', 'cmd', cmd)
248
+ cmd_str = cmd_str.map { |arg| "\"#{arg}\"" }.join(', ') if cmd_str.any?
249
+ @dockerfile += "CMD [#{cmd_str}]\n"
250
+ self
251
+ end
252
+
253
+ # Adds arbitrary Dockerfile-like commands to the image
254
+ #
255
+ # @param dockerfile_commands [Array<String>] The commands to add to the Dockerfile
256
+ # @param context_dir [String, nil] The path to the context directory
257
+ # @return [Image] The image with the Dockerfile commands added
258
+ #
259
+ # @example
260
+ # image = Image.debian_slim("3.12").dockerfile_commands(["RUN echo 'Hello, world!'"])
261
+ def dockerfile_commands(dockerfile_commands, context_dir: nil) # rubocop:disable Metrics/MethodLength
262
+ if context_dir
263
+ context_dir = File.expand_path(context_dir)
264
+ raise Sdk::Error, "Context directory #{context_dir} does not exist" unless Dir.exist?(context_dir)
265
+ end
266
+
267
+ # Extract copy sources from dockerfile commands (class-level helper)
268
+ self.class.send(:extract_copy_sources, dockerfile_commands.join("\n"),
269
+ context_dir || '').each do |context_path, original_path|
270
+ archive_base_path = context_path
271
+ if context_dir && !original_path.start_with?(context_dir)
272
+ archive_base_path = context_path.delete_prefix(context_dir)
273
+ end
274
+ @context_list << Context.new(source_path: context_path, archive_path: archive_base_path)
275
+ end
276
+
277
+ @dockerfile += "#{dockerfile_commands.join("\n")}\n"
278
+ self
279
+ end
280
+
281
+ class << self
282
+ # Creates an Image from an existing Dockerfile
283
+ #
284
+ # @param path [String] The path to the Dockerfile
285
+ # @return [Image] The image with the Dockerfile added
286
+ #
287
+ # @example
288
+ # image = Image.from_dockerfile("Dockerfile")
289
+ def from_dockerfile(path) # rubocop:disable Metrics/AbcSize
290
+ path = Pathname.new(File.expand_path(path))
291
+ dockerfile = path.read
292
+ img = new(dockerfile: dockerfile)
293
+
294
+ # Remove dockerfile filename from path
295
+ path_prefix = path.to_s.delete_suffix(path.basename.to_s)
296
+
297
+ extract_copy_sources(dockerfile, path_prefix).each do |context_path, original_path|
298
+ archive_base_path = context_path
299
+ archive_base_path = context_path.delete_prefix(path_prefix) unless original_path.start_with?(path_prefix)
300
+ img.context_list << Context.new(source_path: context_path, archive_path: archive_base_path)
301
+ end
302
+
303
+ img
304
+ end
305
+
306
+ # Creates an Image from an existing base image
307
+ #
308
+ # @param image [String] The base image to use
309
+ # @return [Image] The image with the base image added
310
+ #
311
+ # @example
312
+ # image = Image.base("python:3.12-slim-bookworm")
313
+ def base(image)
314
+ img = new
315
+ img.instance_variable_set(:@dockerfile, "FROM #{image}\n")
316
+ img
317
+ end
318
+
319
+ # Creates a Debian slim image based on the official Python Docker image
320
+ #
321
+ # @param python_version [String, nil] The Python version to use
322
+ # @return [Image] The image with the Debian slim image added
323
+ #
324
+ # @example
325
+ # image = Image.debian_slim("3.12")
326
+ def debian_slim(python_version = nil) # rubocop:disable Metrics/MethodLength
327
+ python_version = process_python_version(python_version)
328
+ img = new
329
+ commands = [
330
+ "FROM python:#{python_version}-slim-bookworm",
331
+ 'RUN apt-get update',
332
+ 'RUN apt-get install -y gcc gfortran build-essential',
333
+ 'RUN pip install --upgrade pip',
334
+ # Set debian front-end to non-interactive to avoid users getting stuck with input prompts.
335
+ "RUN echo 'debconf debconf/frontend select Noninteractive' | debconf-set-selections"
336
+ ]
337
+ img.instance_variable_set(:@dockerfile, "#{commands.join("\n")}\n")
338
+ img
339
+ end
340
+
341
+ private
342
+
343
+ # Processes the Python version
344
+ #
345
+ # @param python_version [String, nil] The Python version to process
346
+ # @param allow_micro_granularity [Boolean] Whether to allow micro-level granularity
347
+ # @return [String] The processed Python version
348
+ def process_python_version(python_version = nil)
349
+ python_version ||= SUPPORTED_PYTHON_SERIES.last
350
+
351
+ unless SUPPORTED_PYTHON_SERIES.include?(python_version)
352
+ raise Sdk::Error, "Unsupported Python version: #{python_version}"
353
+ end
354
+
355
+ LATEST_PYTHON_MICRO_VERSIONS.select { |v| v.start_with?(python_version) }.last
356
+ end
357
+
358
+ # Extracts source files from COPY commands in a Dockerfile
359
+ #
360
+ # @param dockerfile_content [String] The content of the Dockerfile
361
+ # @param path_prefix [String] The path prefix to use for the sources
362
+ # @return [Array<Array<String>>] The list of the actual file path and its corresponding COPY-command source path
363
+ def extract_copy_sources(dockerfile_content, path_prefix = '') # rubocop:disable Metrics/AbcSize, Metrics/CyclomaticComplexity, Metrics/PerceivedComplexity, Metrics/MethodLength
364
+ sources = []
365
+ lines = dockerfile_content.split("\n")
366
+
367
+ lines.each do |line|
368
+ # Skip empty lines and comments
369
+ next if line.strip.empty? || line.strip.start_with?('#')
370
+
371
+ # Check if the line contains a COPY command (at the beginning of the line)
372
+ next unless line.match?(/^\s*COPY\s+(?!.*--from=)/i)
373
+
374
+ # Skip COPY instructions that use heredoc syntax (inline content, not file references)
375
+ next if line.include?('<<')
376
+
377
+ # Extract the sources from the COPY command
378
+ command_parts = parse_copy_command(line)
379
+ next unless command_parts
380
+
381
+ # Get source paths from the parsed command parts
382
+ command_parts['sources'].each do |source|
383
+ # Handle absolute and relative paths differently
384
+ full_path_pattern = if Pathname.new(source).absolute?
385
+ # Absolute path - use as is
386
+ source
387
+ else
388
+ # Relative path - add prefix
389
+ File.join(path_prefix, source)
390
+ end
391
+
392
+ # Handle glob patterns
393
+ matching_files = Dir.glob(full_path_pattern)
394
+
395
+ if matching_files.any?
396
+ matching_files.each { |matching_file| sources << [matching_file, source] }
397
+ else
398
+ # If no files match, include the pattern anyway
399
+ sources << [full_path_pattern, source]
400
+ end
401
+ end
402
+ end
403
+
404
+ sources
405
+ end
406
+
407
+ # Parses a COPY command to extract sources and destination
408
+ #
409
+ # @param line [String] The line to parse
410
+ # @return [Hash, nil] A hash containing the sources and destination, or nil if parsing fails
411
+ def parse_copy_command(line) # rubocop:disable Metrics/AbcSize, Metrics/CyclomaticComplexity, Metrics/PerceivedComplexity, Metrics/MethodLength
412
+ # Remove initial "COPY" and strip whitespace
413
+ parts = line.strip[4..].strip
414
+
415
+ # Handle JSON array format: COPY ["src1", "src2", "dest"]
416
+ if parts.start_with?('[')
417
+ begin
418
+ # Parse the JSON-like array format
419
+ elements = Shellwords.split(parts.delete('[]'))
420
+ return nil if elements.length < 2
421
+
422
+ { 'sources' => elements[0..-2], 'dest' => elements[-1] }
423
+ rescue StandardError
424
+ nil
425
+ end
426
+ end
427
+
428
+ # Handle regular format with possible flags
429
+ parts = Shellwords.split(parts)
430
+
431
+ # Extract flags like --chown, --chmod, --from
432
+ sources_start_idx = 0
433
+ parts.each_with_index do |part, i|
434
+ break unless part.start_with?('--')
435
+
436
+ # Skip the flag and its value if it has one
437
+ sources_start_idx = if !part.include?('=') && i + 1 < parts.length && !parts[i + 1].start_with?('--')
438
+ i + 2
439
+ else
440
+ i + 1
441
+ end
442
+ end
443
+
444
+ # After skipping flags, we need at least one source and one destination
445
+ return nil if parts.length - sources_start_idx < 2
446
+
447
+ { 'sources' => parts[sources_start_idx..-2], 'dest' => parts[-1] }
448
+ end
449
+ end
450
+
451
+ private
452
+
453
+ # Flattens a list of strings and arrays of strings into a single array of strings
454
+ #
455
+ # @param function_name [String] The name of the function that is being called
456
+ # @param arg_name [String] The name of the argument that is being passed
457
+ # @param args [Array] The list of arguments to flatten
458
+ # @return [Array<String>] A list of strings
459
+ def flatten_str_args(function_name, arg_name, args) # rubocop:disable Metrics/MethodLength
460
+ ret = []
461
+ args.each do |x|
462
+ case x
463
+ when String
464
+ ret << x
465
+ when Array
466
+ unless x.all? { |y| y.is_a?(String) }
467
+ raise Sdk::Error, "#{function_name}: #{arg_name} must only contain strings"
468
+ end
469
+
470
+ ret.concat(x)
471
+
472
+ else
473
+ raise Sdk::Error, "#{function_name}: #{arg_name} must only contain strings"
474
+ end
475
+ end
476
+ ret
477
+ end
478
+
479
+ # Formats the arguments in a single string
480
+ #
481
+ # @param find_links [Array<String>, nil] The find-links to use
482
+ # @param index_url [String, nil] The index URL to use
483
+ # @param extra_index_urls [Array<String>, nil] The extra index URLs to use
484
+ # @param pre [Boolean] Whether to install pre-release packages
485
+ # @param extra_options [String] Additional options to pass to pip
486
+ # @return [String] The formatted arguments
487
+ def format_pip_install_args(find_links: nil, index_url: nil, extra_index_urls: nil, pre: false, extra_options: '') # rubocop:disable Metrics/CyclomaticComplexity, Metrics/PerceivedComplexity
488
+ extra_args = ''
489
+ find_links&.each { |find_link| extra_args += " --find-links #{Shellwords.escape(find_link)}" }
490
+ extra_args += " --index-url #{Shellwords.escape(index_url)}" if index_url
491
+ extra_index_urls&.each do |extra_index_url|
492
+ extra_args += " --extra-index-url #{Shellwords.escape(extra_index_url)}"
493
+ end
494
+ extra_args += ' --pre' if pre
495
+ extra_args += " #{extra_options.strip}" if extra_options && !extra_options.strip.empty?
496
+
497
+ extra_args
498
+ end
499
+ end
500
+ end