lux-hammer 0.3.12 → 0.3.13

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/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,21 +556,30 @@ 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
 
@@ -601,7 +618,7 @@ class Hammer
601
618
  if o.boolean?
602
619
  parts << (val ? "--#{o.name}" : "--no-#{o.name}")
603
620
  else
604
- parts << "--#{o.name}=#{val}"
621
+ parts << "--#{o.name}=#{val.is_a?(Array) ? val.join(',') : val}"
605
622
  end
606
623
  end
607
624
  parts.concat(positional)
@@ -612,6 +629,9 @@ class Hammer
612
629
  # Each class's hooks fire at most once per top-level `start`, so
613
630
  # prereqs dispatched via `needs` won't re-trigger them.
614
631
  def run_before_hooks(instance, opts)
632
+ # Built-in `h:` meta-commands parent to the project root but must not
633
+ # trigger the project's own `before` hooks (dotenv, env checks, ...).
634
+ return if instance_variable_get(:@builtin_namespace)
615
635
  ran = Thread.current[:hammer_before_ran] ||= {}
616
636
  ancestor_chain.each do |klass|
617
637
  next if ran[klass.object_id]
@@ -661,15 +681,29 @@ class Hammer
661
681
 
662
682
  print_top_banner
663
683
  Shell.say "Usage: #{program_name} COMMAND [ARGS]", :cyan
684
+ # Compact (bare-invocation) view only - the extended `--help` view
685
+ # already IS the full usage, so don't nag about it there.
686
+ unless extended
687
+ Shell.say "add `--help` to show usage help", :gray
688
+ # No project Hammerfile + no custom tasks loaded: point the user at
689
+ # `h:init`. The flag is set by `Hammer.cli` when the lookup misses.
690
+ if instance_variable_get(:@no_hammerfile)
691
+ Shell.say "no Hammerfile found in #{Dir.pwd} - run `#{program_name} h:init` to create one", :gray
692
+ end
693
+ end
664
694
  if @app_desc && !@app_desc.empty?
665
695
  Shell.say ''
666
696
  @app_desc.each_line { |l| Shell.say " #{l.chomp}" }
667
697
  end
698
+ # Built-in `h:` commands only surface in the extended view
699
+ # (`--help` / `-h` / `help`); the bare-invocation listing stays
700
+ # focused on the project's own tasks. They remain dispatchable
701
+ # regardless - this only governs what the listing shows.
668
702
  if expanded
669
- each_command { |path, c| print_full_block(path, c) unless c.desc.empty? }
703
+ each_command(include_builtins: extended) { |path, c| print_full_block(path, c) unless c.desc.empty? }
670
704
  else
671
705
  Shell.say ''
672
- print_command_list(self)
706
+ print_command_list(self, include_builtins: extended)
673
707
  end
674
708
  print_recipes_section if extended && root.instance_variable_get(:@hammer_binary)
675
709
  print_extras if extended
@@ -688,7 +722,7 @@ class Hammer
688
722
  entries.each do |name, file|
689
723
  desc = Hammer::Recipe.desc(file)
690
724
  installed = Hammer::Recipe.installed_path(name)
691
- suffix = installed ? "(installed: #{installed})" : "[install: #{program_name} recipes --install #{name}]"
725
+ suffix = installed ? "(installed: #{installed})" : "[install: #{program_name} h:recipes --install #{name}]"
692
726
  Shell.say " #{name.ljust(width)} # #{desc}"
693
727
  Shell.say " #{' ' * width} #{suffix}", :gray
694
728
  end
@@ -761,19 +795,19 @@ class Hammer
761
795
 
762
796
  # Hammerfile cheat-sheet shown under `hammer --help`. Same content
763
797
  # as `hammer --init` writes - single source of truth via
764
- # `Hammer::STARTER_HAMMERFILE`. For exhaustive docs see `hammer agents`.
798
+ # `Hammer::STARTER_HAMMERFILE`. For exhaustive docs see `hammer h:agents`.
765
799
  def print_hammerfile_example
766
800
  Shell.say ''
767
801
  Shell.say 'Hammerfile example:', :yellow
768
802
  Shell.say Hammer::STARTER_HAMMERFILE
769
803
  end
770
804
 
771
- def print_command_list(klass, prefix = nil)
805
+ def print_command_list(klass, prefix = nil, include_builtins: true)
772
806
  rows = []
773
807
  # Commands without a `desc` are hidden from listings but still
774
808
  # dispatchable + `hammer`-callable - useful for private helpers
775
809
  # 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? }
810
+ klass.each_command(prefix, include_builtins: include_builtins) { |full, c| rows << [full, c] unless c.desc.empty? }
777
811
  return if rows.empty?
778
812
 
779
813
  # group by "section" = everything between the view prefix and the
@@ -911,7 +945,7 @@ class Hammer
911
945
  Shell.print_error "unknown recipe: #{name}"
912
946
  Shell.say 'available recipes:', :yellow
913
947
  Recipe.all.keys.sort.each { |n| Shell.say " #{n}" }
914
- Shell.say 'try `hammer recipes` to list with descriptions', :gray
948
+ Shell.say 'try `hammer h:recipes` to list with descriptions', :gray
915
949
  exit 1
916
950
  end
917
951
 
@@ -967,12 +1001,12 @@ class Hammer
967
1001
  end
968
1002
  RUBY
969
1003
 
970
- # Default install dir used by install.sh and `hammer update`.
1004
+ # Default install dir used by install.sh and `hammer h:update`.
971
1005
  SELF_UPDATE_DIR ||= File.expand_path('~/.local/share/lux-hammer')
972
1006
  SELF_UPDATE_REPO ||= 'https://github.com/dux/hammer.git'
973
1007
  SELF_INSTALL_URL ||= 'https://raw.githubusercontent.com/dux/hammer/main/install.sh'
974
1008
 
975
- # `hammer update`: pull main in the install-script checkout and
1009
+ # `hammer h:update`: pull main in the install-script checkout and
976
1010
  # reinstall the gem. Assumes the install.sh layout - if the dir is
977
1011
  # missing, point the user at the curl-pipe installer.
978
1012
  def self.self_update
@@ -1028,15 +1062,17 @@ class Hammer
1028
1062
  path = force_system ? nil : find_hammerfile(Dir.pwd)
1029
1063
  unless path
1030
1064
  # 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.
1065
+ # `hammer`, `hammer h:recipes`, `hammer h:update`, `hammer h:agents`,
1066
+ # `hammer h:version`, `hammer h:init` all work.
1033
1067
  if force_system || dispatches_to_builtin?(argv) || looks_like_builtin?(argv)
1034
1068
  klass = Class.new(Hammer)
1035
1069
  klass.instance_variable_set(:@hammer_binary, true)
1070
+ # No project Hammerfile was found - only built-ins are loaded. The
1071
+ # bare-invocation help uses this to note that no Hammerfile exists.
1072
+ klass.instance_variable_set(:@no_hammerfile, true)
1036
1073
  klass.program_name
1037
1074
  require_relative 'hammer/builtins'
1038
- Hammer::Builtins.register_core(klass)
1039
- Hammer::Builtins.register_no_project(klass)
1075
+ Hammer::Builtins.register(klass)
1040
1076
  klass.start(argv)
1041
1077
  return
1042
1078
  end
@@ -1060,8 +1096,8 @@ class Hammer
1060
1096
  Shell.say STARTER_HAMMERFILE
1061
1097
  Shell.say ''
1062
1098
  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
1099
+ Shell.say "tip: run `#{bin} h:init` to drop the example above into ./Hammerfile", :gray
1100
+ Shell.say "tip: run `#{bin} h:agents` for AI-friendly Hammerfile authoring docs", :gray
1065
1101
  exit 1
1066
1102
  end
1067
1103
 
@@ -1082,14 +1118,13 @@ class Hammer
1082
1118
  # are NOT visible during Hammerfile evaluation, only inside handlers.
1083
1119
  Hammer::Dotenv.load(Dir.pwd) if klass.dotenv_enabled?
1084
1120
 
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.
1121
+ # Built-ins register AFTER Hammerfile eval so user-defined tasks win
1122
+ # (the `unless commands.key?(...)` guards skip a built-in when the
1123
+ # Hammerfile already owns the name - no redefinition warning). All
1124
+ # built-ins live under `h:`, so they can't collide with project root
1125
+ # tasks and the full set registers in every context.
1091
1126
  require_relative 'hammer/builtins'
1092
- Hammer::Builtins.register_core(klass)
1127
+ Hammer::Builtins.register(klass)
1093
1128
 
1094
1129
  klass.start(argv)
1095
1130
  end
@@ -1109,7 +1144,7 @@ class Hammer
1109
1144
  end
1110
1145
 
1111
1146
  # Evaluate a shebang script as a self-contained CLI. Mirrors `recipe`
1112
- # semantics: no chdir, no `@hammer_binary` flag, no `register_core`
1147
+ # semantics: no chdir, no `@hammer_binary` flag, no `Builtins.register`
1113
1148
  # built-ins (so the script's `--help` shows only what it defines).
1114
1149
  # `program_name` is the script's basename so help reads "myscript foo"
1115
1150
  # rather than "hammer foo" - works even when invoked via a symlink in
@@ -1131,15 +1166,14 @@ class Hammer
1131
1166
  first == 'help' || first == '-h' || first == '--help' || first.start_with?('-')
1132
1167
  end
1133
1168
 
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
1169
+ # True if argv targets the reserved `h:` built-in namespace (`h`, `h:`,
1170
+ # `h:update`, ...). Used in the no-Hammerfile branch to wake up the
1171
+ # built-ins for invocations like `hammer h:recipes` that aren't a flag
1137
1172
  # or help request.
1138
- BUILTIN_TASKS ||= %w[recipes update agents version init].freeze
1139
1173
  def self.looks_like_builtin?(argv)
1140
1174
  first = argv.first
1141
1175
  return false unless first
1142
- BUILTIN_TASKS.include?(first) || BUILTIN_TASKS.any? { |t| first.start_with?("#{t}:") }
1176
+ first == 'h' || first.start_with?('h:')
1143
1177
  end
1144
1178
 
1145
1179
  # Walk up the directory tree looking for a Hammerfile.
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'))
@@ -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'