asgard 0.4.0 → 0.4.1
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.
- checksums.yaml +4 -4
- data/CHANGELOG.md +40 -0
- data/README.md +4 -1
- data/docs/schedule.md +6 -1
- data/lib/asgard/schedule/commands.rb +102 -36
- data/lib/asgard/schedule/declaration.rb +61 -0
- data/lib/asgard/schedule/launchd.rb +55 -0
- data/lib/asgard/schedule/systemd.rb +47 -0
- data/lib/asgard/schedule/table.rb +78 -0
- data/lib/asgard/schedule.rb +1 -0
- data/lib/asgard/version.rb +1 -1
- data/lib/asgard.rb +7 -2
- metadata +17 -2
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: a1c6d1f87eea50ba02ac92aff8d7822f9106a7a99afa5ff5c44c7faa7b5e9c87
|
|
4
|
+
data.tar.gz: dcd334548a5a8d67688cd139482d6a0a3a3ce77c0aa59b2515e81d568849356f
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: b45b01bc870415ee36a931278c88ed251c8723a36e7795c6a36d0a142ce5a93822db91e96a099045ef5d35137cd86bc2ce571a37faea7b93818de94be78daca1
|
|
7
|
+
data.tar.gz: 837ab94dabc90b119831d2effc0c4dba00b55fc5a7cd1221d4cc16799f4376985a4dc5150673ca1e5910d6f1932a04d8782d6a87c6883342b312836e921dc40b
|
data/CHANGELOG.md
CHANGED
|
@@ -5,6 +5,46 @@ All notable changes to this project will be documented in this file.
|
|
|
5
5
|
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),
|
|
6
6
|
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
|
|
7
7
|
|
|
8
|
+
## [0.4.1] - 2026-10-02
|
|
9
|
+
|
|
10
|
+
### Added
|
|
11
|
+
|
|
12
|
+
- **`asgard schedule list --all` (`-a`)** — lists every project's scheduled
|
|
13
|
+
entries, one table per project under a
|
|
14
|
+
`Project: name (/full/path/to/project)` header. Other projects' `.loki`
|
|
15
|
+
files are not loaded: their COMMAND and SCHEDULE columns, and the project
|
|
16
|
+
path, are read back from the installed launchd plist or systemd unit files.
|
|
17
|
+
Outside any `.loki` tree, `asgard schedule list` shows every project
|
|
18
|
+
automatically instead of failing with "no .loki file found".
|
|
19
|
+
- **`stop`, `start`, `trigger` and `log` accept entries of other projects**, so
|
|
20
|
+
they work from any directory. A bare name matches this project's entry first,
|
|
21
|
+
then any other project's; when two other projects share a name, write it as
|
|
22
|
+
`project/name`.
|
|
23
|
+
- A project-scoped `list` with nothing installed now ends with
|
|
24
|
+
"Use `asgard schedule list --all` to see all currently scheduled tasks."
|
|
25
|
+
- Backend API: `installed_entries` (every project's entries),
|
|
26
|
+
`installed_columns(name)` and `installed_directory(name)` (read from the job
|
|
27
|
+
files) on both the launchd and systemd backends.
|
|
28
|
+
- Runtime dependency: `tty-table` (~> 0.12), which brings `pastel` and
|
|
29
|
+
`tty-screen`.
|
|
30
|
+
|
|
31
|
+
### Changed
|
|
32
|
+
|
|
33
|
+
- **`asgard schedule list` prints a table** — NAME, COMMAND, SCHEDULE, STATE and
|
|
34
|
+
LAST EXIT, with long cells wrapped to the terminal width, replacing one text
|
|
35
|
+
line per entry. A run of hourly times collapses
|
|
36
|
+
(`10:00-17:00 hourly weekdays`). Color is used on a terminal and turned off
|
|
37
|
+
by `NO_COLOR` or when piped.
|
|
38
|
+
- The not-installed messages from `stop`, `start`, `trigger` and `log` point at
|
|
39
|
+
`asgard schedule list --all`.
|
|
40
|
+
|
|
41
|
+
### Fixed
|
|
42
|
+
|
|
43
|
+
- Scheduled entries were invisible outside the directory tree of the project
|
|
44
|
+
that installed them: `list` and every per-entry command were scoped to the
|
|
45
|
+
project found from the current directory, so `launchctl list` showed jobs
|
|
46
|
+
that `asgard schedule list` did not.
|
|
47
|
+
|
|
8
48
|
## [0.4.0] - 2026-09-26
|
|
9
49
|
|
|
10
50
|
### Added
|
data/README.md
CHANGED
|
@@ -626,7 +626,7 @@ Then manage them with the built-in `schedule` command:
|
|
|
626
626
|
```bash
|
|
627
627
|
asgard schedule preview # job files install would write
|
|
628
628
|
asgard schedule install # load declared entries; drop undeclared ones
|
|
629
|
-
asgard schedule list #
|
|
629
|
+
asgard schedule list # this project's entries, state, last exit (--all: every project's)
|
|
630
630
|
asgard schedule stop NAME # stop one entry (persists across reboots and installs)
|
|
631
631
|
asgard schedule start NAME # start a stopped entry, or install just this one
|
|
632
632
|
asgard schedule trigger NAME # run an installed entry now
|
|
@@ -634,6 +634,9 @@ asgard schedule log NAME [-f] # print (or follow) the entry's log
|
|
|
634
634
|
asgard schedule remove # remove all of this project's entries
|
|
635
635
|
```
|
|
636
636
|
|
|
637
|
+
`stop`, `start`, `trigger` and `log` also take an entry of another project
|
|
638
|
+
(`list --all` shows them); write `project/name` when two projects share a name.
|
|
639
|
+
|
|
637
640
|
Jobs run `asgard <task> [options]` from the directory holding `.loki`, with
|
|
638
641
|
the `PATH` captured at install time; a project `.envrc` is loaded at run time
|
|
639
642
|
through `direnv exec`. See [Scheduled Tasks](https://madbomber.github.io/asgard/schedule/)
|
data/docs/schedule.md
CHANGED
|
@@ -65,7 +65,7 @@ end
|
|
|
65
65
|
```bash
|
|
66
66
|
asgard schedule preview # print the job files install would write
|
|
67
67
|
asgard schedule install # load declared entries; drop entries no longer declared
|
|
68
|
-
asgard schedule list #
|
|
68
|
+
asgard schedule list # this project's entries, state, last exit (--all: every project's)
|
|
69
69
|
asgard schedule stop NAME # stop one entry (stays stopped across reboots and installs)
|
|
70
70
|
asgard schedule start NAME # start a stopped entry, or install just this one
|
|
71
71
|
asgard schedule trigger NAME # run an installed entry now, under the scheduler
|
|
@@ -73,6 +73,11 @@ asgard schedule log NAME [-f] # print the entry's log (-f keeps following it)
|
|
|
73
73
|
asgard schedule remove # unload and delete all of this project's entries
|
|
74
74
|
```
|
|
75
75
|
|
|
76
|
+
`stop`, `start`, `trigger` and `log` also accept an entry of another project, so
|
|
77
|
+
they work from any directory. Use the name shown by `list --all`; if two projects
|
|
78
|
+
have an entry with the same name, write it as `project/name` (this project's own
|
|
79
|
+
entry wins a bare name).
|
|
80
|
+
|
|
76
81
|
Re-run `asgard schedule install` after changing declarations or your `PATH`.
|
|
77
82
|
|
|
78
83
|
---
|
|
@@ -54,6 +54,11 @@ module Asgard
|
|
|
54
54
|
|
|
55
55
|
def schedule_summary(spec) = "#{Schedule.command_line(spec[:task], spec[:args])} (#{Schedule.describe(**spec)})"
|
|
56
56
|
|
|
57
|
+
# One `list` row for this project: its declaration (if still declared) plus live state.
|
|
58
|
+
def schedule_row(name)
|
|
59
|
+
{ name:, **Schedule.declared_columns(schedules[name], "no longer declared"), **Schedule.live_columns(scheduler, name) }
|
|
60
|
+
end
|
|
61
|
+
|
|
57
62
|
def schedule_install(spec, asgard:, direnv:)
|
|
58
63
|
name = spec[:name]
|
|
59
64
|
state = scheduler.install(spec, asgard:, direnv:)
|
|
@@ -65,16 +70,6 @@ module Asgard
|
|
|
65
70
|
scheduler.uninstall(name)
|
|
66
71
|
puts "removed #{name}"
|
|
67
72
|
end
|
|
68
|
-
|
|
69
|
-
def require_installed!(name)
|
|
70
|
-
abort "#{name} is not installed (see `asgard schedule list`)." unless scheduler.installed_names.include?(name)
|
|
71
|
-
end
|
|
72
|
-
|
|
73
|
-
def require_known!(name)
|
|
74
|
-
return if schedules.key?(name) || scheduler.installed_names.include?(name)
|
|
75
|
-
|
|
76
|
-
abort "#{name} is neither declared nor installed (see `asgard schedule list`)."
|
|
77
|
-
end
|
|
78
73
|
end
|
|
79
74
|
|
|
80
75
|
desc "preview", "Show the job files install would write, without installing them"
|
|
@@ -100,61 +95,132 @@ module Asgard
|
|
|
100
95
|
scheduler.notes.each { |note| $stderr.puts note }
|
|
101
96
|
end
|
|
102
97
|
|
|
98
|
+
no_commands do
|
|
99
|
+
# [backend, entry name] for an installed entry of this or any project: a
|
|
100
|
+
# bare name, or project/name when several projects use the same one. nil if none.
|
|
101
|
+
def find_installed(name)
|
|
102
|
+
return [scheduler, name] if scheduler.installed_names.include?(name)
|
|
103
|
+
|
|
104
|
+
project, entry = name.include?("/") ? name.split("/", 2) : [nil, name]
|
|
105
|
+
matches = scheduler.installed_entries.select { |slug, found| found == entry && (project.nil? || slug == project) }
|
|
106
|
+
abort "#{name} is in several projects (#{matches.map(&:first).join(', ')}); name one as project/#{entry}." if matches.size > 1
|
|
107
|
+
|
|
108
|
+
matches.first&.then { |slug, found| [backend_for(slug), found] }
|
|
109
|
+
end
|
|
110
|
+
|
|
111
|
+
def resolve_installed(name) = find_installed(name) || abort("#{name} is not installed (see `asgard schedule list --all`).")
|
|
112
|
+
|
|
113
|
+
# Like resolve_installed, but a declared entry of this project counts even before it is installed.
|
|
114
|
+
def resolve_known(name)
|
|
115
|
+
return [scheduler, name] if schedules.key?(name)
|
|
116
|
+
|
|
117
|
+
find_installed(name) || abort("#{name} is neither declared nor installed (see `asgard schedule list --all`).")
|
|
118
|
+
end
|
|
119
|
+
|
|
120
|
+
# True when list should cover every project: asked for with --all, or
|
|
121
|
+
# there is no .loki here, so no single project to scope to.
|
|
122
|
+
def list_all? = options[:all] || Asgard.find_task_file.nil?
|
|
123
|
+
|
|
124
|
+
# What `list` says when there is nothing to show; a project-scoped list
|
|
125
|
+
# also tells the user how to look further.
|
|
126
|
+
def no_schedules_message
|
|
127
|
+
backend = scheduler.scheduler
|
|
128
|
+
return "No scheduled tasks installed (#{backend})." if list_all?
|
|
129
|
+
|
|
130
|
+
"No scheduled tasks installed for #{schedule_project} (#{backend}).\n" \
|
|
131
|
+
"Use `asgard schedule list --all` to see all currently scheduled tasks."
|
|
132
|
+
end
|
|
133
|
+
|
|
134
|
+
# The rows to list, grouped under a project title: { "Project: name (/path)" => [row, ...] }
|
|
135
|
+
# for every project, or { nil => [row, ...] } for this one. Empty groups drop out.
|
|
136
|
+
def list_groups
|
|
137
|
+
groups = list_all? ? all_schedule_groups : { nil => scheduler.installed_names.map { |name| schedule_row(name) } }
|
|
138
|
+
groups.reject { |_, rows| rows.empty? }
|
|
139
|
+
end
|
|
140
|
+
|
|
141
|
+
# Every installed entry on the machine, grouped by project.
|
|
142
|
+
def all_schedule_groups
|
|
143
|
+
by_project = scheduler.installed_entries.group_by(&:first)
|
|
144
|
+
by_project.to_h { |slug, entries| [project_title(slug, entries.first.last), entry_rows(entries)] }
|
|
145
|
+
end
|
|
146
|
+
|
|
147
|
+
# "Project: slug (/full/path)", the path read from one of the project's job files.
|
|
148
|
+
def project_title(slug, name)
|
|
149
|
+
dir = slug == current_slug ? schedule_root : backend_for(slug).installed_directory(name)
|
|
150
|
+
["Project: #{slug}", ("(#{dir})" if dir)].compact.join(" ")
|
|
151
|
+
end
|
|
152
|
+
|
|
153
|
+
def current_slug = Asgard.find_task_file && Schedule.slug(schedule_project)
|
|
154
|
+
|
|
155
|
+
def backend_for(slug) = Schedule.backend_class.new(project: slug, root: schedule_root)
|
|
156
|
+
|
|
157
|
+
def entry_rows(entries) = entries.map { |slug, name| entry_row(slug, name) }
|
|
158
|
+
|
|
159
|
+
# This project's entries keep their declaration columns; see other_project_row.
|
|
160
|
+
def entry_row(slug, name)
|
|
161
|
+
return schedule_row(name) if slug == current_slug
|
|
162
|
+
|
|
163
|
+
other_project_row(slug, name)
|
|
164
|
+
end
|
|
165
|
+
|
|
166
|
+
# A row for an entry of another project: its .loki isn't loaded, so the
|
|
167
|
+
# command and schedule are read back from the installed job files.
|
|
168
|
+
def other_project_row(slug, name)
|
|
169
|
+
backend = backend_for(slug)
|
|
170
|
+
shown = backend.installed_columns(name) || Schedule.declared_columns(nil, "(unreadable)")
|
|
171
|
+
{ name:, **shown, **Schedule.live_columns(backend, name) }
|
|
172
|
+
end
|
|
173
|
+
end
|
|
174
|
+
|
|
103
175
|
desc "list", "Show this project's installed entries, their state, and last exit status"
|
|
176
|
+
method_option :all, aliases: "-a", type: :boolean, desc: "List every project's entries, not just this one's"
|
|
104
177
|
def list
|
|
105
|
-
|
|
106
|
-
return puts
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
state = case status[:state]
|
|
111
|
-
when :active then "active; last exit: #{status[:last_exit] || 'never run'}"
|
|
112
|
-
when :stopped then "stopped"
|
|
113
|
-
else "not loaded"
|
|
114
|
-
end
|
|
115
|
-
spec = schedules[name]
|
|
116
|
-
timing = spec ? schedule_summary(spec) : "no longer declared"
|
|
117
|
-
puts "#{name} #{timing} [#{state}] log: #{scheduler.log_path(name)}"
|
|
118
|
-
end
|
|
178
|
+
groups = list_groups
|
|
179
|
+
return puts(no_schedules_message) if groups.empty?
|
|
180
|
+
|
|
181
|
+
puts groups.map { |title, rows| Schedule::Table.draw(rows, title:) }.join("\n\n")
|
|
182
|
+
puts "\nLogs: #{File.dirname(scheduler.log_path(groups.values.first.first[:name]))} (asgard schedule log NAME)"
|
|
119
183
|
end
|
|
120
184
|
|
|
121
185
|
desc "stop NAME", "Stop one scheduled entry; it stays stopped across reboots and installs until started"
|
|
122
186
|
def stop(name)
|
|
123
|
-
|
|
124
|
-
|
|
187
|
+
backend, entry = resolve_installed(name)
|
|
188
|
+
backend.stop(entry)
|
|
125
189
|
puts "stopped #{name}"
|
|
126
190
|
end
|
|
127
191
|
|
|
128
192
|
desc "start NAME", "Start a stopped entry, or install and start just this declared entry"
|
|
129
193
|
def start(name)
|
|
130
|
-
|
|
131
|
-
|
|
194
|
+
backend, entry = resolve_known(name)
|
|
195
|
+
own = backend.equal?(scheduler)
|
|
196
|
+
spec = schedules[entry] if own
|
|
132
197
|
|
|
133
198
|
if spec
|
|
134
199
|
declared_schedules # validates the task exists
|
|
135
200
|
scheduler.install(spec, asgard: schedule_asgard, direnv: schedule_direnv) # refresh the job files
|
|
136
201
|
else
|
|
137
|
-
|
|
202
|
+
why = own ? "is no longer declared" : "belongs to another project"
|
|
203
|
+
$stderr.puts "schedule: #{name} #{why}; starting its installed job as-is"
|
|
138
204
|
end
|
|
139
|
-
|
|
205
|
+
backend.start(entry)
|
|
140
206
|
puts "started #{name}"
|
|
141
207
|
end
|
|
142
208
|
|
|
143
209
|
desc "trigger NAME", "Run an installed entry now, under the scheduler's environment"
|
|
144
210
|
def trigger(name)
|
|
145
|
-
|
|
146
|
-
abort "#{name} is stopped; `asgard schedule start #{name}` first." if
|
|
211
|
+
backend, entry = resolve_installed(name)
|
|
212
|
+
abort "#{name} is stopped; `asgard schedule start #{name}` first." if backend.status(entry)[:state] == :stopped
|
|
147
213
|
|
|
148
|
-
|
|
149
|
-
puts "triggered #{name}; output goes to #{
|
|
214
|
+
backend.trigger(entry)
|
|
215
|
+
puts "triggered #{name}; output goes to #{backend.log_path(entry)}"
|
|
150
216
|
end
|
|
151
217
|
|
|
152
218
|
desc "log NAME", "Print the named entry's log file to STDOUT"
|
|
153
219
|
method_option :follow, aliases: "-f", type: :boolean, desc: "Keep printing new output as it arrives (tail -f)"
|
|
154
220
|
def log(name)
|
|
155
|
-
|
|
221
|
+
backend, entry = resolve_known(name)
|
|
156
222
|
|
|
157
|
-
path =
|
|
223
|
+
path = backend.log_path(entry)
|
|
158
224
|
return $stderr.puts "#{name} has no log yet (#{path}); it is created on the first run." unless File.exist?(path)
|
|
159
225
|
return sh("tail -n +1 -f #{path.shellescape}", silent: true, exec: true) if options[:follow]
|
|
160
226
|
|
|
@@ -24,6 +24,9 @@ module Asgard
|
|
|
24
24
|
# #start(name) / #stop(name) # stop persists across reboots and reinstalls
|
|
25
25
|
# #trigger(name) # run once now, under the scheduler
|
|
26
26
|
# #installed_names # => ["demo", ...] for this project
|
|
27
|
+
# #installed_entries # => [[project_slug, "demo"], ...] for every project
|
|
28
|
+
# #installed_columns(name) # => { command:, schedule: } read back from the job files, or nil
|
|
29
|
+
# #installed_directory(name) # => the project root the job runs in, or nil
|
|
27
30
|
# #status(name) # => { state: :active|:stopped|:not_loaded, last_exit: String|nil }
|
|
28
31
|
# #log_path(name) # => path the job's output is appended to
|
|
29
32
|
# #notes # => [String] platform hints to show after install
|
|
@@ -121,6 +124,64 @@ module Asgard
|
|
|
121
124
|
"#{Array(at).join(', ')} #{Array(on).join(', ')}"
|
|
122
125
|
end
|
|
123
126
|
|
|
127
|
+
# For the `list` table: like describe, but a run of hourly times
|
|
128
|
+
# collapses ("10:00, 11:00 ... 17:00" => "10:00-17:00 hourly").
|
|
129
|
+
def describe_compact(at: nil, on: :daily, every: nil, **)
|
|
130
|
+
return "every #{every}s" if every
|
|
131
|
+
|
|
132
|
+
"#{compact_times(Array(at))} #{Array(on).join(', ')}"
|
|
133
|
+
end
|
|
134
|
+
|
|
135
|
+
# The command and schedule columns of a `list` row for a declared entry.
|
|
136
|
+
def list_columns(spec) = { command: command_line(spec[:task], spec[:args]), schedule: describe_compact(**spec) }
|
|
137
|
+
|
|
138
|
+
# The command and schedule columns of a `list` row: the declaration's, or
|
|
139
|
+
# `missing` in the schedule column when there is none.
|
|
140
|
+
def declared_columns(spec, missing) = spec ? list_columns(spec) : { command: "-", schedule: missing }
|
|
141
|
+
|
|
142
|
+
# The state and last-exit columns of a `list` row, from a backend's live status.
|
|
143
|
+
def live_columns(backend, name)
|
|
144
|
+
state, last_exit = backend.status(name).values_at(:state, :last_exit)
|
|
145
|
+
{ state: state.to_s.tr("_", " "), last_exit: state == :active ? (last_exit || "never run") : "-" }
|
|
146
|
+
end
|
|
147
|
+
|
|
148
|
+
# The command a job runs, from its program arguments: everything from
|
|
149
|
+
# `asgard` on, so a direnv wrapper and the asgard install path drop out.
|
|
150
|
+
def command_from_arguments(arguments)
|
|
151
|
+
start = arguments.index { |word| File.basename(word) == "asgard" }
|
|
152
|
+
Shellwords.join(["asgard", *arguments.drop(start ? start + 1 : 0)])
|
|
153
|
+
end
|
|
154
|
+
|
|
155
|
+
# describe_compact for calendar entries (the shape `calendar` returns), one
|
|
156
|
+
# group per distinct set of days: "17:30 weekdays; 09:00 saturday".
|
|
157
|
+
def describe_calendar(entries)
|
|
158
|
+
entries.group_by { |entry| entry[:days]&.sort }.map { |days, group| describe_days(days, group) }.join("; ")
|
|
159
|
+
end
|
|
160
|
+
|
|
161
|
+
def describe_days(days, group)
|
|
162
|
+
describe_compact(at: group.map { |entry| format("%<hour>02d:%<minute>02d", entry) }, on: day_names(days))
|
|
163
|
+
end
|
|
164
|
+
|
|
165
|
+
# The command and schedule columns for a job read back from its files.
|
|
166
|
+
def installed_columns(arguments:, calendar:, every:)
|
|
167
|
+
{ command: command_from_arguments(arguments), schedule: every ? "every #{every}s" : describe_calendar(calendar) }
|
|
168
|
+
end
|
|
169
|
+
|
|
170
|
+
# Weekday numbers back to what on: took: nil => :daily, [1, 2, 3, 4, 5] => :weekdays, [5] => [:friday].
|
|
171
|
+
def day_names(days)
|
|
172
|
+
return :daily unless days
|
|
173
|
+
|
|
174
|
+
names = days.map { |day| DAYS.fetch(day) }
|
|
175
|
+
DAY_GROUPS.key(names) || names
|
|
176
|
+
end
|
|
177
|
+
|
|
178
|
+
def compact_times(times)
|
|
179
|
+
parsed = times.map { |time| parse_time(time) }
|
|
180
|
+
hourly = parsed.size >= 3 && parsed.map(&:last).uniq.size == 1 &&
|
|
181
|
+
parsed.each_cons(2).all? { |(hour, _), (next_hour, _)| next_hour == hour + 1 }
|
|
182
|
+
hourly ? "#{times.first}-#{times.last} hourly" : times.join(", ")
|
|
183
|
+
end
|
|
184
|
+
|
|
124
185
|
# "17:30" => [17, 30]
|
|
125
186
|
def parse_time(time)
|
|
126
187
|
shown = time.inspect
|
|
@@ -64,6 +64,40 @@ module Asgard
|
|
|
64
64
|
end
|
|
65
65
|
end
|
|
66
66
|
|
|
67
|
+
# The parts of a plist (as `plist` writes it) that say what runs and when:
|
|
68
|
+
# { arguments: [String], calendar: [{ hour:, minute:, days: [Integer]|nil }], every: Integer|nil }
|
|
69
|
+
def self.parse_plist(xml)
|
|
70
|
+
{
|
|
71
|
+
arguments: array_body(xml, "ProgramArguments").scan(%r{<string>(.*?)</string>}m).flatten.map { |text| unescape(text) },
|
|
72
|
+
calendar: calendar_entries(array_body(xml, "StartCalendarInterval").scan(%r{<dict>(.*?)</dict>}m).flatten),
|
|
73
|
+
every: xml[%r{<key>StartInterval</key>\s*<integer>(\d+)</integer>}, 1]&.to_i
|
|
74
|
+
}
|
|
75
|
+
end
|
|
76
|
+
|
|
77
|
+
# StartCalendarInterval dict bodies => one entry per time, days collected.
|
|
78
|
+
def self.calendar_entries(dicts)
|
|
79
|
+
dicts.map { |body| parse_dict(body) }
|
|
80
|
+
.group_by { |dict| dict.values_at("Hour", "Minute") }
|
|
81
|
+
.map { |(hour, minute), group| calendar_entry(hour, minute, group) }
|
|
82
|
+
end
|
|
83
|
+
|
|
84
|
+
# <key>Hour</key><integer>13</integer>... => { "Hour" => 13, ... }
|
|
85
|
+
def self.parse_dict(body) = body.scan(%r{<key>(\w+)</key>\s*<integer>(\d+)</integer>}).to_h { |key, num| [key, num.to_i] }
|
|
86
|
+
|
|
87
|
+
# group: the Weekday-bearing dicts for one time; days is nil when there are none (every day).
|
|
88
|
+
def self.calendar_entry(hour, minute, group)
|
|
89
|
+
days = group.filter_map { |dict| dict["Weekday"] }
|
|
90
|
+
{ hour:, minute:, days: days.empty? ? nil : days }
|
|
91
|
+
end
|
|
92
|
+
|
|
93
|
+
# The text inside the <array> that follows <key>key</key>.
|
|
94
|
+
def self.array_body(xml, key) = xml[%r{<key>#{key}</key>\s*<array>(.*?)</array>}m, 1].to_s
|
|
95
|
+
|
|
96
|
+
# The WorkingDirectory (the project root) of a plist, or nil.
|
|
97
|
+
def self.parse_directory(xml) = xml[%r{<key>WorkingDirectory</key>\s*<string>(.*?)</string>}m, 1]&.then { |text| unescape(text) }
|
|
98
|
+
|
|
99
|
+
def self.unescape(text) = text.gsub("<", "<").gsub(">", ">").gsub("&", "&")
|
|
100
|
+
|
|
67
101
|
def self.escape(text) = text.to_s.gsub("&", "&").gsub("<", "<").gsub(">", ">")
|
|
68
102
|
|
|
69
103
|
# ---- backend API ------------------------------------------------------
|
|
@@ -136,6 +170,27 @@ module Asgard
|
|
|
136
170
|
Dir.glob(plist_path("*")).map { |path| File.basename(path, ".plist").delete_prefix(prefix) }.sort
|
|
137
171
|
end
|
|
138
172
|
|
|
173
|
+
# Every asgard entry on this machine, whatever its project: [[project_slug, name], ...].
|
|
174
|
+
def installed_entries
|
|
175
|
+
paths = Dir.glob(File.join(@home, "Library", "LaunchAgents", "#{LABEL_PREFIX}.*.plist"))
|
|
176
|
+
paths.map { |path| File.basename(path, ".plist").delete_prefix("#{LABEL_PREFIX}.").split(".", 2) }
|
|
177
|
+
.select { |entry| entry.size == 2 }.sort
|
|
178
|
+
end
|
|
179
|
+
|
|
180
|
+
# { command:, schedule: } as installed, read from the plist; nil when there isn't one.
|
|
181
|
+
def installed_columns(name)
|
|
182
|
+
path = plist_path(name)
|
|
183
|
+
return unless File.exist?(path)
|
|
184
|
+
|
|
185
|
+
Schedule.installed_columns(**self.class.parse_plist(File.read(path)))
|
|
186
|
+
end
|
|
187
|
+
|
|
188
|
+
# The project root the job runs in, read from the plist; nil when there isn't one.
|
|
189
|
+
def installed_directory(name)
|
|
190
|
+
path = plist_path(name)
|
|
191
|
+
self.class.parse_directory(File.read(path)) if File.exist?(path)
|
|
192
|
+
end
|
|
193
|
+
|
|
139
194
|
def status(name)
|
|
140
195
|
out, ok = run("launchctl", "print", target(name))
|
|
141
196
|
return { state: stopped?(name) ? :stopped : :not_loaded, last_exit: nil } unless ok
|
|
@@ -76,6 +76,30 @@ module Asgard
|
|
|
76
76
|
UNIT
|
|
77
77
|
end
|
|
78
78
|
|
|
79
|
+
# ExecStart= words, undoing `quote`.
|
|
80
|
+
def self.parse_exec_start(service)
|
|
81
|
+
line = service[/^ExecStart=(.*)$/, 1].to_s
|
|
82
|
+
line.scan(/"((?:[^"\\]|\\.)*)"/).flatten.map { |word| word.gsub(/\\(.)/, '\1').gsub("$$", "$").gsub("%%", "%") }
|
|
83
|
+
end
|
|
84
|
+
|
|
85
|
+
# "Mon,Fri *-*-* 17:30:00" => { hour: 17, minute: 30, days: [1, 5] }
|
|
86
|
+
def self.parse_on_calendar(text)
|
|
87
|
+
days = text[/\A([A-Z][a-z]{2}(?:,[A-Z][a-z]{2})*) /, 1]
|
|
88
|
+
hour, minute = text.scan(/(\d+):(\d+)/).first.map(&:to_i)
|
|
89
|
+
{ hour:, minute:, days: days&.split(",")&.map { |abbr| DAY_ABBREVIATIONS.index(abbr) } }
|
|
90
|
+
end
|
|
91
|
+
|
|
92
|
+
# The WorkingDirectory= (the project root) of a service unit, or nil.
|
|
93
|
+
def self.parse_directory(service) = service[/^WorkingDirectory=(.*)$/, 1]&.gsub("%%", "%")
|
|
94
|
+
|
|
95
|
+
# { calendar: [entry], every: Integer|nil } from a timer unit.
|
|
96
|
+
def self.parse_timer(timer)
|
|
97
|
+
{
|
|
98
|
+
calendar: timer.scan(/^OnCalendar=(.*)$/).flatten.map { |text| parse_on_calendar(text) },
|
|
99
|
+
every: timer[/^OnUnitActiveSec=(\d+)/, 1]&.to_i
|
|
100
|
+
}
|
|
101
|
+
end
|
|
102
|
+
|
|
79
103
|
# `systemctl show -p A -p B` output => { "A" => "...", "B" => "..." }
|
|
80
104
|
def self.parse_show(output) = output.lines.to_h { |line| line.chomp.split("=", 2) }.reject { |k, _| k.to_s.empty? }
|
|
81
105
|
|
|
@@ -156,6 +180,29 @@ module Asgard
|
|
|
156
180
|
Dir.glob(timer_path("*")).map { |path| File.basename(path, ".timer").delete_prefix(prefix) }.sort
|
|
157
181
|
end
|
|
158
182
|
|
|
183
|
+
# Every asgard entry on this machine, whatever its project: [[project_slug, name], ...].
|
|
184
|
+
def installed_entries
|
|
185
|
+
paths = Dir.glob(File.join(@config, "systemd", "user", "#{UNIT_PREFIX}.*.timer"))
|
|
186
|
+
paths.map { |path| File.basename(path, ".timer").delete_prefix("#{UNIT_PREFIX}.").split(".", 2) }
|
|
187
|
+
.select { |entry| entry.size == 2 }.sort
|
|
188
|
+
end
|
|
189
|
+
|
|
190
|
+
# { command:, schedule: } as installed, read from the unit files; nil when there are none.
|
|
191
|
+
def installed_columns(name)
|
|
192
|
+
paths = [service_path(name), timer_path(name)]
|
|
193
|
+
return unless paths.all? { |path| File.exist?(path) }
|
|
194
|
+
|
|
195
|
+
klass = self.class
|
|
196
|
+
service, timer = paths.map { |path| File.read(path) }
|
|
197
|
+
Schedule.installed_columns(arguments: klass.parse_exec_start(service), **klass.parse_timer(timer))
|
|
198
|
+
end
|
|
199
|
+
|
|
200
|
+
# The project root the job runs in, read from the service unit; nil when there isn't one.
|
|
201
|
+
def installed_directory(name)
|
|
202
|
+
path = service_path(name)
|
|
203
|
+
self.class.parse_directory(File.read(path)) if File.exist?(path)
|
|
204
|
+
end
|
|
205
|
+
|
|
159
206
|
def status(name)
|
|
160
207
|
_, active = systemctl("is-active", "--quiet", timer(name))
|
|
161
208
|
state = if active then :active
|
|
@@ -0,0 +1,78 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Asgard
|
|
4
|
+
module Schedule
|
|
5
|
+
# The table `asgard schedule list` prints, drawn by tty-table. rows are
|
|
6
|
+
# hashes with :name, :command, :schedule, :state and :last_exit (strings).
|
|
7
|
+
# A cell that doesn't fit wraps onto extra lines; only COMMAND and SCHEDULE
|
|
8
|
+
# give up width, so the short columns always stay whole.
|
|
9
|
+
class Table
|
|
10
|
+
HEADERS = ["NAME", "COMMAND", "SCHEDULE", "STATE", "LAST EXIT"].freeze
|
|
11
|
+
FLEXIBLE = [1, 2].freeze
|
|
12
|
+
MIN_WIDTH = 14
|
|
13
|
+
EXTRA = 4 # a space either side of a cell, plus slack for tty-table's rounding
|
|
14
|
+
BORDERS = HEADERS.size + 1 # one before each column and one after the last
|
|
15
|
+
|
|
16
|
+
# Columns available for output: the terminal's when io is one, else
|
|
17
|
+
# $COLUMNS, else a roomy default (piped output shouldn't wrap at 80).
|
|
18
|
+
def self.terminal_width(io = $stdout, env: ENV, default: 120)
|
|
19
|
+
return Integer(env["COLUMNS"], exception: false) || default unless io.tty?
|
|
20
|
+
|
|
21
|
+
require "tty-screen"
|
|
22
|
+
TTY::Screen.width
|
|
23
|
+
end
|
|
24
|
+
|
|
25
|
+
# rows rendered for the terminal, under title when given: colored when io
|
|
26
|
+
# is a tty and NO_COLOR is unset.
|
|
27
|
+
def self.draw(rows, title: nil, io: $stdout)
|
|
28
|
+
table = new(rows, color: io.tty? && !ENV.key?("NO_COLOR"), width: terminal_width(io)).render
|
|
29
|
+
[title, table].compact.join("\n")
|
|
30
|
+
end
|
|
31
|
+
|
|
32
|
+
# color: styles the header, state and last exit with ANSI codes.
|
|
33
|
+
def initialize(rows, color:, width:)
|
|
34
|
+
require "pastel"
|
|
35
|
+
|
|
36
|
+
@cells = rows.map { |row| row.values_at(:name, :command, :schedule, :state, :last_exit) }
|
|
37
|
+
@pastel = Pastel.new(enabled: color)
|
|
38
|
+
@width = width
|
|
39
|
+
@column_widths = natural_widths
|
|
40
|
+
shrink_widest while overflowing? && shrinkable?
|
|
41
|
+
end
|
|
42
|
+
|
|
43
|
+
def render
|
|
44
|
+
require "tty-table"
|
|
45
|
+
|
|
46
|
+
grid = TTY::Table.new(header: HEADERS.map { |text| @pastel.bold(text) }, rows: @cells.map { |cells| styled(cells) })
|
|
47
|
+
grid.render(:unicode, multiline: true, resize: true, width: total_width, column_widths:, padding: [0, 1]).to_s
|
|
48
|
+
end
|
|
49
|
+
|
|
50
|
+
# Width of each column, padding included. Every column starts at its
|
|
51
|
+
# natural width; when the table would be wider than the screen, the
|
|
52
|
+
# flexible columns give up a character at a time until it fits.
|
|
53
|
+
attr_reader :column_widths
|
|
54
|
+
|
|
55
|
+
# Rendered width: the columns plus their borders.
|
|
56
|
+
def total_width = column_widths.sum + BORDERS
|
|
57
|
+
|
|
58
|
+
private
|
|
59
|
+
|
|
60
|
+
def natural_widths = [HEADERS, *@cells].transpose.map { |column| column.max_by(&:length).length + EXTRA }
|
|
61
|
+
|
|
62
|
+
def overflowing? = total_width > @width
|
|
63
|
+
|
|
64
|
+
def widest_flexible = FLEXIBLE.max_by { |index| @column_widths[index] }
|
|
65
|
+
|
|
66
|
+
def shrinkable? = @column_widths[widest_flexible] > MIN_WIDTH
|
|
67
|
+
|
|
68
|
+
def shrink_widest = @column_widths[widest_flexible] -= 1
|
|
69
|
+
|
|
70
|
+
def styled(cells)
|
|
71
|
+
*lead, state, last_exit = cells
|
|
72
|
+
state_color = { "active" => :green, "stopped" => :yellow }.fetch(state, :red)
|
|
73
|
+
exit_color = last_exit.match?(/\A[1-9]\d*\z/) ? :red : :dim
|
|
74
|
+
[*lead, @pastel.decorate(state, state_color), @pastel.decorate(last_exit, exit_color)]
|
|
75
|
+
end
|
|
76
|
+
end
|
|
77
|
+
end
|
|
78
|
+
end
|
data/lib/asgard/schedule.rb
CHANGED
data/lib/asgard/version.rb
CHANGED
data/lib/asgard.rb
CHANGED
|
@@ -15,6 +15,10 @@ module Asgard
|
|
|
15
15
|
loki_up
|
|
16
16
|
end
|
|
17
17
|
|
|
18
|
+
# `asgard schedule list` reports every project's scheduled entries, so it
|
|
19
|
+
# works from a directory with no .loki above it.
|
|
20
|
+
def self.machine_wide?(argv) = argv.first(2) == %w[schedule list]
|
|
21
|
+
|
|
18
22
|
# Main entry point invoked by the asgard executable.
|
|
19
23
|
def self.run!(argv)
|
|
20
24
|
first = argv.first
|
|
@@ -27,9 +31,10 @@ module Asgard
|
|
|
27
31
|
Asgard::Doctor.new.run
|
|
28
32
|
exit
|
|
29
33
|
end
|
|
30
|
-
task_file = find_task_file
|
|
34
|
+
task_file = find_task_file
|
|
35
|
+
abort "asgard: no .loki file found in #{Dir.pwd}" unless task_file || machine_wide?(argv)
|
|
31
36
|
before = Asgard::Base.subclasses.dup
|
|
32
|
-
load task_file
|
|
37
|
+
load task_file if task_file
|
|
33
38
|
newly_defined = Asgard::Base.subclasses - before
|
|
34
39
|
(newly_defined + [Tasks]).uniq.each(&:validate_deps!)
|
|
35
40
|
Tasks._reset_ran!
|
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.4.
|
|
4
|
+
version: 0.4.1
|
|
5
5
|
platform: ruby
|
|
6
6
|
authors:
|
|
7
7
|
- Dewayne VanHoozer
|
|
@@ -37,6 +37,20 @@ dependencies:
|
|
|
37
37
|
- - "~>"
|
|
38
38
|
- !ruby/object:Gem::Version
|
|
39
39
|
version: '3.0'
|
|
40
|
+
- !ruby/object:Gem::Dependency
|
|
41
|
+
name: tty-table
|
|
42
|
+
requirement: !ruby/object:Gem::Requirement
|
|
43
|
+
requirements:
|
|
44
|
+
- - "~>"
|
|
45
|
+
- !ruby/object:Gem::Version
|
|
46
|
+
version: '0.12'
|
|
47
|
+
type: :runtime
|
|
48
|
+
prerelease: false
|
|
49
|
+
version_requirements: !ruby/object:Gem::Requirement
|
|
50
|
+
requirements:
|
|
51
|
+
- - "~>"
|
|
52
|
+
- !ruby/object:Gem::Version
|
|
53
|
+
version: '0.12'
|
|
40
54
|
description: |
|
|
41
55
|
A powerful Ruby-based task runner for any kind of project with task dependency tracking
|
|
42
56
|
and concurrent execution of designated tasks. Uses Thor for its rich CLI options, var
|
|
@@ -112,6 +126,7 @@ files:
|
|
|
112
126
|
- lib/asgard/schedule/declaration.rb
|
|
113
127
|
- lib/asgard/schedule/launchd.rb
|
|
114
128
|
- lib/asgard/schedule/systemd.rb
|
|
129
|
+
- lib/asgard/schedule/table.rb
|
|
115
130
|
- lib/asgard/shell.rb
|
|
116
131
|
- lib/asgard/tasks.rb
|
|
117
132
|
- lib/asgard/version.rb
|
|
@@ -142,7 +157,7 @@ required_rubygems_version: !ruby/object:Gem::Requirement
|
|
|
142
157
|
- !ruby/object:Gem::Version
|
|
143
158
|
version: '0'
|
|
144
159
|
requirements: []
|
|
145
|
-
rubygems_version: 4.0.
|
|
160
|
+
rubygems_version: 4.0.22
|
|
146
161
|
specification_version: 4
|
|
147
162
|
summary: A powerful Ruby-based task runner
|
|
148
163
|
test_files: []
|