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.
- checksums.yaml +4 -4
- data/.rubocop.yml +6 -0
- data/AGENTS.md +22 -0
- data/CHANGELOG.md +12 -0
- data/CLAUDE.md +206 -51
- data/README.md +144 -11
- data/bin/archive_project.rb +249 -0
- data/bin/configuration.rb +21 -1
- data/bin/generate_manifest.rb +357 -0
- data/bin/sync_from_ssd.rb +236 -0
- data/bin/vat +623 -0
- data/docs/README.md +169 -0
- data/docs/configuration/.env.example +19 -0
- data/docs/configuration/README.md +394 -0
- data/docs/configuration/channels.example.json +26 -0
- data/docs/configuration/settings.example.json +6 -0
- data/docs/development/CODEX-recommendations.md +123 -0
- data/docs/development/README.md +100 -0
- data/docs/development/cli-architecture-patterns.md +1604 -0
- data/docs/development/pattern-comparison.md +284 -0
- data/docs/prd-unified-brands-configuration.md +792 -0
- data/docs/project-brand-systems-analysis.md +934 -0
- data/docs/vat/dam-vision.md +123 -0
- data/docs/vat/session-summary-2025-11-09.md +297 -0
- data/docs/vat/usage.md +508 -0
- data/docs/vat/vat-testing-plan.md +801 -0
- data/lib/appydave/tools/configuration/models/brands_config.rb +238 -0
- data/lib/appydave/tools/configuration/models/config_base.rb +7 -0
- data/lib/appydave/tools/configuration/models/settings_config.rb +4 -0
- data/lib/appydave/tools/vat/config.rb +153 -0
- data/lib/appydave/tools/vat/config_loader.rb +91 -0
- data/lib/appydave/tools/vat/manifest_generator.rb +239 -0
- data/lib/appydave/tools/vat/project_listing.rb +198 -0
- data/lib/appydave/tools/vat/project_resolver.rb +132 -0
- data/lib/appydave/tools/vat/s3_operations.rb +560 -0
- data/lib/appydave/tools/version.rb +1 -1
- data/lib/appydave/tools.rb +9 -1
- data/package.json +1 -1
- metadata +57 -3
- 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
|