asgard 0.1.1 → 0.2.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.
data/docs/shell.md ADDED
@@ -0,0 +1,208 @@
1
+ # Shell Helpers
2
+
3
+ Asgard provides two methods for running shell commands and scripts from within task bodies: `sh` for shell commands and heredocs, and `shebang` for polyglot scripts. Both are provided by `Asgard::Shell` and mixed into every `Tasks` instance.
4
+
5
+ Both methods exit with the command's status code on failure — they do not raise Ruby exceptions.
6
+
7
+ ---
8
+
9
+ ## `sh` — Run Shell Commands
10
+
11
+ ### Single-Line Command
12
+
13
+ Pass a single-line string to run it via `system`:
14
+
15
+ ```ruby
16
+ class Tasks
17
+ desc "build", "Compile the project"
18
+ def build = sh "rake build"
19
+
20
+ desc "clean", "Remove build artifacts"
21
+ def clean = sh "rm -rf dist/ tmp/"
22
+ end
23
+ ```
24
+
25
+ By default, `sh` prints the command before running it:
26
+
27
+ ```
28
+ rake build
29
+ ```
30
+
31
+ ### Multi-Line Heredoc
32
+
33
+ Pass a multiline string (e.g., a heredoc) to run it as a single `bash -c` script. All lines execute in the same shell session, so variable assignments carry across lines:
34
+
35
+ ```ruby
36
+ class Tasks
37
+ desc "setup", "Bootstrap the development environment"
38
+ def setup
39
+ sh <<~SHELL
40
+ brew install redis postgresql
41
+ brew services start redis
42
+ bundle install
43
+ rails db:setup
44
+ SHELL
45
+ end
46
+ end
47
+ ```
48
+
49
+ Asgard detects the newline and automatically routes multiline scripts through `bash -c`.
50
+
51
+ ### Silent Mode
52
+
53
+ Pass `silent: true` to suppress the command echo. The command still runs and still exits on failure; it just doesn't print the command text first:
54
+
55
+ ```ruby
56
+ class Tasks
57
+ desc "build", "Compile (quiet)"
58
+ def build = sh "rake build", silent: true
59
+
60
+ desc "info", "Print environment info without noise"
61
+ def info
62
+ sh "printenv | grep APP_", silent: true
63
+ end
64
+ end
65
+ ```
66
+
67
+ ### Exit on Failure
68
+
69
+ `sh` always calls `exit($?.exitstatus)` if the command fails. There is no rescue path — a failing command terminates the `asgard` process. This is intentional: failed steps should stop the pipeline rather than silently continue.
70
+
71
+ ```ruby
72
+ class Tasks
73
+ depends_on :test
74
+ desc "release", "Test then release"
75
+ def release
76
+ sh "bundle exec rake release"
77
+ # Never reached if rake release fails
78
+ puts "Released!"
79
+ end
80
+ end
81
+ ```
82
+
83
+ ---
84
+
85
+ ## `shebang` — Polyglot Scripts
86
+
87
+ `shebang` writes the script body to a tempfile with the appropriate extension and executes it with the specified interpreter. Use it to embed Python, Node.js, Ruby, Perl, or any other interpreter directly in a task:
88
+
89
+ ### Python
90
+
91
+ ```ruby
92
+ class Tasks
93
+ desc "analyze", "Run Python data analysis"
94
+ def analyze
95
+ shebang :python3, <<~PYTHON
96
+ import json
97
+ data = json.load(open("results.json"))
98
+ print(f"Total: {sum(data.values())}")
99
+ PYTHON
100
+ end
101
+ end
102
+ ```
103
+
104
+ ### Node.js
105
+
106
+ ```ruby
107
+ class Tasks
108
+ desc "bundle_assets", "Build frontend assets with esbuild"
109
+ def bundle_assets
110
+ shebang :node, <<~JS
111
+ const esbuild = require("esbuild")
112
+ esbuild.buildSync({
113
+ entryPoints: ["src/app.js"],
114
+ bundle: true,
115
+ outfile: "dist/app.js"
116
+ })
117
+ JS
118
+ end
119
+ end
120
+ ```
121
+
122
+ ### Ruby
123
+
124
+ ```ruby
125
+ class Tasks
126
+ desc "transform", "Transform data with Ruby"
127
+ def transform
128
+ shebang :ruby, <<~RUBY
129
+ require "json"
130
+ data = JSON.parse(File.read("input.json"))
131
+ File.write("output.json", JSON.pretty_generate(data.transform_values(&:upcase)))
132
+ RUBY
133
+ end
134
+ end
135
+ ```
136
+
137
+ ### Bash
138
+
139
+ ```ruby
140
+ class Tasks
141
+ desc "provision", "Run a bash provisioning script"
142
+ def provision
143
+ shebang :bash, <<~BASH
144
+ set -euo pipefail
145
+ apt-get update
146
+ apt-get install -y curl wget git
147
+ echo "Provisioned at $(date)"
148
+ BASH
149
+ end
150
+ end
151
+ ```
152
+
153
+ ---
154
+
155
+ ## Supported Interpreters
156
+
157
+ | Symbol | File Extension | Interpreter |
158
+ |---|---|---|
159
+ | `:python3` | `.py` | `python3` |
160
+ | `:python` | `.py` | `python` |
161
+ | `:node` | `.js` | `node` |
162
+ | `:ruby` | `.rb` | `ruby` |
163
+ | `:perl` | `.pl` | `perl` |
164
+ | `:bash` | `.sh` | `bash` |
165
+ | `:sh` | `.sh` | `sh` |
166
+ | Any other symbol | `.tmp` | Passed directly to `system` |
167
+
168
+ !!! note
169
+ Unknown interpreter symbols get a `.tmp` extension and are passed to `system` directly. This makes it easy to use interpreters not in the table above:
170
+
171
+ ```ruby
172
+ shebang :lua, <<~LUA
173
+ print("Hello from Lua!")
174
+ LUA
175
+ ```
176
+
177
+ ### Silent Mode
178
+
179
+ `shebang` also accepts `silent: true`, though in practice the interpreter itself controls what is printed:
180
+
181
+ ```ruby
182
+ def analyze
183
+ shebang :python3, script_body, silent: true
184
+ end
185
+ ```
186
+
187
+ ---
188
+
189
+ ## Combining `sh` and `shebang`
190
+
191
+ You can mix both in the same task:
192
+
193
+ ```ruby
194
+ class Tasks
195
+ desc "pipeline", "Run a mixed shell + Python pipeline"
196
+ def pipeline
197
+ sh "bundle exec rake build"
198
+
199
+ shebang :python3, <<~PYTHON
200
+ import subprocess
201
+ result = subprocess.run(["./bin/validate"], capture_output=True, text=True)
202
+ print(result.stdout)
203
+ PYTHON
204
+
205
+ sh "rake deploy"
206
+ end
207
+ end
208
+ ```
@@ -0,0 +1,181 @@
1
+ # Subcommands
2
+
3
+ Subcommands group related tasks under a common namespace, giving you commands like `asgard server start` or `asgard db migrate`. Asgard uses Thor's `subcommand` method for this, with one important convention: subcommand classes inherit from `Tasks` rather than from `Asgard::Base` or `Thor` directly.
4
+
5
+ ---
6
+
7
+ ## Basic Pattern
8
+
9
+ Define a subcommand class that inherits from `Tasks`, then register it on the top-level `Tasks` class with `subcommand`:
10
+
11
+ ```ruby
12
+ class DeployCommands < Tasks
13
+ desc "staging", "Deploy to staging"
14
+ def staging = sh "cap staging deploy"
15
+
16
+ desc "production", "Deploy to production"
17
+ def production = sh "cap production deploy"
18
+ end
19
+
20
+ class Tasks
21
+ desc "deploy SUBCOMMAND", "Deploy the application"
22
+ subcommand "deploy", DeployCommands
23
+ end
24
+ ```
25
+
26
+ ```bash
27
+ asgard deploy # shows deploy subcommand help
28
+ asgard deploy staging
29
+ asgard deploy production
30
+ ```
31
+
32
+ ---
33
+
34
+ ## Why Inherit from `Tasks`?
35
+
36
+ Inheriting from `Tasks` (rather than `Asgard::Base` or `Thor`) gives the subcommand class access to:
37
+
38
+ - `sh` and `shebang` shell helpers (from `Asgard::Shell`)
39
+ - `depends_on` for dependency declarations
40
+ - `var` for variables
41
+ - `dotenv` for environment loading
42
+ - The built-in `--debug` and `--verbose` class options
43
+ - The `debug?` and `verbose?` private predicates
44
+ - Any private helpers or `no_commands` methods defined on `Tasks`
45
+
46
+ !!! warning
47
+ Do **not** redeclare `class_option :debug` or `class_option :verbose` in your subcommand class — they are already inherited from `Tasks`. Redeclaring them causes duplicate option errors.
48
+
49
+ ---
50
+
51
+ ## depends_on Within a Subcommand
52
+
53
+ `depends_on` works exactly as at the top level, scoped to the subcommand's own dependency graph:
54
+
55
+ ```ruby
56
+ class DBCommands < Tasks
57
+ desc "migrate", "Run pending migrations"
58
+ def migrate = sh "rails db:migrate"
59
+
60
+ desc "seed", "Load seed data"
61
+ def seed = sh "rails db:seed"
62
+
63
+ depends_on :migrate, :seed
64
+ desc "reset", "Migrate then seed"
65
+ def reset = puts "Done."
66
+ end
67
+
68
+ class Tasks
69
+ desc "db SUBCOMMAND", "Manage the database"
70
+ subcommand "db", DBCommands
71
+ end
72
+ ```
73
+
74
+ ```bash
75
+ asgard db reset # migrate → seed → reset
76
+ ```
77
+
78
+ ---
79
+
80
+ ## Server Subcommand Example
81
+
82
+ ```ruby
83
+ class ServerCommands < Tasks
84
+ desc "start [PORT]", "Start the server on PORT (default: 3000)"
85
+ option :daemon, aliases: "-d", type: :boolean, default: false, desc: "Run as a background daemon"
86
+ option :workers, aliases: "-w", type: :numeric, default: 2, desc: "Number of worker processes"
87
+ option :log, type: :string, default: "log/server.log",
88
+ banner: "FILE", desc: "Write logs to FILE"
89
+ def start(port = "3000")
90
+ flags = []
91
+ flags << "--daemon" if options[:daemon]
92
+ flags << "--workers #{options[:workers]}"
93
+ flags << "--log #{options[:log]}"
94
+ sh "puma -p #{port} #{flags.join(' ')}"
95
+ end
96
+
97
+ desc "stop", "Stop the running server"
98
+ option :force, aliases: "-f", type: :boolean, default: false, desc: "Force-kill without draining"
99
+ def stop
100
+ options[:force] ? sh "pkill -9 puma" : sh "pumactl stop"
101
+ end
102
+
103
+ desc "status", "Show server status"
104
+ def status = sh "pumactl stats"
105
+
106
+ depends_on :stop, :start
107
+ desc "restart [PORT]", "Stop then start"
108
+ def restart(port = "3000") = puts "Server restarted on :#{port}."
109
+ end
110
+
111
+ class Tasks
112
+ desc "server SUBCOMMAND", "Manage the application server"
113
+ subcommand "server", ServerCommands
114
+ end
115
+ ```
116
+
117
+ ```bash
118
+ asgard server start
119
+ asgard server start 4000 --workers 4 --daemon
120
+ asgard server stop --force
121
+ asgard server restart
122
+ asgard server status
123
+ ```
124
+
125
+ ---
126
+
127
+ ## Scoped DSL
128
+
129
+ Each subcommand class has its own independent scope for:
130
+
131
+ - `desc` / `long_desc` — documentation strings
132
+ - `method_option` / `option` — per-command options
133
+ - `class_option` — options shared across the subcommand's tasks (in addition to inherited ones)
134
+ - `map` — aliases within the subcommand group
135
+ - `default_task` — which command runs when the subcommand is invoked with no further arguments
136
+ - `depends_on` — dependency declarations scoped to this class
137
+
138
+ These do not bleed into the parent `Tasks` class or other subcommand classes.
139
+
140
+ ---
141
+
142
+ ## Multiple Subcommands
143
+
144
+ You can register as many subcommand groups as needed:
145
+
146
+ ```ruby
147
+ class ServerCommands < Tasks
148
+ # ... server tasks ...
149
+ end
150
+
151
+ class DBCommands < Tasks
152
+ # ... database tasks ...
153
+ end
154
+
155
+ class DeployCommands < Tasks
156
+ # ... deploy tasks ...
157
+ end
158
+
159
+ class Tasks
160
+ desc "server SUBCOMMAND", "Manage the server"; subcommand "server", ServerCommands
161
+ desc "db SUBCOMMAND", "Manage the database"; subcommand "db", DBCommands
162
+ desc "deploy SUBCOMMAND", "Deploy"; subcommand "deploy", DeployCommands
163
+ end
164
+ ```
165
+
166
+ ---
167
+
168
+ ## Subcommands Across Files
169
+
170
+ Define each subcommand class in its own `.loki` file. Because all files reopen the same Ruby classes, the classes are available when `.loki` registers them:
171
+
172
+ ```
173
+ myproject/
174
+ .loki ← registers all subcommands
175
+ server_subcommands.loki ← defines ServerCommands
176
+ db_subcommands.loki ← defines DBCommands
177
+ ```
178
+
179
+ When `--auto-load` is used, `*.loki` files are loaded alphabetically before `.loki`, so both `DBCommands` and `ServerCommands` are defined by the time `.loki` runs its `subcommand` calls.
180
+
181
+ See [`examples/server_subcommands.loki`](examples.md#server-subcommands) and [`examples/db_subcommands.loki`](examples.md#db-subcommands) for complete working examples.
@@ -0,0 +1,254 @@
1
+ # Task Files
2
+
3
+ Asgard uses a convention-based file discovery system. A hidden `.loki` file marks the project root; `*.loki` files in the same directory contain tasks that are loaded on demand via `--auto-load`.
4
+
5
+ ---
6
+
7
+ ## The `.loki` Root Marker
8
+
9
+ When you run `asgard`, it searches for a `.loki` file starting in the current working directory and walking upward through parent directories until it finds one or reaches the filesystem root. The first `.loki` file found marks the project root. It may also contain the main task definitions for the project.
10
+
11
+ This means you can run `asgard` from any subdirectory of your project and it will find your tasks:
12
+
13
+ ```
14
+ myproject/
15
+ .loki ← found regardless of which subdirectory you're in
16
+ src/
17
+ app/
18
+ # asgard still works from here
19
+ ```
20
+
21
+ !!! note
22
+ The `.loki` file can be completely empty. Its presence alone is sufficient to mark the project root. If it is empty and you have `*.loki` files, you must pass `--auto-load` when running `asgard` — otherwise Asgard has nothing to do.
23
+
24
+ ---
25
+
26
+ ## Loading `*.loki` Files with `--auto-load`
27
+
28
+ By default, `asgard` only loads `.loki`. To also load `*.loki` files, pass `--auto-load`:
29
+
30
+ ```bash
31
+ asgard --auto-load <task>
32
+ ```
33
+
34
+ When `--auto-load` is active, Asgard loads all files matching `*.loki` in the same directory in alphabetical order before loading `.loki`. Each file typically reopens `class Tasks` to add more tasks. The `*.loki` glob specifically excludes `.loki` (note the leading dot) — the entry point is always loaded last.
35
+
36
+ **Load order when `--auto-load` is passed:**
37
+
38
+ 1. All `*.loki` files alphabetically (e.g., `build.loki`, `deploy.loki`, `test.loki`)
39
+ 2. `.loki` itself (the entry point)
40
+
41
+ This means any tasks, classes, or variables defined in `*.loki` files are available when `.loki` runs.
42
+
43
+ ### Task Name Overloading
44
+
45
+ Because all `*.loki` files reopen the same `class Tasks`, it is possible — by accident or by design — for two files to define a method with the same name. This is **task overloading**. Ruby's class reopening semantics apply: the last definition loaded wins, silently replacing the earlier one.
46
+
47
+ Three things are overwritten when a task name is reused:
48
+
49
+ | What | Effect |
50
+ |---|---|
51
+ | `def method_name` | The Ruby method body — the earlier implementation is gone |
52
+ | `desc` metadata | Thor registers the new usage/description string, discarding the old one |
53
+ | `depends_on` stages | `method_added` captures the pending deps for the new definition; the earlier dep chain is replaced |
54
+
55
+ **Accidental overloading** is a silent bug. If `build.loki` and `ci.loki` both define `def build`, only the alphabetically-later file's version runs — with no warning. Keep task names unique across files, or move shared tasks into a dedicated file loaded first.
56
+
57
+ !!! warning
58
+ There is no runtime error when a task is overloaded. If a task is not behaving as expected, check whether another `*.loki` file defines the same method name and loads after it.
59
+
60
+ **Intentional overloading** lets you extend or wrap a task defined in an earlier file. Use `alias_method` inside a `no_commands` block to preserve the original implementation under a private name, then redefine the task to call it:
61
+
62
+ ```ruby
63
+ # build.loki (loaded first)
64
+ class Tasks
65
+ desc "build", "Compile the project"
66
+ def build
67
+ sh "rake build"
68
+ end
69
+ end
70
+ ```
71
+
72
+ ```ruby
73
+ # postbuild.loki (loaded after build.loki, alphabetically)
74
+ class Tasks
75
+ # Preserve the original under a private name before overwriting it.
76
+ no_commands { alias_method :_build_original, :build }
77
+
78
+ desc "build", "Compile the project and copy assets"
79
+ def build
80
+ _build_original # runs the original sh "rake build"
81
+ sh "cp -r dist/ public/" # adds post-build step
82
+ end
83
+ end
84
+ ```
85
+
86
+ `no_commands` prevents `_build_original` from appearing as a CLI command. The aliased method retains the original's full body including any `sh` calls, `var` access, and private helper calls.
87
+
88
+ !!! tip
89
+ The `_` prefix on the alias name (`_build_original`) follows Asgard's convention for non-user-facing methods and reinforces that it is an implementation detail, not a task to be invoked directly.
90
+
91
+ !!! warning "Prefer `depends_on` over intentional overloading"
92
+ Using `alias_method` to bolt post-task behaviour onto an existing task is a code smell. It is fragile (load-order dependent), obscures intent, and makes the dependency chain invisible to Asgard's cycle-detection and deduplication logic.
93
+
94
+ The idiomatic Asgard solution is to express the relationship explicitly with `depends_on`:
95
+
96
+ ```ruby
97
+ # build.loki
98
+ class Tasks
99
+ desc "build", "Compile the project"
100
+ def build = sh "rake build"
101
+
102
+ desc "copy_assets", "Copy build output to public/"
103
+ def copy_assets = sh "cp -r dist/ public/"
104
+
105
+ depends_on :build, :copy_assets
106
+ desc "build_all", "Compile and copy assets"
107
+ def build_all; end
108
+ end
109
+ ```
110
+
111
+ This approach is transparent, testable, and benefits from Asgard's deduplication — `build` will never run twice even if multiple tasks declare it as a dependency.
112
+
113
+ ---
114
+
115
+ ## Single File Layout
116
+
117
+ The simplest structure: all tasks in `.loki`, nothing else:
118
+
119
+ ```
120
+ myproject/
121
+ .loki
122
+ ```
123
+
124
+ ```ruby
125
+ # .loki
126
+ class Tasks
127
+ var :app, "myapp"
128
+
129
+ desc "build", "Compile the project"
130
+ def build = sh "rake build"
131
+
132
+ desc "test", "Run the test suite"
133
+ def test = sh "rake test"
134
+
135
+ desc "release", "Build and push the gem"
136
+ def release = sh "gem push pkg/#{app}-*.gem"
137
+ end
138
+ ```
139
+
140
+ ---
141
+
142
+ ## Multi-File Layout
143
+
144
+ Split tasks across files by concern — each file reopens `class Tasks`:
145
+
146
+ ```
147
+ myproject/
148
+ .loki ← entry point (may be empty or contain top-level task)
149
+ build.loki ← build-related tasks
150
+ deploy.loki ← deployment tasks
151
+ test.loki ← test tasks
152
+ ```
153
+
154
+ ```ruby
155
+ # build.loki
156
+ class Tasks
157
+ desc "build", "Compile the project"
158
+ def build = sh "rake build"
159
+ end
160
+ ```
161
+
162
+ ```ruby
163
+ # test.loki
164
+ class Tasks
165
+ depends_on :build
166
+ desc "test", "Run the test suite"
167
+ def test = sh "bundle exec rake test"
168
+ end
169
+ ```
170
+
171
+ ```ruby
172
+ # deploy.loki
173
+ class Tasks
174
+ depends_on :test
175
+ desc "deploy", "Deploy to production"
176
+ def deploy = sh "cap production deploy"
177
+ end
178
+ ```
179
+
180
+ ```ruby
181
+ # .loki — can be empty, or can register subcommands, add top-level vars, etc.
182
+ ```
183
+
184
+ Load order: `build.loki` → `deploy.loki` → `test.loki` → `.loki`.
185
+
186
+ !!! tip
187
+ When `--auto-load` is used, `*.loki` files are sorted alphabetically, so `build.loki` loads before `test.loki`, which means `depends_on :build` in `test.loki` correctly references a task that already exists.
188
+
189
+ ---
190
+
191
+ ## Explicit Loading
192
+
193
+ You can explicitly load files from `.loki` using `require_relative`. This gives you control over load order, and lets you load plain Ruby files that are not `.loki` files:
194
+
195
+ ```ruby
196
+ # .loki
197
+ require_relative "shared/helpers"
198
+ require_relative "ci.loki"
199
+
200
+ class Tasks
201
+ include BuildHelpers # defined in shared/helpers.rb
202
+
203
+ desc "full-ci", "Complete CI run"
204
+ def full_ci = sh "echo 'full CI complete'"
205
+ end
206
+ ```
207
+
208
+ Explicitly required files are loaded before `.loki`'s own class body is evaluated. Files loaded via `require_relative` are **not** re-loaded by the alphabetical glob — Ruby's `require_relative` marks them as loaded in `$LOADED_FEATURES`.
209
+
210
+ !!! warning
211
+ If you `require_relative "ci.loki"` from `.loki` and also run `asgard --auto-load`, Asgard's glob will also load `ci.loki`. To prevent double-loading, either: (a) put explicitly loaded files in a subdirectory outside the alphabetical sweep, or (b) rely solely on `--auto-load` without `require_relative`.
212
+
213
+ ---
214
+
215
+ ## Subcommands Across Files
216
+
217
+ Subcommand classes defined in separate `*.loki` files are available in `.loki` when `--auto-load` is used, because the `*.loki` files load first:
218
+
219
+ ```
220
+ myproject/
221
+ .loki ← registers subcommands
222
+ db_subcommands.loki ← defines DBCommands
223
+ server_subcommands.loki ← defines ServerCommands
224
+ ```
225
+
226
+ ```ruby
227
+ # db_subcommands.loki
228
+ class DBCommands < Tasks
229
+ desc "migrate", "Run migrations"
230
+ def migrate = sh "rails db:migrate"
231
+ end
232
+
233
+ # server_subcommands.loki
234
+ class ServerCommands < Tasks
235
+ desc "start", "Start the server"
236
+ def start = sh "rails server"
237
+ end
238
+
239
+ # .loki
240
+ class Tasks
241
+ desc "db SUBCOMMAND", "Manage the database"; subcommand "db", DBCommands
242
+ desc "server SUBCOMMAND", "Manage the server"; subcommand "server", ServerCommands
243
+ end
244
+ ```
245
+
246
+ ---
247
+
248
+ ## Summary of Loading Rules
249
+
250
+ | File | When loaded | Purpose |
251
+ |---|---|---|
252
+ | `.loki` | After all `*.loki` | Project root marker; entry point |
253
+ | `*.loki` | When `--auto-load` is passed, alphabetically before `.loki` | Task definitions that reopen `class Tasks` |
254
+ | `require_relative` targets | At the point of the `require_relative` call | Shared helpers, explicit task files |