diff --git a/config/examples/basic.yml b/config/examples/basic.yml index e4dcc98..fff886c 100644 --- a/config/examples/basic.yml +++ b/config/examples/basic.yml @@ -34,6 +34,9 @@ frameworks: # xcframework_output: ../SDKs # parallel_builds: false # clean_before_build: true +# verbose: false # Enable verbose output (shows xcodebuild logs) +# use_formatter: true # Use xcbeautify/xcpretty for formatted output +# # Can also be: "xcbeautify" or "xcpretty" to specify # Optional: Publishing settings # publishing: diff --git a/lib/xcframework_cli.rb b/lib/xcframework_cli.rb index eff4147..3723dd0 100644 --- a/lib/xcframework_cli.rb +++ b/lib/xcframework_cli.rb @@ -8,6 +8,7 @@ require_relative 'xcframework_cli/config/schema' require_relative 'xcframework_cli/config/defaults' require_relative 'xcframework_cli/platform/registry' +require_relative 'xcframework_cli/xcodebuild/formatter' require_relative 'xcframework_cli/xcodebuild/wrapper' require_relative 'xcframework_cli/builder/cleaner' require_relative 'xcframework_cli/builder/archiver' diff --git a/lib/xcframework_cli/builder/archiver.rb b/lib/xcframework_cli/builder/archiver.rb index 396ee3c..0131ceb 100644 --- a/lib/xcframework_cli/builder/archiver.rb +++ b/lib/xcframework_cli/builder/archiver.rb @@ -45,14 +45,17 @@ def build_archive(platform_identifier, options = {}) ) # Execute archive command - result = Xcodebuild::Wrapper.execute_archive( + archive_options = { project: project_path, scheme: scheme, destination: platform.destination, archive_path: archive_path, build_settings: build_settings, derived_data_path: derived_data_path - ) + } + archive_options[:use_formatter] = options[:use_formatter] if options.key?(:use_formatter) + + result = Xcodebuild::Wrapper.execute_archive(archive_options) if result.success? # Clean private Swift interfaces diff --git a/lib/xcframework_cli/builder/orchestrator.rb b/lib/xcframework_cli/builder/orchestrator.rb index 89f7709..998b0f9 100644 --- a/lib/xcframework_cli/builder/orchestrator.rb +++ b/lib/xcframework_cli/builder/orchestrator.rb @@ -167,6 +167,7 @@ def build_platform_archives build_options = {} build_options[:deployment_target] = config[:deployment_target] if config[:deployment_target] + build_options[:use_formatter] = config[:use_formatter] if config.key?(:use_formatter) archiver.build_archives(config[:platforms], build_options) end diff --git a/lib/xcframework_cli/cli/commands/build.rb b/lib/xcframework_cli/cli/commands/build.rb index c2e581a..dcfaa5d 100644 --- a/lib/xcframework_cli/cli/commands/build.rb +++ b/lib/xcframework_cli/cli/commands/build.rb @@ -14,8 +14,10 @@ module Commands module Build class << self def execute(options) - setup_logger(options) + # Load configuration first to get verbose setting config = load_configuration(options) + # Setup logger with config settings (CLI options override) + setup_logger(options, config[:_raw_config]) validate_configuration(config) run_build(config) rescue XCFrameworkCLI::Error => e @@ -29,9 +31,13 @@ def execute(options) private - def setup_logger(options) - Utils::Logger.verbose = options[:verbose] - Utils::Logger.quiet = options[:quiet] + def setup_logger(options, config = nil) + # CLI options override config file settings + verbose = options[:verbose] || (config && config[:build]&.[](:verbose)) || false + quiet = options[:quiet] || false + + Utils::Logger.verbose = verbose + Utils::Logger.quiet = quiet end def load_configuration(options) @@ -73,7 +79,9 @@ def load_from_config_file(options) output_dir: output_dir, platforms: framework[:platforms] || options[:platforms], clean: config[:build][:clean_before_build].nil? ? options[:clean] : config[:build][:clean_before_build], - include_debug_symbols: options[:debug_symbols] + include_debug_symbols: options[:debug_symbols], + use_formatter: config[:build][:use_formatter], + _raw_config: config } end # rubocop:enable Metrics/AbcSize, Metrics/PerceivedComplexity @@ -88,7 +96,8 @@ def load_from_command_line(options) output_dir: options[:output], platforms: options[:platforms], clean: options[:clean], - include_debug_symbols: options[:debug_symbols] + include_debug_symbols: options[:debug_symbols], + use_formatter: true # Default to true for command-line mode } end diff --git a/lib/xcframework_cli/config/defaults.rb b/lib/xcframework_cli/config/defaults.rb index 2d9ecc0..2b90451 100644 --- a/lib/xcframework_cli/config/defaults.rb +++ b/lib/xcframework_cli/config/defaults.rb @@ -9,7 +9,9 @@ module Defaults output_dir: 'build', xcframework_output: '../SDKs', parallel_builds: false, - clean_before_build: true + clean_before_build: true, + verbose: false, + use_formatter: true }.freeze # Default deployment targets for each platform diff --git a/lib/xcframework_cli/config/schema.rb b/lib/xcframework_cli/config/schema.rb index b4f04bc..2a9b10b 100644 --- a/lib/xcframework_cli/config/schema.rb +++ b/lib/xcframework_cli/config/schema.rb @@ -49,6 +49,8 @@ class Schema < Dry::Validation::Contract optional(:xcframework_output).filled(:string) optional(:parallel_builds).filled(:bool) optional(:clean_before_build).filled(:bool) + optional(:verbose).filled(:bool) + optional(:use_formatter).filled end optional(:publishing).hash do diff --git a/lib/xcframework_cli/xcodebuild/formatter.rb b/lib/xcframework_cli/xcodebuild/formatter.rb new file mode 100644 index 0000000..2aab305 --- /dev/null +++ b/lib/xcframework_cli/xcodebuild/formatter.rb @@ -0,0 +1,188 @@ +# frozen_string_literal: true + +require 'open3' + +module XCFrameworkCLI + module Xcodebuild + # Formatter for xcodebuild output + # Detects and uses xcbeautify or xcpretty if available + class Formatter + FORMATTERS = %w[xcbeautify xcpretty].freeze + + class << self + # Check which formatter is available + # + # @return [String, nil] Name of available formatter or nil + def detect_formatter + FORMATTERS.find { |formatter| command_available?(formatter) } + end + + # Check if a command is available in PATH + # + # @param command [String] Command name + # @return [Boolean] true if command is available + def command_available?(command) + !`which #{command}`.strip.empty? + rescue StandardError + false + end + + # Get formatter command with options + # + # @param formatter [String, nil] Formatter name ('xcbeautify', 'xcpretty', or nil) + # @return [Array, nil] Formatter command with args, or nil if no formatter + def formatter_command(formatter = nil) + case formatter + when 'xcbeautify' + ['xcbeautify'] + when 'xcpretty' + ['xcpretty', '--color'] + else + nil + end + end + + # Execute xcodebuild command with optional output formatting + # + # @param xcodebuild_command [Array] Full xcodebuild command with arguments + # @param stream_output [Boolean] Whether to stream output in real-time + # @param use_formatter [Boolean, String] true to auto-detect, String to specify formatter, false to disable + # @return [Hash] Result hash with :success, :stdout, :stderr, :exit_code + # rubocop:disable Metrics/AbcSize, Metrics/MethodLength + def execute_with_formatting(xcodebuild_command, stream_output: false, use_formatter: true) + # Determine formatter to use + formatter = if use_formatter == false + nil + elsif use_formatter.is_a?(String) + use_formatter if command_available?(use_formatter) + else + detect_formatter + end + + if stream_output && formatter + execute_with_pipe_streaming(xcodebuild_command, formatter) + elsif stream_output + execute_with_streaming(xcodebuild_command) + else + execute_without_streaming(xcodebuild_command) + end + end + # rubocop:enable Metrics/AbcSize, Metrics/MethodLength + + private + + # Execute with formatter pipe and real-time streaming + # + # @param xcodebuild_command [Array] xcodebuild command + # @param formatter [String] Formatter name + # @return [Hash] Result hash + # rubocop:disable Metrics/AbcSize, Metrics/MethodLength + def execute_with_pipe_streaming(xcodebuild_command, formatter) + formatter_cmd = formatter_command(formatter) + stdout_lines = [] + stderr_lines = [] + + Utils::Logger.debug("Using formatter: #{formatter}") + + # Pipe xcodebuild through formatter + Open3.popen3(*xcodebuild_command) do |_stdin, xcode_stdout, xcode_stderr, xcode_wait_thr| + Open3.popen3(*formatter_cmd) do |formatter_stdin, formatter_stdout, _formatter_stderr, formatter_wait_thr| + # Thread to pipe xcodebuild stdout to formatter stdin + pipe_thread = Thread.new do + xcode_stdout.each_line do |line| + formatter_stdin.puts(line) + end + formatter_stdin.close + end + + # Thread to capture and stream formatted output + stdout_thread = Thread.new do + formatter_stdout.each_line do |line| + puts line # Print to console + stdout_lines << line + end + end + + # Thread to capture stderr + stderr_thread = Thread.new do + xcode_stderr.each_line do |line| + warn line # Print to stderr + stderr_lines << line + end + end + + # Wait for all threads + pipe_thread.join + stdout_thread.join + stderr_thread.join + + xcode_status = xcode_wait_thr.value + formatter_wait_thr.value # Wait for formatter to finish + + { + success: xcode_status.success?, + stdout: stdout_lines.join, + stderr: stderr_lines.join, + exit_code: xcode_status.exitstatus + } + end + end + end + # rubocop:enable Metrics/AbcSize, Metrics/MethodLength + + # Execute with real-time streaming (no formatter) + # + # @param command [Array] Command to execute + # @return [Hash] Result hash + def execute_with_streaming(command) + stdout_lines = [] + stderr_lines = [] + + Open3.popen3(*command) do |_stdin, stdout, stderr, wait_thr| + # Stream stdout + stdout_thread = Thread.new do + stdout.each_line do |line| + puts line + stdout_lines << line + end + end + + # Stream stderr + stderr_thread = Thread.new do + stderr.each_line do |line| + warn line + stderr_lines << line + end + end + + stdout_thread.join + stderr_thread.join + status = wait_thr.value + + { + success: status.success?, + stdout: stdout_lines.join, + stderr: stderr_lines.join, + exit_code: status.exitstatus + } + end + end + + # Execute without streaming (capture all output) + # + # @param command [Array] Command to execute + # @return [Hash] Result hash + def execute_without_streaming(command) + stdout, stderr, status = Open3.capture3(*command) + + { + success: status.success?, + stdout: stdout, + stderr: stderr, + exit_code: status.exitstatus + } + end + end + end + end +end diff --git a/lib/xcframework_cli/xcodebuild/wrapper.rb b/lib/xcframework_cli/xcodebuild/wrapper.rb index 4794527..956fbc2 100644 --- a/lib/xcframework_cli/xcodebuild/wrapper.rb +++ b/lib/xcframework_cli/xcodebuild/wrapper.rb @@ -2,6 +2,7 @@ require 'open3' require_relative 'result' +require_relative 'formatter' module XCFrameworkCLI module Xcodebuild @@ -17,6 +18,7 @@ class Wrapper # @option options [String] :archive_path Path where archive will be created # @option options [Hash] :build_settings Additional build settings (default: {}) # @option options [String, nil] :derived_data_path Optional derived data path + # @option options [Boolean, String] :use_formatter Use output formatter (default: true) # @return [Result] Command execution result def self.execute_archive(options) args = ['archive'] @@ -32,15 +34,19 @@ def self.execute_archive(options) args << "#{key}=#{value}" end - execute('xcodebuild', args) + execute_options = {} + execute_options[:use_formatter] = options[:use_formatter] if options.key?(:use_formatter) + + execute('xcodebuild', args, execute_options) end # Execute xcodebuild -create-xcframework command # # @param frameworks [Array] Array of framework hashes with :path and optional :debug_symbols # @param output [String] Output path for the XCFramework + # @param use_formatter [Boolean, String] Use output formatter (default: true) # @return [Result] Command execution result - def self.execute_create_xcframework(frameworks:, output:) + def self.execute_create_xcframework(frameworks:, output:, use_formatter: true) args = ['-create-xcframework'] frameworks.each do |framework| @@ -50,7 +56,7 @@ def self.execute_create_xcframework(frameworks:, output:) args += ['-output', output] - execute('xcodebuild', args) + execute('xcodebuild', args, { use_formatter: use_formatter }) end # Execute xcodebuild clean command @@ -72,27 +78,42 @@ def self.execute_clean(project:, scheme:, derived_data_path: nil) # # @param command [String] Command to execute # @param args [Array] Command arguments + # @param options [Hash] Execution options + # @option options [Boolean] :stream_output Stream output in real-time (default: verbose mode) + # @option options [Boolean, String] :use_formatter Use output formatter (default: true) # @return [Result] Command execution result - def self.execute(command, args) + def self.execute(command, args, options = {}) full_command = [command] + args command_string = full_command.join(' ') Utils::Logger.debug("Executing: #{command_string}") - stdout, stderr, status = Open3.capture3(*full_command) + # Determine if we should stream output (default to verbose mode) + stream_output = options.fetch(:stream_output, Utils::Logger.verbose) + use_formatter = options.fetch(:use_formatter, true) + + # Execute with formatter if streaming + result_hash = Formatter.execute_with_formatting( + full_command, + stream_output: stream_output, + use_formatter: stream_output ? use_formatter : false + ) result = Result.new( - success: status.success?, - stdout: stdout, - stderr: stderr, - exit_code: status.exitstatus, + success: result_hash[:success], + stdout: result_hash[:stdout], + stderr: result_hash[:stderr], + exit_code: result_hash[:exit_code], command: command_string ) if result.failure? Utils::Logger.error("Command failed: #{command_string}") Utils::Logger.error("Exit code: #{result.exit_code}") - Utils::Logger.error("Error output: #{result.error_message}") unless result.error_message.empty? + # Only show error output if we didn't already stream it + unless stream_output + Utils::Logger.error("Error output: #{result.error_message}") unless result.error_message.empty? + end else Utils::Logger.debug("Command succeeded: #{command_string}") end