asgard 0.1.0 → 0.1.2

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/README.md CHANGED
@@ -1,9 +1,11 @@
1
1
  # Asgard
2
2
 
3
- A [just](https://just.systems)-like task runner for Ruby. Define project recipes in a `.loki` file and run them with the `asgard` command. Built on [Thor](https://github.com/rails/thor) for argument handling and [SimpleFlow](https://github.com/madbomber/simple_flow) for dependency ordering.
3
+ A Ruby task runner built on [Thor](https://github.com/rails/thor) for argument handling and [Dagwood](https://github.com/rewindio/dagwood) for dependency ordering.
4
4
 
5
5
  The name comes from Norse mythology: **Thor** is the CLI framework, **Asgard** is the realm where tasks live, and the task file is named **loki** — because Loki holds all the tricks.
6
6
 
7
+ > **Asgard is a wrapper around [Thor](https://github.com/rails/thor).** Anything Thor can do — subcommands, typed options, argument validation, shell completion — is available inside a `.loki` file. Familiarity with Thor's DSL will make you immediately productive with Asgard.
8
+
7
9
  ## Installation
8
10
 
9
11
  ```bash
@@ -16,241 +18,518 @@ Or add to your Gemfile:
16
18
  bundle add asgard
17
19
  ```
18
20
 
19
- ## Quick Start
21
+ ---
22
+
23
+ ## Tasks
24
+
25
+ Every `.loki` file defines tasks as methods inside `class Tasks`. The `Tasks` class is pre-defined by the gem — just reopen it and add methods.
20
26
 
21
- Create a `.loki` file at your project root. `sh` runs any shell command — a single line or a multiline heredoc:
27
+ ### A task with no parameters
22
28
 
23
29
  ```ruby
24
- # filename: .loki
30
+ class Tasks
31
+ desc "hello", "Say hello"
32
+ def hello = sh 'echo "Hello, World!"'
33
+ end
34
+ ```
25
35
 
26
- class Tasks < Asgard::Base
27
- desc "deps", "Install project dependencies"
28
- def deps
29
- sh <<~SHELL
30
- brew install redis
31
- npm install
32
- bundle install
33
- SHELL
34
- end
36
+ ```bash
37
+ asgard hello
38
+ ```
35
39
 
36
- depends_on :deps
37
- desc "test", "Run the test suite"
38
- def test
39
- sh "bundle exec rake test"
40
- end
40
+ ### A task with a positional parameter
41
41
 
42
- depends_on :test
43
- desc "release", "Tag and publish the gem"
44
- def release
45
- sh <<~SHELL
46
- git tag v$(ruby -r./lib/my_gem/version -e 'puts MyGem::VERSION')
47
- git push --tags
48
- bundle exec rake release
49
- SHELL
50
- end
42
+ Declare positional parameters directly in the method signature. Document them in the `desc` usage string:
43
+
44
+ ```ruby
45
+ class Tasks
46
+ desc "hello NAME", "Say hello to NAME"
47
+ def hello(name = "World") = sh "echo 'Hello, #{name}!'"
48
+ end
49
+ ```
50
+
51
+ ```bash
52
+ asgard hello
53
+ asgard hello Alice
54
+ ```
55
+
56
+ ### A task with a formal argument declaration
57
+
58
+ Use `argument` for richer metadata — type checking, enums, and help text:
59
+
60
+ ```ruby
61
+ class Tasks
62
+ argument :name,
63
+ type: :string,
64
+ default: "World",
65
+ desc: "Name to greet"
66
+
67
+ desc "hello NAME", "Say hello to NAME"
68
+ def hello = sh "echo 'Hello, #{name}!'"
51
69
  end
52
70
  ```
53
71
 
54
- Then run tasks from any directory in the project tree:
72
+ ### A task with named options
73
+
74
+ Use `method_option` (alias: `option`) for named flags. Access them inside the method via `options[:name]`:
75
+
76
+ ```ruby
77
+ class Tasks
78
+ desc "hello NAME", "Say hello to NAME"
79
+ method_option :shout, aliases: "-s", type: :boolean, desc: "Uppercase the output"
80
+ method_option :count, aliases: "-n", type: :numeric, default: 1, desc: "Repeat N times"
81
+ def hello(name = "World")
82
+ message = options[:shout] ? "HELLO, #{name.upcase}!" : "Hello, #{name}!"
83
+ options[:count].times { sh "echo '#{message}'" }
84
+ end
85
+ end
86
+ ```
55
87
 
56
88
  ```bash
57
- asgard test # runs deps, then test
58
- asgard release # runs deps, then test, then release
59
- asgard help # list all available tasks
89
+ asgard hello Alice --shout --count 3
60
90
  ```
61
91
 
62
- ## Task Files
92
+ ### A task with an extended description
93
+
94
+ `long_desc` provides detailed help shown by `asgard help <task>`:
95
+
96
+ ```ruby
97
+ class Tasks
98
+ long_desc <<~DESC
99
+ Says hello to NAME.
100
+ Repeats the greeting COUNT times.
101
+ Use --shout to uppercase the output.
102
+ DESC
103
+ desc "hello NAME", "Say hello to NAME"
104
+ method_option :shout, aliases: "-s", type: :boolean, desc: "Uppercase the output"
105
+ method_option :count, aliases: "-n", type: :numeric, default: 1, desc: "Repeat N times"
106
+ def hello(name = "World")
107
+ message = options[:shout] ? "HELLO, #{name.upcase}!" : "Hello, #{name}!"
108
+ options[:count].times { sh "echo '#{message}'" }
109
+ end
110
+ end
111
+ ```
112
+
113
+ ---
114
+
115
+ ## Dependencies
116
+
117
+ `depends_on` declares what must run before a task. Each dependency runs at most once per `asgard` invocation regardless of how many tasks declare it. Circular dependencies are caught at startup.
63
118
 
64
- Asgard searches the current directory and its ancestors for task files, in this order:
119
+ `desc` and `depends_on` are independent either can come first, both must appear before `def`.
65
120
 
66
- 1. **`.loki`** — the hidden default. Found alone, takes priority over everything.
67
- 2. **`*.loki`** — all matching files loaded alphabetically when no `.loki` exists.
121
+ ### Sequential dependencies
68
122
 
69
- This means you can split a large task set across multiple files:
123
+ Bare symbols run one after another in the order declared:
70
124
 
125
+ ```ruby
126
+ class Tasks
127
+ desc "build", "Compile the project"
128
+ def build = sh "rake build"
129
+
130
+ depends_on :build
131
+ desc "test", "Run the test suite"
132
+ def test = sh "rake test"
133
+
134
+ depends_on :test
135
+ desc "release", "Publish the gem"
136
+ def release = sh "bundle exec rake release"
137
+ end
71
138
  ```
72
- deploy.loki
73
- test.loki
74
- build.loki # loaded as: build.loki, deploy.loki, test.loki
139
+
140
+ ```bash
141
+ asgard release # build test release
75
142
  ```
76
143
 
77
- Or use a single hidden default:
144
+ ### Parallel dependencies
145
+
146
+ Wrap symbols in an array to declare they can run concurrently. Asgard waits for all tasks in a parallel group to finish before moving to the next stage:
147
+
148
+ ```ruby
149
+ class Tasks
150
+ desc "lint", "Check code style"
151
+ def lint = sh "bundle exec rubocop"
152
+
153
+ desc "typecheck", "Run type checks"
154
+ def typecheck = sh "bundle exec srb tc"
78
155
 
156
+ # lint and typecheck run in parallel, test waits for both
157
+ depends_on [:lint, :typecheck]
158
+ desc "test", "Run the test suite"
159
+ def test = sh "bundle exec rake test"
160
+ end
79
161
  ```
80
- .loki # takes priority, *.loki files are ignored
162
+
163
+ ```bash
164
+ asgard test # lint ∥ typecheck → test
81
165
  ```
82
166
 
83
- ## Features
167
+ ### Mixed sequential and parallel
84
168
 
85
- ### Task dependencies
169
+ Mix bare symbols (sequential) and arrays (parallel) in a single `depends_on` call. Execution proceeds stage by stage — each stage must complete before the next begins:
86
170
 
87
171
  ```ruby
88
- depends_on :build
89
- desc "test", "Run tests"
90
- def test
91
- sh "bundle exec rake test"
172
+ class Tasks
173
+ desc "setup", "Install dependencies"; def setup = sh "bundle install"
174
+ desc "lint", "Check code style"; def lint = sh "bundle exec rubocop"
175
+ desc "build", "Compile assets"; def build = sh "rake assets:precompile"
176
+ desc "test", "Run tests"; def test = sh "bundle exec rake test"
177
+ desc "notify", "Post to Slack"; def notify = sh "curl $SLACK_WEBHOOK -d '{\"text\":\"done\"}'"
178
+
179
+ # setup first, then lint+build in parallel, then test, then notify
180
+ depends_on :setup, [:lint, :build], :test, :notify
181
+ desc "ci", "Full CI pipeline"
182
+ def ci = sh "echo 'CI complete'"
92
183
  end
93
184
  ```
94
185
 
95
- Dependencies run before the recipe, at most once per invocation regardless of how many recipes declare them. Circular dependencies are caught at startup via `SimpleFlow::DependencyGraph`.
186
+ ```
187
+ asgard ci executes:
188
+
189
+ setup
190
+
191
+ lint ∥ build (concurrent)
192
+
193
+ test
194
+
195
+ notify
196
+
197
+ ci
198
+ ```
199
+
200
+ ---
201
+
202
+ ## Variables
96
203
 
97
- ### Variables
204
+ `var` declares a named value available to all tasks as a method. Pass a lambda for lazy evaluation — it is called once on first use:
98
205
 
99
206
  ```ruby
100
- var :app, "myapp"
101
- var :version, -> { `git describe --tags`.strip } # lazy, evaluated on first use
207
+ class Tasks
208
+ var :app, "myapp"
209
+ var :version, -> { `git describe --tags`.strip }
102
210
 
103
- desc "tag", "Create a git tag"
104
- def tag
105
- sh "git tag #{version}"
211
+ desc "tag", "Create a release tag"
212
+ def tag = sh "git tag #{app}-#{version}"
106
213
  end
107
214
  ```
108
215
 
109
- ### Multi-line shell scripts
216
+ ---
217
+
218
+ ## Helper methods
219
+
220
+ Private methods are callable from any task in the same class but are never registered as commands — they won't appear in `--help` output and can't be invoked from the CLI.
110
221
 
111
222
  ```ruby
112
- desc "setup", "Bootstrap the dev environment"
113
- def setup
114
- sh <<~SHELL
115
- brew install redis postgresql
116
- brew services start redis
117
- bundle install
118
- rails db:setup
119
- SHELL
223
+ class Tasks
224
+ desc "build", "Compile and package"
225
+ def build
226
+ compile("src")
227
+ package(version)
228
+ end
229
+
230
+ desc "release", "Build and publish"
231
+ def release
232
+ build
233
+ sh "gem push pkg/myapp-#{version}.gem"
234
+ end
235
+
236
+ private
237
+
238
+ def compile(dir)
239
+ sh "gcc -O2 -o bin/myapp #{dir}/*.c"
240
+ end
241
+
242
+ def package(ver)
243
+ sh "tar czf pkg/myapp-#{ver}.tar.gz bin/"
244
+ end
120
245
  end
121
246
  ```
122
247
 
123
- ### Embedded scripts in other languages
248
+ Helpers can also be shared across multiple `.loki` files by extracting them into a plain Ruby file and loading it explicitly:
124
249
 
125
250
  ```ruby
126
- desc "analyze", "Run data analysis"
127
- def analyze
128
- shebang :python3, <<~PYTHON
129
- import json
130
- data = json.load(open("results.json"))
131
- print(f"Total: {sum(data.values())}")
132
- PYTHON
251
+ # shared/helpers.rb
252
+ module BuildHelpers
253
+ private
254
+
255
+ def compile(dir)
256
+ sh "gcc -O2 -o bin/myapp #{dir}/*.c"
257
+ end
133
258
  end
134
259
 
135
- desc "bundle", "Build frontend assets"
136
- def bundle_assets
137
- shebang :node, <<~JS
138
- const esbuild = require("esbuild")
139
- esbuild.buildSync({ entryPoints: ["src/app.js"], bundle: true, outfile: "dist/app.js" })
140
- JS
260
+ # .loki
261
+ require_relative "shared/helpers"
262
+
263
+ class Tasks
264
+ include BuildHelpers
265
+
266
+ desc "build", "Compile the project"
267
+ def build = compile("src")
141
268
  end
142
269
  ```
143
270
 
144
- Supported interpreters: `:python3`, `:python`, `:node`, `:ruby`, `:perl`, `:bash`, `:sh`. Any other symbol is passed directly to `system` with a `.tmp` extension.
271
+ ---
145
272
 
146
- ### Importing task modules
273
+ ## Options shared across all tasks
147
274
 
148
- Split tasks into reusable modules and include them flat (all tasks in the same namespace):
275
+ `class_option` defines an option available to every task in the class:
149
276
 
150
277
  ```ruby
151
- # shared/deploy_tasks.rb
152
- module DeployTasks
153
- def self.included(base)
154
- base.desc "deploy", "Deploy to production"
155
- base.define_method(:deploy) { sh "cap production deploy" }
278
+ class Tasks
279
+ class_option :dry_run, aliases: "-n", type: :boolean, desc: "Print commands without running"
280
+
281
+ desc "deploy ENV", "Deploy to the given environment"
282
+ def deploy(env = "staging")
283
+ if options[:dry_run]
284
+ puts "Would deploy to #{env}"
285
+ else
286
+ sh "cap #{env} deploy"
287
+ end
156
288
  end
157
289
  end
290
+ ```
158
291
 
159
- # .loki
160
- require_relative "shared/deploy_tasks"
292
+ ---
293
+
294
+ ## Shell helpers
161
295
 
162
- class Tasks < Asgard::Base
163
- import DeployTasks
296
+ `sh` runs any shell command or multiline heredoc. `shebang` writes a script body to a tempfile and executes it with the given interpreter. Both exit with the command's status code on failure.
297
+
298
+ ```ruby
299
+ class Tasks
300
+ desc "setup", "Bootstrap the development environment"
301
+ def setup
302
+ sh <<~SHELL
303
+ brew install redis postgresql
304
+ brew services start redis
305
+ bundle install
306
+ rails db:setup
307
+ SHELL
308
+ end
309
+
310
+ desc "analyze", "Run Python data analysis"
311
+ def analyze
312
+ shebang :python3, <<~PYTHON
313
+ import json
314
+ data = json.load(open("results.json"))
315
+ print(f"Total: {sum(data.values())}")
316
+ PYTHON
317
+ end
318
+
319
+ desc "bundle_assets", "Build frontend assets with esbuild"
320
+ def bundle_assets
321
+ shebang :node, <<~JS
322
+ const esbuild = require("esbuild")
323
+ esbuild.buildSync({ entryPoints: ["src/app.js"], bundle: true, outfile: "dist/app.js" })
324
+ JS
325
+ end
164
326
  end
165
327
  ```
166
328
 
167
- For namespaced subcommands, use Thor's `register`:
329
+ Supported interpreters: `:python3`, `:python`, `:node`, `:ruby`, `:perl`, `:bash`, `:sh`. Any other symbol is passed directly to `system` with a `.tmp` extension.
330
+
331
+ Pass `silent: true` to suppress the command echo:
168
332
 
169
333
  ```ruby
170
- register DeployTasks, "deploy", "deploy COMMAND", "Deployment tasks"
171
- # invoked as: asgard deploy production
334
+ def build = sh "rake build", silent: true
172
335
  ```
173
336
 
174
- ### Dotenv
337
+ ---
338
+
339
+ ## Environment variables
340
+
341
+ `dotenv` loads a `.env` file into the environment before tasks run:
175
342
 
176
343
  ```ruby
177
- class Tasks < Asgard::Base
178
- dotenv # loads .env from CWD
179
- dotenv ".env.local"
344
+ class Tasks
345
+ dotenv # loads .env
346
+ dotenv ".env.local" # or a specific file
180
347
 
181
- desc "check", "Print the app name"
182
- def check
183
- sh "echo $APP_NAME"
184
- end
348
+ desc "check", "Print the app name from .env"
349
+ def check = sh "echo $APP_NAME"
185
350
  end
186
351
  ```
187
352
 
188
- ### Echo suppression
353
+ ---
354
+
355
+ ## Command aliases
189
356
 
190
- Pass `silent: true` to suppress the command echo (equivalent to `just`'s `@` prefix):
357
+ `map` creates alternative names for a task:
191
358
 
192
359
  ```ruby
193
- def build
194
- sh "bundle exec rake build", silent: true # runs quietly, output still shown
360
+ class Tasks
361
+ map "-v" => "version"
362
+ map "--v" => "version"
363
+ map "t" => "test"
364
+
365
+ desc "version", "Print the version"
366
+ def version = puts Asgard::VERSION
195
367
  end
196
368
  ```
197
369
 
198
- ## Shell helpers
370
+ ---
199
371
 
200
- | Method | Description |
201
- |---|---|
202
- | `sh(script, silent: false)` | Run a shell command or multiline script |
203
- | `shebang(interpreter, script, silent: false)` | Write script to a tempfile and execute it |
372
+ ## Subcommands
204
373
 
205
- Both exit with the command's status code on failure.
374
+ Group related tasks under a common name using Thor's `subcommand` method. Define a subcommand class that inherits from `Tasks`, then register it with a name and description.
206
375
 
207
- ## Full example `.loki`
376
+ ```ruby
377
+ class DeployCommands < Tasks
378
+ desc "staging", "Deploy to staging"
379
+ def staging = sh "cap staging deploy"
380
+
381
+ desc "production", "Deploy to production"
382
+ def production = sh "cap production deploy"
383
+ end
384
+
385
+ class Tasks
386
+ desc "deploy SUBCOMMAND", "Deploy the application"
387
+ subcommand "deploy", DeployCommands
388
+ end
389
+ ```
390
+
391
+ ```bash
392
+ asgard deploy # shows deploy subcommand help
393
+ asgard deploy staging
394
+ asgard deploy production
395
+ ```
396
+
397
+ Subcommand tasks have all the same access to helper methods like `sh`, `shebang`, `depends_on`, `var`, and the built-in `--debug`/`--verbose` class options as normal tasks.
398
+
399
+ `depends_on` only works within a subcommand group exactly as it does at the top level:
208
400
 
209
401
  ```ruby
210
- require "asgard"
402
+ class DBCommands < Tasks
403
+ desc "migrate", "Run pending migrations"
404
+ def migrate = sh "rails db:migrate"
211
405
 
212
- class Tasks < Asgard::Base
213
- dotenv
406
+ desc "seed", "Load seed data"
407
+ def seed = sh "rails db:seed"
214
408
 
215
- var :app, "myapp"
216
- var :version, -> { File.read("VERSION").strip }
409
+ depends_on :migrate, :seed
410
+ desc "reset", "Migrate then seed"
411
+ def reset = puts "Done."
412
+ end
413
+
414
+ class Tasks
415
+ desc "db SUBCOMMAND", "Manage the database"
416
+ subcommand "db", DBCommands
417
+ end
418
+ ```
419
+
420
+ ```bash
421
+ asgard db reset # migrate → seed → reset
422
+ ```
423
+
424
+ Each subcommand group can have its own `desc`, `long_desc`, `option`, `class_option`, and `map` declarations, all scoped to that group.
425
+
426
+ See [`examples/server_subcommands.loki`](examples/server_subcommands.loki) and [`examples/db_subcommands.loki`](examples/db_subcommands.loki) for full working examples.
427
+
428
+ ---
429
+
430
+ ## `method_option` types reference
217
431
 
432
+ | Type | CLI example | Ruby value |
433
+ |---|---|---|
434
+ | `:string` | `--branch main` | `"main"` |
435
+ | `:boolean` | `--force` / `--no-force` | `true` / `false` |
436
+ | `:numeric` | `--count 3` | `3` |
437
+ | `:array` | `--tags foo bar baz` | `["foo", "bar", "baz"]` |
438
+ | `:hash` | `--vars KEY:val FOO:bar` | `{"KEY"=>"val", "FOO"=>"bar"}` |
439
+
440
+ Common `method_option` keys: `aliases`, `type`, `default`, `required`, `desc`, `enum`, `banner`.
441
+
442
+ ---
443
+
444
+ ## Task files
445
+
446
+ Asgard searches the current directory and its ancestors for a `.loki` file. That file marks the project root. All `*.loki` files in the same directory are auto-loaded alphabetically before `.loki` is loaded.
447
+
448
+ ### Single file
449
+
450
+ ```
451
+ myproject/
452
+ .loki
453
+ ```
454
+
455
+ ### Multiple files
456
+
457
+ Split tasks across files — each reopens `class Tasks`:
458
+
459
+ ```
460
+ myproject/
461
+ .loki ← entry point, can be empty
462
+ build.loki
463
+ deploy.loki
464
+ test.loki
465
+ ```
466
+
467
+ ```ruby
468
+ # build.loki
469
+ class Tasks
470
+ desc "build", "Compile the project"
471
+ def build = sh "rake build"
472
+ end
473
+ ```
474
+
475
+ ```ruby
476
+ # test.loki
477
+ class Tasks
478
+ depends_on :build
218
479
  desc "test", "Run the test suite"
219
- def test
220
- sh "bundle exec rake test"
221
- end
480
+ def test = sh "bundle exec rake test"
481
+ end
482
+ ```
222
483
 
484
+ ```ruby
485
+ # deploy.loki
486
+ class Tasks
223
487
  depends_on :test
224
- desc "quality", "Run tests then check complexity"
225
- def quality
226
- sh "flog lib/"
227
- end
488
+ desc "deploy", "Deploy to production"
489
+ def deploy = sh "cap production deploy"
490
+ end
491
+ ```
228
492
 
229
- desc "build", "Build the gem"
230
- def build
231
- sh "bundle exec rake build"
232
- end
493
+ The `.loki` entry point can be completely empty — it only needs to exist to mark the project root.
233
494
 
234
- depends_on :quality, :build
235
- desc "release", "Release #{version} to RubyGems"
236
- def release
237
- sh "bundle exec rake release"
238
- end
495
+ ### Explicit loading
496
+
497
+ Load any Ruby or `.loki` file manually from `.loki`:
498
+
499
+ ```ruby
500
+ # .loki
501
+ require_relative "shared/helpers"
502
+ require_relative "ci.loki"
503
+
504
+ class Tasks
505
+ # additional tasks
239
506
  end
240
507
  ```
241
508
 
509
+ ---
510
+
511
+ ## `Asgard` module API
512
+
513
+ | Method | Description |
514
+ |---|---|
515
+ | `Asgard.run!(argv)` | Entry point — finds `.loki`, loads task files, starts CLI |
516
+ | `Asgard.find_task_file` | Returns path to `.loki` searching from CWD upward, or nil |
517
+ | `Asgard.load_loki(dir)` | Loads all `*.loki` files in dir alphabetically |
518
+
519
+ `run!` handles its own errors — a missing `.loki` or a circular dependency both produce a clean one-line message and exit 1.
520
+
521
+ ---
522
+
242
523
  ## Development
243
524
 
244
525
  ```bash
245
526
  git clone git@github.com:MadBomber/asgard.git
246
527
  cd asgard
247
528
  bundle install
248
- bundle exec rake test # run tests with coverage
249
- bundle exec bin/asgard help # try the CLI against this gem's own .loki file
529
+ bundle exec rake test # run tests (95% coverage minimum enforced)
530
+ bundle exec bin/asgard help # exercise the CLI against this gem's own .loki
250
531
  ```
251
532
 
252
- Coverage threshold is enforced at 95% via SimpleCov.
253
-
254
533
  ## Contributing
255
534
 
256
535
  Bug reports and pull requests are welcome at https://github.com/MadBomber/asgard.
data/bin/asgard CHANGED
@@ -2,23 +2,4 @@
2
2
  # frozen_string_literal: true
3
3
 
4
4
  require "asgard"
5
-
6
- task_files = Asgard.find_task_files
7
-
8
- unless task_files
9
- warn "asgard: no .loki or *.loki task file found in #{Dir.pwd} or any parent directory"
10
- exit 1
11
- end
12
-
13
- task_files.each { |f| load f }
14
-
15
- klass = Asgard::Base.subclasses.last
16
-
17
- unless klass
18
- warn "asgard: no class inheriting Asgard::Base found in #{task_files.join(', ')}"
19
- exit 1
20
- end
21
-
22
- klass.validate_deps!
23
- klass._reset_ran!
24
- klass.start(ARGV)
5
+ Asgard.run!(ARGV)