appydave-tools 0.16.0 → 0.17.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.
Files changed (40) hide show
  1. checksums.yaml +4 -4
  2. data/.rubocop.yml +6 -0
  3. data/AGENTS.md +22 -0
  4. data/CHANGELOG.md +12 -0
  5. data/CLAUDE.md +206 -51
  6. data/README.md +144 -11
  7. data/bin/archive_project.rb +249 -0
  8. data/bin/configuration.rb +21 -1
  9. data/bin/generate_manifest.rb +357 -0
  10. data/bin/sync_from_ssd.rb +236 -0
  11. data/bin/vat +623 -0
  12. data/docs/README.md +169 -0
  13. data/docs/configuration/.env.example +19 -0
  14. data/docs/configuration/README.md +394 -0
  15. data/docs/configuration/channels.example.json +26 -0
  16. data/docs/configuration/settings.example.json +6 -0
  17. data/docs/development/CODEX-recommendations.md +123 -0
  18. data/docs/development/README.md +100 -0
  19. data/docs/development/cli-architecture-patterns.md +1604 -0
  20. data/docs/development/pattern-comparison.md +284 -0
  21. data/docs/prd-unified-brands-configuration.md +792 -0
  22. data/docs/project-brand-systems-analysis.md +934 -0
  23. data/docs/vat/dam-vision.md +123 -0
  24. data/docs/vat/session-summary-2025-11-09.md +297 -0
  25. data/docs/vat/usage.md +508 -0
  26. data/docs/vat/vat-testing-plan.md +801 -0
  27. data/lib/appydave/tools/configuration/models/brands_config.rb +238 -0
  28. data/lib/appydave/tools/configuration/models/config_base.rb +7 -0
  29. data/lib/appydave/tools/configuration/models/settings_config.rb +4 -0
  30. data/lib/appydave/tools/vat/config.rb +153 -0
  31. data/lib/appydave/tools/vat/config_loader.rb +91 -0
  32. data/lib/appydave/tools/vat/manifest_generator.rb +239 -0
  33. data/lib/appydave/tools/vat/project_listing.rb +198 -0
  34. data/lib/appydave/tools/vat/project_resolver.rb +132 -0
  35. data/lib/appydave/tools/vat/s3_operations.rb +560 -0
  36. data/lib/appydave/tools/version.rb +1 -1
  37. data/lib/appydave/tools.rb +9 -1
  38. data/package.json +1 -1
  39. metadata +57 -3
  40. data/docs/dam/overview.md +0 -28
@@ -0,0 +1,239 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'json'
4
+ require 'fileutils'
5
+
6
+ module Appydave
7
+ module Tools
8
+ module Vat
9
+ # Generate manifest JSON for video projects
10
+ class ManifestGenerator
11
+ attr_reader :brand, :brand_info, :brand_path
12
+
13
+ def initialize(brand, brand_info: nil, brand_path: nil)
14
+ @brand = brand
15
+ @brand_path = brand_path || Config.brand_path(brand)
16
+ @brand_info = brand_info || load_brand_info(brand)
17
+ end
18
+
19
+ # Generate manifest for this brand
20
+ def generate(output_file: nil)
21
+ output_file ||= File.join(brand_path, 'projects.json')
22
+ ssd_backup = brand_info.locations.ssd_backup
23
+
24
+ unless ssd_backup && !ssd_backup.empty?
25
+ puts "⚠️ SSD backup location not configured for brand '#{brand}'"
26
+ puts ' Manifest will only include local projects.'
27
+ end
28
+
29
+ ssd_available = ssd_backup && Dir.exist?(ssd_backup)
30
+
31
+ puts "📊 Generating manifest for #{brand}..."
32
+ puts ''
33
+
34
+ # Collect all unique project IDs from both locations
35
+ all_project_ids = collect_project_ids(ssd_backup, ssd_available)
36
+
37
+ if all_project_ids.empty?
38
+ puts "❌ No projects found for brand '#{brand}'"
39
+ return
40
+ end
41
+
42
+ # Build project entries
43
+ projects = build_project_entries(all_project_ids, ssd_backup, ssd_available)
44
+
45
+ # Calculate disk usage
46
+ disk_usage = calculate_disk_usage(projects, ssd_backup)
47
+
48
+ # Build manifest
49
+ manifest = {
50
+ config: {
51
+ brand: brand,
52
+ local_base: brand_path,
53
+ ssd_base: ssd_backup,
54
+ last_updated: Time.now.utc.iso8601,
55
+ note: 'Auto-generated manifest. Regenerate with: vat manifest'
56
+ }.merge(disk_usage),
57
+ projects: projects
58
+ }
59
+
60
+ # Write to file
61
+ File.write(output_file, JSON.pretty_generate(manifest))
62
+
63
+ puts "✅ Generated #{output_file}"
64
+ puts " Found #{projects.size} unique projects"
65
+ puts ''
66
+
67
+ # Summary stats
68
+ show_summary(projects, disk_usage)
69
+
70
+ # Validations
71
+ run_validations(projects)
72
+ end
73
+
74
+ private
75
+
76
+ def load_brand_info(brand)
77
+ Appydave::Tools::Configuration::Config.configure
78
+ Appydave::Tools::Configuration::Config.brands.get_brand(brand)
79
+ end
80
+
81
+ def collect_project_ids(ssd_backup, ssd_available)
82
+ all_project_ids = []
83
+
84
+ # Scan SSD (if available)
85
+ if ssd_available
86
+ Dir.glob(File.join(ssd_backup, '*/')).each do |project_path|
87
+ all_project_ids << File.basename(project_path)
88
+ end
89
+ end
90
+
91
+ # Scan local (all projects in brand directory)
92
+ Dir.glob(File.join(brand_path, '*/')).each do |path|
93
+ basename = File.basename(path)
94
+ # Skip hidden and special directories
95
+ next if basename.start_with?('.', '_')
96
+ next if %w[s3-staging archived final].include?(basename)
97
+
98
+ all_project_ids << basename if valid_project_id?(basename)
99
+ end
100
+
101
+ all_project_ids.uniq.sort
102
+ end
103
+
104
+ def build_project_entries(all_project_ids, ssd_backup, ssd_available)
105
+ projects = []
106
+
107
+ all_project_ids.each do |project_id|
108
+ local_path = File.join(brand_path, project_id)
109
+ ssd_path = ssd_available ? File.join(ssd_backup, project_id) : nil
110
+
111
+ local_exists = Dir.exist?(local_path)
112
+ ssd_exists = ssd_path && Dir.exist?(ssd_path)
113
+
114
+ projects << {
115
+ id: project_id,
116
+ storage: {
117
+ ssd: {
118
+ exists: ssd_exists,
119
+ path: ssd_exists ? project_id : nil
120
+ },
121
+ local: {
122
+ exists: local_exists,
123
+ has_heavy_files: local_exists ? heavy_files?(local_path) : false,
124
+ has_light_files: local_exists ? light_files?(local_path) : false
125
+ }
126
+ }
127
+ }
128
+ end
129
+
130
+ projects
131
+ end
132
+
133
+ def calculate_disk_usage(projects, ssd_backup)
134
+ local_bytes = 0
135
+ ssd_bytes = 0
136
+
137
+ projects.each do |project|
138
+ if project[:storage][:local][:exists]
139
+ local_path = File.join(brand_path, project[:id])
140
+ local_bytes += calculate_directory_size(local_path)
141
+ end
142
+
143
+ if project[:storage][:ssd][:exists]
144
+ ssd_path = File.join(ssd_backup, project[:id])
145
+ ssd_bytes += calculate_directory_size(ssd_path)
146
+ end
147
+ end
148
+
149
+ {
150
+ disk_usage: {
151
+ local: format_bytes_hash(local_bytes),
152
+ ssd: format_bytes_hash(ssd_bytes)
153
+ }
154
+ }
155
+ end
156
+
157
+ def show_summary(projects, disk_usage)
158
+ local_only = projects.count { |p| p[:storage][:local][:exists] && !p[:storage][:ssd][:exists] }
159
+ ssd_only = projects.count { |p| !p[:storage][:local][:exists] && p[:storage][:ssd][:exists] }
160
+ both = projects.count { |p| p[:storage][:local][:exists] && p[:storage][:ssd][:exists] }
161
+
162
+ puts 'Distribution:'
163
+ puts " Local only: #{local_only}"
164
+ puts " SSD only: #{ssd_only}"
165
+ puts " Both locations: #{both}"
166
+ puts ''
167
+ puts 'Disk Usage:'
168
+ puts " Local: #{format_bytes_human(disk_usage[:disk_usage][:local][:total_bytes])}"
169
+ puts " SSD: #{format_bytes_human(disk_usage[:disk_usage][:ssd][:total_bytes])}"
170
+ puts ''
171
+ end
172
+
173
+ def run_validations(projects)
174
+ puts '🔍 Running validations...'
175
+ warnings = []
176
+
177
+ projects.each do |project|
178
+ warnings << "⚠️ Invalid project ID format: #{project[:id]}" unless valid_project_id?(project[:id])
179
+ end
180
+
181
+ if warnings.empty?
182
+ puts '✅ All validations passed!'
183
+ else
184
+ puts "#{warnings.size} warning(s) found:"
185
+ warnings.each { |w| puts " #{w}" }
186
+ end
187
+ end
188
+
189
+ # Helper methods
190
+ def valid_project_id?(project_id)
191
+ # Valid formats:
192
+ # - Modern: letter + 2 digits + dash + name (e.g., b63-flivideo)
193
+ # - Legacy: just numbers (e.g., 006-ac-carnivore-90)
194
+ !!(project_id =~ /^[a-z]\d{2}-/ || project_id =~ /^\d/)
195
+ end
196
+
197
+ def heavy_files?(dir)
198
+ return false unless Dir.exist?(dir)
199
+
200
+ Dir.glob(File.join(dir, '*.{mp4,mov,avi,mkv,webm}')).any?
201
+ end
202
+
203
+ def light_files?(dir)
204
+ return false unless Dir.exist?(dir)
205
+
206
+ Dir.glob(File.join(dir, '**/*.{srt,vtt,jpg,png,md,txt,json,yml}')).any?
207
+ end
208
+
209
+ def calculate_directory_size(dir_path)
210
+ total = 0
211
+ Dir.glob(File.join(dir_path, '**', '*'), File::FNM_DOTMATCH).each do |file|
212
+ total += File.size(file) if File.file?(file)
213
+ end
214
+ total
215
+ end
216
+
217
+ def format_bytes_hash(bytes)
218
+ {
219
+ total_bytes: bytes,
220
+ total_mb: (bytes / 1024.0 / 1024.0).round(2),
221
+ total_gb: (bytes / 1024.0 / 1024.0 / 1024.0).round(2)
222
+ }
223
+ end
224
+
225
+ def format_bytes_human(bytes)
226
+ if bytes < 1024
227
+ "#{bytes} B"
228
+ elsif bytes < 1024 * 1024
229
+ "#{(bytes / 1024.0).round(1)} KB"
230
+ elsif bytes < 1024 * 1024 * 1024
231
+ "#{(bytes / (1024.0 * 1024)).round(1)} MB"
232
+ else
233
+ "#{(bytes / (1024.0 * 1024 * 1024)).round(2)} GB"
234
+ end
235
+ end
236
+ end
237
+ end
238
+ end
239
+ end
@@ -0,0 +1,198 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'find'
4
+
5
+ # rubocop:disable Style/FormatStringToken
6
+ # Disabled: Using simple unannotated tokens (%s) for straightforward string formatting
7
+ # Annotated tokens (%<foo>s) add unnecessary complexity for simple table formatting
8
+
9
+ module Appydave
10
+ module Tools
11
+ module Vat
12
+ # Project listing functionality for VAT
13
+ class ProjectListing
14
+ # List all brands with summary table
15
+ def self.list_brands_with_counts
16
+ brands = Config.available_brands
17
+
18
+ if brands.empty?
19
+ puts "⚠️ No brands found in #{Config.projects_root}"
20
+ return
21
+ end
22
+
23
+ # Gather brand data
24
+ brand_data = brands.map do |brand|
25
+ brand_path = Config.brand_path(brand)
26
+ projects = ProjectResolver.list_projects(brand)
27
+ total_size = calculate_total_size(brand_path, projects)
28
+ last_modified = find_last_modified(brand_path, projects)
29
+
30
+ {
31
+ name: brand,
32
+ path: brand_path,
33
+ count: projects.size,
34
+ size: total_size,
35
+ modified: last_modified
36
+ }
37
+ end
38
+
39
+ # Print table header
40
+ puts 'BRAND PROJECTS SIZE LAST MODIFIED PATH'
41
+ puts '-' * 120
42
+
43
+ # Print table rows
44
+ brand_data.each do |data|
45
+ puts format(
46
+ '%-20s %10d %12s %20s %s',
47
+ data[:name],
48
+ data[:count],
49
+ format_size(data[:size]),
50
+ format_date(data[:modified]),
51
+ shorten_path(data[:path])
52
+ )
53
+ end
54
+ end
55
+
56
+ # List all projects for a specific brand (Mode 3)
57
+ def self.list_brand_projects(brand_arg)
58
+ brand = Config.expand_brand(brand_arg)
59
+ projects = ProjectResolver.list_projects(brand)
60
+
61
+ if projects.empty?
62
+ puts "⚠️ No projects found for brand: #{brand}"
63
+ return
64
+ end
65
+
66
+ brand_path = Config.brand_path(brand)
67
+
68
+ # Gather project data
69
+ project_data = projects.map do |project|
70
+ project_path = File.join(brand_path, project)
71
+ size = calculate_directory_size(project_path)
72
+ modified = File.mtime(project_path)
73
+
74
+ {
75
+ name: project,
76
+ path: project_path,
77
+ size: size,
78
+ modified: modified
79
+ }
80
+ end
81
+
82
+ # Print table header
83
+ puts "Projects in #{brand}:"
84
+ puts 'PROJECT SIZE LAST MODIFIED PATH'
85
+ puts '-' * 120
86
+
87
+ # Print table rows
88
+ project_data.each do |data|
89
+ puts format(
90
+ '%-45s %12s %20s %s',
91
+ data[:name],
92
+ format_size(data[:size]),
93
+ format_date(data[:modified]),
94
+ shorten_path(data[:path])
95
+ )
96
+ end
97
+ end
98
+
99
+ # List with pattern matching (Mode 3b)
100
+ def self.list_with_pattern(brand_arg, pattern)
101
+ brand = Config.expand_brand(brand_arg)
102
+ brand_path = Config.brand_path(brand)
103
+ matches = ProjectResolver.resolve_pattern(brand_path, pattern)
104
+
105
+ if matches.empty?
106
+ puts "⚠️ No projects found matching pattern: #{pattern}"
107
+ return
108
+ end
109
+
110
+ # Gather project data
111
+ project_data = matches.map do |project|
112
+ project_path = File.join(brand_path, project)
113
+ size = calculate_directory_size(project_path)
114
+ modified = File.mtime(project_path)
115
+
116
+ {
117
+ name: project,
118
+ path: project_path,
119
+ size: size,
120
+ modified: modified
121
+ }
122
+ end
123
+
124
+ # Print table header
125
+ puts "Projects matching '#{pattern}' in #{brand}:"
126
+ puts 'PROJECT SIZE LAST MODIFIED PATH'
127
+ puts '-' * 120
128
+
129
+ # Print table rows
130
+ project_data.each do |data|
131
+ puts format(
132
+ '%-45s %12s %20s %s',
133
+ data[:name],
134
+ format_size(data[:size]),
135
+ format_date(data[:modified]),
136
+ shorten_path(data[:path])
137
+ )
138
+ end
139
+ end
140
+
141
+ # Helper methods
142
+
143
+ # Calculate total size of all projects in a brand
144
+ def self.calculate_total_size(brand_path, projects)
145
+ projects.sum do |project|
146
+ calculate_directory_size(File.join(brand_path, project))
147
+ end
148
+ end
149
+
150
+ # Calculate size of a directory in bytes
151
+ def self.calculate_directory_size(path)
152
+ return 0 unless Dir.exist?(path)
153
+
154
+ total = 0
155
+ Find.find(path) do |file_path|
156
+ total += File.size(file_path) if File.file?(file_path)
157
+ rescue StandardError
158
+ # Skip files we can't read
159
+ end
160
+ total
161
+ end
162
+
163
+ # Find the most recent modification time across all projects
164
+ def self.find_last_modified(brand_path, projects)
165
+ return Time.at(0) if projects.empty?
166
+
167
+ projects.map do |project|
168
+ File.mtime(File.join(brand_path, project))
169
+ end.max
170
+ end
171
+
172
+ # Format size in human-readable format
173
+ def self.format_size(bytes)
174
+ return '0 B' if bytes.zero?
175
+
176
+ units = %w[B KB MB GB TB]
177
+ exp = (Math.log(bytes) / Math.log(1024)).to_i
178
+ exp = [exp, units.length - 1].min
179
+
180
+ format('%.1f %s', bytes.to_f / (1024**exp), units[exp])
181
+ end
182
+
183
+ # Format date/time in readable format
184
+ def self.format_date(time)
185
+ return 'N/A' if time.nil?
186
+
187
+ time.strftime('%Y-%m-%d %H:%M')
188
+ end
189
+
190
+ # Shorten path by replacing home directory with ~
191
+ def self.shorten_path(path)
192
+ path.sub(Dir.home, '~')
193
+ end
194
+ end
195
+ end
196
+ end
197
+ end
198
+ # rubocop:enable Style/FormatStringToken
@@ -0,0 +1,132 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Appydave
4
+ module Tools
5
+ module Vat
6
+ # ProjectResolver - Resolve short project names to full paths
7
+ #
8
+ # Handles:
9
+ # - FliVideo pattern: b65 → b65-guy-monroe-marketing-plan
10
+ # - Storyline pattern: boy-baker → boy-baker (exact match)
11
+ # - Pattern matching: b6* → all b60-b69 projects
12
+ class ProjectResolver
13
+ class << self
14
+ # Resolve project hint to full project name(s)
15
+ # @param brand [String] Brand shortcut or full name
16
+ # @param project_hint [String] Project name or pattern (e.g., 'b65', 'boy-baker', 'b6*')
17
+ # @return [String, Array<String>] Full project name or array of names for patterns
18
+ def resolve(brand, project_hint)
19
+ brand_path = Config.brand_path(brand)
20
+
21
+ # Check for pattern (wildcard)
22
+ return resolve_pattern(brand_path, project_hint) if project_hint.include?('*')
23
+
24
+ # Exact match first
25
+ full_path = File.join(brand_path, project_hint)
26
+ return project_hint if Dir.exist?(full_path)
27
+
28
+ # FliVideo pattern: b65 → b65-*
29
+ if project_hint =~ /^[a-z]\d+$/
30
+ matches = Dir.glob("#{brand_path}/#{project_hint}-*")
31
+ .select { |path| File.directory?(path) }
32
+ .map { |path| File.basename(path) }
33
+
34
+ case matches.size
35
+ when 0
36
+ raise "❌ No project found matching '#{project_hint}' in #{Config.expand_brand(brand)}"
37
+ when 1
38
+ return matches.first
39
+ else
40
+ # Multiple matches - show and ask
41
+ puts "⚠️ Multiple projects match '#{project_hint}':"
42
+ matches.each_with_index do |match, idx|
43
+ puts " #{idx + 1}. #{match}"
44
+ end
45
+ print "\nSelect project (1-#{matches.size}): "
46
+ selection = $stdin.gets.to_i
47
+ return matches[selection - 1] if selection.between?(1, matches.size)
48
+
49
+ raise 'Invalid selection'
50
+ end
51
+ end
52
+
53
+ # No match - return as-is (will error later if doesn't exist)
54
+ project_hint
55
+ end
56
+
57
+ # Resolve pattern to list of matching projects
58
+ # @param brand_path [String] Full path to brand directory
59
+ # @param pattern [String] Pattern with wildcards (e.g., 'b6*')
60
+ # @return [Array<String>] List of matching project names
61
+ def resolve_pattern(brand_path, pattern)
62
+ matches = Dir.glob("#{brand_path}/#{pattern}")
63
+ .select { |path| File.directory?(path) }
64
+ .select { |path| valid_project?(path) }
65
+ .map { |path| File.basename(path) }
66
+ .sort
67
+
68
+ raise "❌ No projects found matching pattern '#{pattern}' in #{brand_path}" if matches.empty?
69
+
70
+ matches
71
+ end
72
+
73
+ # List all projects for a brand
74
+ # @param brand [String] Brand shortcut or full name
75
+ # @param pattern [String, nil] Optional filter pattern
76
+ # @return [Array<String>] List of project names
77
+ def list_projects(brand, pattern = nil)
78
+ brand_path = Config.brand_path(brand)
79
+
80
+ glob_pattern = pattern || '*'
81
+ Dir.glob("#{brand_path}/#{glob_pattern}")
82
+ .select { |path| File.directory?(path) }
83
+ .select { |path| valid_project?(path) }
84
+ .map { |path| File.basename(path) }
85
+ .sort
86
+ end
87
+
88
+ # Check if directory is a valid project
89
+ # @param project_path [String] Full path to potential project directory
90
+ # @return [Boolean] true if valid project
91
+ def valid_project?(project_path)
92
+ basename = File.basename(project_path)
93
+
94
+ # Exclude system/infrastructure directories
95
+ excluded = %w[archived docs node_modules .git .github]
96
+ return false if excluded.include?(basename)
97
+
98
+ # Exclude hidden and underscore-prefixed
99
+ return false if basename.start_with?('.', '_')
100
+
101
+ true
102
+ end
103
+
104
+ # Detect brand and project from current directory
105
+ # @return [Array<String, String>] [brand, project] or [nil, nil]
106
+ def detect_from_pwd
107
+ current = Dir.pwd
108
+
109
+ # Check if we're inside a v-* directory
110
+ if current =~ %r{/(v-[^/]+)/([^/]+)/?}
111
+ brand = ::Regexp.last_match(1)
112
+ project = ::Regexp.last_match(2)
113
+ return [brand, project] if project_exists?(brand, project)
114
+ end
115
+
116
+ [nil, nil]
117
+ end
118
+
119
+ # Check if project exists in brand directory
120
+ # @param brand [String] Brand name (v-appydave format)
121
+ # @param project [String] Project name
122
+ # @return [Boolean] true if project directory exists
123
+ def project_exists?(brand, project)
124
+ projects_root = Config.projects_root
125
+ project_path = File.join(projects_root, brand, project)
126
+ Dir.exist?(project_path)
127
+ end
128
+ end
129
+ end
130
+ end
131
+ end
132
+ end