Class: Ukiryu::Shell::PowerShell

Inherits:
Base
  • Object
show all
Defined in:
lib/ukiryu/shell/powershell.rb

Overview

PowerShell shell implementation

PowerShell uses single quotes for literal strings and backtick for escaping special characters inside double quotes. Environment variables are referenced with $ENV:NAME syntax.

Constant Summary collapse

SHELL_NAME =
:powershell
PLATFORM =
:powershell
EXECUTABLE =
'pwsh'

Constants inherited from Base

Base::SPECIAL_CHARS_PATTERN, Base::WHITESPACE_PATTERN

Class Method Summary collapse

Instance Method Summary collapse

Methods inherited from Base

#encoding, #environment_to_h, #format_environment, #supports?

Class Method Details

.detect_alias(_command_name) ⇒ Hash?

Detect if a command is a PowerShell alias

PowerShell has aliases but they are PowerShell-level constructs, not shell aliases. We don’t detect PowerShell aliases for executable discovery.

Parameters:

  • command_name (String)

    the command to check

Returns:

  • (Hash, nil)

    always returns nil (no alias detection)



26
27
28
29
30
# File 'lib/ukiryu/shell/powershell.rb', line 26

def self.detect_alias(_command_name)
  # PowerShell aliases are runtime constructs, not shell aliases
  # They don't help with executable discovery
  nil
end

Instance Method Details

#capabilitiesHash

PowerShell capabilities on Windows

Returns:

  • (Hash)

    capability flags



190
191
192
193
194
195
196
# File 'lib/ukiryu/shell/powershell.rb', line 190

def capabilities
  {
    supports_display: false, # Windows doesn't use DISPLAY
    supports_ansi_colors: true,
    encoding: Encoding::UTF_8 # PowerShell uses UTF-8
  }
end

#env_var(name) ⇒ String

Format an environment variable reference

Parameters:

  • name (String)

    the variable name

Returns:

  • (String)

    the formatted reference ($ENV:NAME)



112
113
114
# File 'lib/ukiryu/shell/powershell.rb', line 112

def env_var(name)
  "$ENV:#{name}"
end

#escape(string) ⇒ String

Escape a string for PowerShell

  • Single quotes are escaped by doubling them (for single-quoted strings)

  • Backtick, dollar, and double quotes are escaped with backtick (for double-quoted strings)

Note: This method escapes for single-quoted strings by default since we use single quotes for arguments to prevent parameter binding issues.

Parameters:

  • string (String)

    the string to escape

Returns:

  • (String)

    the escaped string



54
55
56
57
# File 'lib/ukiryu/shell/powershell.rb', line 54

def escape(string)
  # For single-quoted strings, escape single quotes by doubling them
  string.to_s.gsub("'", "''")
end

#escape_for_double_quotes(string) ⇒ String

Escape a string for double-quoted PowerShell strings Used for executable paths which need double quotes

Parameters:

  • string (String)

    the string to escape

Returns:

  • (String)

    the escaped string



64
65
66
# File 'lib/ukiryu/shell/powershell.rb', line 64

def escape_for_double_quotes(string)
  string.to_s.gsub(/[`"$]/) { "`#{::Regexp.last_match(0)}" }
end

#execute_command(executable, args, env, timeout, cwd = nil) ⇒ Hash

Execute a command using PowerShell

On Windows: Uses cmd /c to execute commands. This provides more predictable quoting behavior than PowerShell’s Start-Process for native executables with arguments containing spaces.

On Unix: Uses the call operator (&) since PowerShell on Unix doesn’t have the parameter binding issues that Windows PowerShell has.

Parameters:

  • executable (String)

    the executable path

  • args (Array<String>)

    command arguments

  • env (Environment)

    environment variables

  • timeout (Integer)

    timeout in seconds

  • cwd (String, nil) (defaults to: nil)

    working directory (nil for current directory)

Returns:

  • (Hash)

    execution result with :status, :stdout, :stderr keys

Raises:

  • (Timeout::Error)

    if command times out



214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
# File 'lib/ukiryu/shell/powershell.rb', line 214

def execute_command(executable, args, env, timeout, cwd = nil)
  # Debug logging for CI - helps identify where prefix stripping might occur
  if ENV['UKIRYU_DEBUG_EXECUTABLE']
    warn "[UKIRYU DEBUG PowerShell#execute_command] executable: #{executable.inspect}"
    warn "[UKIRYU DEBUG PowerShell#execute_command] args: #{args.inspect}"
    warn "[UKIRYU DEBUG PowerShell#execute_command] args.class: #{args.class}"
    args.each_with_index do |a, i|
      warn "[UKIRYU DEBUG PowerShell#execute_command] args[#{i}]: #{a.inspect} (#{a.class})"
      # Check for nested arrays which would cause stringification issues
      warn "[UKIRYU DEBUG PowerShell#execute_command] WARNING: args[#{i}] is a NESTED ARRAY!" if a.is_a?(Array)
    end
  end

  # Build the command line with proper quoting
  if Platform.windows?
    # On Windows: Use PowerShell call operator with single quotes for all arguments
    # Single quotes are completely literal in PowerShell - no parameter binding issues
    # This works for both paths with spaces and without spaces
    exe_normalized = executable.to_s.gsub('/', '\\')
    exe_escaped = exe_normalized.gsub("'", "''")

    args_escaped = args.map do |a|
      # NOTE: Do NOT convert forward slashes to backslashes in arguments!
      # Many tools (like Inkscape) on Windows expect forward slashes in paths
      # because backslashes can be interpreted as escape characters.
      # PowerShell single quotes handle paths correctly regardless of separator.
      arg_str = a.to_s
      if arg_str.start_with?('-')
        # Arguments starting with - must be single-quoted to prevent PowerShell's
        # parameter binder from stripping the prefix (e.g., -sDEVICE=pdfwrite -> =pdfwrite)
        escaped = arg_str.gsub("'", "''")
        "'#{escaped}'"
      elsif arg_str.include?('$') || arg_str.include?('`')
        # Use single quotes for arguments with $ or ` to prevent expansion
        escaped = arg_str.gsub("'", "''")
        "'#{escaped}'"
      elsif arg_str.include?(' ') || arg_str.include?('"')
        # Use single quotes for spaces or quotes (completely literal)
        escaped = arg_str.gsub("'", "''")
        "'#{escaped}'"
      else
        arg_str
      end
    end

    # Propagate exit code from external command using $LASTEXITCODE
  else
    # On Unix: Use the call operator directly with single quotes
    # Single quotes are completely literal in PowerShell (no variable expansion)
    exe_escaped = executable.to_s.gsub("'", "''")

    args_escaped = args.map do |a|
      arg_str = a.to_s
      # Quote arguments that contain special PowerShell characters or
      # start with dash (to prevent parameter binding)
      if arg_str.include?(' ') || arg_str.start_with?('-') || arg_str.include?('$') || arg_str.include?('`') || arg_str.include?(';')
        # Use single quotes for completely literal strings
        escaped = arg_str.gsub("'", "''")
        "'#{escaped}'"
      else
        arg_str
      end
    end

    # Propagate exit code from external command using $LASTEXITCODE
  end
  full_command = ["'#{exe_escaped}'", *args_escaped].join(' ')
  warn "[UKIRYU DEBUG PowerShell#execute_command] full_command: #{full_command.inspect}" if ENV['UKIRYU_DEBUG_EXECUTABLE']
  ps_command = "& #{full_command}; exit $LASTEXITCODE"

  warn "[UKIRYU DEBUG PowerShell#execute_command] ps_command:\n#{ps_command}" if ENV['UKIRYU_DEBUG_EXECUTABLE']

  # Convert Environment to Hash ONLY at Open3 call site
  env_hash = environment_to_h(env)

  # Execute using PowerShell's -Command flag
  Timeout.timeout(timeout) do
    execution = lambda do
      stdout, stderr, status = Open3.capture3(env_hash, powershell_command, '-NoLogo', '-Command', ps_command)
      {
        status: Ukiryu::Executor.extract_status(status),
        stdout: stdout,
        stderr: stderr
      }
    end

    if cwd
      Dir.chdir(cwd) { execution.call }
    else
      execution.call
    end
  end
rescue Timeout::Error, Timeout::ExitException
  # Re-raise with context
  raise Timeout::Error, "Command timed out after #{timeout}s: #{executable}"
end

#execute_command_with_stdin(executable, args, env, timeout, cwd, stdin_data) ⇒ Hash

Execute a command with stdin input using PowerShell

Uses platform-specific execution with stdin redirection.

Parameters:

  • executable (String)

    the executable path

  • args (Array<String>)

    command arguments

  • env (Environment)

    environment variables

  • timeout (Integer)

    timeout in seconds

  • cwd (String, nil)

    working directory (nil for current directory)

  • stdin_data (String, IO)

    stdin input data

Returns:

  • (Hash)

    execution result with :status, :stdout, :stderr keys

Raises:

  • (Timeout::Error)

    if command times out



323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
# File 'lib/ukiryu/shell/powershell.rb', line 323

def execute_command_with_stdin(executable, args, env, timeout, cwd, stdin_data)
  # Write stdin to temp file for redirection
  stdin_file = Tempfile.new('ukiryu_stdin')
  begin
    if stdin_data.is_a?(IO)
      IO.copy_stream(stdin_data, stdin_file)
    elsif stdin_data.is_a?(String)
      stdin_file.write(stdin_data)
    end
    stdin_file.close

    stdin_path = stdin_file.path

    if Platform.windows?
      # On Windows: Use PowerShell call operator with single quotes for all arguments
      # Single quotes are completely literal in PowerShell - no parameter binding issues
      # This works for both paths with spaces and without spaces
      exe_normalized = executable.to_s.gsub('/', '\\')
      exe_escaped = exe_normalized.gsub("'", "''")

      args_escaped = args.map do |a|
        arg_str = a.to_s.gsub('/', '\\')
        if arg_str.start_with?('-')
          # Arguments starting with - must be single-quoted to prevent PowerShell's
          # parameter binder from stripping the prefix (e.g., -sDEVICE=pdfwrite -> =pdfwrite)
          escaped = arg_str.gsub("'", "''")
          "'#{escaped}'"
        elsif arg_str.include?('$') || arg_str.include?('`')
          # Use single quotes for arguments with $ or ` to prevent expansion
          escaped = arg_str.gsub("'", "''")
          "'#{escaped}'"
        elsif arg_str.include?(' ') || arg_str.include?('"')
          # Use single quotes for spaces or quotes (completely literal)
          escaped = arg_str.gsub("'", "''")
          "'#{escaped}'"
        else
          arg_str
        end
      end

      # Propagate exit code from external command using $LASTEXITCODE
    else
      # On Unix: Use the call operator with stdin redirection
      # Single quotes are completely literal in PowerShell (no variable expansion)
      exe_escaped = executable.to_s.gsub("'", "''")

      args_escaped = args.map do |a|
        arg_str = a.to_s
        # Quote arguments that contain special PowerShell characters or
        # start with dash (to prevent parameter binding)
        if arg_str.include?(' ') || arg_str.start_with?('-') || arg_str.include?('$') || arg_str.include?('`') || arg_str.include?(';')
          escaped = arg_str.gsub("'", "''")
          "'#{escaped}'"
        else
          arg_str
        end
      end

      # Use Get-Content to read stdin file and pipe to command
      # Propagate exit code from external command using $LASTEXITCODE
    end
    full_command = ["'#{exe_escaped}'", *args_escaped].join(' ')
    ps_command = "Get-Content '#{stdin_path.gsub("'", "''")}' | & #{full_command}; exit $LASTEXITCODE"

    env_hash = environment_to_h(env)

    Timeout.timeout(timeout) do
      execution = lambda do
        stdout, stderr, status = Open3.capture3(env_hash, powershell_command, '-NoLogo', '-Command', ps_command)
        {
          status: Ukiryu::Executor.extract_status(status),
          stdout: stdout,
          stderr: stderr
        }
      end

      if cwd
        Dir.chdir(cwd) { execution.call }
      else
        execution.call
      end
    end
  ensure
    stdin_file.unlink
  end
rescue Timeout::Error, Timeout::ExitException
  raise Timeout::Error, "Command timed out after #{timeout}s: #{executable}"
end

#format_path(path) ⇒ String

Format a file path for PowerShell on Windows

Returns the path unchanged. Quoting for paths with spaces is handled by the quote method, which wraps paths in double quotes with proper escaping.

Parameters:

  • path (String)

    the file path

Returns:

  • (String)

    the formatted path



124
125
126
# File 'lib/ukiryu/shell/powershell.rb', line 124

def format_path(path)
  path.to_s
end

#headless_environmentHash

PowerShell doesn’t need DISPLAY variable

Returns:

  • (Hash)

    empty hash (no headless environment needed)



183
184
185
# File 'lib/ukiryu/shell/powershell.rb', line 183

def headless_environment
  {}
end

#join(executable, *args) ⇒ String

Join executable and arguments into a command line Uses smart quoting: only quote arguments that need it

Special handling for -Command and -File: The argument after these parameters should NOT be quoted because PowerShell will parse it

Parameters:

  • executable (String)

    the executable path

  • args (Array<String>)

    the arguments

Returns:

  • (String)

    the complete command line



137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
# File 'lib/ukiryu/shell/powershell.rb', line 137

def join(executable, *args)
  # Debug logging for CI - helps identify where prefix stripping might occur
  if ENV['UKIRYU_DEBUG_EXECUTABLE']
    warn "[UKIRYU DEBUG PowerShell#join] executable: #{executable.inspect}"
    warn "[UKIRYU DEBUG PowerShell#join] args: #{args.inspect}"
    warn "[UKIRYU DEBUG PowerShell#join] args.size: #{args.size}"
    warn "[UKIRYU DEBUG PowerShell#join] args.class: #{args.class}"
    args.each_with_index do |a, i|
      warn "[UKIRYU DEBUG PowerShell#join] args[#{i}]: #{a.inspect} (#{a.class})"
      # Check for nested arrays which would cause stringification issues
      if a.is_a?(Array)
        warn "[UKIRYU DEBUG PowerShell#join] WARNING: args[#{i}] is a NESTED ARRAY!"
        warn "[UKIRYU DEBUG PowerShell#join] This will be converted to string: #{a.to_s.inspect}"
      end
    end
  end

  # Quote executable if it needs quoting (e.g., contains spaces)
  # Use double quotes for executables (works in both cmd.exe and PowerShell)
  # Ruby's Open3 on Windows uses cmd.exe, not PowerShell
  exe_formatted = needs_quoting?(executable) ? quote(executable, for_exe: true) : executable

  # Track when we see -Command or -File to skip quoting the next argument
  skip_quote = false
  args_formatted = args.map do |a|
    if skip_quote
      # Don't quote the script/file argument - PowerShell will parse it
      skip_quote = false
      a
    elsif ['-Command', '-File'].include?(a)
      skip_quote = true
      a
    elsif needs_quoting?(a)
      quote(a)
    else
      # For simple strings, pass without quotes
      # PowerShell treats them as literal strings
      a
    end
  end
  [exe_formatted, *args_formatted].join(' ')
end

#nameObject



32
33
34
# File 'lib/ukiryu/shell/powershell.rb', line 32

def name
  :powershell
end

#needs_quoting?(string) ⇒ Boolean

Check if a string needs quoting for PowerShell Overrides base class to add PowerShell-specific handling

In PowerShell, arguments starting with - are interpreted as PowerShell parameters when passed to the call operator (&). This causes the prefix to be stripped (e.g., -sDEVICE=pdfwrite becomes =pdfwrite). To prevent this, we must quote all arguments starting with -.

Also, arguments containing $ must be quoted to prevent variable expansion.

Parameters:

  • string (String)

    the string to check

Returns:

  • (Boolean)

    true if quoting is needed



80
81
82
83
84
85
86
87
88
89
90
91
92
# File 'lib/ukiryu/shell/powershell.rb', line 80

def needs_quoting?(string)
  str = string.to_s
  # Call super for base checks (empty, whitespace, special chars)
  return true if super(string)
  # PowerShell-specific: arguments starting with - must be quoted
  # to prevent PowerShell's parameter binder from stripping the prefix
  return true if str.start_with?('-')
  # PowerShell-specific: arguments containing $ must be quoted
  # to prevent variable expansion
  return true if str.include?('$')

  false
end

#powershell_commandString

Get the PowerShell executable for the current platform On Windows: powershell.exe On Unix/macOS: pwsh (PowerShell Core)

Returns:

  • (String)

    the PowerShell executable command



41
42
43
# File 'lib/ukiryu/shell/powershell.rb', line 41

def powershell_command
  Platform.windows? ? 'powershell' : 'pwsh'
end

#quote(string, for_exe: false) ⇒ String

Quote an argument for PowerShell Uses double quotes for all arguments to prevent PowerShell’s parameter binder from stripping dash prefixes. Double quotes work consistently across both the call operator (&) and Start-Process.

Parameters:

  • string (String)

    the string to quote

  • for_exe (Boolean) (defaults to: false)

    true if quoting for executable path (same behavior)

Returns:

  • (String)

    the quoted string



102
103
104
105
106
# File 'lib/ukiryu/shell/powershell.rb', line 102

def quote(string, for_exe: false)
  # Always use double quotes - this prevents PowerShell's parameter binder
  # from stripping dash prefixes in all contexts (call operator, Start-Process)
  "\"#{escape_for_double_quotes(string)}\""
end