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,287 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Daytona
4
+ class Git
5
+ # @return [String] The Sandbox ID
6
+ attr_reader :sandbox_id
7
+
8
+ # @return [DaytonaApiClient::ToolboxApi] API client for Sandbox operations
9
+ attr_reader :toolbox_api
10
+
11
+ # Initializes a new Git handler instance.
12
+ #
13
+ # @param sandbox_id [String] The Sandbox ID.
14
+ # @param toolbox_api [DaytonaApiClient::ToolboxApi] API client for Sandbox operations.
15
+ def initialize(sandbox_id:, toolbox_api:)
16
+ @sandbox_id = sandbox_id
17
+ @toolbox_api = toolbox_api
18
+ end
19
+
20
+ # Stages the specified files for the next commit, similar to
21
+ # running 'git add' on the command line.
22
+ #
23
+ # @param path [String] Path to the Git repository root. Relative paths are resolved based on
24
+ # the sandbox working directory.
25
+ # @param files [Array<String>] List of file paths or directories to stage, relative to the repository root.
26
+ # @return [void]
27
+ # @raise [Daytona::Sdk::Error] if adding files fails
28
+ #
29
+ # @example
30
+ # # Stage a single file
31
+ # sandbox.git.add("workspace/repo", ["file.txt"])
32
+ #
33
+ # # Stage multiple files
34
+ # sandbox.git.add("workspace/repo", [
35
+ # "src/main.rb",
36
+ # "spec/main_spec.rb",
37
+ # "README.md"
38
+ # ])
39
+ def add(path, files)
40
+ toolbox_api.git_add_files(sandbox_id, DaytonaApiClient::GitAddRequest.new(path:, files:))
41
+ rescue StandardError => e
42
+ raise Sdk::Error, "Failed to add files: #{e.message}"
43
+ end
44
+
45
+ # Lists branches in the repository.
46
+ #
47
+ # @param path [String] Path to the Git repository root. Relative paths are resolved based on
48
+ # the sandbox working directory.
49
+ # @return [DaytonaApiClient::ListBranchResponse] List of branches in the repository.
50
+ # @raise [Daytona::Sdk::Error] if listing branches fails
51
+ #
52
+ # @example
53
+ # response = sandbox.git.branches("workspace/repo")
54
+ # puts "Branches: #{response.branches}"
55
+ def branches(path)
56
+ toolbox_api.git_list_branches(sandbox_id, path)
57
+ rescue StandardError => e
58
+ raise Sdk::Error, "Failed to list branches: #{e.message}"
59
+ end
60
+
61
+ # Clones a Git repository into the specified path. It supports
62
+ # cloning specific branches or commits, and can authenticate with the remote
63
+ # repository if credentials are provided.
64
+ #
65
+ # @param url [String] Repository URL to clone from.
66
+ # @param path [String] Path where the repository should be cloned. Relative paths are resolved
67
+ # based on the sandbox working directory.
68
+ # @param branch [String, nil] Specific branch to clone. If not specified,
69
+ # clones the default branch.
70
+ # @param commit_id [String, nil] Specific commit to clone. If specified,
71
+ # the repository will be left in a detached HEAD state at this commit.
72
+ # @param username [String, nil] Git username for authentication.
73
+ # @param password [String, nil] Git password or token for authentication.
74
+ # @return [void]
75
+ # @raise [Daytona::Sdk::Error] if cloning repository fails
76
+ #
77
+ # @example
78
+ # # Clone the default branch
79
+ # sandbox.git.clone(
80
+ # url: "https://github.com/user/repo.git",
81
+ # path: "workspace/repo"
82
+ # )
83
+ #
84
+ # # Clone a specific branch with authentication
85
+ # sandbox.git.clone(
86
+ # url: "https://github.com/user/private-repo.git",
87
+ # path: "workspace/private",
88
+ # branch: "develop",
89
+ # username: "user",
90
+ # password: "token"
91
+ # )
92
+ #
93
+ # # Clone a specific commit
94
+ # sandbox.git.clone(
95
+ # url: "https://github.com/user/repo.git",
96
+ # path: "workspace/repo-old",
97
+ # commit_id: "abc123"
98
+ # )
99
+ def clone(url:, path:, branch: nil, commit_id: nil, username: nil, password: nil) # rubocop:disable Metrics/MethodLength, Metrics/ParameterLists
100
+ toolbox_api.git_clone_repository(
101
+ sandbox_id,
102
+ DaytonaApiClient::GitCloneRequest.new(
103
+ url: url,
104
+ branch: branch,
105
+ path: path,
106
+ username: username,
107
+ password: password,
108
+ commit_id: commit_id
109
+ )
110
+ )
111
+ rescue StandardError => e
112
+ raise Sdk::Error, "Failed to clone repository: #{e.message}"
113
+ end
114
+
115
+ # Creates a new commit with the staged changes. Make sure to stage
116
+ # changes using the add() method before committing.
117
+ #
118
+ # @param path [String] Path to the Git repository root. Relative paths are resolved based on
119
+ # the sandbox working directory.
120
+ # @param message [String] Commit message describing the changes.
121
+ # @param author [String] Name of the commit author.
122
+ # @param email [String] Email address of the commit author.
123
+ # @param allow_empty [Boolean] Allow creating an empty commit when no changes are staged. Defaults to false.
124
+ # @return [GitCommitResponse] Response containing the commit SHA.
125
+ # @raise [Daytona::Sdk::Error] if committing changes fails
126
+ #
127
+ # @example
128
+ # # Stage and commit changes
129
+ # sandbox.git.add("workspace/repo", ["README.md"])
130
+ # commit_response = sandbox.git.commit(
131
+ # path: "workspace/repo",
132
+ # message: "Update documentation",
133
+ # author: "John Doe",
134
+ # email: "john@example.com",
135
+ # allow_empty: true
136
+ # )
137
+ # puts "Commit SHA: #{commit_response.sha}"
138
+ def commit(path:, message:, author:, email:, allow_empty: false)
139
+ response = toolbox_api.git_commit_changes(
140
+ sandbox_id,
141
+ DaytonaApiClient::GitCommitRequest.new(path:, message:, author:, email:, allow_empty:)
142
+ )
143
+ GitCommitResponse.new(sha: response.hash)
144
+ rescue StandardError => e
145
+ raise Sdk::Error, "Failed to commit changes: #{e.message}"
146
+ end
147
+
148
+ # Pushes all local commits on the current branch to the remote
149
+ # repository. If the remote repository requires authentication, provide
150
+ # username and password/token.
151
+ #
152
+ # @param path [String] Path to the Git repository root. Relative paths are resolved based on
153
+ # the sandbox working directory.
154
+ # @param username [String, nil] Git username for authentication.
155
+ # @param password [String, nil] Git password or token for authentication.
156
+ # @return [void]
157
+ # @raise [Daytona::Sdk::Error] if pushing changes fails
158
+ #
159
+ # @example
160
+ # # Push without authentication (for public repos or SSH)
161
+ # sandbox.git.push("workspace/repo")
162
+ #
163
+ # # Push with authentication
164
+ # sandbox.git.push(
165
+ # path: "workspace/repo",
166
+ # username: "user",
167
+ # password: "github_token"
168
+ # )
169
+ def push(path:, username: nil, password: nil)
170
+ toolbox_api.git_push_changes(
171
+ sandbox_id,
172
+ DaytonaApiClient::GitRepoRequest.new(path:, username:, password:)
173
+ )
174
+ rescue StandardError => e
175
+ raise Sdk::Error, "Failed to push changes: #{e.message}"
176
+ end
177
+
178
+ # Pulls changes from the remote repository. If the remote repository requires authentication,
179
+ # provide username and password/token.
180
+ #
181
+ # @param path [String] Path to the Git repository root. Relative paths are resolved based on
182
+ # the sandbox working directory.
183
+ # @param username [String, nil] Git username for authentication.
184
+ # @param password [String, nil] Git password or token for authentication.
185
+ # @return [void]
186
+ # @raise [Daytona::Sdk::Error] if pulling changes fails
187
+ #
188
+ # @example
189
+ # # Pull without authentication
190
+ # sandbox.git.pull("workspace/repo")
191
+ #
192
+ # # Pull with authentication
193
+ # sandbox.git.pull(
194
+ # path: "workspace/repo",
195
+ # username: "user",
196
+ # password: "github_token"
197
+ # )
198
+ #
199
+ def pull(path:, username: nil, password: nil)
200
+ toolbox_api.git_pull_changes(
201
+ sandbox_id,
202
+ DaytonaApiClient::GitRepoRequest.new(path:, username:, password:)
203
+ )
204
+ rescue StandardError => e
205
+ raise Sdk::Error, "Failed to pull changes: #{e.message}"
206
+ end
207
+
208
+ # Gets the current Git repository status.
209
+ #
210
+ # @param path [String] Path to the Git repository root. Relative paths are resolved based on
211
+ # the sandbox working directory.
212
+ # @return [DaytonaApiClient::GitStatus] Repository status information including:
213
+ # @raise [Daytona::Sdk::Error] if getting status fails
214
+ #
215
+ # @example
216
+ # status = sandbox.git.status("workspace/repo")
217
+ # puts "On branch: #{status.current_branch}"
218
+ # puts "Commits ahead: #{status.ahead}"
219
+ # puts "Commits behind: #{status.behind}"
220
+ def status(path)
221
+ toolbox_api.git_get_status(sandbox_id, path)
222
+ rescue StandardError => e
223
+ raise Sdk::Error, "Failed to get status: #{e.message}"
224
+ end
225
+
226
+ # Checkout branch in the repository.
227
+ #
228
+ # @param path [String] Path to the Git repository root. Relative paths are resolved based on
229
+ # the sandbox working directory.
230
+ # @param branch [String] Name of the branch to checkout
231
+ # @return [void]
232
+ # @raise [Daytona::Sdk::Error] if checking out branch fails
233
+ #
234
+ # @example
235
+ # # Checkout a branch
236
+ # sandbox.git.checkout_branch("workspace/repo", "feature-branch")
237
+ def checkout_branch(path, branch)
238
+ toolbox_api.git_checkout_branch(
239
+ sandbox_id,
240
+ DaytonaApiClient::GitCheckoutRequest.new(path:, branch:)
241
+ )
242
+ rescue StandardError => e
243
+ raise Sdk::Error, "Failed to checkout branch: #{e.message}"
244
+ end
245
+
246
+ # Create branch in the repository.
247
+ #
248
+ # @param path [String] Path to the Git repository root. Relative paths are resolved based on
249
+ # the sandbox working directory.
250
+ # @param name [String] Name of the new branch to create
251
+ # @return [void]
252
+ # @raise [Daytona::Sdk::Error] if creating branch fails
253
+ #
254
+ # @example
255
+ # # Create a new branch
256
+ # sandbox.git.create_branch("workspace/repo", "new-feature")
257
+ #
258
+ def create_branch(path, name)
259
+ toolbox_api.git_create_branch(
260
+ sandbox_id,
261
+ DaytonaApiClient::GitBranchRequest.new(path:, name:)
262
+ )
263
+ rescue StandardError => e
264
+ raise Sdk::Error, "Failed to create branch: #{e.message}"
265
+ end
266
+
267
+ # Delete branch in the repository.
268
+ #
269
+ # @param path [String] Path to the Git repository root. Relative paths are resolved based on
270
+ # the sandbox working directory.
271
+ # @param name [String] Name of the branch to delete
272
+ # @return [void]
273
+ # @raise [Daytona::Sdk::Error] if deleting branch fails
274
+ #
275
+ # @example
276
+ # # Delete a branch
277
+ # sandbox.git.delete_branch("workspace/repo", "old-feature")
278
+ def delete_branch(path, name)
279
+ toolbox_api.git_delete_branch(
280
+ sandbox_id,
281
+ DaytonaApiClient::GitDeleteBranchRequest.new(path:, name:)
282
+ )
283
+ rescue StandardError => e
284
+ raise Sdk::Error, "Failed to delete branch: #{e.message}"
285
+ end
286
+ end
287
+ end
@@ -0,0 +1,130 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Daytona
4
+ class LspServer
5
+ module Language
6
+ ALL = [
7
+ JAVASCRIPT = :javascript,
8
+ PYTHON = :python,
9
+ TYPESCRIPT = :typescript
10
+
11
+ ].freeze
12
+ end
13
+
14
+ # Represents a zero-based position in a text document,
15
+ # specified by line number and character offset.
16
+ Position = Data.define(:line, :character)
17
+
18
+ # @return [Symbol]
19
+ attr_reader :language_id
20
+
21
+ # @return [String]
22
+ attr_reader :path_to_project
23
+
24
+ # @return [DaytonaApiClient::ToolboxApi]
25
+ attr_reader :toolbox_api
26
+
27
+ # @return [String]
28
+ attr_reader :sandbox_id
29
+
30
+ # @param language_id [Symbol]
31
+ # @param path_to_project [String]
32
+ # @param toolbox_api [DaytonaApiClient::ToolboxApi]
33
+ # @param sandbox_id [String]
34
+ def initialize(language_id:, path_to_project:, toolbox_api:, sandbox_id:)
35
+ @language_id = language_id
36
+ @path_to_project = path_to_project
37
+ @toolbox_api = toolbox_api
38
+ @sandbox_id = sandbox_id
39
+ end
40
+
41
+ # Gets completion suggestions at a position in a file
42
+ #
43
+ # @param path [String]
44
+ # @param position [Daytona::LspServer::Position]
45
+ # @return [DaytonaApiClient::CompletionList]
46
+ def completions(path:, position:)
47
+ toolbox_api.lsp_completions(
48
+ sandbox_id,
49
+ DaytonaApiClient::LspCompletionParams.new(
50
+ language_id:,
51
+ path_to_project:,
52
+ uri: uri(path),
53
+ position: DaytonaApiClient::Position.new(line: position.line, character: position.character)
54
+ )
55
+ )
56
+ end
57
+
58
+ # Notify the language server that a file has been closed.
59
+ # This method should be called when a file is closed in the editor to allow
60
+ # the language server to clean up any resources associated with that file.
61
+ #
62
+ # @param path [String]
63
+ # @return [void]
64
+ def did_close(path)
65
+ toolbox_api.lsp_did_close(
66
+ sandbox_id,
67
+ DaytonaApiClient::LspDocumentRequest.new(language_id:, path_to_project:, uri: uri(path))
68
+ )
69
+ end
70
+
71
+ # Notifies the language server that a file has been opened.
72
+ # This method should be called when a file is opened in the editor to enable
73
+ # language features like diagnostics and completions for that file. The server
74
+ # will begin tracking the file's contents and providing language features.
75
+ #
76
+ # @param path [String]
77
+ # @return [void]
78
+ def did_open(path)
79
+ toolbox_api.lsp_did_open(
80
+ sandbox_id,
81
+ DaytonaApiClient::LspDocumentRequest.new(language_id:, path_to_project:, uri: uri(path))
82
+ )
83
+ end
84
+
85
+ # Gets symbol information (functions, classes, variables, etc.) from a document.
86
+ #
87
+ # @param path [String]
88
+ # @return [Array<DaytonaApiClient::LspSymbol]
89
+ def document_symbols(path) = toolbox_api.lsp_document_symbols(sandbox_id, language_id, path_to_project, uri(path))
90
+
91
+ # Searches for symbols matching the query string across all files
92
+ # in the Sandbox.
93
+ #
94
+ # @param query [String]
95
+ # @return [Array<DaytonaApiClient::LspSymbol]
96
+ def sandbox_symbols(query) = toolbox_api.lsp_workspace_symbols(sandbox_id, language_id, path_to_project, query)
97
+
98
+ # Starts the language server.
99
+ # This method must be called before using any other LSP functionality.
100
+ # It initializes the language server for the specified language and project.
101
+ #
102
+ # @return [void]
103
+ def start
104
+ toolbox_api.lsp_start(
105
+ sandbox_id,
106
+ DaytonaApiClient::LspServerRequest.new(language_id:, path_to_project:)
107
+ )
108
+ end
109
+
110
+ # Stops the language server.
111
+ # This method should be called when the LSP server is no longer needed to
112
+ # free up system resources.
113
+ #
114
+ # @return [void]
115
+ def stop
116
+ toolbox_api.lsp_stop(
117
+ sandbox_id,
118
+ DaytonaApiClient::LspServerRequest.new(language_id:, path_to_project:)
119
+ )
120
+ end
121
+
122
+ private
123
+
124
+ # Convert path to file uri.
125
+ #
126
+ # @param path [String]
127
+ # @return [String]
128
+ def uri(path) = "file://#{path}"
129
+ end
130
+ end
@@ -0,0 +1,169 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'digest'
4
+ require 'fileutils'
5
+ require 'pathname'
6
+ require 'tempfile'
7
+ require 'zlib'
8
+ require 'aws-sdk-s3'
9
+
10
+ module Daytona
11
+ class ObjectStorage
12
+ # @return [String] The name of the S3 bucket used for object storage
13
+ attr_reader :bucket_name
14
+
15
+ # @return [Aws::S3::Client] The S3 client
16
+ attr_reader :s3_client
17
+
18
+ # Initialize ObjectStorage with S3-compatible credentials
19
+ #
20
+ # @param endpoint_url [String] The endpoint URL for the object storage service
21
+ # @param aws_access_key_id [String] The access key ID for the object storage service
22
+ # @param aws_secret_access_key [String] The secret access key for the object storage service
23
+ # @param aws_session_token [String] The session token for the object storage service
24
+ # @param bucket_name [String] The name of the bucket to use (defaults to "daytona-volume-builds")
25
+ # @param region [String] AWS region (defaults to us-east-1)
26
+ def initialize(endpoint_url:, aws_access_key_id:, aws_secret_access_key:, aws_session_token:, # rubocop:disable Metrics/ParameterLists
27
+ bucket_name: DEFAULT_BUCKET_NAME, region: DEFAULT_REGION)
28
+ @bucket_name = bucket_name
29
+ @s3_client = Aws::S3::Client.new(
30
+ region:,
31
+ endpoint: endpoint_url,
32
+ access_key_id: aws_access_key_id,
33
+ secret_access_key: aws_secret_access_key,
34
+ session_token: aws_session_token
35
+ )
36
+ end
37
+
38
+ # Uploads a file to the object storage service
39
+ #
40
+ # @param path [String] The path to the file to upload
41
+ # @param organization_id [String] The organization ID to use
42
+ # @param archive_base_path [String, nil] The base path to use for the archive
43
+ # @return [String] The hash of the uploaded file
44
+ # @raise [Errno::ENOENT] If the path does not exist
45
+ def upload(path, organization_id, archive_base_path = nil)
46
+ raise Errno::ENOENT, "Path does not exist: #{path}" unless File.exist?(path)
47
+
48
+ path_hash = compute_hash_for_path_md5(path, archive_base_path)
49
+ s3_key = "#{organization_id}/#{path_hash}/context.tar"
50
+
51
+ return path_hash if file_exists_in_s3(s3_key)
52
+
53
+ upload_as_tar(s3_key, path, archive_base_path)
54
+
55
+ path_hash
56
+ end
57
+
58
+ # Compute the base path for an archive. Returns normalized path without the root
59
+ # (drive letter or leading slash).
60
+ #
61
+ # @param path_str [String] The path to compute the base path for
62
+ # @return [String] The base path for the given path
63
+ def self.compute_archive_base_path(path_str)
64
+ normalized_path = File.basename(path_str)
65
+
66
+ # Remove drive letter for Windows paths (e.g., C:)
67
+ path_without_drive = normalized_path.gsub(/^[A-Za-z]:/, '')
68
+
69
+ # Remove leading separators (both / and \)
70
+ path_without_drive.gsub(%r{^[/\\]+}, '')
71
+ end
72
+
73
+ private
74
+
75
+ # Computes the MD5 hash for a given path
76
+ #
77
+ # @param path_str [String] The path to compute the hash for
78
+ # @param archive_base_path [String, nil] The base path to use for the archive
79
+ # @return [String] The MD5 hash for the given path
80
+ def compute_hash_for_path_md5(path_str, archive_base_path = nil) # rubocop:disable Metrics/AbcSize, Metrics/CyclomaticComplexity, Metrics/MethodLength, Metrics/PerceivedComplexity
81
+ md5_hasher = Digest::MD5.new
82
+ abs_path_str = File.expand_path(path_str)
83
+
84
+ archive_base_path = self.class.compute_archive_base_path(path_str) if archive_base_path.nil?
85
+ md5_hasher.update(archive_base_path)
86
+
87
+ if File.file?(abs_path_str)
88
+ File.open(abs_path_str, 'rb') do |f|
89
+ while (chunk = f.read(8192))
90
+ md5_hasher.update(chunk)
91
+ end
92
+ end
93
+ else
94
+ Dir.glob(File.join(abs_path_str, '**', '*')).each do |file_path|
95
+ next unless File.file?(file_path)
96
+
97
+ rel_path = Pathname.new(file_path).relative_path_from(Pathname.new(abs_path_str)).to_s
98
+
99
+ md5_hasher.update(rel_path)
100
+
101
+ File.open(file_path, 'rb') do |f|
102
+ while (chunk = f.read(8192))
103
+ md5_hasher.update(chunk)
104
+ end
105
+ end
106
+ end
107
+
108
+ # Handle empty directories
109
+ Dir
110
+ .glob(File.join(abs_path_str, '**', '*'))
111
+ .select { |path| File.directory?(path) && Dir.empty?(path) }
112
+ .each do |empty_dir|
113
+ rel_dir = Pathname.new(empty_dir).relative_path_from(Pathname.new(abs_path_str)).to_s
114
+ md5_hasher.update(rel_dir)
115
+ end
116
+ end
117
+
118
+ md5_hasher.hexdigest
119
+ end
120
+
121
+ # Checks whether a specific object exists at the given path
122
+ #
123
+ # @param file_path [String] Full object path, e.g. "org/abcd123/context.tar"
124
+ # @return [Boolean] True if the object exists, False otherwise
125
+ def file_exists_in_s3(file_path)
126
+ s3_client.head_object(bucket: bucket_name, key: file_path)
127
+ true
128
+ rescue Aws::S3::Errors::NotFound
129
+ false
130
+ rescue StandardError => e
131
+ Sdk.logger.debug("Error checking file existence: #{e.message}")
132
+ false
133
+ end
134
+
135
+ # Uploads a file to the object storage service as a tar
136
+ #
137
+ # @param s3_key [String] The key to upload the file to
138
+ # @param source_path [String] The path to the file to upload
139
+ # @param archive_base_path [String, nil] The base path to use for the archive
140
+ def upload_as_tar(s3_key, source_path, archive_base_path = nil) # rubocop:disable Metrics/MethodLength
141
+ source_path = File.expand_path(source_path)
142
+
143
+ self.class.compute_archive_base_path(source_path) if archive_base_path.nil?
144
+
145
+ temp_file = Tempfile.new(['context', '.tar'])
146
+
147
+ begin
148
+ system('tar', '-cf', temp_file.path, '-C', File.dirname(source_path), File.basename(source_path))
149
+
150
+ File.open(temp_file.path, 'rb') do |file|
151
+ s3_client.put_object(
152
+ bucket: bucket_name,
153
+ key: s3_key,
154
+ body: file
155
+ )
156
+ end
157
+ ensure
158
+ temp_file.close
159
+ temp_file.unlink
160
+ end
161
+ end
162
+
163
+ DEFAULT_BUCKET_NAME = 'daytona-volume-builds'
164
+ private_constant :DEFAULT_BUCKET_NAME
165
+
166
+ DEFAULT_REGION = 'us-east-1'
167
+ private_constant :DEFAULT_REGION
168
+ end
169
+ end