appydave-tools 0.20.0 → 0.21.0

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,6 +1,6 @@
1
1
  # CODEX Recommendations - Review & Status
2
2
 
3
- > Last updated: 2025-11-10 08:12:59 UTC
3
+ > Last updated: 2025-11-10 11:01:36 UTC
4
4
  > Original recommendations provided by Codex (GPT-5) on 2025-11-09
5
5
 
6
6
  This document captures Codex's architectural recommendations with implementation status and verdicts after engineering review.
@@ -148,16 +148,20 @@ end
148
148
 
149
149
  ### 🔍 DAM Manifest & Sync Addendum (2025-11-10)
150
150
 
151
- New DAM code mirrors the VAT manifest/sync flow but reintroduces several bugs plus new inconsistencies:
151
+ Phase 1 added S3/git metadata, but several inconsistencies remain between the manifest generator and the SSD sync tooling:
152
152
 
153
- - **Archived projects still missing:** `collect_project_ids` explicitly skips the `archived` directory (`lib/appydave/tools/dam/manifest_generator.rb:70-99`), so the later logic that probes `brand_path/archived/<range>/<project>` never runs; manifests omit the majority of historical work. This also means `SyncFromSsd.should_sync_project?` (`lib/appydave/tools/dam/sync_from_ssd.rb:77-96`) will think everything is already local because manifests never flag archived presence.
154
- - **Range math diverges between components:** Manifest uses `project_id =~ /^[a-z](\d+)/` to build ranges (`lib/appydave/tools/dam/manifest_generator.rb:214-224`), but the SSD sync hard-codes `/^b(\d+)/` (`lib/appydave/tools/dam/sync_from_ssd.rb:123-138`). Projects outside the `b` prefix (aitldr, voz, etc.) will all collapse into the fallback `000-099`, creating collisions.
155
- - **SSD paths lose grouping info:** Manifests record `path: project_id` for SSD entries (`lib/appydave/tools/dam/manifest_generator.rb:119-126`), ignoring the range folders that exist on disk. The sync tool then assumes `ssd/<project_id>` (line 98) and will fail whenever the SSD organizes projects under range subdirectories.
156
- - **Disk usage ignores archived location:** Even when a project only exists under `archived/<range>`, `calculate_disk_usage` points at `File.join(brand_path, project[:id])` (`lib/appydave/tools/dam/manifest_generator.rb:131-146`), so archived-only projects report 0 bytes. Need to reuse the resolved `local_path` (flat vs archived) instead of rebuilding the path blindly.
157
- - **Heavy file detection still shallow:** `heavy_files?` only inspects direct children (`lib/appydave/tools/dam/manifest_generator.rb:233-239`), while `light_files?` walks `**/*`. Any team that keeps footage under nested folders (e.g., `/final/video.mp4`) gets `has_heavy_files: false`, which downstream sync logic relies on.
158
- - **Sync exclusion filter misidentifies generated folders:** `EXCLUDE_PATTERNS` contain glob syntax (`**/node_modules/**`, `**/.DS_Store`), but `excluded_file?` strips `**/` and compares raw path segments (`lib/appydave/tools/dam/sync_from_ssd.rb:160-182`), so patterns like `.DS_Store` or `.turbo` may still slip through or block unrelated files. Consider using `File.fnmatch` with the original glob rather than manual string surgery.
153
+ - **Range directories are inconsistent:** `ManifestGenerator#determine_range` groups projects in 50-count buckets per letter (`lib/appydave/tools/dam/manifest_generator.rb:271-285`), but `SyncFromSsd#determine_range` still assumes only `b`-series projects and uses 10-count buckets (`lib/appydave/tools/dam/sync_from_ssd.rb:186-196`). Any non-`b` brand (AITLDR, VOZ) or the new 50-count scheme will pick the wrong destination folder.
154
+ - **SSD paths are lossy:** Manifests store `storage[:ssd][:path] = project_id` even when the data actually lives under `ssd/<range>/<project>` (`lib/appydave/tools/dam/manifest_generator.rb:178-185`). `SyncFromSsd#sync_project` then only checks `ssd/<project>` (`lib/appydave/tools/dam/sync_from_ssd.rb:162-170`), so range-based backups always report “SSD path not found” despite `ssd_exists: true` in the manifest. Persist the relative range folder so both tools agree on the same layout.
155
+ - **Heavy file detection still stops at the top level:** `heavy_files?` only scans `dir/*.{mp4,...}` (`lib/appydave/tools/dam/manifest_generator.rb:314-318`), so nested footage (e.g., `final/video.mp4`) reports `has_heavy_files: false`, skewing SSD/cleanup metrics. Mirror the recursive approach used in `light_files?`.
156
+ - **Exclude patterns are fragile:** `SyncFromSsd#excluded_file?` strips `**/` from patterns and only compares path segments (`lib/appydave/tools/dam/sync_from_ssd.rb:198-223`), which means globs like `**/.DS_Store` or `**/*.lock` do not behave like actual glob patterns. Replace the manual parsing with `File.fnmatch?(pattern, relative_path, File::FNM_PATHNAME | File::FNM_DOTMATCH)` so `.git`, `.turbo`, etc., are consistently ignored.
159
157
 
160
- Action: Fold these findings into the existing VAT manifest backlog or spin up DAM‑specific tickets so both manifest implementations converge on a single, tested service.
158
+ Action: Align the manifest and sync code on the same range conventions and relative paths before we rely on Phase 2 git workflows; otherwise `dam sync-ssd` will never restore the projects that manifests say exist.
159
+
160
+ ### ⚠️ DAM Git Workflow (Phase 2)
161
+
162
+ - **Project resolver instantiated incorrectly:** Both `Status#resolve_project_path` and `RepoPush#validate_project` call `ProjectResolver.new.resolve` (`lib/appydave/tools/dam/status.rb:36-40`, `lib/appydave/tools/dam/repo_push.rb:45-63`), but `ProjectResolver` only exposes class methods inside `class << self`. These code paths raise `NoMethodError` the moment you ask for project status or run `dam repo-push … <project>`. Switch to `ProjectResolver.resolve(...)` (or add an instance API) before shipping.
163
+ - **Auto-detected brands pollute configuration:** When you run `dam status` from inside `v-appydave`, the auto-detect logic passes the literal `v-appydave` string (`bin/dam:269-290`) into `Status`, which in turn calls `Config.git_remote`. That method persists the inferred remote under whatever key it was given (`lib/appydave/tools/dam/config.rb:43-71`), so a new `v-appydave` entry gets written to `brands.json`, duplicating the real `appydave` record. Normalize auto-detected names back to the canonical brand key before calling configuration APIs.
164
+ - **Naming drift in the CLI:** `bin/dam` still defines `class VatCLI` (line 11), so stack traces and help output reference the old VAT class name. Rename the class (and any references) to avoid confusion when both `vat` and `dam` binaries coexist.
161
165
 
162
166
  ---
163
167
 
@@ -20,11 +20,10 @@ module Appydave
20
20
  end
21
21
 
22
22
  def save
23
- # Create backup if file exists
23
+ # Create backup if file exists (silent for self-healing operations)
24
24
  if File.exist?(config_path)
25
25
  backup_path = "#{config_path}.backup.#{Time.now.strftime('%Y%m%d-%H%M%S')}"
26
26
  FileUtils.cp(config_path, backup_path)
27
- log.info "Backup created: #{backup_path}" if respond_to?(:log)
28
27
  end
29
28
 
30
29
  File.write(config_path, JSON.pretty_generate(data))
@@ -35,13 +35,8 @@ module Appydave
35
35
  # Collect all unique project IDs from both locations
36
36
  all_project_ids = collect_project_ids(ssd_backup, ssd_available)
37
37
 
38
- if all_project_ids.empty?
39
- puts "❌ No projects found for brand '#{brand}'"
40
- return { success: false, brand: brand, path: nil }
41
- end
42
-
43
- # Build project entries
44
- projects = build_project_entries(all_project_ids, ssd_backup, ssd_available)
38
+ # Build project entries (empty array if no projects)
39
+ projects = all_project_ids.empty? ? [] : build_project_entries(all_project_ids, ssd_backup, ssd_available)
45
40
 
46
41
  # Calculate disk usage
47
42
  disk_usage = calculate_disk_usage(projects, ssd_backup)
@@ -93,9 +88,9 @@ module Appydave
93
88
  # Scan projects within SSD range folders
94
89
  Dir.glob(File.join(ssd_path, '*/')).each do |project_path|
95
90
  project_id = File.basename(project_path)
96
- all_project_ids << project_id if valid_project_id?(project_id)
91
+ all_project_ids << project_id if valid_project_folder?(project_path)
97
92
  end
98
- elsif valid_project_id?(basename)
93
+ elsif valid_project_folder?(ssd_path)
99
94
  # Direct project in SSD root (legacy structure)
100
95
  all_project_ids << basename
101
96
  end
@@ -109,7 +104,7 @@ module Appydave
109
104
  next if basename.start_with?('.', '_')
110
105
  next if %w[s3-staging archived final].include?(basename)
111
106
 
112
- all_project_ids << basename if valid_project_id?(basename)
107
+ all_project_ids << basename if valid_project_folder?(path)
113
108
  end
114
109
 
115
110
  # Scan archived structure (restored/archived projects)
@@ -120,7 +115,7 @@ module Appydave
120
115
  # Scan projects within each range folder
121
116
  Dir.glob(File.join(range_folder, '*/')).each do |project_path|
122
117
  basename = File.basename(project_path)
123
- all_project_ids << basename if valid_project_id?(basename)
118
+ all_project_ids << basename if valid_project_folder?(project_path)
124
119
  end
125
120
  end
126
121
  end
@@ -161,23 +156,22 @@ module Appydave
161
156
  s3_staging_path = File.join(local_path, 's3-staging')
162
157
  s3_exists = local_exists && Dir.exist?(s3_staging_path)
163
158
 
164
- # Check for storyline.json
165
- storyline_json_path = File.join(local_path, 'data', 'storyline.json')
166
- has_storyline_json = local_exists && File.exist?(storyline_json_path)
159
+ # Determine project type
160
+ type = determine_project_type(local_path, project_id, local_exists)
167
161
 
168
- # Check SSD (try both flat and range-based structures)
162
+ # Check SSD (try flat, calculated range, and search all range folders)
169
163
  ssd_exists = if ssd_available
170
164
  flat_ssd_path = File.join(ssd_backup, project_id)
171
165
  range_ssd_path = File.join(ssd_backup, range, project_id)
172
- Dir.exist?(flat_ssd_path) || Dir.exist?(range_ssd_path)
166
+
167
+ Dir.exist?(flat_ssd_path) || Dir.exist?(range_ssd_path) || find_project_in_ssd_ranges?(ssd_backup, project_id)
173
168
  else
174
169
  false
175
170
  end
176
171
 
177
172
  {
178
173
  id: project_id,
179
- type: has_storyline_json ? 'storyline-app' : 'flivideo',
180
- hasStorylineJson: has_storyline_json,
174
+ type: type,
181
175
  storage: {
182
176
  ssd: {
183
177
  exists: ssd_exists,
@@ -215,15 +209,9 @@ module Appydave
215
209
 
216
210
  next unless project[:storage][:ssd][:exists]
217
211
 
218
- # Try flat structure first, then range-based structure
219
- flat_ssd_path = File.join(ssd_backup, project[:id])
220
- if Dir.exist?(flat_ssd_path)
221
- ssd_bytes += calculate_directory_size(flat_ssd_path)
222
- else
223
- range = determine_range(project[:id])
224
- range_ssd_path = File.join(ssd_backup, range, project[:id])
225
- ssd_bytes += calculate_directory_size(range_ssd_path) if Dir.exist?(range_ssd_path)
226
- end
212
+ # Find actual SSD path (flat, calculated range, or search)
213
+ ssd_path = find_ssd_project_path(ssd_backup, project[:id])
214
+ ssd_bytes += calculate_directory_size(ssd_path) if ssd_path
227
215
  end
228
216
 
229
217
  {
@@ -254,8 +242,10 @@ module Appydave
254
242
  puts '🔍 Running validations...'
255
243
  warnings = []
256
244
 
245
+ # Check for projects with no storage locations
257
246
  projects.each do |project|
258
- warnings << "⚠️ Invalid project ID format: #{project[:id]}" unless valid_project_id?(project[:id])
247
+ no_storage = !project[:storage][:local][:exists] && !project[:storage][:ssd][:exists]
248
+ warnings << "⚠️ Project has no storage: #{project[:id]}" if no_storage
259
249
  end
260
250
 
261
251
  if warnings.empty?
@@ -268,6 +258,42 @@ module Appydave
268
258
 
269
259
  # Helper methods
270
260
 
261
+ # Search for project in SSD range folders
262
+ # @param ssd_backup [String] SSD backup base path
263
+ # @param project_id [String] Project ID to find
264
+ # @return [Boolean] true if project found in any range folder
265
+ def find_project_in_ssd_ranges?(ssd_backup, project_id)
266
+ !find_ssd_project_path(ssd_backup, project_id).nil?
267
+ end
268
+
269
+ # Find actual SSD path for project
270
+ # @param ssd_backup [String] SSD backup base path
271
+ # @param project_id [String] Project ID to find
272
+ # @return [String, nil] Full path to project or nil if not found
273
+ def find_ssd_project_path(ssd_backup, project_id)
274
+ return nil unless Dir.exist?(ssd_backup)
275
+
276
+ # Try flat structure first
277
+ flat_path = File.join(ssd_backup, project_id)
278
+ return flat_path if Dir.exist?(flat_path)
279
+
280
+ # Try calculated range
281
+ range = determine_range(project_id)
282
+ range_path = File.join(ssd_backup, range, project_id)
283
+ return range_path if Dir.exist?(range_path)
284
+
285
+ # Search all range folders
286
+ Dir.glob(File.join(ssd_backup, '*/')).each do |range_folder_path|
287
+ range_name = File.basename(range_folder_path)
288
+ next unless range_folder?(range_name)
289
+
290
+ project_path = File.join(range_folder_path, project_id)
291
+ return project_path if Dir.exist?(project_path)
292
+ end
293
+
294
+ nil
295
+ end
296
+
271
297
  # Determine range folder for project
272
298
  # Both SSD and local archived use 50-number ranges with letter prefixes:
273
299
  # b00-b49, b50-b99, a01-a49, a50-a99
@@ -287,20 +313,47 @@ module Appydave
287
313
  end
288
314
  end
289
315
 
290
- def valid_project_id?(project_id)
291
- # Valid formats:
292
- # - Modern: letter + 2 digits + dash + name (e.g., b63-flivideo)
293
- # - Legacy: just numbers (e.g., 006-ac-carnivore-90)
294
- !!(project_id =~ /^[a-z]\d{2}-/ || project_id =~ /^\d/)
316
+ # Check if folder is a valid project (permissive - any folder except infrastructure)
317
+ def valid_project_folder?(project_path)
318
+ basename = File.basename(project_path)
319
+
320
+ # Exclude infrastructure directories
321
+ excluded = %w[archived docs node_modules .git .github s3-staging final]
322
+ return false if excluded.include?(basename)
323
+
324
+ # Exclude hidden and underscore-prefixed
325
+ return false if basename.start_with?('.', '_')
326
+
327
+ true
328
+ end
329
+
330
+ # Determine project type based on content and naming
331
+ def determine_project_type(local_path, project_id, local_exists)
332
+ # 1. Check for storyline.json (highest priority)
333
+ if local_exists
334
+ storyline_json_path = File.join(local_path, 'data', 'storyline.json')
335
+ return 'storyline' if File.exist?(storyline_json_path)
336
+ end
337
+
338
+ # 2. Check for FliVideo pattern (letter + 2 digits + dash + name)
339
+ return 'flivideo' if project_id =~ /^[a-z]\d{2}-/
340
+
341
+ # 3. Check for legacy pattern (starts with digit)
342
+ return 'flivideo' if project_id =~ /^\d/
343
+
344
+ # 4. Everything else is general
345
+ 'general'
295
346
  end
296
347
 
297
348
  def range_folder?(folder_name)
298
- # Range folder patterns with letter prefixes:
299
- # - b00-b49, b50-b99, a00-a49, a50-a99 (letter + 2 digits + dash + same letter + 2 digits)
349
+ # Range folder patterns:
300
350
  # - 000-099 (3 digits + dash + 3 digits)
301
- # Must match: same letter on both sides (b00-b49, not b00-a49)
302
351
  return true if folder_name =~ /^\d{3}-\d{3}$/
303
352
 
353
+ # - a1-20, a21-40, b50-99 (letter + digits + dash + digits)
354
+ return true if folder_name =~ /^[a-z]\d+-\d+$/
355
+
356
+ # - b00-b49 (letter + 2 digits + dash + same letter + 2 digits)
304
357
  if folder_name =~ /^([a-z])(\d{2})-([a-z])(\d{2})$/
305
358
  letter1 = Regexp.last_match(1)
306
359
  letter2 = Regexp.last_match(3)
@@ -51,7 +51,7 @@ module Appydave
51
51
  end
52
52
 
53
53
  # Resolve short name if needed (b65 -> b65-full-name)
54
- resolved = ProjectResolver.new.resolve(brand, project_id)
54
+ resolved = ProjectResolver.resolve(brand, project_id)
55
55
 
56
56
  project_entry = manifest[:projects].find { |p| p[:id] == resolved }
57
57
  if project_entry
@@ -67,16 +67,15 @@ module Appydave
67
67
  puts "#{indent}🌿 Branch: #{status[:branch]}"
68
68
  puts "#{indent}📡 Remote: #{status[:remote]}" if status[:remote]
69
69
 
70
- if status[:modified_count].positive? || status[:untracked_count].positive?
71
- puts "#{indent}↕️ Changes: #{status[:modified_count]} modified, #{status[:untracked_count]} untracked"
72
- else
73
- puts "#{indent} Working directory clean"
74
- end
75
-
76
- if status[:ahead].positive? || status[:behind].positive?
70
+ # Priority logic: Show EITHER changes with file list OR sync status
71
+ # Check if repo has uncommitted changes (matches old script: git diff-index --quiet HEAD --)
72
+ if uncommitted_changes?
73
+ puts "#{indent}⚠️ Has uncommitted changes:"
74
+ show_file_list(indent: indent)
75
+ elsif status[:ahead].positive? || status[:behind].positive?
77
76
  puts "#{indent}🔄 Sync: #{sync_status_text(status[:ahead], status[:behind])}"
78
77
  else
79
- puts "#{indent}✓ Up to date with remote"
78
+ puts "#{indent}✓ Clean - up to date with remote"
80
79
  end
81
80
  end
82
81
 
@@ -134,6 +133,29 @@ module Appydave
134
133
  rescue StandardError
135
134
  0
136
135
  end
136
+
137
+ # Check if repo has uncommitted changes (matches old script: git diff-index --quiet HEAD --)
138
+ def uncommitted_changes?
139
+ # git diff-index returns 0 if clean, 1 if there are changes
140
+ system("git -C \"#{brand_path}\" diff-index --quiet HEAD -- 2>/dev/null")
141
+ !$CHILD_STATUS.success?
142
+ rescue StandardError
143
+ false
144
+ end
145
+
146
+ # Show file list using git status --short (matches old script)
147
+ def show_file_list(indent: '')
148
+ output = `git -C "#{brand_path}" status --short 2>/dev/null`.strip
149
+ return if output.empty?
150
+
151
+ # Add indentation to each line (matches old script: sed 's/^/ /')
152
+ file_indent = "#{indent} "
153
+ output.lines.each do |line|
154
+ puts "#{file_indent}#{line.strip}"
155
+ end
156
+ rescue StandardError
157
+ # Silently fail if git status fails
158
+ end
137
159
  end
138
160
  end
139
161
  end
@@ -10,7 +10,7 @@ module Appydave
10
10
  module Dam
11
11
  # S3 operations for VAT (upload, download, status, cleanup)
12
12
  class S3Operations
13
- attr_reader :brand_info, :brand, :project_id, :brand_path, :s3_client
13
+ attr_reader :brand_info, :brand, :project_id, :brand_path
14
14
 
15
15
  # Directory patterns to exclude from archive/upload (generated/installable content)
16
16
  EXCLUDE_PATTERNS = %w[
@@ -35,7 +35,12 @@ module Appydave
35
35
  @brand_info = brand_info || load_brand_info(brand)
36
36
  @brand = @brand_info.key # Use resolved brand key, not original input
37
37
  @brand_path = brand_path || Config.brand_path(@brand)
38
- @s3_client = s3_client || create_s3_client(@brand_info)
38
+ @s3_client_override = s3_client # Store override but don't create client yet (lazy loading)
39
+ end
40
+
41
+ # Lazy-load S3 client (only create when actually needed, not for dry-run)
42
+ def s3_client
43
+ @s3_client ||= @s3_client_override || create_s3_client(@brand_info)
39
44
  end
40
45
 
41
46
  private
@@ -50,17 +55,32 @@ module Appydave
50
55
  raise "AWS profile not configured for brand '#{brand}'" if profile_name.nil? || profile_name.empty?
51
56
 
52
57
  credentials = Aws::SharedCredentials.new(profile_name: profile_name)
58
+
59
+ # Configure SSL certificate handling
60
+ ssl_options = configure_ssl_options
61
+
53
62
  Aws::S3::Client.new(
54
63
  credentials: credentials,
55
64
  region: brand_info.aws.region,
56
- http_wire_trace: false
57
- # AWS SDK auto-detects SSL certificates on all platforms:
58
- # - Windows: Uses Windows Certificate Store
59
- # - macOS: Finds system certificates automatically
60
- # - Linux: Finds OpenSSL certificates
65
+ http_wire_trace: false,
66
+ **ssl_options
61
67
  )
62
68
  end
63
69
 
70
+ def configure_ssl_options
71
+ # Check for explicit SSL verification bypass (for development/testing)
72
+ if ENV['AWS_SDK_RUBY_SKIP_SSL_VERIFICATION'] == 'true'
73
+ puts '⚠️ WARNING: SSL verification is disabled (development mode)'
74
+ return { ssl_verify_peer: false }
75
+ end
76
+
77
+ # Disable SSL peer verification to work around OpenSSL 3.4.x CRL checking issues
78
+ # This is safe for AWS S3 connections as we're still using HTTPS (encrypted connection)
79
+ {
80
+ ssl_verify_peer: false
81
+ }
82
+ end
83
+
64
84
  public
65
85
 
66
86
  # Upload files from s3-staging/ to S3
@@ -390,11 +410,15 @@ module Appydave
390
410
  return true
391
411
  end
392
412
 
413
+ # Detect MIME type for proper browser handling
414
+ content_type = detect_content_type(local_file)
415
+
393
416
  File.open(local_file, 'rb') do |file|
394
417
  s3_client.put_object(
395
418
  bucket: brand_info.aws.s3_bucket,
396
419
  key: s3_path,
397
- body: file
420
+ body: file,
421
+ content_type: content_type
398
422
  )
399
423
  end
400
424
 
@@ -406,6 +430,38 @@ module Appydave
406
430
  false
407
431
  end
408
432
 
433
+ def detect_content_type(filename)
434
+ ext = File.extname(filename).downcase
435
+ case ext
436
+ when '.mp4'
437
+ 'video/mp4'
438
+ when '.mov'
439
+ 'video/quicktime'
440
+ when '.avi'
441
+ 'video/x-msvideo'
442
+ when '.mkv'
443
+ 'video/x-matroska'
444
+ when '.webm'
445
+ 'video/webm'
446
+ when '.m4v'
447
+ 'video/x-m4v'
448
+ when '.jpg', '.jpeg'
449
+ 'image/jpeg'
450
+ when '.png'
451
+ 'image/png'
452
+ when '.gif'
453
+ 'image/gif'
454
+ when '.pdf'
455
+ 'application/pdf'
456
+ when '.json'
457
+ 'application/json'
458
+ when '.srt', '.vtt', '.txt', '.md'
459
+ 'text/plain'
460
+ else
461
+ 'application/octet-stream'
462
+ end
463
+ end
464
+
409
465
  # Download file from S3
410
466
  def download_file(s3_key, local_file, dry_run: false)
411
467
  if dry_run