howzit 2.1.45 → 2.1.47

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: 3b5d2120c8484147d2d3cb8f8844073ca027e7a54655385749ee721f645d05a5
4
- data.tar.gz: 3cef9cda18bf1b43b28d9288e9eda81d30b07fc9ac2505dd932e508240abf86b
3
+ metadata.gz: 477bc94918f7906d52412423ee73992f2d255279d757f36d0feed85f74c1f454
4
+ data.tar.gz: 926727bfe69e059367fa75d4b55b095786663487cdc8f311b13ee92dd44d15e0
5
5
  SHA512:
6
- metadata.gz: 4dc4af7fce8a375a33a3dfee3ed2e6f9d914ea3069601aa3426ae552f4c5488d5aafa332522c4eef0a6b4318bd63afef20eda2d638e9c6fd09ce9784c5e6a434
7
- data.tar.gz: fe60529771944221e4f7acd4bd9883669782f413c5a367ae3c03c8c7d8ce0a0e0413fcf4e086e37584868c58bbea10bb4e24ab49c28583521d901166404146be
6
+ metadata.gz: 8f4c4228bc522c7608f77d0ab4e7f4f802753e71c7f37d2f41d9f81e01764a8b0b285aaed8bc5bad6a9ccaa0544d83007bde4c7aea7c0e41bc2fc834bb8304d7
7
+ data.tar.gz: a24505c7b3f22f7d4bb52eb91372f44d0ef200285faf4006bb8a93588bdb63318063d26b5571026374f8f67b648678d0bd4edf4223c5561c39ad625ec2a08a69
data/CHANGELOG.md CHANGED
@@ -1,3 +1,44 @@
1
+ ### 2.1.47
2
+
3
+ 2026-09-27 13:20
4
+
5
+ #### IMPROVED
6
+
7
+ - **Included topics** receive their bracketed arguments as positional arguments ($1, $2) in shell run blocks for the duration of the include
8
+
9
+ #### FIXED
10
+
11
+ - **default: metadata** now splits topics only on commas outside of brackets, so `default: Build, Deploy[prod, fast], Test` passes both arguments to Deploy
12
+ - **Bracketed arguments in default:** (e.g. `Deploy[prod]`) are now bound to the topic's title parameters, so `${target}` and friends render in @run commands instead of coming out empty
13
+ - **@include bracketed arguments** (e.g. `@include(Deploy [staging])`) now bind to the included topic's parameters, and including the same topic multiple times with different arguments works
14
+ - **Unspecified bracketed arguments** fall back to the topic's parameter defaults
15
+ - **Topics that use @include with arguments** no longer lose their own argument definitions
16
+
17
+ ### 2.1.46
18
+
19
+ 2026-09-27 12:52
20
+
21
+ #### CHANGED
22
+
23
+ - **Shell run blocks** (sh, bash, zsh, dash, ksh, or no hashbang) now receive Howzit variables as environment variables instead of having ${VAR} replaced in the script text, so scripts can use their own variables, parameter expansions, and ENV without Howzit clobbering them.
24
+ - **Command-line arguments** after -- are passed to shell run blocks as script arguments, so $1, $#, and "$@" work like any shell script (including inside shell functions).
25
+
26
+ #### NEW
27
+
28
+ - **:shell_variables: config option** (env or substitute) to switch shell run blocks back to text substitution; defaults to env.
29
+ - **$${VAR} escape** passes ${VAR} through to the shell untouched in @run, @copy, and text-substituted blocks.
30
+
31
+ #### IMPROVED
32
+
33
+ - **Howzit-style defaults** (${name:default}) in shell run blocks are converted to the shell's ${name:-default} so they keep working with environment variables.
34
+
35
+ #### FIXED
36
+
37
+ - **Shell parameter expansions** like ${VAR:-default}, ${VAR:=x}, ${VAR:+x}, ${VAR:?x}, and ${VAR:0:3} are no longer mangled into Howzit defaults when VAR isn't a Howzit variable.
38
+ - **Positional placeholders** ($1, ${2}) without a matching argument are left intact instead of being replaced with an empty string, which broke shell functions and awk '{print $1}'.
39
+ - **$@ and $*** are only replaced when arguments were passed after --, instead of always being replaced with an empty string.
40
+ - **Default values containing colons** (e.g. ${url:http://localhost:3000}) are no longer cut off at the second colon.
41
+
1
42
  ### 2.1.45
2
43
 
3
44
  2026-09-27 12:16
@@ -1608,7 +1608,7 @@ module Howzit
1608
1608
  else
1609
1609
  []
1610
1610
  end
1611
- output.push(process_topic(topic_match, Howzit.options[:run], single: true))
1611
+ output.push(process_topic(topic_match.with_arguments(Howzit.arguments), Howzit.options[:run], single: true))
1612
1612
  break if stop_after_failure?(topic_specs[(idx + 1)..].map(&:first))
1613
1613
  end
1614
1614
  finalize_output(output)
@@ -1640,7 +1640,8 @@ module Howzit
1640
1640
  ## @return [Array] Array of topic specification strings
1641
1641
  ##
1642
1642
  def parse_default_metadata(default_value)
1643
- default_value.strip.split(/\s*,\s*/).map(&:strip).reject(&:empty?)
1643
+ # Commas inside [bracketed arguments] separate arguments, not topics
1644
+ default_value.strip.split(/\s*,\s*(?![^\[]*\])/).map(&:strip).reject(&:empty?)
1644
1645
  end
1645
1646
 
1646
1647
  ##
data/lib/howzit/config.rb CHANGED
@@ -21,6 +21,7 @@ module Howzit
21
21
  output_title: false,
22
22
  pager: 'auto',
23
23
  paginate: true,
24
+ shell_variables: 'env', # env: pass variables to shell run blocks via ENV, substitute: replace ${VAR} in text
24
25
  show_all_code: false,
25
26
  show_all_on_error: false,
26
27
  wrap: 0
@@ -138,7 +138,7 @@ module Howzit
138
138
  if title =~ /\[(.*?)\] *$/
139
139
  args = Regexp.last_match(1).split(/ *, */).map(&:render_arguments)
140
140
  Howzit.arguments = args
141
- parent.arguments
141
+ task_data[:include_args] = args
142
142
  title.sub!(/ *\[.*?\] *$/, '')
143
143
  end
144
144
  title = title.render_arguments if title && !title.empty?
@@ -153,6 +153,35 @@ module Howzit
153
153
  end
154
154
  end
155
155
 
156
+ ##
157
+ ## Whether a run block is a POSIX-style shell script (sh, bash,
158
+ ## zsh, dash, ksh). Blocks without a hashbang run with sh.
159
+ ##
160
+ ## @param script_content [String] The script content
161
+ ##
162
+ ## @return [Boolean]
163
+ ##
164
+ def shell_script?(script_content)
165
+ first_line = script_content.to_s.lines.first&.strip
166
+ return true unless first_line&.start_with?('#!')
167
+
168
+ first_line.match?(%r{\A#!\s*(?:/usr/bin/env\s+)?(?:\S*/)?(?:sh|bash|zsh|dash|ksh)(?:\s|\z)})
169
+ end
170
+
171
+ ##
172
+ ## Howzit variables as environment variables for shell run blocks
173
+ ##
174
+ ## @return [Hash] name => string value
175
+ ##
176
+ def variables_env
177
+ (Howzit.named_arguments || {}).each_with_object({}) do |(key, value), env|
178
+ name = key.to_s
179
+ next if value.nil? || !name.match?(/\A[A-Za-z_][A-Za-z0-9_]*\z/)
180
+
181
+ env[name] = value.to_s
182
+ end
183
+ end
184
+
156
185
  ##
157
186
  ## Get the injection line for a given interpreter
158
187
  ##
@@ -383,29 +383,64 @@ module Howzit
383
383
  str = dup
384
384
  str.render_named_placeholders
385
385
  str.render_numeric_placeholders
386
- Howzit.arguments.nil? ? str : str.gsub(/\$[@*]/, Shellwords.join(Howzit.arguments))
386
+ args = Howzit.arguments
387
+ return str if args.nil? || args.empty?
388
+
389
+ str.gsub(/(?<!\$)\$[@*]/, Shellwords.join(args))
387
390
  end
388
391
 
392
+ ##
393
+ ## Replace ${NAME} and ${NAME:default} with Howzit variable
394
+ ## values. Names Howzit doesn't know, shell parameter
395
+ ## expansions (${VAR:-x}, ${VAR:=x}, ${VAR:0:3}, etc.), and
396
+ ## escaped $${NAME} placeholders are left for the shell.
397
+ ##
389
398
  def render_named_placeholders
390
- gsub!(/\$\{(?<name>[A-Z0-9_]+(?::.*?)?)\}/i) do
399
+ gsub!(/(?<!\$)\$\{(?<name>[A-Z0-9_]+)(?<rest>:[^}]*)?\}/i) do
391
400
  m = Regexp.last_match
392
- arg, default = m['name'].split(/:/).map(&:strip)
393
- if Howzit.named_arguments&.key?(arg) && !Howzit.named_arguments[arg].nil?
394
- Howzit.named_arguments[arg]
395
- elsif default
396
- default
401
+ original = m[0]
402
+ rest = m['rest'].to_s
403
+ value = placeholder_value(m['name'])
404
+
405
+ if rest.empty?
406
+ value.nil? ? original : value
407
+ elsif rest.start_with?(':-')
408
+ value.nil? || value.empty? || rest.include?('$') ? original : value
409
+ elsif rest.match?(/\A:[=+?\s\d]/)
410
+ original
397
411
  else
398
- # Preserve the original ${VAR} syntax if variable is not defined and no default provided
399
- m[0]
412
+ value.nil? ? rest[1..].strip : value
400
413
  end
401
414
  end
402
415
  end
403
416
 
404
417
  def render_numeric_placeholders
405
- gsub!(/\$\{?(\d+)\}?/) do
406
- arg, default = Regexp.last_match(1).split(/:/)
407
- idx = arg.to_i - 1
408
- Howzit.arguments.length > idx ? Howzit.arguments[idx] : default || Regexp.last_match(0)
418
+ gsub!(/(?<!\$)\$(?:\{(\d+)\}|(\d+))/) do
419
+ m = Regexp.last_match
420
+ placeholder_value(m[1] || m[2]) || m[0]
421
+ end
422
+ end
423
+
424
+ ##
425
+ ## Remove the escape from $${NAME} placeholders, leaving
426
+ ## ${NAME} for the shell. Applied right before execution.
427
+ ##
428
+ ## @return [String] unescaped string
429
+ ##
430
+ def unescape_placeholders
431
+ gsub(/\$\$(?=\{)/, '$')
432
+ end
433
+
434
+ ##
435
+ ## Convert Howzit-style ${NAME:default} to the shell's
436
+ ## ${NAME:-default} so defaults work when variables are
437
+ ## passed to a shell script through the environment
438
+ ##
439
+ ## @return [String] converted string
440
+ ##
441
+ def shellify_defaults
442
+ gsub(/(?<!\$)\$\{([A-Za-z_][A-Za-z0-9_]*|\d+):(?![-=+?\s\d$(])([^}]*)\}/) do
443
+ "${#{Regexp.last_match(1)}:-#{Regexp.last_match(2)}}"
409
444
  end
410
445
  end
411
446
 
@@ -560,6 +595,24 @@ module Howzit
560
595
  Color.template("\n\n#{title}#{tail}{x}\n\n")
561
596
  end
562
597
  end
598
+
599
+ private
600
+
601
+ ##
602
+ ## Look up a placeholder name: digits are positional
603
+ ## arguments, anything else is a named variable
604
+ ##
605
+ ## @return [String, nil] value, or nil if not defined
606
+ ##
607
+ def placeholder_value(name)
608
+ if name.match?(/\A\d+\z/)
609
+ idx = name.to_i - 1
610
+ args = Howzit.arguments || []
611
+ return idx >= 0 && idx < args.length ? args[idx].to_s : nil
612
+ end
613
+
614
+ Howzit.named_arguments&.[](name)&.to_s
615
+ end
563
616
  end
564
617
  end
565
618
 
data/lib/howzit/task.rb CHANGED
@@ -27,12 +27,14 @@ module Howzit
27
27
  @prefix = "{bw}\u{25B7}\u{25B7} {x}"
28
28
  # arrow = "{bw}\u{279F}{x}"
29
29
  @arguments = attributes[:arguments] || []
30
+ @include_args = attributes[:include_args]
30
31
 
31
32
  @type = attributes[:type] || :run
32
33
  @title = attributes[:title]&.to_s
33
34
  @parent = attributes[:parent] || nil
34
35
 
35
- @action = attributes[:action].render_arguments || nil
36
+ action = attributes[:action]
37
+ @action = shell_env_block?(action) ? action : action.render_arguments
36
38
  @log_level = attributes[:log_level]
37
39
  # Get source_file from parent topic if available, or from attributes
38
40
  parent_obj = attributes[:parent]
@@ -61,15 +63,37 @@ module Howzit
61
63
  @title
62
64
  end
63
65
 
66
+ ##
67
+ ## Whether this is a shell run block that receives Howzit
68
+ ## variables through the environment instead of text substitution
69
+ ##
70
+ ## @param content [String] The block content
71
+ ##
72
+ def shell_env_block?(content = @action)
73
+ @type == :block &&
74
+ Howzit.options[:shell_variables].to_s != 'substitute' &&
75
+ ScriptSupport.shell_script?(content)
76
+ end
77
+
64
78
  ##
65
79
  ## Execute a block type
66
80
  ##
67
81
  def run_block
68
82
  Howzit.console.info "#{@prefix}{bg}Running block {bw}#{@title}{x}".c if Howzit.options[:log_level] < 2
69
83
  block = @action
84
+ env_mode = shell_env_block?(block)
70
85
  # Apply variable substitution to block content at execution time
71
86
  # (variables from previous run blocks are now available)
72
- block = block.render_arguments if block && !block.empty?
87
+ if env_mode
88
+ block = block.shellify_defaults
89
+ env = ScriptSupport.variables_env
90
+ script_args = Howzit.arguments || []
91
+ else
92
+ block = block.render_arguments if block && !block.empty?
93
+ env = {}
94
+ script_args = []
95
+ end
96
+ block = block.unescape_placeholders
73
97
  script = Tempfile.new('howzit_script')
74
98
  comm_file = ScriptComm.setup
75
99
  old_log_level = apply_log_level
@@ -117,9 +141,9 @@ module Howzit
117
141
  cmd = ScriptSupport.execution_command_for(script.path, interpreter)
118
142
  # If interpreter is nil, execute directly (will respect hashbang)
119
143
  res = if interpreter.nil?
120
- system(script.path)
144
+ system(env, script.path, *script_args)
121
145
  else
122
- system(cmd)
146
+ system(env, [cmd, *script_args.map { |arg| Shellwords.escape(arg) }].join(' '))
123
147
  end
124
148
  ensure
125
149
  # Restore original directory
@@ -148,18 +172,35 @@ module Howzit
148
172
  output = []
149
173
  action = @action
150
174
 
151
- matches = Howzit.buildnote.find_topic(action)
175
+ matches = Howzit.buildnote.find_topic(action.sub(/ *\[.*?\] *$/, ''))
152
176
  raise "Topic not found: #{action}" if matches.empty?
153
177
 
154
- topic = matches[0]
178
+ topic = matches[0].with_arguments(@include_args)
155
179
  Howzit.console.info("#{@prefix}{by}Running tasks from {bw}#{topic.title}{x}".c)
156
- output.concat(topic.run(nested: true))
180
+ output.concat(with_positional_arguments(@include_args) { topic.run(nested: true) })
157
181
  Howzit.console.info("{by}End include: #{topic.all_tasks.count} tasks{x}".c)
158
182
  @last_status = nil
159
183
  @include_results = topic.results.slice(:total, :success, :errors)
160
184
  [output, @include_results[:total], @include_results[:errors].zero?]
161
185
  end
162
186
 
187
+ ##
188
+ ## Temporarily set Howzit.arguments (e.g. $1 in included shell blocks)
189
+ ##
190
+ ## @param args [Array] Positional values, or nil to leave unchanged
191
+ ##
192
+ def with_positional_arguments(args)
193
+ return yield if args.nil? || args.empty?
194
+
195
+ previous = Howzit.arguments
196
+ Howzit.arguments = args
197
+ begin
198
+ yield
199
+ ensure
200
+ Howzit.arguments = previous
201
+ end
202
+ end
203
+
163
204
  ##
164
205
  ## Apply log level for this task
165
206
  ##
@@ -241,7 +282,7 @@ module Howzit
241
282
  Dir.chdir(expanded_exec_dir) if expanded_exec_dir != expanded_original
242
283
  end
243
284
 
244
- res = system(@action)
285
+ res = system(@action.unescape_placeholders)
245
286
  ensure
246
287
  # Restore original directory
247
288
  if exec_dir && Dir.exist?(exec_dir)
@@ -274,7 +315,7 @@ module Howzit
274
315
  @title && !@title.empty? ? @title : @action
275
316
  end
276
317
  Howzit.console.info("#{@prefix}{bg}Copied {bw}#{display_title}{bg} to clipboard{x}".c)
277
- Util.os_copy(@action)
318
+ Util.os_copy(@action.unescape_placeholders)
278
319
  @last_status = 0
279
320
  true
280
321
  end
data/lib/howzit/topic.rb CHANGED
@@ -18,8 +18,11 @@ module Howzit
18
18
  ## @param metadata [Hash] Optional metadata hash
19
19
  ## @param source_file [String] Optional path to the build note file this topic came from
20
20
  ## @param level [Integer] Markdown header level (2 for ##, 3 for ###, etc.)
21
+ ## @param positional [Array] Values to bind to the title's (arguments),
22
+ ## overriding CLI arguments
21
23
  ##
22
- def initialize(title, content, metadata = nil, source_file: nil, level: 2)
24
+ def initialize(title, content, metadata = nil, source_file: nil, level: 2, positional: nil)
25
+ @raw_title = title
23
26
  @title = title
24
27
  @content = content
25
28
  @parent = nil
@@ -30,7 +33,7 @@ module Howzit
30
33
  @named_args = {}
31
34
  @metadata = metadata
32
35
  @source_file = source_file
33
- arguments(from_cli_snapshot: true)
36
+ arguments(from_cli_snapshot: positional.nil?, positional: positional)
34
37
 
35
38
  @directives = parse_directives_with_conditionals
36
39
  @tasks = gather_tasks
@@ -71,24 +74,43 @@ module Howzit
71
74
  list
72
75
  end
73
76
 
77
+ ##
78
+ ## A copy of this topic with bracketed arguments (e.g.
79
+ ## `@include(Deploy [prod])` or `default: Deploy[prod]`) bound to the
80
+ ## title's (arguments). Tasks are rebuilt so commands render with them.
81
+ ##
82
+ ## @param args [Array] Positional values
83
+ ##
84
+ ## @return [Topic] a new Topic, or self if no args given
85
+ ##
86
+ def with_arguments(args)
87
+ return self if args.nil? || args.empty?
88
+
89
+ topic = Topic.new(@raw_title, @content, @metadata, source_file: @source_file, level: @level, positional: args)
90
+ topic.parent = @parent
91
+ topic.parent_topic = @parent_topic
92
+ topic.subtopics.concat(@subtopics)
93
+ topic
94
+ end
95
+
74
96
  # Get named arguments from title
75
97
  # from_cli_snapshot: use Howzit.cli_topic_positional_args (argv after `--`) so earlier
76
- # topics' gather_tasks cannot clobber positional binding. Re-entrant @include [a,b]
77
- # calls pass false to use live Howzit.arguments.
78
- def arguments(from_cli_snapshot: false)
98
+ # topics' gather_tasks cannot clobber positional binding. positional: binds
99
+ # bracketed arguments from @include [a,b] and default: (see #with_arguments).
100
+ def arguments(from_cli_snapshot: false, positional: nil)
79
101
  @arg_definitions = []
80
102
  return unless @title =~ /\(.*?\) *$/
81
103
 
82
- positional = if from_cli_snapshot
83
- # Specs / non-CLI: leave unset to keep using Howzit.arguments
84
- if Howzit.cli_topic_positional_args.nil?
85
- Howzit.arguments || []
104
+ positional ||= if from_cli_snapshot
105
+ # Specs / non-CLI: leave unset to keep using Howzit.arguments
106
+ if Howzit.cli_topic_positional_args.nil?
107
+ Howzit.arguments || []
108
+ else
109
+ Howzit.cli_topic_positional_args
110
+ end
86
111
  else
87
- Howzit.cli_topic_positional_args
112
+ Howzit.arguments || []
88
113
  end
89
- else
90
- Howzit.arguments || []
91
- end
92
114
 
93
115
  a = @title.match(/\((?<args>.*?)\) *$/)
94
116
  args = a['args'].split(/ *, */).each(&:strip)
@@ -538,7 +560,7 @@ module Howzit
538
560
  if title =~ /\[(.*?)\] *$/
539
561
  args = Regexp.last_match(1).split(/ *, */).map(&:render_arguments)
540
562
  Howzit.arguments = args
541
- arguments
563
+ task_args[:include_args] = args
542
564
  title.sub!(/ *\[.*?\] *$/, '')
543
565
  end
544
566
  # Apply variable substitution to title after bracket processing
@@ -3,5 +3,5 @@
3
3
  # Primary module for this gem.
4
4
  module Howzit
5
5
  # Current Howzit version.
6
- VERSION = '2.1.45'
6
+ VERSION = '2.1.47'
7
7
  end
@@ -0,0 +1,142 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'spec_helper'
4
+ require 'fileutils'
5
+ require 'tmpdir'
6
+
7
+ describe 'default: metadata' do
8
+ let(:temp_dir) { File.expand_path(Dir.mktmpdir('howzit_default_test')) }
9
+ let(:out_file) { File.join(temp_dir, 'out.txt') }
10
+ let(:default_line) { 'default: Build, Deploy[prod, fast], Test' }
11
+ let(:buildnote) do
12
+ note = Howzit::BuildNote.new
13
+ allow(note).to receive(:finalize_output)
14
+ note
15
+ end
16
+
17
+ before do
18
+ File.write(
19
+ File.join(temp_dir, 'buildnotes.md'),
20
+ <<~NOTE
21
+ #{default_line}
22
+
23
+ # Default Project
24
+
25
+ ## Build
26
+
27
+ @run(echo "build" >> "#{out_file}") Build it
28
+
29
+ ## Deploy (target, speed:slow)
30
+
31
+ @run(echo "deploy ${target} ${speed}" >> "#{out_file}") Deploy it
32
+
33
+ ## Test
34
+
35
+ @run(echo "test" >> "#{out_file}") Test it
36
+
37
+ ## Broken
38
+
39
+ @run(false) Fail
40
+
41
+ ## Release
42
+
43
+ @include(Deploy [staging])
44
+ @include(Deploy [prod, fast])
45
+ NOTE
46
+ )
47
+
48
+ Dir.chdir(temp_dir)
49
+ Howzit.instance_variable_set(:@buildnote, buildnote)
50
+ Howzit.options[:stack] = false
51
+ Howzit.options[:include_upstream] = false
52
+ Howzit.options[:run] = true
53
+ Howzit.options[:force] = false
54
+ Howzit.options[:log_level] = 3
55
+ Howzit.cli_args = []
56
+ Howzit.run_log = []
57
+ end
58
+
59
+ after do
60
+ Howzit.options[:run] = false
61
+ Howzit.options[:log_level] = 1
62
+ Howzit.cli_args = []
63
+ Howzit.run_log = []
64
+ Dir.chdir(Dir.tmpdir)
65
+ FileUtils.rm_rf(temp_dir) if Dir.exist?(temp_dir)
66
+ Howzit.instance_variable_set(:@buildnote, nil)
67
+ end
68
+
69
+ def output_lines
70
+ File.read(out_file).lines.map(&:strip)
71
+ end
72
+
73
+ describe 'parsing' do
74
+ it 'splits topics on commas outside of brackets' do
75
+ expect(buildnote.send(:parse_default_metadata, 'Build, Deploy[prod, fast],Test')).to eq(
76
+ ['Build', 'Deploy[prod, fast]', 'Test']
77
+ )
78
+ end
79
+
80
+ it 'separates bracketed arguments from the topic name' do
81
+ expect(buildnote.send(:parse_topic_with_args, 'Deploy[prod, fast]')).to eq(['Deploy', 'prod, fast'])
82
+ expect(buildnote.send(:parse_topic_with_args, 'Build')).to eq(['Build', nil])
83
+ end
84
+ end
85
+
86
+ describe 'running' do
87
+ it 'runs the default topics in order with howzit -r' do
88
+ buildnote.run
89
+
90
+ expect(output_lines).to eq(['build', 'deploy prod fast', 'test'])
91
+ end
92
+
93
+ it 'runs the default topics with howzit -r default' do
94
+ Howzit.cli_args = ['default']
95
+ buildnote.run
96
+
97
+ expect(output_lines).to eq(['build', 'deploy prod fast', 'test'])
98
+ end
99
+
100
+ context 'with fewer arguments than the topic accepts' do
101
+ let(:default_line) { 'default: Deploy[prod]' }
102
+
103
+ it 'uses the topic defaults for the rest' do
104
+ buildnote.run
105
+
106
+ expect(output_lines).to eq(['deploy prod slow'])
107
+ end
108
+ end
109
+
110
+ context 'with a missing topic' do
111
+ let(:default_line) { 'default: Build, Nonexistent, Test' }
112
+
113
+ it 'runs the topics that match' do
114
+ buildnote.run
115
+
116
+ expect(output_lines).to eq(%w[build test])
117
+ end
118
+ end
119
+
120
+ context 'with a failing topic' do
121
+ let(:default_line) { 'default: Build, Broken, Test' }
122
+
123
+ it 'stops after the failure and skips the remaining topics' do
124
+ buildnote.run
125
+
126
+ expect(output_lines).to eq(['build'])
127
+ expect(Howzit.run_log.map { |e| [e[:task], e[:skipped] ? :skipped : e[:success]] }).to eq(
128
+ [['Build it', true], ['Fail', false], ['Test it', :skipped]]
129
+ )
130
+ end
131
+ end
132
+ end
133
+
134
+ describe '@include with bracketed arguments' do
135
+ it 'binds the arguments to the included topic for each include' do
136
+ Howzit.cli_args = ['release']
137
+ buildnote.run
138
+
139
+ expect(output_lines).to eq(['deploy staging slow', 'deploy prod fast'])
140
+ end
141
+ end
142
+ end
@@ -0,0 +1,177 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'spec_helper'
4
+ require 'fileutils'
5
+ require 'tmpdir'
6
+
7
+ describe 'shell variable handling' do
8
+ before do
9
+ Howzit.arguments = []
10
+ Howzit.named_arguments = { 'file' => 'README.md', 'version' => '1.2.3' }
11
+ end
12
+
13
+ after do
14
+ Howzit.arguments = []
15
+ Howzit.named_arguments = {}
16
+ end
17
+
18
+ describe 'String#render_arguments' do
19
+ it 'substitutes Howzit variables' do
20
+ expect('v${version}'.render_arguments).to eq('v1.2.3')
21
+ end
22
+
23
+ it 'leaves unknown variables for the shell' do
24
+ expect('echo "${HOME}"'.render_arguments).to eq('echo "${HOME}"')
25
+ end
26
+
27
+ it 'uses Howzit-style defaults for undefined variables' do
28
+ expect('${missing:fallback}'.render_arguments).to eq('fallback')
29
+ expect('${url:http://localhost:3000}'.render_arguments).to eq('http://localhost:3000')
30
+ end
31
+
32
+ it 'leaves shell parameter expansions alone' do
33
+ %w[${OUT:-/tmp} ${OUT:=/tmp} ${OUT:+set} ${OUT:?missing} ${NAME:0:3} ${#NAME} ${NAME/a/b}].each do |expansion|
34
+ expect(expansion.render_arguments).to eq(expansion)
35
+ end
36
+ end
37
+
38
+ it 'substitutes a defined Howzit variable in ${VAR:-default}' do
39
+ expect('${version:-0.0.0}'.render_arguments).to eq('1.2.3')
40
+ end
41
+
42
+ it 'leaves positional placeholders without a matching argument' do
43
+ expect('f() { echo "$1 ${2}"; }'.render_arguments).to eq('f() { echo "$1 ${2}"; }')
44
+ expect("awk '{print $1}'".render_arguments).to eq("awk '{print $1}'")
45
+ end
46
+
47
+ it 'leaves $@ and $* alone when no arguments were passed' do
48
+ expect('run() { "$@"; }'.render_arguments).to eq('run() { "$@"; }')
49
+ end
50
+
51
+ it 'substitutes positional arguments when passed' do
52
+ Howzit.arguments = ['one', 'two words']
53
+ expect('$1 ${2} ${3:three}'.render_arguments).to eq('one two words three')
54
+ expect('cmd $@'.render_arguments).to eq("cmd one two\\ words")
55
+ end
56
+
57
+ it 'leaves escaped $${VAR} placeholders until execution' do
58
+ rendered = 'echo $${file}'.render_arguments
59
+ expect(rendered).to eq('echo $${file}')
60
+ expect(rendered.unescape_placeholders).to eq('echo ${file}')
61
+ end
62
+ end
63
+
64
+ describe 'String#shellify_defaults' do
65
+ it 'converts Howzit defaults to shell defaults' do
66
+ expect('${name:world} ${1:first}'.shellify_defaults).to eq('${name:-world} ${1:-first}')
67
+ end
68
+
69
+ it 'leaves shell expansions alone' do
70
+ %w[${name} ${name:-x} ${name:0:3} ${name:$offset} ${name: -2}].each do |expansion|
71
+ expect(expansion.shellify_defaults).to eq(expansion)
72
+ end
73
+ end
74
+ end
75
+
76
+ describe 'ScriptSupport.shell_script?' do
77
+ it 'detects shell scripts' do
78
+ expect(Howzit::ScriptSupport.shell_script?("echo hi\n")).to be true
79
+ expect(Howzit::ScriptSupport.shell_script?("#!/bin/bash\necho hi\n")).to be true
80
+ expect(Howzit::ScriptSupport.shell_script?("#!/usr/bin/env zsh\n")).to be true
81
+ expect(Howzit::ScriptSupport.shell_script?("#!/bin/sh -e\n")).to be true
82
+ end
83
+
84
+ it 'excludes other interpreters' do
85
+ expect(Howzit::ScriptSupport.shell_script?("#!/usr/bin/env ruby\n")).to be false
86
+ expect(Howzit::ScriptSupport.shell_script?("#!/usr/bin/env fish\n")).to be false
87
+ expect(Howzit::ScriptSupport.shell_script?("#!/usr/bin/env python3\n")).to be false
88
+ end
89
+ end
90
+
91
+ describe 'running blocks' do
92
+ let(:temp_dir) { File.expand_path(Dir.mktmpdir('howzit_shell_vars_test')) }
93
+ let(:out_file) { File.join(temp_dir, 'out.txt') }
94
+
95
+ before do
96
+ File.write(
97
+ File.join(temp_dir, 'buildnotes.md'),
98
+ <<~NOTE
99
+ # Shell Variables
100
+
101
+ ## Shell Block (target:prod)
102
+
103
+ ```run
104
+ #!/bin/bash
105
+ greet() { echo "arg=$1"; }
106
+ for file in a.rb; do echo "loop=${file}"; done > "#{out_file}"
107
+ echo "howzit=${target}" >> "#{out_file}"
108
+ echo "default=${undefined_var:fallback}" >> "#{out_file}"
109
+ echo "shell_default=${UNSET_THING:-shellval}" >> "#{out_file}"
110
+ echo "env=${HOWZIT_SPEC_ENV}" >> "#{out_file}"
111
+ greet hello >> "#{out_file}"
112
+ echo "script_arg=${1:-none}" >> "#{out_file}"
113
+ ```
114
+
115
+ ## Escaped Run
116
+
117
+ @run(echo "$${HOWZIT_SPEC_ENV}" > "#{out_file}") Escaped
118
+ NOTE
119
+ )
120
+
121
+ ENV['HOWZIT_SPEC_ENV'] = 'from_env'
122
+ Dir.chdir(temp_dir)
123
+ Howzit.instance_variable_set(:@buildnote, nil)
124
+ Howzit.options[:stack] = false
125
+ Howzit.options[:include_upstream] = false
126
+ Howzit.options[:log_level] = 3
127
+ Howzit.named_arguments = { 'file' => 'README.md' }
128
+ end
129
+
130
+ after do
131
+ ENV.delete('HOWZIT_SPEC_ENV')
132
+ Howzit.options[:shell_variables] = 'env'
133
+ Howzit.options[:log_level] = 1
134
+ Dir.chdir(Dir.tmpdir)
135
+ FileUtils.rm_rf(temp_dir) if Dir.exist?(temp_dir)
136
+ Howzit.instance_variable_set(:@buildnote, nil)
137
+ end
138
+
139
+ def output_lines
140
+ File.read(out_file).lines.map(&:strip)
141
+ end
142
+
143
+ it 'passes Howzit variables to shell blocks through the environment' do
144
+ Howzit::BuildNote.new.find_topic('Shell Block')[0].run
145
+
146
+ expect(output_lines).to eq([
147
+ 'loop=a.rb',
148
+ 'howzit=prod',
149
+ 'default=fallback',
150
+ 'shell_default=shellval',
151
+ 'env=from_env',
152
+ 'arg=hello',
153
+ 'script_arg=none'
154
+ ])
155
+ end
156
+
157
+ it 'passes positional arguments to the script' do
158
+ Howzit.arguments = ['cli_arg']
159
+ Howzit::BuildNote.new.find_topic('Shell Block')[0].run
160
+
161
+ expect(output_lines).to include('arg=hello', 'script_arg=cli_arg')
162
+ end
163
+
164
+ it 'uses text substitution when shell_variables is substitute' do
165
+ Howzit.options[:shell_variables] = 'substitute'
166
+ Howzit::BuildNote.new.find_topic('Shell Block')[0].run
167
+
168
+ expect(output_lines).to include('loop=README.md', 'howzit=prod')
169
+ end
170
+
171
+ it 'passes escaped placeholders to the shell in @run commands' do
172
+ Howzit::BuildNote.new.find_topic('Escaped Run')[0].run
173
+
174
+ expect(output_lines).to eq(['from_env'])
175
+ end
176
+ end
177
+ 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.45
4
+ version: 2.1.47
5
5
  platform: ruby
6
6
  authors:
7
7
  - Brett Terpstra
@@ -314,13 +314,13 @@ files:
314
314
  - lib/howzit/topic.rb
315
315
  - lib/howzit/util.rb
316
316
  - lib/howzit/version.rb
317
- - nested-topics-for-howzit.md
318
317
  - scripts/runtests.sh
319
318
  - spec/buildnote_spec.rb
320
319
  - spec/cli_spec.rb
321
320
  - spec/condition_evaluator_spec.rb
322
321
  - spec/conditional_blocks_integration_spec.rb
323
322
  - spec/conditional_content_spec.rb
323
+ - spec/default_metadata_spec.rb
324
324
  - spec/includes_metadata_spec.rb
325
325
  - spec/log_level_spec.rb
326
326
  - spec/prompt_spec.rb
@@ -330,6 +330,7 @@ files:
330
330
  - spec/script_comm_spec.rb
331
331
  - spec/sequential_conditional_spec.rb
332
332
  - spec/set_var_spec.rb
333
+ - spec/shell_variables_spec.rb
333
334
  - spec/spec_helper.rb
334
335
  - spec/stack_mode_spec.rb
335
336
  - spec/stringutils_spec.rb
@@ -369,6 +370,7 @@ test_files:
369
370
  - spec/condition_evaluator_spec.rb
370
371
  - spec/conditional_blocks_integration_spec.rb
371
372
  - spec/conditional_content_spec.rb
373
+ - spec/default_metadata_spec.rb
372
374
  - spec/includes_metadata_spec.rb
373
375
  - spec/log_level_spec.rb
374
376
  - spec/prompt_spec.rb
@@ -378,6 +380,7 @@ test_files:
378
380
  - spec/script_comm_spec.rb
379
381
  - spec/sequential_conditional_spec.rb
380
382
  - spec/set_var_spec.rb
383
+ - spec/shell_variables_spec.rb
381
384
  - spec/spec_helper.rb
382
385
  - spec/stack_mode_spec.rb
383
386
  - spec/stringutils_spec.rb
@@ -1,100 +0,0 @@
1
- ---
2
- title: Nested topics for Howzit
3
- layout: post
4
- tags: [howzit, ruby, markdown, cli, automation]
5
- categories: [Blog,Code]
6
- post_class: 'code'
7
- comments: true
8
- ---
9
- [Howzit](https://github.com/ttscoff/howzit) has always split build notes into topics using Markdown headers. What it didn't do was care *which* header level you used. An `##` and a `###` were both just "a topic," and they all got flattened into one long list. As of version 2.1.44, that's changed: deeper headers now nest under the header above them, and that nesting affects both how notes display and what happens when you run them.
10
-
11
- ### The short version
12
-
13
- A `###` following a `##` is now a subtopic of that `##`. A `####` under the `###` is a subtopic of the `###`, and so on down. The next header at the same level (or shallower) closes out the parent.
14
-
15
- ```markdown
16
- ## Deploy
17
-
18
- @run(./scripts/preflight.sh) Preflight checks
19
-
20
- ### Build assets
21
-
22
- @run(gulp) Build
23
-
24
- ### Upload
25
-
26
- @run(gulp sync) Sync to server
27
-
28
- ## Package management
29
- ```
30
-
31
- Here "Build assets" and "Upload" belong to "Deploy." "Package management" is back at the top level.
32
-
33
- ### Viewing a parent topic
34
-
35
- When you pull up a parent topic, you now get the whole thing: the parent, followed by all of its subtopics.
36
-
37
- ```console
38
- $ howzit deploy
39
- ```
40
-
41
- Subtopic headers get a slightly lighter treatment (a dotted rule and a cyan title) so you can see where the nesting happens without it getting noisy. You can still ask for a subtopic directly:
42
-
43
- ```console
44
- $ howzit upload
45
- ```
46
-
47
- ...and you'll get just that section.
48
-
49
- ### Running a parent topic
50
-
51
- This is the part I actually wanted. Running a parent now runs everything under it:
52
-
53
- ```console
54
- $ howzit -r deploy
55
- ```
56
-
57
- That runs Deploy's own tasks first, then each subtopic's tasks in order, depth-first. So the example above runs preflight, then the build, then the sync. You get one combined summary at the end instead of a "Ran 1 task" line for every section.
58
-
59
- A few details worth knowing:
60
-
61
- - **Errors stop the run.** If a task fails, the remaining subtopics are skipped. Add `--force` if you want it to push through anyway.
62
- - **`@before` and `@after` still work per topic.** Each topic shows its prerequisites before its own tasks run. A parent's `@after` block shows up once the parent *and* all of its subtopics are done, which is usually where you want that "don't forget to..." reminder.
63
- - **Parents don't need tasks of their own.** A parent that's just an umbrella for a few subtopics runs fine, and it won't complain about "No @directive found."
64
- - **Subtopics still run on their own.** `howzit -r upload` runs just the upload step.
65
-
66
- ### Includes get the whole tree too
67
-
68
- If another topic does `@include(Deploy)`, it now runs Deploy and all of its subtopics, same as running it directly. Task counts in include notes and menus reflect the full tree, so you're not surprised by how much is about to happen.
69
-
70
- ### Listing and "show everything"
71
-
72
- Running `howzit` with no arguments shows every topic. It now only prints top-level topics, since subtopics come along with their parents. No more seeing the same section twice.
73
-
74
- `howzit -l` indents subtopics under their parents, so the list reflects the structure of your note:
75
-
76
- ```console
77
- $ howzit -l
78
- Topics:
79
-
80
- - Deploy
81
- - Build assets
82
- - Upload
83
- - Package management
84
- ```
85
-
86
- Shell completions stay flat, so you can still tab-complete straight to a subtopic.
87
-
88
- ### One thing to check in your notes
89
-
90
- If you've been using `###` headers under `##` purely as visual formatting, those are now real subtopics. Viewing the parent will include them (probably what you wanted anyway), and running the parent will run their tasks too. If you had a `###` section with tasks you *don't* want run as part of its parent, bump it up to `##`.
91
-
92
- ### Grab it
93
-
94
- It's in 2.1.44:
95
-
96
- ```console
97
- $ gem install howzit
98
- ```
99
-
100
- The details are in the [build notes anatomy page](https://github.com/ttscoff/howzit/wiki/Anatomy-of-a-build-notes-file#subtopics) on the wiki. I've already started restructuring a few of my own notes around this. Having a single "Release" topic that fans out into build, test, and publish steps is a lot nicer than keeping them all at the same level and chaining them together with `@include`.