daytona-sdk 0.125.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.
@@ -0,0 +1,278 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'json'
4
+ require 'uri'
5
+
6
+ module Daytona
7
+ class Daytona # rubocop:disable Metrics/ClassLength
8
+ # @return [Daytona::Config]
9
+ attr_reader :config
10
+
11
+ # @return [DaytonaApiClient]
12
+ attr_reader :api_client
13
+
14
+ # @return [DaytonaApiClient::SandboxApi]
15
+ attr_reader :sandbox_api
16
+
17
+ # @return [DaytonaApiClient::ToolboxApi]
18
+ attr_reader :toolbox_api
19
+
20
+ # @return [Daytona::VolumeService]
21
+ attr_reader :volume
22
+
23
+ # @return [DaytonaApiClient::ObjectStorageApi]
24
+ attr_reader :object_storage_api
25
+
26
+ # @return [DaytonaApiClient::SnapshotsApi]
27
+ attr_reader :snapshots_api
28
+
29
+ # @return [Daytona::SnapshotService]
30
+ attr_reader :snapshot
31
+
32
+ # @param config [Daytona::Config] Configuration options. Defaults to Daytona::Config.new
33
+ def initialize(config = Config.new) # rubocop:disable Metrics/AbcSize
34
+ @config = config
35
+ ensure_access_token_defined
36
+ @api_client = build_api_client
37
+ @sandbox_api = DaytonaApiClient::SandboxApi.new(api_client)
38
+ @toolbox_api = DaytonaApiClient::ToolboxApi.new(api_client)
39
+ @volume = VolumeService.new(DaytonaApiClient::VolumesApi.new(api_client))
40
+ @object_storage_api = DaytonaApiClient::ObjectStorageApi.new(api_client)
41
+ @snapshots_api = DaytonaApiClient::SnapshotsApi.new(api_client)
42
+ @snapshot = SnapshotService.new(snapshots_api:, object_storage_api:)
43
+ end
44
+
45
+ # Creates a sandbox with the specified parameters
46
+ #
47
+ # @param params [Daytona::CreateSandboxFromSnapshotParams, Daytona::CreateSandboxFromImageParams, Nil] Sandbox creation parameters
48
+ # @return [Daytona::Sandbox] The created sandbox
49
+ # @raise [Daytona::Sdk::Error] If auto_stop_interval or auto_archive_interval is negative
50
+ def create(params = nil, on_snapshot_create_logs: nil)
51
+ if params.nil?
52
+ params = CreateSandboxFromSnapshotParams.new(language: CodeLanguage::PYTHON)
53
+ elsif params.language.nil?
54
+ params.language = CodeLanguage::PYTHON
55
+ end
56
+
57
+ _create(params, on_snapshot_create_logs:)
58
+ end
59
+
60
+ # Deletes a Sandbox.
61
+ #
62
+ # @param sandbox [Daytona::Sandbox]
63
+ # @return [void]
64
+ def delete(sandbox) = sandbox.delete
65
+
66
+ # Gets a Sandbox by its ID.
67
+ #
68
+ # @param id [String]
69
+ # @return [Daytona::Sandbox]
70
+ def get(id)
71
+ sandbox_dto = sandbox_api.get_sandbox(id)
72
+ to_sandbox(sandbox_dto:, code_toolbox: code_toolbox_from_labels(sandbox_dto.labels))
73
+ end
74
+
75
+ # Finds a Sandbox by its ID or labels.
76
+ #
77
+ # @param id [String, Nil]
78
+ # @param labels [Hash<String, String>]
79
+ # @return [Daytona::Sandbox]
80
+ # @raise [Daytona::Sdk::Error]
81
+ def find_one(id: nil, labels: nil)
82
+ return get(id) if id
83
+
84
+ response = list(labels)
85
+ raise Sdk::Error, "No sandbox found with labels #{labels}" if response.items.empty?
86
+
87
+ response.items.first
88
+ end
89
+
90
+ # Lists Sandboxes filtered by labels.
91
+ #
92
+ # @param labels [Hash<String, String>]
93
+ # @param page [Integer, Nil]
94
+ # @param limit [Integer, Nil]
95
+ # @return [Daytona::PaginatedResource]
96
+ # @raise [Daytona::Sdk::Error]
97
+ def list(labels = {}, page: nil, limit: nil) # rubocop:disable Metrics/MethodLength
98
+ raise Sdk::Error, 'page must be positive integer' if page && page < 1
99
+
100
+ raise Sdk::Error, 'limit must be positive integer' if limit && limit < 1
101
+
102
+ response = sandbox_api.list_sandboxes_paginated(labels: JSON.dump(labels), page:, limit:)
103
+
104
+ PaginatedResource.new(
105
+ total: response.total,
106
+ page: response.page,
107
+ total_pages: response.total_pages,
108
+ items: response
109
+ .items
110
+ .map { |sandbox_dto| to_sandbox(sandbox_dto:, code_toolbox: code_toolbox_from_labels(sandbox_dto.labels)) }
111
+ )
112
+ end
113
+
114
+ # Starts a Sandbox and waits for it to be ready.
115
+ #
116
+ # @param sandbox [Daytona::Sandbox]
117
+ # @param timeout [Numeric] Maximum wait time in seconds (defaults to 60 s).
118
+ # @return [void]
119
+ def start(sandbox, timeout = Sandbox::DEFAULT_TIMEOUT) = sandbox.start(timeout)
120
+
121
+ # Stops a Sandbox and waits for it to be stopped.
122
+ #
123
+ # @param sandbox [Daytona::Sandbox]
124
+ # @param timeout [Numeric] Maximum wait time in seconds (defaults to 60 s).
125
+ # @return [void]
126
+ def stop(sandbox, timeout = Sandbox::DEFAULT_TIMEOUT) = sandbox.stop(timeout)
127
+
128
+ private
129
+
130
+ # Creates a sandbox with the specified parameters
131
+ #
132
+ # @param params [Daytona::CreateSandboxFromSnapshotParams, Daytona::CreateSandboxFromImageParams] Sandbox creation parameters
133
+ # @param timeout [Numeric] Maximum wait time in seconds (defaults to 60 s).
134
+ # @param on_snapshot_create_logs [Proc]
135
+ # @return [Daytona::Sandbox] The created sandbox
136
+ # @raise [Daytona::Sdk::Error] If auto_stop_interval or auto_archive_interval is negative
137
+ def _create(params, timeout: 60, on_snapshot_create_logs: nil) # rubocop:disable Metrics/AbcSize, Metrics/CyclomaticComplexity, Metrics/MethodLength, Metrics/PerceivedComplexity
138
+ raise Sdk::Error, 'Timeout must be a non-negative number' if timeout.negative?
139
+
140
+ start_time = Time.now
141
+
142
+ raise Sdk::Error, 'auto_stop_interval must be a non-negative integer' if params.auto_stop_interval&.negative?
143
+
144
+ if params.auto_archive_interval&.negative?
145
+ raise Sdk::Error, 'auto_archive_interval must be a non-negative integer'
146
+ end
147
+
148
+ create_sandbox = DaytonaApiClient::CreateSandbox.new(
149
+ user: params.os_user,
150
+ env: params.env_vars || {},
151
+ labels: params.labels,
152
+ public: params.public,
153
+ target: config.target,
154
+ auto_stop_interval: params.auto_stop_interval,
155
+ auto_archive_interval: params.auto_archive_interval,
156
+ auto_delete_interval: params.auto_delete_interval,
157
+ volumes: params.volumes,
158
+ network_block_all: params.network_block_all,
159
+ network_allow_list: params.network_allow_list
160
+ )
161
+
162
+ create_sandbox.snapshot = params.snapshot if params.respond_to?(:snapshot)
163
+
164
+ if params.respond_to?(:image) && params.image.is_a?(String)
165
+ create_sandbox.build_info = DaytonaApiClient::CreateBuildInfo.new(
166
+ dockerfile_content: Image.base(params.image).dockerfile
167
+ )
168
+ elsif params.respond_to?(:image) && params.image.is_a?(Image)
169
+ create_sandbox.build_info = DaytonaApiClient::CreateBuildInfo.new(
170
+ context_hashes: SnapshotService.process_image_context(object_storage_api, params.image),
171
+ dockerfile_content: params.image.dockerfile
172
+ )
173
+ end
174
+
175
+ if params.respond_to?(:resources)
176
+ create_sandbox.cpu = params.resources&.cpu
177
+ create_sandbox.memory = params.resources&.memory
178
+ create_sandbox.disk = params.resources&.disk
179
+ create_sandbox.gpu = params.resources&.gpu
180
+ end
181
+
182
+ response = sandbox_api.create_sandbox(create_sandbox)
183
+
184
+ if response.state == DaytonaApiClient::SandboxState::PENDING_BUILD && on_snapshot_create_logs
185
+ uri = URI.parse(sandbox_api.api_client.config.base_url)
186
+ uri.path = "/api/sandbox/#{response.id}/build-logs"
187
+ uri.query = 'follow=true'
188
+
189
+ headers = {}
190
+ sandbox_api.api_client.update_params_for_auth!(headers, nil, ['bearer'])
191
+ Util.stream_async(uri:, headers:, on_chunk: on_snapshot_create_logs)
192
+ end
193
+
194
+ sandbox = to_sandbox(sandbox_dto: response, code_toolbox: code_toolbox_from_labels(response.labels))
195
+
196
+ if sandbox.state != DaytonaApiClient::SandboxState::STARTED
197
+ sandbox.wait_for_sandbox_start([0.001, timeout - (Time.now - start_time)].max)
198
+ end
199
+
200
+ sandbox
201
+ end
202
+
203
+ # @return [void]
204
+ # @raise [Daytona::Sdk::Error]
205
+ def ensure_access_token_defined
206
+ return if config.api_key
207
+
208
+ raise Sdk::Error, 'API key or JWT token is required' unless config.jwt_token
209
+ raise Sdk::Error, 'Organization ID is required when using JWT token' unless config.organization_id
210
+ end
211
+
212
+ # @return [DaytonaApiClient::ApiClient]
213
+ def build_api_client
214
+ DaytonaApiClient::ApiClient.new(api_client_config).tap do |client|
215
+ client.default_headers[HEADER_SOURCE] = SOURCE_RUBY
216
+ client.default_headers[HEADER_SDK_VERSION] = Sdk::VERSION
217
+ client.default_headers[HEADER_ORGANIZATION_ID] = config.organization_id if config.jwt_token
218
+ end
219
+ end
220
+
221
+ # @return [DaytonaApiClient::Configuration]
222
+ def api_client_config
223
+ DaytonaApiClient::Configuration.new.configure do |api_config|
224
+ uri = URI(config.api_url)
225
+ api_config.scheme = uri.scheme
226
+ api_config.host = uri.host
227
+ api_config.base_path = uri.path
228
+
229
+ api_config.access_token_getter = proc { config.api_key || config.jwt_token }
230
+ api_config
231
+ end
232
+ end
233
+
234
+ # @param sandbox_dto [DaytonaApiClient::Sandbox]
235
+ # @param code_toolbox [Daytona::SandboxPythonCodeToolbox, Daytona::SandboxTsCodeToolbox]
236
+ # @return [Daytona::Sandbox]
237
+ def to_sandbox(sandbox_dto:, code_toolbox:)
238
+ Sandbox.new(sandbox_dto:, config:, sandbox_api:, toolbox_api:, code_toolbox:)
239
+ end
240
+
241
+ # Converts a language to a code toolbox
242
+ #
243
+ # @param language [Symbol]
244
+ # @return [Daytona::CodeToolbox]
245
+ # @raise [Daytona::Sdk::Error] If the language is not supported
246
+ def code_toolbox_from_language(language)
247
+ case language
248
+ when CodeLanguage::PYTHON, nil
249
+ SandboxPythonCodeToolbox.new
250
+ when SandboxTsCodeToolbox, CodeLanguage::TYPESCRIPT
251
+ SandboxTsCodeToolbox.new
252
+ else
253
+ raise Sdk::Error, "Unsupported language: #{language}"
254
+ end
255
+ end
256
+
257
+ # Get code toolbox from Sandbox labels
258
+ #
259
+ # @param labels [Hash<String, String>]
260
+ # @return [Daytona::CodeToolbox]
261
+ def code_toolbox_from_labels(labels) = code_toolbox_from_language(labels[LABEL_CODE_TOOLBOX_LANGUAGE]&.to_sym)
262
+
263
+ SOURCE_RUBY = 'ruby-sdk'
264
+ private_constant :SOURCE_RUBY
265
+
266
+ HEADER_SOURCE = 'X-Daytona-Source'
267
+ private_constant :HEADER_SOURCE
268
+
269
+ HEADER_SDK_VERSION = 'X-Daytona-SDK-Version'
270
+ private_constant :HEADER_SDK_VERSION
271
+
272
+ HEADER_ORGANIZATION_ID = 'X-Daytona-Organization-ID'
273
+ private_constant :HEADER_ORGANIZATION_ID
274
+
275
+ LABEL_CODE_TOOLBOX_LANGUAGE = 'code-toolbox-language'
276
+ private_constant :LABEL_CODE_TOOLBOX_LANGUAGE
277
+ end
278
+ end
@@ -0,0 +1,359 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'tempfile'
4
+ require 'fileutils'
5
+
6
+ module Daytona
7
+ class FileSystem
8
+ # @return [String] The Sandbox ID
9
+ attr_reader :sandbox_id
10
+
11
+ # @return [DaytonaApiClient::ToolboxApi] API client for Sandbox operations
12
+ attr_reader :toolbox_api
13
+
14
+ # Initializes a new FileSystem instance.
15
+ #
16
+ # @param sandbox_id [String] The Sandbox ID
17
+ # @param toolbox_api [DaytonaApiClient::ToolboxApi] API client for Sandbox operations
18
+ def initialize(sandbox_id:, toolbox_api:)
19
+ @sandbox_id = sandbox_id
20
+ @toolbox_api = toolbox_api
21
+ end
22
+
23
+ # Creates a new directory in the Sandbox at the specified path with the given
24
+ # permissions.
25
+ #
26
+ # @param path [String] Path where the folder should be created. Relative paths are resolved based
27
+ # on the sandbox working directory.
28
+ # @param mode [String] Folder permissions in octal format (e.g., "755" for rwxr-xr-x).
29
+ # @return [void]
30
+ # @raise [Daytona::Sdk::Error] If the operation fails
31
+ #
32
+ # @example
33
+ # # Create a directory with standard permissions
34
+ # sandbox.fs.create_folder("workspace/data", "755")
35
+ #
36
+ # # Create a private directory
37
+ # sandbox.fs.create_folder("workspace/secrets", "700")
38
+ def create_folder(path, mode)
39
+ Sdk.logger.debug("Creating folder #{path} with mode #{mode}")
40
+ toolbox_api.create_folder(sandbox_id, path, mode)
41
+ rescue StandardError => e
42
+ raise Sdk::Error, "Failed to create folder: #{e.message}"
43
+ end
44
+
45
+ # Deletes a file from the Sandbox.
46
+ #
47
+ # @param path [String] Path to the file to delete. Relative paths are resolved based on the sandbox working directory.
48
+ # @param recursive [Boolean] If the file is a directory, this must be true to delete it.
49
+ # @return [void]
50
+ # @raise [Daytona::Sdk::Error] If the operation fails
51
+ #
52
+ # @example
53
+ # # Delete a file
54
+ # sandbox.fs.delete_file("workspace/data/old_file.txt")
55
+ #
56
+ # # Delete a directory recursively
57
+ # sandbox.fs.delete_file("workspace/old_dir", recursive: true)
58
+ def delete_file(path, recursive: false)
59
+ toolbox_api.delete_file(sandbox_id, path, { recursive: })
60
+ rescue StandardError => e
61
+ raise Sdk::Error, "Failed to delete file: #{e.message}"
62
+ end
63
+
64
+ # Gets detailed information about a file or directory, including its
65
+ # size, permissions, and timestamps.
66
+ #
67
+ # @param path [String] Path to the file or directory. Relative paths are resolved based
68
+ # on the sandbox working directory.
69
+ # @return [DaytonaApiClient::FileInfo] Detailed file information
70
+ # @raise [Daytona::Sdk::Error] If the operation fails
71
+ #
72
+ # @example
73
+ # # Get file metadata
74
+ # info = sandbox.fs.get_file_info("workspace/data/file.txt")
75
+ # puts "Size: #{info.size} bytes"
76
+ # puts "Modified: #{info.mod_time}"
77
+ # puts "Mode: #{info.mode}"
78
+ #
79
+ # # Check if path is a directory
80
+ # info = sandbox.fs.get_file_info("workspace/data")
81
+ # puts "Path is a directory" if info.is_dir
82
+ def get_file_info(path)
83
+ toolbox_api.get_file_info(sandbox_id, path)
84
+ rescue StandardError => e
85
+ raise Sdk::Error, "Failed to get file info: #{e.message}"
86
+ end
87
+
88
+ # Lists files and directories in a given path and returns their information, similar to the ls -l command.
89
+ #
90
+ # @param path [String] Path to the directory to list contents from. Relative paths are resolved
91
+ # based on the sandbox working directory.
92
+ # @return [Array<DaytonaApiClient::FileInfo>] List of file and directory information
93
+ # @raise [Daytona::Sdk::Error] If the operation fails
94
+ #
95
+ # @example
96
+ # # List directory contents
97
+ # files = sandbox.fs.list_files("workspace/data")
98
+ #
99
+ # # Print files and their sizes
100
+ # files.each do |file|
101
+ # puts "#{file.name}: #{file.size} bytes" unless file.is_dir
102
+ # end
103
+ #
104
+ # # List only directories
105
+ # dirs = files.select(&:is_dir)
106
+ # puts "Subdirectories: #{dirs.map(&:name).join(', ')}"
107
+ def list_files(path)
108
+ toolbox_api.list_files(sandbox_id, { path: })
109
+ rescue StandardError => e
110
+ raise Sdk::Error, "Failed to list files: #{e.message}"
111
+ end
112
+
113
+ # Downloads a file from the Sandbox. Returns the file contents as a string.
114
+ # This method is useful when you want to load the file into memory without saving it to disk.
115
+ # It can only be used for smaller files.
116
+ #
117
+ # @param remote_path [String] Path to the file in the Sandbox. Relative paths are resolved based
118
+ # on the sandbox working directory.
119
+ # @param local_path [String, nil] Optional path to save the file locally. If provided, the file will be saved to disk.
120
+ # @return [File, nil] The file if local_path is nil, otherwise nil
121
+ # @raise [Daytona::Sdk::Error] If the operation fails
122
+ #
123
+ # @example
124
+ # # Download and get file content
125
+ # content = sandbox.fs.download_file("workspace/data/file.txt")
126
+ # puts content
127
+ #
128
+ # # Download and save a file locally
129
+ # sandbox.fs.download_file("workspace/data/file.txt", "local_copy.txt")
130
+ # size_mb = File.size("local_copy.txt") / 1024.0 / 1024.0
131
+ # puts "Size of the downloaded file: #{size_mb} MB"
132
+ def download_file(remote_path, local_path = nil) # rubocop:disable Metrics/MethodLength
133
+ file = toolbox_api.download_file(sandbox_id, remote_path)
134
+
135
+ if local_path
136
+
137
+ parent_dir = File.dirname(local_path)
138
+ FileUtils.mkdir_p(parent_dir) unless parent_dir == '.'
139
+
140
+ File.binwrite(local_path, file.open.read)
141
+ nil
142
+ else
143
+ file
144
+ end
145
+ rescue StandardError => e
146
+ raise Sdk::Error, "Failed to download file: #{e.message}"
147
+ end
148
+
149
+ # Uploads a file to the specified path in the Sandbox. If a file already exists at
150
+ # the destination path, it will be overwritten.
151
+ #
152
+ # @param source [String, IO] File contents as a string/bytes or a local file path or IO object.
153
+ # @param remote_path [String] Path to the destination file. Relative paths are resolved based on
154
+ # the sandbox working directory.
155
+ # @return [void]
156
+ # @raise [Daytona::Sdk::Error] If the operation fails
157
+ #
158
+ # @example
159
+ # # Upload a text file from string content
160
+ # content = "Hello, World!"
161
+ # sandbox.fs.upload_file(content, "tmp/hello.txt")
162
+ #
163
+ # # Upload a local file
164
+ # sandbox.fs.upload_file("local_file.txt", "tmp/file.txt")
165
+ #
166
+ # # Upload binary data
167
+ # data = { key: "value" }.to_json
168
+ # sandbox.fs.upload_file(data, "tmp/config.json")
169
+ def upload_file(source, remote_path) # rubocop:disable Metrics/AbcSize, Metrics/MethodLength
170
+ if source.is_a?(String) && File.exist?(source)
171
+ # Source is a file path
172
+ File.open(source, 'rb') { |file| toolbox_api.upload_file(sandbox_id, remote_path, { file: }) }
173
+ elsif source.respond_to?(:read)
174
+ # Source is an IO object
175
+ toolbox_api.upload_file(sandbox_id, remote_path, { file: source })
176
+ else
177
+ # Source is string content - create a temporary file
178
+ Tempfile.create('daytona_upload') do |file|
179
+ file.binmode
180
+ file.write(source)
181
+ file.rewind
182
+ toolbox_api.upload_file(sandbox_id, remote_path, { file: })
183
+ end
184
+ end
185
+ rescue StandardError => e
186
+ raise Sdk::Error, "Failed to upload file: #{e.message}"
187
+ end
188
+
189
+ # Uploads multiple files to the Sandbox. If files already exist at the destination paths,
190
+ # they will be overwritten.
191
+ #
192
+ # @param files [Array<FileUpload>] List of files to upload.
193
+ # @return [void]
194
+ # @raise [Daytona::Sdk::Error] If the operation fails
195
+ #
196
+ # @example
197
+ # # Upload multiple files
198
+ # files = [
199
+ # FileUpload.new("Content of file 1", "/tmp/file1.txt"),
200
+ # FileUpload.new("workspace/data/file2.txt", "/tmp/file2.txt"),
201
+ # FileUpload.new('{"key": "value"}', "/tmp/config.json")
202
+ # ]
203
+ # sandbox.fs.upload_files(files)
204
+ def upload_files(files)
205
+ files.each { |file_upload| upload_file(file_upload.source, file_upload.destination) }
206
+ rescue StandardError => e
207
+ raise Sdk::Error, "Failed to upload files: #{e.message}"
208
+ end
209
+
210
+ # Searches for files containing a pattern, similar to the grep command.
211
+ #
212
+ # @param path [String] Path to the file or directory to search. If the path is a directory,
213
+ # the search will be performed recursively. Relative paths are resolved based
214
+ # on the sandbox working directory.
215
+ # @param pattern [String] Search pattern to match against file contents.
216
+ # @return [Array<DaytonaApiClient::Match>] List of matches found in files
217
+ # @raise [Daytona::Sdk::Error] If the operation fails
218
+ #
219
+ # @example
220
+ # # Search for TODOs in Ruby files
221
+ # matches = sandbox.fs.find_files("workspace/src", "TODO:")
222
+ # matches.each do |match|
223
+ # puts "#{match.file}:#{match.line}: #{match.content.strip}"
224
+ # end
225
+ def find_files(path, pattern)
226
+ toolbox_api.find_in_files(sandbox_id, path, pattern)
227
+ rescue StandardError => e
228
+ raise Sdk::Error, "Failed to find files: #{e.message}"
229
+ end
230
+
231
+ # Searches for files and directories whose names match the specified pattern.
232
+ # The pattern can be a simple string or a glob pattern.
233
+ #
234
+ # @param path [String] Path to the root directory to start search from. Relative paths are resolved
235
+ # based on the sandbox working directory.
236
+ # @param pattern [String] Pattern to match against file names. Supports glob
237
+ # patterns (e.g., "*.rb" for Ruby files).
238
+ # @return [DaytonaApiClient::SearchFilesResponse]
239
+ # @raise [Daytona::Sdk::Error] If the operation fails
240
+ #
241
+ # @example
242
+ # # Find all Ruby files
243
+ # result = sandbox.fs.search_files("workspace", "*.rb")
244
+ # result.files.each { |file| puts file }
245
+ #
246
+ # # Find files with specific prefix
247
+ # result = sandbox.fs.search_files("workspace/data", "test_*")
248
+ # puts "Found #{result.files.length} test files"
249
+ def search_files(path, pattern)
250
+ toolbox_api.search_files(sandbox_id, path, pattern)
251
+ rescue StandardError => e
252
+ raise Sdk::Error, "Failed to search files: #{e.message}"
253
+ end
254
+
255
+ # Moves or renames a file or directory. The parent directory of the destination must exist.
256
+ #
257
+ # @param source [String] Path to the source file or directory. Relative paths are resolved
258
+ # based on the sandbox working directory.
259
+ # @param destination [String] Path to the destination. Relative paths are resolved based on
260
+ # the sandbox working directory.
261
+ # @return [void]
262
+ # @raise [Daytona::Sdk::Error] If the operation fails
263
+ #
264
+ # @example
265
+ # # Rename a file
266
+ # sandbox.fs.move_files(
267
+ # "workspace/data/old_name.txt",
268
+ # "workspace/data/new_name.txt"
269
+ # )
270
+ #
271
+ # # Move a file to a different directory
272
+ # sandbox.fs.move_files(
273
+ # "workspace/data/file.txt",
274
+ # "workspace/archive/file.txt"
275
+ # )
276
+ #
277
+ # # Move a directory
278
+ # sandbox.fs.move_files(
279
+ # "workspace/old_dir",
280
+ # "workspace/new_dir"
281
+ # )
282
+ def move_files(source, destination)
283
+ toolbox_api.move_file(sandbox_id, source, destination)
284
+ rescue StandardError => e
285
+ raise Sdk::Error, "Failed to move files: #{e.message}"
286
+ end
287
+
288
+ # Performs search and replace operations across multiple files.
289
+ #
290
+ # @param files [Array<String>] List of file paths to perform replacements in. Relative paths are
291
+ # resolved based on the sandbox working directory.
292
+ # @param pattern [String] Pattern to search for.
293
+ # @param new_value [String] Text to replace matches with.
294
+ # @return [Array<DaytonaApiClient::ReplaceResult>] List of results indicating replacements made in each file
295
+ # @raise [Daytona::Sdk::Error] If the operation fails
296
+ #
297
+ # @example
298
+ # # Replace in specific files
299
+ # results = sandbox.fs.replace_in_files(
300
+ # files: ["workspace/src/file1.rb", "workspace/src/file2.rb"],
301
+ # pattern: "old_function",
302
+ # new_value: "new_function"
303
+ # )
304
+ #
305
+ # # Print results
306
+ # results.each do |result|
307
+ # if result.success
308
+ # puts "#{result.file}: #{result.success}"
309
+ # else
310
+ # puts "#{result.file}: #{result.error}"
311
+ # end
312
+ # end
313
+ def replace_in_files(files:, pattern:, new_value:)
314
+ replace_request = DaytonaApiClient::ReplaceRequest.new(
315
+ files: files,
316
+ pattern: pattern,
317
+ new_value: new_value
318
+ )
319
+ toolbox_api.replace_in_files(sandbox_id, replace_request)
320
+ rescue StandardError => e
321
+ raise Sdk::Error, "Failed to replace in files: #{e.message}"
322
+ end
323
+
324
+ # Sets permissions and ownership for a file or directory. Any of the parameters can be nil
325
+ # to leave that attribute unchanged.
326
+ #
327
+ # @param path [String] Path to the file or directory. Relative paths are resolved based on
328
+ # the sandbox working directory.
329
+ # @param mode [String, nil] File mode/permissions in octal format (e.g., "644" for rw-r--r--).
330
+ # @param owner [String, nil] User owner of the file.
331
+ # @param group [String, nil] Group owner of the file.
332
+ # @return [void]
333
+ # @raise [Daytona::Sdk::Error] If the operation fails
334
+ #
335
+ # @example
336
+ # # Make a file executable
337
+ # sandbox.fs.set_file_permissions(
338
+ # path: "workspace/scripts/run.sh",
339
+ # mode: "755" # rwxr-xr-x
340
+ # )
341
+ #
342
+ # # Change file owner
343
+ # sandbox.fs.set_file_permissions(
344
+ # path: "workspace/data/file.txt",
345
+ # owner: "daytona",
346
+ # group: "daytona"
347
+ # )
348
+ def set_file_permissions(path:, mode: nil, owner: nil, group: nil)
349
+ opts = {}
350
+ opts[:mode] = mode if mode
351
+ opts[:owner] = owner if owner
352
+ opts[:group] = group if group
353
+
354
+ toolbox_api.set_file_permissions(sandbox_id, path, opts)
355
+ rescue StandardError => e
356
+ raise Sdk::Error, "Failed to set file permissions: #{e.message}"
357
+ end
358
+ end
359
+ end