howzit 2.1.29 → 2.1.31

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.
@@ -0,0 +1,572 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'fileutils'
4
+ require 'shellwords'
5
+
6
+ module Howzit
7
+ # Script Support module
8
+ # Handles helper script installation and injection for run blocks
9
+ # rubocop:disable Metrics/ModuleLength
10
+ module ScriptSupport
11
+ # Prefer XDG_CONFIG_HOME/howzit/support if set, otherwise ~/.config/howzit/support.
12
+ # For backwards compatibility, we detect an existing ~/.local/share/howzit directory
13
+ # and can optionally migrate it to the new location.
14
+ LEGACY_SUPPORT_DIR = File.join(Dir.home, '.local', 'share', 'howzit')
15
+ SUPPORT_DIR = if ENV['XDG_CONFIG_HOME'] && !ENV['XDG_CONFIG_HOME'].empty?
16
+ File.join(ENV['XDG_CONFIG_HOME'], 'howzit', 'support')
17
+ else
18
+ File.join(Dir.home, '.config', 'howzit', 'support')
19
+ end
20
+
21
+ class << self
22
+ ##
23
+ ## Get the support directory path
24
+ ##
25
+ ## @return [String] expanded path to support directory
26
+ ##
27
+ def support_dir
28
+ File.expand_path(SUPPORT_DIR)
29
+ end
30
+
31
+ ##
32
+ ## Ensure support directory exists and is populated
33
+ ##
34
+ def ensure_support_dir
35
+ dir = support_dir
36
+ legacy_dir = File.expand_path(LEGACY_SUPPORT_DIR)
37
+ new_root = File.expand_path(File.join(SUPPORT_DIR, '..'))
38
+
39
+ # If legacy files exist, always offer to migrate them before proceeding.
40
+ # Use early_init=false here since config is already loaded by the time we reach this point
41
+ migrate_legacy_support(early_init: false) if File.directory?(legacy_dir)
42
+
43
+ FileUtils.mkdir_p(dir) unless File.directory?(dir)
44
+ install_helper_scripts
45
+ dir
46
+ end
47
+
48
+ ##
49
+ ## Simple Y/N prompt that doesn't depend on Howzit.options (for use during early config initialization)
50
+ ##
51
+ def simple_yn_prompt(prompt, default: true)
52
+ return default unless $stdout.isatty
53
+
54
+ tty_state = `stty -g`
55
+ yn = default ? '[Y/n]' : '[y/N]'
56
+ $stdout.syswrite "\e[1;37m#{prompt} #{yn}\e[1;37m? \e[0m"
57
+ system 'stty raw -echo cbreak isig'
58
+ res = $stdin.sysread 1
59
+ res.chomp!
60
+ puts
61
+ system 'stty cooked'
62
+ system "stty #{tty_state}"
63
+ res.empty? ? default : res =~ /y/i
64
+ end
65
+
66
+ ##
67
+ ## Migrate legacy ~/.local/share/howzit directory into the new config root,
68
+ ## merging contents and removing the legacy directory when complete.
69
+ ##
70
+ ## @param early_init [Boolean] If true, use simple prompt that doesn't access Howzit.options
71
+ ##
72
+ def migrate_legacy_support(early_init: false)
73
+ legacy_dir = File.expand_path(LEGACY_SUPPORT_DIR)
74
+ new_root = File.expand_path(File.join(SUPPORT_DIR, '..'))
75
+
76
+ unless File.directory?(legacy_dir)
77
+ if early_init
78
+ return
79
+ else
80
+ Howzit.console.info "No legacy Howzit directory found at #{legacy_dir}; nothing to migrate."
81
+ return
82
+ end
83
+ end
84
+
85
+ prompt = "Migrate Howzit files from #{legacy_dir} to #{new_root}? This will overwrite files in the new location with legacy versions, " \
86
+ 'add new files, and leave any extra files in the new location in place.'
87
+
88
+ if early_init
89
+ unless simple_yn_prompt(prompt, default: true)
90
+ $stderr.puts 'Migration cancelled; no changes made.'
91
+ return
92
+ end
93
+ else
94
+ unless Prompt.yn(prompt, default: true)
95
+ Howzit.console.info 'Migration cancelled; no changes made.'
96
+ return
97
+ end
98
+ end
99
+
100
+ FileUtils.mkdir_p(new_root) unless File.directory?(new_root)
101
+
102
+ # Merge legacy into new_root:
103
+ # - overwrite files in new_root with versions from legacy
104
+ # - add files that do not yet exist
105
+ # - leave files that exist only in new_root untouched
106
+ Dir.children(legacy_dir).each do |entry|
107
+ src = File.join(legacy_dir, entry)
108
+ dest = File.join(new_root, entry)
109
+
110
+ if File.directory?(src)
111
+ FileUtils.mkdir_p(dest)
112
+ FileUtils.cp_r(File.join(src, '.'), dest)
113
+ else
114
+ FileUtils.cp(src, dest)
115
+ end
116
+ end
117
+
118
+ FileUtils.rm_rf(legacy_dir)
119
+ if early_init
120
+ $stderr.puts "Migrated Howzit files from #{legacy_dir} to #{new_root}."
121
+ else
122
+ Howzit.console.info "Migrated Howzit files from #{legacy_dir} to #{new_root}."
123
+ end
124
+ end
125
+
126
+ ##
127
+ ## Detect interpreter from hashbang line
128
+ ##
129
+ ## @param script_content [String] The script content
130
+ ##
131
+ ## @return [Symbol, nil] Language identifier (:bash, :zsh, :fish, :ruby, :python, etc.)
132
+ ##
133
+ def detect_interpreter(script_content)
134
+ first_line = script_content.lines.first&.strip
135
+ return nil unless first_line&.start_with?('#!')
136
+
137
+ shebang = first_line.sub(/^#!/, '').strip
138
+
139
+ case shebang
140
+ when %r{/bin/bash}, %r{/usr/bin/env bash}
141
+ :bash
142
+ when %r{/bin/zsh}, %r{/usr/bin/env zsh}
143
+ :zsh
144
+ when %r{/bin/fish}, %r{/usr/bin/env fish}
145
+ :fish
146
+ when %r{/usr/bin/env ruby}, %r{/usr/bin/ruby}, %r{/usr/local/bin/ruby}
147
+ :ruby
148
+ when %r{/usr/bin/env python3?}, %r{/usr/bin/python3?}, %r{/usr/local/bin/python3?}
149
+ :python
150
+ when %r{/usr/bin/env perl}, %r{/usr/bin/perl}
151
+ :perl
152
+ when %r{/usr/bin/env node}, %r{/usr/bin/node}
153
+ :node
154
+ end
155
+ end
156
+
157
+ ##
158
+ ## Get the injection line for a given interpreter
159
+ ##
160
+ ## @param interpreter [Symbol] The interpreter type
161
+ ##
162
+ ## @return [String, nil] The injection line to add
163
+ ##
164
+ def injection_line_for(interpreter)
165
+ support_path = support_dir
166
+ case interpreter
167
+ when :bash, :zsh
168
+ "source \"#{support_path}/howzit.sh\""
169
+ when :fish
170
+ "source \"#{support_path}/howzit.fish\""
171
+ when :ruby
172
+ "require '#{support_path}/howzit.rb'"
173
+ when :python
174
+ "import sys\nsys.path.insert(0, '#{support_path}')\nimport howzit"
175
+ when :perl
176
+ "require '#{support_path}/howzit.pl'"
177
+ when :node
178
+ "require('#{support_path}/howzit.js')"
179
+ end
180
+ end
181
+
182
+ ##
183
+ ## Inject helper script loading into script content
184
+ ##
185
+ ## @param script_content [String] The original script content
186
+ ##
187
+ ## @return [Array] [modified_content, interpreter] Script content with injection added and interpreter
188
+ ##
189
+ def inject_helper(script_content)
190
+ interpreter = detect_interpreter(script_content)
191
+ return [script_content, nil] unless interpreter
192
+
193
+ injection = injection_line_for(interpreter)
194
+ return [script_content, interpreter] unless injection
195
+
196
+ lines = script_content.lines
197
+ injection_lines = injection.split("\n").map { |l| "#{l}\n" }
198
+ # Find the hashbang line
199
+ if lines.first&.strip&.start_with?('#!')
200
+ # Insert after hashbang
201
+ injection_lines.each_with_index do |line, idx|
202
+ lines.insert(1 + idx, line)
203
+ end
204
+ else
205
+ # No hashbang, prepend
206
+ lines = injection_lines + lines
207
+ end
208
+
209
+ [lines.join, interpreter]
210
+ end
211
+
212
+ ##
213
+ ## Get the command to execute a script based on interpreter
214
+ ##
215
+ ## @param script_path [String] Path to the script file
216
+ ## @param interpreter [Symbol, nil] The interpreter type
217
+ ##
218
+ ## @return [String] Command to execute the script
219
+ ##
220
+ def execution_command_for(script_path, interpreter)
221
+ cmd = case interpreter
222
+ when :bash
223
+ "/bin/bash #{Shellwords.escape(script_path)}"
224
+ when :zsh
225
+ "/bin/zsh #{Shellwords.escape(script_path)}"
226
+ when :fish
227
+ "/usr/bin/env fish #{Shellwords.escape(script_path)}"
228
+ when :ruby
229
+ "/usr/bin/env ruby #{Shellwords.escape(script_path)}"
230
+ when :python
231
+ "/usr/bin/env python3 #{Shellwords.escape(script_path)}"
232
+ when :perl
233
+ "/usr/bin/env perl #{Shellwords.escape(script_path)}"
234
+ when :node
235
+ "/usr/bin/env node #{Shellwords.escape(script_path)}"
236
+ end
237
+ # Fallback to direct execution if interpreter not recognized
238
+ cmd || script_path
239
+ end
240
+
241
+ ##
242
+ ## Install all helper scripts
243
+ ##
244
+ def install_helper_scripts
245
+ dir = support_dir
246
+ FileUtils.mkdir_p(dir)
247
+
248
+ install_bash_helper(dir)
249
+ install_fish_helper(dir)
250
+ install_ruby_helper(dir)
251
+ install_python_helper(dir)
252
+ install_perl_helper(dir)
253
+ install_node_helper(dir)
254
+ end
255
+
256
+ private
257
+
258
+ ##
259
+ ## Install bash/zsh helper script
260
+ ##
261
+ def install_bash_helper(dir)
262
+ file = File.join(dir, 'howzit.sh')
263
+ return if File.exist?(file) && !file_stale?(file)
264
+
265
+ content = <<~BASH
266
+ #!/bin/bash
267
+ # Howzit helper functions for bash/zsh
268
+
269
+ # Log functions
270
+ log() {
271
+ local level="$1"
272
+ shift
273
+ local message="$*"
274
+ if [ -n "$HOWZIT_COMM_FILE" ]; then
275
+ echo "LOG:$level:$message" >> "$HOWZIT_COMM_FILE"
276
+ fi
277
+ }
278
+
279
+ log_info() { log info "$@"; }
280
+ log_warn() { log warn "$@"; }
281
+ log_error() { log error "$@"; }
282
+ log_debug() { log debug "$@"; }
283
+
284
+ # Set variable function
285
+ set_var() {
286
+ local var_name="$1"
287
+ local var_value="$2"
288
+ if [ -n "$HOWZIT_COMM_FILE" ]; then
289
+ echo "VAR:$var_name=$var_value" >> "$HOWZIT_COMM_FILE"
290
+ fi
291
+ }
292
+ BASH
293
+
294
+ File.write(file, content)
295
+ File.chmod(0o644, file)
296
+ end
297
+
298
+ ##
299
+ ## Install fish helper script
300
+ ##
301
+ def install_fish_helper(dir)
302
+ file = File.join(dir, 'howzit.fish')
303
+ return if File.exist?(file) && !file_stale?(file)
304
+
305
+ content = <<~FISH
306
+ #!/usr/bin/env fish
307
+ # Howzit helper functions for fish
308
+
309
+ function log -d "Log a message at the specified level"
310
+ set level $argv[1]
311
+ set -e argv[1]
312
+ set message (string join " " $argv)
313
+ if test -n "$HOWZIT_COMM_FILE"
314
+ echo "LOG:$level:$message" >> "$HOWZIT_COMM_FILE"
315
+ end
316
+ end
317
+
318
+ function log_info -d "Log an info message"
319
+ log info $argv
320
+ end
321
+
322
+ function log_warn -d "Log a warning message"
323
+ log warn $argv
324
+ end
325
+
326
+ function log_error -d "Log an error message"
327
+ log error $argv
328
+ end
329
+
330
+ function log_debug -d "Log a debug message"
331
+ log debug $argv
332
+ end
333
+
334
+ function set_var -d "Set a variable for howzit"
335
+ set var_name $argv[1]
336
+ set var_value $argv[2]
337
+ if test -n "$HOWZIT_COMM_FILE"
338
+ echo "VAR:$var_name=$var_value" >> "$HOWZIT_COMM_FILE"
339
+ end
340
+ end
341
+ FISH
342
+
343
+ File.write(file, content)
344
+ File.chmod(0o644, file)
345
+ end
346
+
347
+ ##
348
+ ## Install Ruby helper script
349
+ ##
350
+ def install_ruby_helper(dir)
351
+ file = File.join(dir, 'howzit.rb')
352
+ return if File.exist?(file) && !file_stale?(file)
353
+
354
+ content = <<~'RUBY'
355
+ # frozen_string_literal: true
356
+
357
+ # Howzit helper module for Ruby
358
+ module Howzit
359
+ class << self
360
+ # Log methods
361
+ def logger
362
+ @logger ||= Logger.new
363
+ end
364
+
365
+ class Logger
366
+ def info(message)
367
+ log(:info, message)
368
+ end
369
+
370
+ def warn(message)
371
+ log(:warn, message)
372
+ end
373
+
374
+ def error(message)
375
+ log(:error, message)
376
+ end
377
+
378
+ def debug(message)
379
+ log(:debug, message)
380
+ end
381
+
382
+ private
383
+
384
+ def log(level, message)
385
+ comm_file = ENV['HOWZIT_COMM_FILE']
386
+ return unless comm_file
387
+
388
+ File.open(comm_file, 'a') do |f|
389
+ f.puts "LOG:#{level}:#{message}"
390
+ end
391
+ end
392
+ end
393
+
394
+ # Set variable method
395
+ def set_var(name, value)
396
+ comm_file = ENV['HOWZIT_COMM_FILE']
397
+ return unless comm_file
398
+
399
+ File.open(comm_file, 'a') do |f|
400
+ f.puts "VAR:#{name}=#{value}"
401
+ end
402
+ end
403
+ end
404
+ end
405
+ RUBY
406
+
407
+ File.write(file, content)
408
+ File.chmod(0o644, file)
409
+ end
410
+
411
+ ##
412
+ ## Install Python helper script
413
+ ##
414
+ def install_python_helper(dir)
415
+ file = File.join(dir, 'howzit.py')
416
+ return if File.exist?(file) && !file_stale?(file)
417
+
418
+ content = <<~PYTHON
419
+ #!/usr/bin/env python3
420
+ # Howzit helper module for Python
421
+
422
+ import os
423
+
424
+ class _Logger:
425
+ def _log(self, level, message):
426
+ comm_file = os.environ.get('HOWZIT_COMM_FILE')
427
+ if comm_file:
428
+ with open(comm_file, 'a') as f:
429
+ f.write(f"LOG:{level}:{message}\\n")
430
+
431
+ def info(self, message):
432
+ self._log('info', message)
433
+
434
+ def warn(self, message):
435
+ self._log('warn', message)
436
+
437
+ def error(self, message):
438
+ self._log('error', message)
439
+
440
+ def debug(self, message):
441
+ self._log('debug', message)
442
+
443
+ class Howzit:
444
+ logger = _Logger()
445
+
446
+ @staticmethod
447
+ def set_var(name, value):
448
+ comm_file = os.environ.get('HOWZIT_COMM_FILE')
449
+ if comm_file:
450
+ with open(comm_file, 'a') as f:
451
+ f.write(f"VAR:{name}={value}\\n")
452
+ PYTHON
453
+
454
+ File.write(file, content)
455
+ File.chmod(0o644, file)
456
+ end
457
+
458
+ ##
459
+ ## Install Perl helper script
460
+ ##
461
+ def install_perl_helper(dir)
462
+ file = File.join(dir, 'howzit.pl')
463
+ return if File.exist?(file) && !file_stale?(file)
464
+
465
+ content = <<~PERL
466
+ #!/usr/bin/env perl
467
+ # Howzit helper module for Perl
468
+
469
+ package Howzit;
470
+
471
+ use strict;
472
+ use warnings;
473
+
474
+ sub log {
475
+ my ($level, $message) = @_;
476
+ my $comm_file = $ENV{'HOWZIT_COMM_FILE'};
477
+ return unless $comm_file;
478
+
479
+ open(my $fh, '>>', $comm_file) or return;
480
+ print $fh "LOG:$level:$message\\n";
481
+ close($fh);
482
+ }
483
+
484
+ sub log_info { log('info', $_[0]); }
485
+ sub log_warn { log('warn', $_[0]); }
486
+ sub log_error { log('error', $_[0]); }
487
+ sub log_debug { log('debug', $_[0]); }
488
+
489
+ sub set_var {
490
+ my ($name, $value) = @_;
491
+ my $comm_file = $ENV{'HOWZIT_COMM_FILE'};
492
+ return unless $comm_file;
493
+
494
+ open(my $fh, '>>', $comm_file) or return;
495
+ print $fh "VAR:$name=$value\\n";
496
+ close($fh);
497
+ }
498
+
499
+ 1;
500
+ PERL
501
+
502
+ File.write(file, content)
503
+ File.chmod(0o644, file)
504
+ end
505
+
506
+ ##
507
+ ## Install Node.js helper script
508
+ ##
509
+ def install_node_helper(dir)
510
+ file = File.join(dir, 'howzit.js')
511
+ return if File.exist?(file) && !file_stale?(file)
512
+
513
+ content = <<~JAVASCRIPT
514
+ // Howzit helper module for Node.js
515
+
516
+ const fs = require('fs');
517
+ const path = require('path');
518
+
519
+ class Logger {
520
+ _log(level, message) {
521
+ const commFile = process.env.HOWZIT_COMM_FILE;
522
+ if (commFile) {
523
+ fs.appendFileSync(commFile, `LOG:${level}:${message}\\n`);
524
+ }
525
+ }
526
+
527
+ info(message) {
528
+ this._log('info', message);
529
+ }
530
+
531
+ warn(message) {
532
+ this._log('warn', message);
533
+ }
534
+
535
+ error(message) {
536
+ this._log('error', message);
537
+ }
538
+
539
+ debug(message) {
540
+ this._log('debug', message);
541
+ }
542
+ }
543
+
544
+ class Howzit {
545
+ static logger = new Logger();
546
+
547
+ static setVar(name, value) {
548
+ const commFile = process.env.HOWZIT_COMM_FILE;
549
+ if (commFile) {
550
+ fs.appendFileSync(commFile, `VAR:${name}=${value}\\n`);
551
+ }
552
+ }
553
+ }
554
+
555
+ module.exports = { Howzit, Logger };
556
+ JAVASCRIPT
557
+
558
+ File.write(file, content)
559
+ File.chmod(0o644, file)
560
+ end
561
+
562
+ ##
563
+ ## Check if a file is stale and needs updating
564
+ ## For now, always update to ensure latest version
565
+ ##
566
+ def file_stale?(_file)
567
+ true
568
+ end
569
+ end
570
+ # rubocop:enable Metrics/ModuleLength
571
+ end
572
+ end
@@ -407,15 +407,47 @@ module Howzit
407
407
  end
408
408
 
409
409
  ##
410
- ## Examine text for multimarkdown-style metadata and return key/value pairs
410
+ ## Examine text for metadata and return key/value pairs
411
+ ##
412
+ ## Supports:
413
+ ## - YAML front matter (starting with --- and ending with --- or ...)
414
+ ## - MultiMarkdown-style key: value lines (up to first blank line)
411
415
  ##
412
416
  ## @return [Hash] The metadata as key/value pairs
413
417
  ##
414
418
  def metadata
415
419
  data = {}
416
- scan(/(?mi)^(\S[\s\S]+?): ([\s\S]*?)(?=\n\S[\s\S]*?:|\Z)/).each do |m|
417
- data[m[0].strip.downcase] = m[1]
420
+ lines = to_s.lines
421
+ first_idx = lines.index { |l| l !~ /^\s*$/ }
422
+ return {} unless first_idx
423
+
424
+ first = lines[first_idx]
425
+
426
+ if first =~ /^---\s*$/
427
+ # YAML front matter: between first --- and closing --- or ...
428
+ closing_rel = lines[(first_idx + 1)..].index { |l| l =~ /^(---|\.\.\.)\s*$/ }
429
+ closing_idx = closing_rel ? first_idx + 1 + closing_rel : lines.length
430
+ yaml_body = lines[(first_idx + 1)...closing_idx].join
431
+ raw = yaml_body.strip.empty? ? {} : YAML.load(yaml_body) || {}
432
+ if raw.is_a?(Hash)
433
+ raw.each do |k, v|
434
+ data[k.to_s.downcase] = v
435
+ end
436
+ end
437
+ else
438
+ # MultiMarkdown-style: key: value lines up to first blank line
439
+ header_lines = []
440
+ lines[first_idx..].each do |l|
441
+ break if l =~ /^\s*$/
442
+
443
+ header_lines << l
444
+ end
445
+ header = header_lines.join
446
+ header.scan(/(?mi)^(\S[\s\S]+?): ([\s\S]*?)(?=\n\S[\s\S]*?:|\Z)/).each do |m|
447
+ data[m[0].strip.downcase] = m[1]
448
+ end
418
449
  end
450
+
419
451
  out = normalize_metadata(data)
420
452
  Howzit.named_arguments ||= {}
421
453
  Howzit.named_arguments = out.merge(Howzit.named_arguments)
@@ -491,11 +523,21 @@ module Howzit
491
523
  cols = Howzit.options[:wrap] if Howzit.options[:wrap].positive? && cols > Howzit.options[:wrap]
492
524
  title = Color.template("#{options[:border]}#{options[:hr] * 2}( #{options[:color]}#{title}#{options[:border]} )")
493
525
 
526
+ # Calculate remaining width for horizontal rule, ensuring it is never negative
527
+ remaining = cols - title.uncolor.length
528
+ if should_mark_iterm?
529
+ # Reserve some space for the iTerm mark escape sequence in the visual layout
530
+ remaining -= 15
531
+ end
532
+ remaining = 0 if remaining.negative?
533
+
534
+ hr_tail = options[:hr] * remaining
494
535
  tail = if should_mark_iterm?
495
- "#{options[:hr] * (cols - title.uncolor.length - 15)}#{options[:mark] ? iterm_marker : ''}"
536
+ "#{hr_tail}#{options[:mark] ? iterm_marker : ''}"
496
537
  else
497
- options[:hr] * (cols - title.uncolor.length)
538
+ hr_tail
498
539
  end
540
+
499
541
  Color.template("\n\n#{title}#{tail}{x}\n\n")
500
542
  end
501
543
  end