appydave-tools 0.77.1 → 0.77.3

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.
@@ -1,187 +1,15 @@
1
1
  # frozen_string_literal: true
2
2
 
3
- require 'fileutils'
4
- require 'json'
5
- require 'digest'
6
- require 'aws-sdk-s3'
7
-
8
3
  module Appydave
9
4
  module Tools
10
5
  module Dam
11
- # S3 operations for VAT (upload, download, status, cleanup)
12
- class S3Operations
13
- attr_reader :brand_info, :brand, :project_id, :brand_path
14
-
15
- # Directory patterns to exclude from archive/upload (generated/installable content)
16
- EXCLUDE_PATTERNS = %w[
17
- **/node_modules/**
18
- **/.git/**
19
- **/.next/**
20
- **/dist/**
21
- **/build/**
22
- **/out/**
23
- **/.cache/**
24
- **/coverage/**
25
- **/.turbo/**
26
- **/.vercel/**
27
- **/tmp/**
28
- **/.DS_Store
29
- **/*:Zone.Identifier
30
- ].freeze
31
-
32
- def initialize(brand, project_id, brand_info: nil, brand_path: nil, s3_client: nil)
33
- @project_id = project_id
34
-
35
- # Use injected dependencies or load from configuration
36
- @brand_info = brand_info || load_brand_info(brand)
37
- @brand = @brand_info.key # Use resolved brand key, not original input
38
- @brand_path = brand_path || Config.brand_path(@brand)
39
- @s3_client_override = s3_client # Store override but don't create client yet (lazy loading)
40
- end
41
-
42
- # Lazy-load S3 client (only create when actually needed, not for dry-run)
43
- def s3_client
44
- @s3_client ||= @s3_client_override || create_s3_client(@brand_info)
45
- end
46
-
47
- private
48
-
49
- def load_brand_info(brand)
50
- Appydave::Tools::Configuration::Config.configure
51
- Appydave::Tools::Configuration::Config.brands.get_brand(brand)
52
- end
53
-
54
- # Build project directory path respecting brand's projects_subfolder setting
55
- def project_directory_path
56
- if brand_info.settings.projects_subfolder && !brand_info.settings.projects_subfolder.empty?
57
- File.join(brand_path, brand_info.settings.projects_subfolder, project_id)
58
- else
59
- File.join(brand_path, project_id)
60
- end
61
- end
62
-
63
- # Determine which AWS profile to use based on current user
64
- # Priority: current user's default_aws_profile > brand's aws.profile
65
- def determine_aws_profile(brand_info)
66
- # Get current user from settings (if available)
67
- begin
68
- current_user_key = Appydave::Tools::Configuration::Config.settings.current_user
69
-
70
- if current_user_key
71
- # Look up current user's default AWS profile
72
- users = Appydave::Tools::Configuration::Config.brands.data['users']
73
- user_info = users[current_user_key]
74
-
75
- return user_info['default_aws_profile'] if user_info && user_info['default_aws_profile']
76
- end
77
- rescue Appydave::Tools::Error
78
- # Config not available (e.g., in test environment) - fall through to brand profile
79
- end
80
-
81
- # Fallback to brand's AWS profile
82
- brand_info.aws.profile
83
- end
84
-
85
- def create_s3_client(brand_info)
86
- profile_name = determine_aws_profile(brand_info)
87
- raise "AWS profile not configured for current user or brand '#{brand}'" if profile_name.nil? || profile_name.empty?
88
-
89
- credentials = Aws::SharedCredentials.new(profile_name: profile_name)
90
-
91
- # Configure SSL certificate handling
92
- ssl_options = configure_ssl_options
93
-
94
- Aws::S3::Client.new(
95
- credentials: credentials,
96
- region: brand_info.aws.region,
97
- http_wire_trace: false,
98
- **ssl_options
99
- )
100
- end
101
-
102
- def configure_ssl_options
103
- return { ssl_verify_peer: false } if ENV['AWS_SDK_RUBY_SKIP_SSL_VERIFICATION'] == 'true'
104
-
105
- {}
106
- end
107
-
108
- public
109
-
6
+ # S3 operations for VAT (upload, download, status, cleanup).
7
+ # Inherits shared infrastructure and helpers from S3Base.
8
+ # Will become a thin delegation facade as focused classes are extracted (B020).
9
+ class S3Operations < S3Base
110
10
  # Upload files from s3-staging/ to S3
111
11
  def upload(dry_run: false)
112
- project_dir = project_directory_path
113
- staging_dir = File.join(project_dir, 's3-staging')
114
-
115
- unless Dir.exist?(staging_dir)
116
- puts "❌ No s3-staging directory found: #{staging_dir}"
117
- puts 'Nothing to upload.'
118
- return
119
- end
120
-
121
- files = Dir.glob("#{staging_dir}/**/*").select { |f| File.file?(f) }
122
-
123
- if files.empty?
124
- puts '❌ No files found in s3-staging/'
125
- return
126
- end
127
-
128
- puts "📦 Uploading #{files.size} file(s) from #{project_id}/s3-staging/ to S3..."
129
- puts ''
130
-
131
- uploaded = 0
132
- skipped = 0
133
- failed = 0
134
-
135
- # rubocop:disable Metrics/BlockLength
136
- files.each do |file|
137
- relative_path = file.sub("#{staging_dir}/", '')
138
-
139
- # Skip excluded files (e.g., Windows Zone.Identifier, .DS_Store)
140
- if excluded_path?(relative_path)
141
- skipped += 1
142
- next
143
- end
144
-
145
- s3_path = build_s3_key(relative_path)
146
-
147
- # Check if file already exists in S3 and compare
148
- s3_info = get_s3_file_info(s3_path)
149
-
150
- if s3_info
151
- s3_etag = s3_info['ETag'].gsub('"', '')
152
- s3_size = s3_info['Size']
153
- match_status = compare_files(local_file: file, s3_etag: s3_etag, s3_size: s3_size)
154
-
155
- if match_status == :synced
156
- comparison_method = multipart_etag?(s3_etag) ? 'size match' : 'unchanged'
157
- puts " ⏭️ Skipped: #{relative_path} (#{comparison_method})"
158
- skipped += 1
159
- next
160
- end
161
-
162
- # File exists but content differs - warn before overwriting
163
- puts " ⚠️ Warning: #{relative_path} exists in S3 with different content"
164
- puts ' (multipart upload detected - comparing by size)' if multipart_etag?(s3_etag)
165
-
166
- s3_time = s3_info['LastModified']
167
- local_time = File.mtime(file)
168
- puts " S3: #{s3_time.strftime('%Y-%m-%d %H:%M')} | Local: #{local_time.strftime('%Y-%m-%d %H:%M')}"
169
-
170
- puts ' ⚠️ S3 file is NEWER than local - you may be overwriting recent changes!' if s3_time > local_time
171
- puts ' Uploading will overwrite S3 version...'
172
- end
173
-
174
- if upload_file(file, s3_path, dry_run: dry_run)
175
- uploaded += 1
176
- else
177
- failed += 1
178
- end
179
- end
180
- # rubocop:enable Metrics/BlockLength
181
-
182
- puts ''
183
- puts '✅ Upload complete!'
184
- puts " Uploaded: #{uploaded}, Skipped: #{skipped}, Failed: #{failed}"
12
+ S3Uploader.new(brand, project_id, **delegated_opts).upload(dry_run: dry_run)
185
13
  end
186
14
 
187
15
  # Download files from S3 to s3-staging/
@@ -406,11 +234,11 @@ module Appydave
406
234
  return
407
235
  end
408
236
 
409
- puts "🗑️ Found #{files.size} file(s) in #{project_id}/s3-staging/"
237
+ puts "🗑️ Found #{files.size} file(s) in local s3-staging/"
410
238
  puts ''
411
239
 
412
240
  unless force
413
- puts '⚠️ This will DELETE all local files in s3-staging/ for this project.'
241
+ puts '⚠️ This will DELETE all files from s3-staging/ for this project.'
414
242
  puts 'Use --force to confirm deletion.'
415
243
  return
416
244
  end
@@ -430,13 +258,11 @@ module Appydave
430
258
  end
431
259
  end
432
260
 
433
- # Clean up empty directories
434
- unless dry_run
435
- Dir.glob("#{staging_dir}/**/").reverse_each do |dir|
436
- Dir.rmdir(dir) if Dir.empty?(dir)
437
- rescue SystemCallError
438
- # Directory not empty, skip
439
- end
261
+ # Remove empty directories
262
+ Dir.glob("#{staging_dir}/**/*").select { |d| File.directory?(d) }.sort.reverse.each do |dir|
263
+ Dir.rmdir(dir) if Dir.empty?(dir)
264
+ rescue StandardError
265
+ nil
440
266
  end
441
267
 
442
268
  puts ''
@@ -586,204 +412,10 @@ module Appydave
586
412
  { last_upload: last_upload, last_download: last_download }
587
413
  end
588
414
 
589
- # Build S3 key for a file
590
- def build_s3_key(relative_path)
591
- "#{brand_info.aws.s3_prefix}#{project_id}/#{relative_path}"
592
- end
593
-
594
- # Extract relative path from S3 key
595
- def extract_relative_path(s3_key)
596
- s3_key.sub("#{brand_info.aws.s3_prefix}#{project_id}/", '')
597
- end
598
-
599
- # Calculate MD5 hash of a file
600
- def file_md5(file_path)
601
- # Use chunked reading for large files to avoid "Invalid argument @ io_fread" errors
602
- puts " 🔍 Calculating MD5 for #{File.basename(file_path)}..." if ENV['DEBUG']
603
- md5 = Digest::MD5.new
604
- File.open(file_path, 'rb') do |file|
605
- while (chunk = file.read(8192))
606
- md5.update(chunk)
607
- end
608
- end
609
- result = md5.hexdigest
610
- puts " ✓ MD5: #{result[0..7]}..." if ENV['DEBUG']
611
- result
612
- rescue StandardError => e
613
- puts " ⚠️ Warning: Failed to calculate MD5 for #{File.basename(file_path)}: #{e.message}"
614
- puts ' → Will upload without MD5 comparison'
615
- nil
616
- end
617
-
618
- # Get MD5 of file in S3 (from ETag)
619
- def s3_file_md5(s3_path)
620
- response = s3_client.head_object(
621
- bucket: brand_info.aws.s3_bucket,
622
- key: s3_path
623
- )
624
- response.etag.gsub('"', '')
625
- rescue Aws::S3::Errors::NotFound, Aws::S3::Errors::ServiceError
626
- nil
627
- end
628
-
629
- # Check if an S3 ETag is from a multipart upload
630
- # Multipart ETags have format: "hash-partcount" (e.g., "d41d8cd98f00b204e9800998ecf8427e-5")
631
- def multipart_etag?(etag)
632
- return false if etag.nil?
633
-
634
- etag.include?('-')
635
- end
636
-
637
- # Compare local file with S3 file, handling multipart ETags
638
- # Returns: :synced, :modified, or :unknown
639
- # For multipart uploads, falls back to size comparison since MD5 won't match
640
- def compare_files(local_file:, s3_etag:, s3_size:)
641
- return :unknown unless File.exist?(local_file)
642
- return :unknown if s3_etag.nil?
643
-
644
- local_size = File.size(local_file)
645
-
646
- if multipart_etag?(s3_etag)
647
- # Multipart upload - MD5 comparison won't work, use size
648
- # Size match is a reasonable proxy for "unchanged" in this context
649
- local_size == s3_size ? :synced : :modified
650
- else
651
- # Standard upload - use MD5 comparison
652
- local_md5 = file_md5(local_file)
653
- return :unknown if local_md5.nil?
654
-
655
- local_md5 == s3_etag ? :synced : :modified
656
- end
657
- end
658
-
659
- # Get S3 file size from path (for upload comparison)
660
- def s3_file_size(s3_path)
661
- response = s3_client.head_object(
662
- bucket: brand_info.aws.s3_bucket,
663
- key: s3_path
664
- )
665
- response.content_length
666
- rescue Aws::S3::Errors::NotFound, Aws::S3::Errors::ServiceError
667
- nil
668
- end
669
-
670
- # Upload file to S3
671
- def upload_file(local_file, s3_path, dry_run: false)
672
- if dry_run
673
- puts " [DRY-RUN] Would upload: #{local_file} → s3://#{brand_info.aws.s3_bucket}/#{s3_path}"
674
- return true
675
- end
676
-
677
- # Detect MIME type for proper browser handling
678
- content_type = detect_content_type(local_file)
679
-
680
- # For large files, use TransferManager for managed uploads (supports multipart)
681
- file_size = File.size(local_file)
682
- start_time = Time.now
683
-
684
- if file_size > 100 * 1024 * 1024 # > 100MB
685
- puts " 📤 Uploading large file (#{file_size_human(file_size)})..."
686
-
687
- # Use TransferManager for multipart upload (modern AWS SDK approach)
688
- transfer_manager = Aws::S3::TransferManager.new(client: s3_client)
689
- transfer_manager.upload_file(
690
- local_file,
691
- bucket: brand_info.aws.s3_bucket,
692
- key: s3_path,
693
- content_type: content_type
694
- )
695
- else
696
- # For smaller files, use direct put_object
697
- File.open(local_file, 'rb') do |file|
698
- s3_client.put_object(
699
- bucket: brand_info.aws.s3_bucket,
700
- key: s3_path,
701
- body: file,
702
- content_type: content_type
703
- )
704
- end
705
- end
706
-
707
- elapsed = Time.now - start_time
708
- elapsed_str = format_duration(elapsed)
709
- puts " ✓ Uploaded: #{File.basename(local_file)} (#{file_size_human(file_size)}) in #{elapsed_str}"
710
- true
711
- rescue Aws::S3::Errors::ServiceError => e
712
- puts " ✗ Failed: #{File.basename(local_file)}"
713
- puts " Error: #{e.message}"
714
- false
715
- rescue StandardError => e
716
- puts " ✗ Failed: #{File.basename(local_file)}"
717
- puts " Error: #{e.class} - #{e.message}"
718
- false
719
- end
720
-
721
- def format_duration(seconds)
722
- if seconds < 60
723
- "#{seconds.round(1)}s"
724
- elsif seconds < 3600
725
- minutes = (seconds / 60).floor
726
- secs = (seconds % 60).round
727
- "#{minutes}m #{secs}s"
728
- else
729
- hours = (seconds / 3600).floor
730
- minutes = ((seconds % 3600) / 60).floor
731
- "#{hours}h #{minutes}m"
732
- end
733
- end
734
-
735
- def format_time_ago(seconds)
736
- return 'just now' if seconds < 60
737
-
738
- minutes = seconds / 60
739
- return "#{minutes.round} minute#{'s' if minutes > 1}" if minutes < 60
740
-
741
- hours = minutes / 60
742
- return "#{hours.round} hour#{'s' if hours > 1}" if hours < 24
743
-
744
- days = hours / 24
745
- return "#{days.round} day#{'s' if days > 1}" if days < 7
746
-
747
- weeks = days / 7
748
- return "#{weeks.round} week#{'s' if weeks > 1}" if weeks < 4
749
-
750
- months = days / 30
751
- return "#{months.round} month#{'s' if months > 1}" if months < 12
752
-
753
- years = days / 365
754
- "#{years.round} year#{'s' if years > 1}"
755
- end
415
+ private
756
416
 
757
- def detect_content_type(filename)
758
- ext = File.extname(filename).downcase
759
- case ext
760
- when '.mp4'
761
- 'video/mp4'
762
- when '.mov'
763
- 'video/quicktime'
764
- when '.avi'
765
- 'video/x-msvideo'
766
- when '.mkv'
767
- 'video/x-matroska'
768
- when '.webm'
769
- 'video/webm'
770
- when '.m4v'
771
- 'video/x-m4v'
772
- when '.jpg', '.jpeg'
773
- 'image/jpeg'
774
- when '.png'
775
- 'image/png'
776
- when '.gif'
777
- 'image/gif'
778
- when '.pdf'
779
- 'application/pdf'
780
- when '.json'
781
- 'application/json'
782
- when '.srt', '.vtt', '.txt', '.md'
783
- 'text/plain'
784
- else
785
- 'application/octet-stream'
786
- end
417
+ def delegated_opts
418
+ { brand_info: brand_info, brand_path: brand_path, s3_client: @s3_client_override }
787
419
  end
788
420
 
789
421
  # Download file from S3
@@ -846,71 +478,6 @@ module Appydave
846
478
  false
847
479
  end
848
480
 
849
- # List files in S3 for a project
850
- def list_s3_files
851
- prefix = build_s3_key('')
852
-
853
- response = s3_client.list_objects_v2(
854
- bucket: brand_info.aws.s3_bucket,
855
- prefix: prefix
856
- )
857
-
858
- return [] unless response.contents
859
-
860
- response.contents.map do |obj|
861
- {
862
- 'Key' => obj.key,
863
- 'Size' => obj.size,
864
- 'ETag' => obj.etag,
865
- 'LastModified' => obj.last_modified
866
- }
867
- end
868
- rescue Aws::S3::Errors::ServiceError
869
- []
870
- end
871
-
872
- # Get full S3 file info including timestamp
873
- def get_s3_file_info(s3_key)
874
- response = s3_client.head_object(
875
- bucket: brand_info.aws.s3_bucket,
876
- key: s3_key
877
- )
878
-
879
- {
880
- 'Key' => s3_key,
881
- 'Size' => response.content_length,
882
- 'ETag' => response.etag,
883
- 'LastModified' => response.last_modified
884
- }
885
- rescue Aws::S3::Errors::NotFound, Aws::S3::Errors::ServiceError
886
- nil
887
- end
888
-
889
- # List local files in staging directory
890
- def list_local_files(staging_dir)
891
- return {} unless Dir.exist?(staging_dir)
892
-
893
- files = Dir.glob("#{staging_dir}/**/*").select { |f| File.file?(f) }
894
-
895
- files.each_with_object({}) do |file, hash|
896
- relative_path = file.sub("#{staging_dir}/", '')
897
- hash[relative_path] = file
898
- end
899
- end
900
-
901
- # Human-readable file size
902
- def file_size_human(bytes)
903
- if bytes < 1024
904
- "#{bytes} B"
905
- elsif bytes < 1024 * 1024
906
- "#{(bytes / 1024.0).round(1)} KB"
907
- elsif bytes < 1024 * 1024 * 1024
908
- "#{(bytes / (1024.0 * 1024)).round(1)} MB"
909
- else
910
- "#{(bytes / (1024.0 * 1024 * 1024)).round(2)} GB"
911
- end
912
- end
913
-
914
481
  # Copy project to SSD
915
482
  def copy_to_ssd(source_dir, dest_dir, dry_run: false)
916
483
  if Dir.exist?(dest_dir)
@@ -922,9 +489,9 @@ module Appydave
922
489
 
923
490
  size = calculate_directory_size(source_dir)
924
491
  puts '📋 Copy to SSD (excluding generated files):'
925
- puts " Source: #{source_dir}"
926
- puts " Dest: #{dest_dir}"
927
- puts " Size: #{file_size_human(size)}"
492
+ puts " From: #{source_dir}"
493
+ puts " To: #{dest_dir}"
494
+ puts " Size: #{file_size_human(size)}"
928
495
  puts ''
929
496
 
930
497
  if dry_run
@@ -969,23 +536,6 @@ module Appydave
969
536
  stats
970
537
  end
971
538
 
972
- # Check if path should be excluded (generated/installable content)
973
- def excluded_path?(relative_path)
974
- EXCLUDE_PATTERNS.any? do |pattern|
975
- # Extract directory/file name from pattern (remove **)
976
- excluded_name = pattern.gsub('**/', '').chomp('/**')
977
- path_segments = relative_path.split('/')
978
-
979
- if excluded_name.include?('*')
980
- # Pattern with wildcards - use fnmatch on filename
981
- File.fnmatch(excluded_name, File.basename(relative_path))
982
- else
983
- # Check if any path segment matches the excluded name
984
- path_segments.include?(excluded_name)
985
- end
986
- end
987
- end
988
-
989
539
  # Delete local project directory
990
540
  def delete_local_project(project_dir, dry_run: false)
991
541
  size = calculate_directory_size(project_dir)