fig 2.0.0.pre.alpha.5 → 2.0.0.pre.alpha.10

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.
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 62f48881b3ac7d401f94298e95315f0500b08a26635ecbc36a47de29e9ef172e
4
- data.tar.gz: b1833add69c8cf53d54cd0ce76ee6cc1608c713eb487963f0af679671916e22e
3
+ metadata.gz: ffb9d7bc5e0a1723f452acc24d3ed117f700814821c5f6b9be69258edeec4903
4
+ data.tar.gz: cff46d3b93314fc559e0e3ab549b472b9cdc1474363d758ef18c4020fca675b7
5
5
  SHA512:
6
- metadata.gz: 062e6bb0dc0a5ccaa087022de0398cb641393f2dd4938afeecf1199ab0a8d81ef3b7c6c784d116b9f9b43aac5c769df1e177b12ac85a06fa4c54dd08b7249a7a
7
- data.tar.gz: 77ec6f3a04820f24d373304f5289c5164ffd66984f3d6922a6de2d02fd3be288432e0b07a319e52dd36f633f877b109b859b79feef609a7aeef9e32ffe8bd98a
6
+ metadata.gz: 947d9c97f725fa69e074b0b580ba4698d4e824278c1ea2c71e036e5146030302de5319fe281171bc1727cd8a0194ace497edc731061fb9314317cb3aa52dacfe
7
+ data.tar.gz: a4c64c9fdf34e90543a790c3caf649cca749a74ef4b3b151902cfe931af17cc2a52d2f99dea12d090fcf8035c448fa5a8e5834b334683be4982882fe42da5e05
@@ -20,6 +20,7 @@ require 'fig/protocol/ftp'
20
20
  require 'fig/protocol/http'
21
21
  require 'fig/protocol/sftp'
22
22
  require 'fig/protocol/ssh'
23
+ require 'fig/protocol/artifactory'
23
24
  require 'fig/repository_error'
24
25
  require 'fig/url'
25
26
  require 'fig/user_input_error'
@@ -83,8 +84,10 @@ class Fig::OperatingSystem
83
84
  @protocols['file'] = Fig::Protocol::File.new
84
85
  @protocols['ftp'] = Fig::Protocol::FTP.new login
85
86
  @protocols['http'] = Fig::Protocol::HTTP.new
87
+ @protocols['https'] = Fig::Protocol::HTTP.new
86
88
  @protocols['sftp'] = Fig::Protocol::SFTP.new
87
89
  @protocols['ssh'] = Fig::Protocol::SSH.new
90
+ @protocols['art'] = @protocols['artifactory'] = Fig::Protocol::Artifactory.new
88
91
  end
89
92
 
90
93
  def list(dir)
@@ -0,0 +1,389 @@
1
+ # coding: utf-8
2
+
3
+ require 'net/http'
4
+ require 'uri'
5
+ require 'etc'
6
+ require 'thread'
7
+ require 'socket'
8
+ require 'digest'
9
+ require 'time'
10
+
11
+ require 'artifactory'
12
+
13
+ require 'fig/logging'
14
+ require 'fig/network_error'
15
+ require 'fig/package_descriptor'
16
+ require 'fig/protocol'
17
+ require 'fig/protocol/netrc_enabled'
18
+
19
+
20
+ module Fig; end
21
+ module Fig::Protocol; end
22
+
23
+
24
+ # file transfers/storage using https as the transport and artifactory as the backing store
25
+ class Fig::Protocol::Artifactory
26
+ include Fig::Protocol
27
+ include Fig::Protocol::NetRCEnabled
28
+
29
+ # Artifactory browser API endpoint (undocumented API)
30
+ BROWSER_API_PATH = 'ui/api/v1/ui/v2/nativeBrowser/'
31
+
32
+ # Default number of list entries to fetch on initial iteration
33
+ INITIAL_LIST_FETCH_SIZE = 20000
34
+
35
+ def initialize
36
+ initialize_netrc
37
+ end
38
+
39
+ # must return a list of strings in the form <package_name>/<version>
40
+ def download_list(uri)
41
+ Fig::Logging.info("Downloading list of packages at #{uri}")
42
+ package_versions = []
43
+
44
+ begin
45
+ # Parse URI to extract base endpoint and repository key
46
+ # Expected format: https://artifacts.example.com/artifactory/repo-name/
47
+ parse_uri(uri) => { repo_key:, base_endpoint: }
48
+
49
+ # Create Artifactory client instance
50
+ authentication = get_authentication_for(uri.host, :prompt_for_login)
51
+ client_config = { endpoint: base_endpoint }
52
+ if authentication
53
+ client_config[:username] = authentication.username
54
+ client_config[:password] = authentication.password
55
+ end
56
+ client = ::Artifactory::Client.new(client_config)
57
+
58
+ # Use Artifactory browser API to list directories at repo root
59
+ list_url = URI.join(base_endpoint, BROWSER_API_PATH, "#{repo_key}/")
60
+
61
+ packages = get_all_artifactory_entries(list_url, client)
62
+
63
+ # Filter to only valid package names upfront
64
+ valid_packages = packages.select do |package_item|
65
+ package_item['folder'] &&
66
+ package_item['name'] =~ Fig::PackageDescriptor::COMPONENT_PATTERN
67
+ end
68
+
69
+ Fig::Logging.debug("Found #{valid_packages.size} valid packages, fetching versions concurrently...")
70
+
71
+ # Use concurrent requests to fetch version lists (major performance improvement)
72
+ package_versions = fetch_versions_concurrently(valid_packages, base_endpoint, repo_key, client_config)
73
+
74
+ rescue => e
75
+ # Follow FTP pattern: log error but don't fail completely
76
+ Fig::Logging.debug("Could not retrieve package list from #{uri}: #{e.message}")
77
+ end
78
+
79
+ return package_versions.sort
80
+ end
81
+
82
+ def download(art_uri, path, prompt_for_login)
83
+ Fig::Logging.info("Downloading from artifactory: #{art_uri}")
84
+
85
+ uri = httpify_uri(art_uri)
86
+
87
+ # Log equivalent curl command for debugging
88
+ authentication = get_authentication_for(uri.host, prompt_for_login)
89
+ if authentication
90
+ Fig::Logging.debug("Equivalent curl: curl -u #{authentication.username}:*** -o '#{path}' '#{uri}'")
91
+ else
92
+ Fig::Logging.debug("Equivalent curl: curl -o '#{path}' '#{uri}'")
93
+ end
94
+
95
+ ::File.open(path, 'wb') do |file|
96
+ file.binmode
97
+
98
+ begin
99
+ download_via_http_get(uri.to_s, file)
100
+ rescue SystemCallError => error
101
+ Fig::Logging.debug error.message
102
+ raise Fig::FileNotFoundError.new error.message, uri
103
+ rescue SocketError => error
104
+ Fig::Logging.debug error.message
105
+ raise Fig::FileNotFoundError.new error.message, uri
106
+ end
107
+ end
108
+
109
+ return true
110
+ end
111
+
112
+ def upload(local_file, uri)
113
+ Fig::Logging.info("Uploading #{local_file} to artifactory at #{uri}")
114
+
115
+ begin
116
+ parse_uri(uri) => { repo_key:, base_endpoint:, target_path: }
117
+
118
+ # Configure Artifactory gem globally - unlike other methods that can use client instances,
119
+ # the artifact.upload() method ignores the client: parameter and only uses global config.
120
+ # This is a limitation of the artifactory gem's upload implementation.
121
+ authentication = get_authentication_for(uri.host, :prompt_for_login)
122
+ ::Artifactory.configure do |config|
123
+ config.endpoint = base_endpoint
124
+ if authentication
125
+ config.username = authentication.username
126
+ config.password = authentication.password
127
+ end
128
+ end
129
+
130
+ # Log equivalent curl command for debugging
131
+ if authentication
132
+ Fig::Logging.debug("Equivalent curl: curl -u #{authentication.username}:*** -T '#{local_file}' '#{uri}'")
133
+ else
134
+ Fig::Logging.debug("Equivalent curl: curl -T '#{local_file}' '#{uri}'")
135
+ end
136
+
137
+ # Create artifact and upload
138
+ artifact = ::Artifactory::Resource::Artifact.new(local_path: local_file)
139
+
140
+ # Collect metadata for upload
141
+ metadata = collect_upload_metadata(local_file, target_path, uri)
142
+
143
+ # Upload with metadata (no client parameter needed - uses global config)
144
+ artifact.upload(repo_key, target_path, metadata)
145
+
146
+ Fig::Logging.info("Successfully uploaded #{local_file} to #{uri}")
147
+
148
+ rescue ArgumentError => e
149
+ # Let ArgumentError bubble up for invalid URIs
150
+ raise e
151
+ rescue => e
152
+ Fig::Logging.debug("Upload failed: #{e.message}")
153
+ raise Fig::NetworkError.new("Failed to upload #{local_file} to #{uri}: #{e.message}")
154
+ end
155
+ end
156
+
157
+ # we can know this most of the time with the stat api
158
+ def path_up_to_date?(uri, path, prompt_for_login)
159
+ Fig::Logging.info("Checking if #{path} is up to date at #{uri}")
160
+
161
+ begin
162
+ parse_uri(uri) => { repo_key:, base_endpoint:, target_path: }
163
+
164
+
165
+ # Create Artifactory client instance (same as upload method)
166
+ authentication = get_authentication_for(uri.host, prompt_for_login)
167
+ client_config = { endpoint: base_endpoint }
168
+ if authentication
169
+ client_config[:username] = authentication.username
170
+ client_config[:password] = authentication.password
171
+ end
172
+ client = ::Artifactory::Client.new(client_config)
173
+
174
+ # use storage api instead of search - more reliable for virtual repos
175
+ storage_url = "/api/storage/#{repo_key}/#{target_path}"
176
+
177
+ response = client.get(storage_url)
178
+
179
+ # compare sizes first
180
+ if response['size'] != ::File.size(path)
181
+ return false
182
+ end
183
+
184
+ # compare modification times
185
+ remote_mtime = Time.parse(response['lastModified'])
186
+ local_mtime = ::File.mtime(path)
187
+
188
+ if remote_mtime <= local_mtime
189
+ return true
190
+ end
191
+
192
+ return false
193
+ rescue => error
194
+ Fig::Logging.debug "Error checking if #{path} is up to date: #{error.message}"
195
+ return nil
196
+ end
197
+ end
198
+
199
+ private
200
+
201
+ def httpify_uri(art_uri)
202
+ # if we got into the artifactory protocol, we get to assume that the URI
203
+ # is related to artifactory, which also means we can be assured that the
204
+ # only scheme that will work is 'https' rather than the faux scheme used
205
+ # to signal that the URL is artifactory protocol.
206
+
207
+ # this is kind of weird b/c `art_uri` will likely be URI::Generic and
208
+ # everything downstream of here is going to want the type for the URL to
209
+ # be URI::HTTPS. So, we hack the scheme for the input to `https` then
210
+ # parse that string to form the https-based URI with the correct type.
211
+ art_uri.scheme = 'https'
212
+ URI(art_uri.to_s)
213
+ end
214
+
215
+ def parse_uri(art_uri)
216
+ uri = httpify_uri(art_uri)
217
+
218
+ uri_path = uri.path.chomp('/')
219
+ path_parts = uri_path.split('/').reject(&:empty?)
220
+
221
+ # Find artifactory in the path and split accordingly
222
+ artifactory_index = path_parts.index('artifactory')
223
+ raise ArgumentError, "URI must contain 'artifactory' in path: #{uri}" unless artifactory_index
224
+
225
+ repo_key = path_parts[artifactory_index + 1]
226
+ raise ArgumentError, "No repository key found in URI: #{uri}" unless repo_key
227
+
228
+ # Everything after repo_key is the target path within the repository
229
+ target_path = path_parts[(artifactory_index + 2)..-1]&.join('/') || ''
230
+
231
+ # Base endpoint includes everything up to and including /artifactory
232
+ artifactory_path = path_parts[0..artifactory_index].join('/')
233
+ the_port = uri.port != uri.default_port ? ":#{uri.port}" : ""
234
+ base_endpoint = "#{uri.scheme}://#{uri.host}#{the_port}/#{artifactory_path}"
235
+
236
+ { repo_key:, base_endpoint:, target_path: }
237
+ end
238
+
239
+ # Collect metadata for file upload, using fig. prefix instead of upload. prefix
240
+ def collect_upload_metadata(local_file, target_path, uri)
241
+ file_stat = ::File.stat(local_file)
242
+
243
+ metadata = {
244
+ # Basic file information
245
+ 'fig.original_path' => local_file,
246
+ 'fig.target_path' => target_path,
247
+ 'fig.file_size' => file_stat.size.to_s,
248
+ 'fig.file_mtime' => file_stat.mtime.iso8601,
249
+
250
+ # Upload context
251
+ 'fig.hostname' => Socket.gethostname,
252
+ 'fig.login' => ENV['USER'] || ENV['USERNAME'] || 'unknown',
253
+ 'fig.timestamp' => Time.now.iso8601,
254
+ 'fig.epoch' => Time.now.to_i.to_s,
255
+
256
+ # Tool information
257
+ 'fig.tool' => 'fig-artifactory-protocol',
258
+ 'fig.uri' => uri.to_s,
259
+
260
+ # Checksums for integrity
261
+ 'fig.sha1' => Digest::SHA1.file(local_file).hexdigest,
262
+ 'fig.md5' => Digest::MD5.file(local_file).hexdigest,
263
+
264
+ # TODO: Add package/version metadata from URI path or local_file decoration
265
+ # TODO: Support user-injected metadata via environment variables or callbacks
266
+ }
267
+
268
+ Fig::Logging.debug("Upload metadata: #{metadata.keys.join(', ')}")
269
+ metadata
270
+ end
271
+
272
+ # Fetch version lists for packages concurrently for major performance improvement
273
+ # Reduces ~2700 sequential API calls to concurrent batches
274
+ def fetch_versions_concurrently(valid_packages, base_endpoint, repo_key, client_config)
275
+ # Scale thread count based on CPU cores, with reasonable bounds
276
+ # Testing showed ~25 threads optimal for most servers, so use 3x CPU cores as default
277
+ default_threads = [Etc.nprocessors * 3, 50].min # cap at 50 to avoid overwhelming servers
278
+ max_threads = ENV['FIG_ARTIFACTORY_THREADS']&.to_i || default_threads
279
+
280
+ Fig::Logging.debug("Using #{max_threads} threads for concurrent version fetching (#{Etc.nprocessors} CPU cores detected)")
281
+
282
+ package_versions = []
283
+ package_versions_mutex = Mutex.new
284
+ work_queue = Queue.new
285
+
286
+ # Add all packages to work queue
287
+ valid_packages.each { |pkg| work_queue << pkg }
288
+
289
+ # Create worker threads
290
+ threads = []
291
+ max_threads.times do
292
+ threads << Thread.new do
293
+ # Each thread gets its own client to avoid connection issues
294
+ thread_client = ::Artifactory::Client.new(client_config)
295
+
296
+ while !work_queue.empty?
297
+ begin
298
+ package_item = work_queue.pop(true) # non-blocking pop
299
+ rescue ThreadError
300
+ break # queue is empty
301
+ end
302
+
303
+ package_name = package_item['name']
304
+
305
+ begin
306
+ package_list_url = URI.join(base_endpoint, BROWSER_API_PATH, "#{repo_key}/", "#{package_name}/")
307
+ versions = get_all_artifactory_entries(package_list_url, thread_client)
308
+
309
+ local_package_versions = []
310
+ versions.each do |version_item|
311
+ next unless version_item['folder']
312
+
313
+ version_name = version_item['name']
314
+ next unless version_name =~ Fig::PackageDescriptor::COMPONENT_PATTERN
315
+
316
+ local_package_versions << "#{package_name}/#{version_name}"
317
+ end
318
+
319
+ # Thread-safe addition to results
320
+ package_versions_mutex.synchronize do
321
+ package_versions.concat(local_package_versions)
322
+ end
323
+
324
+ rescue => e
325
+ # Follow FTP pattern: ignore permission errors and continue processing
326
+ Fig::Logging.debug("Could not list versions for package #{package_name}: #{e.message}")
327
+ end
328
+ end
329
+ end
330
+ end
331
+
332
+ # Wait for all threads to complete
333
+ threads.each(&:join)
334
+
335
+ package_versions
336
+ end
337
+
338
+ # Get all entries from Artifactory browser API with pagination support
339
+ # Returns array of all entries, handling continueState pagination
340
+ def get_all_artifactory_entries(base_url, client)
341
+ record_num = ENV['FIG_ARTIFACTORY_PAGESIZE']&.to_i || INITIAL_LIST_FETCH_SIZE
342
+
343
+ Fig::Logging.debug(">> getting art initial #{record_num} entries from #{base_url}...")
344
+
345
+ loop do
346
+ # Build URL with recordNum parameter
347
+ url = URI(base_url.to_s)
348
+ url.query = "recordNum=#{record_num}"
349
+
350
+ response = client.get(url)
351
+ entries = response['data'] || []
352
+
353
+ # Check if there are more entries to fetch
354
+ continue_state = response['continueState']
355
+ Fig::Logging.debug(">> continue_state is #{continue_state}...")
356
+ return entries if continue_state.nil? || continue_state.to_i < 0
357
+
358
+ # Use continueState as the recordNum for the next request
359
+ record_num = continue_state
360
+ end
361
+ end
362
+
363
+ # swiped directly from http.rb; if no changes are required, then consider refactoring
364
+ def download_via_http_get(uri_string, file, redirection_limit = 10)
365
+ if redirection_limit < 1
366
+ Fig::Logging.debug 'Too many HTTP redirects.'
367
+ raise Fig::FileNotFoundError.new 'Too many HTTP redirects.', uri_string
368
+ end
369
+
370
+ response = Net::HTTP.get_response(URI(uri_string))
371
+
372
+ case response
373
+ when Net::HTTPSuccess then
374
+ file.write(response.body)
375
+ when Net::HTTPRedirection then
376
+ location = response['location']
377
+ Fig::Logging.debug "Redirecting to #{location}."
378
+ download_via_http_get(location, file, redirection_limit - 1)
379
+ else
380
+ Fig::Logging.debug "Download failed: #{response.code} #{response.message}."
381
+ raise Fig::FileNotFoundError.new(
382
+ "Download failed: #{response.code} #{response.message}.", uri_string
383
+ )
384
+ end
385
+
386
+ return
387
+ end
388
+
389
+ end
data/lib/fig/version.rb CHANGED
@@ -1,5 +1,5 @@
1
1
  # coding: utf-8
2
2
 
3
3
  module Fig
4
- VERSION = '2.0.0-alpha.5'.freeze
4
+ VERSION = '2.0.0-alpha.10'.freeze
5
5
  end
@@ -0,0 +1,599 @@
1
+ # coding: utf-8
2
+
3
+ require 'spec_helper'
4
+ require 'fig/protocol/artifactory'
5
+
6
+ describe Fig::Protocol::Artifactory do
7
+ let(:artifactory) { Fig::Protocol::Artifactory.new }
8
+ let(:base_url) { URI('https://artifacts.example.com/artifactory/ui/api/v1/ui/v2/nativeBrowser/repo-name/') }
9
+
10
+ describe '#get_all_artifactory_entries' do
11
+ let(:mock_client) { double('Artifactory::Client') }
12
+ let(:base_url) { URI('https://artifacts.example.com/artifactory/ui/api/v1/ui/v2/nativeBrowser/repo-name/') }
13
+
14
+ before do
15
+ # Stub netrc authentication
16
+ allow(artifactory).to receive(:get_authentication_for).and_return(nil)
17
+ end
18
+
19
+ context 'when all entries fit in single page' do
20
+ it 'returns entries without pagination' do
21
+ response = {
22
+ 'data' => [
23
+ { 'name' => 'package1', 'folder' => true },
24
+ { 'name' => 'package2', 'folder' => true }
25
+ ],
26
+ 'continueState' => -1
27
+ }
28
+
29
+ expect(mock_client).to receive(:get)
30
+ .with(URI("https://artifacts.example.com/artifactory/ui/api/v1/ui/v2/nativeBrowser/repo-name/?recordNum=#{Fig::Protocol::Artifactory::INITIAL_LIST_FETCH_SIZE}"))
31
+ .and_return(response)
32
+
33
+ result = artifactory.send(:get_all_artifactory_entries, base_url, mock_client)
34
+ expect(result).to eq(response['data'])
35
+ end
36
+ end
37
+
38
+ context 'when entries require pagination' do
39
+ it 'follows pagination until continueState is -1' do
40
+ first_response = {
41
+ 'data' => [
42
+ { 'name' => 'package1', 'folder' => true },
43
+ { 'name' => 'package2', 'folder' => true }
44
+ ],
45
+ 'continueState' => 'cursor123'
46
+ }
47
+
48
+ final_response = {
49
+ 'data' => [
50
+ { 'name' => 'package1', 'folder' => true },
51
+ { 'name' => 'package2', 'folder' => true },
52
+ { 'name' => 'package3', 'folder' => true }
53
+ ],
54
+ 'continueState' => -1
55
+ }
56
+
57
+ expect(mock_client).to receive(:get)
58
+ .with(URI("https://artifacts.example.com/artifactory/ui/api/v1/ui/v2/nativeBrowser/repo-name/?recordNum=#{Fig::Protocol::Artifactory::INITIAL_LIST_FETCH_SIZE}"))
59
+ .and_return(first_response)
60
+
61
+ expect(mock_client).to receive(:get)
62
+ .with(URI('https://artifacts.example.com/artifactory/ui/api/v1/ui/v2/nativeBrowser/repo-name/?recordNum=cursor123'))
63
+ .and_return(final_response)
64
+
65
+ result = artifactory.send(:get_all_artifactory_entries, base_url, mock_client)
66
+ expect(result).to eq(final_response['data'])
67
+ end
68
+ end
69
+
70
+ context 'when continueState is nil' do
71
+ it 'returns entries and stops pagination' do
72
+ response = {
73
+ 'data' => [{ 'name' => 'package1', 'folder' => true }],
74
+ 'continueState' => nil
75
+ }
76
+
77
+ expect(mock_client).to receive(:get)
78
+ .with(URI("https://artifacts.example.com/artifactory/ui/api/v1/ui/v2/nativeBrowser/repo-name/?recordNum=#{Fig::Protocol::Artifactory::INITIAL_LIST_FETCH_SIZE}"))
79
+ .and_return(response)
80
+
81
+ result = artifactory.send(:get_all_artifactory_entries, base_url, mock_client)
82
+ expect(result).to eq(response['data'])
83
+ end
84
+ end
85
+
86
+ context 'when FIG_ARTIFACTORY_PAGESIZE is set' do
87
+ it 'uses custom page size' do
88
+ allow(ENV).to receive(:[]).with('FIG_ARTIFACTORY_PAGESIZE').and_return('5000')
89
+
90
+ response = {
91
+ 'data' => [{ 'name' => 'package1', 'folder' => true }],
92
+ 'continueState' => -1
93
+ }
94
+
95
+ expect(mock_client).to receive(:get)
96
+ .with(URI('https://artifacts.example.com/artifactory/ui/api/v1/ui/v2/nativeBrowser/repo-name/?recordNum=5000'))
97
+ .and_return(response)
98
+
99
+ result = artifactory.send(:get_all_artifactory_entries, base_url, mock_client)
100
+ expect(result).to eq(response['data'])
101
+ end
102
+ end
103
+
104
+ context 'when response has no data field' do
105
+ it 'returns empty array' do
106
+ response = { 'continueState' => -1 }
107
+
108
+ expect(mock_client).to receive(:get)
109
+ .with(URI("https://artifacts.example.com/artifactory/ui/api/v1/ui/v2/nativeBrowser/repo-name/?recordNum=#{Fig::Protocol::Artifactory::INITIAL_LIST_FETCH_SIZE}"))
110
+ .and_return(response)
111
+
112
+ result = artifactory.send(:get_all_artifactory_entries, base_url, mock_client)
113
+ expect(result).to eq([])
114
+ end
115
+ end
116
+ end
117
+
118
+ describe '#download_list' do
119
+ let(:uri) { URI('art://artifacts.example.com/artifactory/repo-name/') }
120
+ let(:artifactory) { Fig::Protocol::Artifactory.new }
121
+ let(:mock_client) { double('Artifactory::Client') }
122
+
123
+ before do
124
+ allow(::Artifactory::Client).to receive(:new).and_return(mock_client)
125
+ # Stub netrc authentication
126
+ allow(artifactory).to receive(:get_authentication_for).and_return(nil)
127
+ end
128
+
129
+ context 'when repository has packages and versions' do
130
+ it 'returns sorted package/version strings' do
131
+ # Mock package listing
132
+ packages_response = {
133
+ 'data' => [
134
+ { 'name' => 'package-a', 'folder' => true },
135
+ { 'name' => 'package-b', 'folder' => true },
136
+ { 'name' => 'readme.txt', 'folder' => false } # Should be ignored
137
+ ],
138
+ 'continueState' => -1
139
+ }
140
+
141
+ # Mock version listings for each package
142
+ package_a_versions = {
143
+ 'data' => [
144
+ { 'name' => '1.0.0', 'folder' => true },
145
+ { 'name' => '2.0.0', 'folder' => true },
146
+ { 'name' => 'metadata.xml', 'folder' => false } # Should be ignored
147
+ ],
148
+ 'continueState' => -1
149
+ }
150
+
151
+ package_b_versions = {
152
+ 'data' => [
153
+ { 'name' => '0.5.0', 'folder' => true }
154
+ ],
155
+ 'continueState' => -1
156
+ }
157
+
158
+ # Expect calls in order
159
+ expect(mock_client).to receive(:get)
160
+ .with(URI("https://artifacts.example.com/ui/api/v1/ui/v2/nativeBrowser/repo-name/?recordNum=#{Fig::Protocol::Artifactory::INITIAL_LIST_FETCH_SIZE}"))
161
+ .and_return(packages_response)
162
+
163
+ expect(mock_client).to receive(:get)
164
+ .with(URI("https://artifacts.example.com/ui/api/v1/ui/v2/nativeBrowser/repo-name/package-a/?recordNum=#{Fig::Protocol::Artifactory::INITIAL_LIST_FETCH_SIZE}"))
165
+ .and_return(package_a_versions)
166
+
167
+ expect(mock_client).to receive(:get)
168
+ .with(URI("https://artifacts.example.com/ui/api/v1/ui/v2/nativeBrowser/repo-name/package-b/?recordNum=#{Fig::Protocol::Artifactory::INITIAL_LIST_FETCH_SIZE}"))
169
+ .and_return(package_b_versions)
170
+
171
+ result = artifactory.download_list(uri)
172
+ expect(result).to eq([
173
+ 'package-a/1.0.0',
174
+ 'package-a/2.0.0',
175
+ 'package-b/0.5.0'
176
+ ])
177
+ end
178
+ end
179
+
180
+ context 'when repository is empty' do
181
+ it 'returns empty array' do
182
+ empty_response = {
183
+ 'data' => [],
184
+ 'continueState' => -1
185
+ }
186
+
187
+ expect(mock_client).to receive(:get)
188
+ .with(URI("https://artifacts.example.com/ui/api/v1/ui/v2/nativeBrowser/repo-name/?recordNum=#{Fig::Protocol::Artifactory::INITIAL_LIST_FETCH_SIZE}"))
189
+ .and_return(empty_response)
190
+
191
+ result = artifactory.download_list(uri)
192
+ expect(result).to eq([])
193
+ end
194
+ end
195
+
196
+ context 'when API calls fail' do
197
+ it 'handles errors gracefully and continues processing' do
198
+ packages_response = {
199
+ 'data' => [
200
+ { 'name' => 'good-package', 'folder' => true },
201
+ { 'name' => 'bad-package', 'folder' => true }
202
+ ],
203
+ 'continueState' => -1
204
+ }
205
+
206
+ good_versions = {
207
+ 'data' => [{ 'name' => '1.0.0', 'folder' => true }],
208
+ 'continueState' => -1
209
+ }
210
+
211
+ expect(mock_client).to receive(:get)
212
+ .with(URI("https://artifacts.example.com/ui/api/v1/ui/v2/nativeBrowser/repo-name/?recordNum=#{Fig::Protocol::Artifactory::INITIAL_LIST_FETCH_SIZE}"))
213
+ .and_return(packages_response)
214
+
215
+ expect(mock_client).to receive(:get)
216
+ .with(URI("https://artifacts.example.com/ui/api/v1/ui/v2/nativeBrowser/repo-name/good-package/?recordNum=#{Fig::Protocol::Artifactory::INITIAL_LIST_FETCH_SIZE}"))
217
+ .and_return(good_versions)
218
+
219
+ expect(mock_client).to receive(:get)
220
+ .with(URI("https://artifacts.example.com/ui/api/v1/ui/v2/nativeBrowser/repo-name/bad-package/?recordNum=#{Fig::Protocol::Artifactory::INITIAL_LIST_FETCH_SIZE}"))
221
+ .and_raise(StandardError.new('API error'))
222
+
223
+ # Should continue processing despite error
224
+ result = artifactory.download_list(uri)
225
+ expect(result).to eq(['good-package/1.0.0'])
226
+ end
227
+ end
228
+
229
+ context 'with invalid package/version names' do
230
+ it 'filters out names that do not match COMPONENT_PATTERN' do
231
+ packages_response = {
232
+ 'data' => [
233
+ { 'name' => 'valid-package', 'folder' => true },
234
+ { 'name' => 'invalid package with spaces', 'folder' => true },
235
+ { 'name' => 'invalid@symbols', 'folder' => true }
236
+ ],
237
+ 'continueState' => -1
238
+ }
239
+
240
+ valid_versions = {
241
+ 'data' => [
242
+ { 'name' => '1.0.0', 'folder' => true },
243
+ { 'name' => 'invalid version', 'folder' => true }
244
+ ],
245
+ 'continueState' => -1
246
+ }
247
+
248
+ expect(mock_client).to receive(:get)
249
+ .with(URI("https://artifacts.example.com/ui/api/v1/ui/v2/nativeBrowser/repo-name/?recordNum=#{Fig::Protocol::Artifactory::INITIAL_LIST_FETCH_SIZE}"))
250
+ .and_return(packages_response)
251
+
252
+ expect(mock_client).to receive(:get)
253
+ .with(URI("https://artifacts.example.com/ui/api/v1/ui/v2/nativeBrowser/repo-name/valid-package/?recordNum=#{Fig::Protocol::Artifactory::INITIAL_LIST_FETCH_SIZE}"))
254
+ .and_return(valid_versions)
255
+
256
+ result = artifactory.download_list(uri)
257
+ expect(result).to eq(['valid-package/1.0.0'])
258
+ end
259
+ end
260
+ end
261
+
262
+ describe '#download' do
263
+ let(:uri) { URI('artifactory://artifacts.example.com/artifactory/repo-name/package/version/file.tar.gz') }
264
+ let(:https_uri) { URI(uri.to_s.sub(/\Aartifactory:/, 'https:')) }
265
+ let(:path) { '/tmp/test_file.tar.gz' }
266
+ let(:prompt_for_login) { false }
267
+ let(:mock_file) { double('File') }
268
+ let(:mock_auth) { double('Authentication', username: 'testuser', password: 'testpass') }
269
+
270
+ before do
271
+ allow(::File).to receive(:open).with(path, 'wb').and_yield(mock_file)
272
+ allow(mock_file).to receive(:binmode)
273
+ end
274
+
275
+ context 'with authentication' do
276
+ it 'downloads file and logs curl equivalent with auth' do
277
+ allow(artifactory).to receive(:get_authentication_for).with(uri.host, prompt_for_login).and_return(mock_auth)
278
+ allow(artifactory).to receive(:download_via_http_get).with(https_uri.to_s, mock_file)
279
+
280
+ expect(Fig::Logging).to receive(:debug).with("Equivalent curl: curl -u testuser:*** -o '#{path}' '#{https_uri}'")
281
+
282
+ result = artifactory.download(uri, path, prompt_for_login)
283
+ expect(result).to be true
284
+ end
285
+ end
286
+
287
+ context 'without authentication' do
288
+ it 'downloads file and logs curl equivalent without auth' do
289
+ allow(artifactory).to receive(:get_authentication_for).with(uri.host, prompt_for_login).and_return(nil)
290
+ allow(artifactory).to receive(:download_via_http_get).with(https_uri.to_s, mock_file)
291
+
292
+ expect(Fig::Logging).to receive(:debug).with("Equivalent curl: curl -o '#{path}' '#{https_uri}'")
293
+
294
+ result = artifactory.download(uri, path, prompt_for_login)
295
+ expect(result).to be true
296
+ end
297
+ end
298
+
299
+ context 'when download_via_http_get raises SystemCallError' do
300
+ it 'wraps error in FileNotFoundError' do
301
+ allow(artifactory).to receive(:get_authentication_for).and_return(nil)
302
+ system_error = SystemCallError.new('Connection failed')
303
+ allow(artifactory).to receive(:download_via_http_get).and_raise(system_error)
304
+
305
+ expect(Fig::Logging).to receive(:debug).with("Equivalent curl: curl -o '#{path}' '#{https_uri}'")
306
+ expect(Fig::Logging).to receive(:debug).with('unknown error - Connection failed')
307
+ expect { artifactory.download(uri, path, prompt_for_login) }.to raise_error(Fig::FileNotFoundError, 'unknown error - Connection failed')
308
+ end
309
+ end
310
+
311
+ context 'when download_via_http_get raises SocketError' do
312
+ it 'wraps error in FileNotFoundError' do
313
+ allow(artifactory).to receive(:get_authentication_for).and_return(nil)
314
+ socket_error = SocketError.new('Host not found')
315
+ allow(artifactory).to receive(:download_via_http_get).and_raise(socket_error)
316
+
317
+ expect(Fig::Logging).to receive(:debug).with("Equivalent curl: curl -o '#{path}' '#{https_uri}'")
318
+ expect(Fig::Logging).to receive(:debug).with('Host not found')
319
+ expect { artifactory.download(uri, path, prompt_for_login) }.to raise_error(Fig::FileNotFoundError, 'Host not found')
320
+ end
321
+ end
322
+
323
+ it 'opens file in binary write mode' do
324
+ allow(artifactory).to receive(:get_authentication_for).and_return(nil)
325
+ allow(artifactory).to receive(:download_via_http_get)
326
+
327
+ expect(::File).to receive(:open).with(path, 'wb')
328
+ expect(mock_file).to receive(:binmode)
329
+
330
+ artifactory.download(uri, path, prompt_for_login)
331
+ end
332
+ end
333
+
334
+ describe '#httpify_uri' do
335
+ let(:artifactory) { Fig::Protocol::Artifactory.new }
336
+
337
+ it 'converts art:// scheme to https://' do
338
+ art_uri = URI('art://artifacts.example.com/artifactory/repo-name/')
339
+ result = artifactory.send(:httpify_uri, art_uri)
340
+
341
+ expect(result.scheme).to eq('https')
342
+ expect(result.host).to eq('artifacts.example.com')
343
+ expect(result.path).to eq('/artifactory/repo-name/')
344
+ expect(result).to be_a(URI::HTTPS)
345
+ end
346
+
347
+ it 'converts artifactory:// scheme to https://' do
348
+ art_uri = URI('artifactory://artifacts.example.com/artifactory/repo-name/package/version/file.tar.gz')
349
+ result = artifactory.send(:httpify_uri, art_uri)
350
+
351
+ expect(result.scheme).to eq('https')
352
+ expect(result.host).to eq('artifacts.example.com')
353
+ expect(result.path).to eq('/artifactory/repo-name/package/version/file.tar.gz')
354
+ expect(result).to be_a(URI::HTTPS)
355
+ end
356
+
357
+ it 'preserves port and query parameters' do
358
+ art_uri = URI('art://artifacts.example.com:8080/artifactory/repo-name/?param=value')
359
+ result = artifactory.send(:httpify_uri, art_uri)
360
+
361
+ expect(result.scheme).to eq('https')
362
+ expect(result.host).to eq('artifacts.example.com')
363
+ expect(result.port).to eq(8080)
364
+ expect(result.path).to eq('/artifactory/repo-name/')
365
+ expect(result.query).to eq('param=value')
366
+ expect(result).to be_a(URI::HTTPS)
367
+ end
368
+
369
+ it 'handles URIs with userinfo' do
370
+ art_uri = URI('artifactory://user:pass@artifacts.example.com/artifactory/repo-name/')
371
+ result = artifactory.send(:httpify_uri, art_uri)
372
+
373
+ expect(result.scheme).to eq('https')
374
+ expect(result.userinfo).to eq('user:pass')
375
+ expect(result.host).to eq('artifacts.example.com')
376
+ expect(result.path).to eq('/artifactory/repo-name/')
377
+ expect(result).to be_a(URI::HTTPS)
378
+ end
379
+ end
380
+
381
+ describe '#parse_uri' do
382
+ let(:artifactory) { Fig::Protocol::Artifactory.new }
383
+
384
+ context 'with basic artifactory URI' do
385
+ it 'parses repository-level URI correctly' do
386
+ art_uri = URI('art://artifacts.example.com/artifactory/repo-name/')
387
+ result = artifactory.send(:parse_uri, art_uri)
388
+
389
+ expect(result[:repo_key]).to eq('repo-name')
390
+ expect(result[:base_endpoint]).to eq('https://artifacts.example.com/artifactory')
391
+ expect(result[:target_path]).to eq('')
392
+ end
393
+
394
+ it 'parses URI with file path correctly' do
395
+ art_uri = URI('artifactory://artifacts.example.com/artifactory/repo-name/package/version/file.tar.gz')
396
+ result = artifactory.send(:parse_uri, art_uri)
397
+
398
+ expect(result[:repo_key]).to eq('repo-name')
399
+ expect(result[:base_endpoint]).to eq('https://artifacts.example.com/artifactory')
400
+ expect(result[:target_path]).to eq('package/version/file.tar.gz')
401
+ end
402
+
403
+ it 'parses URI with nested directory path correctly' do
404
+ art_uri = URI('art://artifacts.example.com/artifactory/my-repo/com/example/package/1.0.0/package-1.0.0.jar')
405
+ result = artifactory.send(:parse_uri, art_uri)
406
+
407
+ expect(result[:repo_key]).to eq('my-repo')
408
+ expect(result[:base_endpoint]).to eq('https://artifacts.example.com/artifactory')
409
+ expect(result[:target_path]).to eq('com/example/package/1.0.0/package-1.0.0.jar')
410
+ end
411
+ end
412
+
413
+ context 'with custom port' do
414
+ it 'includes port in base_endpoint when non-standard' do
415
+ art_uri = URI('artifactory://artifacts.example.com:8080/artifactory/repo-name/file.txt')
416
+ result = artifactory.send(:parse_uri, art_uri)
417
+
418
+ expect(result[:repo_key]).to eq('repo-name')
419
+ expect(result[:base_endpoint]).to eq('https://artifacts.example.com:8080/artifactory')
420
+ expect(result[:target_path]).to eq('file.txt')
421
+ end
422
+
423
+ it 'excludes default HTTPS port from base_endpoint' do
424
+ art_uri = URI('art://artifacts.example.com:443/artifactory/repo-name/file.txt')
425
+ result = artifactory.send(:parse_uri, art_uri)
426
+
427
+ expect(result[:repo_key]).to eq('repo-name')
428
+ expect(result[:base_endpoint]).to eq('https://artifacts.example.com/artifactory')
429
+ expect(result[:target_path]).to eq('file.txt')
430
+ end
431
+ end
432
+
433
+ context 'with artifactory in different path positions' do
434
+ it 'handles artifactory in root path' do
435
+ art_uri = URI('art://artifacts.example.com/artifactory/repo-name/file.txt')
436
+ result = artifactory.send(:parse_uri, art_uri)
437
+
438
+ expect(result[:repo_key]).to eq('repo-name')
439
+ expect(result[:base_endpoint]).to eq('https://artifacts.example.com/artifactory')
440
+ expect(result[:target_path]).to eq('file.txt')
441
+ end
442
+
443
+ it 'handles artifactory in nested path' do
444
+ art_uri = URI('artifactory://artifacts.example.com/some/path/artifactory/repo-name/file.txt')
445
+ result = artifactory.send(:parse_uri, art_uri)
446
+
447
+ expect(result[:repo_key]).to eq('repo-name')
448
+ expect(result[:base_endpoint]).to eq('https://artifacts.example.com/some/path/artifactory')
449
+ expect(result[:target_path]).to eq('file.txt')
450
+ end
451
+ end
452
+
453
+ context 'with trailing slashes' do
454
+ it 'handles URI with trailing slash' do
455
+ art_uri = URI('art://artifacts.example.com/artifactory/repo-name/')
456
+ result = artifactory.send(:parse_uri, art_uri)
457
+
458
+ expect(result[:repo_key]).to eq('repo-name')
459
+ expect(result[:base_endpoint]).to eq('https://artifacts.example.com/artifactory')
460
+ expect(result[:target_path]).to eq('')
461
+ end
462
+
463
+ it 'handles URI without trailing slash' do
464
+ art_uri = URI('artifactory://artifacts.example.com/artifactory/repo-name')
465
+ result = artifactory.send(:parse_uri, art_uri)
466
+
467
+ expect(result[:repo_key]).to eq('repo-name')
468
+ expect(result[:base_endpoint]).to eq('https://artifacts.example.com/artifactory')
469
+ expect(result[:target_path]).to eq('')
470
+ end
471
+ end
472
+
473
+ context 'error cases' do
474
+ it 'raises ArgumentError when artifactory is not in path' do
475
+ art_uri = URI('art://artifacts.example.com/some/other/path/repo-name/')
476
+
477
+ expect {
478
+ artifactory.send(:parse_uri, art_uri)
479
+ }.to raise_error(ArgumentError, /URI must contain 'artifactory' in path/)
480
+ end
481
+
482
+ it 'raises ArgumentError when no repository key is found' do
483
+ art_uri = URI('artifactory://artifacts.example.com/artifactory/')
484
+
485
+ expect {
486
+ artifactory.send(:parse_uri, art_uri)
487
+ }.to raise_error(ArgumentError, /No repository key found in URI/)
488
+ end
489
+
490
+ it 'raises ArgumentError when artifactory is at end of path' do
491
+ art_uri = URI('art://artifacts.example.com/some/path/artifactory')
492
+
493
+ expect {
494
+ artifactory.send(:parse_uri, art_uri)
495
+ }.to raise_error(ArgumentError, /No repository key found in URI/)
496
+ end
497
+ end
498
+
499
+ context 'edge cases' do
500
+ it 'handles empty target path correctly' do
501
+ art_uri = URI('artifactory://artifacts.example.com/artifactory/repo-name')
502
+ result = artifactory.send(:parse_uri, art_uri)
503
+
504
+ expect(result[:target_path]).to eq('')
505
+ end
506
+
507
+ it 'handles single character repo key' do
508
+ art_uri = URI('art://artifacts.example.com/artifactory/r/file.txt')
509
+ result = artifactory.send(:parse_uri, art_uri)
510
+
511
+ expect(result[:repo_key]).to eq('r')
512
+ expect(result[:target_path]).to eq('file.txt')
513
+ end
514
+
515
+ it 'handles repo key with special characters' do
516
+ art_uri = URI('artifactory://artifacts.example.com/artifactory/repo-name-with-dashes_and_underscores/file.txt')
517
+ result = artifactory.send(:parse_uri, art_uri)
518
+
519
+ expect(result[:repo_key]).to eq('repo-name-with-dashes_and_underscores')
520
+ expect(result[:target_path]).to eq('file.txt')
521
+ end
522
+ end
523
+ end
524
+
525
+ describe '#upload' do
526
+ let(:local_file) { '/tmp/test.txt' }
527
+ let(:uri) { URI('https://artifacts.example.com/artifactory/repo-name/path/to/file.txt') }
528
+ let(:mock_client) { double('Artifactory::Client') }
529
+ let(:mock_artifact) { double('Artifactory::Resource::Artifact') }
530
+ let(:mock_authentication) { double('authentication', username: 'testuser', password: 'testpass') }
531
+
532
+ before do
533
+ allow(artifactory).to receive(:get_authentication_for).and_return(mock_authentication)
534
+ # Mock the global configuration - create a config double that responds to all setters
535
+ config_mock = double('config')
536
+ allow(config_mock).to receive(:endpoint=)
537
+ allow(config_mock).to receive(:username=)
538
+ allow(config_mock).to receive(:password=)
539
+ allow(::Artifactory).to receive(:configure).and_yield(config_mock)
540
+ allow(::Artifactory::Resource::Artifact).to receive(:new).and_return(mock_artifact)
541
+ allow(::File).to receive(:stat).and_return(double('stat', size: 1024, mtime: Time.now))
542
+ allow(Digest::SHA1).to receive(:file).and_return(double(hexdigest: 'sha1hash'))
543
+ allow(Digest::MD5).to receive(:file).and_return(double(hexdigest: 'md5hash'))
544
+ end
545
+
546
+ it 'parses URI correctly and uploads file' do
547
+ config_mock = double('config')
548
+ expect(::Artifactory).to receive(:configure).and_yield(config_mock)
549
+ expect(config_mock).to receive(:endpoint=).with('https://artifacts.example.com/artifactory')
550
+ expect(config_mock).to receive(:username=).with('testuser')
551
+ expect(config_mock).to receive(:password=).with('testpass')
552
+
553
+ expect(mock_artifact).to receive(:upload).with(
554
+ 'repo-name',
555
+ 'path/to/file.txt',
556
+ hash_including('fig.original_path' => local_file)
557
+ )
558
+
559
+ artifactory.upload(local_file, uri)
560
+ end
561
+
562
+ it 'logs equivalent curl command' do
563
+ allow(mock_artifact).to receive(:upload)
564
+
565
+ expect(Fig::Logging).to receive(:debug).with(
566
+ "Equivalent curl: curl -u testuser:*** -T '#{local_file}' '#{uri}'"
567
+ ).ordered
568
+ expect(Fig::Logging).to receive(:debug).with(
569
+ /Upload metadata:/
570
+ ).ordered
571
+
572
+ artifactory.upload(local_file, uri)
573
+ end
574
+
575
+ it 'raises error for invalid URI without artifactory in path' do
576
+ invalid_uri = URI('https://example.com/repo-name/file.txt')
577
+
578
+ expect {
579
+ artifactory.upload(local_file, invalid_uri)
580
+ }.to raise_error(ArgumentError, /URI must contain 'artifactory' in path/)
581
+ end
582
+
583
+ it 'collects metadata with fig. prefix' do
584
+ metadata_hash = nil
585
+ allow(mock_artifact).to receive(:upload) do |repo, path, metadata|
586
+ metadata_hash = metadata
587
+ end
588
+
589
+ artifactory.upload(local_file, uri)
590
+
591
+ expect(metadata_hash).to include(
592
+ 'fig.original_path' => local_file,
593
+ 'fig.target_path' => 'path/to/file.txt',
594
+ 'fig.tool' => 'fig-artifactory-protocol'
595
+ )
596
+ expect(metadata_hash.keys).to all(start_with('fig.'))
597
+ end
598
+ end
599
+ end
metadata CHANGED
@@ -1,13 +1,13 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: fig
3
3
  version: !ruby/object:Gem::Version
4
- version: 2.0.0.pre.alpha.5
4
+ version: 2.0.0.pre.alpha.10
5
5
  platform: ruby
6
6
  authors:
7
7
  - Fig Folks
8
8
  bindir: bin
9
9
  cert_chain: []
10
- date: 2025-08-20 00:00:00.000000000 Z
10
+ date: 2025-09-24 00:00:00.000000000 Z
11
11
  dependencies:
12
12
  - !ruby/object:Gem::Dependency
13
13
  name: bcrypt_pbkdf
@@ -233,6 +233,34 @@ dependencies:
233
233
  - - "~>"
234
234
  - !ruby/object:Gem::Version
235
235
  version: 2.6.0
236
+ - !ruby/object:Gem::Dependency
237
+ name: artifactory
238
+ requirement: !ruby/object:Gem::Requirement
239
+ requirements:
240
+ - - "~>"
241
+ - !ruby/object:Gem::Version
242
+ version: 3.0.17
243
+ type: :runtime
244
+ prerelease: false
245
+ version_requirements: !ruby/object:Gem::Requirement
246
+ requirements:
247
+ - - "~>"
248
+ - !ruby/object:Gem::Version
249
+ version: 3.0.17
250
+ - !ruby/object:Gem::Dependency
251
+ name: rexml
252
+ requirement: !ruby/object:Gem::Requirement
253
+ requirements:
254
+ - - "~>"
255
+ - !ruby/object:Gem::Version
256
+ version: '3.0'
257
+ type: :runtime
258
+ prerelease: false
259
+ version_requirements: !ruby/object:Gem::Requirement
260
+ requirements:
261
+ - - "~>"
262
+ - !ruby/object:Gem::Version
263
+ version: '3.0'
236
264
  - !ruby/object:Gem::Dependency
237
265
  name: rdoc
238
266
  requirement: !ruby/object:Gem::Requirement
@@ -306,7 +334,7 @@ dependencies:
306
334
  description: |-
307
335
  Fig is a utility for configuring environments and managing dependencies across a team of developers. Given a list of packages and a command to run, Fig builds environment variables named in those packages (e.g., CLASSPATH), then executes the command in that environment. The caller's environment is not affected.
308
336
 
309
- Built from git SHA1: ac8e555-dirty
337
+ Built from git SHA1: 46bcf5e-dirty
310
338
  email: maintainer@figpackagemanager.org
311
339
  executables:
312
340
  - fig
@@ -438,6 +466,7 @@ files:
438
466
  - lib/fig/parser.rb
439
467
  - lib/fig/parser_package_build_state.rb
440
468
  - lib/fig/protocol.rb
469
+ - lib/fig/protocol/artifactory.rb
441
470
  - lib/fig/protocol/file.rb
442
471
  - lib/fig/protocol/ftp.rb
443
472
  - lib/fig/protocol/http.rb
@@ -504,6 +533,7 @@ files:
504
533
  - spec/environment_variables_spec.rb
505
534
  - spec/figrc_spec.rb
506
535
  - spec/parser_spec.rb
536
+ - spec/protocol/artifactory_spec.rb
507
537
  - spec/repository_spec.rb
508
538
  - spec/runtime_environment_spec.rb
509
539
  - spec/spec_helper.rb
@@ -515,7 +545,7 @@ files:
515
545
  licenses:
516
546
  - BSD-3-Clause
517
547
  metadata:
518
- git_sha: ac8e555-dirty
548
+ git_sha: 46bcf5e-dirty
519
549
  rdoc_options: []
520
550
  require_paths:
521
551
  - lib
@@ -533,5 +563,5 @@ requirements: []
533
563
  rubygems_version: 3.6.1
534
564
  specification_version: 4
535
565
  summary: 'Utility for configuring environments and managing dependencies across a
536
- team of developers. Built from git SHA1: ac8e555-dirty'
566
+ team of developers. Built from git SHA1: 46bcf5e-dirty'
537
567
  test_files: []