asgard 0.5.1 → 0.6.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.
Files changed (102) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +179 -99
  3. data/README.md +141 -46
  4. data/bin/asgard +10 -0
  5. data/lib/asgard/base/dispatch.rb +257 -31
  6. data/lib/asgard/base/registry.rb +7 -3
  7. data/lib/asgard/base.rb +140 -9
  8. data/lib/asgard/declarations.rb +21 -0
  9. data/lib/asgard/doctor/task_sections.rb +11 -3
  10. data/lib/asgard/doctor.rb +12 -8
  11. data/lib/asgard/jobs/commands.rb +152 -0
  12. data/lib/asgard/jobs/declaration.rb +149 -0
  13. data/lib/asgard/jobs/launcher.rb +109 -0
  14. data/lib/asgard/jobs/state.rb +190 -0
  15. data/lib/asgard/jobs/stopper.rb +141 -0
  16. data/lib/asgard/jobs.rb +25 -0
  17. data/lib/asgard/kernel_methods.rb +19 -14
  18. data/lib/asgard/leading_flag.rb +10 -0
  19. data/lib/asgard/pid.rb +130 -0
  20. data/lib/asgard/schedule/commands.rb +39 -11
  21. data/lib/asgard/schedule/declaration.rb +139 -12
  22. data/lib/asgard/schedule/job_files.rb +24 -0
  23. data/lib/asgard/schedule/launchd.rb +55 -18
  24. data/lib/asgard/schedule/systemd.rb +69 -27
  25. data/lib/asgard/schedule/table.rb +34 -16
  26. data/lib/asgard/schedule.rb +49 -9
  27. data/lib/asgard/shell.rb +372 -44
  28. data/lib/asgard/state_dir.rb +84 -0
  29. data/lib/asgard/tasks.rb +8 -0
  30. data/lib/asgard/version.rb +1 -1
  31. data/lib/asgard/watch/changes.rb +26 -1
  32. data/lib/asgard/watch/commands.rb +52 -12
  33. data/lib/asgard/watch/daemon.rb +334 -43
  34. data/lib/asgard/watch/declaration.rb +135 -40
  35. data/lib/asgard/watch/dispatcher.rb +61 -10
  36. data/lib/asgard/watch/mailbox.rb +50 -0
  37. data/lib/asgard/watch/reporter.rb +50 -34
  38. data/lib/asgard/watch/spawn.rb +41 -16
  39. data/lib/asgard/watch/state.rb +93 -27
  40. data/lib/asgard/watch/worker.rb +44 -9
  41. data/lib/asgard/watch.rb +4 -11
  42. data/lib/asgard.rb +80 -14
  43. metadata +167 -186
  44. data/.envrc +0 -6
  45. data/.github/workflows/deploy-github-pages.yml +0 -52
  46. data/.loki +0 -39
  47. data/.reek.yml +0 -166
  48. data/.rubocop.yml +0 -163
  49. data/Archspec.rb +0 -24
  50. data/CLAUDE.md +0 -139
  51. data/COMMITS.md +0 -196
  52. data/bin/console +0 -11
  53. data/bin/setup +0 -8
  54. data/doc_tasks.loki +0 -21
  55. data/docs/api.md +0 -368
  56. data/docs/assets/css/custom.css +0 -93
  57. data/docs/assets/images/asgard.jpg +0 -0
  58. data/docs/changelog.md +0 -1
  59. data/docs/dependencies.md +0 -313
  60. data/docs/environment.md +0 -129
  61. data/docs/examples.md +0 -194
  62. data/docs/getting-started.md +0 -181
  63. data/docs/helpers.md +0 -262
  64. data/docs/index.md +0 -87
  65. data/docs/options.md +0 -224
  66. data/docs/schedule.md +0 -106
  67. data/docs/shell.md +0 -237
  68. data/docs/subcommands.md +0 -181
  69. data/docs/task-files.md +0 -407
  70. data/docs/tasks.md +0 -326
  71. data/docs/variables.md +0 -338
  72. data/docs/watch.md +0 -123
  73. data/examples/.env +0 -4
  74. data/examples/.envrc +0 -3
  75. data/examples/.loki +0 -24
  76. data/examples/bad.loki +0 -63
  77. data/examples/concurrent.loki +0 -58
  78. data/examples/confirm.loki +0 -48
  79. data/examples/db_subcommands.loki +0 -74
  80. data/examples/depends_on_block/bad/.loki +0 -51
  81. data/examples/depends_on_block/good/.loki +0 -83
  82. data/examples/do.loki +0 -45
  83. data/examples/env_usage.loki +0 -27
  84. data/examples/kitchen_sink.loki +0 -210
  85. data/examples/server_subcommands.loki +0 -65
  86. data/examples/subdir/.loki +0 -12
  87. data/examples/subdir/import_demo.loki +0 -14
  88. data/examples/subdir/import_up_demo.loki +0 -18
  89. data/examples/watcher_examples/.envrc +0 -3
  90. data/examples/watcher_examples/.loki +0 -54
  91. data/examples/watcher_examples/README.md +0 -112
  92. data/examples/watcher_examples/bin/hello +0 -3
  93. data/examples/watcher_examples/data/records.json +0 -1
  94. data/examples/watcher_examples/docs/drafts/ideas.md +0 -5
  95. data/examples/watcher_examples/docs/index.md +0 -4
  96. data/examples/watcher_examples/lib/greeter.rb +0 -7
  97. data/gem_tasks.loki +0 -51
  98. data/git.loki +0 -13
  99. data/mkdocs.yml +0 -166
  100. data/quality.loki +0 -266
  101. data/quality_rails.loki +0 -47
  102. data/xyzzy.loki +0 -12
@@ -0,0 +1,141 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Asgard
4
+ module Jobs
5
+ # Ends one job: TERM to its process group, a poll every 0.2s for up to
6
+ # the grace period, then KILL and a wait until the group holds none of
7
+ # the job's processes. The signal sender, the liveness check and
8
+ # sleep are injectable so the timing branches test without waiting.
9
+ class Stopper
10
+ POLL = 0.2
11
+ TIMEOUT = 10
12
+ WARN_AFTER = 1.0
13
+ RESEND_EVERY = 1.0
14
+
15
+ # A negative pid addresses a group, and kill(2) gives -1 and 0 their
16
+ # own meanings: every process the user owns, and the caller's own
17
+ # group. A record that is corrupt or hand-written must never turn
18
+ # into either, so only a real group id (above 1) is ever signalled.
19
+ #
20
+ # A valid id is still refused (false) when it is our own group, or
21
+ # when no process leads it any more: a job's pgid is its trampoline's
22
+ # pid, so a group whose leader is gone or moved is not the job. So is
23
+ # a group we may not signal (EPERM): its id now belongs to another
24
+ # user's processes. +kill+ is Process.kill, injectable for tests.
25
+ def self.signal_group(signal, pgid, own_pgrp: Process.getpgrp, pgid_of: Process.method(:getpgid),
26
+ kill: Process.method(:kill))
27
+ unless pgid.is_a?(Integer) && pgid > 1
28
+ raise ArgumentError,
29
+ "refusing to signal process group #{pgid.inspect}: not a group id above 1"
30
+ end
31
+ return false unless led_or_orphaned?(pgid, own_pgrp:, pgid_of:, kill:)
32
+
33
+ kill.call(signal, -pgid)
34
+ rescue Errno::ESRCH
35
+ nil
36
+ rescue Errno::EPERM
37
+ false
38
+ end
39
+
40
+ # A group may be signalled when its leader (the trampoline) still
41
+ # leads it, or when the leader is gone but members remain: then the
42
+ # id cannot have been reused. Never our own group, and never one we
43
+ # may not signal (EPERM). ESRCH from the group check means nobody is
44
+ # left: nil from signal_group.
45
+ def self.led_or_orphaned?(pgid, own_pgrp:, pgid_of:, kill: Process.method(:kill))
46
+ return false if pgid == own_pgrp
47
+
48
+ leader_group = begin
49
+ pgid_of.call(pgid)
50
+ rescue Errno::ESRCH
51
+ kill.call(0, -pgid) # members without their leader, or ESRCH
52
+ pgid
53
+ end
54
+ safe_group?(pgid, pid: pgid, own_pgrp:, pgid_of: ->(_) { leader_group })
55
+ rescue Errno::EPERM
56
+ false
57
+ end
58
+
59
+ # Whether +pgid+ may be signalled as the group +pid+ leads: a group id
60
+ # above 1, not our own, and still the group +pid+ belongs to.
61
+ def self.safe_group?(pgid, pid:, own_pgrp:, pgid_of:)
62
+ return false unless pgid.is_a?(Integer) && pgid > 1 && pgid != own_pgrp
63
+
64
+ pgid_of.call(pid) == pgid
65
+ rescue Errno::ESRCH, Errno::EPERM
66
+ false
67
+ end
68
+
69
+ # +alive+ is State.active?: a job whose script exited still counts
70
+ # while processes it left run in its group, and ps has confirmed
71
+ # they are there before any signal goes out.
72
+ def initialize(timeout: TIMEOUT, poll: POLL, out: $stdout,
73
+ alive: State.method(:active?), signal: Stopper.method(:signal_group), sleeper: Kernel.method(:sleep))
74
+ @timeout = timeout
75
+ @poll = poll
76
+ @out = out
77
+ @alive = alive
78
+ @signal = signal
79
+ @sleeper = sleeper
80
+ end
81
+
82
+ # :stopped when TERM ended the job within the grace period, :killed
83
+ # when KILL had to, :not_running when there was nothing to stop. A
84
+ # KILL takes the trampoline too, so the 137 is recorded here (a
85
+ # record that is not a job's, as the timing tests use, writes none).
86
+ def stop(record)
87
+ return :not_running unless @alive.call(record)
88
+
89
+ @signal.call("TERM", record[:pgid])
90
+ return :stopped if exited?(record, resend: "TERM")
91
+
92
+ @signal.call("KILL", record[:pgid])
93
+ State.record_kill(record) if exited?(record)
94
+ :killed
95
+ end
96
+
97
+ # Whether poll number +tick+ (from 0) re-sends TERM: once every
98
+ # RESEND_EVERY seconds. A TERM that reached the trampoline's child
99
+ # between its fork and its exec met the inherited trap and was lost;
100
+ # by the next one the child runs the job with the default handler.
101
+ def self.resend_tick?(tick, poll:)
102
+ every = (RESEND_EVERY / poll).round
103
+ tick.positive? && (tick % every).zero?
104
+ end
105
+
106
+ # Stops and reports one job; the outcome as above.
107
+ def stop_and_report(record)
108
+ outcome = stop(record)
109
+ @out.puts report_line(outcome, record)
110
+ outcome
111
+ end
112
+
113
+ def report_line(outcome, record)
114
+ name, pid = record.values_at(:name, :pid)
115
+ case outcome
116
+ when :stopped then "stopped #{name} (pid #{pid})"
117
+ when :killed then "killed #{name} (pid #{pid}) after #{@timeout}s"
118
+ else "#{name} is not running"
119
+ end
120
+ end
121
+
122
+ private
123
+
124
+ # True once the job is gone, false when the grace period ran out.
125
+ # Says what it is waiting for once, when the wait reaches a second
126
+ # (or its last poll, for a shorter grace period).
127
+ def exited?(record, resend: nil)
128
+ ticks = (@timeout / @poll).ceil
129
+ warn_tick = [(WARN_AFTER / @poll).round, ticks].min - 1
130
+ ticks.times do |tick|
131
+ return true unless @alive.call(record)
132
+
133
+ @signal.call(resend, record[:pgid]) if resend && Stopper.resend_tick?(tick, poll: @poll)
134
+ @sleeper.call(@poll)
135
+ @out.puts "waiting for #{record[:name]} (pid #{record[:pid]})" if tick == warn_tick
136
+ end
137
+ !@alive.call(record)
138
+ end
139
+ end
140
+ end
141
+ end
@@ -0,0 +1,25 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "jobs/declaration"
4
+ require_relative "jobs/state"
5
+ require_relative "jobs/launcher"
6
+ require_relative "jobs/stopper"
7
+ require_relative "jobs/commands"
8
+
9
+ module Asgard
10
+ # Background jobs started from a task body:
11
+ #
12
+ # class Tasks
13
+ # def doc_server = sh "mkdocs serve", background: true # doc_server-1
14
+ # def servers
15
+ # sh "docker compose up postgres", background: true # servers-1
16
+ # sh "bin/rails server", background: :api # api
17
+ # end
18
+ # end
19
+ #
20
+ # then manage them with `asgard jobs list|stop|log|restart|clean`.
21
+ module Jobs
22
+ # A job could not be managed: an unknown name, no owning task.
23
+ class Error < Asgard::Error; end
24
+ end
25
+ end
@@ -1,7 +1,7 @@
1
1
  # frozen_string_literal: true
2
2
 
3
- require "pathname"
4
-
3
+ # Every helper defined here is also in Asgard::Base::KERNEL_HELPERS, so a
4
+ # task or helper by its name is refused rather than hiding it.
5
5
  module Kernel
6
6
  def debug? = $DEBUG
7
7
  def verbose? = $VERBOSE
@@ -26,7 +26,7 @@ module Kernel
26
26
  dir = Pathname.new(Dir.pwd)
27
27
  loop do
28
28
  candidate = dir + name
29
- return candidate if candidate.exist?
29
+ return candidate if candidate.file? # a directory by that name is not it
30
30
  parent = dir.parent
31
31
  break if parent == dir
32
32
  dir = parent
@@ -39,36 +39,41 @@ module Kernel
39
39
  # defaults to the immediate caller, but is exposed as a keyword so a
40
40
  # wrapper (e.g. Asgard::Doctor's import tracer, prepended via `super`)
41
41
  # can thread through the *real* caller instead of its own frame.
42
+ #
43
+ # Only +path+ as written decides whether it is a glob. The caller's
44
+ # directory is the glob's base, never part of the pattern, so a project
45
+ # directory named like a glob ("glob[1]") does not change what matches.
42
46
  def import(path, from: caller_locations(1, 1).first)
43
47
  path = path.to_s
44
48
  raise ArgumentError, "import: path must end with .loki (got #{path.inspect})" unless path.end_with?(".loki")
45
- unless File.absolute_path?(path)
46
- caller_dir = File.dirname(from.absolute_path)
47
- path = File.expand_path(path, caller_dir)
48
- end
49
- paths = path =~ /[*?\[{]/ ? Dir.glob(path) : [path]
50
- loaded = paths.map do |p|
49
+ base = File.dirname(from.absolute_path) unless File.absolute_path?(path)
50
+ paths = Asgard.glob?(path) ? Dir.glob(path, base:) : [path]
51
+ loaded = paths.map { File.expand_path(it, base) }.map do |p|
51
52
  if $LOADED_FEATURES.include?(p)
52
53
  warn "import: skip #{p} (already loaded)" if debug?
53
54
  next false
54
55
  end
55
56
  warn "import: #{p}" if verbose? || debug?
56
- load p
57
+ # Recorded before it loads, as Asgard.load_root does, so two files
58
+ # that import each other each load once instead of without end.
57
59
  $LOADED_FEATURES << p
60
+ load p
58
61
  true
59
62
  end
60
63
  loaded.any?
61
64
  end
62
65
  module_function :import
63
66
 
67
+ # The paths found are handed to import escaped: the directory they sit
68
+ # in is part of a path, not a pattern.
64
69
  def import_up(name = ".loki")
65
- if name =~ /[*?\[{]/
70
+ if Asgard.glob?(name)
66
71
  dir = Dir.pwd
67
72
  loop do
68
- matches = Dir.glob(File.join(dir, name))
73
+ matches = Dir.glob(name, base: dir)
69
74
  unless matches.empty?
70
75
  warn "import_up: #{name} → #{dir}" if verbose? || debug?
71
- return matches.map { |p| import(p) }.any?
76
+ return matches.map { |p| import(Asgard.glob_escape(File.join(dir, p))) }.any?
72
77
  end
73
78
  parent = File.dirname(dir)
74
79
  break if parent == dir
@@ -83,7 +88,7 @@ module Kernel
83
88
  return false
84
89
  end
85
90
  warn "import_up: #{name} → #{path}" if verbose? || debug?
86
- import path
91
+ import Asgard.glob_escape(path)
87
92
  end
88
93
  module_function :import_up
89
94
  end
@@ -0,0 +1,10 @@
1
+ # frozen_string_literal: true
2
+
3
+ # Loaded alone by bin/asgard to answer --version before Thor loads, so it
4
+ # requires nothing.
5
+ module Asgard
6
+ # Whether +flag+ is one of asgard's own options: among the options
7
+ # before the first word that is not one (the task name). After it, the
8
+ # word belongs to the task, which may take --version as an argument.
9
+ def self.leading_flag?(argv, flag) = argv.take_while { |word| word.start_with?("-") }.include?(flag)
10
+ end
data/lib/asgard/pid.rb ADDED
@@ -0,0 +1,130 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "open3"
4
+
5
+ module Asgard
6
+ # Questions about a pid that outlived the process that recorded it: is
7
+ # it alive, what is it running, and is it still the process we started?
8
+ # The watcher and background jobs both keep pids on disk across asgard
9
+ # runs, and a pid left by a crash or a reboot can belong to an unrelated
10
+ # process by the time it is read back. (Not Asgard::Process: that would
11
+ # shadow ::Process inside the namespace.)
12
+ module Pid
13
+ # ps by absolute path: a scheduled or watched task's PATH may not
14
+ # hold it.
15
+ PS = %w[/bin/ps /usr/bin/ps].find { |path| File.executable?(path) } || "ps"
16
+
17
+ # The system's ps cannot answer what these questions ask of it.
18
+ class Unusable < Asgard::Error; end
19
+
20
+ # One process of a group: its pid, when it started (to ps's one-second
21
+ # resolution) and its command line.
22
+ Member = Data.define(:pid, :started, :command)
23
+
24
+ module_function
25
+
26
+ # Whether a process exists; one we may not signal still exists.
27
+ def alive?(pid)
28
+ Process.kill(0, pid)
29
+ true
30
+ rescue Errno::ESRCH
31
+ false
32
+ rescue Errno::EPERM
33
+ true
34
+ end
35
+
36
+ # The ps argv for one pid's full command line. -ww: procps cuts the
37
+ # line to the terminal width (COLUMNS) without it, and a job's script
38
+ # path sits at the very end of a long one. macOS accepts it too.
39
+ def ps_argv(pid) = [PS, "-ww", "-o", "command=", "-p", pid.to_s]
40
+
41
+ # The command line of a live process as `ps` prints it, or nil when
42
+ # there is no such process. ps is the one way that works unchanged
43
+ # on macOS and Linux.
44
+ def command_line(pid)
45
+ out, status = Open3.capture2(*ps_argv(pid))
46
+ status.success? ? out.strip : nil
47
+ rescue SystemCallError
48
+ nil
49
+ end
50
+
51
+ # The command lines of every process in group +pgid+. ps by group id
52
+ # is not portable, so this lists every process with its group and
53
+ # keeps the matching ones (see group_commands).
54
+ def group_command_lines(pgid)
55
+ out, status = Open3.capture2(PS, "-ww", "-A", "-o", "pgid=,command=")
56
+ status.success? ? group_commands(out, pgid) : []
57
+ rescue SystemCallError
58
+ []
59
+ end
60
+
61
+ # The commands in `ps -o pgid=,command=` output that belong to +pgid+.
62
+ def group_commands(ps_output, pgid)
63
+ ps_output.each_line.filter_map do |line|
64
+ group, command = line.strip.split(/\s+/, 2)
65
+ command.to_s if group.to_i == pgid
66
+ end
67
+ end
68
+
69
+ # Every process in group +pgid+ as Members. Like group_command_lines,
70
+ # but with each one's age, so a caller can tell a group that has kept
71
+ # its members from a later group that reused the id.
72
+ def group_processes(pgid, now: Time.now)
73
+ out, status = Open3.capture2(PS, "-ww", "-A", "-o", "pid=,pgid=,etime=,command=")
74
+ status.success? ? parse_group(out, pgid, now:) : []
75
+ rescue SystemCallError
76
+ []
77
+ end
78
+
79
+ # The Members of +pgid+ in `ps -o pid=,pgid=,etime=,command=` output.
80
+ def parse_group(ps_output, pgid, now:)
81
+ ps_output.each_line.filter_map do |line|
82
+ pid, group, etime, command = line.strip.split(/\s+/, 4)
83
+ Member.new(pid: pid.to_i, started: now - elapsed_seconds(etime), command: command.to_s) if group.to_i == pgid
84
+ end
85
+ end
86
+
87
+ # Seconds in a ps etime, "[[dd-]hh:]mm:ss" (or bare seconds).
88
+ def elapsed_seconds(etime)
89
+ days, clock = etime.include?("-") ? etime.split("-", 2) : [0, etime]
90
+ clock.split(":").map(&:to_i).reduce(0) { |total, part| (total * 60) + part } + (days.to_i * 86_400)
91
+ end
92
+
93
+ # Why +ps+ cannot answer for jobs, or nil when it can: it must accept
94
+ # the flags command_line uses and name the current process. BusyBox
95
+ # ps (Alpine) rejects -ww and -p; without this every job would look
96
+ # gone and a second start would run a duplicate.
97
+ def ps_problem(ps: PS)
98
+ out, err, status = Open3.capture3(ps, "-ww", "-o", "pid=,command=", "-p", Process.pid.to_s)
99
+ return err.lines.first.to_s.strip.then { it.empty? ? "exit status #{status.exitstatus}" : it } unless status.success?
100
+
101
+ out.include?(Process.pid.to_s) ? nil : "it printed nothing for pid #{Process.pid}"
102
+ rescue SystemCallError => e
103
+ e.message
104
+ end
105
+
106
+ # Raises Unusable unless +ps+ works (see ps_problem); asks once per
107
+ # process and ps.
108
+ def require_ps!(ps: PS)
109
+ @usable ||= {}
110
+ problem = @usable.fetch(ps) { @usable[ps] = ps_problem(ps:) }
111
+ return unless problem
112
+
113
+ raise Unusable, "#{ps} cannot list processes: #{problem}. Background jobs need a ps that accepts " \
114
+ "-ww, -o and -p (procps on Linux, the system ps on macOS and BSD); on Alpine, `apk add procps`"
115
+ end
116
+
117
+ # Whether pid is alive and its command line carries every word. The
118
+ # current process counts unless +self_ok+ is false: the watcher asks
119
+ # about itself, but a job record naming our own pid is a reused pid.
120
+ # An empty word matches nothing, since every command line holds "".
121
+ # +command:+ supplies the command line in place of asking ps (tests,
122
+ # or a caller that already has it).
123
+ def matches?(pid, *words, command: nil, self_ok: true)
124
+ return true if self_ok && pid == Process.pid
125
+
126
+ command ||= command_line(pid)
127
+ !command.nil? && words.all? { |word| !word.to_s.empty? && command.include?(word.to_s) }
128
+ end
129
+ end
130
+ end
@@ -1,6 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
- require "pathname"
4
3
  require "shellwords"
5
4
 
6
5
  # warn is silent when $VERBOSE is nil and bypasses $stderr in Ruby 4.0 (see
@@ -23,9 +22,9 @@ module Asgard
23
22
  no_commands do
24
23
  def schedules = Schedule.declarations
25
24
 
26
- def schedule_root = (Asgard.find_task_file&.dirname || Pathname.pwd).to_s
25
+ def schedule_root = @schedule_root ||= File.realpath((Asgard.find_task_file&.dirname || Pathname.pwd).to_s)
27
26
 
28
- def schedule_project = File.basename(schedule_root)
27
+ def schedule_project = @schedule_project ||= Schedule.project_slug(schedule_root)
29
28
 
30
29
  def scheduler
31
30
  @scheduler ||= Schedule.backend_class.new(project: schedule_project, root: schedule_root)
@@ -102,7 +101,9 @@ module Asgard
102
101
  return [scheduler, name] if scheduler.installed_names.include?(name)
103
102
 
104
103
  project, entry = name.include?("/") ? name.split("/", 2) : [nil, name]
105
- matches = scheduler.installed_entries.select { |slug, found| found == entry && (project.nil? || slug == project) }
104
+ matches = scheduler.installed_entries.select do |slug, found|
105
+ found == entry && (project.nil? || Schedule.project_match?(slug, project))
106
+ end
106
107
  abort "#{name} is in several projects (#{matches.map(&:first).join(', ')}); name one as project/#{entry}." if matches.size > 1
107
108
 
108
109
  matches.first&.then { |slug, found| [backend_for(slug), found] }
@@ -144,13 +145,15 @@ module Asgard
144
145
  by_project.to_h { |slug, entries| [project_title(slug, entries.first.last), entry_rows(entries)] }
145
146
  end
146
147
 
147
- # "Project: slug (/full/path)", the path read from one of the project's job files.
148
+ # "Project: slug (/full/path)", the path read from one of the project's
149
+ # job files, flagged when the directory is gone (the project moved or
150
+ # was deleted, and its jobs with it should be).
148
151
  def project_title(slug, name)
149
152
  dir = slug == current_slug ? schedule_root : backend_for(slug).installed_directory(name)
150
- ["Project: #{slug}", ("(#{dir})" if dir)].compact.join(" ")
153
+ ["Project: #{slug}", ("(#{dir})" if dir), ("[root missing]" if dir && !File.directory?(dir))].compact.join(" ")
151
154
  end
152
155
 
153
- def current_slug = Asgard.find_task_file && Schedule.slug(schedule_project)
156
+ def current_slug = Asgard.find_task_file && schedule_project
154
157
 
155
158
  def backend_for(slug) = Schedule.backend_class.new(project: slug, root: schedule_root)
156
159
 
@@ -222,17 +225,42 @@ module Asgard
222
225
 
223
226
  path = backend.log_path(entry)
224
227
  return $stderr.puts "#{name} has no log yet (#{path}); it is created on the first run." unless File.exist?(path)
225
- return sh("tail -n +1 -f #{path.shellescape}", silent: true, exec: true) if options[:follow]
228
+ return sh("tail -n +1 -f #{path.shellescape}", silent: true) if options[:follow]
226
229
 
227
230
  IO.copy_stream(path, $stdout)
228
231
  end
229
232
 
230
- desc "remove", "Remove all of this project's installed entries"
231
- def remove
233
+ desc "remove [NAME]", "Remove one installed entry (NAME or project/NAME), or all of this project's"
234
+ def remove(name = nil)
235
+ return remove_one(name) if name
236
+
232
237
  names = scheduler.installed_names
233
238
  return puts "No scheduled tasks installed for #{schedule_project}." if names.empty?
234
239
 
235
- names.each { |name| schedule_uninstall(name) }
240
+ names.each { |entry| schedule_uninstall(entry) }
241
+ end
242
+
243
+ desc "prune", "Remove every project's entries whose project directory no longer exists"
244
+ method_option :dry_run, aliases: "-n", type: :boolean, desc: "Only list what would be removed"
245
+ def prune
246
+ orphans = Schedule.orphaned_entries(scheduler.installed_entries,
247
+ directory_of: ->(slug, name) { backend_for(slug).installed_directory(name) })
248
+ return puts "Nothing to prune: every installed entry's project directory exists." if orphans.empty?
249
+
250
+ orphans.each do |slug, name|
251
+ next puts "would remove #{slug}/#{name}" if options[:dry_run]
252
+
253
+ backend_for(slug).uninstall(name)
254
+ puts "removed #{slug}/#{name}"
255
+ end
256
+ end
257
+
258
+ no_commands do
259
+ def remove_one(name)
260
+ backend, entry = resolve_installed(name)
261
+ backend.uninstall(entry)
262
+ puts "removed #{name}"
263
+ end
236
264
  end
237
265
  end
238
266
  end