fastlane-plugin-infisical 0.1.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.
- checksums.yaml +7 -0
- data/LICENSE +202 -0
- data/README.md +165 -0
- data/lib/fastlane/plugin/infisical/actions/infisical_storage_action.rb +32 -0
- data/lib/fastlane/plugin/infisical/storage.rb +428 -0
- data/lib/fastlane/plugin/infisical/storage_manual.rb +385 -0
- data/lib/fastlane/plugin/infisical/storage_sdk.rb +259 -0
- data/lib/fastlane/plugin/infisical/version.rb +5 -0
- data/lib/fastlane/plugin/infisical_storage.rb +86 -0
- metadata +79 -0
|
@@ -0,0 +1,385 @@
|
|
|
1
|
+
require "fastlane_core/command_executor"
|
|
2
|
+
require "fastlane_core/configuration/configuration"
|
|
3
|
+
require "match"
|
|
4
|
+
require "fileutils"
|
|
5
|
+
require "net/http"
|
|
6
|
+
require "json"
|
|
7
|
+
require "uri"
|
|
8
|
+
require "base64"
|
|
9
|
+
|
|
10
|
+
module Fastlane
|
|
11
|
+
module InfisicalStorage
|
|
12
|
+
class Storage < ::Match::Storage::Interface
|
|
13
|
+
attr_reader :project_id
|
|
14
|
+
attr_reader :environment
|
|
15
|
+
attr_reader :secret_path
|
|
16
|
+
attr_reader :infisical_url
|
|
17
|
+
attr_reader :username
|
|
18
|
+
attr_reader :readonly
|
|
19
|
+
attr_reader :team_id
|
|
20
|
+
attr_reader :team_name
|
|
21
|
+
attr_reader :api_key_path
|
|
22
|
+
attr_reader :api_key
|
|
23
|
+
|
|
24
|
+
def self.configure(params)
|
|
25
|
+
if params[:git_url].to_s.length > 0
|
|
26
|
+
UI.important("Looks like you still define a `git_url` somewhere, even though")
|
|
27
|
+
UI.important("you use Infisical. You can remove the `git_url`")
|
|
28
|
+
UI.important("from your Matchfile and Fastfile")
|
|
29
|
+
UI.message("The above is just a warning, fastlane will continue as usual now...")
|
|
30
|
+
end
|
|
31
|
+
|
|
32
|
+
return(
|
|
33
|
+
self.new(
|
|
34
|
+
project_id: params[:infisical_project_id],
|
|
35
|
+
environment: params[:infisical_environment],
|
|
36
|
+
secret_path: params[:infisical_secret_path],
|
|
37
|
+
infisical_url: params[:infisical_url],
|
|
38
|
+
username: params[:username],
|
|
39
|
+
readonly: params[:readonly],
|
|
40
|
+
team_id: params[:team_id],
|
|
41
|
+
team_name: params[:team_name],
|
|
42
|
+
api_key_path: params[:api_key_path],
|
|
43
|
+
api_key: params[:api_key],
|
|
44
|
+
)
|
|
45
|
+
)
|
|
46
|
+
end
|
|
47
|
+
|
|
48
|
+
def initialize(
|
|
49
|
+
project_id:,
|
|
50
|
+
environment: "dev",
|
|
51
|
+
secret_path: "/",
|
|
52
|
+
infisical_url: nil,
|
|
53
|
+
username: nil,
|
|
54
|
+
readonly: nil,
|
|
55
|
+
team_id: nil,
|
|
56
|
+
team_name: nil,
|
|
57
|
+
api_key_path: nil,
|
|
58
|
+
api_key: nil
|
|
59
|
+
)
|
|
60
|
+
@project_id = project_id || ENV["INFISICAL_PROJECT_ID"]
|
|
61
|
+
@environment = environment || ENV["INFISICAL_ENVIRONMENT"] || "dev"
|
|
62
|
+
@secret_path = secret_path || ENV["INFISICAL_SECRET_PATH"] || "/"
|
|
63
|
+
@infisical_url = infisical_url || ENV["INFISICAL_URL"] || "https://app.infisical.com"
|
|
64
|
+
|
|
65
|
+
UI.important("🌐 URL Resolution Debug:")
|
|
66
|
+
UI.important(" infisical_url param: #{infisical_url.inspect}")
|
|
67
|
+
UI.important(" ENV['INFISICAL_URL']: #{ENV['INFISICAL_URL'].inspect}")
|
|
68
|
+
UI.important(" Final @infisical_url: #{@infisical_url}")
|
|
69
|
+
|
|
70
|
+
@username = username
|
|
71
|
+
@readonly = readonly
|
|
72
|
+
@team_id = team_id
|
|
73
|
+
@team_name = team_name
|
|
74
|
+
@api_key_path = api_key_path
|
|
75
|
+
@api_key = api_key
|
|
76
|
+
|
|
77
|
+
# Get authentication token
|
|
78
|
+
@auth_token = get_auth_token
|
|
79
|
+
|
|
80
|
+
UI.message("Initializing match for Infisical at project #{@project_id} in environment #{@environment}")
|
|
81
|
+
end
|
|
82
|
+
|
|
83
|
+
def prefixed_working_directory
|
|
84
|
+
@_folder_prefix ||= currently_used_team_id
|
|
85
|
+
if @_folder_prefix.nil?
|
|
86
|
+
UI.important(
|
|
87
|
+
"Looks like you run `match` in `readonly` mode, and didn't provide a `team_id`. This will still work, however it is recommended to provide a `team_id` in your Appfile or Matchfile",
|
|
88
|
+
)
|
|
89
|
+
@_folder_prefix = "*"
|
|
90
|
+
end
|
|
91
|
+
return File.join(working_directory, @_folder_prefix)
|
|
92
|
+
end
|
|
93
|
+
|
|
94
|
+
def download
|
|
95
|
+
return if @working_directory
|
|
96
|
+
|
|
97
|
+
self.working_directory = Dir.mktmpdir
|
|
98
|
+
|
|
99
|
+
with_infisical_error_handling do
|
|
100
|
+
secrets = list_all_secrets
|
|
101
|
+
|
|
102
|
+
secrets.each do |secret|
|
|
103
|
+
# Extract the relative path from the secret key
|
|
104
|
+
relative_path = secret["secretKey"]
|
|
105
|
+
next unless relative_path.start_with?("fastlane/")
|
|
106
|
+
|
|
107
|
+
# Remove the fastlane/ prefix and decode the file content
|
|
108
|
+
filename = File.join(self.working_directory, relative_path.delete_prefix("fastlane/"))
|
|
109
|
+
FileUtils.mkdir_p(File.dirname(filename))
|
|
110
|
+
|
|
111
|
+
# Decode base64 content if it's encoded
|
|
112
|
+
content = secret["secretValue"]
|
|
113
|
+
if is_base64?(content)
|
|
114
|
+
content = Base64.decode64(content)
|
|
115
|
+
end
|
|
116
|
+
|
|
117
|
+
IO.binwrite(filename, content)
|
|
118
|
+
end
|
|
119
|
+
end
|
|
120
|
+
|
|
121
|
+
UI.verbose(
|
|
122
|
+
"Successfully downloaded all secrets from Infisical to #{self.working_directory}",
|
|
123
|
+
)
|
|
124
|
+
end
|
|
125
|
+
|
|
126
|
+
def currently_used_team_id
|
|
127
|
+
if self.readonly
|
|
128
|
+
return self.team_id
|
|
129
|
+
else
|
|
130
|
+
if self.team_id.to_s.empty?
|
|
131
|
+
UI.user_error!(
|
|
132
|
+
"The `team_id` option is required. fastlane cannot automatically determine portal team id via the App Store Connect API (yet)",
|
|
133
|
+
)
|
|
134
|
+
end
|
|
135
|
+
|
|
136
|
+
spaceship =
|
|
137
|
+
::Match::SpaceshipEnsure.new(self.username, self.team_id, self.team_name, api_token)
|
|
138
|
+
return spaceship.team_id
|
|
139
|
+
end
|
|
140
|
+
end
|
|
141
|
+
|
|
142
|
+
def api_token
|
|
143
|
+
api_token =
|
|
144
|
+
Spaceship::ConnectAPI::Token.from(hash: self.api_key, filepath: self.api_key_path)
|
|
145
|
+
api_token ||= Spaceship::ConnectAPI.token
|
|
146
|
+
return api_token
|
|
147
|
+
end
|
|
148
|
+
|
|
149
|
+
def human_readable_description
|
|
150
|
+
"Infisical Storage [Project: #{self.project_id}, Environment: #{self.environment}]"
|
|
151
|
+
end
|
|
152
|
+
|
|
153
|
+
def upload_files(files_to_upload: [], custom_message: nil)
|
|
154
|
+
files_to_upload.each do |current_file|
|
|
155
|
+
# Convert file path to secret key
|
|
156
|
+
secret_key = "fastlane/" + current_file.delete_prefix(self.working_directory + "/")
|
|
157
|
+
UI.verbose("Uploading '#{secret_key}' to Infisical...")
|
|
158
|
+
|
|
159
|
+
# Read file content and encode as base64 for binary files
|
|
160
|
+
content = IO.binread(current_file)
|
|
161
|
+
encoded_content = Base64.encode64(content)
|
|
162
|
+
|
|
163
|
+
create_or_update_secret(secret_key, encoded_content)
|
|
164
|
+
end
|
|
165
|
+
end
|
|
166
|
+
|
|
167
|
+
def delete_files(files_to_delete: [], custom_message: nil)
|
|
168
|
+
files_to_delete.each do |current_file|
|
|
169
|
+
secret_key = "fastlane/" + current_file.delete_prefix(self.working_directory + "/")
|
|
170
|
+
delete_secret(secret_key)
|
|
171
|
+
end
|
|
172
|
+
end
|
|
173
|
+
|
|
174
|
+
def skip_docs
|
|
175
|
+
true
|
|
176
|
+
end
|
|
177
|
+
|
|
178
|
+
def list_files(file_name: "", file_ext: "")
|
|
179
|
+
Dir[File.join(working_directory, self.team_id, "**", file_name, "*.#{file_ext}")]
|
|
180
|
+
end
|
|
181
|
+
|
|
182
|
+
def generate_matchfile_content(template: nil)
|
|
183
|
+
raise "Not Implemented"
|
|
184
|
+
end
|
|
185
|
+
|
|
186
|
+
private
|
|
187
|
+
|
|
188
|
+
def get_auth_token
|
|
189
|
+
# Try different authentication methods
|
|
190
|
+
|
|
191
|
+
# Method 1: Universal Auth (Client ID + Client Secret)
|
|
192
|
+
client_id = ENV["INFISICAL_CLIENT_ID"]
|
|
193
|
+
client_secret = ENV["INFISICAL_CLIENT_SECRET"]
|
|
194
|
+
|
|
195
|
+
if client_id && client_secret
|
|
196
|
+
return authenticate_universal_auth(client_id, client_secret)
|
|
197
|
+
end
|
|
198
|
+
|
|
199
|
+
# Method 2: Service Token (legacy)
|
|
200
|
+
service_token = ENV["INFISICAL_TOKEN"]
|
|
201
|
+
if service_token
|
|
202
|
+
return service_token
|
|
203
|
+
end
|
|
204
|
+
|
|
205
|
+
UI.user_error!("No Infisical authentication method found. Please set either INFISICAL_CLIENT_ID + INFISICAL_CLIENT_SECRET or INFISICAL_TOKEN environment variables.")
|
|
206
|
+
end
|
|
207
|
+
|
|
208
|
+
def authenticate_universal_auth(client_id, client_secret)
|
|
209
|
+
UI.important("🔐 Attempting Universal Auth with URL: #{@infisical_url}")
|
|
210
|
+
UI.important("🔑 Client ID: #{client_id}")
|
|
211
|
+
|
|
212
|
+
uri = URI("#{@infisical_url}/api/v1/auth/universal-auth/login")
|
|
213
|
+
http = Net::HTTP.new(uri.host, uri.port)
|
|
214
|
+
http.use_ssl = true if uri.scheme == 'https'
|
|
215
|
+
|
|
216
|
+
request = Net::HTTP::Post.new(uri)
|
|
217
|
+
request['Content-Type'] = 'application/json'
|
|
218
|
+
request.body = {
|
|
219
|
+
clientId: client_id,
|
|
220
|
+
clientSecret: client_secret
|
|
221
|
+
}.to_json
|
|
222
|
+
|
|
223
|
+
UI.important("📤 Request URL: #{uri}")
|
|
224
|
+
UI.important("📤 Request payload: #{request.body}")
|
|
225
|
+
|
|
226
|
+
response = http.request(request)
|
|
227
|
+
|
|
228
|
+
UI.important("📥 Response: #{response.code}")
|
|
229
|
+
UI.important("📥 Response body: #{response.body}")
|
|
230
|
+
|
|
231
|
+
if response.code == '200'
|
|
232
|
+
result = JSON.parse(response.body)
|
|
233
|
+
UI.success("✅ Universal Auth successful!")
|
|
234
|
+
return result['accessToken']
|
|
235
|
+
else
|
|
236
|
+
UI.user_error!("Failed to authenticate with Infisical: #{response.code} #{response.body}")
|
|
237
|
+
end
|
|
238
|
+
end
|
|
239
|
+
|
|
240
|
+
def list_all_secrets
|
|
241
|
+
uri = URI("#{@infisical_url}/api/v3/secrets")
|
|
242
|
+
uri.query = URI.encode_www_form({
|
|
243
|
+
workspaceId: @project_id,
|
|
244
|
+
environment: @environment,
|
|
245
|
+
secretPath: @secret_path,
|
|
246
|
+
recursive: true
|
|
247
|
+
})
|
|
248
|
+
|
|
249
|
+
http = Net::HTTP.new(uri.host, uri.port)
|
|
250
|
+
http.use_ssl = true if uri.scheme == 'https'
|
|
251
|
+
|
|
252
|
+
request = Net::HTTP::Get.new(uri)
|
|
253
|
+
request['Authorization'] = "Bearer #{@auth_token}"
|
|
254
|
+
|
|
255
|
+
response = http.request(request)
|
|
256
|
+
|
|
257
|
+
if response.code == '200'
|
|
258
|
+
result = JSON.parse(response.body)
|
|
259
|
+
return result['secrets'] || []
|
|
260
|
+
else
|
|
261
|
+
UI.error("Failed to list secrets: #{response.code} #{response.body}")
|
|
262
|
+
return []
|
|
263
|
+
end
|
|
264
|
+
end
|
|
265
|
+
|
|
266
|
+
def create_or_update_secret(secret_key, value)
|
|
267
|
+
# Check if secret exists
|
|
268
|
+
existing_secret = get_secret(secret_key)
|
|
269
|
+
|
|
270
|
+
if existing_secret
|
|
271
|
+
update_secret(secret_key, value)
|
|
272
|
+
else
|
|
273
|
+
create_secret(secret_key, value)
|
|
274
|
+
end
|
|
275
|
+
end
|
|
276
|
+
|
|
277
|
+
def get_secret(secret_key)
|
|
278
|
+
uri = URI("#{@infisical_url}/api/v3/secrets/#{URI.encode_www_form_component(secret_key)}")
|
|
279
|
+
uri.query = URI.encode_www_form({
|
|
280
|
+
workspaceId: @project_id,
|
|
281
|
+
environment: @environment,
|
|
282
|
+
secretPath: @secret_path
|
|
283
|
+
})
|
|
284
|
+
|
|
285
|
+
http = Net::HTTP.new(uri.host, uri.port)
|
|
286
|
+
http.use_ssl = true if uri.scheme == 'https'
|
|
287
|
+
|
|
288
|
+
request = Net::HTTP::Get.new(uri)
|
|
289
|
+
request['Authorization'] = "Bearer #{@auth_token}"
|
|
290
|
+
|
|
291
|
+
response = http.request(request)
|
|
292
|
+
|
|
293
|
+
if response.code == '200'
|
|
294
|
+
result = JSON.parse(response.body)
|
|
295
|
+
return result['secret']
|
|
296
|
+
else
|
|
297
|
+
return nil
|
|
298
|
+
end
|
|
299
|
+
end
|
|
300
|
+
|
|
301
|
+
def create_secret(secret_key, value)
|
|
302
|
+
uri = URI("#{@infisical_url}/api/v3/secrets/#{URI.encode_www_form_component(secret_key)}")
|
|
303
|
+
|
|
304
|
+
http = Net::HTTP.new(uri.host, uri.port)
|
|
305
|
+
http.use_ssl = true if uri.scheme == 'https'
|
|
306
|
+
|
|
307
|
+
request = Net::HTTP::Post.new(uri)
|
|
308
|
+
request['Authorization'] = "Bearer #{@auth_token}"
|
|
309
|
+
request['Content-Type'] = 'application/json'
|
|
310
|
+
request.body = {
|
|
311
|
+
workspaceId: @project_id,
|
|
312
|
+
environment: @environment,
|
|
313
|
+
secretPath: @secret_path,
|
|
314
|
+
secretValue: value,
|
|
315
|
+
secretComment: "Fastlane Match certificate/profile",
|
|
316
|
+
type: "shared"
|
|
317
|
+
}.to_json
|
|
318
|
+
|
|
319
|
+
response = http.request(request)
|
|
320
|
+
|
|
321
|
+
unless response.code == '200'
|
|
322
|
+
UI.error("Failed to create secret #{secret_key}: #{response.code} #{response.body}")
|
|
323
|
+
end
|
|
324
|
+
end
|
|
325
|
+
|
|
326
|
+
def update_secret(secret_key, value)
|
|
327
|
+
uri = URI("#{@infisical_url}/api/v3/secrets/#{URI.encode_www_form_component(secret_key)}")
|
|
328
|
+
|
|
329
|
+
http = Net::HTTP.new(uri.host, uri.port)
|
|
330
|
+
http.use_ssl = true if uri.scheme == 'https'
|
|
331
|
+
|
|
332
|
+
request = Net::HTTP::Patch.new(uri)
|
|
333
|
+
request['Authorization'] = "Bearer #{@auth_token}"
|
|
334
|
+
request['Content-Type'] = 'application/json'
|
|
335
|
+
request.body = {
|
|
336
|
+
workspaceId: @project_id,
|
|
337
|
+
environment: @environment,
|
|
338
|
+
secretPath: @secret_path,
|
|
339
|
+
secretValue: value
|
|
340
|
+
}.to_json
|
|
341
|
+
|
|
342
|
+
response = http.request(request)
|
|
343
|
+
|
|
344
|
+
unless response.code == '200'
|
|
345
|
+
UI.error("Failed to update secret #{secret_key}: #{response.code} #{response.body}")
|
|
346
|
+
end
|
|
347
|
+
end
|
|
348
|
+
|
|
349
|
+
def delete_secret(secret_key)
|
|
350
|
+
uri = URI("#{@infisical_url}/api/v3/secrets/#{URI.encode_www_form_component(secret_key)}")
|
|
351
|
+
|
|
352
|
+
http = Net::HTTP.new(uri.host, uri.port)
|
|
353
|
+
http.use_ssl = true if uri.scheme == 'https'
|
|
354
|
+
|
|
355
|
+
request = Net::HTTP::Delete.new(uri)
|
|
356
|
+
request['Authorization'] = "Bearer #{@auth_token}"
|
|
357
|
+
request['Content-Type'] = 'application/json'
|
|
358
|
+
request.body = {
|
|
359
|
+
workspaceId: @project_id,
|
|
360
|
+
environment: @environment,
|
|
361
|
+
secretPath: @secret_path
|
|
362
|
+
}.to_json
|
|
363
|
+
|
|
364
|
+
response = http.request(request)
|
|
365
|
+
|
|
366
|
+
unless response.code == '200'
|
|
367
|
+
UI.verbose("Failed to delete secret #{secret_key}: #{response.code} #{response.body}")
|
|
368
|
+
end
|
|
369
|
+
end
|
|
370
|
+
|
|
371
|
+
def is_base64?(string)
|
|
372
|
+
# Simple check to see if a string is base64 encoded
|
|
373
|
+
string.is_a?(String) && string.match(/\A[A-Za-z0-9+\/]*={0,2}\z/) && (string.length % 4 == 0)
|
|
374
|
+
end
|
|
375
|
+
|
|
376
|
+
def with_infisical_error_handling
|
|
377
|
+
explainer = "Note: Infisical credentials should be set via environment variables. Set either INFISICAL_CLIENT_ID + INFISICAL_CLIENT_SECRET or INFISICAL_TOKEN."
|
|
378
|
+
yield
|
|
379
|
+
rescue StandardError => e
|
|
380
|
+
UI.error("Infisical authentication error: #{e}.\n\n#{explainer}")
|
|
381
|
+
raise e
|
|
382
|
+
end
|
|
383
|
+
end
|
|
384
|
+
end
|
|
385
|
+
end
|
|
@@ -0,0 +1,259 @@
|
|
|
1
|
+
require "fastlane/plugin/infisical/version"
|
|
2
|
+
require "fastlane_core/ui/ui"
|
|
3
|
+
require "infisical-sdk"
|
|
4
|
+
require "base64"
|
|
5
|
+
require "tempfile"
|
|
6
|
+
|
|
7
|
+
UI = FastlaneCore::UI unless defined?(UI)
|
|
8
|
+
|
|
9
|
+
module Fastlane
|
|
10
|
+
module InfisicalStorage
|
|
11
|
+
class Storage < Match::Storage::Interface
|
|
12
|
+
attr_reader :project_id
|
|
13
|
+
attr_reader :environment
|
|
14
|
+
attr_reader :secret_path
|
|
15
|
+
attr_reader :infisical_url
|
|
16
|
+
|
|
17
|
+
def self.configure(params)
|
|
18
|
+
return(
|
|
19
|
+
Storage.new(
|
|
20
|
+
project_id: params[:project_id],
|
|
21
|
+
environment: params[:environment],
|
|
22
|
+
secret_path: params[:secret_path],
|
|
23
|
+
infisical_url: params[:infisical_url],
|
|
24
|
+
username: params[:username],
|
|
25
|
+
readonly: params[:readonly],
|
|
26
|
+
team_id: params[:team_id],
|
|
27
|
+
team_name: params[:team_name],
|
|
28
|
+
api_key_path: params[:api_key_path],
|
|
29
|
+
api_key: params[:api_key]
|
|
30
|
+
)
|
|
31
|
+
)
|
|
32
|
+
end
|
|
33
|
+
|
|
34
|
+
def initialize(
|
|
35
|
+
project_id:,
|
|
36
|
+
environment: "dev",
|
|
37
|
+
secret_path: "/",
|
|
38
|
+
infisical_url: nil,
|
|
39
|
+
username: nil,
|
|
40
|
+
readonly: nil,
|
|
41
|
+
team_id: nil,
|
|
42
|
+
team_name: nil,
|
|
43
|
+
api_key_path: nil,
|
|
44
|
+
api_key: nil
|
|
45
|
+
)
|
|
46
|
+
@project_id = project_id || ENV["INFISICAL_PROJECT_ID"]
|
|
47
|
+
@environment = environment || ENV["INFISICAL_ENVIRONMENT"] || "dev"
|
|
48
|
+
@secret_path = secret_path || ENV["INFISICAL_SECRET_PATH"] || "/"
|
|
49
|
+
@infisical_url =
|
|
50
|
+
infisical_url || ENV["INFISICAL_URL"] || "https://app.infisical.com"
|
|
51
|
+
@username = username
|
|
52
|
+
@readonly = readonly
|
|
53
|
+
@team_id = team_id
|
|
54
|
+
@team_name = team_name
|
|
55
|
+
@api_key_path = api_key_path
|
|
56
|
+
@api_key = api_key
|
|
57
|
+
|
|
58
|
+
# Initialize Infisical SDK client
|
|
59
|
+
@client = InfisicalSDK::InfisicalClient.new(@infisical_url)
|
|
60
|
+
|
|
61
|
+
# Authenticate using the best available method
|
|
62
|
+
authenticate_client
|
|
63
|
+
|
|
64
|
+
UI.message(
|
|
65
|
+
"Initializing match for Infisical at project #{@project_id} in environment #{@environment}"
|
|
66
|
+
)
|
|
67
|
+
end
|
|
68
|
+
|
|
69
|
+
def prefixed_working_directory
|
|
70
|
+
# the directory depends on the team_id
|
|
71
|
+
if @team_id
|
|
72
|
+
@_folder_prefix = "team_id/#{@team_id}"
|
|
73
|
+
elsif @team_name
|
|
74
|
+
@_folder_prefix = "team_name/#{@team_name}"
|
|
75
|
+
else
|
|
76
|
+
@_folder_prefix = nil
|
|
77
|
+
end
|
|
78
|
+
|
|
79
|
+
# If team_id/team_name is not set, use the one from the Appfile/Matchfile
|
|
80
|
+
if @_folder_prefix.nil?
|
|
81
|
+
UI.important(
|
|
82
|
+
"Looks like you run `match` in `readonly` mode, and didn't provide a `team_id`. This will still work, however it is recommended to provide a `team_id` in your Appfile or Matchfile"
|
|
83
|
+
)
|
|
84
|
+
@_folder_prefix = "*"
|
|
85
|
+
end
|
|
86
|
+
return File.join(working_directory, @_folder_prefix)
|
|
87
|
+
end
|
|
88
|
+
|
|
89
|
+
def download
|
|
90
|
+
return if @working_directory
|
|
91
|
+
|
|
92
|
+
self.working_directory = Dir.mktmpdir
|
|
93
|
+
|
|
94
|
+
with_infisical_error_handling do
|
|
95
|
+
# List all secrets with fastlane/ prefix
|
|
96
|
+
secrets = list_fastlane_secrets
|
|
97
|
+
|
|
98
|
+
secrets.each do |secret|
|
|
99
|
+
# Extract the relative path from the secret key
|
|
100
|
+
relative_path = secret["secretKey"]
|
|
101
|
+
next unless relative_path.start_with?("fastlane/")
|
|
102
|
+
|
|
103
|
+
# Remove fastlane/ prefix for local file path
|
|
104
|
+
local_path = relative_path.sub(%r{^fastlane/}, "")
|
|
105
|
+
full_path = File.join(working_directory, local_path)
|
|
106
|
+
|
|
107
|
+
# Create directory if needed
|
|
108
|
+
FileUtils.mkdir_p(File.dirname(full_path))
|
|
109
|
+
|
|
110
|
+
# Decode and write the file
|
|
111
|
+
content = Base64.decode64(secret["secretValue"])
|
|
112
|
+
File.write(full_path, content)
|
|
113
|
+
|
|
114
|
+
UI.verbose("Downloaded: #{relative_path} -> #{local_path}")
|
|
115
|
+
end
|
|
116
|
+
|
|
117
|
+
UI.success("Downloaded #{secrets.length} files from Infisical")
|
|
118
|
+
end
|
|
119
|
+
end
|
|
120
|
+
|
|
121
|
+
def upload_files(files_to_upload)
|
|
122
|
+
with_infisical_error_handling do
|
|
123
|
+
files_to_upload.each do |file_path|
|
|
124
|
+
# Calculate relative path and secret key
|
|
125
|
+
relative_path = file_path.gsub(working_directory + "/", "")
|
|
126
|
+
secret_key = "fastlane/#{relative_path}"
|
|
127
|
+
|
|
128
|
+
# Read and encode file content
|
|
129
|
+
content = File.read(file_path)
|
|
130
|
+
encoded_content = Base64.encode64(content)
|
|
131
|
+
|
|
132
|
+
# Create or update the secret
|
|
133
|
+
create_or_update_secret(secret_key, encoded_content)
|
|
134
|
+
|
|
135
|
+
UI.verbose("Uploaded: #{relative_path}")
|
|
136
|
+
end
|
|
137
|
+
|
|
138
|
+
UI.success("Uploaded #{files_to_upload.length} files to Infisical")
|
|
139
|
+
end
|
|
140
|
+
end
|
|
141
|
+
|
|
142
|
+
def delete_files(files_to_delete)
|
|
143
|
+
with_infisical_error_handling do
|
|
144
|
+
files_to_delete.each do |file_path|
|
|
145
|
+
relative_path = file_path.gsub(working_directory + "/", "")
|
|
146
|
+
secret_key = "fastlane/#{relative_path}"
|
|
147
|
+
|
|
148
|
+
delete_secret(secret_key)
|
|
149
|
+
|
|
150
|
+
UI.verbose("Deleted: #{relative_path}")
|
|
151
|
+
end
|
|
152
|
+
end
|
|
153
|
+
end
|
|
154
|
+
|
|
155
|
+
def skip_docs
|
|
156
|
+
false
|
|
157
|
+
end
|
|
158
|
+
|
|
159
|
+
def generate_matchfile_content(template: nil)
|
|
160
|
+
return ""
|
|
161
|
+
end
|
|
162
|
+
|
|
163
|
+
private
|
|
164
|
+
|
|
165
|
+
def authenticate_client
|
|
166
|
+
# Try Universal Auth first (preferred method)
|
|
167
|
+
client_id =
|
|
168
|
+
ENV["INFISICAL_CLIENT_ID"] ||
|
|
169
|
+
ENV["INFISICAL_UNIVERSAL_AUTH_CLIENT_ID"]
|
|
170
|
+
client_secret =
|
|
171
|
+
ENV["INFISICAL_CLIENT_SECRET"] ||
|
|
172
|
+
ENV["INFISICAL_UNIVERSAL_AUTH_CLIENT_SECRET"]
|
|
173
|
+
|
|
174
|
+
if client_id && client_secret
|
|
175
|
+
UI.verbose("🔐 Authenticating with Universal Auth...")
|
|
176
|
+
@client.auth.universal_auth(
|
|
177
|
+
client_id: client_id,
|
|
178
|
+
client_secret: client_secret
|
|
179
|
+
)
|
|
180
|
+
UI.success("✅ Universal Auth successful!")
|
|
181
|
+
return
|
|
182
|
+
end
|
|
183
|
+
|
|
184
|
+
# Fallback to service token (legacy)
|
|
185
|
+
service_token = ENV["INFISICAL_TOKEN"]
|
|
186
|
+
if service_token
|
|
187
|
+
UI.verbose("🔐 Using Service Token authentication...")
|
|
188
|
+
# Service tokens are used differently - they're passed as bearer tokens
|
|
189
|
+
# The SDK might handle this automatically, but we may need to set it manually
|
|
190
|
+
UI.important(
|
|
191
|
+
"⚠️ Service Token authentication with SDK - this may not work with E2EE enabled"
|
|
192
|
+
)
|
|
193
|
+
# Note: The SDK may not directly support service tokens in the same way
|
|
194
|
+
# This would need to be tested
|
|
195
|
+
return
|
|
196
|
+
end
|
|
197
|
+
|
|
198
|
+
UI.user_error!(
|
|
199
|
+
"No Infisical authentication method found. Please set either INFISICAL_CLIENT_ID + INFISICAL_CLIENT_SECRET or INFISICAL_TOKEN environment variables."
|
|
200
|
+
)
|
|
201
|
+
end
|
|
202
|
+
|
|
203
|
+
def list_fastlane_secrets
|
|
204
|
+
secrets =
|
|
205
|
+
@client.secrets.list(
|
|
206
|
+
project_id: @project_id,
|
|
207
|
+
environment: @environment,
|
|
208
|
+
path: @secret_path
|
|
209
|
+
)
|
|
210
|
+
|
|
211
|
+
# Filter for fastlane secrets only
|
|
212
|
+
secrets.select { |secret| secret["secretKey"].start_with?("fastlane/") }
|
|
213
|
+
end
|
|
214
|
+
|
|
215
|
+
def create_or_update_secret(secret_key, value)
|
|
216
|
+
begin
|
|
217
|
+
# Try to update first (in case it exists)
|
|
218
|
+
@client.secrets.update(
|
|
219
|
+
secret_name: secret_key,
|
|
220
|
+
secret_value: value,
|
|
221
|
+
project_id: @project_id,
|
|
222
|
+
environment: @environment
|
|
223
|
+
)
|
|
224
|
+
UI.verbose("Updated secret: #{secret_key}")
|
|
225
|
+
rescue => e
|
|
226
|
+
# If update fails, try to create
|
|
227
|
+
if e.message.include?("not found") || e.message.include?("404")
|
|
228
|
+
@client.secrets.create(
|
|
229
|
+
secret_name: secret_key,
|
|
230
|
+
secret_value: value,
|
|
231
|
+
project_id: @project_id,
|
|
232
|
+
environment: @environment
|
|
233
|
+
)
|
|
234
|
+
UI.verbose("Created secret: #{secret_key}")
|
|
235
|
+
else
|
|
236
|
+
raise e
|
|
237
|
+
end
|
|
238
|
+
end
|
|
239
|
+
end
|
|
240
|
+
|
|
241
|
+
def delete_secret(secret_key)
|
|
242
|
+
@client.secrets.delete(
|
|
243
|
+
secret_name: secret_key,
|
|
244
|
+
project_id: @project_id,
|
|
245
|
+
environment: @environment
|
|
246
|
+
)
|
|
247
|
+
end
|
|
248
|
+
|
|
249
|
+
def with_infisical_error_handling
|
|
250
|
+
explainer =
|
|
251
|
+
"Note: Infisical credentials should be set via environment variables. Set either INFISICAL_CLIENT_ID + INFISICAL_CLIENT_SECRET or INFISICAL_TOKEN."
|
|
252
|
+
yield
|
|
253
|
+
rescue StandardError => e
|
|
254
|
+
UI.error("Infisical error: #{e}.\n\n#{explainer}")
|
|
255
|
+
raise e
|
|
256
|
+
end
|
|
257
|
+
end
|
|
258
|
+
end
|
|
259
|
+
end
|