appydave-tools 0.77.1 → 0.77.2

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,112 +1,12 @@
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
12
  project_dir = project_directory_path
@@ -406,11 +306,11 @@ module Appydave
406
306
  return
407
307
  end
408
308
 
409
- puts "🗑️ Found #{files.size} file(s) in #{project_id}/s3-staging/"
309
+ puts "🗑️ Found #{files.size} file(s) in local s3-staging/"
410
310
  puts ''
411
311
 
412
312
  unless force
413
- puts '⚠️ This will DELETE all local files in s3-staging/ for this project.'
313
+ puts '⚠️ This will DELETE all files from s3-staging/ for this project.'
414
314
  puts 'Use --force to confirm deletion.'
415
315
  return
416
316
  end
@@ -430,13 +330,11 @@ module Appydave
430
330
  end
431
331
  end
432
332
 
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
333
+ # Remove empty directories
334
+ Dir.glob("#{staging_dir}/**/*").select { |d| File.directory?(d) }.sort.reverse.each do |dir|
335
+ Dir.rmdir(dir) if Dir.empty?(dir)
336
+ rescue StandardError
337
+ nil
440
338
  end
441
339
 
442
340
  puts ''
@@ -586,86 +484,7 @@ module Appydave
586
484
  { last_upload: last_upload, last_download: last_download }
587
485
  end
588
486
 
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
487
+ private
669
488
 
670
489
  # Upload file to S3
671
490
  def upload_file(local_file, s3_path, dry_run: false)
@@ -718,42 +537,6 @@ module Appydave
718
537
  false
719
538
  end
720
539
 
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
756
-
757
540
  def detect_content_type(filename)
758
541
  ext = File.extname(filename).downcase
759
542
  case ext
@@ -846,71 +629,6 @@ module Appydave
846
629
  false
847
630
  end
848
631
 
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
632
  # Copy project to SSD
915
633
  def copy_to_ssd(source_dir, dest_dir, dry_run: false)
916
634
  if Dir.exist?(dest_dir)
@@ -922,9 +640,9 @@ module Appydave
922
640
 
923
641
  size = calculate_directory_size(source_dir)
924
642
  puts '📋 Copy to SSD (excluding generated files):'
925
- puts " Source: #{source_dir}"
926
- puts " Dest: #{dest_dir}"
927
- puts " Size: #{file_size_human(size)}"
643
+ puts " From: #{source_dir}"
644
+ puts " To: #{dest_dir}"
645
+ puts " Size: #{file_size_human(size)}"
928
646
  puts ''
929
647
 
930
648
  if dry_run
@@ -969,23 +687,6 @@ module Appydave
969
687
  stats
970
688
  end
971
689
 
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
690
  # Delete local project directory
990
691
  def delete_local_project(project_dir, dry_run: false)
991
692
  size = calculate_directory_size(project_dir)
@@ -2,6 +2,6 @@
2
2
 
3
3
  module Appydave
4
4
  module Tools
5
- VERSION = '0.77.1'
5
+ VERSION = '0.77.2'
6
6
  end
7
7
  end
@@ -67,6 +67,7 @@ require 'appydave/tools/dam/brand_resolver'
67
67
  require 'appydave/tools/dam/config'
68
68
  require 'appydave/tools/dam/project_resolver'
69
69
  require 'appydave/tools/dam/config_loader'
70
+ require 'appydave/tools/dam/s3_base'
70
71
  require 'appydave/tools/dam/s3_operations'
71
72
  require 'appydave/tools/dam/s3_scanner'
72
73
  require 'appydave/tools/dam/share_operations'
data/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "appydave-tools",
3
- "version": "0.77.1",
3
+ "version": "0.77.2",
4
4
  "description": "AppyDave YouTube Automation Tools",
5
5
  "scripts": {
6
6
  "release": "semantic-release"
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: appydave-tools
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.77.1
4
+ version: 0.77.2
5
5
  platform: ruby
6
6
  authors:
7
7
  - David Cruwys
@@ -321,6 +321,8 @@ files:
321
321
  - docs/planning/micro-cleanup/AGENTS.md
322
322
  - docs/planning/micro-cleanup/IMPLEMENTATION_PLAN.md
323
323
  - docs/planning/micro-cleanup/assessment.md
324
+ - docs/planning/s3-operations-split/AGENTS.md
325
+ - docs/planning/s3-operations-split/IMPLEMENTATION_PLAN.md
324
326
  - docs/planning/test-coverage-gaps/AGENTS.md
325
327
  - docs/planning/test-coverage-gaps/IMPLEMENTATION_PLAN.md
326
328
  - docs/planning/test-coverage-gaps/assessment.md
@@ -372,6 +374,7 @@ files:
372
374
  - lib/appydave/tools/dam/repo_status.rb
373
375
  - lib/appydave/tools/dam/repo_sync.rb
374
376
  - lib/appydave/tools/dam/s3_arg_parser.rb
377
+ - lib/appydave/tools/dam/s3_base.rb
375
378
  - lib/appydave/tools/dam/s3_operations.rb
376
379
  - lib/appydave/tools/dam/s3_scan_command.rb
377
380
  - lib/appydave/tools/dam/s3_scanner.rb