Class: RepoTender::Launchd::Agent

Inherits:
Object
  • Object
show all
Defined in:
lib/repo_tender/launchd/agent.rb

Overview

launchctl wrapper. Holds an injected command runner (the real default goes through ‘RepoTender::Shell` inside a `Sync{}` block; tests inject a `RecordingRunner` that captures argv and returns canned output — gate G2).

All public methods return ‘Dry::Monads::Result`. A non-zero `launchctl` exit surfaces as `Failure(stderr:, status:)` — the same shape `Shell.run` uses — NOT a raise. A non-zero `runner` exit propagates as `Failure` to the caller.

Domain: every operation targets ‘gui/<UID>` (the user’s per-GUI-session launchd domain — the conventional domain for user-installed LaunchAgents on macOS). The UID is resolved via ‘Process.uid` by default; tests may inject a different UID to assert the exact argv (G2).

Defined Under Namespace

Classes: ShellRunner

Constant Summary collapse

DEFAULT_LABEL =
"io.github.jetpks.repo-tender.sync"

Instance Attribute Summary collapse

Instance Method Summary collapse

Constructor Details

#initialize(runner: ShellRunner.new, uid: Process.uid, label: DEFAULT_LABEL) ⇒ Agent

Returns a new instance of Agent.



48
49
50
51
52
# File 'lib/repo_tender/launchd/agent.rb', line 48

def initialize(runner: ShellRunner.new, uid: Process.uid, label: DEFAULT_LABEL)
  @runner = runner
  @uid = uid
  @label = label
end

Instance Attribute Details

#labelObject (readonly)

Returns the value of attribute label.



54
55
56
# File 'lib/repo_tender/launchd/agent.rb', line 54

def label
  @label
end

Instance Method Details

#install(plist_path) ⇒ Object

‘launchctl bootstrap gui/<UID> <abs-plist-path>`



57
58
59
# File 'lib/repo_tender/launchd/agent.rb', line 57

def install(plist_path)
  run("bootstrap", "gui/#{@uid}", plist_path)
end

#restartObject

‘launchctl kickstart -k gui/<UID>/<label>` — `-k` kills the running instance first so the new one always starts.



104
105
106
# File 'lib/repo_tender/launchd/agent.rb', line 104

def restart
  run("kickstart", "-k", "gui/#{@uid}/#{@label}")
end

#start(plist_path) ⇒ Object

bootstrap the plist, then ‘enable` the service. Both must succeed (both 0 exit) for the operation to be a `Success`; the first failure short-circuits.



78
79
80
81
82
# File 'lib/repo_tender/launchd/agent.rb', line 78

def start(plist_path)
  r1 = run("bootstrap", "gui/#{@uid}", plist_path)
  return r1 if r1.failure?
  run("enable", "gui/#{@uid}/#{@label}")
end

#statusObject

Returns a defensive parse of ‘launchctl list` (the machine-readable form — `launchctl print` is documented as “not API”). We run `launchctl list` (no service target) and search the output for our label.

The parser tolerates: empty output, a “Could not find” line, malformed rows, and PID values that are not integers. On any of those, we return Success(loaded: false) — the gate G4 “no raise on malformed” guarantee.



117
118
119
120
121
# File 'lib/repo_tender/launchd/agent.rb', line 117

def status
  result = run("list")
  return result if result.failure?
  parse_list(result.success)
end

#stopObject

bootout the service, then ‘disable` it.

Idempotency (Slice 5 / CF5): a ‘bootout` Failure with `status == 3` (“No such process”) or matching the not-loaded stderr is treated as **already not loaded** and is not propagated — the disable step still runs so the persistent `disable` override stays in place (matching the gate’s recorded-argv assertion ‘[[bootout,…], [disable,…]]` and the “stopped” semantic). A non-benign bootout Failure (e.g. status 1 “Operation not permitted”) short- circuits as before.



96
97
98
99
100
# File 'lib/repo_tender/launchd/agent.rb', line 96

def stop
  r1 = run("bootout", "gui/#{@uid}/#{@label}")
  return r1 if r1.failure? && !benign_bootout_failure?(r1)
  run("disable", "gui/#{@uid}/#{@label}")
end

#uninstallObject

‘launchctl bootout gui/<UID>/<label>`

Idempotency (Slice 5 / CF5): a benign bootout Failure (status 3 / “No such process” / “Could not find specified service”) is mapped to Success —uninstalling a not-loaded agent is a no-op for the bootout step. The plist removal in the CLI command layer is independent of this result.



69
70
71
72
73
# File 'lib/repo_tender/launchd/agent.rb', line 69

def uninstall
  r = run("bootout", "gui/#{@uid}/#{@label}")
  return Dry::Monads::Success("") if benign_bootout_failure?(r)
  r
end