docopslab-dev 0.1.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 (95) hide show
  1. checksums.yaml +7 -0
  2. data/LICENSE +21 -0
  3. data/README.adoc +904 -0
  4. data/assets/config-packs/actionlint/base.yml +13 -0
  5. data/assets/config-packs/actionlint/project.yml +13 -0
  6. data/assets/config-packs/htmlproofer/base.yml +27 -0
  7. data/assets/config-packs/htmlproofer/project.yml +25 -0
  8. data/assets/config-packs/rubocop/base.yml +130 -0
  9. data/assets/config-packs/rubocop/project.yml +8 -0
  10. data/assets/config-packs/shellcheck/base.shellcheckrc +14 -0
  11. data/assets/config-packs/subtxt/ai-asciidoc-antipatterns.sub.txt +11 -0
  12. data/assets/config-packs/vale/asciidoc/ExplicitSectionIDs.yml +8 -0
  13. data/assets/config-packs/vale/asciidoc/ExtraLineBeforeLevel1.yml +7 -0
  14. data/assets/config-packs/vale/asciidoc/OneSentencePerLine.yml +8 -0
  15. data/assets/config-packs/vale/asciidoc/PreferSourceBlocks.yml +8 -0
  16. data/assets/config-packs/vale/asciidoc/ProperAdmonitions.yml +8 -0
  17. data/assets/config-packs/vale/asciidoc/ProperDLs.yml +7 -0
  18. data/assets/config-packs/vale/asciidoc/UncleanListStart.yml +8 -0
  19. data/assets/config-packs/vale/authoring/ButParagraph.yml +8 -0
  20. data/assets/config-packs/vale/authoring/ExNotEg.yml +8 -0
  21. data/assets/config-packs/vale/authoring/LiteralTerms.yml +20 -0
  22. data/assets/config-packs/vale/authoring/Spelling.yml +679 -0
  23. data/assets/config-packs/vale/base.ini +38 -0
  24. data/assets/config-packs/vale/config/scripts/ExplicitSectionIDs.tengo +56 -0
  25. data/assets/config-packs/vale/config/scripts/ExtraLineBeforeLevel1.tengo +121 -0
  26. data/assets/config-packs/vale/config/scripts/OneSentencePerLine.tengo +53 -0
  27. data/assets/config-packs/vale/project.ini +5 -0
  28. data/assets/hooks/pre-commit +63 -0
  29. data/assets/hooks/pre-push +72 -0
  30. data/assets/scripts/adoc_section_ids.rb +50 -0
  31. data/assets/scripts/build-common.sh +193 -0
  32. data/assets/scripts/build-docker.sh +64 -0
  33. data/assets/scripts/build.sh +56 -0
  34. data/assets/scripts/parse_jekyll_asciidoc_logs.rb +467 -0
  35. data/assets/templates/Gemfile +7 -0
  36. data/assets/templates/Rakefile +3 -0
  37. data/assets/templates/gitignore +69 -0
  38. data/assets/templates/jekyll-asciidoc-fix.prompt.yml +17 -0
  39. data/assets/templates/spellcheck.prompt.yml +16 -0
  40. data/docopslab-dev.gemspec +56 -0
  41. data/docs/agent/AGENTS.md +229 -0
  42. data/docs/agent/index.md +80 -0
  43. data/docs/agent/missions/conduct-release.md +224 -0
  44. data/docs/agent/missions/setup-new-project.md +250 -0
  45. data/docs/agent/roles/devops-release-engineer.md +152 -0
  46. data/docs/agent/roles/docops-engineer.md +193 -0
  47. data/docs/agent/roles/planner-architect.md +74 -0
  48. data/docs/agent/roles/product-engineer.md +153 -0
  49. data/docs/agent/roles/product-manager.md +130 -0
  50. data/docs/agent/roles/project-manager.md +139 -0
  51. data/docs/agent/roles/qa-testing-engineer.md +115 -0
  52. data/docs/agent/roles/tech-docs-manager.md +143 -0
  53. data/docs/agent/roles/tech-writer.md +163 -0
  54. data/docs/agent/skills/asciidoc.md +609 -0
  55. data/docs/agent/skills/code-commenting.md +347 -0
  56. data/docs/agent/skills/fix-broken-links.md +309 -0
  57. data/docs/agent/skills/fix-jekyll-asciidoc-build-errors.md +23 -0
  58. data/docs/agent/skills/fix-spelling-issues.md +13 -0
  59. data/docs/agent/skills/git.md +170 -0
  60. data/docs/agent/skills/github-issues.md +135 -0
  61. data/docs/agent/skills/product-release-rollback-and-patching.md +71 -0
  62. data/docs/agent/skills/rake-cli-dev.md +57 -0
  63. data/docs/agent/skills/readme-driven-dev.md +13 -0
  64. data/docs/agent/skills/release-history.md +29 -0
  65. data/docs/agent/skills/ruby.md +192 -0
  66. data/docs/agent/skills/schemagraphy-sgyml.md +18 -0
  67. data/docs/agent/skills/tests-running.md +25 -0
  68. data/docs/agent/skills/tests-writing.md +45 -0
  69. data/docs/agent/skills/write-the-docs.md +54 -0
  70. data/docs/agent/topics/common-project-paths.md +117 -0
  71. data/docs/agent/topics/dev-tooling-usage.md +202 -0
  72. data/docs/agent/topics/devops-ci-cd.md +55 -0
  73. data/docs/agent/topics/product-docs-deployment.md +25 -0
  74. data/lib/docopslab/dev/auto_fix_asciidoc.rb +46 -0
  75. data/lib/docopslab/dev/checkers.rb +108 -0
  76. data/lib/docopslab/dev/config_manager.rb +241 -0
  77. data/lib/docopslab/dev/file_utils.rb +140 -0
  78. data/lib/docopslab/dev/git_hooks.rb +140 -0
  79. data/lib/docopslab/dev/help.rb +121 -0
  80. data/lib/docopslab/dev/initializer.rb +95 -0
  81. data/lib/docopslab/dev/linters.rb +451 -0
  82. data/lib/docopslab/dev/log_parser.rb +31 -0
  83. data/lib/docopslab/dev/paths.rb +46 -0
  84. data/lib/docopslab/dev/script_manager.rb +136 -0
  85. data/lib/docopslab/dev/spell_check.rb +194 -0
  86. data/lib/docopslab/dev/sync_ops.rb +468 -0
  87. data/lib/docopslab/dev/tasks.rb +440 -0
  88. data/lib/docopslab/dev/tool_execution.rb +68 -0
  89. data/lib/docopslab/dev/version.rb +8 -0
  90. data/lib/docopslab/dev.rb +392 -0
  91. data/specs/data/default-manifest.yml +64 -0
  92. data/specs/data/manifest-schema.yaml +63 -0
  93. data/specs/data/tasks-def.yml +321 -0
  94. data/specs/data/tools.yml +60 -0
  95. metadata +362 -0
@@ -0,0 +1,468 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'fileutils'
4
+ require 'yaml'
5
+ require 'pathname'
6
+
7
+ module DocOpsLab
8
+ module Dev
9
+ module SyncOps
10
+ # rubocop :disable Metrics/ClassLength
11
+ class << self
12
+ def install_vale_styles context
13
+ return unless File.exist?(CONFIG_PATHS[:vale]) && context.tool_available?('vale')
14
+
15
+ puts "๐Ÿ“š Syncing Vale styles using Packages key in #{CONFIG_PATHS[:vale]} (local and remote packages)"
16
+ context.run_with_fallback('vale', "vale --config=#{CONFIG_PATHS[:vale]} sync")
17
+ end
18
+
19
+ def sync_vale_styles context, local: false
20
+ puts '๐Ÿ“š Syncing Vale styles...'
21
+
22
+ styles_source_root = if context.lab_dev_mode?
23
+ # Running inside lab monorepo
24
+ 'gems/docopslab-dev/assets/config-packs/vale'
25
+ else
26
+ # Running from consumer project with path dependency
27
+ File.join(GEM_ROOT, 'assets', 'config-packs', 'vale')
28
+ end
29
+ styles_dest_root = '.config/.vendor/vale/styles'
30
+ FileUtils.mkdir_p(styles_dest_root)
31
+
32
+ # Get the list of local styles from tools.yml
33
+ begin
34
+ tools_yml_path = if context.lab_dev_mode?
35
+ 'gems/docopslab-dev/specs/data/tools.yml'
36
+ else
37
+ File.join(GEM_ROOT, 'specs', 'data', 'tools.yml')
38
+ end
39
+
40
+ style_paths_array = YAML.load_file(tools_yml_path)
41
+ .find { |t| t['slug'] == 'vale' }['packaging']['packages']
42
+
43
+ synced_styles = 0
44
+ style_paths_array.each do |package|
45
+ vale_name = package['target']
46
+ src_name = package['source']
47
+ source_style_dir = File.join(styles_source_root, src_name)
48
+ dest_style_dir = File.join(styles_dest_root, vale_name)
49
+
50
+ next unless File.directory?(source_style_dir)
51
+
52
+ # Copy style directory
53
+ FileUtils.rm_rf(dest_style_dir)
54
+ FileUtils.cp_r(source_style_dir, dest_style_dir)
55
+ puts " โœ“ Synced custom style: #{vale_name}"
56
+ synced_styles += 1
57
+ end
58
+
59
+ # copy style scripts directory
60
+ scripts_source = File.join(styles_source_root, 'config', 'scripts')
61
+ scripts_dest = File.join(styles_dest_root, 'config', 'scripts')
62
+ if File.directory?(scripts_source)
63
+ FileUtils.rm_rf(scripts_dest)
64
+ FileUtils.mkdir_p(File.dirname(scripts_dest))
65
+ FileUtils.cp_r(scripts_source, scripts_dest)
66
+ puts ' โœ“ Synced style scripts directory'
67
+ else
68
+ puts " โš ๏ธ No style scripts directory found at #{scripts_source}; skipping"
69
+ end
70
+
71
+ puts " โœ… Synced #{synced_styles} custom Vale style(s)" if synced_styles.positive?
72
+ rescue StandardError => e
73
+ puts " โš ๏ธ Error syncing local Vale styles: #{e.message}"
74
+ false
75
+ end
76
+
77
+ # If not local-only, also run Vale sync for remote styles
78
+ unless local
79
+ puts '๐Ÿ“ฆ Syncing remote Vale packages...'
80
+ vale_config = CONFIG_PATHS[:vale]
81
+ context.generate_vale_config unless File.exist?(vale_config)
82
+ context.run_with_fallback('vale', "vale --config=#{vale_config} sync")
83
+ end
84
+
85
+ true
86
+ end
87
+
88
+ def sync_docs context, force: false
89
+ manifest = context.load_manifest
90
+ return false unless manifest
91
+
92
+ docs_entries = manifest['docs']
93
+ return false unless docs_entries.is_a?(Array)
94
+
95
+ puts '๐Ÿ“š Syncing documentation files...'
96
+
97
+ synced_count = 0
98
+ skipped_count = 0
99
+ sources_checked = []
100
+ excluded_files = Set.new
101
+
102
+ # First pass: collect all explicitly excluded files (synced: false)
103
+ docs_entries.each do |entry|
104
+ source_pattern = entry['source']
105
+ synced = entry.fetch('synced', false)
106
+
107
+ next unless source_pattern
108
+ next if synced # Only collect exclusions
109
+
110
+ # Resolve source file path
111
+ if source_pattern.include?('*')
112
+ # Glob pattern for exclusions
113
+ source_glob = File.join(GEM_ROOT, source_pattern)
114
+ Dir.glob(source_glob).each do |source_file|
115
+ excluded_files.add(source_file) if File.file?(source_file)
116
+ end
117
+ else
118
+ # Single file exclusion
119
+ source_file = File.join(GEM_ROOT, source_pattern)
120
+ excluded_files.add(source_file) if File.exist?(source_file)
121
+ end
122
+ end
123
+
124
+ # rubocop:disable Style/CombinableLoops
125
+ # Second pass: process inclusions, respecting exclusions
126
+ # These loops cannot be combined as they implement different phases of a two-pass algorithm
127
+ docs_entries.each do |entry|
128
+ source_pattern = entry['source']
129
+ target_path = entry['target']
130
+ synced = entry.fetch('synced', false)
131
+
132
+ next unless source_pattern && target_path
133
+ next unless synced # Only process inclusions
134
+
135
+ # Check if source is a glob pattern
136
+ if source_pattern.include?('*')
137
+ # Glob pattern; copy matching files
138
+ source_glob = File.join(GEM_ROOT, source_pattern)
139
+ matching_files = Dir.glob(source_glob)
140
+
141
+ if matching_files.empty?
142
+ puts " โš ๏ธ No files matched pattern: #{source_pattern}"
143
+ next
144
+ end
145
+
146
+ matching_files.each do |source_file|
147
+ next unless File.file?(source_file)
148
+ next if sources_checked.include?(source_file)
149
+
150
+ # Skip if explicitly excluded
151
+ if excluded_files.include?(source_file)
152
+ puts " โญ๏ธ Skipped #{File.basename(source_file)} (explicitly excluded)"
153
+ next
154
+ end
155
+
156
+ # Determine target file path
157
+ filename = File.basename(source_file)
158
+ target_file = File.join(target_path, filename)
159
+
160
+ sources_checked << source_file
161
+ result = copy_doc_file(source_file, target_file, synced: synced, force: force)
162
+ synced_count += 1 if result == :copied
163
+ skipped_count += 1 if result == :skipped
164
+ end
165
+ else # Single file
166
+ source_file = File.join(GEM_ROOT, source_pattern)
167
+
168
+ unless File.exist?(source_file)
169
+ puts " โŒ Source file not found: #{source_file}"
170
+ puts " Run 'bundle exec rake gemdo:gen_agent_docs' in DocOps/lab to generate docs"
171
+ next
172
+ end
173
+
174
+ next if sources_checked.include?(source_file)
175
+
176
+ # Skip if explicitly excluded (shouldn't happen for inclusions, but safety check)
177
+ if excluded_files.include?(source_file)
178
+ puts " โญ๏ธ Skipped #{File.basename(source_file)} (explicitly excluded)"
179
+ next
180
+ end
181
+
182
+ sources_checked << source_file
183
+
184
+ result = copy_doc_file(source_file, target_path, synced: synced, force: force)
185
+ synced_count += 1 if result == :copied
186
+ skipped_count += 1 if result == :skipped
187
+ end
188
+ end
189
+ # rubocop:enable Style/CombinableLoops
190
+
191
+ puts "โœ… Synced #{synced_count} doc files" if synced_count.positive?
192
+ puts "โ„น๏ธ Skipped #{skipped_count} existing files (use --force to overwrite)" if skipped_count.positive?
193
+
194
+ synced_count.positive? || skipped_count.positive?
195
+ end
196
+
197
+ def sync_scripts _context
198
+ ScriptManager.sync_scripts
199
+ end
200
+
201
+ def sync_config_files context, tool_filter: :all, offline: false
202
+ # Validate tool filter parameter
203
+ unless tool_filter == :all || tool_filter.is_a?(String) || tool_filter.is_a?(Symbol)
204
+ puts "โŒ Invalid tool filter: #{tool_filter}. Must be :all, tool name string, or tool symbol"
205
+ return false
206
+ end
207
+
208
+ puts offline ? '๐Ÿ”„ Syncing configs (offline mode)...' : '๐Ÿ”„ Syncing configuration files...'
209
+
210
+ # Check for docopslab-dev.yml manifest
211
+ unless File.exist?(MANIFEST_PATH)
212
+ puts "โ„น๏ธ No #{MANIFEST_PATH} found"
213
+ puts "โŒ Legacy sync mode not implemented. Run 'rake labdev:init' to create manifest."
214
+ return false
215
+ end
216
+
217
+ # Parse manifest
218
+ begin
219
+ manifest = YAML.load_file(MANIFEST_PATH)
220
+ rescue StandardError => e
221
+ puts "โŒ Failed to parse #{MANIFEST_PATH}: #{e.message}"
222
+ return false
223
+ end
224
+
225
+ unless Dir.exist?(CONFIG_PACKS_SOURCE_DIR)
226
+ puts 'โŒ No assets/config-packs directory found in gem'
227
+ return false
228
+ end
229
+
230
+ # Get available tools from manifest for validation
231
+ available_tools = manifest['tools']&.map { |t| t['tool'] } || []
232
+
233
+ # Validate specific tool filter
234
+ if tool_filter != :all
235
+ tool_filter_str = tool_filter.to_s
236
+ unless available_tools.include?(tool_filter_str)
237
+ puts "โŒ Tool '#{tool_filter_str}' not found in manifest. Available tools: #{available_tools.join(', ')}"
238
+ return false
239
+ end
240
+ puts "๐Ÿ“ฆ Filtering to tool: #{tool_filter_str}"
241
+ end
242
+
243
+ synced_count = 0
244
+ expected_targets = Set.new
245
+
246
+ # Process each tool from manifest
247
+ manifest['tools']&.each do |tool_entry|
248
+ tool_name = tool_entry['tool']
249
+ enabled = tool_entry.fetch('enabled', true)
250
+
251
+ # Skip if filtering to specific tool and this isn't it
252
+ next if tool_filter != :all && tool_name != tool_filter.to_s
253
+
254
+ unless enabled
255
+ puts "โญ๏ธ Skipping #{tool_name} (disabled in manifest)"
256
+ next
257
+ end
258
+
259
+ puts "๐Ÿ“ฆ Processing #{tool_name} config pack..."
260
+
261
+ # Process each file mapping
262
+ tool_entry['files']&.each do |file_config|
263
+ source_rel = file_config['source']
264
+ target_path = file_config['target']
265
+ synced = file_config.fetch('synced', true)
266
+ file_enabled = file_config.fetch('enabled', true)
267
+
268
+ unless file_enabled
269
+ puts " โญ๏ธ Skipping #{source_rel} (disabled)"
270
+ next
271
+ end
272
+
273
+ source_path = File.join(CONFIG_PACKS_SOURCE_DIR, source_rel)
274
+
275
+ unless File.exist?(source_path)
276
+ puts " โŒ Source not found: #{source_rel}"
277
+ next
278
+ end
279
+
280
+ # Add to expected targets for cleanup, regardless of synced status
281
+ expected_targets.add(target_path)
282
+
283
+ # Handle directory syncing (source ends with /)
284
+ if source_rel.end_with?('/')
285
+ sync_result = sync_directory(
286
+ source_path, target_path, synced: synced, expected_targets: expected_targets)
287
+ synced_count += sync_result
288
+ else
289
+ # Create destination directory if needed
290
+ FileUtils.mkdir_p(File.dirname(target_path))
291
+
292
+ # Determine if we should copy the file
293
+ file_existed_before_copy = File.exist?(target_path)
294
+
295
+ should_copy = if synced # If synced: true, copy if missing or different
296
+ !file_existed_before_copy || File.read(source_path) != File.read(target_path)
297
+ else # If synced: false, copy only if missing
298
+ !file_existed_before_copy
299
+ end
300
+
301
+ if should_copy
302
+ FileUtils.cp(source_path, target_path)
303
+ message = if synced
304
+ "๐Ÿ“ Synced: #{target_path} (auto-sync)"
305
+ elsif !file_existed_before_copy
306
+ "โœ… Created: #{target_path}"
307
+ else
308
+ "๐Ÿ“ Synced: #{target_path}" # Fallback
309
+ end
310
+ puts " #{message}"
311
+ synced_count += 1
312
+ else
313
+ puts " โœ… Up to date: #{target_path}"
314
+ end
315
+ end
316
+ end
317
+ end
318
+
319
+ cleanup_count = cleanup_obsolete_files(context, expected_targets)
320
+
321
+ # Generate runtime configs after syncing base configs
322
+ puts '๐Ÿ”ง Generating runtime configs...'
323
+ generated_count = 0
324
+ generated_count += 1 if context.generate_vale_config
325
+ generated_count += 1 if context.generate_htmlproofer_config
326
+
327
+ puts ' โœ… All runtime configs up to date' if generated_count.zero?
328
+
329
+ total_changes = synced_count + cleanup_count + generated_count
330
+ if total_changes.positive?
331
+ puts "โœ… Config sync complete; #{synced_count} files updated, " \
332
+ "#{cleanup_count} files cleaned up, #{generated_count} configs generated"
333
+ else
334
+ puts 'โœ… All configs up to date'
335
+ end
336
+
337
+ true
338
+ end
339
+
340
+ def sync_directory source_dir, target_dir, synced: false, expected_targets: nil
341
+ synced_count = 0
342
+
343
+ FileUtils.mkdir_p(target_dir)
344
+
345
+ # Sync all files in the source directory
346
+ Dir.glob("#{source_dir}/**/*", File::FNM_DOTMATCH).each do |source_file|
347
+ next if File.directory?(source_file)
348
+ next if File.basename(source_file).start_with?('.') && ['.', '..'].include?(File.basename(source_file))
349
+
350
+ # Calculate relative path within source directory
351
+ rel_path = Pathname.new(source_file).relative_path_from(Pathname.new(source_dir))
352
+ target_file = File.join(target_dir, rel_path)
353
+
354
+ # Track expected files for cleanup detection
355
+ expected_targets&.add(target_file) if synced
356
+
357
+ # Create target subdirectory if needed
358
+ FileUtils.mkdir_p(File.dirname(target_file))
359
+
360
+ # Copy file if it doesn't exist or is different
361
+ if !File.exist?(target_file) || File.read(source_file) != File.read(target_file)
362
+ FileUtils.cp(source_file, target_file)
363
+ puts " ๐Ÿ“ Synced: #{target_file}#{' (auto-sync)' if synced}"
364
+ synced_count += 1
365
+ else
366
+ puts " โœ… Up to date: #{target_file}"
367
+ end
368
+ end
369
+
370
+ synced_count
371
+ end
372
+
373
+ def cleanup_obsolete_files _context, expected_targets
374
+ cleanup_count = 0
375
+ obsolete_files = []
376
+ # Common vendor paths to check for obsolete files
377
+ vendor_patterns = [
378
+ File.join(CONFIG_VENDOR_DIR, '**', '*')
379
+ ]
380
+ vendor_patterns.each do |pattern|
381
+ Dir.glob(pattern).each do |file_path|
382
+ next if File.directory?(file_path)
383
+ next if file_path.include?('/.git/') # Skip git files
384
+
385
+ # Check if this file is expected based on manifest
386
+ obsolete_files << file_path unless expected_targets.include?(file_path)
387
+ end
388
+ end
389
+ return 0 if obsolete_files.empty?
390
+
391
+ puts "\n๐Ÿงน Found #{obsolete_files.length} potentially obsolete vendor files:"
392
+ obsolete_files.sort.each do |file|
393
+ puts " ๐Ÿ“„ #{file}"
394
+ end
395
+ print "\nClean up these obsolete files? [y/N]: "
396
+ response = $stdin.gets.chomp.downcase
397
+ if %w[y yes].include?(response)
398
+ obsolete_files.each do |file|
399
+ File.delete(file)
400
+ puts " ๐Ÿ—‘๏ธ Removed: #{file}"
401
+ cleanup_count += 1
402
+ rescue StandardError => e
403
+ puts " โŒ Failed to remove #{file}: #{e.message}"
404
+ end
405
+ # Clean up empty directories
406
+ vendor_patterns.each do |pattern|
407
+ base_dir = pattern.split('/**').first
408
+ next unless Dir.exist?(base_dir)
409
+
410
+ cleanup_empty_directories(base_dir)
411
+ end
412
+ else
413
+ puts 'โญ๏ธ Skipping cleanup of obsolete files'
414
+ end
415
+
416
+ cleanup_count
417
+ end
418
+
419
+ private
420
+
421
+ def cleanup_empty_directories dir_path
422
+ return unless Dir.exist?(dir_path)
423
+
424
+ # Get all subdirectories, sorted by depth (deepest first)
425
+ subdirs = Dir.glob("#{dir_path}/**/*/").sort_by { |d| -d.count('/') }
426
+
427
+ subdirs.each do |subdir|
428
+ next unless Dir.exist?(subdir)
429
+ next unless Dir.empty?(subdir)
430
+
431
+ begin
432
+ Dir.rmdir(subdir)
433
+ puts " ๐Ÿ“ Removed empty directory: #{subdir}"
434
+ rescue StandardError
435
+ # Ignore errors; directory might not be empty due to hidden files
436
+ end
437
+ end
438
+ end
439
+
440
+ def copy_doc_file source_file, target_path, synced:, force:
441
+ # Ensure target directory exists
442
+ target_dir = File.dirname(target_path)
443
+ FileUtils.mkdir_p(target_dir)
444
+
445
+ # Check if target already exists
446
+ if File.exist?(target_path)
447
+ if synced || force
448
+ # Overwrite if synced or force flag
449
+ FileUtils.cp(source_file, target_path)
450
+ puts " ๐Ÿ”„ Updated #{target_path}"
451
+ :copied
452
+ else
453
+ # Skip if not synced and no force
454
+ puts " โญ๏ธ Skipped #{target_path} (already exists, synced=false)"
455
+ :skipped
456
+ end
457
+ else
458
+ # Create new file
459
+ FileUtils.cp(source_file, target_path)
460
+ puts " โœ“ Created #{target_path}"
461
+ :copied
462
+ end
463
+ end
464
+ end
465
+ # rubocop :enable Metrics/ClassLength
466
+ end
467
+ end
468
+ end