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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 0fd4fe5903f93e062c0f515b3f6a49b859671c679b066003c04f2012eaf7176c
4
- data.tar.gz: 5af1e1b65fc854258a68ec98a220d8260720474d9d84a7644cecec2fc054889f
3
+ metadata.gz: a1c6d1f87eea50ba02ac92aff8d7822f9106a7a99afa5ff5c44c7faa7b5e9c87
4
+ data.tar.gz: dcd334548a5a8d67688cd139482d6a0a3a3ce77c0aa59b2515e81d568849356f
5
5
  SHA512:
6
- metadata.gz: eb251bfc200af2b94dc66e6247475f2a4c0d2a474d0f1d4b165caf05250c8dbb55bd31295b2c0b7e963aeace88f2b8f2c2ecb8751271a522ea5c7a99486bccfe
7
- data.tar.gz: c2540f86218ca5fe471ca276081d23224b2bf4f5784862915bf69f21c83acf664ef57f051bb4587a7104d0bb0bf82e768f28acc9b5f3e2cc32d15eac902dbf94
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 # installed entries, state, last exit status
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 # installed entries, schedule, state, last exit status
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
- names = scheduler.installed_names
106
- return puts "No scheduled tasks installed for #{schedule_project} (#{scheduler.scheduler})." if names.empty?
107
-
108
- names.each do |name|
109
- status = scheduler.status(name)
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
- require_installed!(name)
124
- scheduler.stop(name)
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
- require_known!(name)
131
- spec = schedules[name]
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
- $stderr.puts "schedule: #{name} is no longer declared; starting its installed job as-is"
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
- scheduler.start(name)
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
- require_installed!(name)
146
- abort "#{name} is stopped; `asgard schedule start #{name}` first." if scheduler.status(name)[:state] == :stopped
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
- scheduler.trigger(name)
149
- puts "triggered #{name}; output goes to #{scheduler.log_path(name)}"
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
- require_known!(name)
221
+ backend, entry = resolve_known(name)
156
222
 
157
- path = scheduler.log_path(name)
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("&lt;", "<").gsub("&gt;", ">").gsub("&amp;", "&")
100
+
67
101
  def self.escape(text) = text.to_s.gsub("&", "&amp;").gsub("<", "&lt;").gsub(">", "&gt;")
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
@@ -3,6 +3,7 @@
3
3
  require_relative "schedule/declaration"
4
4
  require_relative "schedule/launchd"
5
5
  require_relative "schedule/systemd"
6
+ require_relative "schedule/table"
6
7
  require_relative "schedule/commands"
7
8
 
8
9
  module Asgard
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Asgard
4
- VERSION = "0.4.0"
4
+ VERSION = "0.4.1"
5
5
  end
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 or abort "asgard: no .loki file found in #{Dir.pwd}"
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.0
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.21
160
+ rubygems_version: 4.0.22
146
161
  specification_version: 4
147
162
  summary: A powerful Ruby-based task runner
148
163
  test_files: []