lux-hammer 0.3.12 → 0.3.14

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.
data/lib/hammer/parser.rb CHANGED
@@ -8,9 +8,9 @@ class Hammer
8
8
  @options = options
9
9
  @by_switch = {}
10
10
  options.each do |opt|
11
- @by_switch[opt.switch] = opt
12
- @by_switch[opt.negation] = opt if opt.boolean?
13
- opt.aliases.each { |a| @by_switch[a] = opt }
11
+ register_switch(opt.switch, opt)
12
+ register_switch(opt.negation, opt) if opt.boolean?
13
+ opt.aliases.each { |a| register_switch(a, opt) }
14
14
  end
15
15
  end
16
16
 
@@ -31,7 +31,11 @@ class Hammer
31
31
  if token.start_with?('--') && token.include?('=')
32
32
  key, val = token.split('=', 2)
33
33
  opt = lookup!(key)
34
- values[opt.name] = opt.cast(val)
34
+ if opt.boolean? && key.start_with?('--no-')
35
+ values[opt.name] = !opt.cast(val) # `--no-x=false` -> true
36
+ else
37
+ values[opt.name] = opt.cast(val)
38
+ end
35
39
  i += 1
36
40
  next
37
41
  end
@@ -50,7 +54,18 @@ class Hammer
50
54
  next
51
55
  end
52
56
 
53
- if token.start_with?('-') && token.length > 1
57
+ # Glued short flag with value: `-pVALUE` (non-boolean short opts only).
58
+ if token.start_with?('-') && !token.start_with?('--') && token.length > 2
59
+ if (opt = @by_switch[token[0, 2]]) && !opt.boolean?
60
+ values[opt.name] = opt.cast(token[2..])
61
+ i += 1
62
+ next
63
+ end
64
+ end
65
+
66
+ # Dash-led and not a negative number -> a genuinely unknown flag.
67
+ # Bare `-` and negative numbers (`-5`) fall through to positionals.
68
+ if token.start_with?('-') && token.length > 1 && token !~ /\A-\d/
54
69
  raise Error, "unknown option: #{token}"
55
70
  end
56
71
 
@@ -59,15 +74,21 @@ class Hammer
59
74
  end
60
75
 
61
76
  # Fill un-set non-boolean opts from positional args in declaration
62
- # order. Booleans always need an explicit flag.
77
+ # order. Booleans always need an explicit flag. Scalars take one
78
+ # positional each; an :array opt slurps whatever's left, so it's
79
+ # filled last and never starves a later-declared scalar opt.
80
+ array_opt = nil
63
81
  @options.each do |opt|
64
- break if positional.empty?
65
82
  next if opt.boolean? || values.key?(opt.name)
66
83
  if opt.type == :array
67
- values[opt.name] = opt.cast(positional.shift(positional.size))
68
- else
69
- values[opt.name] = opt.cast(positional.shift)
84
+ array_opt = opt
85
+ next
70
86
  end
87
+ break if positional.empty?
88
+ values[opt.name] = opt.cast(positional.shift)
89
+ end
90
+ if array_opt && !positional.empty?
91
+ values[array_opt.name] = array_opt.cast(positional.shift(positional.size))
71
92
  end
72
93
 
73
94
  @options.each do |opt|
@@ -80,6 +101,15 @@ class Hammer
80
101
 
81
102
  private
82
103
 
104
+ # Map a flag string to its option, refusing silent shadowing when two
105
+ # options would claim the same switch/negation/alias.
106
+ def register_switch(flag, opt)
107
+ if (prev = @by_switch[flag]) && prev != opt
108
+ raise Error, "flag #{flag} claimed by both :#{prev.name} and :#{opt.name}"
109
+ end
110
+ @by_switch[flag] = opt
111
+ end
112
+
83
113
  def lookup!(key)
84
114
  @by_switch[key] or raise Error, "unknown option: #{key}"
85
115
  end
data/lib/hammer/recipe.rb CHANGED
@@ -49,14 +49,14 @@ class Hammer
49
49
  ''
50
50
  end
51
51
 
52
- # Ruby wrapper text printed by `hammer recipes --install`. User
52
+ # Ruby wrapper text printed by `hammer h:recipes --install`. User
53
53
  # redirects it to a file in PATH and chmods +x. The leading comment
54
54
  # documents the canonical install command. Name is passed as a
55
55
  # string literal so hyphenated names (`git-helper`) work too.
56
56
  def stub(name)
57
57
  <<~RUBY
58
58
  #!/usr/bin/env ruby
59
- # install: hammer recipes --install #{name} > ~/bin/#{name} && chmod +x $_
59
+ # install: hammer h:recipes --install #{name} > ~/bin/#{name} && chmod +x $_
60
60
  require 'lux-hammer'
61
61
  Hammer.recipe('#{name}', ARGV)
62
62
  RUBY
data/lib/hammer/shell.rb CHANGED
@@ -13,8 +13,11 @@ class Hammer
13
13
  module_function
14
14
 
15
15
  def color?
16
- return @color if defined?(@color)
17
- @color = $stdout.tty? && ENV['NO_COLOR'].nil?
16
+ # Only an explicit color!(value) override is sticky; otherwise the
17
+ # tty decision is recomputed so a redirected $stdout (tests, capture
18
+ # blocks) is honored instead of frozen at first read.
19
+ return @color if defined?(@color) && !@color.nil?
20
+ $stdout.tty? && ENV['NO_COLOR'].nil?
18
21
  end
19
22
 
20
23
  def color!(value)
data/lib/lux-hammer.rb CHANGED
@@ -94,8 +94,8 @@ class Hammer
94
94
  # If the method takes no args, call it without opts. Otherwise pass
95
95
  # opts. So both `def build` and `def build(opts)` work.
96
96
  m = method_name
97
- arity = instance_method(method_name).arity
98
- cmd.handler = arity.zero? ? proc { send(m) } : proc { |opts| send(m, opts) }
97
+ takes_arg = instance_method(method_name).parameters.any? { |type, _| %i[req opt rest].include?(type) }
98
+ cmd.handler = takes_arg ? proc { |opts| send(m, opts) } : proc { send(m) }
99
99
  cmd.finalize!
100
100
  commands[cmd.name] = cmd
101
101
 
@@ -413,6 +413,13 @@ class Hammer
413
413
  return print_help
414
414
  end
415
415
 
416
+ # An exact namespace beats a fuzzy command match, so `hammer h` lists
417
+ # the `h:` namespace instead of prefix-matching some `h...` command.
418
+ if !commands.key?(name) && namespaces.key?(name)
419
+ ns, canonical = resolve_namespace(name)
420
+ return print_namespace_help(canonical, ns)
421
+ end
422
+
416
423
  cmd, owner, canonical = resolve(name)
417
424
  return owner.run_command(cmd, argv, full: canonical) if cmd
418
425
 
@@ -518,6 +525,7 @@ class Hammer
518
525
  # Tries prefix match first, then substring; raises AmbiguousMatch
519
526
  # when either pass hits more than one item.
520
527
  def fuzzy_pick(name, items, kind, &keys_for)
528
+ return nil if name.empty?
521
529
  [:start_with?, :include?].each do |op|
522
530
  matches = items.select { |item| keys_for.call(item).any? { |k| k.send(op, name) } }
523
531
  next if matches.empty?
@@ -548,24 +556,66 @@ class Hammer
548
556
  opts.each do |k, v|
549
557
  next if v == false
550
558
  flag = "--#{k.to_s.tr('_', '-')}"
551
- argv << (v == true ? flag : "#{flag}=#{v}")
559
+ if v == true
560
+ argv << flag
561
+ else
562
+ argv << "#{flag}=#{v.is_a?(Array) ? v.join(',') : v}"
563
+ end
552
564
  end
553
565
  start(argv)
554
566
  end
555
567
 
556
568
  # Yield [full_colon_path, Command] for every command in this class
557
- # and all nested namespaces.
558
- def each_command(prefix = nil, &block)
569
+ # and all nested namespaces. `include_builtins: false` prunes
570
+ # namespaces flagged `@builtin_namespace` (the reserved `h:` tree) -
571
+ # used so the compact listing hides built-ins outside `--help`. Only
572
+ # affects descent from a parent; iterating a flagged namespace
573
+ # directly (e.g. `hammer h:`) still lists its own commands.
574
+ def each_command(prefix = nil, include_builtins: true, &block)
559
575
  commands.each_value do |c|
560
576
  full = prefix ? "#{prefix}:#{c.name}" : c.name
561
577
  yield full, c
562
578
  end
563
579
  namespaces.each do |ns_name, sub|
580
+ next if !include_builtins && sub.instance_variable_get(:@builtin_namespace)
564
581
  sub_prefix = prefix ? "#{prefix}:#{ns_name}" : ns_name
565
- sub.each_command(sub_prefix, &block)
582
+ sub.each_command(sub_prefix, include_builtins: include_builtins, &block)
566
583
  end
567
584
  end
568
585
 
586
+ # Machine-readable spec for `h:json` -> the macOS GUI (and, later,
587
+ # for lux itself to render the default listing). One hash:
588
+ # commands => { group => { full_path => task_meta } }
589
+ # Grouping/sort mirror the bare-`hammer` listing exactly: group by
590
+ # the first namespace segment (a bare task sharing a namespace's name
591
+ # joins that group via section_for), root tasks under "__root",
592
+ # "__root" first, remaining groups in first-encounter order, tasks
593
+ # within a group by [depth, name]. Hidden (no-`desc`) tasks are
594
+ # skipped and the reserved `h:` tree is pruned unless include_builtins.
595
+ def export_spec(include_builtins: false)
596
+ groups = {} # group => { full_path => meta }, in first-encounter order
597
+
598
+ each_command(include_builtins: include_builtins) do |path, c|
599
+ next if c.desc.empty?
600
+ section = section_for(path, nil, self)
601
+ key = section == :root ? '__root' : section.to_s
602
+ (groups[key] ||= {})[path] = c.to_h(path)
603
+ end
604
+
605
+ sort_tasks = ->(h) { h.sort_by { |p, _| [p.count(':'), p] }.to_h }
606
+ ordered = {}
607
+ ordered['__root'] = sort_tasks.call(groups.delete('__root')) if groups.key?('__root')
608
+ groups.each { |k, v| ordered[k] = sort_tasks.call(v) }
609
+
610
+ {
611
+ schema: 1,
612
+ hammer_version: VERSION,
613
+ program_name: program_name,
614
+ app_desc: app_desc,
615
+ commands: ordered
616
+ }
617
+ end
618
+
569
619
  def run_command(cmd, argv, full: nil, quiet: false)
570
620
  # -h / --help is reserved on every command. Anywhere before a `--`
571
621
  # stop-marker, it short-circuits to per-command help.
@@ -601,17 +651,22 @@ class Hammer
601
651
  if o.boolean?
602
652
  parts << (val ? "--#{o.name}" : "--no-#{o.name}")
603
653
  else
604
- parts << "--#{o.name}=#{val}"
654
+ parts << "--#{o.name}=#{val.is_a?(Array) ? val.join(',') : val}"
605
655
  end
606
656
  end
607
657
  parts.concat(positional)
608
- Shell.say "> #{parts.join(' ')}", :gray
658
+ # Diagnostic, not program output - to stderr so stdout stays clean
659
+ # for machine-readable tasks (`h:json`, `h:version`) and pipes.
660
+ warn Shell.paint("> #{parts.join(' ')}", :gray)
609
661
  end
610
662
 
611
663
  # Fire `before` hooks from root down through the namespace chain.
612
664
  # Each class's hooks fire at most once per top-level `start`, so
613
665
  # prereqs dispatched via `needs` won't re-trigger them.
614
666
  def run_before_hooks(instance, opts)
667
+ # Built-in `h:` meta-commands parent to the project root but must not
668
+ # trigger the project's own `before` hooks (dotenv, env checks, ...).
669
+ return if instance_variable_get(:@builtin_namespace)
615
670
  ran = Thread.current[:hammer_before_ran] ||= {}
616
671
  ancestor_chain.each do |klass|
617
672
  next if ran[klass.object_id]
@@ -661,15 +716,29 @@ class Hammer
661
716
 
662
717
  print_top_banner
663
718
  Shell.say "Usage: #{program_name} COMMAND [ARGS]", :cyan
719
+ # Compact (bare-invocation) view only - the extended `--help` view
720
+ # already IS the full usage, so don't nag about it there.
721
+ unless extended
722
+ Shell.say "add `--help` to show usage help", :gray
723
+ # No project Hammerfile + no custom tasks loaded: point the user at
724
+ # `h:init`. The flag is set by `Hammer.cli` when the lookup misses.
725
+ if instance_variable_get(:@no_hammerfile)
726
+ Shell.say "no Hammerfile found in #{Dir.pwd} - run `#{program_name} h:init` to create one", :gray
727
+ end
728
+ end
664
729
  if @app_desc && !@app_desc.empty?
665
730
  Shell.say ''
666
731
  @app_desc.each_line { |l| Shell.say " #{l.chomp}" }
667
732
  end
733
+ # Built-in `h:` commands only surface in the extended view
734
+ # (`--help` / `-h` / `help`); the bare-invocation listing stays
735
+ # focused on the project's own tasks. They remain dispatchable
736
+ # regardless - this only governs what the listing shows.
668
737
  if expanded
669
- each_command { |path, c| print_full_block(path, c) unless c.desc.empty? }
738
+ each_command(include_builtins: extended) { |path, c| print_full_block(path, c) unless c.desc.empty? }
670
739
  else
671
740
  Shell.say ''
672
- print_command_list(self)
741
+ print_command_list(include_builtins: extended)
673
742
  end
674
743
  print_recipes_section if extended && root.instance_variable_get(:@hammer_binary)
675
744
  print_extras if extended
@@ -688,7 +757,7 @@ class Hammer
688
757
  entries.each do |name, file|
689
758
  desc = Hammer::Recipe.desc(file)
690
759
  installed = Hammer::Recipe.installed_path(name)
691
- suffix = installed ? "(installed: #{installed})" : "[install: #{program_name} recipes --install #{name}]"
760
+ suffix = installed ? "(installed: #{installed})" : "[install: #{program_name} h:recipes --install #{name}]"
692
761
  Shell.say " #{name.ljust(width)} # #{desc}"
693
762
  Shell.say " #{' ' * width} #{suffix}", :gray
694
763
  end
@@ -702,8 +771,8 @@ class Hammer
702
771
  Shell.say "Usage: #{program_name} #{prefix}:COMMAND [ARGS]", :cyan
703
772
  rows = []
704
773
  sibling = find_namespace_sibling(prefix)
705
- rows << [prefix, sibling] if sibling && !sibling.desc.empty?
706
- ns.each_command(prefix) { |path, c| rows << [path, c] unless c.desc.empty? }
774
+ rows << [prefix, sibling.to_h(prefix)] if sibling && !sibling.desc.empty?
775
+ ns.each_command(prefix) { |path, c| rows << [path, c.to_h(path)] unless c.desc.empty? }
707
776
  unless rows.empty?
708
777
  Shell.say ''
709
778
  Shell.say 'Commands:', :yellow
@@ -761,45 +830,37 @@ class Hammer
761
830
 
762
831
  # Hammerfile cheat-sheet shown under `hammer --help`. Same content
763
832
  # as `hammer --init` writes - single source of truth via
764
- # `Hammer::STARTER_HAMMERFILE`. For exhaustive docs see `hammer agents`.
833
+ # `Hammer::STARTER_HAMMERFILE`. For exhaustive docs see `hammer h:agents`.
765
834
  def print_hammerfile_example
766
835
  Shell.say ''
767
836
  Shell.say 'Hammerfile example:', :yellow
768
837
  Shell.say Hammer::STARTER_HAMMERFILE
769
838
  end
770
839
 
771
- def print_command_list(klass, prefix = nil)
772
- rows = []
773
- # Commands without a `desc` are hidden from listings but still
774
- # dispatchable + `hammer`-callable - useful for private helpers
775
- # invoked from `before` hooks or other commands (e.g. `:env`, `:app`).
776
- klass.each_command(prefix) { |full, c| rows << [full, c] unless c.desc.empty? }
777
- return if rows.empty?
778
-
779
- # group by "section" = everything between the view prefix and the
780
- # leaf name. Bare leaves go in :root.
781
- groups = rows.group_by { |full, _| section_for(full, prefix, klass) }
782
- width = rows.map { |full, _| full.length }.max
783
- first = true
784
-
785
- if (rooted = groups.delete(:root))
786
- Shell.say 'Commands:', :yellow
787
- emit_rows(rooted.sort_by { |full, _| [full.count(':'), full] }, width)
788
- first = false
789
- end
790
-
791
- groups.each do |section, items|
792
- Shell.say unless first
793
- first = false
794
- Shell.say "#{section}:", :yellow
795
- emit_rows(items.sort_by { |full, _| [full.count(':'), full] }, width)
840
+ # Pure rendering off `export_spec` - the same grouped structure
841
+ # `h:json` emits, so the listing and the JSON can never drift.
842
+ # `export_spec` already does the work: drops hidden (no-`desc`)
843
+ # tasks, prunes the `h:` tree unless include_builtins, groups by
844
+ # first namespace segment ("__root" for bare tasks), orders "__root"
845
+ # first, and sorts each group by [depth, name].
846
+ def print_command_list(include_builtins: true)
847
+ groups = export_spec(include_builtins: include_builtins)[:commands]
848
+ return if groups.empty?
849
+
850
+ width = groups.values.flat_map(&:keys).map(&:length).max
851
+ groups.each_with_index do |(section, tasks), i|
852
+ Shell.say unless i.zero?
853
+ Shell.say(section == '__root' ? 'Commands:' : "#{section}:", :yellow)
854
+ emit_rows(tasks.to_a, width)
796
855
  end
797
856
  end
798
857
 
858
+ # `rows` is an array of [full_path, task_meta] - the per-task hashes
859
+ # from `Command#to_h` (also used to render namespace listings).
799
860
  def emit_rows(rows, width)
800
- rows.each do |full, c|
801
- brief = c.alts.empty? ? c.brief : "#{c.brief} (alt: #{c.alts.join(', ')})"
802
- brief = "#{brief} #{Shell.paint('(redefined)', :yellow)}" if c.prev_location
861
+ rows.each do |full, t|
862
+ brief = t[:alts].empty? ? t[:brief] : "#{t[:brief]} (alt: #{t[:alts].join(', ')})"
863
+ brief = "#{brief} #{Shell.paint('(redefined)', :yellow)}" if t[:redefined]
803
864
  Shell.say " #{program_name} #{full.ljust(width)} # #{brief}"
804
865
  end
805
866
  end
@@ -911,7 +972,7 @@ class Hammer
911
972
  Shell.print_error "unknown recipe: #{name}"
912
973
  Shell.say 'available recipes:', :yellow
913
974
  Recipe.all.keys.sort.each { |n| Shell.say " #{n}" }
914
- Shell.say 'try `hammer recipes` to list with descriptions', :gray
975
+ Shell.say 'try `hammer h:recipes` to list with descriptions', :gray
915
976
  exit 1
916
977
  end
917
978
 
@@ -967,12 +1028,12 @@ class Hammer
967
1028
  end
968
1029
  RUBY
969
1030
 
970
- # Default install dir used by install.sh and `hammer update`.
1031
+ # Default install dir used by install.sh and `hammer h:update`.
971
1032
  SELF_UPDATE_DIR ||= File.expand_path('~/.local/share/lux-hammer')
972
1033
  SELF_UPDATE_REPO ||= 'https://github.com/dux/hammer.git'
973
1034
  SELF_INSTALL_URL ||= 'https://raw.githubusercontent.com/dux/hammer/main/install.sh'
974
1035
 
975
- # `hammer update`: pull main in the install-script checkout and
1036
+ # `hammer h:update`: pull main in the install-script checkout and
976
1037
  # reinstall the gem. Assumes the install.sh layout - if the dir is
977
1038
  # missing, point the user at the curl-pipe installer.
978
1039
  def self.self_update
@@ -1013,6 +1074,7 @@ class Hammer
1013
1074
  def self.cli(argv = ARGV)
1014
1075
  argv = argv.dup
1015
1076
  force_system = !!argv.delete('--system')
1077
+ launch_gui = !!argv.delete('--gui')
1016
1078
 
1017
1079
  # Shebang invocation: `hammer /path/to/script ...args` (kernel passes
1018
1080
  # the script path as argv[0] for `#!/usr/bin/env hammer` files).
@@ -1026,17 +1088,25 @@ class Hammer
1026
1088
  end
1027
1089
 
1028
1090
  path = force_system ? nil : find_hammerfile(Dir.pwd)
1091
+
1092
+ # `hammer --gui` opens the native macOS runner pointed at this project
1093
+ # (the Hammerfile's dir, or cwd when none was found). The CLI just
1094
+ # launches the bundled app and returns.
1095
+ return launch_gui!(path ? File.dirname(path) : Dir.pwd) if launch_gui
1096
+
1029
1097
  unless path
1030
1098
  # No Hammerfile (or --system) - all built-ins are reachable. Bare
1031
- # `hammer`, `hammer recipes`, `hammer update`, `hammer agents`,
1032
- # `hammer version`, `hammer init` all work.
1099
+ # `hammer`, `hammer h:recipes`, `hammer h:update`, `hammer h:agents`,
1100
+ # `hammer h:version`, `hammer h:init` all work.
1033
1101
  if force_system || dispatches_to_builtin?(argv) || looks_like_builtin?(argv)
1034
1102
  klass = Class.new(Hammer)
1035
1103
  klass.instance_variable_set(:@hammer_binary, true)
1104
+ # No project Hammerfile was found - only built-ins are loaded. The
1105
+ # bare-invocation help uses this to note that no Hammerfile exists.
1106
+ klass.instance_variable_set(:@no_hammerfile, true)
1036
1107
  klass.program_name
1037
1108
  require_relative 'hammer/builtins'
1038
- Hammer::Builtins.register_core(klass)
1039
- Hammer::Builtins.register_no_project(klass)
1109
+ Hammer::Builtins.register(klass)
1040
1110
  klass.start(argv)
1041
1111
  return
1042
1112
  end
@@ -1060,8 +1130,8 @@ class Hammer
1060
1130
  Shell.say STARTER_HAMMERFILE
1061
1131
  Shell.say ''
1062
1132
  bin = File.basename($PROGRAM_NAME)
1063
- Shell.say "tip: run `#{bin} init` to drop the example above into ./Hammerfile", :gray
1064
- Shell.say "tip: run `#{bin} agents` for AI-friendly Hammerfile authoring docs", :gray
1133
+ Shell.say "tip: run `#{bin} h:init` to drop the example above into ./Hammerfile", :gray
1134
+ Shell.say "tip: run `#{bin} h:agents` for AI-friendly Hammerfile authoring docs", :gray
1065
1135
  exit 1
1066
1136
  end
1067
1137
 
@@ -1082,14 +1152,13 @@ class Hammer
1082
1152
  # are NOT visible during Hammerfile evaluation, only inside handlers.
1083
1153
  Hammer::Dotenv.load(Dir.pwd) if klass.dotenv_enabled?
1084
1154
 
1085
- # Core built-ins register AFTER Hammerfile eval so user-defined
1086
- # tasks win (the `unless commands.key?(...)` guards in register_core
1087
- # skip the built-in when overridden - no redefinition warning).
1088
- # `register_no_project` (:recipes, :init) is intentionally NOT
1089
- # called here - those would clash too easily with user tasks. Use
1090
- # `hammer --system recipes` to reach them from inside a project.
1155
+ # Built-ins register AFTER Hammerfile eval so user-defined tasks win
1156
+ # (the `unless commands.key?(...)` guards skip a built-in when the
1157
+ # Hammerfile already owns the name - no redefinition warning). All
1158
+ # built-ins live under `h:`, so they can't collide with project root
1159
+ # tasks and the full set registers in every context.
1091
1160
  require_relative 'hammer/builtins'
1092
- Hammer::Builtins.register_core(klass)
1161
+ Hammer::Builtins.register(klass)
1093
1162
 
1094
1163
  klass.start(argv)
1095
1164
  end
@@ -1109,7 +1178,7 @@ class Hammer
1109
1178
  end
1110
1179
 
1111
1180
  # Evaluate a shebang script as a self-contained CLI. Mirrors `recipe`
1112
- # semantics: no chdir, no `@hammer_binary` flag, no `register_core`
1181
+ # semantics: no chdir, no `@hammer_binary` flag, no `Builtins.register`
1113
1182
  # built-ins (so the script's `--help` shows only what it defines).
1114
1183
  # `program_name` is the script's basename so help reads "myscript foo"
1115
1184
  # rather than "hammer foo" - works even when invoked via a symlink in
@@ -1131,15 +1200,14 @@ class Hammer
1131
1200
  first == 'help' || first == '-h' || first == '--help' || first.start_with?('-')
1132
1201
  end
1133
1202
 
1134
- # True if argv names a built-in task (`recipes`, `update`, `agents`,
1135
- # `version`, `init`). Used in the no-Hammerfile branch to wake up the
1136
- # built-ins for invocations like `hammer recipes` that aren't a flag
1203
+ # True if argv targets the reserved `h:` built-in namespace (`h`, `h:`,
1204
+ # `h:update`, ...). Used in the no-Hammerfile branch to wake up the
1205
+ # built-ins for invocations like `hammer h:recipes` that aren't a flag
1137
1206
  # or help request.
1138
- BUILTIN_TASKS ||= %w[recipes update agents version init].freeze
1139
1207
  def self.looks_like_builtin?(argv)
1140
1208
  first = argv.first
1141
1209
  return false unless first
1142
- BUILTIN_TASKS.include?(first) || BUILTIN_TASKS.any? { |t| first.start_with?("#{t}:") }
1210
+ first == 'h' || first.start_with?('h:')
1143
1211
  end
1144
1212
 
1145
1213
  # Walk up the directory tree looking for a Hammerfile.
@@ -1154,4 +1222,21 @@ class Hammer
1154
1222
  end
1155
1223
  end
1156
1224
 
1225
+ # Spawn the vendored macOS GUI (gui/Hammer.app), pointed at the project
1226
+ # dir and this hammer binary. Launched directly (not via `open`) so it
1227
+ # inherits the caller's environment - the GUI shells back out to this
1228
+ # same `hammer` for `h:json` and task runs, and that needs the same PATH.
1229
+ def self.launch_gui!(project_dir)
1230
+ bin = File.expand_path('../gui/Hammer.app/Contents/MacOS/HammerGUI', __dir__)
1231
+ unless File.executable?(bin)
1232
+ Shell.print_error "GUI app not found at #{bin}"
1233
+ Shell.say 'build it: ./gui/HammerGUI/build_app.sh', :yellow
1234
+ exit 1
1235
+ end
1236
+ hammer_bin = (File.realpath($PROGRAM_NAME) rescue File.expand_path($PROGRAM_NAME))
1237
+ pid = Process.spawn(bin, '--project', File.expand_path(project_dir), '--hammer', hammer_bin)
1238
+ Process.detach(pid)
1239
+ Shell.say "launched Hammer GUI for #{project_dir} (pid #{pid})", :green
1240
+ end
1241
+
1157
1242
  end
data/recipes/deploy.rb ADDED
@@ -0,0 +1,32 @@
1
+ #!/usr/bin/env hammer
2
+ # desc: SSH/rsync deploy - Caddy + systemd + atomic releases
3
+
4
+ desc <<~TXT
5
+ Stupid-simple SSH/rsync deploy (Caddy + systemd + atomic releases).
6
+
7
+ Quickstart:
8
+ deploy app:init # copy templates into ./config/deploy/
9
+ deploy doctor # check & prep the host
10
+ deploy up # deploy current branch
11
+
12
+ Server:
13
+ deploy server:log # tail the systemd journal
14
+ deploy server:ssh # shell into the current release
15
+ deploy server:restart # restart the web service
16
+ deploy log --log errors # dump a remote log
17
+ TXT
18
+
19
+ # Loads the bundled lib (config/ssh/template/doctor/context/manifest/
20
+ # commands/hammer) - same require chain the gem's lib/lux_deploy.rb had.
21
+ require_relative 'lib/deploy/boot'
22
+
23
+ # Auto-load the app's deploy bootstrap, if present, before tasks fire.
24
+ # A consumer can inject Ruby (e.g. a pre-deploy hook) without writing a
25
+ # custom Hammerfile.
26
+ init = File.join(Dir.pwd, 'config', 'deploy', 'init.rb')
27
+ load init if File.file?(init)
28
+
29
+ # `self` here is the recipe's Builder context - same surface a Hammerfile
30
+ # gets. Pass templates_dir explicitly since there's no gem ROOT to fall
31
+ # back to; it resolves to recipes/lib/deploy/templates.
32
+ LuxDeploy::Hammer.register(self, templates_dir: File.join(__dir__, 'lib/deploy/templates'))
@@ -142,8 +142,8 @@ helpers do
142
142
  if message.empty?
143
143
  run 'git reset --mixed'
144
144
  exit
145
- elsif message.length < 5
146
- say 'Please add better commit message, min length 5 chars', :red
145
+ elsif message.length < 1
146
+ say 'Please add better commit message, min length 1 chars', :red
147
147
  next
148
148
  else
149
149
  bump_version
@@ -0,0 +1,52 @@
1
+ require 'fileutils'
2
+ require 'pathname'
3
+ require 'open3'
4
+ require 'set'
5
+ require 'shellwords'
6
+ require 'yaml'
7
+
8
+ module LuxDeploy
9
+ # Recipe layout: this file and its siblings live under
10
+ # recipes/lib/deploy/, so ROOT points here and `templates/` sits
11
+ # right next to us (no gem root anymore).
12
+ ROOT ||= Pathname.new(__dir__)
13
+ VERSION ||= '0.2.0'
14
+
15
+ # Branches that select `.env` instead of `.env.staging`.
16
+ MAIN_BRANCHES ||= %w[master main]
17
+
18
+ # Server-side conventions. Not config-tunable because doctor and the
19
+ # deploy flow both hardcode these paths in the host setup. A different
20
+ # caddy/systemd layout means a different recipe.
21
+ PORT_RANGE ||= (3010..3990).step(10).to_a
22
+ CADDY_SITES ||= '/etc/caddy/sites'
23
+ SYSTEMD_DIR ||= '/etc/systemd/system'
24
+
25
+ class Error < StandardError
26
+ def to_s
27
+ "ERROR: #{super}"
28
+ end
29
+ end
30
+
31
+ # Host-supplied defaults that sit under the user's .yaml. Set once by a
32
+ # wrapping plugin/Hammerfile (e.g. lux-fw seeds 'lux-web' / 'lux-apps'),
33
+ # consumed by Config.new. Empty by default so the recipe stays "generic".
34
+ @defaults = {}
35
+
36
+ class << self
37
+ attr_reader :defaults
38
+
39
+ def set_defaults(hash)
40
+ @defaults = (hash || {}).each_with_object({}) { |(k, v), h| h[k.to_s] = v }
41
+ end
42
+ end
43
+ end
44
+
45
+ require_relative 'config'
46
+ require_relative 'ssh'
47
+ require_relative 'template'
48
+ require_relative 'doctor'
49
+ require_relative 'context'
50
+ require_relative 'manifest'
51
+ require_relative 'commands'
52
+ require_relative 'hammer'