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.
@@ -0,0 +1,428 @@
1
+ require "fastlane/plugin/infisical/version"
2
+ require "fastlane_core/ui/ui"
3
+ require "match"
4
+ require "infisical-sdk"
5
+ require "base64"
6
+ require "tempfile"
7
+ require "fileutils"
8
+
9
+ UI = FastlaneCore::UI unless defined?(UI)
10
+
11
+ module Fastlane
12
+ module InfisicalStorage
13
+ class Storage < Match::Storage::Interface
14
+ attr_reader :project_id
15
+ attr_reader :environment
16
+ attr_reader :secret_path
17
+ attr_reader :infisical_url
18
+ attr_accessor :working_directory
19
+
20
+ def self.configure(params)
21
+ return(
22
+ Storage.new(
23
+ project_id: params[:infisical_project_id] || params[:project_id],
24
+ environment: params[:infisical_environment] || params[:environment],
25
+ secret_path: params[:infisical_secret_path] || params[:secret_path],
26
+ infisical_url: params[:infisical_url],
27
+ username: params[:username],
28
+ readonly: params[:readonly],
29
+ team_id: params[:team_id],
30
+ team_name: params[:team_name],
31
+ api_key_path: params[:api_key_path],
32
+ api_key: params[:api_key]
33
+ )
34
+ )
35
+ end
36
+
37
+ def initialize(
38
+ project_id:,
39
+ environment: "dev",
40
+ secret_path: "/",
41
+ infisical_url: nil,
42
+ username: nil,
43
+ readonly: nil,
44
+ team_id: nil,
45
+ team_name: nil,
46
+ api_key_path: nil,
47
+ api_key: nil
48
+ )
49
+ @project_id = project_id || ENV["INFISICAL_PROJECT_ID"]
50
+ @environment = environment || ENV["INFISICAL_ENVIRONMENT"] || "dev"
51
+ @secret_path = secret_path || ENV["INFISICAL_SECRET_PATH"] || "/"
52
+ @infisical_url =
53
+ infisical_url || ENV["INFISICAL_URL"] || "https://app.infisical.com"
54
+ @username = username
55
+ @readonly = readonly
56
+ @team_id = team_id
57
+ @team_name = team_name
58
+ @api_key_path = api_key_path
59
+ @api_key = api_key
60
+
61
+ # Initialize Infisical SDK client
62
+ @client = InfisicalSDK::InfisicalClient.new(@infisical_url)
63
+
64
+ # Authenticate using the best available method
65
+ authenticate_client
66
+
67
+ UI.message(
68
+ "Initializing match for Infisical at project #{@project_id} in environment #{@environment}"
69
+ )
70
+ end
71
+
72
+ def prefixed_working_directory
73
+ # the directory depends on the team_id
74
+ if @team_id
75
+ @_folder_prefix = "team_id/#{@team_id}"
76
+ elsif @team_name
77
+ @_folder_prefix = "team_name/#{@team_name}"
78
+ else
79
+ @_folder_prefix = nil
80
+ end
81
+
82
+ # If team_id/team_name is not set, use the one from the Appfile/Matchfile
83
+ if @_folder_prefix.nil?
84
+ UI.important(
85
+ "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"
86
+ )
87
+ @_folder_prefix = "*"
88
+ end
89
+ return File.join(working_directory, @_folder_prefix)
90
+ end
91
+
92
+ def working_directory
93
+ @working_directory ||= Dir.mktmpdir
94
+ end
95
+
96
+ def working_directory=(value)
97
+ @working_directory = value
98
+ end
99
+
100
+ def download
101
+ return if @working_directory
102
+
103
+ self.working_directory = Dir.mktmpdir
104
+
105
+ with_infisical_error_handling do
106
+ # List all fastlane secrets from the main fastlane path
107
+ secrets = list_fastlane_secrets
108
+
109
+ secrets.each do |secret|
110
+ secret_name = secret["secretKey"]
111
+
112
+ # Convert the flat secret name back to a file path
113
+ # We need to be smarter about this - only convert the first few dashes that represent directories
114
+ # Common patterns: "profiles-appstore-filename", "certs-development-filename", "filename"
115
+
116
+ if secret_name.include?("-")
117
+ # Try to identify known fastlane directory patterns
118
+ if secret_name.start_with?("profiles-")
119
+ # profiles-TYPE-filename -> profiles/TYPE/filename
120
+ parts = secret_name.split("-", 3) # Split only on first 2 dashes
121
+ if parts.length >= 3
122
+ relative_path = "#{parts[0]}/#{parts[1]}/#{parts[2]}"
123
+ else
124
+ relative_path = secret_name
125
+ end
126
+ elsif secret_name.start_with?("certs-")
127
+ # certs-TYPE-filename -> certs/TYPE/filename
128
+ parts = secret_name.split("-", 3) # Split only on first 2 dashes
129
+ if parts.length >= 3
130
+ relative_path = "#{parts[0]}/#{parts[1]}/#{parts[2]}"
131
+ else
132
+ relative_path = secret_name
133
+ end
134
+ else
135
+ # Unknown pattern, treat as filename
136
+ relative_path = secret_name
137
+ end
138
+ else
139
+ # No dashes, treat as filename
140
+ relative_path = secret_name
141
+ end
142
+
143
+ full_path = File.join(working_directory, relative_path)
144
+
145
+ # Create directory if needed
146
+ FileUtils.mkdir_p(File.dirname(full_path))
147
+
148
+ # Decode and write the file
149
+ content = Base64.strict_decode64(secret["secretValue"])
150
+ File.binwrite(full_path, content)
151
+
152
+ UI.verbose("Downloaded: #{secret_name} -> #{relative_path}")
153
+ end
154
+
155
+ UI.success("Downloaded #{secrets.length} files from Infisical")
156
+ end
157
+ end
158
+
159
+ def upload_files(files_to_upload, custom_working_directory = nil)
160
+ base_directory = custom_working_directory || working_directory
161
+
162
+ with_infisical_error_handling do
163
+ files_to_upload.each do |file_path|
164
+ # Calculate relative path
165
+ relative_path = file_path.gsub(base_directory + "/", "")
166
+
167
+ # Convert the file path to a flat secret name that includes path info
168
+ # For example: "profiles/appstore/file.mobileprovision" becomes "profiles-appstore-file.mobileprovision"
169
+ # Sanitize the secret name by replacing problematic characters
170
+ secret_name = relative_path.gsub("/", "-").gsub(/[^\w\-\.]/, "_")
171
+
172
+ # Read and encode file content
173
+ content = File.binread(file_path)
174
+ encoded_content = Base64.strict_encode64(content)
175
+
176
+ # Create or update the secret using the fastlane path
177
+ create_or_update_secret(secret_name, encoded_content, "/fastlane")
178
+
179
+ UI.verbose(
180
+ "Uploaded: #{relative_path} -> #{secret_name} (path: /fastlane)"
181
+ )
182
+ end
183
+
184
+ UI.success("Uploaded #{files_to_upload.length} files to Infisical")
185
+ end
186
+ end
187
+
188
+ def delete_files(files_to_delete)
189
+ with_infisical_error_handling do
190
+ files_to_delete.each do |file_path|
191
+ # Calculate relative path - handle both absolute and relative paths
192
+ if file_path.start_with?("/")
193
+ # Absolute path - extract just the filename and directory structure
194
+ # Find the last directory that looks like a working directory
195
+ path_parts = file_path.split("/")
196
+ # Look for fastlane-like structure starting from the end
197
+ relevant_parts = []
198
+ path_parts.reverse.each do |part|
199
+ relevant_parts.unshift(part)
200
+ if part == "profiles" || part == "certs" ||
201
+ relevant_parts.length >= 3
202
+ break
203
+ end
204
+ end
205
+ relative_path = relevant_parts.join("/")
206
+ else
207
+ # Already a relative path
208
+ relative_path = file_path
209
+ end
210
+
211
+ # Convert the file path to the same flat secret name used during upload
212
+ # Sanitize the secret name by replacing problematic characters
213
+ secret_name = relative_path.gsub("/", "-").gsub(/[^\w\-\.]/, "_")
214
+
215
+ delete_secret(secret_name, "/fastlane")
216
+
217
+ UI.verbose("Deleted: #{relative_path} (#{secret_name})")
218
+ end
219
+ end
220
+ end
221
+
222
+ def download_files(files_to_download, download_directory)
223
+ with_infisical_error_handling do
224
+ # Ensure download directory exists
225
+ FileUtils.mkdir_p(download_directory)
226
+
227
+ files_to_download.each do |relative_path|
228
+ # Convert the relative path to a flat secret name
229
+ # Sanitize the secret name by replacing problematic characters
230
+ secret_name = relative_path.gsub("/", "-").gsub(/[^\w\-\.]/, "_")
231
+
232
+ begin
233
+ # Get the secret from Infisical
234
+ secret =
235
+ @client.secrets.get(
236
+ secret_name: secret_name,
237
+ project_id: @project_id,
238
+ environment: @environment,
239
+ path: "/fastlane"
240
+ )
241
+
242
+ # Decode and write the file
243
+ content = Base64.strict_decode64(secret["secretValue"])
244
+
245
+ full_path = File.join(download_directory, relative_path)
246
+
247
+ # Create directory if needed
248
+ FileUtils.mkdir_p(File.dirname(full_path))
249
+
250
+ # Write the file
251
+ File.binwrite(full_path, content)
252
+
253
+ UI.verbose("Downloaded: #{secret_name} -> #{relative_path}")
254
+ rescue InfisicalSDK::InfisicalError => e
255
+ UI.verbose("Secret not found: #{secret_name} (#{e.message})")
256
+ # Don't fail the entire operation for missing individual files
257
+ end
258
+ end
259
+
260
+ UI.success(
261
+ "Downloaded #{files_to_download.length} requested files from Infisical"
262
+ )
263
+ end
264
+ end
265
+
266
+ def human_readable_description
267
+ "Infisical Storage (Project: #{@project_id}, Environment: #{@environment}, Path: #{@secret_path})"
268
+ end
269
+
270
+ def skip_docs
271
+ false
272
+ end
273
+
274
+ def generate_matchfile_content(template: nil)
275
+ return ""
276
+ end
277
+
278
+ private
279
+
280
+ def authenticate_client
281
+ # Try Universal Auth first (preferred method)
282
+ client_id =
283
+ ENV["INFISICAL_CLIENT_ID"] ||
284
+ ENV["INFISICAL_UNIVERSAL_AUTH_CLIENT_ID"]
285
+ client_secret =
286
+ ENV["INFISICAL_CLIENT_SECRET"] ||
287
+ ENV["INFISICAL_UNIVERSAL_AUTH_CLIENT_SECRET"]
288
+
289
+ if client_id && client_secret
290
+ UI.verbose("🔐 Authenticating with Universal Auth...")
291
+ @client.auth.universal_auth(
292
+ client_id: client_id,
293
+ client_secret: client_secret
294
+ )
295
+ UI.success("✅ Universal Auth successful!")
296
+ return
297
+ end
298
+
299
+ # Fallback to service token (legacy)
300
+ service_token = ENV["INFISICAL_TOKEN"]
301
+ if service_token
302
+ UI.verbose("🔐 Using Service Token authentication...")
303
+ # Service tokens are used differently - they're passed as bearer tokens
304
+ # The SDK might handle this automatically, but we may need to set it manually
305
+ UI.important(
306
+ "⚠️ Service Token authentication with SDK - this may not work with E2EE enabled"
307
+ )
308
+ # Note: The SDK may not directly support service tokens in the same way
309
+ # This would need to be tested
310
+ return
311
+ end
312
+
313
+ UI.user_error!(
314
+ "No Infisical authentication method found. Please set either INFISICAL_CLIENT_ID + INFISICAL_CLIENT_SECRET or INFISICAL_TOKEN environment variables."
315
+ )
316
+ end
317
+
318
+ def list_fastlane_secrets
319
+ begin
320
+ # List all secrets in the /fastlane path
321
+ secrets =
322
+ @client.secrets.list(
323
+ project_id: @project_id,
324
+ environment: @environment,
325
+ path: "/fastlane"
326
+ )
327
+
328
+ UI.verbose("Found #{secrets.length} secrets in /fastlane")
329
+ secrets
330
+ rescue => e
331
+ UI.verbose("No secrets found in /fastlane: #{e.message}")
332
+ []
333
+ end
334
+ end
335
+
336
+ def list_all_secrets
337
+ list_fastlane_secrets
338
+ end
339
+
340
+ def create_or_update_secret(secret_name, value, path = nil)
341
+ begin
342
+ # Try to create first (simpler approach)
343
+ result =
344
+ if path && path != @secret_path
345
+ @client.secrets.create(
346
+ secret_name: secret_name,
347
+ secret_value: value,
348
+ project_id: @project_id,
349
+ environment: @environment,
350
+ path: path
351
+ )
352
+ else
353
+ @client.secrets.create(
354
+ secret_name: secret_name,
355
+ secret_value: value,
356
+ project_id: @project_id,
357
+ environment: @environment
358
+ )
359
+ end
360
+ UI.verbose(
361
+ "Created secret: #{secret_name} (path: #{path || "default"})"
362
+ )
363
+ result
364
+ rescue InfisicalSDK::InfisicalError => e
365
+ # If create fails, try to update
366
+ UI.verbose("Create failed: #{e.message}, trying update...")
367
+ begin
368
+ result =
369
+ if path && path != @secret_path
370
+ @client.secrets.update(
371
+ secret_name: secret_name,
372
+ secret_value: value,
373
+ project_id: @project_id,
374
+ environment: @environment,
375
+ path: path
376
+ )
377
+ else
378
+ @client.secrets.update(
379
+ secret_name: secret_name,
380
+ secret_value: value,
381
+ project_id: @project_id,
382
+ environment: @environment
383
+ )
384
+ end
385
+ UI.verbose(
386
+ "Updated secret: #{secret_name} (path: #{path || "default"})"
387
+ )
388
+ result
389
+ rescue InfisicalSDK::InfisicalError => update_error
390
+ UI.error(
391
+ "Failed to create/update secret #{secret_name}: #{e.message} (create), #{update_error.message} (update)"
392
+ )
393
+ raise update_error
394
+ end
395
+ rescue => e
396
+ UI.error("Unexpected error with secret #{secret_name}: #{e.message}")
397
+ raise e
398
+ end
399
+ end
400
+
401
+ def delete_secret(secret_name, path = nil)
402
+ if path && path != @secret_path
403
+ @client.secrets.delete(
404
+ secret_name: secret_name,
405
+ project_id: @project_id,
406
+ environment: @environment,
407
+ path: path
408
+ )
409
+ else
410
+ @client.secrets.delete(
411
+ secret_name: secret_name,
412
+ project_id: @project_id,
413
+ environment: @environment
414
+ )
415
+ end
416
+ end
417
+
418
+ def with_infisical_error_handling
419
+ explainer =
420
+ "Note: Infisical credentials should be set via environment variables. Set either INFISICAL_CLIENT_ID + INFISICAL_CLIENT_SECRET or INFISICAL_TOKEN."
421
+ yield
422
+ rescue StandardError => e
423
+ UI.error("Infisical error: #{e}.\n\n#{explainer}")
424
+ raise e
425
+ end
426
+ end
427
+ end
428
+ end