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.
- checksums.yaml +4 -4
- data/CHANGELOG.md +14 -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 +18 -468
- data/lib/appydave/tools/dam/s3_uploader.rb +171 -0
- data/lib/appydave/tools/version.rb +1 -1
- data/lib/appydave/tools.rb +2 -0
- data/package.json +1 -1
- metadata +5 -1
|
@@ -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
|
-
|
|
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
|
-
|
|
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
|
|
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
|
|
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
|
-
#
|
|
434
|
-
|
|
435
|
-
Dir.
|
|
436
|
-
|
|
437
|
-
|
|
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
|
-
|
|
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
|
|
758
|
-
|
|
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 "
|
|
926
|
-
puts "
|
|
927
|
-
puts " 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)
|