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.
- checksums.yaml +4 -4
- data/CHANGELOG.md +7 -0
- data/docs/planning/BACKLOG.md +9 -6
- data/docs/planning/batch-a-features/IMPLEMENTATION_PLAN.md +5 -5
- data/docs/planning/s3-operations-split/AGENTS.md +686 -0
- data/docs/planning/s3-operations-split/IMPLEMENTATION_PLAN.md +42 -0
- data/lib/appydave/tools/dam/s3_base.rb +310 -0
- data/lib/appydave/tools/dam/s3_operations.rb +15 -314
- data/lib/appydave/tools/version.rb +1 -1
- data/lib/appydave/tools.rb +1 -0
- data/package.json +1 -1
- metadata +4 -1
|
@@ -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
|
-
|
|
13
|
-
|
|
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
|
|
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
|
|
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
|
-
#
|
|
434
|
-
|
|
435
|
-
Dir.
|
|
436
|
-
|
|
437
|
-
|
|
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
|
-
|
|
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 "
|
|
926
|
-
puts "
|
|
927
|
-
puts " 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)
|
data/lib/appydave/tools.rb
CHANGED
|
@@ -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
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.
|
|
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
|