howzit 2.1.30 → 2.1.32

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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: e1b854ebbde3ca3bdeac5fa4a633911993e6a9361c02ccc8c7364b7624edb4a1
4
- data.tar.gz: 3a7f716c2f331acb4cb767d0ea3eff2b59dfc82b8756f5716b86b6045d6c46f6
3
+ metadata.gz: 816754b14a92ca32bab9ae753a03926d0163e2af860164032b7d1fdfff58f716
4
+ data.tar.gz: 32fb6fee068779ba312051bd2613c8d47e24777760ecce28da796f2c35899fdd
5
5
  SHA512:
6
- metadata.gz: f2cec86d81ab9b1b51c65d21b0ef628483e214545bba722267e3670978305d862b634dcdb93db8bad56d66627213a60939784feb6d021d9cbd2bfc9639451bea
7
- data.tar.gz: b925f6cbe26ac39b5aa18fcd21896885b85742b88098d25e0b1fb35915983da3c602bc912bc44745fe3fd6f92396bcecdf7ff036411ffe872c54145d00fdf888
6
+ metadata.gz: 2741d38466727329dc19755f910acc88a67068b66c1665256365fecffd31ed64a9d0040b97f26140444676a74bec53b1e33c96048dd9b7044b8aecafa0e3a845
7
+ data.tar.gz: fbe6e5aa03de0eadbc3d4af7a34d67b0a1628d0926dbf0d908ab9ae8f49b31236b5b6d89d5433ef88785e0d78a9066a969ad7a757b5c81e33f7ada0a923bbc24
data/CHANGELOG.md CHANGED
@@ -1,3 +1,32 @@
1
+ ### 2.1.32
2
+
3
+ 2026-01-06 05:21
4
+
5
+ #### IMPROVED
6
+
7
+ - Completion output for howzit -L and howzit -T now shows topic titles without parenthetical variable names, making completion lists cleaner and easier to read while still preserving variable information in verbose output (howzit -R).
8
+
9
+ ### 2.1.31
10
+
11
+ 2026-01-06 04:57
12
+
13
+ #### CHANGED
14
+
15
+ - Updated Howzit to use XDG_CONFIG_HOME/howzit (or ~/.config/howzit if XDG_CONFIG_HOME is not set) for all configuration files, templates, themes, and script support files instead of ~/.local/share/howzit.
16
+
17
+ #### NEW
18
+
19
+ - Added automatic migration prompt that detects existing ~/.local/share/howzit directory and offers to migrate all files to the new config location, merging contents and overwriting existing files in the new location while preserving files that only exist in the new location.
20
+ - Added --migrate flag to explicitly trigger migration of legacy ~/.local/share/howzit directory to the new config location.
21
+
22
+ #### IMPROVED
23
+
24
+ - Migration prompt now appears during config initialization to catch legacy directories before creating new config files, preventing confusion about file locations.
25
+
26
+ #### FIXED
27
+
28
+ - Fixed ArgumentError when topic titles were longer than terminal width by ensuring horizontal rule width calculation never goes negative, clamping to zero when title exceeds available space.
29
+
1
30
  ### 2.1.30
2
31
 
3
32
  2026-01-06 03:55
data/bin/howzit CHANGED
@@ -1,4 +1,4 @@
1
- #!/usr/bin/env ruby
1
+ #!/usr/bin/env ruby -W0
2
2
  # frozen_string_literal: true
3
3
 
4
4
  $LOAD_PATH.unshift File.join(__dir__, '..', 'lib')
@@ -248,6 +248,11 @@ OptionParser.new do |opts|
248
248
  Process.exit 0
249
249
  end
250
250
 
251
+ opts.on('--migrate', 'Migrate legacy files from ~/.local/share/howzit to the new config location') do
252
+ Howzit::ScriptSupport.migrate_legacy_support(early_init: false)
253
+ Process.exit 0
254
+ end
255
+
251
256
  opts.on('-v', '--version', 'Display version number') do
252
257
  puts "#{File.basename(__FILE__)} v#{Howzit::VERSION}"
253
258
  Process.exit 0
@@ -21,9 +21,31 @@ module Howzit
21
21
  content = Util.read_file(file)
22
22
  raise "{br}No content found in build note (#{file}){x}".c if content.nil? || content.empty?
23
23
 
24
+ # Global metadata from config (e.g. ~/.config/howzit/howzit.yml)
25
+ # Expecting something like:
26
+ # metadata:
27
+ # author: Brett Terpstra
28
+ # license: MIT
29
+ global_meta = {}
30
+ raw_global = Howzit.options[:metadata] || Howzit.options['metadata']
31
+ if raw_global.is_a?(Hash)
32
+ raw_global.each do |k, v|
33
+ global_meta[k.to_s.downcase] = v
34
+ end
35
+ end
36
+
37
+ # Metadata defined in the build note itself (before the first heading)
24
38
  this_meta = content.split(/^#/)[0].strip.metadata
25
39
 
26
- @metadata = meta.nil? ? this_meta : meta.merge(this_meta)
40
+ # Merge order:
41
+ # 1. Global config metadata (lowest precedence)
42
+ # 2. Metadata passed in via meta argument (e.g. templates)
43
+ # 3. Build note metadata (highest precedence)
44
+ combined_meta = global_meta.dup
45
+ combined_meta.merge!(meta) if meta
46
+ combined_meta.merge!(this_meta) if this_meta
47
+
48
+ @metadata = combined_meta
27
49
 
28
50
  read_help(file)
29
51
  end
@@ -146,9 +168,7 @@ module Howzit
146
168
  ##
147
169
  def list_topics
148
170
  @topics.map do |topic|
149
- title = topic.title
150
- title += "(#{topic.named_args.keys.join(', ')})" unless topic.named_args.empty?
151
- title
171
+ topic.title
152
172
  end
153
173
  end
154
174
 
@@ -173,9 +193,7 @@ module Howzit
173
193
  @topics.each do |topic|
174
194
  next unless topic.tasks.count.positive?
175
195
 
176
- title = topic.title
177
- title += "(#{topic.named_args.keys.join(', ')})" unless topic.named_args.empty?
178
- output.push(title)
196
+ output.push(topic.title)
179
197
  end
180
198
  output.join("\n")
181
199
  end
data/lib/howzit/config.rb CHANGED
@@ -251,6 +251,14 @@ module Howzit
251
251
  ## @param default [Hash] default configuration to write
252
252
  ##
253
253
  def create_config(default)
254
+ # If a legacy ~/.local/share/howzit directory exists, offer to migrate it
255
+ # into the new config root before creating any new files to avoid confusion
256
+ # about where Howzit stores its configuration.
257
+ # Use early_init=true since we're called during config initialization and can't access Howzit.options yet
258
+ if defined?(Howzit::ScriptSupport) && File.directory?(File.expand_path(Howzit::ScriptSupport::LEGACY_SUPPORT_DIR))
259
+ Howzit::ScriptSupport.migrate_legacy_support(early_init: true)
260
+ end
261
+
254
262
  unless File.directory?(config_dir)
255
263
  Howzit::ConsoleLogger.new(1).info "Creating config directory at #{config_dir}"
256
264
  FileUtils.mkdir_p(config_dir)
@@ -8,7 +8,15 @@ module Howzit
8
8
  # Handles helper script installation and injection for run blocks
9
9
  # rubocop:disable Metrics/ModuleLength
10
10
  module ScriptSupport
11
- SUPPORT_DIR = '~/.local/share/howzit/support'
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
12
20
 
13
21
  class << self
14
22
  ##
@@ -25,11 +33,96 @@ module Howzit
25
33
  ##
26
34
  def ensure_support_dir
27
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
+
28
43
  FileUtils.mkdir_p(dir) unless File.directory?(dir)
29
44
  install_helper_scripts
30
45
  dir
31
46
  end
32
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
+
33
126
  ##
34
127
  ## Detect interpreter from hashbang line
35
128
  ##
@@ -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
@@ -3,5 +3,5 @@
3
3
  # Primary module for this gem.
4
4
  module Howzit
5
5
  # Current Howzit version.
6
- VERSION = '2.1.30'
6
+ VERSION = '2.1.32'
7
7
  end
data/lib/howzit.rb CHANGED
@@ -1,7 +1,12 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  # Main config dir
4
- CONFIG_DIR = '~/.config/howzit'
4
+ # Prefer XDG_CONFIG_HOME if set, otherwise fall back to ~/.config/howzit
5
+ CONFIG_DIR = if ENV['XDG_CONFIG_HOME'] && !ENV['XDG_CONFIG_HOME'].empty?
6
+ File.join(ENV['XDG_CONFIG_HOME'], 'howzit')
7
+ else
8
+ File.join(Dir.home, '.config', 'howzit')
9
+ end
5
10
 
6
11
  # Config file name
7
12
  CONFIG_FILE = 'howzit.yaml'
@@ -8,7 +8,7 @@ describe Howzit::BuildNote do
8
8
  describe ".note_file" do
9
9
  it "locates a build note file" do
10
10
  expect(how.note_file).not_to be_empty
11
- expect(how.note_file).to match /builda.md$/
11
+ expect(how.note_file).to match(/builda.md$/)
12
12
  end
13
13
  end
14
14
 
@@ -16,6 +16,7 @@ describe Howzit::BuildNote do
16
16
  it "finds topic containing 'bermuda'" do
17
17
  expect(how.grep('bermuda').map { |topic| topic.title }).to include('Topic Tropic')
18
18
  end
19
+
19
20
  it "does not return non-matching topic" do
20
21
  expect(how.grep('bermuda').map { |topic| topic.title }).not_to include('Topic Balogna')
21
22
  end
@@ -116,8 +117,9 @@ describe Howzit::BuildNote do
116
117
  it "contains 7 topics" do
117
118
  expect(how.list_topics.count).to eq 7
118
119
  end
120
+
119
121
  it "outputs a newline-separated string for completion" do
120
- expect(how.list_completions.scan(/\n/).count).to eq 6
122
+ expect(how.list_completions.scan("\n").count).to eq 6
121
123
  end
122
124
  end
123
125
 
@@ -159,7 +161,7 @@ describe Howzit::BuildNote do
159
161
 
160
162
  it "falls back to fuzzy match when no exact match" do
161
163
  Howzit.options[:matching] = 'fuzzy'
162
- search_terms = ['trpc'] # fuzzy for 'tropic'
164
+ search_terms = ['trpc'] # fuzzy for 'tropic'
163
165
  output = []
164
166
  matches = how.send(:collect_topic_matches, search_terms, output)
165
167
  expect(matches.count).to eq 1
@@ -228,7 +230,7 @@ describe Howzit::BuildNote do
228
230
 
229
231
  it "parses required variables from template metadata" do
230
232
  vars = how.send(:parse_template_required_vars, template_with_required.path)
231
- expect(vars).to eq(['repo_url', 'author'])
233
+ expect(vars).to eq(%w[repo_url author])
232
234
  end
233
235
 
234
236
  it "returns empty array when no required metadata" do
@@ -236,4 +238,31 @@ describe Howzit::BuildNote do
236
238
  expect(vars).to eq([])
237
239
  end
238
240
  end
241
+
242
+ describe 'global metadata from config' do
243
+ it 'merges config metadata under metadata key before file metadata' do
244
+ # Simulate config metadata in Howzit.options
245
+ Howzit.options[:metadata] = { 'author' => 'Config Author', 'license' => 'MIT' }
246
+
247
+ note = <<~EONOTE
248
+ author: Note Author
249
+
250
+ # Test Note
251
+
252
+ ## Section
253
+ EONOTE
254
+
255
+ Tempfile.create(['buildnote_meta', '.md']) do |f|
256
+ f.write(note)
257
+ f.flush
258
+
259
+ build = Howzit::BuildNote.new(file: f.path)
260
+
261
+ expect(build.metadata['author']).to eq('Note Author')
262
+ expect(build.metadata['license']).to eq('MIT')
263
+ end
264
+ ensure
265
+ Howzit.options[:metadata] = nil
266
+ end
267
+ end
239
268
  end
@@ -78,5 +78,45 @@ describe 'StringUtils' do
78
78
  expect(result).to eq('echo ${BASH_VAR}')
79
79
  end
80
80
  end
81
- end
82
81
 
82
+ describe '#metadata' do
83
+ before do
84
+ Howzit.named_arguments = {}
85
+ end
86
+
87
+ it 'parses MultiMarkdown-style metadata up to first blank line' do
88
+ text = <<~META
89
+ author: Note Author
90
+ license: MIT
91
+
92
+ This is the rest of the file.
93
+ author: Should not override
94
+ META
95
+
96
+ meta = text.metadata
97
+ expect(meta['author']).to eq('Note Author')
98
+ expect(meta['license']).to eq('MIT')
99
+ end
100
+
101
+ it 'parses YAML front matter when starting with ---' do
102
+ text = <<~META
103
+ ---
104
+ author: Brett Terpstra
105
+ license: MIT
106
+ ---
107
+
108
+ # Title
109
+ META
110
+
111
+ meta = text.metadata
112
+ expect(meta['author']).to eq('Brett Terpstra')
113
+ expect(meta['license']).to eq('MIT')
114
+ end
115
+
116
+ it 'returns empty hash when there is no metadata' do
117
+ text = "Just some content\nWithout metadata\n"
118
+ meta = text.metadata
119
+ expect(meta).to eq({})
120
+ end
121
+ end
122
+ end
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: howzit
3
3
  version: !ruby/object:Gem::Version
4
- version: 2.1.30
4
+ version: 2.1.32
5
5
  platform: ruby
6
6
  authors:
7
7
  - Brett Terpstra