asgard 0.2.0 → 0.3.0

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.
@@ -2,7 +2,7 @@
2
2
  # Demonstrates Thor subcommands registered on the top-level Tasks class.
3
3
  #
4
4
  # The subcommand class inherits from Tasks so it has access to sh, shebang,
5
- # var, depends_on, and the built-in --debug/--verbose class options.
5
+ # depends_on, and the built-in --debug/--verbose class options.
6
6
  #
7
7
  # Usage:
8
8
  # asgard server # shows subcommand help
@@ -26,7 +26,7 @@ class ServerCommands < Tasks
26
26
  ]
27
27
  end
28
28
 
29
- desc "stop", "Stop the running server"
29
+ desc "Stop the running server"
30
30
  option :force, aliases: "-f", type: :boolean, default: false, desc: "Force-kill without draining"
31
31
  option :wait, type: :numeric, default: 30, desc: "Seconds to wait for shutdown"
32
32
  def stop
@@ -37,7 +37,7 @@ class ServerCommands < Tasks
37
37
  end
38
38
  end
39
39
 
40
- desc "status", "Show server status and process info"
40
+ desc "Show server status and process info"
41
41
  def status
42
42
  puts "Checking server status..."
43
43
  end
@@ -0,0 +1,12 @@
1
+ # examples/subdir/.loki — root task file for this subdirectory.
2
+ #
3
+ # When asgard is run from examples/subdir/, it loads this file.
4
+ # From here, import pulls in sibling task files explicitly.
5
+ #
6
+ # import accepts a direct path or a glob:
7
+ # import "import_up_demo.loki" # a single file
8
+ # import "*.loki" # all loki files in this directory
9
+ # import "tasks/**/*.loki" # all loki files under a tasks/ tree
10
+ # import "../shared/*.loki" # files in a sibling directory
11
+
12
+ import "import_up_demo.loki"
@@ -0,0 +1,14 @@
1
+ # frozen_string_literal: true
2
+ # This file lives in a subdirectory and is explicitly imported by the
3
+ # parent examples/.loki via import "subdir/import_demo.loki".
4
+ #
5
+ # In a real project a subdirectory might hold task files scoped to a
6
+ # specific concern (deploy, database, CI) while the root .loki wires
7
+ # them all together with import.
8
+
9
+ class Tasks
10
+ desc "Confirm this task was loaded from a subdirectory via import"
11
+ def subdir_task
12
+ puts "Loaded from examples/subdir/import_demo.loki"
13
+ end
14
+ end
@@ -0,0 +1,18 @@
1
+ # frozen_string_literal: true
2
+ # Demonstrates import_up — find a file by walking up the directory tree
3
+ # and load it, without needing to know how deep the current file is.
4
+ #
5
+ # import_up(name) combines loki_up(name) + import(path) in one call.
6
+ # It searches CWD, then each ancestor in turn, and loads the first match.
7
+ #
8
+ # This is useful when a nested task file needs to pull in something from
9
+ # the project root — a shared config, a common helpers file — without
10
+ # hardcoding a relative path that would break if the file moves.
11
+ #
12
+ # import_up also accepts a glob:
13
+ # import_up "*.loki" # first directory (walking up) that contains any .loki file
14
+ # import_up "config/settings.loki" # first ancestor with config/settings.loki
15
+ # import_up ".env" # locate a .env file anywhere up the tree
16
+
17
+ # Load env_usage.loki from the nearest ancestor directory that contains it.
18
+ import_up "env_usage.loki"
data/lib/asgard/base.rb CHANGED
@@ -1,7 +1,6 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  require "thor"
4
- require "set"
5
4
  require "dagwood"
6
5
 
7
6
  module Asgard
@@ -16,9 +15,10 @@ module Asgard
16
15
  def inherited(subclass)
17
16
  super
18
17
  Asgard::Base.subclasses << subclass
19
- subclass.instance_variable_set(:@_deps, {})
20
- subclass.instance_variable_set(:@_vars, {})
21
- subclass.instance_variable_set(:@_pending_deps, [])
18
+ subclass.instance_variable_set(:@_deps, {})
19
+ subclass.instance_variable_set(:@_pending_deps, [])
20
+ subclass.instance_variable_set(:@_pending_single_desc, nil)
21
+ subclass.instance_variable_set(:@_pending_single_desc_opts, nil)
22
22
  subclass.instance_variable_set(:@_running, Set.new)
23
23
  subclass.instance_variable_set(:@_done, Set.new)
24
24
  subclass.instance_variable_set(:@_cond, Hash.new { |h, k| h[k] = ConditionVariable.new })
@@ -29,10 +29,6 @@ module Asgard
29
29
  @_deps ||= {}
30
30
  end
31
31
 
32
- def _vars
33
- @_vars ||= {}
34
- end
35
-
36
32
  def _running
37
33
  @_running ||= Set.new
38
34
  end
@@ -65,7 +61,7 @@ module Asgard
65
61
  def _build_dep_graph(stages)
66
62
  graph = {}
67
63
  stages.each_with_index do |stage, i|
68
- prev_stage = i > 0 ? stages[i - 1] : []
64
+ prev_stage = i.positive? ? stages[i - 1] : []
69
65
  stage.each { |task| graph[task] = prev_stage.dup }
70
66
  end
71
67
  graph
@@ -82,68 +78,98 @@ module Asgard
82
78
  @_pending_deps = tasks
83
79
  end
84
80
 
85
- def var(name, value = nil, &block)
86
- value = block if block_given?
87
- _vars[name.to_sym] = value
88
- no_commands do
89
- define_method(name) do
90
- ivar = :"@__var_#{name}"
91
- unless instance_variable_defined?(ivar)
92
- v = self.class._vars[name.to_sym]
93
- instance_variable_set(ivar, v.respond_to?(:call) ? v.call : v)
94
- end
95
- instance_variable_get(ivar)
96
- end
81
+ # Allow single-argument desc: desc "Run the tests"
82
+ # The usage string defaults to the method name when the description is the only arg.
83
+ def desc(usage_or_desc, description = nil, options = {})
84
+ if description.nil? || description.is_a?(Hash)
85
+ options = description if description.is_a?(Hash)
86
+ @_pending_single_desc = usage_or_desc
87
+ @_pending_single_desc_opts = options
88
+ else
89
+ @_pending_single_desc = nil
90
+ @_pending_single_desc_opts = nil
91
+ super
97
92
  end
98
93
  end
99
94
 
100
- def import(mod)
101
- include mod
102
- end
103
-
104
95
  def dotenv(path = ".env")
105
96
  require "dotenv"
106
97
  Dotenv.load(path) if File.exist?(path)
107
98
  end
108
99
 
109
- # Validate the full dep graph for cycles using Dagwood::DependencyGraph.
110
- def validate_deps!
111
- pending = Array(@_pending_deps)
112
- if pending.any?
113
- raise Asgard::Error,
114
- "depends_on(#{pending.join(', ')}) declared without a following task definition"
100
+ def default_task(meth = nil)
101
+ if meth && meth != :none && @_default_task_location
102
+ here = caller_locations(1, 1).first
103
+ warn "asgard: default_task :#{meth} at #{here.path}:#{here.lineno} " \
104
+ "overrides default_task :#{@_default_task_name} set at " \
105
+ "#{@_default_task_location.path}:#{@_default_task_location.lineno}"
106
+ end
107
+ if meth && meth != :none
108
+ @_default_task_location = caller_locations(1, 1).first
109
+ @_default_task_name = meth
115
110
  end
111
+ super
112
+ end
116
113
 
114
+ # Validate the full dep graph for cycles using Dagwood::DependencyGraph.
115
+ def validate_deps!
116
+ _check_orphaned_deps!
117
117
  return if _deps.empty?
118
118
 
119
119
  all_task_names = all_commands.keys.map(&:to_sym)
120
- full_graph = all_task_names.each_with_object({}) do |task, hash|
121
- hash[task] = _deps.fetch(task, []).flatten
122
- end
120
+ _check_undefined_deps!(all_task_names)
121
+ _check_dep_arities!
122
+ _build_and_sort_graph(all_task_names)
123
+ rescue TSort::Cyclic => e
124
+ raise Asgard::CircularDependencyError, e.message
125
+ end
126
+
127
+ private
128
+
129
+ def _check_orphaned_deps!
130
+ pending = Array(@_pending_deps)
131
+ return unless pending.any?
123
132
 
133
+ raise Asgard::Error,
134
+ "depends_on(#{pending.join(', ')}) declared without a following task definition"
135
+ end
136
+
137
+ def _check_undefined_deps!(all_task_names)
124
138
  undefined = _deps.values.flatten.uniq - all_task_names
125
- if undefined.any?
126
- raise Asgard::Error, "undefined task(s) in depends_on: #{undefined.sort.join(', ')}"
127
- end
139
+ return unless undefined.any?
128
140
 
129
- _deps.each do |_task, stages|
141
+ raise Asgard::Error, "undefined task(s) in depends_on: #{undefined.sort.join(', ')}"
142
+ end
143
+
144
+ def _check_dep_arities!
145
+ _deps.each_value do |stages|
130
146
  stages.flatten.each do |dep|
131
- meth = instance_method(dep.to_s) rescue nil
132
- next unless meth
147
+ meth = instance_method(dep.to_s)
133
148
  required = meth.parameters.count { |type, _| type == :req }
134
- if required > 0
135
- raise Asgard::Error,
136
- "task '#{dep}' has #{required} required argument(s) and cannot be used as a dependency"
137
- end
149
+ next unless required.positive?
150
+
151
+ raise Asgard::Error,
152
+ "task '#{dep}' has #{required} required argument(s) and cannot be used as a dependency"
138
153
  end
139
154
  end
155
+ end
140
156
 
157
+ def _build_and_sort_graph(all_task_names)
158
+ full_graph = all_task_names.to_h { |task| [task, _deps.fetch(task, []).flatten] }
141
159
  Dagwood::DependencyGraph.new(full_graph).order
142
- rescue TSort::Cyclic => e
143
- raise Asgard::CircularDependencyError, e.message
144
160
  end
145
161
 
162
+ public
163
+
146
164
  def method_added(method_name)
165
+ if @_pending_single_desc && !no_commands?
166
+ pending_desc = @_pending_single_desc
167
+ pending_opts = @_pending_single_desc_opts || {}
168
+ @_pending_single_desc = nil
169
+ @_pending_single_desc_opts = nil
170
+ desc(method_name.to_s, pending_desc, pending_opts)
171
+ end
172
+
147
173
  return super unless @usage
148
174
 
149
175
  pending = Array(@_pending_deps).dup
@@ -171,51 +197,67 @@ module Asgard
171
197
  $DEBUG = true if options[:debug]
172
198
  $VERBOSE = true if options[:verbose]
173
199
  target = command.name.to_sym
174
-
175
- should_run = self.class._ran_mutex.synchronize do
176
- if self.class._done.include?(target)
177
- false
178
- elsif self.class._running.include?(target)
179
- self.class._cond[target].wait(self.class._ran_mutex) until self.class._done.include?(target)
180
- false
181
- else
182
- self.class._running.add(target)
183
- true
184
- end
185
- end
186
- return unless should_run
200
+ return unless acquire_run_token(target)
187
201
 
188
202
  begin
189
- stages = self.class._deps[target]
190
- if stages&.any?
191
- graph = self.class._build_dep_graph(stages)
192
- groups = Dagwood::DependencyGraph.new(graph).parallel_order
193
-
194
- groups.each do |group|
195
- if group.size > 1
196
- threads = group.map { |task| Thread.new { _run_dep(task) } }
197
- errors = []
198
- threads.each { |t| begin; t.join; rescue => e; errors << e; end }
199
- raise errors.first if errors.any?
200
- else
201
- _run_dep(group.first)
202
- end
203
- end
204
- end
205
-
203
+ run_deps_for(target)
206
204
  command.run(self, *args)
207
205
  ensure
208
- self.class._ran_mutex.synchronize do
209
- self.class._done.add(target)
210
- self.class._cond[target].broadcast
211
- end
206
+ signal_done(target)
207
+ end
208
+ end
209
+ end
210
+
211
+ private
212
+
213
+ def acquire_run_token(target)
214
+ self.class._ran_mutex.synchronize do
215
+ if self.class._done.include?(target)
216
+ false
217
+ elsif self.class._running.include?(target)
218
+ self.class._cond[target].wait(self.class._ran_mutex) until self.class._done.include?(target)
219
+ false
220
+ else
221
+ self.class._running.add(target)
222
+ true
212
223
  end
213
224
  end
225
+ end
226
+
227
+ def run_deps_for(target)
228
+ stages = self.class._deps[target]
229
+ return unless stages&.any?
230
+
231
+ groups = Dagwood::DependencyGraph.new(self.class._build_dep_graph(stages)).parallel_order
232
+ groups.each { |group| run_dep_group(group) }
233
+ end
214
234
 
215
- def _run_dep(task)
216
- command = self.class.all_commands[task.to_s]
217
- invoke_command(command) if command
235
+ def run_dep_group(group)
236
+ if group.size > 1
237
+ threads = group.map { |task| Thread.new { run_dep(task) } }
238
+ errors = []
239
+ threads.each { |t| begin; t.join; rescue => e; errors << e; end }
240
+ if errors.size == 1
241
+ raise errors.first
242
+ elsif errors.any?
243
+ errors.each { |e| warn "asgard: #{e.message}" }
244
+ raise Asgard::Error, "#{errors.size} parallel dependencies failed"
245
+ end
246
+ else
247
+ run_dep(group.first)
248
+ end
249
+ end
250
+
251
+ def signal_done(target)
252
+ self.class._ran_mutex.synchronize do
253
+ self.class._done.add(target)
254
+ self.class._cond[target].broadcast
218
255
  end
219
256
  end
257
+
258
+ def run_dep(task)
259
+ command = self.class.all_commands[task.to_s]
260
+ invoke_command(command) if command
261
+ end
220
262
  end
221
263
  end
@@ -0,0 +1,77 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Kernel
4
+ def debug? = $DEBUG
5
+ def verbose? = $VERBOSE
6
+ module_function :debug?, :verbose?
7
+
8
+ # Fetch an environment variable by symbol or string name.
9
+ # The name is converted to an uppercase string automatically.
10
+ # Raises KeyError when the variable is missing and no default is given.
11
+ def env(name, default = nil)
12
+ key = name.to_s.upcase
13
+ default.nil? ? ENV.fetch(key) : ENV.fetch(key, default)
14
+ end
15
+ module_function :env
16
+
17
+ def loki_up(name = ".loki")
18
+ dir = Dir.pwd
19
+ loop do
20
+ candidate = File.join(dir, name)
21
+ return candidate if File.exist?(candidate)
22
+ parent = File.dirname(dir)
23
+ break if parent == dir
24
+ dir = parent
25
+ end
26
+ nil
27
+ end
28
+ module_function :loki_up
29
+
30
+ def import(path)
31
+ path = path.to_s
32
+ raise ArgumentError, "import: path must end with .loki (got #{path.inspect})" unless path.end_with?(".loki")
33
+ unless File.absolute_path?(path)
34
+ caller_dir = File.dirname(caller_locations(1, 1).first.absolute_path)
35
+ path = File.expand_path(path, caller_dir)
36
+ end
37
+ paths = path =~ /[*?\[{]/ ? Dir.glob(path) : [path]
38
+ loaded = paths.map do |p|
39
+ if $LOADED_FEATURES.include?(p)
40
+ warn "import: skip #{p} (already loaded)" if debug?
41
+ next false
42
+ end
43
+ warn "import: #{p}" if verbose? || debug?
44
+ load p
45
+ $LOADED_FEATURES << p
46
+ true
47
+ end
48
+ loaded.any?
49
+ end
50
+ module_function :import
51
+
52
+ def import_up(name = ".loki")
53
+ if name =~ /[*?\[{]/
54
+ dir = Dir.pwd
55
+ loop do
56
+ matches = Dir.glob(File.join(dir, name))
57
+ unless matches.empty?
58
+ warn "import_up: #{name} → #{dir}" if verbose? || debug?
59
+ return matches.map { |p| import(p) }.any?
60
+ end
61
+ parent = File.dirname(dir)
62
+ break if parent == dir
63
+ dir = parent
64
+ end
65
+ warn "import_up: #{name} not found" if debug?
66
+ return false
67
+ end
68
+ path = loki_up(name)
69
+ unless path
70
+ warn "import_up: #{name} not found" if debug?
71
+ return false
72
+ end
73
+ warn "import_up: #{name} → #{path}" if verbose? || debug?
74
+ import path
75
+ end
76
+ module_function :import_up
77
+ end
data/lib/asgard/shell.rb CHANGED
@@ -1,5 +1,6 @@
1
1
  # frozen_string_literal: true
2
2
 
3
+ require 'English'
3
4
  require "tempfile"
4
5
 
5
6
  module Asgard
@@ -12,12 +13,12 @@ module Asgard
12
13
  $stdout.puts script unless silent
13
14
 
14
15
  success = if script.include?("\n")
15
- system("bash", "-c", script)
16
- else
17
- system(script)
18
- end
16
+ system("bash", "-c", script)
17
+ else
18
+ system(script)
19
+ end
19
20
 
20
- exit($?.exitstatus) unless success
21
+ exit($CHILD_STATUS.exitstatus) unless success
21
22
  end
22
23
 
23
24
  # Write +script+ to a tempfile and execute it with +interpreter+.
@@ -34,11 +35,11 @@ module Asgard
34
35
 
35
36
  $stdout.puts script unless silent
36
37
 
37
- Tempfile.create(["asgard_", ext]) do |f|
38
+ Tempfile.create(["asgard_", ext]) do |f|
38
39
  f.write(script)
39
40
  f.flush
40
41
  system(interpreter.to_s, f.path)
41
- exit($?.exitstatus) unless $?.success?
42
+ exit($CHILD_STATUS.exitstatus) unless $CHILD_STATUS.success?
42
43
  end
43
44
  end
44
45
  end
data/lib/asgard/tasks.rb CHANGED
@@ -14,21 +14,10 @@ class Tasks < Asgard::Base
14
14
  default: false,
15
15
  desc: "Enable verbose output ($VERBOSE = true)"
16
16
 
17
- desc "--auto-load", "Load all *.loki files from the project root before running"
18
- map "--auto-load" => :_auto_load
19
- def _auto_load
20
- # Consumed by run! before Thor dispatch — never called directly.
21
- end
22
-
23
17
  desc "--version", "Show asgard version"
24
18
  map "--version" => :_version
25
19
  def _version
26
20
  puts Asgard::VERSION
27
21
  exit
28
22
  end
29
-
30
- private
31
-
32
- def debug? = $DEBUG
33
- def verbose? = $VERBOSE
34
23
  end
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Asgard
4
- VERSION = "0.2.0"
4
+ VERSION = "0.3.0"
5
5
  end
data/lib/asgard.rb CHANGED
@@ -1,6 +1,7 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  require_relative "asgard/version"
4
+ require_relative "asgard/kernel_methods"
4
5
  require_relative "asgard/shell"
5
6
  require_relative "asgard/base"
6
7
  require_relative "asgard/tasks"
@@ -12,31 +13,14 @@ module Asgard
12
13
  # Search the current directory and its ancestors for a .loki task file.
13
14
  # Returns the path string, or nil if not found.
14
15
  def self.find_task_file
15
- dir = Dir.pwd
16
- loop do
17
- candidate = File.join(dir, ".loki")
18
- return candidate if File.exist?(candidate)
19
- parent = File.dirname(dir)
20
- break if parent == dir
21
- dir = parent
22
- end
23
- nil
24
- end
25
-
26
- # Load all *.loki files from dir in alphabetical order.
27
- # Each file typically reopens class Tasks to add tasks.
28
- # The .loki entry point is excluded — it is loaded separately by run!.
29
- def self.load_loki(dir)
30
- Dir.glob(File.join(dir, "*.loki")).sort.each { |f| load f }
16
+ loki_up
31
17
  end
32
18
 
33
19
  # Main entry point invoked by the asgard executable.
34
20
  def self.run!(argv)
35
- auto_load = argv.delete("--auto-load")
36
21
  abort "asgard: unknown command '#{argv.first}'" if argv.first&.start_with?("_")
37
22
  task_file = find_task_file or abort "asgard: no .loki file found in #{Dir.pwd}"
38
23
  before = Asgard::Base.subclasses.dup
39
- load_loki(File.dirname(task_file)) if auto_load
40
24
  load task_file
41
25
  newly_defined = Asgard::Base.subclasses - before
42
26
  (newly_defined + [Tasks]).uniq.each(&:validate_deps!)
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: asgard
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.2.0
4
+ version: 0.3.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Dewayne VanHoozer
@@ -51,9 +51,10 @@ dependencies:
51
51
  - - "~>"
52
52
  - !ruby/object:Gem::Version
53
53
  version: '3.0'
54
- description: A powerful Ruby-based task runner for any kind of project with task dependency
55
- tracking and concurrent execution of designated tasks. Uses Thor for its rich CLI
56
- options, var declarations, dotenv, sh/shebang helpers, and importable task files.
54
+ description: |
55
+ A powerful Ruby-based task runner for any kind of project with task dependency tracking
56
+ and concurrent execution of designated tasks. Uses Thor for its rich CLI options, var
57
+ declarations, dotenv, sh/shebang helpers, and importable task files.
57
58
  email:
58
59
  - dewayne@vanhoozer.me
59
60
  executables:
@@ -64,6 +65,7 @@ files:
64
65
  - ".envrc"
65
66
  - ".github/workflows/deploy-github-pages.yml"
66
67
  - ".loki"
68
+ - ".rubocop.yml"
67
69
  - CHANGELOG.md
68
70
  - CLAUDE.md
69
71
  - COMMITS.md
@@ -89,13 +91,19 @@ files:
89
91
  - docs/task-files.md
90
92
  - docs/tasks.md
91
93
  - docs/variables.md
94
+ - examples/.env
92
95
  - examples/.loki
93
96
  - examples/concurrent.loki
94
97
  - examples/db_subcommands.loki
98
+ - examples/env_usage.loki
95
99
  - examples/kitchen_sink.loki
96
100
  - examples/server_subcommands.loki
101
+ - examples/subdir/.loki
102
+ - examples/subdir/import_demo.loki
103
+ - examples/subdir/import_up_demo.loki
97
104
  - lib/asgard.rb
98
105
  - lib/asgard/base.rb
106
+ - lib/asgard/kernel_methods.rb
99
107
  - lib/asgard/shell.rb
100
108
  - lib/asgard/tasks.rb
101
109
  - lib/asgard/version.rb
@@ -108,6 +116,7 @@ metadata:
108
116
  homepage_uri: https://github.com/madbomber/asgard
109
117
  source_code_uri: https://github.com/madbomber/asgard
110
118
  changelog_uri: https://github.com/madbomber/asgard/blob/master/CHANGELOG.md
119
+ rubygems_mfa_required: 'true'
111
120
  rdoc_options: []
112
121
  require_paths:
113
122
  - lib