appydave-tools 0.77.0 → 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.
@@ -0,0 +1,42 @@
1
+ # IMPLEMENTATION_PLAN.md — s3-operations-split
2
+
3
+ **Goal**: Split 1,021-line S3Operations into S3Base + 4 focused classes (S3Uploader, S3Downloader, S3StatusChecker, S3Archiver), with S3Operations becoming a thin delegation facade. Prepares for B007 parallelism.
4
+ **Started**: 2026-03-20
5
+ **Target**: 870 examples passing, rubocop 0, S3Operations ≤ 80 lines, each focused class standalone
6
+
7
+ ## Summary
8
+ - Total: 5 | Complete: 0 | In Progress: 0 | Pending: 5 | Failed: 0
9
+
10
+ ## Pending
11
+ - [ ] WU1-s3-base — Extract shared infrastructure into S3Base class; S3Operations inherits from it; all 870 tests pass with no public API change
12
+ - [ ] WU2-s3-uploader — Create S3Uploader < S3Base; move upload + helpers; S3Operations.upload delegates
13
+ - [ ] WU3-s3-downloader — Create S3Downloader < S3Base; move download + helpers; S3Operations.download delegates
14
+ - [ ] WU4-s3-status-checker — Create S3StatusChecker < S3Base; move status/calculate_sync_status/sync_timestamps + helpers; S3Operations delegates
15
+ - [ ] WU5-s3-archiver — Create S3Archiver < S3Base; move archive/cleanup/cleanup_local + helpers; S3Operations becomes thin facade; add s3_base require to lib/appydave/tools.rb
16
+
17
+ ## In Progress
18
+
19
+ ## Complete
20
+
21
+ ## Failed / Needs Retry
22
+
23
+ ## Notes & Decisions
24
+
25
+ ### Architecture Decision: Inheritance + Delegation
26
+ - S3Base: shared infrastructure + shared helpers (no public operation methods)
27
+ - S3Uploader/S3Downloader/S3StatusChecker/S3Archiver each inherit S3Base
28
+ - S3Operations inherits S3Base (so send(:build_s3_key) etc. still work in existing specs)
29
+ and delegates its public methods to the appropriate sub-class
30
+
31
+ ### Require Order in lib/appydave/tools.rb (add before existing s3_operations line)
32
+ ```ruby
33
+ require 'appydave/tools/dam/s3_base'
34
+ require 'appydave/tools/dam/s3_uploader'
35
+ require 'appydave/tools/dam/s3_downloader'
36
+ require 'appydave/tools/dam/s3_status_checker'
37
+ require 'appydave/tools/dam/s3_archiver'
38
+ require 'appydave/tools/dam/s3_operations' # thin facade — keep existing line
39
+ ```
40
+
41
+ ### Wave size: 1 (sequential only — each WU modifies both a new file AND s3_operations.rb)
42
+ ### kfix commit after each WU once tests + rubocop are green
@@ -0,0 +1,310 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'fileutils'
4
+ require 'json'
5
+ require 'digest'
6
+ require 'aws-sdk-s3'
7
+
8
+ module Appydave
9
+ module Tools
10
+ module Dam
11
+ # Shared infrastructure and helpers for S3 operations.
12
+ # All focused S3 operation classes inherit from this base.
13
+ class S3Base
14
+ attr_reader :brand_info, :brand, :project_id, :brand_path
15
+
16
+ # Directory patterns to exclude from archive/upload (generated/installable content)
17
+ EXCLUDE_PATTERNS = %w[
18
+ **/node_modules/**
19
+ **/.git/**
20
+ **/.next/**
21
+ **/dist/**
22
+ **/build/**
23
+ **/out/**
24
+ **/.cache/**
25
+ **/coverage/**
26
+ **/.turbo/**
27
+ **/.vercel/**
28
+ **/tmp/**
29
+ **/.DS_Store
30
+ **/*:Zone.Identifier
31
+ ].freeze
32
+
33
+ def initialize(brand, project_id, brand_info: nil, brand_path: nil, s3_client: nil)
34
+ @project_id = project_id
35
+
36
+ # Use injected dependencies or load from configuration
37
+ @brand_info = brand_info || load_brand_info(brand)
38
+ @brand = @brand_info.key # Use resolved brand key, not original input
39
+ @brand_path = brand_path || Config.brand_path(@brand)
40
+ @s3_client_override = s3_client # Store override but don't create client yet (lazy loading)
41
+ end
42
+
43
+ # Lazy-load S3 client (only create when actually needed, not for dry-run)
44
+ def s3_client
45
+ @s3_client ||= @s3_client_override || create_s3_client(@brand_info)
46
+ end
47
+
48
+ # Build S3 key for a file
49
+ def build_s3_key(relative_path)
50
+ "#{brand_info.aws.s3_prefix}#{project_id}/#{relative_path}"
51
+ end
52
+
53
+ # Extract relative path from S3 key
54
+ def extract_relative_path(s3_key)
55
+ s3_key.sub("#{brand_info.aws.s3_prefix}#{project_id}/", '')
56
+ end
57
+
58
+ private
59
+
60
+ def load_brand_info(brand)
61
+ Appydave::Tools::Configuration::Config.configure
62
+ Appydave::Tools::Configuration::Config.brands.get_brand(brand)
63
+ end
64
+
65
+ # Build project directory path respecting brand's projects_subfolder setting
66
+ def project_directory_path
67
+ if brand_info.settings.projects_subfolder && !brand_info.settings.projects_subfolder.empty?
68
+ File.join(brand_path, brand_info.settings.projects_subfolder, project_id)
69
+ else
70
+ File.join(brand_path, project_id)
71
+ end
72
+ end
73
+
74
+ # Determine which AWS profile to use based on current user
75
+ # Priority: current user's default_aws_profile > brand's aws.profile
76
+ def determine_aws_profile(brand_info)
77
+ # Get current user from settings (if available)
78
+ begin
79
+ current_user_key = Appydave::Tools::Configuration::Config.settings.current_user
80
+
81
+ if current_user_key
82
+ # Look up current user's default AWS profile
83
+ users = Appydave::Tools::Configuration::Config.brands.data['users']
84
+ user_info = users[current_user_key]
85
+
86
+ return user_info['default_aws_profile'] if user_info && user_info['default_aws_profile']
87
+ end
88
+ rescue Appydave::Tools::Error
89
+ # Config not available (e.g., in test environment) - fall through to brand profile
90
+ end
91
+
92
+ # Fallback to brand's AWS profile
93
+ brand_info.aws.profile
94
+ end
95
+
96
+ def create_s3_client(brand_info)
97
+ profile_name = determine_aws_profile(brand_info)
98
+ raise "AWS profile not configured for current user or brand '#{brand}'" if profile_name.nil? || profile_name.empty?
99
+
100
+ credentials = Aws::SharedCredentials.new(profile_name: profile_name)
101
+
102
+ # Configure SSL certificate handling
103
+ ssl_options = configure_ssl_options
104
+
105
+ Aws::S3::Client.new(
106
+ credentials: credentials,
107
+ region: brand_info.aws.region,
108
+ http_wire_trace: false,
109
+ **ssl_options
110
+ )
111
+ end
112
+
113
+ def configure_ssl_options
114
+ return { ssl_verify_peer: false } if ENV['AWS_SDK_RUBY_SKIP_SSL_VERIFICATION'] == 'true'
115
+
116
+ {}
117
+ end
118
+
119
+ # Calculate MD5 hash of a file
120
+ def file_md5(file_path)
121
+ # Use chunked reading for large files to avoid "Invalid argument @ io_fread" errors
122
+ puts " 🔍 Calculating MD5 for #{File.basename(file_path)}..." if ENV['DEBUG']
123
+ md5 = Digest::MD5.new
124
+ File.open(file_path, 'rb') do |file|
125
+ while (chunk = file.read(8192))
126
+ md5.update(chunk)
127
+ end
128
+ end
129
+ result = md5.hexdigest
130
+ puts " ✓ MD5: #{result[0..7]}..." if ENV['DEBUG']
131
+ result
132
+ rescue StandardError => e
133
+ puts " ⚠️ Warning: Failed to calculate MD5 for #{File.basename(file_path)}: #{e.message}"
134
+ puts ' → Will upload without MD5 comparison'
135
+ nil
136
+ end
137
+
138
+ # Get MD5 of file in S3 (from ETag)
139
+ def s3_file_md5(s3_path)
140
+ response = s3_client.head_object(
141
+ bucket: brand_info.aws.s3_bucket,
142
+ key: s3_path
143
+ )
144
+ response.etag.gsub('"', '')
145
+ rescue Aws::S3::Errors::NotFound, Aws::S3::Errors::ServiceError
146
+ nil
147
+ end
148
+
149
+ # Check if an S3 ETag is from a multipart upload
150
+ # Multipart ETags have format: "hash-partcount" (e.g., "d41d8cd98f00b204e9800998ecf8427e-5")
151
+ def multipart_etag?(etag)
152
+ return false if etag.nil?
153
+
154
+ etag.include?('-')
155
+ end
156
+
157
+ # Compare local file with S3 file, handling multipart ETags
158
+ # Returns: :synced, :modified, or :unknown
159
+ # For multipart uploads, falls back to size comparison since MD5 won't match
160
+ def compare_files(local_file:, s3_etag:, s3_size:)
161
+ return :unknown unless File.exist?(local_file)
162
+ return :unknown if s3_etag.nil?
163
+
164
+ local_size = File.size(local_file)
165
+
166
+ if multipart_etag?(s3_etag)
167
+ # Multipart upload - MD5 comparison won't work, use size
168
+ # Size match is a reasonable proxy for "unchanged" in this context
169
+ local_size == s3_size ? :synced : :modified
170
+ else
171
+ # Standard upload - use MD5 comparison
172
+ local_md5 = file_md5(local_file)
173
+ return :unknown if local_md5.nil?
174
+
175
+ local_md5 == s3_etag ? :synced : :modified
176
+ end
177
+ end
178
+
179
+ # Get S3 file size from path (for upload comparison)
180
+ def s3_file_size(s3_path)
181
+ response = s3_client.head_object(
182
+ bucket: brand_info.aws.s3_bucket,
183
+ key: s3_path
184
+ )
185
+ response.content_length
186
+ rescue Aws::S3::Errors::NotFound, Aws::S3::Errors::ServiceError
187
+ nil
188
+ end
189
+
190
+ def format_duration(seconds)
191
+ if seconds < 60
192
+ "#{seconds.round(1)}s"
193
+ elsif seconds < 3600
194
+ minutes = (seconds / 60).floor
195
+ secs = (seconds % 60).round
196
+ "#{minutes}m #{secs}s"
197
+ else
198
+ hours = (seconds / 3600).floor
199
+ minutes = ((seconds % 3600) / 60).floor
200
+ "#{hours}h #{minutes}m"
201
+ end
202
+ end
203
+
204
+ def format_time_ago(seconds)
205
+ return 'just now' if seconds < 60
206
+
207
+ minutes = seconds / 60
208
+ return "#{minutes.round} minute#{'s' if minutes > 1}" if minutes < 60
209
+
210
+ hours = minutes / 60
211
+ return "#{hours.round} hour#{'s' if hours > 1}" if hours < 24
212
+
213
+ days = hours / 24
214
+ return "#{days.round} day#{'s' if days > 1}" if days < 7
215
+
216
+ weeks = days / 7
217
+ return "#{weeks.round} week#{'s' if weeks > 1}" if weeks < 4
218
+
219
+ months = days / 30
220
+ return "#{months.round} month#{'s' if months > 1}" if months < 12
221
+
222
+ years = days / 365
223
+ "#{years.round} year#{'s' if years > 1}"
224
+ end
225
+
226
+ # List files in S3 for a project
227
+ def list_s3_files
228
+ prefix = build_s3_key('')
229
+
230
+ response = s3_client.list_objects_v2(
231
+ bucket: brand_info.aws.s3_bucket,
232
+ prefix: prefix
233
+ )
234
+
235
+ return [] unless response.contents
236
+
237
+ response.contents.map do |obj|
238
+ {
239
+ 'Key' => obj.key,
240
+ 'Size' => obj.size,
241
+ 'ETag' => obj.etag,
242
+ 'LastModified' => obj.last_modified
243
+ }
244
+ end
245
+ rescue Aws::S3::Errors::ServiceError
246
+ []
247
+ end
248
+
249
+ # Get full S3 file info including timestamp
250
+ def get_s3_file_info(s3_key)
251
+ response = s3_client.head_object(
252
+ bucket: brand_info.aws.s3_bucket,
253
+ key: s3_key
254
+ )
255
+
256
+ {
257
+ 'Key' => s3_key,
258
+ 'Size' => response.content_length,
259
+ 'ETag' => response.etag,
260
+ 'LastModified' => response.last_modified
261
+ }
262
+ rescue Aws::S3::Errors::NotFound, Aws::S3::Errors::ServiceError
263
+ nil
264
+ end
265
+
266
+ # List local files in staging directory
267
+ def list_local_files(staging_dir)
268
+ return {} unless Dir.exist?(staging_dir)
269
+
270
+ files = Dir.glob("#{staging_dir}/**/*").select { |f| File.file?(f) }
271
+
272
+ files.each_with_object({}) do |file, hash|
273
+ relative_path = file.sub("#{staging_dir}/", '')
274
+ hash[relative_path] = file
275
+ end
276
+ end
277
+
278
+ # Human-readable file size
279
+ def file_size_human(bytes)
280
+ if bytes < 1024
281
+ "#{bytes} B"
282
+ elsif bytes < 1024 * 1024
283
+ "#{(bytes / 1024.0).round(1)} KB"
284
+ elsif bytes < 1024 * 1024 * 1024
285
+ "#{(bytes / (1024.0 * 1024)).round(1)} MB"
286
+ else
287
+ "#{(bytes / (1024.0 * 1024 * 1024)).round(2)} GB"
288
+ end
289
+ end
290
+
291
+ # Check if path should be excluded (generated/installable content)
292
+ def excluded_path?(relative_path)
293
+ EXCLUDE_PATTERNS.any? do |pattern|
294
+ # Extract directory/file name from pattern (remove **)
295
+ excluded_name = pattern.gsub('**/', '').chomp('/**')
296
+ path_segments = relative_path.split('/')
297
+
298
+ if excluded_name.include?('*')
299
+ # Pattern with wildcards - use fnmatch on filename
300
+ File.fnmatch(excluded_name, File.basename(relative_path))
301
+ else
302
+ # Check if any path segment matches the excluded name
303
+ path_segments.include?(excluded_name)
304
+ end
305
+ end
306
+ end
307
+ end
308
+ end
309
+ end
310
+ end