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,116 @@
1
+ # Copyright Daytona Platforms Inc.
2
+ # SPDX-License-Identifier: Apache-2.0
3
+
4
+ # frozen_string_literal: true
5
+
6
+ require 'dotenv'
7
+
8
+ module Nightona
9
+ class Config
10
+ API_URL = 'http://localhost:3000/api'
11
+
12
+ # API key for authentication with the Nightona API
13
+ #
14
+ # @return [String, nil] Nightona API key
15
+ attr_accessor :api_key
16
+
17
+ # JWT token for authentication with the Nightona API
18
+ #
19
+ # @return [String, nil] Nightona JWT token
20
+ attr_accessor :jwt_token
21
+
22
+ # URL of the Nightona API
23
+ #
24
+ # @return [String, nil] Nightona API URL
25
+ attr_accessor :api_url
26
+
27
+ # Organization ID for authentication with the Nightona API
28
+ #
29
+ # @return [String, nil] Nightona API URL
30
+ attr_accessor :organization_id
31
+
32
+ # Target environment for sandboxes
33
+ #
34
+ # @return [String, nil] Nightona target
35
+ attr_accessor :target
36
+
37
+ # Enable OpenTelemetry tracing for SDK operations.
38
+ #
39
+ # @return [Boolean, nil]
40
+ attr_accessor :otel_enabled
41
+
42
+ # Experimental configuration options
43
+ #
44
+ # @return [Hash, nil] Experimental configuration hash
45
+ attr_accessor :_experimental
46
+
47
+ # Initializes a new Nightona::Config object.
48
+ #
49
+ # @param api_key [String, nil] Nightona API key. Defaults to ENV['NIGHTONA_API_KEY'].
50
+ # @param jwt_token [String, nil] Nightona JWT token. Defaults to ENV['NIGHTONA_JWT_TOKEN'].
51
+ # @param api_url [String, nil] Nightona API URL. Defaults to ENV['NIGHTONA_API_URL'] or Nightona::Config::API_URL.
52
+ # @param organization_id [String, nil] Nightona organization ID. Defaults to ENV['NIGHTONA_ORGANIZATION_ID'].
53
+ # @param target [String, nil] Nightona target. Defaults to ENV['NIGHTONA_TARGET'].
54
+ # @param otel_enabled [Boolean, nil] Enable OpenTelemetry tracing for SDK operations.
55
+ # @param _experimental [Hash, nil] Experimental configuration options.
56
+ def initialize( # rubocop:disable Metrics/ParameterLists
57
+ api_key: nil,
58
+ jwt_token: nil,
59
+ api_url: nil,
60
+ organization_id: nil,
61
+ target: nil,
62
+ otel_enabled: nil,
63
+ _experimental: nil
64
+ )
65
+ @env_reader = nightona_env_reader
66
+
67
+ @api_key = api_key || @env_reader.call('NIGHTONA_API_KEY')
68
+ @jwt_token = jwt_token || @env_reader.call('NIGHTONA_JWT_TOKEN')
69
+ @api_url = api_url || @env_reader.call('NIGHTONA_API_URL') || API_URL
70
+ @target = target || @env_reader.call('NIGHTONA_TARGET')
71
+ @organization_id = organization_id || @env_reader.call('NIGHTONA_ORGANIZATION_ID')
72
+ @otel_enabled = otel_enabled
73
+ @_experimental = _experimental
74
+ end
75
+
76
+ # Reads a NIGHTONA_-prefixed environment variable using the same precedence
77
+ # as the Config initializer: runtime ENV first, then .env.local, then .env.
78
+ # For backwards compatibility, the legacy DAYTONA_-prefixed variable is used
79
+ # as a fallback at each level when the NIGHTONA_ one is not set.
80
+ # Only names starting with NIGHTONA_ are accepted.
81
+ #
82
+ # @param name [String] The environment variable name. Must start with NIGHTONA_.
83
+ # @return [String, nil] The value of the environment variable, or nil if not set.
84
+ # @raise [ArgumentError] If name does not start with NIGHTONA_.
85
+ def read_env(name)
86
+ @env_reader.call(name)
87
+ end
88
+
89
+ private
90
+
91
+ # Returns a lambda that looks up NIGHTONA_-prefixed env vars without writing to ENV.
92
+ # Files are parsed once; lookups check runtime env first, then .env.local, then .env.
93
+ # Legacy DAYTONA_-prefixed variables are honored as a fallback.
94
+ def nightona_env_reader
95
+ file_vars = {}
96
+ env_file = File.join(Dir.pwd, '.env')
97
+ file_vars.merge!(nightona_filter(Dotenv.parse(env_file))) if File.exist?(env_file)
98
+ env_local_file = File.join(Dir.pwd, '.env.local')
99
+ file_vars.merge!(nightona_filter(Dotenv.parse(env_local_file))) if File.exist?(env_local_file)
100
+
101
+ lambda do |name|
102
+ raise ArgumentError, "Variable must start with 'NIGHTONA_', got '#{name}'" unless name.start_with?('NIGHTONA_')
103
+
104
+ legacy_name = name.sub(/\ANIGHTONA_/, 'DAYTONA_')
105
+ return ENV[name] if ENV.key?(name)
106
+ return ENV[legacy_name] if ENV.key?(legacy_name)
107
+
108
+ file_vars.key?(name) ? file_vars[name] : file_vars[legacy_name]
109
+ end
110
+ end
111
+
112
+ def nightona_filter(env_hash)
113
+ env_hash.select { |k, _| k.start_with?('NIGHTONA_') || k.start_with?('DAYTONA_') }
114
+ end
115
+ end
116
+ end
@@ -0,0 +1,451 @@
1
+ # Copyright Daytona Platforms Inc.
2
+ # SPDX-License-Identifier: Apache-2.0
3
+
4
+ # frozen_string_literal: true
5
+
6
+ require 'tempfile'
7
+ require 'fileutils'
8
+ require_relative 'file_transfer'
9
+
10
+ module Nightona
11
+ class FileSystem # rubocop:disable Metrics/ClassLength
12
+ include Instrumentation
13
+
14
+ # @return [String] The Sandbox ID
15
+ attr_reader :sandbox_id
16
+
17
+ # @return [NightonaToolboxApiClient::FileSystemApi] API client for Sandbox operations
18
+ attr_reader :toolbox_api
19
+
20
+ # Initializes a new FileSystem instance.
21
+ #
22
+ # @param sandbox_id [String] The Sandbox ID
23
+ # @param toolbox_api [NightonaToolboxApiClient::FileSystemApi] API client for Sandbox operations
24
+ # @param otel_state [Nightona::OtelState, nil]
25
+ def initialize(sandbox_id:, toolbox_api:, otel_state: nil)
26
+ @sandbox_id = sandbox_id
27
+ @toolbox_api = toolbox_api
28
+ @otel_state = otel_state
29
+ end
30
+
31
+ # Creates a new directory in the Sandbox at the specified path with the given
32
+ # permissions.
33
+ #
34
+ # @param path [String] Path where the folder should be created. Relative paths are resolved based
35
+ # on the sandbox working directory.
36
+ # @param mode [String] Folder permissions in octal format (e.g., "755" for rwxr-xr-x).
37
+ # @return [void]
38
+ # @raise [Nightona::Sdk::Error] If the operation fails
39
+ #
40
+ # @example
41
+ # # Create a directory with standard permissions
42
+ # sandbox.fs.create_folder("workspace/data", "755")
43
+ #
44
+ # # Create a private directory
45
+ # sandbox.fs.create_folder("workspace/secrets", "700")
46
+ def create_folder(path, mode)
47
+ Sdk.logger.debug("Creating folder #{path} with mode #{mode}")
48
+ toolbox_api.create_folder(path, mode)
49
+ rescue StandardError => e
50
+ raise Sdk::Error, "Failed to create folder: #{e.message}"
51
+ end
52
+
53
+ # Deletes a file from the Sandbox.
54
+ #
55
+ # @param path [String] Path to the file to delete. Relative paths are resolved based on the sandbox working directory.
56
+ # @param recursive [Boolean] If the file is a directory, this must be true to delete it.
57
+ # @return [void]
58
+ # @raise [Nightona::Sdk::Error] If the operation fails
59
+ #
60
+ # @example
61
+ # # Delete a file
62
+ # sandbox.fs.delete_file("workspace/data/old_file.txt")
63
+ #
64
+ # # Delete a directory recursively
65
+ # sandbox.fs.delete_file("workspace/old_dir", recursive: true)
66
+ def delete_file(path, recursive: false)
67
+ toolbox_api.delete_file(path, { recursive: })
68
+ rescue StandardError => e
69
+ raise Sdk::Error, "Failed to delete file: #{e.message}"
70
+ end
71
+
72
+ # Gets detailed information about a file or directory, including its
73
+ # size, permissions, and timestamps.
74
+ #
75
+ # @param path [String] Path to the file or directory. Relative paths are resolved based
76
+ # on the sandbox working directory.
77
+ # @return [NightonaApiClient::FileInfo] Detailed file information
78
+ # @raise [Nightona::Sdk::Error] If the operation fails
79
+ #
80
+ # @example
81
+ # # Get file metadata
82
+ # info = sandbox.fs.get_file_info("workspace/data/file.txt")
83
+ # puts "Size: #{info.size} bytes"
84
+ # puts "Modified: #{info.mod_time}"
85
+ # puts "Mode: #{info.mode}"
86
+ #
87
+ # # Check if path is a directory
88
+ # info = sandbox.fs.get_file_info("workspace/data")
89
+ # puts "Path is a directory" if info.is_dir
90
+ def get_file_info(path)
91
+ toolbox_api.get_file_info(path)
92
+ rescue StandardError => e
93
+ raise Sdk::Error, "Failed to get file info: #{e.message}"
94
+ end
95
+
96
+ # Lists files and directories in a given path and returns their information, similar to the ls -l command.
97
+ #
98
+ # @param path [String] Path to the directory to list contents from. Relative paths are resolved
99
+ # based on the sandbox working directory.
100
+ # @return [Array<NightonaApiClient::FileInfo>] List of file and directory information
101
+ # @raise [Nightona::Sdk::Error] If the operation fails
102
+ #
103
+ # @example
104
+ # # List directory contents
105
+ # files = sandbox.fs.list_files("workspace/data")
106
+ #
107
+ # # Print files and their sizes
108
+ # files.each do |file|
109
+ # puts "#{file.name}: #{file.size} bytes" unless file.is_dir
110
+ # end
111
+ #
112
+ # # List only directories
113
+ # dirs = files.select(&:is_dir)
114
+ # puts "Subdirectories: #{dirs.map(&:name).join(', ')}"
115
+ def list_files(path)
116
+ toolbox_api.list_files({ path: })
117
+ rescue StandardError => e
118
+ raise Sdk::Error, "Failed to list files: #{e.message}"
119
+ end
120
+
121
+ # Downloads a file from the Sandbox. Returns the file contents as a string.
122
+ # This method is useful when you want to load the file into memory without saving it to disk.
123
+ # It can only be used for smaller files.
124
+ #
125
+ # @param remote_path [String] Path to the file in the Sandbox. Relative paths are resolved based
126
+ # on the sandbox working directory.
127
+ # @param local_path [String, nil] Optional path to save the file locally. If provided, the file will be saved to disk.
128
+ # @return [File, nil] The file if local_path is nil, otherwise nil
129
+ # @raise [Nightona::Sdk::Error] If the operation fails
130
+ #
131
+ # @example
132
+ # # Download and get file content
133
+ # content = sandbox.fs.download_file("workspace/data/file.txt")
134
+ # puts content
135
+ #
136
+ # # Download and save a file locally
137
+ # sandbox.fs.download_file("workspace/data/file.txt", "local_copy.txt")
138
+ # size_mb = File.size("local_copy.txt") / 1024.0 / 1024.0
139
+ # puts "Size of the downloaded file: #{size_mb} MB"
140
+ def download_file(remote_path, local_path = nil) # rubocop:disable Metrics/MethodLength
141
+ file = toolbox_api.download_file(remote_path)
142
+
143
+ if local_path
144
+
145
+ parent_dir = File.dirname(local_path)
146
+ FileUtils.mkdir_p(parent_dir) unless parent_dir == '.'
147
+
148
+ File.binwrite(local_path, file.open.read)
149
+ nil
150
+ else
151
+ file
152
+ end
153
+ rescue StandardError => e
154
+ raise Sdk::Error, "Failed to download file: #{e.message}"
155
+ end
156
+
157
+ # Downloads a single file from the Sandbox as a stream without buffering the entire
158
+ # file into memory. Yields file content in chunks to the given block, or returns an
159
+ # Enumerator if no block is given.
160
+ #
161
+ # @param remote_path [String] Path to the file in the Sandbox. Relative paths are resolved
162
+ # based on the sandbox working directory.
163
+ # @param timeout [Integer] Timeout for the download operation in seconds. 0 means no timeout.
164
+ # Default is 30 minutes.
165
+ # @param on_progress [Proc, nil] Optional callback invoked with a Nightona::DownloadProgress
166
+ # struct containing bytes_received (Integer) and total_bytes (Integer or nil).
167
+ # @param cancel_event [#set?, nil] Optional cancellation token (anything responding to +set?+;
168
+ # the standard library's +Concurrent::Event+ or a small ad-hoc object both work). When set
169
+ # during streaming, the next chunk raises Nightona::Sdk::Error and the underlying HTTP
170
+ # connection is torn down.
171
+ # @yield [chunk] Yields each chunk of file content as it arrives
172
+ # @yieldparam chunk [String] A binary string chunk of file content
173
+ # @return [Enumerator, nil] An Enumerator yielding chunks if no block given, nil otherwise
174
+ # @raise [Nightona::Sdk::Error] If the file does not exist, the operation fails, or
175
+ # +cancel_event+ is set during streaming
176
+ #
177
+ # @example Stream to a local file without loading into memory
178
+ # File.open("local_copy.bin", "wb") do |f|
179
+ # sandbox.fs.download_file_stream("workspace/large-file.bin") { |chunk| f.write(chunk) }
180
+ # end
181
+ #
182
+ # @example Collect chunks with an Enumerator
183
+ # content = sandbox.fs.download_file_stream("workspace/data.json").reduce(:+)
184
+ # puts content
185
+ def download_file_stream(remote_path, timeout: 30 * 60, on_progress: nil, cancel_event: nil, &)
186
+ return enum_for(__method__, remote_path, timeout:, on_progress:, cancel_event:) unless block_given?
187
+
188
+ FileTransfer.stream_download(api_client: toolbox_api.api_client, remote_path: remote_path,
189
+ timeout: timeout, on_progress: on_progress,
190
+ cancel_event: cancel_event, &)
191
+ nil
192
+ rescue StandardError => e
193
+ raise Sdk::Error, "Failed to download file: #{e.message}"
194
+ end
195
+
196
+ # Uploads a file to the specified path in the Sandbox. If a file already exists at
197
+ # the destination path, it will be overwritten.
198
+ #
199
+ # @param source [String, IO] File contents as a string/bytes or a local file path or IO object.
200
+ # @param remote_path [String] Path to the destination file. Relative paths are resolved based on
201
+ # the sandbox working directory.
202
+ # @return [void]
203
+ # @raise [Nightona::Sdk::Error] If the operation fails
204
+ #
205
+ # @example
206
+ # # Upload a text file from string content
207
+ # content = "Hello, World!"
208
+ # sandbox.fs.upload_file(content, "tmp/hello.txt")
209
+ #
210
+ # # Upload a local file
211
+ # sandbox.fs.upload_file("local_file.txt", "tmp/file.txt")
212
+ #
213
+ # # Upload binary data
214
+ # data = { key: "value" }.to_json
215
+ # sandbox.fs.upload_file(data, "tmp/config.json")
216
+ def upload_file(source, remote_path)
217
+ if source.is_a?(String) && File.exist?(source)
218
+ # Source is a file path
219
+ File.open(source, 'rb') { |file| toolbox_api.upload_file(remote_path, file) }
220
+ elsif source.respond_to?(:read)
221
+ # Source is an IO object
222
+ toolbox_api.upload_file(remote_path, source)
223
+ else
224
+ # Tempfile.create yields a ::File (works with Typhoeus) but deletes
225
+ # on block exit — too early if curl reads asynchronously. Write via
226
+ # Tempfile, close it, then reopen as ::File for the upload.
227
+ tmp = Tempfile.new('nightona_upload')
228
+ begin
229
+ tmp.binmode
230
+ tmp.write(source.to_s.b)
231
+ tmp.close
232
+ File.open(tmp.path, 'rb') { |file| toolbox_api.upload_file(remote_path, file) }
233
+ ensure
234
+ tmp.unlink
235
+ end
236
+ end
237
+ rescue StandardError => e
238
+ raise Sdk::Error, "Failed to upload file: #{e.message}"
239
+ end
240
+
241
+ # Streams +source+ to the Sandbox without buffering its contents in memory, with
242
+ # optional progress reporting.
243
+ #
244
+ # @param source [String, IO] A local file path or any IO-like object responding to
245
+ # +read(n)+. Strings that don't reference an existing file are uploaded as their
246
+ # raw bytes (still streamed, just from memory).
247
+ # @param remote_path [String] Destination path in the Sandbox.
248
+ # @param timeout [Integer] Timeout in seconds. 0 means no timeout. Default 30 minutes.
249
+ # @param on_progress [Proc, nil] Optional callback invoked with a
250
+ # +Nightona::UploadProgress+ struct as libcurl reports bytes actually uploaded.
251
+ # @param cancel_event [#set?, nil] Optional cancellation token. When set while
252
+ # staging a non-file source or during the libcurl upload, the operation raises
253
+ # Nightona::Sdk::Error and the in-progress upload is aborted (no destination file
254
+ # is left on the sandbox thanks to the daemon's atomic-rename behaviour).
255
+ # @return [void]
256
+ # @raise [Nightona::Sdk::Error] If the operation fails or +cancel_event+ is set.
257
+ #
258
+ # @example
259
+ # File.open("large.bin", "rb") do |f|
260
+ # sandbox.fs.upload_file_stream(f, "tmp/large.bin",
261
+ # on_progress: ->(p) { puts "#{p.bytes_sent} bytes sent" })
262
+ # end
263
+ def upload_file_stream(source, remote_path, timeout: 30 * 60, on_progress: nil, cancel_event: nil)
264
+ FileTransfer.stream_upload(api_client: toolbox_api.api_client, remote_path: remote_path,
265
+ source: source, timeout: timeout, on_progress: on_progress,
266
+ cancel_event: cancel_event)
267
+ rescue StandardError => e
268
+ raise Sdk::Error, "Failed to upload file: #{e.message}"
269
+ end
270
+
271
+ # Uploads multiple files to the Sandbox. If files already exist at the destination paths,
272
+ # they will be overwritten.
273
+ #
274
+ # @param files [Array<FileUpload>] List of files to upload.
275
+ # @return [void]
276
+ # @raise [Nightona::Sdk::Error] If the operation fails
277
+ #
278
+ # @example
279
+ # # Upload multiple files
280
+ # files = [
281
+ # FileUpload.new("Content of file 1", "/tmp/file1.txt"),
282
+ # FileUpload.new("workspace/data/file2.txt", "/tmp/file2.txt"),
283
+ # FileUpload.new('{"key": "value"}', "/tmp/config.json")
284
+ # ]
285
+ # sandbox.fs.upload_files(files)
286
+ def upload_files(files)
287
+ files.each { |file_upload| upload_file(file_upload.source, file_upload.destination) }
288
+ rescue StandardError => e
289
+ raise Sdk::Error, "Failed to upload files: #{e.message}"
290
+ end
291
+
292
+ # Searches for files containing a pattern, similar to the grep command.
293
+ #
294
+ # @param path [String] Path to the file or directory to search. If the path is a directory,
295
+ # the search will be performed recursively. Relative paths are resolved based
296
+ # on the sandbox working directory.
297
+ # @param pattern [String] Search pattern to match against file contents.
298
+ # @return [Array<NightonaApiClient::Match>] List of matches found in files
299
+ # @raise [Nightona::Sdk::Error] If the operation fails
300
+ #
301
+ # @example
302
+ # # Search for TODOs in Ruby files
303
+ # matches = sandbox.fs.find_files("workspace/src", "TODO:")
304
+ # matches.each do |match|
305
+ # puts "#{match.file}:#{match.line}: #{match.content.strip}"
306
+ # end
307
+ def find_files(path, pattern)
308
+ toolbox_api.find_in_files(path, pattern)
309
+ rescue StandardError => e
310
+ raise Sdk::Error, "Failed to find files: #{e.message}"
311
+ end
312
+
313
+ # Searches for files and directories whose names match the specified pattern.
314
+ # The pattern can be a simple string or a glob pattern.
315
+ #
316
+ # @param path [String] Path to the root directory to start search from. Relative paths are resolved
317
+ # based on the sandbox working directory.
318
+ # @param pattern [String] Pattern to match against file names. Supports glob
319
+ # patterns (e.g., "*.rb" for Ruby files).
320
+ # @return [NightonaApiClient::SearchFilesResponse]
321
+ # @raise [Nightona::Sdk::Error] If the operation fails
322
+ #
323
+ # @example
324
+ # # Find all Ruby files
325
+ # result = sandbox.fs.search_files("workspace", "*.rb")
326
+ # result.files.each { |file| puts file }
327
+ #
328
+ # # Find files with specific prefix
329
+ # result = sandbox.fs.search_files("workspace/data", "test_*")
330
+ # puts "Found #{result.files.length} test files"
331
+ def search_files(path, pattern)
332
+ toolbox_api.search_files(path, pattern)
333
+ rescue StandardError => e
334
+ raise Sdk::Error, "Failed to search files: #{e.message}"
335
+ end
336
+
337
+ # Moves or renames a file or directory. The parent directory of the destination must exist.
338
+ #
339
+ # @param source [String] Path to the source file or directory. Relative paths are resolved
340
+ # based on the sandbox working directory.
341
+ # @param destination [String] Path to the destination. Relative paths are resolved based on
342
+ # the sandbox working directory.
343
+ # @return [void]
344
+ # @raise [Nightona::Sdk::Error] If the operation fails
345
+ #
346
+ # @example
347
+ # # Rename a file
348
+ # sandbox.fs.move_files(
349
+ # "workspace/data/old_name.txt",
350
+ # "workspace/data/new_name.txt"
351
+ # )
352
+ #
353
+ # # Move a file to a different directory
354
+ # sandbox.fs.move_files(
355
+ # "workspace/data/file.txt",
356
+ # "workspace/archive/file.txt"
357
+ # )
358
+ #
359
+ # # Move a directory
360
+ # sandbox.fs.move_files(
361
+ # "workspace/old_dir",
362
+ # "workspace/new_dir"
363
+ # )
364
+ def move_files(source, destination)
365
+ toolbox_api.move_file(source, destination)
366
+ rescue StandardError => e
367
+ raise Sdk::Error, "Failed to move files: #{e.message}"
368
+ end
369
+
370
+ # Performs search and replace operations across multiple files.
371
+ #
372
+ # @param files [Array<String>] List of file paths to perform replacements in. Relative paths are
373
+ # resolved based on the sandbox working directory.
374
+ # @param pattern [String] Pattern to search for.
375
+ # @param new_value [String] Text to replace matches with.
376
+ # @return [Array<NightonaApiClient::ReplaceResult>] List of results indicating replacements made in each file
377
+ # @raise [Nightona::Sdk::Error] If the operation fails
378
+ #
379
+ # @example
380
+ # # Replace in specific files
381
+ # results = sandbox.fs.replace_in_files(
382
+ # files: ["workspace/src/file1.rb", "workspace/src/file2.rb"],
383
+ # pattern: "old_function",
384
+ # new_value: "new_function"
385
+ # )
386
+ #
387
+ # # Print results
388
+ # results.each do |result|
389
+ # if result.success
390
+ # puts "#{result.file}: #{result.success}"
391
+ # else
392
+ # puts "#{result.file}: #{result.error}"
393
+ # end
394
+ # end
395
+ def replace_in_files(files:, pattern:, new_value:)
396
+ replace_request = NightonaApiClient::ReplaceRequest.new(
397
+ files: files,
398
+ pattern: pattern,
399
+ new_value: new_value
400
+ )
401
+ toolbox_api.replace_in_files(replace_request)
402
+ rescue StandardError => e
403
+ raise Sdk::Error, "Failed to replace in files: #{e.message}"
404
+ end
405
+
406
+ # Sets permissions and ownership for a file or directory. Any of the parameters can be nil
407
+ # to leave that attribute unchanged.
408
+ #
409
+ # @param path [String] Path to the file or directory. Relative paths are resolved based on
410
+ # the sandbox working directory.
411
+ # @param mode [String, nil] File mode/permissions in octal format (e.g., "644" for rw-r--r--).
412
+ # @param owner [String, nil] User owner of the file.
413
+ # @param group [String, nil] Group owner of the file.
414
+ # @return [void]
415
+ # @raise [Nightona::Sdk::Error] If the operation fails
416
+ #
417
+ # @example
418
+ # # Make a file executable
419
+ # sandbox.fs.set_file_permissions(
420
+ # path: "workspace/scripts/run.sh",
421
+ # mode: "755" # rwxr-xr-x
422
+ # )
423
+ #
424
+ # # Change file owner
425
+ # sandbox.fs.set_file_permissions(
426
+ # path: "workspace/data/file.txt",
427
+ # owner: "nightona",
428
+ # group: "nightona"
429
+ # )
430
+ def set_file_permissions(path:, mode: nil, owner: nil, group: nil)
431
+ opts = {}
432
+ opts[:mode] = mode if mode
433
+ opts[:owner] = owner if owner
434
+ opts[:group] = group if group
435
+
436
+ toolbox_api.set_file_permissions(path, opts)
437
+ rescue StandardError => e
438
+ raise Sdk::Error, "Failed to set file permissions: #{e.message}"
439
+ end
440
+
441
+ instrument :create_folder, :delete_file, :get_file_info, :list_files, :download_file,
442
+ :download_file_stream, :upload_file, :upload_files, :find_files,
443
+ :search_files, :move_files, :replace_in_files, :set_file_permissions,
444
+ component: 'FileSystem'
445
+
446
+ private
447
+
448
+ # @return [Nightona::OtelState, nil]
449
+ attr_reader :otel_state
450
+ end
451
+ end